autosave: remove the repetition row after deletion

Before, when autosaving a draft, removing a repetition row would
send `_destroy` inputs to the controller – but not remove the row
from the DOM. This led to the `_destroy` inputs being sent again
on the next autosave request, which made the controller raise
(because the row fields were already deleted before).

To fix this, we let the controller response remove the deleted
row(s) from the DOM.

Doing it using a controller response avoids the need to keep track
of operations on the Javascript side: the controller can easily
know which row was just deleted, and emit the relevant changes for
the DOM. This keeps the autosave requests robust: even if a request
is skipped (e.g. because of a network interruption), the next request
will still contain the relevant informations to succeed, and not let the
form in an unstable state.

Fix #5470
This commit is contained in:
Pierre de La Morinerie 2020-08-20 15:56:41 +02:00
parent e157a289e1
commit 96037069ff
6 changed files with 30 additions and 17 deletions

View file

@ -163,7 +163,7 @@ module Users
respond_to do |format| respond_to do |format|
format.html { render :brouillon } format.html { render :brouillon }
format.json { render json: {}, status: :ok } format.js { render :brouillon }
end end
end end

View file

@ -1,8 +1,9 @@
import { delegate } from '@utils'; import { delegate } from '@utils';
const CHAMP_SELECTOR = '.editable-champ';
const BUTTON_SELECTOR = '.button.remove-row'; const BUTTON_SELECTOR = '.button.remove-row';
const DESTROY_INPUT_SELECTOR = 'input[type=hidden][name*=_destroy]'; const DESTROY_INPUT_SELECTOR = 'input[type=hidden][name*=_destroy]';
const CHAMP_SELECTOR = '.editable-champ'; const DOM_ID_INPUT_SELECTOR = 'input[type=hidden][name*=deleted_row_dom_ids]';
delegate('click', BUTTON_SELECTOR, (evt) => { delegate('click', BUTTON_SELECTOR, (evt) => {
evt.preventDefault(); evt.preventDefault();
@ -13,6 +14,8 @@ delegate('click', BUTTON_SELECTOR, (evt) => {
input.disabled = false; input.disabled = false;
input.value = true; input.value = true;
} }
row.querySelector(DOM_ID_INPUT_SELECTOR).disabled = false;
for (let champ of row.querySelectorAll(CHAMP_SELECTOR)) { for (let champ of row.querySelectorAll(CHAMP_SELECTOR)) {
champ.remove(); champ.remove();
} }

View file

@ -1,4 +1,4 @@
import { fire, timeoutable } from '@utils'; import { ajax, fire, timeoutable } from '@utils';
// Manages a queue of Autosave operations, // Manages a queue of Autosave operations,
// and sends `autosave:*` events to indicate the state of the requests. // and sends `autosave:*` events to indicate the state of the requests.
@ -34,20 +34,19 @@ export default class AutoSaveController {
return reject(formDataError); return reject(formDataError);
} }
const fetchOptions = { const params = {
method: form.method, url: form.action,
body: formData, type: form.method,
credentials: 'same-origin', data: formData,
headers: { Accept: 'application/json' } dataType: 'script'
}; };
return window.fetch(form.action, fetchOptions).then((response) => { return ajax(params)
if (response.ok) { .then(({ response }) => {
resolve(response); resolve(response);
} else { })
const message = `Network request failed (${response.status}, "${response.statusText}")`; .catch((error) => {
reject(new Error(message)); reject(error);
}
}); });
}); });

View file

@ -1,7 +1,11 @@
- champs = champ.rows.last - champs = champ.rows.last
- if champs.present? - if champs.present?
- index = (champ.rows.size - 1) * champs.size - index = (champ.rows.size - 1) * champs.size
%div{ class: "row row-#{champs.first.row}" } - row_dom_id = "row-#{SecureRandom.hex(4)}"
%div{ class: "row row-#{champs.first.row}", id: row_dom_id }
-# Tell the controller which DOM element should be removed once the row deletion is successful
= hidden_field_tag 'deleted_row_dom_ids[]', row_dom_id, disabled: true
- champs.each.with_index(index) do |champ, index| - champs.each.with_index(index) do |champ, index|
= fields_for "#{attribute}[#{index}]", champ do |form| = fields_for "#{attribute}[#{index}]", champ do |form|
= render partial: "shared/dossiers/editable_champs/editable_champ", locals: { champ: champ, form: form } = render partial: "shared/dossiers/editable_champs/editable_champ", locals: { champ: champ, form: form }

View file

@ -1,6 +1,10 @@
%div{ class: "repetition-#{form.index}" } %div{ class: "repetition-#{form.index}" }
- champ.rows.each do |champs| - champ.rows.each do |champs|
%div{ class: "row row-#{champs.first.row}" } - row_dom_id = "row-#{SecureRandom.hex(4)}"
%div{ class: "row row-#{champs.first.row}", id: row_dom_id }
-# Tell the controller which DOM element should be removed once the row deletion is successful
= hidden_field_tag 'deleted_row_dom_ids[]', row_dom_id, disabled: true
- champs.each do |champ| - champs.each do |champ|
= form.fields_for :champs, champ do |form| = form.fields_for :champs, champ do |form|
= render partial: 'shared/dossiers/editable_champs/editable_champ', locals: { champ: form.object, form: form } = render partial: 'shared/dossiers/editable_champs/editable_champ', locals: { champ: form.object, form: form }

View file

@ -0,0 +1,3 @@
<% (params['deleted_row_dom_ids'] || []).each do |deleted_row_dom_id| %>
<%= remove_element('#' + deleted_row_dom_id) %>
<% end %>