From ab181a230c18d5dbd78bec7450d7309424236d53 Mon Sep 17 00:00:00 2001 From: Camille Regnault Date: Thu, 21 May 2026 16:42:49 +0200 Subject: [PATCH 1/5] feat(lots): add form_started? to presenter, update lot_selection controller --- .../candidate/lot_selections_controller.rb | 16 +++++++++------- app/presenters/market_application_presenter.rb | 13 +++++++++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/controllers/candidate/lot_selections_controller.rb b/app/controllers/candidate/lot_selections_controller.rb index ece120cd..cf2446c2 100644 --- a/app/controllers/candidate/lot_selections_controller.rb +++ b/app/controllers/candidate/lot_selections_controller.rb @@ -9,9 +9,12 @@ class LotSelectionsController < Candidate::ApplicationController def show @presenter = MarketApplicationPresenter.new(@market_application) + @editing = !@presenter.lots_saved? || params[:edit].present? end def update + return complete_application if params[:final_submit].present? + policy = LotSelectionPolicy.new(@market_application, lot_ids_param) unless policy.valid? @@ -20,19 +23,17 @@ def update end @market_application.lot_ids = lot_ids_param - - if params[:final_submit].present? - complete_application - else - redirect_to step_candidate_market_application_path(@market_application.identifier, :api_data_recovery_status) - end + redirect_to lot_selection_candidate_market_application_path(@market_application.identifier) end private def find_market_application @market_application = MarketApplication - .includes(:lots, public_market: :lots) + .includes( + { lots: %i[market_type platform_market_type] }, + public_market: { lots: %i[market_type platform_market_type] } + ) .find_by!(identifier: params[:identifier]) rescue ActiveRecord::RecordNotFound render plain: "La candidature recherchée n'a pas été trouvée", status: :not_found @@ -50,6 +51,7 @@ def lot_ids_param def render_lot_selection_error(errors) @errors = errors + @editing = true @presenter = MarketApplicationPresenter.new(@market_application) render :show, status: :unprocessable_content end diff --git a/app/presenters/market_application_presenter.rb b/app/presenters/market_application_presenter.rb index 78b726b3..66bbef9f 100644 --- a/app/presenters/market_application_presenter.rb +++ b/app/presenters/market_application_presenter.rb @@ -78,7 +78,7 @@ def submitted_at # === LOTS METHODS === def selected_lots - @selected_lots ||= @market_application.lots.ordered.to_a + @selected_lots ||= @market_application.lots.sort_by(&:position) end def public_market_lots @@ -139,8 +139,17 @@ def lots_saved? selected_lots.any? end + def form_started? + responses_by_attribute_id.any? + end + + def lot_type_label(lot) + code = lot.effective_market_type&.code || public_market.market_type_codes.first + code.present? ? I18n.t("market_types.#{code}", default: code.humanize) : nil + end + def cta_translation_key - lots_saved? ? 'candidate.lot_selection.modify' : 'candidate.lot_selection.prepare' + form_started? ? 'candidate.lot_selection.modify' : 'candidate.lot_selection.start' end # === PROGRESS METHODS === From de8ecc23728e445fc9759e9c421b7c2e839e7808 Mon Sep 17 00:00:00 2001 From: Camille Regnault Date: Thu, 21 May 2026 16:45:35 +0200 Subject: [PATCH 2/5] feat(lots): update lot_selection i18n keys for new UX --- config/locales/fr.yml | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index fc2af0eb..57f43ea8 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -520,21 +520,14 @@ fr: not_accessible: "Cette candidature n'est pas consultable" lot_selection: title: "Sélectionnez un ou plusieurs lot(s)" - subtitle: "Sélectionnez un ou plusieurs lots pour lequel/lesquels vous souhaitez soumettre une candidature." + subtitle: "Sélectionnez le ou les lots pour lequel/lesquels vous souhaitez soumettre une candidature." submit: "Transmettre ma candidature" + next: "Suivant" market_info_title: "Informations du marché" buyer_label: "Acheteur" - how_it_works_title: "Comment ça fonctionne" - how_it_works_step_1: "Sélectionnez vos lots" - how_it_works_step_2: "Complétez le formulaire de candidature" - how_it_works_step_3: "Soumettez votre candidature" market_type_label: "Typologie" deadline_label: "Date limite" - application_form_title: "Formulaire de candidature" - prepare: "Préparer" - modify: "Modifier" - fields_count: "%{filled}/%{total} champs complétés" - progress_title: "Avancement des candidatures" + selected_lots_title: "Liste des lots sélectionnés" no_lot_selected: "Aucun lot sélectionné pour le moment" select_all: "Tout sélectionner" deselect_all: "Tout désélectionner" @@ -542,10 +535,9 @@ fr: one: "1 lot" other: "%{count} lots" available_lots_count: - one: "1 lot" - other: "%{count} lots" + one: "1 lot disponible" + other: "%{count} lots disponibles" lot_number: "Lot %{number}" - single_form_notice: "Vous n'avez qu'un seul formulaire de candidature à compléter, Passe Marché se charge de transmettre votre candidature pour les différents lots." lot_limit_notice: one: "L'acheteur a limité le nombre de lot auxquels vous pouvez candidater : %{count} lot maximum" other: "L'acheteur a limité le nombre de lots auxquels vous pouvez candidater : %{count} lots maximum" @@ -555,6 +547,18 @@ fr: lot_limit_exceeded_notice: one: "Vous avez atteint la limite : %{count} lot maximum autorisé" other: "Vous avez atteint la limite : %{count} lots maximum autorisés." + prepare_title: "Préparez votre dossier de candidature" + prepare_subtitle: "Votre dossier est organisé selon les types de lots sélectionnés. Complétez chaque section à votre rythme." + single_form_notice: "Vous n'avez qu'un seul formulaire de candidature à compléter, Passe Marché se charge de transmettre votre candidature pour les différents lots." + lots_list_title: "Liste des lots" + edit_lots: "Modifier les lots" + application_form_title: "Formulaire de candidature" + fields_count: "%{filled}/%{total} champs complétés" + start: "Compléter" + modify: "Modifier" + how_it_works_title: "Comment ça fonctionne" + how_it_works_step_1: "Complétez le formulaire de candidature" + how_it_works_step_2: "Soumettez votre candidature (vous pouvez soumettre même si toutes les informations n'ont pas été complétées)" validations: siret_blank: "Veuillez renseigner votre numéro de SIRET" siret_invalid: "Le numéro de SIRET saisi est invalide" From 4939414dbb6fb5f029a0a18fd969d92fd4e52421 Mon Sep 17 00:00:00 2001 From: Camille Regnault Date: Thu, 21 May 2026 16:45:56 +0200 Subject: [PATCH 3/5] feat(lots): render selected lot tags in real-time with type label --- .../controllers/lot_selection_controller.js | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/app/javascript/controllers/lot_selection_controller.js b/app/javascript/controllers/lot_selection_controller.js index 7842c622..985c48ea 100644 --- a/app/javascript/controllers/lot_selection_controller.js +++ b/app/javascript/controllers/lot_selection_controller.js @@ -1,7 +1,8 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["checkbox", "checkboxGroup", "submitButton", "selectAllButton", "noLotsText", "progressCard", "selectedLotsCount", "limitError"] + static targets = ["checkbox", "checkboxGroup", "submitButton", "selectAllButton", + "noLotsText", "selectedLotsTags", "limitError"] static values = { limit: Number, storageKey: String } connect() { @@ -48,6 +49,7 @@ export default class extends Controller { const checked = this.checkboxTargets.filter(cb => cb.checked) const hasChecked = checked.length > 0 const limitReached = checked.length >= this._limit() + this.submitButtonTargets.forEach(btn => { btn.disabled = !hasChecked }) this.checkboxTargets.forEach((cb, i) => { @@ -69,39 +71,41 @@ export default class extends Controller { : this.selectAllButtonTarget.dataset.selectText || "Tout sélectionner" } - if (this.hasNoLotsTextTarget) { - this.noLotsTextTarget.hidden = hasChecked - } + this._renderSelectedLotsTags(checked) + this._persistSelection(checked) + } - if (this.hasProgressCardTarget) { - this.progressCardTarget.hidden = !hasChecked - } + _renderSelectedLotsTags(checked) { + if (!this.hasNoLotsTextTarget && !this.hasSelectedLotsTagsTarget) return - if (this.hasSelectedLotsCountTarget && hasChecked) { - const template = checked.length === 1 - ? this.selectedLotsCountTarget.dataset.one - : this.selectedLotsCountTarget.dataset.other - this.selectedLotsCountTarget.textContent = template.replace('%{count}', checked.length) + if (this.hasNoLotsTextTarget) { + this.noLotsTextTarget.hidden = checked.length > 0 } - this._persistSelection(checked) + if (this.hasSelectedLotsTagsTarget) { + this.selectedLotsTagsTarget.replaceChildren( + ...checked.map(cb => { + const span = document.createElement("span") + span.className = "fr-tag" + span.style.background = "white" + const name = cb.dataset.lotName || "" + const type = cb.dataset.lotType + span.textContent = type ? `${name} - ${type}` : name + return span + }) + ) + } } _restoreSelectionFromStorage() { - if (!this.hasStorageKeyValue) { - return - } + if (!this.hasStorageKeyValue) return const raw = localStorage.getItem(this.storageKeyValue) - if (!raw) { - return - } + if (!raw) return try { const selectedLotIds = JSON.parse(raw) - if (!Array.isArray(selectedLotIds)) { - return - } + if (!Array.isArray(selectedLotIds)) return const selectedSet = new Set(selectedLotIds.map(String)) this.checkboxTargets.forEach((checkbox) => { @@ -113,9 +117,7 @@ export default class extends Controller { } _persistSelection(checkedCheckboxes) { - if (!this.hasStorageKeyValue) { - return - } + if (!this.hasStorageKeyValue) return const selectedLotIds = checkedCheckboxes.map(checkbox => String(checkbox.value)) localStorage.setItem(this.storageKeyValue, JSON.stringify(selectedLotIds)) From e1a363fea65ef3b5e8125f67ede09486916e022e Mon Sep 17 00:00:00 2001 From: Camille Regnault Date: Thu, 21 May 2026 16:46:32 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(lots):=20redesign=20lot=20selection=20?= =?UTF-8?q?page=20=E2=80=94=20two-state=20layout=20(select=20/=20prepare)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lot_selections/_market_info_card.html.erb | 13 + .../candidate/lot_selections/show.html.erb | 366 ++++++++++-------- 2 files changed, 217 insertions(+), 162 deletions(-) create mode 100644 app/views/candidate/lot_selections/_market_info_card.html.erb diff --git a/app/views/candidate/lot_selections/_market_info_card.html.erb b/app/views/candidate/lot_selections/_market_info_card.html.erb new file mode 100644 index 00000000..2645a3b1 --- /dev/null +++ b/app/views/candidate/lot_selections/_market_info_card.html.erb @@ -0,0 +1,13 @@ +
+

