diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index 7843bcbb1..5016b4bc5 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -12,6 +12,17 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent def column_type = column.present? ? column.type : :text + def html_column_type + case column_type + when :datetime, :date + 'date' + when :integer, :decimal + 'number' + else + 'text' + end + end + def options_for_select_of_column if column.scope.present? I18n.t(column.scope).map(&:to_a).map(&:reverse) @@ -56,10 +67,11 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent private def find_type_de_champ(column) + stable_id = column.to_s.split('->').first TypeDeChamp .joins(:revision_types_de_champ) .where(revision_types_de_champ: { revision_id: procedure.revisions }) .order(created_at: :desc) - .find_by(stable_id: column) + .find_by(stable_id:) end end diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index 603aa3819..ec9c7eee6 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -14,7 +14,7 @@ } = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - - if column_type == :enum || column_type == :enums + - if column_type.in?([:enum, :enums, :boolean]) = select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', @@ -23,7 +23,7 @@ data: { no_autosubmit: true } - else %input#value.fr-input{ - type: column_type, + type: html_column_type, name: "#{prefix}[filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, diff --git a/app/models/champ.rb b/app/models/champ.rb index 7bd7fc223..02f82d25a 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -118,6 +118,10 @@ class Champ < ApplicationRecord TypeDeChamp::CHAMP_TYPE_TO_TYPE_CHAMP.fetch(type) end + def last_write_column_type + TypeDeChamp.column_type(last_write_type_champ) + end + def main_value_name :value end diff --git a/app/models/column.rb b/app/models/column.rb index 2e95a9c3e..65497078a 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -50,4 +50,100 @@ class Column procedure.find_column(h_id: h_id) end + + def get_value(champ) + return if champ.nil? + + value = get_raw_value(champ) + if should_cast? + # FIXME: remove this, once displayable is implemented through columns + return nil if champ.last_write_type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) + from_type = champ.last_write_column_type + to_type = type + parsed_value = parse_value(value, from_type) + cast_value(parsed_value, from_type:, to_type:) + else + value + end + end + + private + + def get_raw_value(champ) + champ.public_send(value_column) + end + + def should_cast? + true + end + + def parse_value(value, type) + return if value.blank? + + case type + when :boolean + parse_boolean(value) + when :integer + value.to_i + when :decimal + value.to_f + when :datetime + parse_datetime(value) + when :date + parse_datetime(value)&.to_date + when :enums + parse_enums(value) + else + value + end + end + + def cast_value(value, from_type:, to_type:) + return if value.blank? + return value if from_type == to_type + + case [from_type, to_type] + when [:integer, :decimal] # recast numbers automatically + value.to_f + when [:decimal, :integer] # may lose some data, but who cares ? + value.to_i + when [:integer, :text], [:decimal, :text] # number to text + value.to_s + when [:enum, :enums] # single list can become multi + [value] + when [:enum, :text] # single list can become text + value + when [:enums, :enum] # multi list can become single list + value.first + when [:enums, :text] # multi list can become text + value.join(', ') + when [:date, :datetime] # date <=> datetime + value.to_datetime + when [:datetime, :date] # may lose some data, but who cares ? + value.to_date + else + nil + end + end + + def parse_boolean(value) + case value + when 'true', 'on', '1' + true + when 'false' + false + end + end + + def parse_enums(value) + JSON.parse(value) + rescue JSON::ParserError + nil + end + + def parse_datetime(value) + Time.zone.parse(value) + rescue ArgumentError + nil + end end diff --git a/app/models/columns/dossier_column.rb b/app/models/columns/dossier_column.rb new file mode 100644 index 000000000..138b6092b --- /dev/null +++ b/app/models/columns/dossier_column.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Columns::DossierColumn < Column + def get_value(dossier) + case table + when 'self' + dossier.public_send(column) + when 'etablissement' + dossier.etablissement.public_send(column) + when 'individual' + dossier.individual.public_send(column) + when 'groupe_instructeur' + dossier.groupe_instructeur.label + when 'followers_instructeurs' + dossier.followers_instructeurs.map(&:email).join(' ') + end + end +end diff --git a/app/models/columns/json_path_column.rb b/app/models/columns/json_path_column.rb index 10b0f3537..210e41e7a 100644 --- a/app/models/columns/json_path_column.rb +++ b/app/models/columns/json_path_column.rb @@ -25,6 +25,14 @@ class Columns::JSONPathColumn < Column private + def get_raw_value(champ) + champ.value_json&.dig(*value_column) + end + + def should_cast? + false + end + def stable_id @column end diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb new file mode 100644 index 000000000..dfa7efe66 --- /dev/null +++ b/app/models/columns/linked_drop_down_column.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Columns::LinkedDropDownColumn < Column + def column + "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other + end + + def filtered_ids(dossiers, values) + dossiers.with_type_de_champ(@column) + .filter_ilike(:champs, :value, values) + .ids + end + + private + + def get_raw_value(champ) + primary_value, secondary_value = unpack_values(champ.value) + case value_column + when :primary + primary_value + when :secondary + secondary_value + end + end + + def should_cast? + false + end + + def unpack_values(value) + JSON.parse(value) + rescue JSON::ParserError + [] + end +end diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 0d1b9a984..943d772a7 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -30,25 +30,25 @@ module ColumnsConcern end def dossier_id_column - Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'id', type: :number) end def dossier_state_column - Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) end def notifications_column - Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + Columns::DossierColumn.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) end def dossier_columns common = [dossier_id_column, notifications_column] dates = ['created_at', 'updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date) } non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } states = [dossier_state_column] @@ -61,11 +61,11 @@ module ColumnsConcern scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ - Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, label: I18n.t("#{sva_svr_decision}_decision_on", scope:)) ] - columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + columns << Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) columns @@ -80,29 +80,29 @@ module ColumnsConcern private def email_column - Column.new(procedure_id: id, table: 'user', column: 'email') + Columns::DossierColumn.new(procedure_id: id, table: 'user', column: 'email') end def standard_columns [ email_column, - Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), - Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), - Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? + Columns::DossierColumn.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), + Columns::DossierColumn.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), + Columns::DossierColumn.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? ] end def individual_columns - ['nom', 'prenom', 'gender'].map { |column| Column.new(procedure_id: id, table: 'individual', column:) } + ['nom', 'prenom', 'gender'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'individual', column:) } end def moral_columns etablissements = ['entreprise_siren', 'entreprise_forme_juridique', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] - .map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } - etablissement_dates = ['entreprise_date_creation'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:, type: :date) } + etablissement_dates = ['entreprise_date_creation'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:, type: :date) } - other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } + other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } [etablissements, etablissement_dates, other].flatten end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 56bd4e7aa..9d82f64b5 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -327,11 +327,6 @@ class TypeDeChamp < ApplicationRecord ]) end - def self.is_choice_type_from(type_champ) - return false if type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) # To remove when we stop using linked_drop_down_list - TYPE_DE_CHAMP_TO_CATEGORIE[type_champ.to_sym] == CHOICE || type_champ.in?([TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions)]) - end - def drop_down_list? type_champ.in?([ TypeDeChamp.type_champs.fetch(:drop_down_list), @@ -531,17 +526,28 @@ class TypeDeChamp < ApplicationRecord end end - def self.filter_hash_type(type_champ) - if type_champ == 'multiple_drop_down_list' + def self.column_type(type_champ) + case type_champ + when TypeDeChamp.type_champs.fetch(:datetime) + :datetime + when TypeDeChamp.type_champs.fetch(:date) + :date + when TypeDeChamp.type_champs.fetch(:integer_number) + :integer + when TypeDeChamp.type_champs.fetch(:decimal_number) + :decimal + when TypeDeChamp.type_champs.fetch(:multiple_drop_down_list) :enums - elsif is_choice_type_from(type_champ) + when TypeDeChamp.type_champs.fetch(:drop_down_list), TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions) :enum + when TypeDeChamp.type_champs.fetch(:checkbox), TypeDeChamp.type_champs.fetch(:yes_no) + :boolean else :text end end - def self.filter_hash_value_column(type_champ) + def self.value_column(type_champ) if type_champ.in?([TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions)]) :external_id else @@ -554,6 +560,12 @@ class TypeDeChamp < ApplicationRecord APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } elsif region? APIGeoService.regions.map { [_1[:name], _1[:code]] } + elsif linked_drop_down_list? + if column.value_column == :primary + primary_options + else + secondary_options.values.flatten + end elsif choice_type? if drop_down_list? drop_down_options diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index 85b7922fb..103922861 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -67,6 +67,29 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas end end + def columns(procedure_id:, displayable: true, prefix: nil) + super.concat([ + Columns::LinkedDropDownColumn.new( + procedure_id:, + table: Column::TYPE_DE_CHAMP_TABLE, + column: stable_id.to_s, + label: "#{libelle_with_prefix(prefix)} (Primaire)", + type: :enum, + value_column: :primary, + displayable: false + ), + Columns::LinkedDropDownColumn.new( + procedure_id:, + table: Column::TYPE_DE_CHAMP_TABLE, + column: stable_id.to_s, + label: "#{libelle_with_prefix(prefix)} (Secondaire)", + type: :enum, + value_column: :secondary, + displayable: false + ) + ]) + end + private def paths diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index e081d0e4e..8d15205d2 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -99,8 +99,8 @@ class TypesDeChamp::TypeDeChampBase table: Column::TYPE_DE_CHAMP_TABLE, column: stable_id.to_s, label: libelle_with_prefix(prefix), - type: TypeDeChamp.filter_hash_type(type_champ), - value_column: TypeDeChamp.filter_hash_value_column(type_champ), + type: TypeDeChamp.column_type(type_champ), + value_column: TypeDeChamp.value_column(type_champ), displayable: ) ] diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index b046dbe5c..d43dae082 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -71,7 +71,7 @@ class DossierFilterService filtered_column = filters_for_column.first.column value_column = filtered_column.value_column - if filtered_column.is_a?(Columns::JSONPathColumn) + if filtered_column.respond_to?(:filtered_ids) filtered_column.filtered_ids(dossiers, values) else case table diff --git a/spec/models/column_spec.rb b/spec/models/column_spec.rb new file mode 100644 index 000000000..2d7652822 --- /dev/null +++ b/spec/models/column_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +describe Column do + describe 'get_value' do + let(:groupe_instructeur) { create(:groupe_instructeur, instructeurs: [create(:instructeur)]) } + + context 'when dossier columns' do + context 'when procedure for individual' do + let(:individual) { create(:individual, nom: "Sim", prenom: "Paul", gender: 'M.') } + let(:procedure) { create(:procedure, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, individual:, mandataire_first_name: "Martin", mandataire_last_name: "Christophe", for_tiers: true) } + + it 'retrieve individual information' do + expect(procedure.find_column(label: "Prénom").get_value(dossier)).to eq("Paul") + expect(procedure.find_column(label: "Nom").get_value(dossier)).to eq("Sim") + expect(procedure.find_column(label: "Civilité").get_value(dossier)).to eq("M.") + end + end + + context 'when procedure for entreprise' do + let(:procedure) { create(:procedure, for_individual: false, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :en_instruction, :with_entreprise, procedure:) } + + it 'retrieve entreprise information' do + expect(procedure.find_column(label: "Libellé NAF").get_value(dossier)).to eq('Transports par conduites') + end + end + + context 'when sva/svr enabled' do + let(:procedure) { create(:procedure, :sva, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :en_instruction, procedure:) } + + it 'does not fail' do + expect(procedure.find_column(label: "Date décision SVA").get_value(dossier)).to eq(nil) + end + end + end + + context 'when champ columns' do + let(:procedure) { create(:procedure, :with_all_champs_mandatory, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } + let(:types_de_champ) { procedure.all_revisions_types_de_champ } + + it 'extracts values for columns and type de champ' do + expect_type_de_champ_values('civilite', ["M."]) + expect_type_de_champ_values('email', ['yoda@beta.gouv.fr']) + expect_type_de_champ_values('phone', ['0666666666']) + expect_type_de_champ_values('address', ["2 rue des Démarches"]) + expect_type_de_champ_values('communes', ["Coye-la-Forêt"]) + expect_type_de_champ_values('departements', ['01']) + expect_type_de_champ_values('regions', ['01']) + expect_type_de_champ_values('pays', ['France']) + expect_type_de_champ_values('epci', [nil]) + expect_type_de_champ_values('iban', [nil]) + expect_type_de_champ_values('siret', ["44011762001530", "postal_code", "city_name", "departement_code", "region_name"]) + expect_type_de_champ_values('text', ['text']) + expect_type_de_champ_values('textarea', ['textarea']) + expect_type_de_champ_values('number', ['42']) + expect_type_de_champ_values('decimal_number', [42.1]) + expect_type_de_champ_values('integer_number', [42]) + expect_type_de_champ_values('date', [Time.zone.parse('2019-07-10').to_date]) + expect_type_de_champ_values('datetime', [Time.zone.parse("1962-09-15T15:35:00+01:00")]) + expect_type_de_champ_values('checkbox', [true]) + expect_type_de_champ_values('drop_down_list', ['val1']) + expect_type_de_champ_values('multiple_drop_down_list', [["val1", "val2"]]) + expect_type_de_champ_values('linked_drop_down_list', [nil, "categorie 1", "choix 1"]) + expect_type_de_champ_values('yes_no', [true]) + expect_type_de_champ_values('annuaire_education', [nil]) + expect_type_de_champ_values('carte', [nil]) + expect_type_de_champ_values('cnaf', [nil]) + expect_type_de_champ_values('dgfip', [nil]) + expect_type_de_champ_values('pole_emploi', [nil]) + expect_type_de_champ_values('mesri', [nil]) + expect_type_de_champ_values('cojo', [nil]) + expect_type_de_champ_values('expression_reguliere', [nil]) + end + end + end + + private + + def expect_type_de_champ_values(type, values) + type_de_champ = types_de_champ.find { _1.type_champ == type } + champ = dossier.send(:filled_champ, type_de_champ, nil) + columns = type_de_champ.columns(procedure_id: procedure.id) + expect(columns.map { _1.get_value(champ) }).to eq(values) + end + + def retrieve_champ(type) + type_de_champ = types_de_champ.find { _1.type_champ == type } + dossier.send(:filled_champ, type_de_champ, nil) + end +end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index d787329d9..27d062f32 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -81,6 +81,15 @@ describe ColumnsConcern do let(:types_de_champ_private) { [] } it { expect(subject.map(&:label)).to include('rna – commune') } end + + context 'with linked drop down list' do + let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: 'linked' }] } + let(:types_de_champ_private) { [] } + it { + expect(subject.map(&:label)).to include('linked (Primaire)') + expect(subject.map(&:label)).to include('linked (Secondaire)') + } + end end context 'when the procedure is for individuals' do