Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/web/src/__tests__/group-switcher.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
);

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(<GroupSwitcher />, { 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(<GroupSwitcher />, { 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(<GroupSwitcher />, { wrapper: Wrapper });
const switcher = screen.getByTestId('group-switcher');
expect(switcher.tagName).toBe('DIV');
expect(switcher.textContent).toContain('Own Group');
});
});
31 changes: 17 additions & 14 deletions apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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;
}

Expand All @@ -43,9 +42,9 @@ export const GroupSwitcher = ({ className }: { className?: string }) => {
</span>
);

// 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 (
<div
className={cn(SURFACE_CLASSNAME, 'flex flex-col items-start rounded-md px-3', className)}
Expand All @@ -57,10 +56,12 @@ export const GroupSwitcher = ({ className }: { className?: string }) => {
);
}

// 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 (
<Select
value={currentGroup.id}
onValueChange={(id) => changeGroup(currentUser.groups.find((group) => group.id === id)!)}
value={currentGroup?.id ?? ''}
onValueChange={(id) => changeGroup(groups.find((group) => group.id === id)!)}
>
<Select.Trigger
className={cn(
Expand All @@ -72,12 +73,14 @@ export const GroupSwitcher = ({ className }: { className?: string }) => {
>
<div className="flex flex-col items-start leading-tight">
{label}
<Select.Value />
<Select.Value
placeholder={t({ en: 'Select a group', es: 'Seleccione un grupo', fr: 'Sélectionnez un groupe' })}
/>
</div>
</Select.Trigger>
<Select.Content>
<Select.Group>
{currentUser.groups.map((group) => (
{groups.map((group) => (
<Select.Item key={group.id} value={group.id}>
{group.name}
</Select.Item>
Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/hooks/__tests__/useSwitchableGroups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { PropsWithChildren } from 'react';
import { createElement } from 'react';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useSwitchableGroups } from '../useSwitchableGroups';

const mockAxios = vi.hoisted(() => ({ get: vi.fn(), isAxiosError: vi.fn(() => false) }));
const store = vi.hoisted(() => ({
currentUser: null as null | { ability: { can: (action: string, subject: string) => boolean }; groups: unknown[] }
}));

vi.mock('axios', () => ({ default: mockAxios }));

vi.mock('@/store', () => ({
useAppStore: vi.fn((selector) => selector(store))
}));

const createGroup = (id: string) => ({
accessibleInstrumentIds: [],
createdAt: new Date('2026-01-01T00:00:00.000Z'),
id,
instrumentRepoIds: [],
name: `Group ${id}`,
settings: { defaultIdentificationMethod: 'CUSTOM_ID' },
subjectIds: [],
type: 'CLINICAL',
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
userIds: []
});

const ownGroup = createGroup('own');
const otherGroup = createGroup('other');

const asUser = ({ isAdmin }: { isAdmin: boolean }) => ({
ability: { can: (action: string, subject: string) => isAdmin && action === 'manage' && subject === 'all' },
groups: [ownGroup]
});

function renderSwitchableGroups() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: PropsWithChildren) =>
createElement(QueryClientProvider, { children, client: queryClient });
return renderHook(() => useSwitchableGroups(), { wrapper });
}

describe('useSwitchableGroups', () => {
beforeEach(() => {
vi.clearAllMocks();
mockAxios.get.mockResolvedValue({ data: [ownGroup, otherGroup] });
});

it('should offer an admin every group on the platform, so they can act in groups they do not belong to', async () => {
store.currentUser = asUser({ isAdmin: true });
const { result } = renderSwitchableGroups();
await waitFor(() => expect(result.current).toEqual([ownGroup, otherGroup]));
expect(mockAxios.get).toHaveBeenCalledWith('/v1/groups');
});

it("should offer an admin their own groups until the platform's groups load, so the switcher does not blank out", () => {
store.currentUser = asUser({ isAdmin: true });
mockAxios.get.mockReturnValue(new Promise(() => undefined));
const { result } = renderSwitchableGroups();
expect(result.current).toEqual([ownGroup]);
});

it('should offer anyone else only the groups on their token, without fetching the platform groups', () => {
store.currentUser = asUser({ isAdmin: false });
const { result } = renderSwitchableGroups();
expect(result.current).toEqual([ownGroup]);
expect(mockAxios.get).not.toHaveBeenCalled();
});

it('should offer nothing when no one is logged in', () => {
store.currentUser = null;
const { result } = renderSwitchableGroups();
expect(result.current).toEqual([]);
expect(mockAxios.get).not.toHaveBeenCalled();
});
});
25 changes: 25 additions & 0 deletions apps/web/src/hooks/useSwitchableGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Group } from '@opendatacapture/schemas/group';
import { useQuery } from '@tanstack/react-query';

import { useAppStore } from '@/store';

import { groupsQueryOptions } from './useGroupsQuery';

/**
* The groups the current user may make their current group. An admin may act in any group on the
* platform, not only the ones they belong to, so theirs are fetched; until that resolves they fall back
* to their own, so the switcher never blanks out while the chrome around it is already on screen.
*/
export function useSwitchableGroups(): Group[] {
const currentUser = useAppStore((store) => store.currentUser);
const isAdmin = currentUser?.ability.can('manage', 'all') ?? false;
const groupsQuery = useQuery({ ...groupsQueryOptions(), enabled: isAdmin });

if (!currentUser) {
return [];
}
if (isAdmin && groupsQuery.data) {
return groupsQuery.data;
}
return currentUser.groups;
}
2 changes: 1 addition & 1 deletion apps/web/src/routes/_app/admin/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ const RouteComponent = () => {
value={groupSwitcherPosition}
onValueChange={(value) => setGroupSwitcherPosition(value as GroupSwitcherPosition)}
>
<Select.Trigger className="w-[180px] shrink-0">
<Select.Trigger className="w-[180px] shrink-0" data-testid="group-switcher-position-select">
<Select.Value />
</Select.Trigger>
<Select.Content>
Expand Down
2 changes: 1 addition & 1 deletion testing/src/pages/_app/admin/settings.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class AdminSettingsPage extends AppPage {
this.pageHeader = page.getByTestId('page-header');
this.uploaderToggle = page.getByRole('switch', { name: 'Enable Uploader' });
this.defaultAssignmentDurationInput = page.getByTestId('default-assignment-duration-input');
this.groupSwitcherPositionSelect = page.getByRole('combobox');
this.groupSwitcherPositionSelect = page.getByTestId('group-switcher-position-select');
}

activeLanguageCheckbox(language: Language): Locator {
Expand Down
19 changes: 19 additions & 0 deletions testing/src/pages/_app/route.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { RootPage } from '../__root.page';

export abstract class AppPage extends RootPage {
readonly _requiresAuth = true;
readonly groupSwitcher: Locator;
readonly sidebar: Locator;

constructor(page: Page) {
super(page);
this.sidebar = page.getByTestId('sidebar');
this.groupSwitcher = this.sidebar.getByTestId('group-switcher');
}

/**
Expand All @@ -24,4 +26,21 @@ export abstract class AppPage extends RootPage {
}
await expect(groupButton).toHaveAttribute('aria-expanded', 'true');
}

/**
* Opens the sidebar group switcher and returns its options, for asserting which groups are offered.
* An admin's switcher first renders from their own groups and becomes a select only once every group
* on the platform has loaded, so wait for the select rather than clicking static text.
*/
async openGroupSwitcher() {
await expect(this.groupSwitcher).toHaveRole('combobox');
await this.groupSwitcher.click();
return this.$ref.getByRole('option');
}

async switchGroup(name: string) {
const options = await this.openGroupSwitcher();
await options.filter({ hasText: name }).click();
await expect(this.groupSwitcher).toContainText(name);
}
}
8 changes: 4 additions & 4 deletions testing/src/specs/admin-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ test.describe('admin management', () => {
await page.goto('/admin/groups/create');

await page.getByLabel('Group Name').fill(groupName);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Clinical' }).click();
await page.getByTestId('type-select-trigger').click();
await page.getByTestId('type-select-item-CLINICAL').click();
await page.getByRole('button', { name: 'Submit' }).click();

// Success toast + redirect are the app's own success signals; the "group is now listed" case is
Expand Down Expand Up @@ -100,8 +100,8 @@ test.describe('admin management', () => {
await page.getByLabel('Confirm Password').fill('weak');
await page.getByLabel('First Name').fill('Test');
await page.getByLabel('Last Name').fill('User');
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Admin' }).click();
await page.getByTestId('basePermissionLevel-select-trigger').click();
await page.getByTestId('basePermissionLevel-select-item-ADMIN').click();
await page.getByRole('button', { name: 'Submit' }).click();

await expect(
Expand Down
Loading
Loading