import { httpRequest, ResponseError, isSelectElement, isCheckboxOrRadioInputElement, isTextInputElement, getConfig } from '@utils'; import { ApplicationController } from './application_controller'; import { AutoUpload } from '../shared/activestorage/auto-upload'; import { FileUploadError, FAILURE_CLIENT, ERROR_CODE_READ } from '../shared/activestorage/file-upload-error'; const { autosave: { debounce_delay } } = getConfig(); const AUTOSAVE_DEBOUNCE_DELAY = debounce_delay; const AUTOSAVE_TIMEOUT_DELAY = 60000; const AUTOSAVE_CONDITIONAL_SPINNER_DEBOUNCE_DELAY = 200; // 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; #pendingPromiseCount = 0; #spinnerTimeoutId?: ReturnType; connect() { this.#latestPromise = Promise.resolve(); this.onGlobal('autosave:retry', () => this.didRequestRetry()); this.on('change', (event) => this.onChange(event)); this.on('input', (event) => this.onInput(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 onChange(event: Event) { const target = event.target as HTMLInputElement; if (!target.disabled) { if (target.type == 'file') { if (target.dataset.autoAttachUrl && target.files?.length) { this.globalDispatch('autosave:input'); this.enqueueAutouploadRequest(target, target.files[0]); } } else if (target.type == 'hidden') { this.globalDispatch('autosave:input'); // In React comboboxes we dispatch a "change" event on hidden inputs to trigger autosave. // We want to debounce them. this.debounce(this.enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY); } else if ( isSelectElement(target) || isCheckboxOrRadioInputElement(target) ) { this.globalDispatch('autosave:input'); // Wait next tick so champs having JS can interact // with form elements before extracting form data. setTimeout(() => { this.enqueueAutosaveRequest(); this.showConditionnalSpinner(target); }, 0); } } } private onInput(event: Event) { const target = event.target as HTMLInputElement; if ( !target.disabled && // Ignore input from React comboboxes. We trigger "change" events on them when selection is changed. target.getAttribute('role') != 'combobox' && isTextInputElement(target) ) { this.globalDispatch('autosave:input'); this.debounce(this.enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY); this.showConditionnalSpinner(target); } } private showConditionnalSpinner(target: HTMLInputElement) { const champWrapperElement = target.closest( '.editable-champ[data-dependent-conditions]' ); if (!champWrapperElement) { return; } this.showSpinner(champWrapperElement); } private showSpinner(champElement: Element) { this.#spinnerTimeoutId = setTimeout(() => { // do not do anything if there is already a spinner for this champ, like SIRET champ if (!champElement.nextElementSibling?.classList.contains('spinner')) { const spinner = document.createElement('div'); spinner.classList.add('spinner', 'spinner-removable'); spinner.setAttribute('aria-live', 'live'); spinner.setAttribute('aria-label', 'Chargement en cours…'); champElement.insertAdjacentElement('afterend', spinner); } }, AUTOSAVE_CONDITIONAL_SPINNER_DEBOUNCE_DELAY); } private didRequestRetry() { if (this.#needsRetry) { this.enqueueAutosaveRequest(); } } private didEnqueue() { this.#needsRetry = false; this.globalDispatch('autosave:enqueue'); } private didSucceed() { this.#pendingPromiseCount -= 1; if (this.#pendingPromiseCount == 0) { this.globalDispatch('autosave:end'); clearTimeout(this.#spinnerTimeoutId); } } private didFail(error: ResponseError) { this.#needsRetry = true; this.#pendingPromiseCount -= 1; this.globalDispatch('autosave:error', { error }); } private enqueueAutouploadRequest(target: HTMLInputElement, file: File) { const autoupload = new AutoUpload(target, file); autoupload.start().catch((e) => { const error = e as FileUploadError; // Report unexpected client errors to Sentry. // (But ignore usual client errors, or errors we can monitor better on the server side.) if ( error.failureReason == FAILURE_CLIENT && error.code != ERROR_CODE_READ ) { throw error; } }); } // 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 { form, inputs } = this; if (!form || inputs.length == 0) { return Promise.resolve(); } const formData = new FormData(); for (const input of 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 { // NOTE: some type inputs (like number) have an empty input.value // when the filled value is invalid (not a number) so we avoid them formData.append(input.name, input.value); } } this.#pendingPromiseCount++; return httpRequest(form.action, { method: 'post', body: formData, headers: { 'x-http-method-override': 'PATCH' }, signal: this.#abortController.signal, timeout: AUTOSAVE_TIMEOUT_DELAY }).turbo(); } private get form() { return this.element.closest('form'); } private get inputs() { const element = this.element as HTMLElement; return [ ...element.querySelectorAll( 'input:not([type=file]), textarea, select' ) ].filter((element) => !element.disabled); } }