Merge pull request #7767 from betagouv/condition_on_multiple_choice

feat(conditonal): ajoute l'operator d'inclusion dans une liste pour le conditionnel
This commit is contained in:
LeSim 2022-09-26 10:28:32 +02:00 committed by GitHub
commit 0f2552d593
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 211 additions and 35 deletions

View file

@ -1,7 +1,7 @@
@import "colors"; @import "colors";
@import "constants"; @import "constants";
.conditionnel { form.form > .conditionnel {
.condition-error { .condition-error {
background: $background-red; background: $background-red;
@ -64,7 +64,7 @@
margin-bottom: 0; margin-bottom: 0;
} }
input[type=number] { input[type=text] {
display: inline-block; display: inline-block;
margin-bottom: 0; margin-bottom: 0;
} }

View file

@ -119,6 +119,10 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent
[t('is', scope: 'logic'), Eq.name], [t('is', scope: 'logic'), Eq.name],
[t('is_not', scope: 'logic'), NotEq.name] [t('is_not', scope: 'logic'), NotEq.name]
] ]
when ChampValue::CHAMP_VALUE_TYPE.fetch(:enums)
[
[t(IncludeOperator.name, scope: 'logic.operators'), IncludeOperator.name]
]
when ChampValue::CHAMP_VALUE_TYPE.fetch(:number) when ChampValue::CHAMP_VALUE_TYPE.fetch(:number)
[Eq, LessThan, GreaterThan, LessThanEq, GreaterThanEq] [Eq, LessThan, GreaterThan, LessThanEq, GreaterThanEq]
.map(&:name) .map(&:name)
@ -151,7 +155,7 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent
options_for_select(empty_target_for_select), options_for_select(empty_target_for_select),
id: input_id_for('value', row_index) id: input_id_for('value', row_index)
) )
when :enum when :enum, :enums
enums_for_select = left.options enums_for_select = left.options
if right_invalid if right_invalid
@ -165,7 +169,7 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent
class: { alert: right_invalid } class: { alert: right_invalid }
) )
when :number when :number
number_field_tag( text_field_tag(
input_name_for('value'), input_name_for('value'),
right.value, right.value,
required: true, required: true,
@ -173,7 +177,7 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent
class: { alert: right_invalid } class: { alert: right_invalid }
) )
else else
number_field_tag(input_name_for('value'), '', id: input_id_for('value', row_index)) text_field_tag(input_name_for('value'), '', id: input_id_for('value', row_index))
end end
end end

View file

