From e82824d67c7891b524abe5e5da8748a50704bae5 Mon Sep 17 00:00:00 2001 From: elisa lee Date: Wed, 14 Jun 2023 16:10:28 -0500 Subject: [PATCH] Refactor facilityForm to use react-hook-form --- cypress/e2e/01-organization_sign_up.cy.js | 20 +- .../Components/FacilityInformation.tsx | 315 ++++++++------ .../Components/ManageDevices.test.tsx | 193 +++++---- .../Facility/Components/ManageDevices.tsx | 51 +-- .../Facility/Components/OrderingProvider.tsx | 169 +++++--- .../Settings/Facility/FacilityForm.test.tsx | 30 +- .../app/Settings/Facility/FacilityForm.tsx | 402 +++++++++--------- .../Facility/FacilityFormContainer.test.tsx | 50 ++- .../Facility/FacilityFormContainer.tsx | 16 +- .../app/Settings/Facility/facilitySchema.ts | 170 -------- .../src/app/commonComponents/Dropdown.tsx | 2 +- .../MultiSelect/MultiSelect.tsx | 2 +- frontend/src/app/utils/address.tsx | 2 + frontend/src/app/utils/clia.test.ts | 60 ++- frontend/src/app/utils/clia.ts | 6 +- frontend/src/config/constants.ts | 2 +- 16 files changed, 768 insertions(+), 722 deletions(-) delete mode 100644 frontend/src/app/Settings/Facility/facilitySchema.ts diff --git a/cypress/e2e/01-organization_sign_up.cy.js b/cypress/e2e/01-organization_sign_up.cy.js index 698b8a94a26..1ebe4915d4d 100644 --- a/cypress/e2e/01-organization_sign_up.cy.js +++ b/cypress/e2e/01-organization_sign_up.cy.js @@ -105,16 +105,16 @@ describe("Organization sign up",() => { cy.checkA11y(); }); it("fills out the form for a new facility", () => { - cy.get('input[name="name"]').type(facility.name); - cy.get('input[name="facility-phone"]').first().type("5308675309"); - cy.get('input[name="facility-street"]').first().type("123 Beach Way"); - cy.get('input[name="facility-zipCode"]').first().type("90210"); - cy.get('select[name="facility-state"]').first().select("CA"); - cy.get('input[name="cliaNumber"]').type("12D4567890"); - cy.get('input[name="firstName"]').type("Phil"); - cy.get('input[name="lastName"]').type("McTester"); - cy.get('input[name="NPI"]').type("1234567890"); - cy.get('input[name="op-phone"]').last().type("5308675309"); + cy.get('input[name="facility.name"]').type(facility.name); + cy.get('input[name="facility.phone"]').first().type("5308675309"); + cy.get('input[name="facility.street"]').first().type("123 Beach Way"); + cy.get('input[name="facility.zipCode"]').first().type("90210"); + cy.get('select[name="facility.state"]').first().select("CA"); + cy.get('input[name="facility.cliaNumber"]').type("12D4567890"); + cy.get('input[name="orderingProvider.firstName"]').type("Phil"); + cy.get('input[name="orderingProvider.lastName"]').type("McTester"); + cy.get('input[name="orderingProvider.NPI"]').type("1234567890"); + cy.get('input[name="orderingProvider.phone"]').last().type("5308675309"); cy.contains("Save changes").last().click(); cy.get( '.modal__container input[name="addressSelect-facility"][value="userAddress"]+label' diff --git a/frontend/src/app/Settings/Facility/Components/FacilityInformation.tsx b/frontend/src/app/Settings/Facility/Components/FacilityInformation.tsx index 2cd83386d28..cc0a55c05d3 100644 --- a/frontend/src/app/Settings/Facility/Components/FacilityInformation.tsx +++ b/frontend/src/app/Settings/Facility/Components/FacilityInformation.tsx @@ -1,32 +1,42 @@ import React from "react"; -import { stateCodes } from "../../../../config/constants"; +import { + liveJurisdictions, + stateCodes, + urls, +} from "../../../../config/constants"; import TextInput from "../../../commonComponents/TextInput"; -import { FacilityErrors } from "../facilitySchema"; -import { ValidateField } from "../FacilityForm"; -import { getSubStrAfterChar } from "../../../utils/text"; import Dropdown from "../../../commonComponents/Dropdown"; +import { + isValidCLIANumber, + stateRequiresCLIANumberValidation, +} from "../../../utils/clia"; +import { getStateNameFromCode } from "../../../utils/state"; +import { emailRegex } from "../../../utils/email"; +import { phoneNumberIsValid } from "../../../patients/personSchema"; +import { FacilityFormData } from "../FacilityForm"; +import { zipCodeRegex } from "../../../utils/address"; interface Props { facility: Facility; - updateFacility: (facility: Facility) => void; - errors: FacilityErrors; - validateField: ValidateField; + setError: any; newOrg?: boolean; + errors: any; + register: any; + formCurrentValues: FacilityFormData; + getFieldState: any; } const FacilityInformation: React.FC = ({ - facility, - updateFacility, - errors, - validateField, newOrg = false, + errors, + register, + formCurrentValues, + getFieldState, }) => { - const onChange = ( - e: React.ChangeEvent - ) => { - let fieldName = getSubStrAfterChar(e.target.name, "-"); - updateFacility({ ...facility, [fieldName]: e.target.value }); + const fieldState = getFieldState("facility.state"); + const isLiveState = (state: string) => { + return liveJurisdictions.includes(state) && stateCodes.includes(state); }; return ( @@ -44,119 +54,166 @@ const FacilityInformation: React.FC = ({

)} -

- Testing facility information -

- { - validateField("name"); - }} - validationStatus={errors.name ? "error" : undefined} - errorMessage={errors.name} - /> - { - validateField("phone"); - }} - validationStatus={errors.phone ? "error" : undefined} - errorMessage={errors.phone} - /> - { - validateField("email"); - }} - validationStatus={errors.email ? "error" : undefined} - errorMessage={errors.email} - /> - { - validateField("street"); - }} - validationStatus={errors.street ? "error" : undefined} - errorMessage={errors.street} - /> - - - { - validateField("zipCode"); - }} - validationStatus={errors.zipCode ? "error" : undefined} - errorMessage={errors.zipCode} - className="usa-input--medium" - /> - ({ label: c, value: c }))} - defaultSelect - required - onChange={onChange} - onBlur={() => { - validateField("state"); - }} - validationStatus={errors.state ? "error" : undefined} - errorMessage={errors.state} - selectClassName="usa-input--medium" - data-testid="facility-state-dropdown" - /> - - Find my CLIA - - } - name="cliaNumber" - value={facility.cliaNumber} - required - onChange={onChange} - onBlur={() => { - validateField("cliaNumber"); - }} - validationStatus={errors.cliaNumber ? "error" : undefined} - errorMessage={errors.cliaNumber} - /> +
+ +

