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,82 +1,47 @@
1
- /* eslint max-lines: ["error", 650] */
2
1
  /* global global, jest */
3
2
  /**
4
3
  * @jest-environment jsdom
5
4
  */
6
5
 
7
- // Mock the Tribute library first
8
- const mockTribute = {
9
- attach: jest.fn(),
10
- detach: jest.fn(),
11
- isActive: false,
12
- current: null,
13
- menu: null,
14
- range: {
15
- getDocument: () => document
16
- },
17
- menuContainer: null
18
- };
19
-
20
- // Create the constructor mock
21
- jest.mock("src/decidim/vendor/tribute", () => {
22
- return jest.fn().mockImplementation(() => mockTribute);
23
- });
24
-
25
6
  import { Application } from "@hotwired/stimulus"
26
7
  import MentionController from "src/decidim/controllers/mention/controller";
27
- import Tribute from "src/decidim/vendor/tribute";
28
8
 
29
- // Get access to mock methods (pure JavaScript approach)
30
- const TributeMock = Tribute;
31
-
32
- // Mock global fetch
33
9
  global.fetch = jest.fn();
34
10
 
35
- // Mock window.Decidim
36
11
  global.window.Decidim = {
37
12
  config: {
38
13
  get: jest.fn()
39
14
  }
40
15
  };
41
16
 