@ -7,28 +7,42 @@ class TypesDeChampEditor::ConditionsErrorsComponent < ApplicationComponent
def errors def errors
@conditions @conditions
.filter { |condition| condition.errors(@upper_tdcs.map(&:stable_id)).present? } .flat_map { |condition| condition.errors(@upper_tdcs.map(&:stable_id)) }
.map { |condition| row_error(Logic.split_condition(condition)) } .map { |error| humanize(error) }
.uniq .uniq
.map { |message| tag.li(message) } .map { |message| tag.li(message) }
.then { |lis| tag.ul(lis.reduce(&:+)) } .then { |lis| tag.ul(lis.reduce(&:+)) }
end end
def row_error((left, operator_name, right)) def humanize(error)
targeted_champ = @upper_tdcs.find { |tdc| tdc.stable_id == left.stable_id } case error
in { type: :not_available }
if targeted_champ.nil?
t('not_available', scope: '.errors') t('not_available', scope: '.errors')
elsif left.type == :unmanaged in { type: :unmanaged, stable_id: stable_id }
t('unmanaged', scope: '.errors', targeted_champ = @upper_tdcs.find { |tdc| tdc.stable_id == stable_id }
t('unmanaged',
scope: '.errors',
libelle: targeted_champ.libelle, libelle: targeted_champ.libelle,
type_champ: t(targeted_champ.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')&.downcase) type_champ: t(targeted_champ.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')&.downcase)
else in { type: :incompatible, stable_id: stable_id, right: right, operator_name: operator_name }
targeted_champ = @upper_tdcs.find { |tdc| tdc.stable_id == stable_id }
t('incompatible', scope: '.errors', t('incompatible', scope: '.errors',
libelle: targeted_champ.libelle, libelle: targeted_champ.libelle,
type_champ: t(targeted_champ.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')&.downcase, type_champ: t(targeted_champ.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')&.downcase,
operator: t(operator_name, scope: 'logic.operators').downcase, operator: t(operator_name, scope: 'logic.operators').downcase,
right: right.to_s.downcase) right: right.to_s.downcase)
in { type: :required_number, operator_name: operator_name }
t('required_number', scope: '.errors',
operator: t(operator_name, scope: 'logic.operators'))
in { type: :not_included, stable_id: stable_id, right: right }
targeted_champ = @upper_tdcs.find { |tdc| tdc.stable_id == stable_id }
t('not_included', scope: '.errors',
libelle: targeted_champ.libelle,
right: right.to_s.downcase)
in { type: :required_list }
t('required_list', scope: '.errors')
else
nil
end end
end end

View file

@ -0,0 +1,9 @@
---
fr:
errors:
not_available: "A targeted field is not available."
unmanaged: "The field « %{libelle} » is a « %{type_champ} » and cannot be used as conditional source."
incompatible: "The field « %{libelle} » is a « %{type_champ} ». It cannot be %{operator} « %{right} »."
required_number: "« %{operator} » applies only to number."
required_list: "The « include » operator only applies to simple or multiple choice."
not_included: "« %{right} » is not included in « %{libelle} »."

View file

@ -3,4 +3,7 @@ fr:
errors: errors:
not_available: "Un champ cible n'est plus disponible. Il est soit supprimé, soit déplacé en dessous de ce champ." not_available: "Un champ cible n'est plus disponible. Il est soit supprimé, soit déplacé en dessous de ce champ."
unmanaged: "Le champ « %{libelle} » est de type « %{type_champ} » et ne peut pas être utilisé comme champ cible." unmanaged: "Le champ « %{libelle} » est de type « %{type_champ} » et ne peut pas être utilisé comme champ cible."
incompatible: "Le champ « %{libelle} » est de type « %{type_champ} ». Il ne peut pas être %{operator} %{right}." incompatible: "Le champ « %{libelle} » est de type « %{type_champ} ». Il ne peut pas être %{operator} « %{right} »."
required_number: "« %{operator} » ne s'applique qu'à des nombres."
required_list: "Lʼopérateur « inclus » ne s'applique qu'au choix simple ou multiple."
not_included: "« %{right} » ne fait pas partie de « %{libelle} »."

View file

@ -8,7 +8,7 @@ module Logic
end end
def self.class_from_name(name) def self.class_from_name(name)
[ChampValue, Constant, Empty, LessThan, LessThanEq, Eq, NotEq, GreaterThanEq, GreaterThan, EmptyOperator, And, Or] [ChampValue, Constant, Empty, LessThan, LessThanEq, Eq, NotEq, GreaterThanEq, GreaterThan, EmptyOperator, IncludeOperator, And, Or]
.find { |c| c.name == name } .find { |c| c.name == name }
end end
@ -24,6 +24,8 @@ module Logic
operator_class = EmptyOperator operator_class = EmptyOperator
in [:enum, _] in [:enum, _]
operator_class = Eq operator_class = Eq
in [:enums, _]
operator_class = IncludeOperator
in [:number, EmptyOperator] in [:number, EmptyOperator]
operator_class = Eq operator_class = Eq
in [:number, _] in [:number, _]
@ -35,7 +37,7 @@ module Logic
Constant.new(true) Constant.new(true)
when :empty when :empty
Empty.new Empty.new
when :enum when :enum, :enums
Constant.new(left.options.first.second) Constant.new(left.options.first.second)
when :number when :number
Constant.new(0) Constant.new(0)
@ -49,8 +51,8 @@ module Logic
case [left.type, right.type] case [left.type, right.type]
in [a, ^a] # syntax for same type in [a, ^a] # syntax for same type
true true
in [:enum, :string] in [:enum, :string] | [:enums, :string]
left.options.map(&:second).include?(right.value) true
else else
false false
end end
@ -84,6 +86,8 @@ module Logic
def less_than_eq(left, right) = Logic::LessThanEq.new(left, right) def less_than_eq(left, right) = Logic::LessThanEq.new(left, right)
def ds_include(left, right) = Logic::IncludeOperator.new(left, right)
def constant(value) = Logic::Constant.new(value) def constant(value) = Logic::Constant.new(value)
def champ_value(stable_id) = Logic::ChampValue.new(stable_id) def champ_value(stable_id) = Logic::ChampValue.new(stable_id)

View file

@ -21,7 +21,7 @@ class Logic::BinaryOperator < Logic::Term
errors = [] errors = []
if @left.type != :number || @right.type != :number if @left.type != :number || @right.type != :number
errors += ["les types sont incompatibles : #{self}"] errors << { type: :required_number, operator_name: self.class.name }
end end
errors + @left.errors(stable_ids) + @right.errors(stable_ids) errors + @left.errors(stable_ids) + @right.errors(stable_ids)

View file

@ -4,13 +4,15 @@ class Logic::ChampValue < Logic::Term
:checkbox, :checkbox,
:integer_number, :integer_number,
:decimal_number, :decimal_number,
:drop_down_list :drop_down_list,
:multiple_drop_down_list
) )
CHAMP_VALUE_TYPE = { CHAMP_VALUE_TYPE = {
boolean: :boolean, boolean: :boolean, # from yes_no or checkbox champ
number: :number, number: :number, # from integer or decimal number champ
enum: :enum, enum: :enum, # a choice from a dropdownlist
enums: :enums, # multiple choice from a dropdownlist (multipledropdownlist)
empty: :empty, empty: :empty,
unmanaged: :unmanaged unmanaged: :unmanaged
} }
@ -35,6 +37,8 @@ class Logic::ChampValue < Logic::Term
targeted_champ.for_api targeted_champ.for_api
when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list) when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list)
targeted_champ.selected targeted_champ.selected
when MANAGED_TYPE_DE_CHAMP.fetch(:multiple_drop_down_list)
targeted_champ.selected_options
end end
end end
@ -49,6 +53,8 @@ class Logic::ChampValue < Logic::Term
CHAMP_VALUE_TYPE.fetch(:number) CHAMP_VALUE_TYPE.fetch(:number)
when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list) when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list)
CHAMP_VALUE_TYPE.fetch(:enum) CHAMP_VALUE_TYPE.fetch(:enum)
when MANAGED_TYPE_DE_CHAMP.fetch(:multiple_drop_down_list)
CHAMP_VALUE_TYPE.fetch(:enums)
else else
CHAMP_VALUE_TYPE.fetch(:unmanaged) CHAMP_VALUE_TYPE.fetch(:unmanaged)
end end
@ -56,7 +62,7 @@ class Logic::ChampValue < Logic::Term
def errors(stable_ids) def errors(stable_ids)
if !stable_ids.include?(stable_id) if !stable_ids.include?(stable_id)
["le type de champ stable_id=#{stable_id} n'est pas disponible"] [{ type: :not_available }]
else else
[] []
end end

