Skip to content

Commit 3130e57

Browse files
authored
Merge pull request #560 from objectstack-ai/copilot/complete-roadmap-console-development
2 parents 17ca680 + 0fbaa82 commit 3130e57

18 files changed

Lines changed: 1607 additions & 85 deletions

File tree

ROADMAP_CONSOLE.md

Lines changed: 67 additions & 64 deletions
Large diffs are not rendered by default.

apps/console/objectstack.shared.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { defineStack } from '@objectstack/spec';
2+
import type { ObjectStackDefinition } from '@objectstack/spec';
23
import crmConfigImport from '@object-ui/example-crm/objectstack.config';
34
import todoConfigImport from '@object-ui/example-todo/objectstack.config';
45
import kitchenSinkConfigImport from '@object-ui/example-kitchen-sink/objectstack.config';
56
import { hotcrmObjects, hotcrmApps, mergeObjects } from '../../examples/hotcrm-bridge.js';
67

7-
const crmConfig = (crmConfigImport as any).default || crmConfigImport;
8-
const todoConfig = (todoConfigImport as any).default || todoConfigImport;
9-
const kitchenSinkConfig = (kitchenSinkConfigImport as any).default || kitchenSinkConfigImport;
8+
/** Resolve ESM default-export interop without `as any`. */
9+
type MaybeDefault<T> = T | { default: T };
10+
function resolveDefault<T>(mod: MaybeDefault<T>): T {
11+
if (mod && typeof mod === 'object' && 'default' in mod) {
12+
return (mod as { default: T }).default;
13+
}
14+
return mod as T;
15+
}
16+
17+
const crmConfig = resolveDefault<ObjectStackDefinition>(crmConfigImport);
18+
const todoConfig = resolveDefault<ObjectStackDefinition>(todoConfigImport);
19+
const kitchenSinkConfig = resolveDefault<ObjectStackDefinition>(kitchenSinkConfigImport);
1020

1121
// Patch CRM App Navigation to include Report using a supported navigation type
1222
// (type: 'url' passes schema validation while still routing correctly via React Router)
@@ -137,4 +147,4 @@ export const sharedConfig = {
137147
]
138148
};
139149

140-
export default defineStack(sharedConfig as any);
150+
export default defineStack(sharedConfig as Parameters<typeof defineStack>[0]);

