From 0bd9d92584af3d7a4942c7d25c4608fae747e40f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:24:01 +0000 Subject: [PATCH 1/4] Initial plan From a8455e082b0446abb954bae6fc56ff61c25c66db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:38:48 +0000 Subject: [PATCH 2/4] fix(console): migrate useFavorites to React Context (FavoritesProvider) to fix star toggle reactivity Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/26bb233a-d5c2-4042-8361-e6c14d430389 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 4 + apps/console/src/App.tsx | 3 + apps/console/src/__tests__/Favorites.test.tsx | 69 ++++++- .../console/src/context/FavoritesProvider.tsx | 186 ++++++++++++++++++ apps/console/src/hooks/useFavorites.ts | 106 +--------- 5 files changed, 261 insertions(+), 107 deletions(-) create mode 100644 apps/console/src/context/FavoritesProvider.tsx 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__/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/context/FavoritesProvider.tsx b/apps/console/src/context/FavoritesProvider.tsx new file mode 100644 index 000000000..d4a1c314f --- /dev/null +++ b/apps/console/src/context/FavoritesProvider.tsx @@ -0,0 +1,186 @@ +/** + * FavoritesProvider + * + * React Context + Provider for shared favorites state across all consumers + * (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps). + * + * Replaces the standalone `useFavorites` hook pattern — all callers now share + * a single state instance so that toggling a star in AppCard immediately + * reflects in HomePage's Starred section and sidebar. + * + * Persistence: localStorage (key: "objectui-favorites", max 20 items). + * + * TODO: Migrate persistence to server-side storage via the adapter/API layer + * (e.g. PUT /api/user/preferences) so favorites sync across devices and browsers. + * The provider should accept an optional `persistenceAdapter` prop that implements + * `load(): Promise` 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'; From baa4b594d4a939a007c01a7039136f21f281c17b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:19:06 +0000 Subject: [PATCH 3/4] fix(ci): resolve all build and test errors Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/eb0d0766-8b77-462f-ac89-6dfeb9b0e31c Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/BrowserSimulation.test.tsx | 10 ++++++++ .../console/src/__tests__/ConsoleApp.test.tsx | 6 +++++ .../console/src/__tests__/HomeLayout.test.tsx | 7 ++++++ .../ObjectManagerMetadataPipeline.test.tsx | 14 +++++++++++ .../app-creation-integration.test.tsx | 5 +++- .../console/src/components/UnifiedSidebar.tsx | 25 ++++++++++--------- .../components/schema/objectDetailWidgets.tsx | 2 +- 7 files changed, 55 insertions(+), 14 deletions(-) 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__/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: () =>