diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e97ca81..89d52a61d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,8 @@ jobs: - name: Install build dependancies # - fonts pickable by ImageMagick # - rust for YJIT support - run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server + # - poppler-utils for pdf previews + run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server poppler-utils - name: Setup the app runtime and dependencies uses: ./.github/actions/ci-setup-rails diff --git a/Gemfile b/Gemfile index 8d78db84d..9a9ff1600 100644 --- a/Gemfile +++ b/Gemfile @@ -95,6 +95,7 @@ gem 'sidekiq' gem 'sidekiq-cron' gem 'skylight' gem 'spreadsheet_architect' +gem 'string-similarity' gem 'strong_migrations' # lint database migrations gem 'sys-proctable' gem 'turbo-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 51281c712..602d74ace 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,47 +12,47 @@ GEM aasm (5.5.0) concurrent-ruby (~> 1.0) acsv (0.0.1) - actioncable (7.0.8.3) - actionpack (= 7.0.8.3) - activesupport (= 7.0.8.3) + actioncable (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.3) - actionpack (= 7.0.8.3) - activejob (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionmailbox (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.3) - actionpack (= 7.0.8.3) - actionview (= 7.0.8.3) - activejob (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionmailer (7.0.8.4) + actionpack (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.3) - actionview (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionpack (7.0.8.4) + actionview (= 7.0.8.4) + activesupport (= 7.0.8.4) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.3) - actionpack (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + actiontext (7.0.8.4) + actionpack (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.3) - activesupport (= 7.0.8.3) + actionview (7.0.8.4) + activesupport (= 7.0.8.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -67,26 +67,26 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (7.0.8.3) - activesupport (= 7.0.8.3) + activejob (7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.3.6) - activemodel (7.0.8.3) - activesupport (= 7.0.8.3) - activerecord (7.0.8.3) - activemodel (= 7.0.8.3) - activesupport (= 7.0.8.3) - activestorage (7.0.8.3) - actionpack (= 7.0.8.3) - activejob (= 7.0.8.3) - activerecord (= 7.0.8.3) - activesupport (= 7.0.8.3) + activemodel (7.0.8.4) + activesupport (= 7.0.8.4) + activerecord (7.0.8.4) + activemodel (= 7.0.8.4) + activesupport (= 7.0.8.4) + activestorage (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activesupport (= 7.0.8.4) marcel (~> 1.0) mini_mime (>= 1.1.0) activestorage-openstack (1.6.0) fog-openstack (>= 1.0.9) marcel rails (>= 5.2.2) - activesupport (7.0.8.3) + activesupport (7.0.8.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -174,7 +174,7 @@ GEM clamav-client (3.2.0) coercible (1.0.0) descendants_tracker (~> 0.0.1) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.1) connection_pool (2.4.1) content_disposition (1.0.0) crack (1.0.0) @@ -438,15 +438,15 @@ GEM rake mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.6) - minitest (5.23.0) + mini_portile2 (2.8.7) + minitest (5.23.1) msgpack (1.7.2) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri - net-imap (0.4.11) + net-imap (0.4.12) date net-protocol net-pop (0.1.2) @@ -531,20 +531,20 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (7.0.8.3) - actioncable (= 7.0.8.3) - actionmailbox (= 7.0.8.3) - actionmailer (= 7.0.8.3) - actionpack (= 7.0.8.3) - actiontext (= 7.0.8.3) - actionview (= 7.0.8.3) - activejob (= 7.0.8.3) - activemodel (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + rails (7.0.8.4) + actioncable (= 7.0.8.4) + actionmailbox (= 7.0.8.4) + actionmailer (= 7.0.8.4) + actionpack (= 7.0.8.4) + actiontext (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activemodel (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) bundler (>= 1.15.0) - railties (= 7.0.8.3) + railties (= 7.0.8.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -567,9 +567,9 @@ GEM rails-pg-extras (5.3.1) rails ruby-pg-extras (= 5.3.1) - railties (7.0.8.3) - actionpack (= 7.0.8.3) - activesupport (= 7.0.8.3) + railties (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) method_source rake (>= 12.2) thor (~> 1.0) @@ -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) @@ -872,7 +873,7 @@ GEM anyway_config (>= 1.3, < 3) sidekiq yabeda (~> 0.6) - zeitwerk (2.6.14) + zeitwerk (2.6.15) zip_tricks (5.6.0) zipline (1.5.0) actionpack (>= 6.0, < 8.0) @@ -1013,6 +1014,7 @@ DEPENDENCIES spring spring-commands-rspec stackprof + string-similarity strong_migrations sys-proctable timecop diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss index 055e9b4f9..59f8bf6b9 100644 --- a/app/assets/stylesheets/conditions_component.scss +++ b/app/assets/stylesheets/conditions_component.scss @@ -57,5 +57,14 @@ form.form > .conditionnel { select.alert { border-color: $dark-red; } + + &:first-child { + padding-left: 0; + } + + &:last-child { + text-align: right; + padding-right: 0; + } } } diff --git a/app/components/conditions/conditions_component.rb b/app/components/conditions/conditions_component.rb index 01f081a85..8817a1336 100644 --- a/app/components/conditions/conditions_component.rb +++ b/app/components/conditions/conditions_component.rb @@ -61,7 +61,7 @@ class Conditions::ConditionsComponent < ApplicationComponent def available_targets_for_select @source_tdcs - .filter { |tdc| ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(tdc.type_champ) } + .filter(&:conditionable?) .map { |tdc| [tdc.libelle, champ_value(tdc.stable_id).to_json] } end diff --git a/app/components/conditions/ineligibilite_rules_component.rb b/app/components/conditions/ineligibilite_rules_component.rb new file mode 100644 index 000000000..a12ab262e --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component.rb @@ -0,0 +1,34 @@ +class Conditions::IneligibiliteRulesComponent < Conditions::ConditionsComponent + include Logic + + def initialize(draft_revision:) + @draft_revision = draft_revision + @published_revision = draft_revision.procedure.published_revision + @condition = draft_revision.ineligibilite_rules + @source_tdcs = draft_revision.types_de_champ_for(scope: :public) + end + + def pending_changes? + return false if !@published_revision + + !@published_revision.compare_ineligibilite_rules(@draft_revision).empty? + end + + private + + def input_prefix + 'procedure_revision[condition_form]' + end + + def input_id_for(name, row_index) + "#{@draft_revision.id}-#{name}-#{row_index}" + end + + def delete_condition_path(row_index) + delete_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id, row_index:) + end + + def add_condition_path + add_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id) + end +end diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..b646c3019 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml @@ -0,0 +1,6 @@ +--- +fr: + display_if: Bloquer si + select: Sélectionner + add_condition: Ajouter une règle d’inéligibilité + remove_a_row: Supprimer une règle diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..313618167 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml @@ -0,0 +1,49 @@ +%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) + .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 + - 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 + %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 + + .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 fca7fab45..5f5bb8980 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?, to: :@dossier + def initialize(dossier:, annotation:) @dossier = dossier @annotation = annotation @@ -14,20 +16,29 @@ class Dossiers::EditFooterComponent < ApplicationComponent @annotation.present? end + def disabled_submit_buttons_options + { + class: 'fr-text--sm fr-mb-0 fr-mr-2w', + data: { 'fr-opened': "true" }, + aria: { controls: 'modal-eligibilite-rules-dialog' } + } + end + def submit_draft_button_options { class: 'fr-btn fr-btn--sm', - disabled: !owner?, + disabled: !owner? || !can_passer_en_construction?, 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: !can_passer_en_construction?, 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 diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml index 098e6ec0b..b6de7d121 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml @@ -2,5 +2,6 @@ en: submit: Submit the file submit_changes: Submit file changes + submit_disabled: File submission disabled submitting: Submitting… invite_notice: You are invited to make amendments to this file but only the owner themselves can submit it. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml index 33937aed6..8ffd062db 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml @@ -2,5 +2,6 @@ fr: submit: Déposer le dossier submit_changes: Déposer les modifications + submit_disabled: Pourquoi je ne peux pas déposer mon dossier ? submitting: Envoi en cours… invite_notice: En tant qu’invité, vous pouvez remplir ce formulaire – mais le titulaire du dossier doit le déposer lui-même. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml index 77540bd16..2f0f59b2b 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml @@ -3,8 +3,13 @@ = render Dossiers::AutosaveFooterComponent.new(dossier: @dossier, annotation: annotation?) - if !annotation? && @dossier.can_transition_to_en_construction? + - 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 - - elsif @dossier.forked_with_changes? + + - if @dossier.forked_with_changes? + - 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/errors_full_messages_component.rb b/app/components/dossiers/errors_full_messages_component.rb index fd8bafd94..207170e8c 100644 --- a/app/components/dossiers/errors_full_messages_component.rb +++ b/app/components/dossiers/errors_full_messages_component.rb @@ -3,17 +3,15 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent ErrorDescriptor = Data.define(:anchor, :label, :error_message) - def initialize(dossier:, errors:) + def initialize(dossier:) @dossier = dossier - @errors = errors end def dedup_and_partitioned_errors - formated_errors = @errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that + @dossier.errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that .to_a # but enum.to_a gives back an array .uniq { |error| [error.inner_error.base] } # dedup cumulated errors from dossier.champs, dossier.champs_public, dossier.champs_private which run the validator one time per association .map { |error| to_error_descriptor(error) } - yield(Array(formated_errors[0..2]), Array(formated_errors[3..])) end def to_error_descriptor(error) @@ -27,6 +25,6 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent end def render? - !@errors.empty? + !@dossier.errors.empty? end end diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml index 3fab8164d..0a595e80a 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml @@ -5,4 +5,3 @@ en: Your file has 1 error. Fix-it to continue : other: | Your file has %{count} errors. Fix-them to continue : - see_more: Show all errors diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml index 1fd0e7f8c..3d94f636f 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml @@ -5,4 +5,3 @@ fr: Votre dossier contient 1 champ en erreur. Corrigez-la pour poursuivre : other: | Votre dossier contient %{count} champs en erreurs. Corrigez-les pour poursuivre : - see_more: Afficher toutes les erreurs diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml index 58d76cb56..ada4150b5 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml @@ -1,15 +1,4 @@ .fr-alert.fr-alert--error.fr-mb-3w{ role: "alertdialog" } - - dedup_and_partitioned_errors do |head, tail| - %p#sumup-errors= t('.sumup_html', count: head.size + tail.size, url: head.first.anchor) - %ul.fr-mb-0#head-errors - - head.each do |error_descriptor| - %li - = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' - = error_descriptor.error_message - - if tail.size > 0 - %button{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline" }= t('.see_more') - %ul#tail-errors.fr-collapse.fr-mt-0 - - tail.each do |error_descriptor| - %li - = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' - = "(#{error_descriptor.error_message})" + - if dedup_and_partitioned_errors.size > 0 + %p#sumup-errors= t('.sumup_html', count: dedup_and_partitioned_errors.size, url: dedup_and_partitioned_errors.first.anchor) + = render ExpandableErrorList.new(errors: dedup_and_partitioned_errors) diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index fbb499483..fd29214f2 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -15,12 +15,12 @@ = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = t(".everything_#{format}_html") - - if export_templates.present? - - export_templates.each do |export_template| + - if @procedure.feature_enabled?(:export_template) + - if export_templates.present? + - export_templates.each do |export_template| + - menu.with_item do + = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do + = "Exporter à partir du modèle #{export_template.name}" - menu.with_item do - = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do - = "Exporter à partir du modèle #{export_template.name}" - - if feature_enabled?(:export_template) - - menu.with_item do - = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do - Ajouter un modèle d'export + = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do + Ajouter un modèle d'export diff --git a/app/components/dossiers/export_link_component.rb b/app/components/dossiers/export_link_component.rb index 0fe2967aa..647d08a4b 100644 --- a/app/components/dossiers/export_link_component.rb +++ b/app/components/dossiers/export_link_component.rb @@ -11,9 +11,10 @@ class Dossiers::ExportLinkComponent < ApplicationComponent @export_url = export_url end - def download_export_path(export_format:, statut:, no_progress_notification: nil) + def download_export_path(export_format:, statut:, export_template_id: nil, no_progress_notification: nil) @export_url.call(@procedure, export_format: export_format, + export_template_id:, statut: statut, no_progress_notification: no_progress_notification) end diff --git a/app/components/dossiers/export_link_component/export_link_component.html.haml b/app/components/dossiers/export_link_component/export_link_component.html.haml index 0ed8d34a2..32d631e33 100644 --- a/app/components/dossiers/export_link_component/export_link_component.html.haml +++ b/app/components/dossiers/export_link_component/export_link_component.html.haml @@ -7,6 +7,9 @@ = export_title(export) %span.fr-text-mention--grey.fr-mb-1w = time_info(export) + - if export.export_template + %span.fr-tag.fr-tag--sm.fr-ml-1w + = export.export_template.name .fr-ml-auto = badge(export) @@ -14,4 +17,4 @@ = export_button(export) - if export.failed? - = button_to refresh_button_options(export)[:title], download_export_path(export_format: export.format, statut: export.statut), refresh_button_options(export) + = button_to refresh_button_options(export)[:title], download_export_path(export_template_id: export.export_template&.id, export_format: export.format, statut: export.statut), refresh_button_options(export) diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component.rb b/app/components/dossiers/invalid_ineligibilite_rules_component.rb new file mode 100644 index 000000000..526bdbc94 --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component.rb @@ -0,0 +1,16 @@ +class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent + delegate :can_passer_en_construction?, to: :@dossier + + def initialize(dossier:) + @dossier = dossier + @revision = dossier.revision + end + + def render? + !can_passer_en_construction? + end + + def error_message + @dossier.revision.ineligibilite_message + end +end diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml new file mode 100644 index 000000000..1a377763c --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml @@ -0,0 +1,6 @@ +fr: + modal: + title: "Your file does not match submission criteria" + close: "Close" + close_alt: "Close this modal" + body: "The procedure « %{procedure_libelle} » have submission criteria, unfortunately your file does not match them. You can not submit your file" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..d191f03d4 --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml @@ -0,0 +1,5 @@ +fr: + modal: + title: "Vous ne pouvez pas déposer votre dossier" + close: "Fermer" + close_alt: "Fermer la fenêtre modale" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..dd39925cd --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml @@ -0,0 +1,16 @@ +%div{ id: dom_id(@dossier, :ineligibilite_rules_broken), data: { controller: 'ineligibilite-rules-match', turbo_force: :server } } + %button.fr-sr-only{ aria: {controls: 'modal-eligibilite-rules-dialog' }, data: {'fr-opened': "false" } } + show modal + + %dialog.fr-modal{ "aria-labelledby" => "fr-modal-title-modal-1", role: "dialog", id: 'modal-eligibilite-rules-dialog', data: { 'ineligibilite-rules-match-target' => 'dialog' } } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn--close.fr-btn{ aria: { controls: 'modal-eligibilite-rules-dialog' }, title: t('.modal.close_alt') }= t('.modal.close') + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg> + = t('.modal.title') + %p= error_message diff --git a/app/components/dsfr/input_component.rb b/app/components/dsfr/input_component.rb index 3ee07149b..367ed74b0 100644 --- a/app/components/dsfr/input_component.rb +++ b/app/components/dsfr/input_component.rb @@ -32,7 +32,7 @@ class Dsfr::InputComponent < ApplicationComponent }.merge(input_group_error_class_names)) } if email? - opts[:data] = { controller: 'email-input' } + opts[:data] = { controller: 'email-input', email_input_url_value: show_email_suggestions_path } end opts end diff --git a/app/components/dsfr/toggle_component.rb b/app/components/dsfr/toggle_component.rb index 20c328e9b..f0a4114b0 100644 --- a/app/components/dsfr/toggle_component.rb +++ b/app/components/dsfr/toggle_component.rb @@ -1,5 +1,5 @@ class Dsfr::ToggleComponent < ApplicationComponent - def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil) + def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil, extra_class_names: nil) @form = form @target = target @title = title @@ -7,7 +7,8 @@ class Dsfr::ToggleComponent < ApplicationComponent @disabled = disabled @toggle_labels = toggle_labels @opt = opt + @extra_class_names = extra_class_names end - attr_reader :toggle_labels + attr_reader :toggle_labels, :extra_class_names end diff --git a/app/components/dsfr/toggle_component/toggle_component.html.haml b/app/components/dsfr/toggle_component/toggle_component.html.haml index 7769a3724..18bde573d 100644 --- a/app/components/dsfr/toggle_component/toggle_component.html.haml +++ b/app/components/dsfr/toggle_component/toggle_component.html.haml @@ -1,4 +1,4 @@ -.fr-toggle.fr-toggle--label-left +%div{ class: "fr-toggle fr-toggle--label-left #{extra_class_names}" } = @form.check_box @target, class: 'fr-toggle__input', disabled: @disabled, data: @opt = @form.label @target, diff --git a/app/components/expandable_error_list.rb b/app/components/expandable_error_list.rb new file mode 100644 index 000000000..43d5c9215 --- /dev/null +++ b/app/components/expandable_error_list.rb @@ -0,0 +1,9 @@ +class ExpandableErrorList < ApplicationComponent + def initialize(errors:) + @errors = errors + end + + def splitted_errors + yield(Array(@errors[0..2]), Array(@errors[3..])) + end +end diff --git a/app/components/expandable_error_list/expandable_error_list.html.en.yml b/app/components/expandable_error_list/expandable_error_list.html.en.yml new file mode 100644 index 000000000..b21ee7d8a --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.en.yml @@ -0,0 +1,3 @@ +--- +en: + see_more: Show all errors diff --git a/app/components/expandable_error_list/expandable_error_list.html.fr.yml b/app/components/expandable_error_list/expandable_error_list.html.fr.yml new file mode 100644 index 000000000..755d13886 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.fr.yml @@ -0,0 +1,3 @@ +--- +fr: + see_more: Afficher toutes les erreurs diff --git a/app/components/expandable_error_list/expandable_error_list.html.haml b/app/components/expandable_error_list/expandable_error_list.html.haml new file mode 100644 index 000000000..1ab5221e5 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.haml @@ -0,0 +1,14 @@ +- splitted_errors do |head, tail| + %ul#head-errors.fr-mb-0 + - head.each do |error_descriptor| + %li + = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' + = error_descriptor.error_message + + - if tail.size > 0 + %button.fr-mt-0.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "" }= t('see_more') + %ul#tail-errors.fr-collapse.fr-mt-0 + - tail.each do |error_descriptor| + %li + = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' + = error_descriptor.error_message diff --git a/app/components/procedure/card/champs_component.rb b/app/components/procedure/card/champs_component.rb index d45b2f666..38604c831 100644 --- a/app/components/procedure/card/champs_component.rb +++ b/app/components/procedure/card/champs_component.rb @@ -7,9 +7,6 @@ class Procedure::Card::ChampsComponent < ApplicationComponent private def error_messages - [ - @procedure.errors.messages_for(:draft_types_de_champ_public), - @procedure.errors.messages_for(:draft_revision) - ].flatten.to_sentence + @procedure.errors.messages_for(:draft_types_de_champ_public).to_sentence end end diff --git a/app/components/procedure/card/ineligibilite_dossier_component.rb b/app/components/procedure/card/ineligibilite_dossier_component.rb new file mode 100644 index 000000000..b1d371708 --- /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? && @procedure.draft_revision.ineligibilite_enabled + end + + def error? + !@procedure.draft_revision.validate(:ineligibilite_rules_editor) + end + + def completed? + @procedure.draft_revision.ineligibilite_enabled + end +end diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml new file mode 100644 index 000000000..6e78d7da6 --- /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: Désactivé + ready: À configurer + completed: Activé + 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 new file mode 100644 index 000000000..aeced88e6 --- /dev/null +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml @@ -0,0 +1,13 @@ +.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= t('.state.pending') + - elsif error? + %p.fr-badge.fr-badge--error À modifier + - 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/components/procedure/card/modifications_component.rb b/app/components/procedure/card/modifications_component.rb deleted file mode 100644 index 35b90a624..000000000 --- a/app/components/procedure/card/modifications_component.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Procedure::Card::ModificationsComponent < ApplicationComponent - def initialize(procedure:) - @procedure = procedure - end - - def render? - @procedure.revised? - end -end diff --git a/app/components/procedure/card/modifications_component/modifications_component.fr.yml b/app/components/procedure/card/modifications_component/modifications_component.fr.yml deleted file mode 100644 index 676fc64cd..000000000 --- a/app/components/procedure/card/modifications_component/modifications_component.fr.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -fr: - title: - one: Modification du formulaire - other: Modifications du formulaire diff --git a/app/components/procedure/card/modifications_component/modifications_component.html.haml b/app/components/procedure/card/modifications_component/modifications_component.html.haml deleted file mode 100644 index 9dc203c17..000000000 --- a/app/components/procedure/card/modifications_component/modifications_component.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.fr-col-6.fr-col-md-4.fr-col-lg-3 - = link_to modifications_admin_procedure_path(@procedure), id: 'modifications', class: 'fr-tile fr-enlarge-link' do - .fr-tile__body.flex.column.align-center.justify-between - %p.fr-badge.fr-badge--success Activée - %div - .line-count.fr-my-1w - %p.fr-tag= @procedure.revisions_count - %h3.fr-h6 - = t('.title', count: @procedure.revisions_count) - - %p.fr-tile-subtitle Historique des modifications apportées au formulaire - %p.fr-btn.fr-btn--tertiary Voir diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb new file mode 100644 index 000000000..bf41ab3da --- /dev/null +++ b/app/components/procedure/errors_summary.rb @@ -0,0 +1,60 @@ +class Procedure::ErrorsSummary < ApplicationComponent + ErrorDescriptor = Data.define(:anchor, :label, :error_message) + + def initialize(procedure:, validation_context:) + @procedure = procedure + @validation_context = validation_context + end + + def title + case @validation_context + when :types_de_champ_private_editor + "Les annotations privées contiennent des erreurs" + when :types_de_champ_public_editor + "Les champs formulaire contiennent des erreurs" + when :publication + if @procedure.publiee? + "Des problèmes empêchent la publication des modifications" + else + "Des problèmes empêchent la publication de la démarche" + end + end + end + + def invalid? + @procedure.validate(@validation_context) + @procedure.errors.present? + end + + def errors + @procedure.errors.map { to_error_descriptor(_1) } + end + + 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)) + when :draft_types_de_champ_private + tdc = error.options[:type_de_champ] + annotations_admin_procedure_path(@procedure, anchor: dom_id(tdc.stable_self, :editor_error)) + when :attestation_template + edit_admin_procedure_attestation_template_path(@procedure) + when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail + klass = "Mails::#{error.attribute.to_s.classify}".constantize + edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) + end + end + + 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 new file mode 100644 index 000000000..e5042916e --- /dev/null +++ b/app/components/procedure/errors_summary/errors_summary.html.haml @@ -0,0 +1,5 @@ +#errors-summary + - if invalid? + = render Dsfr::AlertComponent.new(state: :error, title: , extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + = render ExpandableErrorList.new(errors:) diff --git a/app/components/procedure/pending_republish_component.rb b/app/components/procedure/pending_republish_component.rb new file mode 100644 index 000000000..181eb6f5c --- /dev/null +++ b/app/components/procedure/pending_republish_component.rb @@ -0,0 +1,10 @@ +class Procedure::PendingRepublishComponent < ApplicationComponent + def initialize(procedure:, render_if:) + @procedure = procedure + @render_if = render_if + end + + def render? + @render_if + end +end diff --git a/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml b/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml new file mode 100644 index 000000000..eb941cdba --- /dev/null +++ b/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml @@ -0,0 +1,4 @@ +--- +fr: + pending_republish_html: | + Ces modifications ne seront appliquées qu'à la prochaine publication. Vous pouvez vérifier puis publier les modifications sur l'écran de gestion de la démarche \ No newline at end of file diff --git a/app/components/procedure/pending_republish_component/pending_republish_component.html.haml b/app/components/procedure/pending_republish_component/pending_republish_component.html.haml new file mode 100644 index 000000000..eab7f62fc --- /dev/null +++ b/app/components/procedure/pending_republish_component/pending_republish_component.html.haml @@ -0,0 +1,3 @@ += render Dsfr::AlertComponent.new(state: :warning) do |c| + - c.with_body do + = t('.pending_republish_html', href: admin_procedure_path(@procedure.id)) diff --git a/app/components/procedure/publication_warning_component.rb b/app/components/procedure/publication_warning_component.rb deleted file mode 100644 index 7af808c3d..000000000 --- a/app/components/procedure/publication_warning_component.rb +++ /dev/null @@ -1,39 +0,0 @@ -class Procedure::PublicationWarningComponent < ApplicationComponent - def initialize(procedure:) - @procedure = procedure - end - - def title - return "Des problèmes empêchent la publication des modifications" if @procedure.publiee? - - "Des problèmes empêchent la publication de la démarche" - end - - private - - def render? - @procedure.validate(:publication) - @procedure.errors.delete(:path) - @procedure.errors.any? - end - - def error_messages - @procedure.errors - .to_hash(full_messages: true) - .map do |attribute, messages| - [messages, error_correction_page(attribute)] - end - end - - def error_correction_page(attribute) - case attribute - when :draft_revision - champs_admin_procedure_path(@procedure) - when :attestation_template - edit_admin_procedure_attestation_template_path(@procedure) - when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail - klass = "Mails::#{attribute.to_s.classify}".constantize - edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) - end - end -end diff --git a/app/components/procedure/publication_warning_component/publication_warning_component.html.haml b/app/components/procedure/publication_warning_component/publication_warning_component.html.haml deleted file mode 100644 index 7d8ff43b7..000000000 --- a/app/components/procedure/publication_warning_component/publication_warning_component.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -= render Dsfr::AlertComponent.new(state: :warning, title:) do |c| - - c.with_body do - - error_messages.each do |(messages, path)| - %p.mt-2 - = messages.to_sentence - - if path.present? - = "(#{link_to 'corriger', path, class: 'fr-link'})" diff --git a/app/components/procedure/revision_changes_component.rb b/app/components/procedure/revision_changes_component.rb index e266f13e2..af786e6bc 100644 --- a/app/components/procedure/revision_changes_component.rb +++ b/app/components/procedure/revision_changes_component.rb @@ -1,9 +1,13 @@ class Procedure::RevisionChangesComponent < ApplicationComponent - def initialize(changes:, previous_revision:) - @changes = changes + def initialize(new_revision:, previous_revision:) @previous_revision = previous_revision - @public_move_changes, @private_move_changes = changes.filter { _1.op == :move }.partition { !_1.private? } - @delete_champ_warning = !total_dossiers.zero? && !@changes.all?(&:can_rebase?) + @new_revision = new_revision + + @tdc_changes = previous_revision.compare_types_de_champ(new_revision) + @public_move_changes, @private_move_changes = @tdc_changes.filter { _1.op == :move }.partition { !_1.private? } + @delete_champ_warning = !total_dossiers.zero? && !@tdc_changes.all?(&:can_rebase?) + + @ineligibilite_rules_changes = previous_revision.compare_ineligibilite_rules(new_revision) end private diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml index 10009ce1e..3228c76a8 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml @@ -80,3 +80,10 @@ fr: update_expression_reguliere_exemple_text: L’exemple d’expression régulière de l’annotation privée « %{label} » a été modifiée. Le nouvel exemple est « %{to} ». remove_expression_reguliere_error_message: Le message d’erreur de l’expression régulière de l’annotation privée « %{label} » a été supprimé. update_expression_reguliere_error_message: Le message d’erreur de l’expression régulière de l’annotation privée « %{label} » a été modifiée. Le nouveau message est « %{to} ». + ineligibilite_rules: + add: La condition d’inéligibilité « %{new_condition} » a été ajoutée. + remove: La condition d’inéligibilité « %{previous_condition} » a été supprimée + update: La conditon d’inéligibilité « %{previous_condition} » a été changée pour « %{new_condition} » + enabled: "L’inéligibilité des dossiers a été activée" + disabled: "L’inéligibilité des dossiers a été désactivée" + message_updated: "Le message d’inéligibilité a été changé pour « %{ineligibilite_message} »" \ No newline at end of file diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml index ba19a0dd9..ed7f550c8 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml @@ -2,7 +2,7 @@ - list.with_empty do = t('.no_changes') - - @changes.each do |change| + - @tdc_changes.each do |change| - prefix = change.private? ? 'private' : 'public' - case change.op - when :add @@ -176,3 +176,7 @@ - list.with_item do .fr-alert.fr-alert--warning.fr-mt-1v = t(".invalid_routing_rules_alert") + + - @ineligibilite_rules_changes.each do |change| + - list.with_item do + = t(".ineligibilite_rules.#{change.op}", **change.i18n_params) diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 980bd064c..d28769213 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -1,5 +1,5 @@ %li.type-de-champ.flex.column.justify-start.fr-mb-5v{ html_options } - .type-de-champ-container + .type-de-champ-container{ id: dom_id(type_de_champ.stable_self, :editor_error) } - if @errors.present? .types-de-champ-errors = @errors @@ -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/components/types_de_champ_editor/editor_component.rb b/app/components/types_de_champ_editor/editor_component.rb index 0ea2c76cb..288a2c668 100644 --- a/app/components/types_de_champ_editor/editor_component.rb +++ b/app/components/types_de_champ_editor/editor_component.rb @@ -17,4 +17,12 @@ class TypesDeChampEditor::EditorComponent < ApplicationComponent @revision.revision_types_de_champ_public end end + + def validation_context + if annotations? + :types_de_champ_private_editor + else + :types_de_champ_public_editor + end + end end diff --git a/app/components/types_de_champ_editor/editor_component/editor_component.html.haml b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml index 71df85e46..d161d0ba2 100644 --- a/app/components/types_de_champ_editor/editor_component/editor_component.html.haml +++ b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml @@ -1,6 +1,6 @@ .fr-pb-12w{ 'data-turbo': 'true', id: dom_id(@revision, :types_de_champ_editor) } .types-de-champ-editor.editor-root - = render TypesDeChampEditor::ErrorsSummary.new(revision: @revision) + = render Procedure::ErrorsSummary.new(procedure: @revision.procedure, validation_context:) = render TypesDeChampEditor::BlockComponent.new(block: @revision, coordinates: coordinates) #empty-coordinates{ hidden: coordinates.present? } = render TypesDeChampEditor::AddChampButtonComponent.new(revision: @revision, is_annotation: annotations?) diff --git a/app/components/types_de_champ_editor/errors_summary.rb b/app/components/types_de_champ_editor/errors_summary.rb deleted file mode 100644 index b5367eaf1..000000000 --- a/app/components/types_de_champ_editor/errors_summary.rb +++ /dev/null @@ -1,38 +0,0 @@ -class TypesDeChampEditor::ErrorsSummary < ApplicationComponent - def initialize(revision:) - @revision = revision - end - - def invalid? - @revision.invalid? - end - - def condition_errors? - @revision.errors.include?(:condition) - end - - def header_section_errors? - @revision.errors.include?(:header_section) - end - - def expression_reguliere_errors? - @revision.errors.include?(:expression_reguliere) - end - - private - - def errors_for(key) - @revision.errors.filter { _1.attribute == key } - end - - def error_message_for(key) - errors_for(key) - .map { |error| error.options[:type_de_champ] } - .map { |tdc| tag.li(tdc_anchor(tdc, key)) } - .then { |lis| tag.ul(lis.reduce(&:+)) } - end - - def tdc_anchor(tdc, key) - tag.a(tdc.libelle, href: champs_admin_procedure_path(@revision.procedure_id, anchor: dom_id(tdc.stable_self, key)), data: { turbo: false }) - end -end diff --git a/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml b/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml deleted file mode 100644 index 08d594931..000000000 --- a/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml +++ /dev/null @@ -1,12 +0,0 @@ -fr: - fix_conditional: - one: 'La logique conditionnelle du champ suivant est invalide, veuillez la corriger :' - other: 'La logique conditionnelle des champs suivants sont invalides, veuillez les corriger :' - - fix_header_section: - one: 'Le titre de section suivant est invalide, veuillez le corriger :' - other: 'Les titres de section suivants sont invalides, veuillez les corriger :' - - fix_expressions_regulieres: - one: "L'expression régulière suivante est invalide, veuillez la corriger :" - other: 'Les expressions régulières suivantes sont invalides, veuillez les corriger :' diff --git a/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml b/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml deleted file mode 100644 index 46f599ce5..000000000 --- a/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -#errors-summary - - if invalid? - = render Dsfr::AlertComponent.new(state: :warning, title: "Le formulaire contient des erreurs", extra_class_names: 'fr-mb-2w') do |c| - - c.with_body do - - if condition_errors? - %p= t('.fix_conditional', count: errors_for(:condition).size) - = error_message_for(:condition) - - - if header_section_errors? - %p= t('.fix_header_section', count: errors_for(:header_section).size) - = error_message_for(:header_section) - - - if expression_reguliere_errors? - %p= t('.fix_expressions_regulieres', count: errors_for(:expression_reguliere).size) - = error_message_for(:expression_reguliere) diff --git a/app/components/types_de_champ_editor/header_section_component.rb b/app/components/types_de_champ_editor/header_section_component.rb index 80271b8e1..39f372dd8 100644 --- a/app/components/types_de_champ_editor/header_section_component.rb +++ b/app/components/types_de_champ_editor/header_section_component.rb @@ -31,7 +31,7 @@ class TypesDeChampEditor::HeaderSectionComponent < ApplicationComponent end def errors? - !errors.empty? + errors.present? end def to_html_list(messages) diff --git a/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml b/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml index b40967b26..022c2830b 100644 --- a/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml +++ b/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml @@ -1,5 +1,5 @@ %div{ id: dom_id(@tdc.stable_self, :header_section) } - if errors? - .errors-summary= to_html_list(errors) + .errors-summary= errors = @form.label :header_section_level, "Niveau du titre", for: dom_id(@tdc, :header_section_level) = @form.select :header_section_level, header_section_options_for_select, {}, id: dom_id(@tdc, :header_section_level), class: 'fr-select width-33' diff --git a/app/controllers/administrateurs/ineligibilite_rules_controller.rb b/app/controllers/administrateurs/ineligibilite_rules_controller.rb new file mode 100644 index 000000000..41d6865f9 --- /dev/null +++ b/app/controllers/administrateurs/ineligibilite_rules_controller.rb @@ -0,0 +1,74 @@ +module Administrateurs + class IneligibiliteRulesController < AdministrateurController + before_action :retrieve_procedure + + def edit + end + + def change + if draft_revision.update(procedure_revision_params) + redirect_to edit_admin_procedure_ineligibilite_rules_path(@procedure) + else + flash[:alert] = draft_revision.errors.full_messages + render :edit + end + end + + def add_row + condition = Logic.add_empty_condition_to(draft_revision.ineligibilite_rules) + draft_revision.update!(ineligibilite_rules: condition) + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def delete_row + condition = condition_form.delete_row(row_index).to_condition + draft_revision.update!(ineligibilite_rules: condition) + + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def update + condition = condition_form.to_condition + draft_revision.update!(ineligibilite_rules: condition) + + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def change_targeted_champ + condition = condition_form.change_champ(row_index).to_condition + draft_revision.update!(ineligibilite_rules: condition) + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + private + + def build_ineligibilite_rules_component + Conditions::IneligibiliteRulesComponent.new(draft_revision: draft_revision) + end + + def draft_revision + @procedure.draft_revision + end + + def condition_form + ConditionForm.new(ineligibilite_rules_params.merge(source_tdcs: draft_revision.types_de_champ_for(scope: :public))) + end + + def ineligibilite_rules_params + params + .require(:procedure_revision) + .require(:condition_form) + .permit(:top_operator_name, rows: [:targeted_champ, :operator_name, :value]) + end + + def row_index + params[:row_index].to_i + end + + def procedure_revision_params + params + .require(:procedure_revision) + .permit(:ineligibilite_message, :ineligibilite_enabled) + end + end +end diff --git a/app/controllers/api/public/v1/base_controller.rb b/app/controllers/api/public/v1/base_controller.rb index a60f9caeb..7353c83a3 100644 --- a/app/controllers/api/public/v1/base_controller.rb +++ b/app/controllers/api/public/v1/base_controller.rb @@ -1,8 +1,12 @@ -class API::Public::V1::BaseController < APIController +class API::Public::V1::BaseController < ApplicationController skip_forgery_protection before_action :check_content_type_is_json, if: -> { request.post? || request.patch? || request.put? } + before_action do + Current.browser = 'api' + end + protected def render_missing_param(param_name) diff --git a/app/controllers/email_checker_controller.rb b/app/controllers/email_checker_controller.rb new file mode 100644 index 000000000..19cd0493b --- /dev/null +++ b/app/controllers/email_checker_controller.rb @@ -0,0 +1,5 @@ +class EmailCheckerController < ApplicationController + def show + render json: EmailChecker.new.check(email: params[:email]) + end +end diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index acdfd1332..1fac35eae 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -231,9 +231,9 @@ module Users def submit_brouillon @dossier = dossier_with_champs(pj_template: false) - @errors = submit_dossier_and_compute_errors + submit_dossier_and_compute_errors - if @errors.blank? + if @dossier.errors.blank? && @dossier.can_passer_en_construction? @dossier.passer_en_construction! @dossier.process_declarative! @dossier.process_sva_svr! @@ -278,9 +278,9 @@ module Users editing_fork_origin.resolve_pending_correction end - @errors = submit_dossier_and_compute_errors + submit_dossier_and_compute_errors - if @errors.blank? + if @dossier.errors.blank? && @dossier.can_passer_en_construction? editing_fork_origin.merge_fork(@dossier) editing_fork_origin.submit_en_construction! @@ -288,7 +288,6 @@ module Users else respond_to do |format| format.html do - @dossier = editing_fork_origin render :modifier end @@ -303,10 +302,10 @@ module Users def update @dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier @dossier = dossier_with_champs(pj_template: false) - @errors = update_dossier_and_compute_errors - - @dossier.index_search_terms_later if @errors.empty? - + @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 @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) @@ -567,21 +566,14 @@ module Users def submit_dossier_and_compute_errors @dossier.validate(:champs_public_value) - - errors = @dossier.errors - @dossier.check_mandatory_and_visible_champs.each do |error_on_champ| - errors.import(error_on_champ) - end + @dossier.check_mandatory_and_visible_champs if @dossier.editing_fork_origin&.pending_correction? @dossier.editing_fork_origin.validate(:champs_public_value) @dossier.editing_fork_origin.errors.where(:pending_correction).each do |error| - errors.import(error) + @dossier.errors.import(error) end - end - - errors end def ensure_ownership! diff --git a/app/graphql/api/v2/stored_query.rb b/app/graphql/api/v2/stored_query.rb index e2b5c78c0..1c48a5bd1 100644 --- a/app/graphql/api/v2/stored_query.rb +++ b/app/graphql/api/v2/stored_query.rb @@ -819,6 +819,19 @@ class API::V2::StoredQuery } } + mutation dossierSupprimerMessage($input: DossierSupprimerMessageInput!) { + dossierSupprimerMessage(input: $input) { + message { + id + createdAt + discardedAt + } + errors { + message + } + } + } + mutation dossierModifierAnnotationText( $input: DossierModifierAnnotationTextInput! ) { diff --git a/app/graphql/mutations/dossier_passer_en_instruction.rb b/app/graphql/mutations/dossier_passer_en_instruction.rb index 1ece00ede..d9886b724 100644 --- a/app/graphql/mutations/dossier_passer_en_instruction.rb +++ b/app/graphql/mutations/dossier_passer_en_instruction.rb @@ -21,6 +21,9 @@ module Mutations if !dossier.en_construction? return false, { errors: ["Le dossier est déjà #{dossier_display_state(dossier, lower: true)}"] } end + if dossier.blocked_with_pending_correction? + return false, { errors: ["Le dossier est en attente de correction"] } + end dossier_authorized_for?(dossier, instructeur) end end diff --git a/app/graphql/mutations/dossier_supprimer_message.rb b/app/graphql/mutations/dossier_supprimer_message.rb new file mode 100644 index 000000000..bde6de51f --- /dev/null +++ b/app/graphql/mutations/dossier_supprimer_message.rb @@ -0,0 +1,24 @@ +module Mutations + class DossierSupprimerMessage < Mutations::BaseMutation + description "Supprimer un message." + + argument :message_id, ID, required: true, loads: Types::MessageType + argument :instructeur_id, ID, required: true, loads: Types::ProfileType + + field :message, Types::MessageType, null: true + field :errors, [Types::ValidationErrorType], null: true + + def resolve(message:, **args) + message.soft_delete! + + { message: } + end + + def authorized?(message:, instructeur:, **args) + if !message.soft_deletable?(instructeur) + return false, { errors: ["Le message ne peut pas être supprimé"] } + end + dossier_authorized_for?(message.dossier, instructeur) + end + end +end diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 52d58d1ad..c422408fe 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -2192,6 +2192,30 @@ enum DossierState { sans_suite } +""" +Autogenerated input type of DossierSupprimerMessage +""" +input DossierSupprimerMessageInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + instructeurId: ID! + messageId: ID! +} + +""" +Autogenerated return type of DossierSupprimerMessage. +""" +type DossierSupprimerMessagePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + errors: [ValidationError!] + message: Message +} + type DropDownListChampDescriptor implements ChampDescriptor { """ Description des champs d’un bloc répétable. @@ -3084,6 +3108,7 @@ type Message { body: String! correction: Correction createdAt: ISO8601DateTime! + discardedAt: ISO8601DateTime email: String! id: ID! } @@ -3313,6 +3338,16 @@ type Mutation { input: DossierRepasserEnInstructionInput! ): DossierRepasserEnInstructionPayload + """ + Supprimer un message. + """ + dossierSupprimerMessage( + """ + Parameters for DossierSupprimerMessage + """ + input: DossierSupprimerMessageInput! + ): DossierSupprimerMessagePayload + """ Ajouter des instructeurs à un groupe instructeur. """ diff --git a/app/graphql/types/message_type.rb b/app/graphql/types/message_type.rb index 70622647b..788075bb2 100644 --- a/app/graphql/types/message_type.rb +++ b/app/graphql/types/message_type.rb @@ -4,6 +4,7 @@ module Types field :email, String, null: false field :body, String, null: false field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :discarded_at, GraphQL::Types::ISO8601DateTime, null: true field :attachment, Types::File, null: true, deprecation_reason: "Utilisez le champ `attachments` à la place.", extensions: [ { Extensions::Attachment => { attachments: :piece_jointe, as: :single } } ] @@ -19,5 +20,9 @@ module Types def correction Loaders::Association.for(object.class, :dossier_correction).load(object) end + + def self.authorized?(object, context) + context.authorized_demarche?(object.dossier.revision.procedure) + end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ce2f8ac56..b9ad5a92c 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -3,6 +3,7 @@ module Types field :create_direct_upload, mutation: Mutations::CreateDirectUpload field :dossier_envoyer_message, mutation: Mutations::DossierEnvoyerMessage + field :dossier_supprimer_message, mutation: Mutations::DossierSupprimerMessage field :dossier_passer_en_instruction, mutation: Mutations::DossierPasserEnInstruction field :dossier_classer_sans_suite, mutation: Mutations::DossierClasserSansSuite field :dossier_refuser, mutation: Mutations::DossierRefuser diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb new file mode 100644 index 000000000..1f9ddeeab --- /dev/null +++ b/app/helpers/gallery_helper.rb @@ -0,0 +1,27 @@ +module GalleryHelper + def displayable_pdf?(blob) + blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + end + + def displayable_image?(blob) + blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + end + + def preview_url_for(attachment) + attachment.preview(resize_to_limit: [400, 400]).processed.url + rescue StandardError + 'pdf-placeholder.png' + end + + def variant_url_for(attachment) + attachment.variant(resize_to_limit: [400, 400]).processed.url + rescue StandardError + 'apercu-indisponible.png' + end + + def blob_url(attachment) + attachment.blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : attachment.blob.url + rescue StandardError + attachment.blob.url + end +end diff --git a/app/javascript/controllers/clipboard_controller.ts b/app/javascript/controllers/clipboard_controller.ts index d2b4ae1cf..cecdc5d93 100644 --- a/app/javascript/controllers/clipboard_controller.ts +++ b/app/javascript/controllers/clipboard_controller.ts @@ -17,7 +17,11 @@ export class ClipboardController extends Controller { connect(): void { // some extensions or browsers block clipboard if (!navigator.clipboard) { - this.element.classList.add('hidden'); + if (this.hasToHideTarget) { + this.toHideTarget.classList.add('hidden'); + } else { + this.element.classList.add('hidden'); + } } } diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 8eed97fa9..f8442e1d3 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -1,18 +1,43 @@ -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 || + this.inputTarget.value.length < 5 || + !this.inputTarget.value.includes('@') + ) { + 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/app/javascript/controllers/ineligibilite_rules_match_controller.ts b/app/javascript/controllers/ineligibilite_rules_match_controller.ts new file mode 100644 index 000000000..5b47d79b5 --- /dev/null +++ b/app/javascript/controllers/ineligibilite_rules_match_controller.ts @@ -0,0 +1,19 @@ +import { ApplicationController } from './application_controller'; +declare interface modal { + disclose: () => void; +} +declare interface dsfr { + modal: modal; +} +declare const window: Window & + typeof globalThis & { dsfr: (elem: HTMLElement) => dsfr }; + +export class InvalidIneligibiliteRulesController extends ApplicationController { + static targets = ['dialog']; + + declare dialogTarget: HTMLElement; + + connect() { + setTimeout(() => window.dsfr(this.dialogTarget).modal.disclose(), 100); + } +} diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index 8017d2b35..ee914a925 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -10,6 +10,10 @@ class ImageProcessorJob < ApplicationJob # (to avoid modifying the file while it is being scanned). retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + rescue_from ActiveStorage::PreviewError do + retry_or_discard + end + def perform(blob) return if blob.nil? raise FileNotScannedYetError if blob.virus_scanner.pending? @@ -38,6 +42,9 @@ class ImageProcessorJob < ApplicationJob blob.attachments.each do |attachment| next unless attachment&.representable? attachment.representation(resize_to_limit: [400, 400]).processed + if attachment.blob.content_type.in?(RARE_IMAGE_TYPES) + attachment.variant(resize_to_limit: [2000, 2000]).processed + end end end @@ -55,4 +62,14 @@ class ImageProcessorJob < ApplicationJob end end end + + def retry_or_discard + if executions < max_attempts + retry_job wait: 5.minutes + end + end + + def max_attempts + 3 + end end diff --git a/app/lib/active_job/application_log_subscriber.rb b/app/lib/active_job/application_log_subscriber.rb index 23d31f072..a24b293b7 100644 --- a/app/lib/active_job/application_log_subscriber.rb +++ b/app/lib/active_job/application_log_subscriber.rb @@ -33,6 +33,7 @@ class ActiveJob::ApplicationLogSubscriber < ::ActiveJob::LogSubscriber def process_event(event, type) data = extract_metadata(event) data.merge!(extract_exception(event)) + data[:request_id] = Current.request_id if Current.request_id.present? case type when 'enqueue_at' diff --git a/app/lib/balancer_delivery_method.rb b/app/lib/balancer_delivery_method.rb index 2ed33ae91..4ad5d5728 100644 --- a/app/lib/balancer_delivery_method.rb +++ b/app/lib/balancer_delivery_method.rb @@ -45,6 +45,7 @@ class BalancerDeliveryMethod def prevent_delivery?(mail) return false if mail[BYPASS_UNVERIFIED_MAIL_PROTECTION].present? + return false if mail.to.blank? # bcc list user = User.find_by(email: mail.to.first) return user.unverified_email? if user.present? diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb new file mode 100644 index 000000000..c2cbe3536 --- /dev/null +++ b/app/lib/email_checker.rb @@ -0,0 +1,652 @@ +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', + '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 check(email:) + return { success: false } if email.blank? + + 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 } + + 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 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 close_by_distance_of(a, b, distance:) + String::Similarity.levenshtein_distance(a, b) == distance + end + + 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 email_suggestions(parsed_email:, similar_domains:) + similar_domains.map { Mail::Address.new("#{parsed_email.local}@#{_1}").to_s } + end +end diff --git a/app/models/champ.rb b/app/models/champ.rb index 6a05baea3..093829236 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -88,10 +88,6 @@ class Champ < ApplicationRecord parent_id.present? end - def stable_id_with_row - [row_id, stable_id].compact - end - # used for the `required` html attribute # check visibility to avoid hidden required input # which prevent the form from being sent. diff --git a/app/models/concerns/champ_conditional_concern.rb b/app/models/concerns/champ_conditional_concern.rb index 9e6559be9..63001229d 100644 --- a/app/models/concerns/champ_conditional_concern.rb +++ b/app/models/concerns/champ_conditional_concern.rb @@ -21,6 +21,10 @@ module ChampConditionalConcern end end + def reset_visible # recompute after a dossier update + remove_instance_variable :@visible if instance_variable_defined? :@visible + end + private def champs_for_condition diff --git a/app/models/concerns/dossier_rebase_concern.rb b/app/models/concerns/dossier_rebase_concern.rb index dd6395dc2..49807793c 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -22,7 +22,7 @@ module DossierRebaseConcern end def pending_changes - procedure.published_revision.present? ? revision.compare(procedure.published_revision) : [] + procedure.published_revision.present? ? revision.compare_types_de_champ(procedure.published_revision) : [] end def can_rebase_mandatory_change?(stable_id) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 373ad3018..ae899dc04 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -307,7 +307,8 @@ module TagsSubstitutionConcern def format_date(date) if date.present? - date.strftime('%d/%m/%Y') + format = defined?(self.class::FORMAT_DATE) ? self.class::FORMAT_DATE : '%d/%m/%Y' + date.strftime(format) else '' end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index dbfce2464..343baf06d 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -59,7 +59,7 @@ class Dossier < ApplicationRecord has_many :previous_follows, -> { inactive }, class_name: 'Follow', inverse_of: :dossier has_many :followers_instructeurs, through: :follows, source: :instructeur has_many :previous_followers_instructeurs, -> { distinct }, through: :previous_follows, source: :instructeur - has_many :avis, inverse_of: :dossier, dependent: :destroy + has_many :avis, -> { order(:created_at) }, inverse_of: :dossier, dependent: :destroy has_many :experts, through: :avis has_many :traitements, -> { order(:processed_at) }, inverse_of: :dossier, dependent: :destroy do def passer_en_construction(instructeur: nil, processed_at: Time.zone.now) @@ -156,7 +156,7 @@ class Dossier < ApplicationRecord state :sans_suite event :passer_en_construction, after: :after_passer_en_construction, after_commit: :after_commit_passer_en_construction do - transitions from: :brouillon, to: :en_construction + transitions from: :brouillon, to: :en_construction, guard: :can_passer_en_construction? end event :passer_en_instruction, after: :after_passer_en_instruction, after_commit: :after_commit_passer_en_instruction do @@ -558,8 +558,18 @@ class Dossier < ApplicationRecord false end + 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 procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? + return false if blocked_with_pending_correction? true end @@ -932,6 +942,7 @@ class Dossier < ApplicationRecord .map do |champ| champ.errors.add(:value, :missing) end + .each { errors.import(_1) } end def demander_un_avis!(avis) @@ -1006,7 +1017,6 @@ class Dossier < ApplicationRecord else columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] end - if procedure.chorusable? && procedure.chorus_configuration.complete? columns += [ ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], diff --git a/app/models/export_template.rb b/app/models/export_template.rb index f5ed164b9..550bb57cd 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -7,6 +7,7 @@ class ExportTemplate < ApplicationRecord validates_with ExportTemplateValidator DOSSIER_STATE = Dossier.states.fetch(:en_construction) + FORMAT_DATE = "%Y-%m-%d" def set_default_values content["default_dossier_directory"] = tiptap_json("dossier-") @@ -48,7 +49,7 @@ class ExportTemplate < ApplicationRecord def attachment_and_path(dossier, attachment, index: 0, row_index: nil, champ: nil) [ attachment, - path(dossier, attachment, index, row_index, champ) + path(dossier, attachment, index:, row_index:, champ:) ] end @@ -116,7 +117,7 @@ class ExportTemplate < ApplicationRecord "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" end - def path(dossier, attachment, index, row_index, champ) + def path(dossier, attachment, index: 0, row_index: nil, champ: nil) if attachment.name == 'pdf_export_for_instructeur' return export_path(dossier) end @@ -128,6 +129,8 @@ class ExportTemplate < ApplicationRecord 'messagerie' when 'Avis' 'avis' + when 'Attestation', 'Etablissement' + 'pieces_justificatives' else # for attachment return attachment_path(dossier, attachment, index, row_index, champ) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 337970e89..7995bd49a 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -259,13 +259,19 @@ class Procedure < ApplicationRecord validates :lien_dpo, url: { no_local: true, allow_blank: true, accept_email: true } validates :draft_types_de_champ_public, + 'types_de_champ/condition': true, + 'types_de_champ/expression_reguliere': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_public_editor, :publication] + validates :draft_types_de_champ_private, + 'types_de_champ/condition': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_private_editor, :publication] validate :check_juridique, on: [:create, :publication] @@ -287,7 +293,7 @@ class Procedure < ApplicationRecord validates_with MonAvisEmbedValidator - validates_associated :draft_revision, on: :publication + validate :validates_associated_draft_revision_with_context validates_associated :initiated_mail, on: :publication validates_associated :received_mail, on: :publication validates_associated :closed_mail, on: :publication @@ -425,11 +431,15 @@ class Procedure < ApplicationRecord def draft_changed? preload_draft_and_published_revisions - !brouillon? && published_revision.different_from?(draft_revision) && revision_changes.present? + !brouillon? && (types_de_champ_revision_changes.present? || ineligibilite_rules_revision_changes.present?) end - def revision_changes - published_revision.compare(draft_revision) + def types_de_champ_revision_changes + published_revision.compare_types_de_champ(draft_revision) + end + + def ineligibilite_rules_revision_changes + published_revision.compare_ineligibilite_rules(draft_revision) end def preload_draft_and_published_revisions @@ -553,6 +563,7 @@ class Procedure < ApplicationRecord procedure.closing_notification_brouillon = false procedure.closing_notification_en_cours = false procedure.template = false + procedure.monavis_embed = nil if !procedure.valid? procedure.errors.attribute_names.each do |attribute| @@ -1014,6 +1025,13 @@ class Procedure < ApplicationRecord private + def validates_associated_draft_revision_with_context + return if draft_revision.blank? + return if draft_revision.validate(validation_context) + + draft_revision.errors.map { errors.import(_1) } + end + def validate_auto_archive_on_in_the_future return if auto_archive_on.nil? return if auto_archive_on.future? diff --git a/app/models/procedure_detail.rb b/app/models/procedure_detail.rb index ebe6c3f6d..2ae20f20f 100644 --- a/app/models/procedure_detail.rb +++ b/app/models/procedure_detail.rb @@ -14,7 +14,7 @@ ProcedureDetail = Struct.new(:id, :libelle, :published_at, :aasm_state, :estimat end def parsed_latest_zone_labels - # Replace curly braces with square brackets to make it a valid JSON array + return [] if latest_zone_labels.nil? || latest_zone_labels.strip.empty? JSON.parse(latest_zone_labels.tr('{', '[').tr('}', ']')) rescue JSON::ParserError [] diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index c8daa1bec..0a27fec2c 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,12 +18,19 @@ class ProcedureRevision < ApplicationRecord scope :ordered, -> { order(:created_at) } - validate :conditions_are_valid? - validate :header_sections_are_valid? - validate :expressions_regulieres_are_valid? + 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) @@ -140,16 +148,18 @@ class ProcedureRevision < ApplicationRecord !draft? end - def different_from?(revision) - revision_types_de_champ != revision.revision_types_de_champ - end - - def compare(revision) + def compare_types_de_champ(revision) changes = [] changes += compare_revision_types_de_champ(revision_types_de_champ, revision.revision_types_de_champ) changes end + def compare_ineligibilite_rules(revision) + changes = [] + changes += compare_revision_ineligibilite_rules(revision) + changes + end + def dossier_for_preview(user) dossier = Dossier .create_with(autorisation_donnees: true) @@ -255,6 +265,10 @@ class ProcedureRevision < ApplicationRecord types_de_champ_public.filter(&:routable?) end + def conditionable_types_de_champ + types_de_champ_for(scope: :public).filter(&:conditionable?) + end + private def compute_estimated_fill_duration @@ -322,6 +336,29 @@ class ProcedureRevision < ApplicationRecord end end + def compare_revision_ineligibilite_rules(new_revision) + from_ineligibilite_rules = ineligibilite_rules + to_ineligibilite_rules = new_revision.ineligibilite_rules + changes = [] + + if from_ineligibilite_rules.present? && to_ineligibilite_rules.blank? + changes << ProcedureRevisionChange::RemoveEligibiliteRuleChange + end + if from_ineligibilite_rules.blank? && to_ineligibilite_rules.present? + changes << ProcedureRevisionChange::AddEligibiliteRuleChange + end + if from_ineligibilite_rules != to_ineligibilite_rules + changes << ProcedureRevisionChange::UpdateEligibiliteRuleChange + end + if ineligibilite_message != new_revision.ineligibilite_message + changes << ProcedureRevisionChange::UpdateEligibiliteMessageChange + end + if ineligibilite_enabled != new_revision.ineligibilite_enabled + changes << (new_revision.ineligibilite_enabled ? ProcedureRevisionChange::EligibiliteEnabledChange : ProcedureRevisionChange::EligibiliteDisabledChange) + end + changes.map { _1.new(self, new_revision) } + end + def compare_type_de_champ(from_type_de_champ, to_type_de_champ, from_coordinates, to_coordinates) changes = [] if from_type_de_champ.type_champ != to_type_de_champ.type_champ @@ -446,6 +483,13 @@ class ProcedureRevision < ApplicationRecord changes end + def ineligibilite_rules_are_valid? + if ineligibilite_rules + ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) + .each { errors.add(:ineligibilite_rules, :invalid) } + end + end + def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) @@ -453,48 +497,4 @@ class ProcedureRevision < ApplicationRecord coordinate.update!(type_de_champ: cloned_type_de_champ) cloned_type_de_champ end - - def conditions_are_valid? - public_tdcs = types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - - public_tdcs - .map.with_index - .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } - .map do |tdc, i| - [tdc, tdc.condition.errors(public_tdcs.take(i))] - end - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:condition, message, type_de_champ: tdc) } - end - - def header_sections_are_valid? - public_tdcs = types_de_champ_public.to_a - - root_tdcs_errors = errors_for_header_sections_order(public_tdcs) - repetition_tdcs_errors = public_tdcs - .filter_map { _1.repetition? ? children_of(_1) : nil } - .map { errors_for_header_sections_order(_1) } - - repetition_tdcs_errors + root_tdcs_errors - end - - def expressions_regulieres_are_valid? - types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - .each do |tdc| - if tdc.expression_reguliere? && tdc.invalid_regexp? - errors.add(:expression_reguliere, type_de_champ: tdc) - end - end - end - - def errors_for_header_sections_order(tdcs) - tdcs - .map.with_index - .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } - .map { |tdc, i| [tdc, tdc.check_coherent_header_level(tdcs.take(i))] } - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:header_section, message, type_de_champ: tdc) } - end end diff --git a/app/models/procedure_revision_change.rb b/app/models/procedure_revision_change.rb index fc412cc26..7d99f0fd2 100644 --- a/app/models/procedure_revision_change.rb +++ b/app/models/procedure_revision_change.rb @@ -1,17 +1,19 @@ class ProcedureRevisionChange - attr_reader :type_de_champ - def initialize(type_de_champ) - @type_de_champ = type_de_champ + class TypeDeChange + attr_reader :type_de_champ + def initialize(type_de_champ) + @type_de_champ = type_de_champ + end + + def label = @type_de_champ.libelle + def stable_id = @type_de_champ.stable_id + def private? = @type_de_champ.private? + def child? = @type_de_champ.child? + + def to_h = { op:, stable_id:, label:, private: private? } end - def label = @type_de_champ.libelle - def stable_id = @type_de_champ.stable_id - def private? = @type_de_champ.private? - def child? = @type_de_champ.child? - - def to_h = { op:, stable_id:, label:, private: private? } - - class AddChamp < ProcedureRevisionChange + class AddChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -23,7 +25,7 @@ class ProcedureRevisionChange def to_h = super.merge(mandatory: mandatory?) end - class RemoveChamp < ProcedureRevisionChange + class RemoveChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -32,7 +34,7 @@ class ProcedureRevisionChange def can_rebase?(dossier = nil) = true end - class MoveChamp < ProcedureRevisionChange + class MoveChamp < TypeDeChange attr_reader :from, :to def initialize(type_de_champ, from, to) @@ -46,7 +48,7 @@ class ProcedureRevisionChange def to_h = super.merge(from:, to:) end - class UpdateChamp < ProcedureRevisionChange + class UpdateChamp < TypeDeChange attr_reader :attribute, :from, :to def initialize(type_de_champ, attribute, from, to) @@ -75,4 +77,48 @@ class ProcedureRevisionChange end end end + + class EligibiliteRulesChange + attr_reader :previous_revision, :new_revision + def initialize(previous_revision, new_revision) + @previous_revision = previous_revision + @new_revision = new_revision + @previous_ineligibilite_rules = @previous_revision.ineligibilite_rules + @new_ineligibilite_rules = @new_revision.ineligibilite_rules + end + + def i18n_params + { + previous_condition: @previous_ineligibilite_rules&.to_s(previous_revision.types_de_champ.filter { @previous_ineligibilite_rules.sources.include? _1.stable_id }), + new_condition: @new_ineligibilite_rules&.to_s(new_revision.types_de_champ.filter { @new_ineligibilite_rules.sources.include? _1.stable_id }) + } + end + end + + class AddEligibiliteRuleChange < EligibiliteRulesChange + def op = :add + end + + class RemoveEligibiliteRuleChange < EligibiliteRulesChange + def op = :remove + end + + class UpdateEligibiliteRuleChange < EligibiliteRulesChange + def op = :update + end + + class EligibiliteEnabledChange < EligibiliteRulesChange + def op = :enabled + def i18n_params = {} + end + + class EligibiliteDisabledChange < EligibiliteRulesChange + def op = :disabled + def i18n_params = {} + end + + class UpdateEligibiliteMessageChange < EligibiliteRulesChange + def op = :message_updated + def i18n_params = { ineligibilite_message: @new_revision.ineligibilite_message } + end end diff --git a/app/models/procedure_revision_type_de_champ.rb b/app/models/procedure_revision_type_de_champ.rb index c4842da20..506e205f7 100644 --- a/app/models/procedure_revision_type_de_champ.rb +++ b/app/models/procedure_revision_type_de_champ.rb @@ -75,4 +75,8 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord def used_by_routing_rules? stable_id.in?(procedure.stable_ids_used_by_routing_rules) end + + def used_by_ineligibilite_rules? + revision.ineligibilite_enabled? && stable_id.in?(revision.ineligibilite_rules&.sources || []) + end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index ea2de7468..cf2c321d8 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -505,15 +505,15 @@ class TypeDeChamp < ApplicationRecord end def check_coherent_header_level(upper_tdcs) - errs = [] previous_level = previous_section_level(upper_tdcs) - current_level = header_section_level_value.to_i + difference = current_level - previous_level if current_level > previous_level && difference != 1 - errs << I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + else + nil end - errs end def current_section_level(revision) @@ -657,6 +657,10 @@ class TypeDeChamp < ApplicationRecord type_champ.in?(ROUTABLE_TYPES) end + def conditionable? + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(type_champ) + end + def invalid_regexp? self.errors.delete(:expression_reguliere) self.errors.delete(:expression_reguliere_exemple_text) diff --git a/app/serializers/commentaire_serializer.rb b/app/serializers/commentaire_serializer.rb index 2941765e2..94f652147 100644 --- a/app/serializers/commentaire_serializer.rb +++ b/app/serializers/commentaire_serializer.rb @@ -2,9 +2,18 @@ class CommentaireSerializer < ActiveModel::Serializer attributes :email, :body, :created_at, - :piece_jointe_attachments + :piece_jointe_attachments, + :attachment def created_at object.created_at&.in_time_zone('UTC') end + + def attachment + piece_jointe = object.piece_jointe_attachments.first + + if piece_jointe&.virus_scanner&.safe? + Rails.application.routes.url_helpers.url_for(piece_jointe) + end + end end diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 0fdbc2be9..92d53b088 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -169,7 +169,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = commentaire_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -190,7 +195,12 @@ class PiecesJustificativesService .where(record_type: "Etablissement", record_id: etablissement_id_dossier_id.keys) .map do |a| dossier_id = etablissement_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -201,7 +211,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = a.record_id - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -217,7 +232,12 @@ class PiecesJustificativesService .where(record_type: "Attestation", record_id: attestation_id_dossier_id.keys) .map do |a| dossier_id = attestation_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -241,7 +261,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = avis_ids_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb new file mode 100644 index 000000000..86e23ff62 --- /dev/null +++ b/app/validators/types_de_champ/condition_validator.rb @@ -0,0 +1,34 @@ +class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator + # 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 = tdcs_with_children(procedure, tdcs) + tdcs.each_with_index do |tdc, tdc_index| + next unless tdc.condition? + + 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( + 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 diff --git a/app/validators/types_de_champ/expression_reguliere_validator.rb b/app/validators/types_de_champ/expression_reguliere_validator.rb new file mode 100644 index 000000000..39262adeb --- /dev/null +++ b/app/validators/types_de_champ/expression_reguliere_validator.rb @@ -0,0 +1,11 @@ +class TypesDeChamp::ExpressionReguliereValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + types_de_champ.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + .each do |tdc| + if tdc.expression_reguliere? && tdc.invalid_regexp? + procedure.errors.add(:expression_reguliere, type_de_champ: tdc) + end + end + end +end diff --git a/app/validators/types_de_champ/header_section_consistency_validator.rb b/app/validators/types_de_champ/header_section_consistency_validator.rb new file mode 100644 index 000000000..062b27cf0 --- /dev/null +++ b/app/validators/types_de_champ/header_section_consistency_validator.rb @@ -0,0 +1,29 @@ +class TypesDeChamp::HeaderSectionConsistencyValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + public_tdcs = types_de_champ.to_a + + root_tdcs_errors = errors_for_header_sections_order(procedure, attribute, public_tdcs) + repetition_tdcs_errors = public_tdcs + .filter_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : nil } + .map { errors_for_header_sections_order(procedure, attribute, _1) } + + repetition_tdcs_errors + root_tdcs_errors + end + + private + + def errors_for_header_sections_order(procedure, attribute, types_de_champ) + types_de_champ + .map.with_index + .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } + .map { |tdc, i| [tdc, tdc.check_coherent_header_level(types_de_champ.take(i))] } + .filter { |_tdc, errors| errors.present? } + .each do |tdc, message| + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :inconsistent_header_section, { value: tdc.libelle, custom_message: message }), + type_de_champ: tdc + ) + end + end +end diff --git a/app/validators/types_de_champ/no_empty_block_validator.rb b/app/validators/types_de_champ/no_empty_block_validator.rb index e1ea17739..50356d6ab 100644 --- a/app/validators/types_de_champ/no_empty_block_validator.rb +++ b/app/validators/types_de_champ/no_empty_block_validator.rb @@ -11,7 +11,8 @@ class TypesDeChamp::NoEmptyBlockValidator < ActiveModel::EachValidator if procedure.draft_revision.children_of(parent).empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }) + procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }), + type_de_champ: parent ) end end diff --git a/app/validators/types_de_champ/no_empty_drop_down_validator.rb b/app/validators/types_de_champ/no_empty_drop_down_validator.rb index bd8fa21dd..0be4e5406 100644 --- a/app/validators/types_de_champ/no_empty_drop_down_validator.rb +++ b/app/validators/types_de_champ/no_empty_drop_down_validator.rb @@ -11,7 +11,8 @@ class TypesDeChamp::NoEmptyDropDownValidator < ActiveModel::EachValidator if drop_down.drop_down_list_enabled_non_empty_options.empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }) + procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }), + type_de_champ: drop_down ) end end diff --git a/app/views/administrateurs/conditions/_update.turbo_stream.haml b/app/views/administrateurs/conditions/_update.turbo_stream.haml index 3fba14ec8..028fb8c0e 100644 --- a/app/views/administrateurs/conditions/_update.turbo_stream.haml +++ b/app/views/administrateurs/conditions/_update.turbo_stream.haml @@ -4,7 +4,7 @@ ['Configuration des champs']], preview: @procedure.draft_revision.valid? }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @tdc.public? ? :types_de_champ_public_editor : :types_de_champ_private_editor)) - rendered = render @condition_component diff --git a/app/views/administrateurs/experts_procedures/index.html.haml b/app/views/administrateurs/experts_procedures/index.html.haml index e1d6b6d88..dc8909f82 100644 --- a/app/views/administrateurs/experts_procedures/index.html.haml +++ b/app/views/administrateurs/experts_procedures/index.html.haml @@ -7,102 +7,112 @@ %h1.fr-h2 Avis externes - .groupe-instructeur - .card - .card-title= t('.titles.allow_invite_experts') - %p= t('.descriptions.allow_invite_experts') + = render Dsfr::CalloutComponent.new(title: nil) do |c| + - c.with_body do + Pendant l'instruction d'un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. + %p + = link_to('Comment gérer les avis externes', t('.experts_doc.url'), + title: t('.experts_doc.title'), + **external_link_attributes) + + %ul.fr-toggle__list + %li = form_for @procedure, method: :put, url: allow_expert_review_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_review, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_review, + title: t('.titles.allow_invite_experts'), + hint: t('.descriptions.allow_invite_experts'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') - if @procedure.allow_expert_review? - .card - .card-title= t('.titles.manage_procedure_experts') - %p= t('.descriptions.manage_procedure_experts') + %li + = form_for @procedure, + method: :put, + url: allow_expert_messaging_admin_procedure_path(@procedure), + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_messaging, + title: t('.titles.allow_expert_messaging'), + hint: t('.descriptions.allow_expert_messaging'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') + + %li = form_for @procedure, method: :put, url: experts_require_administrateur_invitation_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :experts_require_administrateur_invitation, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| - .card - .card-title= t('.titles.allow_expert_messaging') - %p= t('.descriptions.allow_expert_messaging') - = form_for @procedure, - method: :put, - url: allow_expert_messaging_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_messaging, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + = render Dsfr::ToggleComponent.new(form: f, + target: :experts_require_administrateur_invitation, + title: t('.titles.manage_procedure_experts'), + hint: t('.descriptions.manage_procedure_experts'), + disabled: false) - - if @procedure.experts_require_administrateur_invitation? - .card - .card-title Affecter des experts à la démarche - = form_for :experts_procedure, - url: admin_procedure_experts_path(@procedure), - html: { class: 'form' } do |f| - .instructeur-wrapper - %p Pendant l'instruction d’un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. - %p#experts-emails Entrez les adresses email des experts que vous souhaitez affecter à cette démarche - = hidden_field_tag :emails, nil - = react_component("ComboMultiple", - options: [], - selected: [], disabled: [], - group: '.instructeur-wrapper', - name: 'emails', - label: 'Emails', - describedby: 'experts-emails', - acceptNewValues: true) + - if @procedure.experts_require_administrateur_invitation? + .card + = form_for :experts_procedure, + url: admin_procedure_experts_path(@procedure), + html: { class: 'form' } do |f| + + .instructeur-wrapper + %p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie + = hidden_field_tag :emails, nil + = react_component("ComboMultiple", + options: [], + selected: [], disabled: [], + group: '.instructeur-wrapper', + name: 'emails', + label: 'Emails', + describedby: 'experts-emails', + acceptNewValues: true) + + = f.submit 'Ajouter à la liste', class: 'fr-btn' - = f.submit 'Affecter à la démarche', class: 'button primary send' - if @experts_procedure.present? - %table.table.mt-2 - %thead - %tr - %th Liste des experts - %th Nombre d’avis - - if @procedure.experts_require_administrateur_invitation - %th Notifier des décisions sur les dossiers - %tbody - - @experts_procedure.each do |expert_procedure| + .fr-table.fr-table--no-caption.fr-table--layout-fixed.fr-mt-3w + %table + %thead %tr - %td - = dsfr_icon('fr-icon-user-fill') - = expert_procedure.expert.email - %td.text-center - = expert_procedure.avis.count + %th Liste des experts + %th Nombre d’avis - if @procedure.experts_require_administrateur_invitation + %th Notifier des décisions sur les dossiers + - if @procedure.experts_require_administrateur_invitation + %th Action + %tbody + - @experts_procedure.each do |expert_procedure| + %tr + %td + = dsfr_icon('fr-icon-user-fill') + = expert_procedure.expert.email %td.text-center - = form_for expert_procedure, - url: admin_procedure_expert_path(id: expert_procedure), - method: :put, - data: { turbo: true }, - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off - - if @procedure.experts_require_administrateur_invitation - %td.actions= button_to 'retirer', - admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), - method: :delete, - data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, - class: 'button' + = expert_procedure.avis.count + - if @procedure.experts_require_administrateur_invitation + %td.text-center + = form_for expert_procedure, + url: admin_procedure_expert_path(id: expert_procedure), + method: :put, + data: { turbo: true }, + html: { class: 'form procedure-form__column--form no-background' } do |f| + %label.toggle-switch{ data: { controller: 'autosubmit' } } + = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' + %span.toggle-switch-control.round + %span.toggle-switch-label.on + %span.toggle-switch-label.off + - if @procedure.experts_require_administrateur_invitation + %td.actions= button_to 'retirer', + admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), + method: :delete, + data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, + class: 'fr-btn fr-btn--secondary' - else .blank-tab %h2.empty-text Aucun expert invité pour le moment. diff --git a/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml new file mode 100644 index 000000000..a0ace0eca --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml @@ -0,0 +1,7 @@ +- rendered = render @ineligibilite_rules_component + +- if rendered.present? + = turbo_stream.replace dom_id(@procedure.draft_revision, :ineligibilite_rules) do + - rendered +- else + = turbo_stream.remove dom_id(@procedure.draft_revision, :ineligibilite_rules) diff --git a/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/edit.html.haml b/app/views/administrateurs/ineligibilite_rules/edit.html.haml new file mode 100644 index 000000000..6eb91d12f --- /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 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 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 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/administrateurs/ineligibilite_rules/update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/procedures/_detail.html.haml b/app/views/administrateurs/procedures/_detail.html.haml index 798bb15a6..99b8b1410 100644 --- a/app/views/administrateurs/procedures/_detail.html.haml +++ b/app/views/administrateurs/procedures/_detail.html.haml @@ -31,7 +31,7 @@ - if show_detail %tr.procedure{ id: "procedure_detail_#{procedure.id}" } - %td.fr-highlight--beige-gris-galet{ colspan: '8' } + %td.fr-highlight--green-emeraude{ colspan: '8' } .fr-container .fr-col-6 - procedure.administrateurs.uniq.each do |admin| diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index 85a8f083c..d8d96870e 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -2,13 +2,13 @@ url: admin_procedure_publish_path(procedure_id: procedure.id), method: :put, html: { class: 'form' } do |f| - = render Procedure::PublicationWarningComponent.new(procedure: procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) .mt-2 - if procedure.draft_changed? %p.mb-2= t('.draft_changed_procedure_alert') = 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/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index 95ddf2860..9f29496ca 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -4,12 +4,16 @@ ['Champs du formulaire']], preview: @procedure.draft_revision.valid? } .fr-container - %h1.fr-h2 Champs du formulaire + .flex.justify-between.align-center.fr-mb-3w + %h1.fr-h2 Champs du formulaire + - if @procedure.revised? + = link_to "Voir l'historique des modifications du formulaire", modifications_admin_procedure_path(@procedure), class: 'fr-link' + = render NestedForms::FormOwnerComponent.new .fr-grid-row = render partial: 'champs_summary' .fr-col - = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision) + = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision, is_annotation: false) .padded-fixed-footer .fixed-footer diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index ea9e9c32b..73b8673bd 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -1,8 +1,11 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Champs du formulaire', champs_admin_procedure_path(@procedure)], ['Historique des modifications du formulaire']] } .fr-container + .fr-mb-3w + = link_to "Champs du formulaire", champs_admin_procedure_path(@procedure), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" %h1.fr-h2 Historique des modifications du formulaire @@ -10,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 @@ -28,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/new_from_existing.html.haml b/app/views/administrateurs/procedures/new_from_existing.html.haml index 76ccd4178..228004b1d 100644 --- a/app/views/administrateurs/procedures/new_from_existing.html.haml +++ b/app/views/administrateurs/procedures/new_from_existing.html.haml @@ -1,35 +1,34 @@ .container - if current_administrateur.procedures.brouillons.count == 0 - .card.feedback - .card-title - Bienvenue, - %br - vous allez pouvoir créer une première démarche de test. - Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. - %br - %br - Besoin d’aide ? - %br - > Vous pouvez - = link_to "visionner cette vidéo", - "https://vimeo.com/261478872", - target: "_blank" - %br - > Vous pouvez lire notre - = link_to "documentation en ligne", - ADMINISTRATEUR_TUTORIAL_URL, - target: "_blank" - - %br - > Vous pouvez enfin - = link_to "prendre un rendez-vous téléphonique avec nous", - CALENDLY_URL, - target: "_blank" - - :javascript - document.addEventListener("DOMContentLoaded", function() { - $crisp.push(["do", "trigger:run", ["admin-signup"]]); - }); + = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line", extra_class_names: 'fr-my-4w') do |c| + - c.with_html_body do + %p + Bienvenue, + %br + vous allez pouvoir créer une première démarche de test. + Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. + %br + %br + Besoin d’aide ? + %br + > Vous pouvez + = link_to "visionner cette vidéo", + "https://vimeo.com/261478872", + target: "_blank" + %br + > Vous pouvez lire notre + = link_to "documentation en ligne", + ADMINISTRATEUR_TUTORIAL_URL, + target: "_blank" + %br + > Vous pouvez enfin + = link_to "prendre un rendez-vous téléphonique avec nous", + CALENDLY_URL, + target: "_blank" + :javascript + document.addEventListener("DOMContentLoaded", function() { + $crisp.push(["do", "trigger:run", ["admin-signup"]]); + }); .form diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index a4c1f16d3..4463a86cf 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -5,7 +5,7 @@ .fr-container.procedure-admin-container %ul.fr-btns-group.fr-btns-group--inline-sm.fr-btns-group--icon-left - - if @procedure.draft_revision.valid? + - if @procedure.validate(:publication) - if !@procedure.brouillon? = link_to 'Télécharger', admin_procedure_archives_path(@procedure), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-download-line', id: "archive-procedure" @@ -27,15 +27,11 @@ = link_to 'Clore', admin_procedure_close_path(procedure_id: @procedure.id), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-calendar-close-fill', id: "close-procedure-link" .fr-container - = render TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision) - -- if @procedure.draft_changed? - .fr-container + - if @procedure.draft_changed? = render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c| - c.with_body do - = render Procedure::RevisionChangesComponent.new changes: @procedure.revision_changes, previous_revision: @procedure.published_revision - - = render Procedure::PublicationWarningComponent.new(procedure: @procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + = 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 @@ -44,6 +40,9 @@ - else %li= button_to 'Publier les modifications', admin_procedure_publication_path(@procedure), class: 'fr-btn', id: 'publish-procedure-link', data: { disable_with: "Publication..." }, disabled: !@procedure.draft_revision.valid? || @procedure.errors.present?, method: :get %li= button_to "Réinitialiser les modifications", admin_procedure_reset_draft_path(@procedure), class: 'fr-btn fr-btn--secondary fr-mr-2w', data: { confirm: 'Êtes-vous sûr de vouloir réinitialiser les modifications ?' }, method: :put + - else + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + - if !@procedure.procedure_expires_when_termine_enabled? = render partial: 'administrateurs/procedures/suggest_expires_when_termine', locals: { procedure: @procedure } @@ -72,10 +71,10 @@ = 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) - = render Procedure::Card::ModificationsComponent.new(procedure: @procedure) %h3.fr-h6 Pour aller plus loin .fr-grid-row.fr-grid-row--gutters.fr-mb-5w diff --git a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml index 9421b594a..ad121dedd 100644 --- a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml +++ b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml @@ -10,9 +10,9 @@ locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Configuration des champs']], - preview: @procedure.draft_revision.valid? }) + preview: @procedure.validate(@coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor) }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor)) = turbo_stream.replace 'summary', render(partial: 'administrateurs/procedures/champs_summary') diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 58a867ccd..d770433d4 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -8,20 +8,20 @@ - champ.piece_justificative_file.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if displayable_pdf?(blob) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(preview_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle = champ.libelle.truncate(25) = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif displayable_image?(blob) + = link_to image_url(blob_url(attachment)), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(variant_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index 0986a977a..793a7d960 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -23,7 +23,7 @@ - else = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) - - if feature_enabled?(:export_template) + - if @procedure.feature_enabled?(:export_template) %h2.fr-mb-1w.fr-mt-8w Liste des modèles d'export %p.fr-hint-text diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 734aeadd8..abb301872 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -8,17 +8,17 @@ - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if displayable_pdf?(blob) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(preview_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif displayable_image?(blob) + = link_to image_url(blob_url(attachment)), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(variant_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - else diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index d5fff3262..c797d35f2 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -10,7 +10,7 @@ = render NestedForms::FormOwnerComponent.new = form_for dossier_for_editing, url: brouillon_dossier_url(dossier), method: :patch, html: { id: 'dossier-edit-form', class: 'form', multipart: true, novalidate: 'novalidate' } do |f| - = render Dossiers::ErrorsFullMessagesComponent.new(dossier: @dossier, errors: @errors || []) + = render Dossiers::ErrorsFullMessagesComponent.new(dossier: dossier) %header.mb-6 .fr-highlight %p.fr-text--sm @@ -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/support/index.html.haml b/app/views/support/index.html.haml index fb97ab53b..99fee2678 100644 --- a/app/views/support/index.html.haml +++ b/app/views/support/index.html.haml @@ -3,8 +3,8 @@ = render partial: "root/footer" #contact-form - .container - %h1.new-h1 + .fr-container + %h1 = t('.contact') = form_tag contact_path, method: :post, multipart: true, class: 'fr-form-group', data: {controller: :support } do diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 3334d0f46..43fe1b97c 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 fr-mb-2w' + %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' - if procedure.monavis_embed .monavis diff --git a/app/views/users/dossiers/update.turbo_stream.haml b/app/views/users/dossiers/update.turbo_stream.haml index 91a898ab0..8224c1abd 100644 --- a/app/views/users/dossiers/update.turbo_stream.haml +++ b/app/views/users/dossiers/update.turbo_stream.haml @@ -1 +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 @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/bun.lockb b/bun.lockb index 9cc8b3bb3..7c777a111 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config/env.example.optional b/config/env.example.optional index b9cc395e9..79710f04e 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -61,6 +61,9 @@ DS_ENV="staging" # Instance customization: URL of the Routage documentation # ROUTAGE_URL="" # +# Instance customization: URL of the EligibiliteDossier documentation +# ELIGIBILITE_URL="" +# # Instance customization: URL of the accessibility statement # ACCESSIBILITE_URL="" diff --git a/config/initializers/02_urls.rb b/config/initializers/02_urls.rb index d6e031dae..c29ea1d5d 100644 --- a/config/initializers/02_urls.rb +++ b/config/initializers/02_urls.rb @@ -37,6 +37,7 @@ CGU_URL = ENV.fetch("CGU_URL", [DOC_URL, "cgu"].join("/")) MENTIONS_LEGALES_URL = ENV.fetch("MENTIONS_LEGALES_URL", "/mentions-legales") ACCESSIBILITE_URL = ENV.fetch("ACCESSIBILITE_URL", "/declaration-accessibilite") ROUTAGE_URL = ENV.fetch("ROUTAGE_URL", [DOC_URL, "/pour-aller-plus-loin/routage"].join("/")) +ELIGIBILITE_URL = ENV.fetch("ELIGIBILITE_URL", [DOC_URL, "/pour-aller-plus-loin/eligibilite-des-dossiers"].join("/")) API_DOC_URL = [DOC_URL, "api-graphql"].join("/") WEBHOOK_DOC_URL = [DOC_URL, "pour-aller-plus-loin", "webhook"].join("/") WEBHOOK_ALTERNATIVE_DOC_URL = [DOC_URL, "api-graphql", "cas-dusages-exemple-dimplementation", "synchroniser-les-dossiers-modifies-sur-ma-demarche"].join("/") diff --git a/config/initializers/authorized_content_types.rb b/config/initializers/authorized_content_types.rb index e5af1c74f..eaa16fbd9 100644 --- a/config/initializers/authorized_content_types.rb +++ b/config/initializers/authorized_content_types.rb @@ -15,6 +15,10 @@ AUTHORIZED_IMAGE_TYPES = [ 'image/vnd.dwg' # multimedia x 137 auto desk ] +RARE_IMAGE_TYPES = [ + 'image/tiff' # multimedia x 3985 +] + AUTHORIZED_CONTENT_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + [ # multimedia 'video/mp4', # multimedia x 2075 diff --git a/config/locales/en.yml b/config/locales/en.yml index 5787289e6..69aef694b 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 jdma_l1: Help us improve this service! @@ -607,6 +608,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 @@ -668,6 +670,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 52100dc78..bc6f19514 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 jdma_l1: Aidez-nous à améliorer ce service ! @@ -611,6 +612,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 @@ -670,6 +672,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: @@ -741,8 +747,6 @@ fr: evil_regexp: L'expression régulière que vous avez entrée est potentiellement dangereuse et pourrait entraîner des problèmes de performance mismatch_regexp: L'exemple doit correspondre à l'expression régulière fournie syntax_error_regexp: La syntaxe de l'expression régulière n'est pas valide - empty_repetition: '« %{value} » doit comporter au moins un champ répétable' - empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' # procedure_not_draft: "Cette démarche n’est maintenant plus en brouillon." cadastres_empty: one: "Aucune parcelle cadastrale sur la zone sélectionnée" diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 2f1d1c8b8..34fc89e35 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -72,8 +72,30 @@ en: invalid: 'invalid format' draft_types_de_champ_public: format: 'Public field %{message}' + invalid_condition: "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: "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: + format: "%{attribute} %{message}" + received_mail: + format: "%{attribute} %{message}" + closed_mail: + format: "%{attribute} %{message}" + refused_mail: + format: "%{attribute} %{message}" + without_continuation_mail: + format: "%{attribute} %{message}" + re_instructed_mail: + format: "%{attribute} %{message}" lien_dpo: invalid_uri_or_email: "Fill in with an email or a link" sva_svr: diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 78e2bcf26..5a9dfd9ab 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -8,7 +8,7 @@ fr: procedure: hints: description: Décrivez en quelques lignes le contexte, la finalité, etc. - description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les critères d’éligibilité s’il y en a, les pré-requis, etc. + description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les conditions d’éligibilité s’il y en a, les pré-requis, etc. description_pj: Décrivez la liste des pièces jointes à fournir s’il y en a lien_site_web: "Il s'agit de la page de votre site web où le lien sera diffusé. Ex: https://exemple.gouv.fr/page_informant_sur_ma_demarche" cadre_juridique: "Exemple: 'https://www.legifrance.gouv.fr/'" @@ -78,8 +78,30 @@ fr: invalid: 'n’a pas le bon format' draft_types_de_champ_public: format: 'Le champ %{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: "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: + format: "%{attribute} %{message}" + received_mail: + format: "%{attribute} %{message}" + closed_mail: + format: "%{attribute} %{message}" + refused_mail: + format: "%{attribute} %{message}" + without_continuation_mail: + format: "%{attribute} %{message}" + re_instructed_mail: + format: "%{attribute} %{message}" lien_dpo: invalid_uri_or_email: "Veuillez saisir un mail ou un lien" auto_archive_on: diff --git a/config/locales/models/procedure_revision/fr.yml b/config/locales/models/procedure_revision/fr.yml new file mode 100644 index 000000000..1665415aa --- /dev/null +++ b/config/locales/models/procedure_revision/fr.yml @@ -0,0 +1,7 @@ +fr: + activerecord: + attributes: + procedure_revision: + ineligibilite_message: Message d’inéligibilité + hints: + ineligibilite_message: "Ce message sera affiché à l’usager si son dossier est bloqué et lui expliquera la raison de son inéligibilité." diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml index a6ea09511..71de8c159 100644 --- a/config/locales/models/type_de_champ/fr.yml +++ b/config/locales/models/type_de_champ/fr.yml @@ -61,4 +61,4 @@ fr: type_de_champ: attributes: header_section_level: - gap_error: "Un titre de section avec le niveau %{level} est manquant." + gap_error: "devrait être précédé d'un titre de niveau %{level}" diff --git a/config/locales/views/administrateurs/experts_procedures/fr.yml b/config/locales/views/administrateurs/experts_procedures/fr.yml index 62db8096c..673b0a609 100644 --- a/config/locales/views/administrateurs/experts_procedures/fr.yml +++ b/config/locales/views/administrateurs/experts_procedures/fr.yml @@ -6,8 +6,11 @@ fr: main: Experts invités sur %{libelle} allow_invite_experts: "Autoriser les instructeurs à solliciter des experts invités" allow_expert_messaging: "Autoriser les experts à accéder à la messagerie usager" - manage_procedure_experts: "Gérer les experts invités de la démarche" + manage_procedure_experts: "Gérer les experts invités de la démarche avec une liste prédéfinie" descriptions: allow_invite_experts : Lorsque cette fonctionnalité est active, les instructeurs peuvent solliciter les experts allow_expert_messaging: Lorsque cette fonctionnalité est active, les experts peuvent demander des informations aux usagers manage_procedure_experts: Lorsque cette fonctionnalité est active, les instructeurs peuvent uniquement solliciter les experts de votre liste + experts_doc: + title: Avis externes documentation + url: 'https://app.gitbook.com/o/-L7_aClyGhmMzzsqtO4_/s/-L7_aKvpAJdAIEfxHudA/tutoriels/tutoriel-administrateur/~/comments?context=post&node=ff7bd481c1994d6aa56817b7237b9e59#id-12.-la-gestion-des-avis-experts-invites-de-votre-demarche' diff --git a/config/routes.rb b/config/routes.rb index c4973d1ae..d16b5f778 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -161,6 +161,7 @@ Rails.application.routes.draw do end get 'password_complexity' => 'password_complexity#show', as: 'show_password_complexity' + get 'check_email' => 'email_checker#show', as: 'show_email_suggestions' resources :targeted_user_links, only: [:show] @@ -607,6 +608,14 @@ Rails.application.routes.draw do delete :delete_row, on: :member end + resource :ineligibilite_rules, only: [:edit, :update, :destroy], param: :revision_id do + patch :change_targeted_champ, on: :member + patch :update_all_rows, on: :member + patch :add_row, on: :member + delete :delete_row, on: :member + patch :change + end + patch :update_defaut_groupe_instructeur, controller: 'routing_rules', as: :update_defaut_groupe_instructeur put 'clone' diff --git a/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb b/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb new file mode 100644 index 000000000..e2a654783 --- /dev/null +++ b/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddTransitionsRulesToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_rules, :jsonb + end +end diff --git a/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb b/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb new file mode 100644 index 000000000..bf8464f8c --- /dev/null +++ b/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddDossierIneligbleMessageToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_message, :text + end +end diff --git a/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb b/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb new file mode 100644 index 000000000..19ce1d243 --- /dev/null +++ b/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddEligibiliteDossiersEnabledToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f7fcfe7c..b33a10582 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -863,6 +863,9 @@ ActiveRecord::Schema[7.0].define(version: 2024_05_27_090508) do create_table "procedure_revisions", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.bigint "dossier_submitted_message_id" + t.boolean "ineligibilite_enabled", default: false, null: false + t.text "ineligibilite_message" + t.jsonb "ineligibilite_rules" t.bigint "procedure_id", null: false t.datetime "published_at", precision: nil t.datetime "updated_at", precision: nil, null: false diff --git a/package.json b/package.json index 8d5144609..edad7ebc6 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "core-js": "^3.37.1", "date-fns": "^2.30.0", "debounce": "^1.2.1", - "email-butler": "^1.0.13", "geojson": "^0.5.0", "graphiql": "^3.2.3", "graphql": "^16.8.1", diff --git a/spec/components/conditions/ineligibilite_rules_component_spec.rb b/spec/components/conditions/ineligibilite_rules_component_spec.rb new file mode 100644 index 000000000..c678c5ace --- /dev/null +++ b/spec/components/conditions/ineligibilite_rules_component_spec.rb @@ -0,0 +1,64 @@ +describe Conditions::IneligibiliteRulesComponent, type: :component do + include Logic + let(:procedure) { create(:procedure) } + let(:component) { described_class.new(draft_revision: procedure.draft_revision) } + + describe 'render' do + let(:ineligibilite_message) { 'ok' } + let(:ineligibilite_enabled) { true } + before do + procedure.draft_revision.update(ineligibilite_rules:, ineligibilite_message:, ineligibilite_enabled:) + end + context 'when ineligibilite_rules are valid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(true)) } + it 'does not render error' do + render_inline(component) + expect(page).not_to have_selector('.errors-summary') + end + end + context 'when ineligibilite_rules are invalid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + it 'does not render error' do + render_inline(component) + expect(page).to have_selector('.errors-summary') + end + end + end + + describe '#pending_changes' do + context 'when procedure is published' do + it 'detect changes when setup changes' do + expect(component.pending_changes?).to be_falsey + + procedure.draft_revision.ineligibilite_message = 'changed' + expect(component.pending_changes?).to be_falsey + + procedure.reload + procedure.draft_revision.ineligibilite_enabled = true + expect(component.pending_changes?).to be_falsey + + procedure.reload + procedure.draft_revision.ineligibilite_rules = {} + expect(component.pending_changes?).to be_falsey + end + end + + context 'when procedure is published' do + let(:procedure) { create(:procedure, :published) } + it 'detect changes when setup changes' do + expect(component.pending_changes?).to be_falsey + + procedure.draft_revision.ineligibilite_message = 'changed' + expect(component.pending_changes?).to be_truthy + + procedure.reload + procedure.draft_revision.ineligibilite_enabled = true + expect(component.pending_changes?).to be_truthy + + procedure.reload + procedure.draft_revision.ineligibilite_rules = {} + expect(component.pending_changes?).to be_truthy + end + end + end +end diff --git a/spec/components/dossiers/edit_footer_component_spec.rb b/spec/components/dossiers/edit_footer_component_spec.rb new file mode 100644 index 000000000..4b8e1a77f --- /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(: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(: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') + 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(: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') + end + end + + context 'when dossier can not be submitted' do + 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 les modifications') + end + end + end +end diff --git a/spec/components/procedures/card/annotations_component_spec.rb b/spec/components/procedures/card/annotations_component_spec.rb new file mode 100644 index 000000000..478937124 --- /dev/null +++ b/spec/components/procedures/card/annotations_component_spec.rb @@ -0,0 +1,30 @@ +describe Procedure::Card::AnnotationsComponent, type: :component do + describe 'render' do + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [] } + before { procedure.validate(:publication) } + subject { render_inline(described_class.new(procedure: procedure)) } + + context 'when no errors' do + it 'does not render' do + expect(subject).to have_selector('.fr-badge--info', text: 'À configurer') + end + end + + context 'when errors on types_de_champs_public' do + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + it 'does not render' do + expect(subject).to have_selector('.fr-badge--info', text: 'À configurer') + end + end + + context 'when errors on types_de_champs_private' do + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + + it 'render the template' do + expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') + end + end + end +end diff --git a/spec/components/procedures/card/champs_component_spec.rb b/spec/components/procedures/card/champs_component_spec.rb new file mode 100644 index 000000000..61a5d5762 --- /dev/null +++ b/spec/components/procedures/card/champs_component_spec.rb @@ -0,0 +1,30 @@ +describe Procedure::Card::ChampsComponent, type: :component do + describe 'render' do + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [] } + before { procedure.validate(:publication) } + subject { render_inline(described_class.new(procedure: procedure)) } + + context 'when no errors' do + it 'does not render' do + expect(subject).to have_selector('.fr-badge--warning', text: 'À faire') + end + end + + context 'when errors on types_de_champs_public' do + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + it 'does not render' do + expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') + end + end + + context 'when errors on types_de_champs_private' do + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + + it 'render the template' do + expect(subject).to have_selector('.fr-badge--warning', text: 'À faire') + end + end + end +end diff --git a/spec/components/procedures/card/ineligibilite_dossier_component.rb b/spec/components/procedures/card/ineligibilite_dossier_component.rb new file mode 100644 index 000000000..433b59155 --- /dev/null +++ b/spec/components/procedures/card/ineligibilite_dossier_component.rb @@ -0,0 +1,25 @@ +describe Procedure::Card::IneligibiliteDossierComponent, type: :component do + describe 'render' do + subject do + render_inline(described_class.new(procedure: procedure)) + end + + context 'when none of types_de_champ_public supports conditional' do + let(:procedure) { create(:procedure, types_de_champ_public: []) } + + it 'render missing setup' do + subject + expect(page).to have_text('Champs manquant') + end + end + + context 'when at least one of types_de_champ_public support conditional' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } + + it 'render the template' do + subject + expect(page).to have_text('À configurer') + end + end + end +end diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb new file mode 100644 index 000000000..ebeb1096d --- /dev/null +++ b/spec/components/procedures/errors_summary_spec.rb @@ -0,0 +1,97 @@ +describe Procedure::ErrorsSummary, type: :component do + subject { render_inline(described_class.new(procedure:, validation_context:)) } + + describe 'validations context' do + let(:procedure) { create(:procedure, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + + before { subject } + + context 'when :publication' do + let(:validation_context) { :publication } + + it 'shows errors 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 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 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 + + describe 'render all kind of champs errors' do + include Logic + + let(:procedure) do + create(:procedure, id: 1, types_de_champ_public: [ + { libelle: 'repetition requires children', type: :repetition, children: [] }, + { libelle: 'drop down list requires options', type: :drop_down_list, options: [] }, + { libelle: 'invalid condition', type: :text, condition: ds_eq(constant(true), constant(1)) }, + { libelle: 'header sections must have consistent order', type: :header_section, level: 2 } + ]) + end + + let(:validation_context) { :types_de_champ_public_editor } + + before { subject } + + it 'renders all errors 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 + + 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) } + let(:initiated_mail) { build(:initiated_mail) } + + 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) + end + end +end diff --git a/spec/components/procedures/pending_republish_component_spec.rb b/spec/components/procedures/pending_republish_component_spec.rb new file mode 100644 index 000000000..a5e301a20 --- /dev/null +++ b/spec/components/procedures/pending_republish_component_spec.rb @@ -0,0 +1,14 @@ +describe Procedure::PendingRepublishComponent, type: :component do + subject { render_inline(described_class.new(render_if:, procedure: build(:procedure, id: 1))) } + let(:page) { subject } + describe 'render_if' do + context 'when false' do + let(:render_if) { false } + it { expect(page).not_to have_text('Ces modifications ne seront appliquées') } + end + context 'when true' do + let(:render_if) { true } + it { expect(page).to have_text('Ces modifications ne seront appliquées') } + end + end +end diff --git a/spec/components/types_de_champ_editor/champ_component_spec.rb b/spec/components/types_de_champ_editor/champ_component_spec.rb index 1e368f1ba..27b57472f 100644 --- a/spec/components/types_de_champ_editor/champ_component_spec.rb +++ b/spec/components/types_de_champ_editor/champ_component_spec.rb @@ -2,10 +2,12 @@ describe TypesDeChampEditor::ChampComponent, type: :component do describe 'render' do let(:component) { described_class.new(coordinate:, upper_coordinates: []) } let(:routing_rules_stable_ids) { [] } + let(:ineligibilite_rules_used?) { false } before do Flipper.enable_actor(:engagement_juridique_type_de_champ, procedure) allow_any_instance_of(Procedure).to receive(:stable_ids_used_by_routing_rules).and_return(routing_rules_stable_ids) + allow_any_instance_of(ProcedureRevisionTypeDeChamp).to receive(:used_by_ineligibilite_rules?).and_return(ineligibilite_rules_used?) render_inline(component) end @@ -29,6 +31,15 @@ describe TypesDeChampEditor::ChampComponent, type: :component do expect(page).to have_text(/utilisé pour\nle routage/) end end + + context 'drop down tdc used for ineligibilite_rules' do + let(:ineligibilite_rules_used?) { true } + + it do + expect(page).to have_css("select[disabled=\"disabled\"]") + expect(page).to have_text(/l’eligibilité des dossiers/) + end + end end describe 'tdc ej' do diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb new file mode 100644 index 000000000..fb7094983 --- /dev/null +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -0,0 +1,28 @@ +describe TypesDeChampEditor::EditorComponent, type: :component do + let(:revision) { procedure.draft_revision } + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + + describe 'render' do + subject { render_inline(described_class.new(revision:, is_annotation:)) } + context 'types_de_champ_public' do + let(:is_annotation) { false } + it 'does not render private champs errors' do + expect(subject).not_to have_text("private") + 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_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 + end + end +end diff --git a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb new file mode 100644 index 000000000..2a76a054a --- /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 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 conditions 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/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index e19155b82..116db94d1 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -16,24 +16,28 @@ describe Administrateurs::ProceduresController, type: :controller do let(:tags) { "[\"planete\",\"environnement\"]" } describe '#apercu' do - render_views - - let(:procedure) { create(:procedure, :with_all_champs) } - subject { get :apercu, params: { id: procedure.id } } before do sign_in(admin.user) end - it do - subject - expect(response).to have_http_status(:ok) - expect(procedure.dossiers.visible_by_user).to be_empty - expect(procedure.dossiers.for_procedure_preview).not_to be_empty + context 'all tdc can be rendered' do + render_views + + let(:procedure) { create(:procedure, :with_all_champs) } + + it do + subject + expect(response).to have_http_status(:ok) + expect(procedure.dossiers.visible_by_user).to be_empty + expect(procedure.dossiers.for_procedure_preview).not_to be_empty + end end context 'when the draft is invalid' do + let(:procedure) { create(:procedure) } + before do allow_any_instance_of(ProcedureRevision).to receive(:invalid?).and_return(true) end @@ -91,9 +95,6 @@ describe Administrateurs::ProceduresController, type: :controller do let!(:draft_procedure) { create(:procedure) } let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } let!(:closed_procedure) { create(:procedure, :closed) } - let!(:procedure_detail_draft) { ProcedureDetail.new(id: draft_procedure.id, latest_zone_labels: '{ "zone1", "zone2" }') } - let!(:procedure_detail_published) { ProcedureDetail.new(id: published_procedure.id, latest_zone_labels: '{ "zone3", "zone4" }') } - let!(:procedure_detail_closed) { ProcedureDetail.new(id: closed_procedure.id, latest_zone_labels: '{ "zone5", "zone6" }') } subject { get :all } @@ -120,19 +121,6 @@ describe Administrateurs::ProceduresController, type: :controller do expect(assigns(:procedures).any? { |p| p.id == draft_procedure.id }).to be_falsey end - context 'with parsed latest zone labels' do - it 'parses the latest zone labels correctly' do - expect(procedure_detail_draft.parsed_latest_zone_labels).to eq(["zone1", "zone2"]) - expect(procedure_detail_published.parsed_latest_zone_labels).to eq(["zone3", "zone4"]) - expect(procedure_detail_closed.parsed_latest_zone_labels).to eq(["zone5", "zone6"]) - end - - it 'returns an empty array for invalid JSON' do - procedure_detail_draft.latest_zone_labels = '{ invalid json }' - expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) - end - end - context 'for default admin zones' do let(:zone1) { create(:zone) } let(:zone2) { create(:zone) } diff --git a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb index 6fa77c40b..a42d90210 100644 --- a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb @@ -912,6 +912,17 @@ describe API::V2::GraphqlController do expect(ActionMailer::Base.deliveries.size).to eq(0) } end + + context 'with pending corrections' do + before { Flipper.enable(:blocking_pending_correction, dossier.procedure) } + let!(:dossier_correction) { create(:dossier_correction, dossier:) } + + it { + expect(dossier.pending_correction?).to be_truthy + expect(gql_errors).to be_nil + expect(gql_data[:dossierPasserEnInstruction][:errors]).to eq([{ message: "Le dossier est en attente de correction" }]) + } + end end context 'dossierRepasserEnConstruction' do @@ -1332,5 +1343,77 @@ describe API::V2::GraphqlController do } end end + + context 'dossierEnvoyerMessage' do + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure:) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id, body: 'Hello World!' } } } + let(:operation_name) { 'dossierEnvoyerMessage' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierEnvoyerMessage][:errors]).to be_nil + expect(gql_data[:dossierEnvoyerMessage][:message][:id]).to eq(dossier.commentaires.first.to_typed_id) + perform_enqueued_jobs + expect(ActionMailer::Base.deliveries.size).to eq(1) + } + end + + context 'dossierSupprimerMessage' do + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure:) } + let(:message) { create(:commentaire, dossier:, instructeur:) } + let(:dossier_correction) { create(:dossier_correction, dossier:, commentaire: message) } + let(:variables) { { input: { messageId: message.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierSupprimerMessage' } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to be_nil + expect(gql_data[:dossierSupprimerMessage][:message][:id]).to eq(message.to_typed_id) + expect(gql_data[:dossierSupprimerMessage][:message][:discardedAt]).not_to be_nil + expect(message.reload.discarded?).to be_truthy + } + + it { + expect(dossier_correction.commentaire.discarded?).to be_falsey + expect(dossier.pending_correction?).to be_truthy + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to be_nil + expect(gql_data[:dossierSupprimerMessage][:message][:id]).to eq(message.to_typed_id) + expect(gql_data[:dossierSupprimerMessage][:message][:discardedAt]).not_to be_nil + expect(message.reload.discarded?).to be_truthy + expect(dossier.pending_correction?).to be_falsey + } + + context 'when unauthorized' do + let(:dossier) { create(:dossier, :en_construction, :with_individual) } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors.first[:message]).to eq("An object of type Message was hidden due to permissions") + } + end + + context 'when from not the same instructeur' do + let(:other_instructeur) { create(:instructeur, followed_dossiers: dossiers) } + let(:variables) { { input: { messageId: message.to_typed_id, instructeurId: other_instructeur.to_typed_id } } } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to eq([{ message: "Le message ne peut pas être supprimé" }]) + } + end + + context 'when from usager' do + let(:message) { create(:commentaire, dossier:) } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to eq([{ message: "Le message ne peut pas être supprimé" }]) + } + end + end end end diff --git a/spec/controllers/email_checker_controller_spec.rb b/spec/controllers/email_checker_controller_spec.rb new file mode 100644 index 000000000..4572c2cd4 --- /dev/null +++ b/spec/controllers/email_checker_controller_spec.rb @@ -0,0 +1,39 @@ +describe EmailCheckerController, type: :controller do + describe '#show' do + render_views + before { get :show, format: :json, params: params } + let(:body) { JSON.parse(response.body, symbolize_names: true) } + + context 'valid email' do + let(:params) { { email: 'martin@orange.fr' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: true }) + end + end + + context 'email with typo' do + let(:params) { { email: 'martin@orane.fr' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: true, email_suggestions: ['martin@orange.fr'] }) + end + end + + context 'empty' do + let(:params) { { email: '' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end + + context 'notanemail' do + let(:params) { { email: 'clarkkent' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end + end +end diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb index 8b8d73b82..36c9f665e 100644 --- a/spec/controllers/instructeurs/export_templates_controller_spec.rb +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -21,26 +21,45 @@ describe Instructeurs::ExportTemplatesController, type: :controller do { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } ] }.to_json, - "pjs" => - [ - { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " _justif", "type" => "text" }] }] }, stable_id: "3" }, - { - path: - { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "cni_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "5" - }, - { - path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "pj_repet_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "10" - } - ] + tiptap_pj_3: { + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json, + tiptap_pj_5: { + + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json, + tiptap_pj_10: { + + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json } end let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let(:procedure) do + create( + :procedure, instructeurs: [instructeur], + types_de_champ_public: [ + { type: :piece_justificative, libelle: "pj1", stable_id: 3 }, + { type: :piece_justificative, libelle: "pj2", stable_id: 5 }, + { type: :piece_justificative, libelle: "pj3", stable_id: 10 } + ] + ) + end let(:groupe_instructeur) { procedure.defaut_groupe_instructeur } + describe '#new' do + let(:subject) { get :new, params: { procedure_id: procedure.id } } + + it do + subject + expect(assigns(:export_template)).to be_present + end + end + describe '#create' do let(:subject) { post :create, params: { procedure_id: procedure.id, export_template: export_template_params } } @@ -130,4 +149,19 @@ describe Instructeurs::ExportTemplatesController, type: :controller do end end end + + describe '#preview' do + render_views + + let(:export_template) { create(:export_template, groupe_instructeur:) } + + let(:subject) { get :preview, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params }, format: :turbo_stream } + + it '' do + dossier = create(:dossier, procedure: procedure, for_procedure_preview: true) + subject + expect(response.body).to include "DOSSIER_#{dossier.id}" + expect(response.body).to include "mon_export_#{dossier.id}.pdf" + end + end end diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 8d3b702f9..2a38eb7d7 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -398,7 +398,9 @@ describe Users::DossiersController, type: :controller do describe '#submit_brouillon' do before { sign_in(user) } - let!(:dossier) { create(:dossier, user: user) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :text }] } + let!(:dossier) { create(:dossier, user:, procedure:) } let(:first_champ) { dossier.champs_public.first } let(:anchor_to_first_champ) { controller.helpers.link_to first_champ.libelle, brouillon_dossier_path(anchor: first_champ.labelledby_id), class: 'error-anchor' } let(:value) { 'beautiful value' } @@ -439,9 +441,9 @@ describe Users::DossiersController, type: :controller do render_views let(:error_message) { 'nop' } before do - expect_any_instance_of(Dossier).to receive(:validate).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( - [double(inner_error: double(base: first_champ), message: 'nop')] + allow_any_instance_of(Dossier).to receive(:validate).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( + [instance_double(ActiveModel::NestedError, inner_error: double(base: first_champ), message: 'nop')] ) subject end @@ -461,11 +463,8 @@ describe Users::DossiersController, type: :controller do render_views let(:value) { nil } - - before do - first_champ.type_de_champ.update(mandatory: true, libelle: 'l') - subject - end + let(:types_de_champ_public) { [{ type: :text, mandatory: true, libelle: 'l' }] } + before { subject } it { expect(response).to render_template(:brouillon) } it { expect(response.body).to have_link(first_champ.libelle, href: "##{first_champ.labelledby_id}") } @@ -548,8 +547,8 @@ describe Users::DossiersController, type: :controller do render_views before do - expect_any_instance_of(Dossier).to receive(:validate).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( + allow_any_instance_of(Dossier).to receive(:validate).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( [double(inner_error: double(base: first_champ), message: 'nop')] ) @@ -661,7 +660,8 @@ describe Users::DossiersController, type: :controller do describe '#update brouillon' do before { sign_in(user) } - let(:procedure) { create(:procedure, :published, types_de_champ_public: [{}, { type: :piece_justificative }]) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{}, { type: :piece_justificative }] } let(:dossier) { create(:dossier, user:, procedure:) } let(:first_champ) { dossier.champs_public.first } let(:piece_justificative_champ) { dossier.champs_public.last } @@ -754,13 +754,66 @@ describe Users::DossiersController, type: :controller do end end - it "debounce search terms indexation" do - # dossier creation trigger a first indexation and flag, - # so we we have to remove this flag - dossier.debounce_index_search_terms_flag.remove + context 'having ineligibilite_rules setup' do + include Logic + render_views - assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do - 3.times { patch :update, params: payload, format: :turbo_stream } + let(:types_de_champ_public) { [{ type: :text }, { type: :integer_number }] } + let(:text_champ) { dossier.champs_public.first } + let(:number_champ) { dossier.champs_public.last } + let(:submit_payload) do + { + id: dossier.id, + dossier: { + groupe_instructeur_id: dossier.groupe_instructeur_id, + champs_public_attributes: { + text_champ.public_id => { + with_public_id: true, + value: "hello world" + }, + number_champ.public_id => { + with_public_id: true, + value: + } + } + } + } + end + let(:must_be_greater_than) { 10 } + + before do + procedure.published_revision.update( + ineligibilite_enabled: true, + ineligibilite_message: 'lol', + ineligibilite_rules: greater_than(champ_value(number_champ.stable_id), constant(must_be_greater_than)) + ) + procedure.published_revision.save! + end + render_views + + context 'when it 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(: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 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(: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 end end @@ -868,8 +921,8 @@ describe Users::DossiersController, type: :controller do context 'classic error' do before do - expect_any_instance_of(Dossier).to receive(:save).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( + allow_any_instance_of(Dossier).to receive(:save).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( [message: 'nop', inner_error: double(base: first_champ)] ) subject @@ -1489,15 +1542,18 @@ describe Users::DossiersController, type: :controller do end describe '#clone' do - let(:procedure) { create(:procedure, :with_all_champs) } let(:dossier) { create(:dossier, procedure: procedure) } subject { post :clone, params: { id: dossier.id } } context 'not signed in' do + let(:procedure) { create(:procedure) } + it { expect(subject).to redirect_to(new_user_session_path) } end context 'signed with user dossier' do + let(:procedure) { create(:procedure, :with_all_champs) } + before { sign_in dossier.user } it { expect(subject).to redirect_to(brouillon_dossier_path(Dossier.last)) } diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index 0f4e8d882..6785356af 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -11,24 +11,78 @@ FactoryBot.define do { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_id", "label" => "id dossier" } }, { "text" => " .pdf", "type" => "text" }] } ] }, - "default_dossier_directory" => - { - "type" => "doc", - "content" => - [ - { - "type" => "paragraph", - "content" => - [ - { "text" => "dossier_", "type" => "text" }, - { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, - { "text" => " ", "type" => "text" } - ] + "default_dossier_directory" => { + "type" => "doc", + "content" => + [ + { + "type" => "paragraph", + "content" => + [ + { "text" => "dossier_", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => " ", "type" => "text" } + ] + } + ] } - ] - } } } kind { "zip" } + + to_create do |export_template, _context| + export_template.set_default_values + export_template.save + end + + trait :with_custom_content do + to_create do |export_template, context| + export_template.set_default_values + export_template.content = context.content + export_template.save + end + end + + trait :with_custom_ddd_prefix do + transient do + ddd_prefix { 'dossier_' } + end + + to_create do |export_template, context| + export_template.set_default_values + export_template.content["default_dossier_directory"]["content"] = [ + { + "type" => "paragraph", + "content" => + [ + { "text" => context.ddd_prefix, "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => " ", "type" => "text" } + ] + } + ] + export_template.save + end + end + + trait :with_date_depot_for_export_pdf do + to_create do |export_template, _| + export_template.set_default_values + export_template.content["pdf_name"]["content"] = [ + { + "type" => "paragraph", + "content" => + [ + { "text" => "export_", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => "-", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_depose_at", "label" => "date de dépôt" } }, + { "text" => " ", "type" => "text" } + ] + } + ] + export_template.save + end + end end end diff --git a/spec/fixtures/files/pencil.tiff b/spec/fixtures/files/pencil.tiff new file mode 100644 index 000000000..67af5a81a Binary files /dev/null and b/spec/fixtures/files/pencil.tiff differ diff --git a/spec/helpers/gallery_helper_spec.rb b/spec/helpers/gallery_helper_spec.rb new file mode 100644 index 000000000..41469798c --- /dev/null +++ b/spec/helpers/gallery_helper_spec.rb @@ -0,0 +1,57 @@ +RSpec.describe GalleryHelper, type: :helper do + let(:procedure) { create(:procedure_with_dossiers) } + let(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } + let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } + let(:blob_info) do + { + filename: file.original_filename, + byte_size: file.size, + checksum: Digest::SHA256.file(file.path), + content_type: file.content_type, + # we don't want to run virus scanner on this file + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + } + end + let(:blob) do + blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_info) + blob.upload(file) + blob + end + let(:attachment) { ActiveStorage::Attachment.create(name: "test", blob: blob, record: champ_pj) } + + describe ".variant_url_for" do + subject { variant_url_for(attachment) } + + context "when attachment can be represented with a variant" do + let(:file) { fixture_file_upload('spec/fixtures/files/logo_test_procedure.png', 'image/png') } + + it { expect { subject }.to change { ActiveStorage::VariantRecord.count }.by(1) } + it { is_expected.not_to eq("apercu-indisponible.png") } + end + + context "when attachment cannot be represented with a variant" do + let(:file) { fixture_file_upload('spec/fixtures/files/instructeurs-file.csv', 'text/csv') } + + it { expect { subject }.not_to change { ActiveStorage::VariantRecord.count } } + it { is_expected.to eq("apercu-indisponible.png") } + end + end + + describe ".preview_url_for" do + subject { preview_url_for(attachment) } + + context "when attachment can be represented with a preview" do + let(:file) { fixture_file_upload('spec/fixtures/files/RIB.pdf', 'application/pdf') } + + it { expect { subject }.to change { ActiveStorage::VariantRecord.count }.by(1) } + it { is_expected.not_to eq("pdf-placeholder.png") } + end + + context "when attachment cannot be represented with a preview" do + let(:file) { fixture_file_upload('spec/fixtures/files/instructeurs-file.csv', 'text/csv') } + + it { expect { subject }.not_to change { ActiveStorage::VariantRecord.count } } + it { is_expected.to eq("pdf-placeholder.png") } + end + end +end diff --git a/spec/jobs/image_processor_job_spec.rb b/spec/jobs/image_processor_job_spec.rb index e32999273..91d7de2c1 100644 --- a/spec/jobs/image_processor_job_spec.rb +++ b/spec/jobs/image_processor_job_spec.rb @@ -63,7 +63,6 @@ describe ImageProcessorJob, type: :job do end describe 'create representation' do - let(:file) { fixture_file_upload('spec/fixtures/files/logo_test_procedure.png', 'image/png') } let(:blob_info) do { filename: file.original_filename, @@ -81,19 +80,35 @@ describe ImageProcessorJob, type: :job do blob end - context "when representation is not required" do - it "it does not create blob representation" do - expect { described_class.perform_now(blob) }.not_to change { ActiveStorage::VariantRecord.count } + context "when type image is usual" do + let(:file) { fixture_file_upload('spec/fixtures/files/logo_test_procedure.png', 'image/png') } + + context "when representation is not required" do + it "it does not create blob representation" do + expect { described_class.perform_now(blob) }.not_to change { ActiveStorage::VariantRecord.count } + end + end + + context "when representation is required" do + before do + allow(blob).to receive(:representation_required?).and_return(true) + end + + it "it creates blob representation" do + expect { described_class.perform_now(blob) }.to change { ActiveStorage::VariantRecord.count }.by(1) + end end end - context "when representation is required" do + context "when type image is rare" do + let(:file) { fixture_file_upload('spec/fixtures/files/pencil.tiff', 'image/tiff') } + before do allow(blob).to receive(:representation_required?).and_return(true) end - it "it creates blob representation" do - expect { described_class.perform_now(blob) }.to change { ActiveStorage::VariantRecord.count }.by(1) + it "creates a second variant" do + expect { described_class.perform_now(blob) }.to change { ActiveStorage::VariantRecord.count }.by(2) end end end diff --git a/spec/lib/balancer_delivery_method_spec.rb b/spec/lib/balancer_delivery_method_spec.rb index 659adb46e..92cb80082 100644 --- a/spec/lib/balancer_delivery_method_spec.rb +++ b/spec/lib/balancer_delivery_method_spec.rb @@ -2,8 +2,8 @@ RSpec.describe BalancerDeliveryMethod do class ExampleMailer < ApplicationMailer include BalancedDeliveryConcern - def greet(name, bypass_unverified_mail_protection: true) - mail(to: name, from: "smtp_from", body: "Hello #{name}") + def greet(name, bypass_unverified_mail_protection: true, **mail_args) + mail(to: name, from: "smtp_from", body: "Hello #{name}", **mail_args) bypass_unverified_mail_protection! if bypass_unverified_mail_protection end @@ -202,6 +202,13 @@ RSpec.describe BalancerDeliveryMethod do it { expect(mail).to have_been_delivered_using(MockSmtp) } end end + + context 'when there are only bcc recipients' do + let(:bypass_unverified_mail_protection) { false } + let(:mail) { ExampleMailer.greet(nil, bypass_unverified_mail_protection: false, bcc: ["'u@a.com'"]) } + + it { expect(mail).to have_been_delivered_using(MockSmtp) } + end end # Helpers diff --git a/spec/lib/email_checker_spec.rb b/spec/lib/email_checker_spec.rb new file mode 100644 index 000000000..f9c35ea91 --- /dev/null +++ b/spec/lib/email_checker_spec.rb @@ -0,0 +1,36 @@ +describe EmailChecker do + describe 'check' do + subject { described_class.new } + + 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 diff --git a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb b/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb deleted file mode 100644 index 1a4fdf386..000000000 --- a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -describe '20220705164551_remove_unused_champs' do - let(:rake_task) { Rake::Task['after_party:remove_unused_champs'] } - let(:procedure) { create(:procedure, :with_all_champs) } - let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } - let(:champ_repetition) { dossier.champs_public.find(&:repetition?) } - - subject(:run_task) do - dossier - rake_task.invoke - end - - before { champ_repetition.champs.first.update(type_de_champ: create(:type_de_champ)) } - after { rake_task.reenable } - - describe 'remove_unused_champs' do - it "with bad champs" do - expect(Champ.where(dossier: dossier).count).to eq(44) - run_task - expect(Champ.where(dossier: dossier).count).to eq(43) - end - end -end diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index f52242650..92f0b0944 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -2112,25 +2112,19 @@ describe Dossier, type: :model do create(:attestation, dossier: dossier) end - it "can destroy dossier" do + it "can destroy dossier, reset demarche, logg context" do + json_message = nil + allow(Rails.logger).to receive(:info) { json_message ||= _1 } + expect(dossier.destroy).to be_truthy expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "can reset demarche" do - expect { dossier.procedure.reset! }.not_to raise_error - expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "call logger with context" do - json_message = nil - - allow(Rails.logger).to receive(:info) { json_message ||= _1 } - dossier.destroy expect(JSON.parse(json_message)).to a_hash_including( { message: "Dossier destroyed", dossier_id: dossier.id, procedure_id: procedure.id }.stringify_keys ) + + expect { dossier.procedure.reset! }.not_to raise_error + expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index f0602597a..d90de6744 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -1,6 +1,6 @@ describe ExportTemplate do let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } - let(:export_template) { create(:export_template, groupe_instructeur:, content:) } + let(:export_template) { create(:export_template, :with_custom_content, groupe_instructeur:, content:) } let(:procedure) { create(:procedure_with_dossiers, types_de_champ_public:, for_individual:) } let(:dossier) { procedure.dossiers.first } let(:for_individual) { false } @@ -69,6 +69,22 @@ describe ExportTemplate do end end + describe '#assign_pj_names' do + let(:pj_params) do + { + "tiptap_pj_1" => { + "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json + } + end + it 'values content from pj params' do + export_template.assign_pj_names(pj_params) + expect(export_template.content["pjs"]).to eq [ + { :path => { "content" => [{ "content" => [{ "text" => "avis-commission-", "type" => "text" }, { "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" }, "type" => "mention" }], "type" => "paragraph" }], "type" => "doc" }, :stable_id => "1" } + ] + end + end + describe '#tiptap_default_dossier_directory' do it 'returns tiptap_default_dossier_directory from content' do expect(export_template.tiptap_default_dossier_directory).to eq({ @@ -174,6 +190,14 @@ describe ExportTemplate do it 'convert pdf_name' do expect(export_template.tiptap_convert(procedure.dossiers.first, "pdf_name")).to eq "mon_export_#{dossier.id}" end + + context 'for date' do + let(:export_template) { create(:export_template, :with_date_depot_for_export_pdf, groupe_instructeur:) } + let(:dossier) { create(:dossier, :en_construction, procedure:, depose_at: Date.parse("2024/03/30")) } + it 'convert date with dash' do + expect(export_template.tiptap_convert(dossier, "pdf_name")).to eq "export_#{dossier.id}-2024-03-30" + end + end end describe '#tiptap_convert_pj' do @@ -297,21 +321,37 @@ describe ExportTemplate do end end - describe 'specific_tags' do - context 'for entreprise procedure' do - let(:for_individual) { false } + context 'for entreprise procedure' do + let(:for_individual) { false } + describe 'specific_tags' do it do tags = export_template.specific_tags expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] end end - context 'for individual procedure' do - let(:for_individual) { true } + describe 'tags_for_pj' do + it do + tags = export_template.tags_for_pj + expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + end + end + end + + context 'for individual procedure' do + let(:for_individual) { true } + describe 'specific_tags' do it do tags = export_template.specific_tags expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] end end + + describe 'tags_for_pj' do + it do + tags = export_template.tags_for_pj + expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + end + end end end diff --git a/spec/models/logic/binary_operator_spec.rb b/spec/models/logic/binary_operator_spec.rb index e27c3b7bc..b7924ebc7 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -43,6 +43,8 @@ end describe Logic::GreaterThanEq do include Logic + let(:champ) { create(:champ_integer_number, value: nil) } + it 'computes' do expect(greater_than_eq(constant(0), constant(1)).compute).to be(false) expect(greater_than_eq(constant(1), constant(1)).compute).to be(true) diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index 9d3ea5924..434d82e44 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -347,306 +347,417 @@ describe ProcedureRevision do end end - describe '#compare' do + describe '#compare_types_de_champ' do include Logic - - let(:first_tdc) { draft.types_de_champ_public.first } - let(:second_tdc) { draft.types_de_champ_public.second } let(:new_draft) { procedure.create_new_revision } + subject { procedure.active_revision.compare_types_de_champ(new_draft.reload).map(&:to_h) } - subject { procedure.active_revision.compare(new_draft.reload).map(&:to_h) } + describe 'when tdcs changes' do + let(:first_tdc) { draft.types_de_champ_public.first } + let(:second_tdc) { draft.types_de_champ_public.second } - context 'with a procedure with 2 tdcs' do - let(:procedure) do - create(:procedure, types_de_champ_public: [ - { type: :integer_number, libelle: 'l1' }, - { type: :text, libelle: 'l2' } - ]) + context 'with a procedure with 2 tdcs' do + let(:procedure) do + create(:procedure, types_de_champ_public: [ + { type: :integer_number, libelle: 'l1' }, + { type: :text, libelle: 'l2' } + ]) + end + + context 'when a condition is added' do + before do + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: nil, + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: "(l1 == 3)" + } + ]) + end + end + + context 'when a condition is removed' do + before do + second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) + draft.reload + + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: nil) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: "(l1 == 2)", + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: nil + } + ]) + end + end + + context 'when a condition is changed' do + before do + second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) + draft.reload + + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: "(l1 == 2)", + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: "(l1 == 3)" + } + ]) + end + end end - context 'when a condition is added' do - before do - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + context 'when a type de champ is added' do + let(:procedure) { create(:procedure) } + let(:new_tdc) do + new_draft.add_type_de_champ( + type_champ: TypeDeChamp.type_champs.fetch(:text), + libelle: "Un champ text" + ) end + before { new_tdc } + it do is_expected.to eq([ { - attribute: :condition, - from: nil, - label: "l2", - op: :update, + op: :add, + label: "Un champ text", private: false, - stable_id: second_tdc.stable_id, - to: "(l1 == 3)" + mandatory: false, + stable_id: new_tdc.stable_id } ]) end end - context 'when a condition is removed' do - before do - second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) - draft.reload + context 'when a type de champ is changed' do + context 'when libelle, description, and mandatory are changed' do + let(:procedure) { create(:procedure, :with_type_de_champ) } - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: nil) + before do + updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) + + updated_tdc.update(libelle: 'modifier le libelle', description: 'une description', mandatory: !updated_tdc.mandatory) + end + + it do + is_expected.to eq([ + { + op: :update, + attribute: :libelle, + label: first_tdc.libelle, + private: false, + from: first_tdc.libelle, + to: "modifier le libelle", + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :description, + label: first_tdc.libelle, + private: false, + from: first_tdc.description, + to: "une description", + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :mandatory, + label: first_tdc.libelle, + private: false, + from: false, + to: true, + stable_id: first_tdc.stable_id + } + ]) + end + end + + context 'when collapsible_explanation_enabled and collapsible_explanation_text are changed' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :explication }]) } + + before do + updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) + + updated_tdc.update(collapsible_explanation_enabled: "1", collapsible_explanation_text: 'afficher au clique') + end + it do + is_expected.to eq([ + { + op: :update, + attribute: :collapsible_explanation_enabled, + label: first_tdc.libelle, + private: first_tdc.private?, + from: false, + to: true, + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :collapsible_explanation_text, + label: first_tdc.libelle, + private: first_tdc.private?, + from: nil, + to: 'afficher au clique', + stable_id: first_tdc.stable_id + } + ]) + end + end + end + + context 'when a type de champ is moved' do + let(:procedure) { create(:procedure, types_de_champ_public: Array.new(3) { { type: :text } }) } + let(:new_draft_second_tdc) { new_draft.types_de_champ_public.second } + let(:new_draft_third_tdc) { new_draft.types_de_champ_public.third } + + before do + new_draft_second_tdc + new_draft_third_tdc + new_draft.move_type_de_champ(new_draft_second_tdc.stable_id, 2) end it do is_expected.to eq([ { - attribute: :condition, - from: "(l1 == 2)", - label: "l2", - op: :update, + op: :move, + label: new_draft_third_tdc.libelle, private: false, - stable_id: second_tdc.stable_id, - to: nil - } - ]) - end - end - - context 'when a condition is changed' do - before do - second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) - draft.reload - - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) - end - - it do - is_expected.to eq([ + from: 2, + to: 1, + stable_id: new_draft_third_tdc.stable_id + }, { - attribute: :condition, - from: "(l1 == 2)", - label: "l2", - op: :update, + op: :move, + label: new_draft_second_tdc.libelle, private: false, - stable_id: second_tdc.stable_id, - to: "(l1 == 3)" + from: 1, + to: 2, + stable_id: new_draft_second_tdc.stable_id } ]) end end - end - context 'when a type de champ is added' do - let(:procedure) { create(:procedure) } - let(:new_tdc) do - new_draft.add_type_de_champ( - type_champ: TypeDeChamp.type_champs.fetch(:text), - libelle: "Un champ text" - ) - end - - before { new_tdc } - - it do - is_expected.to eq([ - { - op: :add, - label: "Un champ text", - private: false, - mandatory: false, - stable_id: new_tdc.stable_id - } - ]) - end - end - - context 'when a type de champ is changed' do - context 'when libelle, description, and mandatory are changed' do + context 'when a type de champ is removed' do let(:procedure) { create(:procedure, :with_type_de_champ) } before do - updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) - - updated_tdc.update(libelle: 'modifier le libelle', description: 'une description', mandatory: !updated_tdc.mandatory) + new_draft.remove_type_de_champ(first_tdc.stable_id) end it do is_expected.to eq([ { - op: :update, - attribute: :libelle, + op: :remove, label: first_tdc.libelle, private: false, - from: first_tdc.libelle, - to: "modifier le libelle", - stable_id: first_tdc.stable_id - }, - { - op: :update, - attribute: :description, - label: first_tdc.libelle, - private: false, - from: first_tdc.description, - to: "une description", - stable_id: first_tdc.stable_id - }, - { - op: :update, - attribute: :mandatory, - label: first_tdc.libelle, - private: false, - from: false, - to: true, stable_id: first_tdc.stable_id } ]) end end - context 'when collapsible_explanation_enabled and collapsible_explanation_text are changed' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :explication }]) } + context 'when a child type de champ is transformed into a drop_down_list' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } before do - updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) - - updated_tdc.update(collapsible_explanation_enabled: "1", collapsible_explanation_text: 'afficher au clique') + child = new_draft.children_of(new_draft.types_de_champ_public.last).first + new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :drop_down_list, drop_down_options: ['one', 'two']) end + it do is_expected.to eq([ { op: :update, - attribute: :collapsible_explanation_enabled, - label: first_tdc.libelle, - private: first_tdc.private?, - from: false, - to: true, - stable_id: first_tdc.stable_id + attribute: :type_champ, + label: "sub type de champ", + private: false, + from: "text", + to: "drop_down_list", + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id }, { op: :update, - attribute: :collapsible_explanation_text, - label: first_tdc.libelle, - private: first_tdc.private?, - from: nil, - to: 'afficher au clique', - stable_id: first_tdc.stable_id + attribute: :drop_down_options, + label: "sub type de champ", + private: false, + from: [], + to: ["one", "two"], + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id + } + ]) + end + end + + context 'when a child type de champ is transformed into a map' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } + + before do + child = new_draft.children_of(new_draft.types_de_champ_public.last).first + new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :carte, options: { cadastres: true, znieff: true }) + end + + it do + is_expected.to eq([ + { + op: :update, + attribute: :type_champ, + label: "sub type de champ", + private: false, + from: "text", + to: "carte", + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id + }, + { + op: :update, + attribute: :carte_layers, + label: "sub type de champ", + private: false, + from: [], + to: [:cadastres, :znieff], + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id } ]) end end end + end - context 'when a type de champ is moved' do - let(:procedure) { create(:procedure, types_de_champ_public: Array.new(3) { { type: :text } }) } - let(:new_draft_second_tdc) { new_draft.types_de_champ_public.second } - let(:new_draft_third_tdc) { new_draft.types_de_champ_public.third } + describe 'compare_ineligibilite_rules' do + include Logic + let(:new_draft) { procedure.create_new_revision } + subject { procedure.active_revision.compare_ineligibilite_rules(new_draft.reload) } - before do - new_draft_second_tdc - new_draft_third_tdc - new_draft.move_type_de_champ(new_draft_second_tdc.stable_id, 2) + context 'when ineligibilite_rules changes' do + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :yes_no }] } + let(:yes_no_tdc) { new_draft.types_de_champ_public.first } + + context 'when nothing changed' do + it { is_expected.to be_empty } end - it do - is_expected.to eq([ - { - op: :move, - label: new_draft_third_tdc.libelle, - private: false, - from: 2, - to: 1, - stable_id: new_draft_third_tdc.stable_id - }, - { - op: :move, - label: new_draft_second_tdc.libelle, - private: false, - from: 1, - to: 2, - stable_id: new_draft_second_tdc.stable_id - } - ]) + context 'when ineligibilite_rules added' do + before do + new_draft.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::AddEligibiliteRuleChange)) } + end + + context 'when ineligibilite_rules removed' do + before do + procedure.published_revision.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::RemoveEligibiliteRuleChange)) } + end + + context 'when ineligibilite_rules changed' do + before do + procedure.published_revision.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + new_draft.update!(ineligibilite_rules: ds_and([ + ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)), + empty_operator(empty, empty) + ])) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::UpdateEligibiliteRuleChange)) } + end + + context 'when when ineligibilite_enabled changes from false to true' do + before do + procedure.published_revision.update!(ineligibilite_enabled: false, ineligibilite_message: :required) + new_draft.update!(ineligibilite_enabled: true, ineligibilite_message: :required) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::EligibiliteEnabledChange)) } + end + + context 'when ineligibilite_enabled changes from true to false' do + before do + procedure.published_revision.update!(ineligibilite_enabled: true, ineligibilite_message: :required) + new_draft.update!(ineligibilite_enabled: false, ineligibilite_message: :required) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::EligibiliteDisabledChange)) } + end + + context 'when ineligibilite_message changes' do + before do + procedure.published_revision.update!(ineligibilite_message: :a) + new_draft.update!(ineligibilite_message: :b) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::UpdateEligibiliteMessageChange)) } end end + end - context 'when a type de champ is removed' do - let(:procedure) { create(:procedure, :with_type_de_champ) } - - before do - new_draft.remove_type_de_champ(first_tdc.stable_id) - end - - it do - is_expected.to eq([ - { - op: :remove, - label: first_tdc.libelle, - private: false, - stable_id: first_tdc.stable_id - } - ]) - end + describe 'ineligibilite_rules_are_valid?' do + include Logic + let(:procedure) { create(:procedure) } + let(:draft_revision) { procedure.draft_revision } + let(:ineligibilite_message) { 'ok' } + let(:ineligibilite_enabled) { true } + before do + procedure.draft_revision.update(ineligibilite_rules:, ineligibilite_message:, ineligibilite_enabled:) end - context 'when a child type de champ is transformed into a drop_down_list' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } - - before do - child = new_draft.children_of(new_draft.types_de_champ_public.last).first - new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :drop_down_list, drop_down_options: ['one', 'two']) - end - - it do - is_expected.to eq([ - { - op: :update, - attribute: :type_champ, - label: "sub type de champ", - private: false, - from: "text", - to: "drop_down_list", - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - }, - { - op: :update, - attribute: :drop_down_options, - label: "sub type de champ", - private: false, - from: [], - to: ["one", "two"], - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - } - ]) + context 'when ineligibilite_rules are valid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(true)) } + it 'is valid' do + expect(draft_revision.validate(:publication)).to be_truthy + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_truthy end end - - context 'when a child type de champ is transformed into a map' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } - - before do - child = new_draft.children_of(new_draft.types_de_champ_public.last).first - new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :carte, options: { cadastres: true, znieff: true }) + context 'when ineligibilite_rules are invalid on simple champ' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + it 'is invalid' do + expect(draft_revision.validate(:publication)).to be_falsey + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey end - - it do - is_expected.to eq([ - { - op: :update, - attribute: :type_champ, - label: "sub type de champ", - private: false, - from: "text", - to: "carte", - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - }, - { - op: :update, - attribute: :carte_layers, - label: "sub type de champ", - private: false, - from: [], - to: [:cadastres, :znieff], - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - } - ]) + end + context 'when ineligibilite_rules are invalid on repetition champ' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :integer_number }] }] } + let(:tdc_number) { draft_revision.types_de_champ_for(scope: :public).find { _1.type_champ == 'integer_number' } } + let(:ineligibilite_rules) do + ds_eq(champ_value(tdc_number.stable_id), constant(true)) + end + it 'is invalid' do + expect(draft_revision.validate(:publication)).to be_falsey + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey end end end @@ -828,23 +939,22 @@ describe ProcedureRevision do describe 'conditions_are_valid' do include Logic - def first_champ = procedure.draft_revision.types_de_champ_public.first - - def second_champ = procedure.draft_revision.types_de_champ_public.second - - let(:procedure) do - create(:procedure).tap do |p| - p.draft_revision.add_type_de_champ(type_champ: :integer_number, libelle: 'l1') - p.draft_revision.add_type_de_champ(type_champ: :integer_number, libelle: 'l2') - end + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) do + [ + { type: :integer_number, libelle: 'l1' }, + { type: :integer_number, libelle: 'l2' } + ] end + def first_champ = procedure.draft_revision.types_de_champ_public.first + def second_champ = procedure.draft_revision.types_de_champ_public.second let(:draft_revision) { procedure.draft_revision } let(:condition) { nil } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end context 'when a champ has a valid condition (type)' do @@ -865,7 +975,7 @@ describe ProcedureRevision do before { second_champ.update(condition: condition) } let(:condition) { ds_eq(constant(true), constant(1)) } - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end context 'when a champ has an invalid condition: needed tdc is down in the forms' do @@ -876,7 +986,7 @@ describe ProcedureRevision do first_champ.update(condition: need_second_champ) end - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end context 'with a repetition' do @@ -904,7 +1014,7 @@ describe ProcedureRevision do context 'when a champ belongs to a repetition' do let(:condition) { ds_eq(champ_value(-1), constant(1)) } - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end end end @@ -918,8 +1028,8 @@ describe ProcedureRevision do let(:draft_revision) { procedure.draft_revision } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end it 'find error' do @@ -936,8 +1046,8 @@ describe ProcedureRevision do let(:draft_revision) { procedure.draft_revision } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end context "When no regexp and no example" do diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0589985bb..0b8b3f4ed 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -211,7 +211,7 @@ describe Procedure do it { is_expected.to allow_value('text').on(:publication).for(:cadre_juridique) } context 'with deliberation' do - let(:procedure) { build(:procedure, cadre_juridique: nil) } + let(:procedure) { build(:procedure, cadre_juridique: nil, revisions: [build(:procedure_revision)]) } it { expect(procedure.valid?(:publication)).to eq(false) } @@ -352,24 +352,12 @@ describe Procedure do end describe 'draft_types_de_champ validations' do - let(:repetition) { repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?) } - let(:text_field) { build(:type_de_champ_text) } - let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' } - - let(:drop_down) { build(:type_de_champ_drop_down_list, :without_selectable_values, libelle: 'Civilité') } - let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' } - - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text }, { type: :integer_number }] }]) } - let(:draft) { procedure.draft_revision } - - before do - draft.revision_types_de_champ.create(type_de_champ: drop_down, position: 100) - - repetition.update(libelle: 'Enfants') - draft.children_of(repetition).destroy_all - end + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } context 'on a draft procedure' do + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [{ type: :repetition, libelle: 'Enfants', children: [] }] } + it 'doesn’t validate the types de champs' do procedure.validate expect(procedure.errors[:draft_types_de_champ_public]).not_to be_present @@ -377,46 +365,123 @@ describe Procedure do end context 'when validating for publication' do + let(:types_de_champ_public) do + [ + { type: :repetition, libelle: 'Enfants', children: [] }, + { type: :drop_down_list, libelle: 'Civilité', options: [] } + ] + end + let(:types_de_champ_private) { [] } + let(:invalid_repetition_error_message) { "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?) parent_coordinate = new_draft.revision_types_de_champ.find_by(type_de_champ: repetition) new_draft.revision_types_de_champ.create(type_de_champ: create(:type_de_champ), position: 0, parent: parent_coordinate) 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 context 'when the champ is private' do - before do - repetition.update(private: true) - drop_down.update(private: true) + let(:types_de_champ_private) do + [ + { type: :repetition, libelle: 'Enfants', children: [] }, + { type: :drop_down_list, libelle: 'Civilité', options: [] } + ] end + let(:types_de_champ_public) { [] } - let(:invalid_repetition_error_message) { 'L’annotation privée « Enfants » doit comporter au moins un champ répétable' } - let(:invalid_drop_down_error_message) { 'L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable' } + 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 + + context 'when condition on champ private use public champ' do + include Logic + let(:types_de_champ_public) { [{ type: :decimal_number, stable_id: 1 }] } + let(:types_de_champ_private) { [{ type: :text, condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } + it 'validate without context' do + 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 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 }] } + let(:types_de_champ_private) { [{ type: :decimal_number, stable_id: 1 }] } + let(:error_on_condition) { "Le champ a une logique conditionnelle invalide" } + + it 'validate without context' do + procedure.validate + expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to be_empty + end + + it 'validate prevent condition' do + procedure.validate(:types_de_champ_public_editor) + expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(error_on_condition) end end end @@ -1776,6 +1841,37 @@ describe Procedure do end end + describe "#parsed_latest_zone_labels" do + let!(:draft_procedure) { create(:procedure) } + let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } + let!(:closed_procedure) { create(:procedure, :closed) } + let!(:procedure_detail_draft) { ProcedureDetail.new(id: draft_procedure.id, latest_zone_labels: '{ "zone1", "zone2" }') } + let!(:procedure_detail_published) { ProcedureDetail.new(id: published_procedure.id, latest_zone_labels: '{ "zone3", "zone4" }') } + let!(:procedure_detail_closed) { ProcedureDetail.new(id: closed_procedure.id, latest_zone_labels: '{ "zone5", "zone6" }') } + context 'with parsed latest zone labels' do + it 'parses the latest zone labels correctly' do + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq(["zone1", "zone2"]) + expect(procedure_detail_published.parsed_latest_zone_labels).to eq(["zone3", "zone4"]) + expect(procedure_detail_closed.parsed_latest_zone_labels).to eq(["zone5", "zone6"]) + end + + it 'returns an empty array for invalid JSON' do + procedure_detail_draft.latest_zone_labels = '{ invalid json }' + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + + it 'returns an empty array when latest_zone_labels is nil' do + procedure_detail_draft.latest_zone_labels = nil + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + + it 'returns an empty array when latest_zone_labels is empty' do + procedure_detail_draft.latest_zone_labels = '' + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + end + end + private def create_dossier_with_pj_of_size(size, procedure) diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index cef564981..341cb33ad 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -110,7 +110,7 @@ describe PiecesJustificativesService do it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } context 'with export_template' do - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } end end @@ -164,6 +164,13 @@ describe PiecesJustificativesService do end it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachments) } + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/messagerie")).to be true + end + end end context 'with a pj not safe on a commentaire' do @@ -180,6 +187,13 @@ describe PiecesJustificativesService do let!(:witness) { create(:dossier, :with_justificatif) } it { expect(subject).to match_array(dossier.justificatif_motivation.attachment) } + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/dossier")).to be true + end + end end context 'with a motivation not safe' do @@ -195,6 +209,16 @@ describe PiecesJustificativesService do let!(:witness) { create(:dossier, :with_attestation) } it { expect(subject).to match_array(dossier.attestation.pdf.attachment) } + it 'uses default name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template: nil).liste_documents(dossiers).map(&:second)[0].starts_with?("dossier-#{dossier.id}/pieces_justificatives")).to be true + end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true + end + end end context 'with an etablissement' do @@ -212,6 +236,17 @@ describe PiecesJustificativesService do end it { expect(subject).to match_array([attestation_sociale.attachment, attestation_fiscale.attachment]) } + + it 'uses default name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template: nil).liste_documents(dossiers).map(&:second)[0].starts_with?("dossier-#{dossier.id}/pieces_justificatives")).to be true + end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true + end + end end end @@ -323,14 +358,14 @@ describe PiecesJustificativesService do context 'given an administrateur' do let(:user_profile) { build(:administrateur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an instructeur' do let(:user_profile) { create(:instructeur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end @@ -346,7 +381,7 @@ describe PiecesJustificativesService do let(:experts_procedure) { create(:experts_procedure, expert: user_profile, procedure:) } let(:avis) { create(:avis, experts_procedure:, dossier: dossier, confidentiel: true) } let(:user_profile) { create(:expert) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end @@ -370,21 +405,28 @@ describe PiecesJustificativesService do context 'given an administrateur' do let(:user_profile) { build(:administrateur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an instructeur' do let(:user_profile) { create(:instructeur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/avis")).to be true + end + end end context 'given an expert' do let(:user_profile) { create(:expert) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end @@ -397,7 +439,8 @@ describe PiecesJustificativesService do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :piece_justificative }] }]) } let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } let(:dossiers) { Dossier.where(id: dossier.id) } - subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } + let(:export_template) { nil } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } it "doesn't update dossier" do expect { subject }.not_to change { dossier.updated_at } @@ -409,11 +452,24 @@ describe PiecesJustificativesService do let!(:not_confidentiel_avis) { create(:avis, :not_confidentiel, dossier: dossier) } let!(:expert_avis) { create(:avis, :confidentiel, dossier: dossier, expert: user_profile) } - subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } it "includes avis not confidentiel as well as expert's avis" do expect_any_instance_of(Dossier).to receive(:avis_for_expert).with(user_profile).and_return([]) subject end + + it 'gives default name to export pdf file' do + expect(subject.first.second.starts_with?("dossier-#{dossier.id}/export-#{dossier.id}")).to eq true + end + end + + context 'with export template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } + + it 'gives custom name to export pdf file' do + expect(subject.first.second).to eq "DOSSIER-#{dossier.id}/export_#{dossier.id}.pdf" + end end end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index b463675be..30488f846 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -2,7 +2,6 @@ require 'csv' describe ProcedureExportService do let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } let(:export_template) { nil } @@ -18,159 +17,166 @@ describe ProcedureExportService do let(:avis_sheet) { subject.sheets.third } let(:repetition_sheet) { subject.sheets.fourth } - before do - # change one tdc place to check if the header is ordered - tdc_first = procedure.active_revision.revision_types_de_champ_public.first - tdc_last = procedure.active_revision.revision_types_de_champ_public.last - - tdc_first.update(position: tdc_last.position + 1) - procedure.reload - end - describe 'sheets' do + let(:procedure) { create(:procedure) } + it 'should have a sheet for each record type' do expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis']) end end describe 'Dossiers sheet' do - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + context 'with all data for individual' do + let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } - let(:nominal_headers) do - [ - "ID", - "Email", - "FranceConnect ?", - "Civilité", - "Nom", - "Prénom", - "Dépôt pour un tiers", - "Nom du mandataire", - "Prénom du mandataire", - "Archivé", - "État du dossier", - "Dernière mise à jour le", - "Dernière mise à jour du dossier le", - "Déposé le", - "Passé en instruction le", - "Traité le", - "Motivation de la décision", - "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" - ] - end + # before do + # # change one tdc place to check if the header is ordered + # tdc_first = procedure.active_revision.revision_types_de_champ_public.first + # tdc_last = procedure.active_revision.revision_types_de_champ_public.last - it 'should have headers' do - expect(dossiers_sheet.headers).to match_array(nominal_headers) - end + # tdc_first.update(position: tdc_last.position + 1) + # procedure.reload + # end - it 'should have data' do - expect(dossiers_sheet.data.size).to eq(1) - expect(etablissements_sheet.data.size).to eq(1) + let(:nominal_headers) do + [ + "ID", + "Email", + "FranceConnect ?", + "Civilité", + "Nom", + "Prénom", + "Dépôt pour un tiers", + "Nom du mandataire", + "Prénom du mandataire", + "Archivé", + "État du dossier", + "Dernière mise à jour le", + "Dernière mise à jour du dossier le", + "Déposé le", + "Passé en instruction le", + "Traité le", + "Motivation de la décision", + "Instructeurs", + "textarea", + "date", + "datetime", + "number", + "decimal_number", + "integer_number", + "checkbox", + "civilite", + "email", + "phone", + "address", + "simple_drop_down_list", + "multiple_drop_down_list", + "linked_drop_down_list", + "communes", + "communes (Code INSEE)", + "communes (Département)", + "departements", + "departements (Code)", + "regions", + "regions (Code)", + "pays", + "pays (Code)", + "dossier_link", + "piece_justificative", + "rna", + "carte", + "titre_identite", + "iban", + "siret", + "annuaire_education", + "cnaf", + "dgfip", + "pole_emploi", + "mesri", + "text", + "epci", + "epci (Code)", + "epci (Département)", + "cojo", + "expression_reguliere", + "rnf", + "rnf (Nom)", + "rnf (Adresse)", + "rnf (Code INSEE Ville)", + "rnf (Département)", + "engagement_juridique", + "yes_no" + ] + end - # SimpleXlsxReader is transforming datetimes in utc... It is only used in test so we just hack around. - offset = dossier.depose_at.utc_offset - depose_at = Time.zone.at(dossiers_sheet.data[0][13] - offset.seconds) - en_instruction_at = Time.zone.at(dossiers_sheet.data[0][14] - offset.seconds) - expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) - expect(depose_at).to eq(dossier.depose_at.round) - expect(en_instruction_at).to eq(dossier.en_instruction_at.round) + it 'should have data' do + expect(dossiers_sheet.headers).to match_array(nominal_headers) + + expect(dossiers_sheet.data.size).to eq(1) + expect(etablissements_sheet.data.size).to eq(1) + + # SimpleXlsxReader is transforming datetimes in utc... It is only used in test so we just hack around. + offset = dossier.depose_at.utc_offset + depose_at = Time.zone.at(dossiers_sheet.data[0][13] - offset.seconds) + en_instruction_at = Time.zone.at(dossiers_sheet.data[0][14] - offset.seconds) + expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) + expect(depose_at).to eq(dossier.depose_at.round) + expect(en_instruction_at).to eq(dossier.en_instruction_at.round) + end end context 'with a birthdate' do - before { procedure.update(ask_birthday: true) } + let(:procedure) { create(:procedure, :published, :for_individual, ask_birthday: true) } + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } - let(:birthdate_headers) { nominal_headers.insert(nominal_headers.index('Archivé'), 'Date de naissance') } - - it { expect(dossiers_sheet.headers).to match_array(birthdate_headers) } - it { expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Date de naissance')]).to be_a(Date) } + it 'find date de naissance' do + expect(dossiers_sheet.headers).to include('Date de naissance') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Date de naissance')]).to be_a(Date) + end end context 'with a procedure routee' do - before { create(:groupe_instructeur, label: '2', procedure: procedure) } + let(:procedure) { create(:procedure, :published, :for_individual) } + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } + before { create(:groupe_instructeur, label: '2', procedure:) } - let(:routee_headers) { nominal_headers.insert(nominal_headers.index('textarea'), 'Groupe instructeur') } - - it { expect(dossiers_sheet.headers).to match_array(routee_headers) } - it { expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') } + it 'find groupe instructeur data' do + expect(dossiers_sheet.headers).to include('Groupe instructeur') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') + end end context 'with a dossier having multiple pjs' do - let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :piece_justificative }] } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } before do dossier_2.champs_public .find { _1.is_a? Champs::PieceJustificativeChamp } .piece_justificative_file .attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") end - it { expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) } + it { expect(dossiers_sheet.data.first.size).to eq(19) } # default number of header when procedure has only one champ end context 'with procedure chorus' do - let(:procedure) { create(:procedure, :published, :for_individual, :filled_chorus, :with_all_champs) } - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, procedure: procedure) } + before { expect_any_instance_of(Procedure).to receive(:chorusable?).and_return(true) } + let(:procedure) { create(:procedure, :published, :for_individual, :filled_chorus) } + let!(:dossier) { create(:dossier, :en_instruction, procedure: procedure) } it 'includes chorus headers' do - expected_headers = [ - 'Domaine Fonctionnel', - 'Référentiel De Programmation', - 'Centre De Coup' - ] - - expect(dossiers_sheet.headers).to match_array(nominal_headers) + expect(dossiers_sheet.headers).to include('Domaine Fonctionnel') + expect(dossiers_sheet.headers).to include('Référentiel De Programmation') + expect(dossiers_sheet.headers).to include('Centre De Coût') end end end describe 'Etablissement sheet' do - let(:procedure) { create(:procedure, :published, :with_all_champs) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :siret, libelle: 'siret' }] } let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_entreprise, procedure: procedure) } let(:dossier_etablissement) { etablissements_sheet.data[1] } @@ -191,54 +197,7 @@ describe ProcedureExportService do "Traité le", "Motivation de la décision", "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" + 'siret' ] end @@ -294,54 +253,7 @@ describe ProcedureExportService do "Traité le", "Motivation de la décision", "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" + 'siret' ] end @@ -352,7 +264,7 @@ describe ProcedureExportService do end end - it 'should have headers' do + it 'should have headers and data' do expect(dossiers_sheet.headers).to match_array(nominal_headers) expect(etablissements_sheet.headers).to eq([ @@ -391,9 +303,7 @@ describe ProcedureExportService do "Association date de déclaration", "Association date de publication" ]) - end - it 'should have data' do expect(etablissements_sheet.data.size).to eq(2) expect(dossier_etablissement[1]).to eq("Dossier") expect(champ_etablissement[1]).to eq("siret") @@ -401,10 +311,11 @@ describe ProcedureExportService do end describe 'Avis sheet' do + let(:procedure) { create(:procedure, :published, :for_individual) } let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } let!(:avis) { create(:avis, :with_answer, dossier: dossier) } - it 'should have headers' do + it 'should have headers and data' do expect(avis_sheet.headers).to eq([ "Dossier ID", "Introduction", @@ -416,14 +327,20 @@ describe ProcedureExportService do "Instructeur", "Expert" ]) - end - - it 'should have data' do expect(avis_sheet.data.size).to eq(1) end end describe 'Repetitions sheet' do + before do + # change one tdc place to check if the header is ordered + tdc_first = procedure.active_revision.revision_types_de_champ_public.first + tdc_last = procedure.active_revision.revision_types_de_champ_public.last + + tdc_first.update(position: tdc_last.position + 1) + procedure.reload + end + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public: [{ type: :repetition, children: [{ libelle: 'Nom' }, { libelle: 'Age' }] }]) } let!(:dossiers) do [ @@ -509,6 +426,9 @@ describe ProcedureExportService do end describe 'to_zip' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :piece_justificative, libelle: 'piece_justificative' }] } + subject { service.to_zip } context 'without files' do it 'does not raises in_batches' do @@ -529,7 +449,7 @@ describe ProcedureExportService do context 'with export_template' do let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template:).generate_dossiers_export(Dossier.where(id: dossier)) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } before do allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") end @@ -588,6 +508,9 @@ describe ProcedureExportService do end describe 'to_geo_json' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :carte }] } + subject do service .to_geo_json diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 0daced35e..e4daaf3ee 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -2,7 +2,7 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative, libelle: 'repet_pj' }] }]) } let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') @@ -39,8 +39,7 @@ describe ProcedureExportService do ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do subject end - - expect(sql_count <= 58).to be_truthy + expect(sql_count <= 62).to be_truthy dossier = dossiers.first diff --git a/spec/system/accessibilite/wcag_usager_spec.rb b/spec/system/accessibilite/wcag_usager_spec.rb index d8ad6fd10..16e6b23c2 100644 --- a/spec/system/accessibilite/wcag_usager_spec.rb +++ b/spec/system/accessibilite/wcag_usager_spec.rb @@ -1,33 +1,25 @@ describe 'wcag rules for usager', js: true do - let(:procedure) { create(:procedure, :published, :with_all_champs, :with_service, :for_individual) } + let(:procedure) { create(:procedure, :published, :with_service, :for_individual) } let(:password) { 'a very complicated password' } let(:litteraire_user) { create(:user, password: password) } - before do - procedure.active_revision.types_de_champ_public.find { |tdc| tdc.type_champ == TypeDeChamp.type_champs.fetch(:carte) }.destroy - end + def test_external_links_have_title_says_it_opens_in_a_new_tab + links = page.all("a[target=_blank]") + expect(links.count).to be_positive - shared_examples "external links have title says it opens in a new tab" do - it do - links = page.all("a[target=_blank]") - expect(links.count).to be_positive - - links.each do |link| - expect(link[:title]).to include("Nouvel onglet"), "link #{link[:href]} does not have title mentioning it opens in a new tab" - end + links.each do |link| + expect(link[:title]).to include("Nouvel onglet"), "link #{link[:href]} does not have title mentioning it opens in a new tab" end end - shared_examples "aria-label do not mix with title attribute" do - it do - elements = page.all("[aria-label][title]") - elements.each do |element| - expect(element[:title]).to be_blank, "path=#{path}, element title=\"#{element[:title]}\" mixes aria-label and title attributes" - end + def test_aria_label_do_not_mix_with_title_attribute + elements = page.all("[aria-label][title]") + elements.each do |element| + expect(element[:title]).to be_blank, "path=#{path}, element title=\"#{element[:title]}\" mixes aria-label and title attributes" end end - def expect_axe_clean_without_main_navigation + def test_expect_axe_clean_without_main_navigation # On page without main navigation content (like anonymous home page), # there are either a bug in axe, either dsfr markup is not conform to wcag2a. # There is no issue on pages having a child navigation. @@ -35,10 +27,6 @@ describe 'wcag rules for usager', js: true do expect(page).to be_axe_clean.within("#modal-header__menu").skipping("aria-prohibited-attr") end - shared_examples "axe clean without main navigation" do - it { expect_axe_clean_without_main_navigation } - end - context 'pages without the need to be logged in' do before do visit path @@ -46,16 +34,20 @@ describe 'wcag rules for usager', js: true do context 'homepage' do let(:path) { root_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'sign_up page' do let(:path) { new_user_registration_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end scenario 'account confirmation page' do @@ -66,43 +58,51 @@ describe 'wcag rules for usager', js: true do perform_enqueued_jobs do click_button 'Créer un compte' - expect_axe_clean_without_main_navigation + test_expect_axe_clean_without_main_navigation end end context 'sign_up confirmation' do let(:path) { user_confirmation_path("user[email]" => "some@email.com") } - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + end end context 'sign_in page' do let(:path) { new_user_session_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'contact page' do let(:path) { contact_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'commencer page' do let(:path) { commencer_path(path: procedure.path) } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end scenario 'commencer page, help dropdown' do visit commencer_path(path: procedure.reload.path) page.find("#help-menu_button").click - expect_axe_clean_without_main_navigation + test_expect_axe_clean_without_main_navigation end end @@ -135,7 +135,7 @@ describe 'wcag rules for usager', js: true do end context "logged in, depot d'un dossier entreprise" do - let(:procedure) { create(:procedure, :with_all_champs, :with_service, :published) } + let(:procedure) { create(:procedure, :with_service, :published) } before do login_as litteraire_user, scope: :user @@ -163,11 +163,6 @@ describe 'wcag rules for usager', js: true do dossier visit dossiers_path expect(page).to be_axe_clean - end - - scenario 'liste des dossiers et actions sur le dossier' do - dossier - visit dossiers_path page.find("#actions_menu_dossier_#{dossier.id}_button").click expect(page).to be_axe_clean end diff --git a/spec/system/administrateurs/procedure_administrateurs_spec.rb b/spec/system/administrateurs/procedure_administrateurs_spec.rb index 420c439b3..b2111cd14 100644 --- a/spec/system/administrateurs/procedure_administrateurs_spec.rb +++ b/spec/system/administrateurs/procedure_administrateurs_spec.rb @@ -11,26 +11,17 @@ describe 'Administrateurs can manage administrateurs', js: true do login_as administrateur.user, scope: :user end - scenario 'card is clickable' do + scenario "card is clickable, and i can send invitation when i'm not a manager" do + another_administrateur = create(:administrateur) visit admin_procedure_path(procedure) find('#administrateurs').click expect(page).to have_css("h1", text: "Administrateurs") - end - context 'as admin not flagged from manager' do - let(:manager) { false } + fill_in('administrateur_email', with: another_administrateur.email) - scenario 'the administrator can add another administrator' do - another_administrateur = create(:administrateur) - visit admin_procedure_administrateurs_path(procedure) - - fill_in('administrateur_email', with: another_administrateur.email) - - click_on 'Ajouter comme administrateur' - - within('.alert-success') do - expect(page).to have_content(another_administrateur.email) - end + click_on 'Ajouter comme administrateur' + within('.alert-success') do + expect(page).to have_content(another_administrateur.email) end end diff --git a/spec/system/administrateurs/procedure_ineligibilite_spec.rb b/spec/system/administrateurs/procedure_ineligibilite_spec.rb new file mode 100644 index 000000000..e93a6ea5d --- /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("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 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') + 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: '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' + + # 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 diff --git a/spec/system/administrateurs/procedure_publish_spec.rb b/spec/system/administrateurs/procedure_publish_spec.rb index f1109ec4c..dc1858358 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -44,13 +44,10 @@ describe 'Publishing a procedure', js: true do end context 'when a procedure isn’t published yet' do - before do - visit admin_procedures_path(statut: "brouillons") - click_on procedure.libelle - find('#publish-procedure-link').click - end - scenario 'an admin can publish it' do + visit admin_procedure_path(procedure) + find('#publish-procedure-link').click + expect(find_field('procedure_path').value).to eq procedure.path fill_in 'lien_site_web', with: 'http://some.website' within('form') { click_on 'Publier' } @@ -72,10 +69,13 @@ describe 'Publishing a procedure', js: true do end scenario 'an error message prevents the publication' do - expect(page).to have_content('Des problèmes empêchent la publication de la démarche') - expect(page).to have_content("Le champ « Enfants » doit comporter au moins un champ répétable") - expect(page).to have_content("L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable") + visit admin_procedure_path(procedure) + expect(page).to have_content('Des problèmes empêchent la publication de la démarche') + expect(page).to have_content("Enfants doit comporter au moins un champ répétable") + expect(page).to have_content("Civilité doit comporter au moins un choix sélectionnable") + + visit admin_procedure_publication_path(procedure) expect(find_field('procedure_path').value).to eq procedure.path fill_in 'lien_site_web', with: 'http://some.website' @@ -85,8 +85,9 @@ describe 'Publishing a procedure', js: true do context 'when the procedure has the same path as another procedure from another admin ' do scenario 'an error message prevents the publication' do - expect(find_field('procedure_path').value).to eq procedure.path + visit admin_procedure_publication_path(procedure) fill_in 'procedure_path', with: other_procedure.path + expect(page).to have_content 'vous devez la modifier afin de pouvoir publier votre démarche' fill_in 'lien_site_web', with: 'http://some.website' @@ -194,7 +195,7 @@ describe 'Publishing a procedure', js: true do scenario 'an error message prevents the publication' do visit admin_procedure_path(procedure) expect(page).to have_content('Des problèmes empêchent la publication des modifications') - expect(page).to have_link('corriger', href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG)) + expect(page).to have_link(href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG)) expect(page).to have_button('Publier les modifications', disabled: true) end end diff --git a/spec/system/administrateurs/types_de_champ_spec.rb b/spec/system/administrateurs/types_de_champ_spec.rb index 8cbba47a7..28023d9b8 100644 --- a/spec/system/administrateurs/types_de_champ_spec.rb +++ b/spec/system/administrateurs/types_de_champ_spec.rb @@ -228,9 +228,7 @@ describe 'As an administrateur I can edit types de champ', js: true do click_on 'Supprimer' end end - - expect(page).to have_content("Le formulaire contient des erreurs") - expect(page).to have_content("Le titre de section suivant est invalide, veuillez le corriger :") + expect(page).to have_content("devrait être précédé d'un titre de niveau 1") end end diff --git a/spec/system/users/dossier_ineligibilite_spec.rb b/spec/system/users/dossier_ineligibilite_spec.rb new file mode 100644 index 000000000..5bbb25c75 --- /dev/null +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -0,0 +1,182 @@ +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.second } + 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 + + 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 + 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 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 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 + 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" } + 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 + click_on "Déposer le dossier" + wait_until { dossier.reload.en_construction? == true } + end + end + + 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([ + 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") + + # 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) + + # first 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" + + # 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 + 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) + 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 + 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 diff --git a/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb b/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb index fcc3aa25c..18b91611f 100644 --- a/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb +++ b/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb @@ -40,7 +40,7 @@ describe 'administrateurs/experts_procedures/index', type: :view do context 'when the experts_require_administrateur_invitation is false' do it 'authorize instructors to invite any expert' do - expect(rendered).not_to have_content "Affecter des experts à la démarche" + expect(rendered).not_to have_content "Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie" end end @@ -50,7 +50,7 @@ describe 'administrateurs/experts_procedures/index', type: :view do subject end it 'does not authorize instructors to invite any expert but only those presents in admin list' do - expect(rendered).to have_content "Affecter des experts à la démarche" + expect(rendered).to have_content "Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie" end end end diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb index c242f3ec8..f6ce8f5bf 100644 --- a/spec/views/shared/dossiers/_edit.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -149,4 +149,17 @@ describe 'shared/dossiers/edit', type: :view do end end end + + context 'when dossier transitions rules are computable and passer_en_construction is false' do + let(:types_de_champ_public) { [] } + let(:dossier) { create(:dossier, procedure:) } + + before do + allow(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