diff --git a/backend/src/main/resources/application-azure-demo.yaml b/backend/src/main/resources/application-azure-demo.yaml index 69005950d6..e490718779 100644 --- a/backend/src/main/resources/application-azure-demo.yaml +++ b/backend/src/main/resources/application-azure-demo.yaml @@ -12,3 +12,5 @@ simple-report: - https://demo.simplereport.gov twilio: enabled: false +features: + testCardRefactorEnabled: false \ No newline at end of file diff --git a/backend/src/main/resources/application-azure-stg.yaml b/backend/src/main/resources/application-azure-stg.yaml index 58cf5768ef..66e7140a06 100644 --- a/backend/src/main/resources/application-azure-stg.yaml +++ b/backend/src/main/resources/application-azure-stg.yaml @@ -15,3 +15,5 @@ simple-report: - https://stg.simplereport.gov experian: enabled: true +features: + testCardRefactorEnabled: false diff --git a/backend/src/main/resources/application-azure-training.yaml b/backend/src/main/resources/application-azure-training.yaml index b34ed52987..8e5007fb80 100644 --- a/backend/src/main/resources/application-azure-training.yaml +++ b/backend/src/main/resources/application-azure-training.yaml @@ -12,3 +12,5 @@ simple-report: - https://training.simplereport.gov twilio: enabled: false +features: + testCardRefactorEnabled: true \ No newline at end of file diff --git a/backend/src/main/resources/application-e2e.yaml b/backend/src/main/resources/application-e2e.yaml index ab98430f36..4748123697 100644 --- a/backend/src/main/resources/application-e2e.yaml +++ b/backend/src/main/resources/application-e2e.yaml @@ -131,4 +131,6 @@ simple-report-initialization: test-ordered-loinc-code: "94558-4" datahub: url: "http://invalidhost:8080" - api-key: "placeholder" \ No newline at end of file + api-key: "placeholder" +features: + testCardRefactorEnabled: false \ No newline at end of file diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js index 358ec8a563..a82e0f8ca0 100644 --- a/frontend/.storybook/main.js +++ b/frontend/.storybook/main.js @@ -1,4 +1,3 @@ -const { resolve } = require("path"); module.exports = { stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], addons: [ diff --git a/frontend/package.json b/frontend/package.json index 55581c3dcf..b7ca4cf67a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -250,7 +250,9 @@ "coveragePathIgnorePatterns": [ "/src/index.tsx", "/src/serviceWorker.ts", - ".stories.tsx" + ".stories.tsx", + "/src/app/testQueue/constants.ts", + "/src/patientApp/timeOfTest/constants.ts" ] } } diff --git a/frontend/src/app/commonComponents/TextInput.tsx b/frontend/src/app/commonComponents/TextInput.tsx index e6111188ba..6b18448049 100644 --- a/frontend/src/app/commonComponents/TextInput.tsx +++ b/frontend/src/app/commonComponents/TextInput.tsx @@ -96,6 +96,7 @@ export const TextInput = ({ labelClassName )} htmlFor={id} + id={`label-for-${id}`} aria-describedby={ariaDescribedBy} > {required ? : } @@ -112,7 +113,6 @@ export const TextInput = ({ "usa-input", validationStatus === "error" && "usa-input--error" )} - id={id} data-testid={idString} name={name} value={value || ""} @@ -135,6 +135,7 @@ export const TextInput = ({ ? { "aria-describedby": `error_${id}`, "aria-invalid": true } : null)} {...registrationProps} + id={id} /> )} diff --git a/frontend/src/app/commonComponents/YesNoRadioGroup.test.tsx b/frontend/src/app/commonComponents/YesNoRadioGroup.test.tsx index d53f2a635b..3e051f4502 100644 --- a/frontend/src/app/commonComponents/YesNoRadioGroup.test.tsx +++ b/frontend/src/app/commonComponents/YesNoRadioGroup.test.tsx @@ -1,12 +1,9 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import YesNoRadioGroup, { - boolToYesNoUnknown, - yesNoUnknownToBool, -} from "./YesNoRadioGroup"; +import YesNoRadioGroup, { boolToYesNo, yesNoToBool } from "./YesNoRadioGroup"; -describe("Yes/No/Unknown RadioGroup", () => { +describe("Yes/No RadioGroup", () => { const onChangeFn = jest.fn(() => {}); const onBlurFn = jest.fn(() => {}); @@ -41,8 +38,6 @@ describe("Yes/No/Unknown RadioGroup", () => { expect(onChangeFn).toHaveBeenCalledWith("YES"); await userEvent.click(screen.getByLabelText("No")); expect(onChangeFn).toHaveBeenCalledWith("NO"); - await userEvent.click(screen.getByLabelText("Unknown")); - expect(onChangeFn).toHaveBeenCalledWith("UNKNOWN"); }); it("calls function on blur", async () => { @@ -53,19 +48,18 @@ describe("Yes/No/Unknown RadioGroup", () => { expect(onBlurFn).toHaveBeenCalled(); }); - describe("Yes/No/Unknown utility methods", () => { + describe("Yes/No utility methods", () => { it("converts value to bool", () => { - expect(yesNoUnknownToBool("YES")).toBeTruthy(); - expect(yesNoUnknownToBool("NO")).toBeFalsy(); - expect(yesNoUnknownToBool("UNKNOWN")).toBeNull(); + expect(yesNoToBool("YES")).toBeTruthy(); + expect(yesNoToBool("NO")).toBeFalsy(); // @ts-ignore - expect(yesNoUnknownToBool(undefined)).toBeUndefined(); + expect(yesNoToBool(undefined)).toBeUndefined(); }); it("converts bool to value", () => { - expect(boolToYesNoUnknown(true)).toBe("YES"); - expect(boolToYesNoUnknown(false)).toBe("NO"); - expect(boolToYesNoUnknown(null)).toBe("UNKNOWN"); - expect(boolToYesNoUnknown(undefined)).toBeUndefined(); + expect(boolToYesNo(true)).toBe("YES"); + expect(boolToYesNo(false)).toBe("NO"); + expect(boolToYesNo(null)).toBeUndefined(); + expect(boolToYesNo(undefined)).toBeUndefined(); }); }); }); diff --git a/frontend/src/app/commonComponents/YesNoRadioGroup.tsx b/frontend/src/app/commonComponents/YesNoRadioGroup.tsx index 523284ea6a..aacf7cba79 100644 --- a/frontend/src/app/commonComponents/YesNoRadioGroup.tsx +++ b/frontend/src/app/commonComponents/YesNoRadioGroup.tsx @@ -4,41 +4,33 @@ import { useTranslatedConstants } from "../constants"; import RadioGroup from "./RadioGroup"; -export const boolToYesNoUnknown = ( +export const boolToYesNo = ( value: boolean | null | undefined -): YesNoUnknown | undefined => { +): YesNo | undefined => { if (value) { return "YES"; } if (value === false) { return "NO"; } - if (value === null) { - return "UNKNOWN"; - } return undefined; }; -export const yesNoUnknownToBool = ( - value: YesNoUnknown -): boolean | null | undefined => { +export const yesNoToBool = (value: YesNo): boolean | undefined => { if (value === "YES") { return true; } if (value === "NO") { return false; } - if (value === "UNKNOWN") { - return null; - } return undefined; }; interface Props { name: string; legend: React.ReactNode; - value: YesNoUnknown | undefined; - onChange: (value: YesNoUnknown) => void; + value: YesNo | undefined; + onChange: (value: YesNo) => void; hintText?: string; onBlur?: (event: React.FocusEvent) => void; validationStatus?: "error" | "success"; @@ -57,7 +49,7 @@ const YesNoRadioGroup: React.FC = ({ errorMessage, required, }) => { - const { YES_NO_UNKNOWN_VALUES: values } = useTranslatedConstants(); + const { YES_NO_VALUES: values } = useTranslatedConstants(); return ( First name Middle name @@ -148,6 +150,7 @@ exports[`EditPatient facility select input matches screenshot 1`] = ` @@ -3920,6 +3926,7 @@ exports[`EditPatient facility select input matches screenshot 1`] = ` @@ -3971,6 +3979,7 @@ exports[`EditPatient facility select input matches screenshot 1`] = ` diff --git a/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceForm.test.tsx.snap b/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceForm.test.tsx.snap index ab9f0cdea1..adc22f06c8 100644 --- a/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceForm.test.tsx.snap +++ b/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceForm.test.tsx.snap @@ -293,6 +293,7 @@ exports[`update existing devices renders the Device Form 1`] = ` @@ -703,6 +710,7 @@ exports[`update existing devices renders the Device Form 1`] = ` diff --git a/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceTypeFormContainer.test.tsx.snap b/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceTypeFormContainer.test.tsx.snap index fb6e992bc3..0d4432b102 100644 --- a/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceTypeFormContainer.test.tsx.snap +++ b/frontend/src/app/supportAdmin/DeviceType/__snapshots__/DeviceTypeFormContainer.test.tsx.snap @@ -92,6 +92,7 @@ exports[`DeviceTypeFormContainer should render the device type form 1`] = ` @@ -454,6 +461,7 @@ exports[`DeviceTypeFormContainer should render the device type form 1`] = ` diff --git a/frontend/src/app/supportAdmin/DeviceType/__snapshots__/ManageDeviceTypeFormContainer.test.tsx.snap b/frontend/src/app/supportAdmin/DeviceType/__snapshots__/ManageDeviceTypeFormContainer.test.tsx.snap index cb611bbee9..0753f48757 100644 --- a/frontend/src/app/supportAdmin/DeviceType/__snapshots__/ManageDeviceTypeFormContainer.test.tsx.snap +++ b/frontend/src/app/supportAdmin/DeviceType/__snapshots__/ManageDeviceTypeFormContainer.test.tsx.snap @@ -255,6 +255,7 @@ exports[`ManageDeviceTypeFormContainer renders the Manage Device Type Form Conta @@ -659,6 +666,7 @@ exports[`ManageDeviceTypeFormContainer renders the Manage Device Type Form Conta diff --git a/frontend/src/app/supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedFlu.ts b/frontend/src/app/supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedFlu.ts new file mode 100644 index 0000000000..cbc2a626b6 --- /dev/null +++ b/frontend/src/app/supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedFlu.ts @@ -0,0 +1,26 @@ +const mockSupportedDiseaseTestPerformedFlu = [ + { + supportedDisease: { + internalId: "e286f2a8-38e2-445b-80a5-c16507a96b66", + loinc: "LP14239-5", + name: "Flu A", + }, + testPerformedLoincCode: "LP14239-3", + equipmentUid: "FluAEquipmentUid123", + testkitNameId: "FluATestkitNameId123", + testOrderedLoincCode: "LP14239-6", + }, + { + supportedDisease: { + internalId: "14924488-268f-47db-bea6-aa706971a098", + loinc: "LP14240-3", + name: "Flu B", + }, + testPerformedLoincCode: "LP14240-1", + equipmentUid: "FluBEquipmentUid123", + testkitNameId: "FluBTestkitNameId123", + testOrderedLoincCode: "LP14240-5", + }, +]; + +export default mockSupportedDiseaseTestPerformedFlu; diff --git a/frontend/src/app/supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedMultiplex.ts b/frontend/src/app/supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedMultiplex.ts index 633ac1aeb5..ead69826ba 100644 --- a/frontend/src/app/supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedMultiplex.ts +++ b/frontend/src/app/supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedMultiplex.ts @@ -1,29 +1,5 @@ import mockSupportedDiseaseTestPerformedCovid from "./mockSupportedDiseaseTestPerformedCovid"; - -let mockSupportedDiseaseTestPerformedFlu = [ - { - supportedDisease: { - internalId: "e286f2a8-38e2-445b-80a5-c16507a96b66", - loinc: "LP14239-5", - name: "Flu A", - }, - testPerformedLoincCode: "LP14239-3", - equipmentUid: "FluAEquipmentUid123", - testkitNameId: "FluATestkitNameId123", - testOrderedLoincCode: "LP14239-6", - }, - { - supportedDisease: { - internalId: "14924488-268f-47db-bea6-aa706971a098", - loinc: "LP14240-3", - name: "Flu B", - }, - testPerformedLoincCode: "LP14240-1", - equipmentUid: "FluBEquipmentUid123", - testkitNameId: "FluBTestkitNameId123", - testOrderedLoincCode: "LP14240-5", - }, -]; +import mockSupportedDiseaseTestPerformedFlu from "./mockSupportedDiseaseTestPerformedFlu"; const mockSupportedDiseaseTestPerformedMultiplex = [ ...mockSupportedDiseaseTestPerformedCovid, diff --git a/frontend/src/app/testQueue/QueueItem.test.tsx b/frontend/src/app/testQueue/QueueItem.test.tsx index 8f8eb0e7d0..dc33c532b2 100644 --- a/frontend/src/app/testQueue/QueueItem.test.tsx +++ b/frontend/src/app/testQueue/QueueItem.test.tsx @@ -411,7 +411,7 @@ describe("QueueItem", () => { expect( screen.getByText("Dixon, Althea Hedda Mclaughlin") ).toBeInTheDocument(); - expect(screen.getByTestId("timer")).toHaveTextContent("15:00"); + expect(screen.getByTestId("timer")).toHaveTextContent("Start timer"); }); it("scroll to patient and highlight when startTestPatientId is present", async () => { @@ -448,7 +448,7 @@ describe("QueueItem", () => { const { user } = await renderQueueItemWithUser(); await user.type(screen.getByTestId("device-type-dropdown"), "lumira"); - expect(await screen.findByTestId("timer")).toHaveTextContent("15:00"); + expect(await screen.findByTestId("timer")).toHaveTextContent("Start timer"); }); it("renders dropdown of device types", async () => { diff --git a/frontend/src/app/testQueue/QueueItemSubmitLoader.scss b/frontend/src/app/testQueue/QueueItemSubmitLoader.scss index 3beac3eee7..2b7c3a87f2 100644 --- a/frontend/src/app/testQueue/QueueItemSubmitLoader.scss +++ b/frontend/src/app/testQueue/QueueItemSubmitLoader.scss @@ -31,3 +31,10 @@ display: none; } } + +.test-card-submit-loader { + top: 0; + left: 0; + border-bottom-left-radius: 0.5rem !important; + border-bottom-right-radius: 0.5rem !important; +} diff --git a/frontend/src/app/testQueue/QueueItemSubmitLoader.tsx b/frontend/src/app/testQueue/QueueItemSubmitLoader.tsx index e02fa338ee..47ff618798 100644 --- a/frontend/src/app/testQueue/QueueItemSubmitLoader.tsx +++ b/frontend/src/app/testQueue/QueueItemSubmitLoader.tsx @@ -1,4 +1,5 @@ import classNames from "classnames"; +import { useFeature } from "flagged"; import { CSSTransition } from "react-transition-group"; import iconLoader from "../../img/loader.svg"; @@ -11,6 +12,10 @@ type Props = { }; export const QueueItemSubmitLoader = ({ name, show }: Props) => { + const testCardRefactorEnabled = useFeature( + "testCardRefactorEnabled" + ) as boolean; + const classnames = classNames( "sr-queue-item-submit-loader", "z-top", @@ -18,9 +23,13 @@ export const QueueItemSubmitLoader = ({ name, show }: Props) => { "height-full", "width-full", "text-center", - "radius-lg" + testCardRefactorEnabled ? "test-card-submit-loader" : "radius-lg" ); + const headingClassNames = testCardRefactorEnabled + ? "" + : classNames("margin-top-6", "margin-bottom-5"); + return ( { classNames="sr-queue-item-submit-loader" >
-

+

Submitting test data for {name}...

