aac7de208f
Les input=number n'ont pas de value lorsque la valeur saisie n'est pas un nombre. Par conséquent dans ces cas là, nous ne pouvions faire remonter au backend pour validation / enregistrement, et il n'y avait aucun feedback signalement l'erreur à l'usager. On les convertit en inputs texte, avec les adaptations nécessaires pour montrer le pavé numérique sur mobile, et un style correct.
238 lines
7.4 KiB
TypeScript
238 lines
7.4 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));
|
|
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)
|
|
) {
|
|
// 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.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<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 {
|
|
// 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<HTMLInputElement>(
|
|
'input:not([type=file]), textarea, select'
|
|
)
|
|
].filter((element) => !element.disabled);
|
|
}
|
|
}
|