diff --git a/app/javascript/__tests__/api/formApiService.test.ts b/app/javascript/__tests__/api/formApiService.test.ts index 7674c216c..50b5b7e74 100644 --- a/app/javascript/__tests__/api/formApiService.test.ts +++ b/app/javascript/__tests__/api/formApiService.test.ts @@ -1,5 +1,10 @@ import { post, apiDelete } from "../../api/apiService" -import { submitForm, uploadProofFile, deleteUploadedProofFile } from "../../api/formApiService" +import { + submitForm, + uploadProofFile, + deleteUploadedProofFile, + locateVerifiedAddress, +} from "../../api/formApiService" jest.mock("axios") @@ -88,4 +93,27 @@ describe("formApiService", () => { }) }) }) + + describe("locateVerifiedAddress", () => { + it("sends the address fields to validate with the correct payload", async () => { + const result = await locateVerifiedAddress({ + street1: "123 Main St", + street2: "Apt 4B", + city: "San Francisco", + state: "CA", + zip: "94105", + }) + + expect(post).toHaveBeenCalledWith("/api/v1/addresses/validate.json", { + address: { + street1: "123 Main St", + street2: "Apt 4B", + city: "San Francisco", + state: "CA", + zip: "94105", + }, + }) + expect(result).toEqual({ id: "test-id" }) + }) + }) }) diff --git a/app/javascript/__tests__/pages/form/components/VerifyAddress.test.tsx b/app/javascript/__tests__/pages/form/components/VerifyAddress.test.tsx new file mode 100644 index 000000000..a153df279 --- /dev/null +++ b/app/javascript/__tests__/pages/form/components/VerifyAddress.test.tsx @@ -0,0 +1,105 @@ +import React from "react" +import { screen, waitFor } from "@testing-library/react" +import { userEvent } from "@testing-library/user-event" +import { t } from "@bloom-housing/ui-components" +import VerifyAddress from "../../../../pages/form/components/VerifyAddress" +import { renderWithFormContextWrapper } from "../../../__util__/renderUtils" +import { locateVerifiedAddress } from "../../../../api/formApiService" +import "@testing-library/jest-dom" + +jest.mock("../../../../api/formApiService", () => ({ + locateVerifiedAddress: jest.fn(), +})) + +const mockLocateVerifiedAddress = locateVerifiedAddress as jest.Mock + +const renderVerifyAddressComponent = (formData: Record = {}) => { + const { mockHandleNextStep, mockHandlePrevStep, mockSaveFormData } = renderWithFormContextWrapper( + , + { + formData, + renderForm: false, + stepInfoMap: [{ slug: "verify-address", fieldNames: ["primaryApplicantAddress"] }], + } + ) + return { mockHandleNextStep, mockHandlePrevStep, mockSaveFormData } +} + +describe("VerifyAddress", () => { + beforeEach(() => { + jest.clearAllMocks() + mockLocateVerifiedAddress.mockResolvedValue({ + address: { + street1: "123 Main St", + street2: "Apt 4B", + city: "San Francisco", + state: "CA", + zip: "94105", + invalid: false, + }, + }) + }) + + it("displays the verify address page", async () => { + renderVerifyAddressComponent() + await waitFor(() => { + expect(screen.getByText(t("b2aVerifyAddress.title"))).toBeInTheDocument() + }) + }) + + it("displays the formatted address", async () => { + renderVerifyAddressComponent() + await waitFor(() => { + expect(screen.getByText(/123 Main St Apt 4B/)).toBeInTheDocument() + expect(screen.getByText(/San Francisco, CA, 94105/)).toBeInTheDocument() + }) + }) + + it("displays an error message when radio is not selected", async () => { + const { mockHandleNextStep, mockSaveFormData } = renderVerifyAddressComponent() + const user = userEvent.setup() + await waitFor(() => { + expect(screen.getByText(/next/i)).toBeInTheDocument() + }) + await user.click(screen.getByText(/next/i)) + await waitFor(() => { + expect(screen.getByText(t("error.confirmedAddress"))).toBeInTheDocument() + }) + expect(mockHandleNextStep).not.toHaveBeenCalled() + expect(mockSaveFormData).not.toHaveBeenCalled() + }) + + it("updates form data with the verified address", async () => { + const { mockHandleNextStep, mockSaveFormData } = renderVerifyAddressComponent({ + primaryApplicantAddressStreet: "123 Main St", + primaryApplicantAddressAptOrUnit: "Apt 4B", + primaryApplicantAddressCity: "San Francisco", + primaryApplicantAddressState: "CA", + primaryApplicantAddressZipcode: "94105", + }) + const user = userEvent.setup() + await waitFor(() => { + expect(screen.getByRole("radio")).toBeInTheDocument() + }) + await user.click(screen.getByRole("radio")) + await user.click(screen.getByRole("button", { name: /next/i })) + await waitFor(() => { + expect(mockSaveFormData).toHaveBeenCalledWith( + expect.objectContaining({ + primaryApplicantAddressStreet: "123 Main St", + primaryApplicantAddressAptOrUnit: "Apt 4B", + primaryApplicantAddressCity: "San Francisco", + primaryApplicantAddressState: "CA", + primaryApplicantAddressZipcode: "94105", + }) + ) + }) + expect(mockHandleNextStep).toHaveBeenCalled() + }) + + it("shows the loading overlay initially", () => { + mockLocateVerifiedAddress.mockImplementation(() => new Promise(() => {})) + renderVerifyAddressComponent() + expect(screen.getByTestId("loading-overlay")).toBeInTheDocument() + }) +}) diff --git a/app/javascript/api/formApiService.ts b/app/javascript/api/formApiService.ts index 6b44ebfd5..54dbcd39c 100644 --- a/app/javascript/api/formApiService.ts +++ b/app/javascript/api/formApiService.ts @@ -49,6 +49,38 @@ export const deleteUploadedProofFile = async ( }).then((response) => response.data) } +export type VerifiedAddressResponse = { + address: { + street1?: string + street2?: string + city?: string + state?: string + zip?: string + invalid?: boolean + error?: string + verifications?: { + delivery: { + success: boolean + } + } + } + error?: string +} + +export type Address = { + street1?: string + street2?: string + city?: string + state?: string + zip?: string +} + +export const locateVerifiedAddress = async (address: Address): Promise => { + return post("/api/v1/addresses/validate.json", { + address, + }).then((response) => response.data) +} + export enum LanguagePrefix { English = "English", Spanish = "Spanish", diff --git a/app/javascript/pages/form/components/ListingApplyStepWrapper.module.scss b/app/javascript/pages/form/components/ListingApplyStepWrapper.module.scss index 5ed5f38a6..1ccd80377 100644 --- a/app/javascript/pages/form/components/ListingApplyStepWrapper.module.scss +++ b/app/javascript/pages/form/components/ListingApplyStepWrapper.module.scss @@ -12,6 +12,10 @@ font-size: var(--seeds-type-heading-size-2xl); } text-align: center; + + &.no-back { + margin-top: var(--seeds-s4); + } } .step-delete-member-subfooter { diff --git a/app/javascript/pages/form/components/VerifyAddress.module.scss b/app/javascript/pages/form/components/VerifyAddress.module.scss new file mode 100644 index 000000000..0e413f480 --- /dev/null +++ b/app/javascript/pages/form/components/VerifyAddress.module.scss @@ -0,0 +1,6 @@ +.addressSection { + display: flex; + flex-direction: row; + margin: var(--seeds-spacer-content); + gap: var(--seeds-spacer-content); +} diff --git a/app/javascript/pages/form/components/VerifyAddress.tsx b/app/javascript/pages/form/components/VerifyAddress.tsx index 1ebc0d556..a7a23e7c9 100644 --- a/app/javascript/pages/form/components/VerifyAddress.tsx +++ b/app/javascript/pages/form/components/VerifyAddress.tsx @@ -1,29 +1,115 @@ -import React from "react" -import { t } from "@bloom-housing/ui-components" -import { Button, Heading } from "@bloom-housing/ui-seeds" +import React, { useState, useEffect } from "react" +import { useForm, FormProvider } from "react-hook-form" +import { Form, Field, t, LoadingOverlay } from "@bloom-housing/ui-components" +import { Button, Card, Heading } from "@bloom-housing/ui-seeds" import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" import { useFormEngineContext } from "../../../formEngine/formEngineContext" +import { locateVerifiedAddress } from "../../../api/formApiService" +import { getFormattedAddress } from "../../../util/formEngineUtil" +import stepStyles from "./ListingApplyStepWrapper.module.scss" +import styles from "./VerifyAddress.module.scss" -const VerifyAddress = () => { - const formEngineContext = useFormEngineContext() - const { handleNextStep, handlePrevStep } = formEngineContext +interface VerifyAddressProps { + address: string +} + +const VerifyAddress = ({ address }: VerifyAddressProps) => { + const { formData, saveFormData, handleNextStep, handlePrevStep } = useFormEngineContext() + const formMethods = useForm({ + mode: "onChange", + shouldFocusError: false, + }) + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, handleSubmit, errors, clearErrors } = formMethods + const [validatedAddress, setValidatedAddress] = useState(null) + const onSubmit = () => { + const updatedFormData = { + [`${address}Street`]: validatedAddress.street1, + [`${address}AptOrUnit`]: validatedAddress.street2, + [`${address}City`]: validatedAddress.city, + [`${address}State`]: validatedAddress.state, + [`${address}Zipcode`]: validatedAddress.zip, + } + saveFormData(updatedFormData) + handleNextStep() + } + + useEffect(() => { + void (async () => { + try { + const response = await locateVerifiedAddress({ + street1: formData[`${address}Street`] as string, + street2: formData[`${address}AptOrUnit`] as string, + city: formData[`${address}City`] as string, + state: formData[`${address}State`] as string, + zip: formData[`${address}Zipcode`] as string, + }) + if (response.address && !response.address.invalid) { + setValidatedAddress({ + street1: response.address.street1, + street2: response.address.street2, + city: response.address.city, + state: response.address.state, + zip: response.address.zip, + }) + } + } catch { + alert(t("error.alert.badRequest")) + handlePrevStep() + } + })() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + const formattedAddress = getFormattedAddress({ + street1: validatedAddress?.street1, + street2: validatedAddress?.street2, + city: validatedAddress?.city, + state: validatedAddress?.state, + zip: validatedAddress?.zip, + }) return ( - <> - - - - - VerifyAddress Component - - - - - + + + + + + {t("b2aVerifyAddress.title")} + + +
+ + clearErrors(address)} + errorMessage={errors?.[address]?.message} + inputProps={{ value: address }} + label={ + <> + {formattedAddress.streets} +
+ {formattedAddress.cityStateZip} + + } + /> + +
+ + + +
+
+
+
) } diff --git a/app/javascript/pages/form/components/household/HouseholdMemberMultiStepWrapper.tsx b/app/javascript/pages/form/components/household/HouseholdMemberMultiStepWrapper.tsx index 4649ec7b9..68e451a83 100644 --- a/app/javascript/pages/form/components/household/HouseholdMemberMultiStepWrapper.tsx +++ b/app/javascript/pages/form/components/household/HouseholdMemberMultiStepWrapper.tsx @@ -104,7 +104,7 @@ const HouseholdMemberMultiStepWrapper = ({ ) } case "HouseholdMemberVerifyAddress": { - return + return } } } diff --git a/app/javascript/util/formEngineUtil.ts b/app/javascript/util/formEngineUtil.ts index 78c77b3bb..db4d8be26 100644 --- a/app/javascript/util/formEngineUtil.ts +++ b/app/javascript/util/formEngineUtil.ts @@ -165,3 +165,15 @@ export const updateFormPath = (newStepIndex: number, stepInfoMap: StepInfoSchema const newPath = paths.join("/") window.history.pushState({}, "", newPath) } + +export const getFormattedAddress = (address: { + street1?: string + street2?: string + city?: string + state?: string + zip?: string +}) => { + const streets = [address.street1, address.street2].filter(Boolean).join(" ") + const cityStateZip = [address.city, address.state, address.zip].filter(Boolean).join(", ") + return { streets, cityStateZip } +}