submitting diff --git a/frontend/src/app/testQueue/TestCard/CloseTestCardModal.tsx b/frontend/src/app/testQueue/TestCard/CloseTestCardModal.tsx new file mode 100644 index 0000000000..cf7c63acab --- /dev/null +++ b/frontend/src/app/testQueue/TestCard/CloseTestCardModal.tsx @@ -0,0 +1,54 @@ +import { + ButtonGroup, + Modal, + ModalFooter, + ModalHeading, + ModalRef, + ModalToggleButton, +} from "@trussworks/react-uswds"; +import React from "react"; + +interface CloseTestCardModalProps { + closeModalRef: React.RefObject; + name: string; + removeTestFromQueue: () => Promise; +} + +const CloseTestCardModal = ({ + closeModalRef, + name, + removeTestFromQueue, +}: CloseTestCardModalProps) => { + return ( + + + Are you sure you want to stop {name}'s test? + +

+ Doing so will remove this person from the list. You can use the search + bar to start their test again later. +

+ + + + Yes, I'm sure + + + No, go back + + + +
+ ); +}; + +export default CloseTestCardModal; diff --git a/frontend/src/app/testQueue/TestCard/TestCard.scss b/frontend/src/app/testQueue/TestCard/TestCard.scss new file mode 100644 index 0000000000..fd7b238315 --- /dev/null +++ b/frontend/src/app/testQueue/TestCard/TestCard.scss @@ -0,0 +1,23 @@ +@use "../../../scss/settings" as settings; + +.list-style-none { + list-style: none; +} + +.card-close-button { + color: settings.$theme-color-prime-gray-darkest !important; +} + +.test-card-header-bottom-border { + border-bottom: 2px solid #dfe1e2; +} + +.test-card-container { + max-width: 64rem; + transition: opacity 2s; +} + +.usa-card__container { + margin-left: 0 !important; + margin-right: 0 !important; +} diff --git a/frontend/src/app/testQueue/TestCard/TestCard.stories.tsx b/frontend/src/app/testQueue/TestCard/TestCard.stories.tsx new file mode 100644 index 0000000000..36108d70eb --- /dev/null +++ b/frontend/src/app/testQueue/TestCard/TestCard.stories.tsx @@ -0,0 +1,167 @@ +import { Provider } from "react-redux"; +import { MemoryRouter } from "react-router-dom"; +import { Meta, StoryFn } from "@storybook/react"; + +import { store } from "../../store"; +import { StoryGraphQLProvider } from "../../../stories/storyMocks"; +import mockSupportedDiseaseCovid from "../mocks/mockSupportedDiseaseCovid"; +import { PhoneType } from "../../../generated/graphql"; +import mockSupportedDiseaseMultiplex from "../mocks/mockSupportedDiseaseMultiplex"; +import { DevicesMap } from "../QueueItem"; + +import { TestCard, TestCardProps } from "./TestCard"; + +export default { + title: "App/Test Queue/Test Card", + component: TestCard, + argTypes: {}, + args: {}, + decorators: [ + (Story) => ( + + + + ), + ], +} as Meta; + +const Template: StoryFn = (args: TestCardProps) => ( + + + + + +); + +const facilityInfo = { + id: "f02cfff5-1921-4293-beff-e2a5d03e1fda", + name: "Testing Site", + deviceTypes: [ + { + internalId: "ee4f40b7-ac32-4709-be0a-56dd77bb9609", + name: "LumiraDX", + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: "Swab of internal nose", + internalId: "8596682d-6053-4720-8a39-1f5d19ff4ed9", + typeCode: "445297001", + }, + { + name: "Nasopharyngeal swab", + internalId: "f127ef55-4133-4556-9bca-33615d071e8d", + typeCode: "258500001", + }, + ], + }, + { + internalId: "5c711888-ba37-4b2e-b347-311ca364efdb", + name: "Abbott BinaxNow", + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: "Swab of internal nose", + internalId: "8596682d-6053-4720-8a39-1f5d19ff4ed9", + typeCode: "445297001", + }, + ], + }, + { + internalId: "32b2ca2a-75e6-4ebd-a8af-b50c7aea1d10", + name: "BD Veritor", + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: "Swab of internal nose", + internalId: "8596682d-6053-4720-8a39-1f5d19ff4ed9", + typeCode: "445297001", + }, + { + name: "Nasopharyngeal swab", + internalId: "f127ef55-4133-4556-9bca-33615d071e8d", + typeCode: "258500001", + }, + ], + }, + { + internalId: "67109f6f-eaee-49d3-b8ff-c61b79a9da8e", + name: "Multiplex", + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseMultiplex, + swabTypes: [ + { + name: "Swab of internal nose", + internalId: "8596682d-6053-4720-8a39-1f5d19ff4ed9", + typeCode: "445297001", + }, + { + name: "Nasopharyngeal swab", + internalId: "f127ef55-4133-4556-9bca-33615d071e8d", + typeCode: "258500001", + }, + ], + }, + ], +}; + +const devicesMap: DevicesMap = new Map(); +facilityInfo.deviceTypes.map((d) => devicesMap.set(d.internalId, d)); + +const testOrderInfo = { + internalId: "1b02363b-ce71-4f30-a2d6-d82b56a91b39", + pregnancy: null, + dateAdded: "2022-11-08 13:33:07.503", + symptoms: + '{"64531003":"false","103001002":"false","84229001":"false","68235000":"false","426000000":"false","49727002":"false","68962001":"false","422587007":"false","267036007":"false","62315008":"false","43724002":"false","36955009":"false","44169009":"false","422400008":"false","230145002":"false","25064002":"false","162397003":"false"}', + symptomOnset: null, + noSymptoms: null, + deviceType: { + internalId: "ee4f40b7-ac32-4709-be0a-56dd77bb9609", + name: "LumiraDX", + model: "LumiraDx SARS-CoV-2 Ag Test*", + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + }, + specimenType: { + internalId: "8596682d-6053-4720-8a39-1f5d19ff4ed9", + name: "Swab of internal nose", + typeCode: "445297001", + }, + patient: { + internalId: "72b3ce1e-9d5a-4ad2-9ae8-e1099ed1b7e0", + firstName: "Jennifer", + middleName: "K", + lastName: "Finley", + telephone: "571-867-5309", + birthDate: "2002-07-21", + gender: "refused", + testResultDelivery: null, + preferredLanguage: null, + email: "sywaporoce@mailinator.com", + emails: ["sywaporoce@mailinator.com"], + phoneNumbers: [ + { + type: PhoneType.Mobile, + number: "(553) 223-0559", + }, + { + type: PhoneType.Landline, + number: "(669) 789-0799", + }, + ], + }, + results: [], + dateTested: null, + correctionStatus: "ORIGINAL", + reasonForCorrection: null, +}; + +export const Default = Template.bind({}); +Default.args = { + testOrder: testOrderInfo, + facility: facilityInfo, + devicesMap: devicesMap, +}; diff --git a/frontend/src/app/testQueue/TestCard/TestCard.test.tsx b/frontend/src/app/testQueue/TestCard/TestCard.test.tsx new file mode 100644 index 0000000000..1eb7d8056f --- /dev/null +++ b/frontend/src/app/testQueue/TestCard/TestCard.test.tsx @@ -0,0 +1,1428 @@ +import { MockedProvider } from "@apollo/client/testing"; +import { Provider } from "react-redux"; +import configureStore, { MockStoreEnhanced } from "redux-mock-store"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import moment from "moment"; +import userEvent from "@testing-library/user-event"; + +import { getAppInsights } from "../../TelemetryService"; +import * as srToast from "../../utils/srToast"; +import { + SubmitQueueItemDocument as SUBMIT_TEST_RESULT, + EditQueueItemDocument as EDIT_QUEUE_ITEM, + UpdateAoeDocument as UPDATE_AOE, + PhoneType, + SubmitQueueItemMutationVariables as SUBMIT_QUEUE_ITEM_VARIABLES, + EditQueueItemMutationVariables as EDIT_QUEUE_ITEM_VARIABLES, + UpdateAoeMutationVariables as UPDATE_AOE_VARIABLES, + SubmitQueueItemMutation as SUBMIT_QUEUE_ITEM_DATA, + EditQueueItemMutation as EDIT_QUEUE_ITEM_DATA, + UpdateAoeMutation as UPDATE_AOE_DATA, +} from "../../../generated/graphql"; +import SRToastContainer from "../../commonComponents/SRToastContainer"; +import { TestCorrectionReason } from "../../testResults/TestResultCorrectionModal"; +import { QueriedFacility, QueriedTestOrder } from "../QueueItem"; +import mockSupportedDiseaseCovid from "../mocks/mockSupportedDiseaseCovid"; +import mockSupportedDiseaseMultiplex, { + mockSupportedDiseaseFlu, +} from "../mocks/mockSupportedDiseaseMultiplex"; + +import { TestCard, TestCardProps } from "./TestCard"; + +jest.mock("../../TelemetryService", () => ({ + getAppInsights: jest.fn(), +})); + +const mockNavigate = jest.fn(); +jest.mock("react-router-dom", () => { + const original = jest.requireActual("react-router-dom"); + return { + ...original, + useNavigate: () => mockNavigate, + }; +}); + +const updatedDateString = "2021-03-10"; +const updatedTimeString = "10:05"; + +const setStartTestPatientIdMock = jest.fn(); + +const device1Name = "LumiraDX"; +const device2Name = "Abbott BinaxNow"; +const device3Name = "BD Veritor"; +const device4Name = "Multiplex"; +const device5Name = "MultiplexAndCovidOnly"; +const device6Name = "FluOnly"; + +const device1Id = "DEVICE-1-ID"; +const device2Id = "DEVICE-2-ID"; +const device3Id = "DEVICE-3-ID"; +const device4Id = "DEVICE-4-ID"; +const device5Id = "DEVICE-5-ID"; +const device6Id = "DEVICE-6-ID"; + +const deletedDeviceId = "DELETED-DEVICE-ID"; +const deletedDeviceName = "Deleted"; + +const specimen1Name = "Swab of internal nose"; +const specimen1Id = "SPECIMEN-1-ID"; +const specimen2Name = "Nasopharyngeal swab"; +const specimen2Id = "SPECIMEN-2-ID"; + +const deletedSpecimenId = "DELETED-SPECIMEN-ID"; + +const getDeviceTypeDropdown = async () => + (await screen.findByTestId("device-type-dropdown")) as HTMLSelectElement; + +async function getSpecimenTypeDropdown() { + return (await screen.findByTestId( + "specimen-type-dropdown" + )) as HTMLSelectElement; +} + +describe("TestCard", () => { + let nowFn = Date.now; + let store: MockStoreEnhanced; + let alertSpy: jest.SpyInstance; + const mockStore = configureStore([]); + const trackEventMock = jest.fn(); + const removePatientFromQueueMock = jest.fn(); + const trackMetricMock = jest.fn(); + const trackExceptionMock = jest.fn(); + + const testOrderInfo: QueriedTestOrder = { + internalId: "1b02363b-ce71-4f30-a2d6-d82b56a91b39", + dateAdded: "2022-11-08 13:33:07.503", + symptoms: + '{"64531003":"false","103001002":"false","84229001":"false","68235000":"false","426000000":"false","49727002":"false","68962001":"false","422587007":"false","267036007":"false","62315008":"false","43724002":"false","36955009":"false","44169009":"false","422400008":"false","230145002":"false","25064002":"false","162397003":"false"}', + symptomOnset: null, + noSymptoms: true, + deviceType: { + internalId: device1Id, + name: device1Name, + model: "LumiraDx SARS-CoV-2 Ag Test*", + testLength: 15, + }, + specimenType: { + internalId: specimen1Id, + name: specimen1Name, + typeCode: "445297001", + }, + patient: { + internalId: "72b3ce1e-9d5a-4ad2-9ae8-e1099ed1b7e0", + telephone: "(571) 867-5309", + birthDate: "2015-09-20", + firstName: "Althea", + middleName: "Hedda Mclaughlin", + lastName: "Dixon", + gender: "refused", + testResultDelivery: null, + preferredLanguage: null, + email: "sywaporoce@mailinator.com", + emails: ["sywaporoce@mailinator.com"], + phoneNumbers: [ + { + type: PhoneType.Mobile, + number: "(553) 223-0559", + }, + { + type: PhoneType.Landline, + number: "(669) 789-0799", + }, + ], + }, + results: [], + dateTested: null, + correctionStatus: "ORIGINAL", + reasonForCorrection: null, + }; + + const facilityInfo: QueriedFacility = { + id: "f02cfff5-1921-4293-beff-e2a5d03e1fda", + name: "Testing Site", + deviceTypes: [ + { + internalId: device1Id, + name: device1Name, + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: device2Id, + name: device2Name, + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + ], + }, + { + internalId: device3Id, + name: device3Name, + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: device4Id, + name: device4Name, + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseMultiplex, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: device5Id, + name: device5Name, + testLength: 15, + supportedDiseaseTestPerformed: [ + ...mockSupportedDiseaseFlu, + { + supportedDisease: mockSupportedDiseaseCovid[0].supportedDisease, + testPerformedLoincCode: "123456", + testOrderedLoincCode: "445566", + }, + { + supportedDisease: mockSupportedDiseaseCovid[0].supportedDisease, + testPerformedLoincCode: "123456", + testOrderedLoincCode: "778899", + }, + ], + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: device6Id, + name: device6Name, + testLength: 15, + supportedDiseaseTestPerformed: [...mockSupportedDiseaseFlu], + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + ], + }; + + const devicesMap = new Map(); + facilityInfo.deviceTypes.map((d) => devicesMap.set(d.internalId, d)); + + const testProps: TestCardProps = { + refetchQueue: jest.fn().mockReturnValue(null), + testOrder: testOrderInfo, + facility: facilityInfo, + devicesMap: devicesMap, + startTestPatientId: "", + setStartTestPatientId: setStartTestPatientIdMock, + removePatientFromQueue: removePatientFromQueueMock, + }; + + type testRenderProps = { + props?: TestCardProps; + mocks?: any; + }; + + async function renderQueueItem( + { props, mocks }: testRenderProps = { props: testProps, mocks: [] } + ) { + props = props || testProps; + const { container } = render( + <> + + + + + + + + ); + return { container, user: userEvent.setup() }; + } + + beforeEach(() => { + store = mockStore({ + organization: { + name: "Organization Name", + }, + }); + + (getAppInsights as jest.Mock).mockImplementation(() => ({ + trackEvent: trackEventMock, + trackMetric: trackMetricMock, + trackException: trackExceptionMock, + })); + // jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(global.Math, "random").mockReturnValue(1); + alertSpy = jest.spyOn(srToast, "showError"); + }); + + afterEach(() => { + Date.now = nowFn; + (getAppInsights as jest.Mock).mockReset(); + jest.spyOn(console, "error").mockRestore(); + jest.spyOn(global.Math, "random").mockRestore(); + alertSpy.mockRestore(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("matches snapshot", async () => { + expect(await renderQueueItem()).toMatchSnapshot(); + }); + + it("correctly renders the test queue", async () => { + await renderQueueItem(); + expect( + screen.getByText("Dixon, Althea Hedda Mclaughlin") + ).toBeInTheDocument(); + expect(screen.getByTestId("timer")).toHaveTextContent("Start timer"); + }); + + it("scroll to patient and highlight when startTestPatientId is present", async () => { + let scrollIntoViewMock = jest.fn(); + window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + + await renderQueueItem({ + props: { + ...testProps, + startTestPatientId: testOrderInfo.patient.internalId, + }, + }); + + const testCard = await screen.findByTestId( + `test-card-${testOrderInfo.patient.internalId}` + ); + expect(testCard).toBeInTheDocument(); + expect(scrollIntoViewMock).toBeCalled(); + }); + + it("navigates to edit the user when clicking their name", async () => { + const { user } = await renderQueueItem(); + const patientName = screen.getByText("Dixon, Althea Hedda Mclaughlin"); + expect(patientName).toBeInTheDocument(); + await user.click(patientName); + expect(mockNavigate).toHaveBeenCalledWith({ + pathname: `/patient/${testOrderInfo.patient.internalId}`, + search: `?facility=${facilityInfo.id}&fromQueue=true`, + }); + }); + + it("updates the timer when a device is changed", async () => { + const { user } = await renderQueueItem(); + await user.type(screen.getByTestId("device-type-dropdown"), "lumira"); + + expect(await screen.findByTestId("timer")).toHaveTextContent("Start timer"); + }); + + it("renders dropdown of device types", async () => { + const { user } = await renderQueueItem(); + + const deviceDropdown = (await screen.findByTestId( + "device-type-dropdown" + )) as HTMLSelectElement; + + expect(deviceDropdown.options.length).toEqual(6); + expect(deviceDropdown.options[0].label).toEqual("Abbott BinaxNow"); + expect(deviceDropdown.options[1].label).toEqual("BD Veritor"); + expect(deviceDropdown.options[2].label).toEqual("FluOnly"); + expect(deviceDropdown.options[3].label).toEqual("LumiraDX"); + expect(deviceDropdown.options[4].label).toEqual("Multiplex"); + expect(deviceDropdown.options[5].label).toEqual("MultiplexAndCovidOnly"); + + await user.selectOptions(deviceDropdown, "Abbott BinaxNow"); + + expect( + ((await screen.findByText("Abbott BinaxNow")) as HTMLOptionElement) + .selected + ).toBeTruthy(); + expect( + ((await screen.findByText("LumiraDX")) as HTMLOptionElement).selected + ).toBeFalsy(); + }); + + it("renders dropdown of swab types configured with selected device", async () => { + const { user } = await renderQueueItem(); + const swabDropdown = (await screen.findByTestId( + "specimen-type-dropdown" + )) as HTMLSelectElement; + + expect(swabDropdown.options.length).toEqual(2); + expect(swabDropdown.options[0].label).toEqual("Nasopharyngeal swab"); + expect(swabDropdown.options[1].label).toEqual("Swab of internal nose"); + + // swab on the queue item is auto selected + expect( + ( + (await screen.findByText( + testOrderInfo.specimenType.name + )) as HTMLOptionElement + ).selected + ).toBeTruthy(); + + await user.selectOptions(swabDropdown, "Nasopharyngeal swab"); + + expect( + ((await screen.findByText("Nasopharyngeal swab")) as HTMLOptionElement) + .selected + ).toBeTruthy(); + }); + + describe("when a selected device or specimen is deleted", () => { + it("correctly handles when device is deleted from facility", async () => { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: deletedDeviceId, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: deletedDeviceId, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device1Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device1Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + deviceType: { + internalId: deletedDeviceId, + name: deletedDeviceName, + model: "test", + testLength: 12, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + }, + correctionStatus: "CORRECTED", + reasonForCorrection: TestCorrectionReason.INCORRECT_RESULT, + }, + }; + + const { user } = await renderQueueItem({ props, mocks }); + + const deviceDropdown = await getDeviceTypeDropdown(); + expect(deviceDropdown.options.length).toEqual(7); + expect(deviceDropdown.options[0].label).toEqual(""); + expect(deviceDropdown.options[1].label).toEqual("Abbott BinaxNow"); + expect(deviceDropdown.options[2].label).toEqual("BD Veritor"); + expect(deviceDropdown.options[3].label).toEqual("FluOnly"); + expect(deviceDropdown.options[4].label).toEqual("LumiraDX"); + expect(deviceDropdown.options[5].label).toEqual("Multiplex"); + expect(deviceDropdown.options[6].label).toEqual("MultiplexAndCovidOnly"); + + expect(deviceDropdown.value).toEqual(""); + + const swabDropdown = await getSpecimenTypeDropdown(); + expect(swabDropdown.options.length).toEqual(0); + expect(swabDropdown).toBeDisabled(); + + // notice the initial error message + expect(screen.getByText("Please select a device.")).toBeInTheDocument(); + + const submitButton = screen.getByText( + "Submit results" + ) as HTMLInputElement; + await user.click(submitButton); + + // attempting to submit should show error toast + expect(screen.getByText("Invalid test device")).toBeInTheDocument(); + + await user.selectOptions(deviceDropdown, device1Id); + + // error goes away after selecting a valid device + const deviceTypeDropdownContainer = screen.getByTestId( + "device-type-dropdown-container" + ); + expect( + within(deviceTypeDropdownContainer).queryByText( + "Please select a device." + ) + ).not.toBeInTheDocument(); + + await user.click(submitButton); + + // able to submit after selecting valid device + // submit modal appears when able to submit but AOE responses are incomplete + expect(screen.getByText("Submit anyway.")).toBeInTheDocument(); + }); + + it("correctly handles when specimen is deleted from device", async () => { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device2Id, + specimenTypeId: deletedSpecimenId, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device2Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device2Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device1Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + deviceType: { + internalId: device2Id, + name: device2Name, + model: "test", + testLength: 12, + supportedDiseases: [ + { + internalId: "6e67ea1c-f9e8-4b3f-8183-b65383ac1283", + loinc: "96741-4", + name: "COVID-19", + }, + ], + }, + specimenType: { + name: "deleted", + internalId: deletedSpecimenId, + typeCode: "12345", + }, + correctionStatus: "CORRECTED", + reasonForCorrection: TestCorrectionReason.INCORRECT_RESULT, + }, + }; + + const { user } = await renderQueueItem({ props, mocks }); + + const deviceDropdown = await getDeviceTypeDropdown(); + expect(deviceDropdown.options.length).toEqual(6); + expect(deviceDropdown.options[0].label).toEqual("Abbott BinaxNow"); + expect(deviceDropdown.options[1].label).toEqual("BD Veritor"); + expect(deviceDropdown.options[2].label).toEqual("FluOnly"); + expect(deviceDropdown.options[3].label).toEqual("LumiraDX"); + expect(deviceDropdown.options[4].label).toEqual("Multiplex"); + expect(deviceDropdown.options[5].label).toEqual("MultiplexAndCovidOnly"); + expect(deviceDropdown.value).toEqual(device2Id); + + const swabDropdown = await getSpecimenTypeDropdown(); + expect(swabDropdown.options.length).toEqual(2); + expect(swabDropdown.options[0].label).toEqual(""); + expect(swabDropdown.options[1].label).toEqual("Swab of internal nose"); + + // notice the error message + expect( + screen.getByText("Please select a specimen type.") + ).toBeInTheDocument(); + + await user.selectOptions(swabDropdown, specimen1Id); + + // error goes away after selecting a valid device + expect( + screen.queryByText("Please select a specimen type.") + ).not.toBeInTheDocument(); + }); + }); + + describe("SMS delivery failure", () => { + it("displays delivery failure alert on submit for invalid patient phone number", async () => { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device1Id, + specimenTypeId: specimen1Id, + results: [ + { diseaseName: "COVID-19", testResult: "UNDETERMINED" }, + ], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "UNDETERMINED", + }, + ], + dateTested: null, + deviceType: { + internalId: device1Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: SUBMIT_TEST_RESULT, + variables: { + patientId: testOrderInfo.patient.internalId, + deviceTypeId: device1Id, + specimenTypeId: specimen1Id, + results: [ + { diseaseName: "COVID-19", testResult: "UNDETERMINED" }, + ], + dateTested: null, + } as SUBMIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + submitQueueItem: { + testResult: { + internalId: testOrderInfo.internalId, + }, + deliverySuccess: false, + }, + } as SUBMIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + pregnancy: null, + symptomOnset: null, + noSymptoms: null, + }, + }; + + const { user } = await renderQueueItem({ props, mocks }); + + // Select result + await user.click( + screen.getByLabelText("Inconclusive", { + exact: false, + }) + ); + + // Submit + await user.click(screen.getByText("Submit results")); + + await user.click( + screen.getByText("Submit anyway", { + exact: false, + }) + ); + + // Displays submitting indicator + expect( + await screen.findByText( + "Submitting test data for Dixon, Althea Hedda Mclaughlin..." + ) + ).toBeInTheDocument(); + + // Verify alert is displayed + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith( + "The phone number provided may not be valid or may not be able to accept text messages", + "Unable to text result to Dixon, Althea Hedda Mclaughlin" + ); + }); + expect( + await screen.findByText( + "Unable to text result to Dixon, Althea Hedda Mclaughlin", + { + exact: false, + } + ) + ).toBeInTheDocument(); + + // Submitting indicator and card are gone + expect( + await screen.findByText("Dixon, Althea Hedda Mclaughlin") + ).toBeInTheDocument(); + expect( + await screen.findByText( + "Submitting test data for Dixon, Althea Hedda Mclaughlin..." + ) + ).toBeInTheDocument(); + }); + }); + + it("updates custom test date/time", async () => { + const { user } = await renderQueueItem(); + const toggle = await screen.findByLabelText("Current date and time"); + await user.click(toggle); + const dateInput = await screen.findByTestId("test-date"); + expect(dateInput).toBeInTheDocument(); + const timeInput = await screen.findByTestId("test-time"); + expect(timeInput).toBeInTheDocument(); + await user.type(dateInput, `${updatedDateString}T00:00`); + await user.type(timeInput, updatedTimeString); + }); + + it("shows error for future test date", async () => { + await renderQueueItem({ + props: { + ...testProps, + testOrder: { ...testOrderInfo, dateTested: "2100-07-15T12:35:00.000Z" }, + }, + }); + + expect( + screen.getByText("Test date can't be in the future.", { + exact: false, + }) + ).toBeInTheDocument(); + }); + + it("formats card with warning state if selected date input is more than six months ago", async () => { + const { user } = await renderQueueItem(); + + const toggle = await screen.findByLabelText("Current date and time"); + await user.click(toggle); + + const dateInput = screen.getByTestId("test-date"); + const oldDate = moment({ year: 2022, month: 1, day: 1 }); + + fireEvent.change(dateInput, { + target: { value: oldDate.format("YYYY-MM-DD") }, + }); + + expect( + screen.getByText( + "The date you selected is more than six months ago. Please make sure it's correct before submitting.", + { + exact: false, + } + ) + ).toBeInTheDocument(); + }); + + it("warn of test corrections and reason for correction", async () => { + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + correctionStatus: "CORRECTED", + reasonForCorrection: TestCorrectionReason.INCORRECT_RESULT, + }, + }; + + await renderQueueItem({ props }); + + // Card is highlighted for visibility + const alert = within( + screen.getByTestId(`test-card-${testOrderInfo.patient.internalId}`) + ).getByTestId("alert"); + expect(alert).toHaveClass("usa-alert--warning"); + + expect( + await within(alert).findByText("Incorrect test result", { + exact: false, + }) + ).toBeInTheDocument(); + }); + + describe("when device supports covid only and multiplex", () => { + it("should allow you to submit covid only results", async () => { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device4Id, + specimenTypeId: specimen1Id, + results: [], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [], + dateTested: null, + deviceType: { + internalId: device4Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device4Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [], + dateTested: null, + deviceType: { + internalId: device4Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device5Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [], + dateTested: null, + deviceType: { + internalId: device5Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const { user } = await renderQueueItem({ mocks }); + + const deviceDropdown = await getDeviceTypeDropdown(); + expect(deviceDropdown.options.length).toEqual(6); + expect(deviceDropdown.options[0].label).toEqual("Abbott BinaxNow"); + expect(deviceDropdown.options[1].label).toEqual("BD Veritor"); + expect(deviceDropdown.options[2].label).toEqual("FluOnly"); + expect(deviceDropdown.options[3].label).toEqual("LumiraDX"); + expect(deviceDropdown.options[4].label).toEqual("Multiplex"); + expect(deviceDropdown.options[5].label).toEqual("MultiplexAndCovidOnly"); + + // Change device type to multiplex + await user.selectOptions(deviceDropdown, device4Name); + + // select results + await user.click( + within( + screen.getByTestId(`covid-test-result-${testOrderInfo.internalId}`) + ).getByLabelText("Positive", { exact: false }) + ); + + // Change device type to multiplex that supports covid only + await user.selectOptions(deviceDropdown, device5Name); + expect(deviceDropdown.value).toEqual(device5Id); + + // Notice submit is enabled + expect(screen.getByText("Submit results")).toBeEnabled(); + }); + }); + + describe("test submission and telemetry", () => { + it("delegates removal of patient from queue to removePatientFromQueue hook", async () => { + const { user } = await renderQueueItem(); + + const button = screen.getByLabelText( + `Close test for Dixon, Althea Hedda Mclaughlin` + ); + await user.click(button); + const iAmSure = screen.getByText("Yes, I'm sure"); + await user.click(iAmSure); + + expect(removePatientFromQueueMock).toHaveBeenCalledWith( + testOrderInfo.patient.internalId + ); + }); + + it("tracks submitted test result as custom event", async () => { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device1Id, + specimenTypeId: specimen1Id, + results: [ + { diseaseName: "COVID-19", testResult: "UNDETERMINED" }, + ], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device1Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: SUBMIT_TEST_RESULT, + variables: { + patientId: testOrderInfo.patient.internalId, + deviceTypeId: device1Id, + specimenTypeId: specimen1Id, + results: [ + { diseaseName: "COVID-19", testResult: "UNDETERMINED" }, + ], + dateTested: null, + } as SUBMIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + submitQueueItem: { + testResult: { + internalId: testOrderInfo.internalId, + }, + deliverySuccess: false, + }, + } as SUBMIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const { user } = await renderQueueItem({ mocks }); + + // Select result + await user.click( + screen.getByLabelText("Inconclusive", { + exact: false, + }) + ); + + // Submit + await user.click(screen.getByText("Submit results")); + await user.click(screen.getByText("Submit anyway.")); + + expect(trackEventMock).toHaveBeenCalledWith({ + name: "Submit Test Result", + }); + }); + + it("tracks AoE form updates as custom event", async () => { + const mocks = [ + { + request: { + query: UPDATE_AOE, + variables: { + patientId: testOrderInfo.patient.internalId, + symptoms: + '{"25064002":false,"36955009":false,"43724002":false,"44169009":false,"49727002":false,"62315008":false,"64531003":false,"68235000":false,"68962001":false,"84229001":false,"103001002":false,"162397003":false,"230145002":false,"267036007":false,"422400008":false,"422587007":false,"426000000":false}', + symptomOnset: null, + noSymptoms: false, + } as UPDATE_AOE_VARIABLES, + }, + result: { + data: { + updateTimeOfTestQuestions: null, + } as UPDATE_AOE_DATA, + }, + }, + { + request: { + query: UPDATE_AOE, + variables: { + patientId: testOrderInfo.patient.internalId, + symptoms: + '{"25064002":false,"36955009":false,"43724002":false,"44169009":false,"49727002":false,"62315008":false,"64531003":false,"68235000":false,"68962001":false,"84229001":false,"103001002":false,"162397003":false,"230145002":false,"267036007":false,"422400008":false,"422587007":false,"426000000":false}', + symptomOnset: "2023-08-15", + noSymptoms: false, + } as UPDATE_AOE_VARIABLES, + }, + result: { + data: { + updateTimeOfTestQuestions: null, + } as UPDATE_AOE_DATA, + }, + }, + ]; + + const { user } = await renderQueueItem({ mocks }); + + await user.click( + within( + screen.getByTestId(`has-any-symptoms-${testOrderInfo.internalId}`) + ).getByLabelText("Yes") + ); + await waitFor(() => + expect( + within( + screen.getByTestId(`has-any-symptoms-${testOrderInfo.internalId}`) + ).getByLabelText("Yes") + ).toBeChecked() + ); + + await user.type( + screen.getByLabelText("When did the patient's symptoms start?"), + "2023-08-15" + ); + await waitFor(() => + expect( + screen.getByLabelText("When did the patient's symptoms start?") + ).toHaveValue("2023-08-15") + ); + + expect(trackEventMock).toHaveBeenCalledWith({ + name: "Update AoE Response", + }); + }); + }); + + describe("on device specimen type change", () => { + it("updates test order on device type and specimen type change", async () => { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device1Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device1Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device3Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device3Id, + testLength: 10, + supportedDiseases: [ + { + internalId: "6e67ea1c-f9e8-4b3f-8183-b65383ac1283", + loinc: "96741-4", + name: "COVID-19", + }, + { + internalId: "e286f2a8-38e2-445b-80a5-c16507a96b66", + loinc: "LP14239-5", + name: "Flu A", + }, + { + internalId: "14924488-268f-47db-bea6-aa706971a098", + loinc: "LP14240-3", + name: "Flu B", + }, + ], + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device3Id, + specimenTypeId: specimen2Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device3Id, + testLength: 10, + supportedDiseases: [ + { + internalId: "6e67ea1c-f9e8-4b3f-8183-b65383ac1283", + loinc: "96741-4", + name: "COVID-19", + }, + { + internalId: "e286f2a8-38e2-445b-80a5-c16507a96b66", + loinc: "LP14239-5", + name: "Flu A", + }, + { + internalId: "14924488-268f-47db-bea6-aa706971a098", + loinc: "LP14240-3", + name: "Flu B", + }, + ], + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const { user } = await renderQueueItem({ mocks }); + + const deviceDropdown = await getDeviceTypeDropdown(); + expect(deviceDropdown.options.length).toEqual(6); + expect(deviceDropdown.options[0].label).toEqual("Abbott BinaxNow"); + expect(deviceDropdown.options[1].label).toEqual("BD Veritor"); + expect(deviceDropdown.options[2].label).toEqual("FluOnly"); + expect(deviceDropdown.options[3].label).toEqual("LumiraDX"); + expect(deviceDropdown.options[4].label).toEqual("Multiplex"); + expect(deviceDropdown.options[5].label).toEqual("MultiplexAndCovidOnly"); + + // select results + await user.click(screen.getByLabelText("Positive", { exact: false })); + + // Change device type + await user.selectOptions(deviceDropdown, device3Name); + + // Change specimen type + const swabDropdown = await getSpecimenTypeDropdown(); + expect(swabDropdown.options.length).toEqual(2); + expect(swabDropdown.options[0].label).toEqual("Nasopharyngeal swab"); + expect(swabDropdown.options[1].label).toEqual("Swab of internal nose"); + + await user.selectOptions(swabDropdown, specimen2Name); + + expect(deviceDropdown.value).toEqual(device3Id); + expect(swabDropdown.value).toEqual(specimen2Id); + }); + + it("adds radio buttons for Flu A and Flu B when a multiplex device is chosen", async () => { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device4Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device4Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const { user } = await renderQueueItem({ mocks }); + + expect(screen.queryByText("Flu A result")).not.toBeInTheDocument(); + expect(screen.queryByText("Flu B result")).not.toBeInTheDocument(); + + const deviceDropdown = await getDeviceTypeDropdown(); + expect(deviceDropdown.options.length).toEqual(6); + expect(deviceDropdown.options[0].label).toEqual("Abbott BinaxNow"); + expect(deviceDropdown.options[1].label).toEqual("BD Veritor"); + expect(deviceDropdown.options[2].label).toEqual("FluOnly"); + expect(deviceDropdown.options[3].label).toEqual("LumiraDX"); + expect(deviceDropdown.options[4].label).toEqual("Multiplex"); + expect(deviceDropdown.options[5].label).toEqual("MultiplexAndCovidOnly"); + + // Change device type to a multiplex device + await user.selectOptions(deviceDropdown, device4Name); + + expect(screen.getByText("Flu A result")).toBeInTheDocument(); + expect(screen.getByText("Flu B result")).toBeInTheDocument(); + }); + + it("should show no AOE questions when a flu only device is chosen", async function () { + const mocks = [ + { + request: { + query: EDIT_QUEUE_ITEM, + variables: { + id: testOrderInfo.internalId, + deviceTypeId: device4Id, + specimenTypeId: specimen1Id, + results: [{ diseaseName: "COVID-19", testResult: "POSITIVE" }], + dateTested: null, + } as EDIT_QUEUE_ITEM_VARIABLES, + }, + result: { + data: { + editQueueItem: { + results: [ + { + disease: { name: "COVID-19" }, + testResult: "POSITIVE", + }, + ], + dateTested: null, + deviceType: { + internalId: device4Id, + testLength: 10, + }, + }, + } as EDIT_QUEUE_ITEM_DATA, + }, + }, + ]; + + const { user } = await renderQueueItem({ mocks }); + + const deviceDropdown = await getDeviceTypeDropdown(); + expect(deviceDropdown.options.length).toEqual(6); + expect(deviceDropdown.options[0].label).toEqual("Abbott BinaxNow"); + expect(deviceDropdown.options[1].label).toEqual("BD Veritor"); + expect(deviceDropdown.options[2].label).toEqual("FluOnly"); + expect(deviceDropdown.options[3].label).toEqual("LumiraDX"); + expect(deviceDropdown.options[4].label).toEqual("Multiplex"); + expect(deviceDropdown.options[5].label).toEqual("MultiplexAndCovidOnly"); + + // Change device type to a flu only device + await user.selectOptions(deviceDropdown, device6Name); + + expect(screen.getByText("Flu A result")).toBeInTheDocument(); + expect(screen.getByText("Flu A result")).toBeInTheDocument(); + expect(screen.queryByText("COVID result")).not.toBeInTheDocument(); + + expect( + screen.queryByText("Is the patient currently experiencing any symptoms") + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/app/testQueue/TestCard/TestCard.tsx b/frontend/src/app/testQueue/TestCard/TestCard.tsx new file mode 100644 index 0000000000..38f41a4ca2 --- /dev/null +++ b/frontend/src/app/testQueue/TestCard/TestCard.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Card, + CardBody, + CardHeader, + Icon, + ModalRef, +} from "@trussworks/react-uswds"; +import { useNavigate } from "react-router-dom"; +import { useSelector } from "react-redux"; + +import { DevicesMap, QueriedFacility, QueriedTestOrder } from "../QueueItem"; +import Button from "../../commonComponents/Button/Button"; +import { removeTimer, TestTimerWidget, useTestTimer } from "../TestTimer"; +import { RootState } from "../../store"; +import "./TestCard.scss"; +import TestCardForm from "../TestCardForm/TestCardForm"; +import { useTestOrderPatient } from "../TestCardForm/TestCardForm.utils"; + +import CloseTestCardModal from "./CloseTestCardModal"; + +export interface TestCardProps { + testOrder: QueriedTestOrder; + facility: QueriedFacility; + devicesMap: DevicesMap; + refetchQueue: () => void; + removePatientFromQueue: (patientId: string) => Promise; + startTestPatientId: string | null; + setStartTestPatientId: React.Dispatch>; +} + +export const TestCard = ({ + testOrder, + facility, + devicesMap, + refetchQueue, + removePatientFromQueue, + startTestPatientId, + setStartTestPatientId, +}: TestCardProps) => { + const navigate = useNavigate(); + const timer = useTestTimer( + testOrder.internalId, + testOrder.deviceType.testLength + ); + const organization = useSelector( + (state: any) => state.organization as Organization + ); + const closeModalRef = useRef(null); + + const [isExpanded, setIsExpanded] = useState(true); + + const testCardElement = useRef() as React.MutableRefObject; + + useEffect(() => { + if (startTestPatientId === testOrder.patient.internalId) { + testCardElement.current.scrollIntoView({ behavior: "smooth" }); + } + // only run on first render to prevent disruptive repeated scrolls + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const timerContext = { + organizationName: organization.name, + facilityName: facility!.name, + patientId: testOrder.patient.internalId, + testOrderId: testOrder.internalId, + }; + + const { patientFullName, patientDateOfBirth } = + useTestOrderPatient(testOrder); + + const toggleExpanded = () => setIsExpanded((prevState) => !prevState); + + const removeTestFromQueue = async () => { + await removePatientFromQueue(testOrder.patient.internalId); + removeTimer(testOrder.internalId); + }; + + return ( +
+ + + +
+
+ +
+
+ +
+
+ + DOB: {patientDateOfBirth.format("MM/DD/YYYY")} + +
+
+
+ +
+
+ +
+
+
+
+ + + +
+
+
+ ); +}; diff --git a/frontend/src/app/testQueue/TestCard/__snapshots__/TestCard.test.tsx.snap b/frontend/src/app/testQueue/TestCard/__snapshots__/TestCard.test.tsx.snap new file mode 100644 index 0000000000..8c4661e38f --- /dev/null +++ b/frontend/src/app/testQueue/TestCard/__snapshots__/TestCard.test.tsx.snap @@ -0,0 +1,652 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TestCard matches snapshot 1`] = ` +Object { + "container":
+
+
  • +
    +
    +
    +
    + +
    +
    + +
    +
    + + DOB: + 09/20/2015 + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient pregnant? + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient currently experiencing any symptoms? + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
  • +
    +
    +
    , + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; diff --git a/frontend/src/app/testQueue/TestCardForm/IncompleteAOEWarningModal.tsx b/frontend/src/app/testQueue/TestCardForm/IncompleteAOEWarningModal.tsx new file mode 100644 index 0000000000..c9f0a4c413 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/IncompleteAOEWarningModal.tsx @@ -0,0 +1,51 @@ +import { + ButtonGroup, + Modal, + ModalFooter, + ModalHeading, + ModalRef, + ModalToggleButton, +} from "@trussworks/react-uswds"; +import React from "react"; + +interface IncompleteAOEWarningModalProps { + submitModalRef: React.RefObject; + name: string; + submitForm: (forceSubmit: boolean) => Promise; +} + +const IncompleteAOEWarningModal = ({ + submitModalRef, + name, + submitForm, +}: IncompleteAOEWarningModalProps) => { + return ( + + + The test questionnaire for {name} has not been completed. + +

    Do you want to submit results anyway?

    + + + submitForm(true)} + > + Submit anyway. + + + No, go back. + + + +
    + ); +}; + +export default IncompleteAOEWarningModal; diff --git a/frontend/src/app/testQueue/TestCardForm/TestCardForm.scss b/frontend/src/app/testQueue/TestCardForm/TestCardForm.scss new file mode 100644 index 0000000000..0c23028419 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/TestCardForm.scss @@ -0,0 +1,4 @@ +.no-left-border { + border-left-width: 0 !important; + padding-left: 0 !important; +} diff --git a/frontend/src/app/testQueue/TestCardForm/TestCardForm.test.tsx b/frontend/src/app/testQueue/TestCardForm/TestCardForm.test.tsx new file mode 100644 index 0000000000..ed952e2ecc --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/TestCardForm.test.tsx @@ -0,0 +1,397 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MockedProvider } from "@apollo/client/testing"; + +import { getAppInsights } from "../../TelemetryService"; +import * as srToast from "../../utils/srToast"; +import { PhoneType } from "../../../generated/graphql"; +import { QueriedFacility, QueriedTestOrder } from "../QueueItem"; +import mockSupportedDiseaseCovid from "../mocks/mockSupportedDiseaseCovid"; +import mockSupportedDiseaseMultiplex, { + mockSupportedDiseaseFlu, +} from "../mocks/mockSupportedDiseaseMultiplex"; + +import TestCardForm, { TestCardFormProps } from "./TestCardForm"; + +jest.mock("../../TelemetryService", () => ({ + getAppInsights: jest.fn(), +})); + +const setStartTestPatientIdMock = jest.fn(); + +const covidDeviceName = "LumiraDX"; +const multiplexDeviceName = "Multiplex"; +const multiplexAndCovidOnlyDeviceName = "MultiplexAndCovidOnly"; +const fluDeviceName = "FLU"; + +const covidDeviceId = "COVID-DEVICE-ID"; +const multiplexDeviceId = "MULTIPLEX-DEVICE-ID"; +const multiplexAndCovidOnlyDeviceId = "MULTIPLEX-COVID-DEVICE-ID"; +const fluDeviceId = "FLU-DEVICE-ID"; + +const specimen1Name = "Swab of internal nose"; +const specimen1Id = "SPECIMEN-1-ID"; +const specimen2Name = "Nasopharyngeal swab"; +const specimen2Id = "SPECIMEN-2-ID"; + +describe("TestCardForm", () => { + let nowFn = Date.now; + let alertSpy: jest.SpyInstance; + const trackEventMock = jest.fn(); + const trackMetricMock = jest.fn(); + const trackExceptionMock = jest.fn(); + + const testOrderInfo: QueriedTestOrder = { + internalId: "1b02363b-ce71-4f30-a2d6-d82b56a91b39", + dateAdded: "2022-11-08 13:33:07.503", + symptoms: + '{"64531003":"false","103001002":"false","84229001":"false","68235000":"false","426000000":"false","49727002":"false","68962001":"false","422587007":"false","267036007":"false","62315008":"false","43724002":"false","36955009":"false","44169009":"false","422400008":"false","230145002":"false","25064002":"false","162397003":"false"}', + symptomOnset: null, + noSymptoms: true, + deviceType: { + internalId: multiplexDeviceId, + name: multiplexDeviceName, + model: multiplexDeviceName, + testLength: 15, + }, + specimenType: { + internalId: specimen1Id, + name: specimen1Name, + typeCode: "445297001", + }, + patient: { + internalId: "72b3ce1e-9d5a-4ad2-9ae8-e1099ed1b7e0", + telephone: "(571) 867-5309", + birthDate: "2015-09-20", + firstName: "Althea", + middleName: "Hedda Mclaughlin", + lastName: "Dixon", + gender: "refused", + testResultDelivery: null, + preferredLanguage: null, + email: "sywaporoce@mailinator.com", + emails: ["sywaporoce@mailinator.com"], + phoneNumbers: [ + { + type: PhoneType.Mobile, + number: "(553) 223-0559", + }, + { + type: PhoneType.Landline, + number: "(669) 789-0799", + }, + ], + }, + results: [], + dateTested: null, + correctionStatus: "ORIGINAL", + reasonForCorrection: null, + }; + + const facilityInfo: QueriedFacility = { + id: "f02cfff5-1921-4293-beff-e2a5d03e1fda", + name: "Testing Site", + deviceTypes: [ + { + internalId: covidDeviceId, + name: covidDeviceName, + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: multiplexDeviceId, + name: multiplexDeviceName, + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseMultiplex, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: fluDeviceId, + name: fluDeviceName, + testLength: 15, + supportedDiseaseTestPerformed: [...mockSupportedDiseaseFlu], + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: multiplexAndCovidOnlyDeviceId, + name: multiplexAndCovidOnlyDeviceName, + testLength: 15, + supportedDiseaseTestPerformed: [ + ...mockSupportedDiseaseFlu, + { + supportedDisease: mockSupportedDiseaseCovid[0].supportedDisease, + testPerformedLoincCode: "123456", + testOrderedLoincCode: "445566", + }, + { + supportedDisease: mockSupportedDiseaseCovid[0].supportedDisease, + testPerformedLoincCode: "123456", + testOrderedLoincCode: "778899", + }, + ], + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + ], + }; + + const devicesMap = new Map(); + facilityInfo.deviceTypes.map((d) => devicesMap.set(d.internalId, d)); + + const testProps: TestCardFormProps = { + refetchQueue: jest.fn().mockReturnValue(null), + testOrder: testOrderInfo, + facility: facilityInfo, + devicesMap: devicesMap, + startTestPatientId: "", + setStartTestPatientId: setStartTestPatientIdMock, + }; + + type testRenderProps = { + props?: TestCardFormProps; + mocks?: any; + }; + + async function renderTestCardForm( + { props, mocks }: testRenderProps = { props: testProps, mocks: [] } + ) { + props = props || testProps; + const view = render( + <> + + + + + ); + return { user: userEvent.setup(), ...view }; + } + + beforeEach(() => { + (getAppInsights as jest.Mock).mockImplementation(() => ({ + trackEvent: trackEventMock, + trackMetric: trackMetricMock, + trackException: trackExceptionMock, + })); + // jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(global.Math, "random").mockReturnValue(1); + alertSpy = jest.spyOn(srToast, "showError"); + }); + + afterEach(() => { + Date.now = nowFn; + (getAppInsights as jest.Mock).mockReset(); + jest.spyOn(console, "error").mockRestore(); + jest.spyOn(global.Math, "random").mockRestore(); + alertSpy.mockRestore(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe("initial state", () => { + it("matches snapshot for covid device", async () => { + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + results: [ + { testResult: "POSITIVE", disease: { name: "COVID-19" } }, + { testResult: "POSITIVE", disease: { name: "Flu A" } }, + { testResult: "POSITIVE", disease: { name: "Flu B" } }, + ], + deviceType: { + internalId: covidDeviceId, + name: covidDeviceName, + model: covidDeviceName, + testLength: 15, + }, + }, + }; + + expect(await renderTestCardForm({ props })).toMatchSnapshot(); + }); + + it("matches snapshot for multiplex device", async () => { + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + results: [ + { testResult: "NEGATIVE", disease: { name: "COVID-19" } }, + { testResult: "NEGATIVE", disease: { name: "Flu A" } }, + { testResult: "NEGATIVE", disease: { name: "Flu B" } }, + ], + deviceType: { + internalId: multiplexDeviceId, + name: multiplexDeviceName, + model: multiplexDeviceName, + testLength: 15, + }, + }, + }; + + expect(await renderTestCardForm({ props })).toMatchSnapshot(); + }); + + it("matches snapshot for flu device", async () => { + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + results: [ + { testResult: "UNDETERMINED", disease: { name: "COVID-19" } }, + { testResult: "UNDETERMINED", disease: { name: "Flu A" } }, + { testResult: "UNDETERMINED", disease: { name: "Flu B" } }, + ], + deviceType: { + internalId: fluDeviceId, + name: fluDeviceName, + model: fluDeviceName, + testLength: 15, + }, + }, + }; + + expect(await renderTestCardForm({ props })).toMatchSnapshot(); + }); + }); + + describe("error handling", () => { + it("should show error when only parts of results are inconclusive", async () => { + const props = { + ...testProps, + testOrder: { + ...testProps.testOrder, + results: [ + { testResult: "UNDETERMINED", disease: { name: "COVID-19" } }, + { testResult: "POSITIVE", disease: { name: "Flu A" } }, + { testResult: "POSITIVE", disease: { name: "Flu B" } }, + ], + }, + }; + + const { user } = await renderTestCardForm({ props }); + + // Submit to start form validation + await user.click(screen.getByText("Submit results")); + + expect( + screen.getByText( + "This device only supports inconclusive results if all are inconclusive." + ) + ).toBeInTheDocument(); + }); + + it("should show error when only flu only of results are filled in for covid/flu device", async () => { + const tprops = { + ...testProps, + testOrder: { + ...testProps.testOrder, + results: [ + { testResult: "POSITIVE", disease: { name: "Flu A" } }, + { testResult: "POSITIVE", disease: { name: "Flu B" } }, + ], + }, + }; + + const { user } = await renderTestCardForm({ props: tprops, mocks: [] }); + + // Submit to start form validation + await user.click(screen.getByText("Submit results")); + + expect( + screen.getByText( + "Please enter results for all conditions tested with this device." + ) + ).toBeInTheDocument(); + }); + + it("should show error when flu is filled out without covid in ", async () => { + const tprops = { + ...testProps, + testOrder: { + ...testProps.testOrder, + deviceType: { + internalId: multiplexAndCovidOnlyDeviceId, + name: multiplexAndCovidOnlyDeviceName, + model: multiplexAndCovidOnlyDeviceName, + testLength: 15, + }, + results: [ + { testResult: "POSITIVE", disease: { name: "Flu A" } }, + { testResult: "POSITIVE", disease: { name: "Flu B" } }, + ], + }, + }; + + const { user } = await renderTestCardForm({ props: tprops, mocks: [] }); + + // Submit to start form validation + await user.click(screen.getByText("Submit results")); + + expect( + screen.getByText("Please enter a COVID-19 test result.") + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/app/testQueue/TestCardForm/TestCardForm.tsx b/frontend/src/app/testQueue/TestCardForm/TestCardForm.tsx new file mode 100644 index 0000000000..800c6a216e --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/TestCardForm.tsx @@ -0,0 +1,565 @@ +import moment from "moment"; +import { + Alert, + Button, + Checkbox, + FormGroup, + Label, + ModalRef, +} from "@trussworks/react-uswds"; +import React, { useEffect, useReducer, useRef, useState } from "react"; + +import TextInput from "../../commonComponents/TextInput"; +import { DevicesMap, QueriedFacility, QueriedTestOrder } from "../QueueItem"; +import { formatDate } from "../../utils/date"; +import { TextWithTooltip } from "../../commonComponents/TextWithTooltip"; +import Dropdown from "../../commonComponents/Dropdown"; +import { MULTIPLEX_DISEASES } from "../../testResults/constants"; +import { + useEditQueueItemMutation, + useSubmitQueueItemMutation, + useUpdateAoeMutation, +} from "../../../generated/graphql"; +import { removeTimer, updateTimer } from "../TestTimer"; +import { showError } from "../../utils/srToast"; +import "./TestCardForm.scss"; +import { + TestCorrectionReason, + TestCorrectionReasons, +} from "../../testResults/TestResultCorrectionModal"; +import { PregnancyCode } from "../../../patientApp/timeOfTest/constants"; +import { QueueItemSubmitLoader } from "../QueueItemSubmitLoader"; + +import { + testCardFormReducer, + TestFormActionCase, + TestFormState, +} from "./TestCardFormReducer"; +import CovidResultInputGroup, { + validateCovidResultInput, +} from "./diseaseSpecificComponents/CovidResultInputGroup"; +import MultiplexResultInputGroup, { + convertFromMultiplexResultInputs, + validateMultiplexResultState, +} from "./diseaseSpecificComponents/MultiplexResultInputGroup"; +import CovidAoEForm, { + parseSymptoms, +} from "./diseaseSpecificComponents/CovidAoEForm"; +import { + AOEFormOption, + areAOEAnswersComplete, + convertFromMultiplexResponse, + doesDeviceSupportMultiplex, + showTestResultDeliveryStatusAlert, + useAOEFormOption, + useAppInsightTestCardEvents, + useDeviceTypeOptions, + useSpecimenTypeOptions, + useTestOrderPatient, +} from "./TestCardForm.utils"; +import IncompleteAOEWarningModal from "./IncompleteAOEWarningModal"; + +const DEBOUNCE_TIME = 300; + +export interface TestCardFormProps { + testOrder: QueriedTestOrder; + devicesMap: DevicesMap; + facility: QueriedFacility; + refetchQueue: () => void; + startTestPatientId: string | null; + setStartTestPatientId: React.Dispatch>; +} + +const TestCardForm = ({ + testOrder, + devicesMap, + facility, + refetchQueue, + startTestPatientId, + setStartTestPatientId, +}: TestCardFormProps) => { + const initialFormState: TestFormState = { + dirty: false, + dateTested: testOrder.dateTested, + deviceId: testOrder.deviceType.internalId ?? "", + devicesMap: devicesMap, + specimenId: testOrder.specimenType.internalId ?? "", + testResults: convertFromMultiplexResponse(testOrder.results), + covidAOEResponses: { + pregnancy: testOrder.pregnancy as PregnancyCode, + noSymptoms: testOrder.noSymptoms, + symptomOnset: testOrder.symptomOnset, + symptoms: JSON.stringify(parseSymptoms(testOrder.symptoms)), + }, + }; + const [state, dispatch] = useReducer(testCardFormReducer, initialFormState); + const [useCurrentTime, setUseCurrentTime] = useState(!testOrder.dateTested); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + + const [editQueueItem, { loading: editQueueItemMutationLoading }] = + useEditQueueItemMutation(); + const [updateAoeMutation, { loading: updateAoeMutationLoading }] = + useUpdateAoeMutation(); + const [submitTestResult, { loading: submitLoading }] = + useSubmitQueueItemMutation(); + + const { trackSubmitTestResult, trackUpdateAoEResponse } = + useAppInsightTestCardEvents(); + + const submitModalRef = useRef(null); + + const { deviceTypeOptions, deviceTypeIsInvalid } = useDeviceTypeOptions( + facility, + state + ); + const { specimenTypeOptions, specimenTypeIsInvalid } = + useSpecimenTypeOptions(state); + + const { patientFullName } = useTestOrderPatient(testOrder); + + const deviceSupportsMultiplex = doesDeviceSupportMultiplex( + state.deviceId, + devicesMap + ); + + const whichAOEFormOption = useAOEFormOption(state.deviceId, devicesMap); + + /** + * When backend sends an updated test order, update the form state + * see refetch function and periodic polling on TestQueue useGetFacilityQueueQuery + */ + useEffect(() => { + // don't update if there are unsaved dirty changes or if still awaiting saved edits + if (state.dirty || editQueueItemMutationLoading || updateAoeMutationLoading) + return; + dispatch({ + type: TestFormActionCase.UPDATE_WITH_CHANGES_FROM_SERVER, + payload: testOrder, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [testOrder]); + + /** + * When backend sends an updated devices map, update the form state + * see refetch function and periodic polling on TestQueue useGetFacilityQueueQuery + */ + useEffect(() => { + // don't update if not done saving changes + if (state.dirty) return; + dispatch({ + type: TestFormActionCase.UPDATE_DEVICES_MAP, + payload: devicesMap, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [devicesMap]); + + /** When device id changes, update the test timer */ + useEffect(() => { + const deviceTestLength = state.devicesMap.get(state.deviceId)?.testLength; + if (deviceTestLength) { + updateTimer(testOrder.internalId, deviceTestLength); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.deviceId]); + + /** When user makes changes on test order fields, send update to backend */ + useEffect(() => { + let debounceTimer: ReturnType; + if (state.dirty) { + dispatch({ type: TestFormActionCase.UPDATE_DIRTY_STATE, payload: false }); + debounceTimer = setTimeout(async () => { + await updateTestOrder(); + }, DEBOUNCE_TIME); + } + return () => { + clearTimeout(debounceTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.deviceId, state.specimenId, state.dateTested, state.testResults]); + + /** When user makes changes to AOE responses, send update to backend */ + useEffect(() => { + if (state.dirty) { + dispatch({ type: TestFormActionCase.UPDATE_DIRTY_STATE, payload: false }); + updateAOE(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.covidAOEResponses]); + + const updateAOE = async () => { + if (whichAOEFormOption === AOEFormOption.COVID) { + trackUpdateAoEResponse(); + await updateAoeMutation({ + variables: { + patientId: testOrder.patient.internalId, + noSymptoms: state.covidAOEResponses.noSymptoms, + // automatically converts boolean strings like "false" to false + symptoms: JSON.stringify( + parseSymptoms(state.covidAOEResponses.symptoms) + ), + symptomOnset: state.covidAOEResponses.symptomOnset, + pregnancy: state.covidAOEResponses.pregnancy, + }, + }); + } + }; + + const updateTestOrder = async () => { + const resultsToSave = doesDeviceSupportMultiplex(state.deviceId, devicesMap) + ? state.testResults + : state.testResults.filter( + (result) => result.diseaseName === MULTIPLEX_DISEASES.COVID_19 + ); + + const response = await editQueueItem({ + variables: { + id: testOrder.internalId, + deviceTypeId: state.deviceId, + dateTested: state.dateTested, + specimenTypeId: state.specimenId, + results: resultsToSave, + }, + }); + if (!response.data) { + throw Error("updateQueueItem null response data"); + } + }; + + const validateDateTested = () => { + const EARLIEST_TEST_DATE = new Date("01/01/2020 12:00:00 AM"); + if (!state.dateTested && !useCurrentTime) { + return "Test date can't be empty"; + } + const dateTested = new Date(state.dateTested); // local time, may be an invalid date + // if it is an invalid date + if (isNaN(dateTested.getTime())) { + return "Test date is invalid. Please enter using the format MM/DD/YYYY."; + } + if (state.dateTested && dateTested < EARLIEST_TEST_DATE) { + return `Test date must be after ${moment(EARLIEST_TEST_DATE).format( + "MM/DD/YYYY" + )}.`; + } + if (state.dateTested && dateTested > new Date()) { + return "Test date can't be in the future."; + } + return ""; + }; + + const validateTestResults = () => { + if (deviceSupportsMultiplex) { + const multiplexResults = convertFromMultiplexResultInputs( + state.testResults + ); + return validateMultiplexResultState( + multiplexResults, + state.deviceId, + devicesMap + ); + } + return validateCovidResultInput(state.testResults); + }; + + // derived state, not expensive to calculate every render and avoids unnecessary tracked state + const dateTestedError = validateDateTested(); + const deviceTypeError = deviceTypeIsInvalid ? "Please select a device." : ""; + const specimenTypeError = specimenTypeIsInvalid + ? "Please select a specimen type." + : ""; + const testResultsError = validateTestResults(); + + const showDateTestedError = dateTestedError.length > 0; + + const showTestResultsError = + hasAttemptedSubmit && testResultsError.length > 0; + + const isCorrection = testOrder.correctionStatus === "CORRECTED"; + const reasonForCorrection = + testOrder.reasonForCorrection as TestCorrectionReason; + + const showDateMonthsAgoWarning = + moment(state.dateTested) < moment().subtract(6, "months") && + dateTestedError.length === 0; + + const validateForm = () => { + if (dateTestedError) { + showError(dateTestedError, "Invalid test date"); + } + if (deviceTypeError) { + showError(deviceTypeError, "Invalid test device"); + } + if (specimenTypeError) { + showError(specimenTypeError, "Invalid specimen type"); + } + if (testResultsError) { + showError(testResultsError, "Invalid test results"); + } + return ( + !dateTestedError && + !deviceTypeError && + !specimenTypeError && + !testResultsError + ); + }; + + const submitForm = async (forceSubmit: boolean = false) => { + setHasAttemptedSubmit(true); + if (!validateForm()) { + return; + } + + if (!forceSubmit && !areAOEAnswersComplete(state, whichAOEFormOption)) { + submitModalRef.current?.toggleModal(); + return; + } + + trackSubmitTestResult(); + + const result = await submitTestResult({ + variables: { + patientId: testOrder.patient?.internalId, + deviceTypeId: state.deviceId, + specimenTypeId: state.specimenId, + dateTested: state.dateTested, + results: doesDeviceSupportMultiplex(state.deviceId, devicesMap) + ? state.testResults + : state.testResults.filter( + (result) => result.diseaseName === MULTIPLEX_DISEASES.COVID_19 + ), + }, + }); + showTestResultDeliveryStatusAlert( + result.data?.submitQueueItem?.deliverySuccess, + testOrder.patient + ); + if (startTestPatientId === testOrder.patient.internalId) { + setStartTestPatientId(null); + } + removeTimer(testOrder.internalId); + refetchQueue(); + }; + + return ( + <> + + +
    + {/* error and warning alerts */} + {isCorrection && ( + + Correction: + {reasonForCorrection in TestCorrectionReasons + ? TestCorrectionReasons[reasonForCorrection] + : reasonForCorrection} + + )} + {showDateMonthsAgoWarning && ( + + Check test date: The date you selected is more than + six months ago. Please make sure it's correct before submitting. + + )} + {showTestResultsError && ( + + {testResultsError} + + )} + {!useCurrentTime && ( +
    +
    + { + if (e.target.value) { + dispatch({ + type: TestFormActionCase.UPDATE_DATE_TESTED, + payload: e.target.value, + }); + } + }} + required={true} + validationStatus={showDateTestedError ? "error" : undefined} + errorMessage={showDateTestedError && dateTestedError} + > +
    +
    + { + if (e.target.value) { + dispatch({ + type: TestFormActionCase.UPDATE_TIME_TESTED, + payload: e.target.value, + }); + } + }} + validationStatus={showDateTestedError ? "error" : undefined} + > +
    +
    + )} +
    +
    + {useCurrentTime && ( + + )} + { + setUseCurrentTime(e.target.checked); + dispatch({ + type: TestFormActionCase.UPDATE_DATE_TESTED, + payload: e.target.checked ? "" : moment().toISOString(), + }); + }} + > +
    +
    +
    +
    + + } + name="testDevice" + ariaLabel="Test device" + selectedValue={state.deviceId} + onChange={(e) => + dispatch({ + type: TestFormActionCase.UPDATE_DEVICE_ID, + payload: e.target.value, + }) + } + className="card-dropdown" + data-testid="device-type-dropdown" + errorMessage={deviceTypeError} + validationStatus={deviceTypeIsInvalid ? "error" : undefined} + required={true} + /> +
    +
    + + dispatch({ + type: TestFormActionCase.UPDATE_SPECIMEN_ID, + payload: e.target.value, + }) + } + className="card-dropdown" + data-testid="specimen-type-dropdown" + disabled={specimenTypeOptions.length === 0} + errorMessage={specimenTypeError} + validationStatus={specimenTypeIsInvalid ? "error" : undefined} + required={true} + /> +
    +
    +
    + + {deviceSupportsMultiplex ? ( + + dispatch({ + type: TestFormActionCase.UPDATE_TEST_RESULT, + payload: results, + }) + } + > + ) : ( + + dispatch({ + type: TestFormActionCase.UPDATE_TEST_RESULT, + payload: results, + }) + } + /> + )} + +
    + {whichAOEFormOption === AOEFormOption.COVID && ( +
    + { + dispatch({ + type: TestFormActionCase.UPDATE_COVID_AOE_RESPONSES, + payload: responses, + }); + }} + /> +
    + )} +
    +
    + +
    +
    +
    + + ); +}; + +export default TestCardForm; diff --git a/frontend/src/app/testQueue/TestCardForm/TestCardForm.utils.tsx b/frontend/src/app/testQueue/TestCardForm/TestCardForm.utils.tsx new file mode 100644 index 0000000000..99e3e93f9a --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/TestCardForm.utils.tsx @@ -0,0 +1,228 @@ +import moment from "moment/moment"; +import { useFeature } from "flagged"; + +import { DevicesMap, QueriedFacility, QueriedTestOrder } from "../QueueItem"; +import { displayFullName } from "../../utils"; +import { MULTIPLEX_DISEASES } from "../../testResults/constants"; +import { MultiplexResultInput } from "../../../generated/graphql"; +import { getAppInsights } from "../../TelemetryService"; +import { + ALERT_CONTENT, + QUEUE_NOTIFICATION_TYPES, + SomeoneWithName, +} from "../constants"; +import { showError, showSuccess } from "../../utils/srToast"; +import { filterRsvFromAllDevices } from "../../utils/rsvHelper"; + +import { TestFormState } from "./TestCardFormReducer"; +import { parseSymptoms } from "./diseaseSpecificComponents/CovidAoEForm"; + +/** Add more options as other disease AOEs are needed */ +export enum AOEFormOption { + COVID = "COVID", + NONE = "NONE", +} + +export function useTestOrderPatient(testOrder: QueriedTestOrder) { + const patientFullName = displayFullName( + testOrder.patient.firstName, + testOrder.patient.middleName, + testOrder.patient.lastName + ); + + const patientDateOfBirth = moment(testOrder.patient.birthDate); + + return { patientFullName, patientDateOfBirth }; +} + +export function useDeviceTypeOptions( + facility: QueriedFacility, + state: TestFormState +) { + const singleEntryRsvEnabled = useFeature("singleEntryRsvEnabled"); + + let deviceTypes = [...facility!.deviceTypes]; + if (!singleEntryRsvEnabled) { + deviceTypes = filterRsvFromAllDevices(deviceTypes); + } + + let deviceTypeOptions = [...deviceTypes].sort(alphabetizeByName).map((d) => ({ + label: d.name, + value: d.internalId, + })); + + const deviceTypeIsInvalid = !state.devicesMap.has(state.deviceId); + + if (state.deviceId && deviceTypeIsInvalid) { + // this adds an empty option for when the device has been deleted from the facility, but it's on the test order + deviceTypeOptions = [{ label: "", value: "" }, ...deviceTypeOptions]; + } + return { deviceTypeOptions, deviceTypeIsInvalid }; +} + +export function useSpecimenTypeOptions(state: TestFormState) { + let specimenTypeOptions = + state.deviceId && state.devicesMap.has(state.deviceId) + ? [...state.devicesMap.get(state.deviceId)!.swabTypes] + .sort(alphabetizeByName) + .map((s: SpecimenType) => ({ + label: s.name, + value: s.internalId, + })) + : []; + + const specimenTypeIsInvalid = + state.devicesMap.has(state.deviceId) && + state.devicesMap + .get(state.deviceId)! + .swabTypes.filter((s) => s.internalId === state.specimenId).length === 0; + + if (specimenTypeIsInvalid) { + // this adds an empty option for when the specimen has been deleted from the device, but it's on the test order + specimenTypeOptions = [{ label: "", value: "" }, ...specimenTypeOptions]; + } + return { specimenTypeOptions, specimenTypeIsInvalid }; +} + +export function useAppInsightTestCardEvents() { + const appInsights = getAppInsights(); + const trackSubmitTestResult = () => { + if (appInsights) { + appInsights.trackEvent({ name: "Submit Test Result" }); + } + }; + const trackUpdateAoEResponse = () => { + if (appInsights) { + appInsights.trackEvent({ name: "Update AoE Response" }); + } + }; + return { trackSubmitTestResult, trackUpdateAoEResponse }; +} + +export function alphabetizeByName( + a: DeviceType | SpecimenType, + b: DeviceType | SpecimenType +): number { + if (a.name < b.name) { + return -1; + } + + if (a.name > b.name) { + return 1; + } + + return 0; +} + +export const doesDeviceSupportMultiplex = ( + deviceId: string, + devicesMap: DevicesMap +) => { + if (devicesMap.has(deviceId)) { + return ( + devicesMap + .get(deviceId)! + .supportedDiseaseTestPerformed.filter( + (disease) => + disease.supportedDisease.name !== MULTIPLEX_DISEASES.COVID_19 + ).length > 0 + ); + } + return false; +}; + +export const isDeviceFluOnly = (deviceId: string, devicesMap: DevicesMap) => { + if (devicesMap.has(deviceId)) { + const supportedDiseaseTests = + devicesMap.get(deviceId)?.supportedDiseaseTestPerformed ?? []; + + return ( + supportedDiseaseTests.length > 0 && + supportedDiseaseTests.every( + (disease) => + disease.supportedDisease.name === MULTIPLEX_DISEASES.FLU_A || + disease.supportedDisease.name === MULTIPLEX_DISEASES.FLU_B + ) + ); + } + return false; +}; + +export const hasAnySupportedDiseaseTests = ( + deviceId: string, + devicesMap: DevicesMap +) => { + if (devicesMap.has(deviceId)) { + const supportedDiseaseTests = + devicesMap.get(deviceId)?.supportedDiseaseTestPerformed ?? []; + return supportedDiseaseTests.length > 0; + } + return false; +}; + +// when other diseases are added, update this to use the correct AOE for that disease +export const useAOEFormOption = (deviceId: string, devicesMap: DevicesMap) => { + // some devices don't have any supported disease tests saved because historically they only supported COVID + // this is often seen in some of the dev environments + if (!hasAnySupportedDiseaseTests(deviceId, devicesMap)) { + return AOEFormOption.COVID; + } + return isDeviceFluOnly(deviceId, devicesMap) + ? AOEFormOption.NONE + : AOEFormOption.COVID; +}; + +export const convertFromMultiplexResponse = ( + responseResult: QueriedTestOrder["results"] +): MultiplexResultInput[] => { + return responseResult.map((result) => ({ + diseaseName: result.disease?.name, + testResult: result.testResult, + })); +}; + +export const areAOEAnswersComplete = ( + formState: TestFormState, + whichAOE: AOEFormOption +) => { + if (whichAOE === AOEFormOption.COVID) { + const isPregnancyAnswered = !!formState.covidAOEResponses.pregnancy; + const hasNoSymptoms = formState.covidAOEResponses.noSymptoms; + if (formState.covidAOEResponses.noSymptoms === false) { + const symptoms = parseSymptoms(formState.covidAOEResponses.symptoms); + const areSymptomsFilledIn = Object.values(symptoms).some((x) => + x.valueOf() + ); + const isSymptomOnsetDateAnswered = + !!formState.covidAOEResponses.symptomOnset; + return ( + isPregnancyAnswered && + !hasNoSymptoms && + areSymptomsFilledIn && + isSymptomOnsetDateAnswered + ); + } + return isPregnancyAnswered && hasNoSymptoms; + } + return true; +}; + +export const showTestResultDeliveryStatusAlert = ( + deliverySuccess: boolean | null | undefined, + patient: SomeoneWithName +) => { + if (deliverySuccess === false) { + const { title, body } = { + ...ALERT_CONTENT[ + QUEUE_NOTIFICATION_TYPES.DELIVERED_RESULT_TO_PATIENT__FAILURE + ](patient), + }; + return showError(body, title); + } + const { title, body } = { + ...ALERT_CONTENT[QUEUE_NOTIFICATION_TYPES.SUBMITTED_RESULT__SUCCESS]( + patient + ), + }; + showSuccess(body, title); +}; diff --git a/frontend/src/app/testQueue/TestCardForm/TestCardFormReducer.tsx b/frontend/src/app/testQueue/TestCardForm/TestCardFormReducer.tsx new file mode 100644 index 0000000000..60be97b00d --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/TestCardFormReducer.tsx @@ -0,0 +1,188 @@ +import moment from "moment/moment"; +import { isEqual, sortBy } from "lodash"; + +import { PregnancyCode } from "../../../patientApp/timeOfTest/constants"; +import { MultiplexResultInput } from "../../../generated/graphql"; +import { DevicesMap, QueriedTestOrder } from "../QueueItem"; + +import { convertFromMultiplexResponse } from "./TestCardForm.utils"; +import { parseSymptoms } from "./diseaseSpecificComponents/CovidAoEForm"; + +export interface TestFormState { + dateTested: string; + dirty: boolean; + deviceId: string; + devicesMap: DevicesMap; + specimenId: string; + testResults: MultiplexResultInput[]; + covidAOEResponses: CovidAoeQuestionResponses; +} + +export interface CovidAoeQuestionResponses { + pregnancy?: PregnancyCode; + noSymptoms?: boolean | null; + symptoms?: string | null; + symptomOnset?: string; +} + +export enum TestFormActionCase { + UPDATE_DATE_TESTED = "UPDATE_DATE_TESTED", + UPDATE_TIME_TESTED = "UPDATE_TIME_TESTED", + UPDATE_DEVICE_ID = "UPDATE_DEVICE_ID", + UPDATE_DEVICES_MAP = "UPDATE_DEVICES_MAP", + UPDATE_SPECIMEN_ID = "UPDATE_SPECIMEN_ID", + UPDATE_TEST_RESULT = "UPDATE_TEST_RESULT", + UPDATE_COVID_AOE_RESPONSES = "UPDATE_COVID_AOE_RESPONSES", + UPDATE_DIRTY_STATE = "UPDATE_DIRTY_STATE", + UPDATE_WITH_CHANGES_FROM_SERVER = "UPDATE_WITH_CHANGES_FROM_SERVER", +} + +export type TestFormAction = + | { type: TestFormActionCase.UPDATE_DATE_TESTED; payload: string } + | { type: TestFormActionCase.UPDATE_TIME_TESTED; payload: string } + | { + type: TestFormActionCase.UPDATE_DEVICE_ID; + payload: string; + } + | { + type: TestFormActionCase.UPDATE_DEVICES_MAP; + payload: DevicesMap; + } + | { type: TestFormActionCase.UPDATE_SPECIMEN_ID; payload: string } + | { + type: TestFormActionCase.UPDATE_TEST_RESULT; + payload: MultiplexResultInput[]; + } + | { + type: TestFormActionCase.UPDATE_COVID_AOE_RESPONSES; + payload: CovidAoeQuestionResponses; + } + | { + type: TestFormActionCase.UPDATE_DIRTY_STATE; + payload: boolean; + } + | { + type: TestFormActionCase.UPDATE_WITH_CHANGES_FROM_SERVER; + payload: QueriedTestOrder; + }; + +export const testCardFormReducer = ( + prevState: TestFormState, + { type, payload }: TestFormAction +): TestFormState => { + switch (type) { + case TestFormActionCase.UPDATE_DATE_TESTED: { + // the date string returned from the server is only precise to seconds; moment's + // toISOString method returns millisecond precision. as a result, an onChange event + // was being fired when this component initialized, sending an EditQueueItem to + // the back end w/ the same data that it already had. this prevents it: + if (!moment(prevState.dateTested).isSame(payload)) { + const newDate = moment(payload); + if (prevState.dateTested) { + const prevDateTested = moment(prevState.dateTested); + newDate.hour(prevDateTested.hours()); + newDate.minute(prevDateTested.minutes()); + } + return { + ...prevState, + dateTested: newDate.toISOString(), + dirty: true, + }; + } + break; + } + case TestFormActionCase.UPDATE_TIME_TESTED: { + if (payload) { + const [hours, minutes] = payload.split(":"); + const newDate = moment(prevState.dateTested) + .hours(parseInt(hours)) + .minutes(parseInt(minutes)); + return { + ...prevState, + dateTested: newDate.toISOString(), + dirty: true, + }; + } + break; + } + case TestFormActionCase.UPDATE_DEVICE_ID: { + return { + ...prevState, + deviceId: payload, + specimenId: + prevState.devicesMap.get(payload)?.swabTypes[0].internalId ?? + prevState.specimenId, + dirty: true, + }; + } + case TestFormActionCase.UPDATE_SPECIMEN_ID: { + return { + ...prevState, + specimenId: payload, + dirty: true, + }; + } + case TestFormActionCase.UPDATE_TEST_RESULT: { + return { + ...prevState, + testResults: payload, + dirty: true, + }; + } + case TestFormActionCase.UPDATE_COVID_AOE_RESPONSES: { + return { + ...prevState, + dirty: true, + covidAOEResponses: payload, + }; + } + case TestFormActionCase.UPDATE_DIRTY_STATE: { + return { + ...prevState, + dirty: payload, + }; + } + case TestFormActionCase.UPDATE_WITH_CHANGES_FROM_SERVER: { + const resultState = { ...prevState, dirty: false }; + + if (prevState.deviceId !== payload.deviceType.internalId) { + resultState.deviceId = payload.deviceType.internalId; + } + if (prevState.specimenId !== payload.specimenType.internalId) { + resultState.specimenId = payload.specimenType.internalId; + } + if (prevState.dateTested !== payload.dateTested) { + resultState.dateTested = payload.dateTested; + } + const updatedResults = convertFromMultiplexResponse(payload.results); + // We need to sort before comparing because the order of results within the array does not always match + // and the deep comparison would then return false even if individual test results are the same + if ( + !isEqual( + sortBy(prevState.testResults, "diseaseName"), + sortBy(updatedResults, "diseaseName") + ) + ) { + resultState.testResults = updatedResults; + } + const aoeAnswers = { + noSymptoms: payload.noSymptoms, + symptoms: JSON.stringify(parseSymptoms(payload.symptoms)), + symptomOnset: payload.symptomOnset, + pregnancy: payload.pregnancy, + } as CovidAoeQuestionResponses; + if (!isEqual(aoeAnswers, prevState.covidAOEResponses)) { + resultState.covidAOEResponses = aoeAnswers; + } + + return resultState; + } + case TestFormActionCase.UPDATE_DEVICES_MAP: { + return { + ...prevState, + devicesMap: payload, + }; + } + } + throw Error("Unknown action: " + type); +}; diff --git a/frontend/src/app/testQueue/TestCardForm/__snapshots__/TestCardForm.test.tsx.snap b/frontend/src/app/testQueue/TestCardForm/__snapshots__/TestCardForm.test.tsx.snap new file mode 100644 index 0000000000..68185012f3 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/__snapshots__/TestCardForm.test.tsx.snap @@ -0,0 +1,3782 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TestCardForm initial state matches snapshot for covid device 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient pregnant? + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient currently experiencing any symptoms? + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + , + "container":
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient pregnant? + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient currently experiencing any symptoms? + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    , + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; + +exports[`TestCardForm initial state matches snapshot for flu device 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + , + "container":
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    , + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; + +exports[`TestCardForm initial state matches snapshot for multiplex device 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient pregnant? + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient currently experiencing any symptoms? + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + , + "container":
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient pregnant? + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient currently experiencing any symptoms? + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    , + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; diff --git a/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidAoEForm.tsx b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidAoEForm.tsx new file mode 100644 index 0000000000..ab24c6b389 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidAoEForm.tsx @@ -0,0 +1,156 @@ +import moment from "moment/moment"; +import React from "react"; + +import RadioGroup from "../../../commonComponents/RadioGroup"; +import YesNoRadioGroup from "../../../commonComponents/YesNoRadioGroup"; +import TextInput from "../../../commonComponents/TextInput"; +import { formatDate } from "../../../utils/date"; +import Checkboxes from "../../../commonComponents/Checkboxes"; +import { + getPregnancyResponses, + globalSymptomDefinitions, + PregnancyCode, +} from "../../../../patientApp/timeOfTest/constants"; +import { QueriedTestOrder } from "../../QueueItem"; +import { CovidAoeQuestionResponses } from "../TestCardFormReducer"; + +export interface CovidAoEFormProps { + testOrder: QueriedTestOrder; + responses: CovidAoeQuestionResponses; + onResponseChange: (responses: CovidAoeQuestionResponses) => void; +} + +const pregnancyResponses = getPregnancyResponses(); + +export const parseSymptoms = ( + symptomsJsonString: string | null | undefined +) => { + const symptoms: Record = {}; + if (symptomsJsonString) { + const parsedSymptoms: { [key: string]: string | boolean } = + JSON.parse(symptomsJsonString); + + globalSymptomDefinitions.forEach((opt) => { + const val = opt.value; + if (typeof parsedSymptoms[val] === "string") { + symptoms[val] = parsedSymptoms[val] === "true"; + } else { + symptoms[val] = parsedSymptoms[val] as boolean; + } + }); + } else { + globalSymptomDefinitions.forEach((opt) => { + symptoms[opt.value] = false; + }); + } + return symptoms; +}; + +const CovidAoEForm = ({ + testOrder, + responses, + onResponseChange, +}: CovidAoEFormProps) => { + const symptoms: Record = parseSymptoms(responses.symptoms); + + const onPregnancyChange = (pregnancyCode: PregnancyCode) => { + onResponseChange({ ...responses, pregnancy: pregnancyCode }); + }; + + const onHasAnySymptomsChange = (hasAnySymptoms: YesNo) => { + onResponseChange({ + ...responses, + noSymptoms: hasAnySymptoms === "NO", + }); + }; + + const onSymptomOnsetDateChange = (symptomOnsetDate: string) => { + onResponseChange({ + ...responses, + symptomOnset: moment(symptomOnsetDate).format("YYYY-MM-DD"), + }); + }; + + const onSymptomsChange = ( + event: React.ChangeEvent, + currentSymptoms: Record + ) => { + onResponseChange({ + ...responses, + symptoms: JSON.stringify({ + ...currentSymptoms, + [event.target.value]: event.target.checked, + }), + }); + }; + + // backend currently stores this in "noSymptoms" + // so we need to convert to YesNo or undefined + let hasSymptoms: YesNo | undefined = undefined; + if (responses.noSymptoms) { + hasSymptoms = "NO"; + } + if (responses.noSymptoms === false) { + hasSymptoms = "YES"; + } + + return ( +
    +
    +
    + +
    +
    +
    +
    + +
    +
    + {hasSymptoms === "YES" && ( + <> +
    + onSymptomOnsetDateChange(e.target.value)} + > +
    +
    + ({ + label, + value, + checked: symptoms[value], + }))} + legend="Select any symptoms the patient is experiencing" + name={`symptoms-${testOrder.internalId}`} + onChange={(e) => onSymptomsChange(e, symptoms)} + /> +
    + + )} +
    + ); +}; + +export default CovidAoEForm; diff --git a/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidResultInputGroup.test.tsx b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidResultInputGroup.test.tsx new file mode 100644 index 0000000000..ae87e737ae --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidResultInputGroup.test.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; +import { within } from "@storybook/testing-library"; + +import CovidResultInputGroup from "./CovidResultInputGroup"; + +describe("CovidResultInputGroup", () => { + const onChangeMock = jest.fn(); + const queueItemId = "QUEUE-ITEM-ID"; + + async function renderCovidResultInputGroup() { + jest.spyOn(global.Math, "random").mockReturnValue(1); + + const { container } = render( + + ); + return { container, user: userEvent.setup() }; + } + + it("matches snapshot", async () => { + expect(await renderCovidResultInputGroup()).toMatchSnapshot(); + }); + + it("calls onChange when result selected", async () => { + const { user } = await renderCovidResultInputGroup(); + + // selecting a negative covid result + onChangeMock.mockReset(); + await user.click( + within( + screen.getByTestId(`covid-test-result-${queueItemId}`) + ).getByLabelText("Negative (-)") + ); + expect(onChangeMock).toHaveBeenCalledWith([ + { diseaseName: "COVID-19", testResult: "NEGATIVE" }, + ]); + + // selecting an inconclusive covid result + onChangeMock.mockReset(); + await user.click( + within( + screen.getByTestId(`covid-test-result-${queueItemId}`) + ).getByLabelText("Inconclusive") + ); + expect(onChangeMock).toHaveBeenCalledWith([ + { diseaseName: "COVID-19", testResult: "UNDETERMINED" }, + ]); + + // selecting a positive covid result + onChangeMock.mockReset(); + await user.click( + within( + screen.getByTestId(`covid-test-result-${queueItemId}`) + ).getByLabelText("Positive (+)") + ); + expect(onChangeMock).toHaveBeenCalledWith([ + { diseaseName: "COVID-19", testResult: "POSITIVE" }, + ]); + }); +}); diff --git a/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidResultInputGroup.tsx b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidResultInputGroup.tsx new file mode 100644 index 0000000000..2286a00cc2 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/CovidResultInputGroup.tsx @@ -0,0 +1,97 @@ +import React from "react"; + +import { + MULTIPLEX_DISEASES, + TEST_RESULTS, +} from "../../../testResults/constants"; +import { findResultByDiseaseName } from "../../QueueItem"; +import { MultiplexResultInput } from "../../../../generated/graphql"; +import RadioGroup from "../../../commonComponents/RadioGroup"; +import { COVID_RESULTS, TEST_RESULT_DESCRIPTIONS } from "../../../constants"; + +interface CovidResult extends MultiplexResultInput { + diseaseName: MULTIPLEX_DISEASES.COVID_19; + testResult: TestResult; +} + +const convertFromMultiplexResultInputs = ( + multiplexResultInputs: MultiplexResultInput[] +): TestResult => { + return ( + (findResultByDiseaseName( + multiplexResultInputs ?? [], + MULTIPLEX_DISEASES.COVID_19 + ) as TestResult) ?? TEST_RESULTS.UNKNOWN + ); +}; + +const convertFromCovidResult = (covidResult: TestResult): CovidResult[] => { + const covidResults: CovidResult[] = [ + { + diseaseName: MULTIPLEX_DISEASES.COVID_19, + testResult: covidResult, + }, + ]; + + return covidResults.filter( + (result) => result.testResult !== TEST_RESULTS.UNKNOWN + ); +}; + +export const validateCovidResultInput = ( + testResults: MultiplexResultInput[] +) => { + const resultCovidFormat = convertFromMultiplexResultInputs(testResults); + if (resultCovidFormat === TEST_RESULTS.UNKNOWN) { + return "Please enter a COVID-19 test result."; + } + return ""; +}; + +interface Props { + queueItemId: string; + testResults: MultiplexResultInput[]; + onChange: (value: CovidResult[]) => void; +} + +const CovidResultInputGroup: React.FC = ({ + queueItemId, + testResults, + onChange, +}) => { + const resultCovidFormat = convertFromMultiplexResultInputs(testResults); + + const convertAndSendResults = (covidResult: TestResult) => { + const results = convertFromCovidResult(covidResult); + onChange(results); + }; + + return ( + { + convertAndSendResults(value); + }} + buttons={[ + { + value: COVID_RESULTS.POSITIVE, + label: `${TEST_RESULT_DESCRIPTIONS.POSITIVE} (+)`, + }, + { + value: COVID_RESULTS.NEGATIVE, + label: `${TEST_RESULT_DESCRIPTIONS.NEGATIVE} (-)`, + }, + { + value: COVID_RESULTS.INCONCLUSIVE, + label: `${TEST_RESULT_DESCRIPTIONS.UNDETERMINED}`, + }, + ]} + name={`covid-test-result-${queueItemId}`} + selectedRadio={resultCovidFormat} + wrapperClassName="prime-radio__group" + required + /> + ); +}; + +export default CovidResultInputGroup; diff --git a/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/MultiplexResultInputGroup.test.tsx b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/MultiplexResultInputGroup.test.tsx new file mode 100644 index 0000000000..5deef41ca0 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/MultiplexResultInputGroup.test.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; +import { within } from "@storybook/testing-library"; + +import mockSupportedDiseaseCovid from "../../mocks/mockSupportedDiseaseCovid"; +import { mockSupportedDiseaseFlu } from "../../mocks/mockSupportedDiseaseMultiplex"; + +import MultiplexResultInputGroup from "./MultiplexResultInputGroup"; + +describe("MultiplexResultInputGroup", () => { + const onChangeMock = jest.fn(); + const queueItemId = "QUEUE-ITEM-ID"; + + const specimen1Name = "Swab of internal nose"; + const specimen1Id = "SPECIMEN-1-ID"; + const specimen2Name = "Nasopharyngeal swab"; + const specimen2Id = "SPECIMEN-2-ID"; + const multiplexDeviceId = "DEVICE-1-ID"; + const fluOnlyDeviceId = "DEVICE-2-ID"; + + const positiveTestResults = [ + { diseaseName: "COVID-19", testResult: "POSITIVE" }, + { diseaseName: "Flu A", testResult: "POSITIVE" }, + { diseaseName: "Flu B", testResult: "POSITIVE" }, + ]; + + const negativeTestResults = [ + { diseaseName: "COVID-19", testResult: "NEGATIVE" }, + { diseaseName: "Flu A", testResult: "NEGATIVE" }, + { diseaseName: "Flu B", testResult: "NEGATIVE" }, + ]; + + const inconclusiveTestResults = [ + { diseaseName: "COVID-19", testResult: "UNDETERMINED" }, + { diseaseName: "Flu A", testResult: "UNDETERMINED" }, + { diseaseName: "Flu B", testResult: "UNDETERMINED" }, + ]; + + const deviceTypes = [ + { + internalId: multiplexDeviceId, + name: "LumiraDX", + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseCovid, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + { + name: specimen2Name, + internalId: specimen2Id, + typeCode: "258500001", + }, + ], + }, + { + internalId: fluOnlyDeviceId, + name: "Abbott BinaxNow", + testLength: 15, + supportedDiseaseTestPerformed: mockSupportedDiseaseFlu, + swabTypes: [ + { + name: specimen1Name, + internalId: specimen1Id, + typeCode: "445297001", + }, + ], + }, + ]; + + const devicesMap = new Map(); + deviceTypes.map((d) => devicesMap.set(d.internalId, d)); + + async function renderMultiplexResultInputGroup( + deviceId?: string, + testResults?: any + ) { + jest.spyOn(global.Math, "random").mockReturnValue(1); + + const { container } = render( + + ); + return { container, user: userEvent.setup() }; + } + + describe("initial state snapshots", () => { + it("matches positive results snapshot", async () => { + expect( + await renderMultiplexResultInputGroup( + multiplexDeviceId, + positiveTestResults + ) + ).toMatchSnapshot(); + }); + + it("matches negative result snapshot", async () => { + expect( + await renderMultiplexResultInputGroup( + multiplexDeviceId, + negativeTestResults + ) + ).toMatchSnapshot(); + }); + + it("matches inconclusive result snapshot", async () => { + expect( + await renderMultiplexResultInputGroup( + multiplexDeviceId, + inconclusiveTestResults + ) + ).toMatchSnapshot(); + }); + + it("matches flu only snapshot", async () => { + expect( + await renderMultiplexResultInputGroup(fluOnlyDeviceId, [ + { diseaseName: "Flu A", testResult: "UNDETERMINED" }, + { diseaseName: "Flu B", testResult: "UNDETERMINED" }, + ]) + ).toMatchSnapshot(); + }); + }); + + it("calls onChange when result selected", async () => { + const { user } = await renderMultiplexResultInputGroup( + multiplexDeviceId, + positiveTestResults + ); + + // selecting a negative covid result + onChangeMock.mockReset(); + await user.click( + within( + screen.getByTestId(`covid-test-result-${queueItemId}`) + ).getByLabelText("Negative (-)") + ); + expect(onChangeMock).toHaveBeenCalledWith([ + { diseaseName: "COVID-19", testResult: "NEGATIVE" }, + { diseaseName: "Flu A", testResult: "POSITIVE" }, + { diseaseName: "Flu B", testResult: "POSITIVE" }, + ]); + + // selecting a negative flu a result + onChangeMock.mockReset(); + await user.click( + within( + screen.getByTestId(`flu-a-test-result-${queueItemId}`) + ).getByLabelText("Negative (-)") + ); + expect(onChangeMock).toHaveBeenCalledWith([ + { diseaseName: "COVID-19", testResult: "NEGATIVE" }, + { diseaseName: "Flu A", testResult: "NEGATIVE" }, + { diseaseName: "Flu B", testResult: "POSITIVE" }, + ]); + + // selecting a negative flu b result + onChangeMock.mockReset(); + await user.click( + within( + screen.getByTestId(`flu-b-test-result-${queueItemId}`) + ).getByLabelText("Negative (-)") + ); + expect(onChangeMock).toHaveBeenCalledWith([ + { diseaseName: "COVID-19", testResult: "NEGATIVE" }, + { diseaseName: "Flu A", testResult: "NEGATIVE" }, + { diseaseName: "Flu B", testResult: "NEGATIVE" }, + ]); + + // selecting an inconclusive result + onChangeMock.mockReset(); + await user.click(screen.getByLabelText("Mark test as inconclusive")); + + expect(onChangeMock).toHaveBeenCalledWith([ + { diseaseName: "COVID-19", testResult: "UNDETERMINED" }, + { diseaseName: "Flu A", testResult: "UNDETERMINED" }, + { diseaseName: "Flu B", testResult: "UNDETERMINED" }, + ]); + }); + + it("doesnt show covid result input with flu only device", async () => { + await renderMultiplexResultInputGroup(fluOnlyDeviceId); + + expect( + screen.queryByTestId(`covid-test-result-${queueItemId}`) + ).not.toBeInTheDocument(); + expect( + screen.getByTestId(`flu-a-test-result-${queueItemId}`) + ).toBeInTheDocument(); + expect( + screen.getByTestId(`flu-b-test-result-${queueItemId}`) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/MultiplexResultInputGroup.tsx b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/MultiplexResultInputGroup.tsx new file mode 100644 index 0000000000..2f3df56293 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/MultiplexResultInputGroup.tsx @@ -0,0 +1,393 @@ +import React from "react"; + +import RadioGroup from "../../../commonComponents/RadioGroup"; +import { COVID_RESULTS, TEST_RESULT_DESCRIPTIONS } from "../../../constants"; +import { DevicesMap, findResultByDiseaseName } from "../../QueueItem"; +import { TextWithTooltip } from "../../../commonComponents/TextWithTooltip"; +import Checkboxes from "../../../commonComponents/Checkboxes"; +import { MultiplexResultInput } from "../../../../generated/graphql"; +import { + MULTIPLEX_DISEASES, + TEST_RESULTS, +} from "../../../testResults/constants"; +import { isDeviceFluOnly } from "../TestCardForm.utils"; + +const MULTIPLEX_DISEASE_TYPE = { + COVID: MULTIPLEX_DISEASES.COVID_19 as MultiplexDisease, + FLU_A: MULTIPLEX_DISEASES.FLU_A as MultiplexDisease, + FLU_B: MULTIPLEX_DISEASES.FLU_B as MultiplexDisease, + ALL: "All", +}; + +interface MultiplexResult { + diseaseName: MultiplexDisease; + testResult: TestResult; +} + +interface MultiplexResultState { + covid: TestResult; + fluA: TestResult; + fluB: TestResult; +} + +export const convertFromMultiplexResultInputs = ( + diseaseResults: MultiplexResultInput[] +): MultiplexResultState => { + return { + covid: + (findResultByDiseaseName( + diseaseResults ?? [], + MULTIPLEX_DISEASES.COVID_19 + ) as TestResult) ?? TEST_RESULTS.UNKNOWN, + fluA: + (findResultByDiseaseName( + diseaseResults ?? [], + MULTIPLEX_DISEASES.FLU_A + ) as TestResult) ?? TEST_RESULTS.UNKNOWN, + fluB: + (findResultByDiseaseName( + diseaseResults ?? [], + MULTIPLEX_DISEASES.FLU_B + ) as TestResult) ?? TEST_RESULTS.UNKNOWN, + }; +}; + +const convertFromMultiplexResult = ( + multiplexResult: MultiplexResultState +): MultiplexResult[] => { + const diseaseResults: MultiplexResult[] = [ + { + diseaseName: MULTIPLEX_DISEASE_TYPE.COVID, + testResult: multiplexResult.covid, + }, + { + diseaseName: MULTIPLEX_DISEASE_TYPE.FLU_A, + testResult: multiplexResult.fluA, + }, + { + diseaseName: MULTIPLEX_DISEASE_TYPE.FLU_B, + testResult: multiplexResult.fluB, + }, + ]; + + return diseaseResults.filter( + (result) => result.testResult !== TEST_RESULTS.UNKNOWN + ); +}; + +const doesDeviceSupportMultiplexAndCovidOnlyResult = ( + deviceId: string, + devicesMap: DevicesMap +) => { + if (devicesMap.has(deviceId)) { + const deviceTypeCovidDiseases = devicesMap + .get(deviceId)! + .supportedDiseaseTestPerformed.filter( + (disease) => + disease.supportedDisease.name === MULTIPLEX_DISEASES.COVID_19 + ); + + if (deviceTypeCovidDiseases.length >= 1) { + const testPerformedLoincs = [ + ...new Set( + deviceTypeCovidDiseases.map((value) => value.testPerformedLoincCode) + ), + ].filter((item): item is string => !!item); + const testOrderedLoincs = [ + ...new Set( + deviceTypeCovidDiseases.map((value) => value.testOrderedLoincCode) + ), + ].filter((item): item is string => !!item); + const hasSingleCovidTestPerformedLoinc = testPerformedLoincs.length === 1; + const hasMultipleCovidTestOrderedLoincs = testOrderedLoincs.length > 1; + return ( + hasSingleCovidTestPerformedLoinc && hasMultipleCovidTestOrderedLoincs + ); + } + } + return false; +}; + +const isCovidFilled = (results: MultiplexResultState) => + results.covid === TEST_RESULTS.POSITIVE || + results.covid === TEST_RESULTS.NEGATIVE; + +const isFluAFilled = (results: MultiplexResultState) => + results.fluA === TEST_RESULTS.POSITIVE || + results.fluA === TEST_RESULTS.NEGATIVE; + +const isFluBFilled = (results: MultiplexResultState) => + results.fluB === TEST_RESULTS.POSITIVE || + results.fluB === TEST_RESULTS.NEGATIVE; + +export const validateMultiplexResultState = ( + resultsMultiplexFormat: MultiplexResultState, + deviceId: string, + devicesMap: DevicesMap +) => { + const deviceSupportsCovidOnlyResult = + doesDeviceSupportMultiplexAndCovidOnlyResult(deviceId, devicesMap); + const isFluOnly = isDeviceFluOnly(deviceId, devicesMap); + + const covidIsFilled = isCovidFilled(resultsMultiplexFormat); + const fluAIsFilled = isFluAFilled(resultsMultiplexFormat); + const fluBIsFilled = isFluBFilled(resultsMultiplexFormat); + + let allResultsAreInconclusive = + resultsMultiplexFormat.covid === TEST_RESULTS.UNDETERMINED && + resultsMultiplexFormat.fluA === TEST_RESULTS.UNDETERMINED && + resultsMultiplexFormat.fluB === TEST_RESULTS.UNDETERMINED; + + let anyResultIsInconclusive = + resultsMultiplexFormat.covid === TEST_RESULTS.UNDETERMINED || + resultsMultiplexFormat.fluA === TEST_RESULTS.UNDETERMINED || + resultsMultiplexFormat.fluB === TEST_RESULTS.UNDETERMINED; + + let allResultsAreEqual = + resultsMultiplexFormat.covid === resultsMultiplexFormat.fluA && + resultsMultiplexFormat.fluA === resultsMultiplexFormat.fluB; + + let allResultsAreFilled = covidIsFilled && fluAIsFilled && fluBIsFilled; + + let allResultsAreRequired = + fluAIsFilled || fluBIsFilled || !deviceSupportsCovidOnlyResult; + + // recalculate booleans if flu only + if (isFluOnly) { + allResultsAreEqual = + resultsMultiplexFormat.fluA === resultsMultiplexFormat.fluB; + + allResultsAreInconclusive = + resultsMultiplexFormat.fluB === TEST_RESULTS.UNDETERMINED && + resultsMultiplexFormat.fluA === TEST_RESULTS.UNDETERMINED; + + anyResultIsInconclusive = + resultsMultiplexFormat.fluA === TEST_RESULTS.UNDETERMINED || + resultsMultiplexFormat.fluB === TEST_RESULTS.UNDETERMINED; + + allResultsAreFilled = fluAIsFilled && fluBIsFilled; + + allResultsAreRequired = true; + } + + if (allResultsAreInconclusive) { + return ""; + } + + if (anyResultIsInconclusive && !allResultsAreEqual) { + return "This device only supports inconclusive results if all are inconclusive."; + } + + if (!covidIsFilled && deviceSupportsCovidOnlyResult) { + return "Please enter a COVID-19 test result."; + } + + if (allResultsAreRequired && !allResultsAreFilled) { + return "Please enter results for all conditions tested with this device."; + } + + return ""; +}; + +/** + * COMPONENT + */ +interface Props { + queueItemId: string; + testResults: MultiplexResultInput[]; + deviceId: string; + devicesMap: DevicesMap; + onChange: (value: MultiplexResult[]) => void; +} + +const MultiplexResultInputGroup: React.FC = ({ + queueItemId, + testResults, + deviceId, + devicesMap, + onChange, +}) => { + //eslint-disable-next-line no-restricted-globals + const isMobile = screen.width <= 600; + const resultsMultiplexFormat: MultiplexResultState = + convertFromMultiplexResultInputs(testResults); + + let allResultsInconclusive = + resultsMultiplexFormat.covid === TEST_RESULTS.UNDETERMINED && + resultsMultiplexFormat.fluA === TEST_RESULTS.UNDETERMINED && + resultsMultiplexFormat.fluB === TEST_RESULTS.UNDETERMINED; + + const deviceSupportsCovidOnlyResult = + doesDeviceSupportMultiplexAndCovidOnlyResult(deviceId, devicesMap); + const isFluOnly = isDeviceFluOnly(deviceId, devicesMap); + + if (isFluOnly) { + allResultsInconclusive = + resultsMultiplexFormat.fluB === TEST_RESULTS.UNDETERMINED && + resultsMultiplexFormat.fluA === TEST_RESULTS.UNDETERMINED; + } + + /** + * Handle Setting Results + */ + const setMultiplexResultInput = ( + diseaseName: "covid" | "fluA" | "fluB", + value: TestResult + ) => { + let newResults: MultiplexResultState = resultsMultiplexFormat; + if (allResultsInconclusive) { + newResults = { + covid: TEST_RESULTS.UNKNOWN, + fluA: TEST_RESULTS.UNKNOWN, + fluB: TEST_RESULTS.UNKNOWN, + }; + } + newResults[diseaseName] = value; + convertAndSendResults(newResults); + }; + + const convertAndSendResults = (multiplexResults: MultiplexResultState) => { + const results = convertFromMultiplexResult(multiplexResults); + onChange(results); + }; + + /** + * Handle Inconclusive + */ + + const handleInconclusiveSelection = (value: any) => { + const markedInconclusive = value.target.checked; + if (markedInconclusive) { + const inconclusiveState: MultiplexResultState = { + covid: isFluOnly ? TEST_RESULTS.UNKNOWN : TEST_RESULTS.UNDETERMINED, + fluA: TEST_RESULTS.UNDETERMINED, + fluB: TEST_RESULTS.UNDETERMINED, + }; + convertAndSendResults(inconclusiveState); + } else { + const currentState = { ...resultsMultiplexFormat }; + if (currentState.covid === TEST_RESULTS.UNDETERMINED) { + currentState.covid = TEST_RESULTS.UNKNOWN; + } + if (currentState.fluA === TEST_RESULTS.UNDETERMINED) { + currentState.fluA = TEST_RESULTS.UNKNOWN; + } + if (currentState.fluB === TEST_RESULTS.UNDETERMINED) { + currentState.fluB = TEST_RESULTS.UNKNOWN; + } + convertAndSendResults(currentState); + } + }; + + const isSomeFluFilled = + isFluAFilled(resultsMultiplexFormat) || + isFluBFilled(resultsMultiplexFormat); + const areAllResultsRequired = + isSomeFluFilled || !deviceSupportsCovidOnlyResult; + + return ( + <> +
    + {!isFluOnly && ( +
    + { + setMultiplexResultInput("covid", value); + }} + buttons={[ + { + value: COVID_RESULTS.POSITIVE, + label: `${TEST_RESULT_DESCRIPTIONS.POSITIVE} (+)`, + }, + { + value: COVID_RESULTS.NEGATIVE, + label: `${TEST_RESULT_DESCRIPTIONS.NEGATIVE} (-)`, + }, + ]} + name={`covid-test-result-${queueItemId}`} + selectedRadio={resultsMultiplexFormat.covid} + wrapperClassName="prime-radio__group" + required={true} + /> +
    + )} +
    + { + setMultiplexResultInput("fluA", value); + }} + buttons={[ + { + value: COVID_RESULTS.POSITIVE, + label: `${TEST_RESULT_DESCRIPTIONS.POSITIVE} (+)`, + }, + { + value: COVID_RESULTS.NEGATIVE, + label: `${TEST_RESULT_DESCRIPTIONS.NEGATIVE} (-)`, + }, + ]} + name={`flu-a-test-result-${queueItemId}`} + selectedRadio={resultsMultiplexFormat.fluA} + wrapperClassName="prime-radio__group" + required={areAllResultsRequired} + /> +
    +
    + { + setMultiplexResultInput("fluB", value); + }} + buttons={[ + { + value: COVID_RESULTS.POSITIVE, + label: `${TEST_RESULT_DESCRIPTIONS.POSITIVE} (+)`, + }, + { + value: COVID_RESULTS.NEGATIVE, + label: `${TEST_RESULT_DESCRIPTIONS.NEGATIVE} (-)`, + }, + ]} + name={`flu-b-test-result-${queueItemId}`} + selectedRadio={resultsMultiplexFormat.fluB} + wrapperClassName="prime-radio__group" + required={areAllResultsRequired} + /> +
    +
    +
    +
    + +
    +
    + +
    +
    + + ); +}; + +export default MultiplexResultInputGroup; diff --git a/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/__snapshots__/CovidResultInputGroup.test.tsx.snap b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/__snapshots__/CovidResultInputGroup.test.tsx.snap new file mode 100644 index 0000000000..fed2af3411 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/__snapshots__/CovidResultInputGroup.test.tsx.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CovidResultInputGroup matches snapshot 1`] = ` +Object { + "container":
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    , + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; diff --git a/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/__snapshots__/MultiplexResultInputGroup.test.tsx.snap b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/__snapshots__/MultiplexResultInputGroup.test.tsx.snap new file mode 100644 index 0000000000..4ca317ada7 --- /dev/null +++ b/frontend/src/app/testQueue/TestCardForm/diseaseSpecificComponents/__snapshots__/MultiplexResultInputGroup.test.tsx.snap @@ -0,0 +1,1229 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MultiplexResultInputGroup initial state snapshots matches flu only snapshot 1`] = ` +Object { + "container":
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    , + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; + +exports[`MultiplexResultInputGroup initial state snapshots matches inconclusive result snapshot 1`] = ` +Object { + "container":
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    , + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; + +exports[`MultiplexResultInputGroup initial state snapshots matches negative result snapshot 1`] = ` +Object { + "container":
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    , + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; + +exports[`MultiplexResultInputGroup initial state snapshots matches positive results snapshot 1`] = ` +Object { + "container":
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu A result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + Flu B result + + + * + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Inconclusive tests + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    , + "user": Object { + "clear": [Function], + "click": [Function], + "copy": [Function], + "cut": [Function], + "dblClick": [Function], + "deselectOptions": [Function], + "hover": [Function], + "keyboard": [Function], + "paste": [Function], + "pointer": [Function], + "selectOptions": [Function], + "setup": [Function], + "tab": [Function], + "tripleClick": [Function], + "type": [Function], + "unhover": [Function], + "upload": [Function], + }, +} +`; diff --git a/frontend/src/app/testQueue/TestQueue.test.tsx b/frontend/src/app/testQueue/TestQueue.test.tsx index 1f74e9de18..393d9c1bfd 100644 --- a/frontend/src/app/testQueue/TestQueue.test.tsx +++ b/frontend/src/app/testQueue/TestQueue.test.tsx @@ -10,6 +10,7 @@ import { MockedProvider } from "@apollo/client/testing"; import { MemoryRouter } from "react-router-dom"; import { Provider } from "react-redux"; import configureStore, { MockStoreEnhanced } from "redux-mock-store"; +import * as flaggedMock from "flagged"; import { GetFacilityQueueDocument, @@ -34,6 +35,7 @@ describe("TestQueue", () => { let store: MockStoreEnhanced; const mockStore = configureStore([]); + const today = new Date("2023-10-17").getTime(); const renderWithUser = (mocks: any[]) => ({ user: userEvent.setup(), ...render( @@ -49,6 +51,7 @@ describe("TestQueue", () => { beforeEach(() => { jest.spyOn(global.Math, "random").mockReturnValue(0.123456789); + jest.spyOn(Date, "now").mockImplementation(() => today); store = mockStore({ organization: { @@ -68,6 +71,7 @@ describe("TestQueue", () => { afterEach(() => { jest.spyOn(global.Math, "random").mockRestore(); + jest.spyOn(Date, "now").mockRestore(); }); it("should render the test queue", async () => { @@ -94,6 +98,33 @@ describe("TestQueue", () => { expect(container).toMatchSnapshot(); }); + it("should render the new test card when feature enabled", async () => { + jest.spyOn(flaggedMock, "useFeature").mockReturnValue(true); + + const { container } = render( + + + + + + + + ); + + await waitFor(() => + expect( + screen.getByLabelText( + `Search for a ${PATIENT_TERM} to start their test` + ) + ) + ); + + expect(await screen.findByText("Doe, John A")); + expect(await screen.findByText("Smith, Jane")); + expect(screen.getAllByText("Submit results").length > 0).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + it("should remove items queue using the transition group", async () => { const { user } = renderWithUser(mocks); expect(await screen.findByText("Doe, John A")); diff --git a/frontend/src/app/testQueue/TestQueue.tsx b/frontend/src/app/testQueue/TestQueue.tsx index e1d0087ebf..b7b978d7c2 100644 --- a/frontend/src/app/testQueue/TestQueue.tsx +++ b/frontend/src/app/testQueue/TestQueue.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from "react"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import { useLocation } from "react-router-dom"; +import { useFeature } from "flagged"; -import { showError } from "../utils/srToast"; +import { showAlertNotification, showError } from "../utils/srToast"; import { LinkWithQuery } from "../commonComponents/LinkWithQuery"; import { appPermissions, hasPermission } from "../permissions"; import { useAppSelector } from "../store"; @@ -10,13 +11,20 @@ import { PATIENT_TERM } from "../../config/constants"; import { useGetFacilityQueueQuery, GetFacilityQueueQuery, + useAddPatientToQueueMutation, + useRemovePatientFromQueueMutation, } from "../../generated/graphql"; +import { getSymptomsAllFalse } from "../../patientApp/timeOfTest/constants"; +import { getAppInsights } from "../TelemetryService"; +import { Patient } from "../patients/ManagePatients"; import AddToQueueSearch, { StartTestProps, } from "./addToQueue/AddToQueueSearch"; import QueueItem, { DevicesMap } from "./QueueItem"; import "./TestQueue.scss"; +import { TestCard } from "./TestCard/TestCard"; +import { ALERT_CONTENT, QUEUE_NOTIFICATION_TYPES } from "./constants"; const pollInterval = 10_000; @@ -71,11 +79,23 @@ const TestQueue: React.FC = ({ activeFacilityId }) => { facilityId: activeFacilityId, }, }); + const testCardRefactorEnabled = useFeature( + "testCardRefactorEnabled" + ) as boolean; + const appInsights = getAppInsights(); + const [addPatientToQueueMutation] = useAddPatientToQueueMutation(); + const [removePatientFromQueueMutation] = useRemovePatientFromQueueMutation(); + const trackRemovePatientFromQueue = () => { + if (appInsights) { + appInsights.trackEvent({ name: "Remove Patient From Queue" }); + } + }; const location = useLocation(); const [startTestPatientId, setStartTestPatientId] = useState( null ); + const canUseCsvUploader = hasPermission( useAppSelector((state) => state.user.permissions), appPermissions.results.canView @@ -87,10 +107,11 @@ const TestQueue: React.FC = ({ activeFacilityId }) => { ); useEffect(() => { - const locationState = (location.state as StartTestProps) || {}; - const { patientId: patientIdParam } = locationState; - if (patientIdParam) { - setStartTestPatientId(patientIdParam); + const patientId = (location.state as StartTestProps)?.patientId; + if (patientId) { + setStartTestPatientId(patientId); + // prevents the patient from being added again if the user had submitted or removed the patient and then refreshed the page + window.history.replaceState({}, ""); } }, [location.state]); @@ -129,6 +150,51 @@ const TestQueue: React.FC = ({ activeFacilityId }) => { ); } + const showPatientAddedToQueueAlert = (patient: Patient) => { + const { type, title, body } = { + ...ALERT_CONTENT[QUEUE_NOTIFICATION_TYPES.ADDED_TO_QUEUE__SUCCESS]( + patient + ), + }; + showAlertNotification(type, title, body); + }; + + const addPatientToQueue = async (patient: Patient) => { + if (appInsights) { + appInsights.trackEvent({ name: "Add Patient To Queue" }); + } + try { + await addPatientToQueueMutation({ + variables: { + facilityId: facility.id, + patientId: patient.internalId, + symptoms: JSON.stringify(getSymptomsAllFalse()), + pregnancy: null, + symptomOnset: null, + noSymptoms: null, + }, + }); + showPatientAddedToQueueAlert(patient); + await refetch(); + } catch (err: any) { + setStartTestPatientId(null); + throw err; + } + }; + + const removePatientFromQueue = async (patientId: string) => { + if (appInsights) { + trackRemovePatientFromQueue(); + } + await removePatientFromQueueMutation({ + variables: { + patientId: patientId, + }, + }); + setStartTestPatientId(null); + await refetch(); + }; + let shouldRenderQueue = data && data.queue && @@ -140,27 +206,39 @@ const TestQueue: React.FC = ({ activeFacilityId }) => { const devicesMap: DevicesMap = new Map(); facility.deviceTypes.map((d) => devicesMap.set(d.internalId, d)); - const createQueueItems = (patientQueue: GetFacilityQueueQuery["queue"]) => { + const createQueueItems = (testOrderQueue: GetFacilityQueueQuery["queue"]) => { const queue = shouldRenderQueue && - patientQueue && - patientQueue.map((queueItem) => { - if (!queueItem) return <>; + testOrderQueue && + testOrderQueue.map((testOrder) => { + if (!testOrder) return <>; return ( - + {testCardRefactorEnabled ? ( + + ) : ( + + )} ); }); @@ -204,6 +282,7 @@ const TestQueue: React.FC = ({ activeFacilityId }) => { startTestPatientId={startTestPatientId} setStartTestPatientId={setStartTestPatientId} canAddPatient={canAddPatient} + addPatientToQueue={addPatientToQueue} />
    {createQueueItems(data.queue)} diff --git a/frontend/src/app/testQueue/TestTimer.scss b/frontend/src/app/testQueue/TestTimer.scss index 8bbdc2b3f2..d381dc2de8 100644 --- a/frontend/src/app/testQueue/TestTimer.scss +++ b/frontend/src/app/testQueue/TestTimer.scss @@ -1,31 +1,35 @@ +@use "../../scss/settings" as settings; +@use "@uswds/uswds/packages/uswds-core" as uswds; + .timer-button { - border: none; border-radius: 3px; min-width: 80px; cursor: pointer; padding: 0.25rem; - padding-bottom: 0.25rem !important; display: flex; align-items: center; justify-content: space-around; } .timer-reset { - color: white; - background-color: #005ea2; + color: #005ea2; + background-color: white; + border: 1px solid settings.$theme-color-prime-blue; } .timer-running { - color: white; background-color: #162e51; + color: white; } .timer-ready { - color: green; - background-color: white; + color: white; + background-color: uswds.color(settings.$theme-color-success-dark); + border: none; } .timer-overtime { - color: gray; + color: settings.$color-white-transparent-80; font-style: italic; + min-width: 6rem; } diff --git a/frontend/src/app/testQueue/TestTimer.test.tsx b/frontend/src/app/testQueue/TestTimer.test.tsx index 5281239410..7c983bc8dc 100644 --- a/frontend/src/app/testQueue/TestTimer.test.tsx +++ b/frontend/src/app/testQueue/TestTimer.test.tsx @@ -117,7 +117,7 @@ describe("TestTimerWidget", () => { // Start timer await user.click(timerButton); - await screen.findByText("15:00"); + await screen.findByText("Start timer"); // The timer does not enter the countdown state instantly, so clicking the // button in rapid succession will register as two "start timer" events. @@ -126,7 +126,7 @@ describe("TestTimerWidget", () => { // Reset timer await user.click(timerButton); - await screen.findByText("15:00"); + await screen.findByText("Start timer"); expect(trackEventMock).toHaveBeenCalledWith( { name: "Test timer reset" }, @@ -138,7 +138,6 @@ describe("TestTimerWidget", () => { const { user } = renderWithUser(0); const timerButton = await screen.findByRole("button"); await user.click(timerButton); - await screen.findByText("0:00"); await screen.findByText("RESULT READY"); await waitFor(() => diff --git a/frontend/src/app/testQueue/TestTimer.tsx b/frontend/src/app/testQueue/TestTimer.tsx index 6c83401607..d5c6230d1e 100644 --- a/frontend/src/app/testQueue/TestTimer.tsx +++ b/frontend/src/app/testQueue/TestTimer.tsx @@ -4,7 +4,7 @@ import { faStopwatch, faRedo } from "@fortawesome/free-solid-svg-icons"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import "./TestTimer.scss"; -import { getAppInsights } from "../../app/TelemetryService"; +import { getAppInsights } from "../TelemetryService"; const alarmModule = require("./test-timer.mp3"); @@ -15,12 +15,14 @@ type DateTimeStamp = ReturnType; function toMillis(minutes: number) { return minutes * 60 * 1000; } + export interface TimerTrackEventMetadata { facilityName: string | undefined; organizationName: string; patientId: string; testOrderId: string; } + export class Timer { id: string; startedAt: DateTimeStamp; @@ -240,8 +242,10 @@ export const TestTimerWidget = ({ timer, context }: Props) => { data-testid="timer" aria-label="Start timer" > - {mmss(countdown)}{" "} + + Start timer + {" "} ); } @@ -253,8 +257,8 @@ export const TestTimerWidget = ({ timer, context }: Props) => { data-testid="timer" aria-label="Reset timer" > - {mmss(countdown)}{" "} + {mmss(countdown)}{" "} ); } @@ -273,8 +277,10 @@ export const TestTimerWidget = ({ timer, context }: Props) => { data-testid="timer" aria-label="Reset timer" > - RESULT READY{" "} - + + RESULT READY + {" "} + {mmss(elapsed)} elapsed{" "} {" "} diff --git a/frontend/src/app/testQueue/__snapshots__/QueueItem.test.tsx.snap b/frontend/src/app/testQueue/__snapshots__/QueueItem.test.tsx.snap index b6b45639ac..504bc83fe6 100644 --- a/frontend/src/app/testQueue/__snapshots__/QueueItem.test.tsx.snap +++ b/frontend/src/app/testQueue/__snapshots__/QueueItem.test.tsx.snap @@ -106,12 +106,6 @@ exports[`QueueItem matches snapshot 1`] = ` class="timer-button timer-reset" data-testid="timer" > - - 15:00 - - + + Start timer + +
    +
    +
    +

    + Conduct tests +

    +
    +
    + +
    +
    +
    +
  • +
    +
    +
    +
    + +
    +
    + +
    +
    + + DOB: + 06/19/1996 + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient pregnant? + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient currently experiencing any symptoms? + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + Select any symptoms the patient is experiencing + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
  • +
    +
    +
  • +
    +
    +
    +
    + +
    +
    + +
    +
    + + DOB: + 02/01/2021 + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + COVID-19 result + + + * + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient pregnant? + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Is the patient currently experiencing any symptoms? + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + Select any symptoms the patient is experiencing + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
  • +
    +
    +
    +
    +`; + exports[`TestQueue should render the test queue 1`] = `
    - - 15:00 - - + + Start timer + +
    - - 15:00 - - + + Start timer + +
    Promise; } const AddToQueueSearchBox = ({ @@ -57,6 +59,7 @@ const AddToQueueSearchBox = ({ startTestPatientId, setStartTestPatientId, canAddPatient, + addPatientToQueue, }: Props) => { const appInsights = getAppInsights(); @@ -76,7 +79,7 @@ const AddToQueueSearchBox = ({ const [mutationError, updateMutationError] = useState(null); const [selectedPatient, setSelectedPatient] = useState(); - const [addPatientToQueue] = useMutation(ADD_PATIENT_TO_QUEUE); + const [addPatientToQueueMutation] = useMutation(ADD_PATIENT_TO_QUEUE); const [updateAoe] = useMutation(UPDATE_AOE); const { ref: dropDownRef, @@ -89,12 +92,19 @@ const AddToQueueSearchBox = ({ [allowQuery, showSuggestion] ); + const testCardRefactorEnabled = useFeature( + "testCardRefactorEnabled" + ) as boolean; + useQuery<{ patient: Patient }>(QUERY_SINGLE_PATIENT, { fetchPolicy: "no-cache", - //variables: { internalId: patientIdParam }, variables: { internalId: startTestPatientId }, - onCompleted: (response) => { + onCompleted: async (response) => { setSelectedPatient(response.patient); + if (testCardRefactorEnabled && addPatientToQueue) { + await addPatientToQueue(response.patient); + setSelectedPatient(undefined); + } }, skip: !startTestPatientId || patientsInQueue.includes(startTestPatientId), }); @@ -143,7 +153,7 @@ const AddToQueueSearchBox = ({ testResultDelivery, }; if (createOrUpdate === "create") { - callback = addPatientToQueue; + callback = addPatientToQueueMutation; variables.facilityId = facilityId; } else { callback = updateAoe; @@ -159,7 +169,7 @@ const AddToQueueSearchBox = ({ refetchQueue(); setStartTestPatientId(null); if (createOrUpdate === "create") { - return res.data.addPatientToQueue; + return res.data.addPatientToQueueMutation; } }) .catch((err) => { @@ -181,6 +191,7 @@ const AddToQueueSearchBox = ({ patients={data?.patients || []} selectedPatient={selectedPatient} onAddToQueue={onAddToQueue} + addPatientToQueue={addPatientToQueue} patientsInQueue={patientsInQueue} shouldShowSuggestions={showDropdown} loading={debounced !== queryString || loading} diff --git a/frontend/src/app/testQueue/addToQueue/SearchResults.tsx b/frontend/src/app/testQueue/addToQueue/SearchResults.tsx index fe1bb1e38d..54e0b87910 100644 --- a/frontend/src/app/testQueue/addToQueue/SearchResults.tsx +++ b/frontend/src/app/testQueue/addToQueue/SearchResults.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import moment from "moment"; import { Navigate, useLocation } from "react-router-dom"; +import { useFeature } from "flagged"; import Button from "../../commonComponents/Button/Button"; import AoEModalForm from "../AoEForm/AoEModalForm"; @@ -17,6 +18,7 @@ interface SearchResultsProps { dropDownRef?: React.RefObject; selectedPatient?: Patient; canAddPatient: boolean; + addPatientToQueue?: (patient: Patient) => Promise; } export interface QueueProps extends SearchResultsProps { @@ -41,7 +43,11 @@ const SearchResults = (props: QueueProps | TestResultsProps) => { loading, dropDownRef, selectedPatient, + addPatientToQueue, } = props; + const testCardRefactorEnabled = useFeature( + "testCardRefactorEnabled" + ) as boolean; const [dialogPatient, setDialogPatient] = useState(null); const [canAddToQueue, setCanAddToQueue] = useState(false); @@ -72,6 +78,18 @@ const SearchResults = (props: QueueProps | TestResultsProps) => { return ; } + const handleBeginTestClick = (patient: Patient) => { + if (testCardRefactorEnabled && addPatientToQueue) { + return addPatientToQueue(patient); + } + + // existing logic + setDialogPatient(patient); + // this will always be true because the "Begin test" button + // is only available when canAddToTestQueue is true + setCanAddToQueue(true); + }; + const actionByPage = (patient: Patient, idx: Number) => { if (props.page === "queue") { const canAddToTestQueue = @@ -81,10 +99,7 @@ const SearchResults = (props: QueueProps | TestResultsProps) => { variant="unstyled" label="Begin test" ariaDescribedBy={`name${idx} birthdate${idx}`} - onClick={() => { - setDialogPatient(patient); - setCanAddToQueue(canAddToTestQueue); - }} + onClick={() => handleBeginTestClick(patient)} /> ) : ( "Test in progress" @@ -182,14 +197,16 @@ const SearchResults = (props: QueueProps | TestResultsProps) => { return ( <> - { - setDialogPatient(null); - }} - saveCallback={handleSaveCallback} - /> + {!testCardRefactorEnabled && ( + { + setDialogPatient(null); + }} + saveCallback={handleSaveCallback} + /> + )} {shouldShowSuggestions && results} ); diff --git a/frontend/src/app/testQueue/constants.ts b/frontend/src/app/testQueue/constants.ts index 6966ae6a44..e0fbee8b23 100644 --- a/frontend/src/app/testQueue/constants.ts +++ b/frontend/src/app/testQueue/constants.ts @@ -4,35 +4,67 @@ import { displayFullName } from "../utils"; export const QUEUE_NOTIFICATION_TYPES = { ADDED_TO_QUEUE__SUCCESS: 1, SUBMITTED_RESULT__SUCCESS: 2, + DELIVERED_RESULT_TO_PATIENT__FAILURE: 3, +}; + +export type SomeoneWithName = { + firstName?: string | null; + middleName?: string | null; + lastName?: string | null; }; export const ALERT_CONTENT = { - [QUEUE_NOTIFICATION_TYPES.ADDED_TO_QUEUE__SUCCESS]: ( - patient: any + [QUEUE_NOTIFICATION_TYPES.ADDED_TO_QUEUE__SUCCESS]: < + T extends SomeoneWithName + >( + patient: T, + startWithLastName: boolean = true ): AlertContent => { return { type: "success", title: `${displayFullName( patient.firstName, patient.middleName, - patient.lastName + patient.lastName, + startWithLastName )} was added to the queue`, body: "Newly added patients go to the bottom of the queue", }; }, - [QUEUE_NOTIFICATION_TYPES.SUBMITTED_RESULT__SUCCESS]: ( - patient: any + [QUEUE_NOTIFICATION_TYPES.SUBMITTED_RESULT__SUCCESS]: < + T extends SomeoneWithName + >( + patient: T, + startWithLastName: boolean = true ): AlertContent => { return { type: "success", title: `Result for ${displayFullName( patient.firstName, patient.middleName, - patient.lastName + patient.lastName, + startWithLastName )} was saved and reported.`, body: "See Results to view all test submissions", }; }, + [QUEUE_NOTIFICATION_TYPES.DELIVERED_RESULT_TO_PATIENT__FAILURE]: < + T extends SomeoneWithName + >( + patient: T, + startWithLastName: boolean = true + ): AlertContent => { + return { + type: "error", + title: `Unable to text result to ${displayFullName( + patient.firstName, + patient.middleName, + patient.lastName, + startWithLastName + )}`, + body: "The phone number provided may not be valid or may not be able to accept text messages", + }; + }, }; export const MIN_SEARCH_CHARACTER_COUNT = 2; diff --git a/frontend/src/app/testResults/CovidResultInputForm.tsx b/frontend/src/app/testResults/CovidResultInputForm.tsx index 0ef4f2abb4..79411ac744 100644 --- a/frontend/src/app/testResults/CovidResultInputForm.tsx +++ b/frontend/src/app/testResults/CovidResultInputForm.tsx @@ -74,8 +74,8 @@ const CovidResultInputForm: React.FC = ({ { - convertAndSendResults(value as TestResult); + onChange={(value: TestResult) => { + convertAndSendResults(value); }} buttons={[ { diff --git a/frontend/src/patientApp/timeOfTest/constants.ts b/frontend/src/patientApp/timeOfTest/constants.ts index 5c50763ec4..9da7c93c8e 100644 --- a/frontend/src/patientApp/timeOfTest/constants.ts +++ b/frontend/src/patientApp/timeOfTest/constants.ts @@ -58,6 +58,14 @@ export const globalSymptomDefinitions = symptomOrder.map((value) => ({ label: symptomsMap[value], })); +export const getSymptomsAllFalse = () => { + const symptomMap: { [key: string]: boolean } = {}; + symptomOrder.forEach((symptomCode) => { + symptomMap[symptomCode] = false; + }); + return symptomMap; +}; + type PregnancyResponses = { label: PregnancyDescription; value: PregnancyCode;