dossiers: autosave drafts

This commit is contained in:
Pierre de La Morinerie 2019-11-19 16:55:30 +00:00
parent 6f2779a312
commit bff7892ba8
11 changed files with 387 additions and 9 deletions

View file

@ -16,3 +16,21 @@
@extend %animation;
animation-name: fade-in-down;
}
@keyframes pulse {
0% {
transform: scale(1, 1);
}
10% {
transform: scale(0.9, 0.9);
}
70% {
transform: scale(1.3, 1.3);
}
100% {
transform: scale(1, 1);
}
}

View file

@ -0,0 +1,123 @@
@import "colors";
@import "constants";
.autosave {
position: relative;
font-size: 0.9em;
}
.autosave-explanation {
color: $grey;
margin-left: 4px;
}
.autosave-explanation-text,
.autosave-label {
margin-right: 6px;
}
.autosave-more-infos {
white-space: nowrap;
}
.autosave-status {
// Position the status over the explanation text
position: absolute;
top: 0;
&.succeeded .autosave-label {
color: $green;
}
&.failed .autosave-label {
color: $orange;
}
}
.autosave-icon {
display: inline-block;
vertical-align: -1px;
margin-right: 4px;
}
.autosave-icon.icon.accept {
vertical-align: -8px;
}
.autosave-retry {
&:disabled {
.autosave-retry-label {
display: none;
}
}
&:not(:disabled) {
cursor: pointer;
.autosave-retrying-label {
display: none;
}
}
}
$autosave-status-fade-in-duration: 0.2s;
$autosave-status-fade-out-duration: 0.7s;
// By default (and in the idle state), display the explanation text and hide statuses.
.autosave-explanation {
visibility: visible;
opacity: 1;
// Make the explanation fade-in slowly when the status is being removed
transition-property: opacity;
transition-duration: $autosave-status-fade-out-duration;
}
.autosave-status {
visibility: hidden;
opacity: 0;
// Make the status fade-out slowly when being removed
transition-property: opacity, visibility;
transition-duration: $autosave-status-fade-out-duration;
}
// When one of the status messages should be displayed:
.autosave-state-succeeded,
.autosave-state-failed {
// Hide the explanation
.autosave-explanation {
visibility: hidden;
opacity: 0;
// Make the explanation fade-out quickly
transition-property: opacity, visibility;
transition-duration: $autosave-status-fade-in-duration;
}
// Show the status message (succeeded or failed)
.autosave-status.succeeded,
.autosave-status.failed {
opacity: 1;
// Make the status message fade-in quickly
transition-property: opacity;
transition-duration: $autosave-status-fade-in-duration;
}
// Make the icon pulse (if any)
.autosave-icon {
opacity: 1;
// Make the icon pulse after being made visible
animation-name: pulse;
animation-duration: 0.25s;
animation-delay: 0.15s;
animation-timing-function: linear;
animation-fill-mode: backwards;
}
}
// Show only the relevant status message (succeeded of failed)
.autosave-state-succeeded .autosave-status.succeeded {
visibility: visible;
}
.autosave-state-failed .autosave-status.failed {
visibility: visible;
}

View file

@ -70,13 +70,13 @@
border-top-right-radius: 5px;
border-bottom: none;
.button {
.button:not(:small) {
min-height: 38px;
line-height: 16px;
}
// If there are more than one button, align the "Send" button to the right
.button:not(:first-of-type).send {
.button:not(:first-child).send {
margin-left: auto;
}
@ -92,4 +92,10 @@
padding-bottom: $default-spacer;
}
}
.autosave {
// Make the autosave block occupy the entire horizontal space,
// to ensure the failed state has room to display its content.
flex-grow: 1;
}
}

View file

@ -31,6 +31,18 @@ module DossierHelper
end
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? && Flipper.enabled?(:autosave_dossier_draft, dossier.user)
end
def dossier_submission_is_closed?(dossier)
dossier.brouillon? && dossier.procedure.archivee?
end

View file

@ -0,0 +1,87 @@
import { fire } from '@utils';
export default class AutosaveController {
constructor() {
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 repond 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 fetchOptions = {
method: form.method,
body: formData,
headers: { Accept: 'application/json' }
};
return window.fetch(form.action, fetchOptions).then(response => {
if (response.ok) {
resolve(response);
} else {
const message = `Network request failed (${response.status}, "${response.statusText}")`;
reject(new Error(message));
}
});
});
return autosavePromise;
}
// 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])'
);
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

