Skip to content

Commit e2b35f9

Browse files
authored
Merge pull request #728 from objectstack-ai/copilot/add-unit-tests-console-integration
2 parents 0a007ab + 54f42c4 commit e2b35f9

8 files changed

Lines changed: 623 additions & 50 deletions

File tree

ROADMAP.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
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.
1515

16-
**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.
16+
**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.
1717

1818
**What Remains:** The gap to **Airtable-level UX** is primarily in:
1919
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
140140
- [x] `SubscriptionToggle` — bell notification toggle
141141
- [x] `ReactionPicker` — emoji reaction selector
142142
- [x] `ThreadedReplies` — collapsible comment reply threading
143-
143+
- [x] Comprehensive unit tests for all 6 core Feed/Chatter components (96 tests)
144+
- [x] Console `RecordDetailView` integration: `CommentThread``RecordChatterPanel` with `FeedItem[]` data model
145+
- [ ] 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)
144146
### P1.6 Console — Automation
145147

146148
> **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
496498
| **AppShell Renderer** | ✅ Complete | Sidebar + nav tree from `AppSchema` JSON | Console renders from spec JSON |
497499
| **Designer Interaction** | Phase 2 (most complete) | ViewDesigner + DataModelDesigner drag/undo | Manual UX testing |
498500
| **Build Status** | 42/42 pass | 42/42 pass | `pnpm build` |
499-
| **Test Count** | 5,070+ | 5,500+ | `pnpm test` summary |
501+
| **Test Count** | 5,070+ | 5,618+ | `pnpm test` summary |
500502
| **Test Coverage** | 90%+ | 90%+ | `pnpm test:coverage` |
501503
| **Storybook Stories** | 78 | 91+ (1 per component) | Story file count |
502504
| **Console i18n** | 100% | 100% | No hardcoded strings |

apps/console/src/components/RecordDetailView.tsx

Lines changed: 111 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88

99
import { useState, useEffect, useCallback } from 'react';
1010
import { useParams } from 'react-router-dom';
11-
import { DetailView } from '@object-ui/plugin-detail';
11+
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
1212
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13-
import { CommentThread, PresenceAvatars, type Comment, type PresenceUser } from '@object-ui/collaboration';
13+
import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration';
1414
import { useAuth } from '@object-ui/auth';
15-
import { Database, MessageSquare, Users } from 'lucide-react';
15+
import { Database, Users } from 'lucide-react';
1616
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
1717
import { SkeletonDetail } from './skeletons';
18-
import type { DetailViewSchema } from '@object-ui/types';
18+
import type { DetailViewSchema, FeedItem } from '@object-ui/types';
1919

2020
interface RecordDetailViewProps {
2121
dataSource: any;
@@ -30,8 +30,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
3030
const { showDebug, toggleDebug } = useMetadataInspector();
3131
const { user } = useAuth();
3232
const [isLoading, setIsLoading] = useState(true);
33-
const [comments, setComments] = useState<Comment[]>([]);
34-
const [threadResolved, setThreadResolved] = useState(false);
33+
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
3534
const [recordViewers, setRecordViewers] = useState<PresenceUser[]>([]);
3635
const objectDef = objects.find((o: any) => o.name === objectName);
3736

@@ -49,58 +48,122 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
4948
.then((res: any) => { if (res.data?.length) setRecordViewers(res.data); })
5049
.catch(() => {});
5150

52-
// Fetch persisted comments
51+
// Fetch persisted comments and map to FeedItem[]
5352
dataSource.find('sys_comment', { $filter: `threadId eq '${threadId}'`, $orderby: 'createdAt asc' })
54-
.then((res: any) => { if (res.data?.length) setComments(res.data); })
53+
.then((res: any) => {
54+
if (res.data?.length) {
55+
setFeedItems(res.data.map((c: any) => ({
56+
id: c.id,
57+
type: 'comment' as const,
58+
actor: c.author?.name ?? 'Unknown',
59+
actorAvatarUrl: c.author?.avatar,
60+
body: c.content,
61+
createdAt: c.createdAt,
62+
updatedAt: c.updatedAt,
63+
parentId: c.parentId,
64+
reactions: c.reactions
65+
? Object.entries(c.reactions as Record<string, string[]>).map(([emoji, userIds]) => ({
66+
emoji,
67+
count: userIds.length,
68+
reacted: userIds.includes(currentUser.id),
69+
}))
70+
: undefined,
71+
})));
72+
}
73+
})
5574
.catch(() => {});
56-
}, [dataSource, objectName, recordId]);
75+
}, [dataSource, objectName, recordId, currentUser]);
5776

