From 7d1455878d47e702c16de36703a80d7245853caf Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 30 Jun 2026 13:59:41 +0200 Subject: [PATCH 1/4] refactor(personnalisation): extract column value formatting into a shared module --- app/components/instructeurs/cell_component.rb | 36 ++-------------- app/lib/column_value_formatter.rb | 43 +++++++++++++++++++ spec/lib/column_value_formatter_spec.rb | 36 ++++++++++++++++ 3 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 app/lib/column_value_formatter.rb create mode 100644 spec/lib/column_value_formatter_spec.rb diff --git a/app/components/instructeurs/cell_component.rb b/app/components/instructeurs/cell_component.rb index 7786c60f6fd..0d50849b5b5 100644 --- a/app/components/instructeurs/cell_component.rb +++ b/app/components/instructeurs/cell_component.rb @@ -28,7 +28,7 @@ def simple_layout raw_value = raw_value_for_column(@dossier, @column) return '' if raw_value.nil? - format(raw_value, @column.type) + format(raw_value) end def raw_value_for_column(dossier, column) @@ -41,38 +41,8 @@ def raw_value_for_column(dossier, column) @column.value(data) end - def format(raw_value, type) - case @column.type - when :boolean - if @column.type_de_champ? && @column.tdc_type == 'checkbox' - raw_value ? I18n.t('activerecord.attributes.type_de_champ.type_champs.checkbox_true') : '' - else - raw_value ? I18n.t('utils.yes') : I18n.t('utils.no') - end - when :attachments - raw_value.present? ? 'présent' : 'absent' - when :enum - format_enum(column: @column, raw_value:) - when :enums - format_enums(column: @column, raw_values: raw_value) - when :date - raw_value = Date.parse(raw_value) if raw_value.is_a?(String) - I18n.l(raw_value, format: :short) - when :datetime - raw_value = DateTime.parse(raw_value) if raw_value.is_a?(String) - I18n.l(raw_value, format: :short_with_time) - else - # Escape if it's a string and not already safe - raw_value.html_safe? ? raw_value : html_escape(raw_value.to_s) - end - end - - def format_enums(column:, raw_values:) - safe_join(raw_values.map { format_enum(column:, raw_value: _1) }, ', ') - end - - def format_enum(column:, raw_value:) - column.label_for_value(raw_value) + def format(raw_value) + ColumnValueFormatter.format(column: @column, raw_value:) end def email_and_tiers(dossier) diff --git a/app/lib/column_value_formatter.rb b/app/lib/column_value_formatter.rb new file mode 100644 index 00000000000..deed1a8e6cd --- /dev/null +++ b/app/lib/column_value_formatter.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ColumnValueFormatter + module_function + + def format(column:, raw_value:) + return if raw_value.nil? + + case column.type + when :boolean + if column.type_de_champ? && column.tdc_type == 'checkbox' + raw_value ? I18n.t('activerecord.attributes.type_de_champ.type_champs.checkbox_true') : '' + else + raw_value ? I18n.t('utils.yes') : I18n.t('utils.no') + end + when :attachments + raw_value.present? ? 'présent' : 'absent' + when :enum + format_enum(column:, raw_value:) + when :enums + format_enums(column:, raw_values: raw_value) + when :date + raw_value = Date.parse(raw_value) if raw_value.is_a?(String) + I18n.l(raw_value, format: :short) + when :datetime + raw_value = DateTime.parse(raw_value) if raw_value.is_a?(String) + I18n.l(raw_value, format: :short_with_time) + else + raw_value.html_safe? ? raw_value : ERB::Util.html_escape(raw_value.to_s) + end + end + + def format_enums(column:, raw_values:) + ActionController::Base.helpers.safe_join( + raw_values.map { |v| format_enum(column:, raw_value: v) }, + ', ' + ) + end + + def format_enum(column:, raw_value:) + column.label_for_value(raw_value) + end +end diff --git a/spec/lib/column_value_formatter_spec.rb b/spec/lib/column_value_formatter_spec.rb new file mode 100644 index 00000000000..66855e4d82b --- /dev/null +++ b/spec/lib/column_value_formatter_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ColumnValueFormatter do + let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :text, libelle: 'Ville' }, { type: :date, libelle: 'Date' }]) } + let(:text_column) { procedure.columns.find { |c| c.respond_to?(:tdc_type) && c.tdc_type == 'text' } } + let(:date_column) { procedure.columns.find { |c| c.respond_to?(:tdc_type) && c.tdc_type == 'date' } } + + it 'formats a date with the short format' do + expect(described_class.format(column: date_column, raw_value: Date.new(2026, 3, 18))).to eq(I18n.l(Date.new(2026, 3, 18), format: :short)) + end + + it 'escapes and returns text values' do + expect(described_class.format(column: text_column, raw_value: 'Presse Océan')).to eq('Presse Océan') + end + + it 'returns nil when raw_value is nil' do + expect(described_class.format(column: text_column, raw_value: nil)).to be_nil + end + + describe 'format_enums' do + let(:enums_column) { double('column', type: :enums) } + + it 'returns an html_safe SafeBuffer and escapes HTML chars in labels' do + allow(enums_column).to receive(:label_for_value).with('a').and_return('A & B') + allow(enums_column).to receive(:label_for_value).with('b').and_return('C') + + result = described_class.format(column: enums_column, raw_value: ['a', 'b']) + + expect(result).to be_html_safe + expect(result).to include('A & B') + expect(result).to include('C') + end + end +end From fa288e7f41265954a85497e244c19cba1cb11ec4 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 30 Jun 2026 13:59:41 +0200 Subject: [PATCH 2/4] feat(personnalisation): add dossier card champs component --- .../users/dossier_card_champs_component.rb | 29 ++++++++++++++++ .../dossier_card_champs_component.en.yml | 2 ++ .../dossier_card_champs_component.fr.yml | 2 ++ .../dossier_card_champs_component.html.erb | 6 ++++ .../dossier_card_champs_component_spec.rb | 34 +++++++++++++++++++ 5 files changed, 73 insertions(+) create mode 100644 app/components/users/dossier_card_champs_component.rb create mode 100644 app/components/users/dossier_card_champs_component/dossier_card_champs_component.en.yml create mode 100644 app/components/users/dossier_card_champs_component/dossier_card_champs_component.fr.yml create mode 100644 app/components/users/dossier_card_champs_component/dossier_card_champs_component.html.erb create mode 100644 spec/components/users/dossier_card_champs_component_spec.rb diff --git a/app/components/users/dossier_card_champs_component.rb b/app/components/users/dossier_card_champs_component.rb new file mode 100644 index 00000000000..76a3a8edc7e --- /dev/null +++ b/app/components/users/dossier_card_champs_component.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Users::DossierCardChampsComponent < ApplicationComponent + Line = Data.define(:label, :value) + + def initialize(columns:, champs_by_stable_id:) + @columns = columns + @champs_by_stable_id = champs_by_stable_id + end + + def render? = lines.any? + + private + + attr_reader :columns, :champs_by_stable_id + + def lines + @lines ||= columns.filter_map do |column| + next unless column.champ_column? + + champ = champs_by_stable_id[column.stable_id] + raw = column.value(champ) + formatted = ColumnValueFormatter.format(column:, raw_value: raw) + next if formatted.blank? + + Line.new(label: column.label, value: formatted) + end + end +end diff --git a/app/components/users/dossier_card_champs_component/dossier_card_champs_component.en.yml b/app/components/users/dossier_card_champs_component/dossier_card_champs_component.en.yml new file mode 100644 index 00000000000..d77d7a7e9e8 --- /dev/null +++ b/app/components/users/dossier_card_champs_component/dossier_card_champs_component.en.yml @@ -0,0 +1,2 @@ +en: + label: "%{champ}:" diff --git a/app/components/users/dossier_card_champs_component/dossier_card_champs_component.fr.yml b/app/components/users/dossier_card_champs_component/dossier_card_champs_component.fr.yml new file mode 100644 index 00000000000..940b3e8601b --- /dev/null +++ b/app/components/users/dossier_card_champs_component/dossier_card_champs_component.fr.yml @@ -0,0 +1,2 @@ +fr: + label: "%{champ} :" diff --git a/app/components/users/dossier_card_champs_component/dossier_card_champs_component.html.erb b/app/components/users/dossier_card_champs_component/dossier_card_champs_component.html.erb new file mode 100644 index 00000000000..1bd3829ad0e --- /dev/null +++ b/app/components/users/dossier_card_champs_component/dossier_card_champs_component.html.erb @@ -0,0 +1,6 @@ +<% lines.each do |line| %> +

