diff --git a/packages/esm-patient-notes-app/src/notes/visit-notes-form.test.tsx b/packages/esm-patient-notes-app/src/notes/visit-notes-form.test.tsx index 0f04e80294..1f8f0258a3 100644 --- a/packages/esm-patient-notes-app/src/notes/visit-notes-form.test.tsx +++ b/packages/esm-patient-notes-app/src/notes/visit-notes-form.test.tsx @@ -62,36 +62,37 @@ test('renders the visit notes form with all the relevant fields and values', () renderVisitNotesForm(); - expect(screen.getByRole('textbox', { name: /Visit date/i })).toBeInTheDocument(); - expect(screen.getByRole('textbox', { name: /Write your notes/i })).toBeInTheDocument(); - expect(screen.getByRole('searchbox', { name: /Enter Primary diagnoses/i })).toBeInTheDocument(); - expect(screen.getByRole('searchbox', { name: /Enter Secondary diagnoses/i })).toBeInTheDocument(); - expect(screen.getByRole('group', { name: /Add an image to this visit/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Add image/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Discard/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Save and close/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /visit date/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /write your notes/i })).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: /enter primary diagnoses/i })).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: /enter secondary diagnoses/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add image/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /discard/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save and close/i })).toBeInTheDocument(); }); -test.only('typing in the diagnosis search input triggers a search', async () => { +test('typing in the diagnosis search input triggers a search', async () => { + const user = userEvent.setup(); + mockFetchDiagnosisConceptsByName.mockResolvedValue(diagnosisSearchResponse.results); renderVisitNotesForm(); const searchBox = screen.getByPlaceholderText('Choose a primary diagnosis'); - await userEvent.type(searchBox, 'Diabetes Mellitus'); + await user.type(searchBox, 'Diabetes Mellitus'); const targetSearchResult = screen.getByText('Diabetes Mellitus'); expect(targetSearchResult).toBeInTheDocument(); expect(screen.getByText('Diabetes Mellitus, Type II')).toBeInTheDocument(); // clicking on a search result displays the selected diagnosis as a tag - await userEvent.click(targetSearchResult); + await user.click(targetSearchResult); expect(screen.getByTitle('Diabetes Mellitus')).toBeInTheDocument(); const diabetesMellitusTag = screen.getByTitle(/^Diabetes Mellitus$/i); expect(diabetesMellitusTag).toBeInTheDocument(); const closeTagButton = screen.getByRole('button', { name: /clear filter/i }); // Clicking the close button on the tag removes the selected diagnosis - await userEvent.click(closeTagButton); + await user.click(closeTagButton); // no selected diagnoses left expect(screen.getByText(/No diagnosis selected — Enter a diagnosis below/i)).toBeInTheDocument(); }); @@ -109,25 +110,29 @@ test('renders an error message when no matching diagnoses are found', async () = }); test('closes the form and the workspace when the cancel button is clicked', async () => { + const user = userEvent.setup(); + renderVisitNotesForm(); const cancelButton = screen.getByRole('button', { name: /Discard/i }); - await userEvent.click(cancelButton); + await user.click(cancelButton); expect(defaultProps.closeWorkspace).toHaveBeenCalledTimes(1); }); test('renders a success snackbar upon successfully recording a visit note', async () => { + const user = userEvent.setup(); + const successPayload = { encounterProviders: expect.arrayContaining([ { encounterRole: ConfigMock.visitNoteConfig.clinicianEncounterRole, - provider: undefined, + provider: mockSessionDataResponse.data.currentProvider.uuid, }, ]), encounterType: ConfigMock.visitNoteConfig.encounterTypeUuid, form: ConfigMock.visitNoteConfig.formConceptUuid, - location: undefined, + location: mockSessionDataResponse.data.sessionLocation.uuid, obs: expect.arrayContaining([ { concept: { display: '', uuid: '162169AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }, @@ -135,6 +140,7 @@ test('renders a success snackbar upon successfully recording a visit note', asyn }, ]), patient: mockPatient.id, + encounterDatetime: undefined, }; mockSaveVisitNote.mockResolvedValueOnce({ status: 201, body: 'Condition created' } as unknown as ReturnType< @@ -144,27 +150,32 @@ test('renders a success snackbar upon successfully recording a visit note', asyn renderVisitNotesForm(); + const submitButton = screen.getByRole('button', { name: /Save and close/i }); + await user.click(submitButton); + + expect(screen.getByText(/choose at least one primary diagnosis/i)).toBeInTheDocument(); + const searchBox = screen.getByPlaceholderText('Choose a primary diagnosis'); - await userEvent.type(searchBox, 'Diabetes Mellitus'); + await user.type(searchBox, 'Diabetes Mellitus'); const targetSearchResult = screen.getByText('Diabetes Mellitus'); expect(targetSearchResult).toBeInTheDocument(); - await userEvent.click(targetSearchResult); + await user.click(targetSearchResult); const clinicalNote = screen.getByRole('textbox', { name: /Write your notes/i }); - await userEvent.clear(clinicalNote); - await userEvent.type(clinicalNote, 'Sample clinical note'); + await user.clear(clinicalNote); + await user.type(clinicalNote, 'Sample clinical note'); expect(clinicalNote).toHaveValue('Sample clinical note'); - const submitButton = screen.getByRole('button', { name: /Save and close/i }); - - await userEvent.click(submitButton); + await user.click(submitButton); expect(mockSaveVisitNote).toHaveBeenCalledTimes(1); expect(mockSaveVisitNote).toHaveBeenCalledWith(new AbortController(), expect.objectContaining(successPayload)); }); test('renders an error snackbar if there was a problem recording a condition', async () => { + const user = userEvent.setup(); + const error = { message: 'Internal Server Error', response: { @@ -178,21 +189,21 @@ test('renders an error snackbar if there was a problem recording a condition', a renderVisitNotesForm(); + const submitButton = screen.getByRole('button', { name: /Save and close/i }); + const searchBox = screen.getByPlaceholderText('Choose a primary diagnosis'); - await userEvent.type(searchBox, 'Diabetes Mellitus'); + await user.type(searchBox, 'Diabetes Mellitus'); const targetSearchResult = screen.getByText('Diabetes Mellitus'); expect(targetSearchResult).toBeInTheDocument(); - await userEvent.click(targetSearchResult); + await user.click(targetSearchResult); const clinicalNote = screen.getByRole('textbox', { name: /Write your notes/i }); - await userEvent.clear(clinicalNote); - await userEvent.type(clinicalNote, 'Sample clinical note'); + await user.clear(clinicalNote); + await user.type(clinicalNote, 'Sample clinical note'); expect(clinicalNote).toHaveValue('Sample clinical note'); - const submitButton = screen.getByRole('button', { name: /Save and close/i }); - - await userEvent.click(submitButton); + await user.click(submitButton); expect(mockShowSnackbar).toHaveBeenCalledWith({ isLowContrast: false, diff --git a/packages/esm-patient-notes-app/src/notes/visit-notes-form.workspace.tsx b/packages/esm-patient-notes-app/src/notes/visit-notes-form.workspace.tsx index 365a4fb4ef..bb31e3e8b1 100644 --- a/packages/esm-patient-notes-app/src/notes/visit-notes-form.workspace.tsx +++ b/packages/esm-patient-notes-app/src/notes/visit-notes-form.workspace.tsx @@ -51,7 +51,7 @@ import { } from './visit-notes.resource'; import styles from './visit-notes-form.scss'; -type VisitNotesFormData = Omit, 'images'> & { +type VisitNotesFormData = Omit>, 'images'> & { images?: UploadedFile[]; }; @@ -76,27 +76,27 @@ interface DiagnosisSearchProps { setIsSearching: (isSearching: boolean) => void; } -const visitNoteFormSchema = z.object({ - noteDate: z.date(), - primaryDiagnosisSearch: z.string({ - required_error: 'Choose at least one primary diagnosis', - }), - secondaryDiagnosisSearch: z.string().optional(), - clinicalNote: z.string().optional(), - images: z - .array( - z.object({ - base64Content: z.string(), - file: z.custom((value) => value instanceof File, { - message: 'Invalid file', +const createSchema = (t: TFunction) => { + return z.object({ + noteDate: z.date(), + primaryDiagnosisSearch: z.string(), + secondaryDiagnosisSearch: z.string().optional(), + clinicalNote: z.string().optional(), + images: z + .array( + z.object({ + base64Content: z.string(), + file: z.custom((value) => value instanceof File, { + message: 'Invalid file', + }), + fileDescription: z.string().optional(), + fileName: z.string(), + fileType: z.string(), }), - fileDescription: z.string().optional(), - fileName: z.string(), - fileType: z.string(), - }), - ) - .optional(), -}); + ) + .optional(), + }); +}; const VisitNotesForm: React.FC = ({ closeWorkspace, @@ -125,9 +125,33 @@ const VisitNotesForm: React.FC = ({ const [error, setError] = useState(null); const { allowedFileExtensions } = useAllowedFileExtensions(); - const { control, handleSubmit, watch, getValues, setValue, formState } = useForm({ + const visitNoteFormSchema = useMemo(() => createSchema(t), [t]); + + const customResolver = useCallback( + async (data, context, options) => { + const zodResult = await zodResolver(visitNoteFormSchema)(data, context, options); + + if (selectedPrimaryDiagnoses.length === 0) { + return { + ...zodResult, + errors: { + ...zodResult.errors, + primaryDiagnosisSearch: { + type: 'custom', + message: t('primaryDiagnosisRequired', 'Choose at least one primary diagnosis'), + }, + }, + }; + } + + return zodResult; + }, + [visitNoteFormSchema, selectedPrimaryDiagnoses, t], + ); + + const { control, handleSubmit, watch, setValue, formState, clearErrors } = useForm({ mode: 'onSubmit', - resolver: zodResolver(visitNoteFormSchema), + resolver: customResolver, defaultValues: { primaryDiagnosisSearch: '', noteDate: new Date(), @@ -153,6 +177,7 @@ const VisitNotesForm: React.FC = ({ const debouncedSearch = useMemo( () => debounce((fieldQuery, fieldName) => { + clearErrors('primaryDiagnosisSearch'); if (fieldQuery) { if (fieldName === 'primaryDiagnosisSearch') { setIsLoadingPrimaryDiagnoses(true); @@ -176,7 +201,7 @@ const VisitNotesForm: React.FC = ({ }); } }, searchTimeoutInMs), - [config.diagnosisConceptClass], + [config.diagnosisConceptClass, clearErrors], ); const handleSearch = useCallback( @@ -190,37 +215,59 @@ const VisitNotesForm: React.FC = ({ [debouncedSearch, watch], ); - const handleAddDiagnosis = (conceptDiagnosisToAdd: Concept, searchInputField: string) => { - let newDiagnosis = createDiagnosis(conceptDiagnosisToAdd); - if (searchInputField === 'primaryDiagnosisSearch') { - newDiagnosis.rank = 1; - setValue('primaryDiagnosisSearch', ''); - setSearchPrimaryResults([]); - setSelectedPrimaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]); - } else if (searchInputField === 'secondaryDiagnosisSearch') { - setValue('secondaryDiagnosisSearch', ''); - setSearchSecondaryResults([]); - setSelectedSecondaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]); - } - setCombinedDiagnoses((diagnosisCombined) => [...diagnosisCombined, newDiagnosis]); - }; + const createDiagnosis = useCallback( + (concept: Concept) => ({ + certainty: 'PROVISIONAL', + display: concept.display, + diagnosis: { + coded: concept.uuid, + }, + patient: patientUuid, + rank: 2, + }), + [patientUuid], + ); - const handleRemoveDiagnosis = (diagnosisToRemove: Diagnosis, searchInputField: string) => { - if (searchInputField === 'primaryInputSearch') { - setSelectedPrimaryDiagnoses( - selectedPrimaryDiagnoses.filter((diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded), - ); - } else if (searchInputField === 'secondaryInputSearch') { - setSelectedSecondaryDiagnoses( - selectedSecondaryDiagnoses.filter( - (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded, - ), + const handleAddDiagnosis = useCallback( + (conceptDiagnosisToAdd: Concept, searchInputField: string) => { + const newDiagnosis = createDiagnosis(conceptDiagnosisToAdd); + if (searchInputField === 'primaryDiagnosisSearch') { + newDiagnosis.rank = 1; + setValue('primaryDiagnosisSearch', ''); + setSearchPrimaryResults([]); + setSelectedPrimaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]); + clearErrors('primaryDiagnosisSearch'); + } else if (searchInputField === 'secondaryDiagnosisSearch') { + setValue('secondaryDiagnosisSearch', ''); + setSearchSecondaryResults([]); + setSelectedSecondaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]); + } + setCombinedDiagnoses((combinedDiagnoses) => [...combinedDiagnoses, newDiagnosis]); + }, + [createDiagnosis, setValue, clearErrors], + ); + + const handleRemoveDiagnosis = useCallback( + (diagnosisToRemove: Diagnosis, searchInputField) => { + if (searchInputField === 'primaryInputSearch') { + setSelectedPrimaryDiagnoses( + selectedPrimaryDiagnoses.filter( + (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded, + ), + ); + } else if (searchInputField === 'secondaryInputSearch') { + setSelectedSecondaryDiagnoses( + selectedSecondaryDiagnoses.filter( + (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded, + ), + ); + } + setCombinedDiagnoses( + combinedDiagnoses.filter((diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded), ); - } - setCombinedDiagnoses( - combinedDiagnoses.filter((diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded), - ); - }; + }, + [combinedDiagnoses, selectedPrimaryDiagnoses, selectedSecondaryDiagnoses], + ); const isDiagnosisNotSelected = (diagnosis: Concept) => { const isPrimaryDiagnosisSelected = selectedPrimaryDiagnoses.some( @@ -233,16 +280,6 @@ const VisitNotesForm: React.FC = ({ return !isPrimaryDiagnosisSelected && !isSecondaryDiagnosisSelected; }; - const createDiagnosis = (concept: Concept) => ({ - certainty: 'PROVISIONAL', - display: concept.display, - diagnosis: { - coded: concept.uuid, - }, - patient: patientUuid, - rank: 2, - }); - const showImageCaptureModal = useCallback(() => { const close = showModal('capture-photo-modal', { saveFile: (file: UploadedFile) => { diff --git a/packages/esm-patient-notes-app/translations/en.json b/packages/esm-patient-notes-app/translations/en.json index f6c61f868f..51df0d8d62 100644 --- a/packages/esm-patient-notes-app/translations/en.json +++ b/packages/esm-patient-notes-app/translations/en.json @@ -20,6 +20,7 @@ "noVisitNoteToDisplay": "No visit note to display", "primaryDiagnosis": "Primary diagnosis", "primaryDiagnosisInputPlaceholder": "Choose a primary diagnosis", + "primaryDiagnosisRequired": "Choose at least one primary diagnosis", "saveAndClose": "Save and close", "saving": "Saving", "searchForPrimaryDiagnosis": "Search for a primary diagnosis",