feat(dossier): prefill drop down list champ (#8361)

* feat(dossier): prefill drop down list champ

* decorate the types de champ to avoid if / else

In order to avoid doing if this a drop down ? / else at several places,
we decorate the types de champ and let the decorator give the possible
and example values.

* show all possible values when there are too many

* allow to prefill 'other' option

* review: remove duplicate

* review: refactor for readability

* validate that value is in options

* review: exclude disabled options
This commit is contained in:
Sébastien Carceles 2023-01-18 09:47:22 +01:00 committed by GitHub
parent 91117fe97f
commit 5c7b2ba1f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 402 additions and 18 deletions

View file

@ -0,0 +1,17 @@
class PrefillTypeDeChampsController < ApplicationController
before_action :retrieve_procedure
before_action :set_prefill_type_de_champ
def show
end
private
def retrieve_procedure
@procedure = Procedure.publiees_ou_brouillons.opendata.find_by!(path: params[:path])
end
def set_prefill_type_de_champ
@type_de_champ = TypesDeChamp::PrefillTypeDeChamp.build(@procedure.active_revision.types_de_champ_public.fillable.find(params[:id]))
end
end

View file

@ -25,6 +25,8 @@ class Champs::DropDownListChamp < Champ
OTHER = '__other__'
delegate :options_without_empty_value_when_mandatory, to: :type_de_champ
validate :value_is_in_options, unless: -> { value.blank? || drop_down_other? }
def render_as_radios?
enabled_non_empty_options.size <= THRESHOLD_NB_OPTIONS_AS_RADIO
end
@ -78,4 +80,12 @@ class Champs::DropDownListChamp < Champ
def remove_option(options)
update_column(:value, nil)
end
private
def value_is_in_options
return if enabled_non_empty_options.include?(value)
errors.add(:value, :not_in_options)
end
end

View file

@ -15,7 +15,7 @@ class PrefillDescription < SimpleDelegator
end
def types_de_champ
active_revision.types_de_champ_public.fillable.partition(&:prefillable?).flatten
TypesDeChamp::PrefillTypeDeChamp.wrap(active_revision.types_de_champ_public.fillable.partition(&:prefillable?).flatten)
end
def include?(type_de_champ_id)
@ -40,21 +40,17 @@ class PrefillDescription < SimpleDelegator
end
def prefilled_champs
@prefilled_champs ||= active_fillable_public_types_de_champ.where(id: selected_type_de_champ_ids)
@prefilled_champs ||= TypesDeChamp::PrefillTypeDeChamp.wrap(active_fillable_public_types_de_champ.where(id: selected_type_de_champ_ids))
end
private
def prefilled_champs_for_link
prefilled_champs.map { |type_de_champ| ["champ_#{type_de_champ.to_typed_id}", example_value(type_de_champ)] }.to_h
prefilled_champs.map { |type_de_champ| ["champ_#{type_de_champ.to_typed_id}", type_de_champ.example_value] }.to_h
end
def prefilled_champs_for_query
prefilled_champs.map { |type_de_champ| "\"champ_#{type_de_champ.to_typed_id}\": \"#{example_value(type_de_champ)}\"" } .join(', ')
end
def example_value(type_de_champ)
I18n.t("views.prefill_descriptions.edit.examples.#{type_de_champ.type_champ}")
prefilled_champs.map { |type_de_champ| "\"champ_#{type_de_champ.to_typed_id}\": \"#{type_de_champ.example_value}\"" } .join(', ')
end
def active_fillable_public_types_de_champ

View file

@ -264,7 +264,8 @@ class TypeDeChamp < ApplicationRecord
TypeDeChamp.type_champs.fetch(:date),
TypeDeChamp.type_champs.fetch(:datetime),
TypeDeChamp.type_champs.fetch(:yes_no),
TypeDeChamp.type_champs.fetch(:checkbox)
TypeDeChamp.type_champs.fetch(:checkbox),
TypeDeChamp.type_champs.fetch(:drop_down_list)
])
end

View file

@ -0,0 +1,16 @@
class TypesDeChamp::PrefillDropDownListTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp
def possible_values
if drop_down_other?
drop_down_list_enabled_non_empty_options.insert(
0,
I18n.t("views.prefill_descriptions.edit.possible_values.drop_down_list_other")
)
else
drop_down_list_enabled_non_empty_options
end
end
def example_value
possible_values.first
end
end

View file

