Skip to content

Commit ae180d4

Browse files
authored
Merge pull request #567 from objectstack-ai/copilot/complete-roadmap-console-development-another-one
2 parents 7fd5d6b + f6cd381 commit ae180d4

19 files changed

Lines changed: 1519 additions & 126 deletions

File tree

ROADMAP_CONSOLE.md

Lines changed: 78 additions & 76 deletions
Large diffs are not rendered by default.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { render, screen, fireEvent } from '@testing-library/react';
11+
import '@testing-library/jest-dom';
12+
import React from 'react';
13+
14+
// Mock UI components – Sheet always renders all children so we can test content
15+
vi.mock('@object-ui/components', () => ({
16+
Button: ({ children, onClick, ...props }: any) => (
17+
<button onClick={onClick} {...props}>{children}</button>
18+
),
19+
Badge: ({ children, onClick, variant, ...props }: any) => (
20+
<span data-variant={variant} onClick={onClick} role="button" {...props}>{children}</span>
21+
),
22+
Sheet: ({ children }: any) => <div data-testid="sheet">{children}</div>,
23+
SheetContent: ({ children }: any) => <div data-testid="sheet-content">{children}</div>,
24+
SheetHeader: ({ children }: any) => <div>{children}</div>,
25+
SheetTitle: ({ children, className }: any) => <div className={className}>{children}</div>,
26+
SheetTrigger: ({ children }: any) => <>{children}</>,
27+
}));
28+
29+
vi.mock('lucide-react', () => ({
30+
Bell: () => <span data-testid="bell-icon">🔔</span>,
31+
Plus: () => <span>+</span>,
32+
Pencil: () => <span></span>,
33+
Trash2: () => <span>🗑</span>,
34+
MessageSquare: () => <span>💬</span>,
35+
Filter: () => <span>🔍</span>,
36+
}));
37+
38+
import { ActivityFeed, type ActivityItem } from '../components/ActivityFeed';
39+
40+
const sampleActivities: ActivityItem[] = [
41+
{ id: '1', type: 'create', objectName: 'Lead', user: 'Alice', description: 'Created lead Alpha', timestamp: new Date().toISOString() },
42+
{ id: '2', type: 'update', objectName: 'Contact', user: 'Bob', description: 'Updated contact Beta', timestamp: new Date().toISOString() },
43+
{ id: '3', type: 'delete', objectName: 'Task', user: 'Charlie', description: 'Deleted task Gamma', timestamp: new Date().toISOString() },
44+
{ id: '4', type: 'comment', objectName: 'Lead', user: 'Diana', description: 'Commented on Delta', timestamp: new Date().toISOString() },
45+
];
46+
47+
describe('ActivityFeed filters', () => {
48+
it('renders all activities by default', () => {
49+
// Sheet mock renders all children unconditionally so content is visible
50+
render(<ActivityFeed activities={sampleActivities} />);
51+
52+
expect(screen.getByText('Created lead Alpha')).toBeInTheDocument();
53+
expect(screen.getByText('Updated contact Beta')).toBeInTheDocument();
54+
expect(screen.getByText('Deleted task Gamma')).toBeInTheDocument();
55+
expect(screen.getByText('Commented on Delta')).toBeInTheDocument();
56+
});
57+
58+
it('toggling a filter type hides matching activities', () => {
59+
render(<ActivityFeed activities={sampleActivities} />);
60+
61+
// Open the filter panel
62+
const filterBtn = screen.getByText('Filter');
63+
fireEvent.click(filterBtn);
64+
65+
// Toggle off the "create" filter badge
66+
const createBadge = screen.getByText('create');
67+
fireEvent.click(createBadge);
68+
69+
// The "create" activity should be hidden
70+
expect(screen.queryByText('Created lead Alpha')).not.toBeInTheDocument();
71+
72+
// Other activities should remain
73+
expect(screen.getByText('Updated contact Beta')).toBeInTheDocument();
74+
expect(screen.getByText('Deleted task Gamma')).toBeInTheDocument();
75+
expect(screen.getByText('Commented on Delta')).toBeInTheDocument();
76+
});
77+
78+
it('shows all filter toggle badges', () => {
79+
render(<ActivityFeed activities={sampleActivities} />);
80+
81+
// Open the filter panel
82+
const filterBtn = screen.getByText('Filter');
83+
fireEvent.click(filterBtn);
84+
85+
expect(screen.getByText('create')).toBeInTheDocument();
86+
expect(screen.getByText('update')).toBeInTheDocument();
87+
expect(screen.getByText('delete')).toBeInTheDocument();
88+
expect(screen.getByText('comment')).toBeInTheDocument();
89+
});
90+
});

