From 5846fb4417f33413b0176ccbb258173d7a89882b Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Mon, 27 May 2024 15:14:34 +0200 Subject: [PATCH 01/63] fix spec titles --- spec/services/pieces_justificatives_service_spec.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index cef564981..3f750829b 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -323,14 +323,14 @@ describe PiecesJustificativesService do context 'given an administrateur' do let(:user_profile) { build(:administrateur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an instructeur' do let(:user_profile) { create(:instructeur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end @@ -346,7 +346,7 @@ describe PiecesJustificativesService do let(:experts_procedure) { create(:experts_procedure, expert: user_profile, procedure:) } let(:avis) { create(:avis, experts_procedure:, dossier: dossier, confidentiel: true) } let(:user_profile) { create(:expert) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end @@ -370,21 +370,21 @@ describe PiecesJustificativesService do context 'given an administrateur' do let(:user_profile) { build(:administrateur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an instructeur' do let(:user_profile) { create(:instructeur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an expert' do let(:user_profile) { create(:expert) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end From 06cbb65d4e204542334741b3759d3038eaa72269 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 10:06:30 +0200 Subject: [PATCH 02/63] spec: simplify export template factory --- spec/factories/export_template.rb | 42 ++++++++++++------- spec/models/export_template_spec.rb | 2 +- .../pieces_justificatives_service_spec.rb | 2 +- .../services/procedure_export_service_spec.rb | 2 +- .../procedure_export_service_zip_spec.rb | 2 +- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index 0f4e8d882..54ce224e5 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -11,24 +11,36 @@ FactoryBot.define do { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_id", "label" => "id dossier" } }, { "text" => " .pdf", "type" => "text" }] } ] }, - "default_dossier_directory" => - { - "type" => "doc", - "content" => - [ - { - "type" => "paragraph", - "content" => - [ - { "text" => "dossier_", "type" => "text" }, - { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, - { "text" => " ", "type" => "text" } - ] + "default_dossier_directory" => { + "type" => "doc", + "content" => + [ + { + "type" => "paragraph", + "content" => + [ + { "text" => "dossier_", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => " ", "type" => "text" } + ] + } + ] } - ] - } } } kind { "zip" } + + to_create do |export_template, _context| + export_template.set_default_values + export_template.save + end + + trait :with_custom_content do + to_create do |export_template, context| + export_template.set_default_values + export_template.content = context.content + export_template.save + end + end end end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index f0602597a..529de503b 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -1,6 +1,6 @@ describe ExportTemplate do let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } - let(:export_template) { create(:export_template, groupe_instructeur:, content:) } + let(:export_template) { create(:export_template, :with_custom_content, groupe_instructeur:, content:) } let(:procedure) { create(:procedure_with_dossiers, types_de_champ_public:, for_individual:) } let(:dossier) { procedure.dossiers.first } let(:for_individual) { false } diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 3f750829b..bdfe28911 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -110,7 +110,7 @@ describe PiecesJustificativesService do it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } context 'with export_template' do - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } end end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index b463675be..0c03cfd98 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -529,7 +529,7 @@ describe ProcedureExportService do context 'with export_template' do let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template:).generate_dossiers_export(Dossier.where(id: dossier)) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } before do allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") end diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 0daced35e..14e5ceaad 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -2,7 +2,7 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative, libelle: 'repet_pj' }] }]) } let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') From ce6ebf35896fb0eac6a94c9215758f47d9413204 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:06:31 +0200 Subject: [PATCH 03/63] fix dossier directory for commentaire when export with export template --- app/models/export_template.rb | 4 ++-- app/services/pieces_justificatives_service.rb | 7 +++++- spec/factories/export_template.rb | 22 +++++++++++++++++++ .../pieces_justificatives_service_spec.rb | 7 ++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index f5ed164b9..3a9bf9c5c 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -48,7 +48,7 @@ class ExportTemplate < ApplicationRecord def attachment_and_path(dossier, attachment, index: 0, row_index: nil, champ: nil) [ attachment, - path(dossier, attachment, index, row_index, champ) + path(dossier, attachment, index:, row_index:, champ:) ] end @@ -116,7 +116,7 @@ class ExportTemplate < ApplicationRecord "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" end - def path(dossier, attachment, index, row_index, champ) + def path(dossier, attachment, index: 0, row_index: nil, champ: nil) if attachment.name == 'pdf_export_for_instructeur' return export_path(dossier) end diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 0fdbc2be9..8f95adc44 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -169,7 +169,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = commentaire_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index 54ce224e5..d62754115 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -42,5 +42,27 @@ FactoryBot.define do export_template.save end end + + trait :with_custom_ddd_prefix do + transient do + ddd_prefix { 'dossier_' } + end + + to_create do |export_template, context| + export_template.set_default_values + export_template.content["default_dossier_directory"]["content"] = [ + { + "type" => "paragraph", + "content" => + [ + { "text" => context.ddd_prefix, "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => " ", "type" => "text" } + ] + } + ] + export_template.save + end + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index bdfe28911..9d6e69fd1 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -164,6 +164,13 @@ describe PiecesJustificativesService do end it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachments) } + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/messagerie")).to be true + end + end end context 'with a pj not safe on a commentaire' do From 08c079ca0b33497c656899921a7a400eb8e276ce Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:10:54 +0200 Subject: [PATCH 04/63] fix dossier directory for avis when export with export template --- app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 8f95adc44..64807d6b5 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -246,7 +246,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = avis_ids_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 9d6e69fd1..770d67e02 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -387,6 +387,13 @@ describe PiecesJustificativesService do it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/avis")).to be true + end + end end context 'given an expert' do From 4232cc98c75046e9e7171dab241541df74cac855 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:28:13 +0200 Subject: [PATCH 05/63] fix dossier directory for motivation when export with export template --- app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 64807d6b5..75210b024 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -206,7 +206,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = a.record_id - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 770d67e02..f0a6a094a 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -187,6 +187,13 @@ describe PiecesJustificativesService do let!(:witness) { create(:dossier, :with_justificatif) } it { expect(subject).to match_array(dossier.justificatif_motivation.attachment) } + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/dossier")).to be true + end + end end context 'with a motivation not safe' do From 2267ec98cf8e154a15b7c9a523908635aef5a60e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:35:49 +0200 Subject: [PATCH 06/63] fix dossier directory for attestation when export with export template --- app/models/export_template.rb | 2 ++ app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 10 ++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 3a9bf9c5c..6555af9c9 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -128,6 +128,8 @@ class ExportTemplate < ApplicationRecord 'messagerie' when 'Avis' 'avis' + when 'Attestation' + 'pieces_justificatives' else # for attachment return attachment_path(dossier, attachment, index, row_index, champ) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 75210b024..871aa6f68 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -227,7 +227,12 @@ class PiecesJustificativesService .where(record_type: "Attestation", record_id: attestation_id_dossier_id.keys) .map do |a| dossier_id = attestation_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index f0a6a094a..c817e9047 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -209,6 +209,16 @@ describe PiecesJustificativesService do let!(:witness) { create(:dossier, :with_attestation) } it { expect(subject).to match_array(dossier.attestation.pdf.attachment) } + it 'uses default name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template: nil).liste_documents(dossiers).map(&:second)[0].starts_with?("dossier-#{dossier.id}/pieces_justificatives")).to be true + end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true + end + end end context 'with an etablissement' do From d9f7b6d1df894d755c76131234bafc270e282ffa Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:40:25 +0200 Subject: [PATCH 07/63] fix dossier directory for etablissement when export with export template --- app/models/export_template.rb | 2 +- app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 6555af9c9..febadd05a 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -128,7 +128,7 @@ class ExportTemplate < ApplicationRecord 'messagerie' when 'Avis' 'avis' - when 'Attestation' + when 'Attestation', 'Etablissement' 'pieces_justificatives' else # for attachment diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 871aa6f68..92d53b088 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -195,7 +195,12 @@ class PiecesJustificativesService .where(record_type: "Etablissement", record_id: etablissement_id_dossier_id.keys) .map do |a| dossier_id = etablissement_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index c817e9047..0057b0b0f 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -236,6 +236,17 @@ describe PiecesJustificativesService do end it { expect(subject).to match_array([attestation_sociale.attachment, attestation_fiscale.attachment]) } + + it 'uses default name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template: nil).liste_documents(dossiers).map(&:second)[0].starts_with?("dossier-#{dossier.id}/pieces_justificatives")).to be true + end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true + end + end end end From bc4deb1fc2d047ff5e18547822c50626648aadf8 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 24 May 2024 14:55:34 +0200 Subject: [PATCH 08/63] add specs to export templates controller --- .../export_templates_controller_spec.rb | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb index 8b8d73b82..36c9f665e 100644 --- a/spec/controllers/instructeurs/export_templates_controller_spec.rb +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -21,26 +21,45 @@ describe Instructeurs::ExportTemplatesController, type: :controller do { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } ] }.to_json, - "pjs" => - [ - { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " _justif", "type" => "text" }] }] }, stable_id: "3" }, - { - path: - { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "cni_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "5" - }, - { - path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "pj_repet_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "10" - } - ] + tiptap_pj_3: { + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json, + tiptap_pj_5: { + + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json, + tiptap_pj_10: { + + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json } end let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let(:procedure) do + create( + :procedure, instructeurs: [instructeur], + types_de_champ_public: [ + { type: :piece_justificative, libelle: "pj1", stable_id: 3 }, + { type: :piece_justificative, libelle: "pj2", stable_id: 5 }, + { type: :piece_justificative, libelle: "pj3", stable_id: 10 } + ] + ) + end let(:groupe_instructeur) { procedure.defaut_groupe_instructeur } + describe '#new' do + let(:subject) { get :new, params: { procedure_id: procedure.id } } + + it do + subject + expect(assigns(:export_template)).to be_present + end + end + describe '#create' do let(:subject) { post :create, params: { procedure_id: procedure.id, export_template: export_template_params } } @@ -130,4 +149,19 @@ describe Instructeurs::ExportTemplatesController, type: :controller do end end end + + describe '#preview' do + render_views + + let(:export_template) { create(:export_template, groupe_instructeur:) } + + let(:subject) { get :preview, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params }, format: :turbo_stream } + + it '' do + dossier = create(:dossier, procedure: procedure, for_procedure_preview: true) + subject + expect(response.body).to include "DOSSIER_#{dossier.id}" + expect(response.body).to include "mon_export_#{dossier.id}.pdf" + end + end end From c0a95ab5256d9d05a2e1144c9ccc8d077fc51655 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 18:24:45 +0200 Subject: [PATCH 09/63] add specs to pieces_justificatives_service --- .../pieces_justificatives_service_spec.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 0057b0b0f..341cb33ad 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -439,7 +439,8 @@ describe PiecesJustificativesService do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :piece_justificative }] }]) } let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } let(:dossiers) { Dossier.where(id: dossier.id) } - subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } + let(:export_template) { nil } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } it "doesn't update dossier" do expect { subject }.not_to change { dossier.updated_at } @@ -451,11 +452,24 @@ describe PiecesJustificativesService do let!(:not_confidentiel_avis) { create(:avis, :not_confidentiel, dossier: dossier) } let!(:expert_avis) { create(:avis, :confidentiel, dossier: dossier, expert: user_profile) } - subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } it "includes avis not confidentiel as well as expert's avis" do expect_any_instance_of(Dossier).to receive(:avis_for_expert).with(user_profile).and_return([]) subject end + + it 'gives default name to export pdf file' do + expect(subject.first.second.starts_with?("dossier-#{dossier.id}/export-#{dossier.id}")).to eq true + end + end + + context 'with export template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } + + it 'gives custom name to export pdf file' do + expect(subject.first.second).to eq "DOSSIER-#{dossier.id}/export_#{dossier.id}.pdf" + end end end From d61203e57ceb03dfa47f51772c0cfc05f03b31d9 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 19:14:57 +0200 Subject: [PATCH 10/63] remove dead code --- app/models/champ.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index 74b5fd1e9..2cb0bb4ec 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -91,10 +91,6 @@ class Champ < ApplicationRecord parent_id.present? end - def stable_id_with_row - [row_id, stable_id].compact - end - # used for the `required` html attribute # check visibility to avoid hidden required input # which prevent the form from being sent. From acf6579aa4b45a07ca63f38f4de01d811c351a04 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 11:08:46 +0200 Subject: [PATCH 11/63] add missing specs to export template --- spec/models/export_template_spec.rb | 42 +++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 529de503b..443959bc1 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -69,6 +69,22 @@ describe ExportTemplate do end end + describe '#assign_pj_names' do + let(:pj_params) do + { + "tiptap_pj_1" => { + "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json + } + end + it 'values content from pj params' do + export_template.assign_pj_names(pj_params) + expect(export_template.content["pjs"]).to eq [ + { :path => { "content" => [{ "content" => [{ "text" => "avis-commission-", "type" => "text" }, { "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" }, "type" => "mention" }], "type" => "paragraph" }], "type" => "doc" }, :stable_id => "1" } + ] + end + end + describe '#tiptap_default_dossier_directory' do it 'returns tiptap_default_dossier_directory from content' do expect(export_template.tiptap_default_dossier_directory).to eq({ @@ -297,21 +313,37 @@ describe ExportTemplate do end end - describe 'specific_tags' do - context 'for entreprise procedure' do - let(:for_individual) { false } + context 'for entreprise procedure' do + let(:for_individual) { false } + describe 'specific_tags' do it do tags = export_template.specific_tags expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] end end - context 'for individual procedure' do - let(:for_individual) { true } + describe 'tags_for_pj' do + it do + tags = export_template.tags_for_pj + expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + end + end + end + + context 'for individual procedure' do + let(:for_individual) { true } + describe 'specific_tags' do it do tags = export_template.specific_tags expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] end end + + describe 'tags_for_pj' do + it do + tags = export_template.tags_for_pj + expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + end + end end end From 0ed166f51008d2b03989238fb406b5a1a3eccedb Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 14:30:20 +0200 Subject: [PATCH 12/63] export_template feature flag scoped by procedure --- .../export_dropdown_component.html.haml | 16 ++++++++-------- .../instructeurs/procedures/exports.html.haml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index fbb499483..fd29214f2 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -15,12 +15,12 @@ = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = t(".everything_#{format}_html") - - if export_templates.present? - - export_templates.each do |export_template| + - if @procedure.feature_enabled?(:export_template) + - if export_templates.present? + - export_templates.each do |export_template| + - menu.with_item do + = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do + = "Exporter à partir du modèle #{export_template.name}" - menu.with_item do - = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do - = "Exporter à partir du modèle #{export_template.name}" - - if feature_enabled?(:export_template) - - menu.with_item do - = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do - Ajouter un modèle d'export + = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do + Ajouter un modèle d'export diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index 0986a977a..793a7d960 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -23,7 +23,7 @@ - else = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) - - if feature_enabled?(:export_template) + - if @procedure.feature_enabled?(:export_template) %h2.fr-mb-1w.fr-mt-8w Liste des modèles d'export %p.fr-hint-text From fc90648c79e618f914f2f83989ddf411c1db12cf Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 15:42:49 +0200 Subject: [PATCH 13/63] fix: regenerate export from export template --- app/components/dossiers/export_link_component.rb | 3 ++- .../export_link_component/export_link_component.html.haml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/dossiers/export_link_component.rb b/app/components/dossiers/export_link_component.rb index 0fe2967aa..647d08a4b 100644 --- a/app/components/dossiers/export_link_component.rb +++ b/app/components/dossiers/export_link_component.rb @@ -11,9 +11,10 @@ class Dossiers::ExportLinkComponent < ApplicationComponent @export_url = export_url end - def download_export_path(export_format:, statut:, no_progress_notification: nil) + def download_export_path(export_format:, statut:, export_template_id: nil, no_progress_notification: nil) @export_url.call(@procedure, export_format: export_format, + export_template_id:, statut: statut, no_progress_notification: no_progress_notification) end diff --git a/app/components/dossiers/export_link_component/export_link_component.html.haml b/app/components/dossiers/export_link_component/export_link_component.html.haml index 0ed8d34a2..6f217d5ae 100644 --- a/app/components/dossiers/export_link_component/export_link_component.html.haml +++ b/app/components/dossiers/export_link_component/export_link_component.html.haml @@ -14,4 +14,4 @@ = export_button(export) - if export.failed? - = button_to refresh_button_options(export)[:title], download_export_path(export_format: export.format, statut: export.statut), refresh_button_options(export) + = button_to refresh_button_options(export)[:title], download_export_path(export_template_id: export.export_template&.id, export_format: export.format, statut: export.statut), refresh_button_options(export) From 9f504dbefd7cdc5adfd349b4075ff6b5f67261d8 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 16:09:13 +0200 Subject: [PATCH 14/63] precise export template source for zip exports --- .../export_link_component/export_link_component.html.haml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/dossiers/export_link_component/export_link_component.html.haml b/app/components/dossiers/export_link_component/export_link_component.html.haml index 6f217d5ae..32d631e33 100644 --- a/app/components/dossiers/export_link_component/export_link_component.html.haml +++ b/app/components/dossiers/export_link_component/export_link_component.html.haml @@ -7,6 +7,9 @@ = export_title(export) %span.fr-text-mention--grey.fr-mb-1w = time_info(export) + - if export.export_template + %span.fr-tag.fr-tag--sm.fr-ml-1w + = export.export_template.name .fr-ml-auto = badge(export) From 823ee11d846cd11ae493bac68fa7f1b4b36cd737 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Wed, 29 May 2024 17:03:09 +0200 Subject: [PATCH 15/63] fix(asset cards): background none for welcome card admin in darkmode --- app/assets/stylesheets/card.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index 82b6f7cf1..f2143d2ec 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -5,6 +5,9 @@ [data-fr-theme="dark"] .card { background: none; border: 1px solid var(--background-action-low-blue-france); + &.feedback { + background: none; + } } .card { From bd94c575d71df8f9f4201f095a61aa12ae03205b Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Wed, 29 May 2024 17:35:40 +0200 Subject: [PATCH 16/63] linter check --- app/assets/stylesheets/card.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index f2143d2ec..e3646961f 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -5,6 +5,7 @@ [data-fr-theme="dark"] .card { background: none; border: 1px solid var(--background-action-low-blue-france); + &.feedback { background: none; } From eb70d63892ba22dee33d4b345bc42dbe28094eb9 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 30 May 2024 13:47:01 +0200 Subject: [PATCH 17/63] remove css fix --- app/assets/stylesheets/card.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index e3646961f..82b6f7cf1 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -5,10 +5,6 @@ [data-fr-theme="dark"] .card { background: none; border: 1px solid var(--background-action-low-blue-france); - - &.feedback { - background: none; - } } .card { From 1bc7cfd9176c616d76998e22547385688dcad208 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 28 May 2024 14:56:32 +0200 Subject: [PATCH 18/63] remove card historique des modifications --- .../procedure/card/modifications_component.rb | 9 --------- .../modifications_component.fr.yml | 5 ----- .../modifications_component.html.haml | 12 ------------ .../administrateurs/procedures/champs.html.haml | 6 +++++- .../procedures/modifications.html.haml | 3 +++ app/views/administrateurs/procedures/show.html.haml | 1 - 6 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 app/components/procedure/card/modifications_component.rb delete mode 100644 app/components/procedure/card/modifications_component/modifications_component.fr.yml delete mode 100644 app/components/procedure/card/modifications_component/modifications_component.html.haml diff --git a/app/components/procedure/card/modifications_component.rb b/app/components/procedure/card/modifications_component.rb deleted file mode 100644 index 35b90a624..000000000 --- a/app/components/procedure/card/modifications_component.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Procedure::Card::ModificationsComponent < ApplicationComponent - def initialize(procedure:) - @procedure = procedure - end - - def render? - @procedure.revised? - end -end diff --git a/app/components/procedure/card/modifications_component/modifications_component.fr.yml b/app/components/procedure/card/modifications_component/modifications_component.fr.yml deleted file mode 100644 index 676fc64cd..000000000 --- a/app/components/procedure/card/modifications_component/modifications_component.fr.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -fr: - title: - one: Modification du formulaire - other: Modifications du formulaire diff --git a/app/components/procedure/card/modifications_component/modifications_component.html.haml b/app/components/procedure/card/modifications_component/modifications_component.html.haml deleted file mode 100644 index 9dc203c17..000000000 --- a/app/components/procedure/card/modifications_component/modifications_component.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.fr-col-6.fr-col-md-4.fr-col-lg-3 - = link_to modifications_admin_procedure_path(@procedure), id: 'modifications', class: 'fr-tile fr-enlarge-link' do - .fr-tile__body.flex.column.align-center.justify-between - %p.fr-badge.fr-badge--success Activée - %div - .line-count.fr-my-1w - %p.fr-tag= @procedure.revisions_count - %h3.fr-h6 - = t('.title', count: @procedure.revisions_count) - - %p.fr-tile-subtitle Historique des modifications apportées au formulaire - %p.fr-btn.fr-btn--tertiary Voir diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index 95ddf2860..1d397ebb0 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -4,7 +4,11 @@ ['Champs du formulaire']], preview: @procedure.draft_revision.valid? } .fr-container - %h1.fr-h2 Champs du formulaire + .flex.justify-between.align-center.fr-mb-3w + %h1.fr-h2 Champs du formulaire + - if @procedure.revised? + = link_to "Voir l'historique des modifications du formulaire", modifications_admin_procedure_path(@procedure), class: 'fr-link' + = render NestedForms::FormOwnerComponent.new .fr-grid-row = render partial: 'champs_summary' diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index ea9e9c32b..978fee30f 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -1,8 +1,11 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Champs du formulaire', champs_admin_procedure_path(@procedure)], ['Historique des modifications du formulaire']] } .fr-container + .fr-mb-3w + = link_to "Champs du formulaire", champs_admin_procedure_path(@procedure), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" %h1.fr-h2 Historique des modifications du formulaire diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index a4c1f16d3..c510fa022 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -75,7 +75,6 @@ = render Procedure::Card::ServiceComponent.new(procedure: @procedure, administrateur: current_administrateur) = render Procedure::Card::AdministrateursComponent.new(procedure: @procedure) = render Procedure::Card::InstructeursComponent.new(procedure: @procedure) - = render Procedure::Card::ModificationsComponent.new(procedure: @procedure) %h3.fr-h6 Pour aller plus loin .fr-grid-row.fr-grid-row--gutters.fr-mb-5w From ee203d1afc198c71c1f6eba9ded8be88ddabd738 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 28 May 2024 16:40:15 +0200 Subject: [PATCH 19/63] reorganize page config expert invites --- app/components/dsfr/toggle_component.rb | 5 +- .../toggle_component.html.haml | 2 +- .../experts_procedures/index.html.haml | 170 +++++++++--------- .../administrateurs/experts_procedures/fr.yml | 5 +- .../index.html.haml_spec.rb | 4 +- 5 files changed, 100 insertions(+), 86 deletions(-) diff --git a/app/components/dsfr/toggle_component.rb b/app/components/dsfr/toggle_component.rb index 20c328e9b..f0a4114b0 100644 --- a/app/components/dsfr/toggle_component.rb +++ b/app/components/dsfr/toggle_component.rb @@ -1,5 +1,5 @@ class Dsfr::ToggleComponent < ApplicationComponent - def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil) + def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil, extra_class_names: nil) @form = form @target = target @title = title @@ -7,7 +7,8 @@ class Dsfr::ToggleComponent < ApplicationComponent @disabled = disabled @toggle_labels = toggle_labels @opt = opt + @extra_class_names = extra_class_names end - attr_reader :toggle_labels + attr_reader :toggle_labels, :extra_class_names end diff --git a/app/components/dsfr/toggle_component/toggle_component.html.haml b/app/components/dsfr/toggle_component/toggle_component.html.haml index 7769a3724..18bde573d 100644 --- a/app/components/dsfr/toggle_component/toggle_component.html.haml +++ b/app/components/dsfr/toggle_component/toggle_component.html.haml @@ -1,4 +1,4 @@ -.fr-toggle.fr-toggle--label-left +%div{ class: "fr-toggle fr-toggle--label-left #{extra_class_names}" } = @form.check_box @target, class: 'fr-toggle__input', disabled: @disabled, data: @opt = @form.label @target, diff --git a/app/views/administrateurs/experts_procedures/index.html.haml b/app/views/administrateurs/experts_procedures/index.html.haml index e1d6b6d88..dc8909f82 100644 --- a/app/views/administrateurs/experts_procedures/index.html.haml +++ b/app/views/administrateurs/experts_procedures/index.html.haml @@ -7,102 +7,112 @@ %h1.fr-h2 Avis externes - .groupe-instructeur - .card - .card-title= t('.titles.allow_invite_experts') - %p= t('.descriptions.allow_invite_experts') + = render Dsfr::CalloutComponent.new(title: nil) do |c| + - c.with_body do + Pendant l'instruction d'un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. + %p + = link_to('Comment gérer les avis externes', t('.experts_doc.url'), + title: t('.experts_doc.title'), + **external_link_attributes) + + %ul.fr-toggle__list + %li = form_for @procedure, method: :put, url: allow_expert_review_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_review, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_review, + title: t('.titles.allow_invite_experts'), + hint: t('.descriptions.allow_invite_experts'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') - if @procedure.allow_expert_review? - .card - .card-title= t('.titles.manage_procedure_experts') - %p= t('.descriptions.manage_procedure_experts') + %li + = form_for @procedure, + method: :put, + url: allow_expert_messaging_admin_procedure_path(@procedure), + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_messaging, + title: t('.titles.allow_expert_messaging'), + hint: t('.descriptions.allow_expert_messaging'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') + + %li = form_for @procedure, method: :put, url: experts_require_administrateur_invitation_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :experts_require_administrateur_invitation, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| - .card - .card-title= t('.titles.allow_expert_messaging') - %p= t('.descriptions.allow_expert_messaging') - = form_for @procedure, - method: :put, - url: allow_expert_messaging_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_messaging, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + = render Dsfr::ToggleComponent.new(form: f, + target: :experts_require_administrateur_invitation, + title: t('.titles.manage_procedure_experts'), + hint: t('.descriptions.manage_procedure_experts'), + disabled: false) - - if @procedure.experts_require_administrateur_invitation? - .card - .card-title Affecter des experts à la démarche - = form_for :experts_procedure, - url: admin_procedure_experts_path(@procedure), - html: { class: 'form' } do |f| - .instructeur-wrapper - %p Pendant l'instruction d’un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. - %p#experts-emails Entrez les adresses email des experts que vous souhaitez affecter à cette démarche - = hidden_field_tag :emails, nil - = react_component("ComboMultiple", - options: [], - selected: [], disabled: [], - group: '.instructeur-wrapper', - name: 'emails', - label: 'Emails', - describedby: 'experts-emails', - acceptNewValues: true) + - if @procedure.experts_require_administrateur_invitation? + .card + = form_for :experts_procedure, + url: admin_procedure_experts_path(@procedure), + html: { class: 'form' } do |f| + + .instructeur-wrapper + %p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie + = hidden_field_tag :emails, nil + = react_component("ComboMultiple", + options: [], + selected: [], disabled: [], + group: '.instructeur-wrapper', + name: 'emails', + label: 'Emails', + describedby: 'experts-emails', + acceptNewValues: true) + + = f.submit 'Ajouter à la liste', class: 'fr-btn' - = f.submit 'Affecter à la démarche', class: 'button primary send' - if @experts_procedure.present? - %table.table.mt-2 - %thead - %tr - %th Liste des experts - %th Nombre d’avis - - if @procedure.experts_require_administrateur_invitation - %th Notifier des décisions sur les dossiers - %tbody - - @experts_procedure.each do |expert_procedure| + .fr-table.fr-table--no-caption.fr-table--layout-fixed.fr-mt-3w + %table + %thead %tr - %td - = dsfr_icon('fr-icon-user-fill') - = expert_procedure.expert.email - %td.text-center - = expert_procedure.avis.count + %th Liste des experts + %th Nombre d’avis - if @procedure.experts_require_administrateur_invitation + %th Notifier des décisions sur les dossiers + - if @procedure.experts_require_administrateur_invitation + %th Action + %tbody + - @experts_procedure.each do |expert_procedure| + %tr + %td + = dsfr_icon('fr-icon-user-fill') + = expert_procedure.expert.email %td.text-center - = form_for expert_procedure, - url: admin_procedure_expert_path(id: expert_procedure), - method: :put, - data: { turbo: true }, - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off - - if @procedure.experts_require_administrateur_invitation - %td.actions= button_to 'retirer', - admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), - method: :delete, - data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, - class: 'button' + = expert_procedure.avis.count + - if @procedure.experts_require_administrateur_invitation + %td.text-center + = form_for expert_procedure, + url: admin_procedure_expert_path(id: expert_procedure), + method: :put, + data: { turbo: true }, + html: { class: 'form procedure-form__column--form no-background' } do |f| + %label.toggle-switch{ data: { controller: 'autosubmit' } } + = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' + %span.toggle-switch-control.round + %span.toggle-switch-label.on + %span.toggle-switch-label.off + - if @procedure.experts_require_administrateur_invitation + %td.actions= button_to 'retirer', + admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), + method: :delete, + data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, + class: 'fr-btn fr-btn--secondary' - else .blank-tab %h2.empty-text Aucun expert invité pour le moment. diff --git a/config/locales/views/administrateurs/experts_procedures/fr.yml b/config/locales/views/administrateurs/experts_procedures/fr.yml index 62db8096c..673b0a609 100644 --- a/config/locales/views/administrateurs/experts_procedures/fr.yml +++ b/config/locales/views/administrateurs/experts_procedures/fr.yml @@ -6,8 +6,11 @@ fr: main: Experts invités sur %{libelle} allow_invite_experts: "Autoriser les instructeurs à solliciter des experts invités" allow_expert_messaging: "Autoriser les experts à accéder à la messagerie usager" - manage_procedure_experts: "Gérer les experts invités de la démarche" + manage_procedure_experts: "Gérer les experts invités de la démarche avec une liste prédéfinie" descriptions: allow_invite_experts : Lorsque cette fonctionnalité est active, les instructeurs peuvent solliciter les experts allow_expert_messaging: Lorsque cette fonctionnalité est active, les experts peuvent demander des informations aux usagers manage_procedure_experts: Lorsque cette fonctionnalité est active, les instructeurs peuvent uniquement solliciter les experts de votre liste + experts_doc: + title: Avis externes documentation + url: 'https://app.gitbook.com/o/-L7_aClyGhmMzzsqtO4_/s/-L7_aKvpAJdAIEfxHudA/tutoriels/tutoriel-administrateur/~/comments?context=post&node=ff7bd481c1994d6aa56817b7237b9e59#id-12.-la-gestion-des-avis-experts-invites-de-votre-demarche' diff --git a/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb b/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb index fcc3aa25c..18b91611f 100644 --- a/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb +++ b/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb @@ -40,7 +40,7 @@ describe 'administrateurs/experts_procedures/index', type: :view do context 'when the experts_require_administrateur_invitation is false' do it 'authorize instructors to invite any expert' do - expect(rendered).not_to have_content "Affecter des experts à la démarche" + expect(rendered).not_to have_content "Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie" end end @@ -50,7 +50,7 @@ describe 'administrateurs/experts_procedures/index', type: :view do subject end it 'does not authorize instructors to invite any expert but only those presents in admin list' do - expect(rendered).to have_content "Affecter des experts à la démarche" + expect(rendered).to have_content "Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie" end end end From ab54b60489b8705645e483a3def39f7282ce1131 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 30 May 2024 18:17:08 +0200 Subject: [PATCH 20/63] add dsfr callout --- app/assets/stylesheets/card_admin.scss | 4 ++ .../procedures/new_from_existing.html.haml | 52 +++++++++---------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/app/assets/stylesheets/card_admin.scss b/app/assets/stylesheets/card_admin.scss index 8f678d3aa..5001efce2 100644 --- a/app/assets/stylesheets/card_admin.scss +++ b/app/assets/stylesheets/card_admin.scss @@ -4,3 +4,7 @@ .fr-tile-subtitle { min-height: 7rem; } + +.card-welcome { + margin: 30px auto; +} diff --git a/app/views/administrateurs/procedures/new_from_existing.html.haml b/app/views/administrateurs/procedures/new_from_existing.html.haml index 76ccd4178..d5f4adf58 100644 --- a/app/views/administrateurs/procedures/new_from_existing.html.haml +++ b/app/views/administrateurs/procedures/new_from_existing.html.haml @@ -1,31 +1,31 @@ .container - if current_administrateur.procedures.brouillons.count == 0 - .card.feedback - .card-title - Bienvenue, - %br - vous allez pouvoir créer une première démarche de test. - Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. - %br - %br - Besoin d’aide ? - %br - > Vous pouvez - = link_to "visionner cette vidéo", - "https://vimeo.com/261478872", - target: "_blank" - %br - > Vous pouvez lire notre - = link_to "documentation en ligne", - ADMINISTRATEUR_TUTORIAL_URL, - target: "_blank" - - %br - > Vous pouvez enfin - = link_to "prendre un rendez-vous téléphonique avec nous", - CALENDLY_URL, - target: "_blank" - + .card-welcome + = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line") do |c| + - c.with_html_body do + %p + Bienvenue, + %br + vous allez pouvoir créer une première démarche de test. + Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. + %br + %br + Besoin d’aide ? + %br + > Vous pouvez + = link_to "visionner cette vidéo", + "https://vimeo.com/261478872", + target: "_blank" + %br + > Vous pouvez lire notre + = link_to "documentation en ligne", + ADMINISTRATEUR_TUTORIAL_URL, + target: "_blank" + %br + > Vous pouvez enfin + = link_to "prendre un rendez-vous téléphonique avec nous", + CALENDLY_URL, + target: "_blank" :javascript document.addEventListener("DOMContentLoaded", function() { $crisp.push(["do", "trigger:run", ["admin-signup"]]); From ac23d5fb4138885187f44b16c2dc1c96a9d2fed2 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 4 Jun 2024 10:19:15 +0200 Subject: [PATCH 21/63] convert date with dash for export renaming --- .../concerns/tags_substitution_concern.rb | 3 ++- app/models/export_template.rb | 1 + spec/factories/export_template.rb | 20 +++++++++++++++++++ spec/models/export_template_spec.rb | 8 ++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 373ad3018..ae899dc04 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -307,7 +307,8 @@ module TagsSubstitutionConcern def format_date(date) if date.present? - date.strftime('%d/%m/%Y') + format = defined?(self.class::FORMAT_DATE) ? self.class::FORMAT_DATE : '%d/%m/%Y' + date.strftime(format) else '' end diff --git a/app/models/export_template.rb b/app/models/export_template.rb index febadd05a..550bb57cd 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -7,6 +7,7 @@ class ExportTemplate < ApplicationRecord validates_with ExportTemplateValidator DOSSIER_STATE = Dossier.states.fetch(:en_construction) + FORMAT_DATE = "%Y-%m-%d" def set_default_values content["default_dossier_directory"] = tiptap_json("dossier-") diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index d62754115..6785356af 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -64,5 +64,25 @@ FactoryBot.define do export_template.save end end + + trait :with_date_depot_for_export_pdf do + to_create do |export_template, _| + export_template.set_default_values + export_template.content["pdf_name"]["content"] = [ + { + "type" => "paragraph", + "content" => + [ + { "text" => "export_", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => "-", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_depose_at", "label" => "date de dépôt" } }, + { "text" => " ", "type" => "text" } + ] + } + ] + export_template.save + end + end end end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 443959bc1..d90de6744 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -190,6 +190,14 @@ describe ExportTemplate do it 'convert pdf_name' do expect(export_template.tiptap_convert(procedure.dossiers.first, "pdf_name")).to eq "mon_export_#{dossier.id}" end + + context 'for date' do + let(:export_template) { create(:export_template, :with_date_depot_for_export_pdf, groupe_instructeur:) } + let(:dossier) { create(:dossier, :en_construction, procedure:, depose_at: Date.parse("2024/03/30")) } + it 'convert date with dash' do + expect(export_template.tiptap_convert(dossier, "pdf_name")).to eq "export_#{dossier.id}-2024-03-30" + end + end end describe '#tiptap_convert_pj' do From bf3455bbf0ca9c778d6ad023d8a75c7649ac05d9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 4 Jun 2024 10:43:37 +0200 Subject: [PATCH 22/63] fix(api): public api v1 should not inherit from api v1 --- app/controllers/api/public/v1/base_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/public/v1/base_controller.rb b/app/controllers/api/public/v1/base_controller.rb index a60f9caeb..7353c83a3 100644 --- a/app/controllers/api/public/v1/base_controller.rb +++ b/app/controllers/api/public/v1/base_controller.rb @@ -1,8 +1,12 @@ -class API::Public::V1::BaseController < APIController +class API::Public::V1::BaseController < ApplicationController skip_forgery_protection before_action :check_content_type_is_json, if: -> { request.post? || request.patch? || request.put? } + before_action do + Current.browser = 'api' + end + protected def render_missing_param(param_name) From febcbf0d5a765b0fa822c694122698760fcf7a19 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Tue, 4 Jun 2024 13:45:59 +0200 Subject: [PATCH 23/63] using dsfr class for spacing --- app/assets/stylesheets/card_admin.scss | 4 -- .../procedures/new_from_existing.html.haml | 59 +++++++++---------- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/app/assets/stylesheets/card_admin.scss b/app/assets/stylesheets/card_admin.scss index 5001efce2..8f678d3aa 100644 --- a/app/assets/stylesheets/card_admin.scss +++ b/app/assets/stylesheets/card_admin.scss @@ -4,7 +4,3 @@ .fr-tile-subtitle { min-height: 7rem; } - -.card-welcome { - margin: 30px auto; -} diff --git a/app/views/administrateurs/procedures/new_from_existing.html.haml b/app/views/administrateurs/procedures/new_from_existing.html.haml index d5f4adf58..228004b1d 100644 --- a/app/views/administrateurs/procedures/new_from_existing.html.haml +++ b/app/views/administrateurs/procedures/new_from_existing.html.haml @@ -1,35 +1,34 @@ .container - if current_administrateur.procedures.brouillons.count == 0 - .card-welcome - = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line") do |c| - - c.with_html_body do - %p - Bienvenue, - %br - vous allez pouvoir créer une première démarche de test. - Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. - %br - %br - Besoin d’aide ? - %br - > Vous pouvez - = link_to "visionner cette vidéo", - "https://vimeo.com/261478872", - target: "_blank" - %br - > Vous pouvez lire notre - = link_to "documentation en ligne", - ADMINISTRATEUR_TUTORIAL_URL, - target: "_blank" - %br - > Vous pouvez enfin - = link_to "prendre un rendez-vous téléphonique avec nous", - CALENDLY_URL, - target: "_blank" - :javascript - document.addEventListener("DOMContentLoaded", function() { - $crisp.push(["do", "trigger:run", ["admin-signup"]]); - }); + = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line", extra_class_names: 'fr-my-4w') do |c| + - c.with_html_body do + %p + Bienvenue, + %br + vous allez pouvoir créer une première démarche de test. + Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. + %br + %br + Besoin d’aide ? + %br + > Vous pouvez + = link_to "visionner cette vidéo", + "https://vimeo.com/261478872", + target: "_blank" + %br + > Vous pouvez lire notre + = link_to "documentation en ligne", + ADMINISTRATEUR_TUTORIAL_URL, + target: "_blank" + %br + > Vous pouvez enfin + = link_to "prendre un rendez-vous téléphonique avec nous", + CALENDLY_URL, + target: "_blank" + :javascript + document.addEventListener("DOMContentLoaded", function() { + $crisp.push(["do", "trigger:run", ["admin-signup"]]); + }); .form From ec269a568c074a1446f8c363e98da42d2c585ce3 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 3 Jun 2024 22:38:23 +0200 Subject: [PATCH 24/63] fix(mailer): fix delivery prevented with bcc --- app/lib/balancer_delivery_method.rb | 1 + spec/lib/balancer_delivery_method_spec.rb | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/lib/balancer_delivery_method.rb b/app/lib/balancer_delivery_method.rb index 2ed33ae91..4ad5d5728 100644 --- a/app/lib/balancer_delivery_method.rb +++ b/app/lib/balancer_delivery_method.rb @@ -45,6 +45,7 @@ class BalancerDeliveryMethod def prevent_delivery?(mail) return false if mail[BYPASS_UNVERIFIED_MAIL_PROTECTION].present? + return false if mail.to.blank? # bcc list user = User.find_by(email: mail.to.first) return user.unverified_email? if user.present? diff --git a/spec/lib/balancer_delivery_method_spec.rb b/spec/lib/balancer_delivery_method_spec.rb index 659adb46e..92cb80082 100644 --- a/spec/lib/balancer_delivery_method_spec.rb +++ b/spec/lib/balancer_delivery_method_spec.rb @@ -2,8 +2,8 @@ RSpec.describe BalancerDeliveryMethod do class ExampleMailer < ApplicationMailer include BalancedDeliveryConcern - def greet(name, bypass_unverified_mail_protection: true) - mail(to: name, from: "smtp_from", body: "Hello #{name}") + def greet(name, bypass_unverified_mail_protection: true, **mail_args) + mail(to: name, from: "smtp_from", body: "Hello #{name}", **mail_args) bypass_unverified_mail_protection! if bypass_unverified_mail_protection end @@ -202,6 +202,13 @@ RSpec.describe BalancerDeliveryMethod do it { expect(mail).to have_been_delivered_using(MockSmtp) } end end + + context 'when there are only bcc recipients' do + let(:bypass_unverified_mail_protection) { false } + let(:mail) { ExampleMailer.greet(nil, bypass_unverified_mail_protection: false, bcc: ["'u@a.com'"]) } + + it { expect(mail).to have_been_delivered_using(MockSmtp) } + end end # Helpers From f98517852f76354e9eb4f8d35c373462c272a8e3 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 31 May 2024 08:20:45 +0200 Subject: [PATCH 25/63] feat(spec.procedure_administrateurs_spec): les navigation and cases --- .../procedure_administrateurs_spec.rb | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/spec/system/administrateurs/procedure_administrateurs_spec.rb b/spec/system/administrateurs/procedure_administrateurs_spec.rb index 420c439b3..b2111cd14 100644 --- a/spec/system/administrateurs/procedure_administrateurs_spec.rb +++ b/spec/system/administrateurs/procedure_administrateurs_spec.rb @@ -11,26 +11,17 @@ describe 'Administrateurs can manage administrateurs', js: true do login_as administrateur.user, scope: :user end - scenario 'card is clickable' do + scenario "card is clickable, and i can send invitation when i'm not a manager" do + another_administrateur = create(:administrateur) visit admin_procedure_path(procedure) find('#administrateurs').click expect(page).to have_css("h1", text: "Administrateurs") - end - context 'as admin not flagged from manager' do - let(:manager) { false } + fill_in('administrateur_email', with: another_administrateur.email) - scenario 'the administrator can add another administrator' do - another_administrateur = create(:administrateur) - visit admin_procedure_administrateurs_path(procedure) - - fill_in('administrateur_email', with: another_administrateur.email) - - click_on 'Ajouter comme administrateur' - - within('.alert-success') do - expect(page).to have_content(another_administrateur.email) - end + click_on 'Ajouter comme administrateur' + within('.alert-success') do + expect(page).to have_content(another_administrateur.email) end end From f14c88a54aee8bb8fefd3243ee876b8b40dc4a64 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 31 May 2024 09:41:31 +0200 Subject: [PATCH 26/63] clean(spec): speed, avoid using with_all_champs factory. takes too much time when not needed --- app/models/dossier.rb | 1 - .../procedures_controller_spec.rb | 22 +- .../users/dossiers_controller_spec.rb | 5 +- ...0220705164551_remove_unused_champs_spec.rb | 22 -- spec/models/dossier_spec.rb | 20 +- .../services/procedure_export_service_spec.rb | 361 +++++++----------- spec/system/accessibilite/wcag_usager_spec.rb | 91 +++-- 7 files changed, 209 insertions(+), 313 deletions(-) delete mode 100644 spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb diff --git a/app/models/dossier.rb b/app/models/dossier.rb index dbfce2464..123c7768c 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -1006,7 +1006,6 @@ class Dossier < ApplicationRecord else columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] end - if procedure.chorusable? && procedure.chorus_configuration.complete? columns += [ ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index e19155b82..0cb5bfa2d 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -16,24 +16,28 @@ describe Administrateurs::ProceduresController, type: :controller do let(:tags) { "[\"planete\",\"environnement\"]" } describe '#apercu' do - render_views - - let(:procedure) { create(:procedure, :with_all_champs) } - subject { get :apercu, params: { id: procedure.id } } before do sign_in(admin.user) end - it do - subject - expect(response).to have_http_status(:ok) - expect(procedure.dossiers.visible_by_user).to be_empty - expect(procedure.dossiers.for_procedure_preview).not_to be_empty + context 'all tdc can be rendered' do + render_views + + let(:procedure) { create(:procedure, :with_all_champs) } + + it do + subject + expect(response).to have_http_status(:ok) + expect(procedure.dossiers.visible_by_user).to be_empty + expect(procedure.dossiers.for_procedure_preview).not_to be_empty + end end context 'when the draft is invalid' do + let(:procedure) { create(:procedure) } + before do allow_any_instance_of(ProcedureRevision).to receive(:invalid?).and_return(true) end diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 8d3b702f9..220bd6722 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -1489,15 +1489,18 @@ describe Users::DossiersController, type: :controller do end describe '#clone' do - let(:procedure) { create(:procedure, :with_all_champs) } let(:dossier) { create(:dossier, procedure: procedure) } subject { post :clone, params: { id: dossier.id } } context 'not signed in' do + let(:procedure) { create(:procedure) } + it { expect(subject).to redirect_to(new_user_session_path) } end context 'signed with user dossier' do + let(:procedure) { create(:procedure, :with_all_champs) } + before { sign_in dossier.user } it { expect(subject).to redirect_to(brouillon_dossier_path(Dossier.last)) } diff --git a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb b/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb deleted file mode 100644 index 1a4fdf386..000000000 --- a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -describe '20220705164551_remove_unused_champs' do - let(:rake_task) { Rake::Task['after_party:remove_unused_champs'] } - let(:procedure) { create(:procedure, :with_all_champs) } - let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } - let(:champ_repetition) { dossier.champs_public.find(&:repetition?) } - - subject(:run_task) do - dossier - rake_task.invoke - end - - before { champ_repetition.champs.first.update(type_de_champ: create(:type_de_champ)) } - after { rake_task.reenable } - - describe 'remove_unused_champs' do - it "with bad champs" do - expect(Champ.where(dossier: dossier).count).to eq(44) - run_task - expect(Champ.where(dossier: dossier).count).to eq(43) - end - end -end diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index f52242650..92f0b0944 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -2112,25 +2112,19 @@ describe Dossier, type: :model do create(:attestation, dossier: dossier) end - it "can destroy dossier" do + it "can destroy dossier, reset demarche, logg context" do + json_message = nil + allow(Rails.logger).to receive(:info) { json_message ||= _1 } + expect(dossier.destroy).to be_truthy expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "can reset demarche" do - expect { dossier.procedure.reset! }.not_to raise_error - expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "call logger with context" do - json_message = nil - - allow(Rails.logger).to receive(:info) { json_message ||= _1 } - dossier.destroy expect(JSON.parse(json_message)).to a_hash_including( { message: "Dossier destroyed", dossier_id: dossier.id, procedure_id: procedure.id }.stringify_keys ) + + expect { dossier.procedure.reset! }.not_to raise_error + expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index 0c03cfd98..30488f846 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -2,7 +2,6 @@ require 'csv' describe ProcedureExportService do let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } let(:export_template) { nil } @@ -18,159 +17,166 @@ describe ProcedureExportService do let(:avis_sheet) { subject.sheets.third } let(:repetition_sheet) { subject.sheets.fourth } - before do - # change one tdc place to check if the header is ordered - tdc_first = procedure.active_revision.revision_types_de_champ_public.first - tdc_last = procedure.active_revision.revision_types_de_champ_public.last - - tdc_first.update(position: tdc_last.position + 1) - procedure.reload - end - describe 'sheets' do + let(:procedure) { create(:procedure) } + it 'should have a sheet for each record type' do expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis']) end end describe 'Dossiers sheet' do - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + context 'with all data for individual' do + let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } - let(:nominal_headers) do - [ - "ID", - "Email", - "FranceConnect ?", - "Civilité", - "Nom", - "Prénom", - "Dépôt pour un tiers", - "Nom du mandataire", - "Prénom du mandataire", - "Archivé", - "État du dossier", - "Dernière mise à jour le", - "Dernière mise à jour du dossier le", - "Déposé le", - "Passé en instruction le", - "Traité le", - "Motivation de la décision", - "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" - ] - end + # before do + # # change one tdc place to check if the header is ordered + # tdc_first = procedure.active_revision.revision_types_de_champ_public.first + # tdc_last = procedure.active_revision.revision_types_de_champ_public.last - it 'should have headers' do - expect(dossiers_sheet.headers).to match_array(nominal_headers) - end + # tdc_first.update(position: tdc_last.position + 1) + # procedure.reload + # end - it 'should have data' do - expect(dossiers_sheet.data.size).to eq(1) - expect(etablissements_sheet.data.size).to eq(1) + let(:nominal_headers) do + [ + "ID", + "Email", + "FranceConnect ?", + "Civilité", + "Nom", + "Prénom", + "Dépôt pour un tiers", + "Nom du mandataire", + "Prénom du mandataire", + "Archivé", + "État du dossier", + "Dernière mise à jour le", + "Dernière mise à jour du dossier le", + "Déposé le", + "Passé en instruction le", + "Traité le", + "Motivation de la décision", + "Instructeurs", + "textarea", + "date", + "datetime", + "number", + "decimal_number", + "integer_number", + "checkbox", + "civilite", + "email", + "phone", + "address", + "simple_drop_down_list", + "multiple_drop_down_list", + "linked_drop_down_list", + "communes", + "communes (Code INSEE)", + "communes (Département)", + "departements", + "departements (Code)", + "regions", + "regions (Code)", + "pays", + "pays (Code)", + "dossier_link", + "piece_justificative", + "rna", + "carte", + "titre_identite", + "iban", + "siret", + "annuaire_education", + "cnaf", + "dgfip", + "pole_emploi", + "mesri", + "text", + "epci", + "epci (Code)", + "epci (Département)", + "cojo", + "expression_reguliere", + "rnf", + "rnf (Nom)", + "rnf (Adresse)", + "rnf (Code INSEE Ville)", + "rnf (Département)", + "engagement_juridique", + "yes_no" + ] + end - # SimpleXlsxReader is transforming datetimes in utc... It is only used in test so we just hack around. - offset = dossier.depose_at.utc_offset - depose_at = Time.zone.at(dossiers_sheet.data[0][13] - offset.seconds) - en_instruction_at = Time.zone.at(dossiers_sheet.data[0][14] - offset.seconds) - expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) - expect(depose_at).to eq(dossier.depose_at.round) - expect(en_instruction_at).to eq(dossier.en_instruction_at.round) + it 'should have data' do + expect(dossiers_sheet.headers).to match_array(nominal_headers) + + expect(dossiers_sheet.data.size).to eq(1) + expect(etablissements_sheet.data.size).to eq(1) + + # SimpleXlsxReader is transforming datetimes in utc... It is only used in test so we just hack around. + offset = dossier.depose_at.utc_offset + depose_at = Time.zone.at(dossiers_sheet.data[0][13] - offset.seconds) + en_instruction_at = Time.zone.at(dossiers_sheet.data[0][14] - offset.seconds) + expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) + expect(depose_at).to eq(dossier.depose_at.round) + expect(en_instruction_at).to eq(dossier.en_instruction_at.round) + end end context 'with a birthdate' do - before { procedure.update(ask_birthday: true) } + let(:procedure) { create(:procedure, :published, :for_individual, ask_birthday: true) } + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } - let(:birthdate_headers) { nominal_headers.insert(nominal_headers.index('Archivé'), 'Date de naissance') } - - it { expect(dossiers_sheet.headers).to match_array(birthdate_headers) } - it { expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Date de naissance')]).to be_a(Date) } + it 'find date de naissance' do + expect(dossiers_sheet.headers).to include('Date de naissance') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Date de naissance')]).to be_a(Date) + end end context 'with a procedure routee' do - before { create(:groupe_instructeur, label: '2', procedure: procedure) } + let(:procedure) { create(:procedure, :published, :for_individual) } + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } + before { create(:groupe_instructeur, label: '2', procedure:) } - let(:routee_headers) { nominal_headers.insert(nominal_headers.index('textarea'), 'Groupe instructeur') } - - it { expect(dossiers_sheet.headers).to match_array(routee_headers) } - it { expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') } + it 'find groupe instructeur data' do + expect(dossiers_sheet.headers).to include('Groupe instructeur') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') + end end context 'with a dossier having multiple pjs' do - let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :piece_justificative }] } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } before do dossier_2.champs_public .find { _1.is_a? Champs::PieceJustificativeChamp } .piece_justificative_file .attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") end - it { expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) } + it { expect(dossiers_sheet.data.first.size).to eq(19) } # default number of header when procedure has only one champ end context 'with procedure chorus' do - let(:procedure) { create(:procedure, :published, :for_individual, :filled_chorus, :with_all_champs) } - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, procedure: procedure) } + before { expect_any_instance_of(Procedure).to receive(:chorusable?).and_return(true) } + let(:procedure) { create(:procedure, :published, :for_individual, :filled_chorus) } + let!(:dossier) { create(:dossier, :en_instruction, procedure: procedure) } it 'includes chorus headers' do - expected_headers = [ - 'Domaine Fonctionnel', - 'Référentiel De Programmation', - 'Centre De Coup' - ] - - expect(dossiers_sheet.headers).to match_array(nominal_headers) + expect(dossiers_sheet.headers).to include('Domaine Fonctionnel') + expect(dossiers_sheet.headers).to include('Référentiel De Programmation') + expect(dossiers_sheet.headers).to include('Centre De Coût') end end end describe 'Etablissement sheet' do - let(:procedure) { create(:procedure, :published, :with_all_champs) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :siret, libelle: 'siret' }] } let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_entreprise, procedure: procedure) } let(:dossier_etablissement) { etablissements_sheet.data[1] } @@ -191,54 +197,7 @@ describe ProcedureExportService do "Traité le", "Motivation de la décision", "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" + 'siret' ] end @@ -294,54 +253,7 @@ describe ProcedureExportService do "Traité le", "Motivation de la décision", "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" + 'siret' ] end @@ -352,7 +264,7 @@ describe ProcedureExportService do end end - it 'should have headers' do + it 'should have headers and data' do expect(dossiers_sheet.headers).to match_array(nominal_headers) expect(etablissements_sheet.headers).to eq([ @@ -391,9 +303,7 @@ describe ProcedureExportService do "Association date de déclaration", "Association date de publication" ]) - end - it 'should have data' do expect(etablissements_sheet.data.size).to eq(2) expect(dossier_etablissement[1]).to eq("Dossier") expect(champ_etablissement[1]).to eq("siret") @@ -401,10 +311,11 @@ describe ProcedureExportService do end describe 'Avis sheet' do + let(:procedure) { create(:procedure, :published, :for_individual) } let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } let!(:avis) { create(:avis, :with_answer, dossier: dossier) } - it 'should have headers' do + it 'should have headers and data' do expect(avis_sheet.headers).to eq([ "Dossier ID", "Introduction", @@ -416,14 +327,20 @@ describe ProcedureExportService do "Instructeur", "Expert" ]) - end - - it 'should have data' do expect(avis_sheet.data.size).to eq(1) end end describe 'Repetitions sheet' do + before do + # change one tdc place to check if the header is ordered + tdc_first = procedure.active_revision.revision_types_de_champ_public.first + tdc_last = procedure.active_revision.revision_types_de_champ_public.last + + tdc_first.update(position: tdc_last.position + 1) + procedure.reload + end + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public: [{ type: :repetition, children: [{ libelle: 'Nom' }, { libelle: 'Age' }] }]) } let!(:dossiers) do [ @@ -509,6 +426,9 @@ describe ProcedureExportService do end describe 'to_zip' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :piece_justificative, libelle: 'piece_justificative' }] } + subject { service.to_zip } context 'without files' do it 'does not raises in_batches' do @@ -588,6 +508,9 @@ describe ProcedureExportService do end describe 'to_geo_json' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :carte }] } + subject do service .to_geo_json diff --git a/spec/system/accessibilite/wcag_usager_spec.rb b/spec/system/accessibilite/wcag_usager_spec.rb index d8ad6fd10..16e6b23c2 100644 --- a/spec/system/accessibilite/wcag_usager_spec.rb +++ b/spec/system/accessibilite/wcag_usager_spec.rb @@ -1,33 +1,25 @@ describe 'wcag rules for usager', js: true do - let(:procedure) { create(:procedure, :published, :with_all_champs, :with_service, :for_individual) } + let(:procedure) { create(:procedure, :published, :with_service, :for_individual) } let(:password) { 'a very complicated password' } let(:litteraire_user) { create(:user, password: password) } - before do - procedure.active_revision.types_de_champ_public.find { |tdc| tdc.type_champ == TypeDeChamp.type_champs.fetch(:carte) }.destroy - end + def test_external_links_have_title_says_it_opens_in_a_new_tab + links = page.all("a[target=_blank]") + expect(links.count).to be_positive - shared_examples "external links have title says it opens in a new tab" do - it do - links = page.all("a[target=_blank]") - expect(links.count).to be_positive - - links.each do |link| - expect(link[:title]).to include("Nouvel onglet"), "link #{link[:href]} does not have title mentioning it opens in a new tab" - end + links.each do |link| + expect(link[:title]).to include("Nouvel onglet"), "link #{link[:href]} does not have title mentioning it opens in a new tab" end end - shared_examples "aria-label do not mix with title attribute" do - it do - elements = page.all("[aria-label][title]") - elements.each do |element| - expect(element[:title]).to be_blank, "path=#{path}, element title=\"#{element[:title]}\" mixes aria-label and title attributes" - end + def test_aria_label_do_not_mix_with_title_attribute + elements = page.all("[aria-label][title]") + elements.each do |element| + expect(element[:title]).to be_blank, "path=#{path}, element title=\"#{element[:title]}\" mixes aria-label and title attributes" end end - def expect_axe_clean_without_main_navigation + def test_expect_axe_clean_without_main_navigation # On page without main navigation content (like anonymous home page), # there are either a bug in axe, either dsfr markup is not conform to wcag2a. # There is no issue on pages having a child navigation. @@ -35,10 +27,6 @@ describe 'wcag rules for usager', js: true do expect(page).to be_axe_clean.within("#modal-header__menu").skipping("aria-prohibited-attr") end - shared_examples "axe clean without main navigation" do - it { expect_axe_clean_without_main_navigation } - end - context 'pages without the need to be logged in' do before do visit path @@ -46,16 +34,20 @@ describe 'wcag rules for usager', js: true do context 'homepage' do let(:path) { root_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'sign_up page' do let(:path) { new_user_registration_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end scenario 'account confirmation page' do @@ -66,43 +58,51 @@ describe 'wcag rules for usager', js: true do perform_enqueued_jobs do click_button 'Créer un compte' - expect_axe_clean_without_main_navigation + test_expect_axe_clean_without_main_navigation end end context 'sign_up confirmation' do let(:path) { user_confirmation_path("user[email]" => "some@email.com") } - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + end end context 'sign_in page' do let(:path) { new_user_session_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'contact page' do let(:path) { contact_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'commencer page' do let(:path) { commencer_path(path: procedure.path) } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end scenario 'commencer page, help dropdown' do visit commencer_path(path: procedure.reload.path) page.find("#help-menu_button").click - expect_axe_clean_without_main_navigation + test_expect_axe_clean_without_main_navigation end end @@ -135,7 +135,7 @@ describe 'wcag rules for usager', js: true do end context "logged in, depot d'un dossier entreprise" do - let(:procedure) { create(:procedure, :with_all_champs, :with_service, :published) } + let(:procedure) { create(:procedure, :with_service, :published) } before do login_as litteraire_user, scope: :user @@ -163,11 +163,6 @@ describe 'wcag rules for usager', js: true do dossier visit dossiers_path expect(page).to be_axe_clean - end - - scenario 'liste des dossiers et actions sur le dossier' do - dossier - visit dossiers_path page.find("#actions_menu_dossier_#{dossier.id}_button").click expect(page).to be_axe_clean end From 9753a91db63d6fba6e01a987bda6721de500ae4c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 4 Jun 2024 17:40:48 +0200 Subject: [PATCH 27/63] feat(graphql): messages can be discarded through api --- app/graphql/api/v2/stored_query.rb | 13 +++ .../dossier_passer_en_instruction.rb | 3 + .../mutations/dossier_supprimer_message.rb | 24 ++++++ app/graphql/schema.graphql | 35 ++++++++ app/graphql/types/message_type.rb | 5 ++ app/graphql/types/mutation_type.rb | 1 + app/models/dossier.rb | 6 +- .../graphql_controller_stored_queries_spec.rb | 83 +++++++++++++++++++ 8 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 app/graphql/mutations/dossier_supprimer_message.rb diff --git a/app/graphql/api/v2/stored_query.rb b/app/graphql/api/v2/stored_query.rb index e2b5c78c0..1c48a5bd1 100644 --- a/app/graphql/api/v2/stored_query.rb +++ b/app/graphql/api/v2/stored_query.rb @@ -819,6 +819,19 @@ class API::V2::StoredQuery } } + mutation dossierSupprimerMessage($input: DossierSupprimerMessageInput!) { + dossierSupprimerMessage(input: $input) { + message { + id + createdAt + discardedAt + } + errors { + message + } + } + } + mutation dossierModifierAnnotationText( $input: DossierModifierAnnotationTextInput! ) { diff --git a/app/graphql/mutations/dossier_passer_en_instruction.rb b/app/graphql/mutations/dossier_passer_en_instruction.rb index 1ece00ede..d9886b724 100644 --- a/app/graphql/mutations/dossier_passer_en_instruction.rb +++ b/app/graphql/mutations/dossier_passer_en_instruction.rb @@ -21,6 +21,9 @@ module Mutations if !dossier.en_construction? return false, { errors: ["Le dossier est déjà #{dossier_display_state(dossier, lower: true)}"] } end + if dossier.blocked_with_pending_correction? + return false, { errors: ["Le dossier est en attente de correction"] } + end dossier_authorized_for?(dossier, instructeur) end end diff --git a/app/graphql/mutations/dossier_supprimer_message.rb b/app/graphql/mutations/dossier_supprimer_message.rb new file mode 100644 index 000000000..bde6de51f --- /dev/null +++ b/app/graphql/mutations/dossier_supprimer_message.rb @@ -0,0 +1,24 @@ +module Mutations + class DossierSupprimerMessage < Mutations::BaseMutation + description "Supprimer un message." + + argument :message_id, ID, required: true, loads: Types::MessageType + argument :instructeur_id, ID, required: true, loads: Types::ProfileType + + field :message, Types::MessageType, null: true + field :errors, [Types::ValidationErrorType], null: true + + def resolve(message:, **args) + message.soft_delete! + + { message: } + end + + def authorized?(message:, instructeur:, **args) + if !message.soft_deletable?(instructeur) + return false, { errors: ["Le message ne peut pas être supprimé"] } + end + dossier_authorized_for?(message.dossier, instructeur) + end + end +end diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 52d58d1ad..c422408fe 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -2192,6 +2192,30 @@ enum DossierState { sans_suite } +""" +Autogenerated input type of DossierSupprimerMessage +""" +input DossierSupprimerMessageInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + instructeurId: ID! + messageId: ID! +} + +""" +Autogenerated return type of DossierSupprimerMessage. +""" +type DossierSupprimerMessagePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + errors: [ValidationError!] + message: Message +} + type DropDownListChampDescriptor implements ChampDescriptor { """ Description des champs d’un bloc répétable. @@ -3084,6 +3108,7 @@ type Message { body: String! correction: Correction createdAt: ISO8601DateTime! + discardedAt: ISO8601DateTime email: String! id: ID! } @@ -3313,6 +3338,16 @@ type Mutation { input: DossierRepasserEnInstructionInput! ): DossierRepasserEnInstructionPayload + """ + Supprimer un message. + """ + dossierSupprimerMessage( + """ + Parameters for DossierSupprimerMessage + """ + input: DossierSupprimerMessageInput! + ): DossierSupprimerMessagePayload + """ Ajouter des instructeurs à un groupe instructeur. """ diff --git a/app/graphql/types/message_type.rb b/app/graphql/types/message_type.rb index 70622647b..788075bb2 100644 --- a/app/graphql/types/message_type.rb +++ b/app/graphql/types/message_type.rb @@ -4,6 +4,7 @@ module Types field :email, String, null: false field :body, String, null: false field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :discarded_at, GraphQL::Types::ISO8601DateTime, null: true field :attachment, Types::File, null: true, deprecation_reason: "Utilisez le champ `attachments` à la place.", extensions: [ { Extensions::Attachment => { attachments: :piece_jointe, as: :single } } ] @@ -19,5 +20,9 @@ module Types def correction Loaders::Association.for(object.class, :dossier_correction).load(object) end + + def self.authorized?(object, context) + context.authorized_demarche?(object.dossier.revision.procedure) + end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ce2f8ac56..b9ad5a92c 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -3,6 +3,7 @@ module Types field :create_direct_upload, mutation: Mutations::CreateDirectUpload field :dossier_envoyer_message, mutation: Mutations::DossierEnvoyerMessage + field :dossier_supprimer_message, mutation: Mutations::DossierSupprimerMessage field :dossier_passer_en_instruction, mutation: Mutations::DossierPasserEnInstruction field :dossier_classer_sans_suite, mutation: Mutations::DossierClasserSansSuite field :dossier_refuser, mutation: Mutations::DossierRefuser diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 123c7768c..54c187169 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -558,8 +558,12 @@ class Dossier < ApplicationRecord false end + def blocked_with_pending_correction? + procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? + end + def can_passer_en_instruction? - return false if procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? + return false if blocked_with_pending_correction? true end diff --git a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb index 6fa77c40b..a42d90210 100644 --- a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb @@ -912,6 +912,17 @@ describe API::V2::GraphqlController do expect(ActionMailer::Base.deliveries.size).to eq(0) } end + + context 'with pending corrections' do + before { Flipper.enable(:blocking_pending_correction, dossier.procedure) } + let!(:dossier_correction) { create(:dossier_correction, dossier:) } + + it { + expect(dossier.pending_correction?).to be_truthy + expect(gql_errors).to be_nil + expect(gql_data[:dossierPasserEnInstruction][:errors]).to eq([{ message: "Le dossier est en attente de correction" }]) + } + end end context 'dossierRepasserEnConstruction' do @@ -1332,5 +1343,77 @@ describe API::V2::GraphqlController do } end end + + context 'dossierEnvoyerMessage' do + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure:) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id, body: 'Hello World!' } } } + let(:operation_name) { 'dossierEnvoyerMessage' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierEnvoyerMessage][:errors]).to be_nil + expect(gql_data[:dossierEnvoyerMessage][:message][:id]).to eq(dossier.commentaires.first.to_typed_id) + perform_enqueued_jobs + expect(ActionMailer::Base.deliveries.size).to eq(1) + } + end + + context 'dossierSupprimerMessage' do + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure:) } + let(:message) { create(:commentaire, dossier:, instructeur:) } + let(:dossier_correction) { create(:dossier_correction, dossier:, commentaire: message) } + let(:variables) { { input: { messageId: message.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierSupprimerMessage' } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to be_nil + expect(gql_data[:dossierSupprimerMessage][:message][:id]).to eq(message.to_typed_id) + expect(gql_data[:dossierSupprimerMessage][:message][:discardedAt]).not_to be_nil + expect(message.reload.discarded?).to be_truthy + } + + it { + expect(dossier_correction.commentaire.discarded?).to be_falsey + expect(dossier.pending_correction?).to be_truthy + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to be_nil + expect(gql_data[:dossierSupprimerMessage][:message][:id]).to eq(message.to_typed_id) + expect(gql_data[:dossierSupprimerMessage][:message][:discardedAt]).not_to be_nil + expect(message.reload.discarded?).to be_truthy + expect(dossier.pending_correction?).to be_falsey + } + + context 'when unauthorized' do + let(:dossier) { create(:dossier, :en_construction, :with_individual) } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors.first[:message]).to eq("An object of type Message was hidden due to permissions") + } + end + + context 'when from not the same instructeur' do + let(:other_instructeur) { create(:instructeur, followed_dossiers: dossiers) } + let(:variables) { { input: { messageId: message.to_typed_id, instructeurId: other_instructeur.to_typed_id } } } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to eq([{ message: "Le message ne peut pas être supprimé" }]) + } + end + + context 'when from usager' do + let(:message) { create(:commentaire, dossier:) } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to eq([{ message: "Le message ne peut pas être supprimé" }]) + } + end + end end end From ef3ca9839ba0ec99876967ce78d3254ec89ec34e Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 3 Jun 2024 07:44:27 +0200 Subject: [PATCH 28/63] feat(procedure.validation): extract validation context: types_de_champ_public_editor, types_de_champ_private_editor and publication [combining both contextes]. validate conditions, headers_sections, regexp on type_de_champ_private too. dry validation --- .../procedure/card/champs_component.rb | 5 +- app/components/procedure/errors_summary.rb | 48 +++++++++++ .../errors_summary/errors_summary.html.haml | 9 +++ .../publication_warning_component.rb | 39 --------- .../publication_warning_component.html.haml | 7 -- .../champ_component/champ_component.html.haml | 2 +- .../types_de_champ_editor/editor_component.rb | 8 ++ .../editor_component.html.haml | 2 +- .../types_de_champ_editor/errors_summary.rb | 38 --------- .../errors_summary/errors_summary.fr.yml | 12 --- .../errors_summary/errors_summary.html.haml | 15 ---- .../header_section_component.rb | 2 +- .../header_section_component.html.haml | 2 +- app/models/procedure.rb | 10 ++- app/models/procedure_revision.rb | 48 ----------- app/models/type_de_champ.rb | 8 +- .../types_de_champ/condition_validator.rb | 21 +++++ .../expression_reguliere_validator.rb | 11 +++ .../header_section_consistency_validator.rb | 29 +++++++ .../no_empty_block_validator.rb | 3 +- .../no_empty_drop_down_validator.rb | 3 +- .../conditions/_update.turbo_stream.haml | 2 +- .../procedures/_publication_form.html.haml | 2 +- .../procedures/champs.html.haml | 2 +- .../administrateurs/procedures/show.html.haml | 13 ++- .../types_de_champ/_insert.turbo_stream.haml | 4 +- config/locales/fr.yml | 2 - config/locales/models/procedure/en.yml | 22 +++++ config/locales/models/procedure/fr.yml | 22 +++++ config/locales/models/type_de_champ/fr.yml | 2 +- .../card/annotations_component_spec.rb | 30 +++++++ .../procedures/card/champs_component_spec.rb | 30 +++++++ .../procedures/errors_summary_spec.rb | 80 +++++++++++++++++++ .../editor_component_spec.rb | 26 ++++++ spec/models/procedure_revision_spec.rb | 35 ++++---- .../administrateurs/procedure_publish_spec.rb | 21 ++--- .../administrateurs/types_de_champ_spec.rb | 4 +- 37 files changed, 398 insertions(+), 221 deletions(-) create mode 100644 app/components/procedure/errors_summary.rb create mode 100644 app/components/procedure/errors_summary/errors_summary.html.haml delete mode 100644 app/components/procedure/publication_warning_component.rb delete mode 100644 app/components/procedure/publication_warning_component/publication_warning_component.html.haml delete mode 100644 app/components/types_de_champ_editor/errors_summary.rb delete mode 100644 app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml delete mode 100644 app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml create mode 100644 app/validators/types_de_champ/condition_validator.rb create mode 100644 app/validators/types_de_champ/expression_reguliere_validator.rb create mode 100644 app/validators/types_de_champ/header_section_consistency_validator.rb create mode 100644 spec/components/procedures/card/annotations_component_spec.rb create mode 100644 spec/components/procedures/card/champs_component_spec.rb create mode 100644 spec/components/procedures/errors_summary_spec.rb create mode 100644 spec/components/types_de_champ_editor/editor_component_spec.rb diff --git a/app/components/procedure/card/champs_component.rb b/app/components/procedure/card/champs_component.rb index d45b2f666..38604c831 100644 --- a/app/components/procedure/card/champs_component.rb +++ b/app/components/procedure/card/champs_component.rb @@ -7,9 +7,6 @@ class Procedure::Card::ChampsComponent < ApplicationComponent private def error_messages - [ - @procedure.errors.messages_for(:draft_types_de_champ_public), - @procedure.errors.messages_for(:draft_revision) - ].flatten.to_sentence + @procedure.errors.messages_for(:draft_types_de_champ_public).to_sentence end end diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb new file mode 100644 index 000000000..adce8fd64 --- /dev/null +++ b/app/components/procedure/errors_summary.rb @@ -0,0 +1,48 @@ +class Procedure::ErrorsSummary < ApplicationComponent + def initialize(procedure:, validation_context:) + @procedure = procedure + @validation_context = validation_context + end + + def title + case @validation_context + when :types_de_champ_private_editor + "Les annotations privées contiennent des erreurs" + when :types_de_champ_public_editor + "Les champs formulaire contiennent des erreurs" + when :publication + if @procedure.publiee? + "Des problèmes empêchent la publication des modifications" + else + "Des problèmes empêchent la publication de la démarche" + end + end + end + + def invalid? + @procedure.validate(@validation_context) + @procedure.errors.present? + end + + def error_messages + @procedure.errors.map do |error| + [error, error_correction_page(error)] + end + end + + def error_correction_page(error) + case error.attribute + when :draft_types_de_champ_public + tdc = error.options[:type_de_champ] + champs_admin_procedure_path(@procedure, anchor: dom_id(tdc.stable_self, :editor_error)) + when :draft_types_de_champ_private + tdc = error.options[:type_de_champ] + annotations_admin_procedure_path(@procedure, anchor: dom_id(tdc.stable_self, :editor_error)) + when :attestation_template + edit_admin_procedure_attestation_template_path(@procedure) + when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail + klass = "Mails::#{error.attribute.to_s.classify}".constantize + edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) + end + end +end diff --git a/app/components/procedure/errors_summary/errors_summary.html.haml b/app/components/procedure/errors_summary/errors_summary.html.haml new file mode 100644 index 000000000..72780bcd1 --- /dev/null +++ b/app/components/procedure/errors_summary/errors_summary.html.haml @@ -0,0 +1,9 @@ +#errors-summary + - if invalid? + = render Dsfr::AlertComponent.new(state: :error, title: , extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + - error_messages.each do |(error, path)| + %p.mt-2 + = error.full_message + - if path.present? + = "(#{link_to 'corriger', path, class: 'fr-link'})" diff --git a/app/components/procedure/publication_warning_component.rb b/app/components/procedure/publication_warning_component.rb deleted file mode 100644 index 7af808c3d..000000000 --- a/app/components/procedure/publication_warning_component.rb +++ /dev/null @@ -1,39 +0,0 @@ -class Procedure::PublicationWarningComponent < ApplicationComponent - def initialize(procedure:) - @procedure = procedure - end - - def title - return "Des problèmes empêchent la publication des modifications" if @procedure.publiee? - - "Des problèmes empêchent la publication de la démarche" - end - - private - - def render? - @procedure.validate(:publication) - @procedure.errors.delete(:path) - @procedure.errors.any? - end - - def error_messages - @procedure.errors - .to_hash(full_messages: true) - .map do |attribute, messages| - [messages, error_correction_page(attribute)] - end - end - - def error_correction_page(attribute) - case attribute - when :draft_revision - champs_admin_procedure_path(@procedure) - when :attestation_template - edit_admin_procedure_attestation_template_path(@procedure) - when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail - klass = "Mails::#{attribute.to_s.classify}".constantize - edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) - end - end -end diff --git a/app/components/procedure/publication_warning_component/publication_warning_component.html.haml b/app/components/procedure/publication_warning_component/publication_warning_component.html.haml deleted file mode 100644 index 7d8ff43b7..000000000 --- a/app/components/procedure/publication_warning_component/publication_warning_component.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -= render Dsfr::AlertComponent.new(state: :warning, title:) do |c| - - c.with_body do - - error_messages.each do |(messages, path)| - %p.mt-2 - = messages.to_sentence - - if path.present? - = "(#{link_to 'corriger', path, class: 'fr-link'})" diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 980bd064c..60cf82102 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -1,5 +1,5 @@ %li.type-de-champ.flex.column.justify-start.fr-mb-5v{ html_options } - .type-de-champ-container + .type-de-champ-container{ id: dom_id(type_de_champ.stable_self, :editor_error) } - if @errors.present? .types-de-champ-errors = @errors diff --git a/app/components/types_de_champ_editor/editor_component.rb b/app/components/types_de_champ_editor/editor_component.rb index 0ea2c76cb..288a2c668 100644 --- a/app/components/types_de_champ_editor/editor_component.rb +++ b/app/components/types_de_champ_editor/editor_component.rb @@ -17,4 +17,12 @@ class TypesDeChampEditor::EditorComponent < ApplicationComponent @revision.revision_types_de_champ_public end end + + def validation_context + if annotations? + :types_de_champ_private_editor + else + :types_de_champ_public_editor + end + end end diff --git a/app/components/types_de_champ_editor/editor_component/editor_component.html.haml b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml index 71df85e46..d161d0ba2 100644 --- a/app/components/types_de_champ_editor/editor_component/editor_component.html.haml +++ b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml @@ -1,6 +1,6 @@ .fr-pb-12w{ 'data-turbo': 'true', id: dom_id(@revision, :types_de_champ_editor) } .types-de-champ-editor.editor-root - = render TypesDeChampEditor::ErrorsSummary.new(revision: @revision) + = render Procedure::ErrorsSummary.new(procedure: @revision.procedure, validation_context:) = render TypesDeChampEditor::BlockComponent.new(block: @revision, coordinates: coordinates) #empty-coordinates{ hidden: coordinates.present? } = render TypesDeChampEditor::AddChampButtonComponent.new(revision: @revision, is_annotation: annotations?) diff --git a/app/components/types_de_champ_editor/errors_summary.rb b/app/components/types_de_champ_editor/errors_summary.rb deleted file mode 100644 index b5367eaf1..000000000 --- a/app/components/types_de_champ_editor/errors_summary.rb +++ /dev/null @@ -1,38 +0,0 @@ -class TypesDeChampEditor::ErrorsSummary < ApplicationComponent - def initialize(revision:) - @revision = revision - end - - def invalid? - @revision.invalid? - end - - def condition_errors? - @revision.errors.include?(:condition) - end - - def header_section_errors? - @revision.errors.include?(:header_section) - end - - def expression_reguliere_errors? - @revision.errors.include?(:expression_reguliere) - end - - private - - def errors_for(key) - @revision.errors.filter { _1.attribute == key } - end - - def error_message_for(key) - errors_for(key) - .map { |error| error.options[:type_de_champ] } - .map { |tdc| tag.li(tdc_anchor(tdc, key)) } - .then { |lis| tag.ul(lis.reduce(&:+)) } - end - - def tdc_anchor(tdc, key) - tag.a(tdc.libelle, href: champs_admin_procedure_path(@revision.procedure_id, anchor: dom_id(tdc.stable_self, key)), data: { turbo: false }) - end -end diff --git a/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml b/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml deleted file mode 100644 index 08d594931..000000000 --- a/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml +++ /dev/null @@ -1,12 +0,0 @@ -fr: - fix_conditional: - one: 'La logique conditionnelle du champ suivant est invalide, veuillez la corriger :' - other: 'La logique conditionnelle des champs suivants sont invalides, veuillez les corriger :' - - fix_header_section: - one: 'Le titre de section suivant est invalide, veuillez le corriger :' - other: 'Les titres de section suivants sont invalides, veuillez les corriger :' - - fix_expressions_regulieres: - one: "L'expression régulière suivante est invalide, veuillez la corriger :" - other: 'Les expressions régulières suivantes sont invalides, veuillez les corriger :' diff --git a/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml b/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml deleted file mode 100644 index 46f599ce5..000000000 --- a/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -#errors-summary - - if invalid? - = render Dsfr::AlertComponent.new(state: :warning, title: "Le formulaire contient des erreurs", extra_class_names: 'fr-mb-2w') do |c| - - c.with_body do - - if condition_errors? - %p= t('.fix_conditional', count: errors_for(:condition).size) - = error_message_for(:condition) - - - if header_section_errors? - %p= t('.fix_header_section', count: errors_for(:header_section).size) - = error_message_for(:header_section) - - - if expression_reguliere_errors? - %p= t('.fix_expressions_regulieres', count: errors_for(:expression_reguliere).size) - = error_message_for(:expression_reguliere) diff --git a/app/components/types_de_champ_editor/header_section_component.rb b/app/components/types_de_champ_editor/header_section_component.rb index 80271b8e1..39f372dd8 100644 --- a/app/components/types_de_champ_editor/header_section_component.rb +++ b/app/components/types_de_champ_editor/header_section_component.rb @@ -31,7 +31,7 @@ class TypesDeChampEditor::HeaderSectionComponent < ApplicationComponent end def errors? - !errors.empty? + errors.present? end def to_html_list(messages) diff --git a/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml b/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml index b40967b26..022c2830b 100644 --- a/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml +++ b/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml @@ -1,5 +1,5 @@ %div{ id: dom_id(@tdc.stable_self, :header_section) } - if errors? - .errors-summary= to_html_list(errors) + .errors-summary= errors = @form.label :header_section_level, "Niveau du titre", for: dom_id(@tdc, :header_section_level) = @form.select :header_section_level, header_section_options_for_select, {}, id: dom_id(@tdc, :header_section_level), class: 'fr-select width-33' diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 11cec13d8..af398a991 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -259,13 +259,19 @@ class Procedure < ApplicationRecord validates :lien_dpo, url: { no_local: true, allow_blank: true, accept_email: true } validates :draft_types_de_champ_public, + 'types_de_champ/condition': true, + 'types_de_champ/expression_reguliere': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_public_editor, :publication] + validates :draft_types_de_champ_private, + 'types_de_champ/condition': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_private_editor, :publication] validate :check_juridique, on: [:create, :publication] diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index c8daa1bec..fef3516d5 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -17,10 +17,6 @@ class ProcedureRevision < ApplicationRecord scope :ordered, -> { order(:created_at) } - validate :conditions_are_valid? - validate :header_sections_are_valid? - validate :expressions_regulieres_are_valid? - delegate :path, to: :procedure, prefix: true def build_champs_public @@ -453,48 +449,4 @@ class ProcedureRevision < ApplicationRecord coordinate.update!(type_de_champ: cloned_type_de_champ) cloned_type_de_champ end - - def conditions_are_valid? - public_tdcs = types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - - public_tdcs - .map.with_index - .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } - .map do |tdc, i| - [tdc, tdc.condition.errors(public_tdcs.take(i))] - end - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:condition, message, type_de_champ: tdc) } - end - - def header_sections_are_valid? - public_tdcs = types_de_champ_public.to_a - - root_tdcs_errors = errors_for_header_sections_order(public_tdcs) - repetition_tdcs_errors = public_tdcs - .filter_map { _1.repetition? ? children_of(_1) : nil } - .map { errors_for_header_sections_order(_1) } - - repetition_tdcs_errors + root_tdcs_errors - end - - def expressions_regulieres_are_valid? - types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - .each do |tdc| - if tdc.expression_reguliere? && tdc.invalid_regexp? - errors.add(:expression_reguliere, type_de_champ: tdc) - end - end - end - - def errors_for_header_sections_order(tdcs) - tdcs - .map.with_index - .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } - .map { |tdc, i| [tdc, tdc.check_coherent_header_level(tdcs.take(i))] } - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:header_section, message, type_de_champ: tdc) } - end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index ea2de7468..10e415c05 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -505,15 +505,15 @@ class TypeDeChamp < ApplicationRecord end def check_coherent_header_level(upper_tdcs) - errs = [] previous_level = previous_section_level(upper_tdcs) - current_level = header_section_level_value.to_i + difference = current_level - previous_level if current_level > previous_level && difference != 1 - errs << I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + else + nil end - errs end def current_section_level(revision) diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb new file mode 100644 index 000000000..74b57c3d5 --- /dev/null +++ b/app/validators/types_de_champ/condition_validator.rb @@ -0,0 +1,21 @@ +class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + public_tdcs = types_de_champ.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + + public_tdcs + .map.with_index + .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } + .map do |tdc, i| + [tdc, tdc.condition.errors(public_tdcs.take(i))] + end + .filter { |_tdc, errors| errors.present? } + .each do |tdc, _error_hash| + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), + type_de_champ: tdc + ) + end + end +end diff --git a/app/validators/types_de_champ/expression_reguliere_validator.rb b/app/validators/types_de_champ/expression_reguliere_validator.rb new file mode 100644 index 000000000..39262adeb --- /dev/null +++ b/app/validators/types_de_champ/expression_reguliere_validator.rb @@ -0,0 +1,11 @@ +class TypesDeChamp::ExpressionReguliereValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + types_de_champ.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + .each do |tdc| + if tdc.expression_reguliere? && tdc.invalid_regexp? + procedure.errors.add(:expression_reguliere, type_de_champ: tdc) + end + end + end +end diff --git a/app/validators/types_de_champ/header_section_consistency_validator.rb b/app/validators/types_de_champ/header_section_consistency_validator.rb new file mode 100644 index 000000000..062b27cf0 --- /dev/null +++ b/app/validators/types_de_champ/header_section_consistency_validator.rb @@ -0,0 +1,29 @@ +class TypesDeChamp::HeaderSectionConsistencyValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + public_tdcs = types_de_champ.to_a + + root_tdcs_errors = errors_for_header_sections_order(procedure, attribute, public_tdcs) + repetition_tdcs_errors = public_tdcs + .filter_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : nil } + .map { errors_for_header_sections_order(procedure, attribute, _1) } + + repetition_tdcs_errors + root_tdcs_errors + end + + private + + def errors_for_header_sections_order(procedure, attribute, types_de_champ) + types_de_champ + .map.with_index + .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } + .map { |tdc, i| [tdc, tdc.check_coherent_header_level(types_de_champ.take(i))] } + .filter { |_tdc, errors| errors.present? } + .each do |tdc, message| + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :inconsistent_header_section, { value: tdc.libelle, custom_message: message }), + type_de_champ: tdc + ) + end + end +end diff --git a/app/validators/types_de_champ/no_empty_block_validator.rb b/app/validators/types_de_champ/no_empty_block_validator.rb index e1ea17739..50356d6ab 100644 --- a/app/validators/types_de_champ/no_empty_block_validator.rb +++ b/app/validators/types_de_champ/no_empty_block_validator.rb @@ -11,7 +11,8 @@ class TypesDeChamp::NoEmptyBlockValidator < ActiveModel::EachValidator if procedure.draft_revision.children_of(parent).empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }) + procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }), + type_de_champ: parent ) end end diff --git a/app/validators/types_de_champ/no_empty_drop_down_validator.rb b/app/validators/types_de_champ/no_empty_drop_down_validator.rb index bd8fa21dd..0be4e5406 100644 --- a/app/validators/types_de_champ/no_empty_drop_down_validator.rb +++ b/app/validators/types_de_champ/no_empty_drop_down_validator.rb @@ -11,7 +11,8 @@ class TypesDeChamp::NoEmptyDropDownValidator < ActiveModel::EachValidator if drop_down.drop_down_list_enabled_non_empty_options.empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }) + procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }), + type_de_champ: drop_down ) end end diff --git a/app/views/administrateurs/conditions/_update.turbo_stream.haml b/app/views/administrateurs/conditions/_update.turbo_stream.haml index 3fba14ec8..028fb8c0e 100644 --- a/app/views/administrateurs/conditions/_update.turbo_stream.haml +++ b/app/views/administrateurs/conditions/_update.turbo_stream.haml @@ -4,7 +4,7 @@ ['Configuration des champs']], preview: @procedure.draft_revision.valid? }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @tdc.public? ? :types_de_champ_public_editor : :types_de_champ_private_editor)) - rendered = render @condition_component diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index 85a8f083c..0c9cc8454 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -2,7 +2,7 @@ url: admin_procedure_publish_path(procedure_id: procedure.id), method: :put, html: { class: 'form' } do |f| - = render Procedure::PublicationWarningComponent.new(procedure: procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) .mt-2 - if procedure.draft_changed? %p.mb-2= t('.draft_changed_procedure_alert') diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index 95ddf2860..4ec783ee1 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -9,7 +9,7 @@ .fr-grid-row = render partial: 'champs_summary' .fr-col - = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision) + = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision, is_annotation: false) .padded-fixed-footer .fixed-footer diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index a4c1f16d3..0bd65ebf8 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -5,7 +5,7 @@ .fr-container.procedure-admin-container %ul.fr-btns-group.fr-btns-group--inline-sm.fr-btns-group--icon-left - - if @procedure.draft_revision.valid? + - if @procedure.validate(:publication) - if !@procedure.brouillon? = link_to 'Télécharger', admin_procedure_archives_path(@procedure), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-download-line', id: "archive-procedure" @@ -27,15 +27,11 @@ = link_to 'Clore', admin_procedure_close_path(procedure_id: @procedure.id), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-calendar-close-fill', id: "close-procedure-link" .fr-container - = render TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision) - -- if @procedure.draft_changed? - .fr-container + - if @procedure.draft_changed? = render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c| - c.with_body do = render Procedure::RevisionChangesComponent.new changes: @procedure.revision_changes, previous_revision: @procedure.published_revision - - = render Procedure::PublicationWarningComponent.new(procedure: @procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) - c.with_bottom do %ul.fr-mt-2w.fr-btns-group.fr-btns-group--inline @@ -44,6 +40,9 @@ - else %li= button_to 'Publier les modifications', admin_procedure_publication_path(@procedure), class: 'fr-btn', id: 'publish-procedure-link', data: { disable_with: "Publication..." }, disabled: !@procedure.draft_revision.valid? || @procedure.errors.present?, method: :get %li= button_to "Réinitialiser les modifications", admin_procedure_reset_draft_path(@procedure), class: 'fr-btn fr-btn--secondary fr-mr-2w', data: { confirm: 'Êtes-vous sûr de vouloir réinitialiser les modifications ?' }, method: :put + - else + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + - if !@procedure.procedure_expires_when_termine_enabled? = render partial: 'administrateurs/procedures/suggest_expires_when_termine', locals: { procedure: @procedure } diff --git a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml index 9421b594a..ad121dedd 100644 --- a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml +++ b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml @@ -10,9 +10,9 @@ locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Configuration des champs']], - preview: @procedure.draft_revision.valid? }) + preview: @procedure.validate(@coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor) }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor)) = turbo_stream.replace 'summary', render(partial: 'administrateurs/procedures/champs_summary') diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 619f5f4f4..07486886c 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -739,8 +739,6 @@ fr: evil_regexp: L'expression régulière que vous avez entrée est potentiellement dangereuse et pourrait entraîner des problèmes de performance mismatch_regexp: L'exemple doit correspondre à l'expression régulière fournie syntax_error_regexp: La syntaxe de l'expression régulière n'est pas valide - empty_repetition: '« %{value} » doit comporter au moins un champ répétable' - empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' # procedure_not_draft: "Cette démarche n’est maintenant plus en brouillon." cadastres_empty: one: "Aucune parcelle cadastrale sur la zone sélectionnée" diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 2f1d1c8b8..55a9c1ddd 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -72,8 +72,30 @@ en: invalid: 'invalid format' draft_types_de_champ_public: format: 'Public field %{message}' + invalid_condition: "« %{value} » have an invalid logic" + empty_repetition: '« %{value} » requires at least one field' + empty_drop_down: '« %{value} » requires at least one option' + inconsistent_header_section: "« %{value} » %{custom_message}" draft_types_de_champ_private: format: 'Private field %{message}' + invalid_condition: "« %{value} » have an invalid logic" + empty_repetition: '« %{value} » requires at least one field' + empty_drop_down: '« %{value} » requires at least one option' + inconsistent_header_section: "« %{value} » %{custom_message}" + attestation_template: + format: "%{attribute} %{message}" + initiated_mail: + format: "%{attribute} %{message}" + received_mail: + format: "%{attribute} %{message}" + closed_mail: + format: "%{attribute} %{message}" + refused_mail: + format: "%{attribute} %{message}" + without_continuation_mail: + format: "%{attribute} %{message}" + re_instructed_mail: + format: "%{attribute} %{message}" lien_dpo: invalid_uri_or_email: "Fill in with an email or a link" sva_svr: diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 78e2bcf26..4a0fdeca5 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -78,8 +78,30 @@ fr: invalid: 'n’a pas le bon format' draft_types_de_champ_public: format: 'Le champ %{message}' + invalid_condition: "« %{value} » a une logique conditionnelle invalide" + empty_repetition: '« %{value} » doit comporter au moins un champ répétable' + empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "« %{value} » %{custom_message}" draft_types_de_champ_private: format: 'L’annotation privée %{message}' + invalid_condition: "« %{value} » a une logique conditionnelle invalide" + empty_repetition: '« %{value} » doit comporter au moins un champ répétable' + empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "« %{value} » %{custom_message}" + attestation_template: + format: "%{attribute} %{message}" + initiated_mail: + format: "%{attribute} %{message}" + received_mail: + format: "%{attribute} %{message}" + closed_mail: + format: "%{attribute} %{message}" + refused_mail: + format: "%{attribute} %{message}" + without_continuation_mail: + format: "%{attribute} %{message}" + re_instructed_mail: + format: "%{attribute} %{message}" lien_dpo: invalid_uri_or_email: "Veuillez saisir un mail ou un lien" auto_archive_on: diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml index a6ea09511..71de8c159 100644 --- a/config/locales/models/type_de_champ/fr.yml +++ b/config/locales/models/type_de_champ/fr.yml @@ -61,4 +61,4 @@ fr: type_de_champ: attributes: header_section_level: - gap_error: "Un titre de section avec le niveau %{level} est manquant." + gap_error: "devrait être précédé d'un titre de niveau %{level}" diff --git a/spec/components/procedures/card/annotations_component_spec.rb b/spec/components/procedures/card/annotations_component_spec.rb new file mode 100644 index 000000000..478937124 --- /dev/null +++ b/spec/components/procedures/card/annotations_component_spec.rb @@ -0,0 +1,30 @@ +describe Procedure::Card::AnnotationsComponent, type: :component do + describe 'render' do + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [] } + before { procedure.validate(:publication) } + subject { render_inline(described_class.new(procedure: procedure)) } + + context 'when no errors' do + it 'does not render' do + expect(subject).to have_selector('.fr-badge--info', text: 'À configurer') + end + end + + context 'when errors on types_de_champs_public' do + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + it 'does not render' do + expect(subject).to have_selector('.fr-badge--info', text: 'À configurer') + end + end + + context 'when errors on types_de_champs_private' do + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + + it 'render the template' do + expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') + end + end + end +end diff --git a/spec/components/procedures/card/champs_component_spec.rb b/spec/components/procedures/card/champs_component_spec.rb new file mode 100644 index 000000000..61a5d5762 --- /dev/null +++ b/spec/components/procedures/card/champs_component_spec.rb @@ -0,0 +1,30 @@ +describe Procedure::Card::ChampsComponent, type: :component do + describe 'render' do + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [] } + before { procedure.validate(:publication) } + subject { render_inline(described_class.new(procedure: procedure)) } + + context 'when no errors' do + it 'does not render' do + expect(subject).to have_selector('.fr-badge--warning', text: 'À faire') + end + end + + context 'when errors on types_de_champs_public' do + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + it 'does not render' do + expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') + end + end + + context 'when errors on types_de_champs_private' do + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + + it 'render the template' do + expect(subject).to have_selector('.fr-badge--warning', text: 'À faire') + end + end + end +end diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb new file mode 100644 index 000000000..4c3ef6337 --- /dev/null +++ b/spec/components/procedures/errors_summary_spec.rb @@ -0,0 +1,80 @@ +describe Procedure::ErrorsSummary, type: :component do + subject { render_inline(described_class.new(procedure:, validation_context:)) } + + describe 'validations context' do + let(:procedure) { create(:procedure, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + + before { subject } + + context 'when :publication' do + let(:validation_context) { :publication } + + it 'shows errors for public and private tdc' do + expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") + expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + end + end + + context 'when :types_de_champ_public_editor' do + let(:validation_context) { :types_de_champ_public_editor } + + it 'shows errors for public only tdc' do + expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") + expect(page).not_to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + end + end + + context 'when :types_de_champ_private_editor' do + let(:validation_context) { :types_de_champ_private_editor } + + it 'shows errors for private only tdc' do + expect(page).not_to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") + expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + end + end + end + + describe 'render all kind of champs errors' do + include Logic + + let(:procedure) do + create(:procedure, id: 1, types_de_champ_public: [ + { libelle: 'repetition requires children', type: :repetition, children: [] }, + { libelle: 'drop down list requires options', type: :drop_down_list, options: [] }, + { libelle: 'invalid condition', type: :text, condition: ds_eq(constant(true), constant(1)) }, + { libelle: 'header sections must have consistent order', type: :header_section, level: 2 } + ]) + end + + let(:validation_context) { :types_de_champ_public_editor } + + before { subject } + + it 'renders all errors on champ' do + expect(page).to have_text("Le champ « drop down list requires options » doit comporter au moins un choix sélectionnable") + expect(page).to have_text("Le champ « repetition requires children » doit comporter au moins un champ répétable") + expect(page).to have_text("Le champ « invalid condition » a une logique conditionnelle invalide") + expect(page).to have_text("Le champ « header sections must have consistent order » devrait être précédé d'un titre de niveau 1") + # TODO, test attestation_template, initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail + end + end + + describe 'render error for other kind of associated objects' do + let(:validation_context) { :publication } + let(:procedure) { create(:procedure, attestation_template:, initiated_mail:) } + let(:attestation_template) { build(:attestation_template) } + let(:initiated_mail) { build(:initiated_mail) } + + before do + [:attestation_template, :initiated_mail].map { procedure.send(_1).update_column(:body, '--invalidtag--') } + subject + end + + it 'render error nicely' do + expect(page).to have_text("Le modèle d’attestation n'est pas valide") + expect(page).to have_text("L’email de notification de passage de dossier en instruction n'est pas valide") + end + end +end diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb new file mode 100644 index 000000000..7b4a19e46 --- /dev/null +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -0,0 +1,26 @@ +describe TypesDeChampEditor::EditorComponent, type: :component do + let(:revision) { procedure.draft_revision } + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + + describe 'render' do + subject { render_inline(described_class.new(revision:, is_annotation:)) } + context 'types_de_champ_public' do + let(:is_annotation) { false } + it 'does not render private champs errors' do + expect(subject).not_to have_text("« private » doit comporter au moins un choix sélectionnable") + expect(subject).to have_text("« public » doit comporter au moins un choix sélectionnable") + end + end + + context 'types_de_champ_private' do + let(:is_annotation) { true } + it 'does not render public champs errors' do + expect(subject).to have_text("« private » doit comporter au moins un choix sélectionnable") + expect(subject).not_to have_text("« public » doit comporter au moins un choix sélectionnable") + end + end + end +end diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index 9d3ea5924..4c7a17ba3 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -828,23 +828,22 @@ describe ProcedureRevision do describe 'conditions_are_valid' do include Logic - def first_champ = procedure.draft_revision.types_de_champ_public.first - - def second_champ = procedure.draft_revision.types_de_champ_public.second - - let(:procedure) do - create(:procedure).tap do |p| - p.draft_revision.add_type_de_champ(type_champ: :integer_number, libelle: 'l1') - p.draft_revision.add_type_de_champ(type_champ: :integer_number, libelle: 'l2') - end + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) do + [ + { type: :integer_number, libelle: 'l1' }, + { type: :integer_number, libelle: 'l2' } + ] end + def first_champ = procedure.draft_revision.types_de_champ_public.first + def second_champ = procedure.draft_revision.types_de_champ_public.second let(:draft_revision) { procedure.draft_revision } let(:condition) { nil } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end context 'when a champ has a valid condition (type)' do @@ -865,7 +864,7 @@ describe ProcedureRevision do before { second_champ.update(condition: condition) } let(:condition) { ds_eq(constant(true), constant(1)) } - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end context 'when a champ has an invalid condition: needed tdc is down in the forms' do @@ -876,7 +875,7 @@ describe ProcedureRevision do first_champ.update(condition: need_second_champ) end - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end context 'with a repetition' do @@ -904,7 +903,7 @@ describe ProcedureRevision do context 'when a champ belongs to a repetition' do let(:condition) { ds_eq(champ_value(-1), constant(1)) } - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end end end @@ -918,8 +917,8 @@ describe ProcedureRevision do let(:draft_revision) { procedure.draft_revision } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end it 'find error' do @@ -936,8 +935,8 @@ describe ProcedureRevision do let(:draft_revision) { procedure.draft_revision } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end context "When no regexp and no example" do diff --git a/spec/system/administrateurs/procedure_publish_spec.rb b/spec/system/administrateurs/procedure_publish_spec.rb index f1109ec4c..10818971c 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -44,13 +44,10 @@ describe 'Publishing a procedure', js: true do end context 'when a procedure isn’t published yet' do - before do - visit admin_procedures_path(statut: "brouillons") - click_on procedure.libelle - find('#publish-procedure-link').click - end - scenario 'an admin can publish it' do + visit admin_procedure_path(procedure) + find('#publish-procedure-link').click + expect(find_field('procedure_path').value).to eq procedure.path fill_in 'lien_site_web', with: 'http://some.website' within('form') { click_on 'Publier' } @@ -72,10 +69,13 @@ describe 'Publishing a procedure', js: true do end scenario 'an error message prevents the publication' do - expect(page).to have_content('Des problèmes empêchent la publication de la démarche') - expect(page).to have_content("Le champ « Enfants » doit comporter au moins un champ répétable") - expect(page).to have_content("L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable") + visit admin_procedure_path(procedure) + expect(page).to have_content('Des problèmes empêchent la publication de la démarche') + expect(page).to have_content("« Enfants » doit comporter au moins un champ répétable") + expect(page).to have_content("« Civilité » doit comporter au moins un choix sélectionnable") + + visit admin_procedure_publication_path(procedure) expect(find_field('procedure_path').value).to eq procedure.path fill_in 'lien_site_web', with: 'http://some.website' @@ -85,8 +85,9 @@ describe 'Publishing a procedure', js: true do context 'when the procedure has the same path as another procedure from another admin ' do scenario 'an error message prevents the publication' do - expect(find_field('procedure_path').value).to eq procedure.path + visit admin_procedure_publication_path(procedure) fill_in 'procedure_path', with: other_procedure.path + expect(page).to have_content 'vous devez la modifier afin de pouvoir publier votre démarche' fill_in 'lien_site_web', with: 'http://some.website' diff --git a/spec/system/administrateurs/types_de_champ_spec.rb b/spec/system/administrateurs/types_de_champ_spec.rb index 8cbba47a7..28023d9b8 100644 --- a/spec/system/administrateurs/types_de_champ_spec.rb +++ b/spec/system/administrateurs/types_de_champ_spec.rb @@ -228,9 +228,7 @@ describe 'As an administrateur I can edit types de champ', js: true do click_on 'Supprimer' end end - - expect(page).to have_content("Le formulaire contient des erreurs") - expect(page).to have_content("Le titre de section suivant est invalide, veuillez le corriger :") + expect(page).to have_content("devrait être précédé d'un titre de niveau 1") end end From 728a28134cc302d0587bbbc7a8f742e952ee369a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 5 Jun 2024 09:40:54 +0200 Subject: [PATCH 29/63] chore(bundle): rails 7.0.8.3 => 7.0.8.4 --- Gemfile.lock | 116 +++++++++++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 51281c712..a0a99d358 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,47 +12,47 @@ GEM aasm (5.5.0) concurrent-ruby (~> 1.0) acsv (0.0.1) - actioncable (7.0.8.3) - actionpack (= 7.0.8.3) - activesupport (= 7.0.8.3) + actioncable (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.3) - actionpack (= 7.0.8.3) - activejob (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionmailbox (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.3) - actionpack (= 7.0.8.3) - actionview (= 7.0.8.3) - activejob (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionmailer (7.0.8.4) + actionpack (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.3) - actionview (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionpack (7.0.8.4) + actionview (= 7.0.8.4) + activesupport (= 7.0.8.4) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.3) - actionpack (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + actiontext (7.0.8.4) + actionpack (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.3) - activesupport (= 7.0.8.3) + actionview (7.0.8.4) + activesupport (= 7.0.8.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -67,26 +67,26 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (7.0.8.3) - activesupport (= 7.0.8.3) + activejob (7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.3.6) - activemodel (7.0.8.3) - activesupport (= 7.0.8.3) - activerecord (7.0.8.3) - activemodel (= 7.0.8.3) - activesupport (= 7.0.8.3) - activestorage (7.0.8.3) - actionpack (= 7.0.8.3) - activejob (= 7.0.8.3) - activerecord (= 7.0.8.3) - activesupport (= 7.0.8.3) + activemodel (7.0.8.4) + activesupport (= 7.0.8.4) + activerecord (7.0.8.4) + activemodel (= 7.0.8.4) + activesupport (= 7.0.8.4) + activestorage (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activesupport (= 7.0.8.4) marcel (~> 1.0) mini_mime (>= 1.1.0) activestorage-openstack (1.6.0) fog-openstack (>= 1.0.9) marcel rails (>= 5.2.2) - activesupport (7.0.8.3) + activesupport (7.0.8.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -174,7 +174,7 @@ GEM clamav-client (3.2.0) coercible (1.0.0) descendants_tracker (~> 0.0.1) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.1) connection_pool (2.4.1) content_disposition (1.0.0) crack (1.0.0) @@ -438,15 +438,15 @@ GEM rake mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.6) - minitest (5.23.0) + mini_portile2 (2.8.7) + minitest (5.23.1) msgpack (1.7.2) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri - net-imap (0.4.11) + net-imap (0.4.12) date net-protocol net-pop (0.1.2) @@ -531,20 +531,20 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (7.0.8.3) - actioncable (= 7.0.8.3) - actionmailbox (= 7.0.8.3) - actionmailer (= 7.0.8.3) - actionpack (= 7.0.8.3) - actiontext (= 7.0.8.3) - actionview (= 7.0.8.3) - activejob (= 7.0.8.3) - activemodel (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + rails (7.0.8.4) + actioncable (= 7.0.8.4) + actionmailbox (= 7.0.8.4) + actionmailer (= 7.0.8.4) + actionpack (= 7.0.8.4) + actiontext (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activemodel (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) bundler (>= 1.15.0) - railties (= 7.0.8.3) + railties (= 7.0.8.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -567,9 +567,9 @@ GEM rails-pg-extras (5.3.1) rails ruby-pg-extras (= 5.3.1) - railties (7.0.8.3) - actionpack (= 7.0.8.3) - activesupport (= 7.0.8.3) + railties (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) method_source rake (>= 12.2) thor (~> 1.0) @@ -872,7 +872,7 @@ GEM anyway_config (>= 1.3, < 3) sidekiq yabeda (~> 0.6) - zeitwerk (2.6.14) + zeitwerk (2.6.15) zip_tricks (5.6.0) zipline (1.5.0) actionpack (>= 6.0, < 8.0) From cb5ba455ebaf621a6caebff9138f9ff6f9f541fb Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Wed, 5 Jun 2024 11:52:18 +0000 Subject: [PATCH 30/63] =?UTF-8?q?Fixe=20bug=20sur=20toutes=20les=20d=C3=A9?= =?UTF-8?q?marches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/procedure_detail.rb | 2 +- .../procedures/_detail.html.haml | 2 +- .../procedures_controller_spec.rb | 16 ---------- spec/models/procedure_spec.rb | 31 +++++++++++++++++++ 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/app/models/procedure_detail.rb b/app/models/procedure_detail.rb index ebe6c3f6d..2ae20f20f 100644 --- a/app/models/procedure_detail.rb +++ b/app/models/procedure_detail.rb @@ -14,7 +14,7 @@ ProcedureDetail = Struct.new(:id, :libelle, :published_at, :aasm_state, :estimat end def parsed_latest_zone_labels - # Replace curly braces with square brackets to make it a valid JSON array + return [] if latest_zone_labels.nil? || latest_zone_labels.strip.empty? JSON.parse(latest_zone_labels.tr('{', '[').tr('}', ']')) rescue JSON::ParserError [] diff --git a/app/views/administrateurs/procedures/_detail.html.haml b/app/views/administrateurs/procedures/_detail.html.haml index 798bb15a6..99b8b1410 100644 --- a/app/views/administrateurs/procedures/_detail.html.haml +++ b/app/views/administrateurs/procedures/_detail.html.haml @@ -31,7 +31,7 @@ - if show_detail %tr.procedure{ id: "procedure_detail_#{procedure.id}" } - %td.fr-highlight--beige-gris-galet{ colspan: '8' } + %td.fr-highlight--green-emeraude{ colspan: '8' } .fr-container .fr-col-6 - procedure.administrateurs.uniq.each do |admin| diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 0cb5bfa2d..116db94d1 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -95,9 +95,6 @@ describe Administrateurs::ProceduresController, type: :controller do let!(:draft_procedure) { create(:procedure) } let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } let!(:closed_procedure) { create(:procedure, :closed) } - let!(:procedure_detail_draft) { ProcedureDetail.new(id: draft_procedure.id, latest_zone_labels: '{ "zone1", "zone2" }') } - let!(:procedure_detail_published) { ProcedureDetail.new(id: published_procedure.id, latest_zone_labels: '{ "zone3", "zone4" }') } - let!(:procedure_detail_closed) { ProcedureDetail.new(id: closed_procedure.id, latest_zone_labels: '{ "zone5", "zone6" }') } subject { get :all } @@ -124,19 +121,6 @@ describe Administrateurs::ProceduresController, type: :controller do expect(assigns(:procedures).any? { |p| p.id == draft_procedure.id }).to be_falsey end - context 'with parsed latest zone labels' do - it 'parses the latest zone labels correctly' do - expect(procedure_detail_draft.parsed_latest_zone_labels).to eq(["zone1", "zone2"]) - expect(procedure_detail_published.parsed_latest_zone_labels).to eq(["zone3", "zone4"]) - expect(procedure_detail_closed.parsed_latest_zone_labels).to eq(["zone5", "zone6"]) - end - - it 'returns an empty array for invalid JSON' do - procedure_detail_draft.latest_zone_labels = '{ invalid json }' - expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) - end - end - context 'for default admin zones' do let(:zone1) { create(:zone) } let(:zone2) { create(:zone) } diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0589985bb..39ebc055f 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1776,6 +1776,37 @@ describe Procedure do end end + describe "#parsed_latest_zone_labels" do + let!(:draft_procedure) { create(:procedure) } + let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } + let!(:closed_procedure) { create(:procedure, :closed) } + let!(:procedure_detail_draft) { ProcedureDetail.new(id: draft_procedure.id, latest_zone_labels: '{ "zone1", "zone2" }') } + let!(:procedure_detail_published) { ProcedureDetail.new(id: published_procedure.id, latest_zone_labels: '{ "zone3", "zone4" }') } + let!(:procedure_detail_closed) { ProcedureDetail.new(id: closed_procedure.id, latest_zone_labels: '{ "zone5", "zone6" }') } + context 'with parsed latest zone labels' do + it 'parses the latest zone labels correctly' do + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq(["zone1", "zone2"]) + expect(procedure_detail_published.parsed_latest_zone_labels).to eq(["zone3", "zone4"]) + expect(procedure_detail_closed.parsed_latest_zone_labels).to eq(["zone5", "zone6"]) + end + + it 'returns an empty array for invalid JSON' do + procedure_detail_draft.latest_zone_labels = '{ invalid json }' + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + + it 'returns an empty array when latest_zone_labels is nil' do + procedure_detail_draft.latest_zone_labels = nil + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + + it 'returns an empty array when latest_zone_labels is empty' do + procedure_detail_draft.latest_zone_labels = '' + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + end + end + private def create_dossier_with_pj_of_size(size, procedure) From 73fe247c3d72a17c09e3f83028e3b886a281a191 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 14:21:58 +0200 Subject: [PATCH 31/63] feat(avis): ensure consistent ordering, use ASC ordering to give back most recent avis first --- app/models/dossier.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 54c187169..e609323bb 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -59,7 +59,7 @@ class Dossier < ApplicationRecord has_many :previous_follows, -> { inactive }, class_name: 'Follow', inverse_of: :dossier has_many :followers_instructeurs, through: :follows, source: :instructeur has_many :previous_followers_instructeurs, -> { distinct }, through: :previous_follows, source: :instructeur - has_many :avis, inverse_of: :dossier, dependent: :destroy + has_many :avis, -> { order(:created_at) }, inverse_of: :dossier, dependent: :destroy has_many :experts, through: :avis has_many :traitements, -> { order(:processed_at) }, inverse_of: :dossier, dependent: :destroy do def passer_en_construction(instructeur: nil, processed_at: Time.zone.now) From f3795ebc9817903597836df6dc0bf99164611f75 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 24 May 2024 09:46:45 +0200 Subject: [PATCH 32/63] feat(gallery): add pdf previews --- app/views/instructeurs/dossiers/pieces_jointes.html.haml | 6 +++--- app/views/shared/champs/piece_justificative/_show.html.haml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 58a867ccd..e3480d617 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -8,17 +8,17 @@ - champ.piece_justificative_file.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle = champ.libelle.truncate(25) = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 734aeadd8..b32f05595 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -8,14 +8,14 @@ - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) From 05ad5dcbd67c44ae2663b37d2bc8bf6217b1501b Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 24 May 2024 09:58:19 +0200 Subject: [PATCH 33/63] feat(gallery): display large variant for rare image types --- app/jobs/image_processor_job.rb | 3 ++ .../dossiers/pieces_jointes.html.haml | 3 +- .../piece_justificative/_show.html.haml | 3 +- .../initializers/authorized_content_types.rb | 4 +++ spec/fixtures/files/pencil.tiff | Bin 0 -> 15224 bytes spec/jobs/image_processor_job_spec.rb | 29 +++++++++++++----- 6 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 spec/fixtures/files/pencil.tiff diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index 8017d2b35..798ba86a1 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -38,6 +38,9 @@ class ImageProcessorJob < ApplicationJob blob.attachments.each do |attachment| next unless attachment&.representable? attachment.representation(resize_to_limit: [400, 400]).processed + if attachment.blob.content_type.in?(RARE_IMAGE_TYPES) + attachment.variant(resize_to_limit: [2000, 2000]).processed + end end end diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index e3480d617..eb67f850c 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -19,7 +19,8 @@ = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url + = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index b32f05595..1624f86e1 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -16,7 +16,8 @@ = 'Visualiser' - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url + = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } diff --git a/config/initializers/authorized_content_types.rb b/config/initializers/authorized_content_types.rb index e5af1c74f..eaa16fbd9 100644 --- a/config/initializers/authorized_content_types.rb +++ b/config/initializers/authorized_content_types.rb @@ -15,6 +15,10 @@ AUTHORIZED_IMAGE_TYPES = [ 'image/vnd.dwg' # multimedia x 137 auto desk ] +RARE_IMAGE_TYPES = [ + 'image/tiff' # multimedia x 3985 +] + AUTHORIZED_CONTENT_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + [ # multimedia 'video/mp4', # multimedia x 2075 diff --git a/spec/fixtures/files/pencil.tiff b/spec/fixtures/files/pencil.tiff new file mode 100644 index 0000000000000000000000000000000000000000..67af5a81aa3db48f070524d1b52c588491b74861 GIT binary patch literal 15224 zcmeHucU)7=(*HqF1W_y?O$dSti1a4S*r*B!NEPG>As{tC=m@cbB1I_{L?S5Ci8QGa z1q3OAbOGs21*G>R=eNP~c<+0k_x*Xxe|P;HGTEK^?#}G&?ChSXrF9V63_;Lt2%@8h z=)O@9-FKP;eBS_Q23kF6r2k$|$wbPVgVw`Cw`=Px|$-|27i-|#O1eva?_ zpBN#Cn{MN}EFS>n`A#1I^qyaEc5~=cL?xJRp5e$hpUo=gtxc1xVMZr*4<7*N>Nc!LQ+~nT3QTnh~a!)JWxJj zE;vCN#5atS7#!N&-qph%>%vdNL|J1!JyZn*Ks*1B@j1Kd==_9t!HKVrRve9WmheHj zN=S)IN;o@9{3?O-Q1b#H-v{&$C2(hbT`>{{7#!Bq9gR`*!nk+{{t97({weS3>F%_y zjtyD@R1} z-*ErXeVrL_>F6k(#G*ZE!_zvcDnRRB$p(wIw^3RrQBu}Yl9IM)F?p1N4%=cdVzQEwa$+cq zwSpK%L0VQ8Wh-xswzXb|vOz1UW8IxmU^?xcQFa&!nvvF9XckvGc1BB8Kw4b#&zdt% zC=Xj8p(=3N-o?}B&xW)1&KN@v6wRDc3R3d2a?*#TBo$=j6eNFHA_b#JGL?_HAuB*{?V8^RKT5 zB^3HwCpeTB#%8@EAor~aeF5cShXH}(yQluBxBmxTAcs;sEGK6pCnj%=krtB$J|TvZ zR6vVK%gLe?7=APEu4-S_#aR#CL@yXyM|A#wrs3!zL>048Oz|Aor~f#4Qj8C4R=i?>3{E z@jqC;&&7YR2LS!wPJYY2|25Zt&GlOr_$}uDde?u=^;;JBE$07v*T2nN%zvJwFfJeq z@&@N5a5~%Y*a_{7V*sZxXBQ851C8VSre@~+jGv&55C=HOaYJ+{G|u(dnKP#W^Iw$3 z_Vt4v)FMX1`U|GNs@`e?PDkLR$PXBfqFvoR0R0D`<-I*zY4m+Svw_^r2GALRKI9Gt z2-Cu9#fK^~A8_;dj^ILH9B zAO8z^?sfSyK*}0O*+Xa`aT3Bp&Jb!{9-_?w-~r`N+v03xq}QwHSdK%`#$Fhn+YdpE zQ4mDA4Z~{*Fic4Vw+tU4=#|T#^4P}^ba(;O-~O|19|3|`uR+j@%0KI@??F&OFa&XS zx}w}s>;2G!zZ-0AA!s%kg0`AL(609o#C-0X-9Q_y9eUCML1%%jPQQbo2eA-z0G#ew zE&f7p+GWX4yZx^`-|YwP03Lt4`Jkh91_~2I1ve73Z|gPR??UEj-@nte2HL#~ZFr;L zKH|r{%O>#s$Gr=U{&BxT-3uB(`F5WHe}Ma;?b_;E>YyW9=im?A z4V{2CZ`#DTY2#)_Mn)#4&CD!pTUoYjVd2`jiqK5-#_fkT3O_6RG8 z9Fmlhm6b&ZDXJcpRuPwxm8OBvF)=Z*Y+>Qtx|LIU@1DKVzg^&0(2mVuMDz4?0?>vX zbo4vu;7SMq#<`Jhy?j%JrYJqb#!ZZynV7c#hWza`OVMv&px?NWfdRAzfOd#s$Htv| zrH*adrH^71aAT9cd?#VE;PLF&>;_HK`(&)$uP`z1=HTSw-hV(ySmdCroV>zeMWqub z)zmdKwX_Y-8X21aw?f-sZ0#=CJK#J#y}W&V{jOfS9u$1zW=LdIbj;m*_aDS2K6#q- zEIB1LEhjfG|3yJzQE_=i<(sPNw>7oREv;?s9UnTo1_pC2nhj^9bh ze$6B(V=&Eb?cT(^d!OvU{u!ET>ze)lDR$+b((I>Vf9ll*vCz|j$)n!^AtCM77W_~T zsSo+qIGHTyg`aE2uYJN__Z^E+F$(B^>AqE2H>B4T^TMpoLR7}(P+dgy!eB$uOqZ_rV^tcWbM{M!gb*hQl zvFJO+^RBa`-cwbT9G=f|)SXw@g~@ab(VpuPl@+8ik>4$l>#aFhqO-L+^Ly zsW`)s!1X0cE(|r@GAhL}lSSGG-scIph}pe3SLHwTs3KQ-JF_!qCAxqpSM?=ct~EbE z{v6K@vE3qWg;k+ES6I2wpUrpTJil_Yl5cg{QT)_k=rQy3>X%Fvd|GGcF-P8)4>ZxH ztvT|V^z=xDe8};l4h-G5qU6I+r)c9i+Hz+9*o`BNi-rAqf?jI6Y!O*bIdl;HPU|}e+JX6l|CJnVu4DFt! zW*Ip8*ZH_u#|)PX2BELM+Ef5T10qeNu{jvpi(EEgmskZ8;?eGG&tfrIcHd$a_11oO|MRyyceD_A){YGc-*2_blvV6gQ(N;Cbx_LWNFWSdR~>3xrNa+@M361r3SQtocP#Bz z&|mSEZ_bl`cC71*rl8=}x2mF(waY_Qgjv<)U~0KHa#b$<{k4aZ4pqEt`o^02YHZN| z@z5E?`Zne2Qmr1)wfYma$kQo>6f9H-ZLMAP|6_Pj8atMOZN`~pPJQFlC| zr(W{IQa5N&*kK1%9;J!Q%Q_8)ZxI#vrXX*~Z51oXinvSOGug;joCg_wh{87P&>|txn?t-DlJmk{E*@Rg?!xVa@ zQQ^Vchujib?3f=LDL>%j@ro3>@Da@t%zzs|N9yczqKrMvMTpCft-ik~xA=NEfM`24 zI6D`L8#m#`#T1<$5zcHlwWbS0RD2eGY8)SSx@^z;udNmzKXrMu)T-3%k8I<56tVxN z;J&B^$0RUGco@=0l7UN1q#v|DQXzZye&4XvEpMG}U77G6(X2;Gc|D`2Ee_i6s-{ZW zn|nQ=+@cVYySjYi!3^h?XMe4}7uwGS_m1I(av>wh6m{Sk_eO%Q;trytk-Y@*>sA&bP{~{Ux^%$3dOAnAB5`I{Cnq z8;>3Szb1YXhPsQ9l#NdK>AlFRy!p@QRK`MA zaNrlHFtjSJH1duXEnUfZ zUzi}z!`--(+1qW7&3U8DcS~0--AQ}MhC)dHk+kXI%{M%=z{`Q>k+iWPv05VJN}h7=~VnAeUq7OLB{z7x_EZ z`DT9I#gq7eS$f#UhEIcmdu>yh!*V(d?H2{CFOaMIMbp^#JPK!yj%II=4-w8heLO_? z@rJ*6SoMJCxjg}{|89x0NFeL@-K)SdeXuX%EeztyGBcmNe-9(1yxohhZHzSi(8rs4 z1+lV8k9welmHDFnw9CV!x~;vrBX3(c=qGadP7`i;taxOK8n3BMEYA+Esic=$p#+ zhmM9>vdy2YLI!H%IWo`Xl-=!`RlIX1XAHU;e$CiM`dLLIUOQgIuzn9UW^7JdX7Nss zBVIDvP)9DI8DFm93qvaXFjS_u+@a>YH@2P26lw^m=9N}+J@0_`pU=u^G<_ak%V0lU z$9+X!!6HEPbB;>0dnz)st+~tcsqg)ICH?aaNH_8H#BN7Ey)HhZ+1!FUe$Q%)tDns{ z&a-9bwdbU9VQqnYr)Rg;TrsH=F(xo-b=ASs9ntan+_tO_J)>l_y%=oAOZ_3XP{ zoYEkz$#2pofiXYQyj6BJQ&+e(b<^XX2t{feVeJl8gY0;}t&HM)c4Aik=C3YZ zQ>OclUUj&}xsmD9lb2qI>09H-xiJ{JV@P&TLp@8$D>-5@Lm3$lomJf9AyAY!#^IgN zBd6=o)H=g@gS0I#VzZB5-wsoE2Bk3^|Q9#SgE zZyei$LnfUk@(Q2kUlHC@&z7gjb|=5^?JDySwFOCDjH0f})kyS|8GBbR)jdKS(zi0f zmkSOMS2^+s_;MbT($XY!{&uA_zDetE7)OT5Y)Ew z>}m(1cpIo|gLd7XTON`o!s0Jx9_Y&(a~af?$?eT2%MDsh67%tizDIN$SL{m^8=ub; zMY7fgl49D%7Lz+OpAoPzCAFb1WJp|ZTnC@$JrYVD`#4!)?cjg%VGEV6M-8_&yn?*p zovD`xvYyp;pXPi{&AaEeJPkWy>vjFo8Kp?KSILxTtSgfU%Dm{P)P0}wQ!#uaTysKR z4&9CS8|(VoGt?BO80%KsJ)dUhoy@qQ{ZCZ-HqJ`a zb+@NrXx$7@C2*Ejze_zH5hjfnAFtULTw-l>mpPqB&#(OeiXP8nF~FnZE^ zP9y0!(T(|e&+P`fwW_l8eebe4Lr;Vs2%Ie0pV<+ql5w7U`xRI7zWRIS&7>EHn)@1N zz1||cdU|eD>yMjSO5}~`Snw_x|bX3jwKjuk9LY+ov|vnT2@z)^UlCKQ<5jv zke-V=4-1rtmSYHi;3u&rEr^{0QTo%HlI_tvmKHT5PC;}pyLToW*01T`wQO<;`1zpK zDn~w+ydetsc~&j1xGg@w{9;ayx*Xd|!rflqy|D@*rXQX>Ni{ZRWZ3Sv-99Ga#$P2= zCIx|wR&yYM?k=FV5qD&gSba#A)5%@sPBS z!7TY3g0qG@So9r~1?TD|)_cgpKcj5ijN!;k_pNjI`=VT_c7s}&!XwKuv! zY70piMG`+7R*SBn3C%@G9BBFQhzi2&_9$u*VpKFaYg6swV3+lDO@7Fb^06Vob|MTp ze?iX8z)-lvvPtuZSubK^&{>BAJ-y(-A?sThx9>~q=q(K^3yCn~uoNrBpGdvd)?>7# zhr2@mzK$p31AfcLQ>bd8>?OHHqd&%Y1RE3d4GA(T;~+hX0DBE$*wH=TQ-@Xec}r5! zi-OtF)6UKlZjp5TaUOCFltinkz$-(KRgCxjD2teJ%qFa4uO_97C$J7!QvLjRFvLS9LTFFSO|$U=&K zA4|VqM=1z;YtsefWkTFgMl&TQQpl&vuj3Ftr$SR`Q`RV|syZc=e zUXWkeso=xhj9Qj))MK;w4ny+w02iIO!EB!-?R4YZPripU#FRW%5>4Atj$sFUZeMy= z!1a0Qx;vqh1JQSD4mn^M^Yd)6;GWE-oEyZRw9)3`>p>*+rGQVk?b)eYk~!z=`#GHY zi;z8Oi-;|9@6^45crsN@#V1O`H%T+vED?3vk;4igDp^V6KdBRa&QXuM<2!bNyj{$( zHd1QTIUZyl%zBlCISN>3eVxeV9^d?c>sEZt=woVZ2AqEx2(Z(^RPFFE;MO3~IUQ4J zObnJ=BeFOycD;#qt#QMaSl2V1DAJC*bnH|0OQGlOV;>dPckxm#jL|Bk`o9E=L6e-Fr&cL_7~xz7i-6;k?eUD>N!J%7U|4I?gDZ zy#GSE?wD#Kz7D_4x{&DsrYt3Y@(6;3LYO!`8}P&nh7Mm1E3A@aTnfXyPXDM{scCRX z=8`KbvAqn<5geN?F`h8loRY-Q)YP`VkjE4rr|&r3YuayePq>6z_I&UZAxZBn>Ony~ zqc))dd(2duw&>693YeKs!*|KxJ&}S`xtP@P0^_BFH}~vgW4s-p_Y!;Um2swFBu`vS zTGjRvSvl;=CLI_8S&Fb8yO^x_QP;j$JF6uNs|*f6E9C=|oe~{0_5TaTvON)@tD+{*##b zU171jwF|rUqi>$s)dA6I`6i!8uN3>r)y7_Ml?xh6b`5ZB{et^27yHFggS!2lGS%M_ z-**azDm!>7C7$49FhGu2ZuzJYbB_=Z+uAw9$icq454m+*CyLb!Z*S)d^>0S2 zn1={ImHfK|>1V=3d|awLBU&!c$Jy-k)zdlZwH=FrP-l{|*_jQ}9E^9m$^sIf=^K1Z zsi{VPveYxqo@tB$8^SPfwlzKw^Y1+AB=H0;b2YSo{(P3BqAMvqv$DO!`o@&k9NU1# zzG+9Ees6L$X|fN7986Lj`erwk0R#FX=Xaeq7G+}6HUK`Wxghx=H6^8U4}S3xfxHMq z)q2ZD2(CG zyxV6<3m;+Vlc*KudY$mV7k^C!yfXgIT#TDxDxdM~c;05BvuC&cvtTF35+V0EjOK)6 zNpD$xlp~1pBFi9#H4#sb$tT82F>mcFS3cWh`FhuG?^jhy+zk;F^MH-=_`)N3bh(sd zI~Y*IH21yVx9A(I`k_JQicBb>p%}4c`IbfLj7ylKXT}$q z?sowU?r+3ea+DshvHzjFnZN(4Ma+c@3N!YB0xfb(ZCyO)3X?sLZecfJaf`!UUf+k> z@gHLKDD;O&)4a&?qIU*eTLRQg6M_y8S)g_trN8{an1d`~#e#1P0(*9#H4F{KG=J77 zaN9p$zN#xAda88A-fi}+{LbLL?n zeRA{Hc!}h-6Ck-^exH3ZXt!yM{wX_VNtw$=X_Y$}j&JkpaH~Ck`Tl8=)KqWQN(5z* zJT4|~ps?-4AAV;^T_73q(tC>!gDezwUA-)O318|cRH@M6W~$!QqZ|B=UUV;SnDM;6G zg;e#jU~L!3ydGQLqtYiYn?&HtmA!$J@q_pkv1~%bJD@GeNKTR3W!!1;m4WH_ztaMf6WTh2=Uk z6}tQLYsOVWzf2bExC7C}dxY+T%?aC(qBZbn!=Oog0ISYjdnWHYlY+LkDv?@yBKb-h?FNA(j<8S(0>>%y zcEs#9@TMV4gnZo+C~O)S^G>Nl$8_=2ABq-s@0Rhe#oBvNMzL>X%FcfE<^xG|1QT*R zm3llsy)hHnhFD7118ME;Xk^5!Tody{Q9gAc#*E~5%eHW7)r=F18m+5?o7C8QFW&#FECJNhkp?7o_btm9-P3PL zcyZUBUy{Ah7}I5T4k^0LJ+VY$+3JW0SRq>xRB{~%!lHG+h-vtNb0DXkn+t#;JcyV# zv+x$p{nH~~%sTTTc&hdvux6^+=S3gB(HURfGp#^jM9v!EsRFrEqY>4HS;+%0);!}* zEqX`s8Si7r<_t)FB5}A{R{kt|=QYk=LT*ht(%_}f*s3Av`6PbqX&-Pc=CajI$N-QfCoz2ZkQ z;d(;hm)Z`ja-I!!S_wle>v`&}Ead`U=YgS+)O)YH0yCZ%#5*R%PWa9qa!NRXtUp>C z6-qF#2Cp;Frog~RatL9_IBGEXs)vWAH1L^O?4jz;v51Kl`I=oLsN2`}Z1-hkZ(OF7;<~6Y13Ag&zCcy>o!3eTAf|k;NMZX`o`JiyxqT2+H;zFE59Mo51 zPc8q;8?o;%z1Zh9USU^u5b+cHg{UDRW!{-QTk&Sik1xnho~JhKb{T#C!U z)fb|&f1;knf3&JCN~<>f_{v24VGD7EBNGJiE{P?^*>uT(x%sT)j_KFp)yzhWSdP-Y zg=`7VzPM4u{LNzg#rEB*MzT$vt@&>$S!2{daJ^mj&SLE}aK&D|wTbt)lVrfUq4>d2 z2}S2TIO2rfB}^kJbm@+-z3z4tt2A47zfrj|5G|X+c{$&wX0j#?&nBpY7-&z&KNI-S zt2Vsiy}P3Jj*K*Ykj0MMnyb6cl|O8XHx}Yzf47CV=OT88J!izl~AB@RE)Rxp5E*_8)gUB0Hh zbj*`Lwni+ab8L=}lQQz?m{mkBtK&aUfrC2?EsR@HxA(2$LBvE-VobAwBClzv=$s#m z8%J&juOdy)AGk%iM@bs15+_gP`6_$weCRdlM5!NBT{RILLWIK5^j#n0rIXZGYZ6^v zfoqcw7biXOCC}}?4iWv*XDX-~=fEh0zzfvU=7JC{K1=h+=x&r6n-SMW+Urxe6OYHk z1Ofr3ebRmw@Z(cYPY?X!1%eO=1nnnJzg^R)@9Hig>M4{O;@y1EGJ<|prDFS0$$G!+Je9NN~j)O;<^p) z4T0d12M|1&@~>h4@XzTl190LW5^%K(^l!udb-HQTO`G zosDpuE-T#Z{R?&~1plY7zoz|9?Cz91zwLj&j=L`u*yJGuuVWWMFy-wxoAmSzz&O9l z@T-cd-@^Za-4Ou6e~;ary&cB+;^8mj<8XBBad_{zxoQB|EvESWig)cZ@;JUhc zcw}S*URYRwZ$`wxeFNaS>^Dkt9^ohUtH5orrIk6Hk(misR#w7oZEf(|nmX9;QV={p zzW~-eIFJ8E8F)v6--c?!#>R$lR8%zl^yyPLFE0oGfOx!Dhht^-~oImG8%TYu!g5X!(ccg<3~7wOD!yZ3zs$o@QXC~tl4?^ z^3`iF+Rzjp_49`pgM;C~i0!~XjIJzT7z0T0{R!CwLPSAT!_t*!09gh|7tl^lSRc=#|}ps5L$ bn3?@sFux%Kd*|f9^ Date: Wed, 29 May 2024 10:54:38 +0200 Subject: [PATCH 34/63] refactor(gallery): add helper methods --- app/helpers/gallery_helper.rb | 21 +++++++++++++++++++ .../dossiers/pieces_jointes.html.haml | 11 +++++----- .../piece_justificative/_show.html.haml | 11 +++++----- 3 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 app/helpers/gallery_helper.rb diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb new file mode 100644 index 000000000..67068d480 --- /dev/null +++ b/app/helpers/gallery_helper.rb @@ -0,0 +1,21 @@ +module GalleryHelper + def displayable_pdf?(blob) + blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + end + + def displayable_image?(blob) + blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + end + + def preview_url_for(attachment) + attachment.preview(resize_to_limit: [400, 400]).processed.url + end + + def variant_url_for(attachment) + attachment.variant(resize_to_limit: [400, 400]).processed.url + end + + def blob_url(attachment) + attachment.blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : attachment.blob.url + end +end diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index eb67f850c..d770433d4 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -8,21 +8,20 @@ - champ.piece_justificative_file.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if displayable_pdf?(blob) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(preview_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle = champ.libelle.truncate(25) = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url - = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif displayable_image?(blob) + = link_to image_url(blob_url(attachment)), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(variant_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 1624f86e1..abb301872 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -8,18 +8,17 @@ - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if displayable_pdf?(blob) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(preview_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url - = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif displayable_image?(blob) + = link_to image_url(blob_url(attachment)), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(variant_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - else From ffc0ddc446775bde5702a5932fa500dd75941340 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 29 May 2024 11:15:40 +0200 Subject: [PATCH 35/63] chore(gallery): add activestorage error catching in front --- app/helpers/gallery_helper.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb index 67068d480..98f7ffc1e 100644 --- a/app/helpers/gallery_helper.rb +++ b/app/helpers/gallery_helper.rb @@ -9,13 +9,19 @@ module GalleryHelper def preview_url_for(attachment) attachment.preview(resize_to_limit: [400, 400]).processed.url + rescue ActiveStorage::Error + 'pdf-placeholder.png' end def variant_url_for(attachment) attachment.variant(resize_to_limit: [400, 400]).processed.url + rescue ActiveStorage::Error + 'apercu-indisponible.png' end def blob_url(attachment) attachment.blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : attachment.blob.url + rescue ActiveStorage::Error + attachment.blob.url end end From f6e54a540bcaef229cb28c5bbfeb1236dbc6a1f4 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 29 May 2024 16:08:23 +0200 Subject: [PATCH 36/63] chore(gallery): add activestorage error catching in job --- app/jobs/image_processor_job.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index 798ba86a1..ee914a925 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -10,6 +10,10 @@ class ImageProcessorJob < ApplicationJob # (to avoid modifying the file while it is being scanned). retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + rescue_from ActiveStorage::PreviewError do + retry_or_discard + end + def perform(blob) return if blob.nil? raise FileNotScannedYetError if blob.virus_scanner.pending? @@ -58,4 +62,14 @@ class ImageProcessorJob < ApplicationJob end end end + + def retry_or_discard + if executions < max_attempts + retry_job wait: 5.minutes + end + end + + def max_attempts + 3 + end end From bb3e53a6fee66c521d1c6af2bc7329ddc2f1cac5 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 6 Jun 2024 11:52:25 +0200 Subject: [PATCH 37/63] chore(job): log request_id enqueueing a job --- app/lib/active_job/application_log_subscriber.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/active_job/application_log_subscriber.rb b/app/lib/active_job/application_log_subscriber.rb index 23d31f072..a24b293b7 100644 --- a/app/lib/active_job/application_log_subscriber.rb +++ b/app/lib/active_job/application_log_subscriber.rb @@ -33,6 +33,7 @@ class ActiveJob::ApplicationLogSubscriber < ::ActiveJob::LogSubscriber def process_event(event, type) data = extract_metadata(event) data.merge!(extract_exception(event)) + data[:request_id] = Current.request_id if Current.request_id.present? case type when 'enqueue_at' From 48c092a74a3c339c307cf846690541de1a91d937 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 5 Jun 2024 11:56:29 +0200 Subject: [PATCH 38/63] test(gallery): test gallery helper --- .github/workflows/ci.yml | 3 +- spec/helpers/gallery_helper_spec.rb | 57 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 spec/helpers/gallery_helper_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e97ca81..89d52a61d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,8 @@ jobs: - name: Install build dependancies # - fonts pickable by ImageMagick # - rust for YJIT support - run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server + # - poppler-utils for pdf previews + run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server poppler-utils - name: Setup the app runtime and dependencies uses: ./.github/actions/ci-setup-rails diff --git a/spec/helpers/gallery_helper_spec.rb b/spec/helpers/gallery_helper_spec.rb new file mode 100644 index 000000000..41469798c --- /dev/null +++ b/spec/helpers/gallery_helper_spec.rb @@ -0,0 +1,57 @@ +RSpec.describe GalleryHelper, type: :helper do + let(:procedure) { create(:procedure_with_dossiers) } + let(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } + let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } + let(:blob_info) do + { + filename: file.original_filename, + byte_size: file.size, + checksum: Digest::SHA256.file(file.path), + content_type: file.content_type, + # we don't want to run virus scanner on this file + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + } + end + let(:blob) do + blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_info) + blob.upload(file) + blob + end + let(:attachment) { ActiveStorage::Attachment.create(name: "test", blob: blob, record: champ_pj) } + + describe ".variant_url_for" do + subject { variant_url_for(attachment) } + + context "when attachment can be represented with a variant" do + let(:file) { fixture_file_upload('spec/fixtures/files/logo_test_procedure.png', 'image/png') } + + it { expect { subject }.to change { ActiveStorage::VariantRecord.count }.by(1) } + it { is_expected.not_to eq("apercu-indisponible.png") } + end + + context "when attachment cannot be represented with a variant" do + let(:file) { fixture_file_upload('spec/fixtures/files/instructeurs-file.csv', 'text/csv') } + + it { expect { subject }.not_to change { ActiveStorage::VariantRecord.count } } + it { is_expected.to eq("apercu-indisponible.png") } + end + end + + describe ".preview_url_for" do + subject { preview_url_for(attachment) } + + context "when attachment can be represented with a preview" do + let(:file) { fixture_file_upload('spec/fixtures/files/RIB.pdf', 'application/pdf') } + + it { expect { subject }.to change { ActiveStorage::VariantRecord.count }.by(1) } + it { is_expected.not_to eq("pdf-placeholder.png") } + end + + context "when attachment cannot be represented with a preview" do + let(:file) { fixture_file_upload('spec/fixtures/files/instructeurs-file.csv', 'text/csv') } + + it { expect { subject }.not_to change { ActiveStorage::VariantRecord.count } } + it { is_expected.to eq("pdf-placeholder.png") } + end + end +end From e1002beacac8e35280523070e701d47f3bc42f05 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Thu, 11 Apr 2024 09:20:44 +0200 Subject: [PATCH 39/63] Met le conteneur au DSFR pour assurer la responsive sur la page contact --- app/views/support/index.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/support/index.html.haml b/app/views/support/index.html.haml index fb97ab53b..99fee2678 100644 --- a/app/views/support/index.html.haml +++ b/app/views/support/index.html.haml @@ -3,8 +3,8 @@ = render partial: "root/footer" #contact-form - .container - %h1.new-h1 + .fr-container + %h1 = t('.contact') = form_tag contact_path, method: :post, multipart: true, class: 'fr-form-group', data: {controller: :support } do From ae103e049c1baa713d3c333e867842b178ed3a93 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 6 Jun 2024 17:35:02 +0200 Subject: [PATCH 40/63] feat(DossiersController#merci): add download link --- app/views/users/dossiers/_merci.html.haml | 46 ++++++++++++----------- config/locales/en.yml | 1 + config/locales/fr.yml | 1 + 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 383f502d9..b9bb097c6 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -1,26 +1,30 @@ .merci.text-center.mb-7 - .container - = image_tag('user/envoi-dossier.svg', alt: '', class: 'mt-8') - %h1.mt-4.mb-3.mx-0= t('views.users.dossiers.merci.thanks') - %h2.send.m-2.text-lg - = t('views.users.dossiers.merci.dossier_send_l1') - %strong= procedure.libelle - = t('views.users.dossiers.merci.dossier_send_l2') - %p.m-2 - = t('views.users.dossiers.merci.dossier_acces_l1') - %strong= t('views.users.dossiers.merci.dossier_acces_l2') - %p.m-2 - = t('views.users.dossiers.merci.dossier_edit_l1') - - if !dossier&.read_only? && !procedure.declarative_accepte? && !procedure.sva_svr_enabled? - %strong= t('views.users.dossiers.merci.dossier_edit_l2') - = t('views.users.dossiers.merci.dossier_edit_l3') - %strong= t('views.users.dossiers.merci.dossier_edit_l4') - - if procedure.active_dossier_submitted_message - %p.m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager + .fr-container + .fr-grid-row.fr-col-offset-md-2.fr-col-md-8 + .fr-col-12 + = image_tag('user/envoi-dossier.svg', alt: '', class: 'mt-8') + %h1.fr-mt-4w.fr-mb-3w.mx-0= t('views.users.dossiers.merci.thanks') + %h2.send.fr-m-2w.text-lg + = t('views.users.dossiers.merci.dossier_send_l1') + %strong= procedure.libelle + = t('views.users.dossiers.merci.dossier_send_l2') + %p.fr-m-2w + = t('views.users.dossiers.merci.dossier_acces_l1') + %strong= t('views.users.dossiers.merci.dossier_acces_l2') + %p.fr-m-2w + = t('views.users.dossiers.merci.dossier_edit_l1') + - if !dossier&.read_only? && !procedure.declarative_accepte? && !procedure.sva_svr_enabled? + %strong= t('views.users.dossiers.merci.dossier_edit_l2') + = t('views.users.dossiers.merci.dossier_edit_l3') + %strong= t('views.users.dossiers.merci.dossier_edit_l4') + - if procedure.active_dossier_submitted_message + %p.fr-m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager + %p.justify-center.flex.fr-mb-5w.fr-mt-2w + = link_to "#{t('views.users.dossiers.merci.download_dossier')} (PDF)", dossier_path(dossier, format: :pdf), download: "Mon dossier", target: "_blank", rel: "noopener", title: t('views.users.dossiers.show.header.print_dossier'), class: 'fr-btn fr-btn--secondary fr-mx-2w fr-btn--icon-left fr-icon-download-line' + = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-mx-2w' - .flex.column.align-center - = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-btn--xl fr-mt-5w' - = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-3w' + %hr.fr-hr + = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-2w' .monavis != procedure.monavis_embed diff --git a/config/locales/en.yml b/config/locales/en.yml index b8413b1eb..17b7328d0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -444,6 +444,7 @@ en: dossier_edit_l2: edit it dossier_edit_l3: and dossier_edit_l4: talk with an instructor. + download_dossier: Download your file acces_dossier: Access your file submit_dossier: Submit an other file show: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 07486886c..227f89a6e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -447,6 +447,7 @@ fr: dossier_edit_l2: le modifier dossier_edit_l3: et dossier_edit_l4: échanger avec un instructeur. + download_dossier: Télécharger mon dossier acces_dossier: Accéder à votre dossier submit_dossier: Déposer un autre dossier show: From c813c0297561123adffb4a193432912672c52df0 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 7 Jun 2024 06:23:52 +0200 Subject: [PATCH 41/63] feat(EmailChecker.check): add class to search for typo in email addresses --- Gemfile | 1 + Gemfile.lock | 2 + app/lib/email_checker.rb | 649 +++++++++++++++++++++++++++++++++ spec/lib/email_checker_spec.rb | 36 ++ 4 files changed, 688 insertions(+) create mode 100644 app/lib/email_checker.rb create mode 100644 spec/lib/email_checker_spec.rb diff --git a/Gemfile b/Gemfile index 8d78db84d..9a9ff1600 100644 --- a/Gemfile +++ b/Gemfile @@ -95,6 +95,7 @@ gem 'sidekiq' gem 'sidekiq-cron' gem 'skylight' gem 'spreadsheet_architect' +gem 'string-similarity' gem 'strong_migrations' # lint database migrations gem 'sys-proctable' gem 'turbo-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 51281c712..98bde51e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -765,6 +765,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) stackprof (0.2.26) + string-similarity (2.1.0) stringio (3.1.0) strong_migrations (1.8.0) activerecord (>= 5.2) @@ -1013,6 +1014,7 @@ DEPENDENCIES spring spring-commands-rspec stackprof + string-similarity strong_migrations sys-proctable timecop diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb new file mode 100644 index 000000000..97fa9d803 --- /dev/null +++ b/app/lib/email_checker.rb @@ -0,0 +1,649 @@ +class EmailChecker + KNOWN_DOMAINS = [ + 'gmail.com', + 'hotmail.fr', + 'orange.fr', + 'yahoo.fr', + 'hotmail.com', + 'outlook.fr', + 'wanadoo.fr', + 'free.fr', + 'yahoo.com', + 'icloud.com', + 'laposte.net', + 'live.fr', + 'sfr.fr', + 'outlook.com', + 'neuf.fr', + 'aol.com', + 'bbox.fr', + 'msn.com', + 'me.com', + 'gmx.fr', + 'protonmail.com', + 'club-internet.fr', + 'live.com', + 'ymail.com', + 'ars.sante.fr', + 'mail.ru', + 'cegetel.net', + 'numericable.fr', + 'aliceadsl.fr', + 'comcast.net', + 'assurance-maladie.fr', + 'mac.com', + 'naver.com', + 'airbus.com', + 'justice.fr', + 'pole-emploi.fr', + 'educagri.fr', + 'aphp.fr', + 'netcourrier.com', + 'dbmail.com', + 'aol.fr', + 'qq.com', + 'hotmail.co.uk', + 'yahoo.co.uk', + 'proxima-mail.fr', + 'yahoo.com.br', + 'sciencespo.fr', + 'gmx.com', + 'etu.univ-st-etienne.fr', + 'yahoo.ca', + '163.com', + 'francetravail.fr', + 'mail.pf', + 'nantesmetropole.fr', + 'hotmail.it', + 'sbcglobal.net', + 'noos.fr', + 'ird.fr', + 'safrangroup.com', + 'croix-rouge.fr', + 'eiffage.com', + 'veolia.com', + 'notaires.fr', + 'nordnet.fr', + 'videotron.ca', + 'paris.fr', + 'lilo.org', + 'mfr.asso.fr', + 'yopmail.com', + 'ukr.net', + 'onf.fr', + 'stellantis.com', + '9online.fr', + 'atmp50.fr', + 'engie.com', + 'libertysurf.fr', + 'mailo.com', + 'auchan.fr', + 'verizon.net', + 'rocketmail.com', + 'mpsa.com', + 'entrepreneur.fr', + 'googlemail.com', + 'arcelormittal.com', + 'groupe-sos.org', + 'proton.me', + 'att.net', + 'pm.me', + 'orange.com', + 'abv.bg', + 'yahoo.es', + 'creditmutuel.fr', + 'yandex.ru', + 'essec.edu', + 'urssaf.fr', + 'bpifrance.fr', + 'uol.com.br', + 'suez.com', + 'univ-st-etienne.fr', + 'korian.fr', + 'developpement-durable.gouv.fr', + 'modernisation.gouv.fr', + 'social.gouv.fr', + 'emploi.gouv.fr', + 'agriculture.gouv.fr', + 'intradef.gouv.fr', + 'interieur.gouv.fr', + 'oise.gouv.fr', + 'direccte.gouv.fr', + 'culture.gouv.fr', + 'pas-de-calais.gouv.fr', + 'finances.gouv.fr', + 'drieets.gouv.fr', + 'drjscs.gouv.fr', + 'sg.social.gouv.fr', + 'martinique.pref.gouv.fr', + 'beta.gouv.fr', + 'dieccte.gouv.fr', + 'cotes-darmor.gouv.fr', + 'vosges.gouv.fr', + 'developppement-durable.gouv.fr', + 'mayenne.gouv.fr', + 'aviation-civile.gouv.fr', + 'data.gouv.fr', + 'recherche.gouv.fr', + 'sante.gouv.fr', + 'paris-idf.gouv.fr', + 'guyane.gouv.fr', + 'douane.finances.gouv.fr', + 'cget.gouv.fr', + 'herault.gouv.fr', + 'loire-atlantique.gouv.fr', + 'manche.gouv.fr', + 'seine-maritime.gouv.fr', + 'dgccrf.finances.gouv.fr', + 'tarn-et-garonne.gouv.fr', + 'dila.gouv.fr', + 'diplomatie.gouv.fr', + 'haut-rhin.gouv.fr', + 'nord.gouv.fr', + 'bouches-du-rhone.gouv.fr', + 'alpes-de-haute-provence.gouv.fr', + 'hautes-alpes.gouv.fr', + 'alpes-maritimes.gouv.fr', + 'var.gouv.fr', + 'vaucluse.gouv.fr', + 'rhone.gouv.fr', + 'occitanie.gouv.fr', + 'ille-et-vilaine.gouv.fr', + 'finistere.gouv.fr', + 'aisne.gouv.fr', + 'indre.gouv.fr', + 'yvelines.gouv.fr', + 'bas-rhin.gouv.fr', + 'landes.gouv.fr', + 'haute-marne.gouv.fr', + 'correze.gouv.fr', + 'val-doise.gouv.fr', + 'seine-et-marne.gouv.fr', + 'essonne.gouv.fr', + 'calvados.gouv.fr', + 'charente-maritime.gouv.fr', + 'corse-du-sud.gouv.fr', + 'gironde.gouv.fr', + 'haute-corse.gouv.fr', + 'morbihan.gouv.fr', + 'pyrenees-atlantiques.gouv.fr', + 'pyrenees-orientales.gouv.fr', + 'somme.gouv.fr', + 'vendee.gouv.fr', + 'dgtresor.gouv.fr', + 'marne.gouv.fr', + 'auvergne-rhone-alpes.gouv.fr', + 'meurthe-et-moselle.gouv.fr', + 'pm.gouv.fr', + 'oncfs.gouv.fr', + 'orne.gouv.fr', + 'charente.gouv.fr', + 'travail.gouv.fr', + 'gard.gouv.fr', + 'maine-et-loire.gouv.fr', + 'moselle.gouv.fr', + 'outre-mer.gouv.fr', + 'jscs.gouv.fr', + 'haute-garonne.gouv.fr', + 'vienne.gouv.fr', + 'dordogne.gouv.fr', + 'eure.gouv.fr', + 'meuse.gouv.fr', + 'savoie.gouv.fr', + 'doubs.gouv.fr', + 'bfc.gouv.fr', + 'education.gouv.fr', + 'ariege.gouv.fr', + 'normandie.gouv.fr', + 'gendarmerie.interieur.gouv.fr', + 'ain.gouv.fr', + 'ardennes.gouv.fr', + 'drome.gouv.fr', + 'bretagne.gouv.fr', + 'paca.gouv.fr', + 'haute-saone.gouv.fr', + 'lot.gouv.fr', + 'dgfip.finances.gouv.fr', + 'aveyron.gouv.fr', + 'gers.gouv.fr', + 'tarn.gouv.fr', + 'aude.gouv.fr', + 'lozere.gouv.fr', + 'hautes-pyrenees.gouv.fr', + 'jeunesse-sports.gouv.fr', + 'alpes.maritimes.gouv.fr', + 'dreets.gouv.fr', + 'justice.gouv.fr', + 'sports.gouv.fr', + 'nouvelle-aquitaine.gouv.fr', + 'jura.gouv.fr', + 'haute-savoie.gouv.fr', + 'creuse.gouv.fr', + 'creps-poitiers.sports.gouv.fr', + 'equipement-agriculture.gouv.fr', + 'ira-metz.gouv.fr', + 'loire.gouv.fr', + 'defense.gouv.fr', + 'paris.gouv.fr', + 'ensm.sports.gouv.fr', + 'isere.gouv.fr', + 'haute-loire.gouv.fr', + 'cantal.gouv.fr', + 'lot-et-garonne.gouv.fr', + 'reunion.pref.gouv.fr', + 'loiret.gouv.fr', + 'indre-et-loire.gouv.fr', + 'eleve.ira-metz.gouv.fr', + 'deux-sevres.gouv.fr', + 'inao.gouv.fr', + 'franceconnect.gouv.fr', + 'essone.gouv.fr', + 'workinfrance.beta.gouv.fr', + 'seine-saint-denis.gouv.fr', + 'val-de-marne.gouv.fr', + 'morbihan.pref.gouv.fr', + 'externes.justice.gouv.fr', + 'haute-vienne.gouv.fr', + 'territoire-de-belfort.gouv.fr', + 'creps-reunion.sports.gouv.fr', + 'creps-centre.sports.gouv.fr', + 'creps-rhonealpes.sports.gouv.fr', + 'creps-montpellier.sports.gouv.fr', + 'nord.pref.gouv.fr', + 'charente-maritime.pref.gouv.fr', + 'cher.gouv.fr', + 'cote-dor.gouv.fr', + 'ssi.gouv.fr', + 'ira.gouv.fr', + 'pays-de-la-loire.gouv.fr', + 'loir-et-cher.gouv.fr', + 'saone-et-loire.gouv.fr', + 'enseignementsup.gouv.fr', + 'eure-et-loir.gouv.fr', + 'yonne.gouv.fr', + 'guadeloupe.pref.gouv.fr', + 'centre-val-de-loire.gouv.fr', + 'entreprise.api.gouv.fr', + 'grand-est.gouv.fr', + 'sarthe.gouv.fr', + 'sarthe.pref.gouv.fr', + 'puy-de-dome.gouv.fr', + 'externes.sante.gouv.fr', + 'allier.gouv.fr', + 'aube.gouv.fr', + 'nievre.gouv.fr', + 'ardeche.gouv.fr', + 'api.gouv.fr', + 'hauts-de-seine.gouv.fr', + 'hauts-de-france.gouv.fr', + 'temp-beta.gouv.fr', + 'def.gouv.fr', + 'particulier.api.gouv.fr', + 'ira-lille.gouv.fr', + 'haute-saone.pref.gouv.fr', + 'yvelines.pref.gouv.fr', + 'sgg.pm.gouv.fr', + 'anah.gouv.fr', + 'corse.gouv.fr', + 'mayenne.pref.gouv.fr', + 'cote-dor.pref.gouv.fr', + 'guyane.pref.gouv.fr', + 'ira-nantes.gouv.fr', + 'igas.gouv.fr', + 'tarn.pref.gouv.fr', + 'martinique.gouv.fr', + 'creps-paca.sports.gouv.fr', + 'ofb.gouv.fr', + 'loir-et-cher.pref.gouv.fr', + 'indre-et-loire.pref.gouv.fr', + 'polynesie-francaise.pref.gouv.fr', + 'scl.finances.gouv.fr', + 'numerique.gouv.fr', + 'cantal.pref.gouv.fr', + 'territoire-de-belfort.pref.gouv.fr', + 'creps-wattignies.sports.gouv.fr', + 'vienne.pref.gouv.fr', + 'ardennes.pref.gouv.fr', + 'creps-strasbourg.sports.gouv.fr', + 'creps-dijon.sports.gouv.fr', + 'ara.gouv.fr', + 'sgdsn.gouv.fr', + 'pays-de-la-loire.pref.gouv.fr', + 'anct.gouv.fr', + 'creps-pap.sports.gouv.fr', + 'sgae.gouv.fr', + 'esnm.sports.gouv.fr', + 'nouvelle-caledonie.gouv.fr', + 'deets.gouv.fr', + 'mayotte.gouv.fr', + 'creps-bordeaux.sports.gouv.fr', + 'civs.gouv.fr', + 'iga.interieur.gouv.fr', + 'cab.travail.gouv.fr', + 'ira-bastia.gouv.fr', + 'ira-lyon.gouv.fr', + 'creps-lorraine.sports.gouv.fr', + 'dihal.gouv.fr', + 'ofpra.gouv.fr', + 'mayotte.pref.gouv.fr', + 'strategie.gouv.fr', + 'territoires.gouv.fr', + 'dgcl.gouv.fr', + 'doubs.pref.gouv.fr', + 'service-civique.gouv.fr', + 'maine-et-loire.pref.gouv.fr', + 'envsn.sports.gouv.fr', + 'wallis-et-futuna.pref.gouv.fr', + 'gendarmerie.defense.gouv.fr', + 'anlci.gouv.fr', + 'cabinets.finances.gouv.fr', + 'seine-maritime.pref.gouv.fr', + 'promo46.ira-metz.gouv.fr', + 'aisne.pref.gouv.fr', + 'sportsdenature.gouv.fr', + 'loire-atlantique.pref.gouv.fr', + 'aude.pref.gouv.fr', + 'premier-ministre.gouv.fr', + 'igf.finances.gouv.fr', + 'eleves.ira-bastia.gouv.fr', + 'igesr.gouv.fr', + 'alpc.gouv.fr', + 'externes.emploi.gouv.fr', + 'prestataire.finances.gouv.fr', + 'gironde.pref.gouv.fr', + 'premar-atlantique.gouv.fr', + 'creps-toulouse.sports.gouv.fr', + 'guadeloupe.gouv.fr', + 'cybermalveillance.gouv.fr', + 'dicod.defense.gouv.fr', + 'creps-vichy.sports.gouv.fr', + 'aft.gouv.fr', + 'equipement.gouv.fr', + 'academie.defense.gouv.fr', + 'aube.pref.gouv.fr', + 'seine-et-marne.pref.gouv.fr', + 'pyrenees-orientales.pref.gouv.fr', + 'haute-garonne.pref.gouv.fr', + 'haut-rhin.pref.gouv.fr', + 'seine-saint-denis.pref.gouv.fr', + 'dcstep.gouv.fr', + 'promo47.ira-metz.gouv.fr', + 'trackdechets.beta.gouv.fr', + 'val-de-marne.pref.gouv.fr', + 'fabrique.social.gouv.fr', + 'agrasc.gouv.fr', + 'indre.pref.gouv.fr', + 'tarn-et-garonne.pref.gouv.fr', + 'corse.pref.gouv.fr', + 'bas-rhin.pref.gouv.fr', + 'inclusion.beta.gouv.fr', + 'hauts-de-seine.pref.gouv.fr', + 'loiret.pref.gouv.fr', + 'essonne.pref.gouv.fr', + 'territoires-industrie.gouv.fr', + 'spm975.gouv.fr', + 'saint-barth-saint-martin.gouv.fr', + 'judiciaire.interieur.gouv.fr', + 'mer.gouv.fr', + 'premar-manche.gouv.fr', + 'haute-normandie.pref.gouv.fr', + 'prestataire.modernisation.gouv.fr', + 'covoiturage.beta.gouv.fr', + 'promo48.ira-metz.gouv.fr', + 'france-services.gouv.fr', + 'ddets.gouv.fr', + 'afa.gouv.fr', + 'externes.social.gouv.fr', + 'vosges.pref.gouv.fr', + 'reunion.gouv.fr', + 'rhone.pref.gouv.fr', + 'alpes-maritimes.pref.gouv.fr', + 'gard.pref.gouv.fr', + 'oise.pref.gouv.fr', + 'creps-reims.sports.gouv.fr', + 'bouches-du-rhone.pref.gouv.fr', + 'esante.gouv.fr', + 'rhone-alpes.pref.gouv.fr', + 'finistere.pref.gouv.fr', + 'ops-bss.defense.gouv.fr', + 'orne.pref.gouv.fr', + 'transformation.gouv.fr', + 'cbcm.social.gouv.fr', + 'recosante.beta.gouv.fr', + 'pas-de-calais.pref.gouv.fr', + 'promo49.ira-metz.gouv.fr', + 'paca.pref.gouv.fr', + 'meurthe-et-moselle.pref.gouv.fr', + 'externes.sg.social.gouv.fr', + 'puy-de-dome.pref.gouv.fr', + 'academie.def.gouv.fr', + 'tarn.gouv.frd81intranet.ddcspp.tarn.gouv.fr', + 'agriculture-equipement.gouv.fr', + 'creps-idf.sports.gouv.fr', + 'eleve.ira-nantes.gouv.fr', + 'cohesion-territoires.gouv.fr', + 'ariege.pref.gouv.fr', + 'pyrenees-atlantiques.pref.gouv.fr', + 'hautes-pyrenees.pref.gouv.fr', + 'lot-et-garonne.pref.gouv.fr', + 'loire.pref.gouv.fr', + 'info-routiere.gouv.fr', + 'diges.gouv.fr', + 'insp.gouv.fr', + 'creps-pdl.sports.gouv.fr', + 'ddc.social.gouv.fr', + 'eleve.insp.gouv.fr', + 'val-doise.pref.gouv.fr', + 'montsaintmichel.gouv.fr', + 'st-cyr.terre-net.defense.gouv.fr', + '.finances.gouv.fr', + 'logement.gouv.fr', + 'cotes-darmor.pref.gouv.fr', + 'marne.pref.gouv.fr', + 'herault.pref.gouv.fr', + 'viennne.gouv.fr', + 'landes.pref.gouv.fr', + 'moselle.pref.gouv.fr', + 'saone-et-loire.pref.gouv.fr', + 'bmpm.gouv.fr', + 'ecologie-territoires.gouv.fr', + 'nievre.pref.gouv.fr', + 'hautes-pyrénées.gouv.fr', + 'gic.gouv.fr', + 'industrie.gouv.fr', + 'lot.pref.gouv.fr', + 'plan.gouv.fr', + 'internet.gouv.fr', + 'mesads.beta.gouv.fr', + 'gers.pref.gouv.fr', + 'dordogne.pref.gouv.fr', + 'somme.pref.gouv.fr', + 'datasubvention.beta.gouv.fr', + 'anc.gouv.fr', + 'premar-mediterranee.gouv.fr', + 'ille-et-vilaine.pref.gouv.fr', + 'eure-et-loir.pref.gouv.fr', + 'prestataires.pm.gouv.fr', + 'snu.gouv.fr', + 'code.gouv.fr', + 'alsace.pref.gouv.fr', + 'haute-vienne.pref.gouv.fr', + 'yonne.pref.gouv.fr', + 'bretagne.pref.gouv.fr', + 'mastere.insp.gouv.fr', + 'cada.pm.gouv.fr', + 'creuse.pref.gouv.fr', + 'ecologie.gouv.fr', + 'midi-pyrenees.pref.gouv.fr', + 'promo54.ira-metz.gouv.fr', + 'var.pref.gouv.fr', + 'alpes-de-haute-provence.pref.gouv.fr', + 'mail.numerique.gouv.fr', + 'france-identite.gouv.fr', + 'transport.data.gouv.fr', + 'allier.pref.gouv.fr', + 'dilhal.gouv.fr', + 'ardeche.pref.gouv.fr', + 'haute-corse.pref.gouv.fr', + 'intérieur.gouv.fr', + 'ddfip.gouv.fr', + 'calvados.pref.gouv.fr', + 'territoir-de-belfort.gouv.fr', + 'nor.gouv.fr', + 'creps-occitanie.sports.gouv.fr', + 'developpement-durabe.gouv.fr', + 'educ.nat.gouv.fr', + 'developpement-duable.gouv.fr', + 'dgfip.finanes.gouv.fr', + 'loire-atlantqieu.gouv.fr', + 'promo55.ira-metz.gouv.fr', + 'haute-saône.gouv.fr', + 'developpement.durable.gouv.fr', + 'dreet.gouv.fr', + 'miprof.gouv.fr', + 'pref.guyane.gouv.fr', + 'developpement.gouv.fr', + 'gendamrerie.interieur.gouv.fr', + 'pyrenees-atlantique.gouv.fr', + 'apprentissage.beta.gouv.fr', + 'yveliens.gouv.fr', + 'justiice.gouv.fr', + 'cutlure.gouv.fr', + 'aidantsconnect.beta.gouv.fr', + 'developpement-durbale.gouv.fr', + 'sine-et-marne.gouv.fr', + 'sociale.gouv.fr', + 'develeoppement-durable.gouv.fr', + 'draaf.gouv.fr', + 'drets.gouv.fr', + 'ancli.gouv.fr', + 'finistrere.gouv.fr', + 'bourgogne.pref.gouv.fr', + 'ac-polynesie.pf', + 'ac-lille.fr', + 'ac-nantes.fr', + 'ac-martinique.fr', + 'ac-creteil.fr', + 'ac-toulouse.fr', + 'ac-amiensfr', + 'ac-amiens.fr', + 'ac-rennes.fr', + 'ac-strasbourg.fr', + 'ac-lyon.fr', + 'ac-versailles.fr', + 'ac-audit.fr', + 'ac-rouen.fr', + 'ac-reunion.fr', + 'ac-poitiers.fr', + 'ac-caen.fr', + 'ac-montpellier.fr', + 'ac-paris.fr', + 'ac-besancon.fr', + 'ac-nancy-metz.fr', + 'ac-aix-marseille.fr', + 'ac-grenoble.fr', + 'ac-corse.fr', + 'ac-nice.fr', + 'ac-orleans-tours.fr', + 'ac-guadeloupe.fr', + 'ac-reims.fr', + 'ac-mayotte.fr', + 'ac-clermont.fr', + 'ac-bordeaux.fr', + 'ac-limoges.fr', + 'ac-normandie.fr', + 'ac-dijon.fr', + 'ac-guyane.fr', + 'ac-transports.fr', + 'ac-arpajonnais.com', + 'ac-cned.fr', + 'ac-nettoyage.com', + 'ac-architectes.fr', + 'ac-ajaccio.corsica', + 'ac-noumea.nc', + 'ac-spm.fr', + 'ac-versailes.fr', + 'ac-polynesie.fr', + 'ac-experts.fr', + 'ac-creteil.com', + 'ac-smart-relocation.com', + 'ac-ec.pro', + 'ac-sas.fr', + 'ac-derma.de', + 'ac-or.com', + 'ac-baugeois.fr', + 'ac-5.ru', + 'ac-arles.fr', + 'ac-holding.net', + 'ac-mb.fr', + 'ac-wf.wf', + 'ac-brest-finistere.fr', + 'ac-leman.com', + 'ac-darboussier.fr', + 'ac-si.fr', + 'ac-bordeau.fr', + 'ac-gatinais.com', + 'ac-cheminots.fr', + 'ac-seyssinet.com', + 'ac-cannes.fr', + 'ac-prev.com', + 'ac-sologne.fr', + 'ac-rennes', + 'ac-courbevoie.com', + 'ac-ce.fr', + 'ac-architecte.fr', + 'ac-tions.org', + 'ac-pm.fr', + 'ac-avocats.com', + 'ac-talents-rh.com', + 'ac-louis.com', + 'ac-internet.fr', + 'ac-toulouse.com', + 'ac-escial.fr', + 'ac-environnement.com', + 'ac-academie.fr', + 'ac-poiters.fr', + 'ac-bordeux.fr', + 'ac-verseilles.fr', + 'ac-ais-marseille.fr', + 'ac-horizon.fr', + 'ac-bordeaux.ft', + 'ac-toulouses.fr', + 'ac-toulous.fr' + ].freeze + + def self.check(email:) + return { success: false } if email.blank? + + parsed_email = Mail::Address.new(email) + return { success: false } if parsed_email.domain.blank? + + return { success: true } if KNOWN_DOMAINS.any? { _1 == parsed_email.domain } + + similar_domains = closest_domains(domain: parsed_email.domain) + return { success: true } if similar_domains.empty? + + { success: true, email_suggestions: email_suggestions(parsed_email:, similar_domains:) } + end + + private + + def self.closest_domains(domain:) + KNOWN_DOMAINS.filter do |known_domain| + close_by_distance_of(domain, known_domain, distance: 1) || + with_same_chars_and_close_by_distance_of(domain, known_domain, distance: 2) + end + end + + def self.close_by_distance_of(a, b, distance:) + String::Similarity.levenshtein_distance(a, b) == distance + end + + def self.with_same_chars_and_close_by_distance_of(a, b, distance:) + close_by_distance_of(a, b, distance: 2) && a.chars.sort == b.chars.sort + end + + def self.email_suggestions(parsed_email:, similar_domains:) + similar_domains.map { Mail::Address.new("#{parsed_email.local}@#{_1}").to_s } + end +end diff --git a/spec/lib/email_checker_spec.rb b/spec/lib/email_checker_spec.rb new file mode 100644 index 000000000..cfcf73bfa --- /dev/null +++ b/spec/lib/email_checker_spec.rb @@ -0,0 +1,36 @@ +describe EmailChecker do + describe 'check' do + subject { described_class } + + it 'works with identified use cases' do + expect(subject.check(email: nil)).to eq({ success: false }) + expect(subject.check(email: '')).to eq({ success: false }) + expect(subject.check(email: 'panpan')).to eq({ success: false }) + + # allow same domain + expect(subject.check(email: "martin@orange.fr")).to eq({ success: true }) + # find difference of 1 lev distance + expect(subject.check(email: "martin@orane.fr")).to eq({ success: true, email_suggestions: ['martin@orange.fr'] }) + # find difference of 2 lev distance, only with same chars + expect(subject.check(email: "martin@oragne.fr")).to eq({ success: true, email_suggestions: ['martin@orange.fr'] }) + # ignore unknown domain + expect(subject.check(email: "martin@ore.fr")).to eq({ success: true }) + end + + it 'passes through real use cases, with levenshtein_distance 1' do + expect(subject.check(email: "martin@asn.com")).to eq({ success: true, email_suggestions: ['martin@msn.com'] }) + expect(subject.check(email: "martin@gamail.com")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@glail.com")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@gmail.coml")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@gmail.con")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@hotmil.fr")).to eq({ success: true, email_suggestions: ['martin@hotmail.fr'] }) + expect(subject.check(email: "martin@mail.com")).to eq({ success: true, email_suggestions: ["martin@gmail.com", "martin@ymail.com", "martin@mailo.com"] }) + expect(subject.check(email: "martin@msc.com")).to eq({ success: true, email_suggestions: ["martin@msn.com", "martin@mac.com"] }) + expect(subject.check(email: "martin@ymail.com")).to eq({ success: true }) + end + + it 'passes through real use cases, with levenshtein_distance 2, must share all chars' do + expect(subject.check(email: "martin@oise.fr")).to eq({ success: true }) # could be live.fr + end + end +end From 66eb3dc821d924f9a3143398ad2263b3b54c2485 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 7 Jun 2024 10:06:40 +0200 Subject: [PATCH 42/63] feat(email_check): change strategy to check email, dropping email_buttler package and using a custom EmailChecker --- app/components/dsfr/input_component.rb | 2 +- app/controllers/email_checker_controller.rb | 5 +++ .../controllers/email_input_controller.ts | 31 +++++++++++--- bun.lockb | Bin 502268 -> 501940 bytes config/routes.rb | 1 + package.json | 1 - .../email_checker_controller_spec.rb | 39 ++++++++++++++++++ 7 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 app/controllers/email_checker_controller.rb create mode 100644 spec/controllers/email_checker_controller_spec.rb diff --git a/app/components/dsfr/input_component.rb b/app/components/dsfr/input_component.rb index 3ee07149b..367ed74b0 100644 --- a/app/components/dsfr/input_component.rb +++ b/app/components/dsfr/input_component.rb @@ -32,7 +32,7 @@ class Dsfr::InputComponent < ApplicationComponent }.merge(input_group_error_class_names)) } if email? - opts[:data] = { controller: 'email-input' } + opts[:data] = { controller: 'email-input', email_input_url_value: show_email_suggestions_path } end opts end diff --git a/app/controllers/email_checker_controller.rb b/app/controllers/email_checker_controller.rb new file mode 100644 index 000000000..b794b4d7a --- /dev/null +++ b/app/controllers/email_checker_controller.rb @@ -0,0 +1,5 @@ +class EmailCheckerController < ApplicationController + def show + render json: EmailChecker.check(email: params[:email]) + end +end diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 8eed97fa9..8b64a7e92 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -1,18 +1,39 @@ -import { suggest } from 'email-butler'; +import { httpRequest } from '@utils'; import { show, hide } from '@utils'; import { ApplicationController } from './application_controller'; +type checkEmailResponse = { + success: boolean; + email_suggestions: string[]; +}; + export class EmailInputController extends ApplicationController { static targets = ['ariaRegion', 'suggestion', 'input']; + static values = { + url: String + }; + + declare readonly urlValue: string; + declare readonly ariaRegionTarget: HTMLElement; declare readonly suggestionTarget: HTMLElement; declare readonly inputTarget: HTMLInputElement; - checkEmail() { - const suggestion = suggest(this.inputTarget.value); - if (suggestion && suggestion.full) { - this.suggestionTarget.innerHTML = suggestion.full; + async checkEmail() { + if (!this.inputTarget.value) { + return; + } + + const url = new URL(this.urlValue, document.baseURI); + url.searchParams.append('email', this.inputTarget.value); + + const data: checkEmailResponse | null = await httpRequest( + url.toString() + ).json(); + + if (data && data.email_suggestions && data.email_suggestions.length > 0) { + this.suggestionTarget.innerHTML = data.email_suggestions[0]; show(this.ariaRegionTarget); this.ariaRegionTarget.setAttribute('aria-live', 'assertive'); } diff --git a/bun.lockb b/bun.lockb index 9cc8b3bb356a97e0f908f76bd947b656fda4a8ab..7c777a1117455aa446b2e741a83ac6e465d43f38 100755 GIT binary patch delta 86005 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 Pg5Km5G8Gr5*!I5wC7i}^ delta 86386 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 diff --git a/config/routes.rb b/config/routes.rb index c4973d1ae..8b5f2152e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -161,6 +161,7 @@ Rails.application.routes.draw do end get 'password_complexity' => 'password_complexity#show', as: 'show_password_complexity' + get 'check_email' => 'email_checker#show', as: 'show_email_suggestions' resources :targeted_user_links, only: [:show] diff --git a/package.json b/package.json index 8d5144609..edad7ebc6 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "core-js": "^3.37.1", "date-fns": "^2.30.0", "debounce": "^1.2.1", - "email-butler": "^1.0.13", "geojson": "^0.5.0", "graphiql": "^3.2.3", "graphql": "^16.8.1", diff --git a/spec/controllers/email_checker_controller_spec.rb b/spec/controllers/email_checker_controller_spec.rb new file mode 100644 index 000000000..4572c2cd4 --- /dev/null +++ b/spec/controllers/email_checker_controller_spec.rb @@ -0,0 +1,39 @@ +describe EmailCheckerController, type: :controller do + describe '#show' do + render_views + before { get :show, format: :json, params: params } + let(:body) { JSON.parse(response.body, symbolize_names: true) } + + context 'valid email' do + let(:params) { { email: 'martin@orange.fr' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: true }) + end + end + + context 'email with typo' do + let(:params) { { email: 'martin@orane.fr' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: true, email_suggestions: ['martin@orange.fr'] }) + end + end + + context 'empty' do + let(:params) { { email: '' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end + + context 'notanemail' do + let(:params) { { email: 'clarkkent' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end + end +end From d2ccea700bf8264fc05f0697e0d0fd0b0ceefb82 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 10 Jun 2024 09:35:29 +0200 Subject: [PATCH 43/63] bug(TypesDeChamp::ConditionValidator): should allow to use types_de_champ_public on condition for types_de_champ_private --- spec/models/procedure_spec.rb | 59 +++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0589985bb..080612253 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -352,24 +352,12 @@ describe Procedure do end describe 'draft_types_de_champ validations' do - let(:repetition) { repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?) } - let(:text_field) { build(:type_de_champ_text) } - let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' } - - let(:drop_down) { build(:type_de_champ_drop_down_list, :without_selectable_values, libelle: 'Civilité') } - let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' } - - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text }, { type: :integer_number }] }]) } - let(:draft) { procedure.draft_revision } - - before do - draft.revision_types_de_champ.create(type_de_champ: drop_down, position: 100) - - repetition.update(libelle: 'Enfants') - draft.children_of(repetition).destroy_all - end + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } context 'on a draft procedure' do + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [{ type: :repetition, libelle: 'Enfants', children: [] }] } + it 'doesn’t validate the types de champs' do procedure.validate expect(procedure.errors[:draft_types_de_champ_public]).not_to be_present @@ -377,12 +365,22 @@ describe Procedure do end context 'when validating for publication' do + let(:types_de_champ_public) do + [ + { type: :repetition, libelle: 'Enfants', children: [] }, + { type: :drop_down_list, libelle: 'Civilité', options: [] } + ] + end + let(:types_de_champ_private) { [] } + let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' } + let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' } + it 'validates that no repetition type de champ is empty' do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message) new_draft = procedure.draft_revision - + repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?) parent_coordinate = new_draft.revision_types_de_champ.find_by(type_de_champ: repetition) new_draft.revision_types_de_champ.create(type_de_champ: create(:type_de_champ), position: 0, parent: parent_coordinate) @@ -394,6 +392,7 @@ describe Procedure do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message) + drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) drop_down.update!(drop_down_list_value: "--title--\r\nsome value") procedure.reload.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message) @@ -401,10 +400,13 @@ describe Procedure do end context 'when the champ is private' do - before do - repetition.update(private: true) - drop_down.update(private: true) + let(:types_de_champ_private) do + [ + { type: :repetition, libelle: 'Enfants', children: [] }, + { type: :drop_down_list, libelle: 'Civilité', options: [] } + ] end + let(:types_de_champ_public) { [] } let(:invalid_repetition_error_message) { 'L’annotation privée « Enfants » doit comporter au moins un champ répétable' } let(:invalid_drop_down_error_message) { 'L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable' } @@ -418,6 +420,23 @@ describe Procedure do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) end + + it 'validates that types de champ private condition works types de champ public and private' do + end + end + + context 'when condition on champ private use public champ' do + include Logic + let(:types_de_champ_private) { [{ type: :text, condition: ds_eq(champ_value(1), constant(2)) }] } + let(:types_de_champ_public) { [{ type: :number, stable_id: 1 }] } + + it 'validate without context' do + expect(procedure.validate).to be_truthy + end + + it 'validate with types_de_champ_private_editor' do + expect(procedure.validate(:types_de_champ_private_editor)).to be_falsey + end end end From 27b86f9848be5fcb73c4d04b7c1e99ca97b6a79a Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 10 Jun 2024 09:57:34 +0200 Subject: [PATCH 44/63] fix(TypesDeChamp::ConditionValidator): allow to use types_de_champ_public on condition for types_de_champ_private --- .../types_de_champ/condition_validator.rb | 35 ++++++++++--------- spec/models/procedure_spec.rb | 35 +++++++++++++------ .../procedure_export_service_zip_spec.rb | 3 +- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb index 74b57c3d5..65e1e887a 100644 --- a/app/validators/types_de_champ/condition_validator.rb +++ b/app/validators/types_de_champ/condition_validator.rb @@ -1,21 +1,24 @@ class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator def validate_each(procedure, attribute, types_de_champ) - public_tdcs = types_de_champ.to_a - .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + return if types_de_champ.empty? - public_tdcs - .map.with_index - .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } - .map do |tdc, i| - [tdc, tdc.condition.errors(public_tdcs.take(i))] - end - .filter { |_tdc, errors| errors.present? } - .each do |tdc, _error_hash| - procedure.errors.add( - attribute, - procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), - type_de_champ: tdc - ) - end + tdcs = if attribute == :draft_types_de_champ_private + procedure.draft_revision.types_de_champ_for + else + procedure.draft_revision.types_de_champ_for(scope: :public) + end + + tdcs.each_with_index do |tdc, i| + next unless tdc.condition? + + errors = tdc.condition.errors(tdcs.take(i)) + next if errors.blank? + + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), + type_de_champ: tdc + ) + end end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 080612253..7c4c8c1e6 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -211,7 +211,7 @@ describe Procedure do it { is_expected.to allow_value('text').on(:publication).for(:cadre_juridique) } context 'with deliberation' do - let(:procedure) { build(:procedure, cadre_juridique: nil) } + let(:procedure) { build(:procedure, cadre_juridique: nil, revisions: [build(:procedure_revision)]) } it { expect(procedure.valid?(:publication)).to eq(false) } @@ -420,22 +420,37 @@ describe Procedure do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) end - - it 'validates that types de champ private condition works types de champ public and private' do - end end context 'when condition on champ private use public champ' do include Logic - let(:types_de_champ_private) { [{ type: :text, condition: ds_eq(champ_value(1), constant(2)) }] } - let(:types_de_champ_public) { [{ type: :number, stable_id: 1 }] } - + let(:types_de_champ_public) { [{ type: :decimal_number, stable_id: 1 }] } + let(:types_de_champ_private) { [{ type: :text, condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } it 'validate without context' do - expect(procedure.validate).to be_truthy + procedure.validate + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty end - it 'validate with types_de_champ_private_editor' do - expect(procedure.validate(:types_de_champ_private_editor)).to be_falsey + it 'validate allows condition' do + procedure.validate(:types_de_champ_private_editor) + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty + end + end + + context 'when condition on champ public use private champ' do + include Logic + let(:types_de_champ_public) { [{ type: :text, libelle: 'condition', condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } + let(:types_de_champ_private) { [{ type: :decimal_number, stable_id: 1 }] } + let(:error_on_condition) { "Le champ « condition » a une logique conditionnelle invalide" } + + it 'validate without context' do + procedure.validate + expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to be_empty + end + + it 'validate prevent condition' do + procedure.validate(:types_de_champ_public_editor) + expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(error_on_condition) end end end diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 14e5ceaad..e4daaf3ee 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -39,8 +39,7 @@ describe ProcedureExportService do ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do subject end - - expect(sql_count <= 58).to be_truthy + expect(sql_count <= 62).to be_truthy dossier = dossiers.first From 915aec6894d0ddee5a19d10f05e654f00716f7a4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 10 Jun 2024 13:06:31 +0200 Subject: [PATCH 45/63] chore(apiv1): fix regression with attachment url in commentaire --- app/serializers/commentaire_serializer.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/serializers/commentaire_serializer.rb b/app/serializers/commentaire_serializer.rb index 2941765e2..94f652147 100644 --- a/app/serializers/commentaire_serializer.rb +++ b/app/serializers/commentaire_serializer.rb @@ -2,9 +2,18 @@ class CommentaireSerializer < ActiveModel::Serializer attributes :email, :body, :created_at, - :piece_jointe_attachments + :piece_jointe_attachments, + :attachment def created_at object.created_at&.in_time_zone('UTC') end + + def attachment + piece_jointe = object.piece_jointe_attachments.first + + if piece_jointe&.virus_scanner&.safe? + Rails.application.routes.url_helpers.url_for(piece_jointe) + end + end end From 1d4a8795c8c38384d8e670c21846de997dc22bf2 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 10 Jun 2024 14:28:50 +0200 Subject: [PATCH 46/63] fix(clipboard): if unsupported, don't hide element when button is on another target --- app/javascript/controllers/clipboard_controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/clipboard_controller.ts b/app/javascript/controllers/clipboard_controller.ts index d2b4ae1cf..cecdc5d93 100644 --- a/app/javascript/controllers/clipboard_controller.ts +++ b/app/javascript/controllers/clipboard_controller.ts @@ -17,7 +17,11 @@ export class ClipboardController extends Controller { connect(): void { // some extensions or browsers block clipboard if (!navigator.clipboard) { - this.element.classList.add('hidden'); + if (this.hasToHideTarget) { + this.toHideTarget.classList.add('hidden'); + } else { + this.element.classList.add('hidden'); + } } } From a8e382d0d07369977111ce5b70e36841d605946d Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Mon, 10 Jun 2024 14:55:51 +0200 Subject: [PATCH 47/63] fix(clone): le lien monavis est reinitialise lors du clonage --- app/models/procedure.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index af398a991..3d6c05b1f 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -559,6 +559,7 @@ class Procedure < ApplicationRecord procedure.closing_notification_brouillon = false procedure.closing_notification_en_cours = false procedure.template = false + procedure.monavis_embed = nil if !procedure.valid? procedure.errors.attribute_names.each do |attribute| From 603bb97679ec8e9f9a33eacb014062b6fbe1238c Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 10 Jun 2024 11:29:50 +0200 Subject: [PATCH 48/63] fix(gallery): catch StandardError --- app/helpers/gallery_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb index 98f7ffc1e..1f9ddeeab 100644 --- a/app/helpers/gallery_helper.rb +++ b/app/helpers/gallery_helper.rb @@ -9,19 +9,19 @@ module GalleryHelper def preview_url_for(attachment) attachment.preview(resize_to_limit: [400, 400]).processed.url - rescue ActiveStorage::Error + rescue StandardError 'pdf-placeholder.png' end def variant_url_for(attachment) attachment.variant(resize_to_limit: [400, 400]).processed.url - rescue ActiveStorage::Error + rescue StandardError 'apercu-indisponible.png' end def blob_url(attachment) attachment.blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : attachment.blob.url - rescue ActiveStorage::Error + rescue StandardError attachment.blob.url end end From 810d272be25e4094431eaf2be9218e62ff58264f Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:02:59 +0200 Subject: [PATCH 49/63] feat(procedure_revision): add ineligibilite columns --- ...409075536_add_transitions_rules_to_procedure_revisions.rb | 5 +++++ ...7_add_dossier_ineligble_message_to_procedure_revisions.rb | 5 +++++ ...dd_eligibilite_dossiers_enabled_to_procedure_revisions.rb | 5 +++++ db/schema.rb | 3 +++ 4 files changed, 18 insertions(+) create mode 100644 db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb create mode 100644 db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb create mode 100644 db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb diff --git a/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb b/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb new file mode 100644 index 000000000..e2a654783 --- /dev/null +++ b/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddTransitionsRulesToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_rules, :jsonb + end +end diff --git a/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb b/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb new file mode 100644 index 000000000..bf8464f8c --- /dev/null +++ b/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddDossierIneligbleMessageToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_message, :text + end +end diff --git a/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb b/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb new file mode 100644 index 000000000..19ce1d243 --- /dev/null +++ b/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddEligibiliteDossiersEnabledToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f7fcfe7c..b33a10582 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -863,6 +863,9 @@ ActiveRecord::Schema[7.0].define(version: 2024_05_27_090508) do create_table "procedure_revisions", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.bigint "dossier_submitted_message_id" + t.boolean "ineligibilite_enabled", default: false, null: false + t.text "ineligibilite_message" + t.jsonb "ineligibilite_rules" t.bigint "procedure_id", null: false t.datetime "published_at", precision: nil t.datetime "updated_at", precision: nil, null: false From 12d23f1498208fc12e01b9447aac84a38a30798f Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:08:00 +0200 Subject: [PATCH 50/63] feat(Procedure::Cards::IneligibleDossier): add an ineligibilite dossier card to procedure dashboard --- .../conditions/conditions_component.rb | 2 +- .../card/ineligibilite_dossier_component.rb | 19 ++++++++++++++ .../ineligibilite_dossier_component.fr.yml | 8 ++++++ .../ineligibilite_dossier_component.html.haml | 15 +++++++++++ app/models/procedure_revision.rb | 4 +++ app/models/type_de_champ.rb | 4 +++ .../administrateurs/procedures/show.html.haml | 1 + .../card/ineligibilite_dossier_component.rb | 25 +++++++++++++++++++ 8 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 app/components/procedure/card/ineligibilite_dossier_component.rb create mode 100644 app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml create mode 100644 app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml create mode 100644 spec/components/procedures/card/ineligibilite_dossier_component.rb diff --git a/app/components/conditions/conditions_component.rb b/app/components/conditions/conditions_component.rb index 01f081a85..8817a1336 100644 --- a/app/components/conditions/conditions_component.rb +++ b/app/components/conditions/conditions_component.rb @@ -61,7 +61,7 @@ class Conditions::ConditionsComponent < ApplicationComponent def available_targets_for_select @source_tdcs - .filter { |tdc| ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(tdc.type_champ) } + .filter(&:conditionable?) .map { |tdc| [tdc.libelle, champ_value(tdc.stable_id).to_json] } end diff --git a/app/components/procedure/card/ineligibilite_dossier_component.rb b/app/components/procedure/card/ineligibilite_dossier_component.rb new file mode 100644 index 000000000..d69e06623 --- /dev/null +++ b/app/components/procedure/card/ineligibilite_dossier_component.rb @@ -0,0 +1,19 @@ +class Procedure::Card::IneligibiliteDossierComponent < ApplicationComponent + def initialize(procedure:) + @procedure = procedure + end + + def ready? + @procedure.draft_revision + .conditionable_types_de_champ + .present? + end + + def error? + !@procedure.draft_revision.validate(:ineligibilite_rules_editor) + end + + def completed? + @procedure.draft_revision.ineligibilite_enabled + end +end diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml new file mode 100644 index 000000000..d65f0d535 --- /dev/null +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml @@ -0,0 +1,8 @@ +--- +fr: + title: Inéligibilité des dossiers + state: + pending: Champs à configurer + ready: À configurer + completed: Activé + subtitle: Gérez vos critères d’inéligibilité en fonction des champs du formulaire diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml new file mode 100644 index 000000000..e82e64fad --- /dev/null +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml @@ -0,0 +1,15 @@ +.fr-col-6.fr-col-md-4.fr-col-lg-3 + = link_to edit_admin_procedure_ineligibilite_rules_path(@procedure), class: 'fr-tile fr-enlarge-link' do + .fr-tile__body.flex.column.align-center.justify-between + - if !ready? + %p.fr-badge.fr-badge--warning= t('.state.pending') + - elsif error? + %p.fr-badge.fr-badge--error À modifier + - elsif !completed? + %p.fr-badge.fr-badge--info= t('.state.ready') + - else + %p.fr-badge.fr-badge--success= t('.state.completed') + %div + %h3.fr-h6.fr-mt-10v= t('.title') + %p.fr-tile-subtitle= t('.subtitle') + %p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit') diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index fef3516d5..157b4dd67 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -251,6 +251,10 @@ class ProcedureRevision < ApplicationRecord types_de_champ_public.filter(&:routable?) end + def conditionable_types_de_champ + types_de_champ_for(scope: :public).filter(&:conditionable?) + end + private def compute_estimated_fill_duration diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 10e415c05..cf2c321d8 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -657,6 +657,10 @@ class TypeDeChamp < ApplicationRecord type_champ.in?(ROUTABLE_TYPES) end + def conditionable? + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(type_champ) + end + def invalid_regexp? self.errors.delete(:expression_reguliere) self.errors.delete(:expression_reguliere_exemple_text) diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 345eb2824..4d63d909b 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -71,6 +71,7 @@ = render Procedure::Card::PresentationComponent.new(procedure: @procedure) = render Procedure::Card::ZonesComponent.new(procedure: @procedure) if Rails.application.config.ds_zonage_enabled = render Procedure::Card::ChampsComponent.new(procedure: @procedure) + = render Procedure::Card::IneligibiliteDossierComponent.new(procedure: @procedure) = render Procedure::Card::ServiceComponent.new(procedure: @procedure, administrateur: current_administrateur) = render Procedure::Card::AdministrateursComponent.new(procedure: @procedure) = render Procedure::Card::InstructeursComponent.new(procedure: @procedure) diff --git a/spec/components/procedures/card/ineligibilite_dossier_component.rb b/spec/components/procedures/card/ineligibilite_dossier_component.rb new file mode 100644 index 000000000..433b59155 --- /dev/null +++ b/spec/components/procedures/card/ineligibilite_dossier_component.rb @@ -0,0 +1,25 @@ +describe Procedure::Card::IneligibiliteDossierComponent, type: :component do + describe 'render' do + subject do + render_inline(described_class.new(procedure: procedure)) + end + + context 'when none of types_de_champ_public supports conditional' do + let(:procedure) { create(:procedure, types_de_champ_public: []) } + + it 'render missing setup' do + subject + expect(page).to have_text('Champs manquant') + end + end + + context 'when at least one of types_de_champ_public support conditional' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } + + it 'render the template' do + subject + expect(page).to have_text('À configurer') + end + end + end +end From aca3e38859784c89a9c9f0fb7558622a479d06a3 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:25:10 +0200 Subject: [PATCH 51/63] feat(ProcedureRevision.ineligibilite_rules): add ineligibilite_rules management to procedure revision based on conditional logic --- .../ineligibilite_rules_component.rb | 34 +++ .../ineligibilite_rules_component.fr.yml | 6 + .../ineligibilite_rules_component.html.haml | 42 ++++ .../procedure/pending_republish_component.rb | 10 + .../pending_republish_component.fr.yml | 4 + .../pending_republish_component.html.haml | 3 + .../ineligibilite_rules_controller.rb | 74 ++++++ app/models/procedure_revision.rb | 12 + .../_update.turbo_stream.haml | 7 + .../add_row.turbo_stream.haml | 1 + .../change_targeted_champ.turbo_stream.haml | 1 + .../delete_row.turbo_stream.haml | 1 + .../destroy.turbo_stream.haml | 1 + .../ineligibilite_rules/edit.html.haml | 28 +++ .../update.turbo_stream.haml | 1 + config/env.example.optional | 3 + config/initializers/02_urls.rb | 1 + config/routes.rb | 8 + .../ineligibilite_rules_component_spec.rb | 64 +++++ .../pending_republish_component_spec.rb | 14 ++ .../ineligibilite_rules_controller_spec.rb | 231 ++++++++++++++++++ .../procedure_ineligibilite_spec.rb | 45 ++++ 22 files changed, 591 insertions(+) create mode 100644 app/components/conditions/ineligibilite_rules_component.rb create mode 100644 app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml create mode 100644 app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml create mode 100644 app/components/procedure/pending_republish_component.rb create mode 100644 app/components/procedure/pending_republish_component/pending_republish_component.fr.yml create mode 100644 app/components/procedure/pending_republish_component/pending_republish_component.html.haml create mode 100644 app/controllers/administrateurs/ineligibilite_rules_controller.rb create mode 100644 app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/edit.html.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml create mode 100644 spec/components/conditions/ineligibilite_rules_component_spec.rb create mode 100644 spec/components/procedures/pending_republish_component_spec.rb create mode 100644 spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb create mode 100644 spec/system/administrateurs/procedure_ineligibilite_spec.rb diff --git a/app/components/conditions/ineligibilite_rules_component.rb b/app/components/conditions/ineligibilite_rules_component.rb new file mode 100644 index 000000000..a12ab262e --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component.rb @@ -0,0 +1,34 @@ +class Conditions::IneligibiliteRulesComponent < Conditions::ConditionsComponent + include Logic + + def initialize(draft_revision:) + @draft_revision = draft_revision + @published_revision = draft_revision.procedure.published_revision + @condition = draft_revision.ineligibilite_rules + @source_tdcs = draft_revision.types_de_champ_for(scope: :public) + end + + def pending_changes? + return false if !@published_revision + + !@published_revision.compare_ineligibilite_rules(@draft_revision).empty? + end + + private + + def input_prefix + 'procedure_revision[condition_form]' + end + + def input_id_for(name, row_index) + "#{@draft_revision.id}-#{name}-#{row_index}" + end + + def delete_condition_path(row_index) + delete_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id, row_index:) + end + + def add_condition_path + add_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id) + end +end diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..b646c3019 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml @@ -0,0 +1,6 @@ +--- +fr: + display_if: Bloquer si + select: Sélectionner + add_condition: Ajouter une règle d’inéligibilité + remove_a_row: Supprimer une règle diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..547a2ad85 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml @@ -0,0 +1,42 @@ +%div{ id: dom_id(@draft_revision, :ineligibilite_rules) } + = render Procedure::PendingRepublishComponent.new(procedure: @draft_revision.procedure, render_if: pending_changes?) + = render Conditions::ConditionsErrorsComponent.new(conditions: condition_per_row, source_tdcs: @source_tdcs) + %fieldset.fr-fieldset + %legend.fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w + Règles d’inéligibilité + %span.fr-hint-text Vous pouvez utiliser 1 ou plusieurs critère pour bloquer le dépot + .fr-fieldset__element + = form_tag admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), method: :patch, data: { turbo: true, controller: 'autosave' }, class: 'form width-100' do + .conditionnel.width-100 + %table.condition-table + %thead + %tr + %th.fr-pt-0.far-left + %th.fr-pt-0.target Champ Cible + %th.fr-pt-0.operator Opérateur + %th.fr-pt-0.value Valeur + %th.fr-pt-0.delete-column + %tbody + - rows.each.with_index do |(targeted_champ, operator_name, value), row_index| + %tr + %td.far-left= far_left_tag(row_index) + %td.target= left_operand_tag(targeted_champ, row_index) + %td.operator= operator_tag(operator_name, targeted_champ, row_index) + %td.value= right_operand_tag(targeted_champ, value, row_index, operator_name) + %td.delete-column= delete_condition_tag(row_index) + %tfoot + %tr + %td.text-right{ colspan: 5 }= add_condition_tag + + + + = form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id)) do |f| + .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5}) + .fr-fieldset__element + .fr-toggle + = f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt + = f.label :ineligibilite_enabled, "Inéligibilité des dossiers", data: { 'fr-checked-label': "Actif", 'fr-unchecked-label': "Inactif" }, class: 'fr-toggle__label' + %p.fr-hint-text Passer l’intérrupteur sur activé pour que les critères d’inéligibilité configurés s'appliquent + + + = render Procedure::FixedFooterComponent.new(procedure: @draft_revision.procedure, form: f, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8') diff --git a/app/components/procedure/pending_republish_component.rb b/app/components/procedure/pending_republish_component.rb new file mode 100644 index 000000000..181eb6f5c --- /dev/null +++ b/app/components/procedure/pending_republish_component.rb @@ -0,0 +1,10 @@ +class Procedure::PendingRepublishComponent < ApplicationComponent + def initialize(procedure:, render_if:) + @procedure = procedure + @render_if = render_if + end + + def render? + @render_if + end +end diff --git a/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml b/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml new file mode 100644 index 000000000..eb941cdba --- /dev/null +++ b/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml @@ -0,0 +1,4 @@ +--- +fr: + pending_republish_html: | + Ces modifications ne seront appliquées qu'à la prochaine publication. Vous pouvez vérifier puis publier les modifications sur l'écran de gestion de la démarche \ No newline at end of file diff --git a/app/components/procedure/pending_republish_component/pending_republish_component.html.haml b/app/components/procedure/pending_republish_component/pending_republish_component.html.haml new file mode 100644 index 000000000..eab7f62fc --- /dev/null +++ b/app/components/procedure/pending_republish_component/pending_republish_component.html.haml @@ -0,0 +1,3 @@ += render Dsfr::AlertComponent.new(state: :warning) do |c| + - c.with_body do + = t('.pending_republish_html', href: admin_procedure_path(@procedure.id)) diff --git a/app/controllers/administrateurs/ineligibilite_rules_controller.rb b/app/controllers/administrateurs/ineligibilite_rules_controller.rb new file mode 100644 index 000000000..41d6865f9 --- /dev/null +++ b/app/controllers/administrateurs/ineligibilite_rules_controller.rb @@ -0,0 +1,74 @@ +module Administrateurs + class IneligibiliteRulesController < AdministrateurController + before_action :retrieve_procedure + + def edit + end + + def change + if draft_revision.update(procedure_revision_params) + redirect_to edit_admin_procedure_ineligibilite_rules_path(@procedure) + else + flash[:alert] = draft_revision.errors.full_messages + render :edit + end + end + + def add_row + condition = Logic.add_empty_condition_to(draft_revision.ineligibilite_rules) + draft_revision.update!(ineligibilite_rules: condition) + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def delete_row + condition = condition_form.delete_row(row_index).to_condition + draft_revision.update!(ineligibilite_rules: condition) + + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def update + condition = condition_form.to_condition + draft_revision.update!(ineligibilite_rules: condition) + + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def change_targeted_champ + condition = condition_form.change_champ(row_index).to_condition + draft_revision.update!(ineligibilite_rules: condition) + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + private + + def build_ineligibilite_rules_component + Conditions::IneligibiliteRulesComponent.new(draft_revision: draft_revision) + end + + def draft_revision + @procedure.draft_revision + end + + def condition_form + ConditionForm.new(ineligibilite_rules_params.merge(source_tdcs: draft_revision.types_de_champ_for(scope: :public))) + end + + def ineligibilite_rules_params + params + .require(:procedure_revision) + .require(:condition_form) + .permit(:top_operator_name, rows: [:targeted_champ, :operator_name, :value]) + end + + def row_index + params[:row_index].to_i + end + + def procedure_revision_params + params + .require(:procedure_revision) + .permit(:ineligibilite_message, :ineligibilite_enabled) + end + end +end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 157b4dd67..2b56ecf80 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -1,4 +1,5 @@ class ProcedureRevision < ApplicationRecord + include Logic self.implicit_order_column = :created_at belongs_to :procedure, -> { with_discarded }, inverse_of: :revisions, optional: false belongs_to :dossier_submitted_message, inverse_of: :revisions, optional: true, dependent: :destroy @@ -17,8 +18,19 @@ class ProcedureRevision < ApplicationRecord scope :ordered, -> { order(:created_at) } + validates :ineligibilite_message, presence: true, if: -> { ineligibilite_enabled? } + delegate :path, to: :procedure, prefix: true + validate :ineligibilite_rules_are_valid?, + on: [:ineligibilite_rules_editor, :publication] + validates :ineligibilite_message, + presence: true, + if: -> { ineligibilite_enabled? }, + on: [:ineligibilite_rules_editor, :publication] + + serialize :ineligibilite_rules, LogicSerializer + def build_champs_public # reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc types_de_champ_public.reload.map(&:build_champ) diff --git a/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml new file mode 100644 index 000000000..a0ace0eca --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml @@ -0,0 +1,7 @@ +- rendered = render @ineligibilite_rules_component + +- if rendered.present? + = turbo_stream.replace dom_id(@procedure.draft_revision, :ineligibilite_rules) do + - rendered +- else + = turbo_stream.remove dom_id(@procedure.draft_revision, :ineligibilite_rules) diff --git a/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/edit.html.haml b/app/views/administrateurs/ineligibilite_rules/edit.html.haml new file mode 100644 index 000000000..a76a30468 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/edit.html.haml @@ -0,0 +1,28 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Inéligibilité des dossiers']] } + + +.fr-container + .fr-grid-row + .fr-col-12.fr-col-offset-md-2.fr-col-md-8 + %h1.fr-h1 Inéligibilité des dossiers + + = render Dsfr::AlertComponent.new(title: nil, size: :sm, state: :info, heading_level: 'h2', extra_class_names: 'fr-my-2w') do |c| + - c.with_body do + %p + Les dossiers répondant à vos critères d’inéligibilité ne pourront pas être déposés. Plus d’informations sur l’inéligibilité des dossiers dans la + = link_to('doc', ELIGIBILITE_URL, title: "Document sur l’inéligibilité des dossiers", **external_link_attributes) + + - if !@procedure.draft_revision.conditionable_types_de_champ.present? + %p.fr-mt-2w.fr-mb-2w + Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : + %ul + - Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.each do + %li= "« #{t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" + %p.fr-mt-2w + = link_to 'Ajouter un champ supportant les critères d’inéligibilité', champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' + = render Procedure::FixedFooterComponent.new(procedure: @procedure) + - else + = render Conditions::IneligibiliteRulesComponent.new(draft_revision: @procedure.draft_revision) diff --git a/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/config/env.example.optional b/config/env.example.optional index b9cc395e9..79710f04e 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -61,6 +61,9 @@ DS_ENV="staging" # Instance customization: URL of the Routage documentation # ROUTAGE_URL="" # +# Instance customization: URL of the EligibiliteDossier documentation +# ELIGIBILITE_URL="" +# # Instance customization: URL of the accessibility statement # ACCESSIBILITE_URL="" diff --git a/config/initializers/02_urls.rb b/config/initializers/02_urls.rb index d6e031dae..c29ea1d5d 100644 --- a/config/initializers/02_urls.rb +++ b/config/initializers/02_urls.rb @@ -37,6 +37,7 @@ CGU_URL = ENV.fetch("CGU_URL", [DOC_URL, "cgu"].join("/")) MENTIONS_LEGALES_URL = ENV.fetch("MENTIONS_LEGALES_URL", "/mentions-legales") ACCESSIBILITE_URL = ENV.fetch("ACCESSIBILITE_URL", "/declaration-accessibilite") ROUTAGE_URL = ENV.fetch("ROUTAGE_URL", [DOC_URL, "/pour-aller-plus-loin/routage"].join("/")) +ELIGIBILITE_URL = ENV.fetch("ELIGIBILITE_URL", [DOC_URL, "/pour-aller-plus-loin/eligibilite-des-dossiers"].join("/")) API_DOC_URL = [DOC_URL, "api-graphql"].join("/") WEBHOOK_DOC_URL = [DOC_URL, "pour-aller-plus-loin", "webhook"].join("/") WEBHOOK_ALTERNATIVE_DOC_URL = [DOC_URL, "api-graphql", "cas-dusages-exemple-dimplementation", "synchroniser-les-dossiers-modifies-sur-ma-demarche"].join("/") diff --git a/config/routes.rb b/config/routes.rb index c4973d1ae..38bd26cc9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -607,6 +607,14 @@ Rails.application.routes.draw do delete :delete_row, on: :member end + resource :ineligibilite_rules, only: [:edit, :update, :destroy], param: :revision_id do + patch :change_targeted_champ, on: :member + patch :update_all_rows, on: :member + patch :add_row, on: :member + delete :delete_row, on: :member + patch :change + end + patch :update_defaut_groupe_instructeur, controller: 'routing_rules', as: :update_defaut_groupe_instructeur put 'clone' diff --git a/spec/components/conditions/ineligibilite_rules_component_spec.rb b/spec/components/conditions/ineligibilite_rules_component_spec.rb new file mode 100644 index 000000000..c678c5ace --- /dev/null +++ b/spec/components/conditions/ineligibilite_rules_component_spec.rb @@ -0,0 +1,64 @@ +describe Conditions::IneligibiliteRulesComponent, type: :component do + include Logic + let(:procedure) { create(:procedure) } + let(:component) { described_class.new(draft_revision: procedure.draft_revision) } + + describe 'render' do + let(:ineligibilite_message) { 'ok' } + let(:ineligibilite_enabled) { true } + before do + procedure.draft_revision.update(ineligibilite_rules:, ineligibilite_message:, ineligibilite_enabled:) + end + context 'when ineligibilite_rules are valid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(true)) } + it 'does not render error' do + render_inline(component) + expect(page).not_to have_selector('.errors-summary') + end + end + context 'when ineligibilite_rules are invalid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + it 'does not render error' do + render_inline(component) + expect(page).to have_selector('.errors-summary') + end + end + end + + describe '#pending_changes' do + context 'when procedure is published' do + it 'detect changes when setup changes' do + expect(component.pending_changes?).to be_falsey + + procedure.draft_revision.ineligibilite_message = 'changed' + expect(component.pending_changes?).to be_falsey + + procedure.reload + procedure.draft_revision.ineligibilite_enabled = true + expect(component.pending_changes?).to be_falsey + + procedure.reload + procedure.draft_revision.ineligibilite_rules = {} + expect(component.pending_changes?).to be_falsey + end + end + + context 'when procedure is published' do + let(:procedure) { create(:procedure, :published) } + it 'detect changes when setup changes' do + expect(component.pending_changes?).to be_falsey + + procedure.draft_revision.ineligibilite_message = 'changed' + expect(component.pending_changes?).to be_truthy + + procedure.reload + procedure.draft_revision.ineligibilite_enabled = true + expect(component.pending_changes?).to be_truthy + + procedure.reload + procedure.draft_revision.ineligibilite_rules = {} + expect(component.pending_changes?).to be_truthy + end + end + end +end diff --git a/spec/components/procedures/pending_republish_component_spec.rb b/spec/components/procedures/pending_republish_component_spec.rb new file mode 100644 index 000000000..a5e301a20 --- /dev/null +++ b/spec/components/procedures/pending_republish_component_spec.rb @@ -0,0 +1,14 @@ +describe Procedure::PendingRepublishComponent, type: :component do + subject { render_inline(described_class.new(render_if:, procedure: build(:procedure, id: 1))) } + let(:page) { subject } + describe 'render_if' do + context 'when false' do + let(:render_if) { false } + it { expect(page).not_to have_text('Ces modifications ne seront appliquées') } + end + context 'when true' do + let(:render_if) { true } + it { expect(page).to have_text('Ces modifications ne seront appliquées') } + end + end +end diff --git a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb new file mode 100644 index 000000000..5c8f94628 --- /dev/null +++ b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb @@ -0,0 +1,231 @@ +describe Administrateurs::IneligibiliteRulesController, type: :controller do + include Logic + let(:user) { create(:user) } + let(:admin) { create(:administrateur, user: create(:user)) } + let(:procedure) { create(:procedure, administrateurs: [admin], types_de_champ_public:) } + let(:types_de_champ_public) { [] } + + describe 'condition management' do + before { sign_in(admin.user) } + + let(:default_params) do + { + procedure_id: procedure.id, + revision_id: procedure.draft_revision.id + } + end + + describe '#add_row' do + subject { post :add_row, params: default_params, format: :turbo_stream } + + context 'without any row' do + it 'creates an empty condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(empty_operator(empty, empty)) + end + end + + context 'with row' do + before do + procedure.draft_revision.ineligibilite_rules = empty_operator(empty, empty) + procedure.draft_revision.save! + end + + it 'add one more creates an empty condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(empty_operator(empty, empty)) + .to(ds_and([ + empty_operator(empty, empty), + empty_operator(empty, empty) + ])) + end + end + end + + describe 'delete_row' do + let(:condition_form) do + { + top_operator_name: Logic::And.name, + rows: [ + { + targeted_champ: empty.to_json, + operator_name: Logic::EmptyOperator, + value: empty.to_json + }, + { + targeted_champ: empty.to_json, + operator_name: Logic::EmptyOperator, + value: empty.to_json + } + ] + } + end + let(:initial_condition) do + ds_and([ + empty_operator(empty, empty), + empty_operator(empty, empty) + ]) + end + + subject { delete :delete_row, params: default_params.merge(row_index: 0, procedure_revision: { condition_form: }), format: :turbo_stream } + it 'remove condition' do + procedure.draft_revision.update(ineligibilite_rules: initial_condition) + + expect { subject } + .to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(initial_condition) + .to(empty_operator(empty, empty)) + end + end + + context 'simple tdc' do + let(:types_de_champ_public) { [{ type: :yes_no }] } + let(:yes_no_tdc) { procedure.draft_revision.types_de_champ_for(scope: :public).first } + let(:targeted_champ) { champ_value(yes_no_tdc.stable_id).to_json } + + describe '#change_targeted_champ' do + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: Logic::Eq.name, + value: constant(true).to_json + } + ] + } + end + subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream } + it 'update condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + + describe '#update' do + let(:value) { constant(true).to_json } + let(:operator_name) { Logic::Eq.name } + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: operator_name, + value: value + } + ] + } + end + subject { patch :update, params: default_params.merge(procedure_revision: { condition_form: condition_form }), format: :turbo_stream } + it 'updates condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + end + + context 'repetition tdc' do + let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :yes_no }] }] } + let(:yes_no_tdc) { procedure.draft_revision.types_de_champ_for(scope: :public).find { _1.type_champ == 'yes_no' } } + let(:targeted_champ) { champ_value(yes_no_tdc.stable_id).to_json } + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: Logic::Eq.name, + value: constant(true).to_json + } + ] + } + end + subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream } + describe "#update" do + it 'update condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + + describe '#change_targeted_champ' do + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: Logic::Eq.name, + value: constant(true).to_json + } + ] + } + end + subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream } + it 'update condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + end + end + + describe '#edit' do + subject { get :edit, params: { procedure_id: procedure.id } } + + context 'when user is not signed in' do + it { is_expected.to redirect_to(new_user_session_path) } + end + + context 'when user is signed in but not admin of procedure' do + before { sign_in(user) } + it { is_expected.to redirect_to(new_user_session_path) } + end + + context 'when user is signed as admin' do + before do + sign_in(admin.user) + subject + end + + it { is_expected.to have_http_status(200) } + + context 'rendered without tdc' do + let(:types_de_champ_public) { [] } + render_views + + it { expect(response.body).to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + end + + context 'rendered with tdc' do + let(:types_de_champ_public) { [{ type: :yes_no }] } + render_views + + it { expect(response.body).not_to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + end + end + end + + describe 'change' do + let(:params) do + { + procedure_id: procedure.id, + procedure_revision: { + ineligibilite_message: 'panpan', + ineligibilite_enabled: '1' + } + } + end + before { sign_in(admin.user) } + it 'works' do + patch :change, params: params + draft_revision = procedure.reload.draft_revision + expect(draft_revision.ineligibilite_message).to eq('panpan') + expect(draft_revision.ineligibilite_enabled).to eq(true) + expect(response).to redirect_to(edit_admin_procedure_ineligibilite_rules_path(procedure)) + end + end +end diff --git a/spec/system/administrateurs/procedure_ineligibilite_spec.rb b/spec/system/administrateurs/procedure_ineligibilite_spec.rb new file mode 100644 index 000000000..9db80cf59 --- /dev/null +++ b/spec/system/administrateurs/procedure_ineligibilite_spec.rb @@ -0,0 +1,45 @@ +describe 'Administrateurs can edit procedures', js: true do + include Logic + + let(:procedure) { create(:procedure, administrateurs: [create(:administrateur)]) } + before do + login_as procedure.administrateurs.first.user, scope: :user + end + + scenario 'setup eligibilite' do + # explain no champ compatible + visit admin_procedure_path(procedure) + expect(page).to have_content("Champs à configurer") + + # explain which champs are compatible + visit edit_admin_procedure_ineligibilite_rules_path(procedure) + expect(page).to have_content("Inéligibilité des dossiers") + expect(page).to have_content("Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ") + click_on "Ajouter un champ supportant les critères d’inéligibilité" + + # setup a compatible champ + expect(page).to have_content('Champs du formulaire') + click_on 'Ajouter un champ' + select "Oui/Non" + fill_in "Libellé du champ", with: "Un champ oui non" + click_on "Revenir à l'écran de gestion" + procedure.reload + first_tdc = procedure.draft_revision.types_de_champ.first + # back to procedure dashboard, explain you can set it up now + expect(page).to have_content('À configurer') + visit edit_admin_procedure_ineligibilite_rules_path(procedure) + + # setup rules and stuffs + expect(page).to have_content("Inéligibilité des dossiers") + fill_in "Message d’inéligibilité", with: "vous n'etes pas eligible" + find('label', text: 'Inéligibilité des dossiers').click + click_on "Ajouter une règle d’inéligibilité" + all('select').first.select 'Un champ oui non' + click_on 'Enregistrer' + + # rules are setup + wait_until { procedure.reload.draft_revision.ineligibilite_enabled == true } + expect(procedure.draft_revision.ineligibilite_message).to eq("vous n'etes pas eligible") + expect(procedure.draft_revision.ineligibilite_rules).to eq(ds_eq(champ_value(first_tdc.stable_id), constant(true))) + end +end From 5de4ce889f4f7f8717388852e73bc5e1978f98ec Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:30:33 +0200 Subject: [PATCH 52/63] feat(ProcedureRevision.ineligibilites_rules): keep track of changes and show it to admin for republication --- .../procedure/revision_changes_component.rb | 12 +- .../revision_changes_component.fr.yml | 7 + .../revision_changes_component.html.haml | 6 +- app/models/concerns/dossier_rebase_concern.rb | 2 +- app/models/procedure.rb | 10 +- app/models/procedure_revision.rb | 35 +- app/models/procedure_revision_change.rb | 74 ++- .../procedures/_publication_form.html.haml | 2 +- .../procedures/modifications.html.haml | 3 +- .../administrateurs/procedures/show.html.haml | 2 +- spec/models/procedure_revision_spec.rb | 563 +++++++++++------- 11 files changed, 458 insertions(+), 258 deletions(-) diff --git a/app/components/procedure/revision_changes_component.rb b/app/components/procedure/revision_changes_component.rb index e266f13e2..af786e6bc 100644 --- a/app/components/procedure/revision_changes_component.rb +++ b/app/components/procedure/revision_changes_component.rb @@ -1,9 +1,13 @@ class Procedure::RevisionChangesComponent < ApplicationComponent - def initialize(changes:, previous_revision:) - @changes = changes + def initialize(new_revision:, previous_revision:) @previous_revision = previous_revision - @public_move_changes, @private_move_changes = changes.filter { _1.op == :move }.partition { !_1.private? } - @delete_champ_warning = !total_dossiers.zero? && !@changes.all?(&:can_rebase?) + @new_revision = new_revision + + @tdc_changes = previous_revision.compare_types_de_champ(new_revision) + @public_move_changes, @private_move_changes = @tdc_changes.filter { _1.op == :move }.partition { !_1.private? } + @delete_champ_warning = !total_dossiers.zero? && !@tdc_changes.all?(&:can_rebase?) + + @ineligibilite_rules_changes = previous_revision.compare_ineligibilite_rules(new_revision) end private diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml index 10009ce1e..3228c76a8 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml @@ -80,3 +80,10 @@ fr: update_expression_reguliere_exemple_text: L’exemple d’expression régulière de l’annotation privée « %{label} » a été modifiée. Le nouvel exemple est « %{to} ». remove_expression_reguliere_error_message: Le message d’erreur de l’expression régulière de l’annotation privée « %{label} » a été supprimé. update_expression_reguliere_error_message: Le message d’erreur de l’expression régulière de l’annotation privée « %{label} » a été modifiée. Le nouveau message est « %{to} ». + ineligibilite_rules: + add: La condition d’inéligibilité « %{new_condition} » a été ajoutée. + remove: La condition d’inéligibilité « %{previous_condition} » a été supprimée + update: La conditon d’inéligibilité « %{previous_condition} » a été changée pour « %{new_condition} » + enabled: "L’inéligibilité des dossiers a été activée" + disabled: "L’inéligibilité des dossiers a été désactivée" + message_updated: "Le message d’inéligibilité a été changé pour « %{ineligibilite_message} »" \ No newline at end of file diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml index ba19a0dd9..ed7f550c8 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml @@ -2,7 +2,7 @@ - list.with_empty do = t('.no_changes') - - @changes.each do |change| + - @tdc_changes.each do |change| - prefix = change.private? ? 'private' : 'public' - case change.op - when :add @@ -176,3 +176,7 @@ - list.with_item do .fr-alert.fr-alert--warning.fr-mt-1v = t(".invalid_routing_rules_alert") + + - @ineligibilite_rules_changes.each do |change| + - list.with_item do + = t(".ineligibilite_rules.#{change.op}", **change.i18n_params) diff --git a/app/models/concerns/dossier_rebase_concern.rb b/app/models/concerns/dossier_rebase_concern.rb index dd6395dc2..49807793c 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -22,7 +22,7 @@ module DossierRebaseConcern end def pending_changes - procedure.published_revision.present? ? revision.compare(procedure.published_revision) : [] + procedure.published_revision.present? ? revision.compare_types_de_champ(procedure.published_revision) : [] end def can_rebase_mandatory_change?(stable_id) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index af398a991..21f272372 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -431,11 +431,15 @@ class Procedure < ApplicationRecord def draft_changed? preload_draft_and_published_revisions - !brouillon? && published_revision.different_from?(draft_revision) && revision_changes.present? + !brouillon? && (types_de_champ_revision_changes.present? || ineligibilite_rules_revision_changes.present?) end - def revision_changes - published_revision.compare(draft_revision) + def types_de_champ_revision_changes + published_revision.compare_types_de_champ(draft_revision) + end + + def ineligibilite_rules_revision_changes + published_revision.compare_ineligibilite_rules(draft_revision) end def preload_draft_and_published_revisions diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 2b56ecf80..a3e16f592 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -148,16 +148,18 @@ class ProcedureRevision < ApplicationRecord !draft? end - def different_from?(revision) - revision_types_de_champ != revision.revision_types_de_champ - end - - def compare(revision) + def compare_types_de_champ(revision) changes = [] changes += compare_revision_types_de_champ(revision_types_de_champ, revision.revision_types_de_champ) changes end + def compare_ineligibilite_rules(revision) + changes = [] + changes += compare_revision_ineligibilite_rules(revision) + changes + end + def dossier_for_preview(user) dossier = Dossier .create_with(autorisation_donnees: true) @@ -334,6 +336,29 @@ class ProcedureRevision < ApplicationRecord end end + def compare_revision_ineligibilite_rules(new_revision) + from_ineligibilite_rules = ineligibilite_rules + to_ineligibilite_rules = new_revision.ineligibilite_rules + changes = [] + + if from_ineligibilite_rules.present? && to_ineligibilite_rules.blank? + changes << ProcedureRevisionChange::RemoveEligibiliteRuleChange + end + if from_ineligibilite_rules.blank? && to_ineligibilite_rules.present? + changes << ProcedureRevisionChange::AddEligibiliteRuleChange + end + if from_ineligibilite_rules != to_ineligibilite_rules + changes << ProcedureRevisionChange::UpdateEligibiliteRuleChange + end + if ineligibilite_message != new_revision.ineligibilite_message + changes << ProcedureRevisionChange::UpdateEligibiliteMessageChange + end + if ineligibilite_enabled != new_revision.ineligibilite_enabled + changes << (new_revision.ineligibilite_enabled ? ProcedureRevisionChange::EligibiliteEnabledChange : ProcedureRevisionChange::EligibiliteDisabledChange) + end + changes.map { _1.new(self, new_revision) } + end + def compare_type_de_champ(from_type_de_champ, to_type_de_champ, from_coordinates, to_coordinates) changes = [] if from_type_de_champ.type_champ != to_type_de_champ.type_champ diff --git a/app/models/procedure_revision_change.rb b/app/models/procedure_revision_change.rb index fc412cc26..7d99f0fd2 100644 --- a/app/models/procedure_revision_change.rb +++ b/app/models/procedure_revision_change.rb @@ -1,17 +1,19 @@ class ProcedureRevisionChange - attr_reader :type_de_champ - def initialize(type_de_champ) - @type_de_champ = type_de_champ + class TypeDeChange + attr_reader :type_de_champ + def initialize(type_de_champ) + @type_de_champ = type_de_champ + end + + def label = @type_de_champ.libelle + def stable_id = @type_de_champ.stable_id + def private? = @type_de_champ.private? + def child? = @type_de_champ.child? + + def to_h = { op:, stable_id:, label:, private: private? } end - def label = @type_de_champ.libelle - def stable_id = @type_de_champ.stable_id - def private? = @type_de_champ.private? - def child? = @type_de_champ.child? - - def to_h = { op:, stable_id:, label:, private: private? } - - class AddChamp < ProcedureRevisionChange + class AddChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -23,7 +25,7 @@ class ProcedureRevisionChange def to_h = super.merge(mandatory: mandatory?) end - class RemoveChamp < ProcedureRevisionChange + class RemoveChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -32,7 +34,7 @@ class ProcedureRevisionChange def can_rebase?(dossier = nil) = true end - class MoveChamp < ProcedureRevisionChange + class MoveChamp < TypeDeChange attr_reader :from, :to def initialize(type_de_champ, from, to) @@ -46,7 +48,7 @@ class ProcedureRevisionChange def to_h = super.merge(from:, to:) end - class UpdateChamp < ProcedureRevisionChange + class UpdateChamp < TypeDeChange attr_reader :attribute, :from, :to def initialize(type_de_champ, attribute, from, to) @@ -75,4 +77,48 @@ class ProcedureRevisionChange end end end + + class EligibiliteRulesChange + attr_reader :previous_revision, :new_revision + def initialize(previous_revision, new_revision) + @previous_revision = previous_revision + @new_revision = new_revision + @previous_ineligibilite_rules = @previous_revision.ineligibilite_rules + @new_ineligibilite_rules = @new_revision.ineligibilite_rules + end + + def i18n_params + { + previous_condition: @previous_ineligibilite_rules&.to_s(previous_revision.types_de_champ.filter { @previous_ineligibilite_rules.sources.include? _1.stable_id }), + new_condition: @new_ineligibilite_rules&.to_s(new_revision.types_de_champ.filter { @new_ineligibilite_rules.sources.include? _1.stable_id }) + } + end + end + + class AddEligibiliteRuleChange < EligibiliteRulesChange + def op = :add + end + + class RemoveEligibiliteRuleChange < EligibiliteRulesChange + def op = :remove + end + + class UpdateEligibiliteRuleChange < EligibiliteRulesChange + def op = :update + end + + class EligibiliteEnabledChange < EligibiliteRulesChange + def op = :enabled + def i18n_params = {} + end + + class EligibiliteDisabledChange < EligibiliteRulesChange + def op = :disabled + def i18n_params = {} + end + + class UpdateEligibiliteMessageChange < EligibiliteRulesChange + def op = :message_updated + def i18n_params = { ineligibilite_message: @new_revision.ineligibilite_message } + end end diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index 0c9cc8454..d8d96870e 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -8,7 +8,7 @@ %p.mb-2= t('.draft_changed_procedure_alert') = render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do - = render Procedure::RevisionChangesComponent.new changes: procedure.revision_changes, previous_revision: procedure.published_revision + = render Procedure::RevisionChangesComponent.new new_revision: procedure.draft_revision, previous_revision: procedure.published_revision - if procedure.close? = render partial: 'publication_form_inputs', locals: { procedure: procedure, closed_procedures: @closed_procedures, form: f } - elsif @procedure.brouillon? && @procedure.missing_steps.empty? diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index 978fee30f..73b8673bd 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -13,7 +13,6 @@ - previous_revision = nil - @procedure.revisions.each do |revision| - if previous_revision.present? && !revision.draft? - - changes = previous_revision.compare(revision) - dossiers = revision.dossiers.visible_by_administration - dossiers_en_construction_count = dossiers.state_en_construction.count - dossiers_en_instruction_count = dossiers.state_en_instruction.count @@ -31,7 +30,7 @@ %p= t('.dossiers_en_construction', count: dossiers_en_construction_count) - elsif !dossiers_en_instruction_count.zero? %p= t('.dossiers_en_instruction', count: dossiers_en_instruction_count) - = render Procedure::RevisionChangesComponent.new changes:, previous_revision: + = render Procedure::RevisionChangesComponent.new new_revision: revision, previous_revision: - previous_revision = revision = render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 4d63d909b..4463a86cf 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -30,8 +30,8 @@ - if @procedure.draft_changed? = render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c| - c.with_body do - = render Procedure::RevisionChangesComponent.new changes: @procedure.revision_changes, previous_revision: @procedure.published_revision = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + = render Procedure::RevisionChangesComponent.new new_revision: @procedure.draft_revision, previous_revision: @procedure.published_revision - c.with_bottom do %ul.fr-mt-2w.fr-btns-group.fr-btns-group--inline diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index 4c7a17ba3..434d82e44 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -347,306 +347,417 @@ describe ProcedureRevision do end end - describe '#compare' do + describe '#compare_types_de_champ' do include Logic - - let(:first_tdc) { draft.types_de_champ_public.first } - let(:second_tdc) { draft.types_de_champ_public.second } let(:new_draft) { procedure.create_new_revision } + subject { procedure.active_revision.compare_types_de_champ(new_draft.reload).map(&:to_h) } - subject { procedure.active_revision.compare(new_draft.reload).map(&:to_h) } + describe 'when tdcs changes' do + let(:first_tdc) { draft.types_de_champ_public.first } + let(:second_tdc) { draft.types_de_champ_public.second } - context 'with a procedure with 2 tdcs' do - let(:procedure) do - create(:procedure, types_de_champ_public: [ - { type: :integer_number, libelle: 'l1' }, - { type: :text, libelle: 'l2' } - ]) + context 'with a procedure with 2 tdcs' do + let(:procedure) do + create(:procedure, types_de_champ_public: [ + { type: :integer_number, libelle: 'l1' }, + { type: :text, libelle: 'l2' } + ]) + end + + context 'when a condition is added' do + before do + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: nil, + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: "(l1 == 3)" + } + ]) + end + end + + context 'when a condition is removed' do + before do + second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) + draft.reload + + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: nil) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: "(l1 == 2)", + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: nil + } + ]) + end + end + + context 'when a condition is changed' do + before do + second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) + draft.reload + + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: "(l1 == 2)", + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: "(l1 == 3)" + } + ]) + end + end end - context 'when a condition is added' do - before do - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + context 'when a type de champ is added' do + let(:procedure) { create(:procedure) } + let(:new_tdc) do + new_draft.add_type_de_champ( + type_champ: TypeDeChamp.type_champs.fetch(:text), + libelle: "Un champ text" + ) end + before { new_tdc } + it do is_expected.to eq([ { - attribute: :condition, - from: nil, - label: "l2", - op: :update, + op: :add, + label: "Un champ text", private: false, - stable_id: second_tdc.stable_id, - to: "(l1 == 3)" + mandatory: false, + stable_id: new_tdc.stable_id } ]) end end - context 'when a condition is removed' do - before do - second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) - draft.reload + context 'when a type de champ is changed' do + context 'when libelle, description, and mandatory are changed' do + let(:procedure) { create(:procedure, :with_type_de_champ) } - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: nil) + before do + updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) + + updated_tdc.update(libelle: 'modifier le libelle', description: 'une description', mandatory: !updated_tdc.mandatory) + end + + it do + is_expected.to eq([ + { + op: :update, + attribute: :libelle, + label: first_tdc.libelle, + private: false, + from: first_tdc.libelle, + to: "modifier le libelle", + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :description, + label: first_tdc.libelle, + private: false, + from: first_tdc.description, + to: "une description", + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :mandatory, + label: first_tdc.libelle, + private: false, + from: false, + to: true, + stable_id: first_tdc.stable_id + } + ]) + end + end + + context 'when collapsible_explanation_enabled and collapsible_explanation_text are changed' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :explication }]) } + + before do + updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) + + updated_tdc.update(collapsible_explanation_enabled: "1", collapsible_explanation_text: 'afficher au clique') + end + it do + is_expected.to eq([ + { + op: :update, + attribute: :collapsible_explanation_enabled, + label: first_tdc.libelle, + private: first_tdc.private?, + from: false, + to: true, + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :collapsible_explanation_text, + label: first_tdc.libelle, + private: first_tdc.private?, + from: nil, + to: 'afficher au clique', + stable_id: first_tdc.stable_id + } + ]) + end + end + end + + context 'when a type de champ is moved' do + let(:procedure) { create(:procedure, types_de_champ_public: Array.new(3) { { type: :text } }) } + let(:new_draft_second_tdc) { new_draft.types_de_champ_public.second } + let(:new_draft_third_tdc) { new_draft.types_de_champ_public.third } + + before do + new_draft_second_tdc + new_draft_third_tdc + new_draft.move_type_de_champ(new_draft_second_tdc.stable_id, 2) end it do is_expected.to eq([ { - attribute: :condition, - from: "(l1 == 2)", - label: "l2", - op: :update, + op: :move, + label: new_draft_third_tdc.libelle, private: false, - stable_id: second_tdc.stable_id, - to: nil - } - ]) - end - end - - context 'when a condition is changed' do - before do - second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) - draft.reload - - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) - end - - it do - is_expected.to eq([ + from: 2, + to: 1, + stable_id: new_draft_third_tdc.stable_id + }, { - attribute: :condition, - from: "(l1 == 2)", - label: "l2", - op: :update, + op: :move, + label: new_draft_second_tdc.libelle, private: false, - stable_id: second_tdc.stable_id, - to: "(l1 == 3)" + from: 1, + to: 2, + stable_id: new_draft_second_tdc.stable_id } ]) end end - end - context 'when a type de champ is added' do - let(:procedure) { create(:procedure) } - let(:new_tdc) do - new_draft.add_type_de_champ( - type_champ: TypeDeChamp.type_champs.fetch(:text), - libelle: "Un champ text" - ) - end - - before { new_tdc } - - it do - is_expected.to eq([ - { - op: :add, - label: "Un champ text", - private: false, - mandatory: false, - stable_id: new_tdc.stable_id - } - ]) - end - end - - context 'when a type de champ is changed' do - context 'when libelle, description, and mandatory are changed' do + context 'when a type de champ is removed' do let(:procedure) { create(:procedure, :with_type_de_champ) } before do - updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) - - updated_tdc.update(libelle: 'modifier le libelle', description: 'une description', mandatory: !updated_tdc.mandatory) + new_draft.remove_type_de_champ(first_tdc.stable_id) end it do is_expected.to eq([ { - op: :update, - attribute: :libelle, + op: :remove, label: first_tdc.libelle, private: false, - from: first_tdc.libelle, - to: "modifier le libelle", - stable_id: first_tdc.stable_id - }, - { - op: :update, - attribute: :description, - label: first_tdc.libelle, - private: false, - from: first_tdc.description, - to: "une description", - stable_id: first_tdc.stable_id - }, - { - op: :update, - attribute: :mandatory, - label: first_tdc.libelle, - private: false, - from: false, - to: true, stable_id: first_tdc.stable_id } ]) end end - context 'when collapsible_explanation_enabled and collapsible_explanation_text are changed' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :explication }]) } + context 'when a child type de champ is transformed into a drop_down_list' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } before do - updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) - - updated_tdc.update(collapsible_explanation_enabled: "1", collapsible_explanation_text: 'afficher au clique') + child = new_draft.children_of(new_draft.types_de_champ_public.last).first + new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :drop_down_list, drop_down_options: ['one', 'two']) end + it do is_expected.to eq([ { op: :update, - attribute: :collapsible_explanation_enabled, - label: first_tdc.libelle, - private: first_tdc.private?, - from: false, - to: true, - stable_id: first_tdc.stable_id + attribute: :type_champ, + label: "sub type de champ", + private: false, + from: "text", + to: "drop_down_list", + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id }, { op: :update, - attribute: :collapsible_explanation_text, - label: first_tdc.libelle, - private: first_tdc.private?, - from: nil, - to: 'afficher au clique', - stable_id: first_tdc.stable_id + attribute: :drop_down_options, + label: "sub type de champ", + private: false, + from: [], + to: ["one", "two"], + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id + } + ]) + end + end + + context 'when a child type de champ is transformed into a map' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } + + before do + child = new_draft.children_of(new_draft.types_de_champ_public.last).first + new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :carte, options: { cadastres: true, znieff: true }) + end + + it do + is_expected.to eq([ + { + op: :update, + attribute: :type_champ, + label: "sub type de champ", + private: false, + from: "text", + to: "carte", + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id + }, + { + op: :update, + attribute: :carte_layers, + label: "sub type de champ", + private: false, + from: [], + to: [:cadastres, :znieff], + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id } ]) end end end + end - context 'when a type de champ is moved' do - let(:procedure) { create(:procedure, types_de_champ_public: Array.new(3) { { type: :text } }) } - let(:new_draft_second_tdc) { new_draft.types_de_champ_public.second } - let(:new_draft_third_tdc) { new_draft.types_de_champ_public.third } + describe 'compare_ineligibilite_rules' do + include Logic + let(:new_draft) { procedure.create_new_revision } + subject { procedure.active_revision.compare_ineligibilite_rules(new_draft.reload) } - before do - new_draft_second_tdc - new_draft_third_tdc - new_draft.move_type_de_champ(new_draft_second_tdc.stable_id, 2) + context 'when ineligibilite_rules changes' do + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :yes_no }] } + let(:yes_no_tdc) { new_draft.types_de_champ_public.first } + + context 'when nothing changed' do + it { is_expected.to be_empty } end - it do - is_expected.to eq([ - { - op: :move, - label: new_draft_third_tdc.libelle, - private: false, - from: 2, - to: 1, - stable_id: new_draft_third_tdc.stable_id - }, - { - op: :move, - label: new_draft_second_tdc.libelle, - private: false, - from: 1, - to: 2, - stable_id: new_draft_second_tdc.stable_id - } - ]) + context 'when ineligibilite_rules added' do + before do + new_draft.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::AddEligibiliteRuleChange)) } + end + + context 'when ineligibilite_rules removed' do + before do + procedure.published_revision.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::RemoveEligibiliteRuleChange)) } + end + + context 'when ineligibilite_rules changed' do + before do + procedure.published_revision.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + new_draft.update!(ineligibilite_rules: ds_and([ + ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)), + empty_operator(empty, empty) + ])) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::UpdateEligibiliteRuleChange)) } + end + + context 'when when ineligibilite_enabled changes from false to true' do + before do + procedure.published_revision.update!(ineligibilite_enabled: false, ineligibilite_message: :required) + new_draft.update!(ineligibilite_enabled: true, ineligibilite_message: :required) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::EligibiliteEnabledChange)) } + end + + context 'when ineligibilite_enabled changes from true to false' do + before do + procedure.published_revision.update!(ineligibilite_enabled: true, ineligibilite_message: :required) + new_draft.update!(ineligibilite_enabled: false, ineligibilite_message: :required) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::EligibiliteDisabledChange)) } + end + + context 'when ineligibilite_message changes' do + before do + procedure.published_revision.update!(ineligibilite_message: :a) + new_draft.update!(ineligibilite_message: :b) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::UpdateEligibiliteMessageChange)) } end end + end - context 'when a type de champ is removed' do - let(:procedure) { create(:procedure, :with_type_de_champ) } - - before do - new_draft.remove_type_de_champ(first_tdc.stable_id) - end - - it do - is_expected.to eq([ - { - op: :remove, - label: first_tdc.libelle, - private: false, - stable_id: first_tdc.stable_id - } - ]) - end + describe 'ineligibilite_rules_are_valid?' do + include Logic + let(:procedure) { create(:procedure) } + let(:draft_revision) { procedure.draft_revision } + let(:ineligibilite_message) { 'ok' } + let(:ineligibilite_enabled) { true } + before do + procedure.draft_revision.update(ineligibilite_rules:, ineligibilite_message:, ineligibilite_enabled:) end - context 'when a child type de champ is transformed into a drop_down_list' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } - - before do - child = new_draft.children_of(new_draft.types_de_champ_public.last).first - new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :drop_down_list, drop_down_options: ['one', 'two']) - end - - it do - is_expected.to eq([ - { - op: :update, - attribute: :type_champ, - label: "sub type de champ", - private: false, - from: "text", - to: "drop_down_list", - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - }, - { - op: :update, - attribute: :drop_down_options, - label: "sub type de champ", - private: false, - from: [], - to: ["one", "two"], - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - } - ]) + context 'when ineligibilite_rules are valid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(true)) } + it 'is valid' do + expect(draft_revision.validate(:publication)).to be_truthy + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_truthy end end - - context 'when a child type de champ is transformed into a map' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } - - before do - child = new_draft.children_of(new_draft.types_de_champ_public.last).first - new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :carte, options: { cadastres: true, znieff: true }) + context 'when ineligibilite_rules are invalid on simple champ' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + it 'is invalid' do + expect(draft_revision.validate(:publication)).to be_falsey + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey end - - it do - is_expected.to eq([ - { - op: :update, - attribute: :type_champ, - label: "sub type de champ", - private: false, - from: "text", - to: "carte", - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - }, - { - op: :update, - attribute: :carte_layers, - label: "sub type de champ", - private: false, - from: [], - to: [:cadastres, :znieff], - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - } - ]) + end + context 'when ineligibilite_rules are invalid on repetition champ' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :integer_number }] }] } + let(:tdc_number) { draft_revision.types_de_champ_for(scope: :public).find { _1.type_champ == 'integer_number' } } + let(:ineligibilite_rules) do + ds_eq(champ_value(tdc_number.stable_id), constant(true)) + end + it 'is invalid' do + expect(draft_revision.validate(:publication)).to be_falsey + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey end end end From 5644692448e922be87c98166cb857d0df95f0cc2 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:33:03 +0200 Subject: [PATCH 53/63] feat(Logic.computable?): add computable? to know if a ineligibilite_rules set is computable --- .../concerns/champ_conditional_concern.rb | 4 ++ app/models/dossier.rb | 4 ++ app/models/logic/and.rb | 7 +++ app/models/logic/binary_operator.rb | 9 ++++ app/models/logic/or.rb | 10 +++++ app/models/procedure_revision.rb | 13 ++++++ spec/models/logic/and_spec.rb | 36 ++++++++++++++++ spec/models/logic/binary_operator_spec.rb | 15 +++++++ spec/models/logic/or_spec.rb | 43 +++++++++++++++++++ 9 files changed, 141 insertions(+) diff --git a/app/models/concerns/champ_conditional_concern.rb b/app/models/concerns/champ_conditional_concern.rb index 9e6559be9..63001229d 100644 --- a/app/models/concerns/champ_conditional_concern.rb +++ b/app/models/concerns/champ_conditional_concern.rb @@ -21,6 +21,10 @@ module ChampConditionalConcern end end + def reset_visible # recompute after a dossier update + remove_instance_variable :@visible if instance_variable_defined? :@visible + end + private def champs_for_condition diff --git a/app/models/dossier.rb b/app/models/dossier.rb index e609323bb..14d9cd4f7 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -938,6 +938,10 @@ class Dossier < ApplicationRecord end end + def ineligibilite_rules_computable? + revision.ineligibilite_rules_computable?(champs_for_revision(scope: :public)) + end + def demander_un_avis!(avis) log_dossier_operation(avis.claimant, :demander_un_avis, avis) end diff --git a/app/models/logic/and.rb b/app/models/logic/and.rb index 51537235f..11d31a9c0 100644 --- a/app/models/logic/and.rb +++ b/app/models/logic/and.rb @@ -7,5 +7,12 @@ class Logic::And < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.all? end + def computable?(champs = []) + return true if sources.blank? + + champs.filter { _1.stable_id.in?(sources) && _1.visible? } + .all? { _1.value.present? } + end + def to_s(type_de_champs) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' && ')})" end diff --git a/app/models/logic/binary_operator.rb b/app/models/logic/binary_operator.rb index 812fa0605..35f6ce1a7 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -42,6 +42,15 @@ class Logic::BinaryOperator < Logic::Term l&.send(operation, r) || false end + def computable?(champs = []) + return true if sources.blank? + + visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } + + return false if visible_champs_sources.size != sources.size + visible_champs_sources.all? { _1.value.present? } + end + def to_s(type_de_champs) = "(#{@left.to_s(type_de_champs)} #{operation} #{@right.to_s(type_de_champs)})" def ==(other) diff --git a/app/models/logic/or.rb b/app/models/logic/or.rb index a0e2dfeae..96a0fe133 100644 --- a/app/models/logic/or.rb +++ b/app/models/logic/or.rb @@ -7,5 +7,15 @@ class Logic::Or < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.any? end + + def computable?(champs = []) + return true if sources.blank? + + visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } + + return false if visible_champs_sources.blank? + visible_champs_sources.all? { _1.value.present? } || compute(visible_champs_sources) + end + def to_s(type_de_champs = []) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' || ')})" end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index a3e16f592..7e4f30860 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -269,6 +269,12 @@ class ProcedureRevision < ApplicationRecord types_de_champ_for(scope: :public).filter(&:conditionable?) end + def ineligibilite_rules_computable?(champs) + ineligibilite_enabled && ineligibilite_rules&.computable?(champs) + ensure + champs.map(&:reset_visible) # otherwise @visible is cached, then dossier can be updated. champs are not updated + end + private def compute_estimated_fill_duration @@ -483,6 +489,13 @@ class ProcedureRevision < ApplicationRecord changes end + def ineligibilite_rules_are_valid? + if ineligibilite_rules + ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) + .each { errors.add(:ineligibilite_rules, :invalid) } + end + end + def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) diff --git a/spec/models/logic/and_spec.rb b/spec/models/logic/and_spec.rb index 67f319acb..c0eefc8e8 100644 --- a/spec/models/logic/and_spec.rb +++ b/spec/models/logic/and_spec.rb @@ -6,6 +6,42 @@ describe Logic::And do it { expect(and_from([true, true, false]).compute).to be false } end + describe '#computable?' do + let(:champ_1) { create(:champ_integer_number, value: value_1) } + let(:champ_2) { create(:champ_integer_number, value: value_2) } + + let(:logic) do + ds_and([ + greater_than(champ_value(champ_1.stable_id), constant(1)), + less_than(champ_value(champ_2.stable_id), constant(10)) + ]) + end + + subject { logic.computable?([champ_1, champ_2]) } + + context "when none of champs.value are filled, and logic can't be computed" do + let(:value_1) { nil } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context "when one champs has a value (that compute to false) the other has not, and logic keeps waiting for the 2nd value" do + let(:value_1) { 1 } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context 'when all champs.value are filled, and logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { 10 } + it { is_expected.to be_truthy } + end + context 'when one champs is not visible and the other has a value, and logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { nil } + before { expect(champ_2).to receive(:visible?).and_return(false) } + it { is_expected.to be_truthy } + end + end + describe '#to_s' do it do expect(and_from([true, false, true]).to_s([])).to eq "(Oui && Non && Oui)" diff --git a/spec/models/logic/binary_operator_spec.rb b/spec/models/logic/binary_operator_spec.rb index e27c3b7bc..f816e81e7 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -28,6 +28,19 @@ describe Logic::BinaryOperator do it { expect(greater_than(constant(2), champ_value(champ.stable_id)).sources).to eq([champ.stable_id]) } it { expect(greater_than(champ_value(champ.stable_id), champ_value(champ2.stable_id)).sources).to eq([champ.stable_id, champ2.stable_id]) } end + + describe '#computable?' do + let(:champ) { create(:champ_integer_number, value: nil) } + + it 'computable?' do + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([])).to be(false) + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) + allow(champ).to receive(:value).and_return(double(present?: true)) + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(true) + allow(champ).to receive(:visible?).and_return(false) + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) + end + end end describe Logic::GreaterThan do @@ -43,6 +56,8 @@ end describe Logic::GreaterThanEq do include Logic + let(:champ) { create(:champ_integer_number, value: nil) } + it 'computes' do expect(greater_than_eq(constant(0), constant(1)).compute).to be(false) expect(greater_than_eq(constant(1), constant(1)).compute).to be(true) diff --git a/spec/models/logic/or_spec.rb b/spec/models/logic/or_spec.rb index 1888587d2..82d5392fb 100644 --- a/spec/models/logic/or_spec.rb +++ b/spec/models/logic/or_spec.rb @@ -7,6 +7,49 @@ describe Logic::Or do it { expect(or_from([false, false, false]).compute).to be false } end + describe '#computable?' do + let(:champ_1) { create(:champ_integer_number, value: value_1) } + let(:champ_2) { create(:champ_integer_number, value: value_2) } + + let(:logic) do + ds_or([ + greater_than(champ_value(champ_1.stable_id), constant(1)), + less_than(champ_value(champ_2.stable_id), constant(10)) + ]) + end + + context 'with all champs' do + subject { logic.computable?([champ_1, champ_2]) } + + context "when none of champs.value are filled, or logic can't be computed" do + let(:value_1) { nil } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context "when one champs has a value (that compute to false) the other has not, or logic keeps waiting for the 2nd value" do + let(:value_1) { 1 } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context 'when all champs.value are filled, or logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { 10 } + it { is_expected.to be_truthy } + end + context 'when one champs.value and his condition is true, or logic can be computed' do + let(:value_1) { 2 } + let(:value_2) { nil } + it { is_expected.to be_truthy } + end + context 'when one champs is not visible and the other has a value that fails, or logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { nil } + before { expect(champ_2).to receive(:visible?).and_return(false) } + it { is_expected.to be_truthy } + end + end + end + describe '#to_s' do it { expect(or_from([true, false, true]).to_s).to eq "(Oui || Non || Oui)" } end From 2210db3b81a98be7cd6c5242316de9334cff3c69 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:34:17 +0200 Subject: [PATCH 54/63] feat(Dossier::EditFooterComponent): disable submit button when inligibilite_rules matches --- .../dossiers/edit_footer_component.rb | 21 ++++++-- .../edit_footer_component.en.yml | 1 + .../edit_footer_component.fr.yml | 1 + .../edit_footer_component.html.haml | 7 ++- app/models/dossier.rb | 8 ++- .../dossiers/edit_footer_component_spec.rb | 50 +++++++++++++++++++ 6 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 spec/components/dossiers/edit_footer_component_spec.rb diff --git a/app/components/dossiers/edit_footer_component.rb b/app/components/dossiers/edit_footer_component.rb index fca7fab45..ac77bbfea 100644 --- a/app/components/dossiers/edit_footer_component.rb +++ b/app/components/dossiers/edit_footer_component.rb @@ -1,4 +1,6 @@ class Dossiers::EditFooterComponent < ApplicationComponent + delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + def initialize(dossier:, annotation:) @dossier = dossier @annotation = annotation @@ -14,24 +16,37 @@ class Dossiers::EditFooterComponent < ApplicationComponent @annotation.present? end + def disabled_submit_buttons_options + { + class: 'fr-text--sm fr-mb-0 fr-mr-2w', + data: { 'fr-opened': "true" }, + aria: { controls: 'modal-eligibilite-rules-dialog' } + } + end + def submit_draft_button_options { class: 'fr-btn fr-btn--sm', - disabled: !owner?, + disabled: !owner? || ineligibilite_rules_invalid?, method: :post, - data: { 'disable-with': t('.submitting'), controller: 'autosave-submit' } + data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server } } end def submit_en_construction_button_options { class: 'fr-btn fr-btn--sm', + disabled: ineligibilite_rules_invalid?, method: :post, - data: { 'disable-with': t('.submitting'), controller: 'autosave-submit' }, + data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server }, form: { id: "form-submit-en-construction" } } end + def ineligibilite_rules_invalid? + ineligibilite_rules_computable? && !can_passer_en_construction? + end + def render? !@dossier.for_procedure_preview? end diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml index 098e6ec0b..b6de7d121 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml @@ -2,5 +2,6 @@ en: submit: Submit the file submit_changes: Submit file changes + submit_disabled: File submission disabled submitting: Submitting… invite_notice: You are invited to make amendments to this file but only the owner themselves can submit it. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml index 33937aed6..8ffd062db 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml @@ -2,5 +2,6 @@ fr: submit: Déposer le dossier submit_changes: Déposer les modifications + submit_disabled: Pourquoi je ne peux pas déposer mon dossier ? submitting: Envoi en cours… invite_notice: En tant qu’invité, vous pouvez remplir ce formulaire – mais le titulaire du dossier doit le déposer lui-même. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml index 77540bd16..fb4ab8fb1 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml @@ -3,8 +3,13 @@ = render Dossiers::AutosaveFooterComponent.new(dossier: @dossier, annotation: annotation?) - if !annotation? && @dossier.can_transition_to_en_construction? + - if ineligibilite_rules_invalid? + = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit'), brouillon_dossier_url(@dossier), submit_draft_button_options - - elsif @dossier.forked_with_changes? + + - if @dossier.forked_with_changes? + - if ineligibilite_rules_invalid? + = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit_changes'), modifier_dossier_url(@dossier.editing_fork_origin), submit_en_construction_button_options diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 14d9cd4f7..e83edfb97 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -156,7 +156,7 @@ class Dossier < ApplicationRecord state :sans_suite event :passer_en_construction, after: :after_passer_en_construction, after_commit: :after_commit_passer_en_construction do - transitions from: :brouillon, to: :en_construction + transitions from: :brouillon, to: :en_construction, guard: :can_passer_en_construction? end event :passer_en_instruction, after: :after_passer_en_instruction, after_commit: :after_commit_passer_en_instruction do @@ -562,6 +562,12 @@ class Dossier < ApplicationRecord procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? end + def can_passer_en_construction? + return true if !revision.ineligibilite_enabled + + !revision.ineligibilite_rules.compute(champs_for_revision(scope: :public)) + end + def can_passer_en_instruction? return false if blocked_with_pending_correction? diff --git a/spec/components/dossiers/edit_footer_component_spec.rb b/spec/components/dossiers/edit_footer_component_spec.rb new file mode 100644 index 000000000..40e60802b --- /dev/null +++ b/spec/components/dossiers/edit_footer_component_spec.rb @@ -0,0 +1,50 @@ +RSpec.describe Dossiers::EditFooterComponent, type: :component do + let(:annotation) { false } + let(:component) { Dossiers::EditFooterComponent.new(dossier:, annotation:) } + + subject { render_inline(component).to_html } + + before { allow(component).to receive(:owner?).and_return(true) } + + context 'when brouillon' do + let(:dossier) { create(:dossier, :brouillon) } + + context 'when dossier can be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + it 'renders submit button without disabled' do + expect(subject).to have_selector('button', text: 'Déposer le dossier') + end + end + + context 'when dossier can not be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + it 'renders submit button with disabled' do + expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') + expect(subject).to have_selector('button[disabled]', text: 'Déposer le dossier') + end + end + end + + context 'when en construction' do + let(:fork_origin) { create(:dossier, :en_construction) } + let(:dossier) { fork_origin.clone(fork: true) } + before { allow(dossier).to receive(:forked_with_changes?).and_return(true) } + + context 'when dossier can be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + + it 'renders submit button without disabled' do + expect(subject).to have_selector('button', text: 'Déposer les modifications') + end + end + + context 'when dossier can not be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + + it 'renders submit button with disabled' do + expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') + expect(subject).to have_selector('button[disabled]', text: 'Déposer les modifications') + end + end + end +end From be5f5802375a78577ae095cbac87acac68694cd3 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:36:25 +0200 Subject: [PATCH 55/63] feat(Users/Dossiers#update): track changes live and pop modal when ineligibilite_rules matches --- .../invalid_ineligibilite_rules_component.rb | 16 ++++ ...valid_ineligibilite_rules_component.en.yml | 6 ++ ...valid_ineligibilite_rules_component.fr.yml | 5 + ...id_ineligibilite_rules_component.html.haml | 16 ++++ app/controllers/users/dossiers_controller.rb | 11 ++- .../ineligibilite_rules_match_controller.ts | 19 ++++ app/views/shared/dossiers/_edit.html.haml | 2 + .../users/dossiers/update.turbo_stream.haml | 7 ++ .../users/dossiers_controller_spec.rb | 92 +++++++++++++++---- .../shared/dossiers/_edit.html.haml_spec.rb | 14 +++ 10 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component.rb create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml create mode 100644 app/javascript/controllers/ineligibilite_rules_match_controller.ts diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component.rb b/app/components/dossiers/invalid_ineligibilite_rules_component.rb new file mode 100644 index 000000000..fe45272f6 --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component.rb @@ -0,0 +1,16 @@ +class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent + delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + + def initialize(dossier:) + @dossier = dossier + @revision = dossier.revision + end + + def render? + ineligibilite_rules_computable? && !can_passer_en_construction? + end + + def error_message + @dossier.revision.ineligibilite_message + end +end diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml new file mode 100644 index 000000000..1a377763c --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml @@ -0,0 +1,6 @@ +fr: + modal: + title: "Your file does not match submission criteria" + close: "Close" + close_alt: "Close this modal" + body: "The procedure « %{procedure_libelle} » have submission criteria, unfortunately your file does not match them. You can not submit your file" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..d191f03d4 --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml @@ -0,0 +1,5 @@ +fr: + modal: + title: "Vous ne pouvez pas déposer votre dossier" + close: "Fermer" + close_alt: "Fermer la fenêtre modale" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..dd39925cd --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml @@ -0,0 +1,16 @@ +%div{ id: dom_id(@dossier, :ineligibilite_rules_broken), data: { controller: 'ineligibilite-rules-match', turbo_force: :server } } + %button.fr-sr-only{ aria: {controls: 'modal-eligibilite-rules-dialog' }, data: {'fr-opened': "false" } } + show modal + + %dialog.fr-modal{ "aria-labelledby" => "fr-modal-title-modal-1", role: "dialog", id: 'modal-eligibilite-rules-dialog', data: { 'ineligibilite-rules-match-target' => 'dialog' } } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn--close.fr-btn{ aria: { controls: 'modal-eligibilite-rules-dialog' }, title: t('.modal.close_alt') }= t('.modal.close') + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg> + = t('.modal.title') + %p= error_message diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index acdfd1332..e686683ff 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -303,10 +303,13 @@ module Users def update @dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier @dossier = dossier_with_champs(pj_template: false) - @errors = update_dossier_and_compute_errors - - @dossier.index_search_terms_later if @errors.empty? - + @ineligibilite_rules_was_computable = @dossier.ineligibilite_rules_computable? + @can_passer_en_construction_was = @dossier.can_passer_en_construction? + update_dossier_and_compute_errors + @dossier.index_search_terms_later if @dossier.errors.empty? + @ineligibilite_rules_is_computable = @dossier.ineligibilite_rules_computable? + @can_passer_en_construction_is = @dossier.can_passer_en_construction? + @ineligibilite_rules_computable_changed = !@ineligibilite_rules_was_computable && @ineligibilite_rules_is_computable respond_to do |format| format.turbo_stream do @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) diff --git a/app/javascript/controllers/ineligibilite_rules_match_controller.ts b/app/javascript/controllers/ineligibilite_rules_match_controller.ts new file mode 100644 index 000000000..5b47d79b5 --- /dev/null +++ b/app/javascript/controllers/ineligibilite_rules_match_controller.ts @@ -0,0 +1,19 @@ +import { ApplicationController } from './application_controller'; +declare interface modal { + disclose: () => void; +} +declare interface dsfr { + modal: modal; +} +declare const window: Window & + typeof globalThis & { dsfr: (elem: HTMLElement) => dsfr }; + +export class InvalidIneligibiliteRulesController extends ApplicationController { + static targets = ['dialog']; + + declare dialogTarget: HTMLElement; + + connect() { + setTimeout(() => window.dsfr(this.dialogTarget).modal.disclose(), 100); + } +} diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index d5fff3262..1962951e8 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -25,4 +25,6 @@ = render Dossiers::PendingCorrectionCheckboxComponent.new(dossier: dossier) + = render Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: dossier) + = render Dossiers::EditFooterComponent.new(dossier: dossier_for_editing, annotation: false) diff --git a/app/views/users/dossiers/update.turbo_stream.haml b/app/views/users/dossiers/update.turbo_stream.haml index 91a898ab0..374291733 100644 --- a/app/views/users/dossiers/update.turbo_stream.haml +++ b/app/views/users/dossiers/update.turbo_stream.haml @@ -1 +1,8 @@ = render partial: 'shared/dossiers/update_champs', locals: { to_show: @to_show, to_hide: @to_hide, to_update: @to_update, dossier: @dossier } + +- if !params.key?(:validate) + - if @ineligibilite_rules_is_computable + = turbo_stream.remove(dom_id(@dossier, :ineligibilite_rules_broken)) + + - if (@ineligibilite_rules_computable_changed && !@can_passer_en_construction_is) || (@can_passer_en_construction_was && !@can_passer_en_construction_is) + = turbo_stream.append('contenu', render(Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: @dossier))) diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 220bd6722..1f78b2f4a 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -398,7 +398,9 @@ describe Users::DossiersController, type: :controller do describe '#submit_brouillon' do before { sign_in(user) } - let!(:dossier) { create(:dossier, user: user) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :text }] } + let!(:dossier) { create(:dossier, user:, procedure:) } let(:first_champ) { dossier.champs_public.first } 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' } @@ -439,9 +441,9 @@ describe Users::DossiersController, type: :controller do render_views let(:error_message) { 'nop' } before do - expect_any_instance_of(Dossier).to receive(:validate).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( - [double(inner_error: double(base: first_champ), message: 'nop')] + allow_any_instance_of(Dossier).to receive(:validate).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( + [instance_double(ActiveModel::NestedError, inner_error: double(base: first_champ), message: 'nop')] ) subject end @@ -461,11 +463,8 @@ describe Users::DossiersController, type: :controller do render_views let(:value) { nil } - - before do - first_champ.type_de_champ.update(mandatory: true, libelle: 'l') - subject - end + let(:types_de_champ_public) { [{ type: :text, mandatory: true, libelle: 'l' }] } + before { subject } it { expect(response).to render_template(:brouillon) } it { expect(response.body).to have_link(first_champ.libelle, href: "##{first_champ.labelledby_id}") } @@ -548,8 +547,8 @@ describe Users::DossiersController, type: :controller do render_views before do - expect_any_instance_of(Dossier).to receive(:validate).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( + allow_any_instance_of(Dossier).to receive(:validate).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( [double(inner_error: double(base: first_champ), message: 'nop')] ) @@ -661,7 +660,8 @@ describe Users::DossiersController, type: :controller do describe '#update brouillon' do before { sign_in(user) } - let(:procedure) { create(:procedure, :published, types_de_champ_public: [{}, { type: :piece_justificative }]) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{}, { type: :piece_justificative }] } let(:dossier) { create(:dossier, user:, procedure:) } let(:first_champ) { dossier.champs_public.first } let(:piece_justificative_champ) { dossier.champs_public.last } @@ -754,13 +754,65 @@ describe Users::DossiersController, type: :controller do end end - it "debounce search terms indexation" do - # dossier creation trigger a first indexation and flag, - # so we we have to remove this flag - dossier.debounce_index_search_terms_flag.remove + context 'having ineligibilite_rules setup' do + include Logic + render_views - assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do - 3.times { patch :update, params: payload, format: :turbo_stream } + let(:types_de_champ_public) { [{ type: :text }, { type: :integer_number }] } + let(:text_champ) { dossier.champs_public.first } + let(:number_champ) { dossier.champs_public.last } + let(:submit_payload) do + { + id: dossier.id, + dossier: { + groupe_instructeur_id: dossier.groupe_instructeur_id, + champs_public_attributes: { + text_champ.public_id => { + with_public_id: true, + value: "hello world" + }, + number_champ.public_id => { + with_public_id: true, + value: + } + } + } + } + end + let(:must_be_greater_than) { 10 } + + before do + procedure.published_revision.update( + ineligibilite_enabled: true, + ineligibilite_message: 'lol', + ineligibilite_rules: greater_than(champ_value(number_champ.stable_id), constant(must_be_greater_than)) + ) + procedure.published_revision.save! + end + render_views + + context 'when it pass from undefined to true' do + let(:value) { must_be_greater_than + 1 } + + it 'raises popup' do + subject + dossier.reload + expect(dossier.can_passer_en_construction?).to be_falsey + expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) + expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(response.body).to match(ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)) + end + end + context 'when it pass from undefined to false' do + let(:value) { must_be_greater_than - 1 } + it 'does nothing' do + subject + dossier.reload + expect(dossier.can_passer_en_construction?).to be_truthy + expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) + expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(response.body).not_to have_selector("##{ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)}") + end end end end @@ -868,8 +920,8 @@ describe Users::DossiersController, type: :controller do context 'classic error' do before do - expect_any_instance_of(Dossier).to receive(:save).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( + allow_any_instance_of(Dossier).to receive(:save).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( [message: 'nop', inner_error: double(base: first_champ)] ) subject diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb index c242f3ec8..5183554d8 100644 --- a/spec/views/shared/dossiers/_edit.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -149,4 +149,18 @@ describe 'shared/dossiers/edit', type: :view do end end end + + context 'when dossier transitions rules are computable and passer_en_construction is false' do + let(:types_de_champ_public) { [] } + let(:dossier) { create(:dossier, procedure:) } + + before do + allow_any_instance_of(Dossiers::InvalidIneligibiliteRulesComponent).to receive(:ineligibilite_rules_computable?).and_return(true) + allow(dossier).to receive(:can_passer_en_construction?).and_return(false) + end + + it 'renders broken transitions rules dialog' do + expect(subject).to have_selector("##{ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)}") + end + end end From 178685b34b059c5cfeffd230a99f111d89e1f9d6 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:37:12 +0200 Subject: [PATCH 56/63] feat(TypeDeChampEditor): prevent to destroy a type de champ used by inligibilite rules --- .../champ_component/champ_component.html.haml | 6 +++++- app/models/procedure_revision_type_de_champ.rb | 4 ++++ .../types_de_champ_editor/champ_component_spec.rb | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 60cf82102..d28769213 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -10,7 +10,7 @@ .flex.justify-start.width-33 .cell.flex.justify-start.column.flex-grow = form.label :type_champ, "Type de champ", for: dom_id(type_de_champ, :type_champ) - = form.select :type_champ, grouped_options_for_select(types_of_type_de_champ, type_de_champ.type_champ), {}, class: 'fr-select small-margin small inline width-100', id: dom_id(type_de_champ, :type_champ), disabled: coordinate.used_by_routing_rules? + = form.select :type_champ, grouped_options_for_select(types_of_type_de_champ, type_de_champ.type_champ), {}, class: 'fr-select small-margin small inline width-100', id: dom_id(type_de_champ, :type_champ), disabled: coordinate.used_by_routing_rules? || coordinate.used_by_ineligibilite_rules? .flex.column.justify-start.flex-grow .cell @@ -136,6 +136,10 @@ %span utilisé pour = link_to('le routage', admin_procedure_groupe_instructeurs_path(revision.procedure_id, anchor: 'routing-rules')) + - elsif coordinate.used_by_ineligibilite_rules? + %span + utilisé pour + = link_to('l’eligibilité des dossiers', edit_admin_procedure_ineligibilite_rules_path(revision.procedure_id)) - else = button_to type_de_champ_path, class: 'fr-btn fr-btn--tertiary-no-outline fr-icon-delete-line', title: "Supprimer le champ", method: :delete, form: { data: { turbo_confirm: 'Êtes vous sûr de vouloir supprimer ce champ ?' } } do %span.sr-only Supprimer diff --git a/app/models/procedure_revision_type_de_champ.rb b/app/models/procedure_revision_type_de_champ.rb index c4842da20..506e205f7 100644 --- a/app/models/procedure_revision_type_de_champ.rb +++ b/app/models/procedure_revision_type_de_champ.rb @@ -75,4 +75,8 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord def used_by_routing_rules? stable_id.in?(procedure.stable_ids_used_by_routing_rules) end + + def used_by_ineligibilite_rules? + revision.ineligibilite_enabled? && stable_id.in?(revision.ineligibilite_rules&.sources || []) + end end diff --git a/spec/components/types_de_champ_editor/champ_component_spec.rb b/spec/components/types_de_champ_editor/champ_component_spec.rb index 1e368f1ba..27b57472f 100644 --- a/spec/components/types_de_champ_editor/champ_component_spec.rb +++ b/spec/components/types_de_champ_editor/champ_component_spec.rb @@ -2,10 +2,12 @@ describe TypesDeChampEditor::ChampComponent, type: :component do describe 'render' do let(:component) { described_class.new(coordinate:, upper_coordinates: []) } let(:routing_rules_stable_ids) { [] } + let(:ineligibilite_rules_used?) { false } before do Flipper.enable_actor(:engagement_juridique_type_de_champ, procedure) allow_any_instance_of(Procedure).to receive(:stable_ids_used_by_routing_rules).and_return(routing_rules_stable_ids) + allow_any_instance_of(ProcedureRevisionTypeDeChamp).to receive(:used_by_ineligibilite_rules?).and_return(ineligibilite_rules_used?) render_inline(component) end @@ -29,6 +31,15 @@ describe TypesDeChampEditor::ChampComponent, type: :component do expect(page).to have_text(/utilisé pour\nle routage/) end end + + context 'drop down tdc used for ineligibilite_rules' do + let(:ineligibilite_rules_used?) { true } + + it do + expect(page).to have_css("select[disabled=\"disabled\"]") + expect(page).to have_text(/l’eligibilité des dossiers/) + end + end end describe 'tdc ej' do From c480bc00c381f4b1c108b862e028bd9e03fd40ba Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:40:35 +0200 Subject: [PATCH 57/63] feat(Users/Dossiers#submit_brouillon_or_en_construction): prevent transition to en_construction if ineligibilite_rules matches. pop error nicely --- app/controllers/users/dossiers_controller.rb | 20 +-- app/models/dossier.rb | 2 +- .../users/dossier_ineligibilite_spec.rb | 119 ++++++++++++++++++ 3 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 spec/system/users/dossier_ineligibilite_spec.rb diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index e686683ff..ebfa2cdb4 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -231,9 +231,9 @@ module Users def submit_brouillon @dossier = dossier_with_champs(pj_template: false) - @errors = submit_dossier_and_compute_errors + submit_dossier_and_compute_errors - if @errors.blank? + if @dossier.errors.blank? && @dossier.can_passer_en_construction? @dossier.passer_en_construction! @dossier.process_declarative! @dossier.process_sva_svr! @@ -278,9 +278,9 @@ module Users editing_fork_origin.resolve_pending_correction end - @errors = submit_dossier_and_compute_errors + submit_dossier_and_compute_errors - if @errors.blank? + if @dossier.errors.blank? && @dossier.can_passer_en_construction? editing_fork_origin.merge_fork(@dossier) editing_fork_origin.submit_en_construction! @@ -288,7 +288,6 @@ module Users else respond_to do |format| format.html do - @dossier = editing_fork_origin render :modifier end @@ -570,21 +569,14 @@ module Users def submit_dossier_and_compute_errors @dossier.validate(:champs_public_value) - - errors = @dossier.errors - @dossier.check_mandatory_and_visible_champs.each do |error_on_champ| - errors.import(error_on_champ) - end + @dossier.check_mandatory_and_visible_champs if @dossier.editing_fork_origin&.pending_correction? @dossier.editing_fork_origin.validate(:champs_public_value) @dossier.editing_fork_origin.errors.where(:pending_correction).each do |error| - errors.import(error) + @dossier.errors.import(error) end - end - - errors end def ensure_ownership! diff --git a/app/models/dossier.rb b/app/models/dossier.rb index e83edfb97..7a1c611a8 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -940,7 +940,7 @@ class Dossier < ApplicationRecord .filter(&:visible?) .filter(&:mandatory_blank?) .map do |champ| - champ.errors.add(:value, :missing) + errors.import(champ.errors.add(:value, :missing)) end end diff --git a/spec/system/users/dossier_ineligibilite_spec.rb b/spec/system/users/dossier_ineligibilite_spec.rb new file mode 100644 index 000000000..366dac780 --- /dev/null +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -0,0 +1,119 @@ +require 'system/users/dossier_shared_examples.rb' + +describe 'Dossier Inéligibilité', js: true do + include Logic + + let(:user) { create(:user) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:dossier) { create(:dossier, procedure:, user:) } + + let(:published_revision) { procedure.published_revision } + let(:first_tdc) { published_revision.types_de_champ.first } + let(:second_tdc) { published_revision.types_de_champ.last } + let(:ineligibilite_message) { 'sry vous pouvez aps soumettre votre dossier' } + let(:eligibilite_params) { { ineligibilite_enabled: true, ineligibilite_message: } } + + before do + published_revision.update(eligibilite_params.merge(ineligibilite_rules:)) + login_as user, scope: :user + end + + context 'single condition' do + let(:types_de_champ_public) { [{ type: :yes_no }] } + let(:ineligibilite_rules) { ds_eq(champ_value(first_tdc.stable_id), constant(true)) } + + scenario 'can submit, can not submit, reload' do + visit brouillon_dossier_path(dossier) + # no error while dossier is empty + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # does raise error when dossier is filled with valid condition + find("label", text: "Non").click + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # raise error when dossier is filled with invalid condition + find("label", text: "Oui").click + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) + expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + + # reload page and see error because it was filled + visit brouillon_dossier_path(dossier) + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) + expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + + # modal is closable, and we can change our dossier response to be eligible + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + find("label", text: "Non").click + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + + # it works, yay + click_on "Déposer le dossier" + wait_until { dossier.reload.en_construction? == true } + end + end + + context 'or condition' do + let(:types_de_champ_public) { [{ type: :yes_no, libelle: 'l1' }, { type: :drop_down_list, libelle: 'l2', options: ['Paris', 'Marseille'] }] } + let(:ineligibilite_rules) do + ds_or([ + ds_eq(champ_value(first_tdc.stable_id), constant(true)), + ds_eq(champ_value(second_tdc.stable_id), constant('Paris')) + ]) + end + + scenario 'can submit, can not submit, can edit, etc...' do + visit brouillon_dossier_path(dossier) + # no error while dossier is empty + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # only one condition is matches, cannot submit dossier and error message is clear + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Oui").click + end + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) + expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + + # only one condition does not matches, I can conitnue + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Non").click + end + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + + # Now test dossier modification + click_on "Déposer le dossier" + click_on "Accéder à votre dossier" + click_on "Modifier le dossier" + + # one condition matches, means i'm blocked to send my file. + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Oui").click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Non").click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: false) + + # second condition matches, means i'm blocked to send my file + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Paris').click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + + # none of conditions matches, i can submit + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Marseille').click + end + + # it works, yay + click_on "Déposer les modifications" + wait_until { dossier.reload.en_construction? == true } + end + end +end From e3a24d53ea080f54aa91986497a3615c2e680380 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 18:00:19 +0200 Subject: [PATCH 58/63] tech(refactor): procedure::error_summary and dossier::ErrorsFullMessagesComponent use same behaviour to compact/expand errors --- .../errors_full_messages_component.rb | 8 ++-- .../errors_full_messages_component.en.yml | 1 - .../errors_full_messages_component.fr.yml | 1 - .../errors_full_messages_component.html.haml | 17 ++----- app/components/expandable_error_list.rb | 9 ++++ .../expandable_error_list.html.en.yml | 3 ++ .../expandable_error_list.html.fr.yml | 3 ++ .../expandable_error_list.html.haml | 14 ++++++ app/components/procedure/errors_summary.rb | 18 +++++-- .../errors_summary/errors_summary.html.haml | 6 +-- app/views/shared/dossiers/_edit.html.haml | 2 +- config/locales/models/procedure/en.yml | 16 +++---- config/locales/models/procedure/fr.yml | 16 +++---- .../procedures/errors_summary_spec.rb | 47 ++++++++++++------- spec/models/procedure_spec.rb | 26 +++++----- .../administrateurs/procedure_publish_spec.rb | 6 +-- 16 files changed, 115 insertions(+), 78 deletions(-) create mode 100644 app/components/expandable_error_list.rb create mode 100644 app/components/expandable_error_list/expandable_error_list.html.en.yml create mode 100644 app/components/expandable_error_list/expandable_error_list.html.fr.yml create mode 100644 app/components/expandable_error_list/expandable_error_list.html.haml diff --git a/app/components/dossiers/errors_full_messages_component.rb b/app/components/dossiers/errors_full_messages_component.rb index fd8bafd94..207170e8c 100644 --- a/app/components/dossiers/errors_full_messages_component.rb +++ b/app/components/dossiers/errors_full_messages_component.rb @@ -3,17 +3,15 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent ErrorDescriptor = Data.define(:anchor, :label, :error_message) - def initialize(dossier:, errors:) + def initialize(dossier:) @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 + @dossier.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) } - yield(Array(formated_errors[0..2]), Array(formated_errors[3..])) end def to_error_descriptor(error) @@ -27,6 +25,6 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent end def render? - !@errors.empty? + !@dossier.errors.empty? end end diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml index 3fab8164d..0a595e80a 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml @@ -5,4 +5,3 @@ en: Your file has 1 error. Fix-it to continue : other: | Your file has %{count} errors. Fix-them to continue : - see_more: Show all errors diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml index 1fd0e7f8c..3d94f636f 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml @@ -5,4 +5,3 @@ fr: Votre dossier contient 1 champ en erreur. Corrigez-la pour poursuivre : other: | Votre dossier contient %{count} champs en erreurs. Corrigez-les pour poursuivre : - see_more: Afficher toutes les erreurs diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml index 58d76cb56..ada4150b5 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml @@ -1,15 +1,4 @@ .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})" + - if dedup_and_partitioned_errors.size > 0 + %p#sumup-errors= t('.sumup_html', count: dedup_and_partitioned_errors.size, url: dedup_and_partitioned_errors.first.anchor) + = render ExpandableErrorList.new(errors: dedup_and_partitioned_errors) diff --git a/app/components/expandable_error_list.rb b/app/components/expandable_error_list.rb new file mode 100644 index 000000000..43d5c9215 --- /dev/null +++ b/app/components/expandable_error_list.rb @@ -0,0 +1,9 @@ +class ExpandableErrorList < ApplicationComponent + def initialize(errors:) + @errors = errors + end + + def splitted_errors + yield(Array(@errors[0..2]), Array(@errors[3..])) + end +end diff --git a/app/components/expandable_error_list/expandable_error_list.html.en.yml b/app/components/expandable_error_list/expandable_error_list.html.en.yml new file mode 100644 index 000000000..b21ee7d8a --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.en.yml @@ -0,0 +1,3 @@ +--- +en: + see_more: Show all errors diff --git a/app/components/expandable_error_list/expandable_error_list.html.fr.yml b/app/components/expandable_error_list/expandable_error_list.html.fr.yml new file mode 100644 index 000000000..755d13886 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.fr.yml @@ -0,0 +1,3 @@ +--- +fr: + see_more: Afficher toutes les erreurs diff --git a/app/components/expandable_error_list/expandable_error_list.html.haml b/app/components/expandable_error_list/expandable_error_list.html.haml new file mode 100644 index 000000000..1ab5221e5 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.haml @@ -0,0 +1,14 @@ +- splitted_errors do |head, tail| + %ul#head-errors.fr-mb-0 + - 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.fr-mt-0.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "" }= 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 diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb index adce8fd64..e185c81b3 100644 --- a/app/components/procedure/errors_summary.rb +++ b/app/components/procedure/errors_summary.rb @@ -1,4 +1,6 @@ class Procedure::ErrorsSummary < ApplicationComponent + ErrorDescriptor = Data.define(:anchor, :label, :error_message) + def initialize(procedure:, validation_context:) @procedure = procedure @validation_context = validation_context @@ -24,10 +26,8 @@ class Procedure::ErrorsSummary < ApplicationComponent @procedure.errors.present? end - def error_messages - @procedure.errors.map do |error| - [error, error_correction_page(error)] - end + def errors + @procedure.errors.map { to_error_descriptor(_1) } end def error_correction_page(error) @@ -45,4 +45,14 @@ class Procedure::ErrorsSummary < ApplicationComponent edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) end end + + def to_error_descriptor(error) + libelle = case error.attribute + when :draft_types_de_champ_public, :draft_types_de_champ_private + error.options[:type_de_champ].libelle.truncate(200) + else + error.base.class.human_attribute_name(error.attribute) + end + ErrorDescriptor.new(error_correction_page(error), libelle, error.message) + end end diff --git a/app/components/procedure/errors_summary/errors_summary.html.haml b/app/components/procedure/errors_summary/errors_summary.html.haml index 72780bcd1..e5042916e 100644 --- a/app/components/procedure/errors_summary/errors_summary.html.haml +++ b/app/components/procedure/errors_summary/errors_summary.html.haml @@ -2,8 +2,4 @@ - if invalid? = render Dsfr::AlertComponent.new(state: :error, title: , extra_class_names: 'fr-mb-2w') do |c| - c.with_body do - - error_messages.each do |(error, path)| - %p.mt-2 - = error.full_message - - if path.present? - = "(#{link_to 'corriger', path, class: 'fr-link'})" + = render ExpandableErrorList.new(errors:) diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index 1962951e8..c797d35f2 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -10,7 +10,7 @@ = 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| - = render Dossiers::ErrorsFullMessagesComponent.new(dossier: @dossier, errors: @errors || []) + = render Dossiers::ErrorsFullMessagesComponent.new(dossier: dossier) %header.mb-6 .fr-highlight %p.fr-text--sm diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 55a9c1ddd..34fc89e35 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -72,16 +72,16 @@ en: invalid: 'invalid format' draft_types_de_champ_public: format: 'Public field %{message}' - invalid_condition: "« %{value} » have an invalid logic" - empty_repetition: '« %{value} » requires at least one field' - empty_drop_down: '« %{value} » requires at least one option' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "have an invalid logic" + empty_repetition: 'requires at least one field' + empty_drop_down: 'requires at least one option' + inconsistent_header_section: "%{custom_message}" draft_types_de_champ_private: format: 'Private field %{message}' - invalid_condition: "« %{value} » have an invalid logic" - empty_repetition: '« %{value} » requires at least one field' - empty_drop_down: '« %{value} » requires at least one option' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "have an invalid logic" + empty_repetition: 'requires at least one field' + empty_drop_down: 'requires at least one option' + inconsistent_header_section: "%{custom_message}" attestation_template: format: "%{attribute} %{message}" initiated_mail: diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 4a0fdeca5..85df92d73 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -78,16 +78,16 @@ fr: invalid: 'n’a pas le bon format' draft_types_de_champ_public: format: 'Le champ %{message}' - invalid_condition: "« %{value} » a une logique conditionnelle invalide" - empty_repetition: '« %{value} » doit comporter au moins un champ répétable' - empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "a une logique conditionnelle invalide" + empty_repetition: 'doit comporter au moins un champ répétable' + empty_drop_down: 'doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "%{custom_message}" draft_types_de_champ_private: format: 'L’annotation privée %{message}' - invalid_condition: "« %{value} » a une logique conditionnelle invalide" - empty_repetition: '« %{value} » doit comporter au moins un champ répétable' - empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "a une logique conditionnelle invalide" + empty_repetition: 'doit comporter au moins un champ répétable' + empty_drop_down: 'doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "%{custom_message}" attestation_template: format: "%{attribute} %{message}" initiated_mail: diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index 4c3ef6337..3e64cc790 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -11,27 +11,33 @@ describe Procedure::ErrorsSummary, type: :component do context 'when :publication' do let(:validation_context) { :publication } - it 'shows errors for public and private tdc' do - expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") - expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + it 'shows errors and links for public and private tdc' do + expect(page).to have_content("Erreur : Des problèmes empêchent la publication de la démarche") + expect(page).to have_selector("a", text: "public") + expect(page).to have_selector("a", text: "private") + expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 2) end end context 'when :types_de_champ_public_editor' do let(:validation_context) { :types_de_champ_public_editor } - it 'shows errors for public only tdc' do - expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") - expect(page).not_to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + it 'shows errors and links for public only tdc' do + expect(page).to have_text("Erreur : Les champs formulaire contiennent des erreurs") + expect(page).to have_selector("a", text: "public") + expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 1) + expect(page).not_to have_selector("a", text: "private") end end context 'when :types_de_champ_private_editor' do let(:validation_context) { :types_de_champ_private_editor } - it 'shows errors for private only tdc' do - expect(page).not_to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") - expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + it 'shows errors and links for private only tdc' do + expect(page).to have_text("Erreur : Les annotations privées contiennent des erreurs") + expect(page).to have_selector("a", text: "private") + expect(page).to have_text("doit comporter au moins un choix sélectionnable") + expect(page).not_to have_selector("a", text: "public") end end end @@ -52,12 +58,18 @@ describe Procedure::ErrorsSummary, type: :component do before { subject } - it 'renders all errors on champ' do - expect(page).to have_text("Le champ « drop down list requires options » doit comporter au moins un choix sélectionnable") - expect(page).to have_text("Le champ « repetition requires children » doit comporter au moins un champ répétable") - expect(page).to have_text("Le champ « invalid condition » a une logique conditionnelle invalide") - expect(page).to have_text("Le champ « header sections must have consistent order » devrait être précédé d'un titre de niveau 1") - # TODO, test attestation_template, initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail + it 'renders all errors and links on champ' do + expect(page).to have_selector("a", text: "drop down list requires options") + expect(page).to have_content("doit comporter au moins un choix sélectionnable") + + expect(page).to have_selector("a", text: "repetition requires children") + expect(page).to have_content("doit comporter au moins un champ répétable") + + expect(page).to have_selector("a", text: "invalid condition") + expect(page).to have_content("a une logique conditionnelle invalide") + + expect(page).to have_selector("a", text: "header sections must have consistent order") + expect(page).to have_content("devrait être précédé d'un titre de niveau 1") end end @@ -73,8 +85,9 @@ describe Procedure::ErrorsSummary, type: :component do end it 'render error nicely' do - expect(page).to have_text("Le modèle d’attestation n'est pas valide") - expect(page).to have_text("L’email de notification de passage de dossier en instruction n'est pas valide") + expect(page).to have_selector("a", text: "Le modèle d’attestation") + expect(page).to have_selector("a", text: "L’email de notification de passage de dossier en instruction") + expect(page).to have_text("n'est pas valide", count: 2) end end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 2a20829ab..0fa94a425 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -372,12 +372,12 @@ describe Procedure do ] end let(:types_de_champ_private) { [] } - let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' } - let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' } + let(:invalid_repetition_error_message) { "doit comporter au moins un champ répétable" } + let(:invalid_drop_down_error_message) { "doit comporter au moins un choix sélectionnable" } it 'validates that no repetition type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message) new_draft = procedure.draft_revision repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?) @@ -385,17 +385,17 @@ describe Procedure do new_draft.revision_types_de_champ.create(type_de_champ: create(:type_de_champ), position: 0, parent: parent_coordinate) procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_repetition_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).not_to include(invalid_repetition_error_message) end it 'validates that no drop-down type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message) drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) drop_down.update!(drop_down_list_value: "--title--\r\nsome value") procedure.reload.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message) end end @@ -408,17 +408,21 @@ describe Procedure do end let(:types_de_champ_public) { [] } - let(:invalid_repetition_error_message) { 'L’annotation privée « Enfants » doit comporter au moins un champ répétable' } - let(:invalid_drop_down_error_message) { 'L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable' } + let(:invalid_repetition_error_message) { "doit comporter au moins un champ répétable" } + let(:invalid_drop_down_error_message) { "doit comporter au moins un choix sélectionnable" } it 'validates that no repetition type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_repetition_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_repetition_error_message) + repetition = procedure.draft_revision.types_de_champ_private.find(&:repetition?) + expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(repetition) end it 'validates that no drop-down type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) + drop_down = procedure.draft_revision.types_de_champ_private.find(&:drop_down_list?) + expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(drop_down) end end @@ -441,7 +445,7 @@ describe Procedure do include Logic let(:types_de_champ_public) { [{ type: :text, libelle: 'condition', condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } let(:types_de_champ_private) { [{ type: :decimal_number, stable_id: 1 }] } - let(:error_on_condition) { "Le champ « condition » a une logique conditionnelle invalide" } + let(:error_on_condition) { "Le champ a une logique conditionnelle invalide" } it 'validate without context' do procedure.validate diff --git a/spec/system/administrateurs/procedure_publish_spec.rb b/spec/system/administrateurs/procedure_publish_spec.rb index 10818971c..dc1858358 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -72,8 +72,8 @@ describe 'Publishing a procedure', js: true do visit admin_procedure_path(procedure) expect(page).to have_content('Des problèmes empêchent la publication de la démarche') - expect(page).to have_content("« Enfants » doit comporter au moins un champ répétable") - expect(page).to have_content("« Civilité » doit comporter au moins un choix sélectionnable") + expect(page).to have_content("Enfants doit comporter au moins un champ répétable") + expect(page).to have_content("Civilité doit comporter au moins un choix sélectionnable") visit admin_procedure_publication_path(procedure) expect(find_field('procedure_path').value).to eq procedure.path @@ -195,7 +195,7 @@ describe 'Publishing a procedure', js: true do scenario 'an error message prevents the publication' do visit admin_procedure_path(procedure) expect(page).to have_content('Des problèmes empêchent la publication des modifications') - expect(page).to have_link('corriger', href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG)) + expect(page).to have_link(href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG)) expect(page).to have_button('Publier les modifications', disabled: true) end end From a0115767579cb447ba5b4efac25adb61fb4152bc Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 18:08:33 +0200 Subject: [PATCH 59/63] feat(procedure_revision.validates): ineligibilite_rules --- app/components/procedure/errors_summary.rb | 2 ++ app/models/procedure.rb | 9 ++++++++- app/models/procedure_revision.rb | 7 +++++++ config/locales/en.yml | 5 +++++ config/locales/fr.yml | 5 +++++ spec/components/procedures/errors_summary_spec.rb | 4 ++++ .../types_de_champ_editor/editor_component_spec.rb | 10 ++++++---- 7 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb index e185c81b3..bf41ab3da 100644 --- a/app/components/procedure/errors_summary.rb +++ b/app/components/procedure/errors_summary.rb @@ -32,6 +32,8 @@ class Procedure::ErrorsSummary < ApplicationComponent def error_correction_page(error) case error.attribute + when :ineligibilite_rules + edit_admin_procedure_ineligibilite_rules_path(@procedure) when :draft_types_de_champ_public tdc = error.options[:type_de_champ] champs_admin_procedure_path(@procedure, anchor: dom_id(tdc.stable_self, :editor_error)) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 21f272372..4e080831c 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -293,7 +293,7 @@ class Procedure < ApplicationRecord validates_with MonAvisEmbedValidator - validates_associated :draft_revision, on: :publication + validate :validates_associated_draft_revision_with_context validates_associated :initiated_mail, on: :publication validates_associated :received_mail, on: :publication validates_associated :closed_mail, on: :publication @@ -1020,6 +1020,13 @@ class Procedure < ApplicationRecord private + def validates_associated_draft_revision_with_context + return if draft_revision.blank? + return if draft_revision.validate(validation_context) + + draft_revision.errors.map { errors.import(_1) } + end + def validate_auto_archive_on_in_the_future return if auto_archive_on.nil? return if auto_archive_on.future? diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 7e4f30860..bb7dbb43e 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -496,6 +496,13 @@ class ProcedureRevision < ApplicationRecord end end + def ineligibilite_rules_are_valid? + if ineligibilite_rules + ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) + .each { errors.add(:ineligibilite_rules, :invalid) } + end + end + def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) diff --git a/config/locales/en.yml b/config/locales/en.yml index 17b7328d0..a05c394b2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -606,6 +606,7 @@ en: otp_attempt: 'OTP code (only if you have already activated 2FA)' procedure: zone: This procedure is run by + ineligibilite_rules: "Eligibility rules" champs: value: Value default_mail_attributes: &default_mail_attributes @@ -667,6 +668,10 @@ en: path: taken: is already used for procedure. You cannot use it because it belongs to another administrator. invalid: is not valid. It must countain between 3 and 200 characters among a-z, 0-9, '_' and '-'. + procedure_revision: + attributes: + ineligibilite_rules: + invalid: are invalid "dossier/champs": format: "%{message}" attributes: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 227f89a6e..624e141d2 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -610,6 +610,7 @@ fr: otp_attempt: 'Code OTP (uniquement si vous avez déjà activé 2FA)' procedure: zone: La démarche est mise en œuvre par + ineligibilite_rules: "Les règles d’Inéligibilité" champs: value: Valeur du champ default_mail_attributes: &default_mail_attributes @@ -669,6 +670,10 @@ fr: path: taken: est déjà utilisé par une démarche. Vous ne pouvez pas l’utiliser car il appartient à un autre administrateur. invalid: n’est pas valide. Il doit comporter au moins 3 caractères, au plus 200 caractères et seuls les caractères a-z, 0-9, '_' et '-' sont autorisés. + procedure_revision: + attributes: + ineligibilite_rules: + invalid: ne sont pas valides "dossier/champs": format: "%{message}" attributes: diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index 3e64cc790..ebeb1096d 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -74,6 +74,8 @@ describe Procedure::ErrorsSummary, type: :component do end describe 'render error for other kind of associated objects' do + include Logic + let(:validation_context) { :publication } let(:procedure) { create(:procedure, attestation_template:, initiated_mail:) } let(:attestation_template) { build(:attestation_template) } @@ -81,10 +83,12 @@ describe Procedure::ErrorsSummary, type: :component do before do [:attestation_template, :initiated_mail].map { procedure.send(_1).update_column(:body, '--invalidtag--') } + procedure.draft_revision.update(ineligibilite_enabled: true, ineligibilite_rules: ds_eq(constant(true), constant(1)), ineligibilite_message: 'ko') subject end it 'render error nicely' do + expect(page).to have_selector("a", text: "Les règles d’inéligibilité") expect(page).to have_selector("a", text: "Le modèle d’attestation") expect(page).to have_selector("a", text: "L’email de notification de passage de dossier en instruction") expect(page).to have_text("n'est pas valide", count: 2) diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb index 7b4a19e46..5b643995c 100644 --- a/spec/components/types_de_champ_editor/editor_component_spec.rb +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -10,16 +10,18 @@ describe TypesDeChampEditor::EditorComponent, type: :component do context 'types_de_champ_public' do let(:is_annotation) { false } it 'does not render private champs errors' do - expect(subject).not_to have_text("« private » doit comporter au moins un choix sélectionnable") - expect(subject).to have_text("« public » doit comporter au moins un choix sélectionnable") + expect(subject).not_to have_text("private") + expect(subject).to have_selector("a", text: "public") + expect(subject).to have_text("doit comporter au moins un choix sélectionnable") end end context 'types_de_champ_private' do let(:is_annotation) { true } it 'does not render public champs errors' do - expect(subject).to have_text("« private » doit comporter au moins un choix sélectionnable") - expect(subject).not_to have_text("« public » doit comporter au moins un choix sélectionnable") + expect(subject).to have_selector("a", "private") + expect(subject).to have_text("doit comporter au moins un choix sélectionnable") + expect(subject).not_to have_text("public") end end end From f819da8921b5e1631a1b4cfb200222e52fa848f7 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 19:16:41 +0200 Subject: [PATCH 60/63] tech(clean): simplify implementation of eligibilite rules, code, enhance wording and test coverage --- .../stylesheets/conditions_component.scss | 9 ++ .../ineligibilite_rules_component.html.haml | 53 ++++++----- .../dossiers/edit_footer_component.rb | 10 +-- .../edit_footer_component.html.haml | 4 +- .../invalid_ineligibilite_rules_component.rb | 4 +- .../card/ineligibilite_dossier_component.rb | 2 +- .../ineligibilite_dossier_component.fr.yml | 4 +- .../ineligibilite_dossier_component.html.haml | 4 +- app/controllers/users/dossiers_controller.rb | 3 - app/models/dossier.rb | 7 +- app/models/logic/and.rb | 7 -- app/models/logic/binary_operator.rb | 9 -- app/models/logic/or.rb | 10 --- app/models/procedure_revision.rb | 13 --- .../ineligibilite_rules/edit.html.haml | 6 +- .../users/dossiers/update.turbo_stream.haml | 7 +- config/locales/fr.yml | 2 +- config/locales/models/procedure/fr.yml | 2 +- .../locales/models/procedure_revision/fr.yml | 7 ++ .../dossiers/edit_footer_component_spec.rb | 8 +- .../editor_component_spec.rb | 2 +- .../ineligibilite_rules_controller_spec.rb | 4 +- .../users/dossiers_controller_spec.rb | 13 +-- spec/models/logic/and_spec.rb | 36 -------- spec/models/logic/binary_operator_spec.rb | 13 --- spec/models/logic/or_spec.rb | 43 --------- .../procedure_ineligibilite_spec.rb | 8 +- .../users/dossier_ineligibilite_spec.rb | 89 ++++++++++++++++--- .../shared/dossiers/_edit.html.haml_spec.rb | 1 - 29 files changed, 161 insertions(+), 219 deletions(-) create mode 100644 config/locales/models/procedure_revision/fr.yml diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss index 055e9b4f9..59f8bf6b9 100644 --- a/app/assets/stylesheets/conditions_component.scss +++ b/app/assets/stylesheets/conditions_component.scss @@ -57,5 +57,14 @@ form.form > .conditionnel { select.alert { border-color: $dark-red; } + + &:first-child { + padding-left: 0; + } + + &:last-child { + text-align: right; + padding-right: 0; + } } } diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml index 547a2ad85..313618167 100644 --- a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml @@ -1,21 +1,30 @@ %div{ id: dom_id(@draft_revision, :ineligibilite_rules) } = render Procedure::PendingRepublishComponent.new(procedure: @draft_revision.procedure, render_if: pending_changes?) = render Conditions::ConditionsErrorsComponent.new(conditions: condition_per_row, source_tdcs: @source_tdcs) - %fieldset.fr-fieldset - %legend.fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w - Règles d’inéligibilité - %span.fr-hint-text Vous pouvez utiliser 1 ou plusieurs critère pour bloquer le dépot + .fr-fieldset + = form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), html: { id: 'ineligibilite_form', class: 'width-100' }) do |f| + .fr-fieldset__element + .fr-toggle.fr-toggle--label-left + = f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt + = f.label :ineligibilite_enabled, "Bloquer le dépôt des dossiers répondant à des conditions d’inéligibilité", data: { 'fr-checked-label': "Activé", 'fr-unchecked-label': "Désactivé" }, class: 'fr-toggle__label' + + .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5}) + + .fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w + Conditions d’inéligibilité + %span.fr-hint-text Vous pouvez utiliser une ou plusieurs condtions pour bloquer le dépot. .fr-fieldset__element = form_tag admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), method: :patch, data: { turbo: true, controller: 'autosave' }, class: 'form width-100' do .conditionnel.width-100 %table.condition-table - %thead - %tr - %th.fr-pt-0.far-left - %th.fr-pt-0.target Champ Cible - %th.fr-pt-0.operator Opérateur - %th.fr-pt-0.value Valeur - %th.fr-pt-0.delete-column + - if rows.size > 0 + %thead + %tr + %th.fr-pt-0.far-left + %th.fr-pt-0.target Champ Cible + %th.fr-pt-0.operator Opérateur + %th.fr-pt-0.value Valeur + %th.fr-pt-0.delete-column %tbody - rows.each.with_index do |(targeted_champ, operator_name, value), row_index| %tr @@ -28,15 +37,13 @@ %tr %td.text-right{ colspan: 5 }= add_condition_tag - - - = form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id)) do |f| - .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5}) - .fr-fieldset__element - .fr-toggle - = f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt - = f.label :ineligibilite_enabled, "Inéligibilité des dossiers", data: { 'fr-checked-label': "Actif", 'fr-unchecked-label': "Inactif" }, class: 'fr-toggle__label' - %p.fr-hint-text Passer l’intérrupteur sur activé pour que les critères d’inéligibilité configurés s'appliquent - - - = render Procedure::FixedFooterComponent.new(procedure: @draft_revision.procedure, form: f, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8') + .padded-fixed-footer + .fixed-footer + .fr-container + .fr-grid-row.fr-col-offset-md-2.fr-col-md-8 + .fr-col-12 + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @draft_revision.procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Si vous avez fait des modifications elles ne seront pas sauvegardées.'} + %li + = button_tag "Enregistrer", class: "fr-btn", form: 'ineligibilite_form' diff --git a/app/components/dossiers/edit_footer_component.rb b/app/components/dossiers/edit_footer_component.rb index ac77bbfea..5f5bb8980 100644 --- a/app/components/dossiers/edit_footer_component.rb +++ b/app/components/dossiers/edit_footer_component.rb @@ -1,5 +1,5 @@ class Dossiers::EditFooterComponent < ApplicationComponent - delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + delegate :can_passer_en_construction?, to: :@dossier def initialize(dossier:, annotation:) @dossier = dossier @@ -27,7 +27,7 @@ class Dossiers::EditFooterComponent < ApplicationComponent def submit_draft_button_options { class: 'fr-btn fr-btn--sm', - disabled: !owner? || ineligibilite_rules_invalid?, + disabled: !owner? || !can_passer_en_construction?, method: :post, data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server } } @@ -36,17 +36,13 @@ class Dossiers::EditFooterComponent < ApplicationComponent def submit_en_construction_button_options { class: 'fr-btn fr-btn--sm', - disabled: ineligibilite_rules_invalid?, + disabled: !can_passer_en_construction?, method: :post, data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server }, form: { id: "form-submit-en-construction" } } end - def ineligibilite_rules_invalid? - ineligibilite_rules_computable? && !can_passer_en_construction? - end - def render? !@dossier.for_procedure_preview? end diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml index fb4ab8fb1..2f0f59b2b 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml @@ -3,12 +3,12 @@ = render Dossiers::AutosaveFooterComponent.new(dossier: @dossier, annotation: annotation?) - if !annotation? && @dossier.can_transition_to_en_construction? - - if ineligibilite_rules_invalid? + - if !can_passer_en_construction? = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit'), brouillon_dossier_url(@dossier), submit_draft_button_options - if @dossier.forked_with_changes? - - if ineligibilite_rules_invalid? + - if !can_passer_en_construction? = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit_changes'), modifier_dossier_url(@dossier.editing_fork_origin), submit_en_construction_button_options diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component.rb b/app/components/dossiers/invalid_ineligibilite_rules_component.rb index fe45272f6..526bdbc94 100644 --- a/app/components/dossiers/invalid_ineligibilite_rules_component.rb +++ b/app/components/dossiers/invalid_ineligibilite_rules_component.rb @@ -1,5 +1,5 @@ class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent - delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + delegate :can_passer_en_construction?, to: :@dossier def initialize(dossier:) @dossier = dossier @@ -7,7 +7,7 @@ class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent end def render? - ineligibilite_rules_computable? && !can_passer_en_construction? + !can_passer_en_construction? end def error_message diff --git a/app/components/procedure/card/ineligibilite_dossier_component.rb b/app/components/procedure/card/ineligibilite_dossier_component.rb index d69e06623..b1d371708 100644 --- a/app/components/procedure/card/ineligibilite_dossier_component.rb +++ b/app/components/procedure/card/ineligibilite_dossier_component.rb @@ -6,7 +6,7 @@ class Procedure::Card::IneligibiliteDossierComponent < ApplicationComponent def ready? @procedure.draft_revision .conditionable_types_de_champ - .present? + .present? && @procedure.draft_revision.ineligibilite_enabled end def error? diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml index d65f0d535..6e78d7da6 100644 --- a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml @@ -2,7 +2,7 @@ fr: title: Inéligibilité des dossiers state: - pending: Champs à configurer + pending: Désactivé ready: À configurer completed: Activé - subtitle: Gérez vos critères d’inéligibilité en fonction des champs du formulaire + subtitle: Gérez vos conditions d’inéligibilité en fonction des champs du formulaire diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml index e82e64fad..aeced88e6 100644 --- a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml @@ -2,11 +2,9 @@ = link_to edit_admin_procedure_ineligibilite_rules_path(@procedure), class: 'fr-tile fr-enlarge-link' do .fr-tile__body.flex.column.align-center.justify-between - if !ready? - %p.fr-badge.fr-badge--warning= t('.state.pending') + %p.fr-badge.fr-badge= t('.state.pending') - elsif error? %p.fr-badge.fr-badge--error À modifier - - elsif !completed? - %p.fr-badge.fr-badge--info= t('.state.ready') - else %p.fr-badge.fr-badge--success= t('.state.completed') %div diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index ebfa2cdb4..1fac35eae 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -302,13 +302,10 @@ module Users def update @dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier @dossier = dossier_with_champs(pj_template: false) - @ineligibilite_rules_was_computable = @dossier.ineligibilite_rules_computable? @can_passer_en_construction_was = @dossier.can_passer_en_construction? update_dossier_and_compute_errors @dossier.index_search_terms_later if @dossier.errors.empty? - @ineligibilite_rules_is_computable = @dossier.ineligibilite_rules_computable? @can_passer_en_construction_is = @dossier.can_passer_en_construction? - @ineligibilite_rules_computable_changed = !@ineligibilite_rules_was_computable && @ineligibilite_rules_is_computable respond_to do |format| format.turbo_stream do @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 7a1c611a8..343baf06d 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -940,12 +940,9 @@ class Dossier < ApplicationRecord .filter(&:visible?) .filter(&:mandatory_blank?) .map do |champ| - errors.import(champ.errors.add(:value, :missing)) + champ.errors.add(:value, :missing) end - end - - def ineligibilite_rules_computable? - revision.ineligibilite_rules_computable?(champs_for_revision(scope: :public)) + .each { errors.import(_1) } end def demander_un_avis!(avis) diff --git a/app/models/logic/and.rb b/app/models/logic/and.rb index 11d31a9c0..51537235f 100644 --- a/app/models/logic/and.rb +++ b/app/models/logic/and.rb @@ -7,12 +7,5 @@ class Logic::And < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.all? end - def computable?(champs = []) - return true if sources.blank? - - champs.filter { _1.stable_id.in?(sources) && _1.visible? } - .all? { _1.value.present? } - end - def to_s(type_de_champs) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' && ')})" end diff --git a/app/models/logic/binary_operator.rb b/app/models/logic/binary_operator.rb index 35f6ce1a7..812fa0605 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -42,15 +42,6 @@ class Logic::BinaryOperator < Logic::Term l&.send(operation, r) || false end - def computable?(champs = []) - return true if sources.blank? - - visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } - - return false if visible_champs_sources.size != sources.size - visible_champs_sources.all? { _1.value.present? } - end - def to_s(type_de_champs) = "(#{@left.to_s(type_de_champs)} #{operation} #{@right.to_s(type_de_champs)})" def ==(other) diff --git a/app/models/logic/or.rb b/app/models/logic/or.rb index 96a0fe133..a0e2dfeae 100644 --- a/app/models/logic/or.rb +++ b/app/models/logic/or.rb @@ -7,15 +7,5 @@ class Logic::Or < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.any? end - - def computable?(champs = []) - return true if sources.blank? - - visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } - - return false if visible_champs_sources.blank? - visible_champs_sources.all? { _1.value.present? } || compute(visible_champs_sources) - end - def to_s(type_de_champs = []) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' || ')})" end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index bb7dbb43e..0a27fec2c 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -269,12 +269,6 @@ class ProcedureRevision < ApplicationRecord types_de_champ_for(scope: :public).filter(&:conditionable?) end - def ineligibilite_rules_computable?(champs) - ineligibilite_enabled && ineligibilite_rules&.computable?(champs) - ensure - champs.map(&:reset_visible) # otherwise @visible is cached, then dossier can be updated. champs are not updated - end - private def compute_estimated_fill_duration @@ -496,13 +490,6 @@ class ProcedureRevision < ApplicationRecord end end - def ineligibilite_rules_are_valid? - if ineligibilite_rules - ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) - .each { errors.add(:ineligibilite_rules, :invalid) } - end - end - def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) diff --git a/app/views/administrateurs/ineligibilite_rules/edit.html.haml b/app/views/administrateurs/ineligibilite_rules/edit.html.haml index a76a30468..6eb91d12f 100644 --- a/app/views/administrateurs/ineligibilite_rules/edit.html.haml +++ b/app/views/administrateurs/ineligibilite_rules/edit.html.haml @@ -12,17 +12,17 @@ = render Dsfr::AlertComponent.new(title: nil, size: :sm, state: :info, heading_level: 'h2', extra_class_names: 'fr-my-2w') do |c| - c.with_body do %p - Les dossiers répondant à vos critères d’inéligibilité ne pourront pas être déposés. Plus d’informations sur l’inéligibilité des dossiers dans la + Les dossiers répondant à vos conditions d’inéligibilité ne pourront pas être déposés. Plus d’informations sur l’inéligibilité des dossiers dans la = link_to('doc', ELIGIBILITE_URL, title: "Document sur l’inéligibilité des dossiers", **external_link_attributes) - if !@procedure.draft_revision.conditionable_types_de_champ.present? %p.fr-mt-2w.fr-mb-2w - Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : + Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les conditions d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : %ul - Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.each do %li= "« #{t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" %p.fr-mt-2w - = link_to 'Ajouter un champ supportant les critères d’inéligibilité', champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' + = link_to 'Ajouter un champ supportant les conditions d’inéligibilité', champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' = render Procedure::FixedFooterComponent.new(procedure: @procedure) - else = render Conditions::IneligibiliteRulesComponent.new(draft_revision: @procedure.draft_revision) diff --git a/app/views/users/dossiers/update.turbo_stream.haml b/app/views/users/dossiers/update.turbo_stream.haml index 374291733..8224c1abd 100644 --- a/app/views/users/dossiers/update.turbo_stream.haml +++ b/app/views/users/dossiers/update.turbo_stream.haml @@ -1,8 +1,7 @@ = render partial: 'shared/dossiers/update_champs', locals: { to_show: @to_show, to_hide: @to_hide, to_update: @to_update, dossier: @dossier } - if !params.key?(:validate) - - if @ineligibilite_rules_is_computable - = turbo_stream.remove(dom_id(@dossier, :ineligibilite_rules_broken)) - - - if (@ineligibilite_rules_computable_changed && !@can_passer_en_construction_is) || (@can_passer_en_construction_was && !@can_passer_en_construction_is) + - if @can_passer_en_construction_was && !@can_passer_en_construction_is = turbo_stream.append('contenu', render(Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: @dossier))) + - else @ineligibilite_rules_is_computable + = turbo_stream.remove(dom_id(@dossier, :ineligibilite_rules_broken)) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 624e141d2..42896bd24 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -610,7 +610,7 @@ fr: otp_attempt: 'Code OTP (uniquement si vous avez déjà activé 2FA)' procedure: zone: La démarche est mise en œuvre par - ineligibilite_rules: "Les règles d’Inéligibilité" + ineligibilite_rules: "Les règles d’inéligibilité" champs: value: Valeur du champ default_mail_attributes: &default_mail_attributes diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 85df92d73..5a9dfd9ab 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -8,7 +8,7 @@ fr: procedure: hints: description: Décrivez en quelques lignes le contexte, la finalité, etc. - description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les critères d’éligibilité s’il y en a, les pré-requis, etc. + description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les conditions d’éligibilité s’il y en a, les pré-requis, etc. description_pj: Décrivez la liste des pièces jointes à fournir s’il y en a lien_site_web: "Il s'agit de la page de votre site web où le lien sera diffusé. Ex: https://exemple.gouv.fr/page_informant_sur_ma_demarche" cadre_juridique: "Exemple: 'https://www.legifrance.gouv.fr/'" diff --git a/config/locales/models/procedure_revision/fr.yml b/config/locales/models/procedure_revision/fr.yml new file mode 100644 index 000000000..1665415aa --- /dev/null +++ b/config/locales/models/procedure_revision/fr.yml @@ -0,0 +1,7 @@ +fr: + activerecord: + attributes: + procedure_revision: + ineligibilite_message: Message d’inéligibilité + hints: + ineligibilite_message: "Ce message sera affiché à l’usager si son dossier est bloqué et lui expliquera la raison de son inéligibilité." diff --git a/spec/components/dossiers/edit_footer_component_spec.rb b/spec/components/dossiers/edit_footer_component_spec.rb index 40e60802b..4b8e1a77f 100644 --- a/spec/components/dossiers/edit_footer_component_spec.rb +++ b/spec/components/dossiers/edit_footer_component_spec.rb @@ -10,14 +10,14 @@ RSpec.describe Dossiers::EditFooterComponent, type: :component do let(:dossier) { create(:dossier, :brouillon) } context 'when dossier can be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(true) } it 'renders submit button without disabled' do expect(subject).to have_selector('button', text: 'Déposer le dossier') end end context 'when dossier can not be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(false) } it 'renders submit button with disabled' do expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') expect(subject).to have_selector('button[disabled]', text: 'Déposer le dossier') @@ -31,7 +31,7 @@ RSpec.describe Dossiers::EditFooterComponent, type: :component do before { allow(dossier).to receive(:forked_with_changes?).and_return(true) } context 'when dossier can be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(true) } it 'renders submit button without disabled' do expect(subject).to have_selector('button', text: 'Déposer les modifications') @@ -39,7 +39,7 @@ RSpec.describe Dossiers::EditFooterComponent, type: :component do end context 'when dossier can not be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(false) } it 'renders submit button with disabled' do expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb index 5b643995c..fb7094983 100644 --- a/spec/components/types_de_champ_editor/editor_component_spec.rb +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -19,7 +19,7 @@ describe TypesDeChampEditor::EditorComponent, type: :component do context 'types_de_champ_private' do let(:is_annotation) { true } it 'does not render public champs errors' do - expect(subject).to have_selector("a", "private") + expect(subject).to have_selector("a", text: "private") expect(subject).to have_text("doit comporter au moins un choix sélectionnable") expect(subject).not_to have_text("public") end diff --git a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb index 5c8f94628..2a76a054a 100644 --- a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb +++ b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb @@ -197,14 +197,14 @@ describe Administrateurs::IneligibiliteRulesController, type: :controller do let(:types_de_champ_public) { [] } render_views - it { expect(response.body).to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + it { expect(response.body).to have_link("Ajouter un champ supportant les conditions d’inéligibilité") } end context 'rendered with tdc' do let(:types_de_champ_public) { [{ type: :yes_no }] } render_views - it { expect(response.body).not_to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + it { expect(response.body).not_to have_link("Ajouter un champ supportant les conditions d’inéligibilité") } end end end diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 1f78b2f4a..2a38eb7d7 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -791,26 +791,27 @@ describe Users::DossiersController, type: :controller do end render_views - context 'when it pass from undefined to true' do + context 'when it switches from true to false' do let(:value) { must_be_greater_than + 1 } it 'raises popup' do subject dossier.reload expect(dossier.can_passer_en_construction?).to be_falsey - expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) - expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(assigns(:can_passer_en_construction_was)).to eq(true) + expect(assigns(:can_passer_en_construction_is)).to eq(false) expect(response.body).to match(ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)) end end - context 'when it pass from undefined to false' do + + context 'when it stays true' do let(:value) { must_be_greater_than - 1 } it 'does nothing' do subject dossier.reload expect(dossier.can_passer_en_construction?).to be_truthy - expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) - expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(assigns(:can_passer_en_construction_was)).to eq(true) + expect(assigns(:can_passer_en_construction_is)).to eq(true) expect(response.body).not_to have_selector("##{ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)}") end end diff --git a/spec/models/logic/and_spec.rb b/spec/models/logic/and_spec.rb index c0eefc8e8..67f319acb 100644 --- a/spec/models/logic/and_spec.rb +++ b/spec/models/logic/and_spec.rb @@ -6,42 +6,6 @@ describe Logic::And do it { expect(and_from([true, true, false]).compute).to be false } end - describe '#computable?' do - let(:champ_1) { create(:champ_integer_number, value: value_1) } - let(:champ_2) { create(:champ_integer_number, value: value_2) } - - let(:logic) do - ds_and([ - greater_than(champ_value(champ_1.stable_id), constant(1)), - less_than(champ_value(champ_2.stable_id), constant(10)) - ]) - end - - subject { logic.computable?([champ_1, champ_2]) } - - context "when none of champs.value are filled, and logic can't be computed" do - let(:value_1) { nil } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context "when one champs has a value (that compute to false) the other has not, and logic keeps waiting for the 2nd value" do - let(:value_1) { 1 } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context 'when all champs.value are filled, and logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { 10 } - it { is_expected.to be_truthy } - end - context 'when one champs is not visible and the other has a value, and logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { nil } - before { expect(champ_2).to receive(:visible?).and_return(false) } - it { is_expected.to be_truthy } - end - end - describe '#to_s' do it do expect(and_from([true, false, true]).to_s([])).to eq "(Oui && Non && Oui)" diff --git a/spec/models/logic/binary_operator_spec.rb b/spec/models/logic/binary_operator_spec.rb index f816e81e7..b7924ebc7 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -28,19 +28,6 @@ describe Logic::BinaryOperator do it { expect(greater_than(constant(2), champ_value(champ.stable_id)).sources).to eq([champ.stable_id]) } it { expect(greater_than(champ_value(champ.stable_id), champ_value(champ2.stable_id)).sources).to eq([champ.stable_id, champ2.stable_id]) } end - - describe '#computable?' do - let(:champ) { create(:champ_integer_number, value: nil) } - - it 'computable?' do - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([])).to be(false) - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) - allow(champ).to receive(:value).and_return(double(present?: true)) - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(true) - allow(champ).to receive(:visible?).and_return(false) - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) - end - end end describe Logic::GreaterThan do diff --git a/spec/models/logic/or_spec.rb b/spec/models/logic/or_spec.rb index 82d5392fb..1888587d2 100644 --- a/spec/models/logic/or_spec.rb +++ b/spec/models/logic/or_spec.rb @@ -7,49 +7,6 @@ describe Logic::Or do it { expect(or_from([false, false, false]).compute).to be false } end - describe '#computable?' do - let(:champ_1) { create(:champ_integer_number, value: value_1) } - let(:champ_2) { create(:champ_integer_number, value: value_2) } - - let(:logic) do - ds_or([ - greater_than(champ_value(champ_1.stable_id), constant(1)), - less_than(champ_value(champ_2.stable_id), constant(10)) - ]) - end - - context 'with all champs' do - subject { logic.computable?([champ_1, champ_2]) } - - context "when none of champs.value are filled, or logic can't be computed" do - let(:value_1) { nil } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context "when one champs has a value (that compute to false) the other has not, or logic keeps waiting for the 2nd value" do - let(:value_1) { 1 } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context 'when all champs.value are filled, or logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { 10 } - it { is_expected.to be_truthy } - end - context 'when one champs.value and his condition is true, or logic can be computed' do - let(:value_1) { 2 } - let(:value_2) { nil } - it { is_expected.to be_truthy } - end - context 'when one champs is not visible and the other has a value that fails, or logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { nil } - before { expect(champ_2).to receive(:visible?).and_return(false) } - it { is_expected.to be_truthy } - end - end - end - describe '#to_s' do it { expect(or_from([true, false, true]).to_s).to eq "(Oui || Non || Oui)" } end diff --git a/spec/system/administrateurs/procedure_ineligibilite_spec.rb b/spec/system/administrateurs/procedure_ineligibilite_spec.rb index 9db80cf59..e93a6ea5d 100644 --- a/spec/system/administrateurs/procedure_ineligibilite_spec.rb +++ b/spec/system/administrateurs/procedure_ineligibilite_spec.rb @@ -9,13 +9,13 @@ describe 'Administrateurs can edit procedures', js: true do scenario 'setup eligibilite' do # explain no champ compatible visit admin_procedure_path(procedure) - expect(page).to have_content("Champs à configurer") + expect(page).to have_content("Désactivé") # explain which champs are compatible visit edit_admin_procedure_ineligibilite_rules_path(procedure) expect(page).to have_content("Inéligibilité des dossiers") - expect(page).to have_content("Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ") - click_on "Ajouter un champ supportant les critères d’inéligibilité" + expect(page).to have_content("Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les conditions d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ") + click_on "Ajouter un champ supportant les conditions d’inéligibilité" # setup a compatible champ expect(page).to have_content('Champs du formulaire') @@ -32,7 +32,7 @@ describe 'Administrateurs can edit procedures', js: true do # setup rules and stuffs expect(page).to have_content("Inéligibilité des dossiers") fill_in "Message d’inéligibilité", with: "vous n'etes pas eligible" - find('label', text: 'Inéligibilité des dossiers').click + find('label', text: 'Bloquer le dépôt des dossiers répondant à des conditions d’inéligibilité').click click_on "Ajouter une règle d’inéligibilité" all('select').first.select 'Un champ oui non' click_on 'Enregistrer' diff --git a/spec/system/users/dossier_ineligibilite_spec.rb b/spec/system/users/dossier_ineligibilite_spec.rb index 366dac780..5bbb25c75 100644 --- a/spec/system/users/dossier_ineligibilite_spec.rb +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -9,7 +9,7 @@ describe 'Dossier Inéligibilité', js: true do let(:published_revision) { procedure.published_revision } let(:first_tdc) { published_revision.types_de_champ.first } - let(:second_tdc) { published_revision.types_de_champ.last } + let(:second_tdc) { published_revision.types_de_champ.second } let(:ineligibilite_message) { 'sry vous pouvez aps soumettre votre dossier' } let(:eligibilite_params) { { ineligibilite_enabled: true, ineligibilite_message: } } @@ -18,8 +18,8 @@ describe 'Dossier Inéligibilité', js: true do login_as user, scope: :user end - context 'single condition' do - let(:types_de_champ_public) { [{ type: :yes_no }] } + describe 'ineligibilite_rules with a single BinaryOperator' do + let(:types_de_champ_public) { [{ type: :yes_no, stable_id: 1 }] } let(:ineligibilite_rules) { ds_eq(champ_value(first_tdc.stable_id), constant(true)) } scenario 'can submit, can not submit, reload' do @@ -28,24 +28,33 @@ describe 'Dossier Inéligibilité', js: true do expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") - # does raise error when dossier is filled with valid condition - find("label", text: "Non").click + # does raise error when dossier is filled with condition that does not match + within "#champ-1" do + find("label", text: "Non").click + end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") - # raise error when dossier is filled with invalid condition - find("label", text: "Oui").click + # raise error when dossier is filled with condition that matches + within "#champ-1" do + find("label", text: "Oui").click + end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") - # reload page and see error because it was filled + # reload page and see error visit brouillon_dossier_path(dossier) expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") # modal is closable, and we can change our dossier response to be eligible + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } - find("label", text: "Non").click + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) + + within "#champ-1" do + find("label", text: "Non").click + end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) # it works, yay @@ -54,7 +63,7 @@ describe 'Dossier Inéligibilité', js: true do end end - context 'or condition' do + describe 'ineligibilite_rules with a Or' do let(:types_de_champ_public) { [{ type: :yes_no, libelle: 'l1' }, { type: :drop_down_list, libelle: 'l2', options: ['Paris', 'Marseille'] }] } let(:ineligibilite_rules) do ds_or([ @@ -69,15 +78,17 @@ describe 'Dossier Inéligibilité', js: true do expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") - # only one condition is matches, cannot submit dossier and error message is clear + # first condition matches (so ineligible), cannot submit dossier and error message is clear within "#champ-#{first_tdc.stable_id}" do find("label", text: "Oui").click end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) - # only one condition does not matches, I can conitnue + # first condition does not matches, I can conitnue within "#champ-#{first_tdc.stable_id}" do find("label", text: "Non").click end @@ -88,12 +99,15 @@ describe 'Dossier Inéligibilité', js: true do click_on "Accéder à votre dossier" click_on "Modifier le dossier" - # one condition matches, means i'm blocked to send my file. + # first matches, means i'm blocked to send my file. within "#champ-#{first_tdc.stable_id}" do find("label", text: "Oui").click end expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) + within "#champ-#{first_tdc.stable_id}" do find("label", text: "Non").click end @@ -104,7 +118,56 @@ describe 'Dossier Inéligibilité', js: true do find("label", text: 'Paris').click end expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) + + # none of conditions matches, i can submit + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Marseille').click + end + + # it works, yay + click_on "Déposer les modifications" + wait_until { dossier.reload.en_construction? == true } + end + end + + describe 'ineligibilite_rules with a And and all visible champs' do + let(:types_de_champ_public) { [{ type: :yes_no, libelle: 'l1' }, { type: :drop_down_list, libelle: 'l2', options: ['Paris', 'Marseille'] }] } + let(:ineligibilite_rules) do + ds_and([ + ds_eq(champ_value(first_tdc.stable_id), constant(true)), + ds_eq(champ_value(second_tdc.stable_id), constant('Paris')) + ]) + end + + scenario 'can submit, can not submit, can edit, etc...' do + visit brouillon_dossier_path(dossier) + # no error while dossier is empty + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # only one condition is matches, can submit dossier + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Oui").click + end + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # Now test dossier modification + click_on "Déposer le dossier" + click_on "Accéder à votre dossier" + click_on "Modifier le dossier" + + # second condition matches, means i'm blocked to send my file + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Paris').click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) # none of conditions matches, i can submit within "#champ-#{second_tdc.stable_id}" do diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb index 5183554d8..f6ce8f5bf 100644 --- a/spec/views/shared/dossiers/_edit.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -155,7 +155,6 @@ describe 'shared/dossiers/edit', type: :view do let(:dossier) { create(:dossier, procedure:) } before do - allow_any_instance_of(Dossiers::InvalidIneligibiliteRulesComponent).to receive(:ineligibilite_rules_computable?).and_return(true) allow(dossier).to receive(:can_passer_en_construction?).and_return(false) end From 8e3d45b0b1e0872fb2523b9ecad885854fcea0b0 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 11 Jun 2024 10:17:27 +0200 Subject: [PATCH 61/63] review(pr): some enhancement, tx @colinux Co-Authored-By: Colin Darie --- app/controllers/email_checker_controller.rb | 2 +- .../controllers/email_input_controller.ts | 6 +++++- app/lib/email_checker.rb | 15 +++++++++------ spec/lib/email_checker_spec.rb | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/controllers/email_checker_controller.rb b/app/controllers/email_checker_controller.rb index b794b4d7a..19cd0493b 100644 --- a/app/controllers/email_checker_controller.rb +++ b/app/controllers/email_checker_controller.rb @@ -1,5 +1,5 @@ class EmailCheckerController < ApplicationController def show - render json: EmailChecker.check(email: params[:email]) + render json: EmailChecker.new.check(email: params[:email]) end end diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 8b64a7e92..f8442e1d3 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -21,7 +21,11 @@ export class EmailInputController extends ApplicationController { declare readonly inputTarget: HTMLInputElement; async checkEmail() { - if (!this.inputTarget.value) { + if ( + !this.inputTarget.value || + this.inputTarget.value.length < 5 || + !this.inputTarget.value.includes('@') + ) { return; } diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb index 97fa9d803..c2cbe3536 100644 --- a/app/lib/email_checker.rb +++ b/app/lib/email_checker.rb @@ -1,4 +1,7 @@ class EmailChecker + # Extracted 100 most used domain on our users table [june 2024] + # + all .gouv.fr domain on our users table + # + all .ac-xxx on our users table KNOWN_DOMAINS = [ 'gmail.com', 'hotmail.fr', @@ -612,10 +615,10 @@ class EmailChecker 'ac-toulous.fr' ].freeze - def self.check(email:) + def check(email:) return { success: false } if email.blank? - parsed_email = Mail::Address.new(email) + parsed_email = Mail::Address.new(EmailSanitizableConcern::EmailSanitizer.sanitize(email)) return { success: false } if parsed_email.domain.blank? return { success: true } if KNOWN_DOMAINS.any? { _1 == parsed_email.domain } @@ -628,22 +631,22 @@ class EmailChecker private - def self.closest_domains(domain:) + def closest_domains(domain:) KNOWN_DOMAINS.filter do |known_domain| close_by_distance_of(domain, known_domain, distance: 1) || with_same_chars_and_close_by_distance_of(domain, known_domain, distance: 2) end end - def self.close_by_distance_of(a, b, distance:) + def close_by_distance_of(a, b, distance:) String::Similarity.levenshtein_distance(a, b) == distance end - def self.with_same_chars_and_close_by_distance_of(a, b, distance:) + def with_same_chars_and_close_by_distance_of(a, b, distance:) close_by_distance_of(a, b, distance: 2) && a.chars.sort == b.chars.sort end - def self.email_suggestions(parsed_email:, similar_domains:) + def email_suggestions(parsed_email:, similar_domains:) similar_domains.map { Mail::Address.new("#{parsed_email.local}@#{_1}").to_s } end end diff --git a/spec/lib/email_checker_spec.rb b/spec/lib/email_checker_spec.rb index cfcf73bfa..f9c35ea91 100644 --- a/spec/lib/email_checker_spec.rb +++ b/spec/lib/email_checker_spec.rb @@ -1,6 +1,6 @@ describe EmailChecker do describe 'check' do - subject { described_class } + subject { described_class.new } it 'works with identified use cases' do expect(subject.check(email: nil)).to eq({ success: false }) From 8cb902821fda2437d8d2d4a8e5eb77d3fe7aa448 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 11 Jun 2024 11:40:15 +0200 Subject: [PATCH 62/63] bug(draft_types_de_champ_private.condition): condition must be validated with upper_tdcs. considering that types_de_champ_private can have a condition using a types_de_champ_public, we have to include all types_de_champs_public plus only types_de_champs_private.upper_tdcs --- spec/models/procedure_spec.rb | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0fa94a425..0b8b3f4ed 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -441,6 +441,33 @@ describe Procedure do end end + context 'when condition on champ private use public champ having a position higher than the champ private' do + include Logic + + let(:types_de_champ_public) do + [ + { type: :decimal_number, stable_id: 1 }, + { type: :decimal_number, stable_id: 2 } + ] + end + + let(:types_de_champ_private) do + [ + { type: :text, condition: ds_eq(champ_value(2), constant(2)), stable_id: 3 } + ] + end + + it 'validate without context' do + procedure.validate + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty + end + + it 'validate allows condition' do + procedure.validate(:types_de_champ_private_editor) + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty + end + end + context 'when condition on champ public use private champ' do include Logic let(:types_de_champ_public) { [{ type: :text, libelle: 'condition', condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } From 06a870a083fe0ed22a10da5f629fef622cbe512d Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 11 Jun 2024 11:48:21 +0200 Subject: [PATCH 63/63] fix(draft_types_de_champ_private.condition): condition must be validated with upper_tdcs. considering that types_de_champ_private can have a condition using a types_de_champ_public, we have to include all types_de_champs_public plus only types_de_champs_private.upper_tdcs --- .../types_de_champ/condition_validator.rb | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb index 65e1e887a..86e23ff62 100644 --- a/app/validators/types_de_champ/condition_validator.rb +++ b/app/validators/types_de_champ/condition_validator.rb @@ -1,24 +1,34 @@ class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator - def validate_each(procedure, attribute, types_de_champ) - return if types_de_champ.empty? + # condition are valid when + # tdc.condition.left is present in upper tdcs + # in case of types_de_champ_private, we should include types_de_champ_publics too + def validate_each(procedure, collection, tdcs) + return if tdcs.empty? - tdcs = if attribute == :draft_types_de_champ_private - procedure.draft_revision.types_de_champ_for - else - procedure.draft_revision.types_de_champ_for(scope: :public) - end - - tdcs.each_with_index do |tdc, i| + tdcs = tdcs_with_children(procedure, tdcs) + tdcs.each_with_index do |tdc, tdc_index| next unless tdc.condition? - errors = tdc.condition.errors(tdcs.take(i)) + upper_tdcs = [] + if collection == :draft_types_de_champ_private # in case of private tdc validation, we must include public tdcs + upper_tdcs += tdcs_with_children(procedure, procedure.draft_types_de_champ_public) + end + upper_tdcs += tdcs.take(tdc_index) # we take all upper_tdcs of current tdcs + + errors = tdc.condition.errors(upper_tdcs) next if errors.blank? procedure.errors.add( - attribute, - procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), + collection, + procedure.errors.generate_message(collection, :invalid_condition, { value: tdc.libelle }), type_de_champ: tdc ) end end + + # find children in repetitions + def tdcs_with_children(procedure, tdcs) + tdcs.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + end end