Skip to content

Commit 9d193ed

Browse files
authored
Merge pull request #718 from objectstack-ai/copilot/complete-roadmap-dev-p1-5-console
2 parents 2f527bf + faf4793 commit 9d193ed

15 files changed

+1083
-15
lines changed

ROADMAP.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,10 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
126126

127127
### P1.5 Console — Comments & Collaboration
128128

129-
- [ ] @mention notification delivery (email/push)
130-
- [ ] Comment search across all records
131-
- [ ] Comment pinning/starring
132-
- [ ] Activity feed filtering (comments only / field changes only)
129+
- [x] @mention notification delivery (email/push)
130+
- [x] Comment search across all records
131+
- [x] Comment pinning/starring
132+
- [x] Activity feed filtering (comments only / field changes only)
133133

134134
### P1.6 Console — Automation
135135

packages/collaboration/src/CommentThread.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface CommentThreadProps {
3939
onResolve?: (resolved: boolean) => void;
4040
/** Callback when a reaction is toggled */
4141
onReaction?: (commentId: string, emoji: string) => void;
42+
/** Callback when @mentions are detected — for notification delivery (email/push) */
43+
onMentionNotify?: (mentionedUserIds: string[], commentContent: string) => void;
4244
/** Whether the thread is resolved */
4345
resolved?: boolean;
4446
/** Additional className */
@@ -326,6 +328,7 @@ export function CommentThread({
326328
onDeleteComment,
327329
onResolve,
328330
onReaction,
331+
onMentionNotify,
329332
resolved = false,
330333
className,
331334
}: CommentThreadProps): React.ReactElement {
@@ -384,10 +387,16 @@ export function CommentThread({
384387

385388
const mentions = parseMentions(trimmed, mentionableUsers);
386389
onAddComment(trimmed, mentions, replyTo ?? undefined);
390+
391+
// Trigger notification delivery for mentioned users
392+
if (mentions.length > 0 && onMentionNotify) {
393+
onMentionNotify(mentions, trimmed);
394+
}
395+
387396
setInputValue('');
388397
setReplyTo(null);
389398
setMentionQuery(null);
390-
}, [inputValue, onAddComment, mentionableUsers, replyTo]);
399+
}, [inputValue, onAddComment, mentionableUsers, replyTo, onMentionNotify]);
391400

