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: () =>