5877
const handleAddComment = useCallback(
59-
async (content: string, mentions: string[], parentId?: string) => {
60-
const newComment: Comment = {
78+
async (text: string) => {
79+
const newItem: FeedItem = {
6180
id: crypto.randomUUID(),
62-
author: currentUser,
63-
content,
64-
mentions,
81+
type: 'comment',
82+
actor: currentUser.name,
83+
actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined,
84+
body: text,
6585
createdAt: new Date().toISOString(),
66-
parentId,
6786
};
68-
setComments(prev => [...prev, newComment]);
87+
setFeedItems(prev => [...prev, newItem]);
6988
// Persist to backend
7089
if (dataSource) {
7190
const threadId = `${objectName}:${recordId}`;
72-
dataSource.create('sys_comment', { ...newComment, threadId }).catch(() => {});
91+
dataSource.create('sys_comment', {
92+
id: newItem.id,
93+
threadId,
94+
author: currentUser,
95+
content: text,
96+
mentions: [],
97+
createdAt: newItem.createdAt,
98+
}).catch(() => {});
7399
}
74100
},
75101
[currentUser, dataSource, objectName, recordId],
76102
);
77103

78-
const handleDeleteComment = useCallback(
79-
async (commentId: string) => {
80-
setComments(prev => prev.filter(c => c.id !== commentId));
104+
const handleAddReply = useCallback(
105+
async (parentId: string | number, text: string) => {
106+
const newItem: FeedItem = {
107+
id: crypto.randomUUID(),
108+
type: 'comment',
109+
actor: currentUser.name,
110+
actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined,
111+
body: text,
112+
createdAt: new Date().toISOString(),
113+
parentId,
114+
};
115+
setFeedItems(prev => {
116+
const updated = [...prev, newItem];
117+
// Increment replyCount on parent
118+
return updated.map(item =>
119+
item.id === parentId
120+
? { ...item, replyCount: (item.replyCount ?? 0) + 1 }
121+
: item
122+
);
123+
});
81124
if (dataSource) {
82-
dataSource.delete('sys_comment', commentId).catch(() => {});
125+
const threadId = `${objectName}:${recordId}`;
126+
dataSource.create('sys_comment', {
127+
id: newItem.id,
128+
threadId,
129+
author: currentUser,
130+
content: text,
131+
mentions: [],
132+
createdAt: newItem.createdAt,
133+
parentId,
134+
}).catch(() => {});
83135
}
84136
},
85-
[dataSource],
137+
[currentUser, dataSource, objectName, recordId],
86138
);
87139

88-
const handleReaction = useCallback(
89-
(commentId: string, emoji: string) => {
90-
setComments(prev => prev.map(c => {
91-
if (c.id !== commentId) return c;
92-
const reactions = { ...(c.reactions || {}) };
93-
const userIds = reactions[emoji] || [];
94-
if (userIds.includes(currentUser.id)) {
95-
reactions[emoji] = userIds.filter(id => id !== currentUser.id);
96-
if (reactions[emoji].length === 0) delete reactions[emoji];
140+
const handleToggleReaction = useCallback(
141+
(itemId: string | number, emoji: string) => {
142+
setFeedItems(prev => prev.map(item => {
143+
if (item.id !== itemId) return item;
144+
const reactions = [...(item.reactions ?? [])];
145+
const idx = reactions.findIndex(r => r.emoji === emoji);
146+
if (idx >= 0) {
147+
const r = reactions[idx];
148+
if (r.reacted) {
149+
// Remove user's reaction
150+
if (r.count <= 1) {
151+
reactions.splice(idx, 1);
152+
} else {
153+
reactions[idx] = { ...r, count: r.count - 1, reacted: false };
154+
}
155+
} else {
156+
reactions[idx] = { ...r, count: r.count + 1, reacted: true };
157+
}
97158
} else {
98-
reactions[emoji] = [...userIds, currentUser.id];
159+
reactions.push({ emoji, count: 1, reacted: true });
99160
}
100-
const updated = { ...c, reactions };
101-
// Persist reaction update to backend
161+
const updated = { ...item, reactions };
162+
// Persist reaction toggle to backend
102163
if (dataSource) {
103-
dataSource.update('sys_comment', commentId, { reactions }).catch(() => {});
164+
dataSource.update('sys_comment', String(itemId), {
165+
$toggleReaction: { emoji, userId: currentUser.id },
166+
}).catch(() => {});
104167
}
105168
return updated;
106169
}));
@@ -186,19 +249,20 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
186249

187250
{/* Comments & Discussion */}
188251
<div className="mt-6 border-t pt-6">
189-
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
190-
<MessageSquare className="h-4 w-4" />
191-
Comments & Discussion
192-
</h3>
193-
<CommentThread
194-
threadId={`${objectName}:${recordId}`}
195-
comments={comments}
196-
currentUser={currentUser}
252+
<RecordChatterPanel
253+
config={{
254+
position: 'bottom',
255+
collapsible: false,
256+
feed: {
257+
enableReactions: true,
258+
enableThreading: true,
259+
showCommentInput: true,
260+
},
261+
}}
262+
items={feedItems}
197263
onAddComment={handleAddComment}
198-
onDeleteComment={handleDeleteComment}
199-
onReaction={handleReaction}
200-
resolved={threadResolved}
201-
onResolve={setThreadResolved}
264+
onAddReply={handleAddReply}
265+
onToggleReaction={handleToggleReaction}
202266
/>
203267
</div>
204268
</div>

packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,51 @@ describe('FieldChangeItem', () => {
6969
const { container } = render(<FieldChangeItem change={change} className="custom-class" />);
7070
expect(container.firstChild).toHaveClass('custom-class');
7171
});
72+
73+
it('should render arrow icon between old and new values', () => {
74+
const change: FieldChangeEntry = {
75+
field: 'status',
76+
fieldLabel: 'Status',
77+
oldValue: 'Open',
78+
newValue: 'Closed',
79+
};
80+
const { container } = render(<FieldChangeItem change={change} />);
81+
// ArrowRight renders as an SVG with lucide classes
82+
const svg = container.querySelector('svg');
83+
expect(svg).toBeInTheDocument();
84+
});
85+
86+
it('should render old value with line-through style', () => {
87+
const change: FieldChangeEntry = {
88+
field: 'status',
89+
fieldLabel: 'Status',
90+
oldDisplayValue: 'Open',
91+
newDisplayValue: 'Closed',
92+
};
93+
render(<FieldChangeItem change={change} />);
94+
const oldEl = screen.getByText('Open');
95+
expect(oldEl).toHaveClass('line-through');
96+
});
97+
98+
it('should use fieldLabel priority over auto-generated label', () => {
99+
const change: FieldChangeEntry = {
100+
field: 'first_name',
101+
fieldLabel: 'Custom Label',
102+
oldValue: 'A',
103+
newValue: 'B',
104+
};
105+
render(<FieldChangeItem change={change} />);
106+
expect(screen.getByText('Custom Label')).toBeInTheDocument();
107+
expect(screen.queryByText('First name')).not.toBeInTheDocument();
108+
});
109+
110+
it('should show (empty) for both null old and new values', () => {
111+
const change: FieldChangeEntry = {
112+
field: 'notes',
113+
fieldLabel: 'Notes',
114+
};
115+
render(<FieldChangeItem change={change} />);
116+
const emptyTexts = screen.getAllByText('(empty)');
117+
expect(emptyTexts).toHaveLength(2);
118+
});
72119
});

packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,48 @@ describe('ReactionPicker', () => {
6666
fireEvent.click(options[0]);
6767
expect(onToggle).toHaveBeenCalledWith('👍');
6868
});
69+
70+
it('should disable reaction buttons when no onToggleReaction', () => {
71+
render(<ReactionPicker reactions={mockReactions} />);
72+
const thumbsBtn = screen.getByLabelText(/👍 3/);
73+
expect(thumbsBtn).toBeDisabled();
74+
const heartBtn = screen.getByLabelText(/ 1/);
75+
expect(heartBtn).toBeDisabled();
76+
});
77+
78+
it('should render custom emojiOptions', () => {
79+
const onToggle = vi.fn();
80+
const customEmoji = ['🚀', '🔥', '✅'];
81+
render(
82+
<ReactionPicker reactions={[]} onToggleReaction={onToggle} emojiOptions={customEmoji} />,
83+
);
84+
fireEvent.click(screen.getByLabelText('Add reaction'));
85+
const options = screen.getAllByRole('option');
86+
expect(options).toHaveLength(3);
87+
expect(options[0]).toHaveTextContent('🚀');
88+
expect(options[1]).toHaveTextContent('🔥');
89+
expect(options[2]).toHaveTextContent('✅');
90+
});
91+
92+
it('should include emoji and count in aria-label', () => {
93+
render(<ReactionPicker reactions={mockReactions} />);
94+
expect(screen.getByLabelText('👍 3 reactions')).toBeInTheDocument();
95+
expect(screen.getByLabelText('❤️ 1 reaction')).toBeInTheDocument();
96+
});
97+
98+
it('should show non-reacted emoji with bg-muted style', () => {
99+
render(<ReactionPicker reactions={mockReactions} />);
100+
const heart = screen.getByLabelText(/ 1/);
101+
expect(heart).toHaveClass('bg-muted');
102+
});
103+
104+
it('should close picker after selecting emoji', () => {
105+
const onToggle = vi.fn();
106+
render(<ReactionPicker reactions={[]} onToggleReaction={onToggle} />);
107+
fireEvent.click(screen.getByLabelText('Add reaction'));
108+
expect(screen.getByRole('listbox', { name: 'Emoji picker' })).toBeInTheDocument();
109+
const options = screen.getAllByRole('option');
110+
fireEvent.click(options[0]);
111+
expect(screen.queryByRole('listbox', { name: 'Emoji picker' })).not.toBeInTheDocument();
112+
});
69113
});

0 commit comments

Comments
 (0)