@ -0,0 +1,84 @@
import AutosaveController from './autosave-controller.js';
import {
debounce,
delegate,
fire,
enable,
disable,
hasClass,
addClass,
removeClass
} from '@utils';
const AUTOSAVE_DEBOUNCE_DELAY = 3000; // 3 seconds
const AUTOSAVE_STATUS_VISIBLE_DURATION = 6000; // 5 seconds
const autosaveController = new AutosaveController(AUTOSAVE_DEBOUNCE_DELAY);
// Whenever a 'change' event is triggered on one of the form inputs, try to autosave.
const formSelector = 'form#dossier-edit-form.autosave-enabled';
const formInputsSelector = `${formSelector} input, ${formSelector} select, ${formSelector} textarea`;
delegate(
'change',
formInputsSelector,
debounce(() => {
const form = document.querySelector(formSelector);
autosaveController.enqueueAutosaveRequest(form);
}, AUTOSAVE_DEBOUNCE_DELAY)
);
delegate('click', '.autosave-retry', () => {
const form = document.querySelector(formSelector);
autosaveController.enqueueAutosaveRequest(form);
});
// 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 => {
enable(document.querySelector('button.autosave-retry'));
setState('failed');
logError(event.detail);
});
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

@ -20,6 +20,7 @@ import '../shared/franceconnect';
import '../shared/toggle-target';
import '../new_design/dropdown';
import '../new_design/autosave';
import '../new_design/form-validation';
import '../new_design/procedure-context';
import '../new_design/procedure-form';

View file

@ -17,6 +17,26 @@ export function toggle(el) {
el && el.classList.toggle('hidden');
}
export function enable(el) {
el && (el.disabled = false);
}
export function disable(el) {
el && (el.disabled = true);
}
export function hasClass(el, cssClass) {
return el && el.classList.contains(cssClass);
}
export function addClass(el, cssClass) {
el && el.classList.add(cssClass);
}
export function removeClass(el, cssClass) {
el && el.classList.remove(cssClass);
}
export function delegate(eventNames, selector, callback) {
eventNames
.split(' ')

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: 'form', multipart: true } }) do |f|
= form_for dossier, form_options.merge({ html: { id: 'dossier-edit-form', class: dossier_form_class(dossier), multipart: true } }) do |f|
.prologue
%p.mandatory-explanation
@ -17,7 +17,10 @@
) sont obligatoires.
- if dossier.brouillon?
%p.mandatory-explanation
Pour enregistrer votre dossier et le reprendre plus tard, cliquez sur le bouton « Enregistrer le brouillon » en bas à gauche du formulaire.
- if autosave_available?(dossier)
Votre dossier est enregistré automatiquement après chaque modification. Vous pouvez à tout moment fermer la fenêtre et reprendre plus tard là où vous en étiez.
- else
Pour enregistrer votre dossier et le reprendre plus tard, cliquez sur le bouton « Enregistrer le brouillon » en bas à gauche du formulaire.
- if notice_url(dossier.procedure).present?
= link_to notice_url(dossier.procedure), target: '_blank', rel: 'noopener', class: 'button notice', title: "Pour vous aider à remplir votre dossier, vous pouvez consulter le guide de cette démarche." do
@ -41,11 +44,13 @@
- if !apercu
.send-dossier-actions-bar
- if dossier.brouillon?
= f.button 'Enregistrer le brouillon',
formnovalidate: true,
value: true,
class: 'button send secondary',
data: { 'disable-with': "Envoi en cours…" }
- if autosave_available?(dossier)
= render partial: 'users/dossiers/autosave'
- else
= f.button 'Enregistrer le brouillon',
formnovalidate: true,
class: 'button send secondary',
data: { 'disable-with': "Envoi en cours…" }
- if dossier.can_transition_to_en_construction?
= f.button 'Déposer le dossier',

View file

@ -0,0 +1,21 @@
- more_infos_url = 'https://faq.demarches-simplifiees.fr/article/73-enregistrer-mon-dossier?preview=5dcbf0bb2c7d3a7e9ae3e33f'
.autosave.autosave-state-idle
%p.autosave-explanation
%span.autosave-explanation-text
Votre brouillon est automatiquement enregistré.
= link_to 'En savoir plus', more_infos_url, target: '_blank', rel: 'noopener', class: 'autosave-more-infos'
%p.autosave-status.succeeded
%span.autosave-icon.icon.accept
%span.autosave-label
Brouillon enregistré
= link_to 'En savoir plus', more_infos_url, target: '_blank', rel: 'noopener', class: 'autosave-more-infos'
%p.autosave-status.failed
%span.autosave-icon ⚠️
%span.autosave-label Impossible denregistrer le brouillon
%button.button.small.autosave-retry
%span.autosave-retry-label réessayer
%span.autosave-retrying-label enregistrement en cours…

View file

@ -30,6 +30,7 @@ features = [
:administrateur_web_hook,
:insee_api_v3,
:instructeur_bypass_email_login_token,
:autosave_dossier_draft,
:maintenance_mode,
:mini_profiler,
:operation_log_serialize_subject,