From 6819d97d8b54c7587d598988963b7026c8fa127f Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 19:30:57 +0200 Subject: [PATCH 01/12] Cascade hiding of questions with chained display conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a question is hidden by its display condition, its response was left in place and questions conditioned on it were not re-evaluated. As a result, a question further down a chain (e.g. Q0 → Q3 → FINAL in the test) stayed visible even after an ancestor trigger stopped being fulfilled. `hideQuestion()` now clears the hidden question's response (unchecking radios/checkboxes and clearing text inputs) and fires a `change` event only on the inputs that actually changed. This lets questions conditioned on it re-evaluate their own display conditions, so hiding propagates down the chain and terminates instead of looping over already-cleared inputs. Add jest specs for display_conditions.component.js covering the direct condition, the intermediate question, and the cascading hide. --- .../forms/display_conditions.component.js | 24 +++++ .../display_conditions.component.test.js | 102 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js index b4a4db03fa756..748e6aa9f0bf8 100644 --- a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js @@ -192,6 +192,30 @@ class DisplayConditionsComponent { } this.wrapperField.find("input, textarea").prop("disabled", "disabled"); + this.clearAnswers(); + } + + // When a question is hidden its answer must not survive: a stale value would + // keep questions conditioned on it visible We clear the answer and notify the + // questions conditioned on this one so the change cascades down the chain. + clearAnswers() { + const changed = []; + + this.wrapperField.find("input[type='checkbox']:checked, input[type='radio']:checked").each((idx, el) => { + el.checked = false; + changed.push(el); + }); + + this.wrapperField.find("input[type='text'], textarea").each((idx, el) => { + if (el.value !== "") { + el.value = ""; + changed.push(el); + } + }); + + if (changed.length) { + $(changed).trigger("change"); + } } } diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js new file mode 100644 index 0000000000000..10e151b85f060 --- /dev/null +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js @@ -0,0 +1,102 @@ +import $ from "jquery"; // eslint-disable-line id-length + +// Relative import: the forms pack is not in jest's moduleDirectories, so it +// cannot be resolved through the absolute "src/..." path used elsewhere. +import createDisplayConditions from "./display_conditions.component"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths + +describe("DisplayConditionsComponent", () => { + // Renders a single-choice (radio) question with the DOM structure the + // component expects (js-collection-input wrapping body/custom_body/option_id). + const radioCollection = (qid, options) => ` +
+ ${options.map((opt) => ` +
+ + + +
+ `).join("")} +
+ `; + + const conditionTag = (data) => { + const attrs = Object.entries(data).map(([key, val]) => `data-${key}='${val}'`).join(" "); + return `
`; + }; + + // Q0 (A/B/C) --C--> Q3 (0/1) --0--> FINAL0 + // \--1--> FINAL1 + const content = ` +
+
+ ${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optB", value: "B" }, { optionId: "optC", value: "C" }])} +
+
+ ${conditionTag({ id: "dcQ3", type: "equal", condition: "Q0", option: "optC", mandatory: false })} + ${radioCollection("Q3", [{ optionId: "opt0", value: "0" }, { optionId: "opt1", value: "1" }])} +
+
+ ${conditionTag({ id: "dcF0", type: "equal", condition: "Q3", option: "opt0", mandatory: false })} + ${radioCollection("FINAL0", [{ optionId: "f0opt", value: "yes" }])} +
+
+ ${conditionTag({ id: "dcF1", type: "equal", condition: "Q3", option: "opt1", mandatory: false })} + ${radioCollection("FINAL1", [{ optionId: "f1opt", value: "yes" }])} +
+
+ `; + + const wrapper = (qid) => $(`.question[data-question-id='${qid}']`); + // The component enables inputs when a question is shown and disables them when + // hidden, so the disabled state is a reliable proxy for visibility in jsdom. + const isVisible = (qid) => !wrapper(qid).find("input[name$='[body]']").first().prop("disabled"); + const selectOption = (qid, value) => { + const $input = wrapper(qid).find(`input[name$='[body]'][value='${value}']`); + $input.prop("checked", true); + $input.trigger("change"); + }; + + beforeEach(() => { + document.body.innerHTML = content; + $(".answer-questionnaire .question[data-conditioned='true']").each((idx, el) => { + createDisplayConditions({ wrapperField: $(el) }); + }); + }); + + it("keeps conditioned questions hidden until their trigger is fulfilled", () => { + expect(isVisible("Q3")).toBe(false); + expect(isVisible("FINAL0")).toBe(false); + expect(isVisible("FINAL1")).toBe(false); + }); + + it("shows only the directly conditioned question when its trigger is met", () => { + selectOption("Q0", "C"); + + expect(isVisible("Q3")).toBe(true); + // Q3 is not answered yet, so neither final must appear + expect(isVisible("FINAL0")).toBe(false); + expect(isVisible("FINAL1")).toBe(false); + }); + + it("shows the matching final once the intermediate question is answered", () => { + selectOption("Q0", "C"); + selectOption("Q3", "1"); + + expect(isVisible("FINAL1")).toBe(true); + expect(isVisible("FINAL0")).toBe(false); + }); + + it("cascades hiding down the chain when an ancestor trigger stops being fulfilled", () => { + selectOption("Q0", "C"); + selectOption("Q3", "1"); + expect(isVisible("FINAL1")).toBe(true); + + // Move Q0 away from C: Q3 hides, its stale answer is cleared and the change + // propagates so FINAL1 hides too instead of lingering. + selectOption("Q0", "A"); + + expect(isVisible("Q3")).toBe(false); + expect(wrapper("Q3").find("input[value='1']").prop("checked")).toBe(false); + expect(isVisible("FINAL1")).toBe(false); + }); +}); From 0209cc41d605a44010241457e637dfc0abeea8df Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 19:32:12 +0200 Subject: [PATCH 02/12] Fix conditioned_questions association of Question has_many through ignores the foreign_key attribute and a source is required because association name on the join model can't be inferred from the association name --- .../app/models/decidim/forms/question.rb | 2 +- .../models/decidim/forms/question_spec.rb | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/decidim-forms/app/models/decidim/forms/question.rb b/decidim-forms/app/models/decidim/forms/question.rb index aad7b7fd6d079..d00ffed4d256e 100644 --- a/decidim-forms/app/models/decidim/forms/question.rb +++ b/decidim-forms/app/models/decidim/forms/question.rb @@ -44,7 +44,7 @@ class Question < Forms::ApplicationRecord # Questions which have display conditions based on the value of this question's answer has_many :conditioned_questions, through: :display_conditions_for_other_questions, - foreign_key: "decidim_condition_question_id", + source: :question, class_name: "Question" has_many :answers, diff --git a/decidim-forms/spec/models/decidim/forms/question_spec.rb b/decidim-forms/spec/models/decidim/forms/question_spec.rb index 1fa44c6c79d04..67254e0a739b0 100644 --- a/decidim-forms/spec/models/decidim/forms/question_spec.rb +++ b/decidim-forms/spec/models/decidim/forms/question_spec.rb @@ -22,6 +22,26 @@ module Forms expect(subject.display_conditions).to match_array(display_conditions) end + context "when this question's answer controls the display of other questions" do + subject { question } + + let(:question) { create(:questionnaire_question, questionnaire:) } + let(:conditioned_questions) { create_list(:questionnaire_question, 2, questionnaire:) } + let!(:display_conditions_for_other_questions) do + conditioned_questions.map do |conditioned_question| + create(:display_condition, condition_question: question, question: conditioned_question) + end + end + + it "has an association of display_conditions_for_other_questions" do + expect(subject.display_conditions_for_other_questions).to match_array(display_conditions_for_other_questions) + end + + it "has an association of conditioned_questions" do + expect(subject.conditioned_questions).to match_array(conditioned_questions) + end + end + context "when there are answer_options belonging to this question" do let(:answer_options) { create_list(:answer_option, 3, question:) } From 8e59ba7d3f9653b4523b82031cd02a9ddd3cb96c Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 19:35:28 +0200 Subject: [PATCH 03/12] Fix display conditions being reassigned to the wrong question on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Question#display_conditions_for_other_questions` is keyed on `decidim_condition_question_id`, but declared `inverse_of: :question` and the `question` belongs_to on DisplayCondition is keyed on `decidim_question_id`. Because of this mismatched inverse_of, assigning a display condition's `condition_question` also re-stamped its owner FK (`decidim_question_id`) with the trigger question's id. In Decidim::Forms::Admin::UpdateQuestions the update attributes for each display condition include `condition_question` but not `question`, and `update!` persists every dirty attribute — so on every save (even with no changes) the condition was silently reassigned to its own trigger question. This corrupted questionnaires as admins added conditions: rules disappeared from the back-office and questions stopped displaying on the participant form. --- decidim-forms/app/models/decidim/forms/question.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/decidim-forms/app/models/decidim/forms/question.rb b/decidim-forms/app/models/decidim/forms/question.rb index d00ffed4d256e..8992f7cc1f299 100644 --- a/decidim-forms/app/models/decidim/forms/question.rb +++ b/decidim-forms/app/models/decidim/forms/question.rb @@ -39,7 +39,7 @@ class Question < Forms::ApplicationRecord class_name: "DisplayCondition", foreign_key: "decidim_condition_question_id", dependent: :destroy, - inverse_of: :question + inverse_of: :condition_question # Questions which have display conditions based on the value of this question's answer has_many :conditioned_questions, From 7b6b6cc8b4f9475e3cb3e85d56cc1b8dd22f1708 Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 19:35:51 +0200 Subject: [PATCH 04/12] Add test --- .../forms/admin/update_questions_spec.rb | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb b/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb index 282207ff9a4a5..120f1feb8bfc4 100644 --- a/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb +++ b/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb @@ -348,6 +348,47 @@ module Admin expect(questionnaire.questions[2].display_conditions.second.decidim_answer_option_id).to eq(question_2_answer_options.first.id) end end + + context "and a display condition already exists and is saved without changes" do + let!(:display_condition) do + create( + :display_condition, + condition_type: "answered", + question: questions[2], + condition_question: questions[0] + ) + end + + let(:form_params) do + { + "questions" => { + "3" => { + "id" => questions[2].id, + "body" => questions[2].body, + "position" => 2, + "question_type" => "short_answer", + "deleted" => "false", + "display_conditions" => { + "1" => { + "id" => display_condition.id, + "decidim_condition_question_id" => questions[0].id, + "decidim_question_id" => questions[2].id, + "condition_type" => "answered" + } + } + } + } + } + end + + it "keeps the display condition owned by the same question" do + expect { command.call }.to broadcast(:ok) + + expect(questions[2].reload.display_conditions.count).to eq(1) + expect(display_condition.reload.decidim_question_id).to eq(questions[2].id) + expect(display_condition.decidim_condition_question_id).to eq(questions[0].id) + end + end end end end From 3a130a8c72e94298b53929dc99c744ff03df734a Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 19:36:13 +0200 Subject: [PATCH 05/12] Add test covering a reported issue --- .../forms/admin/update_questions_spec.rb | 81 ++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb b/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb index 120f1feb8bfc4..015f86d5f7fbf 100644 --- a/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb +++ b/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb @@ -353,7 +353,7 @@ module Admin let!(:display_condition) do create( :display_condition, - condition_type: "answered", + condition_type: "responded", question: questions[2], condition_question: questions[0] ) @@ -366,14 +366,14 @@ module Admin "id" => questions[2].id, "body" => questions[2].body, "position" => 2, - "question_type" => "short_answer", + "question_type" => "short_response", "deleted" => "false", "display_conditions" => { "1" => { "id" => display_condition.id, "decidim_condition_question_id" => questions[0].id, "decidim_question_id" => questions[2].id, - "condition_type" => "answered" + "condition_type" => "responded" } } } @@ -389,6 +389,81 @@ module Admin expect(display_condition.decidim_condition_question_id).to eq(questions[0].id) end end + + # Test for decidim/decidim#16513: two questions conditioned on + # different options of the SAME source question used to overwrite each + # other on save, because assigning the shared condition_question + # re-stamped each condition's owner (decidim_question_id). + context "and multiple display conditions depend on the same question" do + let!(:display_condition_for_q2) do + create( + :display_condition, + :equal, + question: questions[2], + condition_question: questions[1], + response_option: question_2_response_options.first + ) + end + let!(:display_condition_for_q3) do + create( + :display_condition, + :equal, + question: questions[3], + condition_question: questions[1], + response_option: question_2_response_options.second + ) + end + + let(:form_params) do + { + "questions" => { + "3" => { + "id" => questions[2].id, + "body" => questions[2].body, + "position" => 2, + "question_type" => "short_response", + "deleted" => "false", + "display_conditions" => { + "1" => { + "id" => display_condition_for_q2.id, + "decidim_condition_question_id" => questions[1].id, + "decidim_question_id" => questions[2].id, + "condition_type" => "equal", + "decidim_response_option_id" => question_2_response_options.first.id + } + } + }, + "4" => { + "id" => questions[3].id, + "body" => questions[3].body, + "position" => 3, + "question_type" => "short_response", + "deleted" => "false", + "display_conditions" => { + "1" => { + "id" => display_condition_for_q3.id, + "decidim_condition_question_id" => questions[1].id, + "decidim_question_id" => questions[3].id, + "condition_type" => "equal", + "decidim_response_option_id" => question_2_response_options.second.id + } + } + } + } + } + end + + it "keeps each condition owned by its own question" do + expect { command.call }.to broadcast(:ok) + + expect(questions[2].reload.display_conditions.count).to eq(1) + expect(questions[3].reload.display_conditions.count).to eq(1) + expect(display_condition_for_q2.reload.decidim_question_id).to eq(questions[2].id) + expect(display_condition_for_q3.reload.decidim_question_id).to eq(questions[3].id) + # the shared source question must not absorb the conditions + expect(questions[1].reload.display_conditions).to be_empty + end + end end end end From a74439e5647fd03d17370af2fea7fa5f2218d92c Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 21:14:05 +0200 Subject: [PATCH 06/12] Fix N+1 queries and stale re-query when copying questionnaire display conditions --- .../templates/admin/questionnaire_copier.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb b/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb index a390413919ca8..0a8801371a9f7 100644 --- a/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb +++ b/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb @@ -7,8 +7,8 @@ module Admin module QuestionnaireCopier def copy_questionnaire_questions(original_questionnaire, new_questionnaire) # start by copying the questions so that they already exist when cross referencing them in the conditions - original_questionnaire.reload.questions.includes(:answer_options, :matrix_rows, :display_conditions) - original_questionnaire.questions.each do |original_question| + original_questions = original_questionnaire.reload.questions.includes(:response_options, :matrix_rows, :display_conditions).to_a + original_questions.each do |original_question| new_question = original_question.dup new_question.questionnaire = new_questionnaire new_question.assign_attributes( @@ -21,9 +21,12 @@ def copy_questionnaire_questions(original_questionnaire, new_questionnaire) copy_questionnaire_answer_options(original_question, new_question) copy_questionnaire_matrix_rows(original_question, new_question) end - # once all questions are copied, copy display conditions - original_questionnaire.questions.zip(new_questionnaire.questions.load).each do |original_question, new_question| - copy_question_display_conditions(original_question, new_question) + # once all questions are copied, copy display conditions. The destination + # questions are looked up by position (and their response options by body) + # while cross referencing the conditions, so eager load them to avoid N+1s. + destination_questions = new_questionnaire.questions.includes(:response_options).to_a + original_questions.zip(destination_questions).each do |original_question, new_question| + copy_question_display_conditions(original_question, new_question, destination_questions) end end @@ -43,12 +46,12 @@ def copy_questionnaire_matrix_rows(original_question, new_question) end end - def copy_question_display_conditions(original_question, destination_question) + def copy_question_display_conditions(original_question, destination_question, destination_questions) original_question.display_conditions.each do |original_display_condition| new_display_condition = original_display_condition.dup new_display_condition.question = destination_question - destination_question_to_be_checked = find_question_by_position(destination_question.questionnaire.questions, original_display_condition.condition_question.position) + destination_question_to_be_checked = find_question_by_position(destination_questions, original_display_condition.condition_question.position) new_display_condition.condition_question = destination_question_to_be_checked if original_display_condition.answer_option From 9e6dfc9827801a24132e94ee7095c000cbb827ae Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 21:14:29 +0200 Subject: [PATCH 07/12] Clear file upload responses when hiding a questionnaire question --- .../forms/display_conditions.component.js | 6 +++++ .../display_conditions.component.test.js | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js index 748e6aa9f0bf8..08294ee3ea4c1 100644 --- a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js @@ -213,6 +213,12 @@ class DisplayConditionsComponent { } }); + // File responses ("files" question type) keep their selection in hidden + // fields rendered inside the upload previews. Remove those previews (and the + // hidden fields they contain) so a hidden question cannot retain a stale + // attachment. + this.wrapperField.find("[data-active-uploads] .attachment-details").remove(); + if (changed.length) { $(changed).trigger("change"); } diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js index 10e151b85f060..9b46797d9aed5 100644 --- a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js @@ -99,4 +99,30 @@ describe("DisplayConditionsComponent", () => { expect(wrapper("Q3").find("input[value='1']").prop("checked")).toBe(false); expect(isVisible("FINAL1")).toBe(false); }); + + it("clears file upload responses of a hidden question", () => { + document.body.innerHTML = ` +
+
+ ${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optC", value: "C" }])} +
+
+ ${conditionTag({ id: "dcFile", type: "equal", condition: "Q0", option: "optC", mandatory: false })} +
+
+ +
+
+
+
+ `; + $(".answer-questionnaire .question[data-conditioned='true']").each((idx, el) => { + createDisplayConditions({ wrapperField: $(el) }); + }); + + // Q0 is not "C", so QFILE is hidden: its attachment preview and the hidden + // field carrying the upload must be gone so nothing stale is submitted. + expect(wrapper("QFILE").find(".attachment-details").length).toBe(0); + expect(wrapper("QFILE").find("input[name='resp[QFILE][add_attachments]']").length).toBe(0); + }); }); From 22838784349a6a4d3a0fb4a4f1d1ae0d881eb301 Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 21:14:51 +0200 Subject: [PATCH 08/12] Apply coderabbit review suggested changes --- .../commands/decidim/templates/admin/questionnaire_copier.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb b/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb index 0a8801371a9f7..c917f3074b499 100644 --- a/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb +++ b/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb @@ -7,7 +7,7 @@ module Admin module QuestionnaireCopier def copy_questionnaire_questions(original_questionnaire, new_questionnaire) # start by copying the questions so that they already exist when cross referencing them in the conditions - original_questions = original_questionnaire.reload.questions.includes(:response_options, :matrix_rows, :display_conditions).to_a + original_questions = original_questionnaire.reload.questions.includes(:response_options, :matrix_rows, display_conditions: [:condition_question, :response_option]).to_a original_questions.each do |original_question| new_question = original_question.dup new_question.questionnaire = new_questionnaire @@ -58,7 +58,6 @@ def copy_question_display_conditions(original_question, destination_question, de new_display_condition.answer_option = find_answer_option_by_body(destination_question_to_be_checked.answer_options, original_display_condition.answer_option.body) end new_display_condition.save! - destination_question.display_conditions << new_display_condition end end From 3083438b137525a65f37b2133187ff482ae1639a Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Fri, 17 Jul 2026 21:43:43 +0200 Subject: [PATCH 09/12] Adapt cherry-picked questionnaire fixes to 0.30 answer_* naming The display-conditions fixes were authored against develop, which renamed answer_* to response_*. On this 0.30 fork the old naming is still in use, so the cherry-picks left references that do not exist here and would raise at runtime / fail specs: - questionnaire_copier.rb: eager-load :answer_options / :answer_option instead of :response_options / :response_option (the associations on this branch). - update_questions_spec.rb: use "answered", "short_answer", answer_option: and question_2_answer_options / decidim_answer_option_id. - display_conditions.component.test.js: match the real DOM input name [answer_option_id]. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../forms/display_conditions.component.test.js | 2 +- .../forms/admin/update_questions_spec.rb | 18 +++++++++--------- .../templates/admin/questionnaire_copier.rb | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js index 9b46797d9aed5..33cbbf7448314 100644 --- a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js @@ -13,7 +13,7 @@ describe("DisplayConditionsComponent", () => {
- +
`).join("")} diff --git a/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb b/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb index 015f86d5f7fbf..792532bc7c58f 100644 --- a/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb +++ b/decidim-forms/spec/commands/decidim/forms/admin/update_questions_spec.rb @@ -353,7 +353,7 @@ module Admin let!(:display_condition) do create( :display_condition, - condition_type: "responded", + condition_type: "answered", question: questions[2], condition_question: questions[0] ) @@ -366,14 +366,14 @@ module Admin "id" => questions[2].id, "body" => questions[2].body, "position" => 2, - "question_type" => "short_response", + "question_type" => "short_answer", "deleted" => "false", "display_conditions" => { "1" => { "id" => display_condition.id, "decidim_condition_question_id" => questions[0].id, "decidim_question_id" => questions[2].id, - "condition_type" => "responded" + "condition_type" => "answered" } } } @@ -401,7 +401,7 @@ module Admin :equal, question: questions[2], condition_question: questions[1], - response_option: question_2_response_options.first + answer_option: question_2_answer_options.first ) end let!(:display_condition_for_q3) do @@ -410,7 +410,7 @@ module Admin :equal, question: questions[3], condition_question: questions[1], - response_option: question_2_response_options.second + answer_option: question_2_answer_options.second ) end @@ -421,7 +421,7 @@ module Admin "id" => questions[2].id, "body" => questions[2].body, "position" => 2, - "question_type" => "short_response", + "question_type" => "short_answer", "deleted" => "false", "display_conditions" => { "1" => { @@ -429,7 +429,7 @@ module Admin "decidim_condition_question_id" => questions[1].id, "decidim_question_id" => questions[2].id, "condition_type" => "equal", - "decidim_response_option_id" => question_2_response_options.first.id + "decidim_answer_option_id" => question_2_answer_options.first.id } } }, @@ -437,7 +437,7 @@ module Admin "id" => questions[3].id, "body" => questions[3].body, "position" => 3, - "question_type" => "short_response", + "question_type" => "short_answer", "deleted" => "false", "display_conditions" => { "1" => { @@ -445,7 +445,7 @@ module Admin "decidim_condition_question_id" => questions[1].id, "decidim_question_id" => questions[3].id, "condition_type" => "equal", - "decidim_response_option_id" => question_2_response_options.second.id + "decidim_answer_option_id" => question_2_answer_options.second.id } } } diff --git a/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb b/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb index c917f3074b499..39b09e297573a 100644 --- a/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb +++ b/decidim-templates/app/commands/decidim/templates/admin/questionnaire_copier.rb @@ -7,7 +7,7 @@ module Admin module QuestionnaireCopier def copy_questionnaire_questions(original_questionnaire, new_questionnaire) # start by copying the questions so that they already exist when cross referencing them in the conditions - original_questions = original_questionnaire.reload.questions.includes(:response_options, :matrix_rows, display_conditions: [:condition_question, :response_option]).to_a + original_questions = original_questionnaire.reload.questions.includes(:answer_options, :matrix_rows, display_conditions: [:condition_question, :answer_option]).to_a original_questions.each do |original_question| new_question = original_question.dup new_question.questionnaire = new_questionnaire @@ -22,9 +22,9 @@ def copy_questionnaire_questions(original_questionnaire, new_questionnaire) copy_questionnaire_matrix_rows(original_question, new_question) end # once all questions are copied, copy display conditions. The destination - # questions are looked up by position (and their response options by body) + # questions are looked up by position (and their answer options by body) # while cross referencing the conditions, so eager load them to avoid N+1s. - destination_questions = new_questionnaire.questions.includes(:response_options).to_a + destination_questions = new_questionnaire.questions.includes(:answer_options).to_a original_questions.zip(destination_questions).each do |original_question, new_question| copy_question_display_conditions(original_question, new_question, destination_questions) end From cc70bce0ec29097e895b7a9ef4217aa5dee2ef9d Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Mon, 10 Aug 2026 14:21:30 +0200 Subject: [PATCH 10/12] Keep hidden questions' responses instead of clearing them This is an alternative to the hiding half of https://github.com/decidim/decidim/pull/17321 . (cherry picked from commit 613c37cfd40b7b15d936b00ad256e4b80fd6a5d8) --- .../forms/display_conditions.component.js | 49 ++-- .../display_conditions.component.test.js | 30 ++- ...isplay_conditions_hidden_responses.test.js | 224 ++++++++++++++++++ 3 files changed, 269 insertions(+), 34 deletions(-) create mode 100644 decidim-forms/app/packs/src/decidim/forms/display_conditions_hidden_responses.test.js diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js index 08294ee3ea4c1..8cdfe45192906 100644 --- a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js @@ -15,12 +15,23 @@ class DisplayCondition { bindEvent() { this.checkCondition(); this.getInputsToListen().on("change", this.checkCondition.bind(this)); + + const conditionWrapper = document.querySelector(`.question[data-question-id='${this.conditionQuestion}']`); + conditionWrapper?.addEventListener("display-conditions:visibility", this.checkCondition.bind(this)); } getInputValue() { const $conditionWrapperField = $(`.question[data-question-id='${this.conditionQuestion}']`); const $textInput = $conditionWrapperField.find("textarea, input[type='text']:not([name$=\\[custom_body\\]])"); + // A hidden question counts as unanswered, like the server sees it: its inputs are + // disabled, so it submits nothing. The response itself is left alone. + if ($conditionWrapperField.is("[data-condition-hidden]")) { + return $textInput.length + ? "" + : []; + } + if ($textInput.length) { return $textInput.val(); } @@ -181,6 +192,7 @@ class DisplayConditionsComponent { this.wrapperField.fadeIn(); this.wrapperField.find("input, textarea").prop("disabled", null); this.showCount++; + this.setHidden(false); } hideQuestion() { @@ -192,36 +204,21 @@ class DisplayConditionsComponent { } this.wrapperField.find("input, textarea").prop("disabled", "disabled"); - this.clearAnswers(); + this.setHidden(true); } - // When a question is hidden its answer must not survive: a stale value would - // keep questions conditioned on it visible We clear the answer and notify the - // questions conditioned on this one so the change cascades down the chain. - clearAnswers() { - const changed = []; - - this.wrapperField.find("input[type='checkbox']:checked, input[type='radio']:checked").each((idx, el) => { - el.checked = false; - changed.push(el); - }); - - this.wrapperField.find("input[type='text'], textarea").each((idx, el) => { - if (el.value !== "") { - el.value = ""; - changed.push(el); - } - }); + // A hidden question stops counting as answered, so questions conditioned on this + // one have to re-evaluate, which the visibility event asks them to do. Only a real + // change is propagated, so the cascade settles after one pass and cannot loop. + setHidden(hidden) { + const [wrapper] = this.wrapperField; - // File responses ("files" question type) keep their selection in hidden - // fields rendered inside the upload previews. Remove those previews (and the - // hidden fields they contain) so a hidden question cannot retain a stale - // attachment. - this.wrapperField.find("[data-active-uploads] .attachment-details").remove(); - - if (changed.length) { - $(changed).trigger("change"); + if (wrapper.hasAttribute("data-condition-hidden") === hidden) { + return; } + + wrapper.toggleAttribute("data-condition-hidden", hidden); + wrapper.dispatchEvent(new CustomEvent("display-conditions:visibility")); } } diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js index 33cbbf7448314..2b060718be40b 100644 --- a/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js @@ -91,16 +91,22 @@ describe("DisplayConditionsComponent", () => { selectOption("Q3", "1"); expect(isVisible("FINAL1")).toBe(true); - // Move Q0 away from C: Q3 hides, its stale answer is cleared and the change - // propagates so FINAL1 hides too instead of lingering. + // Move Q0 away from C: Q3 hides and stops counting as answered, and the + // visibility change propagates so FINAL1 hides too instead of lingering. selectOption("Q0", "A"); expect(isVisible("Q3")).toBe(false); - expect(wrapper("Q3").find("input[value='1']").prop("checked")).toBe(false); expect(isVisible("FINAL1")).toBe(false); + // Q3's response is kept, so showing it again restores the chain as it was + expect(wrapper("Q3").find("input[value='1']").prop("checked")).toBe(true); + + selectOption("Q0", "C"); + + expect(isVisible("Q3")).toBe(true); + expect(isVisible("FINAL1")).toBe(true); }); - it("clears file upload responses of a hidden question", () => { + it("keeps file upload responses of a hidden question out of the submission without destroying them", () => { document.body.innerHTML = `
@@ -120,9 +126,17 @@ describe("DisplayConditionsComponent", () => { createDisplayConditions({ wrapperField: $(el) }); }); - // Q0 is not "C", so QFILE is hidden: its attachment preview and the hidden - // field carrying the upload must be gone so nothing stale is submitted. - expect(wrapper("QFILE").find(".attachment-details").length).toBe(0); - expect(wrapper("QFILE").find("input[name='resp[QFILE][add_attachments]']").length).toBe(0); + // Q0 is not "C", so QFILE is hidden. The attachment stays in the DOM, but it is + // disabled, so nothing stale is submitted and nothing already uploaded is lost. + const $hiddenField = wrapper("QFILE").find("input[name='resp[QFILE][add_attachments]']"); + + expect(wrapper("QFILE").find(".attachment-details").length).toBe(1); + expect($hiddenField.length).toBe(1); + expect($hiddenField.prop("disabled")).toBe(true); + + // and it is submitted again once the question is shown + selectOption("Q0", "C"); + + expect($hiddenField.prop("disabled")).toBe(false); }); }); diff --git a/decidim-forms/app/packs/src/decidim/forms/display_conditions_hidden_responses.test.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions_hidden_responses.test.js new file mode 100644 index 0000000000000..c8635936c5868 --- /dev/null +++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions_hidden_responses.test.js @@ -0,0 +1,224 @@ +/* eslint-disable no-relative-import-paths/no-relative-import-paths, id-length */ +// +// Covers what happens to a hidden question's response, for the two question types +// the display-conditions component cannot clear by hand: `files` (its response +// lives in UploadModal's markup) and `sorting` (hidden fields only). +// +// On the clearAnswers() implementation, the three cases about an already-saved +// attachment fail: its preview and hidden add_attachments field are removed, and +// showQuestion() never puts them back. The other two pass there as well, and show +// why the selector was inconsistent: a file uploaded in the current session, and a +// sorting question, were never cleared at all. +// +// The fixture reproduces the markup actually rendered to participants: +// decidim-core/app/cells/decidim/upload_modal/files.erb +// decidim-core/app/cells/decidim/upload_modal/modal.erb +// decidim-forms/app/views/decidim/forms/questionnaires/answers/_files.html.erb +// decidim-forms/app/views/decidim/forms/questionnaires/answers/_sorting.html.erb +// and drives it through the real initializeUploadFields() and +// createDisplayConditions(), so no behaviour is stubbed. +// +// Run with: npx jest decidim-forms/app/packs/src/decidim/forms/display_conditions_hidden_responses.test.js +// + +import $ from "jquery"; +import createDisplayConditions from "./display_conditions.component"; +import { initializeUploadFields } from "src/decidim/direct_uploads/upload_field"; + +const MODAL_ID = "modal-qfile"; +// Raw JSON because the keys are the snake_case ones upload_modal/modal.erb emits. +const LOCALES = `{ + "error": "error", "title_required": "title required", "filename": "filename", + "file_size_too_large": "too large", "remove": "remove", "title": "title", + "uploaded": "uploaded", "validating": "validating", "validation_error": "validation error" +}`; +const UPLOAD_OPTS = JSON.stringify({ + addAttribute: "add_attachments", + resourceName: "questionnaire", + resourceClass: "Decidim::Forms::Questionnaire", + required: false, + maxFileSize: 10485760, + multiple: true, + titled: false, + formObjectClass: "Decidim::Forms::QuestionnaireForm" +}); + +const radioCollection = (qid, options) => ` +
+ ${options.map((opt) => ` +
+ + + +
+ `).join("")} +
+`; + +const conditionTag = (data) => + `
`data-${key}='${val}'`).join(" ")}>
`; + +// Mirrors upload_modal/files.erb for one already-persisted, non-image attachment. +const uploadMarkup = () => ` +
+
+
+
+ doc.pdf + +
+
+
+ +
+
+
+
+

