Merge pull request #9491 from mfo/US/better-error-summary

amelioration(usagers.dossiers.erreurs): ETQ usager, lorsque je soumets un dossier avec des erreurs, le format des erreurs est au format du DSFR
This commit is contained in:
mfo 2023-09-28 17:01:09 +00:00 committed by GitHub
commit e1fe1188de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 128 additions and 93 deletions

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent
ErrorDescriptor = Data.define(:anchor, :label, :error_message)
def initialize(dossier:, errors:)
@dossier = dossier
@errors = errors
end
def dedup_and_partitioned_errors
formated_errors = @errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that
.to_a # but enum.to_a gives back an array
.uniq { |error| [error.inner_error.base] } # dedup cumulated errors from dossier.champs, dossier.champs_public, dossier.champs_private which run the validator one time per association
.map { |error| to_error_descriptor(error.message, error.inner_error.base) }
yield(Array(formated_errors[0..2]), Array(formated_errors[3..]))
end
def to_error_descriptor(str_error, model)
ErrorDescriptor.new("##{model.labelledby_id}", model.libelle.truncate(200), str_error)
end
def render?
!@errors.empty?
end
end

View file

@ -0,0 +1,8 @@
---
en:
sumup_html:
one: |
Your file has 1 error. <a href="%{url}">Fix-it</a> to continue :
other: |
Your file has %{count} errors. <a href="%{url}">Fix-them</a> to continue :
see_more: Show all errors

View file

@ -0,0 +1,8 @@
---
fr:
sumup_html:
one: |
Votre dossier contient 1 champ en erreur. <a href="%{url}">Corrigez-la</a> pour poursuivre :
other: |
Votre dossier contient %{count} champs en erreurs. <a href="%{url}">Corrigez-les</a> pour poursuivre :
see_more: Afficher toutes les erreurs

View file

