diff --git a/__mocks__/allergies.mock.ts b/__mocks__/allergies.mock.ts index 3f42897a0..a8ef0b63f 100644 --- a/__mocks__/allergies.mock.ts +++ b/__mocks__/allergies.mock.ts @@ -827,3 +827,18 @@ export const mockAllergies = [ reactionSeverity: 'Severe', }, ]; + +export const mockAllergy = { + clinicalStatus: 'Active', + criticality: 'high', + display: 'ACE inhibitors', + id: 'acf497ae-1f75-436c-ad27-b8a0dec390cd', + lastUpdated: '2024-02-28T11:41:58.000+00:00', + note: 'sample allergy note', + reactionManifestations: ['Anaphylaxis', 'Headache'], + reactionSeverity: undefined, + reactionToSubstance: undefined, + recordedBy: 'Super User', + recordedDate: '2024-02-23T13:45:08+00:00', + recorderType: 'Practitioner', +}; diff --git a/packages/esm-patient-allergies-app/src/allergies/allergies-action-menu.component.tsx b/packages/esm-patient-allergies-app/src/allergies/allergies-action-menu.component.tsx new file mode 100644 index 000000000..e08b85ab6 --- /dev/null +++ b/packages/esm-patient-allergies-app/src/allergies/allergies-action-menu.component.tsx @@ -0,0 +1,60 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Layer, OverflowMenu, OverflowMenuItem } from '@carbon/react'; +import { launchPatientWorkspace } from '@openmrs/esm-patient-common-lib'; +import { showModal, useLayoutType } from '@openmrs/esm-framework'; +import { type Allergy } from './allergy-intolerance.resource'; +import styles from './allergies-action-menu.scss'; +import { patientAllergiesFormWorkspace } from '../constants'; + +interface allergiesActionMenuProps { + allergy: Allergy; + patientUuid?: string; +} + +export const AllergiesActionMenu = ({ allergy, patientUuid }: allergiesActionMenuProps) => { + const { t } = useTranslation(); + const isTablet = useLayoutType() === 'tablet'; + + const launchEditAllergiesForm = useCallback(() => { + launchPatientWorkspace(patientAllergiesFormWorkspace, { + workspaceTitle: t('editAllergy', 'Edit an Allergy'), + allergy, + formContext: 'editing', + }); + }, [allergy, t]); + + const launchDeleteAllergyDialog = (allergyId: string) => { + const dispose = showModal('allergy-delete-confirmation-dialog', { + closeDeleteModal: () => dispose(), + allergyId, + patientUuid, + }); + }; + + return ( + + + + launchDeleteAllergyDialog(allergy.id)} + isDelete + hasDivider + /> + + + ); +}; diff --git a/packages/esm-patient-allergies-app/src/allergies/allergies-action-menu.scss b/packages/esm-patient-allergies-app/src/allergies/allergies-action-menu.scss new file mode 100644 index 000000000..45a954f1f --- /dev/null +++ b/packages/esm-patient-allergies-app/src/allergies/allergies-action-menu.scss @@ -0,0 +1,11 @@ +.layer { + height: 100%; + + :global(.cds--overflow-menu) { + min-height: unset; + } +} + +.menuItem { + max-width: none; +} diff --git a/packages/esm-patient-allergies-app/src/allergies/allergies-detailed-summary.component.tsx b/packages/esm-patient-allergies-app/src/allergies/allergies-detailed-summary.component.tsx index 569217cf9..fd1638e36 100644 --- a/packages/esm-patient-allergies-app/src/allergies/allergies-detailed-summary.component.tsx +++ b/packages/esm-patient-allergies-app/src/allergies/allergies-detailed-summary.component.tsx @@ -21,6 +21,7 @@ import { useAllergies } from './allergy-intolerance.resource'; import { patientAllergiesFormWorkspace } from '../constants'; import styles from './allergies-detailed-summary.scss'; import { ReactionSeverity } from '../types'; +import { AllergiesActionMenu } from './allergies-action-menu.component'; interface AllergiesDetailedSummaryProps { patient: fhir.Patient; @@ -110,6 +111,7 @@ const AllergiesDetailedSummary: React.FC = ({ pat {header.header?.content ?? header.header} ))} + @@ -118,6 +120,12 @@ const AllergiesDetailedSummary: React.FC = ({ pat {row.cells.map((cell) => ( {cell.value?.content ?? cell.value} ))} + + allergy.id == row.id)} + /> + ))} diff --git a/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.component.tsx b/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.component.tsx index 1985c583f..3a0400c93 100644 --- a/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.component.tsx +++ b/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.component.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; -import { useTranslation } from 'react-i18next'; +import { type TFunction, useTranslation } from 'react-i18next'; import { Button, ButtonSet, @@ -19,7 +19,7 @@ import { } from '@carbon/react'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; -import { type Control, Controller, useForm, type UseFormSetValue } from 'react-hook-form'; +import { type Control, Controller, useForm, type UseFormSetValue, type UseFormGetValues } from 'react-hook-form'; import { ExtensionSlot, type FetchResponse, @@ -36,8 +36,9 @@ import { saveAllergy, useAllergens, useAllergicReactions, + updatePatientAllergy, } from './allergy-form.resource'; -import { useAllergies } from '../allergy-intolerance.resource'; +import { type Allergy, useAllergies } from '../allergy-intolerance.resource'; import { AllergenType } from '../../types'; import styles from './allergy-form.scss'; @@ -65,8 +66,14 @@ type AllergyFormData = { comment: string; }; -function AllergyForm(props: DefaultWorkspaceProps) { - const { closeWorkspace, patientUuid, promptBeforeClosing, closeWorkspaceWithSavedChanges } = props; +interface AllergyFormProps extends DefaultWorkspaceProps { + allergy?: Allergy; + formContext: 'creating' | 'editing'; +} + +function AllergyForm(props: AllergyFormProps) { + const { closeWorkspace, patientUuid, allergy, formContext, promptBeforeClosing, closeWorkspaceWithSavedChanges } = + props; const { t } = useTranslation(); const { concepts } = useConfig(); const isTablet = useLayoutType() === 'tablet'; @@ -82,23 +89,73 @@ function AllergyForm(props: DefaultWorkspaceProps) { const [isDisabled, setIsDisabled] = useState(true); const { mutate } = useAllergies(patientUuid); + const getDefaultSeverityUUID = (severity) => { + switch (severity) { + case 'mild': + return mildReactionUuid; + case 'moderate': + return moderateReactionUuid; + case 'severe': + return severeReactionUuid; + default: + return null; + } + }; + + const getDefaultAllergicReactions = () => { + return allergicReactions?.map((reaction) => { + return allergy?.reactionManifestations?.includes(reaction.display) ? reaction.uuid : ''; + }); + }; + + const setDefaultNonCodedAllergen = (defaultAllergy) => { + const codedAllergenDisplays = allergens?.map((allergen) => allergen?.display); + if (!codedAllergenDisplays?.includes(allergy?.display)) { + defaultAllergy.allergen = { uuid: otherConceptUuid, display: t('other', 'Other'), type: AllergenType?.OTHER }; + defaultAllergy.nonCodedAllergen = allergy?.display; + } + }; + + const setDefaultNonCodedReactions = (defaultAllergy) => { + const allergicReactionDisplays = allergicReactions?.map((reaction) => reaction?.display); + allergy?.reactionManifestations?.forEach((reaction) => { + if (!allergicReactionDisplays?.includes(reaction)) { + defaultAllergy.nonCodedAllergicReaction = reaction; + defaultAllergy.allergicReactions?.splice(defaultAllergy.allergicReactions?.length - 1, 1, otherConceptUuid); + } + }); + }; + + const getDefaultAllergy = (allergy: Allergy, formContext) => { + const defaultAllergy = { + allergen: null, + nonCodedAllergen: '', + allergicReactions: [], + nonCodedAllergicReaction: '', + severityOfWorstReaction: null, + comment: '', + }; + if (formContext === 'editing') { + defaultAllergy.allergen = allergens?.find((a) => allergy?.display === a?.display); + defaultAllergy.allergicReactions = getDefaultAllergicReactions(); + defaultAllergy.severityOfWorstReaction = getDefaultSeverityUUID(allergy?.reactionSeverity); + defaultAllergy.comment = allergy?.note !== '--' ? allergy?.note : ''; + setDefaultNonCodedAllergen(defaultAllergy); + setDefaultNonCodedReactions(defaultAllergy); + } + return defaultAllergy; + }; const { control, handleSubmit, watch, + getValues, setValue, formState: { isDirty }, } = useForm({ mode: 'all', resolver: zodResolver(allergyFormSchema), - defaultValues: { - allergen: null, - nonCodedAllergen: '', - allergicReactions: [], - nonCodedAllergicReaction: '', - severityOfWorstReaction: null, - comment: '', - }, + values: getDefaultAllergy(allergy, formContext), }); useEffect(() => { @@ -110,8 +167,8 @@ function AllergyForm(props: DefaultWorkspaceProps) { const selectedSeverityOfWorstReaction = watch('severityOfWorstReaction'); const selectednonCodedAllergen = watch('nonCodedAllergen'); const selectedNonCodedAllergicReaction = watch('nonCodedAllergicReaction'); + const reactionsValidation = selectedAllergicReactions?.some((item) => item !== ''); - const reactionsValidation = selectedAllergicReactions.some((item) => item !== ''); useEffect(() => { if (!!selectedAllergen && reactionsValidation && !!selectedSeverityOfWorstReaction) setIsDisabled(false); else setIsDisabled(true); @@ -137,7 +194,8 @@ function AllergyForm(props: DefaultWorkspaceProps) { } = data; const selectedAllergicReactions = allergicReactions.filter((value) => value !== ''); - let payload: NewAllergy = { + + let patientAllergy: NewAllergy = { allergen: allergen.uuid == otherConceptUuid ? { @@ -160,30 +218,55 @@ function AllergyForm(props: DefaultWorkspaceProps) { }), }; const abortController = new AbortController(); - saveAllergy(payload, patientUuid, abortController) - .then( - (response: FetchResponse) => { - if (response.status === 201) { - mutate(); - closeWorkspaceWithSavedChanges(); - showSnackbar({ - isLowContrast: true, - kind: 'success', - title: t('allergySaved', 'Allergy saved'), - subtitle: t('allergyNowVisible', 'It is now visible on the Allergies page'), - }); - } - }, - (err) => { - showSnackbar({ - title: t('allergySaveError', 'Error saving allergy'), - kind: 'error', - isLowContrast: false, - subtitle: err?.message, - }); - }, - ) - .finally(() => abortController.abort()); + formContext === 'editing' + ? updatePatientAllergy(patientAllergy, patientUuid, allergy?.id, abortController) + .then( + (response: FetchResponse) => { + if (response.status === 200) { + mutate(); + closeWorkspace({ ignoreChanges: true }); + showSnackbar({ + isLowContrast: true, + kind: 'success', + title: t('allergyUpdated', 'Allergy updated'), + subtitle: t('allergyNowVisible', 'It is now visible on the Allergies page'), + }); + } + }, + (err) => { + showSnackbar({ + title: t('allergySaveError', 'Error saving allergy'), + kind: 'error', + isLowContrast: false, + subtitle: err?.message, + }); + }, + ) + .finally(() => abortController.abort()) + : saveAllergy(patientAllergy, patientUuid, abortController) + .then( + (response: FetchResponse) => { + if (response.status === 201) { + mutate(); + closeWorkspace({ ignoreChanges: true }); + showSnackbar({ + isLowContrast: true, + kind: 'success', + title: t('allergySaved', 'Allergy saved'), + subtitle: t('allergyNowVisible', 'It is now visible on the Allergies page'), + }); + } + }, + (err) => { + showSnackbar({ + title: t('allergySaveError', 'Error saving allergy'), + kind: 'error', + isLowContrast: false, + subtitle: err?.message, + }); + }, + ) + .finally(() => abortController.abort()); }, [otherConceptUuid, patientUuid, closeWorkspaceWithSavedChanges, t, mutate], ); @@ -370,7 +453,10 @@ function AllergicReactionsField({ methods: { control, setValue }, }: { allergicReactions: AllergicReaction[]; - methods: { control: Control; setValue: UseFormSetValue }; + methods: { + control: Control; + setValue: UseFormSetValue; + }; }) { const handleAllergicReactionChange = useCallback( (onChange, checked, id, index) => { diff --git a/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.resource.ts b/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.resource.ts index ef2caed2f..c756944dc 100644 --- a/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.resource.ts +++ b/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.resource.ts @@ -143,3 +143,19 @@ export function saveAllergy(payload: NewAllergy, patientUuid: string, abortContr signal: abortController.signal, }); } + +export function updatePatientAllergy( + payload: NewAllergy, + patientUuid: string, + allergenUuid: string, + abortController: AbortController, +) { + return openmrsFetch(`${restBaseUrl}/patient/${patientUuid}/allergy/${allergenUuid}`, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: payload, + signal: abortController.signal, + }); +} diff --git a/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.test.tsx b/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.test.tsx index e736d10f5..a4ae72fef 100644 --- a/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.test.tsx +++ b/packages/esm-patient-allergies-app/src/allergies/allergies-form/allergy-form.test.tsx @@ -4,21 +4,30 @@ import { screen, render, within } from '@testing-library/react'; import { type FetchResponse, showSnackbar } from '@openmrs/esm-framework'; import { mockAllergens, mockAllergicReactions } from '__mocks__'; import { mockPatient } from 'tools'; -import { type NewAllergy, saveAllergy, useAllergens, useAllergicReactions } from './allergy-form.resource'; +import { + type NewAllergy, + saveAllergy, + useAllergens, + useAllergicReactions, + updatePatientAllergy, +} from './allergy-form.resource'; import AllergyForm from './allergy-form.component'; -import { AllergenType } from '../../types'; +import { AllergenType, ReactionSeverity } from '../../types'; +import { mockAllergy } from '__mocks__'; const mockSaveAllergy = saveAllergy as jest.Mock>; const mockUseAllergens = useAllergens as jest.Mock; const mockUseAllergicReactions = useAllergicReactions as jest.Mock; const mockShowSnackbar = showSnackbar as jest.Mock; +const mockUpdatePatientAllergy = updatePatientAllergy as jest.Mock; jest.mock('./allergy-form.resource', () => { const originalModule = jest.requireActual('./allergy-form.resource'); return { ...originalModule, - saveAllergy: jest.fn(() => Promise.resolve({ data: {}, status: 201, statusText: 'Created' })), + saveAllergy: jest.fn().mockResolvedValue({ data: {}, status: 201, statusText: 'Created' }), + updatePatientAllergy: jest.fn().mockResolvedValue({ data: {}, status: 200, statusText: 'Updated' }), useAllergens: jest.fn(), useAllergicReactions: jest.fn(), }; @@ -269,6 +278,45 @@ describe('AllergyForm ', () => { kind: 'error', }); }); + it('Edit Allergy should call the saveAllergy function with updated payload', async () => { + mockSaveAllergy.mockClear(); + renderEditAllergyForm(); + + const user = userEvent.setup(); + const allergenInput = screen.getByPlaceholderText(/select the allergen/i); + const commentInput = screen.getByLabelText(/Date of onset and comments/i); + + const allergen = mockAllergens[2]; + const reaction = mockAllergicReactions[0]; + const comment = 'new comment'; + + await user.click(allergenInput); + await user.click(screen.getByText(allergen.display)); + await user.click(screen.getByRole('checkbox', { name: reaction.display })); + await user.click(screen.getByRole('radio', { name: /moderate/i })); + await user.clear(commentInput); + await user.type(commentInput, comment); + await user.click(screen.getByRole('button', { name: /save and close/i })); + + expect(mockUpdatePatientAllergy).toHaveBeenCalledTimes(1); + + const expectedPayload: NewAllergy = { + allergen: { + allergenType: allergen.type, + codedAllergen: { uuid: allergen.uuid }, + }, + comment, + reactions: [ + { reaction: { uuid: reaction.uuid } }, + { reaction: { uuid: mockAllergicReactions[2].uuid } }, + { reaction: { uuid: mockAllergicReactions[3].uuid } }, + ], + severity: { uuid: mockConcepts.moderateReactionUuid }, + }; + + expect(mockUpdatePatientAllergy.mock.calls[0][0]).toEqual(expectedPayload); + expect(mockAllergy).not.toEqual(expectedPayload); + }); }); function renderAllergyForm() { @@ -276,6 +324,22 @@ function renderAllergyForm() { closeWorkspace: () => {}, closeWorkspaceWithSavedChanges: () => {}, promptBeforeClosing: () => {}, + allergy: null, + formContext: 'creating' as 'creating' | 'editing', + patient: mockPatient, + patientUuid: mockPatient.id, + }; + + render(); +} + +function renderEditAllergyForm() { + const testProps = { + closeWorkspace: () => {}, + closeWorkspaceWithSavedChanges: () => {}, + promptBeforeClosing: () => {}, + allergy: mockAllergy, + formContext: 'editing' as 'creating' | 'editing', patient: mockPatient, patientUuid: mockPatient.id, }; diff --git a/packages/esm-patient-allergies-app/src/allergies/allergy-intolerance.resource.ts b/packages/esm-patient-allergies-app/src/allergies/allergy-intolerance.resource.ts index 64d0de061..0141bb602 100644 --- a/packages/esm-patient-allergies-app/src/allergies/allergy-intolerance.resource.ts +++ b/packages/esm-patient-allergies-app/src/allergies/allergy-intolerance.resource.ts @@ -144,7 +144,7 @@ export function updatePatientAllergy( } export function deletePatientAllergy(patientUuid: string, allergyUuid: any, abortController: AbortController) { - return openmrsFetch(`${restBaseUrl}/patient/${patientUuid}/allergy/${allergyUuid.allergyUuid}`, { + return openmrsFetch(`${restBaseUrl}/patient/${patientUuid}/allergy/${allergyUuid}`, { method: 'DELETE', signal: abortController.signal, }); diff --git a/packages/esm-patient-allergies-app/src/allergies/delete-allergy-modal.component.tsx b/packages/esm-patient-allergies-app/src/allergies/delete-allergy-modal.component.tsx new file mode 100644 index 000000000..3e262ba82 --- /dev/null +++ b/packages/esm-patient-allergies-app/src/allergies/delete-allergy-modal.component.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, InlineLoading, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; +import { showSnackbar } from '@openmrs/esm-framework'; +import { deletePatientAllergy, useAllergies } from './allergy-intolerance.resource'; + +interface DeleteAllergyModalProps { + closeDeleteModal: () => void; + allergyId: string; + patientUuid?: string; +} + +const DeleteAllergyModal: React.FC = ({ closeDeleteModal, allergyId, patientUuid }) => { + const { t } = useTranslation(); + const { mutate } = useAllergies(patientUuid); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = useCallback(async () => { + setIsDeleting(true); + + deletePatientAllergy(patientUuid, allergyId, new AbortController()) + .then((res) => { + if (res.ok) { + mutate(); + closeDeleteModal(); + showSnackbar({ + isLowContrast: true, + kind: 'success', + title: t('allergyDeleted', 'Allergy deleted'), + }); + } + }) + .catch((error) => { + console.error('Error deleting allergy: ', error); + + showSnackbar({ + isLowContrast: false, + kind: 'error', + title: t('errorDeletingAllergy', 'Error deleting allergy'), + subtitle: error?.message, + }); + }); + }, [closeDeleteModal, allergyId, mutate, t]); + + return ( +
+ + +

{t('deleteModalConfirmationText', 'Are you sure you want to delete this allergy?')}

+
+ + + + +
+ ); +}; + +export default DeleteAllergyModal; diff --git a/packages/esm-patient-allergies-app/src/index.ts b/packages/esm-patient-allergies-app/src/index.ts index 922487c7c..1a6cc6107 100644 --- a/packages/esm-patient-allergies-app/src/index.ts +++ b/packages/esm-patient-allergies-app/src/index.ts @@ -61,3 +61,8 @@ registerWorkspace({ }); export const allergyTile = getSyncLifecycle(allergyTileComponent, options); + +export const allergyDeleteConfirmationDialog = getAsyncLifecycle( + () => import('./allergies/delete-allergy-modal.component'), + options, +); diff --git a/packages/esm-patient-allergies-app/src/routes.json b/packages/esm-patient-allergies-app/src/routes.json index bb909ddb0..4e56b94c7 100644 --- a/packages/esm-patient-allergies-app/src/routes.json +++ b/packages/esm-patient-allergies-app/src/routes.json @@ -35,6 +35,10 @@ "slot": "patient-chart-allergies-dashboard-slot", "path": "Allergies" } + }, + { + "name": "allergy-delete-confirmation-dialog", + "component": "allergyDeleteConfirmationDialog" } ] } diff --git a/packages/esm-patient-allergies-app/translations/en.json b/packages/esm-patient-allergies-app/translations/en.json index ec63e9f01..92d41f0b4 100644 --- a/packages/esm-patient-allergies-app/translations/en.json +++ b/packages/esm-patient-allergies-app/translations/en.json @@ -3,12 +3,23 @@ "allergen": "Allergen", "allergies": "Allergies", "Allergies": "Allergies", + "allergyDeleted": "Allergy deleted", "allergyIntolerances": "allergy intolerances", "allergyNowVisible": "It is now visible on the Allergies page", "allergySaved": "Allergy saved", "allergySaveError": "Error saving allergy", + "allergyUpdated": "Allergy updated", + "cancel": "Cancel", "dateOfOnsetAndComments": "Date of onset and comments", + "delete": "Delete", + "deleteModalConfirmationText": "Are you sure you want to delete this allergy?", + "deletePatientAllergy": "Delete allergy", + "deleting": "Deleting", "discard": "Discard", + "edit": "Edit", + "editAllergy": "Edit an Allergy", + "editOrDeleteAllergy": "Edit or delete allergy", + "errorDeletingAllergy": "Error deleting allergy", "invalidComment": "Invalid comment, try again", "loading": "Loading", "mild": "Mild",