diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss index 4633531bd..585819cbc 100644 --- a/app/assets/stylesheets/conditions_component.scss +++ b/app/assets/stylesheets/conditions_component.scss @@ -1,7 +1,7 @@ @import "colors"; @import "constants"; -.conditionnel { +form.form > .conditionnel { .condition-error { background: $background-red; @@ -64,7 +64,7 @@ margin-bottom: 0; } - input[type=number] { + input[type=text] { display: inline-block; margin-bottom: 0; } diff --git a/app/components/types_de_champ_editor/conditions_component.rb b/app/components/types_de_champ_editor/conditions_component.rb index d728ad889..8b792de2b 100644 --- a/app/components/types_de_champ_editor/conditions_component.rb +++ b/app/components/types_de_champ_editor/conditions_component.rb @@ -119,6 +119,10 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent [t('is', scope: 'logic'), Eq.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) [Eq, LessThan, GreaterThan, LessThanEq, GreaterThanEq] .map(&:name) @@ -151,7 +155,7 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent options_for_select(empty_target_for_select), id: input_id_for('value', row_index) ) - when :enum + when :enum, :enums enums_for_select = left.options if right_invalid @@ -165,7 +169,7 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent class: { alert: right_invalid } ) when :number - number_field_tag( + text_field_tag( input_name_for('value'), right.value, required: true, @@ -173,7 +177,7 @@ class TypesDeChampEditor::ConditionsComponent < ApplicationComponent class: { alert: right_invalid } ) 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 diff --git a/app/components/types_de_champ_editor/conditions_errors_component.rb b/app/components/types_de_champ_editor/conditions_errors_component.rb index 7880e7d74..4ad1ea313 100644 --- a/app/components/types_de_champ_editor/conditions_errors_component.rb +++ b/app/components/types_de_champ_editor/conditions_errors_component.rb @@ -7,28 +7,42 @@ class TypesDeChampEditor::ConditionsErrorsComponent < ApplicationComponent def errors @conditions - .filter { |condition| condition.errors(@upper_tdcs.map(&:stable_id)).present? } - .map { |condition| row_error(Logic.split_condition(condition)) } + .flat_map { |condition| condition.errors(@upper_tdcs.map(&:stable_id)) } + .map { |error| humanize(error) } .uniq .map { |message| tag.li(message) } .then { |lis| tag.ul(lis.reduce(&:+)) } end - def row_error((left, operator_name, right)) - targeted_champ = @upper_tdcs.find { |tdc| tdc.stable_id == left.stable_id } - - if targeted_champ.nil? + def humanize(error) + case error + in { type: :not_available } t('not_available', scope: '.errors') - elsif left.type == :unmanaged - t('unmanaged', scope: '.errors', + in { type: :unmanaged, stable_id: stable_id } + targeted_champ = @upper_tdcs.find { |tdc| tdc.stable_id == stable_id } + t('unmanaged', + scope: '.errors', libelle: targeted_champ.libelle, 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', libelle: targeted_champ.libelle, type_champ: t(targeted_champ.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')&.downcase, operator: t(operator_name, scope: 'logic.operators').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 diff --git a/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.en.yml b/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.en.yml new file mode 100644 index 000000000..5cdbcd70e --- /dev/null +++ b/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.en.yml @@ -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} »." diff --git a/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.fr.yml b/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.fr.yml index 5ee5690a3..5be0ad01c 100644 --- a/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.fr.yml +++ b/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.fr.yml @@ -3,4 +3,7 @@ fr: errors: 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." - 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} »." diff --git a/app/models/logic.rb b/app/models/logic.rb index 8f2787754..3a8719a6e 100644 --- a/app/models/logic.rb +++ b/app/models/logic.rb @@ -8,7 +8,7 @@ module Logic end 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 } end @@ -24,6 +24,8 @@ module Logic operator_class = EmptyOperator in [:enum, _] operator_class = Eq + in [:enums, _] + operator_class = IncludeOperator in [:number, EmptyOperator] operator_class = Eq in [:number, _] @@ -35,7 +37,7 @@ module Logic Constant.new(true) when :empty Empty.new - when :enum + when :enum, :enums Constant.new(left.options.first.second) when :number Constant.new(0) @@ -49,8 +51,8 @@ module Logic case [left.type, right.type] in [a, ^a] # syntax for same type true - in [:enum, :string] - left.options.map(&:second).include?(right.value) + in [:enum, :string] | [:enums, :string] + true else false end @@ -84,6 +86,8 @@ module Logic 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 champ_value(stable_id) = Logic::ChampValue.new(stable_id) diff --git a/app/models/logic/binary_operator.rb b/app/models/logic/binary_operator.rb index dcedcc631..7a73312c8 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -21,7 +21,7 @@ class Logic::BinaryOperator < Logic::Term errors = [] if @left.type != :number || @right.type != :number - errors += ["les types sont incompatibles : #{self}"] + errors << { type: :required_number, operator_name: self.class.name } end errors + @left.errors(stable_ids) + @right.errors(stable_ids) diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index d6d2068fb..610d7ee58 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -4,13 +4,15 @@ class Logic::ChampValue < Logic::Term :checkbox, :integer_number, :decimal_number, - :drop_down_list + :drop_down_list, + :multiple_drop_down_list ) CHAMP_VALUE_TYPE = { - boolean: :boolean, - number: :number, - enum: :enum, + boolean: :boolean, # from yes_no or checkbox champ + number: :number, # from integer or decimal number champ + enum: :enum, # a choice from a dropdownlist + enums: :enums, # multiple choice from a dropdownlist (multipledropdownlist) empty: :empty, unmanaged: :unmanaged } @@ -35,6 +37,8 @@ class Logic::ChampValue < Logic::Term targeted_champ.for_api when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list) targeted_champ.selected + when MANAGED_TYPE_DE_CHAMP.fetch(:multiple_drop_down_list) + targeted_champ.selected_options end end @@ -49,6 +53,8 @@ class Logic::ChampValue < Logic::Term CHAMP_VALUE_TYPE.fetch(:number) when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list) CHAMP_VALUE_TYPE.fetch(:enum) + when MANAGED_TYPE_DE_CHAMP.fetch(:multiple_drop_down_list) + CHAMP_VALUE_TYPE.fetch(:enums) else CHAMP_VALUE_TYPE.fetch(:unmanaged) end @@ -56,7 +62,7 @@ class Logic::ChampValue < Logic::Term def errors(stable_ids) if !stable_ids.include?(stable_id) - ["le type de champ stable_id=#{stable_id} n'est pas disponible"] + [{ type: :not_available }] else [] end diff --git a/app/models/logic/eq.rb b/app/models/logic/eq.rb index 34e6fa150..d644f5450 100644 --- a/app/models/logic/eq.rb +++ b/app/models/logic/eq.rb @@ -2,10 +2,24 @@ class Logic::Eq < Logic::BinaryOperator def operation = :== 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) - 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 errors + @left.errors(stable_ids) + @right.errors(stable_ids) diff --git a/app/models/logic/include_operator.rb b/app/models/logic/include_operator.rb new file mode 100644 index 000000000..6ec906500 --- /dev/null +++ b/app/models/logic/include_operator.rb @@ -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 diff --git a/app/views/administrateurs/conditions/_update.turbo_stream.haml b/app/views/administrateurs/conditions/_update.turbo_stream.haml index 10ff59a63..0fac54c18 100644 --- a/app/views/administrateurs/conditions/_update.turbo_stream.haml +++ b/app/views/administrateurs/conditions/_update.turbo_stream.haml @@ -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 - if rendered.present? diff --git a/config/locales/models/logic/fr.yml b/config/locales/models/logic/fr.yml index a4ca68dbc..c6f1f744d 100644 --- a/config/locales/models/logic/fr.yml +++ b/config/locales/models/logic/fr.yml @@ -12,3 +12,4 @@ fr: 'Logic::And': Et 'Logic::Or': Ou 'Logic::NotEq': N'est pas + 'Logic::IncludeOperator': Contient diff --git a/spec/components/types_de_champ_editor/conditions_errors_component_spec.rb b/spec/components/types_de_champ_editor/conditions_errors_component_spec.rb index 74ea2d8f5..f99259365 100644 --- a/spec/components/types_de_champ_editor/conditions_errors_component_spec.rb +++ b/spec/components/types_de_champ_editor/conditions_errors_component_spec.rb @@ -28,7 +28,7 @@ describe TypesDeChampEditor::ConditionsErrorsComponent, type: :component do it do 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 @@ -37,7 +37,39 @@ describe TypesDeChampEditor::ConditionsErrorsComponent, type: :component do let(:upper_tdcs) { [tdc] } 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 diff --git a/spec/models/logic/binary_operator_spec.rb b/spec/models/logic/binary_operator_spec.rb index 437900cd8..ebd791d08 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -16,7 +16,7 @@ describe Logic::BinaryOperator do end 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 diff --git a/spec/models/logic/champ_value_spec.rb b/spec/models/logic/champ_value_spec.rb index 7b1b2bf13..436e7e300 100644 --- a/spec/models/logic/champ_value_spec.rb +++ b/spec/models/logic/champ_value_spec.rb @@ -80,6 +80,6 @@ describe Logic::ChampValue do 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(['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 diff --git a/spec/models/logic/eq_spec.rb b/spec/models/logic/eq_spec.rb index dada5133b..fb9a9dee7 100644 --- a/spec/models/logic/eq_spec.rb +++ b/spec/models/logic/eq_spec.rb @@ -8,7 +8,15 @@ describe Logic::Eq do describe '#errors' do 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 describe '#==' do diff --git a/spec/models/logic/include_operator_spec.rb b/spec/models/logic/include_operator_spec.rb new file mode 100644 index 000000000..3583a720b --- /dev/null +++ b/spec/models/logic/include_operator_spec.rb @@ -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 diff --git a/spec/models/logic/not_eq_spec.rb b/spec/models/logic/not_eq_spec.rb index c789bbd05..b119e75f5 100644 --- a/spec/models/logic/not_eq_spec.rb +++ b/spec/models/logic/not_eq_spec.rb @@ -8,7 +8,15 @@ describe Logic::NotEq 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(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 describe '#==' do diff --git a/spec/models/logic_spec.rb b/spec/models/logic_spec.rb index c7ba00e4e..66dbab3a7 100644 --- a/spec/models/logic_spec.rb +++ b/spec/models/logic_spec.rb @@ -49,6 +49,14 @@ describe Logic do it { is_expected.to eq(ds_eq(champ_value(drop_down), constant(first_option))) } 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 describe '.compatible_type?' do @@ -60,8 +68,7 @@ describe Logic do let(:first_option) { drop_down.drop_down_list_enabled_non_empty_options.first } 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 false + expect(Logic.compatible_type?(champ_value(drop_down.stable_id), constant('a'))).to be true end end end