diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47aa60845..9ed91bf3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- **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.
+
### Changed
- **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.
diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx
index 2ac4588e8..aed53f8f7 100644
--- a/apps/console/src/App.tsx
+++ b/apps/console/src/App.tsx
@@ -63,6 +63,7 @@ const HomeLayout = lazy(() => import('./pages/home/HomeLayout').then(m => ({ def
import { ThemeProvider } from './components/theme-provider';
import { ConsoleToaster } from './components/ConsoleToaster';
import { NavigationProvider } from './context/NavigationContext';
+import { FavoritesProvider } from './context/FavoritesProvider';
/**
* ConnectedShell
@@ -520,6 +521,7 @@ export function App() {
+
}>
@@ -571,6 +573,7 @@ export function App() {
+
diff --git a/apps/console/src/__tests__/BrowserSimulation.test.tsx b/apps/console/src/__tests__/BrowserSimulation.test.tsx
index 235e6d0c5..c8c2cb869 100644
--- a/apps/console/src/__tests__/BrowserSimulation.test.tsx
+++ b/apps/console/src/__tests__/BrowserSimulation.test.tsx
@@ -131,15 +131,21 @@ vi.mock('../context/MetadataProvider', async () => {
// --- 2. Import AppContent ---
import { AppContent } from '../App';
+import { NavigationProvider } from '../context/NavigationContext';
+import { FavoritesProvider } from '../context/FavoritesProvider';
describe('Console Application Simulation', () => {
// Helper to render App at specific route
const renderApp = (initialRoute: string) => {
return render(
+
+
+
+
);
};
@@ -832,9 +838,13 @@ describe('Fields Integration', () => {
describe('Dashboard Integration', () => {
const renderApp = (initialRoute: string) => {
return render(
+
+
+
+
);
};
diff --git a/apps/console/src/__tests__/ConsoleApp.test.tsx b/apps/console/src/__tests__/ConsoleApp.test.tsx
index 75a5c2c08..715804ecb 100644
--- a/apps/console/src/__tests__/ConsoleApp.test.tsx
+++ b/apps/console/src/__tests__/ConsoleApp.test.tsx
@@ -3,6 +3,8 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { AppContent } from '../App';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import { NavigationProvider } from '../context/NavigationContext';
+import { FavoritesProvider } from '../context/FavoritesProvider';
// --- Mocks ---
@@ -181,11 +183,15 @@ describe('Console App Integration', () => {
const renderApp = (initialRoute = '/apps/sales/') => {
return render(
+
+
} />
+
+
);
};
diff --git a/apps/console/src/__tests__/Favorites.test.tsx b/apps/console/src/__tests__/Favorites.test.tsx
index 4f8b3500e..3ec3aa77c 100644
--- a/apps/console/src/__tests__/Favorites.test.tsx
+++ b/apps/console/src/__tests__/Favorites.test.tsx
@@ -1,9 +1,11 @@
/**
- * Tests for useFavorites hook
+ * Tests for useFavorites hook (via FavoritesProvider context)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
+import type { ReactNode } from 'react';
import { useFavorites } from '../hooks/useFavorites';
+import { FavoritesProvider } from '../context/FavoritesProvider';
// Mock localStorage
const localStorageMock = (() => {
@@ -18,6 +20,11 @@ const localStorageMock = (() => {
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+/** Wrapper that provides the FavoritesProvider for renderHook */
+function wrapper({ children }: { children: ReactNode }) {
+ return {children};
+}
+
describe('useFavorites', () => {
beforeEach(() => {
localStorageMock.clear();
@@ -25,12 +32,12 @@ describe('useFavorites', () => {
});
it('starts with empty favorites when localStorage is empty', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
expect(result.current.favorites).toEqual([]);
});
it('adds a favorite item', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
act(() => {
result.current.addFavorite({
@@ -48,7 +55,7 @@ describe('useFavorites', () => {
});
it('does not add duplicate favorites', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
act(() => {
result.current.addFavorite({
@@ -73,7 +80,7 @@ describe('useFavorites', () => {
});
it('removes a favorite', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
act(() => {
result.current.addFavorite({
@@ -92,7 +99,7 @@ describe('useFavorites', () => {
});
it('toggles a favorite on and off', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
const item = {
id: 'dashboard:sales',
label: 'Sales Dashboard',
@@ -116,7 +123,7 @@ describe('useFavorites', () => {
});
it('checks if an item is a favorite', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
expect(result.current.isFavorite('object:contact')).toBe(false);
@@ -134,7 +141,7 @@ describe('useFavorites', () => {
});
it('limits to max 20 favorites', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
for (let i = 0; i < 25; i++) {
act(() => {
@@ -151,7 +158,7 @@ describe('useFavorites', () => {
});
it('clears all favorites', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
act(() => {
result.current.addFavorite({
@@ -170,7 +177,7 @@ describe('useFavorites', () => {
});
it('persists favorites to localStorage', () => {
- const { result } = renderHook(() => useFavorites());
+ const { result } = renderHook(() => useFavorites(), { wrapper });
act(() => {
result.current.addFavorite({
@@ -186,4 +193,46 @@ describe('useFavorites', () => {
expect.any(String),
);
});
+
+ it('two hooks sharing the same provider see each other\'s mutations (cross-component reactivity)', () => {
+ // Both hooks are called within the same render, sharing the same provider.
+ // This simulates the real scenario where AppCard (consumer A) toggles a favorite
+ // and HomePage (consumer B) should immediately see the updated state.
+ const { result } = renderHook(
+ () => ({ hookA: useFavorites(), hookB: useFavorites() }),
+ { wrapper },
+ );
+
+ // Hook A adds a favorite
+ act(() => {
+ result.current.hookA.addFavorite({
+ id: 'app:crm',
+ label: 'CRM',
+ href: '/apps/crm',
+ type: 'object',
+ });
+ });
+
+ // Hook B (simulating HomePage reading favorites) must see the update
+ expect(result.current.hookB.favorites).toHaveLength(1);
+ expect(result.current.hookB.isFavorite('app:crm')).toBe(true);
+
+ // Hook B removes the favorite
+ act(() => {
+ result.current.hookB.removeFavorite('app:crm');
+ });
+
+ // Hook A must see the removal
+ expect(result.current.hookA.favorites).toHaveLength(0);
+ expect(result.current.hookA.isFavorite('app:crm')).toBe(false);
+ });
+
+ it('throws when used outside FavoritesProvider', () => {
+ // Suppress the expected React error boundary console output
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(() => renderHook(() => useFavorites())).toThrow(
+ 'useFavorites must be used within a FavoritesProvider',
+ );
+ spy.mockRestore();
+ });
});
diff --git a/apps/console/src/__tests__/HomeLayout.test.tsx b/apps/console/src/__tests__/HomeLayout.test.tsx
index 8ec20ef3b..130966895 100644
--- a/apps/console/src/__tests__/HomeLayout.test.tsx
+++ b/apps/console/src/__tests__/HomeLayout.test.tsx
@@ -54,6 +54,13 @@ vi.mock('@object-ui/components', async (importOriginal) => {
};
});
+// Mock UnifiedSidebar entirely — HomeLayout tests verify layout composition,
+// not the sidebar's internal rendering. This also avoids SidebarProvider
+// dependency when AppShell is mocked as a plain div.
+vi.mock('../components/UnifiedSidebar', () => ({
+ UnifiedSidebar: () => ,
+}));
+
// Mock @object-ui/layout AppShell
vi.mock('@object-ui/layout', () => ({
AppShell: ({ children, sidebar }: any) => (
diff --git a/apps/console/src/__tests__/ObjectManagerMetadataPipeline.test.tsx b/apps/console/src/__tests__/ObjectManagerMetadataPipeline.test.tsx
index 6c36923f4..7e008ad00 100644
--- a/apps/console/src/__tests__/ObjectManagerMetadataPipeline.test.tsx
+++ b/apps/console/src/__tests__/ObjectManagerMetadataPipeline.test.tsx
@@ -166,6 +166,20 @@ describe('Object Manager (Metadata Pipeline)', () => {
ComponentRegistry.register('object-data-experience', mockWidget('data-experience-section'));
ComponentRegistry.register('object-data-preview', mockWidget('data-preview-section'));
ComponentRegistry.register('object-field-designer', mockWidget('field-management-section'));
+
+ // object-detail-tabs wraps all sub-widgets in the PageSchema-driven detail view.
+ // The mock renders all sections inline so tests can find each by testid without
+ // simulating tab-switching interactions.
+ ComponentRegistry.register('object-detail-tabs', (props: any) => (
+
+
object-properties
+
field-management-section
+
relationships-section
+
keys-section
+
data-experience-section
+
data-preview-section
+
+ ));
});
// =========================================================================
diff --git a/apps/console/src/__tests__/app-creation-integration.test.tsx b/apps/console/src/__tests__/app-creation-integration.test.tsx
index 2c0afe637..603136eaf 100644
--- a/apps/console/src/__tests__/app-creation-integration.test.tsx
+++ b/apps/console/src/__tests__/app-creation-integration.test.tsx
@@ -18,6 +18,7 @@ import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { AppContent } from '../App';
import { CommandPalette } from '../components/CommandPalette';
+import { NavigationProvider } from '../context/NavigationContext';
// --- Mocks ---
@@ -286,11 +287,13 @@ describe('Console App Creation Integration', () => {
const renderApp = (initialRoute = '/apps/sales/') => {
return render(
+
} />
- ,
+
+ ,
);
};
diff --git a/apps/console/src/components/UnifiedSidebar.tsx b/apps/console/src/components/UnifiedSidebar.tsx
index a29c4b361..318c985d4 100644
--- a/apps/console/src/components/UnifiedSidebar.tsx
+++ b/apps/console/src/components/UnifiedSidebar.tsx
@@ -40,11 +40,9 @@ import {
AvatarImage,
AvatarFallback,
useSidebar,
- Button,
} from '@object-ui/components';
import {
ChevronsUpDown,
- Plus,
Settings,
LogOut,
Database,
@@ -55,7 +53,6 @@ import {
Pencil,
ChevronRight,
Home,
- Grid3x3,
HelpCircle,
ArrowLeft,
Layers,
@@ -135,24 +132,28 @@ function useNavOrder(appName: string) {
/**
* Resolve a Lucide icon component by name string.
+ * Safely handles both exact names and kebab-case → PascalCase conversion.
+ * The try/catch guards against strict module proxy environments (e.g. vitest mocks).
*/
function getIcon(name?: string): React.ComponentType {
if (!name) return LucideIcons.Database;
- if ((LucideIcons as any)[name]) {
- return (LucideIcons as any)[name];
- }
+ const lookup = (key: string): React.ComponentType | undefined => {
+ try {
+ const icon = (LucideIcons as Record)[key];
+ return typeof icon === 'function' ? (icon as React.ComponentType) : undefined;
+ } catch {
+ return undefined;
+ }
+ };
+ // Try exact match first, then convert kebab-case / lowercase to PascalCase
const pascalName = name
- .split('-')
+ .split(/[-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
- if ((LucideIcons as any)[pascalName]) {
- return (LucideIcons as any)[pascalName];
- }
-
- return LucideIcons.Database;
+ return lookup(name) ?? lookup(pascalName) ?? LucideIcons.Database;
}
interface UnifiedSidebarProps {
diff --git a/apps/console/src/components/schema/objectDetailWidgets.tsx b/apps/console/src/components/schema/objectDetailWidgets.tsx
index 884710857..f51fd702a 100644
--- a/apps/console/src/components/schema/objectDetailWidgets.tsx
+++ b/apps/console/src/components/schema/objectDetailWidgets.tsx
@@ -161,7 +161,7 @@ export function ObjectRelationshipsWidget({ schema }: { schema: ObjectWidgetSche
{hasRelationships ? (
- {object.relationships.map((rel, i) => (
+ {(object.relationships ?? []).map((rel, i) => (
` and `save(items: FavoriteItem[]): Promise`.
+ * When the adapter is provided, localStorage should be used only as a fallback
+ * during the initial load while the server response is in-flight.
+ *
+ * @module
+ */
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface FavoriteItem {
+ /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
+ id: string;
+ label: string;
+ href: string;
+ type: 'object' | 'dashboard' | 'page' | 'report';
+ /** ISO timestamp of when the item was favorited */
+ favoritedAt: string;
+}
+
+interface FavoritesContextValue {
+ favorites: FavoriteItem[];
+ addFavorite: (item: Omit) => void;
+ removeFavorite: (id: string) => void;
+ toggleFavorite: (item: Omit) => void;
+ isFavorite: (id: string) => boolean;
+ clearFavorites: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// Storage helpers
+// ---------------------------------------------------------------------------
+
+const STORAGE_KEY = 'objectui-favorites';
+const MAX_FAVORITES = 20;
+
+function loadFavorites(): FavoriteItem[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveFavorites(items: FavoriteItem[]) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
+ } catch {
+ // Storage full — silently ignore
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+const FavoritesContext = createContext(null);
+
+// ---------------------------------------------------------------------------
+// Provider
+// ---------------------------------------------------------------------------
+
+interface FavoritesProviderProps {
+ children: ReactNode;
+}
+
+export function FavoritesProvider({ children }: FavoritesProviderProps) {
+ const [favorites, setFavorites] = useState(loadFavorites);
+
+ // Re-sync from storage on mount (handles cases where storage was written
+ // before this provider was mounted, e.g. on initial page load).
+ useEffect(() => {
+ setFavorites(loadFavorites());
+ }, []);
+
+ const addFavorite = useCallback(
+ (item: Omit) => {
+ setFavorites(prev => {
+ if (prev.some(f => f.id === item.id)) return prev;
+ const updated = [
+ { ...item, favoritedAt: new Date().toISOString() },
+ ...prev,
+ ].slice(0, MAX_FAVORITES);
+ saveFavorites(updated);
+ return updated;
+ });
+ },
+ [],
+ );
+
+ const removeFavorite = useCallback((id: string) => {
+ setFavorites(prev => {
+ const updated = prev.filter(f => f.id !== id);
+ saveFavorites(updated);
+ return updated;
+ });
+ }, []);
+
+ const toggleFavorite = useCallback(
+ (item: Omit) => {
+ setFavorites(prev => {
+ const exists = prev.some(f => f.id === item.id);
+ const updated = exists
+ ? prev.filter(f => f.id !== item.id)
+ : [{ ...item, favoritedAt: new Date().toISOString() }, ...prev].slice(
+ 0,
+ MAX_FAVORITES,
+ );
+ saveFavorites(updated);
+ return updated;
+ });
+ },
+ [],
+ );
+
+ const clearFavorites = useCallback(() => {
+ setFavorites([]);
+ saveFavorites([]);
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ favorites,
+ addFavorite,
+ removeFavorite,
+ toggleFavorite,
+ // Inlined here so useMemo sees the freshest `favorites` without needing
+ // a separate useCallback([favorites]) entry in the deps array.
+ isFavorite: (id: string) => favorites.some(f => f.id === id),
+ clearFavorites,
+ }),
+ // addFavorite / removeFavorite / toggleFavorite / clearFavorites are all
+ // stable (useCallback with [] deps) and never cause extra re-renders.
+ [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+/**
+ * Access shared favorites state.
+ *
+ * Must be used inside ``.
+ */
+export function useFavorites(): FavoritesContextValue {
+ const ctx = useContext(FavoritesContext);
+ if (!ctx) {
+ throw new Error('useFavorites must be used within a FavoritesProvider');
+ }
+ return ctx;
+}
diff --git a/apps/console/src/hooks/useFavorites.ts b/apps/console/src/hooks/useFavorites.ts
index 428322360..24170b87b 100644
--- a/apps/console/src/hooks/useFavorites.ts
+++ b/apps/console/src/hooks/useFavorites.ts
@@ -1,102 +1,14 @@
/**
- * useFavorites
+ * useFavorites — re-export shim
*
- * Tracks user-favorited items (objects, dashboards, pages, reports) with
- * localStorage persistence. Exposes helpers to toggle, add, remove, and
- * retrieve favorites.
+ * The favorites state has been migrated to a React Context so all consumers
+ * share a single state instance (fixes star toggle not updating HomePage).
+ *
+ * All existing imports of `useFavorites` and `FavoriteItem` from this path
+ * continue to work without any changes at the call sites.
+ *
+ * @see apps/console/src/context/FavoritesProvider.tsx
* @module
*/
-import { useState, useCallback, useEffect } from 'react';
-
-export interface FavoriteItem {
- /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
- id: string;
- label: string;
- href: string;
- type: 'object' | 'dashboard' | 'page' | 'report';
- /** ISO timestamp of when the item was favorited */
- favoritedAt: string;
-}
-
-const STORAGE_KEY = 'objectui-favorites';
-const MAX_FAVORITES = 20;
-
-function loadFavorites(): FavoriteItem[] {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- return raw ? JSON.parse(raw) : [];
- } catch {
- return [];
- }
-}
-
-function saveFavorites(items: FavoriteItem[]) {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
- } catch {
- // Storage full — silently ignore
- }
-}
-
-export function useFavorites() {
- const [favorites, setFavorites] = useState(loadFavorites);
-
- // Sync from storage on mount
- useEffect(() => {
- setFavorites(loadFavorites());
- }, []);
-
- const addFavorite = useCallback(
- (item: Omit) => {
- setFavorites(prev => {
- // Don't add duplicates
- if (prev.some(f => f.id === item.id)) return prev;
- const updated = [
- { ...item, favoritedAt: new Date().toISOString() },
- ...prev,
- ].slice(0, MAX_FAVORITES);
- saveFavorites(updated);
- return updated;
- });
- },
- [],
- );
-
- const removeFavorite = useCallback((id: string) => {
- setFavorites(prev => {
- const updated = prev.filter(f => f.id !== id);
- saveFavorites(updated);
- return updated;
- });
- }, []);
-
- const toggleFavorite = useCallback(
- (item: Omit) => {
- setFavorites(prev => {
- const exists = prev.some(f => f.id === item.id);
- const updated = exists
- ? prev.filter(f => f.id !== item.id)
- : [{ ...item, favoritedAt: new Date().toISOString() }, ...prev].slice(
- 0,
- MAX_FAVORITES,
- );
- saveFavorites(updated);
- return updated;
- });
- },
- [],
- );
-
- const isFavorite = useCallback(
- (id: string) => favorites.some(f => f.id === id),
- [favorites],
- );
-
- const clearFavorites = useCallback(() => {
- setFavorites([]);
- saveFavorites([]);
- }, []);
-
- return { favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite, clearFavorites };
-}
+export { useFavorites, type FavoriteItem } from '../context/FavoritesProvider';