+ <%= t(".label", champ: line.label) %> + <%= line.value %> +

+<% end %> diff --git a/spec/components/users/dossier_card_champs_component_spec.rb b/spec/components/users/dossier_card_champs_component_spec.rb new file mode 100644 index 00000000000..9e82b55b801 --- /dev/null +++ b/spec/components/users/dossier_card_champs_component_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.describe Users::DossierCardChampsComponent, type: :component do + let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :text, libelle: 'Titre' }]) } + let(:dossier) { create(:dossier, :en_construction, procedure:, populate_champs: true) } + let(:column) { procedure.personnalisable_columns.first } + + it 'renders the formatted value with an info icon when the champ has a value' do + champ = dossier.champs.find { _1.stable_id == column.stable_id } + champ.update(value: 'Presse Océan') + + render_inline(described_class.new(columns: [column], champs_by_stable_id: { column.stable_id => champ.reload })) + + expect(page).to have_css('.fr-icon-info-i') + expect(page).to have_text('Presse Océan') + end + + it 'renders the formatted value for a brouillon dossier' do + brouillon = create(:dossier, :brouillon, procedure:, populate_champs: true) + champ = brouillon.champs.find { _1.stable_id == column.stable_id } + champ.update(value: 'Valeur brouillon') + + render_inline(described_class.new(columns: [column], champs_by_stable_id: { column.stable_id => champ.reload })) + + expect(page).to have_css('.fr-icon-info-i') + expect(page).to have_text('Valeur brouillon') + end + + it 'renders nothing when the champ is missing or empty' do + render_inline(described_class.new(columns: [column], champs_by_stable_id: {})) + + expect(page).not_to have_css('.fr-icon-info-i') + end +end From 9b03b5582482c1ec60d28b0839651f89f29127fb Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 30 Jun 2026 13:59:41 +0200 Subject: [PATCH 3/4] feat(personnalisation): display chosen champ values on the dossiers list --- app/controllers/users/dossiers_controller.rb | 24 ++++++++ app/models/concerns/columns_concern.rb | 2 +- .../dossiers/_dossier_card_content.html.erb | 7 +++ .../users/dossiers/_dossiers_list.html.erb | 7 ++- .../users/dossiers_controller_spec.rb | 61 +++++++++++++++++++ spec/models/procedure_spec.rb | 20 ++++++ 6 files changed, 119 insertions(+), 2 deletions(-) diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 4614231663a..0383ff982d4 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -55,6 +55,7 @@ def index Skylight.instrument(title: list_title) do @dossiers.load end + load_personnalisation_data(@dossiers) @corbeille_count = current_user.dossiers.hidden_by_user.or(current_user.dossiers.hidden_by_expired).count @pending_transfers_count = current_user.dossier_transfers_received_pending.count @show_simple_list = params[:search].blank? && !@filter.active? && @total_count <= SIMPLE_LIST_THRESHOLD @@ -687,6 +688,29 @@ def commentaire_params params.require(:commentaire).permit(:body, piece_jointe: []) end + def load_personnalisation_data(dossiers) + @personnalisations_by_procedure_id = {} + @champs_by_dossier_id = {} + return if !feature_enabled?(:dossiers_list_personnalisation) + + @personnalisations_by_procedure_id = current_user.dossiers_list_personnalisations + .where(procedure_id: dossiers.map { _1.procedure.id }.uniq) + .index_by(&:procedure_id) + return if @personnalisations_by_procedure_id.empty? + + stable_ids = @personnalisations_by_procedure_id.values + .flat_map(&:displayed_columns) + .filter(&:champ_column?) + .map(&:stable_id) + .uniq + return if stable_ids.empty? + + Champ.where(dossier_id: dossiers.map(&:id), stable_id: stable_ids, stream: Champ::MAIN_STREAM) + .find_each do |champ| + (@champs_by_dossier_id[champ.dossier_id] ||= {})[champ.stable_id] = champ + end + end + def personnalisation_available? feature_enabled?(:dossiers_list_personnalisation) && current_user.dossiers.visible_by_user.count > SIMPLE_LIST_THRESHOLD diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 85ac7e3bce4..af5c04bb041 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -281,7 +281,7 @@ def moral_columns end def types_de_champ_columns - all_revisions_types_de_champ.flat_map { _1.columns(procedure_id: id) } + all_revisions_types_de_champ.filter(&:dynamic_type).flat_map { _1.columns(procedure_id: id) } end def dossier_col(**args) = Columns::DossierColumn.new(**(args.merge(procedure_id: id))) diff --git a/app/views/users/dossiers/_dossier_card_content.html.erb b/app/views/users/dossiers/_dossier_card_content.html.erb index e2a50577b39..6d2e3bee713 100644 --- a/app/views/users/dossiers/_dossier_card_content.html.erb +++ b/app/views/users/dossiers/_dossier_card_content.html.erb @@ -76,6 +76,13 @@

