Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 4 additions & 2 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,6 +140,8 @@ 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

Comment thread
hotlong marked this conversation as resolved.
Outdated
### P1.6 Console — Automation

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
156 changes: 110 additions & 46 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]);
Comment thread
hotlong marked this conversation as resolved.
Outdated

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