diff --git a/apps/web/src/__tests__/group-switcher.test.tsx b/apps/web/src/__tests__/group-switcher.test.tsx new file mode 100644 index 000000000..99d7269f9 --- /dev/null +++ b/apps/web/src/__tests__/group-switcher.test.tsx @@ -0,0 +1,84 @@ +import type { PropsWithChildren } from 'react'; + +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GroupSwitcher } from '@/components/GroupSwitcher'; + +import '@/services/i18n'; + +const mockAxios = vi.hoisted(() => ({ get: vi.fn(), isAxiosError: vi.fn(() => false) })); +const store = vi.hoisted(() => ({ + changeGroup: vi.fn(), + currentGroup: null as null | { id: string; name: string }, + currentUser: null as null | { + ability: { can: (action: string, subject: string) => boolean }; + groups: { id: string; name: string }[]; + } +})); + +vi.mock('axios', () => ({ default: mockAxios })); + +vi.mock('@/store', () => ({ + useAppStore: vi.fn((selector) => selector(store)) +})); + +const platformGroup = { + accessibleInstrumentIds: [], + createdAt: new Date('2026-01-01T00:00:00.000Z'), + id: 'platform', + instrumentRepoIds: [], + name: 'Platform Group', + settings: { defaultIdentificationMethod: 'CUSTOM_ID' }, + subjectIds: [], + type: 'CLINICAL', + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + userIds: [] +}; + +const Wrapper = ({ children }: PropsWithChildren) => ( + + {children} + +); + +const asUser = ({ groups, isAdmin }: { groups: { id: string; name: string }[]; isAdmin: boolean }) => ({ + ability: { can: (action: string, subject: string) => isAdmin && action === 'manage' && subject === 'all' }, + groups +}); + +describe('GroupSwitcher', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + vi.clearAllMocks(); + mockAxios.get.mockResolvedValue({ data: [platformGroup] }); + }); + + afterEach(cleanup); + + it('should prompt an admin who belongs to no group to select one, so they can reach any group on the platform', async () => { + store.currentGroup = null; + store.currentUser = asUser({ groups: [], isAdmin: true }); + render(, { wrapper: Wrapper }); + expect((await screen.findByTestId('group-switcher')).textContent).toContain('Select a group'); + }); + + it('should render nothing for a non-admin who belongs to no group, since there is nothing to switch to', () => { + store.currentGroup = null; + store.currentUser = asUser({ groups: [], isAdmin: false }); + render(, { wrapper: Wrapper }); + expect(screen.queryByTestId('group-switcher')).toBeNull(); + }); + + it('should show the only group a non-admin belongs to as static text, since there is nothing to switch to', () => { + const group = { id: 'own', name: 'Own Group' }; + store.currentGroup = group; + store.currentUser = asUser({ groups: [group], isAdmin: false }); + render(, { wrapper: Wrapper }); + const switcher = screen.getByTestId('group-switcher'); + expect(switcher.tagName).toBe('DIV'); + expect(switcher.textContent).toContain('Own Group'); + }); +}); diff --git a/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx b/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx index 847b88ac8..cb90ffd65 100644 --- a/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx +++ b/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx @@ -2,6 +2,7 @@ import { Select } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { cn } from '@douglasneuroinformatics/libui/utils'; +import { useSwitchableGroups } from '@/hooks/useSwitchableGroups'; import { useAppStore } from '@/store'; /** @@ -14,26 +15,24 @@ const SURFACE_CLASSNAME = /** * Whether the switcher will render anything. Call sites that draw chrome around it — a separator, a * sticky bar — need the same answer before laying that chrome out, otherwise they leave an empty - * container behind for a user who belongs to no group. + * container behind for a user with no group to switch to. */ export function useIsGroupSwitcherVisible() { - const currentGroup = useAppStore((store) => store.currentGroup); - const currentUser = useAppStore((store) => store.currentUser); - return Boolean(currentGroup && currentUser && currentUser.groups.length > 0); + return useSwitchableGroups().length > 0; } /** - * The current group, and (when the user belongs to more than one) a control to switch between them. + * The current group, and (when there is more than one to choose from) a control to switch between them. * Renders the control alone with no surrounding layout: the sidebar, the mobile nav sheet, and the top * bar each place it differently, so spacing and width belong to the caller via `className`. */ export const GroupSwitcher = ({ className }: { className?: string }) => { const changeGroup = useAppStore((store) => store.changeGroup); const currentGroup = useAppStore((store) => store.currentGroup); - const currentUser = useAppStore((store) => store.currentUser); + const groups = useSwitchableGroups(); const { t } = useTranslation(); - if (!currentGroup || !currentUser || currentUser.groups.length === 0) { + if (groups.length === 0) { return null; } @@ -43,9 +42,9 @@ export const GroupSwitcher = ({ className }: { className?: string }) => { ); - // A user in exactly one group has nothing to switch between, so show the group as static text styled - // to match the trigger, rather than a select whose only option is the current value. - if (currentUser.groups.length === 1) { + // A user with exactly one group, already selected, has nothing to switch between, so show the group as + // static text styled to match the trigger, rather than a select whose only option is the current value. + if (groups.length === 1 && currentGroup) { return (
{ ); } + // An admin who belongs to no group starts with none selected. Radix shows the placeholder only for an + // empty-string value; `undefined` would flip the select to uncontrolled once a group is chosen. return (