diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index be271c85e..a02f87808 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'; @@ -9,10 +11,13 @@ 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 { 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 +51,394 @@ export const StartSessionForm = ({ onSubmit }: StartSessionFormProps) => { const { resolvedLanguage, t } = useTranslation(); + + const subjectsQuery = useSubjectsQuery({ params: { groupId: currentGroup?.id } }); + const subjects = subjectsQuery.data ?? []; + + const [dropdownPos, setDropdownPos] = useState(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + 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 + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set?.call(inputRef.current, value); + + const event = new Event('input', { bubbles: true }); + inputRef.current.dispatchEvent(event); + + setIsDropdownOpen(false); + } + }, []); + + useEffect(() => { + 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() + // 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) { + searchStringRef.current = inputRef.current.value; + updateDropdownPos(); + setIsDropdownOpen(true); + } + }; + + const handleBlur = (e: FocusEvent) => { + 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 initialInput = document.querySelector('input[name="subjectId"]')!; + const input = initialInput 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'); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + 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]); + + // Update dropdown position on scroll and resize so it stays attached to the input + useEffect(() => { + if (!isDropdownOpen) return; + + const handleScrollOrResize = () => { + updateDropdownPos(); + }; + + 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 = searchStringRef.current.toLowerCase(); + return displayId.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 - } - }); - }} - /> + }} + /> + ); };