Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,7 @@ protonmail
publicable
Pujol
pwa
qfile
QLiterals
queda
qux
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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]"));
})
4 changes: 2 additions & 2 deletions decidim-forms/app/models/decidim/forms/question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -181,6 +192,7 @@ class DisplayConditionsComponent {
this.wrapperField.fadeIn();
this.wrapperField.find("input, textarea").prop("disabled", null);
this.showCount++;
this.setHidden(false);
}

hideQuestion() {
Expand All @@ -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"));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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) => `
<div class="js-radio-button-collection">
${options.map((opt) => `
<div class="js-collection-input">
<input name="resp[${qid}][body]" type="radio" value="${opt.value}" />
<input name="resp[${qid}][custom_body]" type="text" />
<input name="resp[${qid}][answer_option_id]" type="hidden" value="${opt.optionId}" />
</div>
`).join("")}
</div>
`;

const conditionTag = (data) => {
const attrs = Object.entries(data).map(([key, val]) => `data-${key}='${val}'`).join(" ");
return `<div class="display-condition" ${attrs}></div>`;
};

// Q0 (A/B/C) --C--> Q3 (0/1) --0--> FINAL0
// \--1--> FINAL1
const content = `
<div class="answer-questionnaire">
<div class="question" data-question-id="Q0" data-conditioned="false">
${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optB", value: "B" }, { optionId: "optC", value: "C" }])}
</div>
<div class="question" data-question-id="Q3" data-conditioned="true">
${conditionTag({ id: "dcQ3", type: "equal", condition: "Q0", option: "optC", mandatory: false })}
${radioCollection("Q3", [{ optionId: "opt0", value: "0" }, { optionId: "opt1", value: "1" }])}
</div>
<div class="question" data-question-id="FINAL0" data-conditioned="true">
${conditionTag({ id: "dcF0", type: "equal", condition: "Q3", option: "opt0", mandatory: false })}
${radioCollection("FINAL0", [{ optionId: "f0opt", value: "yes" }])}
</div>
<div class="question" data-question-id="FINAL1" data-conditioned="true">
${conditionTag({ id: "dcF1", type: "equal", condition: "Q3", option: "opt1", mandatory: false })}
${radioCollection("FINAL1", [{ optionId: "f1opt", value: "yes" }])}
</div>
</div>
`;

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 = `
<div class="answer-questionnaire">
<div class="question" data-question-id="Q0" data-conditioned="false">
${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optC", value: "C" }])}
</div>
<div class="question" data-question-id="QFILE" data-conditioned="true">
${conditionTag({ id: "dcFile", type: "equal", condition: "Q0", option: "optC", mandatory: false })}
<div class="upload-modal__files" data-active-uploads="QFILE">
<div class="attachment-details" data-filename="doc.pdf" data-state="uploaded">
<input name="resp[QFILE][add_attachments]" type="hidden" value="signed-id-123" />
</div>
</div>
</div>
</div>
`;
$(".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);
});
});
Loading
Loading