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.
- checksums.yaml +4 -4
- data/app/cells/decidim/amendable/announcement_cell.rb +1 -1
- data/app/cells/decidim/card_m_cell.rb +1 -1
- data/app/cells/decidim/newsletter_templates/base_cell.rb +8 -0
- data/app/cells/decidim/newsletter_templates/basic_only_text/show.erb +4 -4
- data/app/cells/decidim/newsletter_templates/image_text_cta/show.erb +4 -4
- data/app/cells/decidim/upload_modal_cell.rb +12 -7
- data/app/commands/decidim/unendorse_resource.rb +1 -1
- data/app/controllers/decidim/devise/invitations_controller.rb +9 -2
- data/app/controllers/decidim/groups_controller.rb +5 -0
- data/app/controllers/decidim/last_activities_controller.rb +5 -2
- data/app/controllers/decidim/links_controller.rb +4 -2
- data/app/controllers/decidim/profiles_controller.rb +1 -1
- data/app/forms/decidim/account_form.rb +2 -2
- data/app/forms/decidim/amendable/form.rb +2 -1
- data/app/forms/decidim/registration_form.rb +2 -2
- data/app/forms/decidim/upload_validation_form.rb +51 -7
- data/app/helpers/decidim/icon_helper.rb +3 -3
- data/app/helpers/decidim/layout_helper.rb +12 -4
- data/app/helpers/decidim/newsletters_helper.rb +1 -0
- data/app/helpers/decidim/sanitize_helper.rb +1 -1
- data/app/mailers/decidim/newsletter_mailer.rb +10 -3
- data/app/mailers/decidim/notification_mailer.rb +1 -0
- data/app/mailers/decidim/notifications_digest_mailer.rb +1 -0
- data/app/models/decidim/newsletter.rb +28 -0
- data/app/models/decidim/user.rb +0 -2
- data/app/models/decidim/user_base_entity.rb +2 -0
- data/app/models/decidim/user_block.rb +2 -2
- data/app/models/decidim/user_group.rb +1 -1
- data/app/packs/src/decidim/editor/clipboard_override.js +143 -0
- data/app/packs/src/decidim/editor/clipboard_utilities.js +119 -0
- data/app/packs/src/decidim/editor/linebreak_module.js +0 -8
- data/app/packs/src/decidim/editor.js +9 -2
- data/app/packs/src/decidim/form_filter.component.test.js +148 -5
- data/app/packs/src/decidim/form_filter.js +26 -4
- data/app/packs/stylesheets/decidim/_editor.scss +129 -0
- data/app/packs/stylesheets/decidim/email.scss +7 -0
- data/app/packs/stylesheets/decidim/extras/_quill.scss +0 -6
- data/app/presenters/decidim/admin_log/user_group_presenter.rb +1 -1
- data/app/presenters/decidim/admin_log/user_moderation_presenter.rb +1 -1
- data/app/presenters/decidim/home_stats_presenter.rb +11 -4
- data/app/presenters/decidim/push_notification_presenter.rb +1 -1
- data/app/presenters/decidim/stats_presenter.rb +7 -8
- data/app/presenters/decidim/user_presenter.rb +9 -4
- data/app/queries/decidim/public_activities.rb +1 -0
- data/app/uploaders/decidim/application_uploader.rb +1 -1
- data/app/uploaders/decidim/avatar_uploader.rb +2 -2
- data/app/validators/etiquette_validator.rb +7 -3
- data/app/validators/file_content_type_validator.rb +103 -0
- data/app/validators/passthru_validator.rb +11 -0
- data/app/validators/uploader_content_type_validator.rb +22 -0
- data/app/views/decidim/messaging/conversations/_conversation.html.erb +1 -1
- data/app/views/decidim/newsletter_mailer/newsletter.html.erb +3 -3
- data/app/views/decidim/newsletters/show.html.erb +1 -1
- data/app/views/decidim/notification_mailer/event_received.html.erb +1 -1
- data/app/views/decidim/notifications_digest_mailer/_email_content.html.erb +1 -1
- data/app/views/layouts/decidim/_mailer_logo.html.erb +2 -2
- data/app/views/layouts/decidim/newsletter_base.html.erb +2 -2
- data/config/locales/ar.yml +5 -17
- data/config/locales/bg.yml +5 -17
- data/config/locales/ca.yml +20 -24
- data/config/locales/cs.yml +12 -17
- data/config/locales/de.yml +2 -18
- data/config/locales/el.yml +4 -18
- data/config/locales/en.yml +11 -15
- data/config/locales/es-MX.yml +13 -17
- data/config/locales/es-PY.yml +13 -17
- data/config/locales/es.yml +22 -26
- data/config/locales/eu.yml +28 -35
- data/config/locales/fi-plain.yml +11 -15
- data/config/locales/fi.yml +12 -16
- data/config/locales/fr-CA.yml +11 -18
- data/config/locales/fr.yml +11 -18
- data/config/locales/ga-IE.yml +0 -2
- data/config/locales/gl.yml +2 -17
- data/config/locales/gn-PY.yml +1 -0
- data/config/locales/hu.yml +4 -18
- data/config/locales/id-ID.yml +5 -17
- data/config/locales/is-IS.yml +0 -1
- data/config/locales/it.yml +1 -18
- data/config/locales/ja.yml +25 -29
- data/config/locales/ka-GE.yml +1 -0
- data/config/locales/lb.yml +0 -17
- data/config/locales/lo-LA.yml +1 -0
- data/config/locales/lt.yml +0 -17
- data/config/locales/lv.yml +5 -17
- data/config/locales/nl.yml +0 -17
- data/config/locales/no.yml +2 -19
- data/config/locales/pl.yml +4 -18
- data/config/locales/pt-BR.yml +0 -17
- data/config/locales/pt.yml +0 -17
- data/config/locales/ro-RO.yml +49 -16
- data/config/locales/ru.yml +5 -3
- data/config/locales/sk.yml +5 -17
- data/config/locales/sv.yml +22 -18
- data/config/locales/tr-TR.yml +4 -18
- data/config/locales/uk.yml +5 -1
- data/config/locales/zh-CN.yml +3 -17
- data/lib/decidim/api/types/localized_string_type.rb +9 -0
- data/lib/decidim/api/types/translated_field_type.rb +20 -5
- data/lib/decidim/asset_router/pipeline.rb +93 -0
- data/lib/decidim/asset_router/storage.rb +82 -0
- data/lib/decidim/asset_router.rb +3 -75
- data/lib/decidim/attribute_object/form.rb +9 -0
- data/lib/decidim/attributes/localized_date.rb +1 -1
- data/lib/decidim/attributes/time_with_zone.rb +5 -2
- data/lib/decidim/core/engine.rb +7 -5
- data/lib/decidim/core/test/factories.rb +13 -6
- data/lib/decidim/core/test/shared_examples/comments_examples.rb +1 -1
- data/lib/decidim/core/test/shared_examples/editor_shared_examples.rb +30 -0
- data/lib/decidim/core/test/shared_examples/mcell_examples.rb +17 -0
- data/lib/decidim/core/test.rb +2 -0
- data/lib/decidim/core/version.rb +1 -1
- data/lib/decidim/dependency_resolver.rb +14 -8
- data/lib/decidim/file_validator_humanizer.rb +1 -1
- data/lib/decidim/form_builder.rb +11 -4
- data/lib/decidim/participatory_space_resourceable.rb +7 -1
- data/lib/decidim/resourceable.rb +5 -4
- data/lib/decidim/settings_manifest.rb +1 -1
- metadata +17 -8
- 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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
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
|
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
|
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().
|
339
|
+
return `filter-form-${new Date().getUTCMilliseconds()}-${Math.floor(Math.random() * 10000000)}`;
|
318
340
|
}
|
319
341
|
|
320
342
|
/**
|