392401
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
393402
if (mentionQuery !== null && filteredMentions.length > 0) {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { render, screen, fireEvent } from '@testing-library/react';
11+
import React from 'react';
12+
import { CommentThread } from '../CommentThread';
13+
import type { Comment } from '../CommentThread';
14+
15+
const mockComments: Comment[] = [
16+
{
17+
id: '1',
18+
author: { id: 'u1', name: 'Alice' },
19+
content: 'Hello everyone',
20+
mentions: [],
21+
createdAt: '2025-01-01T10:00:00Z',
22+
},
23+
];
24+
25+
const currentUser = { id: 'u1', name: 'Alice' };
26+
const mentionableUsers = [
27+
{ id: 'u2', name: 'Bob' },
28+
{ id: 'u3', name: 'Charlie' },
29+
];
30+
31+
describe('CommentThread - onMentionNotify', () => {
32+
it('calls onMentionNotify when posting a comment with @mentions', () => {
33+
const onAddComment = vi.fn();
34+
const onMentionNotify = vi.fn();
35+
36+
render(
37+
<CommentThread
38+
threadId="t1"
39+
comments={mockComments}
40+
currentUser={currentUser}
41+
mentionableUsers={mentionableUsers}
42+
onAddComment={onAddComment}
43+
onMentionNotify={onMentionNotify}
44+
/>,
45+
);
46+
47+
// Type a comment with an @mention
48+
const textarea = screen.getByPlaceholderText(/Add a comment/);
49+
fireEvent.change(textarea, { target: { value: 'Hey @Bob check this' } });
50+
51+
// Submit via Enter key
52+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
53+
54+
expect(onAddComment).toHaveBeenCalled();
55+
expect(onMentionNotify).toHaveBeenCalledWith(['u2'], 'Hey @Bob check this');
56+
});
57+
58+
it('does not call onMentionNotify when there are no mentions', () => {
59+
const onAddComment = vi.fn();
60+
const onMentionNotify = vi.fn();
61+
62+
render(
63+
<CommentThread
64+
threadId="t1"
65+
comments={mockComments}
66+
currentUser={currentUser}
67+
mentionableUsers={mentionableUsers}
68+
onAddComment={onAddComment}
69+
onMentionNotify={onMentionNotify}
70+
/>,
71+
);
72+
73+
const textarea = screen.getByPlaceholderText(/Add a comment/);
74+
fireEvent.change(textarea, { target: { value: 'Hello world' } });
75+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
76+
77+
expect(onAddComment).toHaveBeenCalled();
78+
expect(onMentionNotify).not.toHaveBeenCalled();
79+
});
80+
81+
it('works without onMentionNotify callback', () => {
82+
const onAddComment = vi.fn();
83+
84+
render(
85+
<CommentThread
86+
threadId="t1"
87+
comments={mockComments}
88+
currentUser={currentUser}
89+
mentionableUsers={mentionableUsers}
90+
onAddComment={onAddComment}
91+
/>,
92+
);
93+
94+
const textarea = screen.getByPlaceholderText(/Add a comment/);
95+
fireEvent.change(textarea, { target: { value: 'Hey @Bob check this' } });
96+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
97+
98+
expect(onAddComment).toHaveBeenCalled();
99+
// Should not throw
100+
});
101+
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { renderHook, act } from '@testing-library/react';
11+
import { useCommentSearch } from '../useCommentSearch';
12+
import type { CommentEntry } from '@object-ui/types';
13+
14+
const mockComments: CommentEntry[] = [
15+
{
16+
id: '1',
17+
text: 'Great progress on the dashboard feature',
18+
author: 'Alice',
19+
createdAt: '2026-02-20T10:00:00Z',
20+
objectName: 'Project',
21+
recordId: 'p1',
22+
},
23+
{
24+
id: '2',
25+
text: 'Need to fix the bug in the login page',
26+
author: 'Bob',
27+
createdAt: '2026-02-20T11:00:00Z',
28+
objectName: 'Task',
29+
recordId: 't1',
30+
},
31+
{
32+
id: '3',
33+
text: 'Dashboard design looks good',
34+
author: 'Charlie',
35+
createdAt: '2026-02-20T12:00:00Z',
36+
objectName: 'Project',
37+
recordId: 'p2',
38+
},
39+
{
40+
id: '4',
41+
text: 'Alice approved the PR',
42+
author: 'Diana',
43+
createdAt: '2026-02-20T13:00:00Z',
44+
objectName: 'Review',
45+
recordId: 'r1',
46+
},
47+
];
48+
49+
describe('useCommentSearch', () => {
50+
it('returns empty results when query is empty', () => {
51+
const { result } = renderHook(() =>
52+
useCommentSearch({ comments: mockComments }),
53+
);
54+
expect(result.current.results).toEqual([]);
55+
expect(result.current.isSearching).toBe(false);
56+
expect(result.current.query).toBe('');
57+
});
58+
59+
it('searches by comment text', () => {
60+
const { result } = renderHook(() =>
61+
useCommentSearch({ comments: mockComments }),
62+
);
63+
64+
act(() => {
65+
result.current.setQuery('dashboard');
66+
});
67+
68+
expect(result.current.isSearching).toBe(true);
69+
expect(result.current.results).toHaveLength(2);
70+
expect(result.current.results.map(r => r.comment.id)).toEqual(['1', '3']);
71+
});
72+
73+
it('searches by author name', () => {
74+
const { result } = renderHook(() =>
75+
useCommentSearch({ comments: mockComments }),
76+
);
77+
78+
act(() => {
79+
result.current.setQuery('Alice');
80+
});
81+
82+
// Comment 4 mentions "Alice" in its text, and Comment 1 has author "Alice"
83+
expect(result.current.results).toHaveLength(2);
84+
const ids = result.current.results.map(r => r.comment.id);
85+
expect(ids).toContain('1');
86+
expect(ids).toContain('4');
87+
});
88+
89+
it('is case-insensitive', () => {
90+
const { result } = renderHook(() =>
91+
useCommentSearch({ comments: mockComments }),
92+
);
93+
94+
act(() => {
95+
result.current.setQuery('BUG');
96+
});
97+
98+
expect(result.current.results).toHaveLength(1);
99+
expect(result.current.results[0].comment.id).toBe('2');
100+
});
101+
102+
it('returns results with objectName and recordId', () => {
103+
const { result } = renderHook(() =>
104+
useCommentSearch({ comments: mockComments }),
105+
);
106+
107+
act(() => {
108+
result.current.setQuery('login');
109+
});
110+
111+
expect(result.current.results).toHaveLength(1);
112+
expect(result.current.results[0].objectName).toBe('Task');
113+
expect(result.current.results[0].recordId).toBe('t1');
114+
});
115+
116+
it('provides highlighted text snippets', () => {
117+
const { result } = renderHook(() =>
118+
useCommentSearch({ comments: mockComments }),
119+
);
120+
121+
act(() => {
122+
result.current.setQuery('bug');
123+
});
124+
125+
expect(result.current.results[0].highlight).toBeDefined();
126+
expect(result.current.results[0].highlight).toContain('bug');
127+
});
128+
129+
it('clears search results', () => {
130+
const { result } = renderHook(() =>
131+
useCommentSearch({ comments: mockComments }),
132+
);
133+
134+
act(() => {
135+
result.current.setQuery('dashboard');
136+
});
137+
expect(result.current.results).toHaveLength(2);
138+
139+
act(() => {
140+
result.current.clearSearch();
141+
});
142+
expect(result.current.results).toEqual([]);
143+
expect(result.current.query).toBe('');
144+
expect(result.current.isSearching).toBe(false);
145+
});
146+
147+
it('returns empty results for non-matching query', () => {
148+
const { result } = renderHook(() =>
149+
useCommentSearch({ comments: mockComments }),
150+
);
151+
152+
act(() => {
153+
result.current.setQuery('zzzznonexistent');
154+
});
155+
156+
expect(result.current.results).toEqual([]);
157+
expect(result.current.isSearching).toBe(true);
158+
});
159+
});

0 commit comments

Comments
 (0)