diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml new file mode 100644 index 000000000..c23336d4d --- /dev/null +++ b/.github/workflows/rebase.yml @@ -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." diff --git a/app/assets/stylesheets/new_design/animations.scss b/app/assets/stylesheets/new_design/animations.scss index c9df122d2..4cb79aa38 100644 --- a/app/assets/stylesheets/new_design/animations.scss +++ b/app/assets/stylesheets/new_design/animations.scss @@ -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); + } +} diff --git a/app/assets/stylesheets/new_design/autosave.scss b/app/assets/stylesheets/new_design/autosave.scss new file mode 100644 index 000000000..a12582635 --- /dev/null +++ b/app/assets/stylesheets/new_design/autosave.scss @@ -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; +} diff --git a/app/assets/stylesheets/new_design/dossier_edit.scss b/app/assets/stylesheets/new_design/dossier_edit.scss index ad0490cfd..05096311e 100644 --- a/app/assets/stylesheets/new_design/dossier_edit.scss +++ b/app/assets/stylesheets/new_design/dossier_edit.scss @@ -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; + } } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a48d3b33b..527bf34c7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -16,6 +16,7 @@ class ApplicationController < ActionController::Base before_action :staging_authenticate before_action :set_active_storage_host + before_action :setup_javascript_settings before_action :setup_tracking 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 end + def setup_javascript_settings + gon.autosave = Rails.application.config.ds_autosave + end + def setup_tracking gon.matomo = matomo_config gon.sentry = sentry_config diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index db1ffa829..bc6f7a218 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -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 diff --git a/app/javascript/new_design/autosave-controller.js b/app/javascript/new_design/autosave-controller.js new file mode 100644 index 000000000..3d2c1acff --- /dev/null +++ b/app/javascript/new_design/autosave-controller.js @@ -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); + } +} diff --git a/app/javascript/new_design/autosave.js b/app/javascript/new_design/autosave.js new file mode 100644 index 000000000..2e837491f --- /dev/null +++ b/app/javascript/new_design/autosave.js @@ -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); + } +} diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 9b956474a..c8952609c 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -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'; diff --git a/app/javascript/shared/utils.js b/app/javascript/shared/utils.js index f50bc15ac..9be438b76 100644 --- a/app/javascript/shared/utils.js +++ b/app/javascript/shared/utils.js @@ -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(' ') @@ -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'; function incrementActiveRequestsCount() { diff --git a/app/mailers/devise_user_mailer.rb b/app/mailers/devise_user_mailer.rb index a19c04768..de07cc1b7 100644 --- a/app/mailers/devise_user_mailer.rb +++ b/app/mailers/devise_user_mailer.rb @@ -4,6 +4,11 @@ class DeviseUserMailer < Devise::Mailer include Devise::Controllers::UrlHelpers # Optional. eg. `confirmation_url` 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 ['devise_mailer'] end diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index 7691145af..b0b353520 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -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', diff --git a/app/views/users/dossiers/_autosave.html.haml b/app/views/users/dossiers/_autosave.html.haml new file mode 100644 index 000000000..c19bdc030 --- /dev/null +++ b/app/views/users/dossiers/_autosave.html.haml @@ -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… + diff --git a/config/application.rb b/config/application.rb index b42657278..65080acaf 100644 --- a/config/application.rb +++ b/config/application.rb @@ -40,8 +40,14 @@ module TPS Administrate::ApplicationController.helper(TPS::Application.helpers) end - config.ds_weekly_overview = ENV['APP_NAME'] == 'tps' config.middleware.use Rack::Attack 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 diff --git a/config/environments/test.rb b/config/environments/test.rb index 0f05338b6..7f100ca27 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -53,4 +53,9 @@ Rails.application.configure do # Raises error for missing translations # config.action_view.raise_on_missing_translations = true + + config.ds_autosave = { + debounce_delay: 500, + status_visible_duration: 500 + } end diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index df7bcfdd2..d434f6a72 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -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, diff --git a/spec/features/users/brouillon_spec.rb b/spec/features/users/brouillon_spec.rb index b6c284cbb..90bea2630 100644 --- a/spec/features/users/brouillon_spec.rb +++ b/spec/features/users/brouillon_spec.rb @@ -186,6 +186,47 @@ feature 'The user' do expect(page).to have_no_text('RIB.pdf') 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 def log_in(user, procedure)