Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -520,6 +521,7 @@ export function App() {
<ConditionalAuthWrapper authUrl="/api/v1/auth">
<PreviewBanner />
<NavigationProvider>
<FavoritesProvider>
<BrowserRouter basename={import.meta.env.BASE_URL?.replace(/\/$/, '') || '/'}>
<Suspense fallback={<LoadingScreen />}>
<Routes>
Expand Down Expand Up @@ -571,6 +573,7 @@ export function App() {
</Routes>
</Suspense>
</BrowserRouter>
</FavoritesProvider>
</NavigationProvider>
</ConditionalAuthWrapper>
</ThemeProvider>
Expand Down
10 changes: 10 additions & 0 deletions apps/console/src/__tests__/BrowserSimulation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<NavigationProvider>
<FavoritesProvider>
<MemoryRouter initialEntries={[initialRoute]}>
<AppContent />
</MemoryRouter>
</FavoritesProvider>
</NavigationProvider>
);
};

Expand Down Expand Up @@ -832,9 +838,13 @@ describe('Fields Integration', () => {
describe('Dashboard Integration', () => {
const renderApp = (initialRoute: string) => {
return render(
<NavigationProvider>
<FavoritesProvider>
<MemoryRouter initialEntries={[initialRoute]}>
<AppContent />
</MemoryRouter>
</FavoritesProvider>
</NavigationProvider>
);
};

Expand Down
6 changes: 6 additions & 0 deletions apps/console/src/__tests__/ConsoleApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

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

const renderApp = (initialRoute = '/apps/sales/') => {
return render(
<NavigationProvider>
<FavoritesProvider>
<MemoryRouter initialEntries={[initialRoute]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>
</FavoritesProvider>
</NavigationProvider>
);
};

Expand Down
69 changes: 59 additions & 10 deletions apps/console/src/__tests__/Favorites.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = (() => {
Expand All @@ -18,19 +20,24 @@ const localStorageMock = (() => {

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

/** Wrapper that provides the FavoritesProvider for renderHook */
function wrapper({ children }: { children: ReactNode }) {
return <FavoritesProvider>{children}</FavoritesProvider>;
}

describe('useFavorites', () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});

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({
Expand All @@ -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({
Expand All @@ -73,7 +80,7 @@ describe('useFavorites', () => {
});

it('removes a favorite', () => {
const { result } = renderHook(() => useFavorites());
const { result } = renderHook(() => useFavorites(), { wrapper });

act(() => {
result.current.addFavorite({
Expand All @@ -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',
Expand All @@ -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);

Expand All @@ -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(() => {
Expand All @@ -151,7 +158,7 @@ describe('useFavorites', () => {
});

it('clears all favorites', () => {
const { result } = renderHook(() => useFavorites());
const { result } = renderHook(() => useFavorites(), { wrapper });

act(() => {
result.current.addFavorite({
Expand All @@ -170,7 +177,7 @@ describe('useFavorites', () => {
});

it('persists favorites to localStorage', () => {
const { result } = renderHook(() => useFavorites());
const { result } = renderHook(() => useFavorites(), { wrapper });

act(() => {
result.current.addFavorite({
Expand All @@ -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();
});
});
7 changes: 7 additions & 0 deletions apps/console/src/__tests__/HomeLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <nav data-testid="unified-sidebar" />,
}));

// Mock @object-ui/layout AppShell
vi.mock('@object-ui/layout', () => ({
AppShell: ({ children, sidebar }: any) => (
Expand Down
14 changes: 14 additions & 0 deletions apps/console/src/__tests__/ObjectManagerMetadataPipeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div data-testid="mock-object-detail-tabs">
<div data-testid="object-properties" data-object-name={props?.schema?.objectName}>object-properties</div>
<div data-testid="field-management-section" data-object-name={props?.schema?.objectName}>field-management-section</div>
<div data-testid="relationships-section" data-object-name={props?.schema?.objectName}>relationships-section</div>
<div data-testid="keys-section" data-object-name={props?.schema?.objectName}>keys-section</div>
<div data-testid="data-experience-section" data-object-name={props?.schema?.objectName}>data-experience-section</div>
<div data-testid="data-preview-section" data-object-name={props?.schema?.objectName}>data-preview-section</div>
</div>
));
});

// =========================================================================
Expand Down
5 changes: 4 additions & 1 deletion apps/console/src/__tests__/app-creation-integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

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

const renderApp = (initialRoute = '/apps/sales/') => {
return render(
<NavigationProvider>
<MemoryRouter initialEntries={[initialRoute]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>,
</MemoryRouter>
</NavigationProvider>,
);
};

Expand Down
25 changes: 13 additions & 12 deletions apps/console/src/components/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,9 @@ import {
AvatarImage,
AvatarFallback,
useSidebar,
Button,
} from '@object-ui/components';
import {
ChevronsUpDown,
Plus,
Settings,
LogOut,
Database,
Expand All @@ -55,7 +53,6 @@ import {
Pencil,
ChevronRight,
Home,
Grid3x3,
HelpCircle,
ArrowLeft,
Layers,
Expand Down Expand Up @@ -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<any> {
if (!name) return LucideIcons.Database;

if ((LucideIcons as any)[name]) {
return (LucideIcons as any)[name];
}
const lookup = (key: string): React.ComponentType<any> | undefined => {
try {
const icon = (LucideIcons as Record<string, unknown>)[key];
return typeof icon === 'function' ? (icon as React.ComponentType<any>) : 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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/components/schema/objectDetailWidgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export function ObjectRelationshipsWidget({ schema }: { schema: ObjectWidgetSche
</h3>
{hasRelationships ? (
<div className="space-y-3">
{object.relationships.map((rel, i) => (
{(object.relationships ?? []).map((rel, i) => (
<div
key={i}
className="flex items-start gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
Expand Down
Loading
Loading