decidim-core 0.27.0 → 0.27.2

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of decidim-core might be problematic. Click here for more details.

Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/amendable/announcement_cell.rb +1 -1
  3. data/app/cells/decidim/card_m_cell.rb +1 -1
  4. data/app/cells/decidim/newsletter_templates/base_cell.rb +8 -0
  5. data/app/cells/decidim/newsletter_templates/basic_only_text/show.erb +4 -4
  6. data/app/cells/decidim/newsletter_templates/image_text_cta/show.erb +4 -4
  7. data/app/cells/decidim/upload_modal_cell.rb +12 -7
  8. data/app/commands/decidim/unendorse_resource.rb +1 -1
  9. data/app/controllers/decidim/devise/invitations_controller.rb +9 -2
  10. data/app/controllers/decidim/groups_controller.rb +5 -0
  11. data/app/controllers/decidim/last_activities_controller.rb +5 -2
  12. data/app/controllers/decidim/links_controller.rb +4 -2
  13. data/app/controllers/decidim/profiles_controller.rb +1 -1
  14. data/app/forms/decidim/account_form.rb +2 -2
  15. data/app/forms/decidim/amendable/form.rb +2 -1
  16. data/app/forms/decidim/registration_form.rb +2 -2
  17. data/app/forms/decidim/upload_validation_form.rb +51 -7
  18. data/app/helpers/decidim/icon_helper.rb +3 -3
  19. data/app/helpers/decidim/layout_helper.rb +12 -4
  20. data/app/helpers/decidim/newsletters_helper.rb +1 -0
  21. data/app/helpers/decidim/sanitize_helper.rb +1 -1
  22. data/app/mailers/decidim/newsletter_mailer.rb +10 -3
  23. data/app/mailers/decidim/notification_mailer.rb +1 -0
  24. data/app/mailers/decidim/notifications_digest_mailer.rb +1 -0
  25. data/app/models/decidim/newsletter.rb +28 -0
  26. data/app/models/decidim/user.rb +0 -2
  27. data/app/models/decidim/user_base_entity.rb +2 -0
  28. data/app/models/decidim/user_block.rb +2 -2
  29. data/app/models/decidim/user_group.rb +1 -1
  30. data/app/packs/src/decidim/editor/clipboard_override.js +143 -0
  31. data/app/packs/src/decidim/editor/clipboard_utilities.js +119 -0
  32. data/app/packs/src/decidim/editor/linebreak_module.js +0 -8
  33. data/app/packs/src/decidim/editor.js +9 -2
  34. data/app/packs/src/decidim/form_filter.component.test.js +148 -5
  35. data/app/packs/src/decidim/form_filter.js +26 -4
  36. data/app/packs/stylesheets/decidim/_editor.scss +129 -0
  37. data/app/packs/stylesheets/decidim/email.scss +7 -0
  38. data/app/packs/stylesheets/decidim/extras/_quill.scss +0 -6
  39. data/app/presenters/decidim/admin_log/user_group_presenter.rb +1 -1
  40. data/app/presenters/decidim/admin_log/user_moderation_presenter.rb +1 -1
  41. data/app/presenters/decidim/home_stats_presenter.rb +11 -4
  42. data/app/presenters/decidim/push_notification_presenter.rb +1 -1
  43. data/app/presenters/decidim/stats_presenter.rb +7 -8
  44. data/app/presenters/decidim/user_presenter.rb +9 -4
  45. data/app/queries/decidim/public_activities.rb +1 -0
  46. data/app/uploaders/decidim/application_uploader.rb +1 -1
  47. data/app/uploaders/decidim/avatar_uploader.rb +2 -2
  48. data/app/validators/etiquette_validator.rb +7 -3
  49. data/app/validators/file_content_type_validator.rb +103 -0
  50. data/app/validators/passthru_validator.rb +11 -0
  51. data/app/validators/uploader_content_type_validator.rb +22 -0
  52. data/app/views/decidim/messaging/conversations/_conversation.html.erb +1 -1
  53. data/app/views/decidim/newsletter_mailer/newsletter.html.erb +3 -3
  54. data/app/views/decidim/newsletters/show.html.erb +1 -1
  55. data/app/views/decidim/notification_mailer/event_received.html.erb +1 -1
  56. data/app/views/decidim/notifications_digest_mailer/_email_content.html.erb +1 -1
  57. data/app/views/layouts/decidim/_mailer_logo.html.erb +2 -2
  58. data/app/views/layouts/decidim/newsletter_base.html.erb +2 -2
  59. data/config/locales/ar.yml +5 -17
  60. data/config/locales/bg.yml +5 -17
  61. data/config/locales/ca.yml +20 -24
  62. data/config/locales/cs.yml +12 -17
  63. data/config/locales/de.yml +2 -18
  64. data/config/locales/el.yml +4 -18
  65. data/config/locales/en.yml +11 -15
  66. data/config/locales/es-MX.yml +13 -17
  67. data/config/locales/es-PY.yml +13 -17
  68. data/config/locales/es.yml +22 -26
  69. data/config/locales/eu.yml +28 -35
  70. data/config/locales/fi-plain.yml +11 -15
  71. data/config/locales/fi.yml +12 -16
  72. data/config/locales/fr-CA.yml +11 -18
  73. data/config/locales/fr.yml +11 -18
  74. data/config/locales/ga-IE.yml +0 -2
  75. data/config/locales/gl.yml +2 -17
  76. data/config/locales/gn-PY.yml +1 -0
  77. data/config/locales/hu.yml +4 -18
  78. data/config/locales/id-ID.yml +5 -17
  79. data/config/locales/is-IS.yml +0 -1
  80. data/config/locales/it.yml +1 -18
  81. data/config/locales/ja.yml +25 -29
  82. data/config/locales/ka-GE.yml +1 -0
  83. data/config/locales/lb.yml +0 -17
  84. data/config/locales/lo-LA.yml +1 -0
  85. data/config/locales/lt.yml +0 -17
  86. data/config/locales/lv.yml +5 -17
  87. data/config/locales/nl.yml +0 -17
  88. data/config/locales/no.yml +2 -19
  89. data/config/locales/pl.yml +4 -18
  90. data/config/locales/pt-BR.yml +0 -17
  91. data/config/locales/pt.yml +0 -17
  92. data/config/locales/ro-RO.yml +49 -16
  93. data/config/locales/ru.yml +5 -3
  94. data/config/locales/sk.yml +5 -17
  95. data/config/locales/sv.yml +22 -18
  96. data/config/locales/tr-TR.yml +4 -18
  97. data/config/locales/uk.yml +5 -1
  98. data/config/locales/zh-CN.yml +3 -17
  99. data/lib/decidim/api/types/localized_string_type.rb +9 -0
  100. data/lib/decidim/api/types/translated_field_type.rb +20 -5
  101. data/lib/decidim/asset_router/pipeline.rb +93 -0
  102. data/lib/decidim/asset_router/storage.rb +82 -0
  103. data/lib/decidim/asset_router.rb +3 -75
  104. data/lib/decidim/attribute_object/form.rb +9 -0
  105. data/lib/decidim/attributes/localized_date.rb +1 -1
  106. data/lib/decidim/attributes/time_with_zone.rb +5 -2
  107. data/lib/decidim/core/engine.rb +7 -5
  108. data/lib/decidim/core/test/factories.rb +13 -6
  109. data/lib/decidim/core/test/shared_examples/comments_examples.rb +1 -1
  110. data/lib/decidim/core/test/shared_examples/editor_shared_examples.rb +30 -0
  111. data/lib/decidim/core/test/shared_examples/mcell_examples.rb +17 -0
  112. data/lib/decidim/core/test.rb +2 -0
  113. data/lib/decidim/core/version.rb +1 -1
  114. data/lib/decidim/dependency_resolver.rb +14 -8
  115. data/lib/decidim/file_validator_humanizer.rb +1 -1
  116. data/lib/decidim/form_builder.rb +11 -4
  117. data/lib/decidim/participatory_space_resourceable.rb +7 -1
  118. data/lib/decidim/resourceable.rb +5 -4
  119. data/lib/decidim/settings_manifest.rb +1 -1
  120. metadata +17 -8
  121. data/app/packs/images/decidim/gamification/badges/decidim_gamification_badges_invitations.svg +0 -1
