Skip to content

Commit 905c24c

Browse files
authored
Merge pull request #1172 from objectstack-ai/copilot/add-user-menu-and-navigation
2 parents 334afb1 + d7f3c65 commit 905c24c

8 files changed

Lines changed: 273 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Home page user menu** (`@object-ui/console`): Added complete user menu dropdown (Profile, Settings, Sign Out) to the Home Dashboard via new `HomeLayout` shell component. Users can now access account actions directly from the `/home` page without navigating elsewhere.
13+
14+
- **"Return to Home" navigation** (`@object-ui/console`): Added a "Home" entry in the AppSidebar app switcher dropdown, allowing users to navigate back to `/home` from any application context. Previously, the only way to return to the Home Dashboard was to manually edit the URL.
15+
16+
- **HomeLayout shell** (`@object-ui/console`): New lightweight layout wrapper for the Home Dashboard providing a sticky top navigation bar with Home branding, Settings, and user menu dropdown. Replaces bare `<HomePage />` rendering with a consistent navigation frame for future extensibility (notifications, global guide, unified theming).
17+
1018
### Fixed
1119

1220
- **Charts groupBy value→label resolution** (`@object-ui/plugin-charts`): Chart X-axis labels now display human-readable labels instead of raw values. Select/picklist fields resolve value→label via field metadata options, lookup/master_detail fields batch-fetch referenced record names, and all other fields fall back to `humanizeLabel()` (snake_case → Title Case). Removed hardcoded `value.slice(0, 3)` truncation from `AdvancedChartImpl.tsx` XAxis tick formatters — desktop now shows full labels with angle rotation for long text, mobile truncates at 8 characters with "…".

ROADMAP.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
235235
- [x] **RootRedirect update** — Root path (`/`) now redirects to `/home` instead of first app
236236
- [x] **Responsive design** — Mobile-friendly grid layouts that adapt to screen size
237237
- [x] **Airtable/Notion UX pattern** — Inspired by industry-leading workspace home pages
238+
- [x] **HomeLayout shell** — Lightweight layout wrapper with sticky top nav bar, Home branding, and user menu dropdown (Profile, Settings, Sign Out)
239+
- [x] **Home page user menu** — Complete user menu dropdown in HomeLayout header with avatar, name, email, Profile/Settings/Sign Out actions
240+
- [x] **Return-to-Home navigation** — "Home" entry in AppSidebar app switcher dropdown for navigating back to `/home` from any application
238241

239242
**Impact:** Users now have a unified workspace dashboard that provides overview of all applications, quick actions, and recent activity. This eliminates the previous behavior of auto-redirecting to the first app, giving users better control and visibility.
240243

