@actuate-media/cms-admin 0.12.0 → 0.12.1

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 (152) hide show
  1. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  2. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/comment-mark.test.js +89 -0
  4. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  5. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  9. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/notification-bell.test.js +200 -0
  12. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  13. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/offline-status.test.js +56 -0
  16. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  17. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  21. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  22. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  23. package/dist/__tests__/lib/active-editor.test.js +57 -0
  24. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  25. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  26. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  27. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  28. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  29. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  30. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  31. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  32. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  33. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  34. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  35. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  36. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  37. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  38. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  39. package/dist/__tests__/lib/comments-client.test.js +202 -0
  40. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  41. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  42. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  43. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  44. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  45. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  46. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  47. package/dist/__tests__/lib/slash-command.test.js +123 -0
  48. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  49. package/dist/actuate-admin.css +1 -1
  50. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  51. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  52. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  53. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  54. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  55. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  56. package/dist/components/CoauthorResultPopover.js +42 -0
  57. package/dist/components/CoauthorResultPopover.js.map +1 -0
  58. package/dist/components/CommandPalette.d.ts +1 -1
  59. package/dist/components/CommandPalette.d.ts.map +1 -1
  60. package/dist/components/CommandPalette.js +138 -40
  61. package/dist/components/CommandPalette.js.map +1 -1
  62. package/dist/components/CommentMark.d.ts +54 -0
  63. package/dist/components/CommentMark.d.ts.map +1 -0
  64. package/dist/components/CommentMark.js +104 -0
  65. package/dist/components/CommentMark.js.map +1 -0
  66. package/dist/components/CommentSidePanel.d.ts +86 -0
  67. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  68. package/dist/components/CommentSidePanel.js +165 -0
  69. package/dist/components/CommentSidePanel.js.map +1 -0
  70. package/dist/components/LivePreview.d.ts.map +1 -1
  71. package/dist/components/LivePreview.js +80 -16
  72. package/dist/components/LivePreview.js.map +1 -1
  73. package/dist/components/NotificationBell.d.ts +45 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +90 -0
  76. package/dist/components/NotificationBell.js.map +1 -0
  77. package/dist/components/OfflineStatus.d.ts +39 -0
  78. package/dist/components/OfflineStatus.d.ts.map +1 -0
  79. package/dist/components/OfflineStatus.js +47 -0
  80. package/dist/components/OfflineStatus.js.map +1 -0
  81. package/dist/components/PresenceChips.d.ts +31 -0
  82. package/dist/components/PresenceChips.d.ts.map +1 -0
  83. package/dist/components/PresenceChips.js +58 -0
  84. package/dist/components/PresenceChips.js.map +1 -0
  85. package/dist/components/TipTapEditor.d.ts +61 -1
  86. package/dist/components/TipTapEditor.d.ts.map +1 -1
  87. package/dist/components/TipTapEditor.js +211 -8
  88. package/dist/components/TipTapEditor.js.map +1 -1
  89. package/dist/components/ui/Badge.d.ts +1 -1
  90. package/dist/index.d.ts +8 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +4 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/active-editor.d.ts +6 -0
  95. package/dist/lib/active-editor.d.ts.map +1 -0
  96. package/dist/lib/active-editor.js +35 -0
  97. package/dist/lib/active-editor.js.map +1 -0
  98. package/dist/lib/coauthor-client.d.ts +72 -0
  99. package/dist/lib/coauthor-client.d.ts.map +1 -0
  100. package/dist/lib/coauthor-client.js +98 -0
  101. package/dist/lib/coauthor-client.js.map +1 -0
  102. package/dist/lib/collaboration-provider.d.ts +142 -0
  103. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  104. package/dist/lib/collaboration-provider.js +168 -0
  105. package/dist/lib/collaboration-provider.js.map +1 -0
  106. package/dist/lib/comment-anchor.d.ts +86 -0
  107. package/dist/lib/comment-anchor.d.ts.map +1 -0
  108. package/dist/lib/comment-anchor.js +139 -0
  109. package/dist/lib/comment-anchor.js.map +1 -0
  110. package/dist/lib/comments-client.d.ts +106 -0
  111. package/dist/lib/comments-client.d.ts.map +1 -0
  112. package/dist/lib/comments-client.js +138 -0
  113. package/dist/lib/comments-client.js.map +1 -0
  114. package/dist/lib/notifications-client.d.ts +68 -0
  115. package/dist/lib/notifications-client.d.ts.map +1 -0
  116. package/dist/lib/notifications-client.js +85 -0
  117. package/dist/lib/notifications-client.js.map +1 -0
  118. package/dist/lib/slash-command.d.ts +39 -0
  119. package/dist/lib/slash-command.d.ts.map +1 -0
  120. package/dist/lib/slash-command.js +50 -0
  121. package/dist/lib/slash-command.js.map +1 -0
  122. package/package.json +12 -3
  123. package/src/__tests__/components/comment-mark.test.ts +99 -0
  124. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  125. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  126. package/src/__tests__/components/offline-status.test.tsx +66 -0
  127. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  128. package/src/__tests__/lib/active-editor.test.ts +70 -0
  129. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  130. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  131. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  132. package/src/__tests__/lib/comments-client.test.ts +239 -0
  133. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  134. package/src/__tests__/lib/slash-command.test.ts +146 -0
  135. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  136. package/src/components/CoauthorResultPopover.tsx +193 -0
  137. package/src/components/CommandPalette.tsx +397 -209
  138. package/src/components/CommentMark.ts +138 -0
  139. package/src/components/CommentSidePanel.tsx +521 -0
  140. package/src/components/LivePreview.tsx +117 -11
  141. package/src/components/NotificationBell.tsx +242 -0
  142. package/src/components/OfflineStatus.tsx +90 -0
  143. package/src/components/PresenceChips.tsx +150 -0
  144. package/src/components/TipTapEditor.tsx +310 -12
  145. package/src/index.ts +20 -0
  146. package/src/lib/active-editor.ts +39 -0
  147. package/src/lib/coauthor-client.ts +160 -0
  148. package/src/lib/collaboration-provider.ts +306 -0
  149. package/src/lib/comment-anchor.ts +179 -0
  150. package/src/lib/comments-client.ts +208 -0
  151. package/src/lib/notifications-client.ts +146 -0
  152. package/src/lib/slash-command.ts +67 -0
