Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind

### P1.5 Console — Comments & Collaboration

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

### P1.6 Console — Automation

Expand Down
11 changes: 10 additions & 1 deletion packages/collaboration/src/CommentThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface CommentThreadProps {
onResolve?: (resolved: boolean) => void;
/** Callback when a reaction is toggled */
onReaction?: (commentId: string, emoji: string) => void;
/** Callback when @mentions are detected — for notification delivery (email/push) */
onMentionNotify?: (mentionedUserIds: string[], commentContent: string) => void;
/** Whether the thread is resolved */
resolved?: boolean;
/** Additional className */
Expand Down Expand Up @@ -326,6 +328,7 @@ export function CommentThread({
onDeleteComment,
onResolve,
onReaction,
onMentionNotify,
resolved = false,
className,
}: CommentThreadProps): React.ReactElement {
Expand Down Expand Up @@ -384,10 +387,16 @@ export function CommentThread({

const mentions = parseMentions(trimmed, mentionableUsers);
onAddComment(trimmed, mentions, replyTo ?? undefined);

// Trigger notification delivery for mentioned users
if (mentions.length > 0 && onMentionNotify) {
onMentionNotify(mentions, trimmed);
}

setInputValue('');
setReplyTo(null);
setMentionQuery(null);
}, [inputValue, onAddComment, mentionableUsers, replyTo]);
}, [inputValue, onAddComment, mentionableUsers, replyTo, onMentionNotify]);

const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (mentionQuery !== null && filteredMentions.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { CommentThread } from '../CommentThread';
import type { Comment } from '../CommentThread';

const mockComments: Comment[] = [
{
id: '1',
author: { id: 'u1', name: 'Alice' },
content: 'Hello everyone',
mentions: [],
createdAt: '2025-01-01T10:00:00Z',
},
];

const currentUser = { id: 'u1', name: 'Alice' };
const mentionableUsers = [
{ id: 'u2', name: 'Bob' },
{ id: 'u3', name: 'Charlie' },
];

describe('CommentThread - onMentionNotify', () => {
it('calls onMentionNotify when posting a comment with @mentions', () => {
const onAddComment = vi.fn();
const onMentionNotify = vi.fn();

render(
<CommentThread
threadId="t1"
comments={mockComments}
currentUser={currentUser}
mentionableUsers={mentionableUsers}
onAddComment={onAddComment}
onMentionNotify={onMentionNotify}
/>,
);

// Type a comment with an @mention
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: 'Hey @Bob check this' } });

// Submit via Enter key
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });

expect(onAddComment).toHaveBeenCalled();
expect(onMentionNotify).toHaveBeenCalledWith(['u2'], 'Hey @Bob check this');
});

it('does not call onMentionNotify when there are no mentions', () => {
const onAddComment = vi.fn();
const onMentionNotify = vi.fn();

render(
<CommentThread
threadId="t1"
comments={mockComments}
currentUser={currentUser}
mentionableUsers={mentionableUsers}
onAddComment={onAddComment}
onMentionNotify={onMentionNotify}
/>,
);

const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: 'Hello world' } });
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });

expect(onAddComment).toHaveBeenCalled();
expect(onMentionNotify).not.toHaveBeenCalled();
});

it('works without onMentionNotify callback', () => {
const onAddComment = vi.fn();

render(
<CommentThread
threadId="t1"
comments={mockComments}
currentUser={currentUser}
mentionableUsers={mentionableUsers}
onAddComment={onAddComment}
/>,
);

const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: 'Hey @Bob check this' } });
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });

expect(onAddComment).toHaveBeenCalled();
// Should not throw
});
});
159 changes: 159 additions & 0 deletions packages/collaboration/src/__tests__/useCommentSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCommentSearch } from '../useCommentSearch';
import type { CommentEntry } from '@object-ui/types';

const mockComments: CommentEntry[] = [
{
id: '1',
text: 'Great progress on the dashboard feature',
author: 'Alice',
createdAt: '2026-02-20T10:00:00Z',
objectName: 'Project',
recordId: 'p1',
},
{
id: '2',
text: 'Need to fix the bug in the login page',
author: 'Bob',
createdAt: '2026-02-20T11:00:00Z',
objectName: 'Task',
recordId: 't1',
},
{
id: '3',
text: 'Dashboard design looks good',
author: 'Charlie',
createdAt: '2026-02-20T12:00:00Z',
objectName: 'Project',
recordId: 'p2',
},
{
id: '4',
text: 'Alice approved the PR',
author: 'Diana',
createdAt: '2026-02-20T13:00:00Z',
objectName: 'Review',
recordId: 'r1',
},
];

describe('useCommentSearch', () => {
it('returns empty results when query is empty', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);
expect(result.current.results).toEqual([]);
expect(result.current.isSearching).toBe(false);
expect(result.current.query).toBe('');
});

it('searches by comment text', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);

act(() => {
result.current.setQuery('dashboard');
});

expect(result.current.isSearching).toBe(true);
expect(result.current.results).toHaveLength(2);
expect(result.current.results.map(r => r.comment.id)).toEqual(['1', '3']);
});

it('searches by author name', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);

act(() => {
result.current.setQuery('Alice');
});

// Comment 4 mentions "Alice" in its text, and Comment 1 has author "Alice"
expect(result.current.results).toHaveLength(2);
const ids = result.current.results.map(r => r.comment.id);
expect(ids).toContain('1');
expect(ids).toContain('4');
});

it('is case-insensitive', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);

act(() => {
result.current.setQuery('BUG');
});

expect(result.current.results).toHaveLength(1);
expect(result.current.results[0].comment.id).toBe('2');
});

it('returns results with objectName and recordId', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);

act(() => {
result.current.setQuery('login');
});

expect(result.current.results).toHaveLength(1);
expect(result.current.results[0].objectName).toBe('Task');
expect(result.current.results[0].recordId).toBe('t1');
});

it('provides highlighted text snippets', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);

act(() => {
result.current.setQuery('bug');
});

expect(result.current.results[0].highlight).toBeDefined();
expect(result.current.results[0].highlight).toContain('bug');
});

it('clears search results', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);

act(() => {
result.current.setQuery('dashboard');
});
expect(result.current.results).toHaveLength(2);

act(() => {
result.current.clearSearch();
});
expect(result.current.results).toEqual([]);
expect(result.current.query).toBe('');
expect(result.current.isSearching).toBe(false);
});

it('returns empty results for non-matching query', () => {
const { result } = renderHook(() =>
useCommentSearch({ comments: mockComments }),
);

act(() => {
result.current.setQuery('zzzznonexistent');
});

expect(result.current.results).toEqual([]);
expect(result.current.isSearching).toBe(true);
});
});
Loading