diff --git a/apps/console/package.json b/apps/console/package.json index 85d50600e..e524f416d 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -59,7 +59,8 @@ "lucide-react": "^0.563.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "sonner": "^2.0.7" }, "devDependencies": { "@objectstack/cli": "^2.0.4", diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 251a8c432..a4943d7f2 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useSe import { useState, useEffect } from 'react'; import { ObjectForm } from '@object-ui/plugin-form'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Empty, EmptyTitle } from '@object-ui/components'; +import { toast } from 'sonner'; import { SchemaRendererProvider } from '@object-ui/react'; import { ObjectStackAdapter } from './dataSource'; import type { ConnectionState } from './dataSource'; @@ -20,6 +21,8 @@ import { PageView } from './components/PageView'; import { ReportView } from './components/ReportView'; import { ExpressionProvider } from './context/ExpressionProvider'; import { ConditionalAuthWrapper } from './components/ConditionalAuthWrapper'; +import { KeyboardShortcutsDialog } from './components/KeyboardShortcutsDialog'; +import { useRecentItems } from './hooks/useRecentItems'; // Auth Pages import { LoginPage } from './pages/LoginPage'; @@ -35,6 +38,7 @@ import { ProfilePage } from './pages/system/ProfilePage'; import { useParams } from 'react-router-dom'; import { ThemeProvider } from './components/theme-provider'; +import { ConsoleToaster } from './components/ConsoleToaster'; export function AppContent() { const [dataSource, setDataSource] = useState(null); @@ -55,6 +59,7 @@ export function AppContent() { const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingRecord, setEditingRecord] = useState(null); const [refreshKey, setRefreshKey] = useState(0); + const { addRecentItem } = useRecentItems(); // Branding is now applied by AppShell via ConsoleLayout @@ -116,6 +121,47 @@ export function AppContent() { const currentObjectDef = allObjects.find((o: any) => o.name === objectNameFromPath); + // Track recent items on route change + // Only depend on location.pathname — the sole external trigger. + // All other values (activeApp, allObjects, cleanParts) are derived from + // stable module-level config and the current pathname, so they don't need + // to be in the dependency array (and including array refs would loop). + useEffect(() => { + if (!activeApp) return; + const parts = location.pathname.split('/').filter(Boolean); + let objName = parts[2]; + if (objName === 'view' || objName === 'record' || objName === 'page' || objName === 'dashboard') { + objName = ''; + } + const basePath = `/apps/${activeApp.name}`; + const objects = appConfig.objects || []; + if (objName) { + const obj = objects.find((o: any) => o.name === objName); + if (obj) { + addRecentItem({ + id: `object:${obj.name}`, + label: obj.label || obj.name, + href: `${basePath}/${obj.name}`, + type: 'object', + }); + } + } else if (parts[2] === 'dashboard' && parts[3]) { + addRecentItem({ + id: `dashboard:${parts[3]}`, + label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()), + href: `${basePath}/dashboard/${parts[3]}`, + type: 'dashboard', + }); + } else if (parts[2] === 'report' && parts[3]) { + addRecentItem({ + id: `report:${parts[3]}`, + label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()), + href: `${basePath}/report/${parts[3]}`, + type: 'report', + }); + } + }, [location.pathname, addRecentItem]); // eslint-disable-line react-hooks/exhaustive-deps + const handleEdit = (record: any) => { setEditingRecord(record); setIsDialogOpen(true); @@ -166,6 +212,7 @@ export function AppContent() { objects={allObjects} onAppChange={handleAppChange} /> + @@ -242,7 +289,15 @@ export function AppContent() { ? currentObjectDef.fields.map((f: any) => typeof f === 'string' ? f : f.name) : Object.keys(currentObjectDef.fields)) : [], - onSuccess: () => { setIsDialogOpen(false); setRefreshKey(k => k + 1); }, + onSuccess: () => { + setIsDialogOpen(false); + setRefreshKey(k => k + 1); + toast.success( + editingRecord + ? `${currentObjectDef?.label} updated successfully` + : `${currentObjectDef?.label} created successfully` + ); + }, onCancel: () => setIsDialogOpen(false), showSubmit: true, showCancel: true, @@ -292,6 +347,7 @@ function RootRedirect() { export function App() { return ( + diff --git a/apps/console/src/__tests__/KeyboardShortcuts.test.tsx b/apps/console/src/__tests__/KeyboardShortcuts.test.tsx new file mode 100644 index 000000000..9b9237f80 --- /dev/null +++ b/apps/console/src/__tests__/KeyboardShortcuts.test.tsx @@ -0,0 +1,57 @@ +/** + * Tests for KeyboardShortcutsDialog component + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { KeyboardShortcutsDialog } from '../components/KeyboardShortcutsDialog'; + +// Mock @object-ui/components Dialog +vi.mock('@object-ui/components', () => ({ + Dialog: ({ open, children }: any) => open ?
{children}
: null, + DialogContent: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, +})); + +describe('KeyboardShortcutsDialog', () => { + it('renders without errors', () => { + const { container } = render(); + // Dialog should be closed initially + expect(container.querySelector('[data-testid="dialog"]')).toBeNull(); + }); + + it('opens when ? key is pressed', () => { + render(); + + fireEvent.keyDown(document, { key: '?' }); + + expect(screen.getByTestId('dialog')).toBeInTheDocument(); + expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument(); + }); + + it('shows shortcut categories', () => { + render(); + fireEvent.keyDown(document, { key: '?' }); + + expect(screen.getByText('General')).toBeInTheDocument(); + expect(screen.getByText('Navigation')).toBeInTheDocument(); + expect(screen.getByText('Data Views')).toBeInTheDocument(); + expect(screen.getByText('Preferences')).toBeInTheDocument(); + }); + + it('does not open when ? is pressed in an input', () => { + const { container } = render( +
+ + +
+ ); + + const input = screen.getByTestId('input'); + fireEvent.keyDown(input, { key: '?' }); + + expect(container.querySelector('[data-testid="dialog"]')).toBeNull(); + }); +}); diff --git a/apps/console/src/__tests__/RecentItems.test.tsx b/apps/console/src/__tests__/RecentItems.test.tsx new file mode 100644 index 000000000..be05cb231 --- /dev/null +++ b/apps/console/src/__tests__/RecentItems.test.tsx @@ -0,0 +1,128 @@ +/** + * Tests for useRecentItems hook + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRecentItems } from '../hooks/useRecentItems'; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { store = {}; }), + }; +})(); + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +describe('useRecentItems', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('starts with empty items when localStorage is empty', () => { + const { result } = renderHook(() => useRecentItems()); + expect(result.current.recentItems).toEqual([]); + }); + + it('adds a recent item', () => { + const { result } = renderHook(() => useRecentItems()); + + act(() => { + result.current.addRecentItem({ + id: 'object:contact', + label: 'Contacts', + href: '/apps/crm/contact', + type: 'object', + }); + }); + + expect(result.current.recentItems).toHaveLength(1); + expect(result.current.recentItems[0].id).toBe('object:contact'); + expect(result.current.recentItems[0].label).toBe('Contacts'); + expect(result.current.recentItems[0].visitedAt).toBeDefined(); + }); + + it('deduplicates items by id', () => { + const { result } = renderHook(() => useRecentItems()); + + act(() => { + result.current.addRecentItem({ + id: 'object:contact', + label: 'Contacts', + href: '/apps/crm/contact', + type: 'object', + }); + }); + + act(() => { + result.current.addRecentItem({ + id: 'object:contact', + label: 'Contacts Updated', + href: '/apps/crm/contact', + type: 'object', + }); + }); + + expect(result.current.recentItems).toHaveLength(1); + expect(result.current.recentItems[0].label).toBe('Contacts Updated'); + }); + + it('limits to max 8 items', () => { + const { result } = renderHook(() => useRecentItems()); + + for (let i = 0; i < 10; i++) { + act(() => { + result.current.addRecentItem({ + id: `object:item-${i}`, + label: `Item ${i}`, + href: `/apps/crm/item-${i}`, + type: 'object', + }); + }); + } + + expect(result.current.recentItems.length).toBeLessThanOrEqual(8); + }); + + it('clears all items', () => { + const { result } = renderHook(() => useRecentItems()); + + act(() => { + result.current.addRecentItem({ + id: 'object:contact', + label: 'Contacts', + href: '/apps/crm/contact', + type: 'object', + }); + }); + + act(() => { + result.current.clearRecentItems(); + }); + + expect(result.current.recentItems).toEqual([]); + }); + + it('persists items to localStorage', () => { + const { result } = renderHook(() => useRecentItems()); + + act(() => { + result.current.addRecentItem({ + id: 'object:contact', + label: 'Contacts', + href: '/apps/crm/contact', + type: 'object', + }); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'objectui-recent-items', + expect.any(String), + ); + }); +}); diff --git a/apps/console/src/__tests__/SkeletonComponents.test.tsx b/apps/console/src/__tests__/SkeletonComponents.test.tsx new file mode 100644 index 000000000..37c9c2225 --- /dev/null +++ b/apps/console/src/__tests__/SkeletonComponents.test.tsx @@ -0,0 +1,61 @@ +/** + * Tests for skeleton loading components + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SkeletonGrid } from '../components/skeletons/SkeletonGrid'; +import { SkeletonDashboard } from '../components/skeletons/SkeletonDashboard'; +import { SkeletonDetail } from '../components/skeletons/SkeletonDetail'; + +// Mock @object-ui/components Skeleton +vi.mock('@object-ui/components', () => ({ + Skeleton: ({ className, ...props }: any) => ( +
+ ), +})); + +describe('SkeletonGrid', () => { + it('renders with default props', () => { + const { container } = render(); + const skeletons = container.querySelectorAll('[data-testid="skeleton"]'); + // Header (5) + 8 rows x 5 cols (40) + toolbar (4) + pagination (4) = 53 + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders correct number of rows', () => { + const { container } = render(); + // Should have skeletons for 3 rows x 2 columns in the table body + const rowContainers = container.querySelectorAll('.border-b'); + expect(rowContainers.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe('SkeletonDashboard', () => { + it('renders with default props', () => { + const { container } = render(); + const skeletons = container.querySelectorAll('[data-testid="skeleton"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders correct number of widget cards', () => { + const { container } = render(); + // 3 widget cards, each with 3 skeletons + stats row (4 cards x 3 each) + header (2) + const skeletons = container.querySelectorAll('[data-testid="skeleton"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); +}); + +describe('SkeletonDetail', () => { + it('renders with default props', () => { + const { container } = render(); + const skeletons = container.querySelectorAll('[data-testid="skeleton"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders correct number of field rows', () => { + const { container } = render(); + const skeletons = container.querySelectorAll('[data-testid="skeleton"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/console/src/components/AppHeader.tsx b/apps/console/src/components/AppHeader.tsx index 1baed87d7..69b9af193 100644 --- a/apps/console/src/components/AppHeader.tsx +++ b/apps/console/src/components/AppHeader.tsx @@ -19,8 +19,12 @@ import { SidebarTrigger, Button, Separator, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, } from '@object-ui/components'; -import { Search, Bell, HelpCircle } from 'lucide-react'; +import { Search, Bell, HelpCircle, ChevronDown } from 'lucide-react'; import { ModeToggle } from './mode-toggle'; import { ConnectionStatus } from './ConnectionStatus'; @@ -43,10 +47,17 @@ export function AppHeader({ appName, objects, connectionState }: { appName: stri const appNameFromRoute = params.appName || pathParts[1]; const routeType = pathParts[2]; // 'contact', 'dashboard', 'page', 'report' + const baseHref = `/apps/${appNameFromRoute}`; - // Determine breadcrumb items - const breadcrumbItems: { label: string; href?: string }[] = [ - { label: appName, href: `/apps/${appNameFromRoute}` } + // Build sibling links for quick navigation dropdown + const objectSiblings = objects.map((o: any) => ({ + label: o.label || o.name, + href: `${baseHref}/${o.name}`, + })); + + // Determine breadcrumb items with optional siblings for dropdown + const breadcrumbItems: { label: string; href?: string; siblings?: { label: string; href: string }[] }[] = [ + { label: appName, href: baseHref } ]; if (routeType === 'dashboard') { @@ -75,7 +86,8 @@ export function AppHeader({ appName, objects, connectionState }: { appName: stri if (currentObject) { breadcrumbItems.push({ label: currentObject.label || routeType, - href: `/apps/${appNameFromRoute}/${routeType}` + href: `/apps/${appNameFromRoute}/${routeType}`, + siblings: objectSiblings, }); // Check if viewing a specific record @@ -102,7 +114,37 @@ export function AppHeader({ appName, objects, connectionState }: { appName: stri {index > 0 && } {index === breadcrumbItems.length - 1 || !item.href ? ( - {item.label} + item.siblings && item.siblings.length > 1 ? ( + + + {item.label} + + + + {item.siblings.map((sibling) => ( + + {sibling.label} + + ))} + + + ) : ( + {item.label} + ) + ) : item.siblings && item.siblings.length > 1 ? ( + + + {item.label} + + + + {item.siblings.map((sibling) => ( + + {sibling.label} + + ))} + + ) : ( {item.label} diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index 49c595818..db0b7e2c1 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -36,17 +36,19 @@ import { CollapsibleTrigger, CollapsibleContent, } from '@object-ui/components'; -import { +import { ChevronsUpDown, Plus, Settings, LogOut, Database, ChevronRight, + Clock, } from 'lucide-react'; import appConfig from '../../objectstack.shared'; import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider'; import { useAuth, getUserInitials } from '@object-ui/auth'; +import { useRecentItems } from '../hooks/useRecentItems'; /** * Resolve a Lucide icon component by name string. @@ -77,6 +79,7 @@ function getIcon(name?: string): React.ComponentType { export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: string, onAppChange: (name: string) => void }) { const { isMobile } = useSidebar(); const { user, signOut } = useAuth(); + const { recentItems } = useRecentItems(); const apps = appConfig.apps || []; // Filter out inactive apps @@ -155,6 +158,32 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri + + {/* Recent Items */} + {recentItems.length > 0 && ( + + + + Recent + + + + {recentItems.slice(0, 5).map(item => ( + + + + + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} + + {item.label} + + + + ))} + + + + )} diff --git a/apps/console/src/components/ConsoleLayout.tsx b/apps/console/src/components/ConsoleLayout.tsx index 586d486c1..75a9c4d49 100644 --- a/apps/console/src/components/ConsoleLayout.tsx +++ b/apps/console/src/components/ConsoleLayout.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { AppShell } from '@object-ui/layout'; import { AppSidebar } from './AppSidebar'; import { AppHeader } from './AppHeader'; +import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar'; import type { ConnectionState } from '../dataSource'; interface ConsoleLayoutProps { @@ -21,6 +22,12 @@ interface ConsoleLayoutProps { connectionState?: ConnectionState; } +/** Inner component that can access SidebarProvider context */ +function ConsoleLayoutInner({ children }: { children: React.ReactNode }) { + useResponsiveSidebar(); + return <>{children}; +} + export function ConsoleLayout({ children, activeAppName, @@ -59,7 +66,9 @@ export function ConsoleLayout({ : undefined } > - {children} + + {children} + ); } diff --git a/apps/console/src/components/ConsoleToaster.tsx b/apps/console/src/components/ConsoleToaster.tsx new file mode 100644 index 000000000..981eb551a --- /dev/null +++ b/apps/console/src/components/ConsoleToaster.tsx @@ -0,0 +1,43 @@ +/** + * ConsoleToaster + * + * Sonner Toaster configured for the console app. Uses the local ThemeProvider + * instead of next-themes to resolve the current color scheme. + * @module + */ + +import { Toaster as Sonner } from 'sonner'; +import { CircleCheck, Info, LoaderCircle, OctagonX, TriangleAlert } from 'lucide-react'; +import { useTheme } from './theme-provider'; + +type ToasterProps = React.ComponentProps; + +export function ConsoleToaster(props: ToasterProps) { + const { theme = 'system' } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + toastOptions={{ + classNames: { + toast: + 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', + description: 'group-[.toast]:text-muted-foreground', + actionButton: + 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', + cancelButton: + 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground', + }, + }} + {...props} + /> + ); +} diff --git a/apps/console/src/components/DashboardView.tsx b/apps/console/src/components/DashboardView.tsx index 61e86b963..4b5155f0c 100644 --- a/apps/console/src/components/DashboardView.tsx +++ b/apps/console/src/components/DashboardView.tsx @@ -3,21 +3,35 @@ * Renders a dashboard based on the dashboardName parameter */ +import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { DashboardRenderer } from '@object-ui/plugin-dashboard'; import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import { LayoutDashboard } from 'lucide-react'; import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector'; +import { SkeletonDashboard } from './skeletons'; import appConfig from '../../objectstack.shared'; export function DashboardView({ dataSource }: { dataSource?: any }) { const { dashboardName } = useParams<{ dashboardName: string }>(); const { showDebug, toggleDebug } = useMetadataInspector(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Reset loading on navigation; the actual DashboardRenderer handles data fetching + setIsLoading(true); + // Use microtask to let React render the skeleton before the heavy dashboard + queueMicrotask(() => setIsLoading(false)); + }, [dashboardName]); // Find dashboard definition in config // In a real implementation, this would fetch from the server const dashboard = appConfig.dashboards?.find((d: any) => d.name === dashboardName); + if (isLoading) { + return ; + } + if (!dashboard) { return (
diff --git a/apps/console/src/components/KeyboardShortcutsDialog.tsx b/apps/console/src/components/KeyboardShortcutsDialog.tsx new file mode 100644 index 000000000..69afe04fa --- /dev/null +++ b/apps/console/src/components/KeyboardShortcutsDialog.tsx @@ -0,0 +1,125 @@ +/** + * KeyboardShortcutsDialog + * + * A dialog listing all available keyboard shortcuts, triggered by pressing "?". + * @module + */ + +import { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@object-ui/components'; + +interface ShortcutEntry { + keys: string[]; + description: string; +} + +interface ShortcutGroup { + title: string; + shortcuts: ShortcutEntry[]; +} + +const shortcutGroups: ShortcutGroup[] = [ + { + title: 'General', + shortcuts: [ + { keys: ['⌘', 'K'], description: 'Open command palette' }, + { keys: ['?'], description: 'Show keyboard shortcuts' }, + { keys: ['Esc'], description: 'Close dialog / panel' }, + ], + }, + { + title: 'Navigation', + shortcuts: [ + { keys: ['B'], description: 'Toggle sidebar' }, + { keys: ['⌘', '/'], description: 'Focus search' }, + ], + }, + { + title: 'Data Views', + shortcuts: [ + { keys: ['N'], description: 'Create new record' }, + { keys: ['R'], description: 'Refresh data' }, + { keys: ['⌘', 'E'], description: 'Edit selected record' }, + ], + }, + { + title: 'Preferences', + shortcuts: [ + { keys: ['⌘', 'D'], description: 'Toggle dark mode' }, + ], + }, +]; + +export function KeyboardShortcutsDialog() { + const [open, setOpen] = useState(false); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + // Only trigger when not in an input/textarea/contenteditable + const target = e.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + return; + } + + if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + setOpen(prev => !prev); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + return ( + + + + Keyboard Shortcuts + + Quick reference for all available keyboard shortcuts. + + +
+ {shortcutGroups.map(group => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, idx) => ( +
+ {shortcut.description} +
+ {shortcut.keys.map((key, kidx) => ( + + {key} + + ))} +
+
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index 1647cf493..fd6a39c8e 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -6,11 +6,13 @@ * the object field definitions. */ +import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { DetailView } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import { Database } from 'lucide-react'; import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector'; +import { SkeletonDetail } from './skeletons'; interface RecordDetailViewProps { dataSource: any; @@ -21,8 +23,19 @@ interface RecordDetailViewProps { export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) { const { objectName, recordId } = useParams(); const { showDebug, toggleDebug } = useMetadataInspector(); + const [isLoading, setIsLoading] = useState(true); const objectDef = objects.find((o: any) => o.name === objectName); + useEffect(() => { + // Reset loading on navigation; the actual DetailView handles data fetching + setIsLoading(true); + queueMicrotask(() => setIsLoading(false)); + }, [objectName, recordId]); + + if (isLoading) { + return ; + } + if (!objectDef) { return (
diff --git a/apps/console/src/components/skeletons/SkeletonDashboard.tsx b/apps/console/src/components/skeletons/SkeletonDashboard.tsx new file mode 100644 index 000000000..e333f63ad --- /dev/null +++ b/apps/console/src/components/skeletons/SkeletonDashboard.tsx @@ -0,0 +1,54 @@ +/** + * SkeletonDashboard + * + * Skeleton loading placeholder for dashboard views. + * Renders animated pulse cards that mimic a dashboard grid layout. + * @module + */ + +import { Skeleton } from '@object-ui/components'; + +interface SkeletonDashboardProps { + /** Number of widget cards to render */ + cards?: number; +} + +export function SkeletonDashboard({ cards = 6 }: SkeletonDashboardProps) { + return ( +
+ {/* Dashboard header skeleton */} +
+ + +
+ + {/* Stats row */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + {/* Widget grid */} +
+ {Array.from({ length: cards }).map((_, i) => ( +
+
+ + +
+ +
+ + +
+
+ ))} +
+
+ ); +} diff --git a/apps/console/src/components/skeletons/SkeletonDetail.tsx b/apps/console/src/components/skeletons/SkeletonDetail.tsx new file mode 100644 index 000000000..97b6eb685 --- /dev/null +++ b/apps/console/src/components/skeletons/SkeletonDetail.tsx @@ -0,0 +1,66 @@ +/** + * SkeletonDetail + * + * Skeleton loading placeholder for record detail views. + * Renders animated pulse fields that mimic a detail form layout. + * @module + */ + +import { Skeleton } from '@object-ui/components'; + +interface SkeletonDetailProps { + /** Number of field rows to render */ + fields?: number; + /** Number of columns */ + columns?: number; +} + +export function SkeletonDetail({ fields = 8, columns = 2 }: SkeletonDetailProps) { + return ( +
+ {/* Header skeleton */} +
+
+ +
+ + +
+
+
+ + +
+
+ + {/* Section title */} + + + {/* Field grid */} +
+ {Array.from({ length: fields }).map((_, i) => ( +
+ + +
+ ))} +
+ + {/* Related section */} +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/console/src/components/skeletons/SkeletonGrid.tsx b/apps/console/src/components/skeletons/SkeletonGrid.tsx new file mode 100644 index 000000000..4f8056cec --- /dev/null +++ b/apps/console/src/components/skeletons/SkeletonGrid.tsx @@ -0,0 +1,69 @@ +/** + * SkeletonGrid + * + * Skeleton loading placeholder for grid/table views. + * Renders animated pulse rows that mimic a data table layout. + * @module + */ + +import { Skeleton } from '@object-ui/components'; + +interface SkeletonGridProps { + /** Number of skeleton rows to render */ + rows?: number; + /** Number of columns to render */ + columns?: number; +} + +export function SkeletonGrid({ rows = 8, columns = 5 }: SkeletonGridProps) { + return ( +
+ {/* Toolbar skeleton */} +
+
+ + +
+
+ + +
+
+ + {/* Table skeleton */} +
+ {/* Header */} +
+ {Array.from({ length: columns }).map((_, i) => ( + + ))} +
+ + {/* Rows */} + {Array.from({ length: rows }).map((_, rowIdx) => ( +
+ {Array.from({ length: columns }).map((_, colIdx) => ( + + ))} +
+ ))} +
+ + {/* Pagination skeleton */} +
+ +
+ + + +
+
+
+ ); +} diff --git a/apps/console/src/components/skeletons/index.ts b/apps/console/src/components/skeletons/index.ts new file mode 100644 index 000000000..bf1c6ad88 --- /dev/null +++ b/apps/console/src/components/skeletons/index.ts @@ -0,0 +1,3 @@ +export { SkeletonGrid } from './SkeletonGrid'; +export { SkeletonDashboard } from './SkeletonDashboard'; +export { SkeletonDetail } from './SkeletonDetail'; diff --git a/apps/console/src/dataSource.ts b/apps/console/src/dataSource.ts index bfed2d636..9f9adbc84 100644 --- a/apps/console/src/dataSource.ts +++ b/apps/console/src/dataSource.ts @@ -18,6 +18,7 @@ export type { BatchProgressEvent, ConnectionStateListener, BatchProgressListener, + FileUploadResult, } from '@object-ui/data-objectstack'; /** diff --git a/apps/console/src/hooks/useObjectActions.ts b/apps/console/src/hooks/useObjectActions.ts index d6f8c0db2..6247fbf0a 100644 --- a/apps/console/src/hooks/useObjectActions.ts +++ b/apps/console/src/hooks/useObjectActions.ts @@ -14,6 +14,7 @@ import { useCallback, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useActionRunner } from '@object-ui/react'; +import { toast } from 'sonner'; import type { ActionDef, ActionResult } from '@object-ui/core'; interface ObjectActionConfig { @@ -76,8 +77,12 @@ export function useObjectActions({ try { await dataSource.delete(objectName, recordId); onRefresh?.(); + toast.success(`${objectLabel || objectName} deleted successfully`); return { success: true, reload: true }; } catch (err: any) { + toast.error(`Failed to delete ${objectLabel || objectName}`, { + description: err.message, + }); return { success: false, error: err.message }; } }); diff --git a/apps/console/src/hooks/useRecentItems.ts b/apps/console/src/hooks/useRecentItems.ts new file mode 100644 index 000000000..26f907a93 --- /dev/null +++ b/apps/console/src/hooks/useRecentItems.ts @@ -0,0 +1,70 @@ +/** + * useRecentItems + * + * Tracks recently visited items (objects, dashboards, pages) with + * localStorage persistence. Exposes helpers to add and retrieve items. + * @module + */ + +import { useState, useCallback, useEffect } from 'react'; + +export interface RecentItem { + /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */ + id: string; + label: string; + href: string; + type: 'object' | 'dashboard' | 'page' | 'report' | 'record'; + /** ISO timestamp of last visit */ + visitedAt: string; +} + +const STORAGE_KEY = 'objectui-recent-items'; +const MAX_RECENT = 8; + +function loadRecent(): RecentItem[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function saveRecent(items: RecentItem[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } catch { + // Storage full — silently ignore + } +} + +export function useRecentItems() { + const [recentItems, setRecentItems] = useState(loadRecent); + + // Sync from storage on mount + useEffect(() => { + setRecentItems(loadRecent()); + }, []); + + const addRecentItem = useCallback( + (item: Omit) => { + setRecentItems(prev => { + const filtered = prev.filter(r => r.id !== item.id); + const updated = [ + { ...item, visitedAt: new Date().toISOString() }, + ...filtered, + ].slice(0, MAX_RECENT); + saveRecent(updated); + return updated; + }); + }, + [], + ); + + const clearRecentItems = useCallback(() => { + setRecentItems([]); + saveRecent([]); + }, []); + + return { recentItems, addRecentItem, clearRecentItems }; +} diff --git a/apps/console/src/hooks/useResponsiveSidebar.ts b/apps/console/src/hooks/useResponsiveSidebar.ts new file mode 100644 index 000000000..edf0f47d0 --- /dev/null +++ b/apps/console/src/hooks/useResponsiveSidebar.ts @@ -0,0 +1,34 @@ +/** + * useResponsiveSidebar + * + * Auto-collapses the sidebar on tablet-width viewports (768px–1023px). + * Must be called inside a SidebarProvider context. + * @module + */ + +import { useEffect } from 'react'; +import { useSidebar } from '@object-ui/components'; + +/** Tablet breakpoint range: 768px <= width < 1024px */ +const TABLET_MIN = 768; +const TABLET_MAX = 1024; + +export function useResponsiveSidebar() { + const { setOpen, isMobile } = useSidebar(); + + useEffect(() => { + function handleResize() { + const width = window.innerWidth; + if (width >= TABLET_MIN && width < TABLET_MAX) { + // Tablet: auto-collapse sidebar + setOpen(false); + } + } + + // Run on mount to set initial state + handleResize(); + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [setOpen, isMobile]); +} diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 5c6ef690b..02baf6770 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -7,7 +7,7 @@ */ import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client'; -import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; +import type { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types'; import { convertFiltersToAST } from '@object-ui/core'; import { MetadataCache } from './cache/MetadataCache'; import { @@ -53,6 +53,9 @@ export type ConnectionStateListener = (event: ConnectionStateEvent) => void; */ export type BatchProgressListener = (event: BatchProgressEvent) => void; +// Re-export FileUploadResult from types for consumers +export type { FileUploadResult } from '@object-ui/types'; + /** * ObjectStack Data Source Adapter * @@ -93,6 +96,8 @@ export class ObjectStackAdapter implements DataSource { private maxReconnectAttempts: number; private reconnectDelay: number; private reconnectAttempts: number = 0; + private baseUrl: string; + private token?: string; constructor(config: { baseUrl: string; @@ -111,6 +116,8 @@ export class ObjectStackAdapter implements DataSource { this.autoReconnect = config.autoReconnect ?? true; this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3; this.reconnectDelay = config.reconnectDelay ?? 1000; + this.baseUrl = config.baseUrl; + this.token = config.token; } /** @@ -682,6 +689,131 @@ export class ObjectStackAdapter implements DataSource { clearCache(): void { this.metadataCache.clear(); } + + /** + * Upload a single file to a resource. + * Posts the file as multipart/form-data to the ObjectStack server. + * + * @param resource - The resource/object name to attach the file to + * @param file - File object or Blob to upload + * @param options - Additional upload options (recordId, fieldName, metadata) + * @returns Promise resolving to the upload result (file URL, metadata) + */ + async uploadFile( + resource: string, + file: File | Blob, + options?: { + recordId?: string; + fieldName?: string; + metadata?: Record; + onProgress?: (percent: number) => void; + }, + ): Promise { + await this.connect(); + + const formData = new FormData(); + formData.append('file', file); + + if (options?.recordId) { + formData.append('recordId', options.recordId); + } + if (options?.fieldName) { + formData.append('fieldName', options.fieldName); + } + if (options?.metadata) { + formData.append('metadata', JSON.stringify(options.metadata)); + } + + const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`; + + const response = await fetch(url, { + method: 'POST', + body: formData, + headers: { + ...(this.getAuthHeaders()), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new ObjectStackError( + error.message || `Upload failed with status ${response.status}`, + 'UPLOAD_ERROR', + response.status, + ); + } + + return response.json(); + } + + /** + * Upload multiple files to a resource. + * Posts all files as a single multipart/form-data request. + * + * @param resource - The resource/object name to attach the files to + * @param files - Array of File objects or Blobs to upload + * @param options - Additional upload options + * @returns Promise resolving to array of upload results + */ + async uploadFiles( + resource: string, + files: (File | Blob)[], + options?: { + recordId?: string; + fieldName?: string; + metadata?: Record; + onProgress?: (percent: number) => void; + }, + ): Promise { + await this.connect(); + + const formData = new FormData(); + files.forEach((file, idx) => { + formData.append(`files`, file, (file as File).name || `file-${idx}`); + }); + + if (options?.recordId) { + formData.append('recordId', options.recordId); + } + if (options?.fieldName) { + formData.append('fieldName', options.fieldName); + } + if (options?.metadata) { + formData.append('metadata', JSON.stringify(options.metadata)); + } + + const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`; + + const response = await fetch(url, { + method: 'POST', + body: formData, + headers: { + ...(this.getAuthHeaders()), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new ObjectStackError( + error.message || `Upload failed with status ${response.status}`, + 'UPLOAD_ERROR', + response.status, + ); + } + + return response.json(); + } + + /** + * Get authorization headers from the adapter config. + */ + private getAuthHeaders(): Record { + const headers: Record = {}; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + return headers; + } } /** diff --git a/packages/data-objectstack/src/upload.test.ts b/packages/data-objectstack/src/upload.test.ts new file mode 100644 index 000000000..b98e2afef --- /dev/null +++ b/packages/data-objectstack/src/upload.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for ObjectStackAdapter file upload integration + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ObjectStackAdapter } from './index'; + +describe('ObjectStackAdapter File Upload', () => { + let adapter: ObjectStackAdapter; + + beforeEach(() => { + adapter = new ObjectStackAdapter({ + baseUrl: 'http://localhost:3000', + autoReconnect: false, + }); + vi.clearAllMocks(); + }); + + describe('uploadFile', () => { + it('should be a method on the adapter', () => { + expect(typeof adapter.uploadFile).toBe('function'); + }); + + it('should call fetch with multipart form data when connected', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + id: 'file-1', + filename: 'test.pdf', + mimeType: 'application/pdf', + size: 1024, + url: 'http://localhost:3000/files/file-1', + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + // Manually set connected state by accessing private field + (adapter as any).connected = true; + (adapter as any).connectionState = 'connected'; + + const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); + + const result = await adapter.uploadFile('documents', file, { + recordId: 'rec-123', + fieldName: 'attachment', + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/data/documents/upload'), + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }), + ); + + expect(result.id).toBe('file-1'); + expect(result.filename).toBe('test.pdf'); + }); + + it('should throw on upload failure', async () => { + const mockResponse = { + ok: false, + status: 413, + statusText: 'Payload Too Large', + json: vi.fn().mockResolvedValue({ message: 'File too large' }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + // Manually set connected state + (adapter as any).connected = true; + (adapter as any).connectionState = 'connected'; + + const file = new File(['test'], 'large.bin', { type: 'application/octet-stream' }); + + await expect(adapter.uploadFile('documents', file)).rejects.toThrow('File too large'); + }); + }); + + describe('uploadFiles', () => { + it('should be a method on the adapter', () => { + expect(typeof adapter.uploadFiles).toBe('function'); + }); + + it('should upload multiple files', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue([ + { id: 'file-1', filename: 'a.pdf', mimeType: 'application/pdf', size: 100, url: '/files/1' }, + { id: 'file-2', filename: 'b.pdf', mimeType: 'application/pdf', size: 200, url: '/files/2' }, + ]), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + // Manually set connected state + (adapter as any).connected = true; + (adapter as any).connectionState = 'connected'; + + const files = [ + new File(['content1'], 'a.pdf', { type: 'application/pdf' }), + new File(['content2'], 'b.pdf', { type: 'application/pdf' }), + ]; + + const results = await adapter.uploadFiles('documents', files); + + expect(results).toHaveLength(2); + expect(results[0].id).toBe('file-1'); + expect(results[1].id).toBe('file-2'); + }); + }); +}); diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index e7ac0fb10..7a5b3ad4e 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -113,6 +113,26 @@ export interface QueryResult { metadata?: Record; } +/** + * Result of a file upload operation. + */ +export interface FileUploadResult { + /** Server-assigned unique ID for the uploaded file */ + id: string; + /** Original filename */ + filename: string; + /** MIME type of the uploaded file */ + mimeType: string; + /** File size in bytes */ + size: number; + /** Public URL to access the file */ + url: string; + /** Thumbnail URL (for images) */ + thumbnailUrl?: string; + /** Additional server-side metadata */ + metadata?: Record; +} + /** * Universal data source interface. * This is the core abstraction that makes Object UI backend-agnostic. @@ -226,6 +246,46 @@ export interface DataSource { * @returns Promise resolving to the app definition or null */ getApp?(appId: string): Promise; + + /** + * Upload a single file to a resource. + * Optional — only supported by data sources with file storage integration. + * + * @param resource - Resource name + * @param file - File or Blob to upload + * @param options - Upload options (recordId, fieldName, metadata) + * @returns Promise resolving to the upload result + */ + uploadFile?( + resource: string, + file: File | Blob, + options?: { + recordId?: string; + fieldName?: string; + metadata?: Record; + onProgress?: (percent: number) => void; + }, + ): Promise; + + /** + * Upload multiple files to a resource. + * Optional — only supported by data sources with file storage integration. + * + * @param resource - Resource name + * @param files - Array of Files or Blobs to upload + * @param options - Upload options + * @returns Promise resolving to array of upload results + */ + uploadFiles?( + resource: string, + files: (File | Blob)[], + options?: { + recordId?: string; + fieldName?: string; + metadata?: Record; + onProgress?: (percent: number) => void; + }, + ): Promise; } /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 54068c9fa..b7d22e07a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -253,6 +253,7 @@ export type { DataBinding, ValidationError, APIError, + FileUploadResult, } from './data'; // ============================================================================ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e29c94153..561eefa65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: react-router-dom: specifier: ^7.13.0 version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@objectstack/cli': specifier: ^2.0.4 @@ -1935,7 +1938,7 @@ importers: version: 3.9.1(@types/node@25.2.2)(rollup@4.57.1)(typescript@5.9.3)(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) vitest: specifier: ^1.3.1 - version: 1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2) + version: 1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2) packages/plugin-timeline: dependencies: @@ -21625,7 +21628,7 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vitest@1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2): + vitest@1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1