@@ -0,0 +1,39 @@
1
+ import { type CoauthorAction } from './coauthor-client.js';
2
+ /**
3
+ * Cmd+K slash-command parsing for the AI co-author.
4
+ *
5
+ * Recognized syntax (case-insensitive, leading whitespace tolerated):
6
+ *
7
+ * /ai <action> [text...]
8
+ *
9
+ * `<action>` is one of `COAUTHOR_ACTIONS`. `text` is the payload handed to
10
+ * `/ai/coauthor`; it may be empty while the user is still typing.
11
+ *
12
+ * The parser is pure so we can unit-test every edge case (case folding,
13
+ * trailing whitespace, partial input, unknown actions) without spinning up
14
+ * `cmdk` or the rest of the palette.
15
+ */
16
+ export interface AiSlashCommand {
17
+ action: CoauthorAction;
18
+ text: string;
19
+ }
20
+ /**
21
+ * Returns the matched `{ action, text }` when `input` is a complete
22
+ * slash command (`/ai rewrite ...`), or `null` otherwise. `text` may be
23
+ * empty when the user has typed `/ai rewrite` with no payload yet.
24
+ */
25
+ export declare function parseAiSlashCommand(input: string): AiSlashCommand | null;
26
+ /**
27
+ * Returns true when `input` looks like the start of an AI slash command
28
+ * — even when the action isn't typed yet (`/`, `/a`, `/ai`, `/ai re`).
29
+ * The palette uses this to switch into "AI mode" (hide nav results,
30
+ * show the four action suggestions).
31
+ */
32
+ export declare function isAiSlashCommandInput(input: string): boolean;
33
+ /**
34
+ * Ordered list of actions to show in AI mode. When the user has typed a
35
+ * valid command, that command's action is hoisted to the top so it gets
36
+ * the default keyboard focus (and Enter runs it).
37
+ */
38
+ export declare function orderedAiActions(input: string): CoauthorAction[];
39
+ //# sourceMappingURL=slash-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slash-command.d.ts","sourceRoot":"","sources":["../../src/lib/slash-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAE9F;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,cAAc,CAAA;IACtB,IAAI,EAAE,MAAM,CAAA;CACb;AAID;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAYxE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAM5D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,EAAE,CAIhE"}
@@ -0,0 +1,50 @@
1
+ import { COAUTHOR_ACTIONS, isCoauthorAction } from './coauthor-client.js';
2
+ const SLASH_HEAD = /^\s*\/\s*ai(?:\s+|$)/i;
3
+ /**
4
+ * Returns the matched `{ action, text }` when `input` is a complete
5
+ * slash command (`/ai rewrite ...`), or `null` otherwise. `text` may be
6
+ * empty when the user has typed `/ai rewrite` with no payload yet.
7
+ */
8
+ export function parseAiSlashCommand(input) {
9
+ if (typeof input !== 'string')
10
+ return null;
11
+ if (!SLASH_HEAD.test(input))
12
+ return null;
13
+ const after = input.replace(SLASH_HEAD, '');
14
+ if (after.length === 0)
15
+ return null;
16
+ const firstWhitespace = after.search(/\s/);
17
+ const head = (firstWhitespace === -1 ? after : after.slice(0, firstWhitespace)).toLowerCase();
18
+ if (!isCoauthorAction(head))
19
+ return null;
20
+ const tail = firstWhitespace === -1 ? '' : after.slice(firstWhitespace + 1);
21
+ return { action: head, text: tail.trim() };
22
+ }
23
+ /**
24
+ * Returns true when `input` looks like the start of an AI slash command
25
+ * — even when the action isn't typed yet (`/`, `/a`, `/ai`, `/ai re`).
26
+ * The palette uses this to switch into "AI mode" (hide nav results,
27
+ * show the four action suggestions).
28
+ */
29
+ export function isAiSlashCommandInput(input) {
30
+ if (typeof input !== 'string')
31
+ return false;
32
+ const trimmed = input.trim();
33
+ if (trimmed.length === 0)
34
+ return false;
35
+ if (trimmed === '/')
36
+ return true;
37
+ return /^\/\s*a(i\b|i?[a-z]*|)?(\s.*)?$/i.test(trimmed);
38
+ }
39
+ /**
40
+ * Ordered list of actions to show in AI mode. When the user has typed a
41
+ * valid command, that command's action is hoisted to the top so it gets
42
+ * the default keyboard focus (and Enter runs it).
43
+ */
44
+ export function orderedAiActions(input) {
45
+ const parsed = parseAiSlashCommand(input);
46
+ if (!parsed)
47
+ return [...COAUTHOR_ACTIONS];
48
+ return [parsed.action, ...COAUTHOR_ACTIONS.filter((a) => a !== parsed.action)];
49
+ }
50
+ //# sourceMappingURL=slash-command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slash-command.js","sourceRoot":"","sources":["../../src/lib/slash-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAuB,MAAM,sBAAsB,CAAA;AAsB9F,MAAM,UAAU,GAAG,uBAAuB,CAAA;AAE1C;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAa;IAC/C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IAC1C,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACxC,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAA;IAC3C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAEnC,MAAM,eAAe,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IAC1C,MAAM,IAAI,GAAG,CAAC,eAAe,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,eAAe,CAAC,CAAC,CAAC,WAAW,EAAE,CAAA;IAC7F,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAExC,MAAM,IAAI,GAAG,eAAe,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,eAAe,GAAG,CAAC,CAAC,CAAA;IAC3E,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,CAAA;AAC5C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,KAAa;IACjD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3C,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC5B,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IACtC,IAAI,OAAO,KAAK,GAAG;QAAE,OAAO,IAAI,CAAA;IAChC,OAAO,kCAAkC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;AACzD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,MAAM,MAAM,GAAG,mBAAmB,CAAC,KAAK,CAAC,CAAA;IACzC,IAAI,CAAC,MAAM;QAAE,OAAO,CAAC,GAAG,gBAAgB,CAAC,CAAA;IACzC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,CAAA;AAChF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -38,7 +38,10 @@
38
38
  "@radix-ui/react-select": "^2.2.6",