apps/console/src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({
5757

5858
// Home Page (lazy — landing page)
5959
const HomePage = lazy(() => import('./pages/home/HomePage').then(m => ({ default: m.HomePage })));
60+
const HomeLayout = lazy(() => import('./pages/home/HomeLayout').then(m => ({ default: m.HomeLayout })));
6061

6162
import { useParams } from 'react-router-dom';
6263
import { ThemeProvider } from './components/theme-provider';
@@ -509,7 +510,9 @@ export function App() {
509510
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
510511
<ConnectedShell>
511512
<Suspense fallback={<LoadingScreen />}>
512-
<HomePage />
513+
<HomeLayout>
514+
<HomePage />
515+
</HomeLayout>
513516
</Suspense>
514517
</ConnectedShell>
515518
</AuthGuard>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Tests for HomeLayout — lightweight nav shell for /home.
3+
* Validates: layout rendering, user avatar, navigation links.
4+
*
5+
* Note: Radix DropdownMenu portal rendering is limited in jsdom,
6+
* so we test the trigger and visible elements rather than dropdown contents.
7+
*/
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { render, screen, fireEvent } from '@testing-library/react';
10+
import '@testing-library/jest-dom';
11+
import { MemoryRouter } from 'react-router-dom';
12+
import { HomeLayout } from '../pages/home/HomeLayout';
13+
14+
// --- Mocks ---
15+
16+
const mockNavigate = vi.fn();
17+
vi.mock('react-router-dom', async (importOriginal) => {
18+
const actual = await importOriginal<any>();
19+
return {
20+
...actual,
21+
useNavigate: () => mockNavigate,
22+
};
23+
});
24+
25+
const mockSignOut = vi.fn();
26+
vi.mock('@object-ui/auth', () => ({
27+
useAuth: () => ({
28+
user: { name: 'Alice Dev', email: 'alice@test.com', image: null },
29+
signOut: mockSignOut,
30+
isAuthenticated: true,
31+
}),
32+
getUserInitials: () => 'AD',
33+
}));
34+
35+
vi.mock('@object-ui/i18n', () => ({
36+
useObjectTranslation: () => ({
37+
t: (_key: string, opts?: any) => opts?.defaultValue ?? _key,
38+
language: 'en',
39+
changeLanguage: vi.fn(),
40+
direction: 'ltr',
41+
i18n: {},
42+
}),
43+
}));
44+
45+
// Mock @object-ui/components to keep most components
46+
vi.mock('@object-ui/components', async (importOriginal) => {
47+
const actual = await importOriginal<any>();
48+
return {
49+
...actual,
50+
TooltipProvider: ({ children }: any) => <div>{children}</div>,
51+
};
52+
});
53+
54+
describe('HomeLayout', () => {
55+
beforeEach(() => {
56+
vi.clearAllMocks();
57+
});
58+
59+
const renderLayout = (children: React.ReactNode = <div>Page Content</div>) => {
60+
return render(
61+
<MemoryRouter initialEntries={['/home']}>
62+
<HomeLayout>{children}</HomeLayout>
63+
</MemoryRouter>,
64+
);
65+
};
66+
67+
it('renders the layout shell with data-testid', () => {
68+
renderLayout();
69+
expect(screen.getByTestId('home-layout')).toBeInTheDocument();
70+
});
71+
72+
it('renders the Home branding button in the top bar', () => {
73+
renderLayout();
74+
const brand = screen.getByTestId('home-layout-brand');
75+
expect(brand).toBeInTheDocument();
76+
expect(brand).toHaveTextContent('Home');
77+
});
78+
79+
it('renders children inside the layout', () => {
80+
renderLayout(<div data-testid="child-content">Hello World</div>);
81+
expect(screen.getByTestId('child-content')).toBeInTheDocument();
82+
});
83+
84+
it('renders user avatar with initials fallback', () => {
85+
renderLayout();
86+
// The avatar trigger should show user initials "AD" (Alice Dev)
87+
expect(screen.getByTestId('home-layout-user-trigger')).toBeInTheDocument();
88+
expect(screen.getByText('AD')).toBeInTheDocument();
89+
});
90+
91+
it('renders Settings button in the top bar', () => {
92+
renderLayout();
93+
expect(screen.getByTestId('home-layout-settings-btn')).toBeInTheDocument();
94+
});
95+
96+
it('navigates to /system when Settings button is clicked', () => {
97+
renderLayout();
98+
fireEvent.click(screen.getByTestId('home-layout-settings-btn'));
99+
expect(mockNavigate).toHaveBeenCalledWith('/system');
100+
});
101+
102+
it('navigates to /home when brand button is clicked', () => {
103+
renderLayout();
104+
fireEvent.click(screen.getByTestId('home-layout-brand'));
105+
expect(mockNavigate).toHaveBeenCalledWith('/home');
106+
});
107+
108+
it('renders sticky header element', () => {
109+
renderLayout();
110+
const header = screen.getByTestId('home-layout').querySelector('header');
111+
expect(header).toBeInTheDocument();
112+
expect(header?.className).toContain('sticky');
113+
});
114+
115+
it('renders user menu trigger as a round button', () => {
116+
renderLayout();
117+
const trigger = screen.getByTestId('home-layout-user-trigger');
118+
expect(trigger.className).toContain('rounded-full');
119+
});
120+
});

apps/console/src/components/AppSidebar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
Search,
5050
Pencil,
5151
ChevronRight,
52+
Home,
5253
} from 'lucide-react';
5354
import { NavigationRenderer } from '@object-ui/layout';
5455
import type { NavigationItem } from '@object-ui/types';
@@ -319,6 +320,13 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
319320
</DropdownMenuItem>
320321
))}
321322
<DropdownMenuSeparator />
323+
<DropdownMenuItem className="gap-2 p-2" onClick={() => navigate('/home')} data-testid="home-link-btn">
324+
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
325+
<Home className="size-4" />
326+
</div>
327+
<div className="font-medium text-muted-foreground">Home</div>
328+
</DropdownMenuItem>
329+
<DropdownMenuSeparator />
322330
<DropdownMenuItem className="gap-2 p-2" onClick={() => navigate(`/apps/${activeAppName}/create-app`)} data-testid="add-app-btn">
323331
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
324332
<Plus className="size-4" />
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* HomeLayout
3+
*
4+
* Lightweight layout shell for the Home Dashboard (`/home`).
5+
* Provides a consistent navigation frame (header bar with Home branding
6+
* and user menu) so the page is not rendered "bare" and can be extended
7+
* in the future with notifications, global guide, or unified theming.
8+
*
9+
* @module
10+
*/
11+
12+
import React from 'react';
13+
import { useNavigate } from 'react-router-dom';
14+
import { useObjectTranslation } from '@object-ui/i18n';
15+
import { useAuth, getUserInitials } from '@object-ui/auth';
16+
import {
17+
Button,
18+
DropdownMenu,
19+
DropdownMenuTrigger,
20+
DropdownMenuContent,
21+
DropdownMenuItem,
22+
DropdownMenuLabel,
23+
DropdownMenuSeparator,
24+
DropdownMenuGroup,
25+
Avatar,
26+
AvatarImage,
27+
AvatarFallback,
28+
} from '@object-ui/components';
29+
import { Settings, LogOut, User, Home } from 'lucide-react';
30+
31+
interface HomeLayoutProps {
32+
children: React.ReactNode;
33+
}
34+
35+
export function HomeLayout({ children }: HomeLayoutProps) {
36+
const navigate = useNavigate();
37+
const { t } = useObjectTranslation();
38+
const { user, signOut } = useAuth();
39+
40+
return (
41+
<div className="min-h-screen bg-background" data-testid="home-layout">
42+
{/* Top Navigation Bar */}
43+
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
44+
<div className="container mx-auto flex h-14 items-center justify-between px-6">
45+
{/* Left — Branding / Home link */}
46+
<button
47+
type="button"
48+
onClick={() => navigate('/home')}
49+
className="flex items-center gap-2 font-semibold tracking-tight hover:opacity-80"
50+
data-testid="home-layout-brand"
51+
>
52+
<Home className="h-5 w-5" />
53+
<span>{t('home.title', { defaultValue: 'Home' })}</span>
54+
</button>
55+
56+
{/* Right — Settings + User Menu */}
57+
<div className="flex items-center gap-2">
58+
<Button
59+
variant="ghost"
60+
size="sm"
61+
onClick={() => navigate('/system')}
62+
data-testid="home-layout-settings-btn"
63+
>
64+
<Settings className="mr-2 h-4 w-4" />
65+
{t('common.settings', { defaultValue: 'Settings' })}
66+
</Button>
67+
68+
{/* User Menu Dropdown */}
69+
<DropdownMenu>
70+
<DropdownMenuTrigger asChild>
71+
<Button variant="ghost" size="icon" className="rounded-full" data-testid="home-layout-user-trigger">
72+
<Avatar className="h-8 w-8">
73+
<AvatarImage src={user?.image ?? undefined} alt={user?.name ?? 'User'} />
74+
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
75+
{getUserInitials(user)}
76+
</AvatarFallback>
77+
</Avatar>
78+
</Button>
79+
</DropdownMenuTrigger>
80+
<DropdownMenuContent align="end" className="w-56">
81+
<DropdownMenuLabel>
82+
<div className="flex flex-col space-y-1">
83+
<p className="text-sm font-medium leading-none">{user?.name ?? 'User'}</p>
84+
<p className="text-xs leading-none text-muted-foreground">{user?.email ?? ''}</p>
85+
</div>
86+
</DropdownMenuLabel>
87+
<DropdownMenuSeparator />
88+
<DropdownMenuGroup>
89+
<DropdownMenuItem onClick={() => navigate('/profile')} data-testid="home-layout-user-profile">
90+
<User className="mr-2 h-4 w-4" />
91+
{t('common.profile', { defaultValue: 'Profile' })}
92+
</DropdownMenuItem>
93+
<DropdownMenuItem onClick={() => navigate('/system')} data-testid="home-layout-user-settings">
94+
<Settings className="mr-2 h-4 w-4" />
95+
{t('common.settings', { defaultValue: 'Settings' })}
96+
</DropdownMenuItem>
97+
</DropdownMenuGroup>
98+
<DropdownMenuSeparator />
99+
<DropdownMenuItem
100+
className="text-destructive focus:text-destructive"
101+
onClick={() => signOut()}
102+
data-testid="home-layout-user-signout"
103+
>
104+
<LogOut className="mr-2 h-4 w-4" />
105+
{t('common.signOut', { defaultValue: 'Sign Out' })}
106+
</DropdownMenuItem>
107+
</DropdownMenuContent>
108+
</DropdownMenu>
109+
</div>
110+
</div>
111+
</header>
112+
113+
{/* Page Content */}
114+
{children}
115+
</div>
116+
);
117+
}

