Merge pull request #10292 from mfo/US/elligible-on-submit

ETQ administrateur, je peux ajouter des conditions d'eligibilité auxquelles les dossiers doivent correspondre sans quoi l'usager ne peut déposer son dossier
This commit is contained in:
mfo 2024-06-11 09:46:25 +00:00 committed by GitHub
commit f6a5e932b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 1772 additions and 387 deletions

View file

@ -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;
}
}
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,6 @@
---
fr:
display_if: Bloquer si
select: Sélectionner
add_condition: Ajouter une règle dinéligibilité
remove_a_row: Supprimer une règle

View file

@ -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 diné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 diné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'

View file

@ -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

View file

@ -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 <strong>only the owner themselves can submit it</strong>.

View file

@ -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 quinvité, vous pouvez remplir ce formulaire mais <strong>le titulaire du dossier doit le déposer lui-même</strong>.

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -0,0 +1,5 @@
fr:
modal:
title: "Vous ne pouvez pas déposer votre dossier"
close: "Fermer"
close_alt: "Fermer la fenêtre modale"

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
---
en:
see_more: Show all errors

View file

@ -0,0 +1,3 @@
---
fr:
see_more: Afficher toutes les erreurs

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,8 @@
---
fr:
title: Inéligibilité des dossiers
state:
pending: Désactivé
ready: À configurer
completed: Activé
subtitle: Gérez vos conditions dinéligibilité en fonction des champs du formulaire

View file

@ -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')

View file

@ -1,4 +1,6 @@
class Procedure::ErrorsSummary < ApplicationComponent
ErrorDescriptor = Data.define(:anchor, :label, :error_message)
def initialize(procedure:, validation_context:)
@procedure = procedure
@validation_context = validation_context
@ -24,14 +26,14 @@ class Procedure::ErrorsSummary < ApplicationComponent
@procedure.errors.present?
end
def error_messages
@procedure.errors.map do |error|
[error, error_correction_page(error)]
end
def errors
@procedure.errors.map { to_error_descriptor(_1) }
end
def error_correction_page(error)
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))
@ -45,4 +47,14 @@ class Procedure::ErrorsSummary < ApplicationComponent
edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG))
end
end
def to_error_descriptor(error)
libelle = case error.attribute
when :draft_types_de_champ_public, :draft_types_de_champ_private
error.options[:type_de_champ].libelle.truncate(200)
else
error.base.class.human_attribute_name(error.attribute)
end
ErrorDescriptor.new(error_correction_page(error), libelle, error.message)
end
end

View file

@ -2,8 +2,4 @@
- if invalid?
= render Dsfr::AlertComponent.new(state: :error, title: , extra_class_names: 'fr-mb-2w') do |c|
- c.with_body do
- error_messages.each do |(error, path)|
%p.mt-2
= error.full_message
- if path.present?
= "(#{link_to 'corriger', path, class: 'fr-link'})"
= render ExpandableErrorList.new(errors:)

View file

@ -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

View file

@ -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 <a href="%{href}">gestion de la démarche</a>

View file

@ -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))

View file

@ -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

View file

@ -80,3 +80,10 @@ fr:
update_expression_reguliere_exemple_text: Lexemple dexpression régulière de lannotation privée « %{label} » a été modifiée. Le nouvel exemple est « %{to} ».
remove_expression_reguliere_error_message: Le message derreur de lexpression régulière de lannotation privée « %{label} » a été supprimé.
update_expression_reguliere_error_message: Le message derreur de lexpression régulière de lannotation privée « %{label} » a été modifiée. Le nouveau message est « %{to} ».
ineligibilite_rules:
add: La condition dinéligibilité « %{new_condition} » a été ajoutée.
remove: La condition dinéligibilité « %{previous_condition} » a été supprimée
update: La conditon dinéligibilité « %{previous_condition} » a été changée pour « %{new_condition} »
enabled: "Linéligibilité des dossiers a été activée"
disabled: "Linéligibilité des dossiers a été désactivée"
message_updated: "Le message dinéligibilité a été changé pour « %{ineligibilite_message} »"

View file

@ -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)

View file

@ -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('leligibilité 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

View file

@ -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

View file

@ -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!

View file

@ -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);
}
}

View file

@ -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

View file

@ -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)

View file

