Skip to content

Commit 1abe73c

Browse files
authored
Merge pull request #461 from objectstack-ai/copilot/accelerate-development-progress
2 parents b6c5c03 + 975d727 commit 1abe73c

9 files changed

Lines changed: 817 additions & 6 deletions

File tree

ROADMAP.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,12 +286,12 @@ The v2.0.7 spec introduces 70+ new UI types across 12 domains. This section maps
286286
**Spec Reference:** `NotificationSchema`, `NotificationConfigSchema`, `NotificationActionSchema`, `NotificationPositionSchema`, `NotificationSeveritySchema`, `NotificationTypeSchema`
287287

288288
#### 2.7 Console UX Enhancement (Ongoing)
289-
- [ ] Skeleton loading states for data-heavy views (grid, dashboard, detail)
290-
- [ ] Toast notifications for CRUD operations (create/update/delete)
291-
- [ ] Responsive sidebar auto-collapse on tablet breakpoints
292-
- [ ] Onboarding walkthrough for first-time users
293-
- [ ] Global search results page (beyond command palette)
294-
- [ ] Recent items / favorites in sidebar
289+
- [x] Skeleton loading states for data-heavy views (grid, dashboard, detail)
290+
- [x] Toast notifications for CRUD operations (create/update/delete)
291+
- [x] Responsive sidebar auto-collapse on tablet breakpoints
292+
- [x] Onboarding walkthrough for first-time users
293+
- [x] Global search results page (beyond command palette)
294+
- [x] Recent items / favorites in sidebar
295295

296296
**Q2 Milestone:**
297297
- **v1.0.0 Release (June 2026):** Full interactive experience — DnD, gestures, focus, animation, notifications, view enhancements

apps/console/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { ViewDesignerPage } from './components/ViewDesignerPage';
2323
import { ExpressionProvider } from './context/ExpressionProvider';
2424
import { ConditionalAuthWrapper } from './components/ConditionalAuthWrapper';
2525
import { KeyboardShortcutsDialog } from './components/KeyboardShortcutsDialog';
26+
import { OnboardingWalkthrough } from './components/OnboardingWalkthrough';
27+
import { SearchResultsPage } from './components/SearchResultsPage';
2628
import { useRecentItems } from './hooks/useRecentItems';
2729

