Skip to content

Commit d6769f8

Browse files
authored
Merge pull request #876 from objectstack-ai/copilot/add-chatter-panel-to-drawer
2 parents 006bde0 + e3af727 commit d6769f8

File tree

2 files changed

+237
-28
lines changed

2 files changed

+237
-28
lines changed

apps/console/src/__tests__/ObjectView.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,4 +751,42 @@ describe('ObjectView Component', () => {
751751
expect(screen.getByTestId('schema-navigation-mode')).toHaveTextContent('modal');
752752
});
753753
});
754+
755+
it('defaults to page navigation mode when no navigation config is specified', () => {
756+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
757+
758+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
759+
760+
// With default 'page' mode, NavigationOverlay renders nothing (non-overlay mode)
761+
// and the grid still renders fine
762+
expect(screen.getByTestId('object-grid')).toBeInTheDocument();
763+
});
764+
765+
it('renders RecordChatterPanel inside drawer overlay when navigation mode is drawer', async () => {
766+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
767+
// Provide recordId in URL to trigger overlay open
768+
mockSearchParams = new URLSearchParams('recordId=rec-1');
769+
const objectsWithDrawer = [
770+
{
771+
...mockObjects[0],
772+
navigation: { mode: 'drawer' as const },
773+
}
774+
];
775+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
776+
777+
const dataSourceWithFindOne = {
778+
...mockDataSource,
779+
findOne: vi.fn().mockResolvedValue({ _id: 'rec-1', id: 'rec-1', name: 'Test' }),
780+
};
781+
782+
render(<ObjectView dataSource={dataSourceWithFindOne} objects={objectsWithDrawer} onEdit={vi.fn()} />);
783+
784+
// The drawer should render a "Show Discussion" button (ChatterPanel is defaultCollapsed)
785+
await vi.waitFor(() => {
786+
const showBtn = screen.getByLabelText('Show discussion');
787+
expect(showBtn).toBeInTheDocument();
788+
// Verify items count shows (0) since items are initially empty
789+
expect(showBtn).toHaveTextContent('Show Discussion (0)');
790+
});
791+
});
754792
});

apps/console/src/components/ObjectView.tsx

Lines changed: 199 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useMemo, useState, useCallback, useEffect, type ComponentType } from 'r
1313
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
1414
import { ObjectChart } from '@object-ui/plugin-charts';
1515
import { ListView } from '@object-ui/plugin-list';
16-
import { DetailView } from '@object-ui/plugin-detail';
16+
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
1717
import { ObjectView as PluginObjectView, ViewTabBar } from '@object-ui/plugin-view';
1818
import type { ViewTabItem, AvailableViewType } from '@object-ui/plugin-view';
1919
// Import plugins for side-effects (registration)
@@ -22,7 +22,7 @@ import '@object-ui/plugin-kanban';
2222
import '@object-ui/plugin-calendar';
2323
import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@object-ui/components';
2424
import { Plus, Table as TableIcon, Settings2, Wrench, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3, ChevronRight } from 'lucide-react';
25-
import type { ListViewSchema, ViewNavigationConfig } from '@object-ui/types';
25+
import type { ListViewSchema, ViewNavigationConfig, FeedItem } from '@object-ui/types';
2626
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
2727
import { ViewConfigPanel } from './ViewConfigPanel';
2828
import { useObjectActions } from '../hooks/useObjectActions';
@@ -56,6 +56,195 @@ const AVAILABLE_VIEW_TYPES: AvailableViewType[] = [
5656
{ type: 'chart', label: 'Chart', description: 'Data visualization' },
5757
];
5858

