@actuate-media/cms-admin 0.13.0 → 0.14.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.
- package/dist/__tests__/components/comment-side-panel.test.js +93 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
- package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
- package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
- package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
- package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
- package/dist/__tests__/lib/mention-picker.test.d.ts +2 -0
- package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
- package/dist/__tests__/lib/mention-picker.test.js +122 -0
- package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CommentSidePanel.d.ts +9 -1
- package/dist/components/CommentSidePanel.d.ts.map +1 -1
- package/dist/components/CommentSidePanel.js +8 -7
- package/dist/components/CommentSidePanel.js.map +1 -1
- package/dist/components/MentionableTextarea.d.ts +68 -0
- package/dist/components/MentionableTextarea.d.ts.map +1 -0
- package/dist/components/MentionableTextarea.js +181 -0
- package/dist/components/MentionableTextarea.js.map +1 -0
- package/dist/lib/mention-picker.d.ts +69 -0
- package/dist/lib/mention-picker.d.ts.map +1 -0
- package/dist/lib/mention-picker.js +138 -0
- package/dist/lib/mention-picker.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
- package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
- package/src/__tests__/lib/mention-picker.test.ts +145 -0
- package/src/components/CommentSidePanel.tsx +37 -14
- package/src/components/MentionableTextarea.tsx +328 -0
- package/src/lib/mention-picker.ts +180 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type MentionUser } from '../lib/mention-picker.js';
|
|
3
|
+
/**
|
|
4
|
+
* `<MentionableTextarea />` — drop-in replacement for `<textarea>` /
|
|
5
|
+
* `<input type="text">` that opens a Radix-styled mention picker
|
|
6
|
+
* whenever the user types `@`.
|
|
7
|
+
*
|
|
8
|
+
* Behaviour
|
|
9
|
+
* ─────────
|
|
10
|
+
* - The picker appears anchored to the textarea, below the input.
|
|
11
|
+
* We intentionally do NOT track the caret pixel position (that
|
|
12
|
+
* requires a textarea-mirror trick which doesn't add enough UX to
|
|
13
|
+
* justify the complexity); GitHub, Linear, and Asana all anchor
|
|
14
|
+
* their pickers this way on simple textareas.
|
|
15
|
+
* - Arrow Up / Arrow Down move the highlight. Enter / Tab commit the
|
|
16
|
+
* selection. Escape closes the picker without inserting anything.
|
|
17
|
+
* Mouse click on a row also commits.
|
|
18
|
+
* - The picker only fires server queries while a real mention is
|
|
19
|
+
* being typed; it short-circuits an empty `query` so the dropdown
|
|
20
|
+
* shows up immediately after `@` is typed (with an empty state)
|
|
21
|
+
* and only flips to "no matches" once the user has typed at least
|
|
22
|
+
* one character.
|
|
23
|
+
* - The textarea value is fully controlled by the parent — we never
|
|
24
|
+
* keep a separate copy. `onChange` is called with the next value
|
|
25
|
+
* on every keystroke AND every mention insertion.
|
|
26
|
+
*
|
|
27
|
+
* Dependency-injection seam
|
|
28
|
+
* ─────────────────────────
|
|
29
|
+
* The `search` prop accepts an override so tests can substitute a
|
|
30
|
+
* synchronous fake. Production callers leave it unset and the
|
|
31
|
+
* component uses the real `searchMentionUsers()` REST helper.
|
|
32
|
+
*/
|
|
33
|
+
export interface MentionableTextareaProps {
|
|
34
|
+
value: string;
|
|
35
|
+
onChange: (next: string) => void;
|
|
36
|
+
/** Forwarded to the underlying textarea. */
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
/** Forwarded to the underlying textarea. */
|
|
39
|
+
rows?: number;
|
|
40
|
+
className?: string;
|
|
41
|
+
/** Forwarded — useful for keyboard shortcuts the parent owns. */
|
|
42
|
+
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
43
|
+
/** Forwarded — useful for click-to-stop-propagation in nested cards. */
|
|
44
|
+
onClick?: (event: React.MouseEvent<HTMLTextAreaElement>) => void;
|
|
45
|
+
/** Test hook — required when rendering with React state assertions. */
|
|
46
|
+
'data-testid'?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Override the user search. The default uses the REST helper.
|
|
49
|
+
* Tests inject an in-memory fake so dropdown contents are
|
|
50
|
+
* deterministic without HTTP mocking.
|
|
51
|
+
*/
|
|
52
|
+
search?: (query: string) => Promise<MentionUser[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Optional fixed id for the picker dropdown — pass when the parent
|
|
55
|
+
* wires up `aria-controls`. We generate one with `useId()` when
|
|
56
|
+
* unset.
|
|
57
|
+
*/
|
|
58
|
+
pickerId?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Format the displayed token. By default we show `@<name>` while
|
|
61
|
+
* the underlying value stores `@<userId>`. A future slice can swap
|
|
62
|
+
* to a rich-text editor where these diverge; the prop lets that
|
|
63
|
+
* land without changing the picker.
|
|
64
|
+
*/
|
|
65
|
+
formatLabel?: (user: MentionUser) => string;
|
|
66
|
+
}
|
|
67
|
+
export declare function MentionableTextarea({ value, onChange, placeholder, rows, className, onKeyDown, onClick, 'data-testid': testId, search, pickerId: pickerIdProp, formatLabel, }: MentionableTextareaProps): React.JSX.Element;
|
|
68
|
+
//# sourceMappingURL=MentionableTextarea.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MentionableTextarea.d.ts","sourceRoot":"","sources":["../../src/components/MentionableTextarea.tsx"],"names":[],"mappings":"AAEA,OAAO,KAA0D,MAAM,OAAO,CAAA;AAE9E,OAAO,EACL,KAAK,WAAW,EAIjB,MAAM,0BAA0B,CAAA;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4CAA4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,iEAAiE;IACjE,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,aAAa,CAAC,mBAAmB,CAAC,KAAK,IAAI,CAAA;IACrE,wEAAwE;IACxE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,mBAAmB,CAAC,KAAK,IAAI,CAAA;IAChE,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,EAAE,CAAC,CAAA;IAClD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAA;CAC5C;AAgBD,wBAAgB,mBAAmB,CAAC,EAClC,KAAK,EACL,QAAQ,EACR,WAAW,EACX,IAAQ,EACR,SAAS,EACT,SAAS,EACT,OAAO,EACP,aAAa,EAAE,MAAM,EACrB,MAAyE,EACzE,QAAQ,EAAE,YAAY,EACtB,WAAiC,GAClC,EAAE,wBAAwB,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAiO9C"}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { detectMentionContext, insertMentionToken, searchMentionUsers, } from '../lib/mention-picker.js';
|
|
5
|
+
const initialPicker = {
|
|
6
|
+
open: false,
|
|
7
|
+
query: '',
|
|
8
|
+
highlight: 0,
|
|
9
|
+
users: [],
|
|
10
|
+
};
|
|
11
|
+
export function MentionableTextarea({ value, onChange, placeholder, rows = 3, className, onKeyDown, onClick, 'data-testid': testId, search = (q) => searchMentionUsers(q).then((r) => (r.ok ? r.result : [])), pickerId: pickerIdProp, formatLabel = (u) => `@${u.name}`, }) {
|
|
12
|
+
const generatedId = useId();
|
|
13
|
+
const pickerId = pickerIdProp ?? `actuate-mention-picker-${generatedId}`;
|
|
14
|
+
const textareaRef = useRef(null);
|
|
15
|
+
const [picker, setPicker] = useState(initialPicker);
|
|
16
|
+
// `pickerRef` mirrors `picker` for the keydown handler so the
|
|
17
|
+
// arrow-key / enter listeners don't go stale between renders.
|
|
18
|
+
const pickerRef = useRef(picker);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
pickerRef.current = picker;
|
|
21
|
+
}, [picker]);
|
|
22
|
+
const requestSeq = useRef(0);
|
|
23
|
+
const mountedRef = useRef(true);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
return () => {
|
|
26
|
+
mountedRef.current = false;
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
const closePicker = useCallback(() => {
|
|
30
|
+
setPicker((p) => (p.open ? initialPicker : p));
|
|
31
|
+
}, []);
|
|
32
|
+
// Recompute the active mention context whenever the value or
|
|
33
|
+
// selection changes. Centralising this in one effect keeps the
|
|
34
|
+
// open/close logic in a single place — every textarea event
|
|
35
|
+
// (input, click, keyup) just calls `recompute()`.
|
|
36
|
+
const recompute = useCallback(async (nextValue, caret) => {
|
|
37
|
+
const ctx = detectMentionContext(nextValue, caret);
|
|
38
|
+
if (!ctx) {
|
|
39
|
+
closePicker();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const id = ++requestSeq.current;
|
|
43
|
+
// Open immediately so the user sees a dropdown the moment they
|
|
44
|
+
// type `@`. Clear the `users` array on every new query so the
|
|
45
|
+
// user can never press Enter / Tab and commit a stale row
|
|
46
|
+
// from the previous query while the new request is in flight
|
|
47
|
+
// (PR #159 Copilot finding — type `@a` then `@bo` quickly and
|
|
48
|
+
// hit Enter before the `@bo` results arrive: without this
|
|
49
|
+
// reset, the picker would commit a row from `@a`).
|
|
50
|
+
setPicker((p) => ({
|
|
51
|
+
open: true,
|
|
52
|
+
query: ctx.query,
|
|
53
|
+
highlight: 0,
|
|
54
|
+
users: p.query === ctx.query ? p.users : [],
|
|
55
|
+
}));
|
|
56
|
+
try {
|
|
57
|
+
const users = await search(ctx.query);
|
|
58
|
+
if (!mountedRef.current)
|
|
59
|
+
return;
|
|
60
|
+
// Drop stale responses — only commit if our request is still
|
|
61
|
+
// the latest one. Otherwise the user has typed more and a
|
|
62
|
+
// newer query is in flight.
|
|
63
|
+
if (id !== requestSeq.current)
|
|
64
|
+
return;
|
|
65
|
+
setPicker((p) =>
|
|
66
|
+
// If the picker was closed (Escape) between request and
|
|
67
|
+
// response, don't reopen it.
|
|
68
|
+
p.open ? { ...p, users, highlight: 0 } : p);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
if (!mountedRef.current)
|
|
72
|
+
return;
|
|
73
|
+
if (id !== requestSeq.current)
|
|
74
|
+
return;
|
|
75
|
+
// Surface no rows on failure — the textarea must keep
|
|
76
|
+
// working when the user is offline. The poll loop / native
|
|
77
|
+
// submit path is unaffected.
|
|
78
|
+
setPicker((p) => (p.open ? { ...p, users: [] } : p));
|
|
79
|
+
}
|
|
80
|
+
}, [closePicker, search]);
|
|
81
|
+
const commitMention = useCallback((user) => {
|
|
82
|
+
const el = textareaRef.current;
|
|
83
|
+
if (!el)
|
|
84
|
+
return;
|
|
85
|
+
const caret = el.selectionStart ?? value.length;
|
|
86
|
+
const ctx = detectMentionContext(value, caret);
|
|
87
|
+
if (!ctx) {
|
|
88
|
+
closePicker();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const next = insertMentionToken(value, ctx, user.id);
|
|
92
|
+
onChange(next.value);
|
|
93
|
+
closePicker();
|
|
94
|
+
// Move the caret past the inserted token AFTER React paints
|
|
95
|
+
// the new value, so the selection isn't clobbered by the
|
|
96
|
+
// controlled update.
|
|
97
|
+
requestAnimationFrame(() => {
|
|
98
|
+
const live = textareaRef.current;
|
|
99
|
+
if (!live)
|
|
100
|
+
return;
|
|
101
|
+
live.focus();
|
|
102
|
+
live.setSelectionRange(next.caret, next.caret);
|
|
103
|
+
});
|
|
104
|
+
}, [closePicker, onChange, value]);
|
|
105
|
+
const handleChange = (event) => {
|
|
106
|
+
const next = event.target.value;
|
|
107
|
+
const caret = event.target.selectionStart ?? next.length;
|
|
108
|
+
onChange(next);
|
|
109
|
+
void recompute(next, caret);
|
|
110
|
+
};
|
|
111
|
+
const handleKeyDown = (event) => {
|
|
112
|
+
const p = pickerRef.current;
|
|
113
|
+
if (p.open && p.users.length > 0) {
|
|
114
|
+
if (event.key === 'ArrowDown') {
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
setPicker((s) => ({ ...s, highlight: (s.highlight + 1) % s.users.length }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (event.key === 'ArrowUp') {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
setPicker((s) => ({
|
|
122
|
+
...s,
|
|
123
|
+
highlight: (s.highlight - 1 + s.users.length) % s.users.length,
|
|
124
|
+
}));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
// Defensive: highlight could theoretically point past the
|
|
130
|
+
// end of `users` if state went stale between renders. Guard
|
|
131
|
+
// it so we never `undefined`-bang.
|
|
132
|
+
const target = p.users[p.highlight];
|
|
133
|
+
if (target)
|
|
134
|
+
commitMention(target);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (event.key === 'Escape' && p.open) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
closePicker();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
onKeyDown?.(event);
|
|
144
|
+
};
|
|
145
|
+
const handleClick = (event) => {
|
|
146
|
+
onClick?.(event);
|
|
147
|
+
const el = textareaRef.current;
|
|
148
|
+
if (!el)
|
|
149
|
+
return;
|
|
150
|
+
void recompute(el.value, el.selectionStart ?? el.value.length);
|
|
151
|
+
};
|
|
152
|
+
// Closing on outside click — when the user clicks anywhere outside
|
|
153
|
+
// the textarea or the picker, the dropdown should disappear so it
|
|
154
|
+
// doesn't sit on top of unrelated UI.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!picker.open)
|
|
157
|
+
return;
|
|
158
|
+
const onDocMouseDown = (ev) => {
|
|
159
|
+
const t = ev.target;
|
|
160
|
+
if (!t)
|
|
161
|
+
return;
|
|
162
|
+
const parent = textareaRef.current?.parentElement;
|
|
163
|
+
if (parent && parent.contains(t))
|
|
164
|
+
return;
|
|
165
|
+
closePicker();
|
|
166
|
+
};
|
|
167
|
+
document.addEventListener('mousedown', onDocMouseDown);
|
|
168
|
+
return () => document.removeEventListener('mousedown', onDocMouseDown);
|
|
169
|
+
}, [picker.open, closePicker]);
|
|
170
|
+
return (_jsxs("div", { className: "relative", children: [_jsx("textarea", { ref: textareaRef, value: value, onChange: handleChange, onKeyDown: handleKeyDown, onClick: handleClick, placeholder: placeholder, rows: rows, className: className, "aria-autocomplete": "list", "aria-controls": picker.open ? pickerId : undefined, "aria-expanded": picker.open, "data-testid": testId }), picker.open && (_jsx("div", { id: pickerId, role: "listbox", "aria-label": "Mention a teammate", className: "absolute top-full left-0 z-30 mt-1 max-h-56 w-64 overflow-y-auto rounded border border-gray-200 bg-white shadow-lg", "data-testid": testId ? `${testId}-picker` : 'mention-picker', children: picker.users.length === 0 ? (_jsx("p", { className: "px-3 py-2 text-xs text-gray-500", children: picker.query ? 'No matches' : 'Type a name…' })) : (_jsx("ul", { children: picker.users.map((u, idx) => (_jsx("li", { children: _jsxs("button", { type: "button", role: "option", "aria-selected": idx === picker.highlight, onMouseDown: (e) => {
|
|
171
|
+
// mousedown (not click) — prevents the
|
|
172
|
+
// textarea from losing focus before we
|
|
173
|
+
// commit, which would otherwise hide the
|
|
174
|
+
// selection and feel janky.
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
commitMention(u);
|
|
177
|
+
}, onMouseEnter: () => setPicker((s) => ({ ...s, highlight: idx })), className: `block w-full px-3 py-1.5 text-left text-xs ${idx === picker.highlight
|
|
178
|
+
? 'bg-blue-50 text-blue-900'
|
|
179
|
+
: 'text-gray-800 hover:bg-gray-50'}`, "data-testid": testId ? `${testId}-picker-row-${u.id}` : `mention-row-${u.id}`, children: [_jsx("div", { className: "font-medium", children: formatLabel(u) }), _jsx("div", { className: "text-[10px] text-gray-500", children: u.email })] }) }, u.id))) })) }))] }));
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=MentionableTextarea.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MentionableTextarea.js","sourceRoot":"","sources":["../../src/components/MentionableTextarea.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface MentionUser {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
email: string;
|
|
5
|
+
}
|
|
6
|
+
export type MentionSearchOutcome = {
|
|
7
|
+
ok: true;
|
|
8
|
+
result: MentionUser[];
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
error: string;
|
|
12
|
+
status: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function searchMentionUsers(query: string): Promise<MentionSearchOutcome>;
|
|
15
|
+
/**
|
|
16
|
+
* Description of an active `@`-mention being typed in a textarea / input.
|
|
17
|
+
*
|
|
18
|
+
* - `start` and `end` are character offsets into the field value: the
|
|
19
|
+
* `@` itself sits at `start`, and `end` is the caret position
|
|
20
|
+
* (exclusive end of the query slice).
|
|
21
|
+
* - `query` is the substring between the `@` and the caret — exactly
|
|
22
|
+
* what we should pass to `searchMentionUsers()`.
|
|
23
|
+
*
|
|
24
|
+
* Callers replace `value.slice(start, end)` with the chosen token
|
|
25
|
+
* (`@<userId> `, including the trailing space) to commit a selection.
|
|
26
|
+
*/
|
|
27
|
+
export interface MentionContext {
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
query: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Find an active mention at the caret. Mirrors the server-side
|
|
34
|
+
* `extractMentionedUserIds` regex
|
|
35
|
+
* (`/(^|\s)@[a-zA-Z0-9_-]{3,64}\b/g`) so the picker's intuition
|
|
36
|
+
* about what counts as a mention can never disagree with the
|
|
37
|
+
* server's notification routing.
|
|
38
|
+
*
|
|
39
|
+
* Returns `null` whenever:
|
|
40
|
+
* - the caret is at offset 0,
|
|
41
|
+
* - there is no `@` between the caret and the previous whitespace
|
|
42
|
+
* boundary,
|
|
43
|
+
* - the `@` is preceded by a non-whitespace, non-newline character
|
|
44
|
+
* (so an email address mid-word like `foo@bar` does NOT open
|
|
45
|
+
* the picker), or
|
|
46
|
+
* - the candidate query contains a character outside the mention
|
|
47
|
+
* charset (`[a-zA-Z0-9_-]`).
|
|
48
|
+
*
|
|
49
|
+
* The picker DOES open with a 0–2 character query — that's the
|
|
50
|
+
* "you've started typing a mention" UX. But it can NEVER commit
|
|
51
|
+
* such a token: `insertMentionToken` only fires for a `MentionUser`
|
|
52
|
+
* which always has an id of at least 3 chars, matching the server
|
|
53
|
+
* regex's min-length requirement.
|
|
54
|
+
*/
|
|
55
|
+
export declare function detectMentionContext(value: string, caret: number): MentionContext | null;
|
|
56
|
+
/**
|
|
57
|
+
* Apply a mention selection to a text value. Returns the new value
|
|
58
|
+
* plus the caret position where the next keystroke should land.
|
|
59
|
+
*
|
|
60
|
+
* Inserts `@<userId> ` (trailing space) so the user can keep typing
|
|
61
|
+
* without manually moving the cursor past the token. If the caret
|
|
62
|
+
* already sits before a space, we don't insert a duplicate space.
|
|
63
|
+
*/
|
|
64
|
+
export interface MentionInsertion {
|
|
65
|
+
value: string;
|
|
66
|
+
caret: number;
|
|
67
|
+
}
|
|
68
|
+
export declare function insertMentionToken(value: string, context: MentionContext, userId: string): MentionInsertion;
|
|
69
|
+
//# sourceMappingURL=mention-picker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mention-picker.d.ts","sourceRoot":"","sources":["../../src/lib/mention-picker.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,MAAM,oBAAoB,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,WAAW,EAAE,CAAA;CAAE,GACnC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEhD,wBAAsB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAerF;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAiCxF;AAwBD;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,MAAM,GACb,gBAAgB,CAclB"}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention picker client logic + REST helper.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities, deliberately separated so each can be unit
|
|
5
|
+
* tested without rendering React or hitting fetch:
|
|
6
|
+
*
|
|
7
|
+
* 1. `searchMentionUsers(query)` — typed wrapper over the new
|
|
8
|
+
* `GET /api/cms/users/search` endpoint introduced in Phase 7.
|
|
9
|
+
* Returns up to 10 `{ id, name, email }` rows matching the query.
|
|
10
|
+
* 2. `detectMentionContext(value, caret)` — pure parser that scans a
|
|
11
|
+
* text input value backwards from the caret to decide whether the
|
|
12
|
+
* user is currently typing a `@`-mention, and if so what the
|
|
13
|
+
* query fragment is. Returns `null` when no active mention.
|
|
14
|
+
*
|
|
15
|
+
* Wire format note: the picker INSERTS `@<userId>` tokens at the
|
|
16
|
+
* cursor (matching the server-side `extractMentionedUserIds` regex
|
|
17
|
+
* in `cms-core/realtime/notifications.ts`). The user sees the chosen
|
|
18
|
+
* user's *name* in the picker but the textarea always holds the id.
|
|
19
|
+
* Rendering names back in the comment view is a separate slice that
|
|
20
|
+
* can ship later without breaking the wire format.
|
|
21
|
+
*/
|
|
22
|
+
import { cmsApi } from './api.js';
|
|
23
|
+
export async function searchMentionUsers(query) {
|
|
24
|
+
const trimmed = query.trim();
|
|
25
|
+
if (trimmed.length === 0) {
|
|
26
|
+
// Mirror the server's contract: an empty query returns an empty
|
|
27
|
+
// array. We short-circuit here so the picker doesn't spend a
|
|
28
|
+
// round-trip on a guaranteed-empty response.
|
|
29
|
+
return { ok: true, result: [] };
|
|
30
|
+
}
|
|
31
|
+
const res = await cmsApi(`/users/search?q=${encodeURIComponent(trimmed)}`, {
|
|
32
|
+
method: 'GET',
|
|
33
|
+
});
|
|
34
|
+
if (res.error || !res.data) {
|
|
35
|
+
return { ok: false, error: res.error ?? `Request failed (${res.status})`, status: res.status };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, result: res.data };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Find an active mention at the caret. Mirrors the server-side
|
|
41
|
+
* `extractMentionedUserIds` regex
|
|
42
|
+
* (`/(^|\s)@[a-zA-Z0-9_-]{3,64}\b/g`) so the picker's intuition
|
|
43
|
+
* about what counts as a mention can never disagree with the
|
|
44
|
+
* server's notification routing.
|
|
45
|
+
*
|
|
46
|
+
* Returns `null` whenever:
|
|
47
|
+
* - the caret is at offset 0,
|
|
48
|
+
* - there is no `@` between the caret and the previous whitespace
|
|
49
|
+
* boundary,
|
|
50
|
+
* - the `@` is preceded by a non-whitespace, non-newline character
|
|
51
|
+
* (so an email address mid-word like `foo@bar` does NOT open
|
|
52
|
+
* the picker), or
|
|
53
|
+
* - the candidate query contains a character outside the mention
|
|
54
|
+
* charset (`[a-zA-Z0-9_-]`).
|
|
55
|
+
*
|
|
56
|
+
* The picker DOES open with a 0–2 character query — that's the
|
|
57
|
+
* "you've started typing a mention" UX. But it can NEVER commit
|
|
58
|
+
* such a token: `insertMentionToken` only fires for a `MentionUser`
|
|
59
|
+
* which always has an id of at least 3 chars, matching the server
|
|
60
|
+
* regex's min-length requirement.
|
|
61
|
+
*/
|
|
62
|
+
export function detectMentionContext(value, caret) {
|
|
63
|
+
if (typeof value !== 'string')
|
|
64
|
+
return null;
|
|
65
|
+
if (caret <= 0 || caret > value.length)
|
|
66
|
+
return null;
|
|
67
|
+
// Walk back from the caret looking for a `@`. Stop early on any
|
|
68
|
+
// character that is neither a valid mention char nor `@`. This
|
|
69
|
+
// means a typed space, newline, punctuation, etc. terminates the
|
|
70
|
+
// mention candidate the user was building.
|
|
71
|
+
let i = caret - 1;
|
|
72
|
+
while (i >= 0) {
|
|
73
|
+
const ch = value.charCodeAt(i);
|
|
74
|
+
if (ch === 64 /* @ */)
|
|
75
|
+
break;
|
|
76
|
+
if (!isMentionChar(ch))
|
|
77
|
+
return null;
|
|
78
|
+
i -= 1;
|
|
79
|
+
}
|
|
80
|
+
if (i < 0)
|
|
81
|
+
return null;
|
|
82
|
+
// The `@` must sit at the start of the field or be preceded by
|
|
83
|
+
// whitespace / newline. Otherwise we're mid-word (think `foo@bar`),
|
|
84
|
+
// which is not a mention.
|
|
85
|
+
if (i > 0) {
|
|
86
|
+
const prev = value.charCodeAt(i - 1);
|
|
87
|
+
if (!isBoundary(prev))
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const query = value.slice(i + 1, caret);
|
|
91
|
+
// Server caps queries at 64 chars; the picker simply stops
|
|
92
|
+
// suggesting once the typed query exceeds that — extra typing
|
|
93
|
+
// closes the picker so the user can complete an email or other
|
|
94
|
+
// long token without staring at a dead dropdown.
|
|
95
|
+
if (query.length > 64)
|
|
96
|
+
return null;
|
|
97
|
+
return { start: i, end: caret, query };
|
|
98
|
+
}
|
|
99
|
+
function isMentionChar(code) {
|
|
100
|
+
// [a-zA-Z0-9_-] — keep this in lock-step with the regex in
|
|
101
|
+
// `cms-core/realtime/notifications.ts:extractMentionedUserIds`.
|
|
102
|
+
if (code === 95 /* _ */ || code === 45 /* - */)
|
|
103
|
+
return true;
|
|
104
|
+
if (code >= 48 && code <= 57)
|
|
105
|
+
return true; // 0-9
|
|
106
|
+
if (code >= 65 && code <= 90)
|
|
107
|
+
return true; // A-Z
|
|
108
|
+
if (code >= 97 && code <= 122)
|
|
109
|
+
return true; // a-z
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
function isBoundary(code) {
|
|
113
|
+
// ASCII space, tab, line-feed, or carriage-return. We deliberately
|
|
114
|
+
// do NOT widen this to the full Unicode whitespace class — the
|
|
115
|
+
// server-side `extractMentionedUserIds` regex in
|
|
116
|
+
// `cms-core/realtime/notifications.ts` uses `\s` which DOES match
|
|
117
|
+
// Unicode whitespace, but its `^` anchor means the only realistic
|
|
118
|
+
// mid-string boundaries in practice are these four ASCII chars
|
|
119
|
+
// (a CMS comment body containing U+00A0 NBSP before an `@` would
|
|
120
|
+
// be both unusual and not worth opening the picker for).
|
|
121
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
122
|
+
}
|
|
123
|
+
export function insertMentionToken(value, context, userId) {
|
|
124
|
+
const hasTrailingSpace = value.charAt(context.end) === ' ';
|
|
125
|
+
// If the user already has a space immediately after the mention
|
|
126
|
+
// region, reuse it (don't double-space) but still advance the
|
|
127
|
+
// caret past that space so the next keystroke continues naturally
|
|
128
|
+
// rather than landing back in front of the space.
|
|
129
|
+
const trailingSpace = hasTrailingSpace ? '' : ' ';
|
|
130
|
+
const token = `@${userId}${trailingSpace}`;
|
|
131
|
+
const before = value.slice(0, context.start);
|
|
132
|
+
const after = value.slice(context.end);
|
|
133
|
+
return {
|
|
134
|
+
value: `${before}${token}${after}`,
|
|
135
|
+
caret: context.start + token.length + (hasTrailingSpace ? 1 : 0),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=mention-picker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mention-picker.js","sourceRoot":"","sources":["../../src/lib/mention-picker.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"tailwindcss": "^4.0.0",
|
|
88
88
|
"typescript": "^5.7.0",
|
|
89
89
|
"vitest": "^3.0.0",
|
|
90
|
-
"@actuate-media/cms-core": "0.
|
|
90
|
+
"@actuate-media/cms-core": "0.25.0",
|
|
91
91
|
"@actuate-media/component-blocks": "0.2.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
@@ -341,3 +341,137 @@ describe('CommentSidePanel — reply, edit, resolve, delete', () => {
|
|
|
341
341
|
expect(onActive).toHaveBeenCalledWith('c1')
|
|
342
342
|
})
|
|
343
343
|
})
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Mention picker integration — Phase 7 carry-over
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
describe('CommentSidePanel — mention picker integration', () => {
|
|
350
|
+
/**
|
|
351
|
+
* Drive a `<textarea>` the same way the picker test does. The
|
|
352
|
+
* comment panel's three composer surfaces (new thread, reply,
|
|
353
|
+
* edit) are all `<MentionableTextarea>` now, so this helper works
|
|
354
|
+
* uniformly across them.
|
|
355
|
+
*/
|
|
356
|
+
async function typeAt(el: HTMLTextAreaElement, value: string, caret = value.length) {
|
|
357
|
+
await act(async () => {
|
|
358
|
+
el.focus()
|
|
359
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
|
|
360
|
+
if (setter) setter.call(el, value)
|
|
361
|
+
else el.value = value
|
|
362
|
+
el.setSelectionRange(caret, caret)
|
|
363
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function fakeMentionSearch() {
|
|
368
|
+
return vi.fn(async (q: string) => {
|
|
369
|
+
const all = [
|
|
370
|
+
{ id: 'u_alice', name: 'Alice Anderson', email: 'alice@example.com' },
|
|
371
|
+
{ id: 'u_bob', name: 'Bob Brown', email: 'bob@example.com' },
|
|
372
|
+
]
|
|
373
|
+
const ql = q.toLowerCase()
|
|
374
|
+
return all.filter(
|
|
375
|
+
(u) => u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql),
|
|
376
|
+
)
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
it('opens the mention picker from the new-thread composer and submits @<userId>', async () => {
|
|
381
|
+
const fake = createFakeApi()
|
|
382
|
+
const mentionSearch = fakeMentionSearch()
|
|
383
|
+
render(
|
|
384
|
+
<CommentSidePanel
|
|
385
|
+
documentId="doc-1"
|
|
386
|
+
currentUserId="current"
|
|
387
|
+
api={fake.api}
|
|
388
|
+
mentionSearch={mentionSearch}
|
|
389
|
+
/>,
|
|
390
|
+
)
|
|
391
|
+
await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
|
|
392
|
+
|
|
393
|
+
const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
|
|
394
|
+
await typeAt(composer, '@ali')
|
|
395
|
+
await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
|
|
396
|
+
await act(async () => {
|
|
397
|
+
fireEvent.mouseDown(screen.getByTestId('comment-composer-picker-row-u_alice'))
|
|
398
|
+
})
|
|
399
|
+
expect((composer as HTMLTextAreaElement).value).toBe('@u_alice ')
|
|
400
|
+
|
|
401
|
+
// Continue typing then submit — the resulting comment body must
|
|
402
|
+
// carry the `@<userId>` token so `extractMentionedUserIds` picks
|
|
403
|
+
// it up server-side.
|
|
404
|
+
await typeAt(composer, '@u_alice please review')
|
|
405
|
+
await act(async () => {
|
|
406
|
+
fireEvent.click(screen.getByTestId('comment-submit'))
|
|
407
|
+
})
|
|
408
|
+
expect(fake.api.create).toHaveBeenCalledWith(
|
|
409
|
+
expect.objectContaining({ body: '@u_alice please review' }),
|
|
410
|
+
)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('opens the mention picker from a reply input and inserts @<userId>', async () => {
|
|
414
|
+
const fake = createFakeApi()
|
|
415
|
+
fake.rows.push(comment({ id: 'root', body: 'root' }))
|
|
416
|
+
const mentionSearch = fakeMentionSearch()
|
|
417
|
+
render(
|
|
418
|
+
<CommentSidePanel
|
|
419
|
+
documentId="doc-1"
|
|
420
|
+
currentUserId="current"
|
|
421
|
+
api={fake.api}
|
|
422
|
+
mentionSearch={mentionSearch}
|
|
423
|
+
/>,
|
|
424
|
+
)
|
|
425
|
+
await waitFor(() => expect(screen.getByTestId('comment-thread-root')).toBeTruthy())
|
|
426
|
+
|
|
427
|
+
const reply = screen.getByTestId('reply-input-root') as HTMLTextAreaElement
|
|
428
|
+
await typeAt(reply, '@bo')
|
|
429
|
+
await waitFor(() => expect(screen.getByTestId('reply-input-root-picker')).toBeTruthy())
|
|
430
|
+
fireEvent.keyDown(reply, { key: 'Enter' })
|
|
431
|
+
await waitFor(() => expect(reply.value).toBe('@u_bob '))
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('opens the mention picker from the edit-textarea while editing a comment', async () => {
|
|
435
|
+
const fake = createFakeApi()
|
|
436
|
+
fake.rows.push(comment({ id: 'c1', userId: 'current', body: 'before' }))
|
|
437
|
+
const mentionSearch = fakeMentionSearch()
|
|
438
|
+
render(
|
|
439
|
+
<CommentSidePanel
|
|
440
|
+
documentId="doc-1"
|
|
441
|
+
currentUserId="current"
|
|
442
|
+
api={fake.api}
|
|
443
|
+
mentionSearch={mentionSearch}
|
|
444
|
+
/>,
|
|
445
|
+
)
|
|
446
|
+
await waitFor(() => expect(screen.getByTestId('comment-thread-c1')).toBeTruthy())
|
|
447
|
+
fireEvent.click(screen.getByTestId('edit-c1'))
|
|
448
|
+
const edit = screen.getByTestId('edit-textarea-c1') as HTMLTextAreaElement
|
|
449
|
+
await typeAt(edit, '@al')
|
|
450
|
+
await waitFor(() => expect(screen.getByTestId('edit-textarea-c1-picker')).toBeTruthy())
|
|
451
|
+
await act(async () => {
|
|
452
|
+
fireEvent.mouseDown(screen.getByTestId('edit-textarea-c1-picker-row-u_alice'))
|
|
453
|
+
})
|
|
454
|
+
expect(edit.value).toBe('@u_alice ')
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('escape closes the picker without committing a selection', async () => {
|
|
458
|
+
const fake = createFakeApi()
|
|
459
|
+
const mentionSearch = fakeMentionSearch()
|
|
460
|
+
render(
|
|
461
|
+
<CommentSidePanel
|
|
462
|
+
documentId="doc-1"
|
|
463
|
+
currentUserId="current"
|
|
464
|
+
api={fake.api}
|
|
465
|
+
mentionSearch={mentionSearch}
|
|
466
|
+
/>,
|
|
467
|
+
)
|
|
468
|
+
await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
|
|
469
|
+
|
|
470
|
+
const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
|
|
471
|
+
await typeAt(composer, '@al')
|
|
472
|
+
await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
|
|
473
|
+
fireEvent.keyDown(composer, { key: 'Escape' })
|
|
474
|
+
expect(screen.queryByTestId('comment-composer-picker')).toBeNull()
|
|
475
|
+
expect((composer as HTMLTextAreaElement).value).toBe('@al')
|
|
476
|
+
})
|
|
477
|
+
})
|