decidim-core 0.32.0.rc3 → 0.32.0

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/author_cell.rb +0 -4
  3. data/app/cells/decidim/content_blocks/highlighted_content_banner_cell.rb +1 -1
  4. data/app/cells/decidim/content_blocks/highlighted_participatory_spaces_cell.rb +1 -1
  5. data/app/commands/decidim/destroy_account.rb +12 -1
  6. data/app/commands/decidim/multiple_attachments_methods.rb +28 -27
  7. data/app/jobs/decidim/process_inactive_participant_job.rb +0 -7
  8. data/app/mailers/decidim/delete_user_mailer.rb +14 -0
  9. data/app/mailers/decidim/participants_account_mailer.rb +0 -16
  10. data/app/packs/src/decidim/controllers/main_menu/controller.js +33 -0
  11. data/app/packs/src/decidim/controllers/main_menu/main_menu.test.js +77 -0
  12. data/app/packs/src/decidim/controllers/mention/controller.js +296 -140
  13. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +120 -457
  14. data/app/packs/src/decidim/controllers/multiple_mentions/controller.js +68 -32
  15. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +30 -23
  16. data/app/packs/src/decidim/editor/common/suggestion.js +3 -1
  17. data/app/packs/src/decidim/editor/extensions/indent/index.js +9 -0
  18. data/app/packs/src/decidim/geocoding/reverse_geocoding.js +15 -5
  19. data/app/packs/src/decidim/geocoding/reverse_geocoding.test.js +197 -0
  20. data/app/packs/src/decidim/index.js +2 -2
  21. data/app/packs/stylesheets/decidim/_conversations.scss +14 -0
  22. data/app/packs/stylesheets/decidim/_dropdown.scss +1 -1
  23. data/app/packs/stylesheets/decidim/_editor_suggestions.scss +49 -0
  24. data/app/packs/stylesheets/decidim/_header.scss +12 -8
  25. data/app/packs/stylesheets/decidim/_tom_select.scss +23 -0
  26. data/app/packs/stylesheets/decidim/application.scss +2 -0
  27. data/app/packs/stylesheets/decidim/editor.scss +2 -33
  28. data/app/packs/stylesheets/decidim/geocoding_addons.scss +10 -2
  29. data/app/uploaders/decidim/image_uploader.rb +1 -1
  30. data/app/views/decidim/delete_user_mailer/delete.html.erb +6 -0
  31. data/app/views/decidim/messaging/conversations/_error_modal.html.erb +11 -19
  32. data/app/views/decidim/messaging/conversations/error.js.erb +12 -7
  33. data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile.html.erb +2 -1
  34. data/config/locales/ar.yml +0 -2
  35. data/config/locales/bg.yml +0 -2
  36. data/config/locales/ca-IT.yml +21 -10
  37. data/config/locales/ca.yml +21 -10
  38. data/config/locales/cs.yml +10 -9
  39. data/config/locales/de.yml +4 -13
  40. data/config/locales/el.yml +0 -1
  41. data/config/locales/en.yml +20 -9
  42. data/config/locales/es-MX.yml +20 -9
  43. data/config/locales/es-PY.yml +20 -9
  44. data/config/locales/es.yml +20 -9
  45. data/config/locales/eu.yml +36 -9
  46. data/config/locales/fi-plain.yml +19 -8
  47. data/config/locales/fi.yml +19 -8
  48. data/config/locales/fr-CA.yml +23 -9
  49. data/config/locales/fr.yml +23 -9
  50. data/config/locales/hu.yml +0 -2
  51. data/config/locales/it.yml +0 -2
  52. data/config/locales/ja.yml +59 -19
  53. data/config/locales/lb.yml +0 -2
  54. data/config/locales/lt.yml +0 -2
  55. data/config/locales/nl.yml +0 -2
  56. data/config/locales/no.yml +0 -2
  57. data/config/locales/pl.yml +2 -4
  58. data/config/locales/pt-BR.yml +2 -11
  59. data/config/locales/pt.yml +0 -2
  60. data/config/locales/ro-RO.yml +1 -10
  61. data/config/locales/sk.yml +1 -10
  62. data/config/locales/sv.yml +0 -9
  63. data/config/locales/tr-TR.yml +0 -2
  64. data/config/locales/zh-CN.yml +0 -2
  65. data/config/locales/zh-TW.yml +0 -2
  66. data/decidim-core.gemspec +1 -1
  67. data/lib/decidim/attachment_attributes.rb +58 -9
  68. data/lib/decidim/command.rb +1 -1
  69. data/lib/decidim/core/content_blocks/registry_manager.rb +4 -4
  70. data/lib/decidim/core/engine.rb +8 -0
  71. data/lib/decidim/core/test/factories.rb +3 -0
  72. data/lib/decidim/core/test/shared_examples/admin_resource_gallery_examples.rb +10 -10
  73. data/lib/decidim/core/test/shared_examples/comments_examples.rb +6 -6
  74. data/lib/decidim/core/version.rb +1 -1
  75. data/lib/decidim/map/autocomplete.rb +4 -3
  76. data/lib/decidim/searchable.rb +5 -0
  77. data/lib/decidim/view_model.rb +1 -1
  78. data/lib/tasks/decidim_mailers_tasks.rake +31 -9
  79. metadata +10 -9
  80. data/app/commands/decidim/gallery_methods.rb +0 -107
  81. data/app/packs/src/decidim/vendor/tribute.js +0 -1890
  82. data/app/packs/stylesheets/decidim/_tribute.scss +0 -36
  83. 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
 