39
39
  "@radix-ui/react-switch": "^1.1.0",
40
40
  "@radix-ui/react-tabs": "^1.1.0",
41
+ "@tiptap/core": "^2.11.0",
41
42
  "@tiptap/extension-code-block-lowlight": "^2.11.0",
43
+ "@tiptap/extension-collaboration": "^2.11.0",
44
+ "@tiptap/extension-collaboration-cursor": "^2.11.0",
42
45
  "@tiptap/extension-horizontal-rule": "^2.11.0",
43
46
  "@tiptap/extension-image": "^2.11.0",
44
47
  "@tiptap/extension-link": "^2.11.0",
@@ -59,7 +62,11 @@
59
62
  "react-dom": "^19.2.0",
60
63
  "recharts": "^3.8.1",
61
64
  "sonner": "^2.0.7",
62
- "tailwind-merge": "^3.5.0"
65
+ "tailwind-merge": "^3.5.0",
66
+ "y-indexeddb": "^9.0.12",
67
+ "y-protocols": "^1.0.6",
68
+ "y-websocket": "^2.0.4",
69
+ "yjs": "^13.6.0"
63
70
  },
64
71
  "peerDependencies": {
65
72
  "@actuate-media/cms-core": ">=0.1.0",
@@ -72,13 +79,15 @@
72
79
  },
