diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 3340101e..c73d63de 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { Button } from './ui/button'; import { Plus, Loader2, ChevronLeft } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; @@ -52,6 +52,11 @@ import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surf import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments'; import type { SectionId } from './settings/types'; +// The Security page is heavy (charts, scan sheets) and already code-split on the +// desktop content path, so the bespoke phone screen reuses the same lazy chunk +// rather than pulling SecurityView into the main shell bundle. +const SecurityView = lazy(() => import('./SecurityView').then(m => ({ default: m.SecurityView }))); + export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { status: trivy } = useTrivyStatus(); @@ -760,6 +765,22 @@ export default function EditorLayout() { return ; case 'settings': return ; + case 'security': + return ( + + + + )} + > + + + ); default: return workspaceEl; } diff --git a/frontend/src/components/EditorLayout/mobile-treatments.test.ts b/frontend/src/components/EditorLayout/mobile-treatments.test.ts index d40e0824..25d797a9 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.test.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.test.ts @@ -13,8 +13,8 @@ describe('mobile treatments', () => { } }); - it('treats the Security view as responsive (reflowed, not bespoke or desktop-only)', () => { - expect(MOBILE_TREATMENTS.security).toBe('responsive'); + it('treats the Security view as a bespoke masthead-led phone screen', () => { + expect(MOBILE_TREATMENTS.security).toBe('bespoke'); }); it('keeps BESPOKE_MOBILE_VIEWS in lockstep with the bespoke treatments', () => { @@ -27,10 +27,12 @@ describe('mobile treatments', () => { it('pins the set of bespoke phone screens (update deliberately when adding one)', () => { // A change here means a top-level view gained or lost a bespoke phone screen. - // Updating this list should go hand in hand with adding the screen under - // components/mobile/ and wiring its case in EditorLayout's renderMobileBespoke. + // Updating this list should go hand in hand with wiring the screen's case in + // EditorLayout's renderMobileBespoke (a dedicated component under + // components/mobile/, or a masthead-led mobile branch of the desktop view as + // Security does). expect([...BESPOKE_MOBILE_VIEWS].sort()).toEqual( - ['dashboard', 'fleet', 'scheduled-ops', 'settings'], + ['dashboard', 'fleet', 'scheduled-ops', 'security', 'settings'], ); }); }); diff --git a/frontend/src/components/EditorLayout/mobile-treatments.ts b/frontend/src/components/EditorLayout/mobile-treatments.ts index 70dcee31..960a9ed7 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.ts @@ -22,7 +22,7 @@ export const MOBILE_TREATMENTS: Record = { settings: 'bespoke', editor: 'detail', resources: 'responsive', - security: 'responsive', + security: 'bespoke', templates: 'responsive', 'global-observability': 'responsive', 'auto-updates': 'responsive', diff --git a/frontend/src/components/MobileMoreMenu.test.tsx b/frontend/src/components/MobileMoreMenu.test.tsx new file mode 100644 index 00000000..7879a431 --- /dev/null +++ b/frontend/src/components/MobileMoreMenu.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Home, Radar, Boxes, ShieldCheck } from 'lucide-react'; +import { MobileMoreMenu } from './MobileMoreMenu'; +import type { NavItem } from './EditorLayout/hooks/useViewNavigationState'; + +// Includes a bottom-tab primary (dashboard) and secondary destinations; the +// menu must list every one so navigation is identical on every screen. +const navItems: NavItem[] = [ + { value: 'dashboard', label: 'Home', icon: Home }, + { value: 'fleet', label: 'Fleet', icon: Radar }, + { value: 'resources', label: 'Resources', icon: Boxes }, + { value: 'security', label: 'Security', icon: ShieldCheck }, +]; + +function open(over: Partial> = {}) { + const props: React.ComponentProps = { + navItems, + activeView: 'security', + onNavigate: vi.fn(), + ...over, + }; + render(); + // The destination list lives inside a Sheet that is closed until the trigger + // is clicked, so open it before querying the nav buttons. + fireEvent.click(screen.getByRole('button', { name: 'More destinations' })); + return props; +} + +describe('MobileMoreMenu', () => { + it('lists every nav item, including the bottom-tab primaries', () => { + open(); + for (const item of navItems) { + expect(screen.getByRole('button', { name: item.label })).toBeInTheDocument(); + } + }); + + it('navigates to the chosen destination on click', () => { + const { onNavigate } = open(); + fireEvent.click(screen.getByRole('button', { name: 'Resources' })); + expect(onNavigate).toHaveBeenCalledWith('resources'); + }); + + it('marks the active destination', () => { + open({ activeView: 'security' }); + expect(screen.getByRole('button', { name: 'Security' }).className).toContain('bg-glass-highlight'); + expect(screen.getByRole('button', { name: 'Resources' }).className).not.toContain('font-medium'); + }); +}); diff --git a/frontend/src/components/MobileMoreMenu.tsx b/frontend/src/components/MobileMoreMenu.tsx index be70082b..2da98aa2 100644 --- a/frontend/src/components/MobileMoreMenu.tsx +++ b/frontend/src/components/MobileMoreMenu.tsx @@ -5,11 +5,6 @@ import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { cn } from '@/lib/utils'; import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState'; -// Destinations the bottom tab bar already exposes; the "more" menu omits these -// so it lists only the secondary destinations (auto-updates, app store, -// observability, etc.). Kept in sync with MobileTabBar's tab set. -const TAB_BAR_VIEWS = new Set(['dashboard', 'fleet', 'scheduled-ops', 'settings']); - interface MobileMoreMenuProps { /** The already-gated nav items (admin / remote / paid filtering applied). */ navItems: NavItem[]; @@ -22,12 +17,12 @@ interface MobileMoreMenuProps { /** * The "more destinations" affordance for the mobile content screens. On a phone * the global TopBar is dropped and each screen's masthead leads, so this hosts - * every secondary destination the bottom tab bar does not (auto-updates, app - * store, observability, etc.) plus the theme and account controls. + * the full destination list plus the theme and account controls. It lists every + * gated nav item (the bottom tab bar's primaries included) so the same menu + * opens the same set on every screen, rather than changing contents per page. */ export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: MobileMoreMenuProps) { const [open, setOpen] = useState(false); - const secondaryItems = navItems.filter(item => !TAB_BAR_VIEWS.has(item.value)); return ( @@ -46,7 +41,7 @@ export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: Mob

Navigate