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,48 +523,24 @@ 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 = @dossier.errors
@dossier.check_mandatory_and_visible_champs.map do |error_on_champ|
errors.import(error_on_champ)
end
errors errors
end end
def format_errors(errors:)
errors.map do |active_model_error|
case active_model_error.class.name
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
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
def ensure_ownership! def ensure_ownership!
if !current_user.owns?(dossier) if !current_user.owns?(dossier)
forbidden! forbidden!

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,16 +832,18 @@ describe Users::DossiersController, type: :controller do
end end
context 'when the update fails' do context 'when the update fails' do
render_views
context 'classic error' do
before do before do
expect_any_instance_of(Dossier).to receive(:save).and_return(false) expect_any_instance_of(Dossier).to receive(:save).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)] [message: 'nop', inner_error: double(base: first_champ)]
) )
subject subject
end end
it { expect(response).to render_template(:update) } it { expect(response).to render_template(:update) }
it { expect(flash.alert).to eq(["Le champ « #{first_champ.libelle} » nop, #{anchor_to_first_champ}"]) }
it 'does not update the dossier timestamps' do it 'does not update the dossier timestamps' do
dossier.reload dossier.reload
@ -852,7 +858,7 @@ describe Users::DossiersController, type: :controller do
end end
end end
context 'when a champ validation fails' do context 'iban error' do
let(:value) { 'abc' } let(:value) { 'abc' }
before do before do
@ -862,7 +868,8 @@ describe Users::DossiersController, type: :controller do
subject subject
end end
it { expect(flash.alert).to include("Le champ « l » nest pas au format IBAN, #{anchor_to_first_champ}") } it { expect(response).to have_http_status(:success) }
end
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}")