Skip to content
Open
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
100 changes: 100 additions & 0 deletions apps/web/src/__tests__/start-session-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { StartSessionForm } from '@/components/StartSessionForm';

import '@/services/i18n';

const onSubmit = vi.fn();

const renderForm = (customSubjectIds: string[]) => {
render(
<StartSessionForm
currentGroup={null}
customSubjectIds={customSubjectIds}
readOnly={false}
username="admin"
onSubmit={onSubmit}
/>
);
const form = screen.getByTestId('start-session-form');
fireEvent.change(form.querySelector('[name="subjectIdentificationMethod"]')!, {
target: { value: 'CUSTOM_ID' }
});
return form;
};

const identifierInput = () => screen.getByTestId<HTMLInputElement>('subjectId-combobox-input');

/**
* Base UI reads `inputType` to tell typing from autofill and only opens the popup for the former,
* so `fireEvent.change` — which sets no `inputType` — would leave it closed.
*/
const typeIdentifier = (identifier: string) => {
fireEvent.input(identifierInput(), { inputType: 'insertText', target: { value: identifier } });
};

/** A custom value is committed when the popup closes, not on each keystroke. */
const closeIdentifierPopup = () => {
fireEvent.keyDown(identifierInput(), { key: 'Enter' });
};

const submit = (form: HTMLElement) => {
fireEvent.change(form.querySelector('[name="sessionType"]')!, { target: { value: 'IN_PERSON' } });
fireEvent.click(screen.getByLabelText('Submit'));
};

const submittedSubjectId = () => onSubmit.mock.lastCall?.[0].subjectData.id;

beforeEach(() => {
// There are no vitest setup files in this repo, so RTL never auto-unmounts between tests.
cleanup();
vi.clearAllMocks();
});

describe('StartSessionForm', () => {
// A new subject's identifier is by definition absent from the options, so the combobox has to keep
// text matching no option rather than reverting to the empty selection when the popup closes.
it('should submit an identifier that matches no existing subject, so a new subject can be enrolled', async () => {
const form = renderForm(['alpha']);
typeIdentifier('gamma');
closeIdentifierPopup();
submit(form);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(submittedSubjectId()).toBe(`${DEFAULT_GROUP_NAME}$gamma`);
});

it('should leave an identifier matching no existing subject in the input, so the clinician sees what they typed', () => {
renderForm(['alpha']);
typeIdentifier('gamma');
closeIdentifierPopup();
expect(identifierInput().value).toBe('gamma');
});

it('should offer the identifiers already in use as options, so an existing subject can be chosen', () => {
renderForm(['alpha']);
typeIdentifier('alpha');
expect(screen.getByTestId('subjectId-combobox-item-alpha')).toBeTruthy();
});

it('should submit an identifier picked from the options, so a returning subject reuses their own id', async () => {
const form = renderForm(['alpha']);
typeIdentifier('alpha');
fireEvent.click(screen.getByTestId('subjectId-combobox-item-alpha'));
submit(form);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(submittedSubjectId()).toBe(`${DEFAULT_GROUP_NAME}$alpha`);
});

// The identifier is scoped by prefixing the group name and a `$`, so one inside the identifier
// would make the stored id ambiguous. Validation has to reach a custom value too, not just an option.
it('should reject a custom identifier containing the scope separator', async () => {
const form = renderForm(['alpha']);
typeIdentifier('gam$ma');
closeIdentifierPopup();
submit(form);
await waitFor(() => expect(screen.getByText('Illegal character: $')).toBeTruthy());
expect(onSubmit).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default { component: StartSessionForm } as Meta<typeof StartSessionForm>;

export const Default: Story = {
args: {
customSubjectIds: ['SUBJECT_001', 'SUBJECT_002', 'SUBJECT_003'],
onSubmit(data) {
alert(JSON.stringify(data, null, 2));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type StartSessionFormData = {

type StartSessionFormProps = {
currentGroup: Group | null;
customSubjectIds: string[];
initialValues?: FormTypes.PartialNullableData<StartSessionFormData>;
onSubmit: (data: CreateSessionData) => Promisable<void>;
readOnly: boolean;
Expand All @@ -38,6 +39,7 @@ type StartSessionFormProps = {

export const StartSessionForm = ({
currentGroup,
customSubjectIds,
username,
initialValues,
readOnly,
Expand Down Expand Up @@ -80,7 +82,9 @@ export const StartSessionForm = ({
? {
kind: 'string',
label: t('common.identifier'),
variant: 'input'
variant: 'combobox',
allowCustomValue: true,
options: Object.fromEntries(customSubjectIds.map((id) => [id, id]))
}
: null;
}
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/routes/_app/session/start-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import React, { useEffect, useState } from 'react';
import { Card, Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { FormTypes } from '@opendatacapture/runtime-core';
import { isSubjectWithPersonalInfo, removeSubjectIdScope } from '@opendatacapture/subject-utils';
import { createFileRoute, useLocation } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'motion/react';

import { PageHeader } from '@/components/PageHeader';
import { StartSessionForm } from '@/components/StartSessionForm';
import type { StartSessionFormData } from '@/components/StartSessionForm';
import { useCreateSessionMutation } from '@/hooks/useCreateSessionMutation';
import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery';
import { useAppStore } from '@/store';

const RouteComponent = () => {
Expand All @@ -28,6 +30,11 @@ const RouteComponent = () => {

const { t } = useTranslation('session');
const createSessionMutation = useCreateSessionMutation();
const subjectsQuery = useSubjectsQuery({ params: { groupId: currentGroup?.id } });

const customSubjectIds = subjectsQuery.data
.filter((subject) => !isSubjectWithPersonalInfo(subject))
.map((subject) => removeSubjectIdScope(subject.id));

useEffect(() => {
if (currentSession === null) {
Expand All @@ -45,6 +52,7 @@ const RouteComponent = () => {
{currentSession === null && (
<StartSessionForm
currentGroup={currentGroup}
customSubjectIds={customSubjectIds}
initialValues={initialValues}
readOnly={currentSession !== null || createSessionMutation.isPending}
username={currentUser?.username}
Expand Down Expand Up @@ -110,5 +118,9 @@ const RouteComponent = () => {
};

export const Route = createFileRoute('/_app/session/start-session')({
component: RouteComponent
component: RouteComponent,
loader: async ({ context }) => {
const { currentGroup } = useAppStore.getState();
await context.queryClient.ensureQueryData(subjectsQueryOptions({ params: { groupId: currentGroup?.id } }));
}
});
44 changes: 28 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ catalog:
'@douglasneuroinformatics/libjs': ^3.2.1
'@douglasneuroinformatics/libpasswd': '^0.0.3'
'@douglasneuroinformatics/libstats': '^0.2.1'
'@douglasneuroinformatics/libui': ^6.11.2
'@douglasneuroinformatics/libui-form-types': ^1.1.0
'@douglasneuroinformatics/libui': ^6.14.0
'@douglasneuroinformatics/libui-form-types': ^1.3.0
'@microsoft/api-extractor': '^7.47.6'
'@prisma/client': '^6.9.0'
'@tailwindcss/vite': '4.3.0'
Expand Down
Loading
Loading