@raketa-cloud/admin 1.10.11 → 1.10.13

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 (42) hide show
  1. package/dist/application.css +699 -244
  2. package/package.json +4 -3
  3. package/src/cloud/Comments.js +88 -0
  4. package/src/cloud/Editors.js +20 -0
  5. package/src/cloud/ResourceChanges.js +1 -1
  6. package/src/cloud/documents/commentsDocuments.js +103 -0
  7. package/src/cloud/documents/editorsDocuments.js +27 -0
  8. package/src/handlers/resources/DiscussionsHandler.js +203 -0
  9. package/src/handlers/resources/HistoryHandler.js +7 -5
  10. package/src/handlers/resources/ResourcesHandler.js +6 -5
  11. package/src/helpers/commentMarkdown.js +94 -0
  12. package/src/helpers/fetchCommentCounts.js +14 -0
  13. package/src/helpers/historyUtils.js +11 -0
  14. package/src/helpers/utils.js +24 -0
  15. package/src/index.js +1 -0
  16. package/src/routeResourceModule.js +42 -0
  17. package/src/setupCloud.js +4 -0
  18. package/src/styles/application.css +56 -0
  19. package/src/tests/cloud/Comments.test.js +141 -0
  20. package/src/tests/cloud/Editors.test.js +47 -0
  21. package/src/tests/cloud/ResourceChanges.test.js +25 -0
  22. package/src/tests/handlers/resources/DiscussionsHandler.test/create.test.js +108 -0
  23. package/src/tests/handlers/resources/DiscussionsHandler.test/destroy.test.js +55 -0
  24. package/src/tests/handlers/resources/DiscussionsHandler.test/index.test.js +195 -0
  25. package/src/tests/handlers/resources/DiscussionsHandler.test/update.test.js +87 -0
  26. package/src/tests/handlers/resources/HistoryHandler.test/index.test.js +9 -0
  27. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +33 -0
  28. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +3 -0
  29. package/src/tests/helpers/commentMarkdown.test.js +166 -0
  30. package/src/tests/helpers/fetchCommentCounts.test.js +42 -0
  31. package/src/tests/helpers/historyUtils.test.js +26 -0
  32. package/src/tests/helpers/utils.test.js +21 -0
  33. package/src/tests/routeResourceModule.test.js +82 -2
  34. package/src/tests/setupCloud.test.js +4 -0
  35. package/src/tests/views/discussions.test.js +519 -0
  36. package/src/views/resources/_shared/_history_toolbar.ejs +10 -0
  37. package/src/views/resources/_shared/_tabs.ejs +21 -0
  38. package/src/views/resources/_shared/_toolbar.ejs +1 -1
  39. package/src/views/resources/discussions/_avatar.ejs +13 -0
  40. package/src/views/resources/discussions/_discussion_tabs.ejs +22 -0
  41. package/src/views/resources/discussions/index.ejs +729 -0
  42. package/src/views/resources/show.ejs +17 -0
@@ -36,6 +36,7 @@ describe("routeResourceModule", () => {
36
36
  resourceTypePlural: "articles",
37
37
  dateFields: ["publish_at"],
38
38
  reports: true,
39
+ comments: false,
39
40
  });
40
41
  });
41
42
 
@@ -184,8 +185,8 @@ describe("routeResourceModule", () => {
184
185
  expect(apiCollection).toBeDefined();
185
186
  });
186
187
 
