Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion app/javascript/__tests__/api/formApiService.test.ts
Original file line number Diff line number Diff line change
@@ -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")

Expand Down Expand Up @@ -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",
})
Comment thread
cliu02 marked this conversation as resolved.

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" })
})
})
})
105 changes: 105 additions & 0 deletions app/javascript/__tests__/pages/form/components/VerifyAddress.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => {
const { mockHandleNextStep, mockHandlePrevStep, mockSaveFormData } = renderWithFormContextWrapper(
<VerifyAddress address="primaryApplicantAddress" />,
{
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()
})
Comment thread
cliu02 marked this conversation as resolved.
})

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()
})
})
32 changes: 32 additions & 0 deletions app/javascript/api/formApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ export const deleteUploadedProofFile = async (
}).then((response) => response.data)
}

export type VerifiedAddressResponse = {
address: {
Comment thread
cliu02 marked this conversation as resolved.
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<VerifiedAddressResponse> => {
return post<VerifiedAddressResponse>("/api/v1/addresses/validate.json", {
address,
}).then((response) => response.data)
}

export enum LanguagePrefix {
English = "English",
Spanish = "Spanish",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.addressSection {
display: flex;
flex-direction: row;
margin: var(--seeds-spacer-content);
gap: var(--seeds-spacer-content);
}
128 changes: 107 additions & 21 deletions app/javascript/pages/form/components/VerifyAddress.tsx
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
cliu02 marked this conversation as resolved.
[`${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,
Comment thread
cliu02 marked this conversation as resolved.
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 (
<>
<CardSection>
<Button variant="text" onClick={handlePrevStep}>
{t("t.back")}
</Button>
</CardSection>
<CardSection>
<Heading>VerifyAddress Component</Heading>
</CardSection>
<CardSection>
<Button variant="primary" onClick={() => handleNextStep()}>
{t("t.next")}
</Button>
</CardSection>
</>
<FormProvider {...formMethods}>
<LoadingOverlay isLoading={!validatedAddress}>
<Card>
<Card.Header divider="inset">
<Heading className={`${stepStyles["step-title"]} ${stepStyles["no-back"]}`}>
{t("b2aVerifyAddress.title")}
</Heading>
</Card.Header>
<Form onSubmit={handleSubmit(onSubmit)}>
<CardSection className={styles.addressSection}>
<Field
type="radio"
name={address}
register={register}
validation={{
required: t("error.confirmedAddress"),
}}
error={!!errors?.[address]}
onChange={() => clearErrors(address)}
errorMessage={errors?.[address]?.message}
inputProps={{ value: address }}
label={
<>
{formattedAddress.streets}
<br />
{formattedAddress.cityStateZip}
</>
}
/>
<Button type="button" variant="text" onClick={handlePrevStep}>
{t("t.edit")}
</Button>
</CardSection>
<Card.Footer className={stepStyles["step-footer"]}>
<Button variant="primary" type="submit">
{t("t.next")}
</Button>
</Card.Footer>
</Form>
</Card>
</LoadingOverlay>
</FormProvider>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ const HouseholdMemberMultiStepWrapper = ({
)
}
case "HouseholdMemberVerifyAddress": {
return <VerifyAddress />
return <VerifyAddress address="householdMemberAddress" />
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions app/javascript/util/formEngineUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Loading