Skip to content

Commit 7a384be

Browse files
Merge branch 'main' into claude/fix-ci-build-and-test-errors
2 parents 5f2f865 + 299c30e commit 7a384be

File tree

11 files changed

+315
-117
lines changed

11 files changed

+315
-117
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Home page star/favorite not reactive** (`@object-ui/console`): Migrated `useFavorites` from standalone hook to React Context (`FavoritesProvider`) so all consumers (HomePage, AppCard, AppSidebar, UnifiedSidebar) share a single state instance. Previously, each component calling `useFavorites()` created independent state, so toggling a favorite in AppCard did not trigger re-render in HomePage. localStorage persistence is retained as the storage layer.
13+
1014
### Changed
1115

1216
- **Merged ObjectManagerPage into MetadataManagerPage pipeline** (`@object-ui/console`): Removed the standalone `ObjectManagerPage` component. Object management is now fully handled by the generic `MetadataManagerPage` (list view) and `MetadataDetailPage` (detail view) pipeline. The object type config in `metadataTypeRegistry` uses `listComponent: ObjectManagerListAdapter` for the custom list UI and `pageSchemaFactory: buildObjectDetailPageSchema` for the detail page, eliminating redundant page code and centralizing all metadata management through a single architecture.

apps/console/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const HomeLayout = lazy(() => import('./pages/home/HomeLayout').then(m => ({ def
6363
import { ThemeProvider } from './components/theme-provider';
6464
import { ConsoleToaster } from './components/ConsoleToaster';
6565
import { NavigationProvider } from './context/NavigationContext';
66+
import { FavoritesProvider } from './context/FavoritesProvider';
6667

6768
/**
6869
* ConnectedShell
@@ -520,6 +521,7 @@ export function App() {
520521
<ConditionalAuthWrapper authUrl="/api/v1/auth">
521522
<PreviewBanner />
522523
<NavigationProvider>
524+
<FavoritesProvider>
523525
<BrowserRouter basename={import.meta.env.BASE_URL?.replace(/\/$/, '') || '/'}>
524526
<Suspense fallback={<LoadingScreen />}>
525527
<Routes>
@@ -571,6 +573,7 @@ export function App() {
571573
</Routes>
572574
</Suspense>
573575
</BrowserRouter>
576+
</FavoritesProvider>
574577
</NavigationProvider>
575578
</ConditionalAuthWrapper>
576579
</ThemeProvider>

apps/console/src/__tests__/BrowserSimulation.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,21 @@ vi.mock('../context/MetadataProvider', async () => {
131131

132132
// --- 2. Import AppContent ---
133133
import { AppContent } from '../App';
134+
import { NavigationProvider } from '../context/NavigationContext';
135+
import { FavoritesProvider } from '../context/FavoritesProvider';
134136

135137
describe('Console Application Simulation', () => {
136138

137139
// Helper to render App at specific route
138140
const renderApp = (initialRoute: string) => {
139141
return render(
142+
<NavigationProvider>
143+
<FavoritesProvider>
140144
<MemoryRouter initialEntries={[initialRoute]}>
141145
<AppContent />
142146
</MemoryRouter>
147+
</FavoritesProvider>
148+
</NavigationProvider>
143149
);
144150
};
145151

@@ -832,9 +838,13 @@ describe('Fields Integration', () => {
832838
describe('Dashboard Integration', () => {
833839
const renderApp = (initialRoute: string) => {
834840
return render(
841+
<NavigationProvider>
842+
<FavoritesProvider>
835843
<MemoryRouter initialEntries={[initialRoute]}>
836844
<AppContent />
837845
</MemoryRouter>
846+
</FavoritesProvider>
847+
</NavigationProvider>
838848
);
839849
};
840850

apps/console/src/__tests__/ConsoleApp.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
33
import '@testing-library/jest-dom';
44
import { AppContent } from '../App';
55
import { MemoryRouter, Routes, Route } from 'react-router-dom';
6+
import { NavigationProvider } from '../context/NavigationContext';
7+
import { FavoritesProvider } from '../context/FavoritesProvider';
68

79
// --- Mocks ---
810

@@ -181,11 +183,15 @@ describe('Console App Integration', () => {
181183

182184
const renderApp = (initialRoute = '/apps/sales/') => {
183185
return render(
186+
<NavigationProvider>
187+
<FavoritesProvider>
184188
<MemoryRouter initialEntries={[initialRoute]}>
185189
<Routes>
186190
<Route path="/apps/:appName/*" element={<AppContent />} />
187191
</Routes>
188192
</MemoryRouter>
193+
</FavoritesProvider>
194+
</NavigationProvider>
189195
);
190196
};
191197

apps/console/src/__tests__/Favorites.test.tsx

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/**
2-
* Tests for useFavorites hook
2+
* Tests for useFavorites hook (via FavoritesProvider context)
33
*/
44
import { describe, it, expect, beforeEach, vi } from 'vitest';
55
import { renderHook, act } from '@testing-library/react';
6+
import type { ReactNode } from 'react';
67
import { useFavorites } from '../hooks/useFavorites';
8+
import { FavoritesProvider } from '../context/FavoritesProvider';
79

810
// Mock localStorage
911
const localStorageMock = (() => {
@@ -18,19 +20,24 @@ const localStorageMock = (() => {
1820

1921
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
2022

23+
/** Wrapper that provides the FavoritesProvider for renderHook */
24+
function wrapper({ children }: { children: ReactNode }) {
25+
return <FavoritesProvider>{children}</FavoritesProvider>;
26+
}
27+
2128
describe('useFavorites', () => {
2229
beforeEach(() => {
2330
localStorageMock.clear();
2431
vi.clearAllMocks();
2532
});
2633

2734
it('starts with empty favorites when localStorage is empty', () => {
28-
const { result } = renderHook(() => useFavorites());
35+
const { result } = renderHook(() => useFavorites(), { wrapper });
2936
expect(result.current.favorites).toEqual([]);
3037
});
3138

3239
it('adds a favorite item', () => {
33-
const { result } = renderHook(() => useFavorites());
40+
const { result } = renderHook(() => useFavorites(), { wrapper });
3441

3542
act(() => {
3643
result.current.addFavorite({
@@ -48,7 +55,7 @@ describe('useFavorites', () => {
4855
});
4956

5057
it('does not add duplicate favorites', () => {
51-
const { result } = renderHook(() => useFavorites());
58+
const { result } = renderHook(() => useFavorites(), { wrapper });
5259

5360
act(() => {
5461
result.current.addFavorite({
@@ -73,7 +80,7 @@ describe('useFavorites', () => {
7380
});
7481

7582
it('removes a favorite', () => {
76-
const { result } = renderHook(() => useFavorites());
83+
const { result } = renderHook(() => useFavorites(), { wrapper });
7784

7885
act(() => {
7986
result.current.addFavorite({
@@ -92,7 +99,7 @@ describe('useFavorites', () => {
9299
});
93100

94101
it('toggles a favorite on and off', () => {
95-
const { result } = renderHook(() => useFavorites());
102+
const { result } = renderHook(() => useFavorites(), { wrapper });
96103
const item = {
97104
id: 'dashboard:sales',
98105
label: 'Sales Dashboard',
@@ -116,7 +123,7 @@ describe('useFavorites', () => {
116123
});
117124

118125
it('checks if an item is a favorite', () => {
119-
const { result } = renderHook(() => useFavorites());
126+
const { result } = renderHook(() => useFavorites(), { wrapper });
120127

121128
expect(result.current.isFavorite('object:contact')).toBe(false);
122129

@@ -134,7 +141,7 @@ describe('useFavorites', () => {
134141
});
135142

136143
it('limits to max 20 favorites', () => {
137-
const { result } = renderHook(() => useFavorites());
144+
const { result } = renderHook(() => useFavorites(), { wrapper });
138145

139146
for (let i = 0; i < 25; i++) {
140147
act(() => {
@@ -151,7 +158,7 @@ describe('useFavorites', () => {
151158
});
152159

153160
it('clears all favorites', () => {
154-
const { result } = renderHook(() => useFavorites());
161+
const { result } = renderHook(() => useFavorites(), { wrapper });
155162

156163
act(() => {
157164
result.current.addFavorite({
@@ -170,7 +177,7 @@ describe('useFavorites', () => {
170177
});
171178

172179
it('persists favorites to localStorage', () => {
173-
const { result } = renderHook(() => useFavorites());
180+
const { result } = renderHook(() => useFavorites(), { wrapper });
174181

175182
act(() => {
176183
result.current.addFavorite({
@@ -186,4 +193,46 @@ describe('useFavorites', () => {
186193
expect.any(String),
187194
);
188195
});
196+
197+
it('two hooks sharing the same provider see each other\'s mutations (cross-component reactivity)', () => {
198+
// Both hooks are called within the same render, sharing the same provider.
199+
// This simulates the real scenario where AppCard (consumer A) toggles a favorite
200+
// and HomePage (consumer B) should immediately see the updated state.
201+
const { result } = renderHook(
202+
() => ({ hookA: useFavorites(), hookB: useFavorites() }),
203+
{ wrapper },
204+
);
205+
206+
// Hook A adds a favorite
207+
act(() => {
208+
result.current.hookA.addFavorite({
209+
id: 'app:crm',
210+
label: 'CRM',
211+
href: '/apps/crm',
212+
type: 'object',
213+
});
214+
});
215+
216+
// Hook B (simulating HomePage reading favorites) must see the update
217+
expect(result.current.hookB.favorites).toHaveLength(1);
218+
expect(result.current.hookB.isFavorite('app:crm')).toBe(true);
219+
220+
// Hook B removes the favorite
221+
act(() => {
222+
result.current.hookB.removeFavorite('app:crm');
223+
});
224+
225+
// Hook A must see the removal
226+
expect(result.current.hookA.favorites).toHaveLength(0);
227+
expect(result.current.hookA.isFavorite('app:crm')).toBe(false);
228+
});
229+
230+
it('throws when used outside FavoritesProvider', () => {
231+
// Suppress the expected React error boundary console output
232+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
233+
expect(() => renderHook(() => useFavorites())).toThrow(
234+
'useFavorites must be used within a FavoritesProvider',
235+
);
236+
spy.mockRestore();
237+
});
189238
});

apps/console/src/__tests__/HomeLayout.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ vi.mock('@object-ui/components', async (importOriginal) => {
5454
};
5555
});
5656

57+
// Mock UnifiedSidebar entirely — HomeLayout tests verify layout composition,
58+
// not the sidebar's internal rendering. This also avoids SidebarProvider
59+
// dependency when AppShell is mocked as a plain div.
60+
vi.mock('../components/UnifiedSidebar', () => ({
61+
UnifiedSidebar: () => <nav data-testid="unified-sidebar" />,
62+
}));
63+
5764
// Mock @object-ui/layout AppShell
5865
vi.mock('@object-ui/layout', () => ({
5966
AppShell: ({ children, sidebar }: any) => (

apps/console/src/__tests__/ObjectManagerMetadataPipeline.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,20 @@ describe('Object Manager (Metadata Pipeline)', () => {
166166
ComponentRegistry.register('object-data-experience', mockWidget('data-experience-section'));
167167
ComponentRegistry.register('object-data-preview', mockWidget('data-preview-section'));
168168
ComponentRegistry.register('object-field-designer', mockWidget('field-management-section'));
169+
170+
// object-detail-tabs wraps all sub-widgets in the PageSchema-driven detail view.
171+
// The mock renders all sections inline so tests can find each by testid without
172+
// simulating tab-switching interactions.
173+
ComponentRegistry.register('object-detail-tabs', (props: any) => (
174+
<div data-testid="mock-object-detail-tabs">
175+
<div data-testid="object-properties" data-object-name={props?.schema?.objectName}>object-properties</div>
176+
<div data-testid="field-management-section" data-object-name={props?.schema?.objectName}>field-management-section</div>
177+
<div data-testid="relationships-section" data-object-name={props?.schema?.objectName}>relationships-section</div>
178+
<div data-testid="keys-section" data-object-name={props?.schema?.objectName}>keys-section</div>
179+
<div data-testid="data-experience-section" data-object-name={props?.schema?.objectName}>data-experience-section</div>
180+
<div data-testid="data-preview-section" data-object-name={props?.schema?.objectName}>data-preview-section</div>
181+
</div>
182+
));
169183
});
170184

171185
// =========================================================================

apps/console/src/__tests__/app-creation-integration.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import '@testing-library/jest-dom';
1818
import { MemoryRouter, Routes, Route } from 'react-router-dom';
1919
import { AppContent } from '../App';
2020
import { CommandPalette } from '../components/CommandPalette';
21+
import { NavigationProvider } from '../context/NavigationContext';
2122

2223
// --- Mocks ---
2324

@@ -286,11 +287,13 @@ describe('Console App Creation Integration', () => {
286287

287288
const renderApp = (initialRoute = '/apps/sales/') => {
288289
return render(
290+
<NavigationProvider>
289291
<MemoryRouter initialEntries={[initialRoute]}>
290292
<Routes>
291293
<Route path="/apps/:appName/*" element={<AppContent />} />
292294
</Routes>
293-
</MemoryRouter>,
295+
</MemoryRouter>
296+
</NavigationProvider>,
294297
);
295298
};
296299

apps/console/src/components/UnifiedSidebar.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,24 +132,28 @@ function useNavOrder(appName: string) {
132132

133133
/**
134134
* Resolve a Lucide icon component by name string.
135+
* Safely handles both exact names and kebab-case → PascalCase conversion.
136+
* The try/catch guards against strict module proxy environments (e.g. vitest mocks).
135137
*/
136138
function getIcon(name?: string): React.ComponentType<any> {
137139
if (!name) return LucideIcons.Database;
138140

139-
if ((LucideIcons as any)[name]) {
140-
return (LucideIcons as any)[name];
141-
}
141+
const lookup = (key: string): React.ComponentType<any> | undefined => {
142+
try {
143+
const icon = (LucideIcons as Record<string, unknown>)[key];
144+
return typeof icon === 'function' ? (icon as React.ComponentType<any>) : undefined;
145+
} catch {
146+
return undefined;
147+
}
148+
};
142149

150+
// Try exact match first, then convert kebab-case / lowercase to PascalCase
143151
const pascalName = name
144-
.split('-')
152+
.split(/[-_]/)
145153
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
146154
.join('');
147155

148-
if ((LucideIcons as any)[pascalName]) {
149-
return (LucideIcons as any)[pascalName];
150-
}
151-
152-
return LucideIcons.Database;
156+
return lookup(name) ?? lookup(pascalName) ?? LucideIcons.Database;
153157
}
154158

155159
interface UnifiedSidebarProps {

0 commit comments

Comments
 (0)