diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 41659f58..be14135c 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -64,7 +64,10 @@ interface NavItem { // Feature-gated (only render when the corresponding feature flag is on): // Analytics → flags.analytics // 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.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, @@ -156,7 +159,7 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose, col const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl; 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.featureFlag && !flags[item.featureFlag]) return false; // featureFlagsAny: entry is hidden when none of the listed diff --git a/frontend/src/features/settings/__tests__/SidebarPreview.featureGates.test.tsx b/frontend/src/features/settings/__tests__/SidebarPreview.featureGates.test.tsx new file mode 100644 index 00000000..bb00b805 --- /dev/null +++ b/frontend/src/features/settings/__tests__/SidebarPreview.featureGates.test.tsx @@ -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('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 => + ({ ...DEFAULT_FLAGS, ...overrides }) as FeatureFlags; + +describe('SidebarPreview feature gates (QA J.14)', () => { + it('always lists the unconditional entries', () => { + render(); + + 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(); + expect(screen.queryByText(label)).not.toBeInTheDocument(); + unmount(); + + render(); + 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(); + expect(screen.queryByText('navigation.clients')).not.toBeInTheDocument(); + unmount(); + + render(); + expect(screen.getByText('navigation.clients')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/settings/components/SidebarPreview.tsx b/frontend/src/features/settings/components/SidebarPreview.tsx index d9f9ee00..8a88529a 100644 --- a/frontend/src/features/settings/components/SidebarPreview.tsx +++ b/frontend/src/features/settings/components/SidebarPreview.tsx @@ -1,22 +1,14 @@ import React, { useMemo } from 'react'; 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 { useTranslation } from 'react-i18next'; +import { adminNavigation } from '../../../components/admin/AdminSidebar'; import type { FeatureFlags } from '../../../contexts/FeatureFlagsContext'; interface PreviewItem { key: string; label: string; - icon: LucideIcon; + icon: React.ComponentType<{ className?: string }>; featureDriven: boolean; } @@ -28,21 +20,25 @@ interface SidebarPreviewProps { * Renders the live shape of the main admin sidebar based on the user's * staged (unsaved) feature flags. Items in green (primary tint) are * 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 = ({ staged }) => { const { t } = useTranslation(); - const items = useMemo(() => { - const all: Array = [ - { key: 'dashboard', label: t('navigation.dashboard'), icon: LayoutDashboard, featureDriven: false }, - { key: 'events', label: t('navigation.events'), icon: Calendar, featureDriven: false }, - { key: 'archives', label: t('navigation.archives'), icon: Archive, featureDriven: false }, - { key: 'analytics', label: t('admin.analytics', 'Analytics'), icon: BarChart3, featureDriven: true, gate: 'analytics' }, - { key: 'settings', label: t('navigation.settings'), icon: Settings, featureDriven: false }, - { key: 'users', label: t('navigation.users'), icon: Users, featureDriven: true, gate: 'userManagement' }, - ]; - return all.filter((it) => !it.gate || staged[it.gate]); - }, [staged, t]); + const items = useMemo(() => adminNavigation + .filter((it) => (!it.featureFlag || staged[it.featureFlag]) + && (!it.featureFlagsAny?.length || it.featureFlagsAny.some((k) => staged[k]))) + .map((it) => ({ + key: it.nameKey, + label: t(it.nameKey), + icon: it.icon, + featureDriven: Boolean(it.featureFlag || it.featureFlagsAny?.length), + })), [staged, t]); return (