@ -156,7 +156,7 @@ class Dossier < ApplicationRecord
state :sans_suite
event :passer_en_construction, after: :after_passer_en_construction, after_commit: :after_commit_passer_en_construction do
transitions from: :brouillon, to: :en_construction
transitions from: :brouillon, to: :en_construction, guard: :can_passer_en_construction?
end
event :passer_en_instruction, after: :after_passer_en_instruction, after_commit: :after_commit_passer_en_instruction do
@ -562,6 +562,12 @@ class Dossier < ApplicationRecord
procedure.feature_enabled?(:blocking_pending_correction) && pending_correction?
end
def can_passer_en_construction?
return true if !revision.ineligibilite_enabled
!revision.ineligibilite_rules.compute(champs_for_revision(scope: :public))
end
def can_passer_en_instruction?
return false if blocked_with_pending_correction?
@ -936,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)

View file

@ -293,7 +293,7 @@ class Procedure < ApplicationRecord
validates_with MonAvisEmbedValidator
validates_associated :draft_revision, on: :publication
validate :validates_associated_draft_revision_with_context
validates_associated :initiated_mail, on: :publication
validates_associated :received_mail, on: :publication
validates_associated :closed_mail, on: :publication
@ -431,11 +431,15 @@ class Procedure < ApplicationRecord
def draft_changed?
preload_draft_and_published_revisions
!brouillon? && published_revision.different_from?(draft_revision) && revision_changes.present?
!brouillon? && (types_de_champ_revision_changes.present? || ineligibilite_rules_revision_changes.present?)
end
def revision_changes
published_revision.compare(draft_revision)
def types_de_champ_revision_changes
published_revision.compare_types_de_champ(draft_revision)
end
def ineligibilite_rules_revision_changes
published_revision.compare_ineligibilite_rules(draft_revision)
end
def preload_draft_and_published_revisions
@ -1017,6 +1021,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?

View file

@ -1,4 +1,5 @@
class ProcedureRevision < ApplicationRecord
include Logic
self.implicit_order_column = :created_at
belongs_to :procedure, -> { with_discarded }, inverse_of: :revisions, optional: false
belongs_to :dossier_submitted_message, inverse_of: :revisions, optional: true, dependent: :destroy
@ -17,8 +18,19 @@ class ProcedureRevision < ApplicationRecord
scope :ordered, -> { order(:created_at) }
validates :ineligibilite_message, presence: true, if: -> { ineligibilite_enabled? }
delegate :path, to: :procedure, prefix: true
validate :ineligibilite_rules_are_valid?,
on: [:ineligibilite_rules_editor, :publication]
validates :ineligibilite_message,
presence: true,
if: -> { ineligibilite_enabled? },
on: [:ineligibilite_rules_editor, :publication]
serialize :ineligibilite_rules, LogicSerializer
def build_champs_public
# reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc
types_de_champ_public.reload.map(&:build_champ)
@ -136,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)
@ -251,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
@ -318,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
@ -442,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)

View file

@ -1,4 +1,5 @@
class ProcedureRevisionChange
class TypeDeChange
attr_reader :type_de_champ
def initialize(type_de_champ)
@type_de_champ = type_de_champ
@ -10,8 +11,9 @@ class ProcedureRevisionChange
def child? = @type_de_champ.child?
def to_h = { op:, stable_id:, label:, private: private? }
end
class AddChamp < ProcedureRevisionChange
class AddChamp < TypeDeChange
def initialize(type_de_champ)
super(type_de_champ)
end
@ -23,7 +25,7 @@ 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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1 @@
= render partial: 'update'

View file

@ -0,0 +1 @@
= render partial: 'update'

View file

@ -0,0 +1 @@
= render partial: 'update'

View file

@ -0,0 +1 @@
= render partial: 'update'

View file

@ -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 dinéligibilité ne pourront pas être déposés. Plus dinformations sur linéligibilité des dossiers dans la
= link_to('doc', ELIGIBILITE_URL, title: "Document sur linéligibilité des dossiers", **external_link_attributes)
- if !@procedure.draft_revision.conditionable_types_de_champ.present?
%p.fr-mt-2w.fr-mb-2w
Pour configurer linéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les conditions diné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 diné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)

View file

@ -0,0 +1 @@
= render partial: 'update'

View file

@ -8,7 +8,7 @@
%p.mb-2= t('.draft_changed_procedure_alert')
= render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c|
- c.with_body do
= render Procedure::RevisionChangesComponent.new changes: procedure.revision_changes, previous_revision: procedure.published_revision
= render Procedure::RevisionChangesComponent.new new_revision: procedure.draft_revision, previous_revision: procedure.published_revision
- if procedure.close?
= render partial: 'publication_form_inputs', locals: { procedure: procedure, closed_procedures: @closed_procedures, form: f }
- elsif @procedure.brouillon? && @procedure.missing_steps.empty?