@ -0,0 +1,36 @@
class TypesDeChamp::PrefillTypeDeChamp < SimpleDelegator
POSSIBLE_VALUES_THRESHOLD = 10
def self.build(type_de_champ)
case type_de_champ.type_champ
when TypeDeChamp.type_champs.fetch(:drop_down_list)
TypesDeChamp::PrefillDropDownListTypeDeChamp.new(type_de_champ)
else
new(type_de_champ)
end
end
def self.wrap(collection)
collection.map { |type_de_champ| build(type_de_champ) }
end
def possible_values
return [] unless prefillable?
[I18n.t("views.prefill_descriptions.edit.possible_values.#{type_champ}")]
end
def example_value
return nil unless prefillable?
I18n.t("views.prefill_descriptions.edit.examples.#{type_champ}")
end
def too_many_possible_values?
possible_values.count > POSSIBLE_VALUES_THRESHOLD
end
def possible_values_sample
possible_values.first(POSSIBLE_VALUES_THRESHOLD)
end
end

View file

@ -1,4 +1,4 @@
= turbo_frame_tag "#{dom_id(@prefill_description)}_types_de_champs" do
= turbo_frame_tag "#{dom_id(prefill_description)}_types_de_champs" do
.fr-grid-row.fr-grid-row--gutters.fr-py-5w
- prefill_description.types_de_champ.each do |type_de_champ|
- prefillable = type_de_champ.prefillable?
@ -38,9 +38,14 @@
%th
= t("views.prefill_descriptions.edit.possible_values.title")
%td
= t("views.prefill_descriptions.edit.possible_values.#{type_de_champ.type_champ}") if prefillable
- if type_de_champ.too_many_possible_values?
= "#{type_de_champ.possible_values_sample.join(", ")}..."
%br
= link_to "Voir toutes les valeurs possibles", prefill_type_de_champ_path(prefill_description.path, type_de_champ)
- else
= type_de_champ.possible_values.to_sentence
%tr{ class: prefillable ? "" : "fr-text-mention--grey" }
%th
= t("views.prefill_descriptions.edit.examples.title")
%td
= t("views.prefill_descriptions.edit.examples.#{type_de_champ.type_champ}") if prefillable
= type_de_champ.example_value

View file

@ -0,0 +1,36 @@
- content_for(:title, @procedure.libelle)
- content_for :footer do
= render partial: "root/footer"
.container.fr-py-5w
.card
.card-title.flex.justify-between.align-center
= @type_de_champ.libelle
= @type_de_champ.description
%table.table.vertical
%tbody
%tr
%th
= t("views.prefill_descriptions.edit.champ_id")
%td
= @type_de_champ.to_typed_id
%tr
%th
= t("views.prefill_descriptions.edit.champ_type")
%td
= t("activerecord.attributes.type_de_champ.type_champs.#{@type_de_champ.type_champ}")
%tr
%th
= t("views.prefill_descriptions.edit.possible_values.title")
%td
.fr-grid-row.fr-grid-row--gutters.fr-py-5w
- @type_de_champ.possible_values.each do |possible_value|
.fr-col-lg-3.fr-col-md-4.fr-col-sm-6.fr-col-12
= possible_value
%tr
%th
= t("views.prefill_descriptions.edit.examples.title")
%td
= @type_de_champ.example_value

View file

@ -124,6 +124,7 @@ en:
date: ISO8601 date
datetime: ISO8601 datetime
checkbox: '"true" to check, "false" to uncheck'
drop_down_list_other: Any value
examples:
title: Example
text: Short text
@ -439,6 +440,10 @@ en:
invalid: "must be 13 or 14 characters long"
reference_avis:
invalid: "must be 13 or 14 characters long"
"champs/drop_down_list_champ":
attributes:
value:
not_in_options: "must be in the given options"
errors:
format: "Field « %{attribute} » %{message}"
messages:

View file

@ -116,6 +116,7 @@ fr:
date: Date au format ISO8601
datetime: Datetime au format ISO8601
checkbox: '"true" pour coché, "false" pour décoché'
drop_down_list_other: Toute valeur
examples:
title: Exemple
text: Texte court
@ -435,7 +436,10 @@ fr:
invalid: "doit posséder 13 ou 14 caractères"
reference_avis:
invalid: "doit posséder 13 ou 14 caractères"
"champs/drop_down_list_champ":
attributes:
value:
not_in_options: "doit être dans les options proposées"
errors:
format: "Le champ « %{attribute} » %{message}"
messages:

View file

@ -198,6 +198,7 @@ Rails.application.routes.draw do
resources :procedures, only: [], param: :path do
member do
resource :prefill_description, only: :update
resources :prefill_type_de_champs, only: :show
end
end

View file

