diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss new file mode 100644 index 000000000..4633531bd --- /dev/null +++ b/app/assets/stylesheets/conditions_component.scss @@ -0,0 +1,86 @@ +@import "colors"; +@import "constants"; + +.conditionnel { + + .condition-error { + background: $background-red; + margin: ($default-spacer) (-$default-spacer); + + ul { + padding: $default-spacer; + } + } + + .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; + + button { + background: none; + border: none; + cursor: pointer; + } + } + } + + 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; + } + } + + .add-row { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + line-height: 25px; + margin: 0 $default-padding $default-padding; + } +} 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_editor/conditions_component.rb b/app/components/types_de_champ_editor/conditions_component.rb new file mode 100644 index 000000000..3a68ee59b --- /dev/null +++ b/app/components/types_de_champ_editor/conditions_component.rb @@ -0,0 +1,218 @@ +class TypesDeChampEditor::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(t('.enable_conditionnel'), formaction: add_row_admin_procedure_condition_path(@procedure_id, @tdc.id)) + else + submit_tag( + t('.disable_conditionnel'), + formmethod: 'delete', + formnovalidate: true, + data: { confirm: t('.disable_conditionnel_alert') } + ) + end + end + + def far_left_tag(row_number) + if row_number == 0 + t('.display_if') + elsif row_number == 1 + select_tag( + "#{input_prefix}[top_operator_name]", + options_for_select(options_for_far_left_tag, @condition.class.name) + ) + end + end + + def options_for_far_left_tag + [And, Or] + .map(&:name) + .map { |name| [t(name, scope: 'logic.operators'), name] } + end + + def left_operand_tag(targeted_champ, row_index) + # current_target can be invalid if + # - its type has changed : number -> carto + # - it has been removed + # - it has been put lower in the form + current_target_valid = targets_for_select.map(&:second).include?(targeted_champ.to_json) + + selected_target = current_target_valid ? targeted_champ.to_json : empty.to_json + + select_tag( + input_name_for('targeted_champ'), + options_for_select(targets_for_select, selected_target), + onchange: "this.form.action = this.form.action + '/change_targeted_champ?row_index=#{row_index}'", + id: input_id_for('targeted_champ', row_index), + class: { alert: !current_target_valid } + ) + end + + def targets_for_select + empty_target_for_select + available_targets_for_select + end + + def empty_target_for_select + [[t('.select'), empty.to_json]] + end + + def available_targets_for_select + @upper_tdcs + .filter { |tdc| ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(tdc.type_champ) } + .map { |tdc| [tdc.libelle, champ_value(tdc.stable_id).to_json] } + end + + def operator_tag(operator_name, targeted_champ, row_index) + operators_for_select = compatibles_operators_for_select(targeted_champ) + + current_operator_invalid = !operators_for_select.map(&:second).include?(operator_name) + + if current_operator_invalid + operators_for_select = [[t('.select'), EmptyOperator.name]] + operators_for_select + end + + select_tag( + input_name_for('operator_name'), + options_for_select(operators_for_select, selected: operator_name), + id: input_id_for('operator_name', row_index), + class: { alert: current_operator_invalid } + ) + end + + def compatibles_operators_for_select(left) + case left.type + when ChampValue::CHAMP_VALUE_TYPE.fetch(:boolean) + [ + [t('is', scope: 'logic'), Eq.name] + ] + when ChampValue::CHAMP_VALUE_TYPE.fetch(:empty) + [ + [t('is', scope: 'logic'), EmptyOperator.name] + ] + when ChampValue::CHAMP_VALUE_TYPE.fetch(:enum) + [ + [t('is', scope: 'logic'), 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) + right_invalid = !current_right_valid?(left, right) + + case left.type + when :boolean + booleans_for_select = [[t('utils.yes'), constant(true).to_json], [t('utils.no'), constant(false).to_json]] + + if right_invalid + booleans_for_select = empty_target_for_select + booleans_for_select + end + + select_tag( + input_name_for('value'), + options_for_select(booleans_for_select, right.to_json), + id: input_id_for('value', row_index), + class: { alert: right_invalid } + ) + when :empty + select_tag( + input_name_for('value'), + options_for_select(empty_target_for_select), + id: input_id_for('value', row_index) + ) + when :enum + enums_for_select = left.options + + if right_invalid + enums_for_select = empty_target_for_select + enums_for_select + end + + select_tag( + input_name_for('value'), + options_for_select(enums_for_select, right.value), + id: input_id_for('value', row_index), + class: { alert: right_invalid } + ) + when :number + number_field_tag( + input_name_for('value'), + right.value, + required: true, + id: input_id_for('value', row_index), + class: { alert: right_invalid } + ) + else + number_field_tag(input_name_for('value'), '', id: input_id_for('value', row_index)) + end + end + + def current_right_valid?(left, right) + case [left.type, right.type] + in [:boolean, :boolean] | [:number, :number] | [:empty, :empty] + true + in [:enum, :string] + left.options.include?(right.value) + else + false + end + end + + def add_condition_tag + tag.button( + tag.span('', class: 'icon add') + tag.span(t('.add_condition')), + formaction: add_row_admin_procedure_condition_path(@procedure_id, @tdc.id), + formnovalidate: true, + class: 'add-row' + ) + end + + def delete_condition_tag(row_index) + tag.button( + tag.span('', class: 'icon delete') + tag.span(t('.remove_a_row'), class: 'sr-only'), + formaction: delete_row_admin_procedure_condition_path(@procedure_id, @tdc.id, row_index: row_index), + formmethod: 'delete', + formnovalidate: true + ) + end + + def render? + @condition.present? || available_targets_for_select.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_editor/conditions_component/conditions_component.fr.yml b/app/components/types_de_champ_editor/conditions_component/conditions_component.fr.yml new file mode 100644 index 000000000..1593ff1f9 --- /dev/null +++ b/app/components/types_de_champ_editor/conditions_component/conditions_component.fr.yml @@ -0,0 +1,9 @@ +--- +fr: + display_if: Afficher si + enable_conditionnel: cliquer pour activer + disable_conditionnel: cliquer pour désactiver + disable_conditionnel_alert: "La logique conditionnelle appliquée à ce champ sera désactivé.\nVoulez-vous continuer ?" + select: Sélectionner + add_condition: Ajouter une condition + remove_a_row: Supprimer la ligne diff --git a/app/components/types_de_champ_editor/conditions_component/conditions_component.html.haml b/app/components/types_de_champ_editor/conditions_component/conditions_component.html.haml new file mode 100644 index 000000000..2cccb45b5 --- /dev/null +++ b/app/components/types_de_champ_editor/conditions_component/conditions_component.html.haml @@ -0,0 +1,28 @@ +.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 + + = render TypesDeChampEditor::ConditionsErrorsComponent.new(conditions: condition_per_row, upper_tdcs: @upper_tdcs) + + - 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/app/components/types_de_champ_editor/conditions_errors_component.rb b/app/components/types_de_champ_editor/conditions_errors_component.rb new file mode 100644 index 000000000..fd7263274 --- /dev/null +++ b/app/components/types_de_champ_editor/conditions_errors_component.rb @@ -0,0 +1,40 @@ +class TypesDeChampEditor::ConditionsErrorsComponent < ApplicationComponent + def initialize(conditions:, upper_tdcs:) + @conditions, @upper_tdcs = conditions, upper_tdcs + end + + private + + def errors + @conditions + .filter { |condition| condition.errors(@upper_tdcs.map(&:stable_id)).present? } + .map { |condition| row_error(Logic.split_condition(condition)) } + .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? + t('not_available', scope: '.errors') + elsif left.type == :unmanaged + t('unmanaged', scope: '.errors', + libelle: targeted_champ.libelle, + type_champ: t(targeted_champ.type_champ, scope: '.type')) + else + t('incompatible', scope: '.errors', + libelle: targeted_champ.libelle, + type_champ: t(targeted_champ.type_champ, scope: '.type'), + operator: t(operator_name, scope: 'logic.operators').downcase, + right: right.to_s.downcase) + end + end + + def render? + @conditions + .filter { |condition| condition.errors(@upper_tdcs.map(&:stable_id)).present? } + .present? + end +end 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 new file mode 100644 index 000000000..aecf81d2e --- /dev/null +++ b/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.fr.yml @@ -0,0 +1,12 @@ +--- +fr: + type: + number: un nombre + string: un texte + boolean: soit oui, soit non + yes_no: soit oui, soit non + address: une adresse + 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 %{type_champ} et ne peut pas être utilisé comme champ cible." + incompatible: "Le champ « %{libelle} » est %{type_champ}. Il ne peut pas être %{operator} %{right}." diff --git a/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.html.haml b/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.html.haml new file mode 100644 index 000000000..b0e3dbce7 --- /dev/null +++ b/app/components/types_de_champ_editor/conditions_errors_component/conditions_errors_component.html.haml @@ -0,0 +1,2 @@ +.condition-error + = errors diff --git a/app/models/logic.rb b/app/models/logic.rb index 992f0f800..a69c979f3 100644 --- a/app/models/logic.rb +++ b/app/models/logic.rb @@ -68,6 +68,10 @@ module Logic end end + def self.split_condition(condition) + [condition.left, condition.class.name, condition.right] + end + def ds_eq(left, right) = Logic::Eq.new(left, right) def greater_than(left, right) = Logic::GreaterThan.new(left, right) diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml index cc9cebe04..ada6600eb 100644 --- a/app/views/layouts/component_preview.html.haml +++ b/app/views/layouts/component_preview.html.haml @@ -13,7 +13,7 @@ = favicon_link_tag(image_url("#{FAVICON_32PX_SRC}"), type: "image/png", sizes: "32x32") = favicon_link_tag(image_url("#{FAVICON_96PX_SRC}"), type: "image/png", sizes: "96x96") - = javascript_packs_with_chunks_tag 'application', defer: true + = vite_javascript_tag 'application' = preload_link_tag(asset_url("Muli-Regular.woff2")) = preload_link_tag(asset_url("Muli-Bold.woff2")) diff --git a/config/locales/models/logic/fr.yml b/config/locales/models/logic/fr.yml index bb40dad9c..40eeb5f9f 100644 --- a/config/locales/models/logic/fr.yml +++ b/config/locales/models/logic/fr.yml @@ -1,3 +1,12 @@ fr: logic: empty: un membre vide + is: Est + operators: + 'Logic::LessThan': Inférieur à + 'Logic::LessThanEq': Inférieur ou égal à + 'Logic::Eq': Égal à + 'Logic::GreaterThan': Supérieur à + 'Logic::GreaterThanEq': Supérieur ou égal à + 'Logic::And': Et + 'Logic::Or': Ou diff --git a/spec/components/previews/types_de_champ_editor/conditions_component_preview.rb b/spec/components/previews/types_de_champ_editor/conditions_component_preview.rb new file mode 100644 index 000000000..3edab6544 --- /dev/null +++ b/spec/components/previews/types_de_champ_editor/conditions_component_preview.rb @@ -0,0 +1,50 @@ +class TypesDeChampEditor::ConditionsComponentPreview < ViewComponent::Preview + include Logic + + def with_empty_condition + tdc = TypeDeChamp.create(type_champ: :text, condition: empty_operator(empty, empty), libelle: 't') + upper_tdcs = [] + + render TypesDeChampEditor::ConditionsComponent.new( + tdc: tdc, upper_tdcs: upper_tdcs, procedure_id: '1' + ) + end + + def with_conditions + surface = TypeDeChamp.create(type_champ: :integer_number, libelle: 'surface') + appartement = TypeDeChamp.create(type_champ: :yes_no, libelle: 'appartement') + type_appartement = TypeDeChamp.create(type_champ: :drop_down_list, libelle: 'type', drop_down_list_value: "T1\r\nT2\r\nT3") + upper_tdcs = [surface, appartement, type_appartement] + + condition = ds_and([ + greater_than_eq(champ_value(surface.stable_id), constant(50)), + ds_eq(champ_value(appartement.stable_id), constant(true)), + ds_eq(champ_value(type_appartement.stable_id), constant('T2')) + ]) + tdc = TypeDeChamp.create(type_champ: :integer_number, condition: condition, libelle: 'nb de piece') + + render TypesDeChampEditor::ConditionsComponent.new( + tdc: tdc, upper_tdcs: upper_tdcs, procedure_id: '1' + ) + end + + def with_errors + surface = TypeDeChamp.create(type_champ: :integer_number, libelle: 'surface') + address = TypeDeChamp.create(type_champ: :address, libelle: 'adresse') + yes_non = TypeDeChamp.create(type_champ: :yes_no, libelle: 'oui/non') + + upper_tdcs = [address, yes_non] + + condition = ds_and([ + ds_eq(champ_value(address.stable_id), empty), + greater_than_eq(champ_value(surface.stable_id), constant(50)), + ds_eq(champ_value(yes_non.stable_id), constant(5)) + ]) + + tdc = TypeDeChamp.create(type_champ: :integer_number, condition: condition, libelle: 'nb de piece') + + render TypesDeChampEditor::ConditionsComponent.new( + tdc: tdc, upper_tdcs: upper_tdcs, procedure_id: '1' + ) + end +end diff --git a/spec/components/types_de_champ_editor/conditions_component_spec.rb b/spec/components/types_de_champ_editor/conditions_component_spec.rb new file mode 100644 index 000000000..e4831f87b --- /dev/null +++ b/spec/components/types_de_champ_editor/conditions_component_spec.rb @@ -0,0 +1,162 @@ +describe TypesDeChampEditor::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: ['Sélectionner', '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 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 new file mode 100644 index 000000000..74ea2d8f5 --- /dev/null +++ b/spec/components/types_de_champ_editor/conditions_errors_component_spec.rb @@ -0,0 +1,43 @@ +describe TypesDeChampEditor::ConditionsErrorsComponent, type: :component do + include Logic + + describe 'render' do + let(:conditions) { [] } + let(:upper_tdcs) { [] } + + before { render_inline(described_class.new(conditions: conditions, upper_tdcs: upper_tdcs)) } + + context 'when there are no condition' do + it { expect(page).to have_no_css('.condition-error') } + end + + context 'when the targeted_champ is not available' do + let(:tdc) { create(:type_de_champ_integer_number) } + let(:conditions) { [ds_eq(champ_value(tdc.stable_id), constant(1))] } + + it do + expect(page).to have_css('.condition-error') + expect(page).to have_content("Un champ cible n'est plus disponible") + end + end + + context 'when the targeted_champ is unmanaged' do + let(:tdc) { create(:type_de_champ_address) } + let(:upper_tdcs) { [tdc] } + let(:conditions) { [ds_eq(champ_value(tdc.stable_id), constant(1))] } + + it do + expect(page).to have_css('.condition-error') + expect(page).to have_content("ne peut pas être utilisé") + end + end + + context 'when the types mismatch' do + let(:tdc) { create(:type_de_champ_integer_number) } + 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") } + end + end +end