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,149 @@
1
+ /* eslint-disable prefer-reflect */
2
+ /* global global, jest, process */
3
+
4
+ import Manager from "src/decidim/collaborative_texts/manager";
5
+
6
+ // Create the configuration object to make the configurations available for the tests
7
+ window.Decidim = {}
8
+ class DummyDialog {
9
+ constructor(element) { this.element = element; }
10
+
11
+ open() { this.element.dataset.dialogOpen = true; }
12
+
13
+ close() { this.element.dataset.dialogOpen = null; }
14
+ }
15
+ describe("Manager", () => {
16
+ const content = `
17
+ <body>
18
+ <div class="collaborative-texts-manager hidden">
19
+ <div>
20
+ <button data-collaborative-texts-manager-rollout="true">Rollout</button>
21
+ <button data-collaborative-texts-manager-consolidate="true">Consolidate</button>
22
+ <button data-collaborative-texts-manager-cancel="true">Cancel</button>
23
+ </div>
24
+ <div class="collaborative-texts-manager-counters">
25
+ Applied: <span class="collaborative-texts-manager-applied"></span>
26
+ Pending: <span class="collaborative-texts-manager-pending"></span>
27
+ </div>
28
+ </div>
29
+
30
+ <div data-collaborative-texts-document="true"
31
+ data-collaborative-texts-i18n='{}'
32
+ data-collaborative-texts-suggestions-url="#"
33
+ data-collaborative-texts-rollout-url="rolloutUrl">
34
+ <h2 id="node-1">This is a collaborative text</h2><p>Some content</p>
35
+ <div class="collaborative-texts-ignored"><h2>Ignored content</h2></div>
36
+ <div class="collaborative-texts-changes"><h2 id="node-2">This is another title</h2><ul><li>More content</li><li>Event more content</li></ul></div>
37
+ </div>
38
+
39
+ <div id="confirm-modal" data-dialog="confirm-modal">
40
+ <div id="confirm-modal" data-dialog="confirm-modal">
41
+ <div id="confirm-modal-content">
42
+ <div data-dialog-container>
43
+ <div data-confirm-modal-content></div>
44
+ </div>
45
+ <div data-dialog-actions>
46
+ <button data-confirm-cancel data-dialog-close="confirm-modal">Cancel</button>
47
+ <button data-confirm-ok>Ok</button>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </body>
53
+ `;
54
+
55
+ let manager = null;
56
+ let doc = null;
57
+ let suggestions = [
58
+ {
59
+ id: 1,
60
+ title: "Suggestion 1",
61
+ status: "pending",
62
+ restore: jest.fn()
63
+ },
64
+ {
65
+ id: 2,
66
+ title: "Suggestion 2",
67
+ status: "applied",
68
+ restore: jest.fn()
69
+ }
70
+ ];
71
+
72
+ const fetchResult = {
73
+ redirect: "#redirect-somewhere"
74
+ }
75
+ global.fetch = jest.fn(() =>
76
+ Promise.resolve({
77
+ ok: true,
78
+ json: () => Promise.resolve(fetchResult)
79
+ })
80
+ );
81
+
82
+ beforeEach(() => {
83
+ window.Decidim.currentDialogs = {
84
+ "confirm-modal": new DummyDialog(document.querySelector("#confirm-modal"))
85
+ };
86
+
87
+ document.body.innerHTML = content;
88
+ delete global.window.location;
89
+ global.window = Object.create(window);
90
+ global.window.location = {
91
+ href: "test-url"
92
+ };
93
+
94
+ doc = {
95
+ doc: document.querySelector("[data-collaborative-texts-document]"),
96
+ suggestionsList: {
97
+ suggestions: suggestions,
98
+ getApplied: () => {
99
+ return suggestions.filter((suggestion) => suggestion.status === "applied");
100
+ },
101
+ getPending: () => {
102
+ return suggestions.filter((suggestion) => suggestion.status === "pending");
103
+ }
104
+ },
105
+ i18N: {
106
+ rolloutConfirm: "Confirm rollout",
107
+ consolidateConfirm: "Confirm consolidate"
108
+ }
109
+ }
110
+ manager = new Manager(doc);
111
+ });
112
+
113
+ it("shows the manager", () => {
114
+ manager.show();
115
+ expect(manager.div.classList.contains("hidden")).toBe(false);
116
+ });
117
+
118
+ it("hides the manager", () => {
119
+ manager.hide();
120
+ expect(manager.div.classList.contains("hidden")).toBe(true);
121
+ });
122
+
123
+ it("updates the counters", () => {
124
+ manager.updateCounters(1, 2);
125
+ expect(manager.counters.applied[0].textContent).toBe("1");
126
+ expect(manager.counters.pending[0].textContent).toBe("2");
127
+ });
128
+
129
+ it("cancels the suggestions", () => {
130
+ manager.cancel();
131
+ expect(suggestions[0].restore).toHaveBeenCalled();
132
+ expect(suggestions[1].restore).toHaveBeenCalled();
133
+ expect(manager.div.classList.contains("hidden")).toBe(true);
134
+ });
135
+
136
+ it("gets a new body with applied changes", () => {
137
+ const cleanedBody = manager.cleanBody();
138
+ expect(cleanedBody).toBe("<h2 id=\"node-1\">This is a collaborative text</h2><p>Some content</p><h2 id=\"node-2\">This is another title</h2><ul><li>More content</li><li>Event more content</li></ul>");
139
+ });
140
+
141
+ it("rollouts the suggestions", async () => {
142
+ const rolloutButton = manager.rolloutButton;
143
+ rolloutButton.click();
144
+ window.document.querySelector("[data-confirm-ok]").click();
145
+ await new Promise(process.nextTick);
146
+
147
+ expect(global.location.href).toBe(fetchResult.redirect);
148
+ });
149
+ });
@@ -0,0 +1,125 @@
1
+ /* global global, jest */
2
+
3
+ import Document from "src/decidim/collaborative_texts/document";
4
+ import Selection from "src/decidim/collaborative_texts/selection";
5
+ import Editor from "src/decidim/collaborative_texts/editor";
6
+
7
+ describe("Selection", () => {
8
+ let i18n = {test: "test"};
9
+ let suggestionsUrl = "http://example.com/suggestions";
10
+ let rolloutUrl = "http://example.com/rollout";
11
+
12
+ const content = `
13
+ <body>
14
+ <div class="collaborative-texts-alert hidden">
15
+ <div></div>
16
+ </div>
17
+
18
+ <div data-collaborative-texts-document="true"
19
+ data-collaborative-texts-suggestions-editor-template="#collaborative-texts-editor-template"
20
+ data-collaborative-texts-suggestions-box-template="#collaborative-texts-suggestions-box-template"
21
+ data-collaborative-texts-suggestions-box-item-template="#collaborative-texts-suggestions-box-item-template"
22
+ data-collaborative-texts-i18n='${JSON.stringify(i18n)}'
23
+ data-collaborative-texts-suggestions-url="${suggestionsUrl}"
24
+ data-collaborative-texts-rollout-url="${rolloutUrl}">
25
+ <h2>This is a collaborative text</h2>
26
+ <p>Some content</p>
27
+ <h2>This is another title</h2>
28
+ <ul><li>More content</li><li>Event more content</li></ul>
29
+ </div>
30
+
31
+ <script type="text/template" class="decidim-template" id="collaborative-texts-editor-template">
32
+ <div class="collaborative-texts-editor-header">
33
+ <button class="collaborative-texts-button-cancel">Cancel</button>
34
+ </div>
35
+ <div class="collaborative-texts-editor-profile">Profile</div>
36
+ <div class="collaborative-texts-editor-container"></div>
37
+ <div class="collaborative-texts-editor-menu">
38
+ <button class="collaborative-texts-button-save" disabled>Send suggestion</button>
39
+ </div>
40
+ </script>
41
+ </body>
42
+ `;
43
+
44
+ let doc = null;
45
+ let selection = null;
46
+ global.document.getSelection = jest.fn(() => ({
47
+ rangeCount: 1,
48
+ getRangeAt: jest.fn(() => ({
49
+ intersectsNode: jest.fn((node) => node === doc.nodes[1]),
50
+ commonAncestorContainer: {
51
+ closest: jest.fn(() => null)
52
+ }
53
+ })),
54
+ removeAllRanges: jest.fn(),
55
+ addRange: jest.fn(),
56
+ focusNode: null,
57
+ anchorNode: null
58
+ }));
59
+ let fetchResult = [];
60
+ global.fetch = jest.fn(() =>
61
+ Promise.resolve({
62
+ json: () => Promise.resolve(fetchResult),
63
+ ok: true
64
+ })
65
+ );
66
+ beforeEach(async () => {
67
+ document.body.innerHTML = content;
68
+ doc = new Document(document.querySelector("[data-collaborative-texts-document]"));
69
+ // await new Promise(process.nextTick);
70
+ selection = new Selection(doc);
71
+ });
72
+
73
+ it("Creates a selection", () => {
74
+ expect(selection.blocked).toBe(false);
75
+ expect(selection.doc).toBe(doc);
76
+ selection.detectNodes();
77
+ expect(selection.nodes.length).toBe(2);
78
+ expect(selection.nodes[1]).toEqual(doc.nodes[1]);
79
+ expect(selection.firstNode).toBe(doc.nodes[1]);
80
+ expect(selection.lastNode).toBe(doc.nodes[1]);
81
+ expect(selection.wrapper).toBe(null);
82
+ expect(selection.editor).toBe(null);
83
+ expect(selection.changed()).not.toBe(true);
84
+ });
85
+
86
+ it("Shows the editor", () => {
87
+ selection.detectNodes();
88
+ selection.wrap().showEditor()
89
+ expect(selection.changed()).toBe(false);
90
+ expect(selection.blocked).toBe(true);
91
+ expect(selection.wrapper.classList.contains("collaborative-texts-selection")).toBe(true);
92
+ expect(selection.editor).toBeInstanceOf(Editor);
93
+ expect(selection.editor.container.innerHTML).toContain("<p id=\"ct-node-2\">Some content</p>");
94
+ selection.editor.container.innerHTML = "<p>Another content</p>";
95
+ selection.editor.container.dispatchEvent(new Event("input"));
96
+ expect(selection.changed()).toBe(true);
97
+ expect(selection.editor.saveButton.disabled).toBe(false);
98
+ });
99
+
100
+ it("Scrolls into view", () => {
101
+ selection.detectNodes();
102
+ selection.wrap().showEditor();
103
+ selection.editor.editor.scrollIntoView = jest.fn();
104
+ selection.scrollIntoView();
105
+ expect(selection.editor.editor.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "nearest" });
106
+ });
107
+
108
+ it("saves the changes", () => {
109
+ const spy = jest.spyOn(selection.doc.doc, "dispatchEvent");
110
+ selection.detectNodes();
111
+ selection.wrap().showEditor();
112
+ selection.editor.container.innerHTML = "<p>Another content</p>";
113
+ selection.editor.container.dispatchEvent(new Event("input"));
114
+ selection.editor.saveButton.click();
115
+ expect(spy.mock.calls[0][0].type).toBe("collaborative-texts:suggest");
116
+ expect(spy.mock.calls[0][0].detail.nodes[1]).toEqual(doc.nodes[1]);
117
+ expect(spy.mock.calls[0][0].detail.firstNode).toBe(doc.nodes[1]);
118
+ expect(spy.mock.calls[0][0].detail.lastNode).toBe(doc.nodes[1]);
119
+ expect(spy.mock.calls[0][0].detail.replaceNodes).toEqual(selection.editor.container.childNodes);
120
+ expect(spy.mock.calls[0][0].detail.nodes[1].id).toBe("ct-node-2");
121
+ expect(spy.mock.calls[0][0].detail.nodes[1].textContent).toBe("Some content");
122
+ expect(spy.mock.calls[0][0].detail.replaceNodes[0].textContent).toBe("Another content");
123
+
124
+ });
125
+ });
@@ -0,0 +1,233 @@
1
+ /* eslint-disable prefer-reflect */
2
+ /* global global, jest, process */
3
+
4
+ import Document from "src/decidim/collaborative_texts/document";
5
+ import SuggestionsList from "src/decidim/collaborative_texts/suggestions_list";
6
+ import Suggestion from "src/decidim/collaborative_texts/suggestion";
7
+
8
+ describe("SuggestionsList", () => {
9
+ let i18n = {test: "test"};
10
+ let suggestionsUrl = "http://example.com/suggestions";
11
+ let rolloutUrl = "http://example.com/rollout";
12
+
13
+ const content = `
14
+ <body>
15
+ <div class="collaborative-texts-alert hidden">
16
+ <div></div>
17
+ </div>
18
+
19
+ <div data-collaborative-texts-document="true"
20
+ data-collaborative-texts-suggestions-editor-template="#collaborative-texts-editor-template"
21
+ data-collaborative-texts-suggestions-box-template="#collaborative-texts-suggestions-box-template"
22
+ data-collaborative-texts-suggestions-box-item-template="#collaborative-texts-suggestions-box-item-template"
23
+ data-collaborative-texts-i18n='${JSON.stringify(i18n)}'
24
+ data-collaborative-texts-suggestions-url="${suggestionsUrl}"
25
+ data-collaborative-texts-rollout-url="${rolloutUrl}">
26
+ <h2>This is a collaborative text</h2>
27
+ <p>Some content</p>
28
+ <h2>This is another title</h2>
29
+ <ul><li>More content</li><li>Event more content</li></ul>
30
+ </div>
31
+
32
+ <div class="collaborative-texts-manager hidden">
33
+ <div>
34
+ <button data-collaborative-texts-manager-rollout="true">Rollout</button>
35
+ <button data-collaborative-texts-manager-consolidate="true">Consolidate</button>
36
+ <button data-collaborative-texts-manager-cancel="true">Cancel</button>
37
+ </div>
38
+ <div class="collaborative-texts-manager-counters">
39
+ Applied: <span class="collaborative-texts-manager-applied"></span>
40
+ Pending: <span class="collaborative-texts-manager-pending"></span>
41
+ </div>
42
+ </div>
43
+
44
+ <script type="text/template" class="decidim-template" id="collaborative-texts-suggestions-box-template">
45
+ <div data-controller="accordion" id="collaborative-texts-box-{{ID}}">
46
+ <button data-controls="panel-box-{{ID}}" aria-label="<%= t("decidim.collaborative_texts.document.toggle") %>" aria-expanded="false">
47
+ <span>
48
+ </span>
49
+ <span>
50
+ <span class="collaborative-texts-suggestions-box-items-count"></span>
51
+ </span>
52
+ </button>
53
+ <div class="collaborative-texts-suggestions-box-items" id="panel-box-{{ID}}" aria-hidden="true"></div>
54
+ </div>
55
+ </script>
56
+
57
+ <script type="text/template" class="decidim-template" id="collaborative-texts-suggestions-box-item-template">
58
+ <div class="collaborative-texts-suggestions-box-item-header">
59
+ <div>{{PROFILE}}</div>
60
+ <div class="relative">
61
+ <button class="collaborative-texts-suggestions-box-item-dropdown"
62
+ id="dropdown-trigger-{{ID}}"
63
+ data-controller="dropdown"
64
+ data-target="dropdown-menu-{{ID}}"
65
+ data-auto-close="true">
66
+ </button>
67
+
68
+ <div class="collaborative-texts-suggestions-box-header-menu" id="dropdown-menu-{{ID}}" role="menu" aria-labelledby="dropdown-trigger-{{ID}}" aria-hidden="true">
69
+ <ul role="menu">
70
+ <li role="menuitem">
71
+ <button class="button-apply">Apply</button>
72
+ <button class="button-restore">Restore</button>
73
+ </li>
74
+ </ul>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ <div class="collaborative-texts-suggestions-box-item-text"></div>
79
+ </script>
80
+ </body>
81
+ `;
82
+
83
+ let suggestionsList = null;
84
+ let doc = null;
85
+ let suggestion = null;
86
+ let firstSuggestion = null;
87
+ let fetchResult = [
88
+ {
89
+ "changeset": {
90
+ "original": ["some content"],
91
+ "replace": ["This is another replacement"],
92
+ "firstNode": 2,
93
+ "lastNode": 2
94
+ },
95
+ "status": "pending",
96
+ "profileHtml": "<div>Profile</div>",
97
+ "summary": "This is another summary"
98
+ }, {
99
+ "changeset": {
100
+ "original": ["some content"],
101
+ "replace": ["This is a replacement"],
102
+ "firstNode": 2,
103
+ "lastNode": 2
104
+ },
105
+ "status": "pending",
106
+ "profileHtml": "<div>Profile</div>",
107
+ "summary": "This is a summary"
108
+ }
109
+
110
+ ];
111
+
112
+ global.fetch = jest.fn(() =>
113
+ Promise.resolve({
114
+ json: () => Promise.resolve(fetchResult)
115
+ })
116
+ );
117
+
118
+ global.matchMedia = jest.fn(() => ({
119
+ matches: false,
120
+ addListener: jest.fn(),
121
+ removeListener: jest.fn()
122
+ }));
123
+
124
+ global.setTimeout = jest.fn((fn) => {
125
+ fn();
126
+ return 1;
127
+ });
128
+ global.clearTimeout = jest.fn(() => {
129
+ return 1;
130
+ });
131
+
132
+ beforeEach(async () => {
133
+ document.body.innerHTML = content;
134
+ doc = new Document(document.querySelector("[data-collaborative-texts-document]"));
135
+ suggestionsList = new SuggestionsList(doc);
136
+ doc.suggestionsList = suggestionsList;
137
+ await new Promise(process.nextTick);
138
+ firstSuggestion = suggestionsList.suggestions[0];
139
+ suggestion = suggestionsList.suggestions[1];
140
+ jest.spyOn(suggestion, "setPosition");
141
+ });
142
+
143
+ it("fetches suggestions", () => {
144
+ expect(suggestionsList.nodes).toBe(suggestionsList.document.nodes);
145
+ expect(suggestionsList.doc.innerHTML).toContain("collaborative-texts-suggestions-box");
146
+ expect(suggestionsList.doc.innerHTML).toContain("This is a summary");
147
+ expect(suggestionsList.suggestions.length).toBe(2);
148
+ expect(suggestion).toBeInstanceOf(Suggestion);
149
+ expect(suggestion.valid).toBe(true);
150
+ expect(suggestion.nodes.length).toBe(1);
151
+ expect(suggestion.firstNode).toBe(suggestionsList.nodes[1]);
152
+ expect(suggestion.lastNode).toBe(suggestionsList.nodes[1]);
153
+ expect(suggestion.replace).toEqual(["This is a replacement"]);
154
+ expect(suggestion.applied).toBe(false);
155
+ expect(suggestion.nodes[0].classList.contains("collaborative-texts-hidden")).toBe(false);
156
+ expect(suggestionsList.defaultSuggestions()).not.toContain(suggestion);
157
+ expect(suggestionsList.defaultSuggestions()).toContain(firstSuggestion);
158
+ });
159
+
160
+ it("applies a suggestion", () => {
161
+ const spy = jest.spyOn(doc.doc, "dispatchEvent");
162
+ suggestion.apply();
163
+ expect(spy.mock.calls[0][0].type).toBe("collaborative-texts:applied");
164
+ expect(spy.mock.calls[0][0].detail.suggestion).toBe(suggestion);
165
+ expect(suggestion.setPosition).toHaveBeenCalled();
166
+
167
+ expect(suggestion.applied).toBe(true);
168
+ expect(suggestion.changesWrapper).toBeInstanceOf(HTMLElement);
169
+ expect(suggestion.item.classList.contains("applied")).toBe(true);
170
+ expect(suggestion.nodes[0].classList.contains("collaborative-texts-hidden")).toBe(true);
171
+ expect(suggestion.changesWrapper.textContent).toBe("This is a replacement");
172
+ expect(suggestionsList.getApplied().length).toBe(1);
173
+ expect(suggestionsList.getPending().length).toBe(1);
174
+ expect(doc.doc.innerHTML).toContain("This is a replacement");
175
+ expect(doc.doc.querySelector(".collaborative-texts-hidden").innerHTML).toContain("Some content");
176
+ expect(suggestionsList.defaultSuggestions()).toContain(suggestion);
177
+ expect(suggestionsList.defaultSuggestions()).not.toContain(firstSuggestion);
178
+ });
179
+
180
+ it("restores a suggestion", () => {
181
+ const spy = jest.spyOn(doc.doc, "dispatchEvent");
182
+ suggestion.apply();
183
+ expect(spy.mock.calls[0][0].type).toBe("collaborative-texts:applied");
184
+ expect(spy.mock.calls[0][0].detail.suggestion).toBe(suggestion);
185
+ expect(suggestion.setPosition).toHaveBeenCalled();
186
+
187
+ suggestion.restore();
188
+ expect(spy.mock.calls[1][0].type).toBe("collaborative-texts:restored");
189
+ expect(spy.mock.calls[1][0].detail.suggestion).toBe(suggestion);
190
+ expect(suggestion.setPosition).toHaveBeenCalled();
191
+
192
+
193
+ expect(suggestion.applied).toBe(false);
194
+ expect(suggestion.changesWrapper).toBe(null);
195
+ expect(suggestion.item.classList.contains("applied")).toBe(false);
196
+ expect(suggestion.nodes[0].classList.contains("collaborative-texts-hidden")).toBe(false);
197
+ expect(suggestion.nodes[0].textContent).toBe("Some content");
198
+ expect(suggestionsList.getApplied().length).toBe(0);
199
+ expect(suggestionsList.getPending().length).toBe(2);
200
+ expect(doc.doc.innerHTML).toContain("Some content");
201
+ expect(doc.doc.innerHTML).not.toContain("This is a replacement");
202
+ expect(suggestionsList.defaultSuggestions()).not.toContain(suggestion);
203
+ expect(suggestionsList.defaultSuggestions()).toContain(firstSuggestion);
204
+ });
205
+
206
+ it("restores a suggestion and removes the changes wrapper", () => {
207
+ suggestion.apply();
208
+ suggestionsList.restore([]);
209
+ expect(suggestion.applied).toBe(true);
210
+ suggestionsList.restore([suggestion.nodes[0]], [suggestion]);
211
+ expect(suggestion.applied).toBe(true);
212
+ suggestionsList.restore([suggestion.nodes[0]]);
213
+ expect(suggestion.applied).toBe(false);
214
+ expect(suggestionsList.defaultSuggestions()).not.toContain(suggestion);
215
+ expect(suggestionsList.defaultSuggestions()).toContain(firstSuggestion);
216
+ });
217
+
218
+ it("highlights a suggestion on mouseover", () => {
219
+ suggestion.highlight();
220
+ expect(suggestion.highlightWrapper).toBeInstanceOf(HTMLElement);
221
+ expect(suggestion.highlightWrapper.textContent).toBe("This is a replacement");
222
+ expect(doc.doc.querySelector(".collaborative-texts-highlight-hidden").innerHTML).toContain("Some content");
223
+ expect(doc.doc.querySelector(".collaborative-texts-highlight").innerHTML).toContain("This is a replacement");
224
+ });
225
+
226
+ it("blurs a suggestion on mouseout", () => {
227
+ suggestion.highlight();
228
+ suggestion.blur();
229
+ expect(suggestion.highlightWrapper).toBe(null);
230
+ expect(doc.doc.querySelector(".collaborative-texts-hidden")).toBe(null);
231
+ expect(doc.doc.querySelector(".collaborative-texts-highlight")).toBe(null);
232
+ });
233
+ });
@@ -0,0 +1,70 @@
1
+ /* global jest */
2
+
3
+ import Toc from "src/decidim/collaborative_texts/toc";
4
+
5
+ describe("Toc", () => {
6
+ const content = `
7
+ <body>
8
+ <div class="collaborative-texts-toc" data-collaborative-texts-toc="collaborative-text">
9
+ <ul class="spinner-container"></ul>
10
+ </div>
11
+
12
+ <div data-collaborative-texts-document="true"
13
+ data-collaborative-texts-i18n='{}'
14
+ data-collaborative-texts-suggestions-url="#"
15
+ data-collaborative-texts-rollout-url="#">
16
+ <h2 id="node-1">This is a collaborative text</h2>
17
+ <p>Some content</p>
18
+ <div class="collaborative-texts-changes">
19
+ <h2 id="node-2">This is another title</h2>
20
+ <ul><li>More content</li><li>Event more content</li></ul>
21
+ </div>
22
+ </div>
23
+ </body>
24
+ `;
25
+
26
+ let toc = null;
27
+ let doc = null;
28
+
29
+ beforeEach(() => {
30
+ document.body.innerHTML = content;
31
+ doc = document.querySelector("[data-collaborative-texts-document]");
32
+ toc = new Toc(document.querySelector("[data-collaborative-texts-toc]"), doc);
33
+ });
34
+
35
+ it("filters text nodes and adds ids", () => {
36
+ expect(toc.headings().length).toBe(2);
37
+ expect(toc.headings()[0].id).toBe("node-1");
38
+ expect(toc.headings()[0].textContent).toBe("This is a collaborative text");
39
+ expect(toc.headings()[1].id).toBe("node-2");
40
+ expect(toc.headings()[1].textContent).toBe("This is another title");
41
+ });
42
+
43
+ it("renders the toc", () => {
44
+ toc.render();
45
+ expect(toc.ul.children.length).toBe(2);
46
+ expect(toc.ul.children[0].textContent).toBe("This is a collaborative text");
47
+ expect(toc.ul.children[1].textContent).toBe("This is another title");
48
+
49
+ });
50
+
51
+ it("scrolls to the heading on click", () => {
52
+ toc.render();
53
+ const entry = toc.ul.children[0];
54
+ const scrollIntoViewMock = jest.fn();
55
+ toc.headings()[0].scrollIntoView = scrollIntoViewMock;
56
+ entry.click();
57
+ expect(window.location.hash).toBe("#node-1");
58
+ expect(toc.headings()[0].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth" });
59
+ });
60
+
61
+ it("binds applied event", () => {
62
+ doc.dispatchEvent(new Event("collaborative-texts:applied"));
63
+ expect(toc.ul.children.length).toBe(2);
64
+ });
65
+
66
+ it("binds restored event", () => {
67
+ doc.dispatchEvent(new Event("collaborative-texts:restored"));
68
+ expect(toc.ul.children.length).toBe(2);
69
+ });
70
+ });
@@ -0,0 +1,48 @@
1
+ export default class Toc {
2
+ constructor(toc, doc) {
3
+ this.toc = toc;
4
+ this.doc = doc;
5
+ this.ul = toc.getElementsByTagName("ul")[0];
6
+ this._bindEvents();
7
+ }
8
+
9
+ headings() {
10
+ this.nodes = [];
11
+ this.doc.querySelectorAll("*> h2:not(.collaborative-texts-hidden), *> div.collaborative-texts-changes> h2").forEach((node) => {
12
+ if (node.nodeName === "H2") {
13
+ this.nodes.push(node);
14
+ }
15
+ });
16
+ return this.nodes;
17
+ }
18
+
19
+ render() {
20
+ this.ul.innerHTML = "";
21
+ this.ul.classList.remove("spinner-container");
22
+ this.headings().forEach((heading) => {
23
+ this.ul.appendChild(this.createEntry(heading));
24
+ });
25
+ }
26
+
27
+ createEntry(heading) {
28
+ let entry = window.document.createElement("li");
29
+ entry.textContent = heading.textContent;
30
+ entry.addEventListener("click", this._onClick.bind(this));
31
+ return entry;
32
+ }
33
+
34
+ _onClick(event) {
35
+ event.preventDefault();
36
+ let entry = event.currentTarget;
37
+ let heading = this.headings().find((el) => el.textContent === entry.textContent);
38
+ if (heading) {
39
+ history.replaceState(null, null, `#${heading.id}`);
40
+ heading.scrollIntoView({ behavior: "smooth" });
41
+ }
42
+ }
43
+
44
+ _bindEvents() {
45
+ this.doc.addEventListener("collaborative-texts:applied", this.render.bind(this));
46
+ this.doc.addEventListener("collaborative-texts:restored", this.render.bind(this));
47
+ }
48
+ }