@ -0,0 +1,63 @@
# frozen_string_literal: true
RSpec.describe PrefillTypeDeChampsController, type: :controller do
describe '#show' do
let(:type_de_champ) { create(:type_de_champ_text, procedure: procedure) }
subject(:show_request) { get :show, params: { path: procedure.path, id: type_de_champ.id } }
context 'when the procedure is found' do
context 'when the procedure is publiee' do
context 'when the procedure is opendata' do
let(:procedure) { create(:procedure, :published, opendata: true) }
it { expect(show_request).to render_template(:show) }
context 'when the type de champ is not found' do
let(:type_de_champ) { double(TypeDeChamp, id: -1) }
it { expect { show_request }.to raise_error(ActiveRecord::RecordNotFound) }
end
end
context 'when the procedure is not opendata' do
let(:procedure) { create(:procedure, :published, opendata: false) }
it { expect { show_request }.to raise_error(ActiveRecord::RecordNotFound) }
end
end
context 'when the procedure is brouillon' do
context 'when the procedure is opendata' do
let(:procedure) { create(:procedure, :draft, opendata: true) }
it { expect(show_request).to render_template(:show) }
context 'when the type de champ is not found' do
let(:type_de_champ) { double(TypeDeChamp, id: -1) }
it { expect { show_request }.to raise_error(ActiveRecord::RecordNotFound) }
end
end
context 'when the procedure is not opendata' do
let(:procedure) { create(:procedure, :draft, opendata: false) }
it { expect { show_request }.to raise_error(ActiveRecord::RecordNotFound) }
end
end
context 'when the procedure is not publiee and not brouillon' do
let(:procedure) { create(:procedure, :closed) }
it { expect { show_request }.to raise_error(ActiveRecord::RecordNotFound) }
end
end
context 'when the procedure is not found' do
let(:procedure) { create(:procedure, :published, opendata: true) }
subject(:show_request) { get :show, params: { path: "wrong path", id: type_de_champ.id } }
it { expect { show_request }.to raise_error(ActiveRecord::RecordNotFound) }
end
end
end

View file

@ -92,7 +92,7 @@ FactoryBot.define do
end
type_de_champ { association :type_de_champ_drop_down_list, procedure: dossier.procedure, drop_down_other: other }
value { 'choix 1' }
value { 'val1' }
end
factory :champ_multiple_drop_down_list, class: 'Champs::MultipleDropDownListChamp' do

View file

@ -1,4 +1,54 @@
describe Champs::DropDownListChamp do
describe 'validations' do
describe 'inclusion' do
let(:drop_down) { build(:champ_drop_down_list, other: other, value: value) }
context 'when the other value is accepted' do
let(:other) { true }
context 'when the value is blank' do
let(:value) { '' }
it { expect(drop_down).to be_valid }
end
context 'when the value is included in the option list' do
let(:value) { 'val1' }
it { expect(drop_down).to be_valid }
end
context 'when the value is not included in the option list' do
let(:value) { 'something else' }
it { expect(drop_down).to be_valid }
end
end
context 'when the other value is not accepted' do
let(:other) { false }
context 'when the value is blank' do
let(:value) { '' }
it { expect(drop_down).to be_valid }
end
context 'when the value is included in the option list' do
let(:value) { 'val1' }
it { expect(drop_down).to be_valid }
end
context 'when the value is not included in the option list' do
let(:value) { 'something else' }
it { expect(drop_down).not_to be_valid }
end
end
end
end
describe '#drop_down_other?' do
let(:drop_down) { create(:champ_drop_down_list) }

View file

@ -20,7 +20,11 @@ RSpec.describe PrefillDescription, type: :model do
let!(:type_de_champ) { create(:type_de_champ_text, procedure: procedure) }
let(:prefill_description) { described_class.new(procedure) }
it { expect(prefill_description.types_de_champ).to match([type_de_champ]) }
subject(:types_de_champ) { prefill_description.types_de_champ }
it { expect(types_de_champ.count).to eq(1) }
it { expect(types_de_champ.first).to eql(TypesDeChamp::PrefillTypeDeChamp.build(type_de_champ)) }
shared_examples "filters out non fillable types de champ" do |type_de_champ_name|
context "when the procedure has a #{type_de_champ_name} champ" do

View file