42
- describe("MentionsComponent", () => {
43
- let mockElement = null;
17
+ describe("MentionController", () => {
44
18
  let application = null;
19
+ let mockElement = null;
45
20
  let controller = null;
46
21
 
47
22
  beforeEach(() => {
48
- // Set up Stimulus application
49
23
  application = Application.start();
50
24
  application.register("mention", MentionController);
51
-
52
- // Reset all mocks
53
25
  jest.clearAllMocks();
54
- mockTribute.attach.mockClear();
55
- mockTribute.detach.mockClear();
56
- TributeMock.mockClear();
57
26
 
58
- // Create DOM elements for testing
59
27
  document.body.innerHTML = `
60
28
  <div class="mention-container">
61
29
  <input data-noresults="No users found" data-controller="mention" />
62
- <div class="tribute-container"></div>
63
30
  </div>
64
31
  `;
65
32
 
66
33
  mockElement = document.querySelector("[data-controller='mention']");
67
34
 
68
- // Setup window.Decidim mock
69
35
  window.Decidim.config.get.mockReturnValue("http://localhost:3000/api");
70
36
 
71
- // Setup fetch mock
72
37
  fetch.mockResolvedValue({
73
38
  ok: true,
74
39
  json: () => Promise.resolve({
75
40
  data: {
76
41
  users: [
77
42
  {
78
- nickname: "testuser",
79
- name: "Test User",
43
+ nickname: "@doe_john",
44
+ name: "John Doe",
80
45
  avatarUrl: "http://example.com/avatar.jpg",
81
46
  __typename: "User"
82
47
  }
@@ -84,479 +49,177 @@ describe("MentionsComponent", () => {
84
49
  }
85
50
  })
86
51
  });
87
- return new Promise((resolve) => {
88
- setTimeout(() => {
89
- controller = application.getControllerForElementAndIdentifier(mockElement, "mention");
90
- resolve();
91
- }, 0);
92
- });
93
52
  });
94
53
 
95
54
  afterEach(() => {
96
- controller.disconnect();
55
+ controller?.disconnect();
97
56
  document.body.innerHTML = "";
57
+ application.stop();
98
58
  jest.restoreAllMocks();
99
59
  });
100
60
 
101
- describe("constructor", () => {
102
- it("creates instance with default options", () => {
103
- expect(controller.element).toBe(mockElement);
104
- expect(controller.options.noDataFoundMessage).toBe("No users found");
105
- expect(controller.options.debounceDelay).toBe(250);
106
- expect(controller.options.menuItemLimit).toBe(5);
107
- });
108
-
109
- it("initializes component when not inside editor", () => {
110
- expect(controller.initialized).toBe(true);
111
- expect(TributeMock).toHaveBeenCalled();
112
- expect(mockTribute.attach).toHaveBeenCalledWith(mockElement);
113
- });
114
-
115
- it("does not initialize when inside editor", () => {
116
- const editorContainer = document.createElement("div");
117
- editorContainer.classList.add("editor");
118
- const editorElement = document.createElement("input");
119
- editorElement.setAttribute("data-controller", "mention");
120
- editorContainer.appendChild(editorElement);
121
- document.body.appendChild(editorContainer);
122
-
123
- // Wait for the controller to be connected
124
- return new Promise((resolve) => {
125
- setTimeout(() => {
126
- controller = application.getControllerForElementAndIdentifier(editorElement, "mention");
127
-
128
- expect(controller.initialized).toBe(false);
129
- // Since TributeMock should not be called, we can check that tribute is null
130
- expect(controller.tribute).toBeNull();
131
-
132
- resolve();
133
- }, 0);
134
- });
135
- });
136
- });
137
-
138
- describe("Tribute configuration", () => {
139
- it("configures Tribute with correct options", () => {
140
- const tributeConfig = TributeMock.mock.calls[0][0];
141
-
142
- expect(tributeConfig.trigger).toBe("@");
143
- expect(tributeConfig.positionMenu).toBe(true);
144
- expect(tributeConfig.allowSpaces).toBe(true);
145
- expect(tributeConfig.menuItemLimit).toBe(5);
146
- expect(tributeConfig.fillAttr).toBe("nickname");
147
- expect(tributeConfig.selectClass).toBe("highlight");
148
- });
149
-
150
- it("configures lookup function correctly", () => {
151
- const tributeConfig = TributeMock.mock.calls[0][0];
152
- const mockItem = { nickname: "testuser", name: "Test User" };
153
-
154
- expect(tributeConfig.lookup(mockItem)).toBe("testuserTest User");
155
- });
156
-
157
- it("configures selectTemplate function correctly", () => {
158
- const tributeConfig = TributeMock.mock.calls[0][0];
159
- const mockItem = { original: { nickname: "testuser" } };
160
-
161
- expect(tributeConfig.selectTemplate(mockItem)).toBe("testuser");
162
- // eslint-disable-next-line no-undefined
163
- expect(tributeConfig.selectTemplate(undefined)).toBeNull();
61
+ const connectController = () => {
62
+ return new Promise((resolve) => {
63
+ setTimeout(() => {
64
+ controller = application.getControllerForElementAndIdentifier(mockElement, "mention");
65
+ resolve();
66
+ }, 0);
164
67
  });
68
+ };
165
69
 
166
- it("configures menuItemTemplate function correctly", () => {
167
- const tributeConfig = TributeMock.mock.calls[0][0];
168
- const mockItem = {
169
- original: {
170
- nickname: "testuser",
171
- name: "Test User",
172
- avatarUrl: "http://example.com/avatar.jpg"
173
- }
174
- };
175
-
176
- const template = tributeConfig.menuItemTemplate(mockItem);
177
- expect(template).toContain('img src="http://example.com/avatar.jpg"');
178
- expect(template).toContain("<strong>testuser</strong>");
179
- expect(template).toContain("<small>Test User</small>");
180
- });
70
+ const waitForDebounce = () => {
71
+ return new Promise((resolve) => setTimeout(resolve, 300));
72
+ };
181
73
 
182
- it("configures noMatchTemplate when no data found message exists", () => {
183
- const tributeConfig = TributeMock.mock.calls[0][0];
74
+ it("initializes when not inside editor", async () => {
75
+ await connectController();
184
76
 
185
- expect(tributeConfig.noMatchTemplate()).toBe("<li>No users found</li>");
186
- });
187
-
188
- it("configures null noMatchTemplate when no message provided", () => {
189
- const elementWithoutMessage = document.createElement("input");
190
- elementWithoutMessage.setAttribute("data-controller", "mention");
191
- document.body.appendChild(elementWithoutMessage);
192
-
193
- return new Promise((resolve) => {
194
- setTimeout(() => {
195
- controller = application.getControllerForElementAndIdentifier(elementWithoutMessage, "mention");
196
-
197
- // Check that the tribute was created and the second call (index [1]) has the expected config
198
- const tributeConfig = TributeMock.mock.calls[1][0];
199
- expect(tributeConfig.noMatchTemplate).toEqual(expect.any(Function));
200
-
201
- // Clean up
202
- controller.disconnect();
203
- elementWithoutMessage.remove();
204
- resolve();
205
- }, 0);
206
- });
207
- });
77
+ expect(controller.initialized).toBe(true);
78
+ expect(document.querySelector(".editor-suggestions")).toBeInstanceOf(HTMLDivElement);
208
79
  });
209
80
 
210
- describe("event handling", () => {
211
- it("handles focusin event", () => {
212
- const event = new Event("focusin");
213
- Reflect.defineProperty(event, "target", {
214
- value: mockElement,
215
- enumerable: true
216
- });
217
-
218
- mockElement.dispatchEvent(event);
219
-
220
- expect(mockTribute.menuContainer).toBe(mockElement.parentNode);
221
- });
222
-
223
- it("handles focusout event when parent has is-active class", () => {
224
- mockElement.parentNode.classList.add("is-active");
225
-
226
- const event = new Event("focusout");
227
- Reflect.defineProperty(event, "target", {
228
- value: mockElement,
229
- enumerable: true
230
- });
231
-
232
- mockElement.dispatchEvent(event);
233
-
234
- expect(mockElement.parentNode.classList.contains("is-active")).toBe(false);
235
- });
236
-
237
- it("handles focusout event when parent does not exist", () => {
238
- const isolatedElement = document.createElement("input");
239
- isolatedElement.setAttribute("data-controller", "mention");
240
- document.body.appendChild(isolatedElement);
241
-
242
- return new Promise((resolve) => {
243
- setTimeout(() => {
244
- const isolatedController = application.getControllerForElementAndIdentifier(isolatedElement, "mention");
245
-
246
- const event = new Event("focusout");
247
- Reflect.defineProperty(event, "target", {
248
- value: isolatedElement,
249
- enumerable: true
250
- });
251
-
252
- // Test that dispatching the focusout event does not throw when parent is null
253
- expect(() => isolatedElement.dispatchEvent(event)).not.toThrow();
254
-
255
- // Clean up
256
- isolatedController.disconnect();
257
- isolatedElement.remove();
258
- resolve();
259
- }, 0);
260
- });
261
- });
262
-
263
- it("handles input event when tribute is active", () => {
264
- mockTribute.isActive = true;
265
-
266
- const event = new Event("input");
267
- Reflect.defineProperty(event, "target", {
268
- value: mockElement,
269
- enumerable: true
270
- });
271
-
272
- mockElement.dispatchEvent(event);
273
-
274
- expect(mockElement.parentNode.classList.contains("is-active")).toBe(true);
275
- });
276
-
277
- it("handles input event when tribute is not active", () => {
278
- mockTribute.isActive = false;
279
- mockElement.parentNode.classList.add("is-active");
280
-
281
- const event = new Event("input");
282
- Reflect.defineProperty(event, "target", {
283
- value: mockElement,
284
- enumerable: true
285
- });
81
+ it("does not initialize when inside editor", async () => {
82
+ const editorContainer = document.createElement("div");
83
+ editorContainer.classList.add("editor");
84
+ const editorElement = document.createElement("input");
85
+ editorElement.setAttribute("data-controller", "mention");
86
+ editorContainer.appendChild(editorElement);
87
+ document.body.appendChild(editorContainer);
286
88
 
287
- mockElement.dispatchEvent(event);
89
+ await new Promise((resolve) => setTimeout(resolve, 0));
288
90
 
289
- expect(mockElement.parentNode.classList.contains("is-active")).toBe(false);
290
- });
291
-
292
- it("handles input event when parent does not exist", () => {
293
- const isolatedElement = document.createElement("input");
294
- isolatedElement.setAttribute("data-controller", "mention");
295
- document.body.appendChild(isolatedElement);
296
-
297
- return new Promise((resolve) => {
298
- setTimeout(() => {
299
- const isolatedController = application.getControllerForElementAndIdentifier(isolatedElement, "mention");
300
-
301
- const event = new Event("input");
302
- Reflect.defineProperty(event, "target", {
303
- value: isolatedElement,
304
- enumerable: true
305
- });
306
-
307
- // Test that dispatching the input event does not throw when parent is null
308
- expect(() => isolatedElement.dispatchEvent(event)).not.toThrow();
309
-
310
- // Clean up
311
- isolatedController.disconnect();
312
- isolatedElement.remove();
313
- resolve();
314
- }, 0);
315
- });
316
- });
91
+ const editorController = application.getControllerForElementAndIdentifier(editorElement, "mention");
92
+ expect(editorController.initialized).toBe(false);
93
+ expect(editorController.suggestion).toBeNull();
317
94
  });
318
95
 
319
- describe("remote search", () => {
320
- it("performs remote search successfully", async () => {
321
- const tributeConfig = TributeMock.mock.calls[0][0];
322
- const mockCallback = jest.fn();
323
-
324
- // Execute the values function (which is debounced)
325
- await new Promise((resolve) => {
326
- tributeConfig.values("test", mockCallback);
327
- // Wait for debounce
328
- setTimeout(resolve, 300);
329
- });
330
-
331
- expect(fetch).toHaveBeenCalledWith(
332
- "http://localhost:3000/api",
333
- expect.objectContaining({
334
- method: "POST",
335
- headers: expect.objectContaining({
336
- "Content-Type": "application/json"
337
- }),
338
- body: JSON.stringify({
339
- query: '{users(filter:{wildcard:"test"}){nickname,name,avatarUrl,__typename}}'
340
- })
341
- })
342
- );
343
-
344
- expect(mockCallback).toHaveBeenCalledWith([
345
- {
346
- nickname: "testuser",
347
- name: "Test User",
348
- avatarUrl: "http://example.com/avatar.jpg",
349
- __typename: "User"
350
- }
351
- ]);
352
- });
96
+ it("shows suggestions for valid mention query", async () => {
97
+ await connectController();
353
98
 
354
- it("handles remote search failure", async () => {
355
- fetch.mockRejectedValueOnce(new Error("Network error"));
99
+ mockElement.value = "Hello @do";
100
+ mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
101
+ mockElement.dispatchEvent(new Event("input", { bubbles: true }));
356
102
 
357
- const tributeConfig = TributeMock.mock.calls[0][0];
358
- const mockCallback = jest.fn();
103
+ await waitForDebounce();
359
104
 
360
- await new Promise((resolve) => {
361
- tributeConfig.values("test", mockCallback);
362
- // Wait for debounce
363
- setTimeout(resolve, 300);
364
- });
105
+ const suggestions = document.querySelector(".editor-suggestions");
106
+ const items = suggestions.querySelectorAll(".editor-suggestions-item");
365
107
 
366
- expect(mockCallback).toHaveBeenCalledWith([]);
367
- });
368
-
369
- it("handles empty search results", async () => {
370
- fetch.mockResolvedValueOnce({
371
- ok: true,
372
- json: () => Promise.resolve({ data: {} })
373
- });
374
-
375
- const tributeConfig = TributeMock.mock.calls[0][0];
376
- const mockCallback = jest.fn();
377
-
378
- await new Promise((resolve) => {
379
- tributeConfig.values("test", mockCallback);
380
- // Wait for debounce
381
- setTimeout(resolve, 300);
382
- });
383
-
384
- expect(mockCallback).toHaveBeenCalledWith([]);
385
- });
108
+ expect(items.length).toBe(1);
109
+ expect(items[0].querySelector(".editor-suggestions-item-avatar")).toBeInstanceOf(HTMLImageElement);
110
+ expect(items[0].querySelector(".editor-suggestions-item-label").textContent).toBe("@doe_john (John Doe)");
111
+ expect(suggestions.classList.contains("hidden")).toBe(false);
112
+ });
386
113
 
387
- it("handles non-ok response", async () => {
388
- fetch.mockResolvedValueOnce({
389
- ok: false,
390
- status: 500
391
- });
114
+ it("does not show suggestions when fewer than two mention characters", async () => {
115
+ await connectController();
392
116
 
393
- const tributeConfig = TributeMock.mock.calls[0][0];
394
- const mockCallback = jest.fn();
117
+ mockElement.value = "Hello @d";
118
+ mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
119
+ mockElement.dispatchEvent(new Event("input", { bubbles: true }));
395
120
 
396
- await new Promise((resolve) => {
397
- tributeConfig.values("test", mockCallback);
398
- // Wait for debounce
399
- setTimeout(resolve, 300);
400
- });
121
+ await waitForDebounce();
401
122
 
402
- expect(mockCallback).toHaveBeenCalledWith([]);
403
- });
123
+ const suggestions = document.querySelector(".editor-suggestions");
124
+ expect(suggestions.classList.contains("hidden")).toBe(true);
404
125
  });
405
126
 
406
- describe("destroy", () => {
407
- it("cleans up resources properly", () => {
408
- controller.disconnect();
127
+ it("inserts selected mention on click", async () => {
128
+ await connectController();
409
129
 
410
- expect(mockTribute.detach).toHaveBeenCalledWith(mockElement);
411
- expect(controller.tribute).toBeNull();
412
- expect(controller.initialized).toBe(false);
413
- });
130
+ mockElement.value = "Hello @do";
131
+ mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
132
+ mockElement.dispatchEvent(new Event("input", { bubbles: true }));
414
133
 
415
- it("handles missing tribute gracefully", () => {
416
- controller.tribute = null;
134
+ await waitForDebounce();
417
135
 
418
- expect(() => controller.disconnect()).not.toThrow();
419
- });
136
+ const suggestionItem = document.querySelector(".editor-suggestions-item");
137
+ suggestionItem.click();
420
138
 
421
- it("handles missing element gracefully", () => {
422
- expect(() => controller.disconnect()).not.toThrow();
423
- });
139
+ expect(mockElement.value).toBe("Hello @doe_john ");
140
+ expect(document.querySelector(".editor-suggestions").classList.contains("hidden")).toBe(true);
424
141
  });
425
142
 
426
- describe("debounce functionality", () => {
427
- beforeEach(() => {
428
- jest.useFakeTimers();
429
- });
143
+ it("supports keyboard navigation and enter selection", async () => {
144
+ await connectController();
430
145
 
431
- afterEach(() => {
432
- jest.useRealTimers();
146
+ fetch.mockResolvedValueOnce({
147
+ ok: true,
148
+ json: () => Promise.resolve({
149
+ data: {
150
+ users: [
151
+ { nickname: "@alpha", name: "Alpha", avatarUrl: "", __typename: "User" },
152
+ { nickname: "@bravo", name: "Bravo", avatarUrl: "", __typename: "User" }
153
+ ]
154
+ }
155
+ })
433
156
  });
434
157
 
435
- it("debounces function calls", () => {
436
- const mockCallback = jest.fn();
437
- const debouncedFunction = controller.debounce(mockCallback, 100);
438
-
439
- debouncedFunction("arg1");
440
- debouncedFunction("arg2");
441
- debouncedFunction("arg3");
158
+ mockElement.value = "Hello @ab";
159
+ mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
160
+ mockElement.dispatchEvent(new Event("input", { bubbles: true }));
161
+ await waitForDebounce();
442
162
 
443
- expect(mockCallback).not.toHaveBeenCalled();
163
+ mockElement.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
164
+ mockElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
444
165
 
445
- jest.advanceTimersByTime(100);
446
-
447
- expect(mockCallback).toHaveBeenCalledTimes(1);
448
- expect(mockCallback).toHaveBeenCalledWith("arg3");
449
- });
450
-
451
- it("cancels previous timeout on new call", () => {
452
- const mockCallback = jest.fn();
453
- const debouncedFunction = controller.debounce(mockCallback, 100);
166
+ expect(mockElement.value).toBe("Hello @bravo ");
167
+ });
454
168
 
455
- debouncedFunction("arg1");
456
- jest.advanceTimersByTime(50);
457
- debouncedFunction("arg2");
458
- jest.advanceTimersByTime(50);
169
+ it("positions suggestions near the mention trigger character", async () => {
170
+ await connectController();
459
171
 
460
- expect(mockCallback).not.toHaveBeenCalled();
172
+ mockElement.value = "A long text before @do";
173
+ mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
174
+ mockElement.dispatchEvent(new Event("input", { bubbles: true }));
175
+ await waitForDebounce();
461
176
 
462
- jest.advanceTimersByTime(50);
177
+ const suggestions = document.querySelector(".editor-suggestions");
463
178
 
464
- expect(mockCallback).toHaveBeenCalledTimes(1);
465
- expect(mockCallback).toHaveBeenCalledWith("arg2");
466
- });
179
+ expect(suggestions.style.position).toBe("absolute");
180
+ expect(suggestions.style.top).toMatch(/\d+px/);
181
+ expect(suggestions.style.left).toMatch(/\d+px/);
467
182
  });
468
183
 
469
- describe("tribute container adjustment", () => {
470
- it("adjusts tribute container when tribute is active", () => {
471
- const mockParent = document.createElement("div");
472
- const mockTributeContainer = document.createElement("div");
473
- mockTributeContainer.classList.add("tribute-container");
474
- mockTributeContainer.setAttribute("style", "position: absolute; top: 10px;");
475
- mockParent.appendChild(mockTributeContainer);
476
-
477
- mockTribute.current = { element: { parentNode: mockParent } };
184
+ it("shows no results message when API returns empty list", async () => {
185
+ await connectController();
478
186
 
479
- controller.adjustTributeContainer();
480
-
481
- expect(mockParent.classList.contains("is-active")).toBe(true);
482
- expect(mockTributeContainer.getAttribute("style")).toBeNull();
187
+ fetch.mockResolvedValueOnce({
188
+ ok: true,
189
+ json: () => Promise.resolve({ data: { users: [] } })
483
190
  });
484
191
 
485
- it("handles missing tribute current element", () => {
486
- mockTribute.current = null;
192
+ mockElement.value = "Hello @zz";
193
+ mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
194
+ mockElement.dispatchEvent(new Event("input", { bubbles: true }));
195
+ await waitForDebounce();
487
196
 
488
- expect(() => controller.adjustTributeContainer()).not.toThrow();
489
- });
197
+ const noResultsItem = document.querySelector(".editor-suggestions-item");
198
+ expect(noResultsItem).toBeInstanceOf(HTMLButtonElement);
199
+ expect(noResultsItem.textContent).toBe("No users found");
200
+ expect(noResultsItem.disabled).toBe(true);
201
+ });
490
202
 
491
- it("handles missing parent element", () => {
492
- mockTribute.current = { element: { parentNode: null } };
203
+ it("hides suggestions on escape", async () => {
204
+ await connectController();
493
205
 
494
- expect(() => controller.adjustTributeContainer()).not.toThrow();
495
- });
496
- });
206
+ mockElement.value = "Hello @do";
207
+ mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
208
+ mockElement.dispatchEvent(new Event("input", { bubbles: true }));
209
+ await waitForDebounce();
497
210
 
498
- describe("CSRF token handling", () => {
499
- it("includes CSRF token in requests when available", async () => {
500
- const mockToken = "test-csrf-token";
501
- const metaElement = document.createElement("meta");
502
- metaElement.name = "csrf-token";
503
- metaElement.content = mockToken;
504
- document.head.appendChild(metaElement);
505
-
506
- const tributeConfig = TributeMock.mock.calls[0][0];
507
- const mockCallback = jest.fn();
508
-
509
- await new Promise((resolve) => {
510
- tributeConfig.values("test", mockCallback);
511
- setTimeout(resolve, 300);
512
- });
513
-
514
- expect(fetch).toHaveBeenCalledWith(
515
- expect.any(String),
516
- expect.objectContaining({
517
- headers: expect.objectContaining({
518
- "X-CSRF-Token": mockToken
519
- })
520
- })
521
- );
522
- });
211
+ mockElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
523
212
 
524
- it("handles missing CSRF token gracefully", async () => {
525
- const tributeConfig = TributeMock.mock.calls[0][0];
526
- const mockCallback = jest.fn();
527
-
528
- await new Promise((resolve) => {
529
- tributeConfig.values("test", mockCallback);
530
- setTimeout(resolve, 300);
531
- });
532
-
533
- expect(fetch).toHaveBeenCalledWith(
534
- expect.any(String),
535
- expect.objectContaining({
536
- body: '{"query":"{users(filter:{wildcard:\\"test\\"}){nickname,name,avatarUrl,__typename}}"}',
537
- method: "POST",
538
- headers: expect.objectContaining({
539
- "Content-Type": "application/json",
540
- "X-CSRF-Token": expect.any(String)
541
- })
542
- })
543
- );
544
- });
213
+ const suggestions = document.querySelector(".editor-suggestions");
214
+ expect(suggestions.classList.contains("hidden")).toBe(true);
545
215
  });
546
216
 
547
- describe("external integration", () => {
548
- it("handles attach-mentions-element event with no element", () => {
549
- const attachEvent = new CustomEvent("attach-mentions-element", {
550
- detail: {}
551
- });
552
-
553
- expect(() => document.dispatchEvent(attachEvent)).not.toThrow();
554
- });
217
+ it("cleans up on disconnect", async () => {
218
+ await connectController();
555
219
 
556
- it("handles attach-mentions-element event with no detail", () => {
557
- const attachEvent = new CustomEvent("attach-mentions-element");
220
+ controller.disconnect();
558
221
 
559
- expect(() => document.dispatchEvent(attachEvent)).not.toThrow();
560
- });
222
+ expect(controller.suggestion).toBeNull();
223
+ expect(controller.initialized).toBe(false);
561
224
  });
562
225
  });