+ <%= t('candidate.lot_selection.market_info_title') %> +

+
+ <%= render 'shared/sidebar_market_info', + buyer_label: t('candidate.lot_selection.buyer_label'), + market_name: public_market.name, + typology_label: t('candidate.lot_selection.market_type_label'), + market_types_label: presenter.market_types_label.presence || t('candidate.shared.no_market_type'), + deadline_label: t('candidate.lot_selection.deadline_label'), + deadline: l(public_market.deadline, format: '%d/%m/%Y %H:%M') %> +
diff --git a/app/views/candidate/lot_selections/show.html.erb b/app/views/candidate/lot_selections/show.html.erb index 097f7aa5..6a60dba2 100644 --- a/app/views/candidate/lot_selections/show.html.erb +++ b/app/views/candidate/lot_selections/show.html.erb @@ -1,198 +1,240 @@ <% content_for :title, t('candidate.lot_selection.title') %> -<% lots_already_saved = @presenter.lots_saved? %> <% content_for :head do %> <% end %> -
-
- -
-

<%= t('candidate.lot_selection.title') %>

-

- <%= t('candidate.lot_selection.subtitle') %> -

- - <% if @errors&.any? %> - - <% end %> +<% public_market = @market_application.public_market %> +<% market_type_code = public_market.market_type_codes.first %> + +<% if @editing %> +
+