Add file

+
+ + +
+ +
+
+
+
+ + +
+
+
+`; + +// Mirrors answers/_sorting.html.erb +const sortingMarkup = (qid) => ` +
+ ${[["10", "1"], ["11", "2"], ["12", "3"]].map(([optId, pos], idx) => ` +
+ + + +
+ `).join("")} +
+`; + +const page = () => ` +
+
+ ${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optC", value: "C" }])} +
+
+ ${conditionTag({ id: "dcFile", type: "equal", condition: "Q0", option: "optC", mandatory: false })} + ${uploadMarkup()} + +
+
+ ${conditionTag({ id: "dcSort", type: "equal", condition: "Q0", option: "optC", mandatory: false })} + ${sortingMarkup("QSORT")} + +
+
+`; + +const initUploads = () => initializeUploadFields(document.querySelectorAll("button[data-upload]")); +const initConditions = () => + $(".answer-questionnaire .question[data-conditioned='true']").each((idx, el) => + createDisplayConditions({ wrapperField: $(el) }) + ); + +const wrapper = (qid) => $(`.question[data-question-id='${qid}']`); +const selectOption = (qid, value) => { + const $input = wrapper(qid).find(`input[name$='[body]'][value='${value}']`); + $input.prop("checked", true); + $input.trigger("change"); +}; +// What the browser would actually submit: enabled fields only. +const submitted = () => + Array.from(document.querySelectorAll("form input:not([disabled])")). + filter((el) => el.name && (!["radio", "checkbox"].includes(el.type) || el.checked)). + map((el) => `${el.name}=${el.value}`); +// Any preview node holding the hidden add_attachments field, whether server +// rendered (.attachment-details) or re-rendered by updateActiveUploads (no class). +const previews = () => document.querySelectorAll(`[data-active-uploads='${MODAL_ID}'] > *`).length; +const serverRenderedPreviews = () => document.querySelectorAll(`[data-active-uploads='${MODAL_ID}'] .attachment-details`).length; +const modalItems = () => document.querySelectorAll(`#${MODAL_ID} [data-dropzone-items] > *`).length; +const buttonLabel = () => document.getElementById(`button-${MODAL_ID}`).innerHTML; +const positions = () => + Array.from(document.querySelectorAll("[data-question-id='QSORT'] input[name$='[position]']")).map((el) => el.value); + +describe("hidden questions and their responses", () => { + beforeEach(() => { + // UploadModal renders icons through window.Decidim.config + window.Decidim = { config: { get: () => "/icons.svg" } }; + document.body.innerHTML = page(); + }); + + describe("a file question hidden on page load", () => { + it("keeps the attachment and UploadModal in sync, whichever initializes first", () => { + initUploads(); + initConditions(); + + expect(previews()).toBe(1); + expect(serverRenderedPreviews()).toBe(1); + expect(modalItems()).toBe(1); + expect(buttonLabel()).toBe("Add file"); + // kept, but disabled, so it is not submitted while the question is hidden + expect(submitted().filter((field) => field.includes("add_attachments"))).toEqual([]); + }); + + it("behaves the same when the display conditions initialize first", () => { + initConditions(); + initUploads(); + + expect(previews()).toBe(1); + expect(modalItems()).toBe(1); + expect(submitted().filter((field) => field.includes("add_attachments"))).toEqual([]); + }); + }); + + describe("a file question hidden and shown again", () => { + it("submits the attachment it already had", () => { + initUploads(); + initConditions(); + + selectOption("Q0", "C"); + + expect(previews()).toBe(1); + expect(submitted()).toContain("questionnaire[responses][QFILE][add_attachments]=99"); + }); + + it("submits a file uploaded in this session, whose preview carries no attachment-details class", () => { + initUploads(); + initConditions(); + selectOption("Q0", "C"); + + // saving the modal makes updateActiveUploads() re-render the preview + document.getElementById(`button-${MODAL_ID}`).click(); + document.querySelector(`#${MODAL_ID} [data-dropzone-save]`).click(); + expect(serverRenderedPreviews()).toBe(0); + + selectOption("Q0", "A"); + selectOption("Q0", "C"); + + expect(previews()).toBe(1); + expect(submitted().filter((field) => field.includes("add_attachments"))).not.toEqual([]); + }); + }); + + describe("a sorting question", () => { + it("keeps its order out of the submission while hidden, and restores it when shown", () => { + initUploads(); + initConditions(); + + expect(positions()).toEqual(["1", "2", "3"]); + expect(submitted().filter((field) => field.includes("QSORT"))).toEqual([]); + + selectOption("Q0", "C"); + + expect(positions()).toEqual(["1", "2", "3"]); + expect(submitted()).toContain("questionnaire[responses][QSORT][choices][0][position]=1"); + }); + }); +}); From 496cce257e2d9e3188837da03e1cf4d99f252a7d Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Mon, 31 Aug 2026 18:29:41 +0200 Subject: [PATCH 11/12] Add check-spelling allowed word --- .github/actions/spelling/expect.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 8215c59639fb0..9dd3d8b0b3136 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -751,6 +751,7 @@ protonmail publicable Pujol pwa +qfile QLiterals queda qux From 9f086d46ca1406a4178befe29fbc13a0c9ff336c Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Mon, 31 Aug 2026 19:18:04 +0200 Subject: [PATCH 12/12] Export initializeUploadFields from upload_field --- .../packs/src/decidim/direct_uploads/upload_field.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/decidim-core/app/packs/src/decidim/direct_uploads/upload_field.js b/decidim-core/app/packs/src/decidim/direct_uploads/upload_field.js index d4e6a478b8b71..24dee1da5fa40 100644 --- a/decidim-core/app/packs/src/decidim/direct_uploads/upload_field.js +++ b/decidim-core/app/packs/src/decidim/direct_uploads/upload_field.js @@ -115,9 +115,7 @@ const resetDropzone = (modal) => { /* NOTE: all this actions are supposed to work using the modal object, so, perhaps, it would be more accurate to move all the inner listeners to the UploadModal class */ -document.addEventListener("DOMContentLoaded", () => { - const attachmentButtons = document.querySelectorAll("button[data-upload]"); - +export const initializeUploadFields = function(attachmentButtons) { attachmentButtons.forEach((attachmentButton) => { const modal = new UploadModal(attachmentButton); @@ -141,5 +139,9 @@ document.addEventListener("DOMContentLoaded", () => { modal.saveButton.addEventListener("click", (event) => event.preventDefault() || updateActiveUploads(modal)); // remove the uploaded files if cancel button is clicked modal.cancelButton.addEventListener("click", (event) => event.preventDefault() || modal.cleanAllFiles()); - }) + }); +} + +document.addEventListener("DOMContentLoaded", () => { + initializeUploadFields(document.querySelectorAll("button[data-upload]")); })