59+
const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
60+
61+
/**
62+
* DrawerDetailContent — extracted component for NavigationOverlay content.
63+
* Needs to be a proper component (not a render prop) so it can use hooks
64+
* for data fetching, comment handling, etc.
65+
*/
66+
function DrawerDetailContent({ objectDef, recordId, dataSource, onEdit }: {
67+
objectDef: any;
68+
recordId: string;
69+
dataSource: any;
70+
onEdit: (record: any) => void;
71+
}) {
72+
const { user } = useAuth();
73+
const currentUser = user
74+
? { id: user.id, name: user.name, avatar: user.image }
75+
: FALLBACK_USER;
76+
77+
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
78+
79+
// Fetch persisted comments from API
80+
useEffect(() => {
81+
if (!dataSource || !objectDef?.name || !recordId) return;
82+
const threadId = `${objectDef.name}:${recordId}`;
83+
dataSource.find('sys_comment', { $filter: `threadId eq '${threadId}'`, $orderby: 'createdAt asc' })
84+
.then((res: any) => {
85+
if (res.data?.length) {
86+
setFeedItems(res.data.map((c: any) => ({
87+
id: c.id,
88+
type: 'comment' as const,
89+
actor: c.author?.name ?? 'Unknown',
90+
actorAvatarUrl: c.author?.avatar,
91+
body: c.content,
92+
createdAt: c.createdAt,
93+
updatedAt: c.updatedAt,
94+
parentId: c.parentId,
95+
reactions: c.reactions
96+
? Object.entries(c.reactions as Record<string, string[]>).map(([emoji, userIds]) => ({
97+
emoji,
98+
count: userIds.length,
99+
reacted: userIds.includes(currentUser.id),
100+
}))
101+
: undefined,
102+
})));
103+
}
104+
})
105+
.catch(() => {});
106+
}, [dataSource, objectDef?.name, recordId, currentUser.id]);
107+
108+
const handleAddComment = useCallback(
109+
async (text: string) => {
110+
const newItem: FeedItem = {
111+
id: crypto.randomUUID(),
112+
type: 'comment',
113+
actor: currentUser.name,
114+
actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined,
115+
body: text,
116+
createdAt: new Date().toISOString(),
117+
};
118+
setFeedItems(prev => [...prev, newItem]);
119+
if (dataSource) {
120+
const threadId = `${objectDef.name}:${recordId}`;
121+
dataSource.create('sys_comment', {
122+
id: newItem.id,
123+
threadId,
124+
author: currentUser,
125+
content: text,
126+
mentions: [],
127+
createdAt: newItem.createdAt,
128+
}).catch(() => {});
129+
}
130+
},
131+
[currentUser, dataSource, objectDef?.name, recordId],
132+
);
133+
134+
const handleAddReply = useCallback(
135+
async (parentId: string | number, text: string) => {
136+
const newItem: FeedItem = {
137+
id: crypto.randomUUID(),
138+
type: 'comment',
139+
actor: currentUser.name,
140+
actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined,
141+
body: text,
142+
createdAt: new Date().toISOString(),
143+
parentId,
144+
};
145+
setFeedItems(prev => {
146+
const updated = [...prev, newItem];
147+
return updated.map(item =>
148+
item.id === parentId
149+
? { ...item, replyCount: (item.replyCount ?? 0) + 1 }
150+
: item
151+
);
152+
});
153+
if (dataSource) {
154+
const threadId = `${objectDef.name}:${recordId}`;
155+
dataSource.create('sys_comment', {
156+
id: newItem.id,
157+
threadId,
158+
author: currentUser,
159+
content: text,
160+
mentions: [],
161+
createdAt: newItem.createdAt,
162+
parentId,
163+
}).catch(() => {});
164+
}
165+
},
166+
[currentUser, dataSource, objectDef?.name, recordId],
167+
);
168+
169+
const handleToggleReaction = useCallback(
170+
(itemId: string | number, emoji: string) => {
171+
setFeedItems(prev => prev.map(item => {
172+
if (item.id !== itemId) return item;
173+
const reactions = [...(item.reactions ?? [])];
174+
const idx = reactions.findIndex(r => r.emoji === emoji);
175+
if (idx >= 0) {
176+
const r = reactions[idx];
177+
if (r.reacted) {
178+
if (r.count <= 1) {
179+
reactions.splice(idx, 1);
180+
} else {
181+
reactions[idx] = { ...r, count: r.count - 1, reacted: false };
182+
}
183+
} else {
184+
reactions[idx] = { ...r, count: r.count + 1, reacted: true };
185+
}
186+
} else {
187+
reactions.push({ emoji, count: 1, reacted: true });
188+
}
189+
const updated = { ...item, reactions };
190+
if (dataSource) {
191+
dataSource.update('sys_comment', String(itemId), {
192+
$toggleReaction: { emoji, userId: currentUser.id },
193+
}).catch(() => {});
194+
}
195+
return updated;
196+
}));
197+
},
198+
[currentUser.id, dataSource],
199+
);
200+
201+
return (
202+
<div className="h-full bg-background overflow-auto p-3 sm:p-4 lg:p-6">
203+
<DetailView
204+
schema={{
205+
type: 'detail-view',
206+
objectName: objectDef.name,
207+
resourceId: recordId,
208+
showBack: false,
209+
showEdit: true,
210+
title: objectDef.label,
211+
sections: [
212+
{
213+
title: 'Details',
214+
fields: Object.keys(objectDef.fields || {}).map((key: string) => ({
215+
name: key,
216+
label: objectDef.fields[key].label || key,
217+
type: objectDef.fields[key].type || 'text'
218+
})),
219+
}
220+
]
221+
}}
222+
dataSource={dataSource}
223+
onEdit={() => onEdit({ _id: recordId, id: recordId })}
224+
/>
225+
{/* Discussion panel — collapsible in drawer/overlay mode */}
226+
<div className="mt-6 border-t pt-6">
227+
<RecordChatterPanel
228+
config={{
229+
position: 'bottom',
230+
collapsible: true,
231+
defaultCollapsed: true,
232+
feed: {
233+
enableReactions: true,
234+
enableThreading: true,
235+
showCommentInput: true,
236+
},
237+
}}
238+
items={feedItems}
239+
onAddComment={handleAddComment}
240+
onAddReply={handleAddReply}
241+
onToggleReaction={handleToggleReaction}
242+
/>
243+
</div>
244+
</div>
245+
);
246+
}
247+
59248
export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
60249
const navigate = useNavigate();
61250
const { objectName, viewId } = useParams();
@@ -230,8 +419,8 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
230419
}, [dataSource, objectDef.name, refreshKey]);
231420