187
- test("registers nested collections for revisions, translations, history, locks, unlocks, clones, urls, deletes, publishes, cancel_schedules, reverts", () => {
188
- const adapter = makeAdapter();
188
+ test("registers nested collections for revisions, translations, history, discussions, locks, unlocks, clones, urls, deletes, publishes, cancel_schedules, reverts", () => {
189
+ const adapter = makeAdapter({ features: { comments: { article: true } } });
189
190
 
190
191
  routeResourceModule(adapter, "article", "articles");
191
192
 
@@ -197,6 +198,8 @@ describe("routeResourceModule", () => {
197
198
  expect.arrayContaining([
198
199
  "/articles/:parent_id/translations",
199
200
  "/articles/:parent_id/history",
201
+ "/articles/:parent_id/discussions",
202
+ "/articles/:parent_id/discussions/:revision_id",
200
203
  "/articles/:parent_id/article_revisions",
201
204
  "/article_revisions/:parent_id/locks",
202
205
  "/article_revisions/:parent_id/unlocks",
@@ -304,4 +307,81 @@ describe("routeResourceModule", () => {
304
307
 
305
308
  expect(translationsCall[1].viewsFallback).toBe("resources/translations");
306
309
  });
310
+
311
+ test("scopes the discussion and comment routes by the resource type", () => {
312
+ const adapter = makeAdapter({
313
+ features: { comments: { page: true, "widget-set": true } },
314
+ });
315
+
316
+ routeResourceModule(adapter, "page", "pages");
317
+ routeResourceModule(adapter, "widget-set", "widget-sets");
318
+
319
+ const routes = adapter.routeNestedCollection.mock.calls.flatMap(
320
+ ([mapping, config]) =>
321
+ mapping.map(([verb, path]) => ({
322
+ verb,
323
+ path,
324
+ plural: config.resourceTypePlural,
325
+ })),
326
+ );
327
+ const discussionRoutes = routes.filter(({ path }) =>
328
+ /\/(discussions|comments)/.test(path),
329
+ );
330
+
331
+ expect(discussionRoutes).toEqual(
332
+ expect.arrayContaining([
333
+ { verb: "GET", path: "/pages/:parent_id/discussions", plural: "pages" },
334
+ {
335
+ verb: "PUT",
336
+ path: "/pages/:parent_id/comments/:id",
337
+ plural: "pages",
338
+ },
339
+ {
340
+ verb: "DELETE",
341
+ path: "/widget-sets/:parent_id/comments/:id",
342
+ plural: "widget-sets",
343
+ },
344
+ ]),
345
+ );
346
+ // Shared paths would let the last resource type registered handle all.
347
+ discussionRoutes.forEach(({ path, plural }) => {
348
+ expect(path.startsWith(`/${plural}/`)).toBe(true);
349
+ });
350
+ });
351
+
352
+ function discussionPaths(adapter) {
353
+ return adapter.routeNestedCollection.mock.calls
354
+ .flatMap(([mapping]) => mapping.map(([, path]) => path))
355
+ .filter((path) => /\/(discussions|comments)/.test(path));
356
+ }
357
+
358
+ test("registers the discussions when enabled via the project-level features config", () => {
359
+ const adapter = makeAdapter({ features: { comments: { page: true } } });
360
+
361
+ routeResourceModule(adapter, "page", "pages");
362
+
363
+ expect(discussionPaths(adapter)).toHaveLength(6);
364
+ adapter.routeNestedCollection.mock.calls.forEach(([, config]) => {
365
+ expect(config.comments).toBe(true);
366
+ });
367
+ });
368
+
369
+ test("skips the discussions when the resource is not listed in the features config", () => {
370
+ const adapter = makeAdapter({ features: { comments: { page: true } } });
371
+
372
+ routeResourceModule(adapter, "banner", "banners");
373
+
374
+ expect(discussionPaths(adapter)).toEqual([]);
375
+ adapter.routeNestedCollection.mock.calls.forEach(([, config]) => {
376
+ expect(config.comments).toBe(false);
377
+ });
378
+ });
379
+
380
+ test("defaults the discussions to disabled when no features config is present at all", () => {
381
+ const adapter = makeAdapter();
382
+
383
+ routeResourceModule(adapter, "page", "pages");
384
+
385
+ expect(discussionPaths(adapter)).toEqual([]);
386
+ });
307
387
  });
@@ -1,5 +1,7 @@
1
1
  import setupCloud from "../setupCloud";
2
2
  import Categories from "../cloud/Categories";
3
+ import Comments from "../cloud/Comments";
4
+ import Editors from "../cloud/Editors";
3
5
  import Redirects from "../cloud/Redirects";
4
6
  import ResourceChanges from "../cloud/ResourceChanges";
5
7
  import ResourceRevisions from "../cloud/ResourceRevisions";
@@ -37,6 +39,8 @@ describe("setupCloud", () => {
37
39
  expect(ctx.client.resourceRevisions).toBeInstanceOf(ResourceRevisions);
38
40
  expect(ctx.client.resourceChanges).toBeInstanceOf(ResourceChanges);
39
41
  expect(ctx.client.categories).toBeInstanceOf(Categories);
42
+ expect(ctx.client.comments).toBeInstanceOf(Comments);
43
+ expect(ctx.client.editors).toBeInstanceOf(Editors);
40
44
  });
41
45
 
42
46
  test("clients receive token and config from ctx", () => {
@@ -0,0 +1,519 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ejs from "ejs";
3
+ import utils from "../../helpers/utils";
4
+
5
+ const viewsPath = `${process.cwd()}/src/views/resources/discussions`;
6
+ const template = readFileSync(`${viewsPath}/index.ejs`, "utf8");
7
+
8
+ const generalDiscussion = {
9
+ revisionId: null,
10
+ revision: null,
11
+ archived: false,
12
+ path: "/en/pages/42/discussions",
13
+ };
14
+
15
+ function renderDiscussions({
16
+ comments,
17
+ currentEditor = {},
18
+ query = {},
19
+ discussion = generalDiscussion,
20
+ revisions = [],
21
+ editors,
22
+ }) {
23
+ const assigns = {
24
+ editors,
25
+ discussion,
26
+ revisions,
27
+ _context: {
28
+ locale: "en",
29
+ query,
30
+ resourceConfig: { resourceTypePlural: "pages", comments: true },
31
+ },
32
+ comments,
33
+ currentEditor,
34
+ pagination: () => "<nav>pagination</nav>",
35
+ resource: { id: "42", title: "Hello", translations: {} },
36
+ utils: { ...utils, formattedDateTime: (value) => value },
37
+ };
38
+
39
+ const partials = {
40
+ "./_avatar.ejs": `${viewsPath}/_avatar.ejs`,
41
+ "./_discussion_tabs.ejs": `${viewsPath}/_discussion_tabs.ejs`,
42
+ "../_shared/_tabs.ejs": `${viewsPath}/../_shared/_tabs.ejs`,
43
+ };
44
+
45
+ const render_partial = (path, locals = {}) => {
46
+ if (!partials[path]) return "";
47
+
48
+ return ejs.render(readFileSync(partials[path], "utf8"), {
49
+ ...assigns,
50
+ ...locals,
51
+ });
52
+ };
53
+
54
+ return ejs.render(template, { ...assigns, render_partial });
55
+ }
56
+
57
+ const jane = {
58
+ id: "1",
59
+ name: "Jane Doe",
60
+ email: "jane@example.com",
61
+ avatar: "",
62
+ };
63
+ const john = {
64
+ id: "2",
65
+ name: "John Smith",
66
+ email: "john@example.com",
67
+ avatar: "https://example.com/john.png",
68
+ };
69
+
70
+ describe("discussions page", () => {
71
+ test("renders the comment form and an empty state", () => {
72
+ const rendered = renderDiscussions({ comments: { list: [] } });
73
+
74
+ expect(rendered).toContain('action="/en/pages/42/discussions"');
75
+ expect(rendered).toContain('name="comment[body]"');
76
+ expect(rendered).toContain('name="comment[reply_id]"');
77
+ expect(rendered).toContain("No comments yet.");
78
+ });
79
+
80
+ test("renders the comments with their quotes", () => {
81
+ const rendered = renderDiscussions({
82
+ currentEditor: { email: "JANE@example.com" },
83
+ comments: {
84
+ list: [
85
+ {
86
+ id: "11",
87
+ body: "Agreed <b>bold</b>",
88
+ editor: john,
89
+ reply: { id: "10", body: "First", editor: jane },
90
+ insertedAt: "2026-10-22T10:00:00Z",
91
+ },
92
+ {
93
+ id: "10",
94
+ body: "First",
95
+ editor: jane,
96
+ reply: null,
97
+ insertedAt: "2026-10-22T09:00:00Z",
98
+ },
99
+ ],
100
+ pagination: { pagesCount: 1 },
101
+ },
102
+ });
103
+
104
+ expect(rendered).toContain('id="comment-11"');
105
+ expect(rendered).toContain('data-copy-link="11"');
106
+ expect(rendered).toContain('data-copy-link="10"');
107
+ expect(rendered).toContain(
108
+ '<time data-comment-datetime datetime="2026-10-22T10:00:00Z">',
109
+ );
110
+ expect(rendered).toContain('id="comment-10"');
111
+ expect(rendered).toContain("Agreed &lt;b&gt;bold&lt;/b&gt;");
112
+ expect(rendered).toContain('data-scroll-to="10"');
113
+ expect(rendered).toContain('src="https://example.com/john.png"');
114
+ expect(rendered).toContain(">JD</div>");
115
+ expect(rendered).not.toContain("pagination</nav>");
116
+
117
+ // Only the comments of the current editor can be edited and deleted.
118
+ expect(rendered).toContain('hx-delete="/en/pages/42/comments/10"');
119
+ expect(rendered).not.toContain('hx-delete="/en/pages/42/comments/11"');
120
+ expect(rendered).toContain('hx-put="/en/pages/42/comments/10"');
121
+ expect(rendered).not.toContain('hx-put="/en/pages/42/comments/11"');
122
+ expect(rendered.match(/<details[^>]*data-comment-menu/g)).toHaveLength(1);
123
+ });
124
+
125
+ test("keeps deleted comments in the feed without their body or actions", () => {
126
+ const rendered = renderDiscussions({
127
+ currentEditor: { email: "jane@example.com" },
128
+ comments: {
129
+ list: [
130
+ {
131
+ id: "11",
132
+ body: "Agreed",
133
+ editor: john,
134
+ reply: {
135
+ id: "10",
136
+ body: "Secret plan",
137
+ editor: jane,
138
+ deletedAt: "2026-11-01T10:00:00Z",
139
+ },
140
+ insertedAt: "2026-10-22T10:00:00Z",
141
+ },
142
+ {
143
+ id: "10",
144
+ body: "Secret plan",
145
+ editor: jane,
146
+ reply: null,
147
+ insertedAt: "2026-10-22T09:00:00Z",
148
+ updatedAt: "2026-10-22T09:30:00Z",
149
+ deletedAt: "2026-11-01T10:00:00Z",
150
+ },
151
+ ],
152
+ },
153
+ });
154
+
155
+ expect(rendered).toContain('id="comment-10"');
156
+ expect(rendered).toContain('data-scroll-to="10"');
157
+ expect(rendered).not.toContain("Secret plan");
158
+ expect(rendered.match(/This comment has been deleted\./g)).toHaveLength(2);
159
+ expect(rendered).not.toContain('data-reply-to="10"');
160
+ expect(rendered).not.toContain('hx-delete="/en/pages/42/comments/10"');
161
+ expect(rendered).not.toContain('hx-put="/en/pages/42/comments/10"');
162
+ expect(rendered).not.toContain(">edited</span>");
163
+ expect(rendered).toContain('data-reply-to="11"');
164
+ });
165
+
166
+ test("marks edited comments", () => {
167
+ const rendered = renderDiscussions({
168
+ comments: {
169
+ list: [
170
+ {
171
+ id: "1",
172
+ body: "Edited",
173
+ editor: jane,
174
+ insertedAt: "2026-10-22T09:00:00Z",
175
+ updatedAt: "2026-10-22T10:00:00Z",
176
+ },
177
+ {
178
+ id: "2",
179
+ body: "Untouched",
180
+ editor: jane,
181
+ insertedAt: "2026-10-22T09:00:00Z",
182
+ updatedAt: "2026-10-22T09:00:00Z",
183
+ },
184
+ ],
185
+ },
186
+ });
187
+
188
+ expect(rendered.match(/>edited<\/span>/g)).toHaveLength(1);
189
+ });
190
+
191
+ test("renders the comments as markdown", () => {
192
+ const rendered = renderDiscussions({
193
+ comments: {
194
+ list: [
195
+ {
196
+ id: "1",
197
+ body: "**Looks good**, see raketa.cloud\n\n- one\n- two\n\n<b>raw</b>",
198
+ editor: jane,
199
+ insertedAt: "2026-10-22T09:00:00Z",
200
+ },
201
+ ],
202
+ },
203
+ });
204
+
205
+ expect(rendered).toContain("<strong>Looks good</strong>");
206
+ expect(rendered).toContain(
207
+ '<a href="http://raketa.cloud" target="_blank" rel="noopener noreferrer">raketa.cloud</a>',
208
+ );
209
+ expect(rendered).toContain("<ul>\n<li>one</li>");
210
+ expect(rendered).toContain("&lt;b&gt;raw&lt;/b&gt;");
211
+ expect(rendered).not.toContain("<b>raw</b>");
212
+ // The edit form keeps the markdown as it was written.
213
+ expect(rendered).toContain("**Looks good**, see raketa.cloud");
214
+ });
215
+
216
+ test("renders the pagination and errors", () => {
217
+ const rendered = renderDiscussions({
218
+ query: { comment_error: "Comment can't be blank." },
219
+ comments: {
220
+ list: [{ id: "1", body: "Hi", editor: jane, insertedAt: "" }],
221
+ pagination: { pagesCount: 2 },
222
+ },
223
+ });
224
+
225
+ expect(rendered).toContain("Comment can&#39;t be blank.");
226
+ expect(rendered).toContain("<nav>pagination</nav>");
227
+ });
228
+
229
+ test("uses the avatar of the current editor next to the comment field", () => {
230
+ const rendered = renderDiscussions({
231
+ comments: { list: [] },
232
+ currentEditor: {
233
+ name: "Todor Argirov",
234
+ email: "todor@example.com",
235
+ avatar: "https://example.com/todor.png",
236
+ },
237
+ });
238
+
239
+ expect(rendered).toContain('src="https://example.com/todor.png"');
240
+ // Shown when the avatar image fails to load.
241
+ expect(rendered).toMatch(/class="hidden [^"]*"[^>]*>TA<\/div>/);
242
+ });
243
+
244
+ test("uses the initials of the first two words of the names without avatars", () => {
245
+ const rendered = renderDiscussions({
246
+ currentEditor: { name: "Todor Ivanov Argirov", avatar: "" },
247
+ comments: {
248
+ list: [
249
+ {
250
+ id: "1",
251
+ body: "Hi",
252
+ editor: { name: " ", email: "mila@example.com", avatar: null },
253
+ insertedAt: "",
254
+ },
255
+ ],
256
+ },
257
+ });
258
+
259
+ expect(rendered).toContain(">TI</div>");
260
+ expect(rendered).toContain(">M</div>");
261
+ expect(rendered).not.toContain("<img");
262
+ });
263
+
264
+ test("handles missing comments", () => {
265
+ const rendered = renderDiscussions({
266
+ comments: { list: [], pagination: null },
267
+ });
268
+
269
+ expect(rendered).toContain("No comments yet.");
270
+ });
271
+
272
+ test("renders the discussion tabs with the current one active", () => {
273
+ const rendered = renderDiscussions({
274
+ comments: { list: [] },
275
+ revisions: [
276
+ { id: "200", title: "Homepage", meta: { title: "Hero update" } },
277
+ { id: "201", title: "Homepage", meta: { title: "" } },
278
+ ],
279
+ discussion: {
280
+ revisionId: "200",
281
+ revision: { id: "200" },
282
+ archived: false,
283
+ path: "/en/pages/42/discussions/200",
284
+ },
285
+ });
286
+
287
+ expect(rendered).toContain('href="/en/pages/42/discussions" class=');
288
+ expect(rendered).toContain("Rev #200 – Hero update");
289
+ expect(rendered).toContain("Rev #201 – Homepage");
290
+ expect(rendered).toMatch(
291
+ /href="\/en\/pages\/42\/discussions\/200"[^>]*aria-current="page"/,
292
+ );
293
+ expect(rendered).toContain('action="/en/pages/42/discussions/200"');
294
+ });
295
+
296
+ test("renders published revision discussions as read-only", () => {
297
+ const rendered = renderDiscussions({
298
+ currentEditor: { email: "jane@example.com" },
299
+ discussion: {
300
+ revisionId: "150",
301
+ revision: null,
302
+ archived: true,
303
+ path: "/en/pages/42/discussions/150",
304
+ },
305
+ comments: {
306
+ list: [
307
+ {
308
+ id: "11",
309
+ body: "Reply",
310
+ editor: jane,
311
+ reply: { id: "10", body: "First", editor: jane },
312
+ insertedAt: "2026-10-22T10:00:00Z",
313
+ },
314
+ ],
315
+ },
316
+ });
317
+
318
+ expect(rendered).toContain("Revision #150");
319
+ expect(rendered).toContain("· This discussion is archived");
320
+ expect(rendered).toContain('href="/en/pages/42/history"');
321
+ expect(rendered).toContain("Reply</p>");
322
+ expect(rendered).toContain('data-scroll-to="10"');
323
+ expect(rendered).not.toContain('id="comment-form"');
324
+ expect(rendered).not.toContain('aria-label="Discussions"');
325
+ expect(rendered).not.toContain('data-reply-to="11"');
326
+ expect(rendered).not.toContain('data-copy-link="11"');
327
+ expect(rendered).not.toMatch(/<details[^>]*data-comment-menu/);
328
+ expect(rendered).not.toContain("hx-delete=");
329
+ expect(rendered).not.toContain("hx-put=");
330
+ });
331
+
332
+ test("renders an empty read-only discussion", () => {
333
+ const rendered = renderDiscussions({
334
+ comments: { list: [] },
335
+ discussion: {
336
+ revisionId: "150",
337
+ revision: null,
338
+ archived: true,
339
+ path: "/en/pages/42/discussions/150",
340
+ },
341
+ });
342
+
343
+ expect(rendered).toContain("There are no comments in this discussion.");
344
+ });
345
+
346
+ test("marks the Discussions tab as current for open discussions", () => {
347
+ const rendered = renderDiscussions({ comments: { list: [] } });
348
+
349
+ expect(rendered).toMatch(
350
+ /href="\/en\/pages\/42\/discussions" class="font-semibold text-raketa-primary" aria-current="page"/,
351
+ );
352
+ });
353
+
354
+ test("marks no section tab as current for archived discussions", () => {
355
+ const rendered = renderDiscussions({
356
+ comments: { list: [] },
357
+ discussion: {
358
+ revisionId: "150",
359
+ revision: null,
360
+ archived: true,
361
+ path: "/en/pages/42/discussions/150",
362
+ },
363
+ });
364
+
365
+ expect(rendered).toContain('aria-label="Resource sections"');
366
+ expect(rendered).toContain('href="/en/pages/42/discussions"');
367
+ expect(rendered).not.toContain('aria-current="page"');
368
+ expect(rendered).not.toContain("border-raketa-primary pb-4");
369
+ });
370
+
371
+ describe("mentions", () => {
372
+ const nameless = {
373
+ id: "3",
374
+ name: " ",
375
+ email: "ops@example.com",
376
+ avatar: "",
377
+ };
378
+ const mentions = [
379
+ { editorId: "2", name: "John Smith", email: "john@example.com" },
380
+ { editorId: "3", name: "", email: "ops@example.com" },
381
+ ];
382
+ const comments = {
383
+ list: [
384
+ {
385
+ id: "10",
386
+ body: "Thanks @editor-2 and @editor-3, cc @editor-99",
387
+ mentions,
388
+ insertedAt: "2026-10-20T10:00:00Z",
389
+ editor: jane,
390
+ },
391
+ {
392
+ id: "11",
393
+ body: "Reply",
394
+ mentions: [],
395
+ reply: {
396
+ id: "10",
397
+ body: "Thanks @editor-2",
398
+ mentions,
399
+ editor: jane,
400
+ },
401
+ insertedAt: "2026-10-20T11:00:00Z",
402
+ editor: john,
403
+ },
404
+ ],
405
+ };
406
+ const fieldMarkup = '<div class="relative" data-mention-field>';
407
+
408
+ test("lists the editors of the website in the picker", () => {
409
+ const rendered = renderDiscussions({
410
+ comments,
411
+ editors: [jane, john, nameless],
412
+ });
413
+
414
+ expect(rendered).toContain('id="mention-picker"');
415
+ expect(rendered).toContain('data-editor-id="1" data-mention="Jane Doe"');
416
+ expect(rendered).toContain('data-search="john smith john@example.com"');
417
+ // Editors without a name are mentioned with their email.
418
+ expect(rendered).toContain(
419
+ 'data-editor-id="3" data-mention="ops@example.com"',
420
+ );
421
+ expect(rendered).toContain('src="https://example.com/john.png"');
422
+ expect(rendered.split(fieldMarkup)).toHaveLength(2);
423
+ });
424
+
425
+ test("edits own comments with the names in place of the mentions", () => {
426
+ const rendered = renderDiscussions({
427
+ comments,
428
+ currentEditor: jane,
429
+ editors: [jane, john],
430
+ });
431
+
432
+ expect(rendered.split(fieldMarkup)).toHaveLength(3);
433
+ expect(rendered).toContain(
434
+ 'data-mentions="{&#34;John Smith&#34;:&#34;2&#34;,&#34;ops@example.com&#34;:&#34;3&#34;}"',
435
+ );
436
+ expect(rendered).toContain(
437
+ ">Thanks @John Smith and @ops@example.com, cc @editor-99</textarea>",
438
+ );
439
+ });
440
+
441
+ test("renders the mentions with the names of the editors", () => {
442
+ const rendered = renderDiscussions({ comments });
443
+
444
+ expect(rendered).toContain(
445
+ '<span class="comment-mention">@John Smith</span>',
446
+ );
447
+ expect(rendered).toContain(
448
+ '<span class="comment-mention">@ops@example.com</span>',
449
+ );
450
+ expect(rendered).toContain(
451
+ 'cc <span class="comment-mention-unknown">@Unknown editor</span>',
452
+ );
453
+ });
454
+
455
+ test("writes the names in the quotes and the reply previews", () => {
456
+ const rendered = renderDiscussions({ comments });
457
+
458
+ expect(rendered).toContain(
459
+ 'data-reply-snippet="Thanks @John Smith and @ops@example.com, cc @editor-99"',
460
+ );
461
+ expect(rendered).toContain(
462
+ '<span class="truncate text-raketa-foreground/50">Thanks @John Smith</span>',
463
+ );
464
+ });
465
+
466
+ test("renders no picker without editors", () => {
467
+ const rendered = renderDiscussions({ comments });
468
+
469
+ expect(rendered).not.toContain('id="mention-picker"');
470
+ });
471
+
472
+ test("renders no picker in archived discussions", () => {
473
+ const rendered = renderDiscussions({
474
+ comments,
475
+ editors: [jane, john],
476
+ discussion: {
477
+ revisionId: "150",
478
+ revision: null,
479
+ archived: true,
480
+ path: "/en/pages/42/discussions/150",
481
+ },
482
+ });
483
+
484
+ expect(rendered).not.toContain('id="mention-picker"');
485
+ expect(rendered).not.toContain(fieldMarkup);
486
+ expect(rendered).toContain(
487
+ '<span class="comment-mention">@John Smith</span>',
488
+ );
489
+ });
490
+ });
491
+ });
492
+
493
+ describe("resource tabs", () => {
494
+ const tabsTemplate = readFileSync(
495
+ `${viewsPath}/../_shared/_tabs.ejs`,
496
+ "utf8",
497
+ );
498
+
499
+ function renderTabs(comments) {
500
+ return ejs.render(tabsTemplate, {
501
+ active: "overview",
502
+ resource: { id: "42" },
503
+ _context: {
504
+ locale: "en",
505
+ resourceConfig: { resourceTypePlural: "pages", comments },
506
+ },
507
+ });
508
+ }
509
+
510
+ test("links the discussions when comments are enabled", () => {
511
+ const rendered = renderTabs(true);
512
+
513
+ expect(rendered).toContain('href="/en/pages/42/discussions"');
514
+ });
515
+
516
+ test("renders no tabs when comments are disabled", () => {
517
+ expect(renderTabs(false).trim()).toBe("");
518
+ });
519
+ });
@@ -13,6 +13,16 @@
13
13
  <% } %>
14
14
  </form>
15
15
 
16
+ <% if (_context.resourceConfig.comments && history.hasDiscussion(change, locals.commentCounts)) { %>
17
+ <a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/discussions/${change.revisionId}` %>" class="btn-text has-icon">
18
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
19
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
20
+ </svg>
21
+
22
+ Discussion
23
+ </a>
24
+ <% } %>
25
+
16
26
  <a href="/remote/login?redirect_to=/<%= _context.locale %>/admin/<%= _context.resourceConfig.resourceTypePlural %>/<%= resource.id %>/changes/<%= change.version %>" class="btn-text has-icon">
17
27
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
18
28
  <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
@@ -0,0 +1,21 @@
1
+ <%
2
+ const activeClass = "relative top-0.5 px-4 border-b-2 border-raketa-primary pb-4";
3
+ const inactiveClass = "relative top-0.5 px-4 border-b-2 border-transparent pb-4";
4
+ const resourcePath = `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}`;
5
+ const tabs = [
6
+ { key: "overview", label: "Overview", path: resourcePath },
7
+ _context.resourceConfig.comments && { key: "discussions", label: "Discussions", path: `${resourcePath}/discussions` },
8
+ ].filter(Boolean);
9
+ %>
10
+
11
+ <% if (tabs.length > 1) { %>
12
+ <nav class="flex items-center <%= locals.spacing || 'mb-8' %> border-b-2 border-neutral-200 dark:border-neutral-700" aria-label="Resource sections">
13
+ <% tabs.forEach(tab => { %>
14
+ <div class="<%= active === tab.key ? activeClass : inactiveClass %>">
15
+ <a href="<%= tab.path %>" class="font-semibold text-raketa-primary" <%- active === tab.key ? 'aria-current="page"' : '' %>>
16
+ <%= tab.label %>
17
+ </a>
18
+ </div>
19
+ <% }) %>
20
+ </nav>
21
+ <% } %>
@@ -7,7 +7,7 @@
7
7
  const resourceName = Inflector.humanize(Inflector.downcase(_context.resourceConfig.resourceType));
8
8
  %>
9
9
  <div class="actions-toolbar gap-2">
10
- <%- render_partial('./_shared/_translations_strip.ejs', { render_partial }) %>
10
+ <%- render_partial(`${locals.base || '.'}/_shared/_translations_strip.ejs`, { render_partial }) %>
11
11
 
12
12
  <span class="toolbar-divider"></span>
13
13