Skip to content

Commit 1e80ef6

Browse files
authored
Merge pull request #437 from objectstack-ai/copilot/implement-file-upload-integration
2 parents dd281bf + a9898a2 commit 1e80ef6

25 files changed

Lines changed: 1201 additions & 13 deletions

apps/console/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
"lucide-react": "^0.563.0",
6060
"react": "^19.2.4",
6161
"react-dom": "^19.2.4",
62-
"react-router-dom": "^7.13.0"
62+
"react-router-dom": "^7.13.0",
63+
"sonner": "^2.0.7"
6364
},
6465
"devDependencies": {
6566
"@objectstack/cli": "^2.0.4",

apps/console/src/App.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useSe
22
import { useState, useEffect } from 'react';
33
import { ObjectForm } from '@object-ui/plugin-form';
44
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Empty, EmptyTitle } from '@object-ui/components';
5+
import { toast } from 'sonner';
56
import { SchemaRendererProvider } from '@object-ui/react';
67
import { ObjectStackAdapter } from './dataSource';
78
import type { ConnectionState } from './dataSource';
@@ -20,6 +21,8 @@ import { PageView } from './components/PageView';
2021
import { ReportView } from './components/ReportView';
2122
import { ExpressionProvider } from './context/ExpressionProvider';
2223
import { ConditionalAuthWrapper } from './components/ConditionalAuthWrapper';
24+
import { KeyboardShortcutsDialog } from './components/KeyboardShortcutsDialog';
25+
import { useRecentItems } from './hooks/useRecentItems';
2326

2427
// Auth Pages
2528
import { LoginPage } from './pages/LoginPage';
@@ -35,6 +38,7 @@ import { ProfilePage } from './pages/system/ProfilePage';
3538

3639
import { useParams } from 'react-router-dom';
3740
import { ThemeProvider } from './components/theme-provider';
41+
import { ConsoleToaster } from './components/ConsoleToaster';
3842