View file

@ -2,10 +2,24 @@ class Logic::Eq < Logic::BinaryOperator
def operation = :== def operation = :==
def errors(stable_ids = []) def errors(stable_ids = [])
errors = [] errors = [@left, @right]
.filter { |term| term.type == :unmanaged }
.map { |term| { type: :unmanaged, stable_id: term.stable_id } }
if !Logic.compatible_type?(@left, @right) if !Logic.compatible_type?(@left, @right)
errors += ["les types sont incompatibles : #{self}"] errors << {
type: :incompatible,
stable_id: @left.try(:stable_id),
right: @right,
operator_name: self.class.name
}
elsif @left.type == :enum &&
!left.options.map(&:second).include?(right.value)
errors << {
type: :not_included,
stable_id: @left.stable_id,
right: @right
}
end end
errors + @left.errors(stable_ids) + @right.errors(stable_ids) errors + @left.errors(stable_ids) + @right.errors(stable_ids)

View file

@ -0,0 +1,29 @@
class Logic::IncludeOperator < Logic::BinaryOperator
def operation = :include?
def errors(stable_ids = [])
result = []
if left_not_a_list?
result << { type: :required_list }
elsif right_value_not_in_list?
result << {
type: :not_included,
stable_id: @left.stable_id,
right: @right
}
end
result + @left.errors(stable_ids) + @right.errors(stable_ids)
end
private
def left_not_a_list?
@left.type != :enums
end
def right_value_not_in_list?
!@left.options.map(&:second).include?(@right.value)
end
end

