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