@ -116,6 +116,7 @@ RSpec.describe PrefillParams do
it_behaves_like "a champ public value that is authorized", :yes_no, "false"
it_behaves_like "a champ public value that is authorized", :checkbox, "true"
it_behaves_like "a champ public value that is authorized", :checkbox, "false"
it_behaves_like "a champ public value that is authorized", :drop_down_list, "value"
it_behaves_like "a champ private value that is authorized", :text, "value"
it_behaves_like "a champ private value that is authorized", :textarea, "value"
@ -131,6 +132,7 @@ RSpec.describe PrefillParams do
it_behaves_like "a champ private value that is authorized", :yes_no, "false"
it_behaves_like "a champ private value that is authorized", :checkbox, "true"
it_behaves_like "a champ private value that is authorized", :checkbox, "false"
it_behaves_like "a champ private value that is authorized", :drop_down_list, "value"
it_behaves_like "a champ public value that is unauthorized", :decimal_number, "non decimal string"
it_behaves_like "a champ public value that is unauthorized", :integer_number, "non integer string"
@ -142,7 +144,6 @@ RSpec.describe PrefillParams do
it_behaves_like "a champ public value that is unauthorized", :date, "value"
it_behaves_like "a champ public value that is unauthorized", :datetime, "value"
it_behaves_like "a champ public value that is unauthorized", :datetime, "12-22-2022T10:30"
it_behaves_like "a champ public value that is unauthorized", :drop_down_list, "value"
it_behaves_like "a champ public value that is unauthorized", :multiple_drop_down_list, "value"
it_behaves_like "a champ public value that is unauthorized", :linked_drop_down_list, "value"
it_behaves_like "a champ public value that is unauthorized", :header_section, "value"

View file

@ -247,12 +247,12 @@ describe TypeDeChamp do
it_behaves_like "a prefillable type de champ", :type_de_champ_civilite
it_behaves_like "a prefillable type de champ", :type_de_champ_yes_no
it_behaves_like "a prefillable type de champ", :type_de_champ_checkbox
it_behaves_like "a prefillable type de champ", :type_de_champ_drop_down_list
it_behaves_like "a non-prefillable type de champ", :type_de_champ_number
it_behaves_like "a non-prefillable type de champ", :type_de_champ_communes
it_behaves_like "a non-prefillable type de champ", :type_de_champ_dossier_link
it_behaves_like "a non-prefillable type de champ", :type_de_champ_titre_identite
it_behaves_like "a non-prefillable type de champ", :type_de_champ_drop_down_list
it_behaves_like "a non-prefillable type de champ", :type_de_champ_multiple_drop_down_list
it_behaves_like "a non-prefillable type de champ", :type_de_champ_linked_drop_down_list
it_behaves_like "a non-prefillable type de champ", :type_de_champ_header_section

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
RSpec.describe TypesDeChamp::PrefillDropDownListTypeDeChamp do
describe '#possible_values' do
subject(:possible_values) { described_class.new(type_de_champ).possible_values }
context "when the drop down list accepts 'other'" do
let(:type_de_champ) { build(:type_de_champ_drop_down_list, :with_other) }
it {
expect(possible_values).to match(
[I18n.t("views.prefill_descriptions.edit.possible_values.drop_down_list_other")] + type_de_champ.drop_down_list_enabled_non_empty_options
)
}
end
context "when the drop down list does not accept 'other'" do
let(:type_de_champ) { build(:type_de_champ_drop_down_list) }
it { expect(possible_values).to match(type_de_champ.drop_down_list_enabled_non_empty_options) }
end
end
describe '#example_value' do
let(:type_de_champ) { build(:type_de_champ_drop_down_list) }
subject(:example_value) { described_class.new(type_de_champ).example_value }
it { expect(example_value).to eq(type_de_champ.drop_down_list_enabled_non_empty_options.first) }
end
end

View file

