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
154 changes: 78 additions & 76 deletions ROADMAP_CONSOLE.md

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions apps/console/src/__tests__/ActivityFeedFilters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

// Mock UI components – Sheet always renders all children so we can test content
vi.mock('@object-ui/components', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>{children}</button>
),
Badge: ({ children, onClick, variant, ...props }: any) => (
<span data-variant={variant} onClick={onClick} role="button" {...props}>{children}</span>
),
Sheet: ({ children }: any) => <div data-testid="sheet">{children}</div>,
SheetContent: ({ children }: any) => <div data-testid="sheet-content">{children}</div>,
SheetHeader: ({ children }: any) => <div>{children}</div>,
SheetTitle: ({ children, className }: any) => <div className={className}>{children}</div>,
SheetTrigger: ({ children }: any) => <>{children}</>,
}));

vi.mock('lucide-react', () => ({
Bell: () => <span data-testid="bell-icon">🔔</span>,
Plus: () => <span>+</span>,
Pencil: () => <span>✏</span>,
Trash2: () => <span>🗑</span>,
MessageSquare: () => <span>💬</span>,
Filter: () => <span>🔍</span>,
}));

import { ActivityFeed, type ActivityItem } from '../components/ActivityFeed';

const sampleActivities: ActivityItem[] = [
{ id: '1', type: 'create', objectName: 'Lead', user: 'Alice', description: 'Created lead Alpha', timestamp: new Date().toISOString() },
{ id: '2', type: 'update', objectName: 'Contact', user: 'Bob', description: 'Updated contact Beta', timestamp: new Date().toISOString() },
{ id: '3', type: 'delete', objectName: 'Task', user: 'Charlie', description: 'Deleted task Gamma', timestamp: new Date().toISOString() },
{ id: '4', type: 'comment', objectName: 'Lead', user: 'Diana', description: 'Commented on Delta', timestamp: new Date().toISOString() },
];

