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.
- checksums.yaml +4 -4
- data/app/cells/decidim/author_cell.rb +0 -4
- data/app/cells/decidim/content_blocks/highlighted_content_banner_cell.rb +1 -1
- data/app/cells/decidim/content_blocks/highlighted_participatory_spaces_cell.rb +1 -1
- data/app/commands/decidim/destroy_account.rb +12 -1
- data/app/commands/decidim/multiple_attachments_methods.rb +28 -27
- data/app/jobs/decidim/process_inactive_participant_job.rb +0 -7
- data/app/mailers/decidim/delete_user_mailer.rb +14 -0
- data/app/mailers/decidim/participants_account_mailer.rb +0 -16
- data/app/packs/src/decidim/controllers/main_menu/controller.js +33 -0
- data/app/packs/src/decidim/controllers/main_menu/main_menu.test.js +77 -0
- data/app/packs/src/decidim/controllers/mention/controller.js +296 -140
- data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +120 -457
- data/app/packs/src/decidim/controllers/multiple_mentions/controller.js +68 -32
- data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +30 -23
- data/app/packs/src/decidim/editor/common/suggestion.js +3 -1
- data/app/packs/src/decidim/editor/extensions/indent/index.js +9 -0
- data/app/packs/src/decidim/geocoding/reverse_geocoding.js +15 -5
- data/app/packs/src/decidim/geocoding/reverse_geocoding.test.js +197 -0
- data/app/packs/src/decidim/index.js +2 -2
- data/app/packs/stylesheets/decidim/_conversations.scss +14 -0
- data/app/packs/stylesheets/decidim/_dropdown.scss +1 -1
- data/app/packs/stylesheets/decidim/_editor_suggestions.scss +49 -0
- data/app/packs/stylesheets/decidim/_header.scss +12 -8
- data/app/packs/stylesheets/decidim/_tom_select.scss +23 -0
- data/app/packs/stylesheets/decidim/application.scss +2 -0
- data/app/packs/stylesheets/decidim/editor.scss +2 -33
- data/app/packs/stylesheets/decidim/geocoding_addons.scss +10 -2
- data/app/uploaders/decidim/image_uploader.rb +1 -1
- data/app/views/decidim/delete_user_mailer/delete.html.erb +6 -0
- data/app/views/decidim/messaging/conversations/_error_modal.html.erb +11 -19
- data/app/views/decidim/messaging/conversations/error.js.erb +12 -7
- data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile.html.erb +2 -1
- data/config/locales/ar.yml +0 -2
- data/config/locales/bg.yml +0 -2
- data/config/locales/ca-IT.yml +21 -10
- data/config/locales/ca.yml +21 -10
- data/config/locales/cs.yml +10 -9
- data/config/locales/de.yml +4 -13
- data/config/locales/el.yml +0 -1
- data/config/locales/en.yml +20 -9
- data/config/locales/es-MX.yml +20 -9
- data/config/locales/es-PY.yml +20 -9
- data/config/locales/es.yml +20 -9
- data/config/locales/eu.yml +36 -9
- data/config/locales/fi-plain.yml +19 -8
- data/config/locales/fi.yml +19 -8
- data/config/locales/fr-CA.yml +23 -9
- data/config/locales/fr.yml +23 -9
- data/config/locales/hu.yml +0 -2
- data/config/locales/it.yml +0 -2
- data/config/locales/ja.yml +59 -19
- data/config/locales/lb.yml +0 -2
- data/config/locales/lt.yml +0 -2
- data/config/locales/nl.yml +0 -2
- data/config/locales/no.yml +0 -2
- data/config/locales/pl.yml +2 -4
- data/config/locales/pt-BR.yml +2 -11
- data/config/locales/pt.yml +0 -2
- data/config/locales/ro-RO.yml +1 -10
- data/config/locales/sk.yml +1 -10
- data/config/locales/sv.yml +0 -9
- data/config/locales/tr-TR.yml +0 -2
- data/config/locales/zh-CN.yml +0 -2
- data/config/locales/zh-TW.yml +0 -2
- data/decidim-core.gemspec +1 -1
- data/lib/decidim/attachment_attributes.rb +58 -9
- data/lib/decidim/command.rb +1 -1
- data/lib/decidim/core/content_blocks/registry_manager.rb +4 -4
- data/lib/decidim/core/engine.rb +8 -0
- data/lib/decidim/core/test/factories.rb +3 -0
- data/lib/decidim/core/test/shared_examples/admin_resource_gallery_examples.rb +10 -10
- data/lib/decidim/core/test/shared_examples/comments_examples.rb +6 -6
- data/lib/decidim/core/version.rb +1 -1
- data/lib/decidim/map/autocomplete.rb +4 -3
- data/lib/decidim/searchable.rb +5 -0
- data/lib/decidim/view_model.rb +1 -1
- data/lib/tasks/decidim_mailers_tasks.rake +31 -9
- metadata +10 -9
- data/app/commands/decidim/gallery_methods.rb +0 -107
- data/app/packs/src/decidim/vendor/tribute.js +0 -1890
- data/app/packs/stylesheets/decidim/_tribute.scss +0 -36
- 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("
|
|
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: "
|
|
79
|
-
name: "
|
|
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
|
|
55
|
+
controller?.disconnect();
|
|
97
56
|
document.body.innerHTML = "";
|
|
57
|
+
application.stop();
|
|
98
58
|
jest.restoreAllMocks();
|
|
99
59
|
});
|
|
100
60
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
183
|
-
|
|
74
|
+
it("initializes when not inside editor", async () => {
|
|
75
|
+
await connectController();
|
|
184
76
|
|
|
185
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
288
90
|
|
|
289
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
const mockCallback = jest.fn();
|
|
103
|
+
await waitForDebounce();
|
|
359
104
|
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
tributeConfig.values("test", mockCallback);
|
|
398
|
-
// Wait for debounce
|
|
399
|
-
setTimeout(resolve, 300);
|
|
400
|
-
});
|
|
121
|
+
await waitForDebounce();
|
|
401
122
|
|
|
402
|
-
|
|
403
|
-
|
|
123
|
+
const suggestions = document.querySelector(".editor-suggestions");
|
|
124
|
+
expect(suggestions.classList.contains("hidden")).toBe(true);
|
|
404
125
|
});
|
|
405
126
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
controller.disconnect();
|
|
127
|
+
it("inserts selected mention on click", async () => {
|
|
128
|
+
await connectController();
|
|
409
129
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
416
|
-
controller.tribute = null;
|
|
134
|
+
await waitForDebounce();
|
|
417
135
|
|
|
418
|
-
|
|
419
|
-
|
|
136
|
+
const suggestionItem = document.querySelector(".editor-suggestions-item");
|
|
137
|
+
suggestionItem.click();
|
|
420
138
|
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
jest.useFakeTimers();
|
|
429
|
-
});
|
|
143
|
+
it("supports keyboard navigation and enter selection", async () => {
|
|
144
|
+
await connectController();
|
|
430
145
|
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
163
|
+
mockElement.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
|
164
|
+
mockElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
444
165
|
|
|
445
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
debouncedFunction("arg2");
|
|
458
|
-
jest.advanceTimersByTime(50);
|
|
169
|
+
it("positions suggestions near the mention trigger character", async () => {
|
|
170
|
+
await connectController();
|
|
459
171
|
|
|
460
|
-
|
|
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
|
-
|
|
177
|
+
const suggestions = document.querySelector(".editor-suggestions");
|
|
463
178
|
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
expect(mockTributeContainer.getAttribute("style")).toBeNull();
|
|
187
|
+
fetch.mockResolvedValueOnce({
|
|
188
|
+
ok: true,
|
|
189
|
+
json: () => Promise.resolve({ data: { users: [] } })
|
|
483
190
|
});
|
|
484
191
|
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
203
|
+
it("hides suggestions on escape", async () => {
|
|
204
|
+
await connectController();
|
|
493
205
|
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
557
|
-
const attachEvent = new CustomEvent("attach-mentions-element");
|
|
220
|
+
controller.disconnect();
|
|
558
221
|
|
|
559
|
-
|
|
560
|
-
|
|
222
|
+
expect(controller.suggestion).toBeNull();
|
|
223
|
+
expect(controller.initialized).toBe(false);
|
|
561
224
|
});
|
|
562
225
|
});
|