diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 4337bd04..0450592f 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -170,7 +170,7 @@ export const SettingsPage: React.FC = () => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const { flags, isLoading: flagsLoading } = useFeatureFlags(); - const { hasAnyPermission } = usePermissions(); + const { hasAnyPermission, isLoading: permissionsLoading } = usePermissions(); // Read ?tab=… on mount; default to Features per the redesign. const initialTab: TabType = isValidTab(searchParams.get('tab')) @@ -288,7 +288,11 @@ export const SettingsPage: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [flagsLoading, activeTab, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow]); - if (isLoading) { + // Wait for the permissions context too: on a fresh/hard mount it starts out + // empty, which filters every nav group down to nothing and left `activeItem` + // undefined below (QA J.08 crash). `activeTab` is held in state, so a + // deep-linked ?tab= still lands on the right tab once permissions arrive. + if (isLoading || permissionsLoading) { return (
@@ -483,7 +487,7 @@ export const SettingsPage: React.FC = () => {
- {showSectionHeading && ( + {showSectionHeading && activeItem && (
{/* Section heading icon stays neutral so the Settings diff --git a/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx new file mode 100644 index 00000000..934538de --- /dev/null +++ b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx @@ -0,0 +1,110 @@ +/** + * Settings crashed on a fresh/hard load of any non-default tab (QA J.08). + * + * The nav groups are permission-filtered, so before PermissionsContext has + * resolved every group filters to empty, `allItems[0]` is undefined, and the + * section heading's `` throws. In-app SPA navigation never + * hit it because the context was already warm. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +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) }), + }; +}); + +const flagsState = { flags: {} as Record, isLoading: false }; +vi.mock('../../../contexts/FeatureFlagsContext', () => ({ + useFeatureFlags: () => flagsState, + useFeatureEnabled: () => false, +})); + +const permissionsState = { hasAnyPermission: (_: string[]) => true, isLoading: false }; +vi.mock('../../../contexts/PermissionsContext', () => ({ + usePermissions: () => permissionsState, +})); + +// The settings barrel pulls in every tab; stub it down to the shell's needs. +vi.mock('../../../features/settings', () => { + const Stub = () => null; + return { + useSettingsState: () => ({ isLoading: false }), + FeaturesTab: Stub, + GeneralTab: Stub, + EventsTab: Stub, + StatusTab: Stub, + SecurityTab: Stub, + ImageSecurityTab: Stub, + CategoriesTab: Stub, + AnalyticsTab: Stub, + ModerationTab: Stub, + StylingTab: Stub, + SEOTab: Stub, + ThumbnailsTab: Stub, + DownloadsTab: Stub, + ApiTokensTab: Stub, + WebhooksTab: Stub, + AccountingTab: Stub, + WhatsAppTab: Stub, + SsoTab: Stub, + }; +}); + +vi.mock('../EmailConfigPage', () => ({ EmailConfigPage: () => null })); +vi.mock('../BrandingPage', () => ({ BrandingPage: () => null })); +vi.mock('../EventTypesPage', () => ({ EventTypesPage: () => null })); +vi.mock('../SlideshowSettingsPage', () => ({ SlideshowSettingsPage: () => null })); +vi.mock('../BackupManagement', () => ({ BackupManagement: () => null })); +vi.mock('../CMSPage', () => ({ CMSPage: () => null })); +vi.mock('../settings/SettingsBusinessProfilePage', () => ({ SettingsBusinessProfilePage: () => null })); +vi.mock('../settings/CrmSettingsPage', () => ({ CrmSettingsPage: () => null })); +vi.mock('../settings/ReminderTemplatesPage', () => ({ ReminderTemplatesPage: () => null })); +vi.mock('../contracts/BlockLibraryPage', () => ({ BlockLibraryPage: () => null })); + +import { SettingsPage } from '../SettingsPage'; + +function renderAt(tab: string) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +describe('SettingsPage fresh-mount permission race (QA J.08)', () => { + beforeEach(() => { + permissionsState.hasAnyPermission = () => true; + permissionsState.isLoading = false; + }); + + it('does not crash on a deep-linked tab while permissions are still loading', () => { + permissionsState.isLoading = true; + permissionsState.hasAnyPermission = () => false; + + expect(() => renderAt('webhooks')).not.toThrow(); + expect(screen.getByText('settings.loadingSettings')).toBeInTheDocument(); + }); + + it('still lands on the deep-linked tab once permissions arrive', () => { + renderAt('webhooks'); + + expect(screen.getByRole('heading', { level: 2, name: 'Webhooks' })).toBeInTheDocument(); + }); + + it('does not crash when the role has no settings tab permissions at all', () => { + permissionsState.hasAnyPermission = () => false; + + expect(() => renderAt('webhooks')).not.toThrow(); + expect(screen.getByText('settings.title')).toBeInTheDocument(); + }); +});