commit
0c76fb83f6
17 changed files with 488 additions and 10 deletions
22
.github/workflows/rebase.yml
vendored
Normal file
22
.github/workflows/rebase.yml
vendored
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
name: Rebase automatique
|
||||||
|
jobs:
|
||||||
|
rebase:
|
||||||
|
name: Rebase
|
||||||
|
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
- name: Automatic Rebase
|
||||||
|
uses: cirrus-actions/rebase@master
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# https://github.community/t5/GitHub-Actions/Workflow-is-failing-if-no-job-can-be-ran-due-to-condition/m-p/38186#M3250
|
||||||
|
always_job:
|
||||||
|
name: Aways run job
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Always run
|
||||||
|
run: echo "This job is used to prevent the workflow to fail when all other jobs are skipped."
|
|
@ -16,3 +16,21 @@
|
||||||
@extend %animation;
|
@extend %animation;
|
||||||
animation-name: fade-in-down;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
123
app/assets/stylesheets/new_design/autosave.scss
Normal file
123
app/assets/stylesheets/new_design/autosave.scss
Normal 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;
|
||||||
|
}
|
|
@ -70,13 +70,13 @@
|
||||||
border-top-right-radius: 5px;
|
border-top-right-radius: 5px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|
||||||
.button {
|
.button:not(:small) {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are more than one button, align the "Send" button to the right
|
// 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;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,4 +92,10 @@
|
||||||
padding-bottom: $default-spacer;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ class ApplicationController < ActionController::Base
|
||||||
|
|
||||||
before_action :staging_authenticate
|
before_action :staging_authenticate
|
||||||
before_action :set_active_storage_host
|
before_action :set_active_storage_host
|
||||||
|
before_action :setup_javascript_settings
|
||||||
before_action :setup_tracking
|
before_action :setup_tracking
|
||||||
|
|
||||||
helper_method :multiple_devise_profile_connect?, :instructeur_signed_in?, :current_instructeur,
|
helper_method :multiple_devise_profile_connect?, :instructeur_signed_in?, :current_instructeur,
|
||||||
|
@ -114,6 +115,10 @@ class ApplicationController < ActionController::Base
|
||||||
ActiveStorage::Current.host = request.base_url
|
ActiveStorage::Current.host = request.base_url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def setup_javascript_settings
|
||||||
|
gon.autosave = Rails.application.config.ds_autosave
|
||||||
|
end
|
||||||
|
|
||||||
def setup_tracking
|
def setup_tracking
|
||||||
gon.matomo = matomo_config
|
gon.matomo = matomo_config
|
||||||
gon.sentry = sentry_config
|
gon.sentry = sentry_config
|
||||||
|
|
|
@ -31,6 +31,18 @@ module DossierHelper
|
||||||
end
|
end
|
||||||
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)
|
def dossier_submission_is_closed?(dossier)
|
||||||
dossier.brouillon? && dossier.procedure.archivee?
|
dossier.brouillon? && dossier.procedure.archivee?
|
||||||
end
|
end
|
||||||
|
|
92
app/javascript/new_design/autosave-controller.js
Normal file
92
app/javascript/new_design/autosave-controller.js
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import { fire, timeoutable } from '@utils';
|
||||||
|
|
||||||
|
// Manages a queue of Autosave operations,
|
||||||
|
// and sends `autosave:*` events to indicate the state of the requests.
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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])'
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
85
app/javascript/new_design/autosave.js
Normal file
85
app/javascript/new_design/autosave.js
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import AutosaveController from './autosave-controller.js';
|
||||||
|
import {
|
||||||
|
debounce,
|
||||||
|
delegate,
|
||||||
|
fire,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
hasClass,
|
||||||
|
addClass,
|
||||||
|
removeClass
|
||||||
|
} from '@utils';
|
||||||
|
|
||||||
|
const AUTOSAVE_DEBOUNCE_DELAY = gon.autosave.debounce_delay;
|
||||||
|
const AUTOSAVE_STATUS_VISIBLE_DURATION = gon.autosave.status_visible_duration;
|
||||||
|
|
||||||
|
// Create a controller responsible for queuing autosave operations.
|
||||||
|
const autosaveController = new AutosaveController();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ import '../shared/franceconnect';
|
||||||
import '../shared/toggle-target';
|
import '../shared/toggle-target';
|
||||||
|
|
||||||
import '../new_design/dropdown';
|
import '../new_design/dropdown';
|
||||||
|
import '../new_design/autosave';
|
||||||
import '../new_design/form-validation';
|
import '../new_design/form-validation';
|
||||||
import '../new_design/procedure-context';
|
import '../new_design/procedure-context';
|
||||||
import '../new_design/procedure-form';
|
import '../new_design/procedure-form';
|
||||||
|
|
|
@ -17,6 +17,26 @@ export function toggle(el) {
|
||||||
el && el.classList.toggle('hidden');
|
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) {
|
export function delegate(eventNames, selector, callback) {
|
||||||
eventNames
|
eventNames
|
||||||
.split(' ')
|
.split(' ')
|
||||||
|
@ -66,6 +86,16 @@ function offset(element) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Takes a promise, and return a promise that times out after the given delay.
|
||||||
|
export function timeoutable(promise, timeoutDelay) {
|
||||||
|
let timeoutPromise = new Promise((resolve, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error(`Promise timed out after ${timeoutDelay}ms`));
|
||||||
|
}, timeoutDelay);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeoutPromise]);
|
||||||
|
}
|
||||||
|
|
||||||
const DATA_ACTIVE_REQUESTS_COUNT = 'data-active-requests-count';
|
const DATA_ACTIVE_REQUESTS_COUNT = 'data-active-requests-count';
|
||||||
|
|
||||||
function incrementActiveRequestsCount() {
|
function incrementActiveRequestsCount() {
|
||||||
|
|
|
@ -4,6 +4,11 @@ class DeviseUserMailer < Devise::Mailer
|
||||||
include Devise::Controllers::UrlHelpers # Optional. eg. `confirmation_url`
|
include Devise::Controllers::UrlHelpers # Optional. eg. `confirmation_url`
|
||||||
layout 'mailers/layout'
|
layout 'mailers/layout'
|
||||||
|
|
||||||
|
# Don’t retry to send a message if the server rejects the recipient address
|
||||||
|
rescue_from Net::SMTPSyntaxError do |_error|
|
||||||
|
message.perform_deliveries = false
|
||||||
|
end
|
||||||
|
|
||||||
def template_paths
|
def template_paths
|
||||||
['devise_mailer']
|
['devise_mailer']
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
- else
|
- else
|
||||||
- form_options = { url: modifier_dossier_url(dossier), method: :patch }
|
- 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
|
.prologue
|
||||||
%p.mandatory-explanation
|
%p.mandatory-explanation
|
||||||
|
@ -17,6 +17,9 @@
|
||||||
) sont obligatoires.
|
) sont obligatoires.
|
||||||
- if dossier.brouillon?
|
- if dossier.brouillon?
|
||||||
%p.mandatory-explanation
|
%p.mandatory-explanation
|
||||||
|
- 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.
|
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?
|
- if notice_url(dossier.procedure).present?
|
||||||
|
@ -41,9 +44,11 @@
|
||||||
- if !apercu
|
- if !apercu
|
||||||
.send-dossier-actions-bar
|
.send-dossier-actions-bar
|
||||||
- if dossier.brouillon?
|
- if dossier.brouillon?
|
||||||
|
- if autosave_available?(dossier)
|
||||||
|
= render partial: 'users/dossiers/autosave'
|
||||||
|
- else
|
||||||
= f.button 'Enregistrer le brouillon',
|
= f.button 'Enregistrer le brouillon',
|
||||||
formnovalidate: true,
|
formnovalidate: true,
|
||||||
value: true,
|
|
||||||
class: 'button send secondary',
|
class: 'button send secondary',
|
||||||
data: { 'disable-with': "Envoi en cours…" }
|
data: { 'disable-with': "Envoi en cours…" }
|
||||||
|
|
||||||
|
|
21
app/views/users/dossiers/_autosave.html.haml
Normal file
21
app/views/users/dossiers/_autosave.html.haml
Normal 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 d’enregistrer le brouillon
|
||||||
|
%button.button.small.autosave-retry
|
||||||
|
%span.autosave-retry-label réessayer
|
||||||
|
%span.autosave-retrying-label enregistrement en cours…
|
||||||
|
|
|
@ -40,8 +40,14 @@ module TPS
|
||||||
Administrate::ApplicationController.helper(TPS::Application.helpers)
|
Administrate::ApplicationController.helper(TPS::Application.helpers)
|
||||||
end
|
end
|
||||||
|
|
||||||
config.ds_weekly_overview = ENV['APP_NAME'] == 'tps'
|
|
||||||
config.middleware.use Rack::Attack
|
config.middleware.use Rack::Attack
|
||||||
config.middleware.use Flipper::Middleware::Memoizer, preload_all: true
|
config.middleware.use Flipper::Middleware::Memoizer, preload_all: true
|
||||||
|
|
||||||
|
config.ds_weekly_overview = ENV['APP_NAME'] == 'tps'
|
||||||
|
|
||||||
|
config.ds_autosave = {
|
||||||
|
debounce_delay: 3000,
|
||||||
|
status_visible_duration: 6000
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -53,4 +53,9 @@ Rails.application.configure do
|
||||||
|
|
||||||
# Raises error for missing translations
|
# Raises error for missing translations
|
||||||
# config.action_view.raise_on_missing_translations = true
|
# config.action_view.raise_on_missing_translations = true
|
||||||
|
|
||||||
|
config.ds_autosave = {
|
||||||
|
debounce_delay: 500,
|
||||||
|
status_visible_duration: 500
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
|
@ -30,6 +30,7 @@ features = [
|
||||||
:administrateur_web_hook,
|
:administrateur_web_hook,
|
||||||
:insee_api_v3,
|
:insee_api_v3,
|
||||||
:instructeur_bypass_email_login_token,
|
:instructeur_bypass_email_login_token,
|
||||||
|
:autosave_dossier_draft,
|
||||||
:maintenance_mode,
|
:maintenance_mode,
|
||||||
:mini_profiler,
|
:mini_profiler,
|
||||||
:operation_log_serialize_subject,
|
:operation_log_serialize_subject,
|
||||||
|
|
|
@ -186,6 +186,47 @@ feature 'The user' do
|
||||||
expect(page).to have_no_text('RIB.pdf')
|
expect(page).to have_no_text('RIB.pdf')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when the draft autosave is enabled' do
|
||||||
|
before do
|
||||||
|
Flipper.enable_actor(:autosave_dossier_draft, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
scenario 'autosave a draft', js: true do
|
||||||
|
log_in(user, simple_procedure)
|
||||||
|
fill_individual
|
||||||
|
|
||||||
|
expect(page).not_to have_button('Enregistrer le brouillon')
|
||||||
|
expect(page).to have_content('Votre brouillon est automatiquement enregistré')
|
||||||
|
|
||||||
|
fill_in('texte obligatoire', with: 'a valid user input')
|
||||||
|
blur
|
||||||
|
|
||||||
|
expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true)
|
||||||
|
|
||||||
|
visit current_path
|
||||||
|
expect(page).to have_field('texte obligatoire', with: 'a valid user input')
|
||||||
|
end
|
||||||
|
|
||||||
|
scenario 'retry on autosave error', js: true do
|
||||||
|
log_in(user, simple_procedure)
|
||||||
|
fill_individual
|
||||||
|
|
||||||
|
# Test autosave failure
|
||||||
|
logout(:user) # Make the subsequent autosave requests fail
|
||||||
|
fill_in('texte obligatoire', with: 'a valid user input')
|
||||||
|
blur
|
||||||
|
expect(page).to have_css('span', text: 'Impossible d’enregistrer le brouillon', visible: true)
|
||||||
|
|
||||||
|
# Test that retrying after a failure works
|
||||||
|
login_as(user, scope: :user) # Make the autosave requests work again
|
||||||
|
click_on 'réessayer'
|
||||||
|
expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true)
|
||||||
|
|
||||||
|
visit current_path
|
||||||
|
expect(page).to have_field('texte obligatoire', with: 'a valid user input')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def log_in(user, procedure)
|
def log_in(user, procedure)
|
||||||
|
|
Loading…
Reference in a new issue