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
3 changes: 3 additions & 0 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,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';

/**
* ConnectedShell
Expand Down Expand Up @@ -518,6 +519,7 @@ export function App() {
<ConsoleToaster position="bottom-right" />
<ConditionalAuthWrapper authUrl="/api/v1/auth">
<PreviewBanner />
<NavigationProvider>
<BrowserRouter basename={import.meta.env.BASE_URL?.replace(/\/$/, '') || '/'}>
<Suspense fallback={<LoadingScreen />}>
Comment on lines +522 to 524
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NavigationProvider is currently rendered outside BrowserRouter, which prevents it from using react-router APIs (e.g. useLocation) to initialize/update navigation context from the current route. If you want to avoid the initial Home→App sidebar flicker, consider moving NavigationProvider inside BrowserRouter (or otherwise derive initial context from window.location).

Copilot uses AI. Check for mistakes.
<Routes>
Expand Down Expand Up @@ -569,6 +571,7 @@ export function App() {
</Routes>
</Suspense>
</BrowserRouter>
</NavigationProvider>
</ConditionalAuthWrapper>
</ThemeProvider>
);
Expand Down
129 changes: 83 additions & 46 deletions apps/console/src/__tests__/HomeLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* Tests for HomeLayout — lightweight nav shell for /home.
* Validates: layout rendering, user avatar, navigation links.
* Tests for HomeLayout — unified sidebar nav shell for /home.
* Validates: layout rendering, sidebar presence, navigation context.
*
* Note: Radix DropdownMenu portal rendering is limited in jsdom,
* so we test the trigger and visible elements rather than dropdown contents.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import { HomeLayout } from '../pages/home/HomeLayout';
Expand Down Expand Up @@ -40,6 +40,9 @@ vi.mock('@object-ui/i18n', () => ({
direction: 'ltr',
i18n: {},
}),
useObjectLabel: () => ({
objectLabel: ({ name, label }: any) => label || name,
}),
}));

// Mock @object-ui/components to keep most components
Expand All @@ -51,6 +54,77 @@ vi.mock('@object-ui/components', async (importOriginal) => {
};
});

// Mock @object-ui/layout AppShell
vi.mock('@object-ui/layout', () => ({
AppShell: ({ children, sidebar }: any) => (
<div data-testid="app-shell">
<div data-testid="sidebar">{sidebar}</div>
<div data-testid="content">{children}</div>
</div>
),
useAppShellBranding: () => {},
}));

// Mock NavigationContext
const mockSetContext = vi.fn();
vi.mock('../context/NavigationContext', () => ({
useNavigationContext: () => ({
context: 'home',
setContext: mockSetContext,
currentAppName: undefined,
setCurrentAppName: vi.fn(),
}),
}));

// Mock MetadataProvider
vi.mock('../context/MetadataProvider', () => ({
useMetadata: () => ({
apps: [],
objects: [],
loading: false,
}),
}));

// Mock other required contexts
vi.mock('../context/ExpressionProvider', () => ({
useExpressionContext: () => ({
evaluator: {},
}),
evaluateVisibility: () => true,
}));

vi.mock('@object-ui/permissions', () => ({
usePermissions: () => ({
can: () => true,
}),
}));

vi.mock('../hooks/useRecentItems', () => ({
useRecentItems: () => ({
recentItems: [],
addRecentItem: vi.fn(),
}),
}));

vi.mock('../hooks/useFavorites', () => ({
useFavorites: () => ({
favorites: [],
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
}),
}));

vi.mock('../hooks/useNavPins', () => ({
useNavPins: () => ({
togglePin: vi.fn(),
applyPins: (items: any[]) => items,
}),
}));

vi.mock('../hooks/useResponsiveSidebar', () => ({
useResponsiveSidebar: () => {},
}));

describe('HomeLayout', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -64,57 +138,20 @@ describe('HomeLayout', () => {
);
};

it('renders the layout shell with data-testid', () => {
it('renders the AppShell with sidebar and content', () => {
renderLayout();
expect(screen.getByTestId('home-layout')).toBeInTheDocument();
expect(screen.getByTestId('app-shell')).toBeInTheDocument();
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('content')).toBeInTheDocument();
});

it('renders the Home branding button in the top bar', () => {
it('sets navigation context to "home" on mount', () => {
renderLayout();
const brand = screen.getByTestId('home-layout-brand');
expect(brand).toBeInTheDocument();
expect(brand).toHaveTextContent('Home');
expect(mockSetContext).toHaveBeenCalledWith('home');
});

it('renders children inside the layout', () => {
renderLayout(<div data-testid="child-content">Hello World</div>);
expect(screen.getByTestId('child-content')).toBeInTheDocument();
});

it('renders user avatar with initials fallback', () => {
renderLayout();
// The avatar trigger should show user initials "AD" (Alice Dev)
expect(screen.getByTestId('home-layout-user-trigger')).toBeInTheDocument();
expect(screen.getByText('AD')).toBeInTheDocument();
});

it('renders Settings button in the top bar', () => {
renderLayout();
expect(screen.getByTestId('home-layout-settings-btn')).toBeInTheDocument();
});

it('navigates to /system when Settings button is clicked', () => {
renderLayout();
fireEvent.click(screen.getByTestId('home-layout-settings-btn'));
expect(mockNavigate).toHaveBeenCalledWith('/system');
});

it('navigates to /home when brand button is clicked', () => {
renderLayout();
fireEvent.click(screen.getByTestId('home-layout-brand'));
expect(mockNavigate).toHaveBeenCalledWith('/home');
});

it('renders sticky header element', () => {
renderLayout();
const header = screen.getByTestId('home-layout').querySelector('header');
expect(header).toBeInTheDocument();
expect(header?.className).toContain('sticky');
});

it('renders user menu trigger as a round button', () => {
renderLayout();
const trigger = screen.getByTestId('home-layout-user-trigger');
expect(trigger.className).toContain('rounded-full');
});
});
31 changes: 20 additions & 11 deletions apps/console/src/components/ConsoleLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
* ConsoleLayout
*
* Root layout shell for the console application. Composes the AppShell
* with the sidebar, header, and main content area.
* with the UnifiedSidebar, header, and main content area.
* Includes the global floating chatbot (FAB) widget.
* Sets navigation context to 'app' for app-specific routes.
* @module
*/

import React from 'react';
import React, { useEffect } from 'react';
import { AppShell } from '@object-ui/layout';
import { FloatingChatbot, useObjectChat, type ChatMessage } from '@object-ui/plugin-chatbot';
import { useDiscovery } from '@object-ui/react';
import { AppSidebar } from './AppSidebar';
import { UnifiedSidebar } from './UnifiedSidebar';
import { AppHeader } from './AppHeader';
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
import { useNavigationContext } from '../context/NavigationContext';
import { resolveI18nLabel } from '../utils';
import type { ConnectionState } from '../dataSource';

Expand Down Expand Up @@ -88,28 +90,35 @@ function ConsoleFloatingChatbot({ appLabel, objects }: { appLabel: string; objec
);
}

export function ConsoleLayout({
children,
activeAppName,
export function ConsoleLayout({
children,
activeAppName,
activeApp,
onAppChange,
objects,
connectionState
}: ConsoleLayoutProps) {
const appLabel = resolveI18nLabel(activeApp?.label) || activeAppName;
const { isAiEnabled } = useDiscovery();
const { setContext, setCurrentAppName } = useNavigationContext();

// Set navigation context to 'app' when this layout mounts
useEffect(() => {
setContext('app');
setCurrentAppName(activeAppName);
}, [setContext, setCurrentAppName, activeAppName]);

return (
<AppShell
sidebar={
<AppSidebar
activeAppName={activeAppName}
onAppChange={onAppChange}
<UnifiedSidebar
activeAppName={activeAppName}
onAppChange={onAppChange}
/>
}
navbar={
<AppHeader
appName={appLabel}
<AppHeader
appName={appLabel}
objects={objects}
connectionState={connectionState}
/>
Expand Down
Loading
Loading