feat(autosave): add stimulus autosave controller
This commit is contained in:
parent
525eebf600
commit
adb19a941f
2 changed files with 163 additions and 0 deletions
161
app/javascript/controllers/autosave_controller.ts
Normal file
161
app/javascript/controllers/autosave_controller.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
import invariant from 'tiny-invariant';
|
||||
import { httpRequest, ResponseError } from '@utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationController } from './application_controller';
|
||||
import { AutoUpload } from '../shared/activestorage/auto-upload';
|
||||
|
||||
const Gon = z.object({ autosave: z.object({ debounce_delay: z.number() }) });
|
||||
|
||||
declare const window: Window & typeof globalThis & { gon: unknown };
|
||||
const { debounce_delay } = Gon.parse(window.gon).autosave;
|
||||
|
||||
const AUTOSAVE_DEBOUNCE_DELAY = debounce_delay;
|
||||
const AUTOSAVE_TIMEOUT_DELAY = 60000;
|
||||
|
||||
// This is a controller we attach to each "champ" in the main form. It performs
|
||||
// the save and dispatches a few events that allow `AutosaveStatusController` to
|
||||
// coordinate notifications and retries:
|
||||
// * `autosave:enqueue` - dispatched when a new save attempt starts
|
||||
// * `autosave:end` - dispatched after sucessful save
|
||||
// * `autosave:error` - dispatched when an error occures
|
||||
//
|
||||
// The controller also listens to the following events:
|
||||
// * `autosave:retry` - dispatched by `AutosaveStatusController` when the user
|
||||
// clicks the retry button in the form status bar
|
||||
//
|
||||
export class AutosaveController extends ApplicationController {
|
||||
#abortController?: AbortController;
|
||||
#latestPromise = Promise.resolve();
|
||||
#needsRetry = false;
|
||||
|
||||
connect() {
|
||||
this.#latestPromise = Promise.resolve();
|
||||
this.onGlobal('autosave:retry', () => this.didRequestRetry());
|
||||
this.on('change', (event) => this.onInputChange(event));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#abortController?.abort();
|
||||
this.#latestPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
onClickRetryButton(event: Event) {
|
||||
const target = event.target as HTMLButtonElement;
|
||||
const inputTargetSelector = target.dataset.inputTarget;
|
||||
if (inputTargetSelector) {
|
||||
const target =
|
||||
this.element.querySelector<HTMLInputElement>(inputTargetSelector);
|
||||
if (
|
||||
target &&
|
||||
target.type == 'file' &&
|
||||
target.dataset.autoAttachUrl &&
|
||||
target.files?.length
|
||||
) {
|
||||
this.enqueueAutouploadRequest(target, target.files[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.disabled) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.type == 'file' &&
|
||||
target.dataset.autoAttachUrl &&
|
||||
target.files?.length
|
||||
) {
|
||||
this.enqueueAutouploadRequest(target, target.files[0]);
|
||||
} else if (target.type != 'file') {
|
||||
this.debounce(this.enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
private didRequestRetry() {
|
||||
if (this.#needsRetry) {
|
||||
this.enqueueAutosaveRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private didEnqueue() {
|
||||
this.#needsRetry = false;
|
||||
this.globalDispatch('autosave:enqueue');
|
||||
}
|
||||
|
||||
private didSucceed() {
|
||||
this.globalDispatch('autosave:end');
|
||||
}
|
||||
|
||||
private didFail(error: ResponseError) {
|
||||
this.#needsRetry = true;
|
||||
this.globalDispatch('autosave:error', { error });
|
||||
}
|
||||
|
||||
private enqueueAutouploadRequest(target: HTMLInputElement, file: File) {
|
||||
const autoupload = new AutoUpload(target, file);
|
||||
autoupload.start();
|
||||
}
|
||||
|
||||
// Add a new autosave request to the queue.
|
||||
// It will be started after the previous one finishes (to prevent older form data
|
||||
// to overwrite newer data if the server does not respond in order.)
|
||||
private enqueueAutosaveRequest() {
|
||||
this.#latestPromise = this.#latestPromise.finally(() =>
|
||||
this.sendAutosaveRequest()
|
||||
.then(() => this.didSucceed())
|
||||
.catch((error) => this.didFail(error))
|
||||
);
|
||||
this.didEnqueue();
|
||||
}
|
||||
|
||||
// Create a fetch request that saves the form.
|
||||
// Returns a promise fulfilled when the request completes.
|
||||
private sendAutosaveRequest(): Promise<void> {
|
||||
this.#abortController = new AbortController();
|
||||
|
||||
const formData = new FormData();
|
||||
for (const input of this.inputs) {
|
||||
if (input.type == 'checkbox') {
|
||||
formData.append(input.name, input.checked ? input.value : '');
|
||||
} else if (input.type == 'radio') {
|
||||
if (input.checked) {
|
||||
formData.append(input.name, input.value);
|
||||
}
|
||||
} else {
|
||||
formData.append(input.name, input.value);
|
||||
}
|
||||
}
|
||||
|
||||
return httpRequest(this.form.action, {
|
||||
method: 'patch',
|
||||
body: formData,
|
||||
signal: this.#abortController.signal,
|
||||
timeout: AUTOSAVE_TIMEOUT_DELAY
|
||||
}).turbo();
|
||||
}
|
||||
|
||||
private get form() {
|
||||
const form = this.element.closest('form');
|
||||
invariant(form, 'Could not find the form element.');
|
||||
return form;
|
||||
}
|
||||
|
||||
private get inputs() {
|
||||
const element = this.element as HTMLElement;
|
||||
const inputs = [
|
||||
...element.querySelectorAll<HTMLInputElement>(
|
||||
'input:not([type=file]), textarea, select'
|
||||
)
|
||||
];
|
||||
const parent = this.element.closest('.editable-champ-repetition');
|
||||
if (parent) {
|
||||
return [
|
||||
...inputs,
|
||||
...parent.querySelectorAll<HTMLInputElement>('input[data-id]')
|
||||
];
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
}
|
|
@ -4,9 +4,11 @@ import { ReactController } from './react_controller';
|
|||
import { TurboEventController } from './turbo_event_controller';
|
||||
import { GeoAreaController } from './geo_area_controller';
|
||||
import { TurboInputController } from './turbo_input_controller';
|
||||
import { AutosaveController } from './autosave_controller';
|
||||
|
||||
const Stimulus = Application.start();
|
||||
Stimulus.register('react', ReactController);
|
||||
Stimulus.register('turbo-event', TurboEventController);
|
||||
Stimulus.register('geo-area', GeoAreaController);
|
||||
Stimulus.register('turbo-input', TurboInputController);
|
||||
Stimulus.register('autosave', AutosaveController);
|
||||
|
|
Loading…
Add table
Reference in a new issue