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: 5 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
158 changes: 111 additions & 47 deletions apps/console/src/components/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Comment[]>([]);
const [threadResolved, setThreadResolved] = useState(false);
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
const [recordViewers, setRecordViewers] = useState<PresenceUser[]>([]);
const objectDef = objects.find((o: any) => o.name === objectName);

Expand All @@ -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<string, string[]>).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;
}));
Expand Down Expand Up @@ -186,19 +249,20 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi

{/* Comments & Discussion */}
<div className="mt-6 border-t pt-6">
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Comments & Discussion
</h3>
<CommentThread
threadId={`${objectName}:${recordId}`}
comments={comments}
currentUser={currentUser}
<RecordChatterPanel
config={{
position: 'bottom',
collapsible: false,
feed: {
enableReactions: true,
enableThreading: true,
showCommentInput: true,
},
}}
items={feedItems}
onAddComment={handleAddComment}
onDeleteComment={handleDeleteComment}
onReaction={handleReaction}
resolved={threadResolved}
onResolve={setThreadResolved}
onAddReply={handleAddReply}
onToggleReaction={handleToggleReaction}
/>
</div>
</div>
Expand Down
47 changes: 47 additions & 0 deletions packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,51 @@ describe('FieldChangeItem', () => {
const { container } = render(<FieldChangeItem change={change} className="custom-class" />);
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(<FieldChangeItem change={change} />);
// 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(<FieldChangeItem change={change} />);
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(<FieldChangeItem change={change} />);
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(<FieldChangeItem change={change} />);
const emptyTexts = screen.getAllByText('(empty)');
expect(emptyTexts).toHaveLength(2);
});
});
44 changes: 44 additions & 0 deletions packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,48 @@ describe('ReactionPicker', () => {
fireEvent.click(options[0]);
expect(onToggle).toHaveBeenCalledWith('👍');
});

it('should disable reaction buttons when no onToggleReaction', () => {
render(<ReactionPicker reactions={mockReactions} />);
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(
<ReactionPicker reactions={[]} onToggleReaction={onToggle} emojiOptions={customEmoji} />,
);
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(<ReactionPicker reactions={mockReactions} />);
expect(screen.getByLabelText('👍 3 reactions')).toBeInTheDocument();
expect(screen.getByLabelText('❤️ 1 reaction')).toBeInTheDocument();
});

it('should show non-reacted emoji with bg-muted style', () => {
render(<ReactionPicker reactions={mockReactions} />);
const heart = screen.getByLabelText(/❤️ 1/);
expect(heart).toHaveClass('bg-muted');
});

it('should close picker after selecting emoji', () => {
const onToggle = vi.fn();
render(<ReactionPicker reactions={[]} onToggleReaction={onToggle} />);
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();
});
});
Loading
Loading