describe('ActivityFeed filters', () => {
it('renders all activities by default', () => {
// Sheet mock renders all children unconditionally so content is visible
render(<ActivityFeed activities={sampleActivities} />);

expect(screen.getByText('Created lead Alpha')).toBeInTheDocument();
expect(screen.getByText('Updated contact Beta')).toBeInTheDocument();
expect(screen.getByText('Deleted task Gamma')).toBeInTheDocument();
expect(screen.getByText('Commented on Delta')).toBeInTheDocument();
});

it('toggling a filter type hides matching activities', () => {
render(<ActivityFeed activities={sampleActivities} />);

// Open the filter panel
const filterBtn = screen.getByText('Filter');
fireEvent.click(filterBtn);

// Toggle off the "create" filter badge
const createBadge = screen.getByText('create');
fireEvent.click(createBadge);

// The "create" activity should be hidden
expect(screen.queryByText('Created lead Alpha')).not.toBeInTheDocument();

// Other activities should remain
expect(screen.getByText('Updated contact Beta')).toBeInTheDocument();
expect(screen.getByText('Deleted task Gamma')).toBeInTheDocument();
expect(screen.getByText('Commented on Delta')).toBeInTheDocument();
});

it('shows all filter toggle badges', () => {
render(<ActivityFeed activities={sampleActivities} />);

// Open the filter panel
const filterBtn = screen.getByText('Filter');
fireEvent.click(filterBtn);

expect(screen.getByText('create')).toBeInTheDocument();
expect(screen.getByText('update')).toBeInTheDocument();
expect(screen.getByText('delete')).toBeInTheDocument();
expect(screen.getByText('comment')).toBeInTheDocument();
});
});
53 changes: 49 additions & 4 deletions apps/console/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
import { useState } from 'react';
import {
Button,
Badge,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@object-ui/components';
import { Bell, Plus, Pencil, Trash2, MessageSquare } from 'lucide-react';
import { Bell, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';

export interface ActivityItem {
id: string;
Expand Down Expand Up @@ -59,6 +60,19 @@ function formatRelativeTime(iso: string): string {

export function ActivityFeed({ activities = [], className }: ActivityFeedProps) {
const [open, setOpen] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [notificationPreferences, setNotificationPreferences] = useState<Record<ActivityItem['type'], boolean>>({
create: true,
update: true,
delete: true,
comment: true,
});

const togglePreference = (type: ActivityItem['type']) => {
setNotificationPreferences(prev => ({ ...prev, [type]: !prev[type] }));
};

const filteredActivities = activities.filter(a => notificationPreferences[a.type]);

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

<SheetContent side="right" className="w-80 sm:w-96">
<SheetHeader>
<SheetTitle>Recent Activity</SheetTitle>
<SheetTitle className="flex items-center justify-between">
Recent Activity
<Button
variant={showFilters ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="h-3.5 w-3.5 mr-1" />
Filter
</Button>
</SheetTitle>
</SheetHeader>

{activities.length === 0 ? (
{showFilters && (
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
{(Object.keys(typeConfig) as ActivityItem['type'][]).map(type => {
const { icon: Icon, color } = typeConfig[type];
const active = notificationPreferences[type];
return (
<Badge
key={type}
variant={active ? 'default' : 'outline'}
className="cursor-pointer select-none gap-1 capitalize"
onClick={() => togglePreference(type)}
>
<Icon className={`h-3 w-3 ${active ? '' : color}`} />
{type}
</Badge>
);
})}
</div>
)}

{filteredActivities.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<Bell className="h-8 w-8 opacity-40" />
<p className="text-sm">No recent activity</p>
</div>
) : (
<ul className="mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]">
{activities.map((item) => {
{filteredActivities.map((item) => {
const { icon: Icon, color } = typeConfig[item.type];
return (
<li
Expand Down
13 changes: 11 additions & 2 deletions apps/console/src/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration';
import { ModeToggle } from './mode-toggle';
import { LocaleSwitcher } from './LocaleSwitcher';
import { ConnectionStatus } from './ConnectionStatus';
import { ActivityFeed } from './ActivityFeed';
import { ActivityFeed, type ActivityItem } from './ActivityFeed';
import type { ConnectionState } from '../dataSource';

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

// Demo activity items for local/mock mode
const DEMO_ACTIVITIES: ActivityItem[] = [
{ 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() },
{ 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() },
{ 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() },
{ 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() },
{ 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() },
];

export function AppHeader({ appName, objects, connectionState, presenceUsers }: { appName: string, objects: any[], connectionState?: ConnectionState, presenceUsers?: PresenceUser[] }) {
const location = useLocation();
const params = useParams();
Expand Down Expand Up @@ -218,7 +227,7 @@ export function AppHeader({ appName, objects, connectionState, presenceUsers }:

{/* Activity Feed */}
<div className="hidden sm:flex shrink-0 relative">
<ActivityFeed />
<ActivityFeed activities={DEMO_ACTIVITIES} />
</div>

{/* Help */}
Expand Down
12 changes: 10 additions & 2 deletions apps/console/src/components/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataI
import { useObjectActions } from '../hooks/useObjectActions';
import { useObjectTranslation } from '@object-ui/i18n';
import { usePermissions } from '@object-ui/permissions';
import { useRealtimeSubscription } from '@object-ui/collaboration';
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';

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

// Conflict resolution: detect and queue conflicts on reconnection
const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user';
const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId);

useEffect(() => {
if (realtimeMessage) {
// On reconnection data change, auto-resolve with server-wins strategy
if (hasConflicts) {
resolveAllConflicts('remote');
}
setRefreshKey(k => k + 1);
}
}, [realtimeMessage]);
}, [realtimeMessage, hasConflicts, resolveAllConflicts]);

// Drawer Logic
const drawerRecordId = searchParams.get('recordId');
Expand Down
3 changes: 3 additions & 0 deletions apps/console/src/components/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [comments, setComments] = useState<Comment[]>([]);
const [threadResolved, setThreadResolved] = useState(false);
const objectDef = objects.find((o: any) => o.name === objectName);

const currentUser = user
Expand Down Expand Up @@ -164,6 +165,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
onAddComment={handleAddComment}
onDeleteComment={handleDeleteComment}
onReaction={handleReaction}
resolved={threadResolved}
onResolve={setThreadResolved}
/>
</div>
</div>
Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/actions/UndoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ export interface UndoManagerOptions {
maxHistory?: number;
}

/** Type guard validating the required shape of a persisted UndoableOperation. */
function isValidOperation(op: unknown): op is UndoableOperation {
if (typeof op !== 'object' || op === null) return false;
const o = op as Record<string, unknown>;
return (
typeof o.id === 'string' &&
typeof o.type === 'string' &&
typeof o.objectName === 'string' &&
typeof o.recordId === 'string' &&
typeof o.timestamp === 'number'
);
}

/**
* Manages undo/redo stacks for CRUD operations.
*
Expand Down Expand Up @@ -110,6 +123,91 @@ export class UndoManager {
/** Get a shallow copy of the undo history (for developer tools). */
getHistory(): UndoableOperation[] { return [...this.undoStack]; }

/** Get a shallow copy of the redo history (for developer tools). */
getRedoHistory(): UndoableOperation[] { return [...this.redoStack]; }

// ---------------------------------------------------------------------------
// Batch operations
// ---------------------------------------------------------------------------

/** Push multiple operations as one atomic unit. Clears the redo stack. */
pushBatch(operations: UndoableOperation[]): void {
if (operations.length === 0) return;
this.undoStack.push(...operations);
// Trim from the front if we exceed maxHistory
if (this.undoStack.length > this.maxHistory) {
this.undoStack.splice(0, this.undoStack.length - this.maxHistory);
}
this.redoStack = [];
this.notify();
}

/** Pop `count` operations from the undo stack and move them to redo (LIFO order). */
popUndoBatch(count: number): UndoableOperation[] {
const actual = Math.min(count, this.undoStack.length);
if (actual === 0) return [];
const ops = this.undoStack.splice(this.undoStack.length - actual, actual);
// Preserve LIFO order on the redo stack (last undone goes on top)
this.redoStack.push(...ops);
this.notify();
return ops;
}

/** Pop `count` operations from the redo stack and move them to undo (LIFO order). */
popRedoBatch(count: number): UndoableOperation[] {
const actual = Math.min(count, this.redoStack.length);
if (actual === 0) return [];
const ops = this.redoStack.splice(this.redoStack.length - actual, actual);
this.undoStack.push(...ops);
this.notify();
return ops;
}

// ---------------------------------------------------------------------------
// Persistence (localStorage)
// ---------------------------------------------------------------------------

private static readonly STORAGE_KEY = 'objectui:undo-history';

/** Persist the current undo/redo stacks to localStorage. */
saveToStorage(): void {
try {
const payload = JSON.stringify({
undoStack: this.undoStack,
redoStack: this.redoStack,
});
localStorage.setItem(UndoManager.STORAGE_KEY, payload);
} catch {
// localStorage may be unavailable (SSR, quota exceeded, etc.)
}
}

/** Restore undo/redo stacks from localStorage (no-op when unavailable). */
loadFromStorage(): void {
try {
if (typeof localStorage === 'undefined') return;
const raw = localStorage.getItem(UndoManager.STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as {
undoStack?: UndoableOperation[];
redoStack?: UndoableOperation[];
};
if (Array.isArray(parsed.undoStack)) {
this.undoStack = parsed.undoStack.filter(isValidOperation);
}
if (Array.isArray(parsed.redoStack)) {
this.redoStack = parsed.redoStack.filter(isValidOperation);
}
// Enforce maxHistory in case persisted state used a different limit
if (this.undoStack.length > this.maxHistory) {
this.undoStack.splice(0, this.undoStack.length - this.maxHistory);
}
this.notify();
} catch {
// Silently ignore parse errors or missing storage
}
}

private notify(): void { this.listeners.forEach((fn) => fn()); }
}

Expand Down
Loading