decidim-forms 0.31.7 → 0.31.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0ba9f43cbc4445bdc706cf213d8b99ac38a8270e2c9969e14937c73005bb99a
4
- data.tar.gz: 9f90e9f87e6387e2e4514f3bd5219699d43e1e9efe91f13bb7fb11ac2c750d3b
3
+ metadata.gz: b84a65887877bd8ffdbb7c872bdd1f2a885cceacbd270d6ecc09eb979d4c99c4
4
+ data.tar.gz: 831bd2c87a75931b4c23177bbfc7885d1fb194aeaa6615f8292632d3f9ece517
5
5
  SHA512:
6
- metadata.gz: fc1ffc539c4234c5a1afbad6ee8c980b63829892f30b9a68a0f6dab87ec1fedb7b56b969f08785e69a6b9891adaf5329d55e7760f7ff06ca8d2ffe17fd4f1832
7
- data.tar.gz: '095c5e8668a8d22e15c07cc83f3bc6aea6ded38303f176ae657becb4c81aa5c388745dce06e7e235d4907a960ced01f9fc0ce1a020c13ba56128d3f2bf930c8b'
6
+ metadata.gz: 71987f2643d3d28978ef8c6b73a9ae03b92c2ffe180e572d21618e21664a7ffbb498da2fc729e14b5b4ec7c618c790319f2e89eb9d75853cdbd4f56c317d9ab9
7
+ data.tar.gz: 74ae0de2474b4be8e1c504ccd6a1dd678d1b9904ba8f6f10c3569c417c85f6dc7ddf4a3281f27164df455e9b4af66f942f54b33325fe94c7ebdac20fa4182cde
@@ -51,7 +51,8 @@ module Decidim
51
51
  data: {
52
52
  confirm:,
53
53
  disable: true,
54
- data: "survey-buttons"
54
+ data: "survey-buttons",
55
+ confirm_icon: "checkbox-circle-line"
55
56
  }
56
57
  }
57
58
  end
@@ -20,19 +20,6 @@ module Decidim
20
20
  helper_method :questionnaire_for, :questionnaire, :blank_question, :blank_response_option, :blank_matrix_row,
21
21
  :blank_display_condition, :question_types, :display_condition_types, :update_url, :public_url, :response_options_url, :edit_questionnaire_title
22
22
 
23
- if defined?(Decidim::Templates::Admin::Concerns::Templatable)
24
- include Decidim::Templates::Admin::Concerns::Templatable
25
- helper Decidim::DatalistSelectHelper
26
-
27
- def templatable_type
28
- "Decidim::Forms::Questionnaire"
29
- end
30
-
31
- def templatable
32
- questionnaire
33
- end
34
- end
35
-
36
23
  def edit
37
24
  enforce_permission_to(:update, permission_subject, questionnaire:)
38
25
 
@@ -88,18 +75,41 @@ module Decidim
88
75
  end
89
76
  end
90
77
 
78
+ # This endpoint is only used by the questions form rendered by the
79
+ # already authorized "edit_questions" action. The host controllers
80
+ # resolve their resource from params (e.g. "params[:id]") that have
81
+ # a different meaning in this request, so the authorization cannot
82
+ # be delegated to "questionnaire_for" here. Access is already
83
+ # restricted by the admin dashboard routing constraint.
84
+ # rubocop:disable Decidim/EnforcePermissionTo
91
85
  def response_options
92
86
  respond_to do |format|
93
87
  format.json do
94
88
  question_id = params["id"]
95
- question = Question.find_by(id: question_id)
89
+ question = questionnaire.questions.find_by(id: question_id)
96
90
  render json: question.response_options.map { |response_option| ResponseOptionPresenter.new(response_option).as_json } if question.present?
97
91
  end
98
92
  end
99
93
  end
94
+ # rubocop:enable Decidim/EnforcePermissionTo
95
+
96
+ protected
97
+
98
+ if defined?(Decidim::Templates::Admin::Concerns::Templatable)
99
+ include Decidim::Templates::Admin::Concerns::Templatable
100
+
101
+ helper Decidim::DatalistSelectHelper
102
+
103
+ def templatable_type
104
+ "Decidim::Forms::Questionnaire"
105
+ end
106
+
107
+ def templatable
108
+ questionnaire
109
+ end
110
+ end
100
111
 
101
- # Public: The only method to be implemented at the controller. You need to
102
- # return the object that will hold the questionnaire.
112
+ # You need to return the object that will hold the questionnaire.
103
113
  def questionnaire_for
104
114
  raise "#{self.class.name} is expected to implement #questionnaire_for"
105
115
  end
@@ -155,7 +165,7 @@ module Decidim
155
165
  end
156
166
 
157
167
  def questionnaire
158
- @questionnaire ||= Questionnaire.find_by(questionnaire_for:)
168
+ @questionnaire ||= Questionnaire.find_by(questionnaire_for:) # rubocop:disable Decidim/OrganizationScopedFinder -- questionnaire_for is scoped by the host controller
159
169
  end
160
170
 
161
171
  def blank_question
@@ -60,6 +60,8 @@ module Decidim
60
60
  redirect_back(fallback_location: questionnaire_participant_responses_url(session_token))
61
61
  end
62
62
 
63
+ protected
64
+
63
65
  # Public: The only method to be implemented at the controller. You need to
64
66
  # return the object that will hold the questionnaire.
65
67
  def questionnaire_for
@@ -77,7 +79,7 @@ module Decidim
77
79
  end
78
80
 
79
81
  def questionnaire
80
- @questionnaire ||= Questionnaire.find_by(questionnaire_for:)
82
+ @questionnaire ||= Questionnaire.find_by(questionnaire_for:) # rubocop:disable Decidim/OrganizationScopedFinder -- questionnaire_for is scoped by the host controller
81
83
  end
82
84
 
83
85
  def participants_query
@@ -22,6 +22,8 @@ module Decidim
22
22
  invisible_captcha on_spam: :spam_detected
23
23
 
24
24
  def show
25
+ enforce_permission_to :read, :component
26
+
25
27
  @form = form(Decidim::Forms::QuestionnaireForm).from_model(questionnaire)
26
28
  render template:
27
29
  end
@@ -46,6 +48,8 @@ module Decidim
46
48
  end