<% end %> <% end %> + + <% if local_assigns[:personnalisation].present? %> + <%= render Users::DossierCardChampsComponent.new( + columns: personnalisation.displayed_columns, + champs_by_stable_id: local_assigns[:champs_by_stable_id] || {} + ) %> + <% end %>
diff --git a/app/views/users/dossiers/_dossiers_list.html.erb b/app/views/users/dossiers/_dossiers_list.html.erb index 8c42155458e..a66110743db 100644 --- a/app/views/users/dossiers/_dossiers_list.html.erb +++ b/app/views/users/dossiers/_dossiers_list.html.erb @@ -1,7 +1,12 @@ <% if dossiers.present? %> <% dossiers.each do |dossier| %>
- <%= render partial: 'dossier_card_content', locals: { dossier: dossier, linkable: @statut != "dossiers-supprimes" } %> + <%= render partial: 'dossier_card_content', locals: { + dossier: dossier, + linkable: @statut != "dossiers-supprimes", + personnalisation: @personnalisations_by_procedure_id&.[](dossier.procedure.id), + champs_by_stable_id: @champs_by_dossier_id&.[](dossier.id) || {} + } %> <% if dossier.procedure.close? %> <%= render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: "fr-mb-2w") do |c| %> diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 1a54da44caa..cb471fe9221 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -2788,6 +2788,67 @@ def announced_region(body) end end + describe 'GET #index with personnalisation values' do + render_views + + let(:user) { create(:user) } + let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :text, libelle: 'Titre' }]) } + let(:column) { procedure.personnalisable_columns.first } + + before do + Flipper.enable(:dossiers_list_personnalisation, user) + create(:dossiers_list_personnalisation, user:, procedure:, displayed_columns: [column]) + sign_in(user) + end + + def query_count + count = 0 + callback = -> (*) { count += 1 } + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') { get :index } + count + end + + it 'does not issue more queries as more dossiers are added (no N+1)' do + create_list(:dossier, 6, :en_construction, user:, procedure:, populate_champs: true) + baseline = query_count + + create_list(:dossier, 6, :en_construction, user:, procedure:, populate_champs: true) + expect(query_count).to be <= baseline + 3 + end + end + + describe 'GET #index with personnalisation values – multi-procedure N+1 guard' do + render_views + + let(:user) { create(:user) } + let(:procedure1) { create(:procedure, :published, types_de_champ_public: [{ type: :text, libelle: 'Titre' }]) } + let(:procedure2) { create(:procedure, :published, types_de_champ_public: [{ type: :text, libelle: 'Intitulé' }]) } + + before do + Flipper.enable(:dossiers_list_personnalisation, user) + create(:dossiers_list_personnalisation, user:, procedure: procedure1, displayed_columns: [procedure1.personnalisable_columns.first]) + create(:dossiers_list_personnalisation, user:, procedure: procedure2, displayed_columns: [procedure2.personnalisable_columns.first]) + sign_in(user) + end + + def query_count + count = 0 + callback = -> (*) { count += 1 } + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') { get :index } + count + end + + it 'does not issue more queries per dossier with multiple procedures (no N+1 on Column.find)' do + create_list(:dossier, 3, :en_construction, user:, procedure: procedure1, populate_champs: true) + create_list(:dossier, 3, :en_construction, user:, procedure: procedure2, populate_champs: true) + baseline = query_count + + create_list(:dossier, 3, :en_construction, user:, procedure: procedure1, populate_champs: true) + create_list(:dossier, 3, :en_construction, user:, procedure: procedure2, populate_champs: true) + expect(query_count).to be <= baseline + 3 + end + end + private def find_champ_by_stable_id(dossier, stable_id) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 91437754a2c..c8d91357295 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -2388,6 +2388,26 @@ end end + describe '#columns' do + it 'does not raise and skips champs with unknown type_champ (legacy value)' do + procedure = create(:procedure, :published, types_de_champ_public: [ + { type: :text, libelle: 'Champ valide' }, + { type: :text, libelle: 'Champ legacy' }, + ]) + valid_tdc = procedure.published_revision.types_de_champ_public.find { _1.libelle == 'Champ valide' } + legacy_tdc_id = procedure.published_revision.types_de_champ_public.find { _1.libelle == 'Champ legacy' }.id + TypeDeChamp.where(id: legacy_tdc_id).update_all(type_champ: 'titre_identite') + expect(TypeDeChamp.find(legacy_tdc_id).dynamic_type).to be_nil + procedure.reload + Current.procedure_columns = nil + + expect { procedure.columns }.not_to raise_error + expect(procedure.columns.map(&:label)).not_to include('Champ legacy') + h_id = { procedure_id: procedure.id, column_id: "type_de_champ/#{valid_tdc.stable_id}" } + expect { procedure.find_column(h_id:) }.not_to raise_error + end + end + private def create_dossier_with_pj_of_size(size, procedure) From 0852fc0faadb32066191613d422549f81107842f Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 30 Jun 2026 13:59:42 +0200 Subject: [PATCH 4/4] test(personnalisation): cover champ values displayed on dossier cards --- .../system/users/dossiers_personnalisation_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/system/users/dossiers_personnalisation_spec.rb b/spec/system/users/dossiers_personnalisation_spec.rb index 33a27c05449..f037fafb03f 100644 --- a/spec/system/users/dossiers_personnalisation_spec.rb +++ b/spec/system/users/dossiers_personnalisation_spec.rb @@ -16,6 +16,20 @@ login_as user, scope: :user end + it 'shows the chosen champ values on the dossier cards' do + perso_procedure = create(:procedure, :published, types_de_champ_public: [{ type: :text, libelle: 'Titre de la publication' }]) + column = perso_procedure.personnalisable_columns.first + create(:dossiers_list_personnalisation, user:, procedure: perso_procedure, displayed_columns: [column]) + dossiers = create_list(:dossier, 6, :en_construction, user:, procedure: perso_procedure, populate_champs: true) + champ = dossiers.first.champs.find { _1.stable_id == column.stable_id } + champ.update(value: 'Presse Océan') + + visit dossiers_path + + expect(page).to have_css('.fr-icon-info-i') + expect(page).to have_text('Presse Océan') + end + it 'lets the user pick fields and persists the personnalisation' do visit dossiers_path