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 (