apps/console/src/components/ActivityFeed.tsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
import { useState } from 'react';
1111
import {
1212
Button,
13+
Badge,
1314
Sheet,
1415
SheetContent,
1516
SheetHeader,
1617
SheetTitle,
1718
SheetTrigger,
1819
} from '@object-ui/components';
19-
import { Bell, Plus, Pencil, Trash2, MessageSquare } from 'lucide-react';
20+
import { Bell, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
2021

2122
export interface ActivityItem {
2223
id: string;
@@ -59,6 +60,19 @@ function formatRelativeTime(iso: string): string {
5960

6061
export function ActivityFeed({ activities = [], className }: ActivityFeedProps) {
6162
const [open, setOpen] = useState(false);
63+
const [showFilters, setShowFilters] = useState(false);
64+
const [notificationPreferences, setNotificationPreferences] = useState<Record<ActivityItem['type'], boolean>>({
65+
create: true,
66+
update: true,
67+
delete: true,
68+
comment: true,
69+
});
70+
71+
const togglePreference = (type: ActivityItem['type']) => {
72+
setNotificationPreferences(prev => ({ ...prev, [type]: !prev[type] }));
73+
};
74+
75+
const filteredActivities = activities.filter(a => notificationPreferences[a.type]);
6276

6377
return (
6478
<Sheet open={open} onOpenChange={setOpen}>
@@ -80,17 +94,48 @@ export function ActivityFeed({ activities = [], className }: ActivityFeedProps)
8094

8195
<SheetContent side="right" className="w-80 sm:w-96">
8296
<SheetHeader>
83-
<SheetTitle>Recent Activity</SheetTitle>
97+
<SheetTitle className="flex items-center justify-between">
98+
Recent Activity
99+
<Button
100+
variant={showFilters ? 'secondary' : 'ghost'}
101+
size="sm"
102+
className="h-7 px-2"
103+
onClick={() => setShowFilters(!showFilters)}
104+
>
105+
<Filter className="h-3.5 w-3.5 mr-1" />
106+
Filter
107+
</Button>
108+
</SheetTitle>
84109
</SheetHeader>
85110

86-
{activities.length === 0 ? (
111+
{showFilters && (
112+
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
113+
{(Object.keys(typeConfig) as ActivityItem['type'][]).map(type => {
114+
const { icon: Icon, color } = typeConfig[type];
115+
const active = notificationPreferences[type];
116+
return (
117+
<Badge
118+
key={type}
119+
variant={active ? 'default' : 'outline'}
120+
className="cursor-pointer select-none gap-1 capitalize"
121+
onClick={() => togglePreference(type)}
122+
>
123+
<Icon className={`h-3 w-3 ${active ? '' : color}`} />
124+
{type}
125+
</Badge>
126+
);
127+
})}
128+
</div>
129+
)}
130+
131+
{filteredActivities.length === 0 ? (
87132
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
88133
<Bell className="h-8 w-8 opacity-40" />
89134
<p className="text-sm">No recent activity</p>
90135
</div>
91136
) : (
92137
<ul className="mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]">
93-
{activities.map((item) => {
138+
{filteredActivities.map((item) => {
94139
const { icon: Icon, color } = typeConfig[item.type];
95140
return (
96141
<li

apps/console/src/components/AppHeader.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration';
3131
import { ModeToggle } from './mode-toggle';
3232
import { LocaleSwitcher } from './LocaleSwitcher';
3333
import { ConnectionStatus } from './ConnectionStatus';
34-
import { ActivityFeed } from './ActivityFeed';
34+
import { ActivityFeed, type ActivityItem } from './ActivityFeed';
3535
import type { ConnectionState } from '../dataSource';
3636

3737
/** Convert a slug like "crm_dashboard" or "audit-log" to "Crm Dashboard" / "Audit Log" */
@@ -48,6 +48,15 @@ const MOCK_PRESENCE_USERS: PresenceUser[] = [
4848
{ userId: 'u3', userName: 'Carol Li', color: '#e74c3c', status: 'active', lastActivity: new Date().toISOString() },
4949
];
5050

51+
// Demo activity items for local/mock mode
52+
const DEMO_ACTIVITIES: ActivityItem[] = [
53+
{ id: 'a1', type: 'create', objectName: 'Contact', recordId: 'c-101', user: 'Alice Chen', description: 'Created new contact "Acme Corp"', timestamp: new Date(Date.now() - 2 * 60 * 1000).toISOString() },
54+
{ id: 'a2', type: 'update', objectName: 'Deal', recordId: 'd-42', user: 'Bob Smith', description: 'Updated deal stage to "Negotiation"', timestamp: new Date(Date.now() - 15 * 60 * 1000).toISOString() },
55+
{ id: 'a3', type: 'comment', objectName: 'Task', recordId: 't-88', user: 'Carol Li', description: 'Commented on task "Q4 Review"', timestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString() },
56+
{ id: 'a4', type: 'delete', objectName: 'Lead', recordId: 'l-7', user: 'Alice Chen', description: 'Deleted duplicate lead "Test Lead"', timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() },
57+
{ id: 'a5', type: 'update', objectName: 'Contact', recordId: 'c-55', user: 'Bob Smith', description: 'Updated email for "Jane Doe"', timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString() },
58+
];
59+
5160
export function AppHeader({ appName, objects, connectionState, presenceUsers }: { appName: string, objects: any[], connectionState?: ConnectionState, presenceUsers?: PresenceUser[] }) {
5261
const location = useLocation();
5362
const params = useParams();
@@ -218,7 +227,7 @@ export function AppHeader({ appName, objects, connectionState, presenceUsers }:
218227

219228
{/* Activity Feed */}
220229
<div className="hidden sm:flex shrink-0 relative">
221-
<ActivityFeed />
230+
<ActivityFeed activities={DEMO_ACTIVITIES} />
222231
</div>
223232

224233
{/* Help */}

apps/console/src/components/ObjectView.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataI
2626
import { useObjectActions } from '../hooks/useObjectActions';
2727
import { useObjectTranslation } from '@object-ui/i18n';
2828
import { usePermissions } from '@object-ui/permissions';
29-
import { useRealtimeSubscription } from '@object-ui/collaboration';
29+
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
3030

3131
/** Map view types to Lucide icons (Airtable-style) */
3232
const VIEW_TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
@@ -122,11 +122,19 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
122122
channel: `object:${objectDef.name}`,
123123
});
124124

125+
// Conflict resolution: detect and queue conflicts on reconnection
126+
const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user';
127+
const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId);
128+
125129
useEffect(() => {
126130
if (realtimeMessage) {
131+
// On reconnection data change, auto-resolve with server-wins strategy
132+
if (hasConflicts) {
133+
resolveAllConflicts('remote');
134+
}
127135
setRefreshKey(k => k + 1);
128136
}
129-
}, [realtimeMessage]);
137+
}, [realtimeMessage, hasConflicts, resolveAllConflicts]);
130138

131139
// Drawer Logic
132140
const drawerRecordId = searchParams.get('recordId');

apps/console/src/components/RecordDetailView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
3636
const { user } = useAuth();
3737
const [isLoading, setIsLoading] = useState(true);
3838
const [comments, setComments] = useState<Comment[]>([]);
39+
const [threadResolved, setThreadResolved] = useState(false);
3940
const objectDef = objects.find((o: any) => o.name === objectName);
4041

4142
const currentUser = user
@@ -164,6 +165,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
164165
onAddComment={handleAddComment}
165166
onDeleteComment={handleDeleteComment}
166167
onReaction={handleReaction}
168+
resolved={threadResolved}
169+
onResolve={setThreadResolved}
167170
/>
168171
</div>
169172
</div>

packages/core/src/actions/UndoManager.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export interface UndoManagerOptions {
3333
maxHistory?: number;
3434
}
3535

36+
/** Type guard validating the required shape of a persisted UndoableOperation. */
37+
function isValidOperation(op: unknown): op is UndoableOperation {
38+
if (typeof op !== 'object' || op === null) return false;
39+
const o = op as Record<string, unknown>;
40+
return (
41+
typeof o.id === 'string' &&
42+
typeof o.type === 'string' &&
43+
typeof o.objectName === 'string' &&
44+
typeof o.recordId === 'string' &&
45+
typeof o.timestamp === 'number'
46+
);
47+
}
48+
3649
/**
3750
* Manages undo/redo stacks for CRUD operations.
3851
*
@@ -110,6 +123,91 @@ export class UndoManager {
110123
/** Get a shallow copy of the undo history (for developer tools). */
111124
getHistory(): UndoableOperation[] { return [...this.undoStack]; }
112125

126+
/** Get a shallow copy of the redo history (for developer tools). */
127+
getRedoHistory(): UndoableOperation[] { return [...this.redoStack]; }
128+
129+
// ---------------------------------------------------------------------------
130+
// Batch operations
131+
// ---------------------------------------------------------------------------
132+
133+
/** Push multiple operations as one atomic unit. Clears the redo stack. */
134+
pushBatch(operations: UndoableOperation[]): void {
135+
if (operations.length === 0) return;
136+
this.undoStack.push(...operations);
137+
// Trim from the front if we exceed maxHistory
138+
if (this.undoStack.length > this.maxHistory) {
139+
this.undoStack.splice(0, this.undoStack.length - this.maxHistory);
140+
}
141+
this.redoStack = [];
142+
this.notify();
143+
}
144+
145+
/** Pop `count` operations from the undo stack and move them to redo (LIFO order). */
146+
popUndoBatch(count: number): UndoableOperation[] {
147+
const actual = Math.min(count, this.undoStack.length);
148+
if (actual === 0) return [];
149+
const ops = this.undoStack.splice(this.undoStack.length - actual, actual);
150+
// Preserve LIFO order on the redo stack (last undone goes on top)
151+
this.redoStack.push(...ops);
152+
this.notify();
153+
return ops;
154+
}
155+
156+
/** Pop `count` operations from the redo stack and move them to undo (LIFO order). */
157+
popRedoBatch(count: number): UndoableOperation[] {
158+
const actual = Math.min(count, this.redoStack.length);
159+
if (actual === 0) return [];
160+
const ops = this.redoStack.splice(this.redoStack.length - actual, actual);
161+
this.undoStack.push(...ops);
162+
this.notify();
163+
return ops;
164+
}
165+
166+
// ---------------------------------------------------------------------------
167+
// Persistence (localStorage)
168+
// ---------------------------------------------------------------------------
169+
170+
private static readonly STORAGE_KEY = 'objectui:undo-history';
171+
172+
/** Persist the current undo/redo stacks to localStorage. */
173+
saveToStorage(): void {
174+
try {
175+
const payload = JSON.stringify({
176+
undoStack: this.undoStack,
177+
redoStack: this.redoStack,
178+
});
179+
localStorage.setItem(UndoManager.STORAGE_KEY, payload);
180+
} catch {
181+
// localStorage may be unavailable (SSR, quota exceeded, etc.)
182+
}
183+
}
184+
185+
/** Restore undo/redo stacks from localStorage (no-op when unavailable). */
186+
loadFromStorage(): void {
187+
try {
188+
if (typeof localStorage === 'undefined') return;
189+
const raw = localStorage.getItem(UndoManager.STORAGE_KEY);
190+
if (!raw) return;
191+
const parsed = JSON.parse(raw) as {
192+
undoStack?: UndoableOperation[];
193+
redoStack?: UndoableOperation[];
194+
};
195+
if (Array.isArray(parsed.undoStack)) {
196+
this.undoStack = parsed.undoStack.filter(isValidOperation);
197+
}
198+
if (Array.isArray(parsed.redoStack)) {
199+
this.redoStack = parsed.redoStack.filter(isValidOperation);
200+
}
201+
// Enforce maxHistory in case persisted state used a different limit
202+
if (this.undoStack.length > this.maxHistory) {
203+
this.undoStack.splice(0, this.undoStack.length - this.maxHistory);
204+
}
205+
this.notify();
206+
} catch {
207+
// Silently ignore parse errors or missing storage
208+
}
209+
}
210+
113211
private notify(): void { this.listeners.forEach((fn) => fn()); }
114212
}
115213

0 commit comments

Comments
 (0)