Merge pull request #7257 from tchak/feat-atomic-autosave
refactor(turbo): refactor autosave to use stimulus and turbo
This commit is contained in:
commit
866c4acaf9
25 changed files with 537 additions and 579 deletions
|
@ -174,7 +174,7 @@ module Users
|
|||
|
||||
respond_to do |format|
|
||||
format.html { render :brouillon }
|
||||
format.js
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -29,14 +29,6 @@ module DossierHelper
|
|||
new_dossier_url(procedure_id: revision.procedure.id, brouillon: revision.draft? ? true : nil)
|
||||
end
|
||||
|
||||
def dossier_form_class(dossier)
|
||||
classes = ['form']
|
||||
if autosave_available?(dossier)
|
||||
classes << 'autosave-enabled'
|
||||
end
|
||||
classes.join(' ')
|
||||
end
|
||||
|
||||
def autosave_available?(dossier)
|
||||
dossier.brouillon?
|
||||
end
|
||||
|
|
|
@ -60,7 +60,7 @@ export function useHiddenField(
|
|||
if (hiddenField) {
|
||||
hiddenField.setAttribute('value', value);
|
||||
setValue(value);
|
||||
fire(hiddenField, 'autosave:trigger');
|
||||
fire(hiddenField, 'change');
|
||||
}
|
||||
},
|
||||
hiddenField ?? undefined
|
||||
|
|
|
@ -15,7 +15,7 @@ export class ApplicationController extends Controller {
|
|||
debounced();
|
||||
}
|
||||
|
||||
protected globalDispatch(type: string, detail: Detail): void {
|
||||
protected globalDispatch<T = Detail>(type: string, detail?: T): void {
|
||||
this.dispatch(type, {
|
||||
detail,
|
||||
prefix: '',
|
||||
|
@ -26,14 +26,29 @@ export class ApplicationController extends Controller {
|
|||
protected on<HandlerEvent extends Event = Event>(
|
||||
eventName: string,
|
||||
handler: (event: HandlerEvent) => void
|
||||
): void {
|
||||
this.onTarget(this.element, eventName, handler);
|
||||
}
|
||||
|
||||
protected onGlobal<HandlerEvent extends Event = Event>(
|
||||
eventName: string,
|
||||
handler: (event: HandlerEvent) => void
|
||||
): void {
|
||||
this.onTarget(document.documentElement, eventName, handler);
|
||||
}
|
||||
|
||||
private onTarget<HandlerEvent extends Event = Event>(
|
||||
target: EventTarget,
|
||||
eventName: string,
|
||||
handler: (event: HandlerEvent) => void
|
||||
): void {
|
||||
const disconnect = this.disconnect;
|
||||
const callback = (event: Event): void => {
|
||||
handler(event as HandlerEvent);
|
||||
};
|
||||
this.element.addEventListener(eventName, callback);
|
||||
target.addEventListener(eventName, callback);
|
||||
this.disconnect = () => {
|
||||
this.element.removeEventListener(eventName, callback);
|
||||
target.removeEventListener(eventName, callback);
|
||||
disconnect.call(this);
|
||||
};
|
||||
}
|
||||
|
|
161
app/javascript/controllers/autosave_controller.ts
Normal file
161
app/javascript/controllers/autosave_controller.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
import invariant from 'tiny-invariant';
|
||||
import { httpRequest, ResponseError } from '@utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationController } from './application_controller';
|
||||
import { AutoUpload } from '../shared/activestorage/auto-upload';
|
||||
|
||||
const Gon = z.object({ autosave: z.object({ debounce_delay: z.number() }) });
|
||||
|
||||
declare const window: Window & typeof globalThis & { gon: unknown };
|
||||
const { debounce_delay } = Gon.parse(window.gon).autosave;
|
||||
|
||||
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.onInputChange(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 onInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.disabled) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.type == 'file' &&
|
||||
target.dataset.autoAttachUrl &&
|
||||
target.files?.length
|
||||
) {
|
||||
this.enqueueAutouploadRequest(target, target.files[0]);
|
||||
} else if (target.type != 'file') {
|
||||
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);
|
||||
autoupload.start();
|
||||
}
|
||||
|
||||
// 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 formData = new FormData();
|
||||
for (const input of this.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(this.form.action, {
|
||||
method: 'patch',
|
||||
body: formData,
|
||||
signal: this.#abortController.signal,
|
||||
timeout: AUTOSAVE_TIMEOUT_DELAY
|
||||
}).turbo();
|
||||
}
|
||||
|
||||
private get form() {
|
||||
const form = this.element.closest('form');
|
||||
invariant(form, 'Could not find the form element.');
|
||||
return form;
|
||||
}
|
||||
|
||||
private get inputs() {
|
||||
const element = this.element as HTMLElement;
|
||||
const inputs = [
|
||||
...element.querySelectorAll<HTMLInputElement>(
|
||||
'input:not([type=file]), textarea, select'
|
||||
)
|
||||
];
|
||||
const parent = this.element.closest('.editable-champ-repetition');
|
||||
if (parent) {
|
||||
return [
|
||||
...inputs,
|
||||
...parent.querySelectorAll<HTMLInputElement>('input[data-id]')
|
||||
];
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
}
|
97
app/javascript/controllers/autosave_status_controller.ts
Normal file
97
app/javascript/controllers/autosave_status_controller.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
enable,
|
||||
disable,
|
||||
hasClass,
|
||||
addClass,
|
||||
removeClass,
|
||||
ResponseError
|
||||
} from '@utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationController } from './application_controller';
|
||||
|
||||
const Gon = z.object({
|
||||
autosave: z.object({ status_visible_duration: z.number() })
|
||||
});
|
||||
|
||||
declare const window: Window & typeof globalThis & { gon: unknown };
|
||||
const { status_visible_duration } = Gon.parse(window.gon).autosave;
|
||||
|
||||
const AUTOSAVE_STATUS_VISIBLE_DURATION = status_visible_duration;
|
||||
|
||||
// This is a controller we attach to the status area in the main form. It
|
||||
// coordinates notifications and will dispatch `autosave:retry` event if user
|
||||
// decides to retry after an error.
|
||||
//
|
||||
export class AutosaveStatusController extends ApplicationController {
|
||||
static targets = ['retryButton'];
|
||||
|
||||
declare readonly retryButtonTarget: HTMLButtonElement;
|
||||
|
||||
connect(): void {
|
||||
this.onGlobal('autosave:enqueue', () => this.didEnqueue());
|
||||
this.onGlobal('autosave:end', () => this.didSucceed());
|
||||
this.onGlobal<CustomEvent>('autosave:error', (event) =>
|
||||
this.didFail(event)
|
||||
);
|
||||
}
|
||||
|
||||
onClickRetryButton() {
|
||||
this.globalDispatch('autosave:retry');
|
||||
}
|
||||
|
||||
private didEnqueue() {
|
||||
disable(this.retryButtonTarget);
|
||||
}
|
||||
|
||||
private didSucceed() {
|
||||
enable(this.retryButtonTarget);
|
||||
this.setState('succeeded');
|
||||
this.debounce(this.hideSucceededStatus, AUTOSAVE_STATUS_VISIBLE_DURATION);
|
||||
}
|
||||
|
||||
private didFail(event: CustomEvent<{ error: ResponseError }>) {
|
||||
const error = event.detail.error;
|
||||
|
||||
if (error.response?.status == 401) {
|
||||
// If we are unauthenticated, reload the page using a GET request.
|
||||
// This will allow Devise to properly redirect us to sign-in, and then back to this page.
|
||||
document.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
enable(this.retryButtonTarget);
|
||||
this.setState('failed');
|
||||
|
||||
const shouldLogError = !error.response || error.response.status != 0; // ignore timeout errors
|
||||
if (shouldLogError) {
|
||||
this.logError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: 'succeeded' | 'failed' | 'idle') {
|
||||
const autosave = this.element as HTMLDivElement;
|
||||
if (autosave) {
|
||||
// Re-apply the state even if already present, to get a nice animation
|
||||
removeClass(autosave, 'autosave-state-idle');
|
||||
removeClass(autosave, 'autosave-state-succeeded');
|
||||
removeClass(autosave, 'autosave-state-failed');
|
||||
autosave.offsetHeight; // flush animations
|
||||
addClass(autosave, `autosave-state-${state}`);
|
||||
}
|
||||
}
|
||||
|
||||
private hideSucceededStatus() {
|
||||
if (hasClass(this.element as HTMLElement, 'autosave-state-succeeded')) {
|
||||
this.setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
private logError(error: ResponseError) {
|
||||
if (error && error.message) {
|
||||
error.message = `[Autosave] ${error.message}`;
|
||||
console.error(error);
|
||||
this.globalDispatch('sentry:capture-exception', error);
|
||||
}
|
||||
}
|
||||
}
|
16
app/javascript/controllers/index.ts
Normal file
16
app/javascript/controllers/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Application } from '@hotwired/stimulus';
|
||||
|
||||
import { ReactController } from './react_controller';
|
||||
import { TurboEventController } from './turbo_event_controller';
|
||||
import { GeoAreaController } from './geo_area_controller';
|
||||
import { TurboInputController } from './turbo_input_controller';
|
||||
import { AutosaveController } from './autosave_controller';
|
||||
import { AutosaveStatusController } from './autosave_status_controller';
|
||||
|
||||
const Stimulus = Application.start();
|
||||
Stimulus.register('react', ReactController);
|
||||
Stimulus.register('turbo-event', TurboEventController);
|
||||
Stimulus.register('geo-area', GeoAreaController);
|
||||
Stimulus.register('turbo-input', TurboInputController);
|
||||
Stimulus.register('autosave', AutosaveController);
|
||||
Stimulus.register('autosave-status', AutosaveStatusController);
|
|
@ -1,97 +0,0 @@
|
|||
import { ajax, fire, timeoutable } from '@utils';
|
||||
|
||||
// Manages a queue of Autosave operations,
|
||||
// and sends `autosave:*` events to indicate the state of the requests:
|
||||
//
|
||||
// - autosave:enqueue () when an autosave request has been enqueued
|
||||
// - autosave:end ({ response, statusText, xhr }) when an autosave request finished successfully
|
||||
// - autosave:failure (Error) when an autosave request failed
|
||||
//
|
||||
export default class AutoSaveController {
|
||||
constructor() {
|
||||
this.timeoutDelay = 60000; // 1mn
|
||||
this.latestPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
// 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.)
|
||||
enqueueAutosaveRequest(form) {
|
||||
this.latestPromise = this.latestPromise.finally(() => {
|
||||
return this._sendAutosaveRequest(form)
|
||||
.then(this._didSucceed)
|
||||
.catch(this._didFail);
|
||||
});
|
||||
this._didEnqueue();
|
||||
}
|
||||
|
||||
// Create a fetch request that saves the form.
|
||||
// Returns a promise fulfilled when the request completes.
|
||||
_sendAutosaveRequest(form) {
|
||||
const autosavePromise = new Promise((resolve, reject) => {
|
||||
if (!document.body.contains(form)) {
|
||||
return reject(new Error('The form can no longer be found.'));
|
||||
}
|
||||
|
||||
const [formData, formDataError] = this._formDataForDraft(form);
|
||||
if (formDataError) {
|
||||
formDataError.message = `Error while generating the form data (${formDataError.message})`;
|
||||
return reject(formDataError);
|
||||
}
|
||||
|
||||
const params = {
|
||||
url: form.action,
|
||||
type: form.method,
|
||||
data: formData,
|
||||
dataType: 'script'
|
||||
};
|
||||
|
||||
return ajax(params)
|
||||
.then(({ response }) => {
|
||||
resolve(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Time out the request after a while, to avoid recent requests not starting
|
||||
// because an older one is stuck.
|
||||
return timeoutable(autosavePromise, this.timeoutDelay);
|
||||
}
|
||||
|
||||
// Extract a FormData object of the form fields.
|
||||
_formDataForDraft(form) {
|
||||
// File inputs are handled separatly by ActiveStorage:
|
||||
// exclude them from the draft (by disabling them).
|
||||
// (Also Safari has issue with FormData containing empty file inputs)
|
||||
const fileInputs = form.querySelectorAll(
|
||||
'input[type="file"]:not([disabled]), .editable-champ-piece_justificative input:not([disabled])'
|
||||
);
|
||||
fileInputs.forEach((fileInput) => (fileInput.disabled = true));
|
||||
|
||||
// Generate the form data
|
||||
let formData = null;
|
||||
try {
|
||||
formData = new FormData(form);
|
||||
return [formData, null];
|
||||
} catch (error) {
|
||||
return [null, error];
|
||||
} finally {
|
||||
// Re-enable disabled file inputs
|
||||
fileInputs.forEach((fileInput) => (fileInput.disabled = false));
|
||||
}
|
||||
}
|
||||
|
||||
_didEnqueue() {
|
||||
fire(document, 'autosave:enqueue');
|
||||
}
|
||||
|
||||
_didSucceed(response) {
|
||||
fire(document, 'autosave:end', response);
|
||||
}
|
||||
|
||||
_didFail(error) {
|
||||
fire(document, 'autosave:error', error);
|
||||
}
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
import AutoSaveController from './auto-save-controller.js';
|
||||
import {
|
||||
debounce,
|
||||
delegate,
|
||||
fire,
|
||||
enable,
|
||||
disable,
|
||||
hasClass,
|
||||
addClass,
|
||||
removeClass
|
||||
} from '@utils';
|
||||
|
||||
const AUTOSAVE_DEBOUNCE_DELAY = window?.gon?.autosave?.debounce_delay;
|
||||
const AUTOSAVE_STATUS_VISIBLE_DURATION =
|
||||
window?.gon?.autosave?.status_visible_duration;
|
||||
|
||||
// Create a controller responsible for queuing autosave operations.
|
||||
const autoSaveController = new AutoSaveController();
|
||||
|
||||
function enqueueAutosaveRequest() {
|
||||
const form = document.querySelector(FORM_SELECTOR);
|
||||
autoSaveController.enqueueAutosaveRequest(form);
|
||||
}
|
||||
|
||||
//
|
||||
// Whenever a 'change' event is triggered on one of the form inputs, try to autosave.
|
||||
//
|
||||
|
||||
const FORM_SELECTOR = 'form#dossier-edit-form.autosave-enabled';
|
||||
const INPUTS_SELECTOR = `${FORM_SELECTOR} input:not([type=file]), ${FORM_SELECTOR} select, ${FORM_SELECTOR} textarea`;
|
||||
const RETRY_BUTTON_SELECTOR = '.autosave-retry';
|
||||
|
||||
// When an autosave is requested programmatically, auto-save the form immediately
|
||||
addEventListener('autosave:trigger', (event) => {
|
||||
const form = event.target.closest('form');
|
||||
if (form && form.classList.contains('autosave-enabled')) {
|
||||
enqueueAutosaveRequest();
|
||||
}
|
||||
});
|
||||
|
||||
// When the "Retry" button is clicked, auto-save the form immediately
|
||||
delegate('click', RETRY_BUTTON_SELECTOR, enqueueAutosaveRequest);
|
||||
|
||||
// When an input changes, batches changes for N seconds, then auto-save the form
|
||||
delegate(
|
||||
'change',
|
||||
INPUTS_SELECTOR,
|
||||
debounce(enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY)
|
||||
);
|
||||
|
||||
//
|
||||
// Display some UI during the autosave
|
||||
//
|
||||
|
||||
addEventListener('autosave:enqueue', () => {
|
||||
disable(document.querySelector('button.autosave-retry'));
|
||||
});
|
||||
|
||||
addEventListener('autosave:end', () => {
|
||||
enable(document.querySelector('button.autosave-retry'));
|
||||
setState('succeeded');
|
||||
hideSucceededStatusAfterDelay();
|
||||
});
|
||||
|
||||
addEventListener('autosave:error', (event) => {
|
||||
let error = event.detail;
|
||||
|
||||
if (error.xhr && error.xhr.status == 401) {
|
||||
// If we are unauthenticated, reload the page using a GET request.
|
||||
// This will allow Devise to properly redirect us to sign-in, and then back to this page.
|
||||
document.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
enable(document.querySelector('button.autosave-retry'));
|
||||
setState('failed');
|
||||
|
||||
const shouldLogError = !error.xhr || error.xhr.status != 0; // ignore timeout errors
|
||||
if (shouldLogError) {
|
||||
logError(error);
|
||||
}
|
||||
});
|
||||
|
||||
function setState(state) {
|
||||
const autosave = document.querySelector('.autosave');
|
||||
if (autosave) {
|
||||
// Re-apply the state even if already present, to get a nice animation
|
||||
removeClass(autosave, 'autosave-state-idle');
|
||||
removeClass(autosave, 'autosave-state-succeeded');
|
||||
removeClass(autosave, 'autosave-state-failed');
|
||||
autosave.offsetHeight; // flush animations
|
||||
addClass(autosave, `autosave-state-${state}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hideSucceededStatus() {
|
||||
const autosave = document.querySelector('.autosave');
|
||||
if (hasClass(autosave, 'autosave-state-succeeded')) {
|
||||
setState('idle');
|
||||
}
|
||||
}
|
||||
const hideSucceededStatusAfterDelay = debounce(
|
||||
hideSucceededStatus,
|
||||
AUTOSAVE_STATUS_VISIBLE_DURATION
|
||||
);
|
||||
|
||||
function logError(error) {
|
||||
if (error && error.message) {
|
||||
error.message = `[Autosave] ${error.message}`;
|
||||
console.error(error);
|
||||
fire(document, 'sentry:capture-exception', error);
|
||||
}
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
import Uploader from '../../shared/activestorage/uploader';
|
||||
import { show, hide, toggle } from '@utils';
|
||||
import {
|
||||
ERROR_CODE_READ,
|
||||
FAILURE_CONNECTIVITY
|
||||
} from '../../shared/activestorage/file-upload-error';
|
||||
|
||||
// Given a file input in a champ with a selected file, upload a file,
|
||||
// then attach it to the dossier.
|
||||
//
|
||||
// On success, the champ is replaced by an HTML fragment describing the attachment.
|
||||
// On error, a error message is displayed above the input.
|
||||
export default class AutoUploadController {
|
||||
constructor(input, file) {
|
||||
this.input = input;
|
||||
this.file = file;
|
||||
this.uploader = new Uploader(
|
||||
input,
|
||||
file,
|
||||
input.dataset.directUploadUrl,
|
||||
input.dataset.autoAttachUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Create, upload and attach the file.
|
||||
// On failure, display an error message and throw a FileUploadError.
|
||||
async start() {
|
||||
try {
|
||||
this._begin();
|
||||
await this.uploader.start();
|
||||
this._succeeded();
|
||||
} catch (error) {
|
||||
this._failed(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this._done();
|
||||
}
|
||||
}
|
||||
|
||||
_begin() {
|
||||
this.input.disabled = true;
|
||||
this._hideErrorMessage();
|
||||
}
|
||||
|
||||
_succeeded() {
|
||||
this.input.value = null;
|
||||
}
|
||||
|
||||
_failed(error) {
|
||||
if (!document.body.contains(this.input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploader.progressBar.destroy();
|
||||
|
||||
let message = this._messageFromError(error);
|
||||
this._displayErrorMessage(message);
|
||||
}
|
||||
|
||||
_done() {
|
||||
this.input.disabled = false;
|
||||
}
|
||||
|
||||
_messageFromError(error) {
|
||||
let message = error.message || error.toString();
|
||||
let canRetry = error.status && error.status != 422;
|
||||
|
||||
if (error.failureReason == FAILURE_CONNECTIVITY) {
|
||||
return {
|
||||
title: 'Le fichier n’a pas pu être envoyé.',
|
||||
description: 'Vérifiez votre connexion à Internet, puis ré-essayez.',
|
||||
retry: true
|
||||
};
|
||||
} else if (error.code == ERROR_CODE_READ) {
|
||||
return {
|
||||
title: 'Nous n’arrivons pas à lire ce fichier sur votre appareil.',
|
||||
description: 'Essayez à nouveau, ou sélectionnez un autre fichier.',
|
||||
retry: false
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
title: 'Le fichier n’a pas pu être envoyé.',
|
||||
description: message,
|
||||
retry: canRetry
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_displayErrorMessage(message) {
|
||||
let errorNode = this.input.parentElement.querySelector('.attachment-error');
|
||||
if (errorNode) {
|
||||
show(errorNode);
|
||||
errorNode.querySelector('.attachment-error-title').textContent =
|
||||
message.title || '';
|
||||
errorNode.querySelector('.attachment-error-description').textContent =
|
||||
message.description || '';
|
||||
toggle(errorNode.querySelector('.attachment-error-retry'), message.retry);
|
||||
}
|
||||
}
|
||||
|
||||
_hideErrorMessage() {
|
||||
let errorElement =
|
||||
this.input.parentElement.querySelector('.attachment-error');
|
||||
if (errorElement) {
|
||||
hide(errorElement);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import AutoUploadsControllers from './auto-uploads-controllers.js';
|
||||
import { delegate } from '@utils';
|
||||
|
||||
// Create a controller responsible for managing several concurrent uploads.
|
||||
const autoUploadsControllers = new AutoUploadsControllers();
|
||||
|
||||
function startUpload(input) {
|
||||
Array.from(input.files).forEach((file) => {
|
||||
autoUploadsControllers.upload(input, file);
|
||||
});
|
||||
}
|
||||
|
||||
const fileInputSelector = `input[type=file][data-direct-upload-url][data-auto-attach-url]:not([disabled])`;
|
||||
delegate('change', fileInputSelector, (event) => {
|
||||
startUpload(event.target);
|
||||
});
|
||||
|
||||
const retryButtonSelector = `button.attachment-error-retry`;
|
||||
delegate('click', retryButtonSelector, function () {
|
||||
const inputSelector = this.dataset.inputTarget;
|
||||
const input = document.querySelector(inputSelector);
|
||||
startUpload(input);
|
||||
});
|
|
@ -1,56 +0,0 @@
|
|||
import Rails from '@rails/ujs';
|
||||
import AutoUploadController from './auto-upload-controller.js';
|
||||
import {
|
||||
FAILURE_CLIENT,
|
||||
ERROR_CODE_READ
|
||||
} from '../../shared/activestorage/file-upload-error';
|
||||
|
||||
// Manage multiple concurrent uploads.
|
||||
//
|
||||
// When the first upload starts, all the form "Submit" buttons are disabled.
|
||||
// They are enabled again when the last upload ends.
|
||||
export default class AutoUploadsControllers {
|
||||
constructor() {
|
||||
this.inFlightUploadsCount = 0;
|
||||
}
|
||||
|
||||
async upload(input, file) {
|
||||
let form = input.form;
|
||||
this._incrementInFlightUploads(form);
|
||||
|
||||
try {
|
||||
let controller = new AutoUploadController(input, file);
|
||||
await controller.start();
|
||||
} catch (err) {
|
||||
// Report unexpected client errors to Sentry.
|
||||
// (But ignore usual client errors, or errors we can monitor better on the server side.)
|
||||
if (err.failureReason == FAILURE_CLIENT && err.code != ERROR_CODE_READ) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
this._decrementInFlightUploads(form);
|
||||
}
|
||||
}
|
||||
|
||||
_incrementInFlightUploads(form) {
|
||||
this.inFlightUploadsCount += 1;
|
||||
|
||||
if (form) {
|
||||
form
|
||||
.querySelectorAll('button[type=submit]')
|
||||
.forEach((submitButton) => Rails.disableElement(submitButton));
|
||||
}
|
||||
}
|
||||
|
||||
_decrementInFlightUploads(form) {
|
||||
if (this.inFlightUploadsCount > 0) {
|
||||
this.inFlightUploadsCount -= 1;
|
||||
}
|
||||
|
||||
if (this.inFlightUploadsCount == 0 && form) {
|
||||
form
|
||||
.querySelectorAll('button[type=submit]')
|
||||
.forEach((submitButton) => Rails.enableElement(submitButton));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ import '../shared/polyfills';
|
|||
import Rails from '@rails/ujs';
|
||||
import * as ActiveStorage from '@rails/activestorage';
|
||||
import 'whatwg-fetch'; // window.fetch polyfill
|
||||
import { Application } from '@hotwired/stimulus';
|
||||
import * as Turbo from '@hotwired/turbo';
|
||||
|
||||
import '../shared/activestorage/ujs';
|
||||
|
@ -11,13 +10,8 @@ import '../shared/safari-11-file-xhr-workaround';
|
|||
import '../shared/toggle-target';
|
||||
import '../shared/ujs-error-handling';
|
||||
|
||||
import {
|
||||
ReactController,
|
||||
registerComponents
|
||||
} from '../controllers/react_controller';
|
||||
import { TurboEventController } from '../controllers/turbo_event_controller';
|
||||
import { GeoAreaController } from '../controllers/geo_area_controller';
|
||||
import { TurboInputController } from '../controllers/turbo_input_controller';
|
||||
import { registerComponents } from '../controllers/react_controller';
|
||||
import '../controllers';
|
||||
|
||||
import '../new_design/dropdown';
|
||||
import '../new_design/form-validation';
|
||||
|
@ -26,8 +20,6 @@ import '../new_design/procedure-form';
|
|||
import '../new_design/spinner';
|
||||
import '../new_design/support';
|
||||
import '../new_design/messagerie';
|
||||
import '../new_design/dossiers/auto-save';
|
||||
import '../new_design/dossiers/auto-upload';
|
||||
|
||||
import '../new_design/champs/linked-drop-down-list';
|
||||
import '../new_design/champs/drop-down-list';
|
||||
|
@ -90,11 +82,5 @@ Rails.start();
|
|||
ActiveStorage.start();
|
||||
Turbo.session.drive = false;
|
||||
|
||||
const Stimulus = Application.start();
|
||||
Stimulus.register('react', ReactController);
|
||||
Stimulus.register('turbo-event', TurboEventController);
|
||||
Stimulus.register('geo-area', GeoAreaController);
|
||||
Stimulus.register('turbo-input', TurboInputController);
|
||||
|
||||
// Expose globals
|
||||
window.DS = window.DS || DS;
|
||||
|
|
143
app/javascript/shared/activestorage/auto-upload.ts
Normal file
143
app/javascript/shared/activestorage/auto-upload.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import invariant from 'tiny-invariant';
|
||||
import { show, hide, toggle } from '@utils';
|
||||
|
||||
import Uploader from './uploader';
|
||||
import {
|
||||
FileUploadError,
|
||||
ERROR_CODE_READ,
|
||||
FAILURE_CONNECTIVITY
|
||||
} from './file-upload-error';
|
||||
|
||||
type ErrorMessage = {
|
||||
title: string;
|
||||
description: string;
|
||||
retry: boolean;
|
||||
};
|
||||
|
||||
// Given a file input in a champ with a selected file, upload a file,
|
||||
// then attach it to the dossier.
|
||||
//
|
||||
// On success, the champ is replaced by an HTML fragment describing the attachment.
|
||||
// On error, a error message is displayed above the input.
|
||||
export class AutoUpload {
|
||||
#input: HTMLInputElement;
|
||||
#uploader: Uploader;
|
||||
|
||||
constructor(input: HTMLInputElement, file: File) {
|
||||
const { directUploadUrl, autoAttachUrl } = input.dataset;
|
||||
invariant(directUploadUrl, 'Could not find the direct upload URL.');
|
||||
this.#input = input;
|
||||
this.#uploader = new Uploader(input, file, directUploadUrl, autoAttachUrl);
|
||||
}
|
||||
|
||||
// Create, upload and attach the file.
|
||||
// On failure, display an error message and throw a FileUploadError.
|
||||
async start() {
|
||||
try {
|
||||
this.begin();
|
||||
await this.#uploader.start();
|
||||
this.succeeded();
|
||||
} catch (error) {
|
||||
this.failed(error as FileUploadError);
|
||||
throw error;
|
||||
} finally {
|
||||
this.done();
|
||||
}
|
||||
}
|
||||
|
||||
private begin() {
|
||||
this.#input.disabled = true;
|
||||
this.hideErrorMessage();
|
||||
}
|
||||
|
||||
private succeeded() {
|
||||
this.#input.value = '';
|
||||
}
|
||||
|
||||
private failed(error: FileUploadError) {
|
||||
if (!document.body.contains(this.#input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#uploader.progressBar.destroy();
|
||||
|
||||
const message = this.messageFromError(error);
|
||||
this.displayErrorMessage(message);
|
||||
}
|
||||
|
||||
private done() {
|
||||
this.#input.disabled = false;
|
||||
}
|
||||
|
||||
private messageFromError(error: FileUploadError): ErrorMessage {
|
||||
const message = error.message || error.toString();
|
||||
const canRetry = error.status && error.status != 422;
|
||||
|
||||
if (error.failureReason == FAILURE_CONNECTIVITY) {
|
||||
return {
|
||||
title: 'Le fichier n’a pas pu être envoyé.',
|
||||
description: 'Vérifiez votre connexion à Internet, puis ré-essayez.',
|
||||
retry: true
|
||||
};
|
||||
} else if (error.code == ERROR_CODE_READ) {
|
||||
return {
|
||||
title: 'Nous n’arrivons pas à lire ce fichier sur votre appareil.',
|
||||
description: 'Essayez à nouveau, ou sélectionnez un autre fichier.',
|
||||
retry: false
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
title: 'Le fichier n’a pas pu être envoyé.',
|
||||
description: message,
|
||||
retry: !!canRetry
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private displayErrorMessage(message: ErrorMessage) {
|
||||
const errorElement = this.errorElement;
|
||||
if (errorElement) {
|
||||
show(errorElement);
|
||||
this.errorTitleElement.textContent = message.title || '';
|
||||
this.errorDescriptionElement.textContent = message.description || '';
|
||||
toggle(this.errorRetryButton, message.retry);
|
||||
}
|
||||
}
|
||||
|
||||
private hideErrorMessage() {
|
||||
const errorElement = this.errorElement;
|
||||
if (errorElement) {
|
||||
hide(errorElement);
|
||||
}
|
||||
}
|
||||
|
||||
get errorElement() {
|
||||
return this.#input.parentElement?.querySelector<HTMLElement>(
|
||||
'.attachment-error'
|
||||
);
|
||||
}
|
||||
|
||||
get errorTitleElement() {
|
||||
const element = this.errorElement?.querySelector<HTMLElement>(
|
||||
'.attachment-error-title'
|
||||
);
|
||||
invariant(element, 'Could not find the error title element.');
|
||||
return element;
|
||||
}
|
||||
|
||||
get errorDescriptionElement() {
|
||||
const element = this.errorElement?.querySelector<HTMLElement>(
|
||||
'.attachment-error-description'
|
||||
);
|
||||
invariant(element, 'Could not find the error description element.');
|
||||
return element;
|
||||
}
|
||||
|
||||
get errorRetryButton() {
|
||||
const element = this.errorElement?.querySelector<HTMLButtonElement>(
|
||||
'.attachment-error-retry'
|
||||
);
|
||||
invariant(element, 'Could not find the error retry button element.');
|
||||
return element;
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ export const FAILURE_CONNECTIVITY = 'file-upload-failure-connectivity';
|
|||
/**
|
||||
Represent an error during a file upload.
|
||||
*/
|
||||
export default class FileUploadError extends Error {
|
||||
export class FileUploadError extends Error {
|
||||
status?: number;
|
||||
code?: string;
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { DirectUpload } from '@rails/activestorage';
|
||||
import { ajax } from '@utils';
|
||||
import { httpRequest, ResponseError } from '@utils';
|
||||
import ProgressBar from './progress-bar';
|
||||
import FileUploadError, {
|
||||
import {
|
||||
FileUploadError,
|
||||
errorFromDirectUploadMessage,
|
||||
ERROR_CODE_ATTACH
|
||||
} from './file-upload-error';
|
||||
|
@ -35,10 +36,10 @@ export default class Uploader {
|
|||
this.progressBar.start();
|
||||
|
||||
try {
|
||||
const blobSignedId = await this._upload();
|
||||
const blobSignedId = await this.upload();
|
||||
|
||||
if (this.autoAttachUrl) {
|
||||
await this._attach(blobSignedId, this.autoAttachUrl);
|
||||
await this.attach(blobSignedId, this.autoAttachUrl);
|
||||
// On response, the attachment HTML fragment will replace the progress bar.
|
||||
} else {
|
||||
this.progressBar.end();
|
||||
|
@ -56,7 +57,7 @@ export default class Uploader {
|
|||
Upload the file using the DirectUpload instance, and return the blob signed_id.
|
||||
Throws a FileUploadError on failure.
|
||||
*/
|
||||
async _upload(): Promise<string> {
|
||||
private async upload(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.directUpload.create((errorMsg, attributes) => {
|
||||
if (errorMsg) {
|
||||
|
@ -74,24 +75,22 @@ export default class Uploader {
|
|||
Throws a FileUploadError on failure (containing the first validation
|
||||
error message, if any).
|
||||
*/
|
||||
async _attach(blobSignedId: string, autoAttachUrl: string) {
|
||||
const attachmentRequest = {
|
||||
url: autoAttachUrl,
|
||||
type: 'PUT',
|
||||
data: `blob_signed_id=${blobSignedId}`
|
||||
};
|
||||
private async attach(blobSignedId: string, autoAttachUrl: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('blob_signed_id', blobSignedId);
|
||||
|
||||
try {
|
||||
await ajax(attachmentRequest);
|
||||
await httpRequest(autoAttachUrl, {
|
||||
method: 'put',
|
||||
body: formData
|
||||
}).turbo();
|
||||
} catch (e) {
|
||||
const error = e as {
|
||||
response?: { errors: string[] };
|
||||
xhr?: XMLHttpRequest;
|
||||
};
|
||||
const message = error.response?.errors && error.response.errors[0];
|
||||
const error = e as ResponseError;
|
||||
const errors = (error.jsonBody as { errors: string[] })?.errors;
|
||||
const message = errors && errors[0];
|
||||
throw new FileUploadError(
|
||||
message || 'Error attaching file.',
|
||||
error.xhr?.status,
|
||||
error.response?.status,
|
||||
ERROR_CODE_ATTACH
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,42 +5,42 @@ import { session } from '@hotwired/turbo';
|
|||
export { debounce };
|
||||
export const { fire, csrfToken, cspNonce } = Rails;
|
||||
|
||||
export function show(el: HTMLElement) {
|
||||
el && el.classList.remove('hidden');
|
||||
export function show(el: HTMLElement | null) {
|
||||
el?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function hide(el: HTMLElement) {
|
||||
el && el.classList.add('hidden');
|
||||
export function hide(el: HTMLElement | null) {
|
||||
el?.classList.add('hidden');
|
||||
}
|
||||
|
||||
export function toggle(el: HTMLElement, force?: boolean) {
|
||||
export function toggle(el: HTMLElement | null, force?: boolean) {
|
||||
if (force == undefined) {
|
||||
el && el.classList.toggle('hidden');
|
||||
el?.classList.toggle('hidden');
|
||||
} else if (force) {
|
||||
el && el.classList.remove('hidden');
|
||||
el?.classList.remove('hidden');
|
||||
} else {
|
||||
el && el.classList.add('hidden');
|
||||
el?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
export function enable(el: HTMLInputElement) {
|
||||
export function enable(el: HTMLInputElement | HTMLButtonElement | null) {
|
||||
el && (el.disabled = false);
|
||||
}
|
||||
|
||||
export function disable(el: HTMLInputElement) {
|
||||
export function disable(el: HTMLInputElement | HTMLButtonElement | null) {
|
||||
el && (el.disabled = true);
|
||||
}
|
||||
|
||||
export function hasClass(el: HTMLElement, cssClass: string) {
|
||||
return el && el.classList.contains(cssClass);
|
||||
export function hasClass(el: HTMLElement | null, cssClass: string) {
|
||||
return el?.classList.contains(cssClass);
|
||||
}
|
||||
|
||||
export function addClass(el: HTMLElement, cssClass: string) {
|
||||
el && el.classList.add(cssClass);
|
||||
export function addClass(el: HTMLElement | null, cssClass: string) {
|
||||
el?.classList.add(cssClass);
|
||||
}
|
||||
|
||||
export function removeClass(el: HTMLElement, cssClass: string) {
|
||||
el && el.classList.remove(cssClass);
|
||||
export function removeClass(el: HTMLElement | null, cssClass: string) {
|
||||
el?.classList.remove(cssClass);
|
||||
}
|
||||
|
||||
export function delegate<E extends Event = Event>(
|
||||
|
@ -60,46 +60,16 @@ export function delegate<E extends Event = Event>(
|
|||
);
|
||||
}
|
||||
|
||||
// A promise-based wrapper for Rails.ajax().
|
||||
//
|
||||
// Returns a Promise that is either:
|
||||
// - resolved in case of a 20* HTTP response code,
|
||||
// - rejected with an Error object otherwise.
|
||||
//
|
||||
// See Rails.ajax() code for more details.
|
||||
export function ajax(options: Rails.AjaxOptions) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Object.assign(options, {
|
||||
success: (
|
||||
response: unknown,
|
||||
statusText: string,
|
||||
xhr: { status: number }
|
||||
) => {
|
||||
resolve({ response, statusText, xhr });
|
||||
},
|
||||
error: (
|
||||
response: unknown,
|
||||
statusText: string,
|
||||
xhr: { status: number }
|
||||
) => {
|
||||
// NB: on HTTP/2 connections, statusText is always empty.
|
||||
const error = new Error(
|
||||
`Erreur ${xhr.status}` + (statusText ? ` : ${statusText}` : '')
|
||||
);
|
||||
Object.assign(error, { response, statusText, xhr });
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
Rails.ajax(options);
|
||||
});
|
||||
}
|
||||
|
||||
export class ResponseError extends Error {
|
||||
response: Response;
|
||||
readonly response: Response;
|
||||
readonly jsonBody?: unknown;
|
||||
readonly textBody?: string;
|
||||
|
||||
constructor(response: Response) {
|
||||
constructor(response: Response, jsonBody?: unknown, textBody?: string) {
|
||||
super(String(response.statusText || response.status));
|
||||
this.response = response;
|
||||
this.jsonBody = jsonBody;
|
||||
this.textBody = textBody;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -119,10 +89,6 @@ const FETCH_TIMEOUT = 30 * 1000; // 30 sec
|
|||
// Execute a GET request, and apply the Turbo stream in the Response
|
||||
// await httpRequest(url).turbo();
|
||||
//
|
||||
// Execute a GET request, and interpret the JavaScript code in the Response
|
||||
// DEPRECATED: Don't use this in new code; instead let the server respond with a turbo stream
|
||||
// await httpRequest(url).js();
|
||||
//
|
||||
export function httpRequest(
|
||||
url: string,
|
||||
{
|
||||
|
@ -163,26 +129,42 @@ export function httpRequest(
|
|||
}
|
||||
}
|
||||
|
||||
const request = (init: RequestInit, accept?: string): Promise<Response> => {
|
||||
const request = async (
|
||||
init: RequestInit,
|
||||
accept?: string
|
||||
): Promise<Response> => {
|
||||
if (accept && init.headers instanceof Headers) {
|
||||
init.headers.set('accept', accept);
|
||||
}
|
||||
return fetch(url, init)
|
||||
.then((response) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
|
||||
if (response.ok) {
|
||||
return response;
|
||||
} else if (response.status == 401) {
|
||||
location.reload(); // reload whole page so Devise will redirect to sign-in
|
||||
if (response.ok) {
|
||||
return response;
|
||||
} else if (response.status == 401) {
|
||||
location.reload(); // reload whole page so Devise will redirect to sign-in
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
let jsonBody: unknown;
|
||||
let textBody: string | undefined;
|
||||
try {
|
||||
if (contentType?.match('json')) {
|
||||
jsonBody = await response.json();
|
||||
} else {
|
||||
textBody = await response.text();
|
||||
}
|
||||
throw new ResponseError(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ResponseError(response, jsonBody, textBody);
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
|
||||
throw error;
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -199,19 +181,6 @@ export function httpRequest(
|
|||
const stream = await response.text();
|
||||
session.renderStreamMessage(stream);
|
||||
}
|
||||
},
|
||||
async js(): Promise<void> {
|
||||
const response = await request(init, 'text/javascript');
|
||||
if (response.status != 204) {
|
||||
const script = document.createElement('script');
|
||||
const nonce = cspNonce();
|
||||
if (nonce) {
|
||||
script.setAttribute('nonce', nonce);
|
||||
}
|
||||
script.text = await response.text();
|
||||
document.head.appendChild(script);
|
||||
document.head.removeChild(script);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -234,18 +203,6 @@ export function scrollToBottom(container: HTMLElement) {
|
|||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
export function on(
|
||||
selector: string,
|
||||
eventName: string,
|
||||
fn: (event: Event, detail: unknown) => void
|
||||
) {
|
||||
[...document.querySelectorAll(selector)].forEach((element) =>
|
||||
element.addEventListener(eventName, (event) =>
|
||||
fn(event, (event as CustomEvent).detail)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNumeric(s: string) {
|
||||
const n = parseFloat(s);
|
||||
return !isNaN(n) && isFinite(n);
|
||||
|
@ -258,16 +215,3 @@ function offset(element: HTMLElement) {
|
|||
left: rect.left + document.body.scrollLeft
|
||||
};
|
||||
}
|
||||
|
||||
// Takes a promise, and return a promise that times out after the given delay.
|
||||
export function timeoutable<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutDelay: number
|
||||
): Promise<T> {
|
||||
const timeoutPromise = new Promise<T>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Promise timed out after ${timeoutDelay}ms`));
|
||||
}, timeoutDelay);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
<%= fields_for @champ.input_name, @champ do |form| %>
|
||||
<%= render_to_element("##{@champ.input_group_id}", partial: "shared/dossiers/editable_champs/editable_champ", locals: { champ: @champ, form: form }, outer: true) %>
|
||||
<% end %>
|
||||
|
||||
<% attachment = @champ.piece_justificative_file.attachment %>
|
||||
<% if attachment.virus_scanner.pending? %>
|
||||
<%= fire_event('attachment:update', { url: attachment_url(attachment.id, { signed_id: attachment.blob.signed_id, user_can_upload: true }) }.to_json ) %>
|
||||
<% end %>
|
||||
|
||||
<%= focus_element("button[data-toggle-target=\".attachment-input-#{attachment.id}\"]") %>
|
|
@ -0,0 +1,6 @@
|
|||
= fields_for @champ.input_name, @champ do |form|
|
||||
= turbo_stream.replace @champ.input_group_id, partial: "shared/dossiers/editable_champs/editable_champ", locals: { champ: @champ, form: form }
|
||||
|
||||
- if @champ.piece_justificative_file.attached?
|
||||
- attachment = @champ.piece_justificative_file.attachment
|
||||
= turbo_stream.focus_all "button[data-toggle-target=\".attachment-input-#{attachment.id}\"]"
|
|
@ -30,7 +30,7 @@
|
|||
Une erreur s’est produite pendant l’envoi du fichier.
|
||||
%p.attachment-error-description
|
||||
Une erreur inconnue s'est produite pendant l'envoi du fichier
|
||||
= button_tag type: 'button', class: 'button attachment-error-retry', data: { 'input-target': ".attachment-input-#{attachment_id}" } do
|
||||
= button_tag type: 'button', class: 'button attachment-error-retry', data: { 'input-target': ".attachment-input-#{attachment_id}", action: 'autosave#onClickRetryButton' } do
|
||||
%span.icon.retry
|
||||
Ré-essayer
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
- else
|
||||
- form_options = { url: modifier_dossier_url(dossier), method: :patch }
|
||||
|
||||
= form_for dossier, form_options.merge({ html: { id: 'dossier-edit-form', class: dossier_form_class(dossier), multipart: true } }) do |f|
|
||||
= form_for dossier, form_options.merge({ html: { id: 'dossier-edit-form', class: 'form', multipart: true } }) do |f|
|
||||
|
||||
.prologue
|
||||
%p.mandatory-explanation= t('utils.asterisk_html')
|
||||
|
@ -28,12 +28,13 @@
|
|||
%hr
|
||||
|
||||
- if dossier.show_groupe_instructeur_selector?
|
||||
= f.label :groupe_instructeur_id do
|
||||
= dossier.procedure.routing_criteria_name
|
||||
%span.mandatory *
|
||||
= f.select :groupe_instructeur_id,
|
||||
dossier.procedure.groupe_instructeurs.order(:label).map { |gi| [gi.label, gi.id] },
|
||||
{ include_blank: dossier.brouillon? }
|
||||
%span{ data: autosave_available?(dossier) ? { controller: 'autosave', autosave_url_value: brouillon_dossier_path(dossier) } : {} }
|
||||
= f.label :groupe_instructeur_id do
|
||||
= dossier.procedure.routing_criteria_name
|
||||
%span.mandatory *
|
||||
= f.select :groupe_instructeur_id,
|
||||
dossier.procedure.groupe_instructeurs.order(:label).map { |gi| [gi.label, gi.id] },
|
||||
{ include_blank: dossier.brouillon? }
|
||||
|
||||
- dossier.champs.each do |champ|
|
||||
= fields_for champ.input_name, champ do |form|
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
.editable-champ{ class: "editable-champ-#{champ.type_champ}", id: champ.input_group_id }
|
||||
- autosave_controller = autosave_available?(champ.dossier) && !champ.repetition? ? { controller: 'autosave', autosave_url_value: brouillon_dossier_path(champ.dossier) } : {}
|
||||
.editable-champ{ class: "editable-champ-#{champ.type_champ}", id: champ.input_group_id, data: autosave_controller }
|
||||
- if champ.repetition?
|
||||
%h3.header-subsection= champ.libelle
|
||||
- if champ.description.present?
|
||||
|
@ -9,5 +10,5 @@
|
|||
- if champ.type_champ == "titre_identite"
|
||||
%p.notice Carte nationale d’identité (uniquement le recto), passeport, titre de séjour ou autre justificatif d’identité. Formats acceptés : jpg/png
|
||||
|
||||
= form.hidden_field :id, value: champ.id
|
||||
= form.hidden_field :id, value: champ.id, data: champ.repetition? ? { id: true } : {}
|
||||
= render partial: "shared/dossiers/editable_champs/#{champ.type_champ}", locals: { form: form, champ: champ }
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.autosave.autosave-state-idle
|
||||
.autosave.autosave-state-idle{ data: { controller: 'autosave-status' } }
|
||||
%p.autosave-explanation
|
||||
%span.autosave-explanation-text
|
||||
= t('views.users.dossiers.autosave.autosave_draft')
|
||||
|
@ -13,7 +13,7 @@
|
|||
%p.autosave-status.failed
|
||||
%span.autosave-icon ⚠️
|
||||
%span.autosave-label Impossible d’enregistrer le brouillon
|
||||
%button.button.small.autosave-retry
|
||||
%button.button.small.autosave-retry{ type: :button, data: { action: 'autosave-status#onClickRetryButton', autosave_status_target: 'retryButton' } }
|
||||
%span.autosave-retry-label réessayer
|
||||
%span.autosave-retrying-label enregistrement en cours…
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ describe Champs::PieceJustificativeController, type: :controller do
|
|||
position: '1',
|
||||
champ_id: champ.id,
|
||||
blob_signed_id: file
|
||||
}, format: 'js'
|
||||
}, format: :turbo_stream
|
||||
end
|
||||
|
||||
context 'when the file is valid' do
|
||||
|
@ -29,7 +29,7 @@ describe Champs::PieceJustificativeController, type: :controller do
|
|||
it 'renders the attachment template as Javascript' do
|
||||
subject
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.body).to include("##{champ.input_group_id}")
|
||||
expect(response.body).to include("action=\"replace\" target=\"#{champ.input_group_id}\"")
|
||||
end
|
||||
|
||||
it 'updates dossier.last_champ_updated_at' do
|
||||
|
|
|
@ -247,14 +247,18 @@ describe 'The user' do
|
|||
fill_individual
|
||||
|
||||
# Test auto-upload failure
|
||||
logout(:user) # Make the subsequent auto-upload request fail
|
||||
# Make the subsequent auto-upload request fail
|
||||
allow_any_instance_of(Champs::PieceJustificativeController).to receive(:update) do |instance|
|
||||
instance.render json: { errors: ['Error'] }, status: :bad_request
|
||||
end
|
||||
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/file.pdf')
|
||||
expect(page).to have_text('Une erreur s’est produite pendant l’envoi du fichier')
|
||||
expect(page).to have_button('Ré-essayer', visible: true)
|
||||
expect(page).to have_button('Déposer le dossier', disabled: false)
|
||||
|
||||
allow_any_instance_of(Champs::PieceJustificativeController).to receive(:update).and_call_original
|
||||
|
||||
# Test that retrying after a failure works
|
||||
login_as(user, scope: :user) # Make the auto-upload request work again
|
||||
click_on('Ré-essayer', visible: true)
|
||||
expect(page).to have_text('analyse antivirus en cours')
|
||||
expect(page).to have_text('file.pdf')
|
||||
|
|
Loading…
Reference in a new issue