2830
// Auth Pages
@@ -214,6 +216,7 @@ export function AppContent() {
214216
onAppChange={handleAppChange}
215217
/>
216218
<KeyboardShortcutsDialog />
219+
<OnboardingWalkthrough />
217220
<SchemaRendererProvider dataSource={dataSource || {}}>
218221
<ErrorBoundary>
219222
<Routes>
@@ -264,6 +267,9 @@ export function AppContent() {
264267
<Route path="page/:pageName" element={
265268
<PageView />
266269
} />
270+
<Route path="search" element={
271+
<SearchResultsPage />
272+
} />
267273

268274
{/* System Administration Routes */}
269275
<Route path="system/users" element={<UserManagementPage />} />
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Tests for useFavorites hook
3+
*/
4+
import { describe, it, expect, beforeEach, vi } from 'vitest';
5+
import { renderHook, act } from '@testing-library/react';
6+
import { useFavorites } from '../hooks/useFavorites';
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('useFavorites', () => {
22+
beforeEach(() => {
23+
localStorageMock.clear();
24+
vi.clearAllMocks();
25+
});
26+
27+
it('starts with empty favorites when localStorage is empty', () => {
28+
const { result } = renderHook(() => useFavorites());
29+
expect(result.current.favorites).toEqual([]);
30+
});
31+
32+
it('adds a favorite item', () => {
33+
const { result } = renderHook(() => useFavorites());
34+
35+
act(() => {
36+
result.current.addFavorite({
37+
id: 'object:contact',
38+
label: 'Contacts',
39+
href: '/apps/crm/contact',
40+
type: 'object',
41+
});
42+
});
43+
44+
expect(result.current.favorites).toHaveLength(1);
45+
expect(result.current.favorites[0].id).toBe('object:contact');
46+
expect(result.current.favorites[0].label).toBe('Contacts');
47+
expect(result.current.favorites[0].favoritedAt).toBeDefined();
48+
});
49+
50+
it('does not add duplicate favorites', () => {
51+
const { result } = renderHook(() => useFavorites());
52+
53+
act(() => {
54+
result.current.addFavorite({
55+
id: 'object:contact',
56+
label: 'Contacts',
57+
href: '/apps/crm/contact',
58+
type: 'object',
59+
});
60+
});
61+
62+
act(() => {
63+
result.current.addFavorite({
64+
id: 'object:contact',
65+
label: 'Contacts Again',
66+
href: '/apps/crm/contact',
67+
type: 'object',
68+
});
69+
});
70+
71+
expect(result.current.favorites).toHaveLength(1);
72+
expect(result.current.favorites[0].label).toBe('Contacts');
73+
});
74+
75+
it('removes a favorite', () => {
76+
const { result } = renderHook(() => useFavorites());
77+
78+
act(() => {
79+
result.current.addFavorite({
80+
id: 'object:contact',
81+
label: 'Contacts',
82+
href: '/apps/crm/contact',
83+
type: 'object',
84+
});
85+
});
86+
87+
act(() => {
88+
result.current.removeFavorite('object:contact');
89+
});
90+
91+
expect(result.current.favorites).toEqual([]);
92+
});
93+
94+
it('toggles a favorite on and off', () => {
95+
const { result } = renderHook(() => useFavorites());
96+
const item = {
97+
id: 'dashboard:sales',
98+
label: 'Sales Dashboard',
99+
href: '/apps/crm/dashboard/sales',
100+
type: 'dashboard' as const,
101+
};
102+
103+
// Toggle on
104+
act(() => {
105+
result.current.toggleFavorite(item);
106+
});
107+
expect(result.current.favorites).toHaveLength(1);
108+
expect(result.current.isFavorite('dashboard:sales')).toBe(true);
109+
110+
// Toggle off
111+
act(() => {
112+
result.current.toggleFavorite(item);
113+
});
114+
expect(result.current.favorites).toHaveLength(0);
115+
expect(result.current.isFavorite('dashboard:sales')).toBe(false);
116+
});
117+
118+
it('checks if an item is a favorite', () => {
119+
const { result } = renderHook(() => useFavorites());
120+
121+
expect(result.current.isFavorite('object:contact')).toBe(false);
122+
123+
act(() => {
124+
result.current.addFavorite({
125+
id: 'object:contact',
126+
label: 'Contacts',
127+
href: '/apps/crm/contact',
128+
type: 'object',
129+
});
130+
});
131+
132+
expect(result.current.isFavorite('object:contact')).toBe(true);
133+
expect(result.current.isFavorite('object:other')).toBe(false);
134+
});
135+
136+
it('limits to max 20 favorites', () => {
137+
const { result } = renderHook(() => useFavorites());
138+
139+
for (let i = 0; i < 25; i++) {
140+
act(() => {
141+
result.current.addFavorite({
142+
id: `object:item-${i}`,
143+
label: `Item ${i}`,
144+
href: `/apps/crm/item-${i}`,
145+
type: 'object',
146+
});
147+
});
148+
}
149+
150+
expect(result.current.favorites.length).toBeLessThanOrEqual(20);
151+
});
152+
153+
it('clears all favorites', () => {
154+
const { result } = renderHook(() => useFavorites());
155+
156+
act(() => {
157+
result.current.addFavorite({
158+
id: 'object:contact',
159+
label: 'Contacts',
160+
href: '/apps/crm/contact',
161+
type: 'object',
162+
});
163+
});
164+
165+
act(() => {
166+
result.current.clearFavorites();
167+
});
168+
169+
expect(result.current.favorites).toEqual([]);
170+
});
171+
172+
it('persists favorites to localStorage', () => {
173+
const { result } = renderHook(() => useFavorites());
174+
175+
act(() => {
176+
result.current.addFavorite({
177+
id: 'object:contact',
178+
label: 'Contacts',
179+
href: '/apps/crm/contact',
180+
type: 'object',
181+
});
182+
});
183+
184+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
185+
'objectui-favorites',
186+
expect.any(String),
187+
);
188+
});
189+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Tests for OnboardingWalkthrough component
3+
*/
4+
import { describe, it, expect, beforeEach, vi } from 'vitest';
5+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
6+
import { OnboardingWalkthrough } from '../components/OnboardingWalkthrough';
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('OnboardingWalkthrough', () => {
22+
beforeEach(() => {
23+
localStorageMock.clear();
24+
vi.clearAllMocks();
25+
});
26+
27+
it('shows the onboarding dialog for first-time users', async () => {
28+
render(<OnboardingWalkthrough />);
29+
30+
await waitFor(() => {
31+
expect(screen.getByText('Welcome to ObjectUI')).toBeInTheDocument();
32+
}, { timeout: 3000 });
33+
});
34+
35+
it('does not show the dialog if already dismissed', async () => {
36+
localStorageMock.setItem('objectui-onboarding-dismissed', new Date().toISOString());
37+
38+
render(<OnboardingWalkthrough />);
39+
40+
// Wait a bit then verify it's not shown
41+
await new Promise(r => setTimeout(r, 1000));
42+
expect(screen.queryByText('Welcome to ObjectUI')).not.toBeInTheDocument();
43+
});
44+
45+
it('shows the first step initially', async () => {
46+
render(<OnboardingWalkthrough />);
47+
48+
await waitFor(() => {
49+
expect(screen.getByText('Navigate Your Apps')).toBeInTheDocument();
50+
}, { timeout: 3000 });
51+
});
52+
53+
it('navigates to the next step on Next click', async () => {
54+
render(<OnboardingWalkthrough />);
55+
56+
await waitFor(() => {
57+
expect(screen.getByText('Navigate Your Apps')).toBeInTheDocument();
58+
}, { timeout: 3000 });
59+
60+
fireEvent.click(screen.getByText('Next'));
61+
62+
await waitFor(() => {
63+
expect(screen.getByText('Quick Search')).toBeInTheDocument();
64+
});
65+
});
66+
67+
it('dismisses on Skip click and persists to localStorage', async () => {
68+
render(<OnboardingWalkthrough />);
69+
70+
await waitFor(() => {
71+
expect(screen.getByText('Welcome to ObjectUI')).toBeInTheDocument();
72+
}, { timeout: 3000 });
73+
74+
fireEvent.click(screen.getByText('Skip'));
75+
76+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
77+
'objectui-onboarding-dismissed',
78+
expect.any(String),
79+
);
80+
});
81+
82+
it('shows Get Started on the last step', async () => {
83+
render(<OnboardingWalkthrough />);
84+
85+
await waitFor(() => {
86+
expect(screen.getByText('Navigate Your Apps')).toBeInTheDocument();
87+
}, { timeout: 3000 });
88+
89+
// Click through all steps
90+
fireEvent.click(screen.getByText('Next'));
91+
fireEvent.click(screen.getByText('Next'));
92+
fireEvent.click(screen.getByText('Next'));
93+
94+
await waitFor(() => {
95+
expect(screen.getByText('Get Started')).toBeInTheDocument();
96+
});
97+
});
98+
});

0 commit comments

Comments
 (0)