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
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]"));
})
diff --git a/decidim-forms/app/models/decidim/forms/question.rb b/decidim-forms/app/models/decidim/forms/question.rb
index aad7b7fd6d079..8992f7cc1f299 100644
--- a/decidim-forms/app/models/decidim/forms/question.rb
+++ b/decidim-forms/app/models/decidim/forms/question.rb
@@ -39,12 +39,12 @@ 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,
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/app/packs/src/decidim/forms/display_conditions.component.js b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.js
index b4a4db03fa756..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,6 +204,21 @@ class DisplayConditionsComponent {
}
this.wrapperField.find("input, textarea").prop("disabled", "disabled");
+ this.setHidden(true);
+ }
+
+ // 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;
+
+ 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
new file mode 100644
index 0000000000000..2b060718be40b
--- /dev/null
+++ b/decidim-forms/app/packs/src/decidim/forms/display_conditions.component.test.js
@@ -0,0 +1,142 @@
+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) => `
+
+ `;
+
+ 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 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(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("keeps file upload responses of a hidden question out of the submission without destroying them", () => {
+ 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. 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) => `
+
+`;
+
+const conditionTag = (data) =>
+ ` `data-${key}='${val}'`).join(" ")}>
`;
+
+// Mirrors upload_modal/files.erb for one already-persisted, non-image attachment.
+const uploadMarkup = () => `
+
+
+`;
+
+// Mirrors answers/_sorting.html.erb
+const sortingMarkup = (qid) => `
+
+ ${[["10", "1"], ["11", "2"], ["12", "3"]].map(([optId, pos], idx) => `
+
+
+
+
+
+ `).join("")}
+
+`;
+
+const page = () => `
+
+`;
+
+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");
+ });
+ });
+});
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..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
@@ -348,6 +348,122 @@ 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
+
+ # 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],
+ answer_option: question_2_answer_options.first
+ )
+ end
+ let!(:display_condition_for_q3) do
+ create(
+ :display_condition,
+ :equal,
+ question: questions[3],
+ condition_question: questions[1],
+ answer_option: question_2_answer_options.second
+ )
+ 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_for_q2.id,
+ "decidim_condition_question_id" => questions[1].id,
+ "decidim_question_id" => questions[2].id,
+ "condition_type" => "equal",
+ "decidim_answer_option_id" => question_2_answer_options.first.id
+ }
+ }
+ },
+ "4" => {
+ "id" => questions[3].id,
+ "body" => questions[3].body,
+ "position" => 3,
+ "question_type" => "short_answer",
+ "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_answer_option_id" => question_2_answer_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
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:) }
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..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,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(: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
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 answer options by body)
+ # while cross referencing the conditions, so eager load them to avoid N+1s.
+ 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
end
@@ -43,19 +46,18 @@ 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
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