3943
export function AppContent() {
4044
const [dataSource, setDataSource] = useState<ObjectStackAdapter | null>(null);
@@ -55,6 +59,7 @@ export function AppContent() {
5559
const [isDialogOpen, setIsDialogOpen] = useState(false);
5660
const [editingRecord, setEditingRecord] = useState<any>(null);
5761
const [refreshKey, setRefreshKey] = useState(0);
62+
const { addRecentItem } = useRecentItems();
5863

5964
// Branding is now applied by AppShell via ConsoleLayout
6065

@@ -116,6 +121,47 @@ export function AppContent() {
116121

117122
const currentObjectDef = allObjects.find((o: any) => o.name === objectNameFromPath);
118123

124+
// Track recent items on route change
125+
// Only depend on location.pathname — the sole external trigger.
126+
// All other values (activeApp, allObjects, cleanParts) are derived from
127+
// stable module-level config and the current pathname, so they don't need
128+
// to be in the dependency array (and including array refs would loop).
129+
useEffect(() => {
130+
if (!activeApp) return;
131+
const parts = location.pathname.split('/').filter(Boolean);
132+
let objName = parts[2];
133+
if (objName === 'view' || objName === 'record' || objName === 'page' || objName === 'dashboard') {
134+
objName = '';
135+
}
136+
const basePath = `/apps/${activeApp.name}`;
137+
const objects = appConfig.objects || [];
138+
if (objName) {
139+
const obj = objects.find((o: any) => o.name === objName);
140+
if (obj) {
141+
addRecentItem({
142+
id: `object:${obj.name}`,
143+
label: obj.label || obj.name,
144+
href: `${basePath}/${obj.name}`,
145+
type: 'object',
146+
});
147+
}
148+
} else if (parts[2] === 'dashboard' && parts[3]) {
149+
addRecentItem({
150+
id: `dashboard:${parts[3]}`,
151+
label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()),
152+
href: `${basePath}/dashboard/${parts[3]}`,
153+
type: 'dashboard',
154+
});
155+
} else if (parts[2] === 'report' && parts[3]) {
156+
addRecentItem({
157+
id: `report:${parts[3]}`,
158+
label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()),
159+
href: `${basePath}/report/${parts[3]}`,
160+
type: 'report',
161+
});
162+
}
163+
}, [location.pathname, addRecentItem]); // eslint-disable-line react-hooks/exhaustive-deps
164+
119165
const handleEdit = (record: any) => {
120166
setEditingRecord(record);
121167
setIsDialogOpen(true);
@@ -166,6 +212,7 @@ export function AppContent() {
166212
objects={allObjects}
167213
onAppChange={handleAppChange}
168214
/>
215+
<KeyboardShortcutsDialog />
169216
<SchemaRendererProvider dataSource={dataSource || {}}>
170217
<ErrorBoundary>
171218
<Routes>
@@ -242,7 +289,15 @@ export function AppContent() {
242289
? currentObjectDef.fields.map((f: any) => typeof f === 'string' ? f : f.name)
243290
: Object.keys(currentObjectDef.fields))
244291
: [],
245-
onSuccess: () => { setIsDialogOpen(false); setRefreshKey(k => k + 1); },
292+
onSuccess: () => {
293+
setIsDialogOpen(false);
294+
setRefreshKey(k => k + 1);
295+
toast.success(
296+
editingRecord
297+
? `${currentObjectDef?.label} updated successfully`
298+
: `${currentObjectDef?.label} created successfully`
299+
);
300+
},
246301
onCancel: () => setIsDialogOpen(false),
247302
showSubmit: true,
248303
showCancel: true,
@@ -292,6 +347,7 @@ function RootRedirect() {
292347
export function App() {
293348
return (
294349
<ThemeProvider defaultTheme="system" storageKey="object-ui-theme">
350+
<ConsoleToaster position="bottom-right" />
295351
<ConditionalAuthWrapper authUrl="/api/auth">
296352
<BrowserRouter basename="/">
297353
<Routes>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Tests for KeyboardShortcutsDialog component
3+
*/
4+
import { describe, it, expect, vi } from 'vitest';
5+
import { render, screen, fireEvent } from '@testing-library/react';
6+
import '@testing-library/jest-dom';
7+
import { KeyboardShortcutsDialog } from '../components/KeyboardShortcutsDialog';
8+
9+
// Mock @object-ui/components Dialog
10+
vi.mock('@object-ui/components', () => ({
11+
Dialog: ({ open, children }: any) => open ? <div data-testid="dialog">{children}</div> : null,
12+
DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
13+
DialogHeader: ({ children }: any) => <div>{children}</div>,
14+
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
15+
DialogDescription: ({ children }: any) => <p>{children}</p>,
16+
}));
17+
18+
describe('KeyboardShortcutsDialog', () => {
19+
it('renders without errors', () => {
20+
const { container } = render(<KeyboardShortcutsDialog />);
21+
// Dialog should be closed initially
22+
expect(container.querySelector('[data-testid="dialog"]')).toBeNull();
23+
});
24+
25+
it('opens when ? key is pressed', () => {
26+
render(<KeyboardShortcutsDialog />);
27+
28+
fireEvent.keyDown(document, { key: '?' });
29+
30+
expect(screen.getByTestId('dialog')).toBeInTheDocument();
31+
expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument();
32+
});
33+
34+
it('shows shortcut categories', () => {
35+
render(<KeyboardShortcutsDialog />);
36+
fireEvent.keyDown(document, { key: '?' });
37+
38+
expect(screen.getByText('General')).toBeInTheDocument();
39+
expect(screen.getByText('Navigation')).toBeInTheDocument();
40+
expect(screen.getByText('Data Views')).toBeInTheDocument();
41+
expect(screen.getByText('Preferences')).toBeInTheDocument();
42+
});
43+
44+
it('does not open when ? is pressed in an input', () => {
45+
const { container } = render(
46+
<div>
47+
<input data-testid="input" />
48+
<KeyboardShortcutsDialog />
49+
</div>
50+
);
51+
52+
const input = screen.getByTestId('input');
53+
fireEvent.keyDown(input, { key: '?' });
54+
55+
expect(container.querySelector('[data-testid="dialog"]')).toBeNull();
56+
});
57+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Tests for useRecentItems hook
3+
*/
4+
import { describe, it, expect, beforeEach, vi } from 'vitest';
5+
import { renderHook, act } from '@testing-library/react';
6+
import { useRecentItems } from '../hooks/useRecentItems';
7+
8+
// Mock localStorage
9+
const localStorageMock = (() => {
10+
let store: Record<string, string> = {};
11+
return {
12+
getItem: vi.fn((key: string) => store[key] ?? null),
13+
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
14+
removeItem: vi.fn((key: string) => { delete store[key]; }),
15+
clear: vi.fn(() => { store = {}; }),
16+
};
17+
})();
18+
19+
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
20+
21+
describe('useRecentItems', () => {
22+
beforeEach(() => {
23+
localStorageMock.clear();
24+
vi.clearAllMocks();
25+
});
26+
27+
it('starts with empty items when localStorage is empty', () => {
28+
const { result } = renderHook(() => useRecentItems());
29+
expect(result.current.recentItems).toEqual([]);
30+
});
31+
32+
it('adds a recent item', () => {
33+
const { result } = renderHook(() => useRecentItems());
34+
35+
act(() => {
36+
result.current.addRecentItem({
37+
id: 'object:contact',
38+
label: 'Contacts',
39+
href: '/apps/crm/contact',
40+
type: 'object',
41+
});
42+
});
43+
44+
expect(result.current.recentItems).toHaveLength(1);
45+
expect(result.current.recentItems[0].id).toBe('object:contact');
46+
expect(result.current.recentItems[0].label).toBe('Contacts');
47+
expect(result.current.recentItems[0].visitedAt).toBeDefined();
48+
});
49+
50+
it('deduplicates items by id', () => {
51+
const { result } = renderHook(() => useRecentItems());
52+
53+
act(() => {
54+
result.current.addRecentItem({
55+
id: 'object:contact',
56+
label: 'Contacts',
57+
href: '/apps/crm/contact',
58+
type: 'object',
59+
});
60+
});
61+
62+
act(() => {
63+
result.current.addRecentItem({
64+
id: 'object:contact',
65+
label: 'Contacts Updated',
66+
href: '/apps/crm/contact',
67+
type: 'object',
68+
});
69+
});
70+
71+
expect(result.current.recentItems).toHaveLength(1);
72+
expect(result.current.recentItems[0].label).toBe('Contacts Updated');
73+
});
74+
75+
it('limits to max 8 items', () => {
76+
const { result } = renderHook(() => useRecentItems());
77+
78+
for (let i = 0; i < 10; i++) {
79+
act(() => {
80+
result.current.addRecentItem({
81+
id: `object:item-${i}`,
82+
label: `Item ${i}`,
83+
href: `/apps/crm/item-${i}`,
84+
type: 'object',
85+
});
86+
});
87+
}
88+
89+
expect(result.current.recentItems.length).toBeLessThanOrEqual(8);
90+
});
91+
92+
it('clears all items', () => {
93+
const { result } = renderHook(() => useRecentItems());
94+
95+
act(() => {
96+
result.current.addRecentItem({
97+
id: 'object:contact',
98+
label: 'Contacts',
99+
href: '/apps/crm/contact',
100+
type: 'object',
101+
});
102+
});
103+
104+
act(() => {
105+
result.current.clearRecentItems();
106+
});
107+
108+
expect(result.current.recentItems).toEqual([]);
109+
});
110+
111+
it('persists items to localStorage', () => {
112+
const { result } = renderHook(() => useRecentItems());
113+
114+
act(() => {
115+
result.current.addRecentItem({
116+
id: 'object:contact',
117+
label: 'Contacts',
118+
href: '/apps/crm/contact',
119+
type: 'object',
120+
});
121+
});
122+
123+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
124+
'objectui-recent-items',
125+
expect.any(String),
126+
);
127+
});
128+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Tests for skeleton loading components
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { render } from '@testing-library/react';
6+
import '@testing-library/jest-dom';
7+
import { SkeletonGrid } from '../components/skeletons/SkeletonGrid';
8+
import { SkeletonDashboard } from '../components/skeletons/SkeletonDashboard';
9+
import { SkeletonDetail } from '../components/skeletons/SkeletonDetail';
10+
11+
// Mock @object-ui/components Skeleton
12+
vi.mock('@object-ui/components', () => ({
13+
Skeleton: ({ className, ...props }: any) => (
14+
<div data-testid="skeleton" className={className} {...props} />
15+
),
16+
}));
17+
18+
describe('SkeletonGrid', () => {
19+
it('renders with default props', () => {
20+
const { container } = render(<SkeletonGrid />);
21+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
22+
// Header (5) + 8 rows x 5 cols (40) + toolbar (4) + pagination (4) = 53
23+
expect(skeletons.length).toBeGreaterThan(0);
24+
});
25+
26+
it('renders correct number of rows', () => {
27+
const { container } = render(<SkeletonGrid rows={3} columns={2} />);
28+
// Should have skeletons for 3 rows x 2 columns in the table body
29+
const rowContainers = container.querySelectorAll('.border-b');
30+
expect(rowContainers.length).toBeGreaterThanOrEqual(3);
31+
});
32+
});
33+
34+
describe('SkeletonDashboard', () => {
35+
it('renders with default props', () => {
36+
const { container } = render(<SkeletonDashboard />);
37+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
38+
expect(skeletons.length).toBeGreaterThan(0);
39+
});
40+
41+
it('renders correct number of widget cards', () => {
42+
const { container } = render(<SkeletonDashboard cards={3} />);
43+
// 3 widget cards, each with 3 skeletons + stats row (4 cards x 3 each) + header (2)
44+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
45+
expect(skeletons.length).toBeGreaterThan(0);
46+
});
47+
});
48+
49+
describe('SkeletonDetail', () => {
50+
it('renders with default props', () => {
51+
const { container } = render(<SkeletonDetail />);
52+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
53+
expect(skeletons.length).toBeGreaterThan(0);
54+
});
55+
56+
it('renders correct number of field rows', () => {
57+
const { container } = render(<SkeletonDetail fields={4} columns={1} />);
58+
const skeletons = container.querySelectorAll('[data-testid="skeleton"]');
59+
expect(skeletons.length).toBeGreaterThan(0);
60+
});
61+
});

0 commit comments

Comments
 (0)