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,6 @@
1
+ /* eslint max-lines: ["error", 400] */
2
+
1
3
  import { Controller } from "@hotwired/stimulus"
2
- import Tribute from "src/decidim/vendor/tribute";
3
4
 
4
5
  export default class extends Controller {
5
6
  connect() {
@@ -9,7 +10,12 @@ export default class extends Controller {
9
10
  menuItemLimit: 5
10
11
  };
11
12
 
12
- this.tribute = null;
13
+ this.suggestion = null;
14
+ this.suggestions = [];
15
+ this.selectedIndex = -1;
16
+ this.isActive = false;
17
+ this.currentMentionStart = null;
18
+ this.requestId = 0;
13
19
  this.isInitialized = false;
14
20
 
15
21
  // Prevent initialization inside editor components
@@ -17,100 +23,62 @@ export default class extends Controller {
17
23
  return;
18
24
  }
19
25
 
20
- if (this.element.hasAttribute("data-tribute")) {
21
- this.element.removeAttribute("data-tribute");
22
- }
23
-
24
- this.createTribute();
26
+ this.createSuggestionContainer();
25
27
  this.setupEventListeners();
26
28
  this.isInitialized = true;
27
29
  }
28
30
 
29
31
  disconnect() {
30
- if (this.tribute) {
31
- this.tribute.detach(this.element);
32
+ if (this.suggestion) {
33
+ this.suggestion.remove();
32
34
  }
33
35
 
34
36
  this.element.removeEventListener("focusin", this.handleFocusIn);
35
37
  this.element.removeEventListener("focusout", this.handleFocusOut);
36
38
  this.element.removeEventListener("input", this.handleInput);
39
+ this.element.removeEventListener("keydown", this.handleKeyDown);
40
+ document.removeEventListener("click", this.handleDocumentClick);
37
41
 
38
- this.tribute = null;
42
+ this.suggestion = null;
43
+ this.suggestions = [];
39
44
  this.isInitialized = false;
40
45
  }
41
46
 
42
- /**
43
- * Create and configure the Tribute instance
44
- * @returns {void}
45
- * @private
46
- */
47
- createTribute() {
48
- const noMatchTemplate = this.options.noDataFoundMessage
49
- ? () => `<li>${this.options.noDataFoundMessage}</li>`
50
- : null;
51
-
52
- this.tribute = new Tribute({
53
- trigger: "@",
54
- values: this.debounce((text, callback) => {
55
- this.performRemoteSearch(text, callback);
56
- }, this.options.debounceDelay),
57
- positionMenu: true,
58
- menuContainer: null,
59
- allowSpaces: true,
60
- menuItemLimit: this.options.menuItemLimit,
61
- fillAttr: "nickname",
62
- selectClass: "highlight",
63
- noMatchTemplate: noMatchTemplate,
64
- lookup: (item) => item.nickname + item.name,
65
- selectTemplate: (item) => {
66
- if (typeof item === "undefined") {
67
- return null;
68
- }
69
- return item.original.nickname;
70
- },
71
- menuItemTemplate: (item) => {
72
- return `
73
- <img src="${item.original.avatarUrl}" alt="author-avatar">
74
- <strong>${item.original.nickname}</strong>
75
- <small>${item.original.name}</small>
76
- `;
77
- }
78
- });
47
+ createSuggestionContainer() {
48
+ this.suggestion = document.createElement("div");
49
+ this.suggestion.classList.add("editor-suggestions", "hidden", "hide");
50
+ document.body.append(this.suggestion);
51
+ this.suggestion.addEventListener("mousedown", (event) => event.preventDefault());
79
52
 
80
- this.tribute.attach(this.element);
53
+ this.performRemoteSearch = this.debounce(this.performRemoteSearch.bind(this), this.options.debounceDelay);
81
54
  }
82
55
 
83
- /**
84
- * Set up event listeners for the element
85
- * @returns {void}
86
- * @private
87
- */
88
56
  setupEventListeners() {
89
- // Handle focus events to set menu container
90
- this.element.addEventListener("focusin", this.handleFocusIn.bind(this));
91
- this.element.addEventListener("focusout", this.handleFocusOut.bind(this));
92
- this.element.addEventListener("input", this.handleInput.bind(this));
93
- }
94
-
95
- /**
96
- * Handle focus in event
97
- * @param {Event} event - The focus in event
98
- * @returns {void}
99
- * @private
100
- */
101
- handleFocusIn(event) {
102
- if (this.tribute) {
103
- this.tribute.menuContainer = event.target.parentNode;
104
- }
105
- }
106
-
107
- /**
108
- * Handle focus out event
109
- * @param {Event} event - The focus out event
110
- * @returns {void}
111
- * @private
112
- */
57
+ this.handleFocusIn = this.handleFocusIn.bind(this);
58
+ this.handleFocusOut = this.handleFocusOut.bind(this);
59
+ this.handleInput = this.handleInput.bind(this);
60
+ this.handleKeyDown = this.handleKeyDown.bind(this);
61
+ this.handleDocumentClick = this.handleDocumentClick.bind(this);
62
+
63
+ this.element.addEventListener("focusin", this.handleFocusIn);
64
+ this.element.addEventListener("focusout", this.handleFocusOut);
65
+ this.element.addEventListener("input", this.handleInput);
66
+ this.element.addEventListener("keydown", this.handleKeyDown);
67
+ document.addEventListener("click", this.handleDocumentClick);
68
+ }
69
+
70
+ handleFocusIn() {
71
+ if (this.element.parentNode) {
72
+ this.element.parentNode.classList.add("is-active");
73
+ }
74
+ }
75
+
113
76
  handleFocusOut(event) {
77
+ if (this.suggestion && this.suggestion.contains(event.relatedTarget)) {
78
+ return;
79
+ }
80
+
81
+ this.hideSuggestions();
114
82
  const parent = event.target.parentNode;
115
83
 
116
84
  if (parent && parent.classList.contains("is-active")) {
@@ -118,63 +86,99 @@ export default class extends Controller {
118
86
  }
119
87
  }
120
88
 
121
- /**
122
- * Handle input event
123
- * @param {Event} event - The input event
124
- * @returns {void}
125
- * @private
126
- */
127
- handleInput(event) {
128
- const parent = event.target.parentNode;
89
+ handleInput() {
90
+ const trigger = this.mentionTriggerAtCursor();
91
+ if (!trigger) {
92
+ this.hideSuggestions();
93
+ return;
94
+ }
95
+
96
+ this.currentMentionStart = trigger.start;
97
+ this.performRemoteSearch(trigger.query);
98
+ }
129
99
 
130
- if (!parent) {
100
+ handleKeyDown(event) {
101
+ if (!this.isActive) {
131
102
  return;
132
103
  }
133
104
 
134
- if (this.tribute && this.tribute.isActive) {
135
- // Move the tribute container to the correct parent
136
- const tributeContainer = document.querySelector(".tribute-container");
137
- if (tributeContainer) {
138
- parent.appendChild(tributeContainer);
139
- }
105
+ if (event.key === "Escape") {
106
+ event.preventDefault();
107
+ this.hideSuggestions();
108
+ } else if (event.key === "ArrowDown") {
109
+ event.preventDefault();
110
+ this.updateSelectedIndex(1);
111
+ this.renderSuggestions();
112
+ } else if (event.key === "ArrowUp") {
113
+ event.preventDefault();
114
+ this.updateSelectedIndex(-1);
115
+ this.renderSuggestions();
116
+ } else if (event.key === "Enter") {
117
+ event.preventDefault();
118
+ this.selectSuggestion(this.selectedIndex);
119
+ }
120
+ }
140
121
 
141
- parent.classList.add("is-active");
142
- } else {
143
- parent.classList.remove("is-active");
122
+ handleDocumentClick(event) {
123
+ if (this.element === event.target || this.element.contains(event.target) || this.suggestion?.contains(event.target)) {
124
+ return;
125
+ }
126
+
127
+ this.hideSuggestions();
128
+ }
129
+
130
+ mentionTriggerAtCursor() {
131
+ const value = this.element.value || "";
132
+ const caretPosition = this.element.selectionStart;
133
+
134
+ if (typeof caretPosition !== "number") {
135
+ return null;
136
+ }
137
+
138
+ const textBeforeCursor = value.slice(0, caretPosition);
139
+ const mentionMatch = textBeforeCursor.match(/(?:^|\s)@([\w.-]{2,})$/);
140
+ if (!mentionMatch) {
141
+ return null;
144
142
  }
143
+
144
+ return {
145
+ query: mentionMatch[1],
146
+ start: caretPosition - mentionMatch[1].length - 1
147
+ };
145
148
  }
146
149
 
147
- /**
148
- * Perform remote search for users
149
- * @param {string} text - The search text
150
- * @param {Function} callback - The callback function to call with results
151
- * @returns {void}
152
- * @private
153
- */
154
- performRemoteSearch(text, callback) {
150
+ performRemoteSearch(text) {
151
+ const currentRequestId = this.requestId + 1;
152
+ this.requestId = currentRequestId;
153
+
155
154
  const query = `{users(filter:{wildcard:"${text}"}){nickname,name,avatarUrl,__typename}}`;
156
155
  const apiPath = window.Decidim.config.get("api_path");
157
156
 
158
157
  this.makeRequest(apiPath, { query }).
159
158
  then((response) => {
159
+ if (this.requestId !== currentRequestId) {
160
+ return;
161
+ }
162
+
160
163
  const data = response.data.users || [];
161
- callback(data);
164
+ const sortedData = data.sort((first, second) => first.nickname.localeCompare(second.nickname));
165
+ this.suggestions = sortedData.slice(0, this.options.menuItemLimit);
166
+ this.selectedIndex = this.suggestions.length > 0
167
+ ? 0
168
+ : -1;
169
+ this.renderSuggestions({ showNoResults: true });
162
170
  }).
163
171
  catch(() => {
164
- callback([]);
165
- }).
166
- finally(() => {
167
- this.adjustTributeContainer();
172
+ if (this.requestId !== currentRequestId) {
173
+ return;
174
+ }
175
+
176
+ this.suggestions = [];
177
+ this.selectedIndex = -1;
178
+ this.renderSuggestions({ showNoResults: true });
168
179
  });
169
180
  }
170
181
 
171
- /**
172
- * Make an HTTP POST request
173
- * @param {string} url - The request URL
174
- * @param {Object} data - The request data
175
- * @returns {Promise} The request promise
176
- * @private
177
- */
178
182
  makeRequest(url, data) {
179
183
  return fetch(url, {
180
184
  method: "POST",
@@ -191,35 +195,191 @@ export default class extends Controller {
191
195
  });
192
196
  }
193
197
 
194
- /**
195
- * Adjust the tribute container positioning and styling
196
- * @returns {void}
197
- * @private
198
- */
199
- adjustTributeContainer() {
200
- if (!this.tribute || !this.tribute.current || !this.tribute.current.element) {
198
+ renderSuggestions({ showNoResults = false } = {}) {
199
+ if (!this.suggestion) {
200
+ return;
201
+ }
202
+
203
+ this.suggestion.innerHTML = "";
204
+
205
+ if (this.suggestions.length < 1) {
206
+ this.isActive = false;
207
+
208
+ if (showNoResults && this.options.noDataFoundMessage) {
209
+ const noResultsItem = document.createElement("button");
210
+ noResultsItem.type = "button";
211
+ noResultsItem.disabled = true;
212
+ noResultsItem.classList.add("editor-suggestions-item", "editor-suggestions-item-disabled");
213
+ noResultsItem.textContent = this.options.noDataFoundMessage;
214
+ this.suggestion.append(noResultsItem);
215
+ this.positionSuggestionMenu();
216
+ this.suggestion.classList.remove("hidden", "hide");
217
+ } else {
218
+ this.suggestion.classList.add("hidden", "hide");
219
+ }
220
+
201
221
  return;
202
222
  }
203
223
 
204
- const parent = this.tribute.current.element.parentNode;
205
- if (parent) {
206
- parent.classList.add("is-active");
224
+ this.suggestions.forEach((item, index) => {
225
+ const suggestionItem = document.createElement("button");
226
+ suggestionItem.type = "button";
227
+ suggestionItem.classList.add("editor-suggestions-item");
228
+ suggestionItem.dataset.index = index;
229
+
230
+ if (item.avatarUrl) {
231
+ const avatar = document.createElement("img");
232
+ avatar.classList.add("editor-suggestions-item-avatar");
233
+ avatar.src = item.avatarUrl;
234
+ avatar.alt = item.name || item.nickname;
235
+ suggestionItem.append(avatar);
236
+ }
237
+
238
+ const label = document.createElement("span");
239
+ label.classList.add("editor-suggestions-item-label");
240
+ label.textContent = `${item.nickname} (${item.name})`;
241
+ suggestionItem.append(label);
207
242
 
208
- const tributeContainer = parent.querySelector(".tribute-container");
209
- if (tributeContainer) {
210
- // Remove inline styles for absolute positioning
211
- tributeContainer.removeAttribute("style");
243
+ if (index === this.selectedIndex) {
244
+ suggestionItem.dataset.selected = "true";
212
245
  }
246
+
247
+ suggestionItem.addEventListener("click", (event) => {
248
+ event.preventDefault();
249
+ this.selectSuggestion(index);
250
+ });
251
+
252
+ this.suggestion.append(suggestionItem);
253
+ });
254
+
255
+ this.positionSuggestionMenu();
256
+ this.isActive = true;
257
+ this.suggestion.classList.remove("hidden", "hide");
258
+ }
259
+
260
+ positionSuggestionMenu() {
261
+ if (!this.suggestion || this.currentMentionStart === null) {
262
+ return;
213
263
  }
264
+
265
+ const coordinates = this.coordinatesForTextIndex(this.currentMentionStart);
266
+ if (!coordinates) {
267
+ return;
268
+ }
269
+
270
+ Object.assign(this.suggestion.style, {
271
+ position: "absolute",
272
+ top: `${coordinates.top}px`,
273
+ left: `${coordinates.left}px`
274
+ });
275
+ }
276
+
277
+ coordinatesForTextIndex(index) {
278
+ const element = this.element;
279
+ const styles = window.getComputedStyle(element);
280
+ const elementRect = element.getBoundingClientRect();
281
+ const borderTopWidth = parseFloat(styles.borderTopWidth) || 0;
282
+ const borderLeftWidth = parseFloat(styles.borderLeftWidth) || 0;
283
+ const lineHeight = parseFloat(styles.lineHeight) || 16;
284
+
285
+ const mirror = document.createElement("div");
286
+ const mirrorStyles = [
287
+ "fontFamily",
288
+ "fontSize",
289
+ "fontWeight",
290
+ "fontStyle",
291
+ "letterSpacing",
292
+ "textTransform",
293
+ "wordSpacing",
294
+ "textIndent",
295
+ "boxSizing",
296
+ "width",
297
+ "paddingTop",
298
+ "paddingRight",
299
+ "paddingBottom",
300
+ "paddingLeft",
301
+ "borderTopWidth",
302
+ "borderRightWidth",
303
+ "borderBottomWidth",
304
+ "borderLeftWidth",
305
+ "borderStyle",
306
+ "lineHeight"
307
+ ];
308
+
309
+ mirrorStyles.forEach((property) => {
310
+ mirror.style[property] = styles[property];
311
+ });
312
+
313
+ mirror.style.position = "absolute";
314
+ mirror.style.visibility = "hidden";
315
+ mirror.style.whiteSpace = element.nodeName === "TEXTAREA"
316
+ ? "pre-wrap"
317
+ : "pre";
318
+ mirror.style.overflowWrap = "break-word";
319
+ mirror.style.top = "0";
320
+ mirror.style.left = "-9999px";
321
+
322
+ const before = document.createTextNode((element.value || "").slice(0, index));
323
+ const marker = document.createElement("span");
324
+ marker.textContent = "@";
325
+
326
+ mirror.append(before);
327
+ mirror.append(marker);
328
+ document.body.append(mirror);
329
+
330
+ const top = elementRect.top + window.scrollY + marker.offsetTop - element.scrollTop + borderTopWidth + lineHeight;
331
+ const left = elementRect.left + window.scrollX + marker.offsetLeft - element.scrollLeft + borderLeftWidth;
332
+
333
+ mirror.remove();
334
+
335
+ return { top, left };
336
+ }
337
+
338
+ updateSelectedIndex(direction) {
339
+ if (this.suggestions.length < 1) {
340
+ this.selectedIndex = -1;
341
+ return;
342
+ }
343
+
344
+ const maxIndex = this.suggestions.length - 1;
345
+ const nextIndex = this.selectedIndex + direction;
346
+
347
+ this.selectedIndex = Math.max(0, Math.min(nextIndex, maxIndex));
348
+ }
349
+
350
+ selectSuggestion(index) {
351
+ const selectedItem = this.suggestions[index];
352
+ if (!selectedItem || this.currentMentionStart === null) {
353
+ return;
354
+ }
355
+
356
+ const cursorPosition = this.element.selectionStart;
357
+ const value = this.element.value || "";
358
+ const mentionValue = `${selectedItem.nickname} `;
359
+ const newValue = `${value.slice(0, this.currentMentionStart)}${mentionValue}${value.slice(cursorPosition)}`;
360
+
361
+ this.element.value = newValue;
362
+
363
+ const newPosition = this.currentMentionStart + mentionValue.length;
364
+ this.element.setSelectionRange(newPosition, newPosition);
365
+
366
+ this.element.dispatchEvent(new Event("input", { bubbles: true }));
367
+ this.hideSuggestions();
368
+ }
369
+
370
+ hideSuggestions() {
371
+ if (!this.suggestion) {
372
+ return;
373
+ }
374
+
375
+ this.isActive = false;
376
+ this.currentMentionStart = null;
377
+ this.selectedIndex = -1;
378
+ this.suggestions = [];
379
+ this.suggestion.classList.add("hidden", "hide");
380
+ this.suggestion.innerHTML = "";
214
381
  }
215
382
 
216
- /**
217
- * Create a debounced version of a function
218
- * @param {Function} callback - The function to debounce
219
- * @param {number} wait - The debounce delay in milliseconds
220
- * @returns {Function} The debounced function
221
- * @private
222
- */
223
383
  debounce(callback, wait) {
224
384
  let timeout = null;
225
385
  return (...args) => {
@@ -233,10 +393,6 @@ export default class extends Controller {
233
393
  };
234
394
  }
235
395
 
236
- /**
237
- * Check if the component is initialized
238
- * @returns {boolean} True if initialized, false otherwise
239
- */
240
396
  get initialized() {
241
397
  return this.isInitialized;
242
398
  }