decidim-collaborative_texts 0.31.0.rc1

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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +3 -0
  4. data/app/cells/decidim/collaborative_texts/document_cell.rb +23 -0
  5. data/app/cells/decidim/collaborative_texts/document_l_cell.rb +15 -0
  6. data/app/commands/decidim/collaborative_texts/admin/create_document.rb +25 -0
  7. data/app/commands/decidim/collaborative_texts/admin/publish_document.rb +52 -0
  8. data/app/commands/decidim/collaborative_texts/admin/unpublish_document.rb +44 -0
  9. data/app/commands/decidim/collaborative_texts/admin/update_document.rb +101 -0
  10. data/app/commands/decidim/collaborative_texts/admin/update_document_settings.rb +13 -0
  11. data/app/commands/decidim/collaborative_texts/create_suggestion.rb +28 -0
  12. data/app/commands/decidim/collaborative_texts/rollout.rb +50 -0
  13. data/app/controllers/concerns/decidim/collaborative_texts/admin/filterable.rb +23 -0
  14. data/app/controllers/decidim/collaborative_texts/admin/application_controller.rb +15 -0
  15. data/app/controllers/decidim/collaborative_texts/admin/documents_controller.rb +142 -0
  16. data/app/controllers/decidim/collaborative_texts/application_controller.rb +14 -0
  17. data/app/controllers/decidim/collaborative_texts/documents_controller.rb +57 -0
  18. data/app/controllers/decidim/collaborative_texts/suggestions_controller.rb +55 -0
  19. data/app/events/decidim/collaborative_texts/suggestion_accepted_event.rb +6 -0
  20. data/app/forms/decidim/collaborative_texts/admin/document_form.rb +29 -0
  21. data/app/forms/decidim/collaborative_texts/rollout_form.rb +26 -0
  22. data/app/forms/decidim/collaborative_texts/suggestion_form.rb +42 -0
  23. data/app/helpers/decidim/collaborative_texts/application_helper.rb +20 -0
  24. data/app/models/decidim/collaborative_texts/application_record.rb +10 -0
  25. data/app/models/decidim/collaborative_texts/document.rb +78 -0
  26. data/app/models/decidim/collaborative_texts/suggestion.rb +36 -0
  27. data/app/models/decidim/collaborative_texts/version.rb +35 -0
  28. data/app/packs/entrypoints/decidim_collaborative_texts.js +7 -0
  29. data/app/packs/images/decidim/collaborative_texts/decidim_collaborative_texts.svg +1 -0
  30. data/app/packs/src/decidim/collaborative_texts/document.js +168 -0
  31. data/app/packs/src/decidim/collaborative_texts/editor.js +80 -0
  32. data/app/packs/src/decidim/collaborative_texts/init_documents.js +27 -0
  33. data/app/packs/src/decidim/collaborative_texts/manager.js +106 -0
  34. data/app/packs/src/decidim/collaborative_texts/selection.js +106 -0
  35. data/app/packs/src/decidim/collaborative_texts/suggestion.js +243 -0
  36. data/app/packs/src/decidim/collaborative_texts/suggestions_list.js +103 -0
  37. data/app/packs/src/decidim/collaborative_texts/test/document.test.js +83 -0
  38. data/app/packs/src/decidim/collaborative_texts/test/manager.test.js +149 -0
  39. data/app/packs/src/decidim/collaborative_texts/test/selection.test.js +125 -0
  40. data/app/packs/src/decidim/collaborative_texts/test/suggestions.test.js +233 -0
  41. data/app/packs/src/decidim/collaborative_texts/test/toc.test.js +70 -0
  42. data/app/packs/src/decidim/collaborative_texts/toc.js +48 -0
  43. data/app/packs/stylesheets/decidim/collaborative_texts/collaborative_texts.scss +287 -0
  44. data/app/permissions/decidim/collaborative_texts/admin/permissions.rb +28 -0
  45. data/app/permissions/decidim/collaborative_texts/permissions.rb +36 -0
  46. data/app/presenters/decidim/collaborative_texts/admin_log/document_presenter.rb +54 -0
  47. data/app/presenters/decidim/collaborative_texts/admin_log/suggestion_presenter.rb +62 -0
  48. data/app/presenters/decidim/collaborative_texts/admin_log/suggestion_resource_presenter.rb +20 -0
  49. data/app/presenters/decidim/collaborative_texts/admin_log/version_presenter.rb +53 -0
  50. data/app/presenters/decidim/collaborative_texts/official_author_presenter.rb +11 -0
  51. data/app/presenters/decidim/collaborative_texts/suggestion_presenter.rb +57 -0
  52. data/app/views/decidim/collaborative_texts/admin/documents/_actions.html.erb +82 -0
  53. data/app/views/decidim/collaborative_texts/admin/documents/_document-tr.html.erb +15 -0
  54. data/app/views/decidim/collaborative_texts/admin/documents/_documents-thead.html.erb +7 -0
  55. data/app/views/decidim/collaborative_texts/admin/documents/_draft_options.html.erb +6 -0
  56. data/app/views/decidim/collaborative_texts/admin/documents/_form.html.erb +16 -0
  57. data/app/views/decidim/collaborative_texts/admin/documents/_non_draft_options.html.erb +9 -0
  58. data/app/views/decidim/collaborative_texts/admin/documents/_versions.html.erb +18 -0
  59. data/app/views/decidim/collaborative_texts/admin/documents/edit.html.erb +33 -0
  60. data/app/views/decidim/collaborative_texts/admin/documents/edit_settings.html.erb +18 -0
  61. data/app/views/decidim/collaborative_texts/admin/documents/index.html.erb +32 -0
  62. data/app/views/decidim/collaborative_texts/admin/documents/manage_trash.html.erb +19 -0
  63. data/app/views/decidim/collaborative_texts/admin/documents/new.html.erb +18 -0
  64. data/app/views/decidim/collaborative_texts/admin/settings/_form.html.erb +9 -0
  65. data/app/views/decidim/collaborative_texts/documents/_editor_template.html.erb +15 -0
  66. data/app/views/decidim/collaborative_texts/documents/_manager.html.erb +9 -0
  67. data/app/views/decidim/collaborative_texts/documents/_suggestions_box_item_template.html.erb +33 -0
  68. data/app/views/decidim/collaborative_texts/documents/_suggestions_box_template.html.erb +20 -0
  69. data/app/views/decidim/collaborative_texts/documents/index.html.erb +29 -0
  70. data/app/views/decidim/collaborative_texts/documents/show.html.erb +70 -0
  71. data/config/assets.rb +8 -0
  72. data/config/locales/am-ET.yml +1 -0
  73. data/config/locales/ar.yml +1 -0
  74. data/config/locales/bg.yml +1 -0
  75. data/config/locales/bn-BD.yml +1 -0
  76. data/config/locales/bs-BA.yml +1 -0
  77. data/config/locales/ca-IT.yml +154 -0
  78. data/config/locales/ca.yml +154 -0
  79. data/config/locales/cs.yml +122 -0
  80. data/config/locales/da.yml +1 -0
  81. data/config/locales/de.yml +154 -0
  82. data/config/locales/el.yml +1 -0
  83. data/config/locales/en.yml +154 -0
  84. data/config/locales/eo.yml +1 -0
  85. data/config/locales/es-MX.yml +154 -0
  86. data/config/locales/es-PY.yml +154 -0
  87. data/config/locales/es.yml +154 -0
  88. data/config/locales/et.yml +1 -0
  89. data/config/locales/eu.yml +154 -0
  90. data/config/locales/fa-IR.yml +1 -0
  91. data/config/locales/fi-plain.yml +154 -0
  92. data/config/locales/fi.yml +154 -0
  93. data/config/locales/fr-CA.yml +125 -0
  94. data/config/locales/fr.yml +125 -0
  95. data/config/locales/ga-IE.yml +1 -0
  96. data/config/locales/gl.yml +1 -0
  97. data/config/locales/gn-PY.yml +1 -0
  98. data/config/locales/he-IL.yml +1 -0
  99. data/config/locales/hr.yml +1 -0
  100. data/config/locales/hu.yml +1 -0
  101. data/config/locales/id-ID.yml +1 -0
  102. data/config/locales/is-IS.yml +1 -0
  103. data/config/locales/it.yml +1 -0
  104. data/config/locales/ja.yml +153 -0
  105. data/config/locales/ka-GE.yml +1 -0
  106. data/config/locales/kaa.yml +1 -0
  107. data/config/locales/ko.yml +1 -0
  108. data/config/locales/lb.yml +1 -0
  109. data/config/locales/lo-LA.yml +1 -0
  110. data/config/locales/lt.yml +1 -0
  111. data/config/locales/lv.yml +1 -0
  112. data/config/locales/mt.yml +1 -0
  113. data/config/locales/nl.yml +1 -0
  114. data/config/locales/no.yml +6 -0
  115. data/config/locales/oc-FR.yml +1 -0
  116. data/config/locales/om-ET.yml +1 -0
  117. data/config/locales/pl.yml +1 -0
  118. data/config/locales/pt-BR.yml +1 -0
  119. data/config/locales/pt.yml +1 -0
  120. data/config/locales/ro-RO.yml +89 -0
  121. data/config/locales/ru.yml +1 -0
  122. data/config/locales/si-LK.yml +1 -0
  123. data/config/locales/sk.yml +1 -0
  124. data/config/locales/sl.yml +1 -0
  125. data/config/locales/so-SO.yml +1 -0
  126. data/config/locales/sq-AL.yml +1 -0
  127. data/config/locales/sr-CS.yml +1 -0
  128. data/config/locales/sv.yml +117 -0
  129. data/config/locales/sw-KE.yml +1 -0
  130. data/config/locales/th-TH.yml +1 -0
  131. data/config/locales/ti-ER.yml +1 -0
  132. data/config/locales/tr-TR.yml +69 -0
  133. data/config/locales/uk.yml +1 -0
  134. data/config/locales/val-ES.yml +1 -0
  135. data/config/locales/vi.yml +1 -0
  136. data/config/locales/zh-CN.yml +1 -0
  137. data/config/locales/zh-TW.yml +1 -0
  138. data/db/migrate/20250205215038_create_decidim_collaborative_texts_documents.rb +16 -0
  139. data/db/migrate/20250213113536_create_collaborative_texts_versions.rb +13 -0
  140. data/db/migrate/20250227204839_create_collaborative_texts_suggestions.rb +13 -0
  141. data/db/migrate/20250312140133_add_counter_caches_to_collaborative_texts_documents.rb +13 -0
  142. data/db/migrate/20250408205231_add_counter_caches_to_collaborative_text_versions.rb +8 -0
  143. data/decidim-collaborative_texts.gemspec +36 -0
  144. data/lib/decidim/api/document_input_filter.rb +29 -0
  145. data/lib/decidim/api/document_input_sort.rb +14 -0
  146. data/lib/decidim/api/document_type.rb +31 -0
  147. data/lib/decidim/api/documents_type.rb +39 -0
  148. data/lib/decidim/api/suggestion_type.rb +18 -0
  149. data/lib/decidim/api/version_type.rb +21 -0
  150. data/lib/decidim/collaborative_texts/admin.rb +10 -0
  151. data/lib/decidim/collaborative_texts/admin_engine.rb +34 -0
  152. data/lib/decidim/collaborative_texts/api.rb +12 -0
  153. data/lib/decidim/collaborative_texts/component.rb +54 -0
  154. data/lib/decidim/collaborative_texts/engine.rb +28 -0
  155. data/lib/decidim/collaborative_texts/seeds.rb +117 -0
  156. data/lib/decidim/collaborative_texts/test/factories.rb +80 -0
  157. data/lib/decidim/collaborative_texts/version.rb +9 -0
  158. data/lib/decidim/collaborative_texts.rb +13 -0
  159. metadata +233 -0
@@ -0,0 +1,106 @@
1
+ import confirmDialog from "src/decidim/confirm";
2
+ export default class Manager {
3
+ constructor(document) {
4
+ this.document = document;
5
+ this.suggestions = document.suggestionsList.suggestions;
6
+ this.doc = document.doc;
7
+ this.i18n = document.i18n || {};
8
+ this.div = window.document.getElementsByClassName("collaborative-texts-manager")[0];
9
+ this.rolloutButton = this.div.querySelector("[data-collaborative-texts-manager-rollout]");
10
+ this.consolidateButton = this.div.querySelector("[data-collaborative-texts-manager-consolidate]");
11
+ this.cancelButton = this.div.querySelector("[data-collaborative-texts-manager-cancel]");
12
+ this.counters = {
13
+ applied: [...this.div.getElementsByClassName("collaborative-texts-manager-applied")],
14
+ pending: [...this.div.getElementsByClassName("collaborative-texts-manager-pending")]
15
+ };
16
+ this._bindEvents();
17
+ }
18
+
19
+ updateCounters(applied, pending) {
20
+ this.counters.applied.forEach((counter) => {
21
+ counter.textContent = applied;
22
+ });
23
+ this.counters.pending.forEach((counter) => {
24
+ counter.textContent = pending;
25
+ });
26
+ }
27
+
28
+ show() {
29
+ if (this.div) {
30
+ this.div.classList.remove("hidden");
31
+ }
32
+ }
33
+
34
+ hide() {
35
+ this.div.classList.add("hidden");
36
+ }
37
+
38
+ cancel() {
39
+ this.suggestions.forEach((suggestion) => suggestion.restore());
40
+ this.hide();
41
+ }
42
+
43
+ // Duplicates the body of the document, removing non accepted suggestions
44
+ // Accepted suggestions nodes are converted to first level nodes
45
+ cleanBody() {
46
+ this.container = window.document.createElement("div");
47
+ this.doc.childNodes.forEach((node) => {
48
+ if (node.nodeType !== Node.ELEMENT_NODE) {
49
+ return;
50
+ }
51
+ if (node.classList.contains("collaborative-texts-changes")) {
52
+ node.childNodes.forEach((child) => this.container.appendChild(child.cloneNode(true)));
53
+ return;
54
+ }
55
+ if ([...node.classList].find((cls) => cls.startsWith("collaborative-texts-"))) {
56
+ return;
57
+ }
58
+ this.container.appendChild(node.cloneNode(true));
59
+ });
60
+ return this.container.innerHTML;
61
+ }
62
+
63
+ _save(event) {
64
+ const draft = !event.target.dataset.collaborativeTextsManagerConsolidate;
65
+ confirmDialog(draft
66
+ ? this.i18n.rolloutConfirm
67
+ : this.i18n.consolidateConfirm).then((accepted) => {
68
+ if (accepted) {
69
+ fetch(this.doc.dataset.collaborativeTextsRolloutUrl, {
70
+ method: "PATCH",
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ "X-Requested-With": "XMLHttpRequest",
74
+ "X-CSRF-Token": this.document.csrfToken
75
+ },
76
+ body: JSON.stringify({
77
+ body: this.cleanBody(),
78
+ accepted: this.document.suggestionsList.getApplied().map((suggestion) => suggestion.id),
79
+ pending: this.document.suggestionsList.getPending().map((suggestion) => suggestion.id),
80
+ draft: draft
81
+ })
82
+ }).
83
+ then(async (response) => {
84
+ const data = await response.json();
85
+ if (!response.ok) {
86
+ throw new Error(data.message
87
+ ? data.message
88
+ : data);
89
+ }
90
+ window.location.href = data.redirect;
91
+ }).
92
+ catch((error) => {
93
+ console.error("Error saving:", error);
94
+ this.document.alert(error);
95
+ });
96
+ }
97
+ });
98
+
99
+ }
100
+
101
+ _bindEvents() {
102
+ this.rolloutButton.addEventListener("click", this._save.bind(this));
103
+ this.consolidateButton.addEventListener("click", this._save.bind(this));
104
+ this.cancelButton.addEventListener("click", this.cancel.bind(this));
105
+ }
106
+ }
@@ -0,0 +1,106 @@
1
+ import Editor from "src/decidim/collaborative_texts/editor";
2
+
3
+ class Selection {
4
+ constructor(doc) {
5
+ this.doc = doc;
6
+ this.selection = window.document.getSelection();
7
+ this.nodes = [];
8
+ this.firstNode = null;
9
+ this.lastNode = null;
10
+ this.wrapper = null;
11
+ this.editor = null;
12
+ this.blocked = false;
13
+ }
14
+
15
+ getRanges() {
16
+ const ranges = [];
17
+ for (let idx = 0; idx < this.selection.rangeCount; idx++) { // eslint-disable-line no-plusplus
18
+ ranges.push(this.selection.getRangeAt(idx));
19
+ }
20
+ return ranges;
21
+ }
22
+
23
+ isEditing() {
24
+ if (!this.blocked) {
25
+ return false;
26
+ }
27
+ if (this.changed()) {
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ isValid() {
34
+ if (this.selection.rangeCount === 0 || this.selection.isCollapsed) {
35
+ return false;
36
+ }
37
+ this.detectNodes();
38
+ if (this.nodes.length === 0) {
39
+ return false;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ detectNodes() {
45
+ this.nodes = [];
46
+ this.firstNode = null;
47
+ this.lastNode = null;
48
+ this.getRanges().forEach((range) => {
49
+ this.doc.nodes.forEach((node, index) => {
50
+ if (range.intersectsNode(node)) {
51
+ this.nodes[index] = node;
52
+ this.firstNode = this.firstNode || node;
53
+ this.lastNode = node;
54
+ }
55
+ });
56
+ });
57
+ return this;
58
+ }
59
+
60
+ changed() {
61
+ return this.editor && !this.editor.saveButton.disabled;
62
+ }
63
+
64
+ scrollIntoView() {
65
+ if (this.editor) {
66
+ this.editor.editor.scrollIntoView({ behavior: "smooth", block: "nearest" });
67
+ }
68
+ return this;
69
+ }
70
+
71
+ wrap() {
72
+ this.blocked = true;
73
+ this.wrapper = window.document.createElement("div");
74
+ this.wrapper.classList.add("collaborative-texts-selection");
75
+ this.firstNode.before(this.wrapper);
76
+ this.nodes.forEach((node) => this.wrapper.appendChild(node));
77
+ return this;
78
+ }
79
+
80
+ unWrap() {
81
+ if (this.wrapper) {
82
+ while (this.wrapper.firstChild) {
83
+ this.wrapper.parentNode.insertBefore(this.wrapper.firstChild, this.wrapper);
84
+ }
85
+ this.wrapper.remove();
86
+ }
87
+ return this;
88
+ }
89
+
90
+
91
+ showEditor() {
92
+ this.editor = new Editor(this);
93
+ return this;
94
+ }
95
+
96
+ clear() {
97
+ this.unWrap();
98
+ if (this.editor) {
99
+ this.editor.destroy();
100
+ }
101
+ this.blocked = false;
102
+ return this;
103
+ }
104
+ }
105
+
106
+ export default Selection;
@@ -0,0 +1,243 @@
1
+ export default class Suggestion {
2
+ constructor(suggestionsList, entry) {
3
+ this.id = entry.id;
4
+ this.authorCell = entry.profileHtml;
5
+ this.suggestionsList = suggestionsList;
6
+ this.selection = suggestionsList.document.selection;
7
+ this.templates = suggestionsList.document.templates;
8
+ this.doc = suggestionsList.doc;
9
+ this.nodes = [];
10
+ for (const node of suggestionsList.nodes) {
11
+ if (node.id === `ct-node-${entry.changeset.firstNode}`) {
12
+ this.firstNode = node;
13
+ }
14
+ if (node.id === `ct-node-${entry.changeset.lastNode}`) {
15
+ this.lastNode = node;
16
+ }
17
+ if (this.firstNode) {
18
+ this.nodes.push(node);
19
+ }
20
+ if (this.lastNode) {
21
+ break;
22
+ }
23
+ }
24
+ this.replace = entry.changeset.replace;
25
+ this.summary = entry.summary;
26
+ this.item = null;
27
+ this.boxWrapper = null;
28
+ this.highlightWrapper = null;
29
+ this.changesWrapper = null;
30
+ this.applied = false;
31
+ this.isFirst = false;
32
+ this.valid = this.nodes.length > 0 && this.firstNode && this.lastNode && Array.isArray(this.replace);
33
+ if (this.valid) {
34
+ this._createBoxWrapper();
35
+ this._createBoxItem();
36
+ this._bindEvents();
37
+ }
38
+ }
39
+
40
+ // Get the nodes that are affected by the suggestion
41
+ siblingSuggestions() {
42
+ return this.suggestionsList.suggestions.filter((suggestion) => {
43
+ return suggestion.id !== this.id && suggestion.nodes.some((node) => this.nodes.includes(node));
44
+ });
45
+ }
46
+
47
+ // wrap affected nodes by the suggestion in a div to temporarily apply the changes
48
+ // this is used to highlight (or replace) the nodes when hovering the suggestion
49
+ highlight() {
50
+ // If applied, just add the style to the changesWrapper
51
+ if (this.applied) {
52
+ this.changesWrapper.classList.add("collaborative-texts-highlight");
53
+ return;
54
+ }
55
+ this.highlightWrapper = window.document.createElement("div");
56
+ this.highlightWrapper.classList.add("collaborative-texts-highlight");
57
+ this.firstNode.before(this.highlightWrapper);
58
+ this._hideOriginalNodes("collaborative-texts-highlight-hidden");
59
+ this.siblingSuggestions().filter((suggestion) => suggestion.applied).forEach((suggestion) => {
60
+ suggestion.changesWrapper.classList.add("collaborative-texts-highlight-hidden");
61
+ suggestion.nodes.forEach((node) => node.classList.add("collaborative-texts-highlight-shown"));
62
+ });
63
+ this._applyTo(this.highlightWrapper);
64
+ }
65
+
66
+ // Restores the highlighted nodes to their original state
67
+ // this is used to remove the highlight when leaving the suggestion
68
+ blur() {
69
+ // If applied, just remove the style from the changesWrapper
70
+ if (this.applied && this.changesWrapper) {
71
+ this.changesWrapper.classList.remove("collaborative-texts-highlight");
72
+ }
73
+ if (this.highlightWrapper) {
74
+ this.highlightWrapper.remove();
75
+ this.highlightWrapper = null;
76
+ this._showOriginalNodes("collaborative-texts-highlight-hidden");
77
+ this.siblingSuggestions().filter((suggestion) => suggestion.applied).forEach((suggestion) => {
78
+ suggestion.changesWrapper.classList.remove("collaborative-texts-highlight-hidden");
79
+ suggestion.nodes.forEach((node) => node.classList.remove("collaborative-texts-highlight-shown"));
80
+ });
81
+ }
82
+ }
83
+
84
+ // Apply the suggestion by replacing the nodes with the replace content
85
+ apply() {
86
+ if (!this.applied && !this.selection || !this.selection.blocked) {
87
+ this.applied = true;
88
+ if (this.highlightWrapper) {
89
+ this.blur();
90
+ }
91
+ // restore any other changes affecting the same nodes
92
+ this.suggestionsList.restore(this.nodes, [this]);
93
+ this._createChangesWrapper();
94
+ this._hideOriginalNodes();
95
+ this._applyTo(this.changesWrapper);
96
+ this.item.classList.add("applied");
97
+ this._resetAccordion();
98
+ let event = new CustomEvent("collaborative-texts:applied", { detail: { suggestion: this } });
99
+ this.doc.dispatchEvent(event);
100
+ }
101
+ return this;
102
+ }
103
+
104
+ // Restore the suggestion by removing the replace content and showing the original nodes
105
+ restore() {
106
+ if (this.applied) {
107
+ this.applied = false;
108
+ this.item.classList.remove("applied");
109
+ this._showOriginalNodes();
110
+ this.changesWrapper.remove();
111
+ this.changesWrapper = null;
112
+ this._resetAccordion();
113
+ let event = new CustomEvent("collaborative-texts:restored", { detail: { suggestion: this } });
114
+ this.doc.dispatchEvent(event);
115
+ }
116
+ return this;
117
+ }
118
+
119
+ // Apply the changes to the wrapper passed as argument
120
+ // this does not manipulate the DOM, but only the wrapper
121
+ _applyTo(wrapper) {
122
+ this.replace.forEach((text) => wrapper.insertAdjacentHTML("beforeend", text));
123
+ // wrap in <p> any text nodes
124
+ wrapper.childNodes.forEach((node) => {
125
+ if (node.nodeType === Node.TEXT_NODE) {
126
+ let paragraph = window.document.createElement("p");
127
+ paragraph.textContent = node.textContent;
128
+ node.replaceWith(paragraph);
129
+ }
130
+ });
131
+ }
132
+
133
+ _hideOriginalNodes(className = "collaborative-texts-hidden") {
134
+ this.nodes.forEach((node) => node.classList.add(className));
135
+ }
136
+
137
+ _showOriginalNodes(className = "collaborative-texts-hidden") {
138
+ this.nodes.forEach((node) => node.classList.remove(className));
139
+ }
140
+
141
+ getPosition() {
142
+ let node = this.changesWrapper || this.highlightWrapper || this.firstNode;
143
+
144
+ while (node.classList.contains("collaborative-texts-hidden") || node.classList.contains("hidden")) {
145
+ node = node.previousSibling;
146
+ }
147
+
148
+ return node.offsetTop;
149
+ }
150
+
151
+ // Reset the position of the suggestion boxWrapper using the current position of the first node
152
+ setPosition(position) {
153
+ if (!this.boxWrapper) {
154
+ return this;
155
+ }
156
+ this.boxWrapper.style.top = `${position}px`;
157
+ return this;
158
+ }
159
+
160
+ destroy() {
161
+ if (this.item) {
162
+ this.item.remove();
163
+ this.item = null;
164
+ }
165
+ if (this.boxWrapper) {
166
+ this.boxWrapper.remove();
167
+ this.boxWrapper = null;
168
+ }
169
+ if (this.highlightWrapper) {
170
+ this.highlightWrapper.remove();
171
+ this.highlightWrapper = null;
172
+ }
173
+ if (this.changesWrapper) {
174
+ this.changesWrapper.remove();
175
+ this.changesWrapper = null;
176
+ }
177
+ }
178
+
179
+ // Create a wrapper where to put the changes when applying the suggestion
180
+ // Replaced nodes will be hidden, and the changes will be inserted in the wrapper
181
+ _createChangesWrapper() {
182
+ this.changesWrapper = window.document.createElement("div");
183
+ this.changesWrapper.classList.add("collaborative-texts-changes");
184
+ this.firstNode.before(this.changesWrapper);
185
+ }
186
+
187
+ // Because the A11y accordion has not an API to programmatically toggle the
188
+ // accordion, we need to destroy and recreate it to reset the state
189
+ _resetAccordion() {
190
+ let accordion = this.boxWrapper.querySelector('[data-controller="accordion"]');
191
+
192
+ accordion.dispatchEvent(new CustomEvent("accordion:reconnect", { detail: { collapse: true } }));
193
+ }
194
+
195
+ // Create a wrapper for all the suggestions applying to the same nodes
196
+ _createBoxWrapper() {
197
+ if (this.firstNode.previousSibling &&
198
+ this.firstNode.previousSibling.nodeType === Node.ELEMENT_NODE &&
199
+ this.firstNode.previousSibling.classList.contains("collaborative-texts-suggestions-box")) {
200
+ this.boxWrapper = this.firstNode.previousSibling;
201
+ return;
202
+ }
203
+ this.isFirst = true;
204
+ this.boxWrapper = window.document.createElement("div");
205
+ this.boxWrapper.id = `suggestion-box-wrapper-${this.id}`;
206
+ this.boxWrapper.classList.add("collaborative-texts-suggestions-box");
207
+ this.boxWrapper.innerHTML = this.templates.suggestionsBox.innerHTML.replaceAll("{{ID}}", this.id);
208
+ this.firstNode.before(this.boxWrapper);
209
+ this._resetAccordion();
210
+ }
211
+
212
+ // Create the box item for the suggestion inside the wrapper
213
+ _createBoxItem() {
214
+ this.boxItems = this.boxWrapper.querySelector(".collaborative-texts-suggestions-box-items");
215
+ this.itemsCounts = this.boxWrapper.querySelectorAll(".collaborative-texts-suggestions-box-items-count");
216
+ this.item = window.document.createElement("div");
217
+ this.item.classList.add("collaborative-texts-suggestions-box-item");
218
+ this.item.innerHTML = this.templates.suggestionsBoxItem.innerHTML.replaceAll("{{ID}}", this.id).replaceAll("{{PROFILE}}", this.authorCell);
219
+ this.text = this.item.querySelector(".collaborative-texts-suggestions-box-item-text");
220
+ this.text.innerHTML = this.summary;
221
+ this.boxItems.appendChild(this.item);
222
+ if (!this.doc.dataset.collaborativeTextsRolloutUrl) {
223
+ this.dropdown = this.item.querySelector('[data-controller="dropdown"]');
224
+ if (this.dropdown) {
225
+ this.dropdown.remove();
226
+ }
227
+ }
228
+ this.itemsCounts.forEach((item) => {
229
+ item.textContent = this.boxItems.childElementCount;
230
+ });
231
+ }
232
+
233
+ _resetDropdown() {
234
+
235
+ }
236
+
237
+ _bindEvents() {
238
+ this.item.addEventListener("mouseenter", this.highlight.bind(this));
239
+ this.item.addEventListener("mouseleave", this.blur.bind(this));
240
+ this.item.querySelector(".button-apply").addEventListener("click", this.apply.bind(this));
241
+ this.item.querySelector(".button-restore").addEventListener("click", this.restore.bind(this));
242
+ }
243
+ }
@@ -0,0 +1,103 @@
1
+ import Suggestion from "src/decidim/collaborative_texts/suggestion";
2
+
3
+ export default class SuggestionsList {
4
+ constructor(document) {
5
+ this.document = document;
6
+ this.nodes = document.nodes;
7
+ this.doc = document.doc;
8
+ this.i18n = document.i18n || {};
9
+ this.suggestions = [];
10
+ this._fetchSuggestions();
11
+ this._bindEvents();
12
+ }
13
+
14
+ resetPositions() {
15
+ if (this.restoreTimeout) {
16
+ clearTimeout(this.restoreTimeout);
17
+ this.restoreTimeout = null;
18
+ }
19
+ // Restore the positions after a timeout to allow the DOM to update
20
+ // and avoid flickering
21
+ this.restoreTimeout = setTimeout(() => {
22
+ const offset = 58;
23
+ const positions = this.defaultSuggestions().map((suggestion) => [suggestion, suggestion.getPosition()]);
24
+ positions.sort((one, two) => one[1] - two[1]);
25
+ for (let i = 0; i < positions.length - 1; i++) { // eslint-disable-line
26
+ if (positions[i + 1][1] < positions[i][1] + offset) {
27
+ positions[i + 1][1] += offset - (positions[i + 1][1] - positions[i][1]);
28
+ }
29
+ }
30
+ positions.forEach(([suggestion, position]) => suggestion.setPosition(position));
31
+ }, 100);
32
+ return this;
33
+ }
34
+
35
+ // restore changes for any suggestion affecting the specified nodes
36
+ restore(nodes, except = []) {
37
+ this.suggestions.forEach((suggestion) => {
38
+ if (!except.includes(suggestion) && suggestion.nodes.some((node) => nodes.includes(node))) {
39
+ suggestion.restore();
40
+ }
41
+ });
42
+ }
43
+
44
+ // The applied suggestion for each boxWrapper or the first suggestion if none is applied
45
+ defaultSuggestions() {
46
+ let suggestions = {};
47
+ this.suggestions.forEach((suggestion) => {
48
+ if (suggestion.applied) {
49
+ suggestions[suggestion.boxWrapper.id] = suggestion;
50
+ } else if (!suggestions[suggestion.boxWrapper.id] && suggestion.isFirst) {
51
+ suggestions[suggestion.boxWrapper.id] = suggestion;
52
+ }
53
+ });
54
+ return Object.values(suggestions);
55
+ }
56
+
57
+ getApplied() {
58
+ return this.suggestions.filter((suggestion) => suggestion.applied);
59
+ }
60
+
61
+ getPending() {
62
+ return this.suggestions.filter((suggestion) => !suggestion.applied);
63
+ }
64
+
65
+ // destroy all suggestions
66
+ destroy() {
67
+ this.suggestions.forEach((suggestion) => suggestion.destroy());
68
+ this.suggestions = [];
69
+ return this;
70
+ }
71
+
72
+ _fetchSuggestions() {
73
+ fetch(this.doc.dataset.collaborativeTextsSuggestionsUrl, {
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ "X-Requested-With": "XMLHttpRequest"
77
+ }
78
+ }).
79
+ then((response) => response.json()).
80
+ then((data) => {
81
+ data.forEach((item) => {
82
+ let suggestion = new Suggestion(this, item)
83
+ if (suggestion.valid) {
84
+ this.suggestions.push(suggestion);
85
+ }
86
+ });
87
+ this.resetPositions();
88
+ });
89
+ }
90
+
91
+ _bindEvents() {
92
+ this.doc.addEventListener("collaborative-texts:applied", this._onSuggestionApplied.bind(this));
93
+ this.doc.addEventListener("collaborative-texts:restored", this._onSuggestionRestored.bind(this));
94
+ }
95
+
96
+ _onSuggestionApplied() {
97
+ this.resetPositions();
98
+ }
99
+
100
+ _onSuggestionRestored() {
101
+ this.resetPositions();
102
+ }
103
+ }
@@ -0,0 +1,83 @@
1
+ /* global global, jest */
2
+
3
+ import Document from "src/decidim/collaborative_texts/document";
4
+ import SuggestionsList from "src/decidim/collaborative_texts/suggestions_list";
5
+
6
+ describe("Document", () => {
7
+ let i18n = {test: "test"};
8
+ let suggestionsUrl = "http://example.com/suggestions";
9
+ let rolloutUrl = "http://example.com/rollout";
10
+
11
+ const content = `
12
+ <body>
13
+ <div class="collaborative-texts-alert hidden">
14
+ <div></div>
15
+ </div>
16
+
17
+ <div data-collaborative-texts-document="true"
18
+ data-collaborative-texts-i18n='${JSON.stringify(i18n)}'
19
+ data-collaborative-texts-suggestions-url="${suggestionsUrl}"
20
+ data-collaborative-texts-rollout-url="${rolloutUrl}">
21
+ <h2>This is a collaborative text</h2>
22
+ <p>Some content</p>
23
+ <h2>This is another title</h2>
24
+ <ul><li>More content</li><li>Event more content</li></ul>
25
+ </div>
26
+ </body>
27
+ `;
28
+
29
+ let doc = null;
30
+ let fetchResult = [];
31
+
32
+ global.fetch = jest.fn(() =>
33
+ Promise.resolve({
34
+ json: () => Promise.resolve(fetchResult)
35
+ })
36
+ );
37
+
38
+ beforeEach(() => {
39
+ document.body.innerHTML = content;
40
+ doc = new Document(document.querySelector("[data-collaborative-texts-document]"));
41
+ });
42
+
43
+ it("Filters text nodes and adds ids", () => {
44
+ expect(doc.fetchSuggestions().suggestionsList).toBeInstanceOf(SuggestionsList);
45
+ expect(doc.fetchSuggestions().suggestionsList.document).toBe(doc);
46
+ expect(doc.nodes.length).toBe(4);
47
+ expect(doc.nodes[0].id).toBe("ct-node-1");
48
+ expect(doc.nodes[0].textContent).toBe("This is a collaborative text");
49
+ expect(doc.nodes[1].id).toBe("ct-node-2");
50
+ expect(doc.nodes[1].textContent).toBe("Some content");
51
+ expect(doc.nodes[2].id).toBe("ct-node-3");
52
+ expect(doc.nodes[2].textContent).toBe("This is another title");
53
+ expect(doc.nodes[3].id).toBe("ct-node-4");
54
+ expect(doc.nodes[3].childNodes[0].textContent).toBe("More content");
55
+ expect(doc.nodes[3].childNodes[1].textContent).toBe("Event more content");
56
+ });
57
+
58
+ it("Shows the alert", () => {
59
+ doc.alert("This is a test");
60
+ expect(doc.alertWrapper.classList.contains("hidden")).toBe(false);
61
+ expect(doc.alertDiv.textContent).toBe("This is a test");
62
+ });
63
+
64
+ it("enables suggestions", () => {
65
+ expect(doc.active).toBe(true);
66
+ doc.enableSuggestions();
67
+ window.document.dispatchEvent(new Event("selectstart"));
68
+ expect(doc.selecting).toBe(true);
69
+ window.document.dispatchEvent(new Event("mouseup"));
70
+ expect(doc.selecting).toBe(false);
71
+ });
72
+
73
+ describe("when disabled", () => {
74
+ beforeEach(() => {
75
+ document.body.innerHTML = '<div class="collaborative-texts-alert"></div><div data-collaborative-texts-document="false" data-collaborative-texts-i18n="{}"></div>';
76
+ doc = new Document(document.querySelector("[data-collaborative-texts-document]"));
77
+ });
78
+
79
+ it("disables suggestions", () => {
80
+ expect(doc.active).toBe(false);
81
+ });
82
+ });
83
+ });