232421
// Navigation overlay for record detail (supports drawer/modal/split/popover via config)
233-
// Priority: activeView.navigation > objectDef.navigation > default drawer
234-
const detailNavigation: ViewNavigationConfig = activeView?.navigation ?? objectDef.navigation ?? { mode: 'drawer' };
422+
// Priority: activeView.navigation > objectDef.navigation > default page
423+
const detailNavigation: ViewNavigationConfig = activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' };
235424
const drawerRecordId = searchParams.get('recordId');
236425
const navOverlay = useNavigationOverlay({
237426
navigation: detailNavigation,
@@ -630,30 +819,12 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
630819
{(record: Record<string, unknown>) => {
631820
const recordId = (record._id || record.id) as string;
632821
return (
633-
<div className="h-full bg-background overflow-auto p-3 sm:p-4 lg:p-6">
634-
<DetailView
635-
schema={{
636-
type: 'detail-view',
637-
objectName: objectDef.name,
638-
resourceId: recordId,
639-
showBack: false,
640-
showEdit: true,
641-
title: objectDef.label,
642-
sections: [
643-
{
644-
title: 'Details',
645-
fields: Object.keys(objectDef.fields || {}).map((key: string) => ({
646-
name: key,
647-
label: objectDef.fields[key].label || key,
648-
type: objectDef.fields[key].type || 'text'
649-
})),
650-
}
651-
]
652-
}}
653-
dataSource={dataSource}
654-
onEdit={() => onEdit({ _id: recordId, id: recordId })}
655-
/>
656-
</div>
822+
<DrawerDetailContent
823+
objectDef={objectDef}
824+
recordId={recordId}
825+
dataSource={dataSource}
826+
onEdit={onEdit}
827+
/>
657828
);
658829
}}
659830
</NavigationOverlay>

0 commit comments

Comments
 (0)