@@ -0,0 +1,143 @@
1
+ /* eslint max-lines: ["error", 350] */
2
+
3
+ /**
4
+ * Quill clipboard utilities
5
+ *
6
+ * Copyright (c) 2017, Slab
7
+ * Copyright (c) 2014, Jason Chen
8
+ * Copyright (c) 2013, salesforce.com
9
+ * BSD 3-Clause "New" or "Revised" License
10
+ *
11
+ * Extends the original version from https://github.com/quilljs/quill
12
+ * Relevant parts converted from TypeScript to JavaScript
13
+ */
14
+
15
+ import CodeBlock from "quill/formats/code";
16
+ import { matchNewline, matchBreak, deltaEndsWith, traverse } from "src/decidim/editor/clipboard_utilities";
17
+
18
+ const Delta = Quill.import("delta");
19
+ const Clipboard = Quill.import("modules/clipboard");
20
+
21
+ /**
22
+ * Pasting bold text is broken in Quill as described at:
23
+ * https://github.com/quilljs/quill/issues/306
24
+ *
25
+ * The reason is that the `<strong>` nodes are not recognized as bold types.
26
+ * This override fixes the issue by introducing parts of the newer Quill code
27
+ * at GitHub and defining the `<strong>` tags as bold tags.
28
+ */
29
+ export default class ClipboardOverride extends Clipboard {
30
+ constructor(quill, options) {
31
+ super(quill, options);
32
+ this.overrideMatcher("b", "b, strong");
33
+ this.overrideMatcher("br", "br", matchBreak);
34
+
35
+ // Change the matchNewLine matchers to the newer version
36
+ this.matchers[1][1] = matchNewline;
37
+ this.matchers[3][1] = matchNewline;
38
+
39
+ // Remove `matchSpacing` as that is also removed in the newer versions.
40
+ this.removeMatcher(Node.ELEMENT_NODE, "matchSpacing");
41
+ }
42
+
43
+ overrideMatcher(originalSelector, newSelector, newMatcher = null) {
44
+ const idx = this.matchers.findIndex((item) => item[0] === originalSelector);
45
+ if (idx >= 0) {
46
+ this.matchers[idx][0] = newSelector;
47
+ if (newMatcher) {
48
+ this.matchers[idx][1] = newMatcher;
49
+ }
50
+ }
51
+ }
52
+
53
+ removeMatcher(selector, matcherName) {
54
+ const idx = this.matchers.findIndex((item) => item[0] === selector && item[1].name === matcherName);
55
+ if (idx >= 0) {
56
+ this.matchers.splice(idx, 1);
57
+ }
58
+ }
59
+
60
+ onPaste(ev) {
61
+ if (ev.defaultPrevented || !this.quill.isEnabled()) {
62
+ return;
63
+ }
64
+ ev.preventDefault();
65
+ const range = this.quill.getSelection(true);
66
+ if (range === null) {
67
+ return;
68
+ }
69
+ const html = ev.clipboardData.getData("text/html");
70
+ const text = ev.clipboardData.getData("text/plain");
71
+ const files = Array.from(ev.clipboardData.files || []);
72
+ if (!html && files.length > 0) {
73
+ this.quill.uploader.upload(range, files);
74
+ return;
75
+ }
76
+ if (html && files.length > 0) {
77
+ const doc = new DOMParser().parseFromString(html, "text/html");
78
+ if (
79
+ doc.body.childElementCount === 1 &&
80
+ doc.body.firstElementChild.tagName === "IMG"
81
+ ) {
82
+ this.quill.uploader.upload(range, files);
83
+ return;
84
+ }
85
+ }
86
+ this.onPasteRange(range, { html, text });
87
+ }
88
+
89
+ onPasteRange(range, { text, html }) {
90
+ const formats = this.quill.getFormat(range.index);
91
+ const pastedDelta = this.convertPaste({ text, html }, formats);
92
+ // debug.log('onPaste", pastedDelta, { text, html });
93
+ const delta = new Delta().retain(range.index).delete(range.length).concat(pastedDelta);
94
+ this.quill.updateContents(delta, Quill.sources.USER);
95
+ // range.length contributes to delta.length()
96
+ this.quill.setSelection(
97
+ delta.length() - range.length,
98
+ Quill.sources.SILENT,
99
+ );
100
+ this.quill.scrollIntoView();
101
+ }
102
+
103
+ convertPaste({ html, text }, formats = {}) {
104
+ if (formats[CodeBlock.blotName]) {
105
+ return new Delta().insert(text, {
106
+ [CodeBlock.blotName]: formats[CodeBlock.blotName]
107
+ });
108
+ }
109
+ if (!html) {
110
+ return new Delta().insert(text || "");
111
+ }
112
+ const delta = this.convertPasteHTML(html);
113
+ // Remove trailing newline
114
+ if (
115
+ deltaEndsWith(delta, "\n") &&
116
+ (delta.ops[delta.ops.length - 1].attributes === null || formats.table)
117
+ ) {
118
+ return delta.compose(new Delta().retain(delta.length() - 1).delete(1));
119
+ }
120
+ return delta;
121
+ }
122
+
123
+ convertPasteHTML(html) {
124
+ const doc = new DOMParser().parseFromString(html, "text/html");
125
+ const container = doc.body;
126
+ const nodeMatches = new WeakMap();
127
+ const [elementMatchers, textMatchers] = this.prepareMatching(
128
+ container,
129
+ nodeMatches
130
+ );
131
+ return traverse(
132
+ this.quill.scroll,
133
+ container,
134
+ elementMatchers,
135
+ textMatchers,
136
+ nodeMatches
137
+ );
138
+ }
139
+ }
140
+
141
+ // Disable warning messages from overwritting modules
142
+ Quill.debug("error");
143
+ Quill.register({"modules/clipboard": ClipboardOverride}, true);
@@ -0,0 +1,119 @@
1
+ import { BlockEmbed } from "quill/blots/block";
2
+
3
+ const Delta = Quill.import("delta");
4
+ const Parchment = Quill.import("parchment");
5
+
6
+ // Newer version used only for the pasting, not compatible with the version of
7
+ // Quill in use.
8
+ const traverse = (scroll, node, elementMatchers, textMatchers, nodeMatches) => { // eslint-disable-line max-params
9
+ // Post-order
10
+ if (node.nodeType === node.TEXT_NODE) {
11
+ return textMatchers.reduce((delta, matcher) => {
12
+ return matcher(node, delta, scroll);
13
+ }, new Delta());
14
+ }
15
+ if (node.nodeType === node.ELEMENT_NODE) {
16
+ return Array.from(node.childNodes || []).reduce((delta, childNode) => {
17
+ let childrenDelta = traverse(
18
+ scroll,
19
+ childNode,
20
+ elementMatchers,
21
+ textMatchers,
22
+ nodeMatches,
23
+ );
24
+ if (childNode.nodeType === node.ELEMENT_NODE) {
25
+ childrenDelta = elementMatchers.reduce((reducedDelta, matcher) => {
26
+ return matcher(childNode, reducedDelta, scroll);
27
+ }, childrenDelta);
28
+ childrenDelta = (nodeMatches.get(childNode) || []).reduce(
29
+ (reducedDelta, matcher) => {
30
+ return matcher(childNode, reducedDelta, scroll);
31
+ },
32
+ childrenDelta,
33
+ );
34
+ }
35
+ return delta.concat(childrenDelta);
36
+ }, new Delta());
37
+ }
38
+ return new Delta();
39
+ }
40
+
41
+ const deltaEndsWith = (delta, text) => {
42
+ let endText = "";
43
+ for (let idx = delta.ops.length - 1; idx >= 0 && endText.length < text.length; idx -= 1) {
44
+ const op = delta.ops[idx];
45
+ if (typeof op.insert !== "string") {
46
+ break;
47
+ }
48
+ endText = op.insert + endText;
49
+ }
50
+ return endText.slice(-1 * text.length) === text;
51
+ }
52
+
53
+ const isLine = (node) => {
54
+ if (node.childNodes.length === 0) {
55
+ // Exclude embed blocks
56
+ return false;
57
+ }
58
+ return [
59
+ "address", "article", "blockquote", "canvas", "dd", "div", "dl", "dt",
60
+ "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3",
61
+ "h4", "h5", "h6", "header", "iframe", "li", "main", "nav", "ol", "output",
62
+ "p", "pre", "section", "table", "td", "tr", "ul", "video"
63
+ ].includes(node.tagName.toLowerCase());
64
+ }
65
+
66
+ const matchNewLineScroll = (nextSibling, delta, scroll) => {
67
+ if (!scroll) {
68
+ return null;
69
+ }
70
+
71
+ const match = Parchment.query(nextSibling)
72
+ if (match && match.prototype instanceof BlockEmbed) {
73
+ return delta.insert("\n");
74
+ }
75
+ return null;
76
+ }
77
+
78
+ const matchNewline = (node, delta, scroll) => {
79
+ if (!deltaEndsWith(delta, "\n")) {
80
+ // When scroll is defined, it was initiated from the paste event. Otherwise
81
+ // it is a normal Quill initiated traversal which handles adding the line
82
+ // breaks already.
83
+ if (scroll && node.nodeType === node.ELEMENT_NODE && node.tagName === "BR") {
84
+ return delta.insert({"break": ""});
85
+ }
86
+ if (isLine(node)) {
87
+ return delta.insert("\n");
88
+ }
89
+ if (delta.length() > 0 && node.nextSibling) {
90
+ let { nextSibling } = node;
91
+ while (nextSibling !== null) {
92
+ if (isLine(nextSibling)) {
93
+ return delta.insert("\n");
94
+ }
95
+ const scrollMatch = matchNewLineScroll(nextSibling, delta, scroll);
96
+ if (scrollMatch) {
97
+ return scrollMatch;
98
+ }
99
+ nextSibling = nextSibling.firstChild;
100
+ }
101
+ }
102
+ }
103
+ return delta;
104
+ }
105
+
106
+ const matchBreak = (node, delta) => {
107
+ if (!deltaEndsWith(delta, "\n")) {
108
+ delta.insert({"break": ""});
109
+ }
110
+ return delta;
111
+ }
112
+
113
+ export {
114
+ traverse,
115
+ deltaEndsWith,
116
+ isLine,
117
+ matchNewline,
118
+ matchBreak
119
+ }
@@ -129,7 +129,6 @@ class ScrollOvderride extends Scroll {
129
129
  Quill.register("blots/scroll", ScrollOvderride, true);
130
130
  Parchment.register(ScrollOvderride);
131
131
 
132
-
133
132
  export default function lineBreakButtonHandler(quill) {
134
133
  let range = quill.selection.getRange()[0];
135
134
  let currentLeaf = quill.getLeaf(range.index)[0];
@@ -167,13 +166,6 @@ Quill.register("modules/linebreak", (quill) => {
167
166
  }
168
167
  });
169
168
 
170
- quill.clipboard.addMatcher("BR", (node) => {
171
- if (node?.parentNode?.tagName === "A") {
172
- return new Delta().insert("\n");
173
- }
174
- return new Delta().insert({"break": ""});
175
- });
176
-
177
169
  addEnterBindings(quill);
178
170
  backspaceBindingsRangeAny(quill);
179
171
  backspaceBindings(quill);
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable require-jsdoc */
2
2
 
3
3
  import lineBreakButtonHandler from "src/decidim/editor/linebreak_module"
4
+ import "src/decidim/editor/clipboard_override"
4
5
  import "src/decidim/vendor/image-resize.min"
5
6
  import "src/decidim/vendor/image-upload.min"
6
7
 
@@ -10,6 +11,7 @@ export default function createQuillEditor(container) {
10
11
  const toolbar = $(container).data("toolbar");
11
12
  const disabled = $(container).data("disabled");
12
13
 
14
+ const allowedEmptyContentSelector = "iframe";
13
15
  let quillToolbar = [
14
16
  ["bold", "italic", "underline", "linebreak"],
15
17
  [{ list: "ordered" }, { list: "bullet" }],
@@ -93,10 +95,15 @@ export default function createQuillEditor(container) {
93
95
  });
94
96
  container.dispatchEvent(event);
95
97
 
96
- if (text === "\n" || text === "\n\n") {
98
+ if ((text === "\n" || text === "\n\n") && quill.root.querySelectorAll(allowedEmptyContentSelector).length === 0) {
97
99
  $input.val("");
98
100
  } else {
99
- $input.val(quill.root.innerHTML);
101
+ const emptyParagraph = "<p><br></p>";
102
+ const cleanHTML = quill.root.innerHTML.replace(
103
+ new RegExp(`^${emptyParagraph}|${emptyParagraph}$`, "g"),
104
+ ""
105
+ );
106
+ $input.val(cleanHTML);
100
107
  }
101
108
  });
102
109
  // After editor is ready, linebreak_module deletes two extraneous new lines
@@ -1,4 +1,4 @@
1
- /* global spyOn */
1
+ /* global spyOn, jest */
2
2
  /* eslint-disable id-length */
3
3
  window.$ = $;
4
4
 
@@ -7,6 +7,20 @@ import DataPicker from "./data_picker"
7
7
 
8
8
  const FormFilterComponent = require("./form_filter.component_for_testing.js");
9
9
 
10
+ const expectedPushState = (state, filters) => {
11
+ const queryString = Object.keys(filters).map((key) => {
12
+ const name = `filter[${key}]`;
13
+ const val = filters[key];
14
+ if (Array.isArray(val)) {
15
+ return val.map((v) => `${encodeURIComponent(`${name}[]`)}=${encodeURIComponent(v)}`).join("&");
16
+ }
17
+
18
+ return `${encodeURIComponent(name)}=${encodeURIComponent(val)}`;
19
+ }).join("&");
20
+
21
+ return [state, null, `/filters?${queryString}`];
22
+ }
23
+
10
24
  describe("FormFilterComponent", () => {
11
25
  const selector = "form#new_filter";
12
26
  let subject = null;
@@ -15,6 +29,10 @@ describe("FormFilterComponent", () => {
15
29
  beforeEach(() => {
16
30
  let form = `
17
31
  <form id="new_filter" action="/filters" method="get">
32
+ <fieldset>
33
+ <input id="filter_search_text_cont" placeholder="Search" data-disable-dynamic-change="true" type="search" name="filter[search_text_cont]">
34
+ </fieldset>
35
+
18
36
  <fieldset>
19
37
  <div id="filter_somerandomid_scope_id" class="data-picker picker-multiple" data-picker-name="filter[scope_id]">
20
38
  <div class="picker-values">
@@ -67,11 +85,25 @@ describe("FormFilterComponent", () => {
67
85
  `;
68
86
  $("body").append(form);
69
87
 
88
+ const $form = $(document).find("form");
89
+
70
90
  window.Decidim = window.Decidim || {};
71
91
 
72
92
  window.theDataPicker = new DataPicker($(".data-picker"));
73
93
  window.theCheckBoxesTree = new CheckBoxesTree();
74
- subject = new FormFilterComponent($(document).find("form"));
94
+ window.Rails = {
95
+ fire: (htmlElement, event) => {
96
+ // Hack to call trigger on the correct instance of the form, as fetching
97
+ // with the selector does not work.
98
+ if (htmlElement === $form[0]) {
99
+ $form.trigger(event);
100
+ }
101
+ }
102
+ };
103
+
104
+ subject = new FormFilterComponent($form);
105
+
106
+ jest.useFakeTimers();
75
107
  });
76
108
 
77
109
  it("exists", () => {
@@ -88,7 +120,19 @@ describe("FormFilterComponent", () => {
88
120
 
89
121
  describe("when mounted", () => {
90
122
  beforeEach(() => {
91
- spyOn(subject.$form, "on");
123
+ // Jest doesn't implement listening on the form submit event so we need
124
+ // to hack it.
125
+ const originalOn = subject.$form.on.bind(subject.$form);
126
+ jest.spyOn(subject.$form, "on").mockImplementation((...args) => {
127
+ if (args[0] === "submit") {
128
+ subject.$form.submitHandler = args[1];
129
+ } else if (args[0] === "change" && typeof args[1] === "string") {
130
+ subject.$form.changeHandler = args[2];
131
+ } else {
132
+ originalOn(...args);
133
+ }
134
+ });
135
+
92
136
  subject.mountComponent();
93
137
  });
94
138
 
@@ -100,8 +144,98 @@ describe("FormFilterComponent", () => {
100
144
  expect(subject.mounted).toBeTruthy();
101
145
  });
102
146
 
103
- it("binds the form change event", () => {
147
+ it("binds the form change and submit events", () => {
104
148
  expect(subject.$form.on).toHaveBeenCalledWith("change", "input:not([data-disable-dynamic-change]), select:not([data-disable-dynamic-change])", subject._onFormChange);
149
+ expect(subject.$form.on).toHaveBeenCalledWith("submit", subject._onFormSubmit);
150
+ });
151
+
152
+ describe("form changes", () => {
153
+ beforeEach(() => {
154
+ spyOn(window.history, "pushState");
155
+
156
+ // This is a hack to be able to trigger the events even somewhat close
157
+ // to an actual situation. In real browser environment the change events
158
+ // would be triggered by the input/select elements but to simplify the
159
+ // test implementation, we trigger them directly on the form.
160
+ const originalTrigger = subject.$form.trigger.bind(subject.$form);
161
+ jest.spyOn(subject.$form, "trigger").mockImplementation((...args) => {
162
+ if (args[0] === "submit") {
163
+ subject.$form.submitHandler(
164
+ $.event.fix(new CustomEvent("submit", { bubbles: true, cancelable: true }))
165
+ );
166
+ } else if (args[0] === "change") {
167
+ subject.$form.changeHandler();
168
+ } else {
169
+ originalTrigger(...args);
170
+ }
171
+
172
+ jest.runAllTimers();
173
+ });
174
+ });
175
+
176
+ it("does not save the state in case there were no changes to previous state", () => {
177
+ subject.$form.trigger("change");
178
+
179
+ expect(window.history.pushState).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("saves the state after dynamic form changes", () => {
183
+ $("#filter_somerandomid_category_id").val(2);
184
+
185
+ subject.$form.trigger("change");
186
+
187
+ const state = {
188
+ "filter_somerandomid_scope_id": [
189
+ {
190
+ "text": "Scope 1",
191
+ "url": "picker_url_1",
192
+ "value": "3"
193
+ },
194
+ {
195
+ "text": "Scope 2",
196
+ "url": "picker_url_2",
197
+ "value": "4"
198
+ }
199
+ ]
200
+ };
201
+ const filters = {
202
+ "search_text_cont": "",
203
+ "scope_id": [3, 4],
204
+ "category_id": 2,
205
+ "state": [""]
206
+ };
207
+ expect(window.history.pushState).toHaveBeenCalledWith(...expectedPushState(state, filters));
208
+ });
209
+
210
+ it("saves the state after form submission through input element", () => {
211
+ const textInput = document.getElementById("filter_search_text_cont");
212
+ textInput.value = "search";
213
+
214
+ subject.$form.trigger("submit");
215
+
216
+ const state = {
217
+ "filter_somerandomid_scope_id": [
218
+ {
219
+ "text": "Scope 1",
220
+ "url": "picker_url_1",
221
+ "value": "3"
222
+ },
223
+ {
224
+ "text": "Scope 2",
225
+ "url": "picker_url_2",
226
+ "value": "4"
227
+ }
228
+ ]
229
+ }
230
+ const filters = {
231
+ "search_text_cont": "search",
232
+ "scope_id": [3, 4],
233
+ "category_id": 1,
234
+ "state": [""]
235
+ }
236
+
237
+ expect(window.history.pushState).toHaveBeenCalledWith(...expectedPushState(state, filters));
238
+ });
105
239
  });
106
240
 
107
241
  describe("onpopstate event", () => {
@@ -131,6 +265,14 @@ describe("FormFilterComponent", () => {
131
265
  expect(checked.map((input) => input.value)).toEqual(["", "accepted", "evaluating"]);
132
266
  expect(checked.filter((input) => input.indeterminate).map((input) => input.value)).toEqual([""]);
133
267
  });
268
+
269
+ it("does not save the state", () => {
270
+ spyOn(window.history, "pushState");
271
+
272
+ window.onpopstate({ isTrusted: true, state: scopesPickerState});
273
+
274
+ expect(window.history.pushState).not.toHaveBeenCalled();
275
+ });
134
276
  });
135
277
  });
136
278
 
@@ -145,8 +287,9 @@ describe("FormFilterComponent", () => {
145
287
  expect(subject.mounted).toBeFalsy();
146
288
  });
147
289
 
148
- it("unbinds the form change event", () => {
290
+ it("unbinds the form change and submit events", () => {
149
291
  expect(subject.$form.off).toHaveBeenCalledWith("change", "input, select", subject._onFormChange);
292
+ expect(subject.$form.off).toHaveBeenCalledWith("submit", subject._onFormSubmit);
150
293
  });
151
294
  });
152
295
 
@@ -23,6 +23,7 @@ export default class FormFilterComponent {
23
23
 
24
24
  this._updateInitialState();
25
25
  this._onFormChange = delayed(this, this._onFormChange.bind(this));
26
+ this._onFormSubmit = delayed(this, this._onFormSubmit.bind(this));
26
27
  this._onPopState = this._onPopState.bind(this);
27
28
 
28
29
  if (window.Decidim.PopStateHandler) {
@@ -42,6 +43,7 @@ export default class FormFilterComponent {
42
43
  if (this.mounted) {
43
44
  this.mounted = false;
44
45
  this.$form.off("change", "input, select", this._onFormChange);
46
+ this.$form.off("submit", this._onFormSubmit);
45
47
 
46
48
  unregisterCallback(`filters-${this.id}`)
47
49
  }
@@ -62,6 +64,7 @@ export default class FormFilterComponent {
62
64
  contentContainer = this.$form.data("remoteFill");
63
65
  }
64
66
  this.$form.on("change", "input:not([data-disable-dynamic-change]), select:not([data-disable-dynamic-change])", this._onFormChange);
67
+ this.$form.on("submit", this._onFormSubmit);
65
68
 
66
69
  this.currentFormRequest = null;
67
70
  this.$form.on("ajax:beforeSend", (e) => {
@@ -254,14 +257,16 @@ export default class FormFilterComponent {
254
257
 
255
258
  // Only one instance should submit the form on browser history navigation
256
259
  if (this.popStateSubmiter) {
257
- Rails.fire(this.$form[0], "submit");
260
+ Rails.fire(this.$form[0], "submit", { from: "pop" });
258
261
  }
259
262
 
260
263
  this.changeEvents = true;
261
264
  }
262
265
 
263
266
  /**
264
- * Handles the logic to update the current location after a form change event.
267
+ * Handles the logic to decide whether the form should be submitted or not
268
+ * after a form change event. The form is only submitted when changes have
269
+ * occurred.
265
270
  * @private
266
271
  * @returns {Void} - Returns nothing.
267
272
  */
@@ -270,7 +275,7 @@ export default class FormFilterComponent {
270
275
  return;
271
276
  }
272
277
 
273
- const [newPath, newState] = this._currentStateAndPath();
278
+ const [newPath] = this._currentStateAndPath();
274
279
  const path = this._getLocation(false);
275
280
 
276
281
  if (newPath === path) {
@@ -278,6 +283,23 @@ export default class FormFilterComponent {
278
283
  }
279
284
 
280
285
  Rails.fire(this.$form[0], "submit");
286
+ }
287
+
288
+ /**
289
+ * Saves the current state of the search on form submit to update the search
290
+ * parameters to the URL and store the picker states.
291
+ * @private
292
+ * @param {jQuery.Event} ev The event that caused the form to submit.
293
+ * @returns {Void} - Returns nothing.
294
+ */
295
+ _onFormSubmit(ev) {
296
+ const eventDetail = ev.originalEvent.detail;
297
+ if (eventDetail && eventDetail.from === "pop") {
298
+ return;
299
+ }
300
+
301
+ const [newPath, newState] = this._currentStateAndPath();
302
+
281
303
  pushState(newPath, newState);
282
304
  this._saveFilters(newPath);
283
305
  }
@@ -314,7 +336,7 @@ export default class FormFilterComponent {
314
336
  * @returns {String} - Returns a unique identifier
315
337
  */
316
338
  _getUID() {
317
- return `filter-form-${new Date().setUTCMilliseconds()}-${Math.floor(Math.random() * 10000000)}`;
339
+ return `filter-form-${new Date().getUTCMilliseconds()}-${Math.floor(Math.random() * 10000000)}`;
318
340
  }
319
341
 
320
342
  /**