From 6c2ad11915f8b5c58eac7c7a29773d5908c45775 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:59:49 +0000 Subject: [PATCH 1/3] Initial plan From ea5a10cc1a2743249b5ca202b9957fc62d36b205 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 07:04:13 +0000 Subject: [PATCH 2/3] feat(P1.5): add types, hooks, and UI for comments & collaboration - Add MentionNotification, CommentSearchResult types to @object-ui/types - Extend CommentEntry with pinned, mentions, objectName, recordId fields - Create useMentionNotifications hook in @object-ui/collaboration - Create useCommentSearch hook in @object-ui/collaboration - Add onMentionNotify callback to CommentThread - Add comment pinning/starring and search to RecordComments - Add activity type filtering to ActivityTimeline Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/collaboration/src/CommentThread.tsx | 11 ++- packages/collaboration/src/index.ts | 12 +++ .../collaboration/src/useCommentSearch.ts | 86 ++++++++++++++++ .../src/useMentionNotifications.ts | 97 +++++++++++++++++++ .../plugin-detail/src/ActivityTimeline.tsx | 55 ++++++++++- packages/plugin-detail/src/RecordComments.tsx | 77 ++++++++++++++- packages/plugin-detail/src/index.tsx | 2 +- packages/types/src/index.ts | 2 + packages/types/src/views.ts | 50 ++++++++++ 9 files changed, 381 insertions(+), 11 deletions(-) create mode 100644 packages/collaboration/src/useCommentSearch.ts create mode 100644 packages/collaboration/src/useMentionNotifications.ts diff --git a/packages/collaboration/src/CommentThread.tsx b/packages/collaboration/src/CommentThread.tsx index da8407597..397b7b3b0 100644 --- a/packages/collaboration/src/CommentThread.tsx +++ b/packages/collaboration/src/CommentThread.tsx @@ -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 */ @@ -326,6 +328,7 @@ export function CommentThread({ onDeleteComment, onResolve, onReaction, + onMentionNotify, resolved = false, className, }: CommentThreadProps): React.ReactElement { @@ -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) => { if (mentionQuery !== null && filteredMentions.length > 0) { diff --git a/packages/collaboration/src/index.ts b/packages/collaboration/src/index.ts index 8e984db28..cd64563a9 100644 --- a/packages/collaboration/src/index.ts +++ b/packages/collaboration/src/index.ts @@ -49,6 +49,18 @@ export { type CommentThreadProps, } from './CommentThread'; +export { + useMentionNotifications, + type MentionNotificationsConfig, + type MentionNotificationsResult, +} from './useMentionNotifications'; + +export { + useCommentSearch, + type CommentSearchConfig, + type CommentSearchReturn, +} from './useCommentSearch'; + export { LiveCursors, type LiveCursorsProps, diff --git a/packages/collaboration/src/useCommentSearch.ts b/packages/collaboration/src/useCommentSearch.ts new file mode 100644 index 000000000..41b6880ce --- /dev/null +++ b/packages/collaboration/src/useCommentSearch.ts @@ -0,0 +1,86 @@ +/** + * 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 { useState, useCallback, useMemo } from 'react'; +import type { CommentEntry, CommentSearchResult } from '@object-ui/types'; + +export interface CommentSearchConfig { + /** All comments across records, each must have objectName and recordId set */ + comments: CommentEntry[]; +} + +export interface CommentSearchReturn { + /** Current search query */ + query: string; + /** Update the search query */ + setQuery: (query: string) => void; + /** Filtered/matched results */ + results: CommentSearchResult[]; + /** Whether a search is active */ + isSearching: boolean; + /** Clear the search */ + clearSearch: () => void; +} + +/** + * Build a highlighted snippet around the first match of `query` in `text`. + * Returns the match wrapped in tags for display. + */ +function buildHighlight(text: string, query: string): string { + if (!query) return text; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return text; + const start = Math.max(0, idx - 30); + const end = Math.min(text.length, idx + query.length + 30); + const before = start > 0 ? '…' : ''; + const after = end < text.length ? '…' : ''; + return `${before}${text.slice(start, end)}${after}`; +} + +/** + * Hook for searching comments across all records. + * + * Accepts a flat list of CommentEntry items (each should have objectName + * and recordId set) and provides a search interface that returns matching + * results with highlighted snippets. + */ +export function useCommentSearch({ comments }: CommentSearchConfig): CommentSearchReturn { + const [query, setQuery] = useState(''); + + const isSearching = query.trim().length > 0; + + const results = useMemo(() => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return []; + + return comments + .filter(c => { + const textMatch = c.text.toLowerCase().includes(trimmed); + const authorMatch = c.author.toLowerCase().includes(trimmed); + return textMatch || authorMatch; + }) + .map(c => ({ + comment: c, + objectName: c.objectName ?? '', + recordId: c.recordId ?? '', + highlight: buildHighlight(c.text, trimmed), + })); + }, [query, comments]); + + const clearSearch = useCallback(() => { + setQuery(''); + }, []); + + return { + query, + setQuery, + results, + isSearching, + clearSearch, + }; +} diff --git a/packages/collaboration/src/useMentionNotifications.ts b/packages/collaboration/src/useMentionNotifications.ts new file mode 100644 index 000000000..4a0c5413a --- /dev/null +++ b/packages/collaboration/src/useMentionNotifications.ts @@ -0,0 +1,97 @@ +/** + * 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 { useState, useCallback, useMemo } from 'react'; +import type { MentionNotification } from '@object-ui/types'; + +export interface MentionNotificationsConfig { + /** Current user ID to filter notifications for */ + currentUserId: string; + /** Initial notifications */ + initialNotifications?: MentionNotification[]; + /** Callback when a notification should be delivered (email/push) */ + onDeliver?: (notification: MentionNotification) => void | Promise; +} + +export interface MentionNotificationsResult { + /** All notifications for the current user */ + notifications: MentionNotification[]; + /** Unread notifications count */ + unreadCount: number; + /** Add a new mention notification (triggered when @mention is detected) */ + addNotification: (notification: MentionNotification) => void; + /** Mark a notification as read */ + markAsRead: (notificationId: string) => void; + /** Mark all notifications as read */ + markAllAsRead: () => void; + /** Dismiss/remove a notification */ + dismiss: (notificationId: string) => void; + /** Clear all notifications */ + clearAll: () => void; +} + +/** + * Hook for managing @mention notifications with delivery support. + * + * Tracks mention notifications for the current user and provides + * callbacks for delivery via email/push channels. + */ +export function useMentionNotifications({ + currentUserId, + initialNotifications = [], + onDeliver, +}: MentionNotificationsConfig): MentionNotificationsResult { + const [notifications, setNotifications] = useState( + initialNotifications.filter(n => n.recipientId === currentUserId), + ); + + const unreadCount = useMemo( + () => notifications.filter(n => !n.read).length, + [notifications], + ); + + const addNotification = useCallback( + (notification: MentionNotification) => { + if (notification.recipientId !== currentUserId) return; + setNotifications(prev => { + if (prev.some(n => n.id === notification.id)) return prev; + return [notification, ...prev]; + }); + onDeliver?.(notification); + }, + [currentUserId, onDeliver], + ); + + const markAsRead = useCallback((notificationId: string) => { + setNotifications(prev => + prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)), + ); + }, []); + + const markAllAsRead = useCallback(() => { + setNotifications(prev => prev.map(n => ({ ...n, read: true }))); + }, []); + + const dismiss = useCallback((notificationId: string) => { + setNotifications(prev => prev.filter(n => n.id !== notificationId)); + }, []); + + const clearAll = useCallback(() => { + setNotifications([]); + }, []); + + return { + notifications, + unreadCount, + addNotification, + markAsRead, + markAllAsRead, + dismiss, + clearAll, + }; +} diff --git a/packages/plugin-detail/src/ActivityTimeline.tsx b/packages/plugin-detail/src/ActivityTimeline.tsx index c10b31d92..a7c97b12c 100644 --- a/packages/plugin-detail/src/ActivityTimeline.tsx +++ b/packages/plugin-detail/src/ActivityTimeline.tsx @@ -8,11 +8,17 @@ import * as React from 'react'; import { cn, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components'; -import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft } from 'lucide-react'; +import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft, Filter } from 'lucide-react'; import type { ActivityEntry } from '@object-ui/types'; +export type ActivityFilterType = ActivityEntry['type'] | 'all'; + export interface ActivityTimelineProps { activities: ActivityEntry[]; + /** Show filter controls for activity types */ + filterable?: boolean; + /** Default filter (defaults to 'all') */ + defaultFilter?: ActivityFilterType; className?: string; } @@ -71,10 +77,28 @@ function formatFieldChange(entry: ActivityEntry): string { return 'Updated record'; } +const FILTER_LABELS: Record = { + all: 'All', + field_change: 'Field Changes', + create: 'Creates', + delete: 'Deletes', + comment: 'Comments', + status_change: 'Status Changes', +}; + export const ActivityTimeline: React.FC = ({ activities, + filterable = false, + defaultFilter = 'all', className, }) => { + const [activeFilter, setActiveFilter] = React.useState(defaultFilter); + + const filteredActivities = React.useMemo(() => { + if (activeFilter === 'all') return activities; + return activities.filter(a => a.type === activeFilter); + }, [activities, activeFilter]); + return ( @@ -82,12 +106,35 @@ export const ActivityTimeline: React.FC = ({ Activity - ({activities.length}) + ({filteredActivities.length}) - {activities.length === 0 ? ( + {/* Filter controls */} + {filterable && ( +
+ {(Object.keys(FILTER_LABELS) as ActivityFilterType[]).map(type => ( + + ))} +
+ )} + + {filteredActivities.length === 0 ? (

No activity recorded

@@ -97,7 +144,7 @@ export const ActivityTimeline: React.FC = ({
- {activities.map((entry) => { + {filteredActivities.map((entry) => { const Icon = ACTIVITY_ICONS[entry.type] || Edit; const colorClass = ACTIVITY_COLORS[entry.type] || 'bg-gray-100 text-gray-600'; diff --git a/packages/plugin-detail/src/RecordComments.tsx b/packages/plugin-detail/src/RecordComments.tsx index 88119b2ab..e1f1dfc4b 100644 --- a/packages/plugin-detail/src/RecordComments.tsx +++ b/packages/plugin-detail/src/RecordComments.tsx @@ -8,12 +8,16 @@ import * as React from 'react'; import { cn, Button, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components'; -import { MessageSquare, Send } from 'lucide-react'; +import { MessageSquare, Send, Pin, Search, X } from 'lucide-react'; import type { CommentEntry } from '@object-ui/types'; export interface RecordCommentsProps { comments: CommentEntry[]; onAddComment?: (text: string) => void | Promise; + /** Callback to toggle pin/star on a comment */ + onTogglePin?: (commentId: string | number) => void; + /** Enable search input for filtering comments */ + searchable?: boolean; className?: string; } @@ -42,10 +46,13 @@ function formatTimestamp(timestamp: string): string { export const RecordComments: React.FC = ({ comments, onAddComment, + onTogglePin, + searchable = false, className, }) => { const [newComment, setNewComment] = React.useState(''); const [isSubmitting, setIsSubmitting] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(''); const handleSubmit = React.useCallback(async () => { const text = newComment.trim(); @@ -70,6 +77,22 @@ export const RecordComments: React.FC = ({ [handleSubmit], ); + /** Sort pinned comments first, then by date */ + const sortedComments = React.useMemo(() => { + const filtered = searchQuery.trim() + ? comments.filter(c => { + const q = searchQuery.trim().toLowerCase(); + return c.text.toLowerCase().includes(q) || c.author.toLowerCase().includes(q); + }) + : comments; + + return [...filtered].sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return 0; + }); + }, [comments, searchQuery]); + return ( @@ -82,6 +105,32 @@ export const RecordComments: React.FC = ({ + {/* Search Input */} + {searchable && ( +
+
+ + setSearchQuery(e.target.value)} + aria-label="Search comments" + /> + {searchQuery && ( + + )} +
+
+ )} + {/* Comment Input */} {onAddComment && (
@@ -106,14 +155,14 @@ export const RecordComments: React.FC = ({ )} {/* Comment List */} - {comments.length === 0 ? ( + {sortedComments.length === 0 ? (

- No comments yet + {searchQuery.trim() ? 'No matching comments' : 'No comments yet'}

) : (
- {comments.map((comment) => ( -
+ {sortedComments.map((comment) => ( +
{/* Avatar */}
{comment.avatarUrl ? ( @@ -135,8 +184,26 @@ export const RecordComments: React.FC = ({ {formatTimestamp(comment.createdAt)} + {comment.pinned && ( + + + Pinned + + )}

{comment.text}

+ {/* Pin action */} + {onTogglePin && ( + + )}
))} diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index e16caa255..3ea5c752b 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -29,7 +29,7 @@ export type { DetailSectionProps } from './DetailSection'; export type { DetailTabsProps } from './DetailTabs'; export type { RelatedListProps } from './RelatedList'; export type { RecordCommentsProps } from './RecordComments'; -export type { ActivityTimelineProps } from './ActivityTimeline'; +export type { ActivityTimelineProps, ActivityFilterType } from './ActivityTimeline'; export type { InlineCreateRelatedProps, RelatedFieldDefinition, RelatedRecordOption } from './InlineCreateRelated'; export type { RichTextCommentInputProps, MentionSuggestion } from './RichTextCommentInput'; export type { DiffViewProps, DiffFieldType, DiffMode, DiffLine } from './DiffView'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ebdb2f5b2..6b336b28d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -684,6 +684,8 @@ export type { SortUISchema, ViewComponentSchema, CommentEntry, + MentionNotification, + CommentSearchResult, ActivityEntry, } from './views'; diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index a85d2dc98..ed589e3a3 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -182,6 +182,56 @@ export interface CommentEntry { avatarUrl?: string; /** Timestamp when the comment was created */ createdAt: string; + /** Whether this comment is pinned/starred */ + pinned?: boolean; + /** Mentioned user IDs extracted from the comment text */ + mentions?: string[]; + /** Object/record this comment belongs to (for cross-record search) */ + objectName?: string; + /** Record ID this comment belongs to (for cross-record search) */ + recordId?: string | number; +} + +/** + * Mention notification - delivered when a user is @mentioned in a comment + */ +export interface MentionNotification { + /** Unique notification ID */ + id: string; + /** Type of notification */ + type: 'mention'; + /** ID of the user being notified */ + recipientId: string; + /** The comment that contains the mention */ + commentId: string | number; + /** Author who mentioned the recipient */ + mentionedBy: string; + /** The comment text (or excerpt) */ + commentText: string; + /** Object name the comment belongs to */ + objectName?: string; + /** Record ID the comment belongs to */ + recordId?: string | number; + /** When the mention was created */ + createdAt: string; + /** Whether the notification has been read */ + read?: boolean; + /** Delivery channels */ + channels?: Array<'in_app' | 'email' | 'push'>; +} + +/** + * Comment search result - returned when searching comments across records + */ +export interface CommentSearchResult { + /** The matching comment */ + comment: CommentEntry; + /** Object name the comment belongs to */ + objectName: string; + /** Record ID the comment belongs to */ + recordId: string | number; + /** Highlighted text snippet with search term marked */ + highlight?: string; } /** From faf479388e8972e8e35f943e99847f88f4646218 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 07:07:28 +0000 Subject: [PATCH 3/3] test(P1.5): add tests for mention notifications, comment search, pinning, and activity filtering - 40 new tests across 5 test files - All 291 tests in affected packages pass - ROADMAP.md updated to mark P1.5 items as complete Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 8 +- .../CommentThreadMentionNotify.test.tsx | 101 +++++++++++ .../src/__tests__/useCommentSearch.test.ts | 159 +++++++++++++++++ .../__tests__/useMentionNotifications.test.ts | 162 ++++++++++++++++++ .../ActivityTimelineFiltering.test.tsx | 143 ++++++++++++++++ .../RecordCommentsPinSearch.test.tsx | 133 ++++++++++++++ 6 files changed, 702 insertions(+), 4 deletions(-) create mode 100644 packages/collaboration/src/__tests__/CommentThreadMentionNotify.test.tsx create mode 100644 packages/collaboration/src/__tests__/useCommentSearch.test.ts create mode 100644 packages/collaboration/src/__tests__/useMentionNotifications.test.ts create mode 100644 packages/plugin-detail/src/__tests__/ActivityTimelineFiltering.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/RecordCommentsPinSearch.test.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 2130e7e15..9008113ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/packages/collaboration/src/__tests__/CommentThreadMentionNotify.test.tsx b/packages/collaboration/src/__tests__/CommentThreadMentionNotify.test.tsx new file mode 100644 index 000000000..2af194baf --- /dev/null +++ b/packages/collaboration/src/__tests__/CommentThreadMentionNotify.test.tsx @@ -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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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 + }); +}); diff --git a/packages/collaboration/src/__tests__/useCommentSearch.test.ts b/packages/collaboration/src/__tests__/useCommentSearch.test.ts new file mode 100644 index 000000000..40e05838b --- /dev/null +++ b/packages/collaboration/src/__tests__/useCommentSearch.test.ts @@ -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); + }); +}); diff --git a/packages/collaboration/src/__tests__/useMentionNotifications.test.ts b/packages/collaboration/src/__tests__/useMentionNotifications.test.ts new file mode 100644 index 000000000..db39f4275 --- /dev/null +++ b/packages/collaboration/src/__tests__/useMentionNotifications.test.ts @@ -0,0 +1,162 @@ +/** + * 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 { renderHook, act } from '@testing-library/react'; +import { useMentionNotifications } from '../useMentionNotifications'; +import type { MentionNotification } from '@object-ui/types'; + +const makeNotification = (overrides: Partial = {}): MentionNotification => ({ + id: 'n1', + type: 'mention', + recipientId: 'user1', + commentId: 'c1', + mentionedBy: 'Alice', + commentText: 'Hey @user1 check this out', + createdAt: new Date().toISOString(), + ...overrides, +}); + +describe('useMentionNotifications', () => { + it('initializes with empty notifications', () => { + const { result } = renderHook(() => + useMentionNotifications({ currentUserId: 'user1' }), + ); + expect(result.current.notifications).toEqual([]); + expect(result.current.unreadCount).toBe(0); + }); + + it('initializes with provided notifications filtered to current user', () => { + const myNotif = makeNotification({ id: 'n1', recipientId: 'user1' }); + const otherNotif = makeNotification({ id: 'n2', recipientId: 'user2' }); + + const { result } = renderHook(() => + useMentionNotifications({ + currentUserId: 'user1', + initialNotifications: [myNotif, otherNotif], + }), + ); + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0].id).toBe('n1'); + }); + + it('adds a notification and calls onDeliver', () => { + const onDeliver = vi.fn(); + const { result } = renderHook(() => + useMentionNotifications({ currentUserId: 'user1', onDeliver }), + ); + + const notif = makeNotification(); + act(() => { + result.current.addNotification(notif); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.unreadCount).toBe(1); + expect(onDeliver).toHaveBeenCalledWith(notif); + }); + + it('ignores notifications for other users', () => { + const onDeliver = vi.fn(); + const { result } = renderHook(() => + useMentionNotifications({ currentUserId: 'user1', onDeliver }), + ); + + act(() => { + result.current.addNotification(makeNotification({ recipientId: 'user2' })); + }); + + expect(result.current.notifications).toHaveLength(0); + expect(onDeliver).not.toHaveBeenCalled(); + }); + + it('prevents duplicate notifications', () => { + const { result } = renderHook(() => + useMentionNotifications({ currentUserId: 'user1' }), + ); + + const notif = makeNotification(); + act(() => { + result.current.addNotification(notif); + result.current.addNotification(notif); + }); + + expect(result.current.notifications).toHaveLength(1); + }); + + it('marks a notification as read', () => { + const notif = makeNotification(); + const { result } = renderHook(() => + useMentionNotifications({ + currentUserId: 'user1', + initialNotifications: [notif], + }), + ); + + expect(result.current.unreadCount).toBe(1); + + act(() => { + result.current.markAsRead('n1'); + }); + + expect(result.current.unreadCount).toBe(0); + expect(result.current.notifications[0].read).toBe(true); + }); + + it('marks all notifications as read', () => { + const n1 = makeNotification({ id: 'n1' }); + const n2 = makeNotification({ id: 'n2' }); + const { result } = renderHook(() => + useMentionNotifications({ + currentUserId: 'user1', + initialNotifications: [n1, n2], + }), + ); + + expect(result.current.unreadCount).toBe(2); + + act(() => { + result.current.markAllAsRead(); + }); + + expect(result.current.unreadCount).toBe(0); + }); + + it('dismisses a notification', () => { + const notif = makeNotification(); + const { result } = renderHook(() => + useMentionNotifications({ + currentUserId: 'user1', + initialNotifications: [notif], + }), + ); + + act(() => { + result.current.dismiss('n1'); + }); + + expect(result.current.notifications).toHaveLength(0); + }); + + it('clears all notifications', () => { + const n1 = makeNotification({ id: 'n1' }); + const n2 = makeNotification({ id: 'n2' }); + const { result } = renderHook(() => + useMentionNotifications({ + currentUserId: 'user1', + initialNotifications: [n1, n2], + }), + ); + + act(() => { + result.current.clearAll(); + }); + + expect(result.current.notifications).toHaveLength(0); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/ActivityTimelineFiltering.test.tsx b/packages/plugin-detail/src/__tests__/ActivityTimelineFiltering.test.tsx new file mode 100644 index 000000000..d5a725fcc --- /dev/null +++ b/packages/plugin-detail/src/__tests__/ActivityTimelineFiltering.test.tsx @@ -0,0 +1,143 @@ +/** + * 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 { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActivityTimeline } from '../ActivityTimeline'; +import type { ActivityEntry } from '@object-ui/types'; + +const mockActivities: ActivityEntry[] = [ + { + id: '1', + type: 'create', + user: 'Alice', + timestamp: '2026-02-15T10:00:00Z', + }, + { + id: '2', + type: 'field_change', + field: 'status', + oldValue: 'open', + newValue: 'in_progress', + user: 'Bob', + timestamp: '2026-02-16T08:30:00Z', + }, + { + id: '3', + type: 'comment', + user: 'Charlie', + timestamp: '2026-02-16T09:00:00Z', + description: 'Added a comment on the record', + }, + { + id: '4', + type: 'delete', + user: 'Diana', + timestamp: '2026-02-16T09:30:00Z', + }, + { + id: '5', + type: 'comment', + user: 'Eve', + timestamp: '2026-02-16T10:00:00Z', + description: 'Another comment here', + }, +]; + +describe('ActivityTimeline - Filtering', () => { + it('does not render filter controls when filterable is false/not provided', () => { + render(); + expect(screen.queryByRole('group', { name: 'Activity type filter' })).not.toBeInTheDocument(); + }); + + it('renders filter controls when filterable is true', () => { + render(); + const filterGroup = screen.getByRole('group', { name: 'Activity type filter' }); + expect(filterGroup).toBeInTheDocument(); + + // Should have "All", "Field Changes", "Creates", "Deletes", "Comments", "Status Changes" + expect(screen.getByText('All')).toBeInTheDocument(); + expect(screen.getByText('Comments')).toBeInTheDocument(); + expect(screen.getByText('Field Changes')).toBeInTheDocument(); + expect(screen.getByText('Creates')).toBeInTheDocument(); + expect(screen.getByText('Deletes')).toBeInTheDocument(); + }); + + it('shows all activities by default', () => { + render(); + // Count should be (5) + expect(screen.getByText('(5)')).toBeInTheDocument(); + }); + + it('filters to only comments when Comments filter is selected', () => { + render(); + + const commentsFilter = screen.getByText('Comments'); + fireEvent.click(commentsFilter); + + // Only 2 comment activities + expect(screen.getByText('(2)')).toBeInTheDocument(); + expect(screen.getByText('Added a comment on the record')).toBeInTheDocument(); + expect(screen.getByText('Another comment here')).toBeInTheDocument(); + }); + + it('filters to only field changes when Field Changes filter is selected', () => { + render(); + + const fieldChangesFilter = screen.getByText('Field Changes'); + fireEvent.click(fieldChangesFilter); + + // Only 1 field_change activity + expect(screen.getByText('(1)')).toBeInTheDocument(); + }); + + it('shows all activities when All filter is re-selected', () => { + render(); + + // First filter to comments only + fireEvent.click(screen.getByText('Comments')); + expect(screen.getByText('(2)')).toBeInTheDocument(); + + // Then go back to All + fireEvent.click(screen.getByText('All')); + expect(screen.getByText('(5)')).toBeInTheDocument(); + }); + + it('respects defaultFilter prop', () => { + render( + , + ); + + // Should start filtered to comments + expect(screen.getByText('(2)')).toBeInTheDocument(); + }); + + it('shows "No activity recorded" when filter has no results', () => { + render( + , + ); + + expect(screen.getByText('No activity recorded')).toBeInTheDocument(); + }); + + it('renders all activities without filter when filterable is not set', () => { + render(); + expect(screen.getByText('(5)')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/RecordCommentsPinSearch.test.tsx b/packages/plugin-detail/src/__tests__/RecordCommentsPinSearch.test.tsx new file mode 100644 index 000000000..46e7dcd28 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordCommentsPinSearch.test.tsx @@ -0,0 +1,133 @@ +/** + * 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 '@testing-library/jest-dom'; +import { RecordComments } from '../RecordComments'; +import type { CommentEntry } from '@object-ui/types'; + +const mockComments: CommentEntry[] = [ + { + id: '1', + text: 'First comment about the project', + author: 'Alice', + createdAt: '2026-02-16T08:00:00Z', + }, + { + id: '2', + text: 'Second comment on this record', + author: 'Bob', + avatarUrl: 'https://example.com/bob.jpg', + createdAt: '2026-02-16T09:00:00Z', + pinned: true, + }, + { + id: '3', + text: 'Third comment here', + author: 'Charlie', + createdAt: '2026-02-16T10:00:00Z', + }, +]; + +describe('RecordComments - Pinning', () => { + it('shows "Pinned" label on pinned comments', () => { + render(); + expect(screen.getByText('Pinned')).toBeInTheDocument(); + }); + + it('sorts pinned comments to the top', () => { + const { container } = render(); + // Bob (pinned) should appear first + const authors = container.querySelectorAll('.text-sm.font-medium'); + const authorTexts = Array.from(authors).map(el => el.textContent); + expect(authorTexts[0]).toBe('Bob'); + }); + + it('renders pin/unpin button when onTogglePin is provided', () => { + const onTogglePin = vi.fn(); + render(); + + // Should show "Unpin" for Bob (pinned) and "Pin" for others + expect(screen.getByText('Unpin')).toBeInTheDocument(); + expect(screen.getAllByText('Pin')).toHaveLength(2); + }); + + it('does not render pin button when onTogglePin is not provided', () => { + render(); + expect(screen.queryByText('Unpin')).not.toBeInTheDocument(); + expect(screen.queryAllByText('Pin')).toHaveLength(0); + }); + + it('calls onTogglePin when pin button is clicked', () => { + const onTogglePin = vi.fn(); + render(); + + const unpinBtn = screen.getByText('Unpin'); + fireEvent.click(unpinBtn); + + expect(onTogglePin).toHaveBeenCalledWith('2'); + }); +}); + +describe('RecordComments - Search', () => { + it('does not render search input when searchable is false/not provided', () => { + render(); + expect(screen.queryByLabelText('Search comments')).not.toBeInTheDocument(); + }); + + it('renders search input when searchable is true', () => { + render(); + expect(screen.getByLabelText('Search comments')).toBeInTheDocument(); + }); + + it('filters comments by text when searching', () => { + render(); + + const searchInput = screen.getByLabelText('Search comments'); + fireEvent.change(searchInput, { target: { value: 'project' } }); + + expect(screen.getByText('First comment about the project')).toBeInTheDocument(); + expect(screen.queryByText('Second comment on this record')).not.toBeInTheDocument(); + expect(screen.queryByText('Third comment here')).not.toBeInTheDocument(); + }); + + it('filters comments by author when searching', () => { + render(); + + const searchInput = screen.getByLabelText('Search comments'); + fireEvent.change(searchInput, { target: { value: 'Charlie' } }); + + expect(screen.getByText('Third comment here')).toBeInTheDocument(); + expect(screen.queryByText('First comment about the project')).not.toBeInTheDocument(); + }); + + it('shows "No matching comments" when search has no results', () => { + render(); + + const searchInput = screen.getByLabelText('Search comments'); + fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } }); + + expect(screen.getByText('No matching comments')).toBeInTheDocument(); + }); + + it('clears search when clear button is clicked', () => { + render(); + + const searchInput = screen.getByLabelText('Search comments'); + fireEvent.change(searchInput, { target: { value: 'project' } }); + + const clearBtn = screen.getByLabelText('Clear search'); + fireEvent.click(clearBtn); + + // All comments should be visible again + expect(screen.getByText('First comment about the project')).toBeInTheDocument(); + expect(screen.getByText('Second comment on this record')).toBeInTheDocument(); + expect(screen.getByText('Third comment here')).toBeInTheDocument(); + }); +});