From aac7de208f6e79a49dcb71fdde9e1034922ff2a1 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 25 Sep 2023 11:39:08 +0200 Subject: [PATCH 1/4] chore(champs-number): render as text input to validate them when invalid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les input=number n'ont pas de value lorsque la valeur saisie n'est pas un nombre. Par conséquent dans ces cas là, nous ne pouvions faire remonter au backend pour validation / enregistrement, et il n'y avait aucun feedback signalement l'erreur à l'usager. On les convertit en inputs texte, avec les adaptations nécessaires pour montrer le pavé numérique sur mobile, et un style correct. --- app/assets/stylesheets/forms.scss | 4 ++++ .../decimal_number_component.html.haml | 2 +- .../integer_number_component.html.haml | 2 +- .../number_component.html.haml | 2 +- .../controllers/autosave_controller.ts | 2 ++ spec/system/users/brouillon_spec.rb | 19 ++++++++++++++++++- 6 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index f40b1d781..0d85c92ca 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -296,6 +296,8 @@ input[type=email], input[type=password], input[type=number], + input[inputmode=numeric], + input[inputmode=decimal], input[type=tel] { max-width: 500px; } @@ -315,6 +317,8 @@ &[type='date'], &[type='tel'], &[type='number'], + &[inputmode='numeric'], + &[inputmode='decimal'], &[type='datetime-local'] { width: 33.33%; } diff --git a/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml b/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml index 3be6fd3eb..a3efb88f4 100644 --- a/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml +++ b/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml @@ -1 +1 @@ -= @form.number_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, step: :any, required: @champ.required?)) += @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, required: @champ.required?, pattern: "-?[0-9]+([\.,][0-9]{1,3})?", inputmode: :decimal)) diff --git a/app/components/editable_champ/integer_number_component/integer_number_component.html.haml b/app/components/editable_champ/integer_number_component/integer_number_component.html.haml index fc2680744..bab39525f 100644 --- a/app/components/editable_champ/integer_number_component/integer_number_component.html.haml +++ b/app/components/editable_champ/integer_number_component/integer_number_component.html.haml @@ -1 +1 @@ -= @form.number_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, placeholder: 5, required: @champ.required?)) += @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, pattern: "[0-9]*", inputmode: :numeric, required: @champ.required?)) diff --git a/app/components/editable_champ/number_component/number_component.html.haml b/app/components/editable_champ/number_component/number_component.html.haml index 4da3486d1..806ce8d58 100644 --- a/app/components/editable_champ/number_component/number_component.html.haml +++ b/app/components/editable_champ/number_component/number_component.html.haml @@ -1 +1 @@ -= @form.number_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, placeholder: @champ.libelle, required: @champ.required?)) += @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, placeholder: @champ.libelle, required: @champ.required?, pattern: "[0-9]*", inputmode: :decimal)) diff --git a/app/javascript/controllers/autosave_controller.ts b/app/javascript/controllers/autosave_controller.ts index 4e1964351..f3a4b7d1f 100644 --- a/app/javascript/controllers/autosave_controller.ts +++ b/app/javascript/controllers/autosave_controller.ts @@ -205,6 +205,8 @@ export class AutosaveController extends ApplicationController { formData.append(input.name, input.value); } } else { + // NOTE: some type inputs (like number) have an empty input.value + // when the filled value is invalid (not a number) so we avoid them formData.append(input.name, input.value); } } diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index 2493e2663..10b9604e9 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -144,7 +144,11 @@ describe 'The user' do end.to change { Champ.count } end - let(:simple_procedure) { create(:procedure, :published, :for_individual, types_de_champ_public: [{ mandatory: true, libelle: 'texte obligatoire' }, { mandatory: false, libelle: 'texte optionnel' }]) } + let(:simple_procedure) { + create(:procedure, :published, :for_individual, types_de_champ_public: [ + { mandatory: true, libelle: 'texte obligatoire' }, { mandatory: false, libelle: 'texte optionnel' }, { mandatory: false, libelle: "nombre", type: :integer_number } + ]) + } scenario 'save an incomplete dossier as draft but cannot not submit it', js: true, retry: 3 do log_in(user, simple_procedure) @@ -170,6 +174,19 @@ describe 'The user' do expect(page).to have_current_path(merci_dossier_path(user_dossier)) end + scenario 'validates invalid number', js: true, retry: 3 do + log_in(user, simple_procedure) + fill_individual + + # Check an incomplete dossier can be saved as a draft, even when mandatory fields are missing + fill_in('nombre', with: 'environ 300') + wait_for_autosave + + within ".fr-message--error" do + expect(page).to have_content("doit être un nombre entier") + end + end + scenario 'extends dossier experation date more than one time, ', js: true, retry: 3 do simple_procedure.update(procedure_expires_when_termine_enabled: true) user_old_dossier = create(:dossier, From 123114be8167431602029fd1ebd283120edcb7cd Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 26 Sep 2023 10:38:38 +0200 Subject: [PATCH 2/4] fix(conditional): don't .to_i/to_f => 0 an invalid number --- app/models/champs/decimal_number_champ.rb | 2 ++ app/models/champs/integer_number_champ.rb | 2 ++ spec/models/logic/champ_value_spec.rb | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/app/models/champs/decimal_number_champ.rb b/app/models/champs/decimal_number_champ.rb index 31278e52a..9d67ee267 100644 --- a/app/models/champs/decimal_number_champ.rb +++ b/app/models/champs/decimal_number_champ.rb @@ -18,6 +18,8 @@ class Champs::DecimalNumberChamp < Champ private def processed_value + return if invalid? + value&.to_f end end diff --git a/app/models/champs/integer_number_champ.rb b/app/models/champs/integer_number_champ.rb index b78af269c..8fe6747a6 100644 --- a/app/models/champs/integer_number_champ.rb +++ b/app/models/champs/integer_number_champ.rb @@ -20,6 +20,8 @@ class Champs::IntegerNumberChamp < Champ private def processed_value + return if invalid? + value&.to_i end end diff --git a/spec/models/logic/champ_value_spec.rb b/spec/models/logic/champ_value_spec.rb index 387438e0f..1664165cb 100644 --- a/spec/models/logic/champ_value_spec.rb +++ b/spec/models/logic/champ_value_spec.rb @@ -39,6 +39,12 @@ describe Logic::ChampValue do it { is_expected.to be nil } end + + context 'with invalid value' do + before { champ.value = 'environ 300' } + + it { is_expected.to be nil } + end end context 'decimal tdc' do @@ -46,6 +52,12 @@ describe Logic::ChampValue do it { expect(champ_value(champ.stable_id).type([champ.type_de_champ])).to eq(:number) } it { is_expected.to eq(42.01) } + + context 'with invalid value' do + before { champ.value = 'racine de 2' } + + it { is_expected.to be nil } + end end context 'dropdown tdc' do From 75bf30bed23974b3c5d1fb87c6f9bc3d357a374a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 26 Sep 2023 16:31:35 +0200 Subject: [PATCH 3/4] feat(champ-numbers): format value in input to a backend compatible value --- .../decimal_number_component.html.haml | 2 +- .../integer_number_component.html.haml | 2 +- .../decimal_number_input_controller.ts | 35 +++++++++++++++++++ .../controllers/format_controller.ts | 17 +++++++++ app/models/champs/decimal_number_champ.rb | 7 ++++ .../users/dossiers_controller_spec.rb | 30 ++++++++++++++++ spec/system/users/brouillon_spec.rb | 25 ++++++++----- 7 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 app/javascript/controllers/decimal_number_input_controller.ts diff --git a/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml b/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml index a3efb88f4..1eca76fae 100644 --- a/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml +++ b/app/components/editable_champ/decimal_number_component/decimal_number_component.html.haml @@ -1 +1 @@ -= @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, required: @champ.required?, pattern: "-?[0-9]+([\.,][0-9]{1,3})?", inputmode: :decimal)) += @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, required: @champ.required?, pattern: "-?[0-9]+([\.,][0-9]{1,3})?", inputmode: :decimal, data: { controller: 'format decimal-number-input', format: :decimal })) diff --git a/app/components/editable_champ/integer_number_component/integer_number_component.html.haml b/app/components/editable_champ/integer_number_component/integer_number_component.html.haml index bab39525f..117e412f9 100644 --- a/app/components/editable_champ/integer_number_component/integer_number_component.html.haml +++ b/app/components/editable_champ/integer_number_component/integer_number_component.html.haml @@ -1 +1 @@ -= @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, pattern: "[0-9]*", inputmode: :numeric, required: @champ.required?)) += @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, pattern: "[0-9]*", inputmode: :numeric, required: @champ.required?, data: { controller: 'format', format: :integer })) diff --git a/app/javascript/controllers/decimal_number_input_controller.ts b/app/javascript/controllers/decimal_number_input_controller.ts new file mode 100644 index 000000000..74746e52c --- /dev/null +++ b/app/javascript/controllers/decimal_number_input_controller.ts @@ -0,0 +1,35 @@ +import { ApplicationController } from './application_controller'; + +export class DecimalNumberInputController extends ApplicationController { + connect() { + const value = this.inputElement.value; + + if (value) { + this.formatValue(value); + } + } + + formatValue(value: string) { + const number = parseFloat(value); + + if (isNaN(number)) { + return; + } + + this.inputElement.value = number.toLocaleString(); + this.emitInputEvent(); // trigger format controller + } + + private get inputElement(): HTMLInputElement { + return this.element as HTMLInputElement; + } + + private emitInputEvent() { + const event = new InputEvent('input', { + bubbles: true, + cancelable: true + }); + + this.inputElement.dispatchEvent(event); + } +} diff --git a/app/javascript/controllers/format_controller.ts b/app/javascript/controllers/format_controller.ts index aed58a6cf..439bbe35b 100644 --- a/app/javascript/controllers/format_controller.ts +++ b/app/javascript/controllers/format_controller.ts @@ -21,6 +21,13 @@ export class FormatController extends ApplicationController { const target = event.target as HTMLInputElement; target.value = this.formatInteger(target.value); }); + break; + case 'decimal': + this.on('input', (event) => { + const target = event.target as HTMLInputElement; + target.value = this.formatDecimal(target.value); + }); + break; } } @@ -39,4 +46,14 @@ export class FormatController extends ApplicationController { private formatInteger(value: string) { return value.replace(/[^\d]/g, ''); } + + private formatDecimal(value: string) { + // Le séparateur de décimales est toujours après le séparateur de milliers (un point ou une virgule). + // S'il n'y a qu'un seul séparateur, on considère que c'est celui des décimales. + // S'il n'y en a pas, ça n'a pas d'effet. + const decimalSeparator = + value.lastIndexOf(',') > value.lastIndexOf('.') ? ',' : '.'; + + return value.replace(new RegExp(`[^\\d${decimalSeparator}]`, 'g'), ''); + } } diff --git a/app/models/champs/decimal_number_champ.rb b/app/models/champs/decimal_number_champ.rb index 9d67ee267..267ce6bca 100644 --- a/app/models/champs/decimal_number_champ.rb +++ b/app/models/champs/decimal_number_champ.rb @@ -1,4 +1,5 @@ class Champs::DecimalNumberChamp < Champ + before_validation :format_value validates :value, numericality: { allow_nil: true, allow_blank: true, @@ -17,6 +18,12 @@ class Champs::DecimalNumberChamp < Champ private + def format_value + return if value.blank? + + self.value = value.tr(",", ".") + end + def processed_value return if invalid? diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 49441e50b..c85c9b477 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -694,6 +694,36 @@ describe Users::DossiersController, type: :controller do it { expect(first_champ.reload.value).to eq('beautiful value') } it { expect(response).to have_http_status(:ok) } end + + context 'decimal number champ separator' do + let (:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :decimal_number }]) } + let (:submit_payload) do + { + id: dossier.id, + dossier: { + champs_public_attributes: { first_champ.id => { id: first_champ.id, value: value } } + } + } + end + + context 'when spearator is dot' do + let(:value) { '3.14' } + + it "saves the value" do + subject + expect(first_champ.reload.value).to eq('3.14') + end + end + + context 'when spearator is comma' do + let(:value) { '3,14' } + + it "saves the value" do + subject + expect(first_champ.reload.value).to eq('3.14') + end + end + end end describe '#update en_construction' do diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index 10b9604e9..f20ef195f 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -146,7 +146,9 @@ describe 'The user' do let(:simple_procedure) { create(:procedure, :published, :for_individual, types_de_champ_public: [ - { mandatory: true, libelle: 'texte obligatoire' }, { mandatory: false, libelle: 'texte optionnel' }, { mandatory: false, libelle: "nombre", type: :integer_number } + { mandatory: true, libelle: 'texte obligatoire' }, { mandatory: false, libelle: 'texte optionnel' }, + { mandatory: false, libelle: "nombre entier", type: :integer_number }, + { mandatory: false, libelle: "nombre décimal", type: :decimal_number } ]) } @@ -174,17 +176,24 @@ describe 'The user' do expect(page).to have_current_path(merci_dossier_path(user_dossier)) end - scenario 'validates invalid number', js: true, retry: 3 do + scenario 'numbers champs formatting', js: true, retry: 3 do log_in(user, simple_procedure) fill_individual - # Check an incomplete dossier can be saved as a draft, even when mandatory fields are missing - fill_in('nombre', with: 'environ 300') - wait_for_autosave + fill_in('nombre entier', with: '300 environ') + wait_until { + champ_value_for('nombre entier') == '300' + } - within ".fr-message--error" do - expect(page).to have_content("doit être un nombre entier") - end + fill_in('nombre décimal', with: '123 456,78') + wait_until { + champ_value_for('nombre décimal') == '123456.78' + } + + fill_in('nombre décimal', with: '1,234.56') + wait_until { + champ_value_for('nombre décimal') == '1234.56' + } end scenario 'extends dossier experation date more than one time, ', js: true, retry: 3 do From e492c8a3ad0809280592d6fb048b4016012807e5 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 28 Sep 2023 15:27:42 +0200 Subject: [PATCH 4/4] fix(decimal number): description with 3 digits --- config/locales/models/champs/decimal_number_champ/en.yml | 2 +- config/locales/models/champs/decimal_number_champ/fr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/models/champs/decimal_number_champ/en.yml b/config/locales/models/champs/decimal_number_champ/en.yml index a597a4b92..1e324bac5 100644 --- a/config/locales/models/champs/decimal_number_champ/en.yml +++ b/config/locales/models/champs/decimal_number_champ/en.yml @@ -3,4 +3,4 @@ en: attributes: champs/decimal_number_champ: hints: - value: "You can enter up to 3 decimal places after the decimal point. Exemple: 3.14" + value: "You can enter up to 3 decimal places after the decimal point. Exemple: 3.141" diff --git a/config/locales/models/champs/decimal_number_champ/fr.yml b/config/locales/models/champs/decimal_number_champ/fr.yml index 3b6879514..59718dca3 100644 --- a/config/locales/models/champs/decimal_number_champ/fr.yml +++ b/config/locales/models/champs/decimal_number_champ/fr.yml @@ -3,4 +3,4 @@ fr: attributes: champs/decimal_number_champ: hints: - value: "Vous pouvez saisir jusqu’à 3 décimales après la virgule. Exemple: 3,14" + value: "Vous pouvez saisir jusqu’à 3 décimales après la virgule. Exemple: 3,141"