import { httpRequest, ResponseError, isSelectElement, isCheckboxOrRadioInputElement, isTextInputElement, show, hide, enable, disable, getConfig } from '@utils'; import { z } from 'zod'; 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; // 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.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.enqueueAutouploadRequest(target, target.files[0]); } } else if (target.type == 'hidden') { // 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.toggleOtherInput(target); this.toggleLinkedSelect(target); this.enqueueAutosaveRequest(); } } } 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.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); try { 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 { formData.append(input.name, input.value); } } return httpRequest(form.action, { method: 'patch', body: formData, 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; 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.filter((element) => !element.disabled); } private toggleOtherInput(target: HTMLSelectElement | HTMLInputElement) { const parent = target.closest('.editable-champ-drop_down_list'); const inputGroup = parent?.querySelector('.drop_down_other'); if (inputGroup) { const input = inputGroup.querySelector('input'); if (input) { if (target.value == '__other__') { show(inputGroup); input.disabled = false; } else { hide(inputGroup); input.disabled = true; } } } } private toggleLinkedSelect(target: HTMLSelectElement | HTMLInputElement) { const secondaryOptions = target.dataset.secondaryOptions; if (isSelectElement(target) && secondaryOptions) { const parent = target.closest('.editable-champ-linked_drop_down_list'); const secondary = parent?.querySelector( 'select[data-secondary]' ); if (secondary) { const options = parseOptions(secondaryOptions); this.setSecondaryOptions(secondary, options[target.value]); } } } private setSecondaryOptions( secondarySelectElement: HTMLSelectElement, options: string[] ) { const wrapper = secondarySelectElement.closest('.secondary'); const hidden = wrapper?.nextElementSibling as HTMLInputElement | null; secondarySelectElement.innerHTML = ''; if (options.length) { disable(hidden); if (secondarySelectElement.required) { secondarySelectElement.appendChild(makeOption('')); } for (const option of options) { secondarySelectElement.appendChild(makeOption(option)); } secondarySelectElement.selectedIndex = 0; enable(secondarySelectElement); show(wrapper); } else { hide(wrapper); disable(secondarySelectElement); enable(hidden); } } } const SecondaryOptions = z.record(z.string().array()); function parseOptions(options: string) { return SecondaryOptions.parse(JSON.parse(options)); } function makeOption(option: string) { const element = document.createElement('option'); element.textContent = option; element.value = option; return element; }