Merge pull request #7522 from betagouv/add_condition_component

feature: ajout du composant d'édition des conditons
This commit is contained in:
LeSim 2022-07-07 11:23:36 +02:00 committed by GitHub
commit 745d0809a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 665 additions and 2 deletions

View file

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

View file

@ -93,7 +93,7 @@
.flex {
&.section {
padding: 10px 10px 0 10px;
padding: $default-spacer $default-spacer 0;
margin-bottom: 8px;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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}."

View file

@ -0,0 +1,2 @@
.condition-error
= errors

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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