Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(fix) Fix required validation in Visit Note form #2025

Merged
merged 2 commits into from
Sep 21, 2024
Merged
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
69 changes: 40 additions & 29 deletions packages/esm-patient-notes-app/src/notes/visit-notes-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -109,32 +110,37 @@ 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' },
value: 'Sample clinical note',
},
]),
patient: mockPatient.id,
encounterDatetime: undefined,
};

mockSaveVisitNote.mockResolvedValueOnce({ status: 201, body: 'Condition created' } as unknown as ReturnType<
Expand All @@ -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: {
Expand All @@ -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,
Expand Down
163 changes: 100 additions & 63 deletions packages/esm-patient-notes-app/src/notes/visit-notes-form.workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
} from './visit-notes.resource';
import styles from './visit-notes-form.scss';

type VisitNotesFormData = Omit<z.infer<typeof visitNoteFormSchema>, 'images'> & {
type VisitNotesFormData = Omit<z.infer<ReturnType<typeof createSchema>>, 'images'> & {
images?: UploadedFile[];
};

Expand All @@ -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<File>((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<File>((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<DefaultPatientWorkspaceProps> = ({
closeWorkspace,
Expand Down Expand Up @@ -125,9 +125,33 @@ const VisitNotesForm: React.FC<DefaultPatientWorkspaceProps> = ({
const [error, setError] = useState<Error>(null);
const { allowedFileExtensions } = useAllowedFileExtensions();

const { control, handleSubmit, watch, getValues, setValue, formState } = useForm<VisitNotesFormData>({
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<VisitNotesFormData>({
mode: 'onSubmit',
resolver: zodResolver(visitNoteFormSchema),
resolver: customResolver,
defaultValues: {
primaryDiagnosisSearch: '',
noteDate: new Date(),
Expand All @@ -153,6 +177,7 @@ const VisitNotesForm: React.FC<DefaultPatientWorkspaceProps> = ({
const debouncedSearch = useMemo(
() =>
debounce((fieldQuery, fieldName) => {
clearErrors('primaryDiagnosisSearch');
if (fieldQuery) {
if (fieldName === 'primaryDiagnosisSearch') {
setIsLoadingPrimaryDiagnoses(true);
Expand All @@ -176,7 +201,7 @@ const VisitNotesForm: React.FC<DefaultPatientWorkspaceProps> = ({
});
}
}, searchTimeoutInMs),
[config.diagnosisConceptClass],
[config.diagnosisConceptClass, clearErrors],
);

const handleSearch = useCallback(
Expand All @@ -190,37 +215,59 @@ const VisitNotesForm: React.FC<DefaultPatientWorkspaceProps> = ({
[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(
Expand All @@ -233,16 +280,6 @@ const VisitNotesForm: React.FC<DefaultPatientWorkspaceProps> = ({
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) => {
Expand Down
1 change: 1 addition & 0 deletions packages/esm-patient-notes-app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down