diff --git a/apps/web/src/__tests__/start-session-form.test.tsx b/apps/web/src/__tests__/start-session-form.test.tsx
new file mode 100644
index 000000000..6aee367b1
--- /dev/null
+++ b/apps/web/src/__tests__/start-session-form.test.tsx
@@ -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(
+
+ );
+ const form = screen.getByTestId('start-session-form');
+ fireEvent.change(form.querySelector('[name="subjectIdentificationMethod"]')!, {
+ target: { value: 'CUSTOM_ID' }
+ });
+ return form;
+};
+
+const identifierInput = () => screen.getByTestId('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();
+ });
+});
diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx
index e308c9df8..cef795dfc 100644
--- a/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx
+++ b/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx
@@ -8,6 +8,7 @@ export default { component: StartSessionForm } as Meta;
export const Default: Story = {
args: {
+ customSubjectIds: ['SUBJECT_001', 'SUBJECT_002', 'SUBJECT_003'],
onSubmit(data) {
alert(JSON.stringify(data, null, 2));
}
diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx
index 42f063d3d..dda1d6ea5 100644
--- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx
+++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx
@@ -30,6 +30,7 @@ type StartSessionFormData = {
type StartSessionFormProps = {
currentGroup: Group | null;
+ customSubjectIds: string[];
initialValues?: FormTypes.PartialNullableData;
onSubmit: (data: CreateSessionData) => Promisable;
readOnly: boolean;
@@ -38,6 +39,7 @@ type StartSessionFormProps = {
export const StartSessionForm = ({
currentGroup,
+ customSubjectIds,
username,
initialValues,
readOnly,
@@ -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;
}
diff --git a/apps/web/src/routes/_app/session/start-session.tsx b/apps/web/src/routes/_app/session/start-session.tsx
index 3ba33529b..a5797785a 100644
--- a/apps/web/src/routes/_app/session/start-session.tsx
+++ b/apps/web/src/routes/_app/session/start-session.tsx
@@ -3,6 +3,7 @@ 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';
@@ -10,6 +11,7 @@ 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 = () => {
@@ -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) {
@@ -45,6 +52,7 @@ const RouteComponent = () => {
{currentSession === null && (
{
};
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 } }));
+ }
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 448760668..cf4e17322 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,11 +25,11 @@ catalogs:
specifier: ^0.2.1
version: 0.2.1
'@douglasneuroinformatics/libui':
- specifier: ^6.11.2
- version: 6.11.2
+ specifier: ^6.14.0
+ version: 6.14.0
'@douglasneuroinformatics/libui-form-types':
- specifier: ^1.1.0
- version: 1.1.0
+ specifier: ^1.3.0
+ version: 1.3.0
'@microsoft/api-extractor':
specifier: ^7.47.6
version: 7.58.7
@@ -386,7 +386,7 @@ importers:
version: 0.0.6
'@douglasneuroinformatics/libui':
specifier: 'catalog:'
- version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
+ version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
'@opendatacapture/react-core':
specifier: workspace:*
version: link:../../packages/react-core
@@ -486,7 +486,7 @@ importers:
dependencies:
'@douglasneuroinformatics/libui':
specifier: 'catalog:'
- version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)
+ version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)
'@opendatacapture/licenses':
specifier: workspace:*
version: link:../../packages/licenses
@@ -571,7 +571,7 @@ importers:
version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x)
'@douglasneuroinformatics/libui':
specifier: 'catalog:'
- version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
+ version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.52.2)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)
@@ -698,7 +698,7 @@ importers:
version: 0.0.3(typescript@6.0.3)
'@douglasneuroinformatics/libui':
specifier: 'catalog:'
- version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
+ version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@vendor+react@19.x)
@@ -992,7 +992,7 @@ importers:
version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x)
'@douglasneuroinformatics/libui':
specifier: 'catalog:'
- version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
+ version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
'@opendatacapture/instrument-bundler':
specifier: workspace:*
version: link:../instrument-bundler
@@ -1107,7 +1107,7 @@ importers:
dependencies:
'@douglasneuroinformatics/libui-form-types':
specifier: 'catalog:'
- version: 1.1.0
+ version: 1.3.0
'@opendatacapture/licenses':
specifier: workspace:*
version: link:../licenses
@@ -1183,7 +1183,7 @@ importers:
version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x)
'@douglasneuroinformatics/libui':
specifier: 'catalog:'
- version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
+ version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)
'@opendatacapture/instrument-bundler':
specifier: 'workspace:'
version: link:../instrument-bundler
@@ -1396,7 +1396,7 @@ importers:
devDependencies:
'@douglasneuroinformatics/libui':
specifier: 'catalog:'
- version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)
+ version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)
'@opendatacapture/instrument-stubs':
specifier: workspace:*
version: link:../packages/instrument-stubs
@@ -2238,8 +2238,16 @@ packages:
'@types/react':
optional: true
- '@douglasneuroinformatics/libui@6.11.2':
- resolution: {integrity: sha512-sLPNCaCWbmP2N8DM8NXH2bPNSikjnThNPuILvGqy0NlNjAsTSuaHa198vHQHYbE6nVEO3N7lwRjyreM35O1lsw==}
+ '@douglasneuroinformatics/libui-form-types@1.3.0':
+ resolution: {integrity: sha512-F+q2MVGET3vw8/pg2gYFoa9wtBhFxxE/oLdjIl98RYGc7iSt6D0izTHuSWIcfJNcZPluGM9KkykVF1fYR2sVyg==}
+ peerDependencies:
+ '@types/react': '*'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@douglasneuroinformatics/libui@6.14.0':
+ resolution: {integrity: sha512-17z7FQnxEVq8Su+aTXlyTqkkam3Lbe094Tr5tb+8AQ3l22sfFAgvOlwddYVXxvtnkEEalUPtIafPi4PVvA6G4g==}
engines: {node: 24.x}
peerDependencies:
react: ^19.1.0
@@ -11992,7 +12000,11 @@ snapshots:
dependencies:
type-fest: 4.41.0
- '@douglasneuroinformatics/libui@6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)':
+ '@douglasneuroinformatics/libui-form-types@1.3.0':
+ dependencies:
+ type-fest: 4.41.0
+
+ '@douglasneuroinformatics/libui@6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)':
dependencies:
'@base-ui/react': 1.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x)
@@ -12049,7 +12061,7 @@ snapshots:
- neverthrow
- use-sync-external-store
- '@douglasneuroinformatics/libui@6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)':
+ '@douglasneuroinformatics/libui@6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)':
dependencies:
'@base-ui/react': 1.6.0(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)
'@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3ccffa76b..608ee4b40 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -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'
diff --git a/testing/src/pages/_app/session/start-session.page.ts b/testing/src/pages/_app/session/start-session.page.ts
index dd530f6b8..2b549c075 100644
--- a/testing/src/pages/_app/session/start-session.page.ts
+++ b/testing/src/pages/_app/session/start-session.page.ts
@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test';
import { AppPage } from '../route.page';
export class StartSessionPage extends AppPage {
+ readonly endSessionButton: Locator;
readonly errorMessages: Locator;
readonly pageHeader: Locator;
readonly selectField: Locator;
@@ -18,18 +19,33 @@ export class StartSessionPage extends AppPage {
this.successMessage = page.getByRole('heading', { name: 'Session Successfully Started' });
this.errorMessages = page.getByTestId('error-message-text');
this.subjectIdField = this.sessionForm.locator('[name="subjectId"]');
+ // The end session nav item opens a dialog rather than navigating, so it carries no route.
+ this.endSessionButton = page.getByTestId('nav-button-#');
+ }
+
+ /** Clicks outside the identifier combobox, which closes its popup and commits what was typed. */
+ async dismissSubjectIdOptions() {
+ await this.pageHeader.click();
+ }
+
+ async endSession() {
+ await this.endSessionButton.click();
+ await this.$ref.getByRole('button', { name: 'Yes' }).click();
+ await this.sessionForm.waitFor({ state: 'visible' });
}
async fillCustomIdentifier(customIdentifier: string, sex: string) {
- const subjectIdField = this.sessionForm.locator('[name="subjectId"]');
+ await this.typeSubjectId(customIdentifier);
+ await this.fillSessionDetails(sex);
+ }
+
+ /** Everything the form needs beyond how the subject was identified. */
+ async fillSessionDetails(sex: string) {
const dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]');
const sexSelector = this.sessionForm.locator('[name="subjectSex"]');
const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]');
const sessionDate = this.sessionForm.locator('[name="sessionDate"]');
- await subjectIdField.waitFor({ state: 'visible' });
- await subjectIdField.fill(customIdentifier);
-
await dateOfBirthField.waitFor({ state: 'visible' });
await dateOfBirthField.fill('1990-01-01');
@@ -45,10 +61,6 @@ export class StartSessionPage extends AppPage {
async fillSessionForm(firstName: string, lastName: string, sex: string) {
const firstNameField = this.sessionForm.locator('[name="subjectFirstName"]');
const lastNameField = this.sessionForm.locator('[name="subjectLastName"]');
- const dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]');
- const sexSelector = this.sessionForm.locator('[name="subjectSex"]');
- const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]');
- const sessionDate = this.sessionForm.locator('[name="sessionDate"]');
await firstNameField.waitFor({ state: 'visible' });
await firstNameField.fill(firstName);
@@ -56,22 +68,18 @@ export class StartSessionPage extends AppPage {
await lastNameField.waitFor({ state: 'visible' });
await lastNameField.fill(lastName);
- await dateOfBirthField.waitFor({ state: 'visible' });
- await dateOfBirthField.fill('1990-01-01');
-
- await sexSelector.selectOption(sex);
-
- await sessionTypeSelector.selectOption('Retrospective');
-
- await sessionDate.waitFor({ state: 'visible' });
- const expectedSessionDate = new Date().toISOString().split('T')[0]!;
- await sessionDate.fill(expectedSessionDate);
+ await this.fillSessionDetails(sex);
}
async selectIdentificationMethod(methodName: string) {
await this.selectField.selectOption(methodName);
}
+ /** An identifier already in use by a subject in the current group, offered in the popup. */
+ subjectIdOption(identifier: string) {
+ return this.$ref.getByTestId(`subjectId-combobox-item-${identifier}`);
+ }
+
async submitForm() {
const submitButton = this.sessionForm.getByLabel('Submit');
@@ -79,4 +87,10 @@ export class StartSessionPage extends AppPage {
await submitButton.click();
}
+
+ /** Types into the identifier combobox, leaving the options popup open. */
+ async typeSubjectId(identifier: string) {
+ await this.subjectIdField.waitFor({ state: 'visible' });
+ await this.subjectIdField.fill(identifier);
+ }
}
diff --git a/testing/src/specs/start-session.spec.ts b/testing/src/specs/start-session.spec.ts
index 9826e29b2..d1ae7e446 100644
--- a/testing/src/specs/start-session.spec.ts
+++ b/testing/src/specs/start-session.spec.ts
@@ -30,6 +30,56 @@ test.describe('start session', () => {
await expect(startSessionPage.successMessage).toBeVisible();
});
+ // The identifier combobox offers the subjects already enrolled in the group, so a brand new
+ // subject's identifier necessarily matches no option. Clicking away used to discard it.
+ test('should keep a custom identifier matching no existing subject when the options popup is dismissed', async ({
+ getPageModel,
+ uniqueId
+ }) => {
+ const startSessionPage = await getPageModel('/session/start-session');
+ await startSessionPage.sessionForm.waitFor({ state: 'visible' });
+
+ await startSessionPage.selectIdentificationMethod('CUSTOM_ID');
+ await startSessionPage.typeSubjectId(`unmatched-${uniqueId}`);
+ await startSessionPage.dismissSubjectIdOptions();
+
+ await expect(startSessionPage.subjectIdField).toHaveValue(`unmatched-${uniqueId}`);
+
+ await startSessionPage.fillSessionDetails('Male');
+ await startSessionPage.submitForm();
+ await expect(startSessionPage.successMessage).toBeVisible();
+ });
+
+ test('should start a session for a subject chosen from the existing custom identifiers', async ({
+ getPageModel,
+ page,
+ uniqueId
+ }) => {
+ const identifier = `returning-${uniqueId}`;
+ const startSessionPage = await getPageModel('/session/start-session');
+ await startSessionPage.sessionForm.waitFor({ state: 'visible' });
+
+ await startSessionPage.selectIdentificationMethod('CUSTOM_ID');
+ await startSessionPage.fillCustomIdentifier(identifier, 'Male');
+ await startSessionPage.submitForm();
+ await expect(startSessionPage.successMessage).toBeVisible();
+
+ // The subject only becomes an option once it exists, and the options are read by the route
+ // loader, so the second visit has to be a fresh load rather than a client-side navigation.
+ await startSessionPage.endSession();
+ await page.reload();
+ await startSessionPage.sessionForm.waitFor({ state: 'visible' });
+
+ await startSessionPage.selectIdentificationMethod('CUSTOM_ID');
+ await startSessionPage.typeSubjectId(identifier);
+ await startSessionPage.subjectIdOption(identifier).click();
+ await expect(startSessionPage.subjectIdField).toHaveValue(identifier);
+
+ await startSessionPage.fillSessionDetails('Male');
+ await startSessionPage.submitForm();
+ await expect(startSessionPage.successMessage).toBeVisible();
+ });
+
test('should show a required-field error for every missing field when submitting the personal information form empty', async ({
getPageModel
}) => {