feat(ProcedureRevision.ineligibilite_rules): add ineligibilite_rules management to procedure revision based on conditional logic

This commit is contained in:
mfo 2024-06-05 17:25:10 +02:00
parent 12d23f1498
commit aca3e38859
No known key found for this signature in database
GPG key ID: 7CE3E1F5B794A8EC
22 changed files with 591 additions and 0 deletions

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,42 @@
%div{ id: dom_id(@draft_revision, :ineligibilite_rules) }
= render Procedure::PendingRepublishComponent.new(procedure: @draft_revision.procedure, render_if: pending_changes?)
= render Conditions::ConditionsErrorsComponent.new(conditions: condition_per_row, source_tdcs: @source_tdcs)
%fieldset.fr-fieldset
%legend.fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w
Règles dinéligibilité
%span.fr-hint-text Vous pouvez utiliser 1 ou plusieurs critère pour bloquer le dépot
.fr-fieldset__element
= form_tag admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), method: :patch, data: { turbo: true, controller: 'autosave' }, class: 'form width-100' do
.conditionnel.width-100
%table.condition-table
%thead
%tr
%th.fr-pt-0.far-left
%th.fr-pt-0.target Champ Cible
%th.fr-pt-0.operator Opérateur
%th.fr-pt-0.value Valeur
%th.fr-pt-0.delete-column
%tbody
- rows.each.with_index do |(targeted_champ, operator_name, value), row_index|
%tr
%td.far-left= far_left_tag(row_index)
%td.target= left_operand_tag(targeted_champ, row_index)
%td.operator= operator_tag(operator_name, targeted_champ, row_index)
%td.value= right_operand_tag(targeted_champ, value, row_index, operator_name)
%td.delete-column= delete_condition_tag(row_index)
%tfoot
%tr
%td.text-right{ colspan: 5 }= add_condition_tag
= form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id)) do |f|
.fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5})
.fr-fieldset__element
.fr-toggle
= f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt
= f.label :ineligibilite_enabled, "Inéligibilité des dossiers", data: { 'fr-checked-label': "Actif", 'fr-unchecked-label': "Inactif" }, class: 'fr-toggle__label'
%p.fr-hint-text Passer lintérrupteur sur activé pour que les critères dinéligibilité configurés s'appliquent
= render Procedure::FixedFooterComponent.new(procedure: @draft_revision.procedure, form: f, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8')

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

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

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

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 critères 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 critères 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 critères 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

@ -61,6 +61,9 @@ DS_ENV="staging"
# Instance customization: URL of the Routage documentation # Instance customization: URL of the Routage documentation
# ROUTAGE_URL="" # ROUTAGE_URL=""
# #
# Instance customization: URL of the EligibiliteDossier documentation
# ELIGIBILITE_URL=""
#
# Instance customization: URL of the accessibility statement # Instance customization: URL of the accessibility statement
# ACCESSIBILITE_URL="" # 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") MENTIONS_LEGALES_URL = ENV.fetch("MENTIONS_LEGALES_URL", "/mentions-legales")
ACCESSIBILITE_URL = ENV.fetch("ACCESSIBILITE_URL", "/declaration-accessibilite") ACCESSIBILITE_URL = ENV.fetch("ACCESSIBILITE_URL", "/declaration-accessibilite")
ROUTAGE_URL = ENV.fetch("ROUTAGE_URL", [DOC_URL, "/pour-aller-plus-loin/routage"].join("/")) 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("/") API_DOC_URL = [DOC_URL, "api-graphql"].join("/")
WEBHOOK_DOC_URL = [DOC_URL, "pour-aller-plus-loin", "webhook"].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("/") WEBHOOK_ALTERNATIVE_DOC_URL = [DOC_URL, "api-graphql", "cas-dusages-exemple-dimplementation", "synchroniser-les-dossiers-modifies-sur-ma-demarche"].join("/")

View file

@ -607,6 +607,14 @@ Rails.application.routes.draw do
delete :delete_row, on: :member delete :delete_row, on: :member
end 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 patch :update_defaut_groupe_instructeur, controller: 'routing_rules', as: :update_defaut_groupe_instructeur
put 'clone' put 'clone'

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

@ -0,0 +1,231 @@
describe Administrateurs::IneligibiliteRulesController, type: :controller do
include Logic
let(:user) { create(:user) }
let(:admin) { create(:administrateur, user: create(:user)) }
let(:procedure) { create(:procedure, administrateurs: [admin], types_de_champ_public:) }
let(:types_de_champ_public) { [] }
describe 'condition management' do
before { sign_in(admin.user) }
let(:default_params) do
{
procedure_id: procedure.id,
revision_id: procedure.draft_revision.id
}
end
describe '#add_row' do
subject { post :add_row, params: default_params, format: :turbo_stream }
context 'without any row' do
it 'creates an empty condition' do
expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules }
.from(nil)
.to(empty_operator(empty, empty))
end
end
context 'with row' do
before do
procedure.draft_revision.ineligibilite_rules = empty_operator(empty, empty)
procedure.draft_revision.save!
end
it 'add one more creates an empty condition' do
expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules }
.from(empty_operator(empty, empty))
.to(ds_and([
empty_operator(empty, empty),
empty_operator(empty, empty)
]))
end
end
end
describe 'delete_row' do
let(:condition_form) do
{
top_operator_name: Logic::And.name,
rows: [
{
targeted_champ: empty.to_json,
operator_name: Logic::EmptyOperator,
value: empty.to_json
},
{
targeted_champ: empty.to_json,
operator_name: Logic::EmptyOperator,
value: empty.to_json
}
]
}
end
let(:initial_condition) do
ds_and([
empty_operator(empty, empty),
empty_operator(empty, empty)
])
end
subject { delete :delete_row, params: default_params.merge(row_index: 0, procedure_revision: { condition_form: }), format: :turbo_stream }
it 'remove condition' do
procedure.draft_revision.update(ineligibilite_rules: initial_condition)
expect { subject }
.to change { procedure.draft_revision.reload.ineligibilite_rules }
.from(initial_condition)
.to(empty_operator(empty, empty))
end
end
context 'simple tdc' do
let(:types_de_champ_public) { [{ type: :yes_no }] }
let(:yes_no_tdc) { procedure.draft_revision.types_de_champ_for(scope: :public).first }
let(:targeted_champ) { champ_value(yes_no_tdc.stable_id).to_json }
describe '#change_targeted_champ' do
let(:condition_form) do
{
rows: [
{
targeted_champ: targeted_champ,
operator_name: Logic::Eq.name,
value: constant(true).to_json
}
]
}
end
subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream }
it 'update condition' do
expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules }
.from(nil)
.to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)))
end
end
describe '#update' do
let(:value) { constant(true).to_json }
let(:operator_name) { Logic::Eq.name }
let(:condition_form) do
{
rows: [
{
targeted_champ: targeted_champ,
operator_name: operator_name,
value: value
}
]
}
end
subject { patch :update, params: default_params.merge(procedure_revision: { condition_form: condition_form }), format: :turbo_stream }
it 'updates condition' do
expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules }
.from(nil)
.to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)))
end
end
end
context 'repetition tdc' do
let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :yes_no }] }] }
let(:yes_no_tdc) { procedure.draft_revision.types_de_champ_for(scope: :public).find { _1.type_champ == 'yes_no' } }
let(:targeted_champ) { champ_value(yes_no_tdc.stable_id).to_json }
let(:condition_form) do
{
rows: [
{
targeted_champ: targeted_champ,
operator_name: Logic::Eq.name,
value: constant(true).to_json
}
]
}
end
subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream }
describe "#update" do
it 'update condition' do
expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules }
.from(nil)
.to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)))
end
end
describe '#change_targeted_champ' do
let(:condition_form) do
{
rows: [
{
targeted_champ: targeted_champ,
operator_name: Logic::Eq.name,
value: constant(true).to_json
}
]
}
end
subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream }
it 'update condition' do
expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules }
.from(nil)
.to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)))
end
end
end
end
describe '#edit' do
subject { get :edit, params: { procedure_id: procedure.id } }
context 'when user is not signed in' do
it { is_expected.to redirect_to(new_user_session_path) }
end
context 'when user is signed in but not admin of procedure' do
before { sign_in(user) }
it { is_expected.to redirect_to(new_user_session_path) }
end
context 'when user is signed as admin' do
before do
sign_in(admin.user)
subject
end
it { is_expected.to have_http_status(200) }
context 'rendered without tdc' do
let(:types_de_champ_public) { [] }
render_views
it { expect(response.body).to have_link("Ajouter un champ supportant les critères 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 critères 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

@ -0,0 +1,45 @@
describe 'Administrateurs can edit procedures', js: true do
include Logic
let(:procedure) { create(:procedure, administrateurs: [create(:administrateur)]) }
before do
login_as procedure.administrateurs.first.user, scope: :user
end
scenario 'setup eligibilite' do
# explain no champ compatible
visit admin_procedure_path(procedure)
expect(page).to have_content("Champs à configurer")
# explain which champs are compatible
visit edit_admin_procedure_ineligibilite_rules_path(procedure)
expect(page).to have_content("Inéligibilité des dossiers")
expect(page).to have_content("Pour configurer linéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères dinéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ")
click_on "Ajouter un champ supportant les critères 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: 'Inéligibilité des dossiers').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