View file

@ -1,3 +1,11 @@
= turbo_stream.replace 'breadcrumbs' , render(partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Démarches', admin_procedures_path],
[@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)],
['Configuration des champs']],
preview: @procedure.draft_revision.valid? })
= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision))
- rendered = render @condition_component - rendered = render @condition_component
- if rendered.present? - if rendered.present?

View file

@ -12,3 +12,4 @@ fr:
'Logic::And': Et 'Logic::And': Et
'Logic::Or': Ou 'Logic::Or': Ou
'Logic::NotEq': N'est pas 'Logic::NotEq': N'est pas
'Logic::IncludeOperator': Contient

View file

@ -28,7 +28,7 @@ describe TypesDeChampEditor::ConditionsErrorsComponent, type: :component do
it do it do
expect(page).to have_css('.condition-error') expect(page).to have_css('.condition-error')
expect(page).to have_content("ne peut pas être utilisé") expect(page).to have_content("Le champ « #{tdc.libelle} » est de type « adresse » et ne peut pas être utilisé comme champ cible.")
end end
end end
@ -37,7 +37,39 @@ describe TypesDeChampEditor::ConditionsErrorsComponent, type: :component do
let(:upper_tdcs) { [tdc] } let(:upper_tdcs) { [tdc] }
let(:conditions) { [ds_eq(champ_value(tdc.stable_id), constant('a'))] } let(:conditions) { [ds_eq(champ_value(tdc.stable_id), constant('a'))] }
it { expect(page).to have_content("Il ne peut pas être") } it { expect(page).to have_content("Le champ « #{tdc.libelle} » est de type « nombre entier ». Il ne peut pas être égal à « a ».") }
end
context 'when a number operator is applied on not a number' do
let(:tdc) { create(:type_de_champ_multiple_drop_down_list) }
let(:upper_tdcs) { [tdc] }
let(:conditions) { [greater_than(champ_value(tdc.stable_id), constant('a text'))] }
it { expect(page).to have_content("« Supérieur à » ne s'applique qu'à des nombres.") }
end
context 'when the include operator is applied on a list' do
let(:tdc) { create(:type_de_champ_integer_number) }
let(:upper_tdcs) { [tdc] }
let(:conditions) { [ds_include(champ_value(tdc.stable_id), constant('a text'))] }
it { expect(page).to have_content("Lʼopérateur « inclus » ne s'applique qu'au choix simple ou multiple.") }
end
context 'when a choice is not in a drop_down' do
let(:tdc) { create(:type_de_champ_drop_down_list) }
let(:upper_tdcs) { [tdc] }
let(:conditions) { [ds_eq(champ_value(tdc.stable_id), constant('another choice'))] }
it { expect(page).to have_content("« another choice » ne fait pas partie de « #{tdc.libelle} ».") }
end
context 'when a choice is not in a multiple_drop_down' do
let(:tdc) { create(:type_de_champ_multiple_drop_down_list) }
let(:upper_tdcs) { [tdc] }
let(:conditions) { [ds_include(champ_value(tdc.stable_id), constant('another choice'))] }
it { expect(page).to have_content("« another choice » ne fait pas partie de « #{tdc.libelle} ».") }
end end
end end
end end

View file

@ -16,7 +16,7 @@ describe Logic::BinaryOperator do
end end
describe '#errors' do describe '#errors' do
it { expect(greater_than(constant(1), constant(true)).errors).to eq(['les types sont incompatibles : (1 > Oui)']) } it { expect(greater_than(constant(1), constant(true)).errors).to eq([{ operator_name: "Logic::GreaterThan", type: :required_number }]) }
end end
end end

View file