47
49
  end
48
50
 
51
+ private
52
+
49
53
  def template
50
54
  "decidim/forms/questionnaires/show"
51
55
  end
@@ -39,13 +39,13 @@ module Decidim
39
39
  class_name: "DisplayCondition",
40
40
  foreign_key: "decidim_condition_question_id",
41
41
  dependent: :destroy,
42
- inverse_of: :question,
42
+ inverse_of: :condition_question,
43
43
  counter_cache: :display_conditions_for_other_questions_count
44
44
 
45
45
  # Questions which have display conditions based on the value of this question's response
46
46
  has_many :conditioned_questions,
47
47
  through: :display_conditions_for_other_questions,
48
- foreign_key: "decidim_condition_question_id",
48
+ source: :question,
49
49
  class_name: "Question"
50
50
 
51
51
  has_many :responses,
@@ -1,14 +1,4 @@
1
1
  document.addEventListener("turbo:load", () => {
2
- const getButtons = document.querySelectorAll("button.question--collapse");
3
-
4
- setTimeout(() => {
5
- getButtons.forEach((button) => {
6
- if (button.classList.contains("question-error")) {
7
- button.click();
8
- }
9
- });
10
- }, 100);
11
-
12
2
  document.querySelector("button.collapse-all")?.addEventListener("click", () => {
13
3
  document.querySelectorAll("[id$=field] button.question--collapse[aria-expanded='true']").
14
4
  forEach((button) => button.click());
@@ -15,12 +15,23 @@ class DisplayCondition {
15
15
  bindEvent() {
16
16
  this.checkCondition();
17
17
  this.getInputsToListen().on("change", this.checkCondition.bind(this));
18
+
19
+ const conditionWrapper = document.querySelector(`.question[data-question-id='${this.conditionQuestion}']`);
20
+ conditionWrapper?.addEventListener("display-conditions:visibility", this.checkCondition.bind(this));
18
21
  }
19
22
 
20
23
  getInputValue() {
21
24
  const $conditionWrapperField = $(`.question[data-question-id='${this.conditionQuestion}']`);
22
25
  const $textInput = $conditionWrapperField.find("textarea, input[type='text']:not([name$=\\[custom_body\\]])");
23
26
 
27
+ // A hidden question counts as unanswered, like the server sees it: its inputs are
28
+ // disabled, so it submits nothing. The response itself is left alone.
29
+ if ($conditionWrapperField.is("[data-condition-hidden]")) {
30
+ return $textInput.length
31
+ ? ""
32
+ : [];
33
+ }
34
+
24
35
  if ($textInput.length) {
25
36
  return $textInput.val();
26
37
  }
@@ -181,6 +192,7 @@ class DisplayConditionsComponent {
181
192
  this.wrapperField.fadeIn();
182
193
  this.wrapperField.find("input, textarea").prop("disabled", null);
183
194
  this.showCount++;
195
+ this.setHidden(false);
184
196
  }
185
197
 
186
198
  hideQuestion() {
@@ -192,6 +204,21 @@ class DisplayConditionsComponent {
192
204
  }
193
205
 
194
206
  this.wrapperField.find("input, textarea").prop("disabled", "disabled");
207
+ this.setHidden(true);
208
+ }
209
+
210
+ // A hidden question stops counting as answered, so questions conditioned on this
211
+ // one have to re-evaluate, which the visibility event asks them to do. Only a real
212
+ // change is propagated, so the cascade settles after one pass and cannot loop.
213
+ setHidden(hidden) {
214
+ const [wrapper] = this.wrapperField;
215
+
216
+ if (wrapper.hasAttribute("data-condition-hidden") === hidden) {
217
+ return;
218
+ }
219
+
220
+ wrapper.toggleAttribute("data-condition-hidden", hidden);
221
+ wrapper.dispatchEvent(new CustomEvent("display-conditions:visibility"));
195
222
  }
196
223
  }
197
224
 
@@ -0,0 +1,142 @@
1
+ import $ from "jquery"; // eslint-disable-line id-length
2
+
3
+ // Relative import: the forms pack is not in jest's moduleDirectories, so it
4
+ // cannot be resolved through the absolute "src/..." path used elsewhere.
5
+ import createDisplayConditions from "./display_conditions.component"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths
6
+
7
+ describe("DisplayConditionsComponent", () => {
8
+ // Renders a single-choice (radio) question with the DOM structure the
9
+ // component expects (js-collection-input wrapping body/custom_body/option_id).
10
+ const radioCollection = (qid, options) => `
11
+ <div class="js-radio-button-collection">
12
+ ${options.map((opt) => `
13
+ <div class="js-collection-input">
14
+ <input name="resp[${qid}][body]" type="radio" value="${opt.value}" />
15
+ <input name="resp[${qid}][custom_body]" type="text" />
16
+ <input name="resp[${qid}][response_option_id]" type="hidden" value="${opt.optionId}" />
17
+ </div>
18
+ `).join("")}
19
+ </div>
20
+ `;
21
+
22
+ const conditionTag = (data) => {
23
+ const attrs = Object.entries(data).map(([key, val]) => `data-${key}='${val}'`).join(" ");
24
+ return `<div class="display-condition" ${attrs}></div>`;
25
+ };
26
+
27
+ // Q0 (A/B/C) --C--> Q3 (0/1) --0--> FINAL0
28
+ // \--1--> FINAL1
29
+ const content = `
30
+ <div class="answer-questionnaire">
31
+ <div class="question" data-question-id="Q0" data-conditioned="false">
32
+ ${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optB", value: "B" }, { optionId: "optC", value: "C" }])}
33
+ </div>
34
+ <div class="question" data-question-id="Q3" data-conditioned="true">
35
+ ${conditionTag({ id: "dcQ3", type: "equal", condition: "Q0", option: "optC", mandatory: false })}
36
+ ${radioCollection("Q3", [{ optionId: "opt0", value: "0" }, { optionId: "opt1", value: "1" }])}
37
+ </div>
38
+ <div class="question" data-question-id="FINAL0" data-conditioned="true">
39
+ ${conditionTag({ id: "dcF0", type: "equal", condition: "Q3", option: "opt0", mandatory: false })}
40
+ ${radioCollection("FINAL0", [{ optionId: "f0opt", value: "yes" }])}
41
+ </div>
42
+ <div class="question" data-question-id="FINAL1" data-conditioned="true">
43
+ ${conditionTag({ id: "dcF1", type: "equal", condition: "Q3", option: "opt1", mandatory: false })}
44
+ ${radioCollection("FINAL1", [{ optionId: "f1opt", value: "yes" }])}
45
+ </div>
46
+ </div>
47
+ `;
48
+
49
+ const wrapper = (qid) => $(`.question[data-question-id='${qid}']`);
50
+ // The component enables inputs when a question is shown and disables them when
51
+ // hidden, so the disabled state is a reliable proxy for visibility in jsdom.
52
+ const isVisible = (qid) => !wrapper(qid).find("input[name$='[body]']").first().prop("disabled");
53
+ const selectOption = (qid, value) => {
54
+ const $input = wrapper(qid).find(`input[name$='[body]'][value='${value}']`);
55
+ $input.prop("checked", true);
56
+ $input.trigger("change");
57
+ };
58
+
59
+ beforeEach(() => {
60
+ document.body.innerHTML = content;
61
+ $(".answer-questionnaire .question[data-conditioned='true']").each((idx, el) => {
62
+ createDisplayConditions({ wrapperField: $(el) });
63
+ });
64
+ });
65
+
66
+ it("keeps conditioned questions hidden until their trigger is fulfilled", () => {
67
+ expect(isVisible("Q3")).toBe(false);
68
+ expect(isVisible("FINAL0")).toBe(false);
69
+ expect(isVisible("FINAL1")).toBe(false);
70
+ });
71
+
72
+ it("shows only the directly conditioned question when its trigger is met", () => {
73
+ selectOption("Q0", "C");
74
+
75
+ expect(isVisible("Q3")).toBe(true);
76
+ // Q3 is not answered yet, so neither final must appear
77
+ expect(isVisible("FINAL0")).toBe(false);
78
+ expect(isVisible("FINAL1")).toBe(false);
79
+ });
80
+
81
+ it("shows the matching final once the intermediate question is answered", () => {
82
+ selectOption("Q0", "C");
83
+ selectOption("Q3", "1");
84
+
85
+ expect(isVisible("FINAL1")).toBe(true);
86
+ expect(isVisible("FINAL0")).toBe(false);
87
+ });
88
+
89
+ it("cascades hiding down the chain when an ancestor trigger stops being fulfilled", () => {
90
+ selectOption("Q0", "C");
91
+ selectOption("Q3", "1");
92
+ expect(isVisible("FINAL1")).toBe(true);
93
+
94
+ // Move Q0 away from C: Q3 hides and stops counting as answered, and the
95
+ // visibility change propagates so FINAL1 hides too instead of lingering.
96
+ selectOption("Q0", "A");
97
+
98
+ expect(isVisible("Q3")).toBe(false);
99
+ expect(isVisible("FINAL1")).toBe(false);
100
+ // Q3's response is kept, so showing it again restores the chain as it was
101
+ expect(wrapper("Q3").find("input[value='1']").prop("checked")).toBe(true);
102
+
103
+ selectOption("Q0", "C");
104
+
105
+ expect(isVisible("Q3")).toBe(true);
106
+ expect(isVisible("FINAL1")).toBe(true);
107
+ });
108
+
109
+ it("keeps file upload responses of a hidden question out of the submission without destroying them", () => {
110
+ document.body.innerHTML = `
111
+ <div class="answer-questionnaire">
112
+ <div class="question" data-question-id="Q0" data-conditioned="false">
113
+ ${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optC", value: "C" }])}
114
+ </div>
115
+ <div class="question" data-question-id="QFILE" data-conditioned="true">
116
+ ${conditionTag({ id: "dcFile", type: "equal", condition: "Q0", option: "optC", mandatory: false })}
117
+ <div class="upload-modal__files" data-active-uploads="QFILE">
118
+ <div class="attachment-details" data-filename="doc.pdf" data-state="uploaded">
119
+ <input name="resp[QFILE][add_attachments]" type="hidden" value="signed-id-123" />
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ `;
125
+ $(".answer-questionnaire .question[data-conditioned='true']").each((idx, el) => {
126
+ createDisplayConditions({ wrapperField: $(el) });
127
+ });
128
+
129
+ // Q0 is not "C", so QFILE is hidden. The attachment stays in the DOM, but it is
130
+ // disabled, so nothing stale is submitted and nothing already uploaded is lost.
131
+ const $hiddenField = wrapper("QFILE").find("input[name='resp[QFILE][add_attachments]']");
132
+
133
+ expect(wrapper("QFILE").find(".attachment-details").length).toBe(1);
134
+ expect($hiddenField.length).toBe(1);
135
+ expect($hiddenField.prop("disabled")).toBe(true);
136
+
137
+ // and it is submitted again once the question is shown
138
+ selectOption("Q0", "C");
139
+
140
+ expect($hiddenField.prop("disabled")).toBe(false);
141
+ });
142
+ });
@@ -0,0 +1,224 @@
1
+ /* eslint-disable no-relative-import-paths/no-relative-import-paths, id-length */
2
+ //
3
+ // Covers what happens to a hidden question's response, for the two question types
4
+ // the display-conditions component cannot clear by hand: `files` (its response
5
+ // lives in UploadModal's markup) and `sorting` (hidden fields only).
6
+ //
7
+ // On the clearAnswers() implementation, the three cases about an already-saved
8
+ // attachment fail: its preview and hidden add_attachments field are removed, and
9
+ // showQuestion() never puts them back. The other two pass there as well, and show
10
+ // why the selector was inconsistent: a file uploaded in the current session, and a
11
+ // sorting question, were never cleared at all.
12
+ //
13
+ // The fixture reproduces the markup actually rendered to participants:
14
+ // decidim-core/app/cells/decidim/upload_modal/files.erb
15
+ // decidim-core/app/cells/decidim/upload_modal/modal.erb
16
+ // decidim-forms/app/views/decidim/forms/questionnaires/responses/_files.html.erb
17
+ // decidim-forms/app/views/decidim/forms/questionnaires/responses/_sorting.html.erb
18
+ // and drives it through the real initializeUploadFields() and
19
+ // createDisplayConditions(), so no behaviour is stubbed.
20
+ //
21
+ // Run with: npx jest decidim-forms/app/packs/src/decidim/forms/display_conditions_hidden_responses.test.js
22
+ //
23
+
24
+ import $ from "jquery";
25
+ import createDisplayConditions from "./display_conditions.component";
26
+ import { initializeUploadFields } from "src/decidim/direct_uploads/upload_field";
27
+
28
+ const MODAL_ID = "modal-qfile";
29
+ // Raw JSON because the keys are the snake_case ones upload_modal/modal.erb emits.
30
+ const LOCALES = `{
31
+ "error": "error", "title_required": "title required", "filename": "filename",
32
+ "file_size_too_large": "too large", "remove": "remove", "title": "title",
33
+ "uploaded": "uploaded", "validating": "validating", "validation_error": "validation error"
34
+ }`;
35
+ const UPLOAD_OPTS = JSON.stringify({
36
+ addAttribute: "add_attachments",
37
+ resourceName: "questionnaire",
38
+ resourceClass: "Decidim::Forms::Questionnaire",
39
+ required: false,
40
+ maxFileSize: 10485760,
41
+ multiple: true,
42
+ titled: false,
43
+ formObjectClass: "Decidim::Forms::QuestionnaireForm"
44
+ });
45
+
46
+ const radioCollection = (qid, options) => `
47
+ <div class="js-radio-button-collection">
48
+ ${options.map((opt) => `
49
+ <div class="js-collection-input">
50
+ <input name="questionnaire[responses][${qid}][body]" type="radio" value="${opt.value}" />
51
+ <input name="questionnaire[responses][${qid}][custom_body]" type="text" />
52
+ <input name="questionnaire[responses][${qid}][response_option_id]" type="hidden" value="${opt.optionId}" />
53
+ </div>
54
+ `).join("")}
55
+ </div>
56
+ `;
57
+
58
+ const conditionTag = (data) =>
59
+ `<div class="display-condition" ${Object.entries(data).map(([key, val]) => `data-${key}='${val}'`).join(" ")}></div>`;
60
+
61
+ // Mirrors upload_modal/files.erb for one already-persisted, non-image attachment.
62
+ const uploadMarkup = () => `
63
+ <div class="upload-modal__files-container upload-container-for-add_attachments">
64
+ <div>
65
+ <div class="upload-modal__files" data-active-uploads="${MODAL_ID}">
66
+ <div class="attachment-details" data-attachment-id="99" data-title="doc.pdf" data-filename="doc.pdf" data-state="uploaded" data-hidden-field="signed-id-abc">
67
+ <a href="/rails/active_storage/blobs/doc.pdf">doc.pdf</a>
68
+ <input type="hidden" name="questionnaire[responses][QFILE][add_attachments]" value="99" id="hidden_add_attachments_99" />
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <button id="button-${MODAL_ID}" name="add_attachments" type="button" data-dialog-open="${MODAL_ID}" data-upload='${UPLOAD_OPTS}'>Add file</button>
73
+ </div>
74
+ <div id="${MODAL_ID}" data-dialog="${MODAL_ID}">
75
+ <div id="${MODAL_ID}-content" class="upload-modal">
76
+ <div data-dialog-container>
77
+ <h2 data-dialog-title data-addlabel="Add file" data-editlabel="Edit file">Add file</h2>
78
+ <div data-name="add_attachments" data-dropzone>
79
+ <input id="files-${MODAL_ID}" type="file" multiple hidden />
80
+ <ul hidden data-dropzone-items="" data-locales='${LOCALES}'></ul>
81
+ <div data-dropzone-no-items="">
82
+ <label for="files-${MODAL_ID}"><span>Select file</span></label>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ <div data-dialog-actions>
87
+ <button type="button" data-dropzone-cancel data-dialog-close="${MODAL_ID}">Cancel</button>
88
+ <button type="button" data-dropzone-save data-dialog-close="${MODAL_ID}" disabled>Save</button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ `;
93
+
94
+ // Mirrors responses/_sorting.html.erb
95
+ const sortingMarkup = (qid) => `
96
+ <div class="response-questionnaire__sorting-container js-sortable-check-box-collection">
97
+ ${[["10", "1"], ["11", "2"], ["12", "3"]].map(([optId, pos], idx) => `
98
+ <div class="response-questionnaire__sorting js-collection-input" role="button">
99
+ <input type="hidden" name="questionnaire[responses][${qid}][choices][${idx}][position]" value="${pos}" />
100
+ <input type="hidden" name="questionnaire[responses][${qid}][choices][${idx}][body]" value="opt${optId}" />
101
+ <input type="hidden" name="questionnaire[responses][${qid}][choices][${idx}][response_option_id]" value="${optId}" />
102
+ </div>
103
+ `).join("")}
104
+ </div>
105
+ `;
106
+
107
+ const page = () => `
108
+ <form class="response-questionnaire" data-safe-path="/f">
109
+ <div class="question" data-question-id="Q0" data-conditioned="false">
110
+ ${radioCollection("Q0", [{ optionId: "optA", value: "A" }, { optionId: "optC", value: "C" }])}
111
+ </div>
112
+ <div class="question" data-question-id="QFILE" data-conditioned="true">
113
+ ${conditionTag({ id: "dcFile", type: "equal", condition: "Q0", option: "optC", mandatory: false })}
114
+ ${uploadMarkup()}
115
+ <input type="hidden" name="questionnaire[responses][QFILE][question_id]" value="QFILE" />
116
+ </div>
117
+ <div class="question" data-question-id="QSORT" data-conditioned="true">
118
+ ${conditionTag({ id: "dcSort", type: "equal", condition: "Q0", option: "optC", mandatory: false })}
119
+ ${sortingMarkup("QSORT")}
120
+ <input type="hidden" name="questionnaire[responses][QSORT][question_id]" value="QSORT" />
121
+ </div>
122
+ </form>
123
+ `;
124
+
125
+ const initUploads = () => initializeUploadFields(document.querySelectorAll("button[data-upload]"));
126
+ const initConditions = () =>
127
+ $(".response-questionnaire .question[data-conditioned='true']").each((idx, el) =>
128
+ createDisplayConditions({ wrapperField: $(el) })
129
+ );
130
+
131
+ const wrapper = (qid) => $(`.question[data-question-id='${qid}']`);
132
+ const selectOption = (qid, value) => {
133
+ const $input = wrapper(qid).find(`input[name$='[body]'][value='${value}']`);
134
+ $input.prop("checked", true);
135
+ $input.trigger("change");
136
+ };
137
+ // What the browser would actually submit: enabled fields only.
138
+ const submitted = () =>
139
+ Array.from(document.querySelectorAll("form input:not([disabled])")).
140
+ filter((el) => el.name && (!["radio", "checkbox"].includes(el.type) || el.checked)).
141
+ map((el) => `${el.name}=${el.value}`);
142
+ // Any preview node holding the hidden add_attachments field, whether server
143
+ // rendered (.attachment-details) or re-rendered by updateActiveUploads (no class).
144
+ const previews = () => document.querySelectorAll(`[data-active-uploads='${MODAL_ID}'] > *`).length;
145
+ const serverRenderedPreviews = () => document.querySelectorAll(`[data-active-uploads='${MODAL_ID}'] .attachment-details`).length;
146
+ const modalItems = () => document.querySelectorAll(`#${MODAL_ID} [data-dropzone-items] > *`).length;
147
+ const buttonLabel = () => document.getElementById(`button-${MODAL_ID}`).innerHTML;
148
+ const positions = () =>
149
+ Array.from(document.querySelectorAll("[data-question-id='QSORT'] input[name$='[position]']")).map((el) => el.value);
150
+
151
+ describe("hidden questions and their responses", () => {
152
+ beforeEach(() => {
153
+ // UploadModal renders icons through window.Decidim.config
154
+ window.Decidim = { config: { get: () => "/icons.svg" } };
155
+ document.body.innerHTML = page();
156
+ });
157
+
158
+ describe("a file question hidden on page load", () => {
159
+ it("keeps the attachment and UploadModal in sync, whichever initializes first", () => {
160
+ initUploads();
161
+ initConditions();
162
+
163
+ expect(previews()).toBe(1);
164
+ expect(serverRenderedPreviews()).toBe(1);
165
+ expect(modalItems()).toBe(1);
166
+ expect(buttonLabel()).toBe("Add file");
167
+ // kept, but disabled, so it is not submitted while the question is hidden
168
+ expect(submitted().filter((field) => field.includes("add_attachments"))).toEqual([]);
169
+ });
170
+
171
+ it("behaves the same when the display conditions initialize first", () => {
172
+ initConditions();
173
+ initUploads();
174
+
175
+ expect(previews()).toBe(1);
176
+ expect(modalItems()).toBe(1);
177
+ expect(submitted().filter((field) => field.includes("add_attachments"))).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe("a file question hidden and shown again", () => {
182
+ it("submits the attachment it already had", () => {
183
+ initUploads();
184
+ initConditions();
185
+
186
+ selectOption("Q0", "C");
187
+
188
+ expect(previews()).toBe(1);
189
+ expect(submitted()).toContain("questionnaire[responses][QFILE][add_attachments]=99");
190
+ });
191
+
192
+ it("submits a file uploaded in this session, whose preview carries no attachment-details class", () => {
193
+ initUploads();
194
+ initConditions();
195
+ selectOption("Q0", "C");
196
+
197
+ // saving the modal makes updateActiveUploads() re-render the preview
198
+ document.getElementById(`button-${MODAL_ID}`).click();
199
+ document.querySelector(`#${MODAL_ID} [data-dropzone-save]`).click();
200
+ expect(serverRenderedPreviews()).toBe(0);
201
+
202
+ selectOption("Q0", "A");
203
+ selectOption("Q0", "C");
204
+
205
+ expect(previews()).toBe(1);
206
+ expect(submitted().filter((field) => field.includes("add_attachments"))).not.toEqual([]);
207
+ });
208
+ });
209
+
210
+ describe("a sorting question", () => {
211
+ it("keeps its order out of the submission while hidden, and restores it when shown", () => {
212
+ initUploads();
213
+ initConditions();
214
+
215
+ expect(positions()).toEqual(["1", "2", "3"]);
216
+ expect(submitted().filter((field) => field.includes("QSORT"))).toEqual([]);
217
+
218
+ selectOption("Q0", "C");
219
+
220
+ expect(positions()).toEqual(["1", "2", "3"]);
221
+ expect(submitted()).toContain("questionnaire[responses][QSORT][choices][0][position]=1");
222
+ });
223
+ });
224
+ });
@@ -12,7 +12,7 @@
12
12
  </span>
13
13
 
14
14
  <span class="ml-auto flex flex-row-reverse items-center gap-2">
15
- <button type="button" class="question--collapse <%= "question-error" if is_expanded %>" data-controls="panel-<%= id %>-question-card">
15
+ <button type="button" class="question--collapse" data-controls="panel-<%= id %>-question-card" data-open="<%= is_expanded %>">
16
16
  <span class="icon-collapse">
17
17
  <%== icon("arrow-up-s-line", aria_label: t(".collapse"), role: "img") %>
18
18
  </span>
@@ -44,6 +44,7 @@ de:
44
44
  allow_editing_responses: Registrierten Benutzern die Bearbeitung eigener Umfrageantworten ermöglichen
45
45
  allow_responses: Antworten erlauben
46
46
  allow_unregistered: Teilnahme an der Umfrage ohne Registrierung erlauben
47
+ allow_unregistered_help: Wenn aktiviert, ist keine Anmeldung erforderlich, um die Umfrage zu beantworten. Dies kann zu ungenauen oder unzuverlässigen Daten führen und macht die Umfrage anfälliger für automatisierte Angriffe. Mit Vorsicht verwenden! Beachten Sie, dass dieselbe Umfrage von einer teilnehmenden Person mehrfach beantwortet werden kann, etwa durch die Verwendung unterschiedlicher Browser oder der Funktion "Privates Surfen" des Webbrowsers.
47
48
  announcement: Ankündigung
48
49
  clean_after_publish: Antworten beim Veröffentlichen der Umfrage löschen
49
50
  description: Beschreibung
@@ -31,7 +31,7 @@ eu:
31
31
  responses:
32
32
  id: Erantzunaren identifikatzaile bakarra
33
33
  question: Erantzun den galdera
34
- questionnaire: Erantzun zen galdetegia
34
+ questionnaire: Galdetegia erantzuna izan da
35
35
  response: Galderaren erantzuna
36
36
  user: Inkesta erantzun duen parte-hartzailea
37
37
  show:
@@ -48,9 +48,9 @@ eu:
48
48
  announcement: Oharra
49
49
  clean_after_publish: Ezabatu erantzunak inkesta argitaratzean
50
50
  description: Deskribapena
51
- ends_at: Erantzunak noiz arte onartuta
51
+ ends_at: Erantzunak noiz arte onartua
52
52
  ends_at_help: Utzi zuriz data zehatzik ez badago
53
- starts_at: Erantzunak noiztik onartuta
53
+ starts_at: Erantzunak noiztik onartua
54
54
  starts_at_help: Zuriz utzi data zehatzik ez badago
55
55
  tos: Zerbitzu-baldintzak
56
56
  questionnaires:
@@ -70,7 +70,7 @@ eu:
70
70
  condition_value: Testua barne
71
71
  display_condition: Bistaratzeko baldintza
72
72
  mandatory: Baldintza hau beti bete behar da, beste baldintza batzuen egoera edozein dela ere
73
- remove: Kendu
73
+ remove: Ezabatu
74
74
  response_option: Erantzuteko aukera
75
75
  save_warning: Gogoratu formularioa gorde behar duzula bistaratzeko baldintzak konfiguratu aurretik
76
76
  select_condition_question: Hautatu galdera bat
@@ -78,7 +78,7 @@ eu:
78
78
  select_response_option: Hautatu erantzuteko aukera
79
79
  edit:
80
80
  save: Gorde
81
- title: Galdetegia editau
81
+ title: Galdetegia editatu
82
82
  edit_questions:
83
83
  add_question: Gehitu galdera
84
84
  add_separator: Gehitu banatzailea
@@ -89,11 +89,11 @@ eu:
89
89
  add_question: Gehitu galdera
90
90
  collapse: Bildu galdera guztiak
91
91
  expand: Zabaldu galdera guztiak
92
- preview: Aurreikusi
92
+ preview: Aurrebistaratu
93
93
  title: Editatu galdetegia honetarako %{questionnaire_for}
94
94
  matrix_row:
95
95
  matrix_row: Errenkada
96
- remove: Kendu
96
+ remove: Ezabatu
97
97
  statement: Adierazpena
98
98
  question:
99
99
  add_display_condition: Bistaratzeko baldintza gehitu
@@ -105,7 +105,7 @@ eu:
105
105
  description: Deskribapena
106
106
  expand: Zabaldu
107
107
  question: Galdera
108
- remove: Kendu
108
+ remove: Ezabatu
109
109
  statement: Adierazpena
110
110
  questions_form:
111
111
  already_responded_warning: Formularioa parte-hartzaile batzuek erantzuten dute dagoeneko; beraz, ezin dituzu galderak aldatu.
@@ -116,7 +116,7 @@ eu:
116
116
  success: Inkestaren galderak zuzen gordeta.
117
117
  response_option:
118
118
  free_text: Testu librea
119
- remove: Kendu
119
+ remove: Ezabatu
120
120
  response_option: Erantzuteko aukera
121
121
  statement: Adierazpena
122
122
  responses:
@@ -137,7 +137,7 @@ eu:
137
137
  show:
138
138
  title: '#%{number} erantzun'
139
139
  separator:
140
- remove: Kendu
140
+ remove: Ezabatu
141
141
  separator: Bereizlea
142
142
  title_and_description:
143
143
  collapse: Kolapsoa
@@ -7,27 +7,136 @@ gl:
7
7
  question_type: Tipo
8
8
  questionnaire_question:
9
9
  mandatory: Obrigatorio
10
+ max_characters: Límite de caracteres (deixa 0 se non hai límite)
11
+ response:
12
+ body: Resposta
13
+ choices: Opcións
14
+ selected_choices: Opcións seleccionadas
15
+ errors:
16
+ models:
17
+ questionnaire:
18
+ request_invalid: Produciuse un problema ao procesar a solicitude. Téntao de novo.
19
+ response:
20
+ attributes:
21
+ add_documents:
22
+ needs_to_be_reattached: Debe anexarse de novo
23
+ body:
24
+ too_long: é demasiado longo
25
+ choices:
26
+ missing: non están completas
27
+ too_many: Podes escoller un máximo de %{count}.
10
28
  decidim:
29
+ download_your_data:
30
+ help:
31
+ responses:
32
+ id: O identificador único da resposta
33
+ question: A pregunta que se respondeu
34
+ questionnaire: O cuestionario que se respondeu
35
+ response: A resposta á pregunta
36
+ user: A persoa participante que respondeu ao cuestionario
37
+ show:
38
+ responses: Exportación de respostas
39
+ survey_user_responses: Respostas das persoas participantes á enquisa
11
40
  forms:
12
41
  admin:
13
42
  models:
14
43
  components:
44
+ allow_editing_responses: Permitir que as persoas participantes rexistradas editen as súas propias respostas á enquisa
45
+ allow_responses: Permitir respostas
46
+ allow_unregistered: Permitir que persoas participantes non rexistradas respondan á enquisa
47
+ allow_unregistered_help: Se está activado, non será necesario iniciar sesión para responder á enquisa. Isto pode producir datos de baixa calidade ou pouco fiables e aumentar a vulnerabilidade fronte a ataques automatizados. Úsao con precaución. Ten en conta que unha persoa participante podería responder á mesma enquisa varias veces usando navegadores distintos ou a función de «navegación privada» do navegador.
48
+ announcement: Aviso
15
49
  description: Descrición
50
+ ends_at: Respostas aceptadas ata
51
+ ends_at_help: Déixao en branco se non hai unha data específica
52
+ starts_at: Respostas aceptadas desde
53
+ starts_at_help: Déixao en branco se non hai unha data específica
16
54
  tos: Condicións de servicio
17
55
  questionnaires:
56
+ actions:
57
+ back: Volver ás respostas
58
+ publish_responses: Publicar respostas
59
+ show: Respostas
60
+ display_condition:
61
+ condition_question: Pregunta
62
+ condition_type: Condición
63
+ condition_types:
64
+ equal: Igual
65
+ match: Inclúe texto
66
+ not_equal: Distinto
67
+ not_responded: Sen responder
68
+ responded: Respondida
69
+ condition_value: Texto incluído
70
+ display_condition: Condición de visualización
71
+ mandatory: Esta condición debe cumprirse sempre, independentemente do estado das demais condicións
72
+ remove: Eliminar
73
+ response_option: Opción de resposta
74
+ save_warning: Lembra gardar o formulario antes de configurar as condicións de visualización
75
+ select_condition_question: Seleccionar unha pregunta
76
+ select_condition_type: Seleccionar un tipo de condición
77
+ select_response_option: Seleccionar unha opción de resposta
18
78
  edit:
19
79
  save: Gardar
80
+ title: Editar cuestionario
81
+ edit_questions:
82
+ add_question: Engadir pregunta
83
+ add_separator: Engadir separador
84
+ add_title_and_description: Engadir título e descrición
85
+ save: Gardar
86
+ title: Preguntas
20
87
  form:
21
88
  add_question: Engadir pregunta
89
+ collapse: Contraer todas as preguntas
90
+ expand: Expandir todas as preguntas
91
+ preview: Vista previa
92
+ title: Editar o formulario de %{questionnaire_for}
22
93
  matrix_row:
94
+ matrix_row: Fila
23
95
  remove: Quitar
24
96
  statement: Declaración
25
97
  question:
98
+ add_display_condition: Engadir condición de visualización
99
+ add_display_condition_info: Garda o formulario para configurar as condicións de visualización
100
+ add_matrix_row: Engadir fila
101
+ add_response_option: Engadir opción de resposta
26
102
  any: Calquera
103
+ collapse: Contraer
27
104
  description: Descrición
105
+ expand: Expandir
28
106
  question: Pregunta
29
107
  remove: Quitar
30
108
  statement: Declaración
109
+ questions_form:
110
+ already_responded_warning: O formulario xa foi respondido por algunhas persoas participantes, polo que non podes modificar as súas preguntas.
111
+ collapse: Contraer todas as preguntas
112
+ expand: Expandir todas as preguntas
113
+ unpublished_warning: O formulario non está publicado. Podes modificar as súas preguntas, pero ao facelo eliminaranse as respostas actuais.
114
+ update:
115
+ success: As preguntas da enquisa gardáronse correctamente.
116
+ response_option:
117
+ free_text: Texto libre
118
+ remove: Eliminar
119
+ response_option: Opción de resposta
120
+ statement: Declaración
121
+ responses:
122
+ actions:
123
+ back: Volver ás respostas
124
+ export: Exportar
125
+ next: Seguinte &rsaquo;
126
+ previous: "&lsaquo; Anterior"
127
+ empty: Aínda non hai respostas
128
+ export:
129
+ response:
130
+ title: 'Resposta n.º %{number}'
131
+ export_response:
132
+ title: survey_participant_responses_%{token}
133
+ index:
134
+ title: "%{total} respostas en total"
135
+ show:
136
+ title: 'Resposta n.º %{number}'
137
+ separator:
138
+ remove: Eliminar
139
+ separator: Separador
31
140
  title_and_description:
32
141
  collapse: Contraer
33
142
  description: Descrición
@@ -37,25 +146,83 @@ gl:
37
146
  title_and_description: Título e descrición
38
147
  update:
39
148
  invalid: Produciuse un erro ao gardar o cuestionario.
149
+ success: O formulario gardouse correctamente.
150
+ admin_log:
151
+ question:
152
+ publish_responses: "%{user_name} publicou as respostas da pregunta %{resource_name} no espazo %{space_name}"
153
+ unpublish_responses: "%{user_name} retirou a publicación das respostas da pregunta %{resource_name} no espazo %{space_name}"
154
+ questionnaire:
155
+ update: "%{user_name} actualizou o cuestionario %{resource_name}"
156
+ errors:
157
+ response:
158
+ body: O corpo non pode estar baleiro
40
159
  images:
41
160
  processors:
42
161
  resize_and_pad: A imaxe axustarase para se encadrar a %{dimensions}.
43
162
  resize_to_fit: A imaxe axustarase para se adaptar a %{dimensions}.
44
163
  question_types:
164
+ files: Arquivos
165
+ long_response: Resposta longa
166
+ matrix_multiple: Matriz (opción múltiple)
167
+ matrix_single: Matriz (opción única)
45
168
  multiple_option: Múltipla opción
169
+ short_response: Resposta curta
46
170
  single_option: Opción única
47
171
  sorting: Clasificación
48
172
  title_and_description: Título e descrición
173
+ questionnaire_response_presenter:
174
+ download_attachment: Descargar anexo
49
175
  questionnaires:
50
176
  question:
51
177
  max_choices: 'Opcións máximas: %{n}'
178
+ response:
179
+ invalid: Produciuse un problema ao responder ao formulario.
180
+ max_choices_alert: Seleccionáronse demasiadas opcións
181
+ success: O formulario respondeuse correctamente.
52
182
  show:
183
+ current_step: Paso %{step}
184
+ empty: Aínda non hai preguntas configuradas para este formulario.
185
+ of_total_steps: de %{total_steps}
53
186
  questionnaire_closed:
187
+ body: O formulario está pechado e non se pode responder.
54
188
  title: Cuestionario pechado
55
189
  questionnaire_for_private_users:
56
190
  body: O cuestionario só está dispoñible para usuarios privados
57
191
  title: Cuestionario pechado
192
+ questionnaire_js_disabled:
193
+ body: Algunhas funcións deste formulario estarán desactivadas. Para mellorar a experiencia, activa JavaScript no navegador.
194
+ title: JavaScript está desactivado
195
+ questionnaire_not_published:
196
+ body: Este formulario aínda non está publicado.
197
+ questionnaire_responded:
198
+ body: Xa respondiches a este formulario.
199
+ title: Xa respondido
200
+ questionnaire_responded_edit:
201
+ body: Xa respondiches a este formulario. %{link}
202
+ edit: Editar as túas respostas
203
+ response_questionnaire:
204
+ already_have_an_account?: Xa ten unha conta?
205
+ are_you_new?: Nova persoa participante?
206
+ sign_in_description: Inicia sesión para responder á enquisa
207
+ sign_up_description: Crea unha conta de participante para responder á enquisa
208
+ title: Responder ao formulario
58
209
  tos_agreement: Ao participar aceptas as Condicións de servizo
59
210
  step_navigation:
60
211
  show:
212
+ are_you_sure_edit_guest: Se queres poder editar as túas respostas máis adiante, debes iniciar sesión ou crear unha conta.
213
+ are_you_sure_no_edit: Esta acción non se pode desfacer e non poderás editar as túas respostas. Seguro que queres continuar?
214
+ back: Volver
215
+ continue: Continuar
216
+ disallowed: Non tes permiso para editar as túas respostas.
61
217
  submit: Enviar
218
+ user_responses_serializer:
219
+ body: Resposta
220
+ completion: Completado
221
+ created_at: Respondida o
222
+ id: ID da resposta
223
+ ip_hash: Hash da IP
224
+ question: Pregunta
225
+ registered: Rexistrada
226
+ session_token: Identificador da persoa participante
227
+ unregistered: Non rexistrada
228
+ user_status: Estado da persoa participante
@@ -263,6 +263,7 @@ shared_examples_for "add questions" do
263
263
  select "Long response", from: "Type"
264
264
  click_on "Save"
265
265
 
266
+ expect(page).to have_css(".panel-error", visible: :visible)
266
267
  expand_all_questions
267
268
  expect(page).to have_select("Type", selected: "Long response", wait: 10)
268
269
  end
@@ -280,6 +281,7 @@ shared_examples_for "add questions" do
280
281
  select "Long response", from: "Type"
281
282
 
282
283
  click_on "Save"
284
+ expect(page).to have_css(".panel-error", visible: :visible)
283
285
  expand_all_questions
284
286
 
285
287
  expect(page).to have_text("Type")
@@ -303,6 +305,7 @@ shared_examples_for "add questions" do
303
305
  select "Long response", from: "Type"
304
306
 
305
307
  click_on "Save"
308
+ expect(page).to have_css(".panel-error", visible: :visible)
306
309
  expand_all_questions
307
310
 
308
311
  expect(page).to have_text("Type")
@@ -332,6 +335,7 @@ shared_examples_for "add questions" do
332
335
  select "3", from: "Maximum number of choices"
333
336
 
334
337
  click_on "Save"
338
+ expect(page).to have_css(".panel-error", visible: :visible)
335
339
  expand_all_questions
336
340
 
337
341
  expect(page).to have_css(".questionnaire-question-response-option")
@@ -359,6 +363,7 @@ shared_examples_for "add questions" do
359
363
  click_on "Add row"
360
364
 
361
365
  click_on "Save"
366
+ expect(page).to have_css(".panel-error", visible: :visible)
362
367
  expand_all_questions
363
368
 
364
369
  within ".questionnaire-question-matrix-row:first-of-type" do
@@ -45,6 +45,7 @@ shared_examples_for "update questions" do
45
45
  end
46
46
 
47
47
  click_on "Save"
48
+ expect(page).to have_css(".panel-error", visible: :visible)
48
49
  click_on "Expand all questions"
49
50
 
50
51
  expect(page).to have_admin_callout("There was a problem saving")
@@ -512,6 +513,7 @@ shared_examples_for "update questions" do
512
513
  end
513
514
 
514
515
  click_on "Save"
516
+ expect(page).to have_css(".panel-error", visible: :visible)
515
517
  expand_all_questions
516
518
 
517
519
  within ".questionnaire-question:last-of-type" do
@@ -4,7 +4,7 @@ module Decidim
4
4
  # This holds the decidim-forms version.
5
5
  module Forms
6
6
  def self.version
7
- "0.31.7"
7
+ "0.31.8"
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: decidim-forms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.7
4
+ version: 0.31.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josep Jaume Rey Peroy
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2026-07-30 00:00:00.000000000 Z
14
+ date: 2026-10-22 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: decidim-core
@@ -19,42 +19,42 @@ dependencies:
19
19
  requirements:
20
20
  - - '='
21
21
  - !ruby/object:Gem::Version
22
- version: 0.31.7
22
+ version: 0.31.8
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - '='
28
28
  - !ruby/object:Gem::Version
29
- version: 0.31.7
29
+ version: 0.31.8
30
30
  - !ruby/object:Gem::Dependency
31
31
  name: decidim-admin
32
32
  requirement: !ruby/object:Gem::Requirement
33
33
  requirements:
34
34
  - - '='
35
35
  - !ruby/object:Gem::Version
36
- version: 0.31.7
36
+ version: 0.31.8
37
37
  type: :development
38
38
  prerelease: false
39
39
  version_requirements: !ruby/object:Gem::Requirement
40
40
  requirements:
41
41
  - - '='
42
42
  - !ruby/object:Gem::Version
43
- version: 0.31.7
43
+ version: 0.31.8
44
44
  - !ruby/object:Gem::Dependency
45
45
  name: decidim-dev
46
46
  requirement: !ruby/object:Gem::Requirement
47
47
  requirements:
48
48
  - - '='
49
49
  - !ruby/object:Gem::Version
50
- version: 0.31.7
50
+ version: 0.31.8
51
51
  type: :development
52
52
  prerelease: false
53
53
  version_requirements: !ruby/object:Gem::Requirement
54
54
  requirements:
55
55
  - - '='
56
56
  - !ruby/object:Gem::Version
57
- version: 0.31.7
57
+ version: 0.31.8
58
58
  description: A forms gem for decidim.
59
59
  email:
60
60
  - josepjaume@gmail.com
@@ -117,6 +117,8 @@ files:
117
117
  - app/packs/src/decidim/forms/admin/live_text_update.component.js
118
118
  - app/packs/src/decidim/forms/admin/publish_responses_buttons.js
119
119
  - app/packs/src/decidim/forms/display_conditions.component.js
120
+ - app/packs/src/decidim/forms/display_conditions.component.test.js
121
+ - app/packs/src/decidim/forms/display_conditions_hidden_responses.test.js
120
122
  - app/packs/src/decidim/forms/forms.js
121
123
  - app/packs/src/decidim/forms/max_choices_alert.component.js
122
124
  - app/packs/src/decidim/forms/option_attached_inputs.component.js