fix(settings): derive the sidebar preview from the real sidebar declaration

SidebarPreview kept its own hand-maintained 6-item array with only two gates
wired (analytics, userManagement), so toggling e.g. Workflows changed nothing
in the preview even though it does add a real sidebar entry once saved.

Export AdminSidebar's `navigation` as `adminNavigation` (2 lines) and derive
the preview from it, so every gate -- transfers, messaging, analytics,
userManagement, clients incl. its featureFlagsAny set, accounting, workflows --
is covered and the two can't drift again.

Note the report's item list was partly wrong: Quotes, Contracts, Invoices,
Hours, Projects, Calendar and the CRM dev tools have no top-level sidebar
entries at all -- they are sub-nav inside /admin/clients and surface in the
preview through the CRM entry's featureFlagsAny.

Permission filtering is deliberately not applied (unchanged): the preview
answers "what do these flags do to the sidebar", not "what can this admin see".

Refs testplan REPORT.md #20 (Part 3, J.14).
This commit is contained in:
Paul Nothaft
2026-09-01 16:29:33 +02:00
parent 3e16b81be8
commit c6cb01865e
3 changed files with 82 additions and 23 deletions
@@ -64,7 +64,10 @@ interface NavItem {
// Feature-gated (only render when the corresponding feature flag is on): // Feature-gated (only render when the corresponding feature flag is on):
// Analytics → flags.analytics // Analytics → flags.analytics
// Users → flags.userManagement // Users → flags.userManagement
const navigation: NavItem[] = [ // Exported so Settings → Features can render its "Sidebar preview" against
// the same declaration the real sidebar uses (it used to keep a second,
// hand-maintained array that only knew about 2 of the feature gates).
export const adminNavigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false }, { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
@@ -156,7 +159,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl; const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl;
const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title'); const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title');
const filteredNavigation = navigation.filter((item) => { const filteredNavigation = adminNavigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false; if (item.permission && !hasPermission(item.permission as string)) return false;
if (item.featureFlag && !flags[item.featureFlag]) return false; if (item.featureFlag && !flags[item.featureFlag]) return false;
// featureFlagsAny: entry is hidden when none of the listed // featureFlagsAny: entry is hidden when none of the listed
@@ -0,0 +1,60 @@
/**
* The "Sidebar preview" on Settings → Features kept its own hardcoded
* 6-item array with 2 of the feature gates wired, so toggling e.g.
* Workflows changed nothing in the preview (QA J.14). It now derives
* from AdminSidebar's own `adminNavigation` declaration.
*/
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k) }),
};
});
import { SidebarPreview } from '../components/SidebarPreview';
import { DEFAULT_FLAGS, type FeatureFlags } from '../../../contexts/FeatureFlagsContext';
const staged = (overrides: Partial<FeatureFlags>): FeatureFlags =>
({ ...DEFAULT_FLAGS, ...overrides }) as FeatureFlags;
describe('SidebarPreview feature gates (QA J.14)', () => {
it('always lists the unconditional entries', () => {
render(<SidebarPreview staged={staged({})} />);
expect(screen.getByText('navigation.dashboard')).toBeInTheDocument();
expect(screen.getByText('navigation.events')).toBeInTheDocument();
expect(screen.getByText('navigation.settings')).toBeInTheDocument();
});
it.each([
['workflows', 'navigation.workflows'],
['transfers', 'navigation.transfers'],
['messaging', 'navigation.messages'],
['accounting', 'navigation.accounting'],
['analytics', 'admin.analytics'],
['userManagement', 'navigation.users'],
] as const)('reflects the %s toggle', (flag, label) => {
const { unmount } = render(<SidebarPreview staged={staged({ [flag]: false })} />);
expect(screen.queryByText(label)).not.toBeInTheDocument();
unmount();
render(<SidebarPreview staged={staged({ [flag]: true })} />);
expect(screen.getByText(label)).toBeInTheDocument();
});
it('shows the CRM entry only when one of its sub-features is on', () => {
// `clients` is derived, so the entry needs a real sub-feature — mirrors
// AdminSidebar's featureFlagsAny check.
const { unmount } = render(<SidebarPreview staged={staged({ clients: true })} />);
expect(screen.queryByText('navigation.clients')).not.toBeInTheDocument();
unmount();
render(<SidebarPreview staged={staged({ clients: true, contracts: true })} />);
expect(screen.getByText('navigation.clients')).toBeInTheDocument();
});
});
@@ -1,22 +1,14 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import {
LayoutDashboard,
Calendar,
Archive,
BarChart3,
Settings,
Users,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Card } from '../../../components/common'; import { Card } from '../../../components/common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { adminNavigation } from '../../../components/admin/AdminSidebar';
import type { FeatureFlags } from '../../../contexts/FeatureFlagsContext'; import type { FeatureFlags } from '../../../contexts/FeatureFlagsContext';
interface PreviewItem { interface PreviewItem {
key: string; key: string;
label: string; label: string;
icon: LucideIcon; icon: React.ComponentType<{ className?: string }>;
featureDriven: boolean; featureDriven: boolean;
} }
@@ -28,21 +20,25 @@ interface SidebarPreviewProps {
* Renders the live shape of the main admin sidebar based on the user's * Renders the live shape of the main admin sidebar based on the user's
* staged (unsaved) feature flags. Items in green (primary tint) are * staged (unsaved) feature flags. Items in green (primary tint) are
* controlled by toggles above; greyscale items are unconditional. * controlled by toggles above; greyscale items are unconditional.
*
* Derived from AdminSidebar's own `adminNavigation` declaration so every
* feature-gated entry is covered automatically — the previous hand-kept
* copy had drifted to 2 of the gates (QA J.14). Permissions are
* deliberately NOT applied here: the preview answers "what do these flags
* do to the sidebar", not "what can this particular admin see".
*/ */
export const SidebarPreview: React.FC<SidebarPreviewProps> = ({ staged }) => { export const SidebarPreview: React.FC<SidebarPreviewProps> = ({ staged }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const items = useMemo<PreviewItem[]>(() => { const items = useMemo<PreviewItem[]>(() => adminNavigation
const all: Array<PreviewItem & { gate?: keyof FeatureFlags }> = [ .filter((it) => (!it.featureFlag || staged[it.featureFlag])
{ key: 'dashboard', label: t('navigation.dashboard'), icon: LayoutDashboard, featureDriven: false }, && (!it.featureFlagsAny?.length || it.featureFlagsAny.some((k) => staged[k])))
{ key: 'events', label: t('navigation.events'), icon: Calendar, featureDriven: false }, .map((it) => ({
{ key: 'archives', label: t('navigation.archives'), icon: Archive, featureDriven: false }, key: it.nameKey,
{ key: 'analytics', label: t('admin.analytics', 'Analytics'), icon: BarChart3, featureDriven: true, gate: 'analytics' }, label: t(it.nameKey),
{ key: 'settings', label: t('navigation.settings'), icon: Settings, featureDriven: false }, icon: it.icon,
{ key: 'users', label: t('navigation.users'), icon: Users, featureDriven: true, gate: 'userManagement' }, featureDriven: Boolean(it.featureFlag || it.featureFlagsAny?.length),
]; })), [staged, t]);
return all.filter((it) => !it.gate || staged[it.gate]);
}, [staged, t]);
return ( return (
<Card padding="md"> <Card padding="md">