apps/console/src/App.tsx

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useSearchParams } from 'react-router-dom';
2-
import { useState, useEffect, lazy, Suspense, useMemo, type ReactNode } from 'react';
2+
import { useState, useEffect, useCallback, lazy, Suspense, useMemo, type ReactNode } from 'react';
33
import { ObjectForm } from '@object-ui/plugin-form';
44
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
55
import { toast } from 'sonner';
6-
import { SchemaRendererProvider } from '@object-ui/react';
6+
import { SchemaRendererProvider, useActionRunner } from '@object-ui/react';
77
import type { ConnectionState } from './dataSource';
88
import { AuthGuard, useAuth, PreviewBanner } from '@object-ui/auth';
99
import { MetadataProvider, useMetadata } from './context/MetadataProvider';
@@ -92,6 +92,23 @@ export function AppContent() {
9292
const [refreshKey, setRefreshKey] = useState(0);
9393
const { addRecentItem } = useRecentItems();
9494

95+
// ActionRunner for CRUD dialog callbacks (Phase 2.9)
96+
const { execute: executeAction, runner } = useActionRunner();
97+
98+
useEffect(() => {
99+
runner.registerHandler('crud_success', async (action) => {
100+
setIsDialogOpen(false);
101+
setRefreshKey(k => k + 1);
102+
toast.success(action.params?.message ?? 'Record saved successfully');
103+
return { success: true, reload: true };
104+
});
105+
106+
runner.registerHandler('dialog_cancel', async () => {
107+
setIsDialogOpen(false);
108+
return { success: true };
109+
});
110+
}, [runner]);
111+
95112
// Branding is now applied by AppShell via ConsoleLayout
96113

97114
useEffect(() => {
@@ -123,6 +140,22 @@ export function AppContent() {
123140

124141
const currentObjectDef = allObjects.find((o: any) => o.name === objectNameFromPath);
125142

143+
const handleCrudSuccess = useCallback(() => {
144+
const label = currentObjectDef?.label || 'Record';
145+
executeAction({
146+
type: 'crud_success',
147+
params: {
148+
message: editingRecord
149+
? `${label} updated successfully`
150+
: `${label} created successfully`,
151+
},
152+
});
153+
}, [executeAction, editingRecord, currentObjectDef?.label]);
154+
155+
const handleDialogCancel = useCallback(() => {
156+
executeAction({ type: 'dialog_cancel' });
157+
}, [executeAction]);
158+
126159
// Track recent items on route change
127160
// Only depend on location.pathname — the sole external trigger.
128161
// All other values (activeApp, allObjects, cleanParts) are derived from
@@ -323,16 +356,8 @@ export function AppContent() {
323356
.filter(([_, f]: [string, any]) => evaluateVisibility(f.visible, expressionEvaluator))
324357
.map(([key]: [string, any]) => key))
325358
: [],
326-
onSuccess: () => {
327-
setIsDialogOpen(false);
328-
setRefreshKey(k => k + 1);
329-
toast.success(
330-
editingRecord
331-
? `${currentObjectDef?.label} updated successfully`
332-
: `${currentObjectDef?.label} created successfully`
333-
);
334-
},
335-
onCancel: () => setIsDialogOpen(false),
359+
onSuccess: handleCrudSuccess,
360+
onCancel: handleDialogCancel,
336361
showSubmit: true,
337362
showCancel: true,
338363
submitText: 'Save Record',
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* ActivityFeed
3+
*
4+
* Sidebar panel that displays recent activity items (create, update, delete,
5+
* comment). Opens as a slide-out Sheet triggered by a bell icon button.
6+
* Phase 17 L1 – local state only, no server integration.
7+
* @module
8+
*/
9+
10+
import { useState } from 'react';
11+
import {
12+
Button,
13+
Sheet,
14+
SheetContent,
15+
SheetHeader,
16+
SheetTitle,
17+
SheetTrigger,
18+
} from '@object-ui/components';
19+
import { Bell, Plus, Pencil, Trash2, MessageSquare } from 'lucide-react';
20+
21+
export interface ActivityItem {
22+
id: string;
23+
type: 'create' | 'update' | 'delete' | 'comment';
24+
objectName: string;
25+
recordId?: string;
26+
user: string;
27+
description: string;
28+
timestamp: string;
29+
}
30+
31+
export interface ActivityFeedProps {
32+
activities?: ActivityItem[];
33+
className?: string;
34+
}
35+
36+
const typeConfig: Record<
37+
ActivityItem['type'],
38+
{ icon: React.ElementType; color: string }
39+
> = {
40+
create: { icon: Plus, color: 'text-green-500' },
41+
update: { icon: Pencil, color: 'text-blue-500' },
42+
delete: { icon: Trash2, color: 'text-red-500' },
43+
comment: { icon: MessageSquare, color: 'text-amber-500' },
44+
};
45+
46+
/** Format an ISO timestamp as a relative string (e.g. "2m ago"). */
47+
function formatRelativeTime(iso: string): string {
48+
const ms = new Date(iso).getTime();
49+
if (Number.isNaN(ms)) return '';
50+
const seconds = Math.floor((Date.now() - ms) / 1000);
51+
if (seconds < 60) return `${Math.max(seconds, 0)}s ago`;
52+
const minutes = Math.floor(seconds / 60);
53+
if (minutes < 60) return `${minutes}m ago`;
54+
const hours = Math.floor(minutes / 60);
55+
if (hours < 24) return `${hours}h ago`;
56+
const days = Math.floor(hours / 24);
57+
return `${days}d ago`;
58+
}
59+
60+
export function ActivityFeed({ activities = [], className }: ActivityFeedProps) {
61+
const [open, setOpen] = useState(false);
62+
63+
return (
64+
<Sheet open={open} onOpenChange={setOpen}>
65+
<SheetTrigger asChild>
66+
<Button
67+
variant="ghost"
68+
size="icon"
69+
className={className ?? 'h-8 w-8'}
70+
aria-label="Activity feed"
71+
>
72+
<Bell className="h-4 w-4" />
73+
{activities.length > 0 && (
74+
<span className="absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-[9px] font-bold text-primary-foreground flex items-center justify-center">
75+
{activities.length > 9 ? '9+' : activities.length}
76+
</span>
77+
)}
78+
</Button>
79+
</SheetTrigger>
80+
81+
<SheetContent side="right" className="w-80 sm:w-96">
82+
<SheetHeader>
83+
<SheetTitle>Recent Activity</SheetTitle>
84+
</SheetHeader>
85+
86+
{activities.length === 0 ? (
87+
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
88+
<Bell className="h-8 w-8 opacity-40" />
89+
<p className="text-sm">No recent activity</p>
90+
</div>
91+
) : (
92+
<ul className="mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]">
93+
{activities.map((item) => {
94+
const { icon: Icon, color } = typeConfig[item.type];
95+
return (
96+
<li
97+
key={item.id}
98+
className="flex items-start gap-3 rounded-md px-2 py-2 hover:bg-muted/50 transition-colors"
99+
>
100+
<span className={`mt-0.5 shrink-0 ${color}`}>
101+
<Icon className="h-4 w-4" />
102+
</span>
103+
<div className="min-w-0 flex-1">
104+
<p className="text-sm leading-snug">{item.description}</p>
105+
<p className="mt-0.5 text-xs text-muted-foreground">
106+
{item.user} · {formatRelativeTime(item.timestamp)}
107+
</p>
108+
</div>
109+
</li>
110+
);
111+
})}
112+
</ul>
113+
)}
114+
</SheetContent>
115+
</Sheet>
116+
);
117+
}

apps/console/src/components/RecordDetailView.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
* the object field definitions.
77
*/
88

9-
import { useState, useEffect } from 'react';
9+
import { useState, useEffect, useCallback } from 'react';
1010
import { useParams } from 'react-router-dom';
1111
import { DetailView } from '@object-ui/plugin-detail';
1212
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13-
import { Database } from 'lucide-react';
13+
import { CommentThread, type Comment } from '@object-ui/collaboration';
14+
import { useAuth } from '@object-ui/auth';
15+
import { Database, MessageSquare } from 'lucide-react';
1416
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
1517
import { SkeletonDetail } from './skeletons';
1618

@@ -20,12 +22,42 @@ interface RecordDetailViewProps {
2022
onEdit: (record: any) => void;
2123
}
2224

25+
const MOCK_USER = { id: 'current-user', name: 'Demo User' };
26+
2327
export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) {
2428
const { objectName, recordId } = useParams();
2529
const { showDebug, toggleDebug } = useMetadataInspector();
30+
const { user } = useAuth();
2631
const [isLoading, setIsLoading] = useState(true);
32+
const [comments, setComments] = useState<Comment[]>([]);
2733
const objectDef = objects.find((o: any) => o.name === objectName);
2834

35+
const currentUser = user
36+
? { id: user.id, name: user.name, avatar: user.image }
37+
: MOCK_USER;
38+
39+
const handleAddComment = useCallback(
40+
(content: string, mentions: string[], parentId?: string) => {
41+
const newComment: Comment = {
42+
id: crypto.randomUUID(),
43+
author: currentUser,
44+
content,
45+
mentions,
46+
createdAt: new Date().toISOString(),
47+
parentId,
48+
};
49+
setComments(prev => [...prev, newComment]);
50+
},
51+
[currentUser],
52+
);
53+
54+
const handleDeleteComment = useCallback(
55+
(commentId: string) => {
56+
setComments(prev => prev.filter(c => c.id !== commentId));
57+
},
58+
[],
59+
);
60+
2961
useEffect(() => {
3062
// Reset loading on navigation; the actual DetailView handles data fetching
3163
setIsLoading(true);
@@ -87,6 +119,21 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
87119
dataSource={dataSource}
88120
onEdit={() => onEdit({ _id: recordId, id: recordId })}
89121
/>
122+
123+
{/* Comments & Discussion */}
124+
<div className="mt-6 border-t pt-6">
125+
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
126+
<MessageSquare className="h-4 w-4" />
127+
Comments & Discussion
128+
</h3>
129+
<CommentThread
130+
threadId={`${objectName}:${recordId}`}
131+
comments={comments}
132+
currentUser={currentUser}
133+
onAddComment={handleAddComment}
134+
onDeleteComment={handleDeleteComment}
135+
/>
136+
</div>
90137
</div>
91138
<MetadataPanel
92139
open={showDebug}

0 commit comments

Comments
 (0)