+ Testing facility information +

+
+ + + phoneNumberIsValid(facPhone) || + "Facility phone number is invalid", + }, + })} + validationStatus={errors?.facility?.phone?.type ? "error" : undefined} + errorMessage={errors?.facility?.phone?.message} + /> + + + + + + ({ label: c, value: c }))} + defaultSelect + required + hintText={ + fieldState?.error && ( + + See a{" "} + + {" "} + list of states where SimpleReport is supported + + . + + ) + } + onChange={() => {}} + validationStatus={errors?.facility?.state?.type ? "error" : undefined} + errorMessage={errors?.facility?.state?.message} + selectClassName="usa-input--medium" + data-testid="facility-state-dropdown" + registrationProps={register("facility.state", { + required: "Facility state is required", + validate: { + liveJurisdiction: (state: string) => + isLiveState(state) || + `SimpleReport isn’t currently supported in ${getStateNameFromCode( + state + )}.`, + }, + })} + /> + + Find my CLIA + + } + name="cliaNumber" + value={formCurrentValues.facility?.cliaNumber} + required + validationStatus={ + errors?.facility?.cliaNumber?.type ? "error" : undefined + } + errorMessage={errors?.facility?.cliaNumber?.message} + registrationProps={register("facility.cliaNumber", { + required: "Facility CLIA number is required", + validate: { + validCLIA: (clia: string) => + (stateRequiresCLIANumberValidation( + formCurrentValues.facility.state + ) + ? isValidCLIANumber(clia, formCurrentValues.facility.state) + : true) || + "CLIA numbers must be 10 characters (##D#######), or a special temporary number from CA, IL, VT, WA, WY, or the Department of Defense", + }, + })} + /> +
); }; diff --git a/frontend/src/app/Settings/Facility/Components/ManageDevices.test.tsx b/frontend/src/app/Settings/Facility/Components/ManageDevices.test.tsx index cd7477b4331..ce719e6e82f 100644 --- a/frontend/src/app/Settings/Facility/Components/ManageDevices.test.tsx +++ b/frontend/src/app/Settings/Facility/Components/ManageDevices.test.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { act, fireEvent, @@ -9,8 +8,38 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { FacilityFormData } from "../FacilityForm"; + import ManageDevices from "./ManageDevices"; +const validFacility: FacilityFormData = { + facility: { + name: "Foo Facility", + cliaNumber: "12D4567890", + phone: "(202) 395-3080", + street: "736 Jackson Pl NW", + zipCode: "20503", + state: "AZ", + email: null, + streetTwo: null, + city: null, + }, + orderingProvider: { + firstName: "Frank", + lastName: "Grimes", + NPI: "1231231231", + street: null, + zipCode: null, + state: "", + middleName: null, + suffix: null, + phone: "2031232381", + streetTwo: null, + city: null, + }, + devices: [], +}; + const deviceA = { internalId: "device-a", name: "Device A", @@ -24,114 +53,98 @@ const deviceC = { name: "Device C", }; -const devices: DeviceType[] = [deviceA, deviceB, deviceC]; -const selectedDevices: FacilityFormDeviceType[] = [deviceA, deviceB]; - -function ManageDevicesContainer(props: { selectedDevices: DeviceType[] }) { - const [selectedDevices, updateSelectedDevices] = useState( - props.selectedDevices - ); +const devices: DeviceType[] = [deviceC, deviceB, deviceA]; +function ManageDevicesContainer(props: { facility: FacilityFormData }) { return ( {}} + registrationProps={{ setFocus: () => {} }} /> ); } describe("ManageDevices", () => { - describe("with no devices set for facility", () => { - beforeEach(() => render()); - - it("renders a message if no devices are present in the list", async () => { - const expected = await screen.findByText( - "There are currently no devices", - { exact: false } - ); + it("renders a message if no devices are present in the list", async () => { + render(); - expect(expected).toBeInTheDocument(); - }); - }); - - describe("with devices set for facility", () => { - beforeEach(() => { - render(); + const expected = await screen.findByText("There are currently no devices", { + exact: false, }); - it("renders a list of devices", () => { - const multiselect = screen.getByTestId("multi-select"); - - expect(multiselect).toBeInTheDocument(); - }); + expect(expected).toBeInTheDocument(); + }); - it("renders the device dropdown in alphabetical order", async () => { - const multiselect = screen.getByTestId("multi-select"); - const deviceList = within(multiselect).getByTestId( - "multi-select-option-list" - ); - const deviceOptionIds = within(deviceList) - .getAllByTestId("multi-select-option-device", { exact: false }) - .map((deviceOption) => deviceOption.id); - const expectedDeviceOptionIds = [ - deviceA.internalId, - deviceB.internalId, - deviceC.internalId, - ]; - - expect(deviceOptionIds.length === expectedDeviceOptionIds.length); + it("renders the selected devices in alphabetical order", async () => { + validFacility.devices = ["device-a", "device-b"]; + render(); + + const multiselect = screen.getByTestId("multi-select"); + expect(multiselect).toBeInTheDocument(); + const deviceList = within(multiselect).getByTestId( + "multi-select-option-list" + ); + const deviceOptionIds = within(deviceList) + .getAllByTestId("multi-select-option-device", { exact: false }) + .map((deviceOption) => deviceOption.id); + const expectedDeviceOptionIds = [ + deviceA.internalId, + deviceB.internalId, + deviceC.internalId, + ]; + + expect(deviceOptionIds.length === expectedDeviceOptionIds.length); + expect( + deviceOptionIds.every( + (id, index) => id === expectedDeviceOptionIds[index] + ) + ); + const pillContainer = screen.getByTestId("pill-container"); + + within(pillContainer).getByText("Device A"); + within(pillContainer).getByText("Device B"); + await waitFor(() => expect( - deviceOptionIds.every( - (id, index) => id === expectedDeviceOptionIds[index] - ) - ); - }); - - it("renders the selected devices", async () => { - const pillContainer = screen.getByTestId("pill-container"); + within(pillContainer).queryByText("Device C") + ).not.toBeInTheDocument() + ); + }); - within(pillContainer).getByText("Device A"); - within(pillContainer).getByText("Device B"); - await waitFor(() => - expect( - within(pillContainer).queryByText("Device C") - ).not.toBeInTheDocument() - ); - }); + it("allows adding devices", async () => { + validFacility.devices = ["device-a", "device-b"]; + render(); + const deviceInput = screen.getByTestId("multi-select-toggle"); + const deviceList = screen.getByTestId("multi-select-option-list"); + + await act(async () => await userEvent.click(deviceInput)); + await act( + async () => + await userEvent.click(within(deviceList).getByText("Device C")) + ); + + expect(await screen.findByTestId("pill-container")); + expect( + await within(screen.getByTestId("pill-container")).findByText("Device C") + ); + }); - it("allows user to add a device type to the existing list of devices", async () => { - const deviceInput = screen.getByTestId("multi-select-toggle"); - const deviceList = screen.getByTestId("multi-select-option-list"); + it("removes a device from the list", async () => { + validFacility.devices = ["device-a", "device-b"]; + render(); + const pillContainer = screen.getByTestId("pill-container"); + const deleteIcon = await within(pillContainer).getAllByRole("button")[0]; - await act(async () => await userEvent.click(deviceInput)); - await act( - async () => - await userEvent.click(within(deviceList).getByText("Device C")) - ); + within(pillContainer).getByText("Device A"); + fireEvent.click(deleteIcon); - expect(await screen.findByTestId("pill-container")); + await waitFor(() => expect( - await within(screen.getByTestId("pill-container")).findByText( - "Device C" - ) - ); - }); - - it("removes a device from the list", async () => { - const pillContainer = screen.getByTestId("pill-container"); - const deleteIcon = within(pillContainer).getAllByRole("button")[0]; - - within(pillContainer).getByText("Device A"); - fireEvent.click(deleteIcon); - - await waitFor(() => - expect( - within(pillContainer).queryByText("Device A") - ).not.toBeInTheDocument() - ); - }); + within(pillContainer).queryByText("Device A") + ).not.toBeInTheDocument() + ); }); }); diff --git a/frontend/src/app/Settings/Facility/Components/ManageDevices.tsx b/frontend/src/app/Settings/Facility/Components/ManageDevices.tsx index c02bccfe788..75a847e6d22 100644 --- a/frontend/src/app/Settings/Facility/Components/ManageDevices.tsx +++ b/frontend/src/app/Settings/Facility/Components/ManageDevices.tsx @@ -1,24 +1,25 @@ import React from "react"; -import { FacilityErrors } from "../facilitySchema"; import MultiSelect from "../../../commonComponents/MultiSelect/MultiSelect"; +import { RegistrationProps } from "../../../commonComponents/MultiSelect/MultiSelectDropdown/MultiSelectDropdown"; +import { FacilityFormData } from "../FacilityForm"; interface Props { deviceTypes: FacilityFormDeviceType[]; - selectedDevices: FacilityFormDeviceType[]; - updateSelectedDevices: (deviceTypes: FacilityFormDeviceType[]) => void; - errors: FacilityErrors; - clearError: (field: keyof FacilityErrors) => void; + errors: any; newOrg?: boolean; + formCurrentValues: FacilityFormData; + registrationProps: RegistrationProps; + onChange: (selectedItems: string[]) => void; } const ManageDevices: React.FC = ({ deviceTypes, - selectedDevices, - updateSelectedDevices, errors, - clearError, newOrg = false, + formCurrentValues, + onChange, + registrationProps, }) => { const getDeviceTypeOptions = Array.from( deviceTypes.map((device) => ({ @@ -27,25 +28,6 @@ const ManageDevices: React.FC = ({ })) ); - const getDeviceTypesFromIds = (newDeviceIds: String[]) => { - return newDeviceIds.length - ? newDeviceIds.map((deviceId) => { - return deviceTypes.find( - (deviceType) => deviceType.internalId === deviceId - ) as DeviceType; - }) - : []; - }; - - const updateDevices = (newDeviceIds: String[]) => { - clearError("deviceTypes"); - updateSelectedDevices(getDeviceTypesFromIds(newDeviceIds)); - }; - - const getInitialValues = selectedDevices.length - ? selectedDevices.map((device) => device.internalId) || [] - : undefined; - return (
@@ -62,17 +44,18 @@ const ManageDevices: React.FC = ({ { - updateDevices(newDeviceIds); - }} + onChange={onChange} options={getDeviceTypeOptions} - initialSelectedValues={getInitialValues} - errorMessage={errors.deviceTypes} - validationStatus={errors.deviceTypes ? "error" : "success"} + initialSelectedValues={formCurrentValues.devices} + validationStatus={errors?.devices?.type ? "error" : "success"} required placeholder="Add device" + errorMessage={errors?.devices?.message} + registrationProps={registrationProps} /> - {!selectedDevices.length &&

There are currently no devices

} + {formCurrentValues.devices.length === 0 && ( +

There are currently no devices

+ )}

If you don’t see a device you’re using, please contact{" "} support@simplereport.gov{" "} diff --git a/frontend/src/app/Settings/Facility/Components/OrderingProvider.tsx b/frontend/src/app/Settings/Facility/Components/OrderingProvider.tsx index c5f5ff081d2..ec645ccd9dc 100644 --- a/frontend/src/app/Settings/Facility/Components/OrderingProvider.tsx +++ b/frontend/src/app/Settings/Facility/Components/OrderingProvider.tsx @@ -2,41 +2,39 @@ import React from "react"; import { stateCodes } from "../../../../config/constants"; import { requiresOrderProvider } from "../../../utils/state"; -import { ValidateField } from "../FacilityForm"; -import { FacilityErrors } from "../facilitySchema"; import Dropdown from "../../../commonComponents/Dropdown"; import TextInput from "../../../commonComponents/TextInput"; -import { getSubStrAfterChar } from "../../../utils/text"; +import { phoneNumberIsValid } from "../../../patients/personSchema"; +import { FacilityFormData } from "../FacilityForm"; +import { zipCodeRegex } from "../../../utils/address"; interface Props { - facility: Facility; - updateProvider: (provider: Provider) => void; - errors: FacilityErrors; - validateField: ValidateField; newOrg?: boolean; + errors: any; + register: any; + formCurrentValues: FacilityFormData; } const OrderingProvider: React.FC = ({ - facility, - updateProvider, - errors, - validateField, newOrg = false, + errors, + register, + formCurrentValues, }) => { - const onChange = ( - e: React.ChangeEvent - ) => { - let fieldName = getSubStrAfterChar(e.target.name, "-"); - updateProvider({ ...provider, [fieldName]: e.target.value }); - }; + const isRequired = requiresOrderProvider( + formCurrentValues.facility.state || "" + ); - const { orderingProvider: provider } = facility; - const isRequired = requiresOrderProvider(facility.state || ""); + const isValidNPI = (npi: string) => { + return /^\d{10}$/.test(npi); + }; return ( -

+
-

Ordering provider

+ +

Ordering provider

+
{newOrg && ( @@ -56,41 +54,47 @@ const OrderingProvider: React.FC = ({ label="First name" name="firstName" required={isRequired} - value={provider.firstName || ""} - onChange={onChange} - onBlur={() => { - validateField("orderingProvider.firstName"); - }} + value={formCurrentValues.orderingProvider.firstName ?? undefined} validationStatus={ - errors["orderingProvider.firstName"] ? "error" : undefined + errors?.orderingProvider?.firstName?.type ? "error" : undefined } - errorMessage={errors["orderingProvider.firstName"]} + errorMessage={errors?.orderingProvider?.firstName?.message} + registrationProps={register("orderingProvider.firstName", { + required: isRequired + ? "Ordering provider first name is required" + : "", + })} /> { - validateField("orderingProvider.lastName"); - }} + value={formCurrentValues.orderingProvider.lastName ?? undefined} validationStatus={ - errors["orderingProvider.lastName"] ? "error" : undefined + errors?.orderingProvider?.lastName?.type ? "error" : undefined } - errorMessage={errors["orderingProvider.lastName"]} + errorMessage={errors?.orderingProvider?.lastName?.message} + registrationProps={register("orderingProvider.lastName", { + required: isRequired + ? "Ordering provider last name is required" + : "", + })} /> = ({ } name="NPI" required={isRequired} - value={provider.NPI || ""} - onChange={onChange} - onBlur={() => { - validateField("orderingProvider.NPI"); - }} + value={formCurrentValues.orderingProvider.NPI ?? undefined} + registrationProps={register("orderingProvider.NPI", { + required: isRequired + ? "NPI should be a 10-digit numerical value (##########)" + : "", + validate: { + validNPI: (npi: string) => + npi.length + ? isValidNPI(npi) || + "NPI should be a 10-digit numerical value (##########)" + : true, + }, + })} validationStatus={ - errors["orderingProvider.NPI"] ? "error" : undefined + errors?.orderingProvider?.NPI?.type ? "error" : undefined } - errorMessage={errors["orderingProvider.NPI"]} + errorMessage={errors?.orderingProvider?.NPI?.message} /> { - validateField("orderingProvider.phone"); - }} + value={formCurrentValues.orderingProvider.phone ?? undefined} validationStatus={ - errors["orderingProvider.phone"] ? "error" : undefined + errors?.orderingProvider?.phone?.type ? "error" : undefined } - errorMessage={errors["orderingProvider.phone"]} + errorMessage={errors?.orderingProvider?.phone?.message} + registrationProps={register("orderingProvider.phone", { + required: isRequired ? "Ordering provider phone is required" : "", + validate: { + validPhone: (opPhone: string) => + opPhone.length + ? phoneNumberIsValid(opPhone) || + "Ordering provider phone number is invalid" + : true, + }, + })} /> + zipCode?.length + ? zipCodeRegex.test(zipCode) || + "Ordering provider ZIP code is invalid" + : true, + }, + })} className="usa-input--medium" /> {}} options={stateCodes.map((c) => ({ label: c, value: c }))} defaultSelect className="usa-input--medium" - onChange={onChange} />
-
+ ); }; diff --git a/frontend/src/app/Settings/Facility/FacilityForm.test.tsx b/frontend/src/app/Settings/Facility/FacilityForm.test.tsx index 28f3d16d46b..c584bf3e92c 100644 --- a/frontend/src/app/Settings/Facility/FacilityForm.test.tsx +++ b/frontend/src/app/Settings/Facility/FacilityForm.test.tsx @@ -45,13 +45,13 @@ const validFacility: Facility = { orderingProvider: { firstName: "Frank", lastName: "Grimes", - NPI: "000", + NPI: "1231231231", street: null, zipCode: null, state: null, middleName: null, suffix: null, - phone: "phone", + phone: "2015592381", streetTwo: null, city: null, }, @@ -187,7 +187,7 @@ describe("FacilityForm", () => { }); await act(async () => await userEvent.clear(facilityNameInput)); await act(async () => await userEvent.tab()); - const nameWarning = await screen.findByText("Facility name is missing"); + const nameWarning = await screen.findByText("Facility name is required"); expect(nameWarning).toBeInTheDocument(); await act( async () => await userEvent.type(facilityNameInput, "Test facility A") @@ -202,7 +202,7 @@ describe("FacilityForm", () => { await act(async () => await userEvent.clear(facilityStreetAddressInput)); await act(async () => await userEvent.tab()); const streetAddressWarning = await screen.findByText( - "Facility street is missing" + "Facility street is required" ); expect(streetAddressWarning).toBeInTheDocument(); await act( @@ -216,7 +216,7 @@ describe("FacilityForm", () => { await act(async () => await userEvent.clear(facilityZipCodeInput)); await act(async () => await userEvent.tab()); const facilityZipCodeWarning = await screen.findByText( - "Facility zip code is missing" + "Facility ZIP code is required" ); expect(facilityZipCodeWarning).toBeInTheDocument(); }); @@ -450,7 +450,7 @@ describe("FacilityForm", () => { expect(saveFacility).toBeCalledTimes(1); }); - it("doesn't allow a Z-CLIA for a non-Washington state", async () => { + it("doesn't allow a Z-CLIA for a non (WA, NM, VT, IL, WY) state", async () => { const marylandFacility: Facility = validFacility; marylandFacility.state = "MD"; render( @@ -786,13 +786,23 @@ describe("FacilityForm", () => { await validateAddress(saveFacility, "suggested address"); expect(getIsValidZipForStateSpy).toBeCalledTimes(1); expect(saveFacility).toBeCalledWith({ - ...validFacility, - name: "La Croix Facility", - ...addresses[0].good, + facility: { + name: "La Croix Facility", + cliaNumber: "12D4567890", + phone: "(202) 395-3080", + email: null, + ...addresses[0].good, + }, orderingProvider: { - ...validFacility.orderingProvider, + firstName: "Frank", + lastName: "Grimes", + NPI: "1231231231", + middleName: null, + suffix: null, + phone: "(201) 559-2381", ...addresses[1].good, }, + devices: ["device-1", "device-2"], }); }); diff --git a/frontend/src/app/Settings/Facility/FacilityForm.tsx b/frontend/src/app/Settings/Facility/FacilityForm.tsx index a8d5a5ecb6b..c516d102da9 100644 --- a/frontend/src/app/Settings/Facility/FacilityForm.tsx +++ b/frontend/src/app/Settings/Facility/FacilityForm.tsx @@ -1,142 +1,54 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { + getBestSuggestion, + getZipCodeData, + isValidZipCodeForState, + suggestionIsCloseEnough, +} from "../../utils/smartyStreets"; import iconSprite from "../../../../node_modules/uswds/dist/img/sprite.svg"; import Button from "../../commonComponents/Button/Button"; import RequiredMessage from "../../commonComponents/RequiredMessage"; import { LinkWithQuery } from "../../commonComponents/LinkWithQuery"; import { showError } from "../../utils/srToast"; -import { stateCodes, urls } from "../../../config/constants"; -import { getStateNameFromCode, requiresOrderProvider } from "../../utils/state"; -import { - getBestSuggestion, - suggestionIsCloseEnough, - isValidZipCodeForState, - getZipCodeData, -} from "../../utils/smartyStreets"; import { AddressConfirmationModal, AddressSuggestionConfig, } from "../../commonComponents/AddressConfirmationModal"; import Prompt from "../../utils/Prompt"; -import { focusOnFirstInputWithError } from "../../utils/formValidation"; +import { formatPhoneNumberParens } from "../../utils/text"; import ManageDevices from "./Components/ManageDevices"; import OrderingProviderSettings from "./Components/OrderingProvider"; import FacilityInformation from "./Components/FacilityInformation"; -import { FacilityErrors, facilitySchema } from "./facilitySchema"; - -export type ValidateField = (field: keyof FacilityErrors) => Promise; - -export const useFacilityValidation = (facility: Facility) => { - const [errors, setErrors] = useState({}); - const focusOnce = useRef(false); - - const clearError = useCallback( - (field: keyof FacilityErrors) => { - if (errors[field]) { - setErrors({ ...errors, [field]: undefined }); - } - }, - [errors] - ); - - const validateField = useCallback( - async (field: keyof FacilityErrors) => { - try { - clearError(field); - await facilitySchema.validateAt(field, facility, { - context: { - orderingProviderIsRequired: requiresOrderProvider(facility.state), - }, - }); - } catch (e: any) { - const errorMessage = - field === "state" && stateCodes.includes(facility[field]) - ? createStateError(facility.state) - : e.errors.join(", "); - setErrors((existingErrors) => ({ - ...existingErrors, - [field]: errorMessage, - })); - } - }, - [facility, clearError] - ); - const validateFacility = async () => { - try { - await facilitySchema.validate(facility, { - abortEarly: false, - context: { - orderingProviderIsRequired: requiresOrderProvider(facility.state), - }, - }); - return ""; - } catch (e: any) { - const errors = e.inner.reduce( - ( - acc: FacilityErrors, - el: { path: keyof FacilityErrors; message: string } - ) => { - acc[el.path] = - el.path === "state" ? createStateError(facility.state) : el.message; - return acc; - }, - {} as FacilityErrors - ); - setErrors(errors); - focusOnce.current = true; - showError( - "Please check the form to make sure you complete all of the required fields.", - "Form Errors" - ); - return "error"; - } +export type FacilityFormData = { + facility: { + name: string; + phone: string; + email: string | null; + street: string; + streetTwo: string | null; + city: string | null; + zipCode: string; + state: string; + cliaNumber: string; }; - - /** - * Focus on fields with errors - */ - useEffect(() => { - if ( - focusOnce.current && - (errors.name || - errors.phone || - errors.street || - errors.zipCode || - errors.state || - errors.cliaNumber || - errors.deviceTypes || - errors["orderingProvider.firstName"] || - errors["orderingProvider.lastName"] || - errors["orderingProvider.NPI"] || - errors["orderingProvider.phone"]) - ) { - focusOnFirstInputWithError(); - focusOnce.current = false; - } - }, [errors]); - - return { errors, clearError, validateField, validateFacility }; -}; - -const createStateError = (stateCode: string | number) => { - return ( - <> - - SimpleReport isn’t currently supported in{" "} - {getStateNameFromCode(stateCode)}. - - - See a{" "} - - {" "} - list of states where SimpleReport is supported - - . - - - ); + orderingProvider: { + firstName: string | null; + middleName: string | null; + lastName: string | null; + suffix: string | null; + NPI: string | null; + street: string | null; + streetTwo: string | null; + city: string | null; + state: string; + zipCode: string | null; + phone: string | null; + }; + devices: string[]; }; type AddressOptions = "facility" | "provider"; @@ -144,48 +56,91 @@ type AddressOptions = "facility" | "provider"; export interface Props { facility: Facility; deviceTypes: FacilityFormDeviceType[]; - saveFacility: (facility: Facility) => void; + saveFacility: (facilityFormData: FacilityFormData) => void; newOrg?: boolean; } +const getDefaultValues = (facility: Facility) => { + return { + facility: { + name: facility.name, + phone: facility.phone + ? formatPhoneNumberParens(facility.phone) ?? "" + : "", + email: facility.email, + street: facility.street, + streetTwo: facility.streetTwo, + city: facility.city, + zipCode: facility.zipCode, + state: facility.state, + cliaNumber: facility.cliaNumber, + }, + orderingProvider: { + firstName: facility.orderingProvider.firstName, + middleName: facility.orderingProvider.middleName, + lastName: facility.orderingProvider.lastName, + suffix: facility.orderingProvider.suffix, + NPI: facility.orderingProvider.NPI, + phone: facility.orderingProvider.phone + ? formatPhoneNumberParens(facility.orderingProvider.phone) ?? "" + : null, + street: facility.orderingProvider.street, + streetTwo: facility.orderingProvider.streetTwo, + city: facility.orderingProvider.city, + zipCode: facility.orderingProvider.zipCode, + state: facility.orderingProvider.state ?? "", + }, + devices: facility.deviceTypes.length + ? facility.deviceTypes.map((device) => device.internalId) + : [], + }; +}; const FacilityForm: React.FC = (props) => { - const [facility, updateFormData] = useState(props.facility); - const [formChanged, updateFormChanged] = useState(false); + const facility = props.facility; const [addressModalOpen, setAddressModalOpen] = useState(false); const [suggestedAddresses, setSuggestedAddresses] = useState< AddressSuggestionConfig[] >([]); - - const updateForm: typeof updateFormData = (data) => { - updateFormData(data); - updateFormChanged(true); - }; - - const updateFacility = (newFacility: Facility) => { - updateForm({ - ...facility, - ...newFacility, - }); - }; - - const updateProvider = (orderingProvider: Provider) => { - updateForm({ - ...facility, - orderingProvider, - }); - }; - - const updateSelectedDevices = (deviceTypes: DeviceType[]) => { - updateForm((facility) => ({ - ...facility, - deviceTypes, - })); + const { + watch, + register, + handleSubmit, + setFocus, + control, + setValue, + setError, + getValues, + getFieldState, + clearErrors, + formState: { errors, isSubmitting, isDirty }, + } = useForm({ + mode: "onBlur", + defaultValues: getDefaultValues(facility), + }); + + const updateSelectedDevices = (selectedItems: string[]) => { + setValue("devices", selectedItems, { shouldDirty: true }); + if (selectedItems.length === 0) { + setError("devices", { + type: "required", + message: "There must be at least one device", + }); + } else { + clearErrors("devices"); + } }; - const { errors, clearError, validateField, validateFacility } = - useFacilityValidation(facility); - - const getFacilityAddress = (f: Nullable): AddressWithMetaData => { + const getFacilityAddress = (f: { + name: string; + phone: string; + email: string | null; + street: string; + streetTwo: string | null; + city: string | null; + zipCode: string; + state: string; + cliaNumber: string; + }): AddressWithMetaData => { return { street: f.street || "", streetTwo: f.streetTwo, @@ -226,8 +181,8 @@ const FacilityForm: React.FC = (props) => { }; const validateFacilityAddresses = async () => { - const originalFacilityAddress = getFacilityAddress(facility); - + let updatedValues = getUpdatedValues(); + const originalFacilityAddress = getFacilityAddress(updatedValues.facility); const zipCodeData = await getZipCodeData(originalFacilityAddress.zipCode); const isValidZipForState = isValidZipCodeForState( originalFacilityAddress.state, @@ -235,7 +190,11 @@ const FacilityForm: React.FC = (props) => { ); if (!isValidZipForState) { - showError("Invalid ZIP code for this state", "Form Errors"); + setError("facility.zipCode", { + type: "validZipForState", + message: "Invalid ZIP code for this state", + }); + setFocus("facility.zipCode", { shouldSelect: true }); return; } @@ -261,7 +220,7 @@ const FacilityForm: React.FC = (props) => { } if (facilityCloseEnough && providerCloseEnough) { - props.saveFacility(facility); + props.saveFacility(updatedValues); } else { const suggestions: AddressSuggestionConfig[] = []; if (!facilityCloseEnough) { @@ -286,37 +245,78 @@ const FacilityForm: React.FC = (props) => { } }; + const getUpdatedValues = ( + addresses?: Partial> + ) => { + const values = getValues(); + let adjustedFacility: FacilityFormData; + + if (addresses) { + adjustedFacility = { + facility: { + ...values.facility, + ...addresses.facility, + }, + orderingProvider: { + ...values.orderingProvider, + ...addresses.provider, + }, + devices: values.devices || [], + }; + } else { + adjustedFacility = { + facility: { + ...values.facility, + }, + orderingProvider: { + ...values.orderingProvider, + }, + devices: values.devices || [], + }; + } + + return adjustedFacility; + }; + const updateAddressesAndSave = ( addresses: Partial> ) => { - const adjustedFacility: Facility = { - ...facility, - ...addresses.facility, - orderingProvider: { - ...facility.orderingProvider, - ...addresses.provider, - }, - }; - updateFormData(adjustedFacility); + const adjustedFacility = getUpdatedValues(addresses); + setValue("facility", adjustedFacility.facility); + setValue("orderingProvider", adjustedFacility.orderingProvider); props.saveFacility(adjustedFacility); }; - const validateAndSaveFacility = async () => { - if ((await validateFacility()) === "error") { - return; - } + const onSubmit = async (facilityData: FacilityFormData) => { + const { facility, orderingProvider, devices } = facilityData; + setValue("facility", facility); + setValue("orderingProvider", orderingProvider); + setValue("devices", devices); + clearErrors(); await validateFacilityAddresses(); }; + const onError = () => { + showError( + "Please check the form to make sure you complete all of the required fields.", + "Form Errors" + ); + }; + + const formCurrentValues = watch(); + return ( <> -
+
@@ -354,11 +354,10 @@ const FacilityForm: React.FC = (props) => { }} >
@@ -369,35 +368,46 @@ const FacilityForm: React.FC = (props) => {
- + ( + setFocus(name), + }} + onChange={updateSelectedDevices} + /> + )} + defaultValue={formCurrentValues.devices} + name="devices" + control={control} + rules={{ required: "There must be at least one device" }} /> -
+
= (props) => { }} onClose={() => setAddressModalOpen(false)} /> -
+ ); }; diff --git a/frontend/src/app/Settings/Facility/FacilityFormContainer.test.tsx b/frontend/src/app/Settings/Facility/FacilityFormContainer.test.tsx index 6571fe82615..72471566f1f 100644 --- a/frontend/src/app/Settings/Facility/FacilityFormContainer.test.tsx +++ b/frontend/src/app/Settings/Facility/FacilityFormContainer.test.tsx @@ -18,7 +18,7 @@ import { } from "../../../generated/graphql"; import SRToastContainer from "../../commonComponents/SRToastContainer"; -import { Props as FacilityFormProps } from "./FacilityForm"; +import { FacilityFormData, Props as FacilityFormProps } from "./FacilityForm"; import FacilityFormContainer from "./FacilityFormContainer"; export const deviceTypes: FacilityFormDeviceType[] = [ @@ -59,6 +59,34 @@ const mockFacility: Facility = { }, }; +const mockFacilityFormData: FacilityFormData = { + facility: { + cliaNumber: "99D1234567", + name: "Testing Site", + street: "1001 Rodeo Dr", + streetTwo: "qwqweqwe123123", + city: "Los Angeles", + state: "CA", + zipCode: "90000", + phone: "(516) 432-1390", + email: "testingsite@disorg.com", + }, + devices: ["Fake Device 1"], + orderingProvider: { + firstName: "Fred", + middleName: null, + lastName: "Flintstone", + suffix: null, + NPI: "PEBBLES", + street: "123 Main Street", + streetTwo: "", + city: "Oz", + state: "KS", + zipCode: "12345", + phone: "(202) 555-1212", + }, +}; + const getFacilityRequest: any = { request: { query: GET_FACILITY_QUERY, @@ -122,7 +150,7 @@ const facilityVariables: any = { orderingProviderState: mockFacility.orderingProvider.state, orderingProviderZipCode: mockFacility.orderingProvider.zipCode, orderingProviderPhone: mockFacility.orderingProvider.phone || null, - devices: mockFacility.deviceTypes.map((d) => d.internalId), + devices: ["Fake Device 1"], }; const store = configureStore([])({ @@ -156,6 +184,19 @@ const mocks = [ }, }, }, + { + request: { + query: UPDATE_FACILITY_MUTATION, + variables: { ...facilityVariables, facilityId: mockFacility.id }, + }, + result: { + data: { + updateFacility: { + id: mockFacility.id, + }, + }, + }, + }, { request: { query: ADD_FACILITY_MUTATION, @@ -174,7 +215,10 @@ const mocks = [ jest.mock("./FacilityForm", () => { return (f: FacilityFormProps) => { return ( - ); diff --git a/frontend/src/app/Settings/Facility/FacilityFormContainer.tsx b/frontend/src/app/Settings/Facility/FacilityFormContainer.tsx index d77ea17ac78..e58634f8131 100644 --- a/frontend/src/app/Settings/Facility/FacilityFormContainer.tsx +++ b/frontend/src/app/Settings/Facility/FacilityFormContainer.tsx @@ -13,7 +13,7 @@ import { useUpdateFacilityMutation, } from "../../../generated/graphql"; -import FacilityForm from "./FacilityForm"; +import FacilityForm, { FacilityFormData } from "./FacilityForm"; interface Props { newOrg?: boolean; @@ -33,7 +33,9 @@ const FacilityFormContainer: any = (props: Props) => { const [addFacilityMutation] = useAddFacilityMutation(); const [saveSuccess, updateSaveSuccess] = useState(false); - const [facilityData, setFacilityData] = useState(null); + const [facilityData, setFacilityData] = useState< + FacilityFormData | undefined + >(undefined); const dispatch = useDispatch(); if (loading) { return

Loading...

; @@ -54,11 +56,13 @@ const FacilityFormContainer: any = (props: Props) => { ); } - const saveFacility = async (facility: Facility) => { + const saveFacility = async (facilityData: FacilityFormData) => { if (appInsights) { appInsights.trackEvent({ name: "Save Settings" }); } - const provider = facility.orderingProvider; + const provider = facilityData.orderingProvider; + const facility = facilityData.facility; + const facilityInfo = { testingFacilityName: facility.name, cliaNumber: facility.cliaNumber, @@ -80,7 +84,7 @@ const FacilityFormContainer: any = (props: Props) => { orderingProviderState: provider.state, orderingProviderZipCode: provider.zipCode, orderingProviderPhone: provider.phone || null, - devices: facility.deviceTypes.map((d) => d.internalId), + devices: facilityData.devices, }; const savedFacilityId = facilityId @@ -95,7 +99,7 @@ const FacilityFormContainer: any = (props: Props) => { ); setFacilityData(() => ({ - ...facility, + ...facilityData, id: savedFacilityId as string, })); diff --git a/frontend/src/app/Settings/Facility/facilitySchema.ts b/frontend/src/app/Settings/Facility/facilitySchema.ts deleted file mode 100644 index 34bca17b24c..00000000000 --- a/frontend/src/app/Settings/Facility/facilitySchema.ts +++ /dev/null @@ -1,170 +0,0 @@ -import * as yup from "yup"; -import { PhoneNumberUtil } from "google-libphonenumber"; - -import { liveJurisdictions } from "../../../config/constants"; -import { - isValidCLIANumber, - stateRequiresCLIANumberValidation, -} from "../../utils/clia"; -import { isEmptyString } from "../../utils"; - -const phoneUtil = PhoneNumberUtil.getInstance(); - -type PartialBy = Omit & Partial>; - -export type RequiredFacilityFields = PartialBy< - Facility, - "id" | "email" | "streetTwo" | "city" | "orderingProvider" ->; - -function orderingProviderIsRequired( - this: yup.TestContext>, - input = "" -): boolean { - if (this?.options?.context?.orderingProviderIsRequired) { - return !isEmptyString(input); - } - return true; -} - -function isValidNpi( - this: yup.TestContext>, - input = "" -): boolean { - if (this?.options?.context?.orderingProviderIsRequired) { - let npiValidator = /^\d{1,10}$/; - return npiValidator.test(input); - } - return true; -} - -type RequiredProviderFields = Nullable>; - -const orderingProviderFormatError = (field: string) => - field === "NPI" - ? "NPI should be a 10-digit numerical value (##########)" - : `Ordering provider ${field} is incorrectly formatted`; - -const providerSchema: yup.SchemaOf = yup.object({ - firstName: yup - .string() - .test( - "ordering-provider-first-name", - orderingProviderFormatError("first name"), - orderingProviderIsRequired - ), - middleName: yup.string().nullable(), - lastName: yup - .string() - .test( - "ordering-provider-last-name", - orderingProviderFormatError("last name"), - orderingProviderIsRequired - ), - suffix: yup.string().nullable(), - NPI: yup - .string() - .test( - "ordering-provider-npi", - orderingProviderFormatError("NPI"), - isValidNpi - ), - phone: yup - .string() - .test( - "ordering-provider-phone", - orderingProviderFormatError("phone"), - orderingProviderIsRequired - ), - street: yup.string().nullable(), - streetTwo: yup.string().nullable(), - city: yup.string().nullable(), - state: yup.string().nullable(), - zipCode: yup.string().nullable(), -}); - -const deviceTypeSchema: yup.SchemaOf = yup.object({ - internalId: yup.string().required(), - name: yup.string().required(), - testLength: yup.number().optional(), -}); - -export const facilitySchema: yup.SchemaOf = yup.object({ - name: yup.string().required("Facility name is missing"), - cliaNumber: yup - .string() - .required() - .test( - "facility-clia", - "CLIA numbers must be 10 characters (##D#######), or a special temporary number from CA, IL, VT, WA, WY, or the Department of Defense", - (input, facility) => { - if (!stateRequiresCLIANumberValidation(facility.parent.state)) { - return true; - } - - if (!input) { - return false; - } - - return isValidCLIANumber(input, facility.parent.state); - } - ), - street: yup.string().required("Facility street is missing"), - zipCode: yup.string().required("Facility zip code is missing"), - deviceTypes: yup - .array() - .of(deviceTypeSchema) - .min(1, "There must be at least one device") - .required("There must be at least one device"), - orderingProvider: providerSchema.nullable(), - phone: yup - .string() - .test( - "facility-phone", - "Facility phone number is missing or invalid", - function (input) { - if (!input) { - return false; - } - try { - const number = phoneUtil.parseAndKeepRawInput(input, "US"); - return phoneUtil.isValidNumber(number); - } catch (e: any) { - return false; - } - } - ) - .required("Facility phone number is missing or invalid"), - state: yup - .string() - .test("facility-state", "Facility state is missing", function (input) { - if (!input) { - return false; - } - - return liveJurisdictions.includes(input); - }) - .required("Facility state is missing"), - id: yup.string(), - email: yup.string().email("Email is incorrectly formatted").nullable(), - streetTwo: yup.string().nullable(), - city: yup.string().nullable(), -}); - -type FacilityErrorKeys = - | keyof Facility - | "orderingProvider.firstName" - | "orderingProvider.middleName" - | "orderingProvider.lastName" - | "orderingProvider.suffix" - | "orderingProvider.NPI" - | "orderingProvider.phone" - | "orderingProvider.street" - | "orderingProvider.streetTwo" - | "orderingProvider.city" - | "orderingProvider.state" - | "orderingProvider.zipCode"; - -export type FacilityErrors = Partial< - Record ->; diff --git a/frontend/src/app/commonComponents/Dropdown.tsx b/frontend/src/app/commonComponents/Dropdown.tsx index b6beb3946e7..51645d615b2 100644 --- a/frontend/src/app/commonComponents/Dropdown.tsx +++ b/frontend/src/app/commonComponents/Dropdown.tsx @@ -27,7 +27,7 @@ interface Props { errorMessage?: React.ReactNode; validationStatus?: "error" | "success"; selectClassName?: string; - hintText?: string; + hintText?: string | React.ReactNode; registrationProps?: UseFormRegisterReturn; } diff --git a/frontend/src/app/commonComponents/MultiSelect/MultiSelect.tsx b/frontend/src/app/commonComponents/MultiSelect/MultiSelect.tsx index 7f6791fa9a0..2ed668d7318 100644 --- a/frontend/src/app/commonComponents/MultiSelect/MultiSelect.tsx +++ b/frontend/src/app/commonComponents/MultiSelect/MultiSelect.tsx @@ -44,7 +44,7 @@ const Pill = (props: PillProps) => (
{props.option.label}