From 7aa1d58a50682926b6ce597095b4e18adaf675fd Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 6 Mar 2026 12:36:58 -0500 Subject: [PATCH 1/8] feat: created dropdown list of custom ids for subjectId input --- .../StartSessionForm/StartSessionForm.tsx | 540 +++++++++++------- 1 file changed, 338 insertions(+), 202 deletions(-) diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index be271c85e..caf5d1416 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -9,10 +9,14 @@ import { $SessionType } from '@opendatacapture/schemas/session'; import type { CreateSessionData } from '@opendatacapture/schemas/session'; import { $SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import type { Sex, SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; -import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/subject-utils'; +import { encodeScopedSubjectId, generateSubjectHash, removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import { useEffect, useState, useRef } from 'react'; +import { createPortal } from 'react-dom'; import type { Promisable } from 'type-fest'; import { z } from 'zod/v4'; +import { useSubjectsQuery } from '@/hooks/useSubjectsQuery'; + const currentDate = new Date(); const EIGHTEEN_YEARS = 568025136000; // milliseconds @@ -46,223 +50,355 @@ export const StartSessionForm = ({ onSubmit }: StartSessionFormProps) => { const { resolvedLanguage, t } = useTranslation(); + + const subjectsQuery = useSubjectsQuery({ params: { groupId: currentGroup?.id } }); + const subjects = subjectsQuery.data ?? []; + + const [dropdownPos, setDropdownPos] = useState<{ left: number; top: number; width: number } | null>(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [searchString, setSearchString] = useState(''); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleInput = (e: Event) => { + setSearchString((e.target as HTMLInputElement).value); + }; + + const handleFocus = () => { + if (inputRef.current) { + const rect = inputRef.current.getBoundingClientRect(); + setDropdownPos({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width + }); + setSearchString(inputRef.current.value); + setIsDropdownOpen(true); + } + }; + + const handleBlur = (e: FocusEvent) => { + // Prevent closing if we clicked inside the dropdown + if (dropdownRef.current && e.relatedTarget instanceof Node && dropdownRef.current.contains(e.relatedTarget)) { + return; + } + setIsDropdownOpen(false); + }; + + const observer = new MutationObserver(() => { + const input = document.querySelector('input[name="subjectId"]') as HTMLInputElement; + if (input && input !== inputRef.current) { + if (inputRef.current) { + inputRef.current.removeEventListener('input', handleInput); + inputRef.current.removeEventListener('focus', handleFocus); + inputRef.current.removeEventListener('blur', handleBlur); + } + inputRef.current = input; + input.addEventListener('input', handleInput); + input.addEventListener('focus', handleFocus); + input.addEventListener('blur', handleBlur); + + // Disable browser autocomplete since we're using our own + input.setAttribute('autocomplete', 'off'); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + if (inputRef.current) { + inputRef.current.removeEventListener('input', handleInput); + inputRef.current.removeEventListener('focus', handleFocus); + inputRef.current.removeEventListener('blur', handleBlur); + } + }; + }, []); + + const handleSelectOption = (value: string) => { + if (inputRef.current) { + // Set value natively to trigger React's internal state mechanism in the Form component + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + nativeInputValueSetter?.call(inputRef.current, value); + + const event = new Event('input', { bubbles: true }); + inputRef.current.dispatchEvent(event); + + setIsDropdownOpen(false); + inputRef.current.focus(); + } + }; + + const filteredSubjects = subjects.filter((s) => { + const displayId = removeSubjectIdScope(s.id); + const search = searchString.toLowerCase(); + return ( + displayId.toLowerCase().includes(search) || + (s.firstName && s.firstName.toLowerCase().includes(search)) || + (s.lastName && s.lastName.toLowerCase().includes(search)) + ); + }); + return ( -
+ {isDropdownOpen && + dropdownPos && + createPortal( +
+ {filteredSubjects.length > 0 ? ( + filteredSubjects.map((subject) => { + const displayId = removeSubjectIdScope(subject.id); + return ( + + ); + }) + ) : ( +
+ {t({ en: 'No subjects found.', fr: 'Aucun sujet trouvé.' })} +
+ )} +
, + document.body + )} + !arg.includes('$'), - t({ - en: 'Illegal character: $', - fr: 'Caractère illégal : $' - }) - ) - .optional(), - subjectDateOfBirth: z - .date() - .max(MIN_DATE_OF_BIRTH, { message: t('session.errors.mustBeAdult') }) - .optional(), - subjectSex: z.enum(['MALE', 'FEMALE']).optional(), - sessionType: $SessionType.exclude(['REMOTE']), - sessionDate: z - .date() - .max(currentDate, { message: t('session.errors.assessmentMustBeInPast') }) - .default(currentDate) - }) - .superRefine((val, ctx) => { - if (val.subjectIdentificationMethod === 'CUSTOM_ID') { - if (!val.subjectId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t('core.form.requiredField'), - path: ['subjectId'] - }); - } else if (currentGroup?.settings.idValidationRegex) { - try { - const regex = new RegExp(currentGroup?.settings.idValidationRegex); - if (!regex.test(val.subjectId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - currentGroup.settings.idValidationRegexErrorMessage?.[resolvedLanguage] ?? - t({ - en: `Must match regular expression: ${regex.source}`, - fr: `Doit correspondre à l'expression régulière : ${regex.source}` - }), - path: ['subjectId'] - }); + }, + { + title: t('session.additionalData.title'), + fields: { + sessionType: { + kind: 'string', + label: t('session.type.label'), + variant: 'select', + options: { + RETROSPECTIVE: t('session.type.retrospective'), + IN_PERSON: t('session.type.in-person') + } + }, + sessionDate: { + kind: 'dynamic', + deps: ['sessionType'], + render({ sessionType }) { + return sessionType === 'RETROSPECTIVE' + ? { + description: t('session.dateAssessed.description'), + kind: 'date', + label: t('session.dateAssessed.label') + } + : null; } - } catch (err) { - // this should be checked already on the backend - console.error(err); } } - } else if (val.subjectIdentificationMethod === 'PERSONAL_INFO') { - const requiredKeys = ['subjectFirstName', 'subjectLastName', 'subjectSex', 'subjectDateOfBirth'] as const; - for (const key of requiredKeys) { - if (!val[key]) { + } + ]} + data-testid="start-session-form" + initialValues={initialValues} + readOnly={readOnly} + submitBtnLabel={t('core.submit')} + validationSchema={z + .object({ + subjectFirstName: z.string().optional(), + subjectLastName: z.string().optional(), + subjectIdentificationMethod: $SubjectIdentificationMethod, + subjectId: z + .string() + .min(1) + .refine( + (arg) => !arg.includes('$'), + t({ + en: 'Illegal character: $', + fr: 'Caractère illégal : $' + }) + ) + .optional(), + subjectDateOfBirth: z + .date() + .max(MIN_DATE_OF_BIRTH, { message: t('session.errors.mustBeAdult') }) + .optional(), + subjectSex: z.enum(['MALE', 'FEMALE']).optional(), + sessionType: $SessionType.exclude(['REMOTE']), + sessionDate: z + .date() + .max(currentDate, { message: t('session.errors.assessmentMustBeInPast') }) + .default(currentDate) + }) + .superRefine((val, ctx) => { + if (val.subjectIdentificationMethod === 'CUSTOM_ID') { + if (!val.subjectId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t('core.form.requiredField'), - path: [key] + path: ['subjectId'] }); + } else if (currentGroup?.settings.idValidationRegex) { + try { + const regex = new RegExp(currentGroup?.settings.idValidationRegex); + if (!regex.test(val.subjectId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + currentGroup.settings.idValidationRegexErrorMessage?.[resolvedLanguage] ?? + t({ + en: `Must match regular expression: ${regex.source}`, + fr: `Doit correspondre à l'expression régulière : ${regex.source}` + }), + path: ['subjectId'] + }); + } + } catch (err) { + // this should be checked already on the backend + console.error(err); + } + } + } else if (val.subjectIdentificationMethod === 'PERSONAL_INFO') { + const requiredKeys = ['subjectFirstName', 'subjectLastName', 'subjectSex', 'subjectDateOfBirth'] as const; + for (const key of requiredKeys) { + if (!val[key]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('core.form.requiredField'), + path: [key] + }); + } } } + })} + onSubmit={async ({ + sessionType, + sessionDate, + subjectId, + subjectFirstName, + subjectLastName, + subjectDateOfBirth, + subjectSex + }) => { + if (!subjectId) { + subjectId = await generateSubjectHash({ + firstName: subjectFirstName!, + lastName: subjectLastName!, + dateOfBirth: subjectDateOfBirth!, + sex: subjectSex! + }); + } else { + subjectId = encodeScopedSubjectId(subjectId, { + groupName: currentGroup?.name ?? DEFAULT_GROUP_NAME + }); } - })} - onSubmit={async ({ - sessionType, - sessionDate, - subjectId, - subjectFirstName, - subjectLastName, - subjectDateOfBirth, - subjectSex - }) => { - if (!subjectId) { - subjectId = await generateSubjectHash({ - firstName: subjectFirstName!, - lastName: subjectLastName!, - dateOfBirth: subjectDateOfBirth!, - sex: subjectSex! - }); - } else { - subjectId = encodeScopedSubjectId(subjectId, { - groupName: currentGroup?.name ?? DEFAULT_GROUP_NAME + await onSubmit({ + date: sessionDate, + groupId: currentGroup?.id ?? null, + username: username ?? null, + type: sessionType, + subjectData: { + id: subjectId, + firstName: subjectFirstName, + lastName: subjectLastName, + dateOfBirth: subjectDateOfBirth, + sex: subjectSex + } }); - } - await onSubmit({ - date: sessionDate, - groupId: currentGroup?.id ?? null, - username: username ?? null, - type: sessionType, - subjectData: { - id: subjectId, - firstName: subjectFirstName, - lastName: subjectLastName, - dateOfBirth: subjectDateOfBirth, - sex: subjectSex - } - }); - }} - /> + }} + /> + ); }; From 18288bf69ae66456c653818009fa65c269d98383 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 6 Mar 2026 12:45:06 -0500 Subject: [PATCH 2/8] fix: make search bar dissappear on selection --- apps/web/src/components/StartSessionForm/StartSessionForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index caf5d1416..736440277 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -126,7 +126,6 @@ export const StartSessionForm = ({ inputRef.current.dispatchEvent(event); setIsDropdownOpen(false); - inputRef.current.focus(); } }; From d1ac1ef1e68558211e24c2b9daf2f234c88ed3bd Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 9 Mar 2026 11:52:21 -0400 Subject: [PATCH 3/8] feat: edit dropdown into useCallBack method, useRef for search string --- .../StartSessionForm/StartSessionForm.tsx | 101 +++++++++++++----- 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index 736440277..d4993efa6 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -10,7 +10,7 @@ import type { CreateSessionData } from '@opendatacapture/schemas/session'; import { $SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import type { Sex, SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import { encodeScopedSubjectId, generateSubjectHash, removeSubjectIdScope } from '@opendatacapture/subject-utils'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import type { Promisable } from 'type-fest'; import { z } from 'zod/v4'; @@ -56,48 +56,87 @@ export const StartSessionForm = ({ const [dropdownPos, setDropdownPos] = useState<{ left: number; top: number; width: number } | null>(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [searchString, setSearchString] = useState(''); + const searchStringRef = useRef(''); + const [dropdownKey, setDropdownKey] = useState(0); const inputRef = useRef(null); const dropdownRef = useRef(null); + // Recalculate dropdown position from the input's current bounding rect + const updateDropdownPos = useCallback(() => { + if (inputRef.current) { + const rect = inputRef.current.getBoundingClientRect(); + setDropdownPos({ + top: rect.bottom, + left: rect.left, + width: rect.width + }); + } + }, []); + + const handleSelectOption = useCallback((value: string) => { + if (inputRef.current) { + // Set value natively to trigger React's internal state mechanism in the Form component + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + nativeInputValueSetter?.call(inputRef.current, value); + + const event = new Event('input', { bubbles: true }); + inputRef.current.dispatchEvent(event); + + setIsDropdownOpen(false); + } + }, []); + useEffect(() => { + let rafId: number | null = null; const handleInput = (e: Event) => { - setSearchString((e.target as HTMLInputElement).value); + searchStringRef.current = (e.target as HTMLInputElement).value; + // Defer the re-render to the next animation frame so Playwright's fill() + // can complete without the Form re-rendering and resetting the value + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + setDropdownKey((k) => k + 1); + }); }; const handleFocus = () => { if (inputRef.current) { - const rect = inputRef.current.getBoundingClientRect(); - setDropdownPos({ - top: rect.bottom + window.scrollY, - left: rect.left + window.scrollX, - width: rect.width - }); - setSearchString(inputRef.current.value); + searchStringRef.current = inputRef.current.value; + updateDropdownPos(); setIsDropdownOpen(true); } }; const handleBlur = (e: FocusEvent) => { - // Prevent closing if we clicked inside the dropdown - if (dropdownRef.current && e.relatedTarget instanceof Node && dropdownRef.current.contains(e.relatedTarget)) { - return; - } + const related = e.relatedTarget; + // Use setTimeout so the relatedTarget check works after the browser has settled focus + setTimeout(() => { + if (dropdownRef.current && related instanceof Node && dropdownRef.current.contains(related)) { + return; + } + setIsDropdownOpen(false); + }, 0); + }; + + // Close dropdown on programmatic value changes (e.g., Playwright's fill()) + const handleChange = () => { setIsDropdownOpen(false); }; const observer = new MutationObserver(() => { const input = document.querySelector('input[name="subjectId"]') as HTMLInputElement; if (input && input !== inputRef.current) { + // Clean up old listeners if (inputRef.current) { inputRef.current.removeEventListener('input', handleInput); inputRef.current.removeEventListener('focus', handleFocus); inputRef.current.removeEventListener('blur', handleBlur); + inputRef.current.removeEventListener('change', handleChange); } inputRef.current = input; input.addEventListener('input', handleInput); input.addEventListener('focus', handleFocus); input.addEventListener('blur', handleBlur); + input.addEventListener('change', handleChange); // Disable browser autocomplete since we're using our own input.setAttribute('autocomplete', 'off'); @@ -108,30 +147,38 @@ export const StartSessionForm = ({ return () => { observer.disconnect(); + if (rafId) cancelAnimationFrame(rafId); if (inputRef.current) { inputRef.current.removeEventListener('input', handleInput); inputRef.current.removeEventListener('focus', handleFocus); inputRef.current.removeEventListener('blur', handleBlur); + inputRef.current.removeEventListener('change', handleChange); } }; - }, []); + }, [updateDropdownPos]); - const handleSelectOption = (value: string) => { - if (inputRef.current) { - // Set value natively to trigger React's internal state mechanism in the Form component - const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; - nativeInputValueSetter?.call(inputRef.current, value); + // Update dropdown position on scroll and resize so it stays attached to the input + useEffect(() => { + if (!isDropdownOpen) return; - const event = new Event('input', { bubbles: true }); - inputRef.current.dispatchEvent(event); + const handleScrollOrResize = () => { + updateDropdownPos(); + }; - setIsDropdownOpen(false); - } - }; + window.addEventListener('scroll', handleScrollOrResize, true); + window.addEventListener('resize', handleScrollOrResize); + + return () => { + window.removeEventListener('scroll', handleScrollOrResize, true); + window.removeEventListener('resize', handleScrollOrResize); + }; + }, [isDropdownOpen, updateDropdownPos]); + // Use dropdownKey as a dependency signal; actual filtering uses the ref value + void dropdownKey; const filteredSubjects = subjects.filter((s) => { const displayId = removeSubjectIdScope(s.id); - const search = searchString.toLowerCase(); + const search = searchStringRef.current.toLowerCase(); return ( displayId.toLowerCase().includes(search) || (s.firstName && s.firstName.toLowerCase().includes(search)) || @@ -148,7 +195,7 @@ export const StartSessionForm = ({ ref={dropdownRef} tabIndex={-1} style={{ - position: 'absolute', + position: 'fixed', top: `${dropdownPos.top + 4}px`, left: `${dropdownPos.left}px`, width: `${dropdownPos.width}px`, From d0cda4c88912d6a9315455916e779677d9d397b3 Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 9 Mar 2026 15:02:10 -0400 Subject: [PATCH 4/8] chore: linting fixes --- .../StartSessionForm/StartSessionForm.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index d4993efa6..bc92736f0 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -1,5 +1,7 @@ /* eslint-disable perfectionist/sort-objects */ +import { useCallback, useEffect, useRef, useState } from 'react'; + import { Form } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { FormTypes } from '@opendatacapture/runtime-core'; @@ -10,7 +12,6 @@ import type { CreateSessionData } from '@opendatacapture/schemas/session'; import { $SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import type { Sex, SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import { encodeScopedSubjectId, generateSubjectHash, removeSubjectIdScope } from '@opendatacapture/subject-utils'; -import { useEffect, useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import type { Promisable } from 'type-fest'; import { z } from 'zod/v4'; @@ -54,7 +55,7 @@ export const StartSessionForm = ({ const subjectsQuery = useSubjectsQuery({ params: { groupId: currentGroup?.id } }); const subjects = subjectsQuery.data ?? []; - const [dropdownPos, setDropdownPos] = useState<{ left: number; top: number; width: number } | null>(null); + const [dropdownPos, setDropdownPos] = useState(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const searchStringRef = useRef(''); const [dropdownKey, setDropdownKey] = useState(0); @@ -87,7 +88,7 @@ export const StartSessionForm = ({ }, []); useEffect(() => { - let rafId: number | null = null; + let rafId: null | number = null; const handleInput = (e: Event) => { searchStringRef.current = (e.target as HTMLInputElement).value; // Defer the re-render to the next animation frame so Playwright's fill() @@ -123,7 +124,7 @@ export const StartSessionForm = ({ }; const observer = new MutationObserver(() => { - const input = document.querySelector('input[name="subjectId"]') as HTMLInputElement; + const input = document.querySelector('input[name="subjectId"]')!; if (input && input !== inputRef.current) { // Clean up old listeners if (inputRef.current) { @@ -181,8 +182,8 @@ export const StartSessionForm = ({ const search = searchStringRef.current.toLowerCase(); return ( displayId.toLowerCase().includes(search) || - (s.firstName && s.firstName.toLowerCase().includes(search)) || - (s.lastName && s.lastName.toLowerCase().includes(search)) + s.firstName?.toLowerCase().includes(search) || + s.lastName?.toLowerCase().includes(search) ); }); @@ -192,8 +193,8 @@ export const StartSessionForm = ({ dropdownPos && createPortal(
{filteredSubjects.length > 0 ? ( filteredSubjects.map((subject) => { const displayId = removeSubjectIdScope(subject.id); return ( ); }) From 02b80cc23f06c1e2c8efc5ffaf8c95e7792ede27 Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 10 Mar 2026 10:44:34 -0400 Subject: [PATCH 8/8] chore: fix null issue for input --- apps/web/src/components/StartSessionForm/StartSessionForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index e8e2f3ba5..a02f87808 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -123,7 +123,8 @@ export const StartSessionForm = ({ }; const observer = new MutationObserver(() => { - const input = document.querySelector('input[name="subjectId"]') as HTMLInputElement; + const initialInput = document.querySelector('input[name="subjectId"]')!; + const input = initialInput as HTMLInputElement; if (input && input !== inputRef.current) { // Clean up old listeners if (inputRef.current) {