feat(autosave): add stimulus autosave-status controller
This commit is contained in:
parent
adb19a941f
commit
3fea34732e
2 changed files with 99 additions and 0 deletions
97
app/javascript/controllers/autosave_status_controller.ts
Normal file
97
app/javascript/controllers/autosave_status_controller.ts
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import {
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
hasClass,
|
||||||
|
addClass,
|
||||||
|
removeClass,
|
||||||
|
ResponseError
|
||||||
|
} from '@utils';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ApplicationController } from './application_controller';
|
||||||
|
|
||||||
|
const Gon = z.object({
|
||||||
|
autosave: z.object({ status_visible_duration: z.number() })
|
||||||
|
});
|
||||||
|
|
||||||
|
declare const window: Window & typeof globalThis & { gon: unknown };
|
||||||
|
const { status_visible_duration } = Gon.parse(window.gon).autosave;
|
||||||
|
|
||||||
|
const AUTOSAVE_STATUS_VISIBLE_DURATION = status_visible_duration;
|
||||||
|
|
||||||
|
// This is a controller we attach to the status area in the main form. It
|
||||||
|
// coordinates notifications and will dispatch `autosave:retry` event if user
|
||||||
|
// decides to retry after an error.
|
||||||
|
//
|
||||||
|
export class AutosaveStatusController extends ApplicationController {
|
||||||
|
static targets = ['retryButton'];
|
||||||
|
|
||||||
|
declare readonly retryButtonTarget: HTMLButtonElement;
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.onGlobal('autosave:enqueue', () => this.didEnqueue());
|
||||||
|
this.onGlobal('autosave:end', () => this.didSucceed());
|
||||||
|
this.onGlobal<CustomEvent>('autosave:error', (event) =>
|
||||||
|
this.didFail(event)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickRetryButton() {
|
||||||
|
this.globalDispatch('autosave:retry');
|
||||||
|
}
|
||||||
|
|
||||||
|
private didEnqueue() {
|
||||||
|
disable(this.retryButtonTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
private didSucceed() {
|
||||||
|
enable(this.retryButtonTarget);
|
||||||
|
this.setState('succeeded');
|
||||||
|
this.debounce(this.hideSucceededStatus, AUTOSAVE_STATUS_VISIBLE_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
private didFail(event: CustomEvent<{ error: ResponseError }>) {
|
||||||
|
const error = event.detail.error;
|
||||||
|
|
||||||
|
if (error.response?.status == 401) {
|
||||||
|
// If we are unauthenticated, reload the page using a GET request.
|
||||||
|
// This will allow Devise to properly redirect us to sign-in, and then back to this page.
|
||||||
|
document.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enable(this.retryButtonTarget);
|
||||||
|
this.setState('failed');
|
||||||
|
|
||||||
|
const shouldLogError = !error.response || error.response.status != 0; // ignore timeout errors
|
||||||
|
if (shouldLogError) {
|
||||||
|
this.logError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: 'succeeded' | 'failed' | 'idle') {
|
||||||
|
const autosave = this.element as HTMLDivElement;
|
||||||
|
if (autosave) {
|
||||||
|
// Re-apply the state even if already present, to get a nice animation
|
||||||
|
removeClass(autosave, 'autosave-state-idle');
|
||||||
|
removeClass(autosave, 'autosave-state-succeeded');
|
||||||
|
removeClass(autosave, 'autosave-state-failed');
|
||||||
|
autosave.offsetHeight; // flush animations
|
||||||
|
addClass(autosave, `autosave-state-${state}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideSucceededStatus() {
|
||||||
|
if (hasClass(this.element as HTMLElement, 'autosave-state-succeeded')) {
|
||||||
|
this.setState('idle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logError(error: ResponseError) {
|
||||||
|
if (error && error.message) {
|
||||||
|
error.message = `[Autosave] ${error.message}`;
|
||||||
|
console.error(error);
|
||||||
|
this.globalDispatch('sentry:capture-exception', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import { TurboEventController } from './turbo_event_controller';
|
||||||
import { GeoAreaController } from './geo_area_controller';
|
import { GeoAreaController } from './geo_area_controller';
|
||||||
import { TurboInputController } from './turbo_input_controller';
|
import { TurboInputController } from './turbo_input_controller';
|
||||||
import { AutosaveController } from './autosave_controller';
|
import { AutosaveController } from './autosave_controller';
|
||||||
|
import { AutosaveStatusController } from './autosave_status_controller';
|
||||||
|
|
||||||
const Stimulus = Application.start();
|
const Stimulus = Application.start();
|
||||||
Stimulus.register('react', ReactController);
|
Stimulus.register('react', ReactController);
|
||||||
|
@ -12,3 +13,4 @@ Stimulus.register('turbo-event', TurboEventController);
|
||||||
Stimulus.register('geo-area', GeoAreaController);
|
Stimulus.register('geo-area', GeoAreaController);
|
||||||
Stimulus.register('turbo-input', TurboInputController);
|
Stimulus.register('turbo-input', TurboInputController);
|
||||||
Stimulus.register('autosave', AutosaveController);
|
Stimulus.register('autosave', AutosaveController);
|
||||||
|
Stimulus.register('autosave-status', AutosaveStatusController);
|
||||||
|
|
Loading…
Reference in a new issue