@ -80,6 +80,6 @@ describe Logic::ChampValue do
let(:champ) { create(:champ) } let(:champ) { create(:champ) }
it { expect(champ_value(champ.stable_id).errors([champ.stable_id])).to be_empty } it { expect(champ_value(champ.stable_id).errors([champ.stable_id])).to be_empty }
it { expect(champ_value(champ.stable_id).errors(['other stable ids'])).to eq(["le type de champ stable_id=#{champ.stable_id} n'est pas disponible"]) } it { expect(champ_value(champ.stable_id).errors(['other stable ids'])).to eq([{ type: :not_available }]) }
end end
end end

View file

@ -8,7 +8,15 @@ describe Logic::Eq do
describe '#errors' do describe '#errors' do
it { expect(ds_eq(constant(true), constant(true)).errors).to be_empty } it { expect(ds_eq(constant(true), constant(true)).errors).to be_empty }
it { expect(ds_eq(constant(true), constant(1)).errors).to eq(["les types sont incompatibles : (Oui == 1)"]) } it do
expected = {
operator_name: "Logic::Eq",
right: constant(1),
stable_id: nil,
type: :incompatible
}
expect(ds_eq(constant(true), constant(1)).errors).to eq([expected])
end
end end
describe '#==' do describe '#==' do

View file

@ -0,0 +1,29 @@
describe Logic::IncludeOperator do
include Logic
let(:champ) { create(:champ_multiple_drop_down_list, value: '["val1", "val2"]') }
describe '#compute' do
it { expect(ds_include(champ_value(champ.stable_id), constant('val1')).compute([champ])).to be(true) }
it { expect(ds_include(champ_value(champ.stable_id), constant('something else')).compute([champ])).to be(false) }
end
describe '#errors' do
it { expect(ds_include(champ_value(champ.stable_id), constant('val1')).errors([champ.stable_id])).to be_empty }
it do
expected = {
right: constant('something else'),
stable_id: champ.stable_id,
type: :not_included
}
expect(ds_include(champ_value(champ.stable_id), constant('something else')).errors([champ.stable_id])).to eq([expected])
end
it { expect(ds_include(constant(1), constant('val1')).errors).to eq([{ type: :required_list }]) }
end
describe '#==' do
it { expect(ds_include(champ_value(champ.stable_id), constant('val1'))).to eq(ds_include(champ_value(champ.stable_id), constant('val1'))) }
end
end

View file

@ -8,7 +8,15 @@ describe Logic::NotEq do
describe '#errors' do describe '#errors' do
it { expect(ds_not_eq(constant(true), constant(true)).errors).to be_empty } it { expect(ds_not_eq(constant(true), constant(true)).errors).to be_empty }
it { expect(ds_not_eq(constant(true), constant(1)).errors).to eq(["les types sont incompatibles : (Oui != 1)"]) } it do
expected = {
operator_name: "Logic::NotEq",
right: constant(1),
stable_id: nil,
type: :incompatible
}
expect(ds_not_eq(constant(true), constant(1)).errors).to eq([expected])
end
end end
describe '#==' do describe '#==' do

View file

@ -49,6 +49,14 @@ describe Logic do
it { is_expected.to eq(ds_eq(champ_value(drop_down), constant(first_option))) } it { is_expected.to eq(ds_eq(champ_value(drop_down), constant(first_option))) }
end end
context 'when multiple dropdown empty operator true' do
let(:multiple_drop_down) { create(:type_de_champ_multiple_drop_down_list) }
let(:first_option) { multiple_drop_down.drop_down_list_enabled_non_empty_options.first }
let(:condition) { empty_operator(champ_value(multiple_drop_down), constant(true)) }
it { is_expected.to eq(ds_include(champ_value(multiple_drop_down), constant(first_option))) }
end
end end
describe '.compatible_type?' do describe '.compatible_type?' do
@ -60,8 +68,7 @@ describe Logic do
let(:first_option) { drop_down.drop_down_list_enabled_non_empty_options.first } let(:first_option) { drop_down.drop_down_list_enabled_non_empty_options.first }
it do it do
expect(Logic.compatible_type?(champ_value(drop_down.stable_id), constant(first_option))).to be true expect(Logic.compatible_type?(champ_value(drop_down.stable_id), constant('a'))).to be true
expect(Logic.compatible_type?(champ_value(drop_down.stable_id), constant('a'))).to be false
end end
end end
end end