<%= t('candidate.lot_selection.title') %>

+

+ <%= t('candidate.lot_selection.subtitle') %> +

+ +
+ + <%# Sidebar gauche %> +
+ <%= render 'market_info_card', public_market: public_market, presenter: @presenter %> -
-
-

-

- <%= t('candidate.lot_selection.single_form_notice') %> +

+

+ <%= t('candidate.lot_selection.selected_lots_title') %> +

+
+

+ <%= t('candidate.lot_selection.no_lot_selected') %>

+
- <%= form_with url: lot_selection_candidate_market_application_path(@market_application.identifier), - method: :patch, - local: true, - id: "lot-selection-form", - data: { turbo: false } do %> - <% lots = @market_application.public_market.lots.ordered %> - -
-
-
- <%= market_type_icon_tag(@market_application.public_market.market_type_codes) %> -
-

- <%= @market_application.public_market.market_type_codes.any? ? - t("market_types.#{@market_application.public_market.market_type_codes.first}", - default: @market_application.public_market.market_type_codes.first.humanize) : - t('candidate.lot_selection.lots_count', count: lots.size) %> -

-

- <%= t('candidate.lot_selection.available_lots_count', count: lots.size) %> -

-
-
- + <%# Colonne droite : formulaire de sélection %> +
+ <% if @errors&.any? %> + + <% end %> + + <%= form_with url: lot_selection_candidate_market_application_path(@market_application.identifier), + method: :patch, + local: true, + id: "lot-selection-form", + data: { turbo: false } do %> + <% lots = public_market.lots.sort_by(&:position) %> - <% if @market_application.public_market.lot_limit.present? %> -
-