73
80
  "devDependencies": {
74
81
  "@tailwindcss/cli": "^4.0.0",
82
+ "@testing-library/react": "^16.1.0",
75
83
  "@types/react": "^19.0.0",
76
84
  "@types/react-dom": "^19.0.0",
77
85
  "concurrently": "^9.2.1",
86
+ "happy-dom": "^20.9.0",
78
87
  "tailwindcss": "^4.0.0",
79
88
  "typescript": "^5.7.0",
80
89
  "vitest": "^3.0.0",
81
- "@actuate-media/cms-core": "0.21.0",
90
+ "@actuate-media/cms-core": "0.22.0",
82
91
  "@actuate-media/component-blocks": "0.2.0"
83
92
  },
84
93
  "scripts": {
@@ -0,0 +1,99 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+
6
+ import { CommentMark } from '../../components/CommentMark.js'
7
+
8
+ function newEditor(initialHtml = '<p>hello world</p>') {
9
+ return new Editor({
10
+ extensions: [StarterKit.configure({ history: false }), CommentMark],
11
+ content: initialHtml,
12
+ })
13
+ }
14
+
15
+ describe('CommentMark', () => {
16
+ it('registers under the name "comment"', () => {
17
+ const ed = newEditor()
18
+ expect(ed.extensionManager.extensions.some((e) => e.name === 'comment')).toBe(true)
19
+ ed.destroy()
20
+ })
21
+
22
+ it('setComment applies the mark across the current selection', () => {
23
+ const ed = newEditor('<p>hello world</p>')
24
+ ed.commands.setTextSelection({ from: 1, to: 6 }) // "hello"
25
+ const ok = ed.chain().setComment({ commentId: 'c-1' }).run()
26
+ expect(ok).toBe(true)
27
+ expect(ed.isActive('comment', { commentId: 'c-1' })).toBe(true)
28
+ // The rendered HTML should now contain the data attribute.
29
+ const html = ed.getHTML()
30
+ expect(html).toContain('data-comment-id="c-1"')
31
+ expect(html).toContain('hello')
32
+ ed.destroy()
33
+ })
34
+
35
+ it('setComment rejects a missing commentId', () => {
36
+ const ed = newEditor()
37
+ ed.commands.setTextSelection({ from: 1, to: 6 })
38
+ const ok = ed.chain().setComment({ commentId: '' }).run()
39
+ expect(ok).toBe(false)
40
+ expect(ed.getHTML()).not.toContain('data-comment-id')
41
+ ed.destroy()
42
+ })
43
+
44
+ it('updateComment swaps the commentId across the mark range', () => {
45
+ const ed = newEditor('<p>hello world</p>')
46
+ ed.commands.setTextSelection({ from: 1, to: 6 })
47
+ ed.chain().setComment({ commentId: 'c-1' }).run()
48
+ // Re-select the same range so updateAttributes can find the mark to
49
+ // mutate. TipTap's updateAttributes applies to the marks that fall
50
+ // within the selection — a collapsed cursor at the boundary would
51
+ // not match this mark.
52
+ ed.commands.setTextSelection({ from: 1, to: 6 })
53
+ const ok = ed.chain().updateComment({ commentId: 'c-2' }).run()
54
+ expect(ok).toBe(true)
55
+ expect(ed.getHTML()).toContain('data-comment-id="c-2"')
56
+ ed.destroy()
57
+ })
58
+
59
+ it('unsetComment removes the mark from the selection', () => {
60
+ const ed = newEditor('<p>hello world</p>')
61
+ ed.commands.setTextSelection({ from: 1, to: 6 })
62
+ ed.chain().setComment({ commentId: 'c-1' }).run()
63
+ expect(ed.getHTML()).toContain('data-comment-id')
64
+ ed.commands.setTextSelection({ from: 1, to: 6 })
65
+ ed.chain().unsetComment().run()
66
+ expect(ed.getHTML()).not.toContain('data-comment-id')
67
+ ed.destroy()
68
+ })
69
+
70
+ it('parses an existing comment span on load', () => {
71
+ const ed = newEditor('<p><span data-comment-id="c-7">hello</span> world</p>')
72
+ expect(ed.getHTML()).toContain('data-comment-id="c-7"')
73
+ ed.destroy()
74
+ })
75
+
76
+ it('applies the default CSS class on every rendered span', () => {
77
+ const ed = newEditor('<p>hello world</p>')
78
+ ed.commands.setTextSelection({ from: 1, to: 6 })
79
+ ed.chain().setComment({ commentId: 'c-9' }).run()
80
+ expect(ed.getHTML()).toMatch(/class="actuate-comment"/)
81
+ ed.destroy()
82
+ })
83
+
84
+ it('supports overlapping comments (non-spanning behaviour)', () => {
85
+ // Set a comment over chars 1..6 ("hello").
86
+ const ed = newEditor('<p>hello world</p>')
87
+ ed.commands.setTextSelection({ from: 1, to: 6 })
88
+ ed.chain().setComment({ commentId: 'c-a' }).run()
89
+ // Now apply a second comment over chars 4..8 ("o wo"). TipTap will
90
+ // split the marks — that's the standard mark-overlap behaviour.
91
+ ed.commands.setTextSelection({ from: 4, to: 8 })
92
+ ed.chain().setComment({ commentId: 'c-b' }).run()
93
+
94
+ const html = ed.getHTML()
95
+ expect(html).toContain('data-comment-id="c-a"')
96
+ expect(html).toContain('data-comment-id="c-b"')
97
+ ed.destroy()
98
+ })
99
+ })
@@ -0,0 +1,343 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, beforeEach, vi } from 'vitest'
3
+ import React from 'react'
4
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ import { CommentSidePanel, type CommentsApi } from '../../components/CommentSidePanel.js'
7
+ import type { Comment, CommentOutcome } from '../../lib/comments-client.js'
8
+
9
+ function ok<T>(value: T): CommentOutcome<T> {
10
+ return { ok: true, result: value }
11
+ }
12
+
13
+ function err(error = 'boom', status = 500): CommentOutcome<never> {
14
+ return { ok: false, error, status }
15
+ }
16
+
17
+ function comment(overrides: Partial<Comment> = {}): Comment {
18
+ return {
19
+ id: 'c-1',
20
+ documentId: 'doc-1',
21
+ parentId: null,
22
+ userId: 'user-1',
23
+ body: 'Hello there',
24
+ anchor: null,
25
+ resolvedAt: null,
26
+ resolvedById: null,
27
+ deletedAt: null,
28
+ createdAt: '2026-01-01T00:00:00.000Z',
29
+ updatedAt: '2026-01-01T00:00:00.000Z',
30
+ isResolved: false,
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Build a fresh in-memory CommentsApi stub for each test. We expose
37
+ * the `list` payload as `state.rows` so tests can poke server state
38
+ * directly and verify the panel re-fetches after every mutation.
39
+ */
40
+ function createFakeApi() {
41
+ const rows: Comment[] = []
42
+ const calls: { name: string; args: unknown[] }[] = []
43
+
44
+ const stub: CommentsApi = {
45
+ list: vi.fn(async (_documentId: string, opts?: { includeResolved?: boolean }) => {
46
+ calls.push({ name: 'list', args: [opts] })
47
+ const visible = rows.filter(
48
+ (c) => (opts?.includeResolved ? true : !c.isResolved) && c.deletedAt === null,
49
+ )
50
+ return ok(visible)
51
+ }),
52
+ create: vi.fn(async (input) => {
53
+ calls.push({ name: 'create', args: [input] })
54
+ const created = comment({
55
+ id: `c-${rows.length + 1}`,
56
+ body: input.body,
57
+ anchor: input.anchor ?? null,
58
+ parentId: input.parentId ?? null,
59
+ userId: 'current',
60
+ })
61
+ rows.push(created)
62
+ return ok(created)
63
+ }),
64
+ update: vi.fn(async (id, body) => {
65
+ calls.push({ name: 'update', args: [id, body] })
66
+ const row = rows.find((r) => r.id === id)
67
+ if (!row) return err('not found', 404)
68
+ row.body = body
69
+ return ok(row)
70
+ }),
71
+ resolve: vi.fn(async (id) => {
72
+ calls.push({ name: 'resolve', args: [id] })
73
+ const row = rows.find((r) => r.id === id)
74
+ if (!row) return err('not found', 404)
75
+ row.isResolved = true
76
+ row.resolvedAt = '2026-01-02T00:00:00.000Z'
77
+ return ok(row)
78
+ }),
79
+ reopen: vi.fn(async (id) => {
80
+ calls.push({ name: 'reopen', args: [id] })
81
+ const row = rows.find((r) => r.id === id)
82
+ if (!row) return err('not found', 404)
83
+ row.isResolved = false
84
+ row.resolvedAt = null
85
+ return ok(row)
86
+ }),
87
+ remove: vi.fn(async (id) => {
88
+ calls.push({ name: 'remove', args: [id] })
89
+ const row = rows.find((r) => r.id === id)
90
+ if (!row) return err('not found', 404)
91
+ row.deletedAt = '2026-01-02T00:00:00.000Z'
92
+ return ok(row)
93
+ }),
94
+ }
95
+
96
+ return { api: stub, rows, calls }
97
+ }
98
+
99
+ beforeEach(() => {
100
+ vi.clearAllMocks()
101
+ })
102
+
103
+ describe('CommentSidePanel — loading & list', () => {
104
+ it('renders an empty state once the list call resolves', async () => {
105
+ const { api } = createFakeApi()
106
+ render(<CommentSidePanel documentId="doc-1" currentUserId="current" api={api} />)
107
+ expect(screen.getByText('Loading…')).toBeTruthy()
108
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
109
+ expect(api.list).toHaveBeenCalledWith('doc-1', { includeResolved: false })
110
+ })
111
+
112
+ it('groups roots + replies into a single card', async () => {
113
+ const { api, rows } = createFakeApi()
114
+ rows.push(comment({ id: 'root', body: 'root' }))
115
+ rows.push(
116
+ comment({
117
+ id: 'reply',
118
+ parentId: 'root',
119
+ body: 'reply',
120
+ createdAt: '2026-01-01T00:00:01.000Z',
121
+ }),
122
+ )
123
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
124
+ await waitFor(() => expect(screen.getByTestId('comment-thread-root')).toBeTruthy())
125
+ expect(screen.getByTestId('comment-body-root').textContent).toBe('root')
126
+ expect(screen.getByTestId('comment-body-reply').textContent).toBe('reply')
127
+ })
128
+
129
+ it('hides resolved threads until the toggle is flipped', async () => {
130
+ const { api, rows } = createFakeApi()
131
+ rows.push(comment({ id: 'open', body: 'open' }))
132
+ rows.push(
133
+ comment({
134
+ id: 'closed',
135
+ body: 'closed',
136
+ isResolved: true,
137
+ resolvedAt: '2026-01-02T00:00:00.000Z',
138
+ }),
139
+ )
140
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
141
+ await waitFor(() => expect(screen.getByTestId('comment-thread-open')).toBeTruthy())
142
+ expect(screen.queryByTestId('comment-thread-closed')).toBeNull()
143
+
144
+ fireEvent.click(screen.getByTestId('include-resolved-toggle'))
145
+ await waitFor(() => expect(screen.getByTestId('comment-thread-closed')).toBeTruthy())
146
+ // Most-recent list call should have flipped `includeResolved`.
147
+ expect(api.list).toHaveBeenLastCalledWith('doc-1', { includeResolved: true })
148
+ })
149
+
150
+ it('surfaces a server failure through onError', async () => {
151
+ const { api } = createFakeApi()
152
+ ;(api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
153
+ const onError = vi.fn()
154
+ render(
155
+ <CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} onError={onError} />,
156
+ )
157
+ await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
158
+ })
159
+ })
160
+
161
+ describe('CommentSidePanel — compose', () => {
162
+ it('disables the submit button while the draft is empty', async () => {
163
+ const { api } = createFakeApi()
164
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
165
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
166
+ const btn = screen.getByTestId('comment-submit') as HTMLButtonElement
167
+ expect(btn.disabled).toBe(true)
168
+ fireEvent.change(screen.getByTestId('comment-composer'), {
169
+ target: { value: 'hello' },
170
+ })
171
+ expect((screen.getByTestId('comment-submit') as HTMLButtonElement).disabled).toBe(false)
172
+ })
173
+
174
+ it('posts a new comment, clears the draft, and refreshes', async () => {
175
+ const { api, calls } = createFakeApi()
176
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
177
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
178
+
179
+ fireEvent.change(screen.getByTestId('comment-composer'), {
180
+ target: { value: ' fresh thought ' },
181
+ })
182
+ await act(async () => {
183
+ fireEvent.click(screen.getByTestId('comment-submit'))
184
+ })
185
+
186
+ expect(calls.some((c) => c.name === 'create')).toBe(true)
187
+ const createCall = calls.find((c) => c.name === 'create')!
188
+ expect((createCall.args[0] as { body: string }).body).toBe('fresh thought')
189
+ expect((screen.getByTestId('comment-composer') as HTMLTextAreaElement).value).toBe('')
190
+ expect(calls.filter((c) => c.name === 'list').length).toBeGreaterThanOrEqual(2)
191
+ })
192
+
193
+ it('forwards onComposeAnchor result into the create call', async () => {
194
+ const { api, calls } = createFakeApi()
195
+ const onComposeAnchor = vi.fn().mockReturnValue({ from: 'AQ==', to: 'Ag==' })
196
+ render(
197
+ <CommentSidePanel
198
+ documentId="doc-1"
199
+ currentUserId="user-1"
200
+ api={api}
201
+ onComposeAnchor={onComposeAnchor}
202
+ />,
203
+ )
204
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
205
+ fireEvent.change(screen.getByTestId('comment-composer'), {
206
+ target: { value: 'anchored' },
207
+ })
208
+ await act(async () => {
209
+ fireEvent.click(screen.getByTestId('comment-submit'))
210
+ })
211
+
212
+ expect(onComposeAnchor).toHaveBeenCalledTimes(1)
213
+ const createCall = calls.find((c) => c.name === 'create')!
214
+ expect((createCall.args[0] as { anchor: unknown }).anchor).toEqual({
215
+ from: 'AQ==',
216
+ to: 'Ag==',
217
+ })
218
+ })
219
+ })
220
+
221
+ describe('CommentSidePanel — reply, edit, resolve, delete', () => {
222
+ async function setupWith(rowsInit: Comment[]) {
223
+ const fake = createFakeApi()
224
+ for (const row of rowsInit) fake.rows.push(row)
225
+ const renderResult = render(
226
+ <CommentSidePanel documentId="doc-1" currentUserId="user-1" isAdmin={false} api={fake.api} />,
227
+ )
228
+ await waitFor(() =>
229
+ expect(screen.getByTestId(`comment-thread-${rowsInit[0]!.id}`)).toBeTruthy(),
230
+ )
231
+ return { ...fake, ...renderResult }
232
+ }
233
+
234
+ it('replies through the thread input + clears it on submit', async () => {
235
+ const { api, calls } = await setupWith([
236
+ comment({ id: 'root', userId: 'user-1', body: 'root' }),
237
+ ])
238
+ fireEvent.change(screen.getByTestId('reply-input-root'), {
239
+ target: { value: 'hi back' },
240
+ })
241
+ await act(async () => {
242
+ fireEvent.click(screen.getByTestId('reply-submit-root'))
243
+ })
244
+
245
+ const replyCall = calls.find(
246
+ (c) => c.name === 'create' && (c.args[0] as { parentId?: string | null }).parentId === 'root',
247
+ )
248
+ expect(replyCall).toBeDefined()
249
+ expect((screen.getByTestId('reply-input-root') as HTMLInputElement).value).toBe('')
250
+ expect(api.list).toHaveBeenCalled()
251
+ })
252
+
253
+ it('shows edit + delete only on comments the user owns (non-admin)', async () => {
254
+ await setupWith([
255
+ comment({ id: 'mine', userId: 'user-1', body: 'mine' }),
256
+ comment({
257
+ id: 'theirs',
258
+ userId: 'someone-else',
259
+ body: 'theirs',
260
+ createdAt: '2026-01-01T00:00:01.000Z',
261
+ }),
262
+ ])
263
+ expect(screen.queryByTestId('edit-mine')).toBeTruthy()
264
+ expect(screen.queryByTestId('delete-mine')).toBeTruthy()
265
+ expect(screen.queryByTestId('edit-theirs')).toBeNull()
266
+ expect(screen.queryByTestId('delete-theirs')).toBeNull()
267
+ })
268
+
269
+ it('admins see edit + delete on every comment', async () => {
270
+ const fake = createFakeApi()
271
+ fake.rows.push(comment({ id: 'theirs', userId: 'someone-else' }))
272
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" isAdmin api={fake.api} />)
273
+ await waitFor(() => expect(screen.getByTestId('comment-thread-theirs')).toBeTruthy())
274
+ expect(screen.getByTestId('edit-theirs')).toBeTruthy()
275
+ expect(screen.getByTestId('delete-theirs')).toBeTruthy()
276
+ })
277
+
278
+ it('edits a comment body in place', async () => {
279
+ const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1', body: 'before' })])
280
+ fireEvent.click(screen.getByTestId('edit-c1'))
281
+ fireEvent.change(screen.getByTestId('edit-textarea-c1'), {
282
+ target: { value: 'after' },
283
+ })
284
+ await act(async () => {
285
+ fireEvent.click(screen.getByTestId('edit-save-c1'))
286
+ })
287
+ expect(api.update).toHaveBeenCalledWith('c1', 'after')
288
+ await waitFor(() => expect(screen.getByTestId('comment-body-c1').textContent).toBe('after'))
289
+ })
290
+
291
+ it('cancels an edit without writing', async () => {
292
+ const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1', body: 'before' })])
293
+ fireEvent.click(screen.getByTestId('edit-c1'))
294
+ fireEvent.change(screen.getByTestId('edit-textarea-c1'), {
295
+ target: { value: 'never saved' },
296
+ })
297
+ fireEvent.click(screen.getByTestId('edit-cancel-c1'))
298
+ expect(api.update).not.toHaveBeenCalled()
299
+ expect(screen.getByTestId('comment-body-c1').textContent).toBe('before')
300
+ })
301
+
302
+ it('resolves an open thread and reopens a closed one', async () => {
303
+ const { api, rows } = await setupWith([comment({ id: 'open', userId: 'user-1', body: 'open' })])
304
+ await act(async () => {
305
+ fireEvent.click(screen.getByTestId('resolve-open'))
306
+ })
307
+ expect(api.resolve).toHaveBeenCalledWith('open')
308
+ // Flip the toggle so resolved comments stay in the list, then reopen.
309
+ fireEvent.click(screen.getByTestId('include-resolved-toggle'))
310
+ await waitFor(() => expect(screen.getByTestId('resolve-open')).toBeTruthy())
311
+ await act(async () => {
312
+ fireEvent.click(screen.getByTestId('resolve-open'))
313
+ })
314
+ expect(api.reopen).toHaveBeenCalledWith('open')
315
+ expect(rows[0]!.isResolved).toBe(false)
316
+ })
317
+
318
+ it('deletes a comment and refreshes', async () => {
319
+ const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1' })])
320
+ await act(async () => {
321
+ fireEvent.click(screen.getByTestId('delete-c1'))
322
+ })
323
+ expect(api.remove).toHaveBeenCalledWith('c1')
324
+ await waitFor(() => expect(screen.queryByTestId('comment-thread-c1')).toBeNull())
325
+ })
326
+
327
+ it('notifies onActiveCommentChange when a thread is clicked', async () => {
328
+ const fake = createFakeApi()
329
+ fake.rows.push(comment({ id: 'c1', userId: 'user-1' }))
330
+ const onActive = vi.fn()
331
+ render(
332
+ <CommentSidePanel
333
+ documentId="doc-1"
334
+ currentUserId="user-1"
335
+ api={fake.api}
336
+ onActiveCommentChange={onActive}
337
+ />,
338
+ )
339
+ await waitFor(() => expect(screen.getByTestId('comment-thread-c1')).toBeTruthy())
340
+ fireEvent.click(screen.getByTestId('comment-thread-c1'))
341
+ expect(onActive).toHaveBeenCalledWith('c1')
342
+ })
343
+ })