diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss new file mode 100644 index 000000000..7cfcd62cb --- /dev/null +++ b/app/assets/stylesheets/conditions_component.scss @@ -0,0 +1,62 @@ +@import "colors"; +@import "constants"; + +.conditionnel { + + .condition-table { + table-layout: fixed; + + .far-left { + width: 100px; + } + + .target { + width: 350px; + + select { + width: 100%; + } + } + + .operator { + width: 250px; + + select { + width: 100%; + } + } + + .value { + width: 200px; + } + + .delete-column { + width: 50px; + } + } + + th { + text-align: left; + padding: $default-spacer; + + } + + td { + padding: $default-spacer; + + input, + select { + margin-bottom: 0; + } + + input[type=number] { + display: inline-block; + margin-bottom: 0; + } + + input.alert, + select.alert { + border-color: $dark-red; + } + } +} diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index 93beefd4a..404e90890 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -93,7 +93,7 @@ .flex { &.section { - padding: 10px 10px 0 10px; + padding: $default-spacer $default-spacer 0; margin-bottom: 8px; } diff --git a/app/components/types_de_champ/conditions_component.rb b/app/components/types_de_champ/conditions_component.rb new file mode 100644 index 000000000..b76d0f3e4 --- /dev/null +++ b/app/components/types_de_champ/conditions_component.rb @@ -0,0 +1,151 @@ +class TypesDeChamp::ConditionsComponent < ApplicationComponent + include Logic + + def initialize(tdc:, upper_tdcs:, procedure_id:) + @tdc, @condition, @upper_tdcs = tdc, tdc.condition, upper_tdcs + @procedure_id = procedure_id + end + + private + + def rows + condition_per_row.map { |c| Logic.split_condition(c) } + end + + def condition_per_row + if [And, Or].include?(@condition.class) + @condition.operands + else + [@condition].compact + end + end + + def logic_conditionnel_button + if @condition.nil? + submit_tag('cliquer pour activer', formaction: add_row_admin_procedure_condition_path(@procedure_id, @tdc.id)) + else + submit_tag( + 'cliquer pour désactiver', + formmethod: 'delete', + formnovalidate: true, + data: { confirm: "La logique conditionnelle appliquée à ce champ sera désactivé.\nVoulez-vous continuer ?" } + ) + end + end + + def far_left_tag(row_number) + if row_number == 0 + 'Afficher si' + elsif row_number == 1 + select_tag( + "#{input_prefix}[top_operator_name]", + options_for_select([['Et', And.name], ['Ou', Or.name]], @condition.class.name) + ) + end + end + + def left_operand_tag(targeted_champ, row_index) + select_tag( + input_name_for('targeted_champ'), + options_for_select(available_targets, targeted_champ.to_json), + onchange: "this.form.action = this.form.action + '/change_targeted_champ?row_index=#{row_index}'", + id: input_id_for('targeted_champ', row_index) + ) + end + + def available_targets + targets = @upper_tdcs + .filter { |tdc| ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(tdc.type_champ) } + .map do |tdc| + [tdc.libelle, champ_value(tdc.stable_id).to_json] + end + + if targets.present? + targets.unshift(['Sélectionner', empty.to_json]) + end + + targets + end + + def operator_tag(operator_name, targeted_champ, row_index) + select_tag( + input_name_for('operator_name'), + options_for_select(available_operators(targeted_champ), operator_name), + id: input_id_for('operator_name', row_index) + ) + end + + def available_operators(left) + case left.type + when ChampValue::CHAMP_VALUE_TYPE.fetch(:boolean) + [ + ['Est', Eq.name] + ] + when ChampValue::CHAMP_VALUE_TYPE.fetch(:empty) + [ + ['Est', Eq.name] + ] + when ChampValue::CHAMP_VALUE_TYPE.fetch(:enum) + [ + ['Est', Eq.name] + ] + when ChampValue::CHAMP_VALUE_TYPE.fetch(:number) + [Eq, LessThan, GreaterThan, LessThanEq, GreaterThanEq] + .map(&:name) + .map { |name| [t(name, scope: 'logic.operators'), name] } + else + [] + end + end + + def right_operand_tag(left, right, row_index) + case left.type + when :boolean + select_tag( + input_name_for('value'), + options_for_select([['Oui', constant(true).to_json], ['Non', constant(false).to_json]], right.to_json), + id: input_id_for('value', row_index) + ) + when :empty + select_tag( + input_name_for('value'), + options_for_select([['Sélectionner', empty.to_json]]), + id: input_id_for('value', row_index) + ) + when :enum + select_tag( + input_name_for('value'), + options_for_select(left.options, right.value), + id: input_id_for('value', row_index) + ) + when :number + number_field_tag(input_name_for('value'), right.value, required: true, id: input_id_for('value', row_index)) + else + number_field_tag(input_name_for('value'), '', id: input_id_for('value', row_index)) + end + end + + def add_condition_tag + submit_tag('Ajouter une condition', formaction: add_row_condition_path(@tdc.id)) + end + + def delete_condition_tag(row_index) + submit_tag('X', formaction: delete_row_condition_path(@tdc.id, row_index: row_index)) + end + + def render? + @condition.present? || available_targets.any? + end + + def input_name_for(name) + "#{input_prefix}[rows][][#{name}]" + end + + def input_id_for(name, row_index) + "#{@tdc.id}-#{name}-#{row_index}" + end + + def input_prefix + 'type_de_champ[condition_form]' + end +end diff --git a/app/components/types_de_champ/conditions_component/conditions_component.html.haml b/app/components/types_de_champ/conditions_component/conditions_component.html.haml new file mode 100644 index 000000000..f28edcfc2 --- /dev/null +++ b/app/components/types_de_champ/conditions_component/conditions_component.html.haml @@ -0,0 +1,26 @@ +.flex.justify-start.section{ id: dom_id(@tdc, :conditions) } + = form_with url: admin_procedure_condition_path(@procedure_id, @tdc), method: :patch, class: 'form width-100' do |f| + .conditionnel.mt-2.width-100 + .flex + %p.mr-2 Logique conditionnelle + = logic_conditionnel_button + + - if @condition.present? + %table.condition-table.mt-2.width-100 + %thead + %tr + %th.far-left + %th.target Champ Cible + %th.operator Opérateur + %th.value Valeur + %th.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) + %td.delete-column= delete_condition_tag(row_index) + + .flex.justify-end.mt-2= add_condition_tag diff --git a/config/locales/models/logic/fr.yml b/config/locales/models/logic/fr.yml index bb40dad9c..3f48dadd9 100644 --- a/config/locales/models/logic/fr.yml +++ b/config/locales/models/logic/fr.yml @@ -1,3 +1,9 @@ fr: logic: empty: un membre vide + operators: + 'Logic::LessThan': Inférieur à + 'Logic::LessThanEq': Inférieur ou égal à + 'Logic::Eq': Égal à + 'Logic::GreaterThan': Supérieur à + 'Logic::GreaterThanEq': Supérieur ou égal à diff --git a/spec/components/types_de_champ/conditions_component_spec.rb b/spec/components/types_de_champ/conditions_component_spec.rb new file mode 100644 index 000000000..8b69afb39 --- /dev/null +++ b/spec/components/types_de_champ/conditions_component_spec.rb @@ -0,0 +1,162 @@ +describe TypesDeChamp::ConditionsComponent, type: :component do + include Logic + + describe 'render' do + let(:tdc) { create(:type_de_champ, condition: condition) } + let(:condition) { nil } + let(:upper_tdcs) { [] } + + before { render_inline(described_class.new(tdc: tdc, upper_tdcs: upper_tdcs, procedure_id: 123)) } + + context 'when there are no upper tdc' do + it { expect(page).not_to have_text('Logique conditionnelle') } + end + + context 'when there are upper tdcs but not managed' do + let(:upper_tdcs) { [build(:type_de_champ_address)] } + + it { expect(page).not_to have_text('Logique conditionnelle') } + end + + context 'when there are upper tdc but no condition to display' do + let(:upper_tdcs) { [build(:type_de_champ_integer_number)] } + + it do + expect(page).to have_text('Logique conditionnelle') + expect(page).to have_button('cliquer pour activer') + expect(page).not_to have_selector('table') + end + end + + context 'when there are upper tdc and a condition' do + let(:upper_tdc) { create(:type_de_champ_number) } + let(:upper_tdcs) { [upper_tdc] } + + context 'and one condition' do + let(:condition) { ds_eq(champ_value(upper_tdc.stable_id), constant(1)) } + + it do + expect(page).to have_button('cliquer pour désactiver') + expect(page).to have_selector('table') + expect(page).to have_selector('tbody > tr', count: 1) + end + end + + context 'focus one row' do + context 'empty' do + let(:condition) { empty_operator(empty, empty) } + + it do + expect(page).to have_select('type_de_champ[condition_form][rows][][operator_name]', options: ['Est']) + expect(page).to have_select('type_de_champ[condition_form][rows][][value]', options: ['Sélectionner']) + end + end + + context 'number' do + let(:condition) { empty_operator(constant(1), constant(0)) } + + it do + expect(page).to have_select('type_de_champ[condition_form][rows][][operator_name]', with_options: ['Égal à']) + expect(page).to have_selector('input[name="type_de_champ[condition_form][rows][][value]"][value=0]') + end + end + + context 'boolean' do + let(:condition) { empty_operator(constant(true), constant(true)) } + + it do + expect(page).to have_select('type_de_champ[condition_form][rows][][operator_name]', with_options: ['Est']) + expect(page).to have_select('type_de_champ[condition_form][rows][][value]', options: ['Oui', 'Non']) + end + end + + context 'enum' do + let(:drop_down) { create(:type_de_champ_drop_down_list) } + let(:upper_tdcs) { [drop_down] } + let(:condition) { empty_operator(champ_value(drop_down.stable_id), constant(true)) } + + it do + expect(page).to have_select('type_de_champ[condition_form][rows][][operator_name]', with_options: ['Est']) + expect(page).to have_select('type_de_champ[condition_form][rows][][value]', options: ['val1', 'val2', 'val3']) + end + end + end + + context 'and 2 conditions' do + let(:condition) { ds_and([empty_operator(empty, empty), empty_operator(empty, empty)]) } + + it do + expect(page).to have_selector('tbody > tr', count: 2) + expect(page).to have_select("type_de_champ_condition_form_top_operator_name", selected: "Et", options: ['Et', 'Ou']) + end + end + + context 'when there are 3 conditions' do + let(:upper_tdc) { create(:type_de_champ_number) } + let(:upper_tdcs) { [upper_tdc] } + + let(:condition) do + ds_or([ + ds_eq(champ_value(upper_tdc.stable_id), constant(1)), + ds_eq(champ_value(upper_tdc.stable_id), empty), + greater_than(champ_value(upper_tdc.stable_id), constant(3)) + ]) + end + + it do + expect(page).to have_selector('tbody > tr', count: 3) + expect(page).to have_select("type_de_champ_condition_form_top_operator_name", selected: "Ou", options: ['Et', 'Ou']) + end + end + end + end + + describe '.rows' do + let(:tdc) { build(:type_de_champ, condition: condition) } + let(:condition) { nil } + + subject { described_class.new(tdc: tdc, upper_tdcs: [], procedure_id: 123).send(:rows) } + + context 'when there is one condition' do + let(:condition) { ds_eq(empty, constant(1)) } + + it { is_expected.to eq([[empty, Logic::Eq.name, constant(1)]]) } + end + + context 'when there are 2 conditions' do + let(:condition) { ds_and([ds_eq(empty, constant(1)), ds_eq(empty, empty)]) } + + let(:expected) do + [ + [empty, Logic::Eq.name, constant(1)], + [empty, Logic::Eq.name, empty] + ] + end + + it { is_expected.to eq(expected) } + end + + context 'when there are 3 conditions' do + let(:upper_tdc) { create(:type_de_champ_number) } + let(:upper_tdcs) { [upper_tdc] } + + let(:condition) do + ds_or([ + ds_eq(champ_value(upper_tdc.stable_id), constant(1)), + ds_eq(champ_value(upper_tdc.stable_id), empty), + greater_than(champ_value(upper_tdc.stable_id), constant(3)) + ]) + end + + let(:expected) do + [ + [champ_value(upper_tdc.stable_id), Logic::Eq.name, constant(1)], + [champ_value(upper_tdc.stable_id), Logic::Eq.name, empty], + [champ_value(upper_tdc.stable_id), Logic::GreaterThan.name, constant(3)] + ] + end + + it { is_expected.to eq(expected) } + end + end +end