diff --git a/ROADMAP.md b/ROADMAP.md index 4f10e6e47..4b9a812dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind + Shadcn. It renders JSON metadata from the @objectstack/spec protocol into pixel-perfect, accessible, and interactive enterprise interfaces. -**Where We Are:** Foundation is **solid and shipping** — 35 packages, 99+ components, 5,177+ tests, 78 Storybook stories, 42/42 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder ✅), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), **Flow Designer** (P2.4), and **Feed/Chatter UI** (P1.5) — all ✅ complete. +**Where We Are:** Foundation is **solid and shipping** — 35 packages, 99+ components, 5,618+ tests, 78 Storybook stories, 42/42 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder ✅), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), **Flow Designer** (P2.4), and **Feed/Chatter UI** (P1.5) — all ✅ complete. **What Remains:** The gap to **Airtable-level UX** is primarily in: 1. ~~**AppShell** — No dynamic navigation renderer from spec JSON (last P0 blocker)~~ ✅ Complete @@ -140,7 +140,9 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] `SubscriptionToggle` — bell notification toggle - [x] `ReactionPicker` — emoji reaction selector - [x] `ThreadedReplies` — collapsible comment reply threading - + - [x] Comprehensive unit tests for all 6 core Feed/Chatter components (96 tests) + - [x] Console `RecordDetailView` integration: `CommentThread` → `RecordChatterPanel` with `FeedItem[]` data model + - [ ] Documentation for Feed/Chatter plugin in `content/docs/plugins/plugin-detail.mdx` (purpose/use cases, JSON schema, props, and Console integration for `RecordChatterPanel`, `RecordActivityTimeline`, and related components) ### P1.6 Console — Automation > **Spec v3.0.9** significantly expanded the automation/workflow protocol. New node types, BPMN interop, execution tracking, and wait/timer executors are now available in the spec. @@ -496,7 +498,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th | **AppShell Renderer** | ✅ Complete | Sidebar + nav tree from `AppSchema` JSON | Console renders from spec JSON | | **Designer Interaction** | Phase 2 (most complete) | ViewDesigner + DataModelDesigner drag/undo | Manual UX testing | | **Build Status** | 42/42 pass | 42/42 pass | `pnpm build` | -| **Test Count** | 5,070+ | 5,500+ | `pnpm test` summary | +| **Test Count** | 5,070+ | 5,618+ | `pnpm test` summary | | **Test Coverage** | 90%+ | 90%+ | `pnpm test:coverage` | | **Storybook Stories** | 78 | 91+ (1 per component) | Story file count | | **Console i18n** | 100% | 100% | No hardcoded strings | diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index fa491d60a..9227b74b4 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -8,14 +8,14 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; -import { DetailView } from '@object-ui/plugin-detail'; +import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; -import { CommentThread, PresenceAvatars, type Comment, type PresenceUser } from '@object-ui/collaboration'; +import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration'; import { useAuth } from '@object-ui/auth'; -import { Database, MessageSquare, Users } from 'lucide-react'; +import { Database, Users } from 'lucide-react'; import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector'; import { SkeletonDetail } from './skeletons'; -import type { DetailViewSchema } from '@object-ui/types'; +import type { DetailViewSchema, FeedItem } from '@object-ui/types'; interface RecordDetailViewProps { dataSource: any; @@ -30,8 +30,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi const { showDebug, toggleDebug } = useMetadataInspector(); const { user } = useAuth(); const [isLoading, setIsLoading] = useState(true); - const [comments, setComments] = useState([]); - const [threadResolved, setThreadResolved] = useState(false); + const [feedItems, setFeedItems] = useState([]); const [recordViewers, setRecordViewers] = useState([]); const objectDef = objects.find((o: any) => o.name === objectName); @@ -49,58 +48,122 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi .then((res: any) => { if (res.data?.length) setRecordViewers(res.data); }) .catch(() => {}); - // Fetch persisted comments + // Fetch persisted comments and map to FeedItem[] dataSource.find('sys_comment', { $filter: `threadId eq '${threadId}'`, $orderby: 'createdAt asc' }) - .then((res: any) => { if (res.data?.length) setComments(res.data); }) + .then((res: any) => { + if (res.data?.length) { + setFeedItems(res.data.map((c: any) => ({ + id: c.id, + type: 'comment' as const, + actor: c.author?.name ?? 'Unknown', + actorAvatarUrl: c.author?.avatar, + body: c.content, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + parentId: c.parentId, + reactions: c.reactions + ? Object.entries(c.reactions as Record).map(([emoji, userIds]) => ({ + emoji, + count: userIds.length, + reacted: userIds.includes(currentUser.id), + })) + : undefined, + }))); + } + }) .catch(() => {}); - }, [dataSource, objectName, recordId]); + }, [dataSource, objectName, recordId, currentUser]); const handleAddComment = useCallback( - async (content: string, mentions: string[], parentId?: string) => { - const newComment: Comment = { + async (text: string) => { + const newItem: FeedItem = { id: crypto.randomUUID(), - author: currentUser, - content, - mentions, + type: 'comment', + actor: currentUser.name, + actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined, + body: text, createdAt: new Date().toISOString(), - parentId, }; - setComments(prev => [...prev, newComment]); + setFeedItems(prev => [...prev, newItem]); // Persist to backend if (dataSource) { const threadId = `${objectName}:${recordId}`; - dataSource.create('sys_comment', { ...newComment, threadId }).catch(() => {}); + dataSource.create('sys_comment', { + id: newItem.id, + threadId, + author: currentUser, + content: text, + mentions: [], + createdAt: newItem.createdAt, + }).catch(() => {}); } }, [currentUser, dataSource, objectName, recordId], ); - const handleDeleteComment = useCallback( - async (commentId: string) => { - setComments(prev => prev.filter(c => c.id !== commentId)); + const handleAddReply = useCallback( + async (parentId: string | number, text: string) => { + const newItem: FeedItem = { + id: crypto.randomUUID(), + type: 'comment', + actor: currentUser.name, + actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined, + body: text, + createdAt: new Date().toISOString(), + parentId, + }; + setFeedItems(prev => { + const updated = [...prev, newItem]; + // Increment replyCount on parent + return updated.map(item => + item.id === parentId + ? { ...item, replyCount: (item.replyCount ?? 0) + 1 } + : item + ); + }); if (dataSource) { - dataSource.delete('sys_comment', commentId).catch(() => {}); + const threadId = `${objectName}:${recordId}`; + dataSource.create('sys_comment', { + id: newItem.id, + threadId, + author: currentUser, + content: text, + mentions: [], + createdAt: newItem.createdAt, + parentId, + }).catch(() => {}); } }, - [dataSource], + [currentUser, dataSource, objectName, recordId], ); - const handleReaction = useCallback( - (commentId: string, emoji: string) => { - setComments(prev => prev.map(c => { - if (c.id !== commentId) return c; - const reactions = { ...(c.reactions || {}) }; - const userIds = reactions[emoji] || []; - if (userIds.includes(currentUser.id)) { - reactions[emoji] = userIds.filter(id => id !== currentUser.id); - if (reactions[emoji].length === 0) delete reactions[emoji]; + const handleToggleReaction = useCallback( + (itemId: string | number, emoji: string) => { + setFeedItems(prev => prev.map(item => { + if (item.id !== itemId) return item; + const reactions = [...(item.reactions ?? [])]; + const idx = reactions.findIndex(r => r.emoji === emoji); + if (idx >= 0) { + const r = reactions[idx]; + if (r.reacted) { + // Remove user's reaction + if (r.count <= 1) { + reactions.splice(idx, 1); + } else { + reactions[idx] = { ...r, count: r.count - 1, reacted: false }; + } + } else { + reactions[idx] = { ...r, count: r.count + 1, reacted: true }; + } } else { - reactions[emoji] = [...userIds, currentUser.id]; + reactions.push({ emoji, count: 1, reacted: true }); } - const updated = { ...c, reactions }; - // Persist reaction update to backend + const updated = { ...item, reactions }; + // Persist reaction toggle to backend if (dataSource) { - dataSource.update('sys_comment', commentId, { reactions }).catch(() => {}); + dataSource.update('sys_comment', String(itemId), { + $toggleReaction: { emoji, userId: currentUser.id }, + }).catch(() => {}); } return updated; })); @@ -186,19 +249,20 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi {/* Comments & Discussion */}
-

