mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
feat(ui): add Classic, Smart, and Compact desktop navigation styles (#1642)
* feat(ui): add Classic, Smart, and Compact desktop navigation styles Introduce a shared app-nav registry and reachable model so TopBar, the command palette, and mobile menus share one destination source. Smart bar is the default; Appearance gains a Navigation subsection with quick links. * fix(e2e): stop clearing top-nav prefs on every reload The desktop-navigation suite used addInitScript to wipe mode storage, which re-ran on reload and undid the classic/compact values under test. * fix(ui): harden nav PR docs and Compact quick-link coverage Drop the partial docs-refresh import that left missing image assets and a stale Display screenshot, keep navigation-scoped operator docs against main, and add an E2E path that proves Compact quick-link add, persist, and render after reload. * fix(ui): polish Compact quick links and menu mastheads Align Smart/Compact menus with Theme chrome, keep pin labels always visible, and drive add capacity from persisted pins (max five) with a trailing + picker and per-pin remove. * test(e2e): exact-match Compact Networking pin locator Avoid Playwright strict-mode clash with Actions for Networking. * fix(ui): simplify Compact quick link removal and fix + button trailing Remove the (...) dropdown per quick link in Compact mode. Right-click context menu remains as the sole on-bar removal affordance. Fix the + add-button to trail quick links rather than pinning to the far right by removing flex-1 from the quick-link rail.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
APP_NAV_REGISTRY,
|
||||
recommendedQuickLinkIds,
|
||||
} from './appNavRegistry';
|
||||
|
||||
describe('appNavRegistry', () => {
|
||||
it('has unique ActiveView values', () => {
|
||||
const values = APP_NAV_REGISTRY.map((item) => item.value);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
|
||||
it('gives every non-Settings destination exactly one Smart placement of primary or overflow', () => {
|
||||
for (const item of APP_NAV_REGISTRY) {
|
||||
if (item.value === 'settings') {
|
||||
expect(item.smart).toBe('launcher-only');
|
||||
continue;
|
||||
}
|
||||
expect(item.smart === 'primary' || item.smart === 'overflow').toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('exports recommendedQuickLinkIds from defaultQuickLink metadata in that order', () => {
|
||||
const flagged = APP_NAV_REGISTRY.filter((item) => item.defaultQuickLink).map((item) => item.value);
|
||||
expect(new Set(recommendedQuickLinkIds)).toEqual(new Set(flagged));
|
||||
expect([...recommendedQuickLinkIds]).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
]);
|
||||
for (const id of recommendedQuickLinkIds) {
|
||||
const item = APP_NAV_REGISTRY.find((entry) => entry.value === id);
|
||||
expect(item?.quickLinkEligible).toBe(true);
|
||||
expect(item?.defaultQuickLink).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Networking after Resources in Classic order metadata', () => {
|
||||
const resources = APP_NAV_REGISTRY.find((item) => item.value === 'resources');
|
||||
const networking = APP_NAV_REGISTRY.find((item) => item.value === 'networking');
|
||||
expect(resources).toBeDefined();
|
||||
expect(networking).toBeDefined();
|
||||
expect(networking!.classicOrder).toBeGreaterThan(resources!.classicOrder);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Terminal, CloudDownload, Home, HardDrive, ScrollText,
|
||||
Activity, Radar, RefreshCw, Clock, ShieldCheck, Network, Settings,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
|
||||
export type NavGroup =
|
||||
| 'overview'
|
||||
| 'stack-workspace'
|
||||
| 'fleet'
|
||||
| 'security-review'
|
||||
| 'operations'
|
||||
| 'tools'
|
||||
| 'settings';
|
||||
|
||||
export type SmartPlacement = 'primary' | 'overflow' | 'launcher-only';
|
||||
|
||||
/** Shared destination shape for TopBar, palette, and mobile consumers. */
|
||||
export interface NavDestination {
|
||||
value: ActiveView;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface AppNavItem extends NavDestination {
|
||||
group: NavGroup;
|
||||
/** Classic strip order (ascending). Settings uses a high value and is excluded from Classic. */
|
||||
classicOrder: number;
|
||||
smart: SmartPlacement;
|
||||
quickLinkEligible: boolean;
|
||||
defaultQuickLink: boolean;
|
||||
}
|
||||
|
||||
export const NAV_GROUP_META: readonly { id: NavGroup; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'stack-workspace', label: 'Stack workspace' },
|
||||
{ id: 'fleet', label: 'Fleet' },
|
||||
{ id: 'security-review', label: 'Security & review' },
|
||||
{ id: 'operations', label: 'Operations' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
] as const;
|
||||
|
||||
export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
|
||||
{
|
||||
value: 'dashboard',
|
||||
label: 'Home',
|
||||
icon: Home,
|
||||
group: 'overview',
|
||||
classicOrder: 10,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'fleet',
|
||||
label: 'Fleet',
|
||||
icon: Radar,
|
||||
group: 'fleet',
|
||||
classicOrder: 20,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'resources',
|
||||
label: 'Resources',
|
||||
icon: HardDrive,
|
||||
group: 'fleet',
|
||||
classicOrder: 30,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'networking',
|
||||
label: 'Networking',
|
||||
icon: Network,
|
||||
group: 'fleet',
|
||||
classicOrder: 40,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'security',
|
||||
label: 'Security',
|
||||
icon: ShieldCheck,
|
||||
group: 'security-review',
|
||||
classicOrder: 50,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'templates',
|
||||
label: 'App Store',
|
||||
icon: CloudDownload,
|
||||
group: 'stack-workspace',
|
||||
classicOrder: 60,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'global-observability',
|
||||
label: 'Logs',
|
||||
icon: Activity,
|
||||
group: 'operations',
|
||||
classicOrder: 70,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'auto-updates',
|
||||
label: 'Update',
|
||||
icon: RefreshCw,
|
||||
group: 'operations',
|
||||
classicOrder: 80,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'scheduled-ops',
|
||||
label: 'Schedules',
|
||||
icon: Clock,
|
||||
group: 'operations',
|
||||
classicOrder: 90,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'host-console',
|
||||
label: 'Console',
|
||||
icon: Terminal,
|
||||
group: 'tools',
|
||||
classicOrder: 100,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'audit-log',
|
||||
label: 'Audit',
|
||||
icon: ScrollText,
|
||||
group: 'security-review',
|
||||
classicOrder: 110,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
group: 'settings',
|
||||
classicOrder: 999,
|
||||
smart: 'launcher-only',
|
||||
quickLinkEligible: false,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Recommended quick-link pins for missing/malformed storage and Reset.
|
||||
* Order is intentional (Home, Fleet, Security, Resources), not Classic strip order.
|
||||
*/
|
||||
export const recommendedQuickLinkIds: readonly ActiveView[] = [
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
] as const;
|
||||
|
||||
const BY_VALUE = new Map(APP_NAV_REGISTRY.map((item) => [item.value, item]));
|
||||
|
||||
export function getAppNavItem(value: ActiveView): AppNavItem | undefined {
|
||||
return BY_VALUE.get(value);
|
||||
}
|
||||
|
||||
export function isQuickLinkEligibleId(value: string): value is ActiveView {
|
||||
const item = BY_VALUE.get(value as ActiveView);
|
||||
return Boolean(item?.quickLinkEligible);
|
||||
}
|
||||
|
||||
export function toNavDestination(item: AppNavItem): NavDestination {
|
||||
return { value: item.value, label: item.label, icon: item.icon };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildNavigationModel } from './buildNavigationModel';
|
||||
import type { ReachabilityContext } from '@/lib/routing/reachability';
|
||||
|
||||
function makeCtx(overrides: Partial<ReachabilityContext> = {}): ReachabilityContext {
|
||||
return {
|
||||
isAdmin: true,
|
||||
isPaid: true,
|
||||
can: () => true,
|
||||
isRemote: false,
|
||||
hasFleetCapability: true,
|
||||
containerLabelsEnabled: true,
|
||||
permissionsStatus: 'ready',
|
||||
licenseStatus: 'ready',
|
||||
experimental: true,
|
||||
experimentalReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildNavigationModel', () => {
|
||||
it('returns exact Classic page order including Networking after Resources', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
expect(model.allPageItems.map((item) => item.value)).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'resources',
|
||||
'networking',
|
||||
'security',
|
||||
'templates',
|
||||
'global-observability',
|
||||
'auto-updates',
|
||||
'scheduled-ops',
|
||||
'host-console',
|
||||
'audit-log',
|
||||
]);
|
||||
expect(model.allPageItems.some((item) => item.value === 'settings')).toBe(false);
|
||||
});
|
||||
|
||||
it('partitions Smart primary and overflow disjointly covering all page destinations', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
const primary = model.primaryItems.map((item) => item.value);
|
||||
const overflow = model.overflowGroups.flatMap((g) => g.items.map((i) => i.value));
|
||||
expect(primary).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'resources',
|
||||
'networking',
|
||||
'security',
|
||||
'templates',
|
||||
]);
|
||||
expect(primary.filter((v) => overflow.includes(v))).toEqual([]);
|
||||
expect(new Set([...primary, ...overflow])).toEqual(
|
||||
new Set(model.allPageItems.map((item) => item.value)),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes Settings only in launcher groups', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
const launcherValues = model.launcherGroups.flatMap((g) => g.items.map((i) => i.value));
|
||||
expect(launcherValues).toContain('settings');
|
||||
expect(model.allPageItems.map((i) => i.value)).not.toContain('settings');
|
||||
expect(model.primaryItems.map((i) => i.value)).not.toContain('settings');
|
||||
});
|
||||
|
||||
it('keeps Networking reachable on a remote node while dropping hub-only pages', () => {
|
||||
const model = buildNavigationModel(makeCtx({ isRemote: true }));
|
||||
const values = model.allPageItems.map((item) => item.value);
|
||||
expect(values).toContain('networking');
|
||||
expect(values).toContain('resources');
|
||||
expect(values).toContain('security');
|
||||
expect(values).toContain('templates');
|
||||
expect(values).not.toContain('fleet');
|
||||
expect(values).not.toContain('global-observability');
|
||||
expect(values).not.toContain('auto-updates');
|
||||
expect(values).not.toContain('scheduled-ops');
|
||||
expect(values).not.toContain('audit-log');
|
||||
});
|
||||
|
||||
it('omits Console until experimental discovery is ready and enabled via reachCtx only', () => {
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: false, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: true }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).toContain('host-console');
|
||||
});
|
||||
|
||||
it('excludes hidden views from quick-link candidates', () => {
|
||||
const model = buildNavigationModel(makeCtx({ isRemote: true, isPaid: false }));
|
||||
const values = model.quickLinkCandidates.map((i) => i.value);
|
||||
expect(values).toContain('networking');
|
||||
expect(values).not.toContain('fleet');
|
||||
expect(values).not.toContain('settings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
APP_NAV_REGISTRY,
|
||||
NAV_GROUP_META,
|
||||
toNavDestination,
|
||||
type AppNavItem,
|
||||
type NavDestination,
|
||||
type NavGroup,
|
||||
} from '@/lib/navigation/appNavRegistry';
|
||||
import { isViewHidden, type ReachabilityContext } from '@/lib/routing/reachability';
|
||||
|
||||
export interface NavGroupBucket {
|
||||
group: NavGroup;
|
||||
label: string;
|
||||
items: NavDestination[];
|
||||
}
|
||||
|
||||
export interface ReachableNavigationModel {
|
||||
/** Classic / palette / mobile page list (excludes Settings). Exact Classic order. */
|
||||
allPageItems: NavDestination[];
|
||||
primaryItems: NavDestination[];
|
||||
overflowGroups: NavGroupBucket[];
|
||||
launcherGroups: NavGroupBucket[];
|
||||
quickLinkCandidates: NavDestination[];
|
||||
}
|
||||
|
||||
function isVisuallyDiscoverable(item: AppNavItem, reachCtx: ReachabilityContext): boolean {
|
||||
if (item.smart === 'launcher-only') {
|
||||
// Settings is always discoverable in the launcher when the operator can open Settings.
|
||||
return true;
|
||||
}
|
||||
// Console: fail-closed visual discovery until /meta settles and the flag is on.
|
||||
// URL normalization still uses isViewHidden cold-load deferral separately.
|
||||
if (item.value === 'host-console') {
|
||||
if (!reachCtx.experimentalReady || !reachCtx.experimental) return false;
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
|
||||
function bucketByGroup(items: AppNavItem[]): NavGroupBucket[] {
|
||||
const byGroup = new Map<NavGroup, NavDestination[]>();
|
||||
for (const item of items) {
|
||||
const list = byGroup.get(item.group) ?? [];
|
||||
list.push(toNavDestination(item));
|
||||
byGroup.set(item.group, list);
|
||||
}
|
||||
return NAV_GROUP_META
|
||||
.map(({ id, label }) => {
|
||||
const groupItems = byGroup.get(id);
|
||||
if (!groupItems?.length) return null;
|
||||
return { group: id, label, items: groupItems };
|
||||
})
|
||||
.filter((bucket): bucket is NavGroupBucket => bucket !== null);
|
||||
}
|
||||
|
||||
/** Derive reachable navigation collections from a single ReachabilityContext. */
|
||||
export function buildNavigationModel(reachCtx: ReachabilityContext): ReachableNavigationModel {
|
||||
const reachable = APP_NAV_REGISTRY.filter((item) => isVisuallyDiscoverable(item, reachCtx));
|
||||
|
||||
const pages = reachable
|
||||
.filter((item) => item.smart !== 'launcher-only')
|
||||
.slice()
|
||||
.sort((a, b) => a.classicOrder - b.classicOrder);
|
||||
|
||||
const allPageItems = pages.map(toNavDestination);
|
||||
const primaryItems = pages
|
||||
.filter((item) => item.smart === 'primary')
|
||||
.map(toNavDestination);
|
||||
const overflowItems = pages.filter((item) => item.smart === 'overflow');
|
||||
const overflowGroups = bucketByGroup(overflowItems);
|
||||
const launcherGroups = bucketByGroup(reachable);
|
||||
const quickLinkCandidates = reachable
|
||||
.filter((item) => item.quickLinkEligible)
|
||||
.map(toNavDestination);
|
||||
|
||||
return {
|
||||
allPageItems,
|
||||
primaryItems,
|
||||
overflowGroups,
|
||||
launcherGroups,
|
||||
quickLinkCandidates,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user