3265fff30e
Fix le workflow suivant pour un champ drop down avec option "autre" : - on choisit la valeur "autre" avec une valeur => ça autosave la bonne valeur - on choisit finalement une autre valeur proposée => l'autosave envoyait la nouvelle valeur, et toujours la valeur "other" car l'input n'était pas encore `disabled`. Par conséquent la valeur other overridait la valeur choisie et ça provoquait des erreurs en cascade, notamment dans le conditionnel.
244 lines
7.3 KiB
TypeScript
244 lines
7.3 KiB
TypeScript
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);
|
|
}
|
|
}
|