From ed496a45b4d44aba4fc493fc6cbfc0498547c50f Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 6 Jun 2024 11:53:08 +0200 Subject: [PATCH 001/111] ajout du texte introductif du bouton JDMA dans merci --- app/assets/stylesheets/merci.scss | 7 ------- app/views/users/dossiers/_merci.html.haml | 6 +++++- config/locales/en.yml | 2 ++ config/locales/fr.yml | 2 ++ 4 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 app/assets/stylesheets/merci.scss diff --git a/app/assets/stylesheets/merci.scss b/app/assets/stylesheets/merci.scss deleted file mode 100644 index b73c2d8e655..00000000000 --- a/app/assets/stylesheets/merci.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import "constants"; - -.merci .monavis { - img { - margin-top: 2 * $default-padding; - } -} diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 383f502d96e..12bd195f630 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -20,7 +20,11 @@ .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' + = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-3w fr-mb-2w' .monavis + %p.fr-mt-5w.fr-mb-1w + %strong= t('views.users.dossiers.merci.jdma_l1') + %p= t('views.users.dossiers.merci.jdma_l2') + != procedure.monavis_embed diff --git a/config/locales/en.yml b/config/locales/en.yml index b8413b1eb38..5787289e6a3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -446,6 +446,8 @@ en: dossier_edit_l4: talk with an instructor. acces_dossier: Access your file submit_dossier: Submit an other file + jdma_l1: Help us improve this service! + jdma_l2: Give us your feedback, it only takes 2 minutes. show: header: summary: "Summary" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 619f5f4f4f8..52100dc7873 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -449,6 +449,8 @@ fr: dossier_edit_l4: échanger avec un instructeur. acces_dossier: Accéder à votre dossier submit_dossier: Déposer un autre dossier + jdma_l1: Aidez-nous à améliorer ce service ! + jdma_l2: Donnez-nous votre avis, cela ne prend que 2 minutes. show: header: summary: "Résumé" From 2ecaee6fe283ad6ca64f24160c603023edfc31f4 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 Jun 2024 16:28:35 +0200 Subject: [PATCH 002/111] fix(graphql): use null_session forgery protection on graphql controller to allow open data requests --- app/controllers/api/v2/base_controller.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v2/base_controller.rb b/app/controllers/api/v2/base_controller.rb index 3c7d44e091e..f247e2a1249 100644 --- a/app/controllers/api/v2/base_controller.rb +++ b/app/controllers/api/v2/base_controller.rb @@ -1,5 +1,11 @@ class API::V2::BaseController < ApplicationController - skip_forgery_protection if: -> { request.headers.key?('HTTP_AUTHORIZATION') } + # This controller is used for API v2 through api endpoint (/api/v2/graphql) + # and through the web interface (/graphql). When used through the web interface, + # we use connected administrateur to authenticate the request. We want CSRF protection + # for the web interface, but not for the API endpoint. :null_session means that when the + # request is not CSRF protected, we will not raise an exception, + # but we will provide the controller with an empty session. + protect_from_forgery with: :null_session skip_before_action :setup_tracking before_action :authenticate_from_token before_action :ensure_authorized_network, if: -> { @api_token.present? } From ae103e049c1baa713d3c333e867842b178ed3a93 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 6 Jun 2024 17:35:02 +0200 Subject: [PATCH 003/111] 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 383f502d96e..b9bb097c687 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 b8413b1eb38..17b7328d0c0 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 07486886cd7..227f89a6ef7 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 f106e558c058a9ca48f93e9599bf4448e2b5bfa7 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 12:25:30 +0200 Subject: [PATCH 004/111] pour le site nb_source=site --- app/views/users/dossiers/_merci.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 12bd195f630..9081ee58a64 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -27,4 +27,4 @@ %strong= t('views.users.dossiers.merci.jdma_l1') %p= t('views.users.dossiers.merci.jdma_l2') - != procedure.monavis_embed + != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') From 0f1c1302a9dff0a354d69809fcf41e587a1ab2f1 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 12:32:55 +0200 Subject: [PATCH 005/111] =?UTF-8?q?on=20affiche=20uniquement=20si=20JDMA?= =?UTF-8?q?=20activ=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/users/dossiers/_merci.html.haml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 9081ee58a64..955f7fdf81f 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -22,9 +22,9 @@ = 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 fr-mb-2w' - .monavis - %p.fr-mt-5w.fr-mb-1w - %strong= t('views.users.dossiers.merci.jdma_l1') - %p= t('views.users.dossiers.merci.jdma_l2') - - != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') + - if procedure.monavis_embed + .monavis + %p.fr-mt-5w.fr-mb-1w + %strong= t('views.users.dossiers.merci.jdma_l1') + %p= t('views.users.dossiers.merci.jdma_l2') + != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') From a9b56459c83eb2611e309839d899a16e612349cf Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 12:52:27 +0200 Subject: [PATCH 006/111] on fait ouvrir dans un nouvel onglet --- app/views/users/dossiers/_merci.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 955f7fdf81f..c25957a48a2 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -27,4 +27,4 @@ %p.fr-mt-5w.fr-mb-1w %strong= t('views.users.dossiers.merci.jdma_l1') %p= t('views.users.dossiers.merci.jdma_l2') - != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') + != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site').gsub(' Date: Fri, 7 Jun 2024 06:23:52 +0200 Subject: [PATCH 007/111] 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 8d78db84dd0..9a9ff160037 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 51281c712e5..98bde51e969 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 00000000000..97fa9d80318 --- /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 00000000000..cfcf73bfa22 --- /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 008/111] 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 3ee07149b03..367ed74b00b 100644 --- a/app/components/dsfr/input_component.rb +++ b/app/components/dsfr/input_component.rb @@ -32,7 +32,7 @@ def input_group_opts }.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 00000000000..b794b4d7a39 --- /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 8eed97fa9fb..8b64a7e929a 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 c4973d1ae3c..8b5f2152e0c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -161,6 +161,7 @@ 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 8d51446099b..edad7ebc6c2 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 00000000000..4572c2cd4a8 --- /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 d7a19bd421201eaf554e35f9904d745892e15c0f Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 18:20:28 +0200 Subject: [PATCH 009/111] ajout du JDMA au mail de depot de dossier (avec nb_source=email) --- app/mailers/notification_mailer.rb | 7 +++++++ app/views/layouts/mailers/_jdma.html.haml | 10 ++++++++++ .../notification_mailer/send_notification.html.haml | 3 +++ 3 files changed, 20 insertions(+) create mode 100644 app/views/layouts/mailers/_jdma.html.haml diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 4459e741dda..276c489111b 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -8,6 +8,7 @@ class NotificationMailer < ApplicationMailer before_action :set_dossier, except: [:send_notification_for_tiers, :send_accuse_lecture_notification] before_action :set_services_publics_plus, only: :send_notification + before_action :set_jdma, only: :send_notification helper ServiceHelper helper MailerHelper @@ -88,6 +89,12 @@ def set_services_publics_plus @services_publics_plus_url = ENV['SERVICES_PUBLICS_PLUS_URL'].presence end + def set_jdma + return unless params[:state] == Dossier.states.fetch(:en_construction) + + @jdma_html = @dossier.procedure.monavis_embed.presence + end + def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) diff --git a/app/views/layouts/mailers/_jdma.html.haml b/app/views/layouts/mailers/_jdma.html.haml new file mode 100644 index 00000000000..e88cd4dc25b --- /dev/null +++ b/app/views/layouts/mailers/_jdma.html.haml @@ -0,0 +1,10 @@ += vertical_margin(50) + +%div{ align: "center" } + %p + %strong Aidez-nous à améliorer ce service ! + %br + Donnez-nous votre avis, cela ne prend que 2 minutes. + != @jdma_html.gsub('nd_source=button', 'nd_source=email').gsub(' Date: Mon, 10 Jun 2024 11:09:55 +0200 Subject: [PATCH 010/111] ajout d'un helper pour la source --- app/mailers/notification_mailer.rb | 2 +- app/models/procedure.rb | 4 ++++ app/views/layouts/mailers/_jdma.html.haml | 2 +- app/views/users/dossiers/_merci.html.haml | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 276c489111b..9b0d94493ff 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -92,7 +92,7 @@ def set_services_publics_plus def set_jdma return unless params[:state] == Dossier.states.fetch(:en_construction) - @jdma_html = @dossier.procedure.monavis_embed.presence + @jdma_html = @dossier.procedure.monavis_embed_html_source("email").presence end def set_dossier diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 11cec13d892..337970e89cb 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -1008,6 +1008,10 @@ def reset_closing_params update!(closing_reason: nil, closing_details: nil, replaced_by_procedure_id: nil, closing_notification_brouillon: false, closing_notification_en_cours: false) end + def monavis_embed_html_source(source) + monavis_embed.gsub('nd_source=button', "nd_source=#{source}").gsub(' Date: Mon, 10 Jun 2024 11:26:37 +0200 Subject: [PATCH 011/111] fix(brakeman): maj avec le nouvel appel dans la vue --- config/brakeman.ignore | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index bf9b76294ae..7dd6545daf7 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -3,19 +3,19 @@ { "warning_type": "Cross-Site Scripting", "warning_code": 2, - "fingerprint": "1b805585567775589825c0eda58cb84c074fc760d0a7afb101c023a51427f2b5", + "fingerprint": "26f504696b074d18ef3f5568dc8f6a46d1283a67fe37822498fa25d0409664ab", "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/users/dossiers/_merci.html.haml", - "line": 26, + "line": 30, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed", + "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed_html_source(\"site\")", "render_path": [ { "type": "controller", "class": "Users::DossiersController", "method": "merci", - "line": 309, + "line": 320, "file": "app/controllers/users/dossiers_controller.rb", "rendered": { "name": "users/dossiers/merci", @@ -74,7 +74,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped parameter value", "file": "app/views/faq/show.html.haml", - "line": 12, + "line": 13, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Redcarpet::Markdown.new(Redcarpet::TrustedRenderer.new(view_context), :autolink => true).render(loader_service.find(\"#{params[:category]}/#{params[:slug]}\").content)", "render_path": [ @@ -203,6 +203,6 @@ "note": "Current is not a model" } ], - "updated": "2024-04-23 18:27:12 +0200", + "updated": "2024-06-10 11:21:19 +0200", "brakeman_version": "6.1.2" } From ef2a02197147b3ecc7fc957dfce70833df0bebdd Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Mon, 10 Jun 2024 13:06:54 +0200 Subject: [PATCH 012/111] fix condition si monavis est nil --- .DS_Store | Bin 0 -> 8196 bytes app/mailers/notification_mailer.rb | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..765f57dc5914c412cd52c3818b1a7cf891808d37 GIT binary patch literal 8196 zcmeHMT~8ZF6un~;yq19)wUKyc@<=d)K??%$+%N?~G?nM5;0Fd`z@LL=g_Q z)e25Ig`e{=QwrwJJy-=kWryt~YvWsW1{-b!1%d)WfuKN8ASm$9PylN-m*|FdUq21! zpg>UIf2jb^4>cTY>&8yBm8kgct$0#WNNws->Y;x1P-UPp8qko|XoRa%N?eAzlfxKO3VZ_mA)Qd$WBlwfDnMn_p|037 zFkk2R`O{;~3p%Bj=*5xip>X73%QhDE4`}Q%h}HSUQs)EOrpNS%4rrhDs6kI~wTJHy z!9Jyq$7a=Rc4T_eg{PQ~sO#cIGaFw(@AFi$F-2gYr&klJVxyL!M+`+09$R>BVdVOv ztRBHvG;(VYt?U{~=wlasWL*=#<*buhvUFN-(buEydLby}@XFwg(b55WmFtN$au`MF zQ;tdwszP64oVIBfW7Wi&qjsjx3aU9Qt~<+%A_uhqMRlCEpbuY-Ss@yFePxZ_I2!fQ zQ_cj51+?i0j7pZ5dk(LJk&Ds(Lpq-IBKp`~mVwStFXM;!tVs--{Lnt#b}Hnd<8Oh! z#&~efnh^nuv+~U2v3j=Wd3YXUp5U1C;*+Dx!7kZYX(4;a&3K8mKP(+1LA#bB*r$23d*ORaD}!pE5BugF?r-gW(i@J}*MC=q;@qwIQdEj6 z(Ra<`?7TUON8@259z5r72U(W1r`K`&X|H?Us(!ScrK7l)b_YV}b-I9j`>dCCvhzkZ zPCG;4HuZz39F<$um5Ymw&(>FKTbrAgtF?=*PjLNUDl^J#5jT+`12fGAp51u$m zd7Zk>D0ug{Xsg4mUU4}u|DKP`a`;mX-pv*@Y^4Ibd-MDMcdIO*f&xK-f29IyZl}4^ zK+~sFs^1IVYu9kRz`=!a6K#cplWfOfWjhXg^@ky@Yk)GRZtO%`+(CKoF9NRrq2%9~ X_DUJn|9)8i<^2;P!&z7VgX{lalf>_= literal 0 HcmV?d00001 diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 9b0d94493ff..3e606438996 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -90,9 +90,9 @@ def set_services_publics_plus end def set_jdma - return unless params[:state] == Dossier.states.fetch(:en_construction) + return unless params[:state] == Dossier.states.fetch(:en_construction) && @dossier.procedure.monavis_embed - @jdma_html = @dossier.procedure.monavis_embed_html_source("email").presence + @jdma_html = @dossier.procedure.monavis_embed_html_source("email") end def set_dossier From 1d4a8795c8c38384d8e670c21846de997dc22bf2 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 10 Jun 2024 14:28:50 +0200 Subject: [PATCH 013/111] 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 d2b4ae1cf97..cecdc5d9305 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 014/111] 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 af398a9910c..3d6c05b1fdd 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -559,6 +559,7 @@ def clone(admin, from_library) 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 810d272be25e4094431eaf2be9218e62ff58264f Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:02:59 +0200 Subject: [PATCH 015/111] 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 00000000000..e2a65478376 --- /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 00000000000..bf8464f8c9c --- /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 00000000000..19ce1d243bd --- /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 6f7fcfe7c81..b33a105829e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -863,6 +863,9 @@ 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 016/111] 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 01f081a85a8..8817a1336d3 100644 --- a/app/components/conditions/conditions_component.rb +++ b/app/components/conditions/conditions_component.rb @@ -61,7 +61,7 @@ def empty_target_for_select 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 00000000000..d69e066230b --- /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 00000000000..d65f0d535b5 --- /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 00000000000..e82e64fad28 --- /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 fef3516d5bb..157b4dd675f 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -251,6 +251,10 @@ def routable_types_de_champ 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 10e415c051f..cf2c321d872 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -657,6 +657,10 @@ def routable? 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 345eb2824dc..4d63d909bb3 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 00000000000..433b591557d --- /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 017/111] 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 00000000000..a12ab262e99 --- /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 00000000000..b646c30198a --- /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 00000000000..547a2ad8560 --- /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 00000000000..181eb6f5c90 --- /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 00000000000..eb941cdbab1 --- /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 00000000000..eab7f62fc87 --- /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 00000000000..41d6865f961 --- /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 157b4dd675f..2b56ecf808d 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 00000000000..a0ace0eca8d --- /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 00000000000..8f9900e50db --- /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 00000000000..8f9900e50db --- /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 00000000000..8f9900e50db --- /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 00000000000..8f9900e50db --- /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 00000000000..a76a3046829 --- /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 00000000000..8f9900e50db --- /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 b9cc395e9f3..79710f04e68 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 d6e031daef4..c29ea1d5d8e 100644 --- a/config/initializers/02_urls.rb +++ b/config/initializers/02_urls.rb @@ -37,6 +37,7 @@ 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 c4973d1ae3c..38bd26cc93a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -607,6 +607,14 @@ 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 00000000000..c678c5ace52 --- /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 00000000000..a5e301a206e --- /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 00000000000..5c8f94628de --- /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 00000000000..9db80cf5957 --- /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 018/111] 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 | 33 +- app/models/procedure_revision_change.rb | 72 ++- .../procedures/_publication_form.html.haml | 2 +- .../procedures/modifications.html.haml | 3 +- .../administrateurs/procedures/show.html.haml | 2 +- spec/models/procedure_revision_spec.rb | 525 +++++++++++------- 11 files changed, 437 insertions(+), 237 deletions(-) diff --git a/app/components/procedure/revision_changes_component.rb b/app/components/procedure/revision_changes_component.rb index e266f13e202..af786e6bcd9 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 10009ce1e42..3228c76a8ad 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 ba19a0dd928..ed7f550c885 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 dd6395dc298..49807793c68 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -22,7 +22,7 @@ def can_rebase? 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 af398a9910c..21f2723720a 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -431,11 +431,15 @@ def locked? 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 2b56ecf808d..a3e16f59225 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -148,13 +148,15 @@ def locked? !draft? end - def different_from?(revision) - revision_types_de_champ != revision.revision_types_de_champ + 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(revision) + def compare_ineligibilite_rules(revision) changes = [] - changes += compare_revision_types_de_champ(revision_types_de_champ, revision.revision_types_de_champ) + changes += compare_revision_ineligibilite_rules(revision) changes end @@ -334,6 +336,29 @@ def compare_revision_types_de_champ(from_coordinates, to_coordinates) 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 fc412cc261f..7d99f0fd257 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 - end + 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 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? } + def to_h = { op:, stable_id:, label:, private: private? } + end - class AddChamp < ProcedureRevisionChange + class AddChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -23,7 +25,7 @@ def can_rebase?(dossier = nil) = !mandatory? 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 @@ def op = :remove 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 @@ def can_rebase?(dossier = nil) = true 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 @@ def can_rebase?(dossier = nil) 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 0c9cc8454b2..d8d96870ec6 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 978fee30f77..73b8673bd67 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 4d63d909bb3..4463a86cf41 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 4c7a17ba3f1..434d82e44f6 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -347,306 +347,417 @@ 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 + 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 } ]) 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 + context 'when a type de champ is removed' do + let(:procedure) { create(:procedure, :with_type_de_champ) } - 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))) + before do + new_draft.remove_type_de_champ(first_tdc.stable_id) end it do is_expected.to eq([ { - attribute: :condition, - from: "(l1 == 2)", - label: "l2", - op: :update, + op: :remove, + label: first_tdc.libelle, private: false, - stable_id: second_tdc.stable_id, - to: "(l1 == 3)" + stable_id: first_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 - let(:procedure) { create(:procedure, :with_type_de_champ) } + 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(libelle: 'modifier le libelle', description: 'une description', mandatory: !updated_tdc.mandatory) + 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: :libelle, - label: first_tdc.libelle, + attribute: :type_champ, + label: "sub type de champ", 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 + 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: :mandatory, - label: first_tdc.libelle, + attribute: :drop_down_options, + label: "sub type de champ", private: false, - from: false, - to: true, - stable_id: first_tdc.stable_id + from: [], + to: ["one", "two"], + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.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 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 - 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: :carte, options: { cadastres: true, znieff: true }) 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: "carte", + 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: :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 - end - context 'when a type de champ is removed' do - let(:procedure) { create(:procedure, :with_type_de_champ) } + 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 - before do - new_draft.remove_type_de_champ(first_tdc.stable_id) + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::RemoveEligibiliteRuleChange)) } end - it do - is_expected.to eq([ - { - op: :remove, - label: first_tdc.libelle, - private: false, - stable_id: first_tdc.stable_id - } - ]) + 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 - 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 }] }]) } + 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 - 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']) + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::EligibiliteEnabledChange)) } 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_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 - 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 }] }]) } + context 'when ineligibilite_message changes' do + before do + procedure.published_revision.update!(ineligibilite_message: :a) + new_draft.update!(ineligibilite_message: :b) + end - 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 }) + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::UpdateEligibiliteMessageChange)) } end + end + 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 - } - ]) + 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 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 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 + 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 019/111] 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 9e6559be980..63001229d59 100644 --- a/app/models/concerns/champ_conditional_concern.rb +++ b/app/models/concerns/champ_conditional_concern.rb @@ -21,6 +21,10 @@ def visible? 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 e609323bbf2..14d9cd4f77e 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -938,6 +938,10 @@ def check_mandatory_and_visible_champs 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 51537235f63..11d31a9c005 100644 --- a/app/models/logic/and.rb +++ b/app/models/logic/and.rb @@ -7,5 +7,12 @@ def compute(champs = []) @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 812fa0605b1..35f6ce1a71a 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -42,6 +42,15 @@ def compute(champs = []) 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 a0e2dfeae5a..96a0fe13323 100644 --- a/app/models/logic/or.rb +++ b/app/models/logic/or.rb @@ -7,5 +7,15 @@ def compute(champs = []) @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 a3e16f59225..7e4f3086029 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -269,6 +269,12 @@ def conditionable_types_de_champ 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 @@ def compare_type_de_champ(from_type_de_champ, to_type_de_champ, from_coordinates 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 67f319acb8e..c0eefc8e825 100644 --- a/spec/models/logic/and_spec.rb +++ b/spec/models/logic/and_spec.rb @@ -6,6 +6,42 @@ 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 e27c3b7bcec..f816e81e73f 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -28,6 +28,19 @@ 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 @@ 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 1888587d2d9..82d5392fb81 100644 --- a/spec/models/logic/or_spec.rb +++ b/spec/models/logic/or_spec.rb @@ -7,6 +7,49 @@ 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 020/111] 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 fca7fab4540..ac77bbfeab8 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 @@ def annotation? @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 098e6ec0b86..b6de7d121d8 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 33937aed683..8ffd062db75 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 77540bd1638..fb4ab8fb1e7 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 14d9cd4f77e..e83edfb9735 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -156,7 +156,7 @@ def classer_sans_suite(motivation: nil, instructeur: nil, processed_at: Time.zon 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 @@ def blocked_with_pending_correction? 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 00000000000..40e60802b53 --- /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 021/111] 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 00000000000..fe45272f6bc --- /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 00000000000..1a377763c1b --- /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 00000000000..d191f03d4db --- /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 00000000000..dd39925cd6d --- /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 acdfd1332ac..e686683ff7b 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -303,10 +303,13 @@ def submit_en_construction 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 00000000000..5b47d79b505 --- /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 d5fff32626e..1962951e8b9 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 91a898ab0e4..374291733a7 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 220bd67220b..1f78b2f4af0 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -398,7 +398,9 @@ 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 @@ 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 @@ 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 @@ 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 '#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 @@ 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 @@ 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 c242f3ec8c3..5183554d828 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 @@ 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 022/111] 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 60cf8210249..d2876921389 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 c4842da20d3..506e205f742 100644 --- a/app/models/procedure_revision_type_de_champ.rb +++ b/app/models/procedure_revision_type_de_champ.rb @@ -75,4 +75,8 @@ def block 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 1e368f1ba72..27b57472ff4 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 '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 @@ 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 023/111] 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 e686683ff7b..ebfa2cdb458 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -231,9 +231,9 @@ def brouillon 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 @@ def submit_en_construction 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 @@ def submit_en_construction else respond_to do |format| format.html do - @dossier = editing_fork_origin render :modifier end @@ -570,21 +569,14 @@ def update_dossier_and_compute_errors 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 e83edfb9735..7a1c611a891 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -940,7 +940,7 @@ def check_mandatory_and_visible_champs .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 00000000000..366dac7803c --- /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 024/111] 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 fd8bafd94a7..207170e8c59 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 @@ def to_error_descriptor(error) 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 3fab8164dca..0a595e80a63 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 1fd0e7f8c38..3d94f636f52 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 58d76cb56d7..ada4150b557 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 00000000000..43d5c9215ca --- /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 00000000000..b21ee7d8a5d --- /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 00000000000..755d1388641 --- /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 00000000000..1ab5221e58c --- /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 adce8fd64bc..e185c81b37f 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 @@ def invalid? @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 @@ def error_correction_page(error) 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 72780bcd145..e5042916e45 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 1962951e8b9..c797d35f237 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 55a9c1dddb3..34fc89e3572 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 4a0fdeca53b..85df92d73dc 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 4c3ef6337c7..3e64cc790f9 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -11,27 +11,33 @@ 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 @@ 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 @@ 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 2a20829ab9b..0fa94a425d1 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -372,12 +372,12 @@ ] 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 @@ 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 @@ 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 @@ 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 10818971c1f..dc18583581a 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -72,8 +72,8 @@ 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 @@ 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 025/111] 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 e185c81b37f..bf41ab3da0c 100644 --- a/app/components/procedure/errors_summary.rb +++ b/app/components/procedure/errors_summary.rb @@ -32,6 +32,8 @@ def errors 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 21f2723720a..4e080831c82 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -293,7 +293,7 @@ def revisions_with_pending_dossiers 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 @@ def reset_closing_params 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 7e4f3086029..bb7dbb43e14 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -496,6 +496,13 @@ def ineligibilite_rules_are_valid? 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 17b7328d0c0..a05c394b212 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 227f89a6ef7..624e141d25b 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 3e64cc790f9..ebeb1096da3 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -74,6 +74,8 @@ 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 @@ 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 7b4a19e4602..5b643995c4a 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 @@ 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 026/111] 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 055e9b4f903..59f8bf6b90c 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 547a2ad8560..3136181671f 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 ac77bbfeab8..5f5bb89801a 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 @@ def disabled_submit_buttons_options 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 @@ def submit_draft_button_options 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 fb4ab8fb1e7..2f0f59b2b1a 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 fe45272f6bc..526bdbc94cd 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 @@ def initialize(dossier:) 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 d69e066230b..b1d371708fb 100644 --- a/app/components/procedure/card/ineligibilite_dossier_component.rb +++ b/app/components/procedure/card/ineligibilite_dossier_component.rb @@ -6,7 +6,7 @@ def initialize(procedure:) 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 d65f0d535b5..6e78d7da6fa 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 e82e64fad28..aeced88e678 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 ebfa2cdb458..1fac35eae9e 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -302,13 +302,10 @@ def submit_en_construction 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 7a1c611a891..343baf06d0c 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -940,12 +940,9 @@ def check_mandatory_and_visible_champs .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 11d31a9c005..51537235f63 100644 --- a/app/models/logic/and.rb +++ b/app/models/logic/and.rb @@ -7,12 +7,5 @@ def compute(champs = []) @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 35f6ce1a71a..812fa0605b1 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -42,15 +42,6 @@ def compute(champs = []) 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 96a0fe13323..a0e2dfeae5a 100644 --- a/app/models/logic/or.rb +++ b/app/models/logic/or.rb @@ -7,15 +7,5 @@ def compute(champs = []) @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 bb7dbb43e14..0a27fec2c91 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -269,12 +269,6 @@ def conditionable_types_de_champ 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 @@ def ineligibilite_rules_are_valid? 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 a76a3046829..6eb91d12f1b 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 374291733a7..8224c1abdc6 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 624e141d25b..42896bd24f1 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 85df92d73dc..5a9dfd9abfc 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 00000000000..1665415aa6f --- /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 40e60802b53..4b8e1a77f9f 100644 --- a/spec/components/dossiers/edit_footer_component_spec.rb +++ b/spec/components/dossiers/edit_footer_component_spec.rb @@ -10,14 +10,14 @@ 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 @@ 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 @@ 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 5b643995c4a..fb709498363 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 @@ 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 5c8f94628de..2a76a054a58 100644 --- a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb +++ b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb @@ -197,14 +197,14 @@ 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 1f78b2f4af0..2a38eb7d7e5 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -791,26 +791,27 @@ 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 c0eefc8e825..67f319acb8e 100644 --- a/spec/models/logic/and_spec.rb +++ b/spec/models/logic/and_spec.rb @@ -6,42 +6,6 @@ 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 f816e81e73f..b7924ebc76a 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -28,19 +28,6 @@ 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 82d5392fb81..1888587d2d9 100644 --- a/spec/models/logic/or_spec.rb +++ b/spec/models/logic/or_spec.rb @@ -7,49 +7,6 @@ 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 9db80cf5957..e93a6ea5dfa 100644 --- a/spec/system/administrateurs/procedure_ineligibilite_spec.rb +++ b/spec/system/administrateurs/procedure_ineligibilite_spec.rb @@ -9,13 +9,13 @@ 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 @@ # 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 366dac7803c..5bbb25c7580 100644 --- a/spec/system/users/dossier_ineligibilite_spec.rb +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -9,7 +9,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 5183554d828..f6ce8f5bf32 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 @@ 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 027/111] 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 b794b4d7a39..19cd0493bd8 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 8b64a7e929a..f8442e1d312 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 97fa9d80318..c2cbe353699 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 @@ def self.check(email:) 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 cfcf73bfa22..f9c35ea9174 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 7553a5fc52f468f9951a49a2c8910aad7b5849b1 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Sat, 1 Jun 2024 23:07:56 +0200 Subject: [PATCH 028/111] test: don't trace coverage by default in local machines --- spec/spec_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ae50174c7fb..5ff703c1afe 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -18,7 +18,7 @@ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration # # -require 'simplecov' # see config in .simplecov file +require 'simplecov' if ENV["CI"] || ENV["COVERAGE"] # see config in .simplecov file require 'rspec/retry' From 89cea04cfdb07c87b3208dbbc8d68613111b9df8 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 11 Jun 2024 14:48:13 +0200 Subject: [PATCH 029/111] chore(git): ignore system or local files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 638c56b9eb5..a210a1d8719 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ public/downloads doc/*.svg uploads/* .byebug_history +.DS_Store +*.swp +.envrc .env storage/ /node_modules From 8cb902821fda2437d8d2d4a8e5eb77d3fe7aa448 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 11 Jun 2024 11:40:15 +0200 Subject: [PATCH 030/111] 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 0fa94a425d1..0b8b3f4ed10 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -441,6 +441,33 @@ 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 031/111] 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 65e1e887ae1..86e23ff62d8 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 From c5fa25ee78f6f7f208389ec4f35ecb09a9f10891 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Tue, 11 Jun 2024 17:45:51 +0200 Subject: [PATCH 032/111] unless to if condition --- app/mailers/notification_mailer.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 9b0d94493ff..c319df7dd36 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -90,9 +90,11 @@ def set_services_publics_plus end def set_jdma - return unless params[:state] == Dossier.states.fetch(:en_construction) - - @jdma_html = @dossier.procedure.monavis_embed_html_source("email").presence + if params[:state] == Dossier.states.fetch(:en_construction) && @dossier.procedure.monavis_embed + @jdma_html = @dossier.procedure.monavis_embed_html_source("email") + else + return + end end def set_dossier From f504e7968d0088ce1d031b2ed9a8717271f6b770 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 12 Jun 2024 19:31:50 +0200 Subject: [PATCH 033/111] chore(redis): reduce connect timeout from 1s to 0.2s Prevent web workers from being stalled when Redis is down. --- config/environments/production.rb | 18 +++++++++++------- config/initializers/kredis.rb | 3 ++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 64ed28732c9..5008e607187 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -62,17 +62,21 @@ # Use a different cache store in production. if ENV['REDIS_CACHE_URL'].present? - redis_options = { url: ENV['REDIS_CACHE_URL'] } - redis_options[:ssl] = (ENV['REDIS_CACHE_SSL'] == 'enabled') + redis_options = { + url: ENV['REDIS_CACHE_URL'], + connect_timeout: 0.2, + error_handler: -> (method:, returning:, exception:) { + Sentry.capture_exception exception, level: 'warning', + tags: { method: method, returning: returning } + } + } + + redis_options[:ssl] = ENV['REDIS_CACHE_SSL'] == 'enabled' + if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' redis_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } end - redis_options[:error_handler] = -> (method:, returning:, exception:) { - Sentry.capture_exception exception, level: 'warning', - tags: { method: method, returning: returning } - } - config.cache_store = :redis_cache_store, redis_options end diff --git a/config/initializers/kredis.rb b/config/initializers/kredis.rb index 878b8177a7b..90dd906b9cd 100644 --- a/config/initializers/kredis.rb +++ b/config/initializers/kredis.rb @@ -1,6 +1,7 @@ redis_volatile_options = { url: ENV['REDIS_CACHE_URL'], # will fallback to default redis url if empty, and won't fail if there is no redis server - ssl: ENV['REDIS_CACHE_SSL'] == 'enabled' + ssl: ENV['REDIS_CACHE_SSL'] == 'enabled', + connect_timeout: 0.2 } redis_volatile_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' From 9d4113befb1aeaa1782fadfb1fa0aeba4ea8cf8a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 11 Jun 2024 19:09:34 +0200 Subject: [PATCH 034/111] feat(helpscout): delete old conversations --- app/lib/helpscout/api.rb | 33 +++ ...helpscout_delete_old_conversations_task.rb | 57 +++++ .../helpscout_list_old_conversations.yml | 226 ++++++++++++++++++ ...cout_delete_old_conversations_task_spec.rb | 62 +++++ 4 files changed, 378 insertions(+) create mode 100644 app/tasks/maintenance/helpscout_delete_old_conversations_task.rb create mode 100644 spec/fixtures/cassettes/helpscout_list_old_conversations.yml create mode 100644 spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb diff --git a/app/lib/helpscout/api.rb b/app/lib/helpscout/api.rb index c0538c7a935..b16585240d2 100644 --- a/app/lib/helpscout/api.rb +++ b/app/lib/helpscout/api.rb @@ -7,6 +7,8 @@ class Helpscout::API PHONES = 'phones' OAUTH2_TOKEN = 'oauth2/token' + RATELIMIT_KEY = "helpscout-rate-limit-remaining" + def ready? required_secrets = [ Rails.application.secrets.helpscout[:mailbox_id], @@ -42,6 +44,30 @@ def create_conversation(email, subject, text, file) call_api(:post, CONVERSATIONS, body) end + def list_old_conversations(status, before, page: 1) + body = { + page:, + status:, # active, open, closed, pending, spam. "all" does not work + query: "( + modifiedAt:[* TO #{before.iso8601}] + )", + sortField: "modifiedAt", + sortOrder: "desc" + } + + response = call_api(:get, "#{CONVERSATIONS}?#{body.to_query}") + if !response.success? + raise StandardError, "Error while listing conversations: #{response.response_code} '#{response.body}'" + end + + body = parse_response_body(response) + [body[:_embedded][:conversations], body[:page]] + end + + def delete_conversation(conversation_id) + call_api(:delete, "#{CONVERSATIONS}/#{conversation_id}") + end + def add_phone_number(email, phone) query = CGI.escape("(email:#{email})") response = call_api(:get, "#{CUSTOMERS}?mailbox=#{user_support_mailbox_id}&query=#{query}") @@ -129,6 +155,13 @@ def call_api(method, path, body = nil) body: body.to_json, headers: headers }) + when :delete + Typhoeus.delete(url, { + body: body.to_json, + headers: headers + }) + end.tap do |response| + Rails.cache.write(RATELIMIT_KEY, response.headers["X-Ratelimit-Remaining-Minute"], expires_in: 1.minute) end end diff --git a/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb b/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb new file mode 100644 index 00000000000..cbe388fb1d6 --- /dev/null +++ b/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Maintenance + class HelpscoutDeleteOldConversationsTask < MaintenanceTasks::Task + # Delete Helpscout conversations not modified in the last 2 years, given a status. + # In order to delete all conversations, this task must be invoked 4 times + # for the 4 status: active, closed, spam, pending. + # Respects the Helpscount API rate limit (200 calls per minute). + + attribute :status, :string # active, closed, spam, or pending + validates :status, presence: true + + MODIFIED_BEFORE = 2.years.freeze + + throttle_on do + limit = Rails.cache.read(Helpscout::API::RATELIMIT_KEY) + limit.present? && limit == 0 + end + + def count + _conversations, pagination = api.list_old_conversations(status, modified_before) + + pagination[:totalElements] + end + + # Because conversations are deleted progressively, + # ignore cursor and always pick the first page + def enumerator_builder(cursor:) + Enumerator.new do |yielder| + loop do + conversations, pagination = api.list_old_conversations(status, modified_before) + conversations.each do |conversation| + yielder.yield(conversation[:id], nil) # don't care about cursor parameter + end + + # "number" is the current page (always 1 in our case) + # iterate until there are no remaining pages + break if pagination[:totalPages] == pagination[:number] + end + end + end + + def process(conversation_id) + @api.delete_conversation(conversation_id) + end + + private + + def api + @api ||= Helpscout::API.new + end + + def modified_before + MODIFIED_BEFORE.ago.utc.beginning_of_day + end + end +end diff --git a/spec/fixtures/cassettes/helpscout_list_old_conversations.yml b/spec/fixtures/cassettes/helpscout_list_old_conversations.yml new file mode 100644 index 00000000000..e712fbb4770 --- /dev/null +++ b/spec/fixtures/cassettes/helpscout_list_old_conversations.yml @@ -0,0 +1,226 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.helpscout.net/v2/oauth2/token + body: + encoding: UTF-8 + string: client_id=1234&client_secret=5678&grant_type=client_credentials + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Date: + - Tue, 11 Jun 2024 14:13:26 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '94' + Server: + - kong/0.14.1 + Cache-Control: + - no-store + Pragma: + - no-cache + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Location,Resource-Id + body: + encoding: UTF-8 + string: '{"token_type":"bearer","access_token":"redacted","expires_in":172800}' + recorded_at: Wed, 05 Jun 2024 00:00:00 GMT +- request: + method: get + uri: https://api.helpscout.net/v2/conversations?page=1&query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)&sortField=modifiedAt&sortOrder=desc&status=closed + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Authorization: + - Bearer redacted + Content-Type: + - application/json; charset=UTF-8 + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Date: + - Tue, 11 Jun 2024 14:13:27 GMT + Content-Type: + - application/hal+json + X-Ratelimit-Limit-Minute: + - '200' + X-Ratelimit-Remaining-Minute: + - '199' + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Frame-Options: + - DENY + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Location,Resource-Id + Correlation-Id: + - a9ca7664-2711-4c36-a092-73203365b474#13211836 + X-Kong-Upstream-Latency: + - '640' + X-Kong-Proxy-Latency: + - '3' + Via: + - kong/0.14.1 + body: + encoding: UTF-8 + string: '{"_embedded":{"conversations":[{"id":1910642153,"number":1978770,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T23:34:03Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T23:34:04Z","userUpdatedAt":"2022-06-04T23:34:04Z","customerWaitingSince":{"time":"2022-06-04T23:34:04Z","friendly":"Jun + 5, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910642153"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910642153/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910642153/1978770"}},"assignee":{}},{"id":1910621183,"number":1978769,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T22:52:48Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T22:52:48Z","userUpdatedAt":"2022-06-04T22:52:48Z","customerWaitingSince":{"time":"2022-06-04T22:52:48Z","friendly":"Jun + 5, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910621183"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910621183/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910621183/1978769"}},"assignee":{}},{"id":1910573873,"number":1978767,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Une demande de transfert de dossier vous est adressée","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T21:20:15Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T21:20:17Z","userUpdatedAt":"2022-06-04T21:20:15Z","customerWaitingSince":{"time":"2022-06-04T21:20:15Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910573873"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533568101"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533568101"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910573873/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910573873/1978767"}},"assignee":{}},{"id":1910547792,"number":1978766,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Votre dossier nº 6988215 a été classé sans suite (Demande de premier titre + de séjour vie privée et familiale)","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T20:34:17Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T20:34:17Z","userUpdatedAt":"2022-06-04T20:34:17Z","customerWaitingSince":{"time":"2022-06-04T20:34:17Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910547792"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/485472353"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/485472353"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910547792/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910547792/1978766"}},"assignee":{}},{"id":1910474797,"number":1978763,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Nouveau message pour votre dossier nº 5172954 « Demande d''un premier titre + de séjour -VIE PRIVEE ET FAMILIALE ------------- -CITOYEN UE ET FAMILLE -REFUGIE, + PROTECTION SUBSIDIAIRE, APATRIDE-- -VISITEUR-- Préfecture de Nanterre »","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T18:30:40Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T18:30:40Z","userUpdatedAt":"2022-06-04T18:30:40Z","customerWaitingSince":{"time":"2022-06-04T18:30:40Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910474797"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533546294"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533546294"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910474797/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910474797/1978763"}},"assignee":{}},{"id":1910462923,"number":1978761,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Faire + une nouvelle demande de titre séjour","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T18:11:21Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T18:11:21Z","userUpdatedAt":"2022-06-04T18:11:21Z","customerWaitingSince":{"time":"2022-06-04T18:11:21Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910462923"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533543448"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533543448"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910462923/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910462923/1978761"}},"assignee":{}},{"id":1910327544,"number":1978755,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"reactivation + de numero NEPH","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T14:49:14Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T14:49:14Z","userUpdatedAt":"2022-06-04T14:49:14Z","customerWaitingSince":{"time":"2022-06-04T14:49:14Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910327544"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533513486"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533513486"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910327544/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910327544/1978755"}},"assignee":{}},{"id":1910270744,"number":1978750,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"[Free + Report] The State of Productivity 2022","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T13:18:23Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T13:18:23Z","userUpdatedAt":"2022-06-04T13:18:23Z","customerWaitingSince":{"time":"2022-06-04T13:18:23Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910270744"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/457241695"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/457241695"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910270744/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910270744/1978750"}},"assignee":{}},{"id":1910265845,"number":1978749,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Titre + de séjour , numéro étranger : 9914064788","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T13:09:52Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T13:09:52Z","userUpdatedAt":"2022-06-04T13:09:52Z","customerWaitingSince":{"time":"2022-06-04T13:09:52Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910265845"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533500019"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533500019"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910265845/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910265845/1978749"}},"assignee":{}},{"id":1910174669,"number":1978744,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Votre dossier nº 6779552 a été accepté (CPAM 75 - CONVENTIONNEMENT DES TAXIS + PARISIENS)","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T10:17:58Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T10:17:58Z","userUpdatedAt":"2022-06-04T10:17:58Z","customerWaitingSince":{"time":"2022-06-04T10:17:58Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910174669"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533480456"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533480456"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910174669/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910174669/1978744"}},"assignee":{}},{"id":1910154851,"number":1978743,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re :Nouveau + message pour votre dossier nº 5528204 « ARSIF - Procédure d’autorisation d’exercice + des médecins à diplômes hors UE (PADHUE) »","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T09:35:53Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T09:35:53Z","userUpdatedAt":"2022-06-04T09:35:53Z","customerWaitingSince":{"time":"2022-06-04T09:35:53Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910154851"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533476412"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533476412"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910154851/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910154851/1978743"}},"assignee":{}},{"id":1910119695,"number":1978739,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Demande + de renseignements","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T08:18:32Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T08:18:33Z","userUpdatedAt":"2022-06-04T08:18:32Z","customerWaitingSince":{"time":"2022-06-04T08:18:33Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910119695"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/477844062"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/477844062"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910119695/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910119695/1978739"}},"assignee":{}},{"id":1910118535,"number":1978738,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Demande de rendez-vous en vue du dépôt d''une demande de renouvellement de + la carte de séjour ou du visa long séjour valant titre de séjour","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T08:15:43Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T08:15:43Z","userUpdatedAt":"2022-06-04T08:15:43Z","customerWaitingSince":{"time":"2022-06-04T08:15:43Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910118535"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533468939"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533468939"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910118535/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910118535/1978738"}},"assignee":{}},{"id":1910073903,"number":1978735,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Renouvellement","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T06:45:33Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T06:45:33Z","userUpdatedAt":"2022-06-04T06:45:33Z","customerWaitingSince":{"time":"2022-06-04T06:45:33Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910073903"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910073903/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910073903/1978735"}},"assignee":{}},{"id":1910073830,"number":1978734,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Renouvellement","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T06:45:25Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T06:45:25Z","userUpdatedAt":"2022-06-04T06:45:25Z","customerWaitingSince":{"time":"2022-06-04T06:45:25Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910073830"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910073830/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910073830/1978734"}},"assignee":{}},{"id":1910049439,"number":1978733,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Renseignements","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T05:55:41Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T05:55:41Z","userUpdatedAt":"2022-06-04T05:55:41Z","customerWaitingSince":{"time":"2022-06-04T05:55:41Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910049439"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533455039"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533455039"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910049439/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910049439/1978733"}},"assignee":{}},{"id":1909873707,"number":1978732,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Votre dossier nº 8851246 a été accepté (DEMANDE D''AUTORISATION D''INSTRUCTION + DANS LA FAMILLE 2022/2023)","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T00:33:59Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T00:33:59Z","userUpdatedAt":"2022-06-04T00:33:59Z","customerWaitingSince":{"time":"2022-06-04T00:33:59Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909873707"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533409205"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533409205"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909873707/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909873707/1978732"}},"assignee":{}},{"id":1909608573,"number":1978730,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Oui + c’est bon","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-03T19:34:20Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T19:34:21Z","userUpdatedAt":"2022-06-03T19:34:21Z","customerWaitingSince":{"time":"2022-06-03T19:34:21Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909608573"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/472328447"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/472328447"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909608573/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909608573/1978730"}},"assignee":{}},{"id":1909571857,"number":1978729,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Montant + du timbre","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-03T19:01:30Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T19:01:30Z","userUpdatedAt":"2022-06-03T19:01:30Z","customerWaitingSince":{"time":"2022-06-03T19:01:30Z","friendly":"Jun + 3, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909571857"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533345249"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533345249"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909571857/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909571857/1978729"}},"assignee":{}},{"id":1909508850,"number":1978726,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"RE: + Un dossier en construction va bientôt être supprimé","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-03T18:07:27Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T18:07:27Z","userUpdatedAt":"2022-06-03T18:07:27Z","customerWaitingSince":{"time":"2022-06-03T18:07:27Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909508850"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/420667968"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/420667968"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909508850/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909508850/1978726"}},"assignee":{}},{"id":1909460281,"number":1978724,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-03T17:29:05Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T17:29:06Z","userUpdatedAt":"2022-06-03T17:29:05Z","customerWaitingSince":{"time":"2022-06-03T17:29:05Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909460281"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533255322"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533255322"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909460281/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909460281/1978724"}},"assignee":{}},{"id":1909359672,"number":1978719,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Prise + de rdv","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-03T16:02:00Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T16:02:00Z","userUpdatedAt":"2022-06-03T16:02:00Z","customerWaitingSince":{"time":"2022-06-03T16:02:00Z","friendly":"Jun + 3, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909359672"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/435717422"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/435717422"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909359672/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909359672/1978719"}},"assignee":{}},{"id":1909341225,"number":1978718,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Préfecture de la Marne, renouvellement récépissé/APS","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-03T15:47:20Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T15:47:20Z","userUpdatedAt":"2022-06-03T15:47:20Z","customerWaitingSince":{"time":"2022-06-03T15:47:20Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909341225"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/505891768"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/505891768"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909341225/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909341225/1978718"}},"assignee":{}},{"id":1907817899,"number":1978559,"threads":4,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + DUREE DE CONSERVATION DES DONNEES","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-02T14:02:18Z","closedBy":577293,"closedByUser":{},"closedAt":"2022-06-03T14:53:13Z","userUpdatedAt":"2022-06-03T14:54:29Z","customerWaitingSince":{"time":"2022-06-03T14:53:13Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[{"id":9822999,"color":"#A5B2BD","tag":"conservation + données"},{"id":3754718,"color":"#A5B2BD","tag":"webinaire"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1907817899"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/190044272"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/190044272"},"closedBy":{"href":"https://api.helpscout.net/v2/users/577293"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1907817899/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1907817899/1978559"}},"assignee":{}},{"id":1904794963,"number":1978181,"threads":3,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Webinaire tour de France DS - Etape occitanie le 9 juin 2022","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-05-31T14:20:10Z","closedBy":577293,"closedByUser":{},"closedAt":"2022-06-03T14:53:34Z","userUpdatedAt":"2022-06-03T14:53:45Z","customerWaitingSince":{"time":"2022-06-03T14:53:34Z","friendly":"Jun + 3, ''22"},"source":{"type":"web","via":"user"},"tags":[{"id":3754718,"color":"#A5B2BD","tag":"webinaire"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1904794963"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/172457074"},"createdByUser":{"href":"https://api.helpscout.net/v2/users/412221"},"closedBy":{"href":"https://api.helpscout.net/v2/users/577293"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1904794963/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1904794963/1978181"}},"assignee":{}}]},"_links":{"next":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=2"},"self":{"href":"https://api.helpscout.net/v2/conversations?page=1\u0026query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"},"first":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=1"},"last":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=75678"},"page":{"href":"https://api.helpscout.net/v2/conversations?page=1\u0026query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"}},"page":{"size":25,"totalElements":1891943,"totalPages":2,"number":1}}' + recorded_at: Wed, 05 Jun 2024 00:00:00 GMT + +- request: + method: get + uri: https://api.helpscout.net/v2/conversations?page=1&query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)&sortField=modifiedAt&sortOrder=desc&status=closed + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Authorization: + - Bearer redacted + Content-Type: + - application/json; charset=UTF-8 + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Date: + - Tue, 11 Jun 2024 14:13:28 GMT + Content-Type: + - application/hal+json + X-Ratelimit-Limit-Minute: + - '200' + X-Ratelimit-Remaining-Minute: + - '198' + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Frame-Options: + - DENY + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Location,Resource-Id + Correlation-Id: + - a9ca7664-2711-4c36-a092-73203365b474#13211836 + X-Kong-Upstream-Latency: + - '640' + X-Kong-Proxy-Latency: + - '3' + Via: + - kong/0.14.1 + body: + encoding: UTF-8 + string: + '{"_embedded":{"conversations":[{"id":1000000,"number":1978770,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T23:34:03Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T23:34:04Z","userUpdatedAt":"2022-06-04T23:34:04Z","customerWaitingSince":{"time":"2022-06-04T23:34:04Z","friendly":"Jun + 5, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910642153"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910642153/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910642153/1978770"}},"assignee":{}},{"id":1910621183,"number":1978769,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T22:52:48Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T22:52:48Z","userUpdatedAt":"2022-06-04T22:52:48Z","customerWaitingSince":{"time":"2022-06-04T22:52:48Z","friendly":"Jun + 5,"}}]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations?page=1\u0026query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"},"first":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=1"},"last":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=1"},"page":{"href":"https://api.helpscout.net/v2/conversations?page=2\u0026query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"}},"page":{"size":25,"totalElements":1,"totalPages":1,"number":1}}' + recorded_at: Wed, 05 Jun 2024 00:00:00 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb b/spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb new file mode 100644 index 00000000000..494be20bf2b --- /dev/null +++ b/spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb @@ -0,0 +1,62 @@ +describe Maintenance::HelpscoutDeleteOldConversationsTask do + before do + mock_helpscout_secrets + travel_to DateTime.new(2024, 6, 5) + end + + subject do + described_class.new.tap { _1.status = "closed" } + end + + describe '#enumerator_builder' do + it "enumerates conversation ids" do + VCR.use_cassette("helpscout_list_old_conversations") do |c| + ids = subject.enumerator_builder(cursor: 0).to_a + # Warning: calling a enumerable method always reinvoke the enumerable ! + # So immediately convert in array and run expectations on it + + # anonymize when recorded cassettes + c.new_recorded_interactions.each do |interaction| + interaction.request.body = anonymize_request(interaction) + + body = anonymize_response(interaction) + interaction.response.body = body.to_json + end + + expect(ids.count).to eq(27) # 25 first page + 2 next page + expect(ids[0][0]).to eq(1910642153) + end + end + end + + def anonymize_response(interaction) + body = JSON.parse(interaction.response.body) + + Array(body.dig("_embedded", "conversations")).each do |conversation| + conversation["createdBy"] = {} + conversation["closedByUser"] = {} + conversation["primaryCustomer"] = {} + conversation["assignee"] = {} + conversation["preview"] = "" # this also removes binary string + conversation["cc"] = [] + end + + body["access_token"] = "redacted" if body.key?("access_token") + + body + end + + def anonymize_request(interaction) + body = interaction.request.body + + return body unless body.include?("client_secret") + + URI.decode_www_form(body).to_h.merge("client_id" => "1234", "client_secret" => "5678").to_query + end + + def mock_helpscout_secrets + Rails.application.secrets.helpscout[:mailbox_id] = '9999' + Rails.application.secrets.helpscout[:client_id] = '1234' + Rails.application.secrets.helpscout[:client_secret] = '5678' + end +end From 939bc37eabb9892e36f370e7b520b5caf09a8935 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 12 Jun 2024 16:08:05 +0200 Subject: [PATCH 035/111] test(vcr): automatically redact bearer tokens --- spec/support/vcr.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index cd86fba50d1..8ad4ebd3456 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -4,4 +4,13 @@ c.cassette_library_dir = 'spec/fixtures/cassettes' c.configure_rspec_metadata! c.ignore_hosts 'test.host', 'chromedriver.storage.googleapis.com' + + c.filter_sensitive_data('redacted') do |interaction| + auth = interaction.request.headers['Authorization']&.first + next if auth.nil? + + if (match = auth.match(/^Bearer\s+([^,\s]+)/)) + match.captures.first + end + end end From 44cb588c422cfe5e3e28772e029d99900cef7cde Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 13 Jun 2024 12:08:37 +0200 Subject: [PATCH 036/111] mise en page & traduction page session --- app/views/users/sessions/link_sent.html.haml | 47 ++++++++++---------- config/locales/en.yml | 3 ++ config/locales/fr.yml | 3 ++ 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index 66ef591924e..26b7e69e5e2 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -3,27 +3,26 @@ - content_for :footer do = render partial: 'root/footer' -.fr-container.fr-my-5w - .fr-grid-row - .fr-col-12.fr-col-offset-md-1.fr-col-md-7 - %h1.fr-mt-6w Encore une petite étape ! - - %section - %p.fr-text--lead - Nous venons de vous envoyer un courriel sur votre boite email #{@email}. - Veuillez l’ouvrir et cliquer sur le lien de connexion sécurisée à #{Current.application_name}. - - %p.fr-text--lead - Ce lien est valide une semaine et peut être réutilisé plusieurs fois. - - %p.fr-text--sm.fr-text-mention--grey - Ce courriel peut mettre jusqu’à 15 minutes pour arriver. Si vous n’avez pas reçu de courriel (n’hésitez pas à vérifier dans les indésirables), cliquez sur le bouton ci-dessous. - - = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-mail-line', method: 'POST' do - Renvoyer le courriel - - %section - %p.fr-mt-3w - Si vous voyez cette page trop souvent, #{link_to "consultez notre aide", t("links.common.faq.confirmer_compte_chaque_connexion_url")} - %p.fr-mt-3w - = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) +.fr-container + .fr-col-12.fr-col-md-6.fr-col-offset-md-3 + %h1.fr-mt-6w.fr-h2.center + = t('views.confirmation.new.title') + + %p.center{ aria: { hidden: true } }= image_tag("user/confirmation-email.svg", alt: t('views.confirmation.new.image_alt')) + + = render Dsfr::AlertComponent.new(title: '', state: :info, heading_level: 'h2', extra_class_names: 'fr-mt-6w fr-mb-3w') do |c| + - c.with_body do + %p= t('views.users.sessions.link_sent.email_cta_html', email: @email) + %p= t('views.confirmation.new.email_guidelines_html') + + %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w + = t('views.confirmation.new.email_missing') + + = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-mail-line', method: 'POST' do + = t('views.confirmation.new.resent') + + %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w + = t('views.users.sessions.link_sent.consult_help_page_html', href: t("links.common.faq.confirmer_compte_chaque_connexion_url")) + + %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w.fr-mb-6w + = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) diff --git a/config/locales/en.yml b/config/locales/en.yml index a05c394b212..c4a9ea30918 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -554,6 +554,9 @@ en: connect_with_agent_connect: Visit our dedicated page subtitle: "Sign in with my account" for_tiers_alert: If you are completing a forme for someone else, you must use your own credentials. + link_sent: + consult_help_page_html: If you're seeing this page too often, please consult our help page. + email_cta_html: "We have to validate your email address %{email}." passwords: edit: subtitle: Change password diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 42896bd24f1..24815f182ff 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -557,6 +557,9 @@ fr: connect_with_agent_connect: Accédez à notre page dédiée subtitle: "Se connecter avec son compte" for_tiers_alert: Si vous remplissez un dossier pour un tiers, vous devez utiliser vos propres identifiants. + link_sent: + consult_help_page_html: Si vous voyez cette page trop souvent, consultez notre aide. + email_cta_html: "Nous avons besoin de vérifier votre adresse électronique %{email}." passwords: edit: subtitle: Changement de mot de passe From d124127f10a064c9774eb70363ccfe011d6683a9 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 13 Jun 2024 12:35:02 +0200 Subject: [PATCH 037/111] ajustements de front avec la page confirmation --- app/views/users/confirmations/new.html.haml | 4 +++- app/views/users/sessions/link_sent.html.haml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/users/confirmations/new.html.haml b/app/views/users/confirmations/new.html.haml index 58753180198..786c1aa8940 100644 --- a/app/views/users/confirmations/new.html.haml +++ b/app/views/users/confirmations/new.html.haml @@ -17,7 +17,9 @@ %p= t('views.confirmation.new.email_cta_html', email: resource.email) %p= t('views.confirmation.new.email_guidelines_html') + %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w + = t('views.confirmation.new.email_missing') + = form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { class: 'fr-mb-6w'}) do |f| - %legend.fr-hint-text.fr-mb-3w= t('views.confirmation.new.email_missing') = f.hidden_field :email = f.submit t('views.confirmation.new.resent'), class: 'fr-btn fr-btn--secondary' diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index 26b7e69e5e2..270ff697ad6 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -18,7 +18,7 @@ %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w = t('views.confirmation.new.email_missing') - = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-mail-line', method: 'POST' do + = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary', method: 'POST' do = t('views.confirmation.new.resent') %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w From ae5937b22afaf6e2f85a8610141198098c6eeb6f Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 12 Jun 2024 16:44:46 +0200 Subject: [PATCH 038/111] chore(sidekiq): concise transition and avoid typos by not (re)opening classes --- config/initializers/transition_to_sidekiq.rb | 121 +++++-------------- 1 file changed, 27 insertions(+), 94 deletions(-) diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb index 3b5360b6a78..bfc07449b59 100644 --- a/config/initializers/transition_to_sidekiq.rb +++ b/config/initializers/transition_to_sidekiq.rb @@ -1,99 +1,32 @@ if Rails.env.production? && SIDEKIQ_ENABLED ActiveSupport.on_load(:after_initialize) do - class ActiveStorage::PurgeJob < ActiveStorage::BaseJob - self.queue_adapter = :sidekiq - end - - class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob - self.queue_adapter = :sidekiq - end - - class VirusScannerJob - self.queue_adapter = :sidekiq - end - - class DossierRebaseJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ProcedureExternalURLCheckJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class MaintenanceTasks::TaskJob - self.queue_adapter = :sidekiq - end - - class PriorizedMailDeliveryJob < ActionMailer::MailDeliveryJob - self.queue_adapter = :sidekiq - end - - class ProcedureSVASVRProcessDossierJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class WebHookJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class DestroyRecordLaterJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ChampFetchExternalDataJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class DossierIndexSearchTermsJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class Migrations::BackfillStableIdJob - self.queue_adapter = :sidekiq - end - - class Cron::CronJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class APIEntreprise::Job < ApplicationJob - self.queue_adapter = :sidekiq - end - - class DossierOperationLogMoveToColdStorageBatchJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class BatchOperationEnqueueAllJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class BatchOperationProcessOneJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class TitreIdentiteWatermarkJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class AdminUpdateDefaultZonesJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ProcessStalledDeclarativeDossierJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ResetExpiringDossiersJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class SendClosingNotificationJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ImageProcessorJob < ApplicationJob - self.queue_adapter = :sidekiq + [ + ActiveStorage::AnalyzeJob, + ActiveStorage::PurgeJob, + AdminUpdateDefaultZonesJob, + APIEntreprise::Job, + AdminUpdateDefaultZonesJob, + BatchOperationEnqueueAllJob, + BatchOperationProcessOneJob, + ChampFetchExternalDataJob, + Cron::CronJob, + DestroyRecordLaterJob, + DossierIndexSearchTermsJob, + DossierOperationLogMoveToColdStorageBatchJob, + DossierRebaseJob, + ImageProcessorJob, + MaintenanceTasks::TaskJob, + Migrations::BackfillStableIdJob, + PriorizedMailDeliveryJob, + ProcedureExternalURLCheckJob, + ProcedureSVASVRProcessDossierJob, + ProcessStalledDeclarativeDossierJob, + ResetExpiringDossiersJob, + SendClosingNotificationJob, + VirusScannerJob, + WebHookJob + ].each do |job_class| + job_class.queue_adapter = :sidekiq end end end From a1469b04fe10d1537449c6785fb530ff69baa47b Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 13 Jun 2024 13:27:32 +0200 Subject: [PATCH 039/111] refactor(support): create HS conversation in async and run virus scanner on attachments --- app/controllers/support_controller.rb | 43 +++++++------ app/jobs/helpscout_create_conversation_job.rb | 21 ++++++ app/lib/helpscout/api.rb | 14 ++-- app/lib/helpscout/form_adapter.rb | 4 +- config/initializers/transition_to_sidekiq.rb | 1 + spec/controllers/support_controller_spec.rb | 30 ++++++--- .../helpscout_create_conversation_job_spec.rb | 64 +++++++++++++++++++ spec/lib/helpscout/form_adapter_spec.rb | 2 +- 8 files changed, 139 insertions(+), 40 deletions(-) create mode 100644 app/jobs/helpscout_create_conversation_job.rb create mode 100644 spec/jobs/helpscout_create_conversation_job_spec.rb diff --git a/app/controllers/support_controller.rb b/app/controllers/support_controller.rb index 3951e26581c..e2e536499d8 100644 --- a/app/controllers/support_controller.rb +++ b/app/controllers/support_controller.rb @@ -14,24 +14,16 @@ def create flash.notice = "Votre message a été envoyé sur la messagerie de votre dossier." redirect_to messagerie_dossier_path(dossier) - elsif create_conversation - flash.notice = "Votre message a été envoyé." - - if params[:admin] - redirect_to root_path(formulaire_contact_admin_submitted: true) - else - redirect_to root_path(formulaire_contact_general_submitted: true) - end + return + end + + create_conversation_later + flash.notice = "Votre message a été envoyé." + + if params[:admin] + redirect_to root_path(formulaire_contact_admin_submitted: true) else - flash.now.alert = "Une erreur est survenue. Vous pouvez nous contacter à #{helpers.mail_to(Current.contact_email)}." - - if params[:admin] - setup_context_admin - render :admin - else - setup_context - render :index - end + redirect_to root_path(formulaire_contact_general_submitted: true) end end @@ -48,17 +40,26 @@ def setup_context_admin @options = Helpscout::FormAdapter.admin_options end - def create_conversation - Helpscout::FormAdapter.new( + def create_conversation_later + if params[:piece_jointe] + blob = ActiveStorage::Blob.create_and_upload!( + io: params[:piece_jointe].tempfile, + filename: params[:piece_jointe].original_filename, + content_type: params[:piece_jointe].content_type, + identify: false + ).tap(&:scan_for_virus_later) + end + + HelpscoutCreateConversationJob.perform_later( + blob_id: blob&.id, subject: params[:subject], email: email, phone: params[:phone], text: params[:text], - file: params[:piece_jointe], dossier_id: dossier&.id, browser: browser_name, tags: tags - ).send_form + ) end def create_commentaire diff --git a/app/jobs/helpscout_create_conversation_job.rb b/app/jobs/helpscout_create_conversation_job.rb new file mode 100644 index 00000000000..a8175d0294c --- /dev/null +++ b/app/jobs/helpscout_create_conversation_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class HelpscoutCreateConversationJob < ApplicationJob + queue_as :default + + class FileNotScannedYetError < StandardError + end + + retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + + def perform(blob_id: nil, **args) + if blob_id.present? + blob = ActiveStorage::Blob.find(blob_id) + raise FileNotScannedYetError if blob.virus_scanner.pending? + + blob = nil unless blob.virus_scanner.safe? + end + + Helpscout::FormAdapter.new(**args, blob:).send_form + end +end diff --git a/app/lib/helpscout/api.rb b/app/lib/helpscout/api.rb index c0538c7a935..b2f72d85c86 100644 --- a/app/lib/helpscout/api.rb +++ b/app/lib/helpscout/api.rb @@ -22,7 +22,7 @@ def add_tags(conversation_id, tags) }) end - def create_conversation(email, subject, text, file) + def create_conversation(email, subject, text, blob) body = { subject: subject, customer: customer(email), @@ -34,7 +34,7 @@ def create_conversation(email, subject, text, file) type: 'customer', customer: customer(email), text: text, - attachments: attachments(file) + attachments: attachments(blob) } ] }.compact @@ -76,13 +76,13 @@ def productivity_report(year, month) private - def attachments(file) - if file.present? + def attachments(blob) + if blob.present? [ { - fileName: file.original_filename, - mimeType: file.content_type, - data: Base64.strict_encode64(file.read) + fileName: blob.filename, + mimeType: blob.content_type, + data: Base64.strict_encode64(blob.download) } ] else diff --git a/app/lib/helpscout/form_adapter.rb b/app/lib/helpscout/form_adapter.rb index 03c168f081a..4127e9dc252 100644 --- a/app/lib/helpscout/form_adapter.rb +++ b/app/lib/helpscout/form_adapter.rb @@ -66,7 +66,7 @@ def create_conversation params[:email], params[:subject], params[:text], - params[:file] + params[:blob] ) if response.success? @@ -74,6 +74,8 @@ def create_conversation @api.add_phone_number(params[:email], params[:phone]) end response.headers['Resource-ID'] + else + raise StandardError, "Error while creating conversation: #{response.response_code} '#{response.body}'" end end end diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb index bfc07449b59..28e9075e7ab 100644 --- a/config/initializers/transition_to_sidekiq.rb +++ b/config/initializers/transition_to_sidekiq.rb @@ -14,6 +14,7 @@ DossierIndexSearchTermsJob, DossierOperationLogMoveToColdStorageBatchJob, DossierRebaseJob, + HelpscoutCreateConversationJob, ImageProcessorJob, MaintenanceTasks::TaskJob, Migrations::BackfillStableIdJob, diff --git a/spec/controllers/support_controller_spec.rb b/spec/controllers/support_controller_spec.rb index d7275991d1f..281c7e4a3dc 100644 --- a/spec/controllers/support_controller_spec.rb +++ b/spec/controllers/support_controller_spec.rb @@ -58,9 +58,9 @@ let(:params) { { subject: 'bonjour', text: 'un message' } } it 'creates a conversation on HelpScout' do - expect_any_instance_of(Helpscout::FormAdapter).to receive(:send_form).and_return(true) - - expect { subject }.to change(Commentaire, :count).by(0) + expect { subject }.to \ + change(Commentaire, :count).by(0).and \ + have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(params)) expect(flash[:notice]).to match('Votre message a été envoyé.') expect(response).to redirect_to root_path(formulaire_contact_general_submitted: true) @@ -80,9 +80,9 @@ end it 'creates a conversation on HelpScout' do - expect_any_instance_of(Helpscout::FormAdapter).to receive(:send_form).and_return(true) - - expect { subject }.to change(Commentaire, :count).by(0) + expect { subject }.to \ + change(Commentaire, :count).by(0).and \ + have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(subject: 'bonjour', dossier_id: dossier.id)) expect(flash[:notice]).to match('Votre message a été envoyé.') expect(response).to redirect_to root_path(formulaire_contact_general_submitted: true) @@ -103,9 +103,8 @@ end it 'posts the message to the dossier messagerie' do - expect_any_instance_of(Helpscout::FormAdapter).not_to receive(:send_form) - expect { subject }.to change(Commentaire, :count).by(1) + assert_no_enqueued_jobs(only: HelpscoutCreateConversationJob) expect(Commentaire.last.email).to eq(user.email) expect(Commentaire.last.dossier).to eq(dossier) @@ -159,10 +158,21 @@ describe "when form is filled" do it "creates a conversation on HelpScout" do - expect_any_instance_of(Helpscout::FormAdapter).to receive(:send_form).and_return(true) - subject + expect { subject }.to have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(params.except(:admin))) expect(flash[:notice]).to match('Votre message a été envoyé.') end + + context "with a piece justificative" do + let(:logo) { fixture_file_upload('spec/fixtures/files/white.png', 'image/png') } + let(:params) { super().merge(piece_jointe: logo) } + + it "create blob and pass it to conversation job" do + expect { subject }.to \ + change(ActiveStorage::Blob, :count).by(1).and \ + have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(blob_id: Integer)).and \ + have_enqueued_job(VirusScannerJob) + end + end end describe "when invisible captcha is filled" do diff --git a/spec/jobs/helpscout_create_conversation_job_spec.rb b/spec/jobs/helpscout_create_conversation_job_spec.rb new file mode 100644 index 00000000000..1220e538d64 --- /dev/null +++ b/spec/jobs/helpscout_create_conversation_job_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe HelpscoutCreateConversationJob, type: :job do + let(:args) { { email: 'sender@email.com' } } + + describe '#perform' do + context 'when blob_id is not present' do + it 'sends the form without a file' do + form_adapter = double('Helpscout::FormAdapter') + allow(Helpscout::FormAdapter).to receive(:new).with(hash_including(args.merge(blob: nil))).and_return(form_adapter) + expect(form_adapter).to receive(:send_form) + + described_class.perform_now(**args) + end + end + + context 'when blob_id is present' do + let(:blob) { + ActiveStorage::Blob.create_and_upload!(io: StringIO.new("toto"), filename: "toto.png") + } + + before do + allow(blob).to receive(:virus_scanner).and_return(double('VirusScanner', pending?: pending, safe?: safe)) + end + + context 'when the file has not been scanned yet' do + let(:pending) { true } + let(:safe) { false } + + it 'reenqueue job' do + expect { + described_class.perform_now(blob_id: blob.id, **args) + }.to have_enqueued_job(described_class).with(blob_id: blob.id, **args) + end + end + + context 'when the file is safe' do + let(:pending) { false } + let(:safe) { true } + + it 'downloads the file and sends the form' do + form_adapter = double('Helpscout::FormAdapter') + allow(Helpscout::FormAdapter).to receive(:new).with(hash_including(args.merge(blob:))).and_return(form_adapter) + allow(form_adapter).to receive(:send_form) + + described_class.perform_now(blob_id: blob.id, **args) + end + end + + context 'when the file is not safe' do + let(:pending) { false } + let(:safe) { false } + + it 'downloads the file and sends the form' do + form_adapter = double('Helpscout::FormAdapter') + allow(Helpscout::FormAdapter).to receive(:new).with(hash_including(args.merge(blob: nil))).and_return(form_adapter) + allow(form_adapter).to receive(:send_form) + + described_class.perform_now(blob_id: blob.id, **args) + end + end + end + end +end diff --git a/spec/lib/helpscout/form_adapter_spec.rb b/spec/lib/helpscout/form_adapter_spec.rb index d2e146cabc7..8eaa085a622 100644 --- a/spec/lib/helpscout/form_adapter_spec.rb +++ b/spec/lib/helpscout/form_adapter_spec.rb @@ -5,7 +5,7 @@ context 'create_conversation' do before do allow(api).to receive(:create_conversation) - .and_return(double(success?: false)) + .and_return(double(success?: true, headers: {})) described_class.new(params, api).send_form end From 88c957806f13aaf7a66f9a60acbb36f25ebfbd29 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 13 Jun 2024 14:01:05 +0200 Subject: [PATCH 040/111] fix link faq --- app/controllers/application_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 32d8c088f0c..bc172fe4737 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -312,7 +312,8 @@ def sensitive_path path == '/contact-admin' || path.start_with?('/connexion-par-jeton') || path.start_with?('/api/') || - path.start_with?('/lien-envoye') + path.start_with?('/lien-envoye') || + path.start_with?('/faq') false else From e283f2d8cd59b4b52f226e0ca66141ede9afe4c7 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 13 Jun 2024 14:12:00 +0200 Subject: [PATCH 041/111] chore(search): don't index on autosave --- app/controllers/users/dossiers_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 1fac35eae9e..f9e0522685b 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -304,7 +304,6 @@ def update @dossier = dossier_with_champs(pj_template: false) @can_passer_en_construction_was = @dossier.can_passer_en_construction? update_dossier_and_compute_errors - @dossier.index_search_terms_later if @dossier.errors.empty? @can_passer_en_construction_is = @dossier.can_passer_en_construction? respond_to do |format| format.turbo_stream do From e6d761b915a951b4b808dab9c3b120109e1c1d94 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Fri, 14 Jun 2024 10:36:55 +0200 Subject: [PATCH 042/111] fix(dossier): see_more errors missing translation --- .../expandable_error_list/expandable_error_list.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/expandable_error_list/expandable_error_list.html.haml b/app/components/expandable_error_list/expandable_error_list.html.haml index 1ab5221e58c..b9c958b3187 100644 --- a/app/components/expandable_error_list/expandable_error_list.html.haml +++ b/app/components/expandable_error_list/expandable_error_list.html.haml @@ -6,7 +6,7 @@ = 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') + %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 From 2e1e1c060b17bc05781d515f12bc122ee459f9de Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 14 Jun 2024 11:41:54 +0200 Subject: [PATCH 043/111] fix(dossier_submitted_message#edit): missing dossier for preview --- app/views/users/dossiers/_merci.html.haml | 2 +- .../dossier_submitted_messages_controller_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index b9bb097c687..be9f3096c0b 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -20,7 +20,7 @@ - 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.download_dossier')} (PDF)", dossier ? 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' %hr.fr-hr diff --git a/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb b/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb index 4bfb5babeb3..46f002f0502 100644 --- a/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb +++ b/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb @@ -34,7 +34,7 @@ describe '#edit' do context 'when procedure is draft and have a DossierSubmittedMessage' do let(:procedure) { create(:procedure, :with_dossier_submitted_message, administrateur: administrateur) } - + render_views it 'assigns the existing DossierSubmittedMessage' do get(:edit, params: { procedure_id: procedure.id }) expect(response).to have_http_status(200) From 2dda5e44f9f158f3c6d27df712fbfddf90223e05 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Fri, 14 Jun 2024 11:34:41 +0200 Subject: [PATCH 044/111] chore(kredis): use default shared connection name, fixing dossier index debounce --- app/controllers/concerns/lockable_concern.rb | 2 +- config/initializers/kredis.rb | 6 +++--- spec/controllers/concerns/lockable_concern_spec.rb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/lockable_concern.rb b/app/controllers/concerns/lockable_concern.rb index f6e59743bdc..8800854269f 100644 --- a/app/controllers/concerns/lockable_concern.rb +++ b/app/controllers/concerns/lockable_concern.rb @@ -3,7 +3,7 @@ module LockableConcern included do def lock_action(key) - lock = Kredis.flag(key, config: :volatile) + lock = Kredis.flag(key) head :locked and return if lock.marked? lock.mark(expires_in: 10.seconds) diff --git a/config/initializers/kredis.rb b/config/initializers/kredis.rb index 878b8177a7b..48a7a7a2142 100644 --- a/config/initializers/kredis.rb +++ b/config/initializers/kredis.rb @@ -1,7 +1,7 @@ -redis_volatile_options = { +redis_shared_options = { url: ENV['REDIS_CACHE_URL'], # will fallback to default redis url if empty, and won't fail if there is no redis server ssl: ENV['REDIS_CACHE_SSL'] == 'enabled' } -redis_volatile_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' +redis_shared_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' -Kredis::Connections.connections[:volatile] = Redis.new(redis_volatile_options) +Kredis::Connections.connections[:shared] = Redis.new(redis_shared_options) diff --git a/spec/controllers/concerns/lockable_concern_spec.rb b/spec/controllers/concerns/lockable_concern_spec.rb index add57dc8d72..dc47db17b34 100644 --- a/spec/controllers/concerns/lockable_concern_spec.rb +++ b/spec/controllers/concerns/lockable_concern_spec.rb @@ -27,7 +27,7 @@ def test_action context 'when there are concurrent requests' do it 'aborts the second request' do # Simulating the first request acquiring the lock - Kredis.flag(lock_key, config: :volatile).mark(expires_in: 3.seconds) + Kredis.flag(lock_key).mark(expires_in: 3.seconds) # Making the second request expect(subject).to have_http_status(:locked) @@ -36,7 +36,7 @@ def test_action context 'when the lock expires' do it 'allows another request after expiration' do - Kredis.flag(lock_key, config: :volatile).mark(expires_in: 0.001.seconds) + Kredis.flag(lock_key).mark(expires_in: 0.001.seconds) sleep 0.002 expect(subject).to have_http_status(:ok) From 98782f8bf01328a37ea772e6a3b705344cd0a125 Mon Sep 17 00:00:00 2001 From: Benoit Queyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:37:05 +0200 Subject: [PATCH 045/111] suggestion de Colin Co-authored-by: Colin Darie --- app/mailers/notification_mailer.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index c319df7dd36..e64a68ddcad 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -92,11 +92,8 @@ def set_services_publics_plus def set_jdma if params[:state] == Dossier.states.fetch(:en_construction) && @dossier.procedure.monavis_embed @jdma_html = @dossier.procedure.monavis_embed_html_source("email") - else - return end end - def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) From cf787d322a274ff9f6d05250ebf4b4fa1d319289 Mon Sep 17 00:00:00 2001 From: Benoit Queyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:43:13 +0200 Subject: [PATCH 046/111] linter check --- app/mailers/notification_mailer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index e64a68ddcad..87d75fa1cb2 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -94,6 +94,7 @@ def set_jdma @jdma_html = @dossier.procedure.monavis_embed_html_source("email") end end + def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) From ee35dba37efdb00f2a19776989e41c478972aaeb Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 14 Jun 2024 18:04:16 +0200 Subject: [PATCH 047/111] linter check --- app/mailers/notification_mailer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 87d75fa1cb2..8aa68de199d 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -94,7 +94,7 @@ def set_jdma @jdma_html = @dossier.procedure.monavis_embed_html_source("email") end end - + def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) From 5a7316bc5b1c474fe5907752b3b3ad87739aaae4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 10:36:46 +0200 Subject: [PATCH 048/111] chore(css): import segmented controls --- app/javascript/entrypoints/main.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/entrypoints/main.css b/app/javascript/entrypoints/main.css index aa7dec85b88..c6e48839d1c 100644 --- a/app/javascript/entrypoints/main.css +++ b/app/javascript/entrypoints/main.css @@ -24,6 +24,7 @@ @import '@gouvfr/dsfr/dist/component/modal/modal.css'; @import '@gouvfr/dsfr/dist/component/navigation/navigation.css'; @import '@gouvfr/dsfr/dist/component/notice/notice.css'; +@import '@gouvfr/dsfr/dist/component/segmented/segmented.css'; @import '@gouvfr/dsfr/dist/component/table/table.css'; @import '@gouvfr/dsfr/dist/component/tile/tile.css'; @import '@gouvfr/dsfr/dist/component/tag/tag.css'; From 266a7dbcdd20d40a93479f30101ce98bd5fe7004 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 10:37:11 +0200 Subject: [PATCH 049/111] style(stats): better responsiveness & dark theme --- app/assets/stylesheets/stats.scss | 51 +------- .../controllers/lazy/chartkick_controller.ts | 73 ++++++----- app/views/stats/index.html.haml | 116 +++++++++--------- config/initializers/chartkick.rb | 16 ++- 4 files changed, 114 insertions(+), 142 deletions(-) diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss index 01721ec382d..9869fdf1586 100644 --- a/app/assets/stylesheets/stats.scss +++ b/app/assets/stylesheets/stats.scss @@ -70,51 +70,8 @@ $stat-card-half-horizontal-spacing: 4 * $default-space; font-style: italic; } -$segmented-control-margin-top: $default-space; - -.segmented-control { - border-radius: 36px; - height: 36px; - line-height: 36px; - font-size: 0; - padding: 0; - display: inline-block; - margin-top: $segmented-control-margin-top; -} - $segmented-control-item-horizontal-padding: $default-space; -$segmented-control-item-border-radius: 2 * $default-space; - -.segmented-control-item { - display: inline-block; - font-size: 15px; - border: 2px solid $blue-france-700; - margin-right: -2px; - padding-top: var(--li-bottom); - padding-left: $segmented-control-item-horizontal-padding; - padding-right: $segmented-control-item-horizontal-padding; - color: $blue-france-700; - - &:first-of-type { - border-radius: $segmented-control-item-border-radius 0px 0px $segmented-control-item-border-radius; - } - - &:last-of-type { - border-radius: 0px $segmented-control-item-border-radius $segmented-control-item-border-radius 0px; - margin-right: 0; - } - - &:hover { - background-color: $blue-france-500; - color: #FFFFFF; - cursor: pointer; - } -} - -.segmented-control-item-active { - background-color: $blue-france-700; - color: #FFFFFF; -} +$segmented-control-item-border-radius: 0.25rem; .chart-container { margin-top: 36px; @@ -147,10 +104,10 @@ $big-number-card-padding: 2 * $segmented-control-item-border-radius; .big-number-card-number { display: block; text-align: center; - font-size: 80px; - line-height: 1em; + font-size: 4.5rem; + line-height: 1.5em; font-weight: bold; - color: $blue-france-500; + color: var(--text-title-blue-france); white-space: nowrap; } diff --git a/app/javascript/controllers/lazy/chartkick_controller.ts b/app/javascript/controllers/lazy/chartkick_controller.ts index 90758e10ff3..a5e84394c05 100644 --- a/app/javascript/controllers/lazy/chartkick_controller.ts +++ b/app/javascript/controllers/lazy/chartkick_controller.ts @@ -1,44 +1,43 @@ import { Controller } from '@hotwired/stimulus'; -import { toggle, delegate } from '@utils'; -import Highcharts from 'highcharts'; import Chartkick from 'chartkick'; +import Highcharts from 'highcharts'; +import invariant from 'tiny-invariant'; + +Chartkick.use(Highcharts); + +export default class ChartkickController extends Controller { + static targets = ['chart']; + + declare readonly chartTargets: HTMLElement[]; + + toggleChart(event: Event) { + const target = event.currentTarget as HTMLInputElement; + const chartClass = target.dataset.toggleChart; + + invariant(chartClass, 'Missing data-toggle-chart attribute'); -export class ChartkickController extends Controller { - async connect() { - delegate('click', '[data-toggle-chart]', (event) => - toggleChart(event as MouseEvent) + const nextChart = document.querySelector(chartClass); + const currentChart = this.chartTargets.find( + (chart) => !chart.classList.contains('hidden') ); - } -} -Chartkick.use(Highcharts); -function reflow(nextChartId?: string) { - nextChartId && Chartkick.charts[nextChartId]?.getChartObject()?.reflow(); -} + if (currentChart) { + currentChart.classList.add('hidden'); + } -function toggleChart(event: MouseEvent) { - const nextSelectorItem = event.target as HTMLButtonElement, - chartClass = nextSelectorItem.dataset.toggleChart, - nextChart = chartClass - ? document.querySelector(chartClass) - : undefined, - nextChartId = nextChart?.children[0]?.id, - currentSelectorItem = nextSelectorItem.parentElement?.querySelector( - '.segmented-control-item-active' - ), - currentChart = - nextSelectorItem.parentElement?.parentElement?.querySelector( - '.chart:not(.hidden)' - ); - - // Change the current selector and the next selector states - currentSelectorItem?.classList.toggle('segmented-control-item-active'); - nextSelectorItem.classList.toggle('segmented-control-item-active'); - - // Hide the currently shown chart and show the new one - currentChart && toggle(currentChart); - nextChart && toggle(nextChart); - - // Reflow needed, see https://github.com/highcharts/highcharts/issues/1979 - reflow(nextChartId); + if (nextChart) { + nextChart.classList.remove('hidden'); + const nextChartId = nextChart.children[0]?.id; + this.reflow(nextChartId); + } + } + + reflow(chartId: string) { + if (chartId) { + const chart = Chartkick.charts[chartId]; + if (chart) { + chart.getChartObject()?.reflow(); + } + } + } } diff --git a/app/views/stats/index.html.haml b/app/views/stats/index.html.haml index d1712f63a4f..af8fc0bc14f 100644 --- a/app/views/stats/index.html.haml +++ b/app/views/stats/index.html.haml @@ -2,71 +2,75 @@ - content_for :footer do = render partial: "root/footer" -.statistiques{ 'data-controller': 'chartkick' } - %h1.new-h1 Statistiques +.fr-container.fr-my-4w + %h1 Statistiques d’utilisation de la plateforme - .stat-cards - .stat-card.stat-card-half.big-number-card.pull-left - %span.big-number-card-title.long-title TOTAL DÉMARCHES DÉMAT. OU EN COURS DE DÉMAT. - %span.big-number-card-number - = number_with_delimiter(@procedures_numbers[:total]) - %span.big-number-card-detail - #{number_with_delimiter(@procedures_numbers[:last_30_days_count])} (#{@procedures_numbers[:evolution]} %) sur les 30 derniers jours - %span.big-number-card-detail - = link_to "Voir carte de déploiement", carte_path + .fr-grid-row.fr-grid-row--gutters + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title Démarches dématérialisées (total) + %p.fr-callout__text.big-number-card-number.fr-mb-2w + %span.big-number-card-number= number_with_delimiter(@procedures_numbers[:total]) + %p.fr-callout__text.fr-text--md.text-center + #{number_with_delimiter(@procedures_numbers[:last_30_days_count])} (#{@procedures_numbers[:evolution]} %) sur les 30 derniers jours + %br + = link_to "Voir carte de déploiement", carte_path - .stat-card.stat-card-half.big-number-card.pull-left - %span.big-number-card-title TOTAL DOSSIERS DÉPOSÉS - %span.big-number-card-number - = number_with_delimiter(@dossiers_numbers[:total]) - %span.big-number-card-detail - #{number_with_delimiter(@dossiers_numbers[:last_30_days_count])} (#{@dossiers_numbers[:evolution]} %) sur les 30 derniers jours - %span.big-number-card-detail - = link_to "Voir carte de déploiement", carte_path(map_filter: { kind: :nb_dossiers }) + %fieldset.fr-segmented.fr-segmented--sm.pull-right.fr-mt-2w.fr-my-1w + .fr-segmented__elements + .fr-segmented__element + %input{ value: "1", checked: true, type: "radio", id: "segmented-procedures-1", name: "segmented-procedures", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.monthly-procedures-chart' } } + %label.fr-label{ for: "segmented-procedures-1" } + Par mois + .fr-segmented__element + %input{ value: "2", type: "radio", id: "segmented-procedures-2", name: "segmented-procedures", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.cumulative-procedures-chart' } } + %label.fr-label{ for: "segmented-procedures-2" } + Cumul + .chart-container + .chart.monthly-procedures-chart{ data: { 'chartkick-target': 'chart' }} + = column_chart @procedures_in_the_last_4_months, library: Chartkick.options[:default_library_config] + .chart.cumulative-procedures-chart.hidden{ data: { 'chartkick-target': 'chart' } } + = area_chart @procedures_cumulative, library: Chartkick.options[:default_library_config] - .stat-card.stat-card-half.pull-left - %ul.segmented-control.pull-right - %li.segmented-control-item.segmented-control-item-active{ data: { 'toggle-chart': '.monthly-procedures-chart' } } - Par mois - %li.segmented-control-item{ data: { 'toggle-chart': '.cumulative-procedures-chart' } } - Cumul - %span.stat-card-title.pull-left Démarches dématérialisées - .clearfix + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title Dossiers déposés (total) + %p.fr-callout__text.big-number-card-number.fr-mb-2w + = number_with_delimiter(@dossiers_numbers[:total]) + %p.fr-callout__text.fr-text--md.text-center + #{number_with_delimiter(@dossiers_numbers[:last_30_days_count])} (#{@dossiers_numbers[:evolution]} %) sur les 30 derniers jours + %br + = link_to "Voir carte de déploiement", carte_path(map_filter: { kind: :nb_dossiers }) - .chart-container - .chart.monthly-procedures-chart - = column_chart @procedures_in_the_last_4_months - .chart.cumulative-procedures-chart.hidden - = area_chart @procedures_cumulative + %fieldset.fr-segmented.fr-segmented--sm.pull-right.fr-mt-2w.fr-my-1w + .fr-segmented__elements + .fr-segmented__element + %input{ value: "1", checked: true, type: "radio", id: "segmented-dossiers-1", name: "segmented-dossiers", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.monthly-dossiers-chart' } } + %label.fr-label{ for: "segmented-dossiers-1" } + Par mois + .fr-segmented__element + %input{ value: "2", type: "radio", id: "segmented-dossiers-2", name: "segmented-dossiers", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.cumulative-dossiers-chart' } } + %label.fr-label{ for: "segmented-dossiers-2" } + Cumul - .stat-card.stat-card-half.pull-left - %ul.segmented-control.pull-right - %li.segmented-control-item.segmented-control-item-active{ data: { 'toggle-chart': '.monthly-dossiers-chart' } } - Par mois - %li.segmented-control-item{ data: { 'toggle-chart': '.cumulative-dossiers-chart' } } - Cumul - %span.stat-card-title.pull-left Dossiers déposés - .clearfix - .chart-container - .chart.monthly-dossiers-chart - = column_chart @dossiers_in_the_last_4_months - .chart.cumulative-dossiers-chart.hidden - = area_chart @dossiers_cumulative + .chart-container + .chart.monthly-dossiers-chart{ data: { 'chartkick-target': 'chart' }} + = column_chart @dossiers_in_the_last_4_months, library: Chartkick.options[:default_library_config] + .chart.cumulative-dossiers-chart.hidden{ data: { 'chartkick-target': 'chart' } } + = area_chart @dossiers_cumulative, library: Chartkick.options[:default_library_config] - .stat-card.stat-card-half.pull-left - %span.stat-card-title - Répartition des dossiers + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title Répartition des dossiers - .chart-container - .chart - = pie_chart @dossiers_states_for_pie, - colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] - - .clearfix + .chart-container + .chart + = pie_chart @dossiers_states_for_pie, library: Chartkick.options[:default_library_config], + colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] - if super_admin_signed_in? - %h2.new-h2 Téléchargement + %h2.fr-h4 Téléchargement - = link_to "Télécharger les statistiques (CSV)", stats_download_path(format: :csv), class: 'fr-btn fr-btn-primary mb-4' + = link_to "Télécharger les statistiques (CSV)", stats_download_path(format: :csv), class: 'fr-btn fr-btn-primary fr-mb-4w' diff --git a/config/initializers/chartkick.rb b/config/initializers/chartkick.rb index 4f44c1a38d3..fe4d559ad8f 100644 --- a/config/initializers/chartkick.rb +++ b/config/initializers/chartkick.rb @@ -1,6 +1,18 @@ Chartkick.options = { content_for: :charts_js, - colors: ["#000091"], + colors: ["var(--background-action-high-blue-france)"], thousands: ' ', - decimal: ',' + decimal: ',', + default_library_config: { + chart: { backgroundColor: 'var(--background-contrast-grey)' }, + xAxis: { + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + yAxis: { + gridLineColor: 'var(--border-plain-grey)', + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + } + } } From c288d340d8707d252bdacee05903c5b0b3749f9c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 11:53:01 +0200 Subject: [PATCH 050/111] style(stats): better responsive & dark theme for procedure stats --- app/assets/stylesheets/stats.scss | 97 ------------------- app/views/shared/procedures/_stats.html.haml | 99 +++++++++++--------- config/initializers/chartkick.rb | 34 +++++-- 3 files changed, 80 insertions(+), 150 deletions(-) diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss index 9869fdf1586..8c255c6f692 100644 --- a/app/assets/stylesheets/stats.scss +++ b/app/assets/stylesheets/stats.scss @@ -1,78 +1,8 @@ -@import "colors"; -@import "constants"; - -$dark-grey: #333333; -$light-grey: #999999; - -$default-space: 15px; - -$new-h1-margin-bottom: 4 * $default-space; -$new-h2-margin-bottom: 3 * $default-space; - -.new-h1, -.new-h2 { - color: $dark-grey; - text-align: center; - font-weight: bold; -} - .new-h1 { margin-bottom: 3.75rem; font-size: 2.5rem; } -.new-h2 { - margin-bottom: $new-h2-margin-bottom; - font-size: 36px; -} - -$statistiques-padding-top: $default-space * 2; - -.statistiques { - width: 1040px; - margin: 0 auto; - padding-top: $statistiques-padding-top; -} - -.stat-cards { - .stat-card:nth-of-type(even) { - margin-right: 0px; - } -} - -$stat-card-margin-bottom: 3 * $default-space; - -.stat-card { - padding: 15px; - margin-bottom: $stat-card-margin-bottom; - border-radius: 5px; - box-shadow: none; - border: 1px solid rgba(0, 0, 0, 0.15); -} - -$stat-card-half-horizontal-spacing: 4 * $default-space; - -.stat-card-half { - width: calc((100% - #{$stat-card-half-horizontal-spacing}) / 2); - margin-right: $stat-card-half-horizontal-spacing; -} - -.stat-card-title { - color: $dark-grey; - font-size: 26px; - font-weight: bold; - width: 200px; - text-transform: uppercase; -} - -.stat-card-details { - font-size: 13px; - font-style: italic; -} - -$segmented-control-item-horizontal-padding: $default-space; -$segmented-control-item-border-radius: 0.25rem; - .chart-container { margin-top: 36px; } @@ -81,26 +11,6 @@ $segmented-control-item-border-radius: 0.25rem; width: 100%; } -$big-number-card-padding: 2 * $segmented-control-item-border-radius; - -.big-number-card { - padding: $big-number-card-padding $segmented-control-item-horizontal-padding; -} - -.big-number-card-title { - display: block; - text-align: center; - margin: 0 auto; - margin-bottom: 20px; - color: $light-grey; - text-transform: uppercase; - - &.long-title { - margin-left: -30px; - margin-right: -30px; - } -} - .big-number-card-number { display: block; text-align: center; @@ -110,10 +20,3 @@ $big-number-card-padding: 2 * $segmented-control-item-border-radius; color: var(--text-title-blue-france); white-space: nowrap; } - -.big-number-card-detail { - display: block; - margin-top: $default-padding; - text-align: center; - color: $blue-france-500; -} diff --git a/app/views/shared/procedures/_stats.html.haml b/app/views/shared/procedures/_stats.html.haml index b39856c9e86..1fe3affe0c5 100644 --- a/app/views/shared/procedures/_stats.html.haml +++ b/app/views/shared/procedures/_stats.html.haml @@ -1,45 +1,56 @@ -.statistiques{ 'data-controller': 'chartkick' } - %h1.new-h1= title - .stat-cards +.fr-container.fr-my-4w + %h1= title + .fr-grid-row.fr-grid-row--gutters - if @usual_traitement_time.present? - .stat-card.big-number-card - %span.big-number-card-title= t('.usual_processing_time') - = render Procedure::EstimatedDelayComponent.new(procedure: @procedure) - - .stat-cards - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.processing_time') - .stat-card-details= t('.since_procedure_creation') - .chart-container - .chart - - colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss - = column_chart @usual_traitement_time_by_month, ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') - - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.status_evolution') - .stat-card-details= t('.status_evolution_details') - .chart-container - .chart - = area_chart @dossiers_funnel, ytitle: t('.dossiers_count'), label: t('.dossiers_count') - - .stat-cards - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.acceptance_rate') - .stat-card-details= t('.acceptance_rate_details') - .chart-container - .chart - = pie_chart @termines_states, - code: true, - colors: %w(#387EC3 #AE2C2B #FAD859), - label: t('.rate'), - suffix: '%', - library: { plotOptions: { pie: { dataLabels: { enabled: true, format: '{point.name} : {point.percentage: .1f}%' } } } } - - - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.weekly_distribution') - .stat-card-details= t('.weekly_distribution_details') - .chart-container - .chart - = line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"], ytitle: t('.dossiers_count') -.clearfix + .fr-col-xs-12 + .fr-callout + %h2.fr-callout__title= t('.usual_processing_time') + = render Procedure::EstimatedDelayComponent.new(procedure: @procedure) + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title= t('.processing_time') + %p.fr-callout__text.fr-text--md= t('.since_procedure_creation') + + .chart-container + .chart-procedures-chart{ data: { 'chartkick-target': 'chart' }} + = column_chart @usual_traitement_time_by_month, + library: Chartkick.options[:default_library_config], + ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.status_evolution') + %p.fr-callout__text.fr-text--md= t('.status_evolution_details') + + .chart-container + .chart + = area_chart @dossiers_funnel, + library: Chartkick.options[:default_library_config], + ytitle: t('.dossiers_count'), label: t('.dossiers_count') + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.acceptance_rate') + %p.fr-callout__text.fr-text--md= t('.acceptance_rate_details') + + .chart-container + .chart + = pie_chart @termines_states, + library: Chartkick.options[:default_library_config], + code: true, + colors: ["var(--background-flat-success)", "var(--background-flat-error)", "#FAD859" ], + label: t('.rate'), + suffix: '%' + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.weekly_distribution') + %p.fr-callout__text.fr-text--md= t('.weekly_distribution_details') + + .chart-container + .chart + = line_chart @termines_by_week, + library: Chartkick.options[:default_library_config], + colors: ["var(--background-flat-success)", "var(--background-flat-error)", "#FAD859" ], + ytitle: t('.dossiers_count') diff --git a/config/initializers/chartkick.rb b/config/initializers/chartkick.rb index fe4d559ad8f..68af7c1517c 100644 --- a/config/initializers/chartkick.rb +++ b/config/initializers/chartkick.rb @@ -5,14 +5,30 @@ decimal: ',', default_library_config: { chart: { backgroundColor: 'var(--background-contrast-grey)' }, - xAxis: { - lineColor: 'var(--border-action-high-grey)', - labels: { style: { color: "var(--text-default-grey)" } } - }, - yAxis: { - gridLineColor: 'var(--border-plain-grey)', - lineColor: 'var(--border-action-high-grey)', - labels: { style: { color: "var(--text-default-grey)" } } - } + xAxis: { + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + yAxis: { + gridLineColor: 'var(--border-plain-grey)', + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + legend: { + itemStyle: { + color: "var(--text-default-grey)" + } + }, + plotOptions: { + pie: { + dataLabels: { + color: "var(--text-default-grey)", + enabled: true, format: '{point.name} : {point.percentage: .1f}%', + style: { + textOutline: 'none' + } + } + } + } } } From 225206425980fae4f7099d2db86ed4826d76b555 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 11:57:52 +0200 Subject: [PATCH 051/111] style: cleanup stats.scss --- app/assets/stylesheets/stats.scss | 9 --------- app/views/shared/procedures/_stats.html.haml | 10 +++++----- app/views/stats/index.html.haml | 10 +++++----- app/views/support/admin.html.haml | 2 +- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss index 8c255c6f692..759768e6eec 100644 --- a/app/assets/stylesheets/stats.scss +++ b/app/assets/stylesheets/stats.scss @@ -1,12 +1,3 @@ -.new-h1 { - margin-bottom: 3.75rem; - font-size: 2.5rem; -} - -.chart-container { - margin-top: 36px; -} - .chart { width: 100%; } diff --git a/app/views/shared/procedures/_stats.html.haml b/app/views/shared/procedures/_stats.html.haml index 1fe3affe0c5..fd6fb1c85f3 100644 --- a/app/views/shared/procedures/_stats.html.haml +++ b/app/views/shared/procedures/_stats.html.haml @@ -12,8 +12,8 @@ %h2.fr-callout__title= t('.processing_time') %p.fr-callout__text.fr-text--md= t('.since_procedure_creation') - .chart-container - .chart-procedures-chart{ data: { 'chartkick-target': 'chart' }} + .fr-mt-4w + .chart-procedures-chart{ data: { 'chartkick-target': 'chart' } } = column_chart @usual_traitement_time_by_month, library: Chartkick.options[:default_library_config], ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') @@ -23,7 +23,7 @@ %h2.fr-callout__title= t('.status_evolution') %p.fr-callout__text.fr-text--md= t('.status_evolution_details') - .chart-container + .fr-mt-4w .chart = area_chart @dossiers_funnel, library: Chartkick.options[:default_library_config], @@ -34,7 +34,7 @@ %h2.fr-callout__title= t('.acceptance_rate') %p.fr-callout__text.fr-text--md= t('.acceptance_rate_details') - .chart-container + .fr-mt-4w .chart = pie_chart @termines_states, library: Chartkick.options[:default_library_config], @@ -48,7 +48,7 @@ %h2.fr-callout__title= t('.weekly_distribution') %p.fr-callout__text.fr-text--md= t('.weekly_distribution_details') - .chart-container + .fr-mt-4w .chart = line_chart @termines_by_week, library: Chartkick.options[:default_library_config], diff --git a/app/views/stats/index.html.haml b/app/views/stats/index.html.haml index af8fc0bc14f..96c847e4704 100644 --- a/app/views/stats/index.html.haml +++ b/app/views/stats/index.html.haml @@ -27,8 +27,8 @@ %label.fr-label{ for: "segmented-procedures-2" } Cumul - .chart-container - .chart.monthly-procedures-chart{ data: { 'chartkick-target': 'chart' }} + .fr-mt-4w + .chart.monthly-procedures-chart{ data: { 'chartkick-target': 'chart' } } = column_chart @procedures_in_the_last_4_months, library: Chartkick.options[:default_library_config] .chart.cumulative-procedures-chart.hidden{ data: { 'chartkick-target': 'chart' } } = area_chart @procedures_cumulative, library: Chartkick.options[:default_library_config] @@ -55,8 +55,8 @@ Cumul - .chart-container - .chart.monthly-dossiers-chart{ data: { 'chartkick-target': 'chart' }} + .fr-mt-4w + .chart.monthly-dossiers-chart{ data: { 'chartkick-target': 'chart' } } = column_chart @dossiers_in_the_last_4_months, library: Chartkick.options[:default_library_config] .chart.cumulative-dossiers-chart.hidden{ data: { 'chartkick-target': 'chart' } } = area_chart @dossiers_cumulative, library: Chartkick.options[:default_library_config] @@ -65,7 +65,7 @@ .fr-callout %h2.fr-callout__title Répartition des dossiers - .chart-container + .fr-mt-4w .chart = pie_chart @dossiers_states_for_pie, library: Chartkick.options[:default_library_config], colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] diff --git a/app/views/support/admin.html.haml b/app/views/support/admin.html.haml index dff48a0cc92..bfc845b325b 100644 --- a/app/views/support/admin.html.haml +++ b/app/views/support/admin.html.haml @@ -2,7 +2,7 @@ #contact-form .container - %h1.new-h1 + %h1 = t('.contact_team') .description From d1e983ed97ff31310d7065ef2c27203ff43e2e68 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 17 Jun 2024 13:25:28 +0200 Subject: [PATCH 052/111] fix(EmailCheckerController): with partial email, should not raise error --- app/lib/email_checker.rb | 2 ++ spec/controllers/email_checker_controller_spec.rb | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb index c2cbe353699..e2d2d522243 100644 --- a/app/lib/email_checker.rb +++ b/app/lib/email_checker.rb @@ -627,6 +627,8 @@ def check(email:) return { success: true } if similar_domains.empty? { success: true, email_suggestions: email_suggestions(parsed_email:, similar_domains:) } + rescue Mail::Field::IncompleteParseError + return { success: false } end private diff --git a/spec/controllers/email_checker_controller_spec.rb b/spec/controllers/email_checker_controller_spec.rb index 4572c2cd4a8..685bd5de900 100644 --- a/spec/controllers/email_checker_controller_spec.rb +++ b/spec/controllers/email_checker_controller_spec.rb @@ -35,5 +35,13 @@ expect(body).to eq({ success: false }) end end + + context 'incomplete' do + let(:params) { { email: 'bikram.subedi81@' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end end end From 8cbf4753ff40840bc2b3f37714300bd47fffbe25 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 17 Jun 2024 16:40:31 +0200 Subject: [PATCH 053/111] bug(ineligibilite_rules): caching champs.visible without re-validation afterward means we can skip conditions --- .../users/dossier_ineligibilite_spec.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/spec/system/users/dossier_ineligibilite_spec.rb b/spec/system/users/dossier_ineligibilite_spec.rb index 5bbb25c7580..9b9fa9de6dc 100644 --- a/spec/system/users/dossier_ineligibilite_spec.rb +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -179,4 +179,27 @@ wait_until { dossier.reload.en_construction? == true } end end + + describe 'ineligibilite_rules does not mess with champs.visible' do + let(:types_de_champ_public) do + [ + { type: :yes_no, libelle: 'l1', stable_id: 1 }, + { type: :yes_no, libelle: 'l2', stable_id: 2, condition: ds_eq(champ_value(1), constant(false)) } + ] + end + let(:ineligibilite_rules) do + ds_eq(champ_value(2), constant(false)) + end + + scenario 'ineligibilite rules without validation on champ ensure to re-process cached champs.visible' do + visit brouillon_dossier_path(dossier) + 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") + + within "#champ-1" do + find("label", text: "Non").click + end + expect(page).to have_selector("#champ-2", visible: true) + end + end end From 31bf30830dd6eb8c0c8d6a634da1eadfcfc1c865 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 17 Jun 2024 14:46:49 +0200 Subject: [PATCH 054/111] Place the menu in the