@ -0,0 +1,16 @@
.fr-alert.fr-alert--error.fr-mb-3w{ role: "alertdialog" }
- dedup_and_partitioned_errors do |head, tail|
%p#sumup-errors= t('.sumup_html', count: head.size + tail.size, url: head.first.anchor)
%ul.fr-mb-0#head-errors
- head.each do |error_descriptor|
%li
= link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor'
= error_descriptor.error_message
- if tail.size > 0
%button{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline" }= t('.see_more')
%ul#tail-errors.fr-collapse.fr-mt-0
- tail.each do |error_descriptor|
%li
= link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor'
= "(#{error_descriptor.error_message})"

View file

@ -207,9 +207,9 @@ module Users
def submit_brouillon def submit_brouillon
@dossier = dossier_with_champs(pj_template: false) @dossier = dossier_with_champs(pj_template: false)
errors = submit_dossier_and_compute_errors @errors = submit_dossier_and_compute_errors
if errors.blank? if @errors.blank?
@dossier.passer_en_construction! @dossier.passer_en_construction!
@dossier.process_declarative! @dossier.process_declarative!
@dossier.process_sva_svr! @dossier.process_sva_svr!
@ -218,8 +218,6 @@ module Users
end end
redirect_to merci_dossier_path(@dossier) redirect_to merci_dossier_path(@dossier)
else else
flash.now.alert = errors
respond_to do |format| respond_to do |format|
format.html { render :brouillon } format.html { render :brouillon }
format.turbo_stream format.turbo_stream
@ -250,9 +248,9 @@ module Users
def submit_en_construction def submit_en_construction
@dossier = dossier_with_champs(pj_template: false) @dossier = dossier_with_champs(pj_template: false)
errors = submit_dossier_and_compute_errors @errors = submit_dossier_and_compute_errors
if errors.blank? if @errors.blank?
pending_correction_confirm = cast_bool(params.dig(:dossier, :pending_correction_confirm)) pending_correction_confirm = cast_bool(params.dig(:dossier, :pending_correction_confirm))
editing_fork_origin = @dossier.editing_fork_origin editing_fork_origin = @dossier.editing_fork_origin
editing_fork_origin.merge_fork(@dossier) editing_fork_origin.merge_fork(@dossier)
@ -260,8 +258,6 @@ module Users
redirect_to dossier_path(editing_fork_origin) redirect_to dossier_path(editing_fork_origin)
else else
flash.now.alert = errors
respond_to do |format| respond_to do |format|
format.html do format.html do
@dossier = @dossier.editing_fork_origin @dossier = @dossier.editing_fork_origin
@ -279,11 +275,7 @@ module Users
def update def update
@dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier @dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier
@dossier = dossier_with_champs(pj_template: false) @dossier = dossier_with_champs(pj_template: false)
errors = update_dossier_and_compute_errors @errors = update_dossier_and_compute_errors
if errors.present?
flash.now.alert = errors
end
respond_to do |format| respond_to do |format|
format.turbo_stream do format.turbo_stream do
@ -531,46 +523,22 @@ module Users
if @dossier.champs_public_all.any?(&:changed_for_autosave?) if @dossier.champs_public_all.any?(&:changed_for_autosave?)
@dossier.last_champ_updated_at = Time.zone.now @dossier.last_champ_updated_at = Time.zone.now
end end
if !@dossier.save(**validation_options) if !@dossier.save(**validation_options)
errors += format_errors(errors: @dossier.errors) errors = @dossier.errors
end end
errors errors
end end
def submit_dossier_and_compute_errors def submit_dossier_and_compute_errors
errors = []
@dossier.valid?(**submit_validation_options) @dossier.valid?(**submit_validation_options)
errors += format_errors(errors: @dossier.errors)
errors += format_errors(errors: @dossier.check_mandatory_and_visible_champs)
errors
end
def format_errors(errors:) errors = @dossier.errors
errors.map do |active_model_error| @dossier.check_mandatory_and_visible_champs.map do |error_on_champ|
case active_model_error.class.name errors.import(error_on_champ)
when "ActiveModel::NestedError"
append_anchor_link(active_model_error.full_message, active_model_error.inner_error.base)
when "ActiveModel::Error"
append_anchor_link(active_model_error.full_message, active_model_error.base)
else # "String"
active_model_error
end
end end
end errors
def append_anchor_link(str_error, model)
return str_error.full_message if !model.is_a?(Champ)
route_helper = @dossier.editing_fork? ? :modifier_dossier_path : :brouillon_dossier_path
[
"Le champ « #{model.libelle.truncate(200)} » #{str_error}",
helpers.link_to(t('views.users.dossiers.fix_champ'), public_send(route_helper, anchor: model.labelledby_id), class: 'error-anchor')
].join(", ")
rescue # case of invalid type de champ on champ
str_error
end end
def ensure_ownership! def ensure_ownership!

View file

@ -29,7 +29,7 @@ class Champs::DateChamp < Champ
def iso_8601 def iso_8601
return if parsable_iso8601? || value.blank? return if parsable_iso8601? || value.blank?
# i18n-tasks-use t('errors.messages.not_a_date') # i18n-tasks-use t('errors.messages.not_a_date')
errors.add :date, errors.generate_message(:value, :not_a_date) errors.add :date, :not_a_date
end end
def likely_iso8601_format? def likely_iso8601_format?

View file

@ -40,7 +40,7 @@ class Champs::DatetimeChamp < Champ
def iso_8601 def iso_8601
return if valid_iso8601? || value.blank? return if valid_iso8601? || value.blank?
# i18n-tasks-use t('errors.messages.not_a_datetime') # i18n-tasks-use t('errors.messages.not_a_datetime')
errors.add :datetime, errors.generate_message(:value, :not_a_datetime) errors.add :datetime, :not_a_datetime
end end
def valid_iso8601? def valid_iso8601?

View file

@ -1148,7 +1148,7 @@ class Dossier < ApplicationRecord
.filter(&:visible?) .filter(&:visible?)
.filter(&:mandatory_blank?) .filter(&:mandatory_blank?)
.map do |champ| .map do |champ|
champ.errors.add(:value, message: champ.errors.generate_message(:value, :missing)) champ.errors.add(:value, :missing)
end end
end end

View file

@ -4,7 +4,7 @@ class IbanValidator < ActiveModel::Validator
def validate(record) def validate(record)
if record.value.present? if record.value.present?
unless IBANTools::IBAN.valid?(record.value) unless IBANTools::IBAN.valid?(record.value)
record.errors.add :value, message: record.errors.generate_message(:value, :invalid_iban) record.errors.add :value, :invalid_iban
end end
end end
end end

View file

@ -1,13 +1,17 @@
- dossier_for_editing = dossier.en_construction? ? dossier.owner_editing_fork : dossier - dossier_for_editing = dossier.en_construction? ? dossier.owner_editing_fork : dossier
- if dossier.france_connect_information.present? - if dossier.france_connect_information.present?
- content_for(:notice_info) do - content_for(:notice_info) do
= render partial: "shared/dossiers/france_connect_informations_notice", locals: { user_information: dossier.france_connect_information } = render partial: "shared/dossiers/france_connect_informations_notice", locals: { user_information: dossier.france_connect_information }
.dossier-edit.container.counter-start-header-section .dossier-edit.container.counter-start-header-section
= render partial: "shared/dossiers/submit_is_over", locals: { dossier: dossier } = render partial: "shared/dossiers/submit_is_over", locals: { dossier: dossier }
= render NestedForms::FormOwnerComponent.new = render NestedForms::FormOwnerComponent.new
= form_for dossier_for_editing, url: brouillon_dossier_url(dossier), method: :patch, html: { id: 'dossier-edit-form', class: 'form', multipart: true, novalidate: 'novalidate' } do |f| = form_for dossier_for_editing, url: brouillon_dossier_url(dossier), method: :patch, html: { id: 'dossier-edit-form', class: 'form', multipart: true, novalidate: 'novalidate' } do |f|
= render Dossiers::ErrorsFullMessagesComponent.new(dossier: @dossier, errors: @errors || [])
%header.mb-6 %header.mb-6
.fr-highlight .fr-highlight
%p.fr-text--sm %p.fr-text--sm

View file

@ -408,7 +408,6 @@ en:
dossier_not_in_instructor_group: "File no. %{dossier_id} of the “%{procedure_libelle}” procedure corresponds to your search, but it is attached to the “%{groupe_instructeur_label}” instructor group." dossier_not_in_instructor_group: "File no. %{dossier_id} of the “%{procedure_libelle}” procedure corresponds to your search, but it is attached to the “%{groupe_instructeur_label}” instructor group."
users: users:
dossiers: dossiers:
fix_champ: "fill in this field"
archived_dossier: "Your file will be kept %{duree_conservation_dossiers_dans_ds} more months" archived_dossier: "Your file will be kept %{duree_conservation_dossiers_dans_ds} more months"
identite: identite:
identity_data: Identity data identity_data: Identity data

View file

@ -410,7 +410,6 @@ fr:
dossier_not_in_instructor_group: "Le dossier n° %{dossier_id} de la procédure « %{procedure_libelle} » correspond à votre recherche mais il est rattaché au groupe dinstructeurs « %{groupe_instructeur_label} »." dossier_not_in_instructor_group: "Le dossier n° %{dossier_id} de la procédure « %{procedure_libelle} » correspond à votre recherche mais il est rattaché au groupe dinstructeurs « %{groupe_instructeur_label} »."
users: users:
dossiers: dossiers:
fix_champ: "corriger lerreur"
archived_dossier: "Votre dossier sera conservé %{duree_conservation_dossiers_dans_ds} mois supplémentaire" archived_dossier: "Votre dossier sera conservé %{duree_conservation_dossiers_dans_ds} mois supplémentaire"
identite: identite:
identity_data: Données didentité identity_data: Données didentité

View file

@ -370,10 +370,9 @@ describe Users::DossiersController, type: :controller do
describe '#submit_brouillon' do describe '#submit_brouillon' do
before { sign_in(user) } before { sign_in(user) }
let!(:dossier) { create(:dossier, user: user) } let!(:dossier) { create(:dossier, user: user) }
let(:first_champ) { dossier.champs_public.first } let(:first_champ) { dossier.champs_public.first }
let(:anchor_to_first_champ) { controller.helpers.link_to I18n.t('views.users.dossiers.fix_champ'), brouillon_dossier_path(anchor: first_champ.labelledby_id), class: 'error-anchor' } let(:anchor_to_first_champ) { controller.helpers.link_to first_champ.libelle, brouillon_dossier_path(anchor: first_champ.labelledby_id), class: 'error-anchor' }
let(:value) { 'beautiful value' } let(:value) { 'beautiful value' }
let(:now) { Time.zone.parse('01/01/2100') } let(:now) { Time.zone.parse('01/01/2100') }
let(:payload) { { id: dossier.id } } let(:payload) { { id: dossier.id } }
@ -409,16 +408,19 @@ describe Users::DossiersController, type: :controller do
end end
context 'when the update fails' do context 'when the update fails' do
render_views
let(:error_message) { 'nop' }
before do before do
expect_any_instance_of(Dossier).to receive(:valid?).and_return(false) expect_any_instance_of(Dossier).to receive(:valid?).and_return(false)
expect_any_instance_of(Dossier).to receive(:errors).and_return( expect_any_instance_of(Dossier).to receive(:errors).and_return(
[double(class: ActiveModel::Error, full_message: 'nop', base: first_champ)] [double(inner_error: double(base: first_champ), message: 'nop')]
) )
subject subject
end end
it { expect(response).to render_template(:brouillon) } it { expect(response).to render_template(:brouillon) }
it { expect(flash.alert).to eq(["Le champ « #{first_champ.libelle} » nop, #{anchor_to_first_champ}"]) } it { expect(response.body).to have_link(first_champ.libelle, href: "##{first_champ.labelledby_id}") }
it { expect(response.body).to have_content(error_message) }
it 'does not send an email' do it 'does not send an email' do
expect(NotificationMailer).not_to receive(:send_en_construction_notification) expect(NotificationMailer).not_to receive(:send_en_construction_notification)
@ -428,6 +430,8 @@ describe Users::DossiersController, type: :controller do
end end
context 'when a mandatory champ is missing' do context 'when a mandatory champ is missing' do
render_views
let(:value) { nil } let(:value) { nil }
before do before do
@ -436,7 +440,8 @@ describe Users::DossiersController, type: :controller do
end end
it { expect(response).to render_template(:brouillon) } it { expect(response).to render_template(:brouillon) }
it { expect(flash.alert).to eq(["Le champ « l » doit être rempli, #{anchor_to_first_champ}"]) } it { expect(response.body).to have_link(first_champ.libelle, href: "##{first_champ.labelledby_id}") }
it { expect(response.body).to have_content("doit être rempli") }
end end
context 'when dossier has no champ' do context 'when dossier has no champ' do
@ -515,27 +520,26 @@ describe Users::DossiersController, type: :controller do
before do before do
expect_any_instance_of(Dossier).to receive(:valid?).and_return(false) expect_any_instance_of(Dossier).to receive(:valid?).and_return(false)
expect_any_instance_of(Dossier).to receive(:errors).and_return( expect_any_instance_of(Dossier).to receive(:errors).and_return(
[double(class: ActiveModel::Error, full_message: 'nop', base: first_champ)] [double(inner_error: double(base: first_champ), message: 'nop')]
) )
subject subject
end end
it { expect(response).to render_template(:modifier) } it { expect(response).to render_template(:modifier) }
it { expect(flash.alert).to eq(["Le champ « #{first_champ.libelle} » nop, #{anchor_to_first_champ}"]) }
it { expect(response.body).to include("Dossier nº #{dossier.id}") }
end end
context 'when a mandatory champ is missing' do context 'when a mandatory champ is missing' do
let(:value) { nil } let(:value) { nil }
render_views
before do before do
first_champ.type_de_champ.update(mandatory: true, libelle: 'l') first_champ.type_de_champ.update(mandatory: true, libelle: 'l')
subject subject
end end
it { expect(response).to render_template(:modifier) } it { expect(response).to render_template(:modifier) }
it { expect(flash.alert).to eq(["Le champ « l » doit être rempli, #{anchor_to_first_champ}"]) } it { expect(response.body).to have_content("doit être rempli") }
it { expect(response.body).to have_link(first_champ.libelle, href: "##{first_champ.labelledby_id}") }
end end
context 'when dossier has no champ' do context 'when dossier has no champ' do
@ -828,41 +832,44 @@ describe Users::DossiersController, type: :controller do
end end
context 'when the update fails' do context 'when the update fails' do
before do render_views
expect_any_instance_of(Dossier).to receive(:save).and_return(false)
expect_any_instance_of(Dossier).to receive(:errors).and_return( context 'classic error' do
[double(class: ActiveModel::Error, full_message: 'nop', base: first_champ)] before do
) expect_any_instance_of(Dossier).to receive(:save).and_return(false)
subject expect_any_instance_of(Dossier).to receive(:errors).and_return(
[message: 'nop', inner_error: double(base: first_champ)]
)
subject
end
it { expect(response).to render_template(:update) }
it 'does not update the dossier timestamps' do
dossier.reload
expect(dossier.updated_at).not_to eq(now)
expect(dossier.last_champ_updated_at).not_to eq(now)
end
it 'does not send an email' do
expect(NotificationMailer).not_to receive(:send_en_construction_notification)
subject
end
end end
it { expect(response).to render_template(:update) } context 'iban error' do
it { expect(flash.alert).to eq(["Le champ « #{first_champ.libelle} » nop, #{anchor_to_first_champ}"]) } let(:value) { 'abc' }
it 'does not update the dossier timestamps' do before do
dossier.reload first_champ.type_de_champ.update!(type_champ: :iban, mandatory: true, libelle: 'l')
expect(dossier.updated_at).not_to eq(now) dossier.champs_public.first.becomes!(Champs::IbanChamp).save!
expect(dossier.last_champ_updated_at).not_to eq(now)
subject
end
it { expect(response).to have_http_status(:success) }
end end
it 'does not send an email' do
expect(NotificationMailer).not_to receive(:send_en_construction_notification)
subject
end
end
context 'when a champ validation fails' do
let(:value) { 'abc' }
before do
first_champ.type_de_champ.update!(type_champ: :iban, mandatory: true, libelle: 'l')
dossier.champs_public.first.becomes!(Champs::IbanChamp).save!
subject
end
it { expect(flash.alert).to include("Le champ « l » nest pas au format IBAN, #{anchor_to_first_champ}") }
end end
context 'when the user has an invitation but is not the owner' do context 'when the user has an invitation but is not the owner' do

View file

@ -282,7 +282,7 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
wait_until { cnaf_champ.reload.code_postal == 'wrong_code' } wait_until { cnaf_champ.reload.code_postal == 'wrong_code' }
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
expect(page).to have_content(/Le champ « Champs public code postal » doit posséder 5 caractères/) expect(page).to have_content("cnaf doit posséder 5 caractères")
VCR.use_cassette('api_particulier/success/composition_familiale') do VCR.use_cassette('api_particulier/success/composition_familiale') do
fill_in 'Le code postal', with: code_postal fill_in 'Le code postal', with: code_postal
@ -479,7 +479,7 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
wait_until { dgfip_champ.reload.reference_avis == 'wrong_code' } wait_until { dgfip_champ.reload.reference_avis == 'wrong_code' }
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
expect(page).to have_content(/Le champ « Champs public reference avis » doit posséder 13 ou 14 caractères/) expect(page).to have_content(/dgfip doit posséder 13 ou 14 caractères/)
fill_in "La référence davis dimposition", with: reference_avis fill_in "La référence davis dimposition", with: reference_avis
wait_for_autosave wait_for_autosave

View file

@ -109,7 +109,7 @@ describe 'The user' do
fill_individual fill_individual
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
expect(page).to have_selector("#flash_message") expect(page).to have_selector("#sumup-errors")
all('.error-anchor').map do |link_element| all('.error-anchor').map do |link_element|
error_anchor = URI(link_element['href']) error_anchor = URI(link_element['href'])
expect(page).to have_selector("##{error_anchor.fragment}") expect(page).to have_selector("##{error_anchor.fragment}")