diff --git a/app/javascript/controllers/autosave_controller.ts b/app/javascript/controllers/autosave_controller.ts new file mode 100644 index 000000000..824f354d5 --- /dev/null +++ b/app/javascript/controllers/autosave_controller.ts @@ -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(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 { + 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( + 'input:not([type=file]), textarea, select' + ) + ]; + const parent = this.element.closest('.editable-champ-repetition'); + if (parent) { + return [ + ...inputs, + ...parent.querySelectorAll('input[data-id]') + ]; + } + return inputs; + } +} diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index ea3cceb06..b5e6372b6 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -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);