Merge pull request #7257 from tchak/feat-atomic-autosave

refactor(turbo): refactor autosave to use stimulus and turbo
This commit is contained in:
Paul Chavard 2022-05-11 08:29:59 +02:00 committed by GitHub
commit 866c4acaf9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 537 additions and 579 deletions

View file

@ -174,7 +174,7 @@ module Users
respond_to do |format|
format.html { render :brouillon }
format.js
format.turbo_stream
end
end

View file

@ -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

View file

@ -60,7 +60,7 @@ export function useHiddenField(
if (hiddenField) {
hiddenField.setAttribute('value', value);
setValue(value);
fire(hiddenField, 'autosave:trigger');
fire(hiddenField, 'change');
}
},
hiddenField ?? undefined

View file

@ -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);
};
}

View 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;
}
}

View 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);
}
}
}

View 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);

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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 na 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 narrivons pas à lire ce fichier sur votre appareil.',
description: 'Essayez à nouveau, ou sélectionnez un autre fichier.',
retry: false
};
} else {
return {
title: 'Le fichier na 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);
}
}
}

View file

@ -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);
});

View file

@ -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));
}
}
}

View file

@ -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;

View 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 na 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 narrivons pas à lire ce fichier sur votre appareil.',
description: 'Essayez à nouveau, ou sélectionnez un autre fichier.',
retry: false
};
} else {
return {
title: 'Le fichier na 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;
}
}

View file

@ -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;

View file

@ -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
);
}

View file

@ -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]);
}

View file

@ -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}\"]") %>

View file

@ -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}\"]"

View file

@ -30,7 +30,7 @@
Une erreur sest produite pendant lenvoi 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

View file

@ -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|

View file

@ -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 didentité (uniquement le recto), passeport, titre de séjour ou autre justificatif didentité. 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 }

View file

@ -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 denregistrer 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…

View file

@ -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

View file

@ -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 sest produite pendant lenvoi 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')