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:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user