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

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