- <%= t('candidate.lot_selection.lot_limit_notice', count: @market_application.public_market.lot_limit) %> -

- +
+
+
+ <%= market_type_icon_tag(public_market.market_type_codes) %> +
+

+ <%= market_type_code.present? ? + t("market_types.#{market_type_code}", default: market_type_code.humanize) : + t('candidate.lot_selection.lots_count', count: lots.size) %> +

+

+ <%= t('candidate.lot_selection.available_lots_count', count: lots.size) %> +

+
+
+
- <% end %> - -
- <% lots.each_with_index do |lot, index| %> -
- <%= check_box_tag "market_application[lot_ids][]", - lot.id, - @market_application.lot_ids.include?(lot.id), - id: "lot_#{lot.id}", - class: "fr-checkbox", - data: { - lot_selection_target: "checkbox", - action: "change->lot-selection#toggle" - } %> - + + <% if public_market.lot_limit.present? %> +
+

+ <%= t('candidate.lot_selection.lot_limit_notice', count: public_market.lot_limit) %> +

+
<% end %> + +
+ <% lots.each_with_index do |lot, index| %> +
+ <%= check_box_tag "market_application[lot_ids][]", + lot.id, + @market_application.lot_ids.include?(lot.id), + id: "lot_#{lot.id}", + class: "fr-checkbox", + data: { + lot_selection_target: "checkbox", + action: "change->lot-selection#toggle", + lot_name: lot.name, + lot_type: @presenter.lot_type_label(lot) + } %> + +
+ <% end %> +
-
+ <% end %> + + +
- <% end %> - -
+
+ +<% else %> +
+

<%= t('candidate.lot_selection.prepare_title') %>

+

+ <%= t('candidate.lot_selection.prepare_subtitle') %> +

-
- -
-

- <%= t('candidate.lot_selection.market_info_title') %> -

-
- <%= render 'shared/sidebar_market_info', - buyer_label: t('candidate.lot_selection.buyer_label'), - market_name: @market_application.public_market.name, - typology_label: t('candidate.lot_selection.market_type_label'), - market_types_label: @presenter.market_types_label.presence || t('candidate.shared.no_market_type'), - deadline_label: t('candidate.lot_selection.deadline_label'), - deadline: l(@market_application.public_market.deadline, format: '%d/%m/%Y %H:%M') %> +
+
+