@ -0,0 +1,85 @@
# frozen_string_literal: true
RSpec.describe TypesDeChamp::PrefillTypeDeChamp, type: :model do
describe '.build' do
subject(:built) { described_class.build(type_de_champ) }
context 'when the type de champ is a drop_down_list' do
let(:type_de_champ) { build(:type_de_champ_drop_down_list) }
it { expect(built).to be_kind_of(TypesDeChamp::PrefillDropDownListTypeDeChamp) }
end
context 'when any other type de champ' do
let(:type_de_champ) { build(:type_de_champ_date) }
it { expect(built).to be_kind_of(TypesDeChamp::PrefillTypeDeChamp) }
end
end
describe '.wrap' do
subject(:wrapped) { described_class.wrap([build(:type_de_champ_drop_down_list), build(:type_de_champ_email)]) }
it 'wraps the collection' do
expect(wrapped.first).to be_kind_of(TypesDeChamp::PrefillDropDownListTypeDeChamp)
expect(wrapped.last).to be_kind_of(TypesDeChamp::PrefillTypeDeChamp)
end
end
describe '#possible_values' do
subject(:possible_values) { described_class.build(type_de_champ).possible_values }
context 'when the type de champ is not prefillable' do
let(:type_de_champ) { build(:type_de_champ_mesri) }
it { expect(possible_values).to be_empty }
end
context 'when the type de champ is prefillable' do
let(:type_de_champ) { build(:type_de_champ_email) }
it { expect(possible_values).to match([I18n.t("views.prefill_descriptions.edit.possible_values.#{type_de_champ.type_champ}")]) }
end
end
describe '#example_value' do
subject(:example_value) { described_class.build(type_de_champ).example_value }
context 'when the type de champ is not prefillable' do
let(:type_de_champ) { build(:type_de_champ_mesri) }
it { expect(example_value).to be_nil }
end
context 'when the type de champ is prefillable' do
let(:type_de_champ) { build(:type_de_champ_email) }
it { expect(example_value).to eq(I18n.t("views.prefill_descriptions.edit.examples.#{type_de_champ.type_champ}")) }
end
end
describe '#too_many_possible_values?' do
let(:type_de_champ) { build(:type_de_champ_drop_down_list) }
subject(:too_many_possible_values) { described_class.build(type_de_champ).too_many_possible_values? }
context 'when there are too many possible values' do
before { type_de_champ.drop_down_options = (1..described_class::POSSIBLE_VALUES_THRESHOLD + 1).map(&:to_s) }
it { expect(too_many_possible_values).to eq(true) }
end
context 'when there are not too many possible values' do
before { type_de_champ.drop_down_options = (1..described_class::POSSIBLE_VALUES_THRESHOLD).map(&:to_s) }
it { expect(too_many_possible_values).to eq(false) }
end
end
describe '#possible_values_sample' do
let(:drop_down_options) { (1..described_class::POSSIBLE_VALUES_THRESHOLD + 1).map(&:to_s) }
let(:type_de_champ) { build(:type_de_champ_drop_down_list, drop_down_options: drop_down_options) }
subject(:possible_values_sample) { described_class.build(type_de_champ).possible_values_sample }
it { expect(possible_values_sample).to match(drop_down_options.first(described_class::POSSIBLE_VALUES_THRESHOLD)) }
end
end

View file

@ -0,0 +1,22 @@
describe 'prefill_descriptions/types_de_champs.html.haml', type: :view do
let(:prefill_description) { PrefillDescription.new(create(:procedure)) }
let!(:type_de_champ) { create(:type_de_champ_drop_down_list, procedure: prefill_description, drop_down_options: options) }
subject { render('prefill_descriptions/types_de_champs.html.haml', prefill_description: prefill_description) }
context 'when a type de champ has too many values' do
let(:options) { (1..20).map(&:to_s) }
it { is_expected.to have_content(type_de_champ.libelle) }
it { is_expected.to have_link(text: "Voir toutes les valeurs possibles", href: prefill_type_de_champ_path(prefill_description.path, type_de_champ)) }
end
context 'when a type de champ does not have too many values' do
let(:options) { (1..2).map(&:to_s) }
it { is_expected.to have_content(type_de_champ.libelle) }
it { is_expected.not_to have_link(text: "Voir toutes les valeurs possibles", href: prefill_type_de_champ_path(prefill_description.path, type_de_champ)) }
end
end

View file

@ -40,7 +40,7 @@ describe 'shared/dossiers/edit.html.haml', type: :view do
context 'with a single-value list' do
let(:dossier) { create(:dossier) }
let(:type_de_champ) { create(:type_de_champ_drop_down_list, mandatory: mandatory, procedure: dossier.procedure) }
let(:champ) { create(:champ_drop_down_list, dossier: dossier, type_de_champ: type_de_champ) }
let(:champ) { create(:champ_drop_down_list, dossier: dossier, type_de_champ: type_de_champ, value: value) }
let(:options) { type_de_champ.drop_down_list_options }
let(:enabled_options) { type_de_champ.drop_down_list_enabled_non_empty_options }
let(:mandatory) { true }
@ -48,6 +48,7 @@ describe 'shared/dossiers/edit.html.haml', type: :view do
before { dossier.champs_public << champ }
context 'when the list is short' do
let(:value) { 'val1' }
it 'renders the list as radio buttons' do
expect(subject).to have_selector('input[type=radio]', count: enabled_options.count)
end
@ -63,6 +64,7 @@ describe 'shared/dossiers/edit.html.haml', type: :view do
end
context 'when the list is long' do
let(:value) { 'alpha' }
let(:type_de_champ) { create(:type_de_champ_drop_down_list, :long, procedure: dossier.procedure) }
it 'renders the list as a dropdown' do