- - Comments & Discussion -

-
diff --git a/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx b/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx index a12ad081e..02c460799 100644 --- a/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx +++ b/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx @@ -69,4 +69,51 @@ describe('FieldChangeItem', () => { const { container } = render(); expect(container.firstChild).toHaveClass('custom-class'); }); + + it('should render arrow icon between old and new values', () => { + const change: FieldChangeEntry = { + field: 'status', + fieldLabel: 'Status', + oldValue: 'Open', + newValue: 'Closed', + }; + const { container } = render(); + // ArrowRight renders as an SVG with lucide classes + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should render old value with line-through style', () => { + const change: FieldChangeEntry = { + field: 'status', + fieldLabel: 'Status', + oldDisplayValue: 'Open', + newDisplayValue: 'Closed', + }; + render(); + const oldEl = screen.getByText('Open'); + expect(oldEl).toHaveClass('line-through'); + }); + + it('should use fieldLabel priority over auto-generated label', () => { + const change: FieldChangeEntry = { + field: 'first_name', + fieldLabel: 'Custom Label', + oldValue: 'A', + newValue: 'B', + }; + render(); + expect(screen.getByText('Custom Label')).toBeInTheDocument(); + expect(screen.queryByText('First name')).not.toBeInTheDocument(); + }); + + it('should show (empty) for both null old and new values', () => { + const change: FieldChangeEntry = { + field: 'notes', + fieldLabel: 'Notes', + }; + render(); + const emptyTexts = screen.getAllByText('(empty)'); + expect(emptyTexts).toHaveLength(2); + }); }); diff --git a/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx b/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx index 2632c43d6..72fb82c8b 100644 --- a/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx +++ b/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx @@ -66,4 +66,48 @@ describe('ReactionPicker', () => { fireEvent.click(options[0]); expect(onToggle).toHaveBeenCalledWith('👍'); }); + + it('should disable reaction buttons when no onToggleReaction', () => { + render(); + const thumbsBtn = screen.getByLabelText(/👍 3/); + expect(thumbsBtn).toBeDisabled(); + const heartBtn = screen.getByLabelText(/❤️ 1/); + expect(heartBtn).toBeDisabled(); + }); + + it('should render custom emojiOptions', () => { + const onToggle = vi.fn(); + const customEmoji = ['🚀', '🔥', '✅']; + render( + , + ); + fireEvent.click(screen.getByLabelText('Add reaction')); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent('🚀'); + expect(options[1]).toHaveTextContent('🔥'); + expect(options[2]).toHaveTextContent('✅'); + }); + + it('should include emoji and count in aria-label', () => { + render(); + expect(screen.getByLabelText('👍 3 reactions')).toBeInTheDocument(); + expect(screen.getByLabelText('❤️ 1 reaction')).toBeInTheDocument(); + }); + + it('should show non-reacted emoji with bg-muted style', () => { + render(); + const heart = screen.getByLabelText(/❤️ 1/); + expect(heart).toHaveClass('bg-muted'); + }); + + it('should close picker after selecting emoji', () => { + const onToggle = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('Add reaction')); + expect(screen.getByRole('listbox', { name: 'Emoji picker' })).toBeInTheDocument(); + const options = screen.getAllByRole('option'); + fireEvent.click(options[0]); + expect(screen.queryByRole('listbox', { name: 'Emoji picker' })).not.toBeInTheDocument(); + }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx b/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx index f36348daa..24597bdfe 100644 --- a/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx @@ -50,6 +50,16 @@ const mockItems: FeedItem[] = [ }, ]; +const allTypeItems: FeedItem[] = [ + { id: 'c1', type: 'comment', actor: 'A', body: 'comment', createdAt: '2026-02-20T10:00:00Z' }, + { id: 'fc1', type: 'field_change', actor: 'B', createdAt: '2026-02-20T10:01:00Z', fieldChanges: [{ field: 'x', oldValue: '1', newValue: '2' }] }, + { id: 't1', type: 'task', actor: 'C', body: 'task', createdAt: '2026-02-20T10:02:00Z' }, + { id: 'e1', type: 'event', actor: 'D', body: 'event', createdAt: '2026-02-20T10:03:00Z' }, + { id: 's1', type: 'system', actor: 'E', body: 'system', createdAt: '2026-02-20T10:04:00Z' }, + { id: 'm1', type: 'email', actor: 'F', body: 'email', createdAt: '2026-02-20T10:05:00Z' }, + { id: 'p1', type: 'call', actor: 'G', body: 'call', createdAt: '2026-02-20T10:06:00Z' }, +]; + describe('RecordActivityTimeline', () => { it('should render activity heading with count', () => { render(); @@ -206,4 +216,175 @@ describe('RecordActivityTimeline', () => { ); expect(screen.getByLabelText('Unsubscribe from notifications')).toBeInTheDocument(); }); + + it('should render all 7 item types', () => { + render(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('C')).toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); + expect(screen.getByText('E')).toBeInTheDocument(); + expect(screen.getByText('F')).toBeInTheDocument(); + expect(screen.getByText('G')).toBeInTheDocument(); + }); + + it('should render actor avatar when actorAvatarUrl is provided', () => { + const items: FeedItem[] = [ + { id: '1', type: 'comment', actor: 'Alice', actorAvatarUrl: 'https://example.com/alice.png', body: 'Hi', createdAt: '2026-02-20T10:00:00Z' }, + ]; + render(); + const img = screen.getByAltText('Alice'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://example.com/alice.png'); + }); + + it('should submit comment via Ctrl+Enter', () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByPlaceholderText(/Leave a comment/); + fireEvent.change(textarea, { target: { value: 'Ctrl enter test' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + expect(onAdd).toHaveBeenCalledWith('Ctrl enter test'); + }); + + it('should clear input after successful comment submission', async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByPlaceholderText(/Leave a comment/) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'Will be cleared' } }); + fireEvent.click(screen.getByLabelText('Submit comment')); + await vi.waitFor(() => { + expect(textarea.value).toBe(''); + }); + }); + + it('should disable input and button during submission', async () => { + let resolveSubmit: () => void; + const onAdd = vi.fn(() => new Promise((r) => { resolveSubmit = r; })); + render( + , + ); + const textarea = screen.getByPlaceholderText(/Leave a comment/) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'submitting' } }); + fireEvent.click(screen.getByLabelText('Submit comment')); + expect(textarea).toBeDisabled(); + expect(screen.getByLabelText('Submit comment')).toBeDisabled(); + resolveSubmit!(); + await vi.waitFor(() => { + expect(textarea).not.toBeDisabled(); + }); + }); + + it('should show loading spinner when loading more', async () => { + let resolveLoad: () => void; + const onLoadMore = vi.fn(() => new Promise((r) => { resolveLoad = r; })); + render( + , + ); + fireEvent.click(screen.getByLabelText('Load more activity')); + // Loader2 spinner should be present while loading + expect(screen.getByLabelText('Load more activity')).toBeDisabled(); + resolveLoad!(); + await vi.waitFor(() => { + expect(screen.getByLabelText('Load more activity')).not.toBeDisabled(); + }); + }); + + it('should use controlled filterMode and call onFilterChange', () => { + const onFilterChange = vi.fn(); + render( + , + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + // Change filter via select + fireEvent.change(screen.getByLabelText('Filter activity'), { target: { value: 'all' } }); + expect(onFilterChange).toHaveBeenCalledWith('all'); + }); + + it('should use internal filter state when no controlled filterMode', () => { + render( + , + ); + // Initially all visible + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + // Switch to comments_only + fireEvent.change(screen.getByLabelText('Filter activity'), { target: { value: 'comments_only' } }); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + }); + + it('should not render comment input when onAddComment is not provided', () => { + render(); + expect(screen.queryByPlaceholderText(/Leave a comment/)).not.toBeInTheDocument(); + }); + + it('should render reactions when enableReactions is true', () => { + const items: FeedItem[] = [ + { + id: '1', + type: 'comment', + actor: 'Alice', + body: 'Nice!', + createdAt: '2026-02-20T10:00:00Z', + reactions: [{ emoji: '👍', count: 2, reacted: true }], + }, + ]; + render( + , + ); + expect(screen.getByText('👍')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('should not render reactions when enableReactions is false', () => { + const items: FeedItem[] = [ + { + id: '1', + type: 'comment', + actor: 'Alice', + body: 'Nice!', + createdAt: '2026-02-20T10:00:00Z', + reactions: [{ emoji: '👍', count: 2, reacted: true }], + }, + ]; + render( + , + ); + expect(screen.queryByText('👍')).not.toBeInTheDocument(); + }); + + it('should group items by parentId when enableThreading is true', () => { + const items: FeedItem[] = [ + { id: 'p1', type: 'comment', actor: 'Alice', body: 'Root comment', createdAt: '2026-02-20T10:00:00Z', replyCount: 1 }, + { id: 'r1', type: 'comment', actor: 'Bob', body: 'Reply', createdAt: '2026-02-20T11:00:00Z', parentId: 'p1' }, + ]; + render( + , + ); + // Root comment rendered + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Root comment')).toBeInTheDocument(); + // Reply is shown as threaded reply (collapsed) + expect(screen.getByText('1 reply')).toBeInTheDocument(); + // Reply actor should not be directly visible (collapsed in ThreadedReplies) + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + }); + + it('should show "via {source}" label', () => { + const items: FeedItem[] = [ + { id: '1', type: 'comment', actor: 'Alice', body: 'Hi', createdAt: '2026-02-20T10:00:00Z', source: 'slack' }, + ]; + render(); + expect(screen.getByText('via slack')).toBeInTheDocument(); + }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx index d13e958c4..ed06f54d7 100644 --- a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx @@ -126,4 +126,102 @@ describe('RecordChatterPanel', () => { expect(screen.queryByLabelText('Filter activity')).not.toBeInTheDocument(); }); }); + + describe('left sidebar mode', () => { + it('should render with border-r in left position', () => { + const { container } = render( + , + ); + const panel = container.firstChild as HTMLElement; + expect(panel).toHaveClass('border-r'); + }); + }); + + describe('right sidebar width', () => { + it('should apply configured width via style', () => { + const { container } = render( + , + ); + const panel = container.firstChild as HTMLElement; + expect(panel.style.width).toBe('400px'); + }); + }); + + describe('collapsible=false', () => { + it('should not show collapse button when collapsible is false', () => { + render( + , + ); + expect(screen.getByText('Discussion')).toBeInTheDocument(); + expect(screen.queryByLabelText('Close discussion panel')).not.toBeInTheDocument(); + }); + }); + + describe('sidebar timeline styling', () => { + it('should pass border-0 shadow-none to embedded timeline', () => { + const { container } = render( + , + ); + // The RecordActivityTimeline renders a Card; in sidebar mode it gets border-0 shadow-none + const card = container.querySelector('.border-0.shadow-none'); + expect(card).toBeInTheDocument(); + }); + }); + + describe('callback passthrough', () => { + it('should forward onAddComment to embedded timeline', () => { + const onAddComment = vi.fn().mockResolvedValue(undefined); + render( + , + ); + expect(screen.getByPlaceholderText(/Leave a comment/)).toBeInTheDocument(); + }); + }); + + describe('inline collapsible buttons', () => { + it('should show "Show Discussion (N)" when collapsed inline', () => { + render( + , + ); + expect(screen.getByText('Show Discussion (2)')).toBeInTheDocument(); + }); + + it('should show "Hide discussion" button when expanded inline', () => { + render( + , + ); + expect(screen.getByLabelText('Hide discussion')).toBeInTheDocument(); + }); + + it('should toggle between collapsed and expanded inline', () => { + render( + , + ); + // Collapsed + expect(screen.getByLabelText('Show discussion')).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText('Show discussion')); + // Expanded + expect(screen.getByText('Activity')).toBeInTheDocument(); + // Click hide + fireEvent.click(screen.getByLabelText('Hide discussion')); + // Collapsed again + expect(screen.getByLabelText('Show discussion')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx b/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx index 845c7e2dd..455f0b4b5 100644 --- a/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx +++ b/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx @@ -48,4 +48,37 @@ describe('SubscriptionToggle', () => { render(); expect(screen.getByRole('button')).toBeDisabled(); }); + + it('should show title for subscribed state', () => { + const sub: RecordSubscription = { recordId: '1', subscribed: true }; + render( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribed — click to unsubscribe'); + }); + + it('should show title for unsubscribed state', () => { + const sub: RecordSubscription = { recordId: '1', subscribed: false }; + render( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribe to notifications'); + }); + + it('should be disabled during loading after click', async () => { + let resolveToggle: () => void; + const onToggle = vi.fn(() => new Promise((r) => { resolveToggle = r; })); + const sub: RecordSubscription = { recordId: '1', subscribed: false }; + render(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('button')).toBeDisabled(); + resolveToggle!(); + await vi.waitFor(() => { + expect(screen.getByRole('button')).not.toBeDisabled(); + }); + }); + + it('should update aria-label based on subscribed state', () => { + const sub: RecordSubscription = { recordId: '1', subscribed: true }; + const { rerender } = render( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unsubscribe from notifications'); + rerender( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Subscribe to notifications'); + }); }); diff --git a/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx b/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx index 821cc22fa..b192870fb 100644 --- a/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx +++ b/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx @@ -105,4 +105,108 @@ describe('ThreadedReplies', () => { ); expect(container.firstChild).toBeNull(); }); + + it('should have aria-expanded=false when collapsed', () => { + render( + , + ); + const toggle = screen.getByText('2 replies').closest('button'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should have aria-expanded=true when expanded', () => { + render( + , + ); + fireEvent.click(screen.getByText('2 replies')); + const toggle = screen.getByText('2 replies').closest('button'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should render avatarUrl when provided on reply', () => { + const repliesWithAvatar: FeedItem[] = [ + { + id: 'r1', + type: 'comment', + actor: 'Bob', + actorAvatarUrl: 'https://example.com/bob.png', + body: 'With avatar', + createdAt: '2026-02-20T11:00:00Z', + parentId: 'p1', + }, + ]; + render( + , + ); + fireEvent.click(screen.getByText('1 reply')); + const img = screen.getByAltText('Bob'); + expect(img).toHaveAttribute('src', 'https://example.com/bob.png'); + }); + + it('should render first-letter avatar fallback', () => { + render( + , + ); + fireEvent.click(screen.getByText('2 replies')); + // Bob should show 'B' as fallback initial + expect(screen.getByText('B')).toBeInTheDocument(); + }); + + it('should submit reply via Ctrl+Enter', () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const input = screen.getByPlaceholderText('Reply…'); + fireEvent.change(input, { target: { value: 'Ctrl reply' } }); + fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true }); + expect(onAdd).toHaveBeenCalledWith('p1', 'Ctrl reply'); + }); + + it('should disable Send button when input is empty', () => { + const onAdd = vi.fn(); + render( + , + ); + expect(screen.getByLabelText('Send reply')).toBeDisabled(); + }); + + it('should disable input and button during reply submission', async () => { + let resolveReply: () => void; + const onAdd = vi.fn(() => new Promise((r) => { resolveReply = r; })); + render( + , + ); + const input = screen.getByPlaceholderText('Reply…') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'submitting reply' } }); + fireEvent.click(screen.getByLabelText('Send reply')); + expect(input).toBeDisabled(); + expect(screen.getByLabelText('Send reply')).toBeDisabled(); + resolveReply!(); + await vi.waitFor(() => { + expect(input).not.toBeDisabled(); + }); + }); + + it('should clear input after successful reply submission', async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const input = screen.getByPlaceholderText('Reply…') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'reply text' } }); + fireEvent.click(screen.getByLabelText('Send reply')); + await vi.waitFor(() => { + expect(input.value).toBe(''); + }); + }); + + it('should render reply body and timestamp when expanded', () => { + render( + , + ); + fireEvent.click(screen.getByText('2 replies')); + expect(screen.getByText('First reply')).toBeInTheDocument(); + expect(screen.getByText('Second reply')).toBeInTheDocument(); + }); });