+ <%= t('candidate.lot_selection.single_form_notice') %> +

+
+ +
-
-

- <%= t('candidate.lot_selection.how_it_works_title') %> -

-
-
    -
  1. <%= t('candidate.lot_selection.how_it_works_step_1') %>
  2. -
  3. <%= t('candidate.lot_selection.how_it_works_step_2') %>
  4. -
  5. <%= t('candidate.lot_selection.how_it_works_step_3') %>
  6. -
+ <%# Sidebar gauche %> +
+ <%= render 'market_info_card', public_market: public_market, presenter: @presenter %> + +
+
+

+ <%= t('candidate.lot_selection.lots_list_title') %> +

+ <%= link_to lot_selection_candidate_market_application_path(@market_application.identifier, edit: 1), + class: "fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-edit-fill", + title: t('candidate.lot_selection.edit_lots'), + aria: { label: t('candidate.lot_selection.edit_lots') } do %> + <% end %> +
+
+
+ <% @presenter.selected_lots.each do |lot| %> + <% type = @presenter.lot_type_label(lot) %> + <%= type ? "#{lot.name} - #{type}" : lot.name %> + <% end %> +
+
+ + <% unless @presenter.form_started? %> +
+

+ <%= t('candidate.lot_selection.how_it_works_title') %> +

+
+
    +
  1. <%= t('candidate.lot_selection.how_it_works_step_1') %>
  2. +
  3. <%= t('candidate.lot_selection.how_it_works_step_2') %>
  4. +
+
+ <% end %>
-
-

- <%= t('candidate.lot_selection.progress_title') %> -

-
-

> - <%= t('candidate.lot_selection.no_lot_selected') %> -

-
- style="border: 1px solid var(--border-default-grey); border-radius: 4px; padding: 1rem; background: white;"> -
- - - <%= t('candidate.lot_selection.application_form_title') %> + <%# Colonne droite : dossier de candidature %> +
+
+
+ <%= market_type_icon_tag(public_market.market_type_codes) %> + <% if market_type_code.present? %> + + <%= t("market_types.#{market_type_code}", default: market_type_code.humanize) %> + + <% end %> + + <%= t('candidate.lot_selection.lots_count', count: @presenter.selected_lots.size) %>
- <% if @presenter %> -

+

<%= t('candidate.lot_selection.application_form_title') %>

+

<%= t('candidate.lot_selection.fields_count', filled: @presenter.filled_fields_count, total: @presenter.total_fields_count) %>

- <% end %> -

