the filter was not applied in repetition block so an additional empty input was send which overrided the real value
212 lines
6.1 KiB
TypeScript
212 lines
6.1 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;
|
|
|
|
// 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));
|
|
|
|
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))
|
|
) {
|
|
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 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.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<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);
|
|
}
|
|
}
|
|
|
|
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<HTMLInputElement>(
|
|
'input:not([type=file]), textarea, select'
|
|
)
|
|
].filter((element) => !element.disabled);
|
|
|
|
const parent = this.element.closest('.editable-champ-repetition');
|
|
if (parent) {
|
|
return [
|
|
...inputs,
|
|
...parent.querySelectorAll<HTMLInputElement>('input[data-id]')
|
|
];
|
|
}
|
|
return inputs;
|
|
}
|
|
}
|