View file

@ -13,7 +13,6 @@
- previous_revision = nil
- @procedure.revisions.each do |revision|
- if previous_revision.present? && !revision.draft?
- changes = previous_revision.compare(revision)
- dossiers = revision.dossiers.visible_by_administration
- dossiers_en_construction_count = dossiers.state_en_construction.count
- dossiers_en_instruction_count = dossiers.state_en_instruction.count
@ -31,7 +30,7 @@
%p= t('.dossiers_en_construction', count: dossiers_en_construction_count)
- elsif !dossiers_en_instruction_count.zero?
%p= t('.dossiers_en_instruction', count: dossiers_en_instruction_count)
= render Procedure::RevisionChangesComponent.new changes:, previous_revision:
= render Procedure::RevisionChangesComponent.new new_revision: revision, previous_revision:
- previous_revision = revision
= render Procedure::FixedFooterComponent.new(procedure: @procedure)

View file

@ -30,8 +30,8 @@
- if @procedure.draft_changed?
= render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c|
- c.with_body do
= render Procedure::RevisionChangesComponent.new changes: @procedure.revision_changes, previous_revision: @procedure.published_revision
= render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication)
= render Procedure::RevisionChangesComponent.new new_revision: @procedure.draft_revision, previous_revision: @procedure.published_revision
- c.with_bottom do
%ul.fr-mt-2w.fr-btns-group.fr-btns-group--inline
@ -71,6 +71,7 @@
= render Procedure::Card::PresentationComponent.new(procedure: @procedure)
= render Procedure::Card::ZonesComponent.new(procedure: @procedure) if Rails.application.config.ds_zonage_enabled
= render Procedure::Card::ChampsComponent.new(procedure: @procedure)
= render Procedure::Card::IneligibiliteDossierComponent.new(procedure: @procedure)
= render Procedure::Card::ServiceComponent.new(procedure: @procedure, administrateur: current_administrateur)
= render Procedure::Card::AdministrateursComponent.new(procedure: @procedure)
= render Procedure::Card::InstructeursComponent.new(procedure: @procedure)

View file

@ -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)

View file

@ -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))

View file

@ -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=""

View file

@ -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("/")

View file

@ -606,6 +606,7 @@ en:
otp_attempt: 'OTP code (only if you have already activated 2FA)'
procedure:
zone: This procedure is run by
ineligibilite_rules: "Eligibility rules"
champs:
value: Value
default_mail_attributes: &default_mail_attributes
@ -667,6 +668,10 @@ en:
path:
taken: is already used for procedure. You cannot use it because it belongs to another administrator.
invalid: is not valid. It must countain between 3 and 200 characters among a-z, 0-9, '_' and '-'.
procedure_revision:
attributes:
ineligibilite_rules:
invalid: are invalid
"dossier/champs":
format: "%{message}"
attributes:

View file

@ -610,6 +610,7 @@ fr:
otp_attempt: 'Code OTP (uniquement si vous avez déjà activé 2FA)'
procedure:
zone: La démarche est mise en œuvre par
ineligibilite_rules: "Les règles dinéligibilité"
champs:
value: Valeur du champ
default_mail_attributes: &default_mail_attributes
@ -669,6 +670,10 @@ fr:
path:
taken: est déjà utilisé par une démarche. Vous ne pouvez pas lutiliser car il appartient à un autre administrateur.
invalid: nest 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:

View file

@ -72,16 +72,16 @@ en:
invalid: 'invalid format'
draft_types_de_champ_public:
format: 'Public field %{message}'
invalid_condition: "« %{value} » have an invalid logic"
empty_repetition: '« %{value} » requires at least one field'
empty_drop_down: '« %{value} » requires at least one option'
inconsistent_header_section: "« %{value} » %{custom_message}"
invalid_condition: "have an invalid logic"
empty_repetition: 'requires at least one field'
empty_drop_down: 'requires at least one option'
inconsistent_header_section: "%{custom_message}"
draft_types_de_champ_private:
format: 'Private field %{message}'
invalid_condition: "« %{value} » have an invalid logic"
empty_repetition: '« %{value} » requires at least one field'
empty_drop_down: '« %{value} » requires at least one option'
inconsistent_header_section: "« %{value} » %{custom_message}"
invalid_condition: "have an invalid logic"
empty_repetition: 'requires at least one field'
empty_drop_down: 'requires at least one option'
inconsistent_header_section: "%{custom_message}"
attestation_template:
format: "%{attribute} %{message}"
initiated_mail:

View file

@ -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é sil 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é sil y en a, les pré-requis, etc.
description_pj: Décrivez la liste des pièces jointes à fournir sil 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,16 +78,16 @@ fr:
invalid: 'na pas le bon format'
draft_types_de_champ_public:
format: 'Le champ %{message}'
invalid_condition: "« %{value} » a une logique conditionnelle invalide"
empty_repetition: '« %{value} » doit comporter au moins un champ répétable'
empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable'
inconsistent_header_section: "« %{value} » %{custom_message}"
invalid_condition: "a une logique conditionnelle invalide"
empty_repetition: 'doit comporter au moins un champ répétable'
empty_drop_down: 'doit comporter au moins un choix sélectionnable'
inconsistent_header_section: "%{custom_message}"
draft_types_de_champ_private:
format: 'Lannotation privée %{message}'
invalid_condition: "« %{value} » a une logique conditionnelle invalide"
empty_repetition: '« %{value} » doit comporter au moins un champ répétable'
empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable'
inconsistent_header_section: "« %{value} » %{custom_message}"
invalid_condition: "a une logique conditionnelle invalide"
empty_repetition: 'doit comporter au moins un champ répétable'
empty_drop_down: 'doit comporter au moins un choix sélectionnable'
inconsistent_header_section: "%{custom_message}"
attestation_template:
format: "%{attribute} %{message}"
initiated_mail:

View file

@ -0,0 +1,7 @@
fr:
activerecord:
attributes:
procedure_revision:
ineligibilite_message: Message dinéligibilité
hints:
ineligibilite_message: "Ce message sera affiché à lusager si son dossier est bloqué et lui expliquera la raison de son inéligibilité."

View file

@ -608,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'

View file

@ -0,0 +1,5 @@
class AddTransitionsRulesToProcedureRevisions < ActiveRecord::Migration[7.0]
def change
add_column :procedure_revisions, :ineligibilite_rules, :jsonb
end
end

View file

@ -0,0 +1,5 @@
class AddDossierIneligbleMessageToProcedureRevisions < ActiveRecord::Migration[7.0]
def change
add_column :procedure_revisions, :ineligibilite_message, :text
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -11,27 +11,33 @@ describe Procedure::ErrorsSummary, type: :component do
context 'when :publication' do
let(:validation_context) { :publication }
it 'shows errors for public and private tdc' do
expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable")
expect(page).to have_text("Lannotation privée « private » doit comporter au moins un choix sélectionnable")
it 'shows errors and links for public and private tdc' do
expect(page).to have_content("Erreur : Des problèmes empêchent la publication de la démarche")
expect(page).to have_selector("a", text: "public")
expect(page).to have_selector("a", text: "private")
expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 2)
end
end
context 'when :types_de_champ_public_editor' do
let(:validation_context) { :types_de_champ_public_editor }
it 'shows errors for public only tdc' do
expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable")
expect(page).not_to have_text("Lannotation privée « private » doit comporter au moins un choix sélectionnable")
it 'shows errors and links for public only tdc' do
expect(page).to have_text("Erreur : Les champs formulaire contiennent des erreurs")
expect(page).to have_selector("a", text: "public")
expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 1)
expect(page).not_to have_selector("a", text: "private")
end
end
context 'when :types_de_champ_private_editor' do
let(:validation_context) { :types_de_champ_private_editor }
it 'shows errors for private only tdc' do
expect(page).not_to have_text("Le champ « public » doit comporter au moins un choix sélectionnable")
expect(page).to have_text("Lannotation privée « private » doit comporter au moins un choix sélectionnable")
it 'shows errors and links for private only tdc' do
expect(page).to have_text("Erreur : Les annotations privées contiennent des erreurs")
expect(page).to have_selector("a", text: "private")
expect(page).to have_text("doit comporter au moins un choix sélectionnable")
expect(page).not_to have_selector("a", text: "public")
end
end
end
@ -52,16 +58,24 @@ describe Procedure::ErrorsSummary, type: :component do
before { subject }
it 'renders all errors on champ' do
expect(page).to have_text("Le champ « drop down list requires options » doit comporter au moins un choix sélectionnable")
expect(page).to have_text("Le champ « repetition requires children » doit comporter au moins un champ répétable")
expect(page).to have_text("Le champ « invalid condition » a une logique conditionnelle invalide")
expect(page).to have_text("Le champ « header sections must have consistent order » devrait être précédé d'un titre de niveau 1")
# TODO, test attestation_template, initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail
it 'renders all errors and links on champ' do
expect(page).to have_selector("a", text: "drop down list requires options")
expect(page).to have_content("doit comporter au moins un choix sélectionnable")
expect(page).to have_selector("a", text: "repetition requires children")
expect(page).to have_content("doit comporter au moins un champ répétable")
expect(page).to have_selector("a", text: "invalid condition")
expect(page).to have_content("a une logique conditionnelle invalide")
expect(page).to have_selector("a", text: "header sections must have consistent order")
expect(page).to have_content("devrait être précédé d'un titre de niveau 1")
end
end
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) }
@ -69,12 +83,15 @@ describe Procedure::ErrorsSummary, type: :component do
before do
[:attestation_template, :initiated_mail].map { procedure.send(_1).update_column(:body, '--invalidtag--') }
procedure.draft_revision.update(ineligibilite_enabled: true, ineligibilite_rules: ds_eq(constant(true), constant(1)), ineligibilite_message: 'ko')
subject
end
it 'render error nicely' do
expect(page).to have_text("Le modèle dattestation n'est pas valide")
expect(page).to have_text("Lemail de notification de passage de dossier en instruction n'est pas valide")
expect(page).to have_selector("a", text: "Les règles dinéligibilité")
expect(page).to have_selector("a", text: "Le modèle dattestation")
expect(page).to have_selector("a", text: "Lemail de notification de passage de dossier en instruction")
expect(page).to have_text("n'est pas valide", count: 2)
end
end
end