@@ -134,7 +134,9 @@ export const createSuggestionRenderer = (node, { itemConverter } = {}) => () =>
134
134
  }
135
135
  },
136
136
 
137
- onUpdate({ clientRect, items }) {
137
+ onUpdate({ clientRect, items, command }) {
138
+ selectCommand = command;
139
+
138
140
  if (!clientRect || !suggestion) {
139
141
  return;
140
142
  }
@@ -170,6 +170,15 @@ export default Extension.create({
170
170
  "Shift-Tab": outdent,
171
171
  Backspace: () => {
172
172
  if (this.editor.isActive("listItem")) {
173
+ // When at the start of a list item, join the list items
174
+ // together using joinItemBackward which properly merges
175
+ // inline content. This ensures the items are merged
176
+ // correctly regardless of how the browser handles the
177
+ // event.
178
+ if (this.editor.state.selection.$head.parentOffset === 0) {
179
+ return this.editor.commands.joinItemBackward();
180
+ }
181
+
173
182
  return false;
174
183
  }
175
184
 
@@ -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
+ });
@@ -60,6 +60,7 @@ window.Decidim = window.Decidim || {
60
60
  announceForScreenReader
61
61
  };
62
62
 
63
+ window.createDialog = createDialog;
63
64
  window.morphdom = morphdom
64
65
 
65
66
  // eslint-disable-next-line max-params
@@ -236,8 +237,7 @@ const initializer = (element = document) => {
236
237
  document.dispatchEvent(new CustomEvent("decidim:loaded", { detail: { element } }));
237
238
  }
238
239
 
239
- // If no jQuery is used the Tribute feature used in comments to autocomplete
240
- // mentions stops working
240
+ // Keep this under jQuery ready to support components initialized on legacy templates
241
241
  $(() => initializer());
242
242
 
243
243
  // Run initializer action over the new DOM elements
@@ -88,6 +88,20 @@
88
88
  }
89
89
 
90
90
  &__modal {
91
+ &-error {
92
+ [data-dialog-closable] {
93
+ @apply top-6 right-6;
94
+ }
95
+
96
+ [data-dialog-container] {
97
+ @apply inline grid-cols-none;
98
+ }
99
+
100
+ [data-dialog-title] {
101
+ @apply col-span-2 md:col-span-1 md:col-start-2 grid grid-cols-[auto_1fr] items-start md:items-center gap-2 pb-4 text-left text-xl text-gray-2 md:border-b md:border-gray-3;
102
+ }
103
+ }
104
+
91
105
  &-results {
92
106
  @apply mb-20 flex flex-wrap gap-x-8 gap-y-4;
93
107
  }
@@ -1,5 +1,5 @@
1
1
  [id*="dropdown-menu"] {
2
- @apply flex flex-col py-0 mx-3.5 md:mx-0 border-t-0 border-gray-3 cursor-pointer;
2
+ @apply flex flex-col py-0 mx-3.5 md:mx-0 border-t-0 border-gray-3;
3
3
 
4
4
  &[aria-hidden="true"] {
5
5
  @apply hidden md:flex;
@@ -0,0 +1,49 @@
1
+ .editor-suggestions-props {
2
+ --editor-suggestions-border-color: #000;
3
+ --editor-suggestions-background-color: #eee;
4
+ --editor-suggestions-selected-highlight-color: #ccc;
5
+ }
6
+
7
+ .editor-suggestions {
8
+ @apply editor-suggestions-props border-0 bg-[var(--editor-suggestions-background-color)] max-w-sm drop-shadow-md rounded-md;
9
+
10
+ &:hover {
11
+ .editor-suggestions-item[data-selected]:not(:hover) {
12
+ @apply bg-transparent;
13
+ }
14
+ }
15
+
16
+ .editor-suggestions-item {
17
+ @apply flex items-center gap-2 w-full text-left py-[0.375rem] px-1.5 border-0 rounded-none text-sm;
18
+
19
+ &:first-child {
20
+ @apply rounded-t-md;
21
+ }
22
+
23
+ &:last-child {
24
+ @apply rounded-b-md;
25
+ }
26
+
27
+ &:hover,
28
+ &[data-selected] {
29
+ @apply bg-[var(--editor-suggestions-selected-highlight-color)];
30
+ }
31
+
32
+ .editor-suggestions-item-avatar {
33
+ @apply rounded-full w-6 h-6 object-cover flex-none;
34
+ }
35
+
36
+ .editor-suggestions-item-label {
37
+ @apply truncate;
38
+ }
39
+ }
40
+
41
+ .editor-suggestions-item-disabled {
42
+ @apply cursor-default;
43
+
44
+ &:hover,
45
+ &[data-selected] {
46
+ @apply bg-transparent;
47
+ }
48
+ }
49
+ }