-
- +
+ <%= link_to step_candidate_market_application_path(@market_application.identifier, :api_data_recovery_status), + class: "fr-btn fr-icon-arrow-right-line fr-btn--icon-right" do %> + <%= t(@presenter.cta_translation_key) %> + <% end %> +
+ + <%= form_with url: lot_selection_candidate_market_application_path(@market_application.identifier), + method: :patch, + local: true, + data: { turbo: false } do %> + + <% end %>
-
+<% end %> From 4ea241b2f1a99847a52f1429c70e47aacf530c0f Mon Sep 17 00:00:00 2001 From: Camille Regnault Date: Thu, 21 May 2026 16:47:24 +0200 Subject: [PATCH 5/5] test(lots): update cucumber scenarios for new lot selection flow --- features/candidate_lot_selection.feature | 32 +++++++++++--- .../candidate_lot_selection_steps.rb | 42 +++++++++++++++++-- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/features/candidate_lot_selection.feature b/features/candidate_lot_selection.feature index 9362bc27..ae5b61a8 100644 --- a/features/candidate_lot_selection.feature +++ b/features/candidate_lot_selection.feature @@ -19,11 +19,12 @@ Feature: Candidate lot selection When the candidate visits the lot selection step Then the candidate should be on the api data recovery status step - Scenario: Candidate can select lots and proceed to the next step + Scenario: Candidate can select lots and reach the preparation page When the candidate visits the lot selection step And the candidate selects the first lot - And the candidate submits the lot selection step - Then the candidate should be on the api data recovery status step + And the candidate clicks Suivant + Then the candidate should be on the lot selection step + And the candidate should see the preparation page Scenario: Candidate cannot proceed without selecting a lot When the candidate visits the lot selection step @@ -35,13 +36,34 @@ Feature: Candidate lot selection Given the public market has a lot limit of 1 When the candidate visits the lot selection step And the candidate selects all available lots - And the candidate submits the lot selection step + And the candidate submits the lot selection step without selecting any lot Then the candidate should see an error about the lot limit And the candidate should remain on the lot selection step + Scenario: Preparation page shows Commencer when form not started + When the candidate visits the lot selection step + And the candidate selects the first lot + And the candidate clicks Suivant + Then the candidate should see the preparation page + And the candidate should see the complete button + + Scenario: Preparation page hides how it works when form is started + Given the candidate has already started the form + When the candidate visits the preparation page + Then the candidate should see the preparation page + And the candidate should not see the how it works section + And the candidate should see the modify button + + Scenario: Candidate can edit lots from the preparation page + When the candidate visits the lot selection step + And the candidate selects the first lot + And the candidate clicks Suivant + And the candidate clicks the edit lots button + Then the candidate should be on the lot selection step + Scenario: Candidate is redirected to company identification when reconnecting When the candidate visits the lot selection step And the candidate selects the first lot - And the candidate submits the lot selection step + And the candidate clicks Suivant And the candidate reconnects to the application Then the candidate should be on the company identification step diff --git a/features/step_definitions/candidate_lot_selection_steps.rb b/features/step_definitions/candidate_lot_selection_steps.rb index 8a0d7185..ad64b9b1 100644 --- a/features/step_definitions/candidate_lot_selection_steps.rb +++ b/features/step_definitions/candidate_lot_selection_steps.rb @@ -40,17 +40,31 @@ def create_public_market_with_company_name_attribute(editor) @public_market.update!(lot_limit: limit) end +Given('the candidate has already started the form') do + @market_application = create(:market_application, public_market: @public_market, siret: '73282932000074') + @market_application.lots << @lot1 + attr = @public_market.market_attributes.first + create(:market_attribute_response_text_input, + market_application: @market_application, + market_attribute: attr, + value: { text: 'Acme Corp' }) + authenticate_as_candidate_for(@market_application) +end + When('the candidate visits the lot selection step') do application = @market_application_no_lots || @market_application visit lot_selection_candidate_market_application_path(application.identifier) end +When('the candidate visits the preparation page') do + visit lot_selection_candidate_market_application_path(@market_application.identifier) +end + Then('the candidate should be on the lot selection step') do expect(page).to have_current_path( %r{/candidate/market_applications/.+/lots}, ignore_query: true ) - expect(page).to have_content(I18n.t('candidate.lot_selection.title')) end Then('the candidate should be on the api data recovery status step') do @@ -74,12 +88,32 @@ def create_public_market_with_company_name_attribute(editor) check 'Lot 2 - Services' end -When('the candidate submits the lot selection step') do - find('button[type="submit"][form="lot-selection-form"][name="final_submit"]').click +When('the candidate clicks Suivant') do + find('button[type="submit"][form="lot-selection-form"]').click end When('the candidate submits the lot selection step without selecting any lot') do - find('button[type="submit"][form="lot-selection-form"][name="final_submit"]', visible: :all).click + find('button[type="submit"][form="lot-selection-form"]', visible: :all).click +end + +Then('the candidate should see the preparation page') do + expect(page).to have_content(I18n.t('candidate.lot_selection.prepare_title')) +end + +Then('the candidate should see the complete button') do + expect(page).to have_content(I18n.t('candidate.lot_selection.start')) +end + +Then('the candidate should see the modify button') do + expect(page).to have_content(I18n.t('candidate.lot_selection.modify')) +end + +Then('the candidate should not see the how it works section') do + expect(page).not_to have_content(I18n.t('candidate.lot_selection.how_it_works_title')) +end + +When('the candidate clicks the edit lots button') do + click_on I18n.t('candidate.lot_selection.edit_lots') end Then('the candidate should be on the company identification step') do