View file

@ -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

View file

@ -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(/leligibilité des dossiers/)
end
end
end
describe 'tdc ej' do

View file

@ -10,16 +10,18 @@ describe TypesDeChampEditor::EditorComponent, type: :component do
context 'types_de_champ_public' do
let(:is_annotation) { false }
it 'does not render private champs errors' do
expect(subject).not_to have_text("« private » doit comporter au moins un choix sélectionnable")
expect(subject).to have_text("« public » doit comporter au moins un choix sélectionnable")
expect(subject).not_to have_text("private")
expect(subject).to have_selector("a", text: "public")
expect(subject).to have_text("doit comporter au moins un choix sélectionnable")
end
end
context 'types_de_champ_private' do
let(:is_annotation) { true }
it 'does not render public champs errors' do
expect(subject).to have_text("« private » doit comporter au moins un choix sélectionnable")
expect(subject).not_to have_text("« public » doit comporter au moins un choix sélectionnable")
expect(subject).to have_selector("a", 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

View file

@ -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 diné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 diné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

View file

@ -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

View file

@ -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)

View file

@ -347,14 +347,14 @@ describe ProcedureRevision do
end
end
describe '#compare' do
describe '#compare_types_de_champ' do
include Logic
let(:new_draft) { procedure.create_new_revision }
subject { procedure.active_revision.compare_types_de_champ(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 }
let(:new_draft) { procedure.create_new_revision }
subject { procedure.active_revision.compare(new_draft.reload).map(&:to_h) }
context 'with a procedure with 2 tdcs' do
let(:procedure) do
@ -650,6 +650,117 @@ describe ProcedureRevision do
end
end
end
end
describe 'compare_ineligibilite_rules' do
include Logic
let(:new_draft) { procedure.create_new_revision }
subject { procedure.active_revision.compare_ineligibilite_rules(new_draft.reload) }
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
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
describe 'ineligibilite_rules_are_valid?' do
include Logic
let(:procedure) { create(:procedure) }
let(:draft_revision) { procedure.draft_revision }
let(:ineligibilite_message) { 'ok' }
let(:ineligibilite_enabled) { true }
before do
procedure.draft_revision.update(ineligibilite_rules:, ineligibilite_message:, ineligibilite_enabled:)
end
context 'when ineligibilite_rules are valid' do
let(:ineligibilite_rules) { ds_eq(constant(true), constant(true)) }
it 'is valid' do
expect(draft_revision.validate(:publication)).to be_truthy
expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_truthy
end
end
context 'when ineligibilite_rules are invalid on simple champ' do
let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) }
it 'is invalid' do
expect(draft_revision.validate(:publication)).to be_falsey
expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey
end
end
context 'when ineligibilite_rules are invalid on repetition champ' do
let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) }
let(:procedure) { create(:procedure, types_de_champ_public:) }
let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :integer_number }] }] }
let(:tdc_number) { draft_revision.types_de_champ_for(scope: :public).find { _1.type_champ == 'integer_number' } }
let(:ineligibilite_rules) do
ds_eq(champ_value(tdc_number.stable_id), constant(true))
end
it 'is invalid' do
expect(draft_revision.validate(:publication)).to be_falsey
expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey
end
end
end
describe 'children_of' do
context 'with a simple tdc' do

View file

@ -372,12 +372,12 @@ describe Procedure do
]
end
let(:types_de_champ_private) { [] }
let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' }
let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' }
let(:invalid_repetition_error_message) { "doit comporter au moins un champ répétable" }
let(:invalid_drop_down_error_message) { "doit comporter au moins un choix sélectionnable" }
it 'validates that no repetition type de champ is empty' do
procedure.validate(:publication)
expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message)
expect(procedure.errors.messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message)
new_draft = procedure.draft_revision
repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?)
@ -385,17 +385,17 @@ describe Procedure do
new_draft.revision_types_de_champ.create(type_de_champ: create(:type_de_champ), position: 0, parent: parent_coordinate)
procedure.validate(:publication)
expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_repetition_error_message)
expect(procedure.errors.messages_for(:draft_types_de_champ_public)).not_to include(invalid_repetition_error_message)
end
it 'validates that no drop-down type de champ is empty' do
procedure.validate(:publication)
expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message)
expect(procedure.errors.messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message)
drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?)
drop_down.update!(drop_down_list_value: "--title--\r\nsome value")
procedure.reload.validate(:publication)
expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message)
expect(procedure.errors.messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message)
end
end
@ -408,17 +408,21 @@ describe Procedure do
end
let(:types_de_champ_public) { [] }
let(:invalid_repetition_error_message) { 'Lannotation privée « Enfants » doit comporter au moins un champ répétable' }
let(:invalid_drop_down_error_message) { 'Lannotation privée « Civilité » doit comporter au moins un choix sélectionnable' }
let(:invalid_repetition_error_message) { "doit comporter au moins un champ répétable" }
let(:invalid_drop_down_error_message) { "doit comporter au moins un choix sélectionnable" }
it 'validates that no repetition type de champ is empty' do
procedure.validate(:publication)
expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_repetition_error_message)
expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_repetition_error_message)
repetition = procedure.draft_revision.types_de_champ_private.find(&:repetition?)
expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(repetition)
end
it 'validates that no drop-down type de champ is empty' do
procedure.validate(:publication)
expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message)
expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message)
drop_down = procedure.draft_revision.types_de_champ_private.find(&:drop_down_list?)
expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(drop_down)
end
end
@ -441,7 +445,7 @@ describe Procedure do
include Logic
let(:types_de_champ_public) { [{ type: :text, libelle: 'condition', condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] }
let(:types_de_champ_private) { [{ type: :decimal_number, stable_id: 1 }] }
let(:error_on_condition) { "Le champ « condition » a une logique conditionnelle invalide" }
let(:error_on_condition) { "Le champ a une logique conditionnelle invalide" }
it 'validate without context' do
procedure.validate

View file

@ -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 linéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les conditions dinéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ")
click_on "Ajouter un champ supportant les conditions diné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 dinéligibilité", with: "vous n'etes pas eligible"
find('label', text: 'Bloquer le dépôt des dossiers répondant à des conditions dinéligibilité').click
click_on "Ajouter une règle diné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

View file

@ -72,8 +72,8 @@ describe 'Publishing a procedure', js: true do
visit admin_procedure_path(procedure)
expect(page).to have_content('Des problèmes empêchent la publication de la démarche')
expect(page).to have_content("« Enfants » doit comporter au moins un champ répétable")
expect(page).to have_content("« Civilité » doit comporter au moins un choix sélectionnable")
expect(page).to have_content("Enfants doit comporter au moins un champ répétable")
expect(page).to have_content("Civilité doit comporter au moins un choix sélectionnable")
visit admin_procedure_publication_path(procedure)
expect(find_field('procedure_path').value).to eq procedure.path
@ -195,7 +195,7 @@ describe 'Publishing a procedure', js: true do
scenario 'an error message prevents the publication' do
visit admin_procedure_path(procedure)
expect(page).to have_content('Des problèmes empêchent la publication des modifications')
expect(page).to have_link('corriger', href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG))
expect(page).to have_link(href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG))
expect(page).to have_button('Publier les modifications', disabled: true)
end
end

View file

@ -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

View file

@ -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