From 9ba1e9bbc0e9382a1af3675f135e2e2991380bbf Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 30 Sep 2026 14:00:29 -0400 Subject: [PATCH] feat(web): let admins switch to any group An admin already holds `manage all` on the API, but the group switcher only offered the groups on their token. Admins are now offered every group on the platform. An admin who belongs to no group still starts with none selected and sees a placeholder until they pick one. Admin specs that opened the first combobox on the page now target their own field by testid, since the switcher is now a combobox for the seeded admin too. Closes #1545 Co-Authored-By: Claude Opus 5.5 --- .../web/src/__tests__/group-switcher.test.tsx | 84 +++++++++++++++++++ .../GroupSwitcher/GroupSwitcher.tsx | 31 +++---- .../__tests__/useSwitchableGroups.test.ts | 82 ++++++++++++++++++ apps/web/src/hooks/useSwitchableGroups.ts | 25 ++++++ apps/web/src/routes/_app/admin/settings.tsx | 2 +- testing/src/pages/_app/admin/settings.page.ts | 2 +- testing/src/pages/_app/route.page.ts | 19 +++++ testing/src/specs/admin-management.spec.ts | 8 +- testing/src/specs/group-switcher.spec.ts | 63 ++++++++++++++ 9 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/__tests__/group-switcher.test.tsx create mode 100644 apps/web/src/hooks/__tests__/useSwitchableGroups.test.ts create mode 100644 apps/web/src/hooks/useSwitchableGroups.ts create mode 100644 testing/src/specs/group-switcher.spec.ts 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 (