apps/console/src/pages/home/HomePage.tsx

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { useMetadata } from '../../context/MetadataProvider';
2121
import { useRecentItems } from '../../hooks/useRecentItems';
2222
import { useFavorites } from '../../hooks/useFavorites';
2323
import { useObjectTranslation } from '@object-ui/i18n';
24-
import { resolveI18nLabel } from '../../utils';
2524
import { QuickActions } from './QuickActions';
2625
import { AppCard } from './AppCard';
2726
import { RecentApps } from './RecentApps';
@@ -51,7 +50,7 @@ export function HomePage() {
5150

5251
if (loading) {
5352
return (
54-
<div className="min-h-screen flex items-center justify-center">
53+
<div className="flex flex-1 items-center justify-center py-20">
5554
<div className="text-muted-foreground">Loading workspace...</div>
5655
</div>
5756
);
@@ -60,7 +59,7 @@ export function HomePage() {
6059
// Empty state - no apps configured
6160
if (activeApps.length === 0) {
6261
return (
63-
<div className="min-h-screen flex items-center justify-center p-6">
62+
<div className="flex flex-1 items-center justify-center p-6">
6463
<Empty>
6564
<EmptyTitle>Welcome to ObjectUI</EmptyTitle>
6665
<EmptyDescription>
@@ -89,35 +88,19 @@ export function HomePage() {
8988
}
9089

9190
return (
92-
<div className="min-h-screen bg-background">
93-
{/* Header */}
94-
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
95-
<div className="container mx-auto px-6 py-6">
96-
<div className="flex items-center justify-between">
97-
<div>
98-
<h1 className="text-3xl font-bold tracking-tight">
99-
{t('home.title', { defaultValue: 'Home' })}
100-
</h1>
101-
<p className="text-muted-foreground mt-1">
102-
{t('home.subtitle', { defaultValue: 'Your workspace dashboard' })}
103-
</p>
104-
</div>
105-
<div className="flex items-center gap-2">
106-
<Button
107-
variant="outline"
108-
onClick={() => navigate('/system')}
109-
data-testid="home-settings-btn"
110-
>
111-
<Settings className="mr-2 h-4 w-4" />
112-
{t('common.settings', { defaultValue: 'Settings' })}
113-
</Button>
114-
</div>
115-
</div>
116-
</div>
91+
<div className="bg-background">
92+
{/* Page Title */}
93+
<div className="container mx-auto px-6 pt-8 pb-4">
94+
<h1 className="text-3xl font-bold tracking-tight">
95+
{t('home.title', { defaultValue: 'Home' })}
96+
</h1>
97+
<p className="text-muted-foreground mt-1">
98+
{t('home.subtitle', { defaultValue: 'Your workspace dashboard' })}
99+
</p>
117100
</div>
118101

119102
{/* Main Content */}
120-
<div className="container mx-auto px-6 py-8 space-y-8">
103+
<div className="container mx-auto px-6 py-4 space-y-8">
121104
{/* Quick Actions */}
122105
<QuickActions />
123106

apps/console/src/pages/home/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
export { HomePage } from './HomePage';
8+
export { HomeLayout } from './HomeLayout';
89
export { QuickActions } from './QuickActions';
910
export { AppCard } from './AppCard';
1011
export { RecentApps } from './RecentApps';

0 commit comments

Comments
 (0)