decidim-core 0.31.5 → 0.31.6

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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/amendable/amend_button_card/show.erb +2 -2
  3. data/app/cells/decidim/author_cell.rb +0 -4
  4. data/app/cells/decidim/content_blocks/cta_settings_form/show.erb +1 -1
  5. data/app/cells/decidim/content_blocks/hero_settings_form/show.erb +2 -2
  6. data/app/cells/decidim/content_blocks/highlighted_content_banner_cell.rb +1 -1
  7. data/app/cells/decidim/content_blocks/highlighted_content_banner_settings_form/show.erb +1 -1
  8. data/app/cells/decidim/content_blocks/highlighted_participatory_spaces_cell.rb +1 -1
  9. data/app/cells/decidim/content_blocks/participatory_space_hero_settings_form/show.erb +1 -1
  10. data/app/cells/decidim/content_blocks/static_page/section_settings_form/show.erb +3 -1
  11. data/app/cells/decidim/content_blocks/static_page/summary_settings_form/show.erb +3 -1
  12. data/app/cells/decidim/content_blocks/static_page/two_pane_section_settings_form/show.erb +4 -2
  13. data/app/cells/decidim/resource_types_filter/show.erb +2 -2
  14. data/app/commands/decidim/destroy_account.rb +12 -1
  15. data/app/commands/decidim/multiple_attachments_methods.rb +28 -27
  16. data/app/controllers/concerns/decidim/devise_controllers.rb +10 -0
  17. data/app/jobs/decidim/process_inactive_participant_job.rb +0 -7
  18. data/app/mailers/decidim/delete_user_mailer.rb +14 -0
  19. data/app/mailers/decidim/participants_account_mailer.rb +0 -16
  20. data/app/models/decidim/moderation.rb +1 -1
  21. data/app/models/decidim/participatory_space_private_user.rb +1 -1
  22. data/app/models/decidim/user_base_entity.rb +17 -2
  23. data/app/models/decidim/user_moderation.rb +1 -1
  24. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +6 -6
  25. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +23 -1
  26. data/app/packs/src/decidim/controllers/mention/controller.js +296 -140
  27. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +120 -457
  28. data/app/packs/src/decidim/controllers/multiple_mentions/controller.js +68 -32
  29. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +30 -23
  30. data/app/packs/src/decidim/direct_uploads/upload_field.js +4 -4
  31. data/app/packs/src/decidim/direct_uploads/upload_modal.js +11 -7
  32. data/app/packs/src/decidim/geocoding/reverse_geocoding.js +15 -5
  33. data/app/packs/src/decidim/geocoding/reverse_geocoding.test.js +197 -0
  34. data/app/packs/src/decidim/index.js +4 -3
  35. data/app/packs/src/decidim/sw/sw.js +1 -1
  36. data/app/packs/src/decidim/utilities/text.js +6 -6
  37. data/app/packs/stylesheets/decidim/_conversations.scss +14 -0
  38. data/app/packs/stylesheets/decidim/_editor_suggestions.scss +49 -0
  39. data/app/packs/stylesheets/decidim/_floating_help.scss +1 -1
  40. data/app/packs/stylesheets/decidim/_tom_select.scss +23 -0
  41. data/app/packs/stylesheets/decidim/application.scss +2 -0
  42. data/app/packs/stylesheets/decidim/editor.scss +2 -33
  43. data/app/packs/stylesheets/decidim/geocoding_addons.scss +10 -2
  44. data/app/presenters/decidim/menu_item_presenter.rb +9 -3
  45. data/app/presenters/decidim/stats_presenter.rb +1 -1
  46. data/app/uploaders/decidim/image_uploader.rb +1 -1
  47. data/app/views/decidim/delete_user_mailer/delete.html.erb +6 -0
  48. data/app/views/decidim/gamification/badges/index.html.erb +1 -1
  49. data/app/views/decidim/homepage/show.html.erb +1 -1
  50. data/app/views/decidim/last_activities/index.html.erb +1 -1
  51. data/app/views/decidim/messaging/conversations/_error_modal.html.erb +11 -19
  52. data/app/views/decidim/messaging/conversations/error.js.erb +12 -7
  53. data/app/views/decidim/newsletters/unsubscribe.html.erb +1 -1
  54. data/app/views/decidim/offline/show.html.erb +1 -1
  55. data/app/views/decidim/pages/index.html.erb +1 -1
  56. data/app/views/decidim/profiles/show.html.erb +1 -1
  57. data/app/views/decidim/shared/_resource_actions.html.erb +4 -4
  58. data/app/views/decidim/user_activities/index.html.erb +1 -1
  59. data/app/views/layouts/decidim/_wrapper.html.erb +2 -2
  60. data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile_tablet.html.erb +1 -1
  61. data/app/views/layouts/decidim/shared/_layout_center.html.erb +1 -1
  62. data/app/views/layouts/decidim/shared/_layout_item.html.erb +1 -1
  63. data/app/views/layouts/decidim/shared/_layout_two_col.html.erb +1 -1
  64. data/config/locales/ar.yml +0 -5
  65. data/config/locales/bg.yml +0 -6
  66. data/config/locales/ca-IT.yml +9 -10
  67. data/config/locales/ca.yml +9 -10
  68. data/config/locales/cs.yml +2 -13
  69. data/config/locales/de.yml +5 -17
  70. data/config/locales/el.yml +0 -4
  71. data/config/locales/en.yml +8 -9
  72. data/config/locales/es-MX.yml +8 -9
  73. data/config/locales/es-PY.yml +8 -9
  74. data/config/locales/es.yml +8 -9
  75. data/config/locales/eu.yml +48 -49
  76. data/config/locales/fi-plain.yml +9 -9
  77. data/config/locales/fi.yml +9 -9
  78. data/config/locales/fr-CA.yml +9 -9
  79. data/config/locales/fr.yml +9 -9
  80. data/config/locales/gl.yml +0 -3
  81. data/config/locales/hu.yml +0 -5
  82. data/config/locales/id-ID.yml +0 -3
  83. data/config/locales/it.yml +1 -3
  84. data/config/locales/ja.yml +24 -21
  85. data/config/locales/lb.yml +0 -5
  86. data/config/locales/lt.yml +0 -5
  87. data/config/locales/lv.yml +0 -3
  88. data/config/locales/nl.yml +0 -5
  89. data/config/locales/no.yml +0 -5
  90. data/config/locales/pl.yml +2 -8
  91. data/config/locales/pt-BR.yml +2 -15
  92. data/config/locales/pt.yml +0 -5
  93. data/config/locales/ro-RO.yml +1 -14
  94. data/config/locales/sk.yml +1 -14
  95. data/config/locales/sv.yml +2 -11
  96. data/config/locales/tr-TR.yml +0 -6
  97. data/config/locales/zh-CN.yml +0 -5
  98. data/config/locales/zh-TW.yml +0 -5
  99. data/lib/decidim/api/functions/user_entity_list.rb +2 -0
  100. data/lib/decidim/attachment_attributes.rb +58 -9
  101. data/lib/decidim/command.rb +1 -1
  102. data/lib/decidim/content_renderers/base_renderer.rb +112 -0
  103. data/lib/decidim/content_renderers/blob_renderer.rb +4 -7
  104. data/lib/decidim/content_renderers/mention_resource_renderer.rb +10 -6
  105. data/lib/decidim/content_renderers/resource_renderer.rb +16 -7
  106. data/lib/decidim/content_renderers/user_renderer.rb +11 -9
  107. data/lib/decidim/core/content_blocks/registry_manager.rb +4 -4
  108. data/lib/decidim/core/engine.rb +8 -0
  109. data/lib/decidim/core/test/factories.rb +3 -0
  110. data/lib/decidim/core/test/shared_examples/admin_resource_gallery_examples.rb +10 -10
  111. data/lib/decidim/core/test/shared_examples/comments_examples.rb +6 -6
  112. data/lib/decidim/core/version.rb +1 -1
  113. data/lib/decidim/map/autocomplete.rb +4 -3
  114. data/lib/decidim/searchable.rb +5 -0
  115. data/lib/decidim/view_model.rb +1 -1
  116. data/lib/tasks/decidim_mailers_tasks.rake +31 -9
  117. metadata +11 -10
  118. data/app/commands/decidim/gallery_methods.rb +0 -107
  119. data/app/packs/src/decidim/vendor/tribute.js +0 -1890
  120. data/app/packs/stylesheets/decidim/_tribute.scss +0 -36
  121. data/app/views/decidim/participants_account_mailer/removal_notification.html.erb +0 -11
@@ -1,5 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import AutoComplete from "src/decidim/refactor/moved/autocomplete";
2
+ import TomSelect from "tom-select/dist/cjs/tom-select.popular";
3
3
  import icon from "src/decidim/refactor/moved/icon";
4
4
 
5
5
  export default class extends Controller {
@@ -16,20 +16,6 @@ export default class extends Controller {
16
16
 
17
17
  this.initializeEmptyFocusElement();
18
18
  this.initializeAutoComplete();
19
- this.setupSelectionListener();
20
- }
21
-
22
- /**
23
- * Setup the selection event listener
24
- * @returns {void}
25
- */
26
- setupSelectionListener() {
27
- this.selectionHandler = (event) => {
28
- const feedback = event.detail;
29
- const selection = feedback.selection;
30
- this.handleSelection(selection);
31
- };
32
- this.searchInput.addEventListener("selection", this.selectionHandler);
33
19
  }
34
20
 
35
21
  /*
@@ -37,8 +23,8 @@ export default class extends Controller {
37
23
  * @returns {void}
38
24
  */
39
25
  disconnect() {
40
- if (this.searchInput && this.selectionHandler) {
41
- this.searchInput.removeEventListener("selection", this.selectionHandler);
26
+ if (this.tomSelect) {
27
+ this.tomSelect.destroy();
42
28
  }
43
29
  }
44
30
 
@@ -80,11 +66,56 @@ export default class extends Controller {
80
66
  * @returns {void}
81
67
  */
82
68
  initializeAutoComplete() {
83
- this.autoComplete = new AutoComplete(this.searchInput, {
84
- dataMatchKeys: ["name", "nickname"],
85
- dataSource: this.getDataSource.bind(this),
86
- dataFilter: this.filterResults.bind(this),
87
- modifyResult: this.modifyResult.bind(this)
69
+ this.tomSelect = new TomSelect(this.searchInput, {
70
+ maxItems: 1,
71
+ valueField: "id",
72
+ labelField: "name",
73
+ searchField: ["name", "nickname"],
74
+ loadThrottle: 200,
75
+ loadingClass: "loading",
76
+ preload: false,
77
+ highlight: true,
78
+ load: (query, callback) => {
79
+ if (!query || query.length < 2) {
80
+ callback();
81
+ return;
82
+ }
83
+ this.getDataSource(query, (results) => {
84
+ const filtered = this.filterResults(results);
85
+ filtered.forEach((item) => {
86
+ if (item.directMessagesEnabled === "false") {
87
+ item.disabled = true;
88
+ }
89
+ });
90
+ callback(filtered);
91
+ });
92
+ },
93
+ render: {
94
+ option: (data, escape) => {
95
+ const isDisabled = data.directMessagesEnabled === "false";
96
+ const className = isDisabled
97
+ ? "disabled"
98
+ : "";
99
+ const disabledMsg = isDisabled
100
+ ? `<small>${escape(this.searchInput.dataset.directMessagesDisabled)}</small>`
101
+ : "";
102
+ return `<div class="${className}">
103
+ <img src="${escape(data.avatarUrl)}" alt="${escape(data.name)}">
104
+ <span>${escape(data.nickname)}</span>
105
+ <small>${escape(data.name)}</small>
106
+ ${disabledMsg}
107
+ </div>`;
108
+ },
109
+ "no_results": () => `<div class="no-results">${this.searchInput.dataset.noresults || ""}</div>`
110
+ },
111
+ onChange: (value) => {
112
+ if (value) {
113
+ const option = this.tomSelect.options[value];
114
+ this.handleSelection({ value: option });
115
+ this.tomSelect.clear();
116
+ this.tomSelect.clearOptions();
117
+ }
118
+ }
88
119
  });
89
120
  }
90
121
 
@@ -129,7 +160,7 @@ export default class extends Controller {
129
160
  */
130
161
  filterResults(list) {
131
162
  return list.filter(
132
- (item) => !this.selected.includes(item.value.id)
163
+ (item) => !this.selected.includes(item.id)
133
164
  );
134
165
  }
135
166
 
@@ -161,18 +192,23 @@ export default class extends Controller {
161
192
  */
162
193
  handleSelection(selection) {
163
194
  const id = selection.value.id;
164
- // Check if we have reached the maximum limit or if direct messages are disabled
165
195
  if (this.isMaxLimitReached() || selection.value.directMessagesEnabled === "false") {
166
196
  return;
167
197
  }
168
198
 
169
199
  this.addSelectedUser(selection, id);
170
- this.autoComplete.setInput("");
171
200
  this.selected.push(id);
201
+ }
172
202
 
173
- if (this.autoComplete && this.autoComplete.autocomplete) {
174
- this.autoComplete.autocomplete.close();
175
- }
203
+ /**
204
+ * Escape HTML characters in a string
205
+ * @param {string} str - The string to escape
206
+ * @returns {string} The escaped HTML string
207
+ */
208
+ htmlEscape(str) {
209
+ const div = document.createElement("div");
210
+ div.appendChild(document.createTextNode(str));
211
+ return div.innerHTML;
176
212
  }
177
213
 
178
214
  /**
@@ -187,10 +223,10 @@ export default class extends Controller {
187
223
  const listItem = document.createElement("li");
188
224
  listItem.tabIndex = "-1";
189
225
  listItem.innerHTML = `
190
- <input type="hidden" name="${this.options.name}" value="${id}">
191
- <img src="${selection.value.avatarUrl}" alt="${selection.value.name}">
192
- <span>${selection.value.name}</span>
193
- <button type="button" data-remove="${id}" tabindex="0" aria-controls="0" aria-label="${label}">${icon("delete-bin-line")}</button>
226
+ <input type="hidden" name="${this.htmlEscape(this.options.name)}" value="${this.htmlEscape(id)}">
227
+ <img src="${this.htmlEscape(selection.value.avatarUrl)}" alt="${this.htmlEscape(selection.value.name)}">
228
+ <span>${this.htmlEscape(selection.value.name)}</span>
229
+ <button type="button" data-remove="${this.htmlEscape(id)}" tabindex="0" aria-controls="0" aria-label="${this.htmlEscape(label)}">${icon("delete-bin-line")}</button>
194
230
  `;
195
231
 
196
232
  this.selectedItems.appendChild(listItem);
@@ -7,13 +7,14 @@
7
7
  import { Application } from "@hotwired/stimulus"
8
8
  import MultipleMentionsController from "src/decidim/controllers/multiple_mentions/controller"
9
9
 
10
- const AutoComplete = require("src/decidim/refactor/moved/autocomplete");
10
+ const TomSelect = require("tom-select/dist/cjs/tom-select.popular");
11
11
  const iconMock = require("src/decidim/refactor/moved/icon");
12
12
 
13
13
  // Mock the dependencies
14
- jest.mock("src/decidim/refactor/moved/autocomplete", () => {
14
+ jest.mock("tom-select/dist/cjs/tom-select.popular", () => {
15
15
  return jest.fn().mockImplementation(() => ({
16
- setInput: jest.fn()
16
+ destroy: jest.fn(),
17
+ clearOptions: jest.fn()
17
18
  }));
18
19
  });
19
20
 
@@ -114,12 +115,22 @@ describe("MultipleMentionsManager", () => {
114
115
  expect(controller.emptyFocusElement).toBe(existingElement);
115
116
  });
116
117
 
117
- it("should initialize AutoComplete with correct configuration", () => {
118
- expect(AutoComplete).toHaveBeenCalledWith(searchInput, {
119
- dataMatchKeys: ["name", "nickname"],
120
- dataSource: expect.any(Function),
121
- dataFilter: expect.any(Function),
122
- modifyResult: expect.any(Function)
118
+ it("should initialize TomSelect with correct configuration", () => {
119
+ expect(TomSelect).toHaveBeenCalledWith(searchInput, {
120
+ maxItems: 1,
121
+ valueField: "id",
122
+ labelField: "name",
123
+ searchField: ["name", "nickname"],
124
+ loadThrottle: 200,
125
+ loadingClass: "loading",
126
+ preload: false,
127
+ highlight: true,
128
+ load: expect.any(Function),
129
+ render: {
130
+ option: expect.any(Function),
131
+ "no_results": expect.any(Function)
132
+ },
133
+ onChange: expect.any(Function)
123
134
  });
124
135
  });
125
136
  });
@@ -226,24 +237,24 @@ describe("MultipleMentionsManager", () => {
226
237
  controller.selected = ["1", "3"];
227
238
 
228
239
  const list = [
229
- { value: { id: "1" } },
230
- { value: { id: "2" } },
231
- { value: { id: "3" } },
232
- { value: { id: "4" } }
240
+ { id: "1" },
241
+ { id: "2" },
242
+ { id: "3" },
243
+ { id: "4" }
233
244
  ];
234
245
 
235
246
  const filtered = controller.filterResults(list);
236
247
 
237
248
  expect(filtered).toEqual([
238
- { value: { id: "2" } },
239
- { value: { id: "4" } }
249
+ { id: "2" },
250
+ { id: "4" }
240
251
  ]);
241
252
  });
242
253
 
243
254
  it("should return all users when none are selected", () => {
244
255
  const list = [
245
- { value: { id: "1" } },
246
- { value: { id: "2" } }
256
+ { id: "1" },
257
+ { id: "2" }
247
258
  ];
248
259
 
249
260
  const filtered = controller.filterResults(list);
@@ -255,8 +266,8 @@ describe("MultipleMentionsManager", () => {
255
266
  controller.selected = ["1", "2"];
256
267
 
257
268
  const list = [
258
- { value: { id: "1" } },
259
- { value: { id: "2" } }
269
+ { id: "1" },
270
+ { id: "2" }
260
271
  ];
261
272
 
262
273
  const filtered = controller.filterResults(list);
@@ -330,7 +341,6 @@ describe("MultipleMentionsManager", () => {
330
341
 
331
342
  expect(controller.selected).toContain("1");
332
343
  expect(selectedItems.children.length).toBe(1);
333
- expect(controller.autoComplete.setInput).toHaveBeenCalledWith("");
334
344
  });
335
345
 
336
346
  it("should not add user when max limit is reached", () => {
@@ -350,7 +360,6 @@ describe("MultipleMentionsManager", () => {
350
360
 
351
361
  expect(controller.selected).not.toContain("10");
352
362
  expect(selectedItems.children.length).toBe(0);
353
- expect(controller.autoComplete.setInput).not.toHaveBeenCalled();
354
363
  });
355
364
 
356
365
  it("should not add user when direct messages are disabled", () => {
@@ -368,7 +377,6 @@ describe("MultipleMentionsManager", () => {
368
377
 
369
378
  expect(controller.selected).not.toContain("1");
370
379
  expect(selectedItems.children.length).toBe(0);
371
- expect(controller.autoComplete.setInput).not.toHaveBeenCalled();
372
380
  });
373
381
 
374
382
  it("should handle multiple valid selections", () => {
@@ -383,7 +391,6 @@ describe("MultipleMentionsManager", () => {
383
391
 
384
392
  expect(controller.selected).toEqual(["1", "2"]);
385
393
  expect(selectedItems.children.length).toBe(2);
386
- expect(controller.autoComplete.setInput).toHaveBeenCalledTimes(2);
387
394
  });
388
395
  });
389
396
 
@@ -1,5 +1,5 @@
1
1
  import UploadModal from "src/decidim/direct_uploads/upload_modal";
2
- import { escapeHtml, escapeQuotes } from "src/decidim/utilities/text";
2
+ import { escapeHtml } from "src/decidim/utilities/text";
3
3
 
4
4
  const updateModalTitle = (modal) => {
5
5
  if (modal.uploadItems.children.length === 0) {
@@ -67,7 +67,7 @@ const updateActiveUploads = (modal) => {
67
67
  const titleField = isMultiple
68
68
  ? `${modal.options.resourceName}[${modal.options.addAttribute}][${ix}][title]`
69
69
  : `${modal.options.resourceName}[${modal.options.addAttribute}][title]`
70
- hidden += `<input type="hidden" name="${titleField}" value="${escapeQuotes(titleValue)}" />`
70
+ hidden += `<input type="hidden" name="${titleField}" value="${escapeHtml(titleValue)}" />`
71
71
 
72
72
  title = titleValue
73
73
  }
@@ -77,8 +77,8 @@ const updateActiveUploads = (modal) => {
77
77
  : `data-hidden-field="${file.hiddenField}"`
78
78
 
79
79
  const template = `
80
- <div ${attachmentIdOrHiddenField} data-filename="${escapeQuotes(file.name)}" data-title="${escapeQuotes(title)}">
81
- ${(/image/).test(file.type) && "<div><img src=\"data:,\" role=\"presentation\" /></div>" || ""}
80
+ <div ${attachmentIdOrHiddenField} data-filename="${escapeHtml(file.name)}" data-title="${escapeHtml(title)}">
81
+ ${(/image/).test(file.type) && "<div><img src=\"data:,\" alt=\"\" /></div>" || ""}
82
82
  <p>${escapeHtml(title)}</p>
83
83
  ${hidden}
84
84
  </div>
@@ -1,6 +1,6 @@
1
1
  import { Uploader } from "src/decidim/direct_uploads/uploader";
2
2
  import icon from "src/decidim/refactor/moved/icon";
3
- import { escapeHtml, escapeQuotes } from "src/decidim/utilities/text";
3
+ import { escapeHtml } from "src/decidim/utilities/text";
4
4
 
5
5
  const STATUS = {
6
6
  VALIDATED: "validated",
@@ -170,7 +170,7 @@ export default class UploadModal {
170
170
 
171
171
  createUploadItem(file, errors, opts = {}) {
172
172
  const okTemplate = `
173
- <img src="data:,", role="presentation" />
173
+ <img src="data:," alt="" />
174
174
  <span class="upload-modal__span">${escapeHtml(file.name)}</span>
175
175
  `
176
176
 
@@ -183,16 +183,17 @@ export default class UploadModal {
183
183
  </div>
184
184
  `
185
185
 
186
+ const titleInputId = `upload-title-${this.getOrdinalNumber()}`
186
187
  const titleTemplate = `
187
- <img src="data:," role="presentation" />
188
+ <img src="data:," alt="" />
188
189
  <div>
189
190
  <div>
190
191
  <label>${this.locales.filename}</label>
191
192
  <span class="upload-modal__span">${escapeHtml(file.name)}</span>
192
193
  </div>
193
194
  <div>
194
- <label for="${file.name}">${this.locales.title}</label>
195
- <input class="sm" type="text" value="${escapeQuotes(opts.title || file.name)}" id="${file.name}" />
195
+ <label for="${titleInputId}">${this.locales.title}</label>
196
+ <input class="sm" type="text" value="${escapeHtml(opts.title || file.name)}" id="${titleInputId}" />
196
197
  </div>
197
198
  </div>
198
199
  `
@@ -216,7 +217,7 @@ export default class UploadModal {
216
217
  ? `data-attachment-id="${opts.attachmentId}"`
217
218
  : ""
218
219
  const fullTemplate = `
219
- <li ${attachmentId} data-filename="${escapeQuotes(file.name)}" data-state="${state}">
220
+ <li ${attachmentId} data-filename="${escapeHtml(file.name)}" data-state="${state}">
220
221
  <div data-template="${template}">
221
222
  ${content.trim()}
222
223
  <button>${this.locales.remove}</button>
@@ -252,7 +253,10 @@ export default class UploadModal {
252
253
  }
253
254
 
254
255
  setProgressBar(name, value) {
255
- this.uploadItems.querySelector(`[data-filename="${escapeQuotes(name)}"] progress`).value = value
256
+ const item = Array.from(this.uploadItems.querySelectorAll("[data-filename]")).find((el) => el.dataset.filename === name);
257
+ if (item) {
258
+ item.querySelector("progress").value = value;
259
+ }
256
260
  }
257
261
 
258
262
  updateAddAttachmentsButton() {
@@ -13,13 +13,24 @@ export const initializeReverseGeocoding = function() {
13
13
 
14
14
  const setLocating = (button, enable) => {
15
15
  if (enable) {
16
+ button.dataset.originalContent = button.innerHTML;
17
+ button.textContent = "";
18
+ const spinner = document.createElement("span");
19
+ spinner.className = "geocoding__spinner";
20
+ button.appendChild(spinner);
21
+ button.append(` ${button.dataset.locatingText || "Locating..."}`);
16
22
  button.setAttribute("disabled", true);
23
+ button.classList.add("geocoding__button--locating");
17
24
  } else {
25
+ if (button.dataset.originalContent) {
26
+ button.innerHTML = button.dataset.originalContent;
27
+ }
18
28
  button.removeAttribute("disabled");
29
+ button.classList.remove("geocoding__button--locating");
19
30
  }
20
31
  };
21
32
 
22
- document.querySelectorAll(".user-device-location button").forEach((button) => {
33
+ document.querySelectorAll(".geocoding__button").forEach((button) => {
23
34
  button.addEventListener("click", (event) => {
24
35
  const target = event.target;
25
36
  if (target.disabled) {
@@ -36,19 +47,18 @@ export const initializeReverseGeocoding = function() {
36
47
  navigator.geolocation.getCurrentPosition((position) => {
37
48
  const coordinates = [position.coords.latitude, position.coords.longitude];
38
49
 
39
- // reverse geolocation
40
50
  $.post(url, { latitude: coordinates[0], longitude: coordinates[1] }, (data) => {
41
51
  input.value = data.address;
42
52
  $(input).trigger("geocoder-suggest-coordinates.decidim", [coordinates]);
53
+ setLocating(target, false);
43
54
  }).fail((xhr, status, error) => {
44
55
  info(input, `${errorNoLocation} ${error}`);
56
+ setLocating(target, false);
45
57
  });
46
58
 
47
- setLocating(target, false);
48
-
49
59
  }, (evt) => {
50
60
  info(input, `${errorNoLocation} ${evt.message}`);
51
- target.removeAttribute("disabled");
61
+ setLocating(target, false);
52
62
  }, {
53
63
  enableHighAccuracy: true
54
64
  });
@@ -0,0 +1,197 @@
1
+ /* global global, jest */
2
+
3
+ import { initializeReverseGeocoding } from "src/decidim/geocoding/reverse_geocoding";
4
+
5
+ describe("reverseGeocoding", () => {
6
+ let container = null;
7
+ let button = null;
8
+ let input = null;
9
+ let label = null;
10
+ let mockGeolocation = null;
11
+ let mockPost = null;
12
+ let originalGeolocation = null;
13
+
14
+ beforeEach(() => {
15
+ document.body.innerHTML = "";
16
+
17
+ container = document.createElement("div");
18
+ container.innerHTML = `
19
+ <label>
20
+ <input id="test_address" type="text" />
21
+ <div class="geocoding__locate">
22
+ <button
23
+ class="geocoding__button"
24
+ type="button"
25
+ data-input="test_address"
26
+ data-error-no-location="Could not detect location."
27
+ data-error-unsupported="Device not supported."
28
+ data-locating-text="Locating..."
29
+ data-url="/locate">
30
+ Use my location
31
+ </button>
32
+ </div>
33
+ </label>
34
+ `;
35
+ document.body.appendChild(container);
36
+
37
+ button = container.querySelector(".geocoding__button");
38
+ input = container.querySelector("#test_address");
39
+ label = container.querySelector("label");
40
+
41
+ originalGeolocation = navigator.geolocation;
42
+ mockGeolocation = {
43
+ getCurrentPosition: jest.fn()
44
+ };
45
+ Reflect.defineProperty(global.navigator, "geolocation", {
46
+ value: mockGeolocation,
47
+ configurable: true
48
+ });
49
+
50
+ mockPost = jest.fn();
51
+ global.jQuery = jest.fn(() => ({ trigger: jest.fn() }));
52
+ global.jQuery.post = mockPost;
53
+ // eslint-disable-next-line id-length
54
+ global.$ = global.jQuery;
55
+
56
+ initializeReverseGeocoding();
57
+ });
58
+
59
+ afterEach(() => {
60
+ Reflect.defineProperty(global.navigator, "geolocation", {
61
+ value: originalGeolocation,
62
+ configurable: true
63
+ });
64
+ jest.restoreAllMocks();
65
+ });
66
+
67
+ describe("when clicking the button", () => {
68
+ it("shows the spinner and disables the button", () => {
69
+ mockGeolocation.getCurrentPosition.mockImplementation(() => {});
70
+
71
+ button.click();
72
+
73
+ expect(button.disabled).toBe(true);
74
+ expect(button.classList.contains("geocoding__button--locating")).toBe(true);
75
+ expect(button.querySelector(".geocoding__spinner")).not.toBeNull();
76
+ expect(button.textContent).toContain("Locating...");
77
+ });
78
+
79
+ it("uses custom locating text from data attribute", () => {
80
+ button.dataset.locatingText = "Finding you...";
81
+ mockGeolocation.getCurrentPosition.mockImplementation(() => {});
82
+
83
+ button.click();
84
+
85
+ expect(button.textContent).toContain("Finding you...");
86
+ });
87
+
88
+ it("stores original content for restoration", () => {
89
+ mockGeolocation.getCurrentPosition.mockImplementation(() => {});
90
+
91
+ button.click();
92
+
93
+ expect(button.dataset.originalContent).toContain("Use my location");
94
+ });
95
+ });
96
+
97
+ describe("when geolocation succeeds", () => {
98
+ it("restores the button after successful reverse geocoding", () => {
99
+ const mockPosition = {
100
+ coords: { latitude: 40.7128, longitude: -74.006 }
101
+ };
102
+ mockGeolocation.getCurrentPosition.mockImplementation((success) => {
103
+ success(mockPosition);
104
+ });
105
+
106
+ const mockDeferred = { fail: jest.fn() };
107
+ mockPost.mockImplementation((url, data, callback) => {
108
+ callback({ address: "New York, NY, USA" });
109
+ return mockDeferred;
110
+ });
111
+
112
+ button.click();
113
+
114
+ expect(button.disabled).toBe(false);
115
+ expect(button.classList.contains("geocoding__button--locating")).toBe(false);
116
+ expect(button.querySelector(".geocoding__spinner")).toBeNull();
117
+ expect(button.textContent).toContain("Use my location");
118
+ expect(input.value).toBe("New York, NY, USA");
119
+ });
120
+
121
+ it("restores the button when reverse geocoding fails", () => {
122
+ const mockPosition = {
123
+ coords: { latitude: 40.7128, longitude: -74.006 }
124
+ };
125
+ mockGeolocation.getCurrentPosition.mockImplementation((success) => {
126
+ success(mockPosition);
127
+ });
128
+
129
+ const mockDeferred = { fail: jest.fn((callback) => {
130
+ callback({}, "error", "Not Found");
131
+ }) };
132
+ mockPost.mockReturnValue(mockDeferred);
133
+
134
+ button.click();
135
+
136
+ expect(button.disabled).toBe(false);
137
+ expect(button.classList.contains("geocoding__button--locating")).toBe(false);
138
+ expect(button.textContent).toContain("Use my location");
139
+ });
140
+ });
141
+
142
+ describe("when geolocation fails", () => {
143
+ it("restores the button when user denies permission", () => {
144
+ const mockError = { message: "User denied geolocation" };
145
+ mockGeolocation.getCurrentPosition.mockImplementation((success, error) => {
146
+ error(mockError);
147
+ });
148
+
149
+ button.click();
150
+
151
+ expect(button.disabled).toBe(false);
152
+ expect(button.classList.contains("geocoding__button--locating")).toBe(false);
153
+ expect(button.textContent).toContain("Use my location");
154
+ });
155
+
156
+ it("shows an error message", () => {
157
+ const mockError = { message: "User denied geolocation" };
158
+ mockGeolocation.getCurrentPosition.mockImplementation((success, error) => {
159
+ error(mockError);
160
+ });
161
+
162
+ button.click();
163
+
164
+ const errorElement = label.querySelector(".form-error");
165
+ expect(errorElement).not.toBeNull();
166
+ expect(errorElement.textContent).toContain("Could not detect location.");
167
+ });
168
+ });
169
+
170
+ describe("when geolocation is not supported", () => {
171
+ it("shows an unsupported error and does not show spinner", () => {
172
+ Reflect.defineProperty(global.navigator, "geolocation", {
173
+ value: null,
174
+ configurable: true
175
+ });
176
+
177
+ button.click();
178
+
179
+ expect(button.disabled).toBe(false);
180
+ expect(button.classList.contains("geocoding__button--locating")).toBe(false);
181
+ const errorElement = label.querySelector(".form-error");
182
+ expect(errorElement).not.toBeNull();
183
+ expect(errorElement.textContent).toContain("Device not supported.");
184
+ });
185
+ });
186
+
187
+ describe("when button is already disabled", () => {
188
+ it("does not trigger geolocation again", () => {
189
+ button.disabled = true;
190
+ mockGeolocation.getCurrentPosition.mockImplementation(() => {});
191
+
192
+ button.click();
193
+
194
+ expect(mockGeolocation.getCurrentPosition).not.toHaveBeenCalled();
195
+ });
196
+ });
197
+ });
@@ -34,7 +34,6 @@ import "src/decidim/security/selfxss_warning"
34
34
  import "src/decidim/session_timeouter"
35
35
  import "src/decidim/results_listing"
36
36
  import "src/decidim/data_consent"
37
- import "src/decidim/sw"
38
37
  import "src/decidim/attachments"
39
38
  import "src/decidim/dropdown_menu"
40
39
  import "src/decidim/callout"
@@ -62,6 +61,7 @@ window.Decidim = window.Decidim || {
62
61
  announceForScreenReader
63
62
  };
64
63
 
64
+ window.createDialog = createDialog;
65
65
  window.morphdom = morphdom
66
66
 
67
67
  // eslint-disable-next-line max-params
@@ -156,6 +156,8 @@ document.addEventListener("turbo:load", () => {
156
156
  deprecationMessage(container, '.callout[role="alert"]', '.flash[role="alert"]'));
157
157
  document.querySelectorAll(".js-back-to-list").forEach((container) =>
158
158
  deprecationMessage(container, ".js-back-to-list", "NEEDS TO BE REMOVED"));
159
+ document.querySelectorAll("[data-toggler]").forEach((container) =>
160
+ deprecationMessage(container, "[data-toggler]", "Use the Stimulus toggle controller with hidden targets"));
159
161
  })
160
162
 
161
163
  // REDESIGN_PENDING: deprecated
@@ -236,8 +238,7 @@ const initializer = (element = document) => {
236
238
  document.dispatchEvent(new CustomEvent("decidim:loaded", { detail: { element } }));
237
239
  }
238
240
 
239
- // If no jQuery is used the Tribute feature used in comments to autocomplete
240
- // mentions stops working
241
+ // Keep this under jQuery ready to support components initialized on legacy templates
241
242
  $(() => initializer());
242
243
 
243
244
  // Run initializer action over the new DOM elements
@@ -53,7 +53,7 @@ self.addEventListener("notificationclick", (event) => {
53
53
  // avoid caching admin or users paths
54
54
  registerRoute(
55
55
  ({ url }) =>
56
- ["/admin/", "/users/"].some((path) => url.pathname.startsWith(path)),
56
+ ["admin", "users"].some((pathSegment) => url.pathname.split("/").includes(pathSegment)),
57
57
  new NetworkOnly()
58
58
  );
59
59
 
@@ -1,17 +1,17 @@
1
- export const escapeHtml = (text) => {
1
+ const escapeQuotes = (text) => {
2
2
  if (!text) {
3
3
  return "";
4
4
  }
5
5
 
6
- const el = document.createElement("div");
7
- el.appendChild(document.createTextNode(text));
8
- return el.innerHTML;
6
+ return text.replace(/"/g, "&quot;");
9
7
  }
10
8
 
11
- export const escapeQuotes = (text) => {
9
+ export const escapeHtml = (text) => {
12
10
  if (!text) {
13
11
  return "";
14
12
  }
15
13
 
16
- return text.replace(/"/g, "&quot;");
14
+ const el = document.createElement("div");
15
+ el.appendChild(document.createTextNode(text));
16
+ return escapeQuotes(el.innerHTML);
17
17
  }