demarches-normaliennes/app/javascript/controllers/autosave_controller.ts

245 lines
7.3 KiB
TypeScript
Raw Normal View History

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<typeof setTimeout>;
connect() {
this.#latestPromise = Promise.resolve();
this.onGlobal('autosave:retry', () => this.didRequestRetry());
this.on('change', (event) => this.onChange(event));
if (this.saveOnInput) {
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<HTMLInputElement>(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.saveOnInput && isTextInputElement(target))
) {
// 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.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.querySelector('.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.appendChild(spinner);
}
}, AUTOSAVE_CONDITIONAL_SPINNER_DEBOUNCE_DELAY);
}
private get saveOnInput() {
return !!this.form?.dataset.saveOnInput;
}
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<void> {
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);
}
}
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<HTMLInputElement>(
'input:not([type=file]), textarea, select'
)
].filter((element) => !element.disabled);
}
}