diff --git a/e2e/example.spec.demo.ts b/e2e/example.spec.demo.ts index 23eaadb15..a9d49959a 100644 --- a/e2e/example.spec.demo.ts +++ b/e2e/example.spec.demo.ts @@ -68,14 +68,14 @@ test('proof of concept', async ({ page }) => { await expect(page.locator('h1')).toContainText('Review warnings'); }); - await test.step('Review warnings: navigate to Provide point of contact', async () => { + await test.step('Review warnings: navigate to Provide filing details', async () => { await page.getByText('I verify the accuracy of').click(); await page.getByRole('button', { name: 'Continue to next step' }).click(); - await expect(page.locator('h1')).toContainText('Provide point of contact'); + await expect(page.locator('h1')).toContainText('Provide filing details'); }); - await test.step('Provide point of contact: navigate to Sign and submit', async () => { - await test.step('Provide point of contact: fill out form', async () => { + await test.step('Provide filing details: navigate to Sign and submit', async () => { + await test.step('Provide filing details: fill out form', async () => { await page.getByLabel('First name').fill(pointOfContactJson.first_name); await page.getByLabel('Last name').fill(pointOfContactJson.last_name); await page @@ -107,7 +107,7 @@ test('proof of concept', async ({ page }) => { .getByLabel('ZIP codeZIP code must be in') .fill(pointOfContactJson.hq_address_zip); }); - await test.step('Provide point of contact: continue to next step', async () => { + await test.step('Provide filing details: continue to next step', async () => { await page.getByRole('button', { name: 'Continue to next step' }).click(); await expect(page.locator('h1')).toContainText('Sign and submit'); }); diff --git a/e2e/fixtures/testFixture.ts b/e2e/fixtures/testFixture.ts index 66fbb7b13..7fa2a1721 100644 --- a/e2e/fixtures/testFixture.ts +++ b/e2e/fixtures/testFixture.ts @@ -322,12 +322,10 @@ export const test = baseTest.extend<{ use, ) => { navigateToReviewWarningsAfterOnlyWarningsUpload; - await test.step('Review warnings: navigate to Provide point of contact', async () => { + await test.step('Review warnings: navigate to Provide filing details', async () => { await page.getByText('I verify the accuracy of').click(); await clickContinueNext(test, page); - await expect(page.locator('h1')).toContainText( - 'Provide point of contact', - ); + await expect(page.locator('h1')).toContainText('Provide filing details'); }); await use(page); }, @@ -337,8 +335,8 @@ export const test = baseTest.extend<{ use, ) => { navigateToProvidePointOfContact; - await test.step('Provide point of contact: navigate to Sign and submit', async () => { - await test.step('Provide point of contact: fill out form', async () => { + await test.step('Provide filing details: navigate to Sign and submit', async () => { + await test.step('Provide filing details: fill out form', async () => { await page.getByLabel('First name').fill(pointOfContactJson.first_name); await page.getByLabel('Last name').fill(pointOfContactJson.last_name); await page @@ -370,7 +368,7 @@ export const test = baseTest.extend<{ .getByLabel('ZIP codeZIP code must be in') .fill(pointOfContactJson.hq_address_zip); }); - await test.step('Provide point of contact: continue to next step', async () => { + await test.step('Provide filing details: continue to next step', async () => { await clickContinueNext(test, page); await expect(page.locator('h1')).toContainText('Sign and submit'); }); diff --git a/e2e/pages/filing-app/filing-step-routing/pointOfContact.spec.ts b/e2e/pages/filing-app/filing-step-routing/pointOfContact.spec.ts index b1b0471a7..aae1b6f15 100644 --- a/e2e/pages/filing-app/filing-step-routing/pointOfContact.spec.ts +++ b/e2e/pages/filing-app/filing-step-routing/pointOfContact.spec.ts @@ -7,7 +7,7 @@ const currentStepPath = '/contact'; const userShouldNotAccess = ['/submit']; -const afterRedirectHeading = 'Provide point of contact'; +const afterRedirectHeading = 'Provide filing details'; const afterRedirectURL = /.*\/contact$/; test(testLabel, async ({ page, navigateToProvidePointOfContact }) => { diff --git a/e2e/pages/filing-app/formAlerts.spec.ts b/e2e/pages/filing-app/formAlerts.spec.ts index f9f241ee5..833fc3d1b 100644 --- a/e2e/pages/filing-app/formAlerts.spec.ts +++ b/e2e/pages/filing-app/formAlerts.spec.ts @@ -189,7 +189,7 @@ test('Form Alerts', async ({ // Point of contact page await test.step('Point of contact page', async () => { await expect(page.locator('h1'), 'h1 is correct').toContainText( - 'Provide point of contact', + 'Provide filing details', ); // Submit Incomplete form @@ -199,13 +199,14 @@ test('Form Alerts', async ({ page.locator('.m-notification__error'), 'Error alert is visible', ).toContainText( - 'There was a problem updating your point of contact informationEnter the first name of the point of contactEnter the last name of the point of contactEnter the phone number of the point of contactEnter a valid phone extensionEnter the email address of the point of contactEnter the street address of the point of contactEnter the city of the point of contactSelect the state or territory of the point of contactEnter the ZIP code of the point of contact', + 'There was a problem updating your filing detailsEnter the first name of the point of contactEnter the last name of the point of contactEnter the phone number of the point of contactEnter the email address of the point of contactEnter the street address of the point of contactEnter the city of the point of contactSelect the state or territory of the point of contactEnter the ZIP code of the point of contact', ); }); // Submit Completed form await test.step('Submit Completed form', async () => { await test.step('Complete form', async () => { + await page.getByText('Voluntary reporter', { exact: true }).click(); await page.getByLabel('First name').fill('Playwright'); await page.getByLabel('Last name').fill('Test'); await page.getByLabel('Phone number').fill('555-555-5555'); diff --git a/e2e/pages/filing-app/point-of-contact/checkPocFormErrors.spec.ts b/e2e/pages/filing-app/point-of-contact/checkPocFormErrors.spec.ts index 370e29bc2..c89f838e0 100644 --- a/e2e/pages/filing-app/point-of-contact/checkPocFormErrors.spec.ts +++ b/e2e/pages/filing-app/point-of-contact/checkPocFormErrors.spec.ts @@ -22,7 +22,7 @@ test('Point of Contact: Checking for form errors based on user input', async ({ await test.step('Point of Contact: Check that the error header render when no input is filled', async () => { await clickContinueNext(test, page); await expect( - page.locator('#PointOfContactFormErrors div').first(), + page.locator('#FilingDetailsFormErrors div').first(), ).toBeVisible(); }); @@ -39,10 +39,10 @@ test('Point of Contact: Checking for form errors based on user input', async ({ await expect(page.locator('form')).toContainText( 'The last name must not contain invalid characters', ); - await expect(page.locator('#PointOfContactFormErrors')).toContainText( + await expect(page.locator('#FilingDetailsFormErrors')).toContainText( 'The first name must not contain invalid characters', ); - await expect(page.locator('#PointOfContactFormErrors')).toContainText( + await expect(page.locator('#FilingDetailsFormErrors')).toContainText( 'The last name must not contain invalid characters', ); }); @@ -57,7 +57,7 @@ test('Point of Contact: Checking for unicode tolerance based on user input', asy await test.step('Point of Contact: Check that the error header render when no input is filled', async () => { await clickContinueNext(test, page); await expect( - page.locator('#PointOfContactFormErrors div').first(), + page.locator('#FilingDetailsFormErrors div').first(), ).toBeVisible(); }); @@ -154,13 +154,13 @@ test('Point of Contact: Checking for unicode tolerance based on user input', asy await clickContinueNext(test, page); - await expect(page.locator('#PointOfContactFormErrors')).toContainText( + await expect(page.locator('#FilingDetailsFormErrors')).toContainText( 'Enter a valid phone number', ); - await expect(page.locator('#PointOfContactFormErrors')).toContainText( + await expect(page.locator('#FilingDetailsFormErrors')).toContainText( 'Enter a valid email address', ); - await expect(page.locator('#PointOfContactFormErrors')).toContainText( + await expect(page.locator('#FilingDetailsFormErrors')).toContainText( 'Enter a valid ZIP code', ); diff --git a/e2e/pages/filing-app/unavailableApis.spec.ts b/e2e/pages/filing-app/unavailableApis.spec.ts index 0a8a012c5..ef4829af2 100644 --- a/e2e/pages/filing-app/unavailableApis.spec.ts +++ b/e2e/pages/filing-app/unavailableApis.spec.ts @@ -108,10 +108,10 @@ test('Blocking API Calls - Error Boundaries', async ({ }); }); - // Provide point of contact page - await test.step('Provide point of contact page', async () => { + // Provide filing details page + await test.step('Provide filing details page', async () => { await verifyApiBlockThenUnblock({ - expectedHeading: 'Provide point of contact', + expectedHeading: 'Provide filing details', endpointPath: '**/v1/admin/me/', endpointLabel: '/v1/admin/me', page, diff --git a/src/api/requests/submitVoluntaryReporterStatus.ts b/src/api/requests/submitVoluntaryReporterStatus.ts new file mode 100644 index 000000000..90b287ba5 --- /dev/null +++ b/src/api/requests/submitVoluntaryReporterStatus.ts @@ -0,0 +1,26 @@ +import { filingApiClient, request } from 'api/axiosService'; +import type { SblAuthProperties } from 'api/useSblAuth'; +import type { FormattedVoluntaryReporterStatusSchema } from 'types/formTypes'; + +interface Options { + data: FormattedVoluntaryReporterStatusSchema; + lei: string; + filingPeriod: string; +} + +const submitVoluntaryReporterStatus = async ( + auth: SblAuthProperties, + options: Options, +): Promise => { + const { data, lei, filingPeriod } = options; + + return request({ + axiosInstance: filingApiClient, + url: `/v1/filing/institutions/${lei}/filings/${filingPeriod}/is-voluntary`, + method: 'put', + data, + headers: { Authorization: `Bearer ${auth.user?.access_token}` }, + }); +}; + +export default submitVoluntaryReporterStatus; diff --git a/src/components/CommonLinks.tsx b/src/components/CommonLinks.tsx index c723726c4..2c89182b4 100644 --- a/src/components/CommonLinks.tsx +++ b/src/components/CommonLinks.tsx @@ -50,7 +50,7 @@ interface UpdatePointOfContactProperties { } function UpdatePointOfContact({ - label = 'update your point of contact information', + label = 'update your filing details', className = 'font-normal', }: UpdatePointOfContactProperties): ReactElement { const { lei, year } = useParams(); diff --git a/src/components/FormErrorHeader.data.ts b/src/components/FormErrorHeader.data.ts index 1419ab10e..02f31e5d1 100644 --- a/src/components/FormErrorHeader.data.ts +++ b/src/components/FormErrorHeader.data.ts @@ -170,6 +170,25 @@ export const IdFormHeaderErrors: IdFormHeaderErrorsType = { export type IdFormHeaderErrorsValues = (typeof IdFormHeaderErrors)[IdZodSchemaErrorsValues]; +// Voluntary Reporter Status - Zod Schema Error Messages +export const VrsZodSchemaErrors = { + isVoluntaryMin: 'You must indicate your voluntary reporter status.', +} as const; + +export type VrsZodSchemaErrorsType = typeof VrsZodSchemaErrors; +export type VrsZodSchemaErrorsKeys = keyof typeof VrsZodSchemaErrors; +export type VrsZodSchemaErrorsValues = + (typeof VrsZodSchemaErrors)[VrsZodSchemaErrorsKeys]; + +// Point of Contact - Form Header Error Messages +export type VrsFormHeaderErrorsType = Record; +export const VrsFormHeaderErrors: VrsFormHeaderErrorsType = { + [VrsZodSchemaErrors.isVoluntaryMin]: + 'Indicate your voluntary reporter status', +} as const; +export type VrsFormHeaderErrorsValues = + (typeof VrsFormHeaderErrors)[VrsZodSchemaErrorsValues]; + // Point of Contact - Zod Schema Error Messages export const PocZodSchemaErrors = { firstNameMin: diff --git a/src/components/RadioButtonGroup.tsx b/src/components/RadioButtonGroup.tsx new file mode 100644 index 000000000..5fe55a38d --- /dev/null +++ b/src/components/RadioButtonGroup.tsx @@ -0,0 +1,57 @@ +import { Label } from 'design-system-react'; + +function RadioButtonGroup({ + id = 'radio-button-group', + label = 'Radio Group', + onChange = undefined, + children, +}: { + id?: string | null | undefined; + label?: string | null | undefined; + onChange?: React.ChangeEventHandler | null | undefined; + children: React.ReactNode; +}): JSX.Element { + const ENTER_KEY_CODE = 13; + + const onKeyDown = (event: React.KeyboardEvent): void => { + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + event.keyCode === ENTER_KEY_CODE + ) { + event.preventDefault(); + event.stopPropagation(); + (event.target as HTMLInputElement).click(); + } + }; + + return ( + <> + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {children} +
+ + ); +} + +RadioButtonGroup.defaultProps = { + id: 'radio-button-group', + label: 'Radio Group', + onChange: undefined, +}; + +export default RadioButtonGroup; diff --git a/src/components/StepIndicator.tsx b/src/components/StepIndicator.tsx index 7f074e1b4..5a6d13275 100644 --- a/src/components/StepIndicator.tsx +++ b/src/components/StepIndicator.tsx @@ -19,7 +19,7 @@ export const mockSteps: StepType[] = [ { status: STEP_CURRENT, label: 'Upload file' }, { status: STEP_INCOMPLETE, label: 'Resolve errors' }, { status: STEP_INCOMPLETE, label: 'Review warnings' }, - { status: STEP_INCOMPLETE, label: 'Provide point of contact' }, + { status: STEP_INCOMPLETE, label: 'Provide filing details' }, { status: STEP_INCOMPLETE, label: 'Sign and submit' }, ]; diff --git a/src/index.css b/src/index.css index 3a19c3f95..363e3a165 100644 --- a/src/index.css +++ b/src/index.css @@ -29,7 +29,7 @@ https://v1.tailwindcss.com/docs/adding-base-styles#using-css */ @apply border-pacific; } - .a-btn.a-btn__secondary .a-btn_icon__on-right{ + .a-btn.a-btn__secondary .a-btn_icon__on-right { @apply border-pacific; } @@ -40,7 +40,7 @@ https://v1.tailwindcss.com/docs/adding-base-styles#using-css */ .a-btn.a-btn__secondary:hover .a-btn_icon__on-left { @apply border-pacificDark; } - + .a-btn.a-btn__secondary:hover .a-btn_icon__on-right { @apply border-pacificDark; } @@ -52,7 +52,7 @@ https://v1.tailwindcss.com/docs/adding-base-styles#using-css */ .a-btn.a-btn__secondary:focus .a-btn_icon__on-left { @apply border-pacificDark; } - + .a-btn.a-btn__secondary:focus .a-btn_icon__on-right { @apply border-pacificDark; } @@ -64,11 +64,10 @@ https://v1.tailwindcss.com/docs/adding-base-styles#using-css */ .a-btn.a-btn__secondary:active .a-btn_icon__on-left { @apply border-navy; } - + .a-btn.a-btn__secondary:active .a-btn_icon__on-right { @apply border-navy; } - } select.error { @@ -127,7 +126,7 @@ td { /* Design System overrides */ /* Alerts - all icons in DS Alerts are colored based on the Alert type */ -a .link-icon-override-color .cf-icon-svg{ +a .link-icon-override-color .cf-icon-svg { @apply fill-pacific; } diff --git a/src/pages/Filing/FilingApp/FilingContact.tsx b/src/pages/Filing/FilingApp/FilingContact.tsx index 2bfdd8a93..9613a9ad8 100644 --- a/src/pages/Filing/FilingApp/FilingContact.tsx +++ b/src/pages/Filing/FilingApp/FilingContact.tsx @@ -1,7 +1,7 @@ -import PointOfContact from 'pages/PointOfContact'; +import FilingDetails from 'pages/PointOfContact'; function FilingContact(): JSX.Element { - return ; + return ; } export default FilingContact; diff --git a/src/pages/Filing/FilingApp/FilingSteps.helpers.tsx b/src/pages/Filing/FilingApp/FilingSteps.helpers.tsx index f34edc650..b1cf1ca6b 100644 --- a/src/pages/Filing/FilingApp/FilingSteps.helpers.tsx +++ b/src/pages/Filing/FilingApp/FilingSteps.helpers.tsx @@ -95,7 +95,7 @@ export const getFilingSteps = ( }, { status: getContactStatus(currentSubmission, currentFiling), - label: 'Provide point of contact', + label: 'Provide filing details', isCurrent: isStepCurrent('/contact'), }, { diff --git a/src/pages/Filing/FilingApp/FilingSubmit.helpers.tsx b/src/pages/Filing/FilingApp/FilingSubmit.helpers.tsx index 8a6f8fbf9..adca0e802 100644 --- a/src/pages/Filing/FilingApp/FilingSubmit.helpers.tsx +++ b/src/pages/Filing/FilingApp/FilingSubmit.helpers.tsx @@ -1,7 +1,7 @@ import Links from 'components/CommonLinks'; import FormSectionWrapper from 'components/FormSectionWrapper'; import SectionIntro from 'components/SectionIntro'; -import { Checkbox, WellContainer } from 'design-system-react'; +import { Checkbox, Label, WellContainer } from 'design-system-react'; import type { ChangeEvent, ReactNode } from 'react'; import { useParams } from 'react-router-dom'; import type { FilingType, SubmissionResponse } from 'types/filingTypes'; @@ -126,29 +126,23 @@ export function FileInformation({ } export function VoluntaryReportingStatus({ - onChange, - value, + data, + heading = 'Confirm voluntary reporter status', + description = getDescriptionForSignAndSubmitSection('poc'), }: { - onChange: (event: ChangeEvent) => void; - value: boolean; + data: FilingType; + // eslint-disable-next-line react/require-default-props + heading?: string; + // eslint-disable-next-line react/require-default-props + description?: ReactNode; }): JSX.Element { return ( - - - Pursuant to , indicate - whether your financial institution is voluntarily reporting covered - applications from small businesses. Leave the box unchecked if you are - not a voluntary reporter. - + + {description} - + + {data?.is_voluntary ? 'Voluntary reporter' : 'Not a voluntary reporter'} ); diff --git a/src/pages/Filing/FilingApp/FilingSubmit.tsx b/src/pages/Filing/FilingApp/FilingSubmit.tsx index 1a8b1f01d..542a80584 100644 --- a/src/pages/Filing/FilingApp/FilingSubmit.tsx +++ b/src/pages/Filing/FilingApp/FilingSubmit.tsx @@ -174,10 +174,6 @@ export function FilingSubmit(): JSX.Element { ) : ( '' )} - + +
+ +
+ {/* @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 */} 0 ? user.name : user.email} diff --git a/src/pages/Filing/FilingApp/InstitutionCard.helpers.ts b/src/pages/Filing/FilingApp/InstitutionCard.helpers.ts index 02d460325..ff7a24009 100644 --- a/src/pages/Filing/FilingApp/InstitutionCard.helpers.ts +++ b/src/pages/Filing/FilingApp/InstitutionCard.helpers.ts @@ -109,7 +109,7 @@ export function deriveCardContent({ // Latest submission has no point of contact info populated case POINT_OF_CONTACT: { - title = 'Provide point of contact'; + title = 'Provide filing details'; description = 'You have completed the validation steps. Next, provide the contact information of a person that the Bureau or other regulators may contact with questions about your filing.'; diff --git a/src/pages/PointOfContact/FilingDetailsUtils.ts b/src/pages/PointOfContact/FilingDetailsUtils.ts new file mode 100644 index 000000000..c449ad0a5 --- /dev/null +++ b/src/pages/PointOfContact/FilingDetailsUtils.ts @@ -0,0 +1,37 @@ +import type { + FilingDetailsSchema, + FormattedPointOfContactSchema, + FormattedVoluntaryReporterStatusSchema, +} from 'types/formTypes'; + +export const formatPointOfContactObject = ( + filingDetailsObject: FilingDetailsSchema, +): FormattedPointOfContactSchema => { + const formattedObject: FormattedPointOfContactSchema = { + // NOTE: 'id' is not necessary + first_name: filingDetailsObject.firstName, + last_name: filingDetailsObject.lastName, + phone_number: filingDetailsObject.phone, + phone_ext: filingDetailsObject.phoneExtension, + email: filingDetailsObject.email, + hq_address_street_1: filingDetailsObject.hq_address_street_1, + hq_address_street_2: filingDetailsObject.hq_address_street_2, + hq_address_street_3: filingDetailsObject.hq_address_street_3, + hq_address_street_4: filingDetailsObject.hq_address_street_4, + hq_address_city: filingDetailsObject.hq_address_city, + hq_address_state: filingDetailsObject.hq_address_state, + hq_address_zip: filingDetailsObject.hq_address_zip, + }; + + return formattedObject; +}; + +export const formatVoluntaryReporterStatusObject = ( + filingDetailsObject: FilingDetailsSchema, +): FormattedVoluntaryReporterStatusSchema => { + const formattedObject: FormattedVoluntaryReporterStatusSchema = { + is_voluntary: filingDetailsObject.isVoluntary, + }; + + return formattedObject; +}; diff --git a/src/pages/PointOfContact/VoluntaryReporterStatus.tsx b/src/pages/PointOfContact/VoluntaryReporterStatus.tsx new file mode 100644 index 000000000..da8a4e0ae --- /dev/null +++ b/src/pages/PointOfContact/VoluntaryReporterStatus.tsx @@ -0,0 +1,79 @@ +import Links from 'components/CommonLinks'; +import FieldGroup from 'components/FieldGroup'; +import SectionIntro from 'components/SectionIntro'; +import { RadioButton } from 'design-system-react'; +import InputErrorMessage from 'components/InputErrorMessage'; +import type { FieldErrors } from 'react-hook-form'; +import type { FilingDetailsSchema } from 'types/formTypes'; +import RadioButtonGroup from '../../components/RadioButtonGroup'; + +function VoluntaryReporterStatus({ + value, + disabled = false, + formErrors, + onChange, +}: { + value?: boolean | null | undefined; + disabled?: boolean | null | undefined; + formErrors?: FieldErrors | null | undefined; + onChange?: ((selected: boolean) => void) | null | undefined; +}): JSX.Element { + const onGroupChange = (event: React.ChangeEvent): void => { + onChange?.((event.target as HTMLInputElement).id === 'is-voluntary'); + }; + + return ( + <> + + Pursuant to , select + voluntary reporter if your financial institution is voluntarily + reporting covered applications from small businesses. + + + + + + +
+ {formErrors?.isVoluntary?.message ? ( + + {formErrors?.isVoluntary.message} + + ) : null} +
+
+ + ); +} + +VoluntaryReporterStatus.defaultProps = { + disabled: false, + formErrors: undefined, + onChange: undefined, + value: undefined, +}; + +export default VoluntaryReporterStatus; diff --git a/src/pages/PointOfContact/index.tsx b/src/pages/PointOfContact/index.tsx index 287e9b6d5..0ace82c26 100644 --- a/src/pages/PointOfContact/index.tsx +++ b/src/pages/PointOfContact/index.tsx @@ -17,20 +17,22 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useQueryClient } from '@tanstack/react-query'; import WrapperPageContent from 'WrapperPageContent'; import FormErrorHeader from 'components/FormErrorHeader'; -import type { PocFormHeaderErrorsType } from 'components/FormErrorHeader.data'; -import { PocFormHeaderErrors } from 'components/FormErrorHeader.data'; +import type { + PocFormHeaderErrorsType, + VrsFormHeaderErrorsType, +} from 'components/FormErrorHeader.data'; +import { + PocFormHeaderErrors, + VrsFormHeaderErrors, +} from 'components/FormErrorHeader.data'; import FormMain from 'components/FormMain'; -import FormParagraph from 'components/FormParagraph'; import InputErrorMessage from 'components/InputErrorMessage'; import { Link } from 'components/Link'; import { LoadingContent } from 'components/Loading'; import FilingNavButtons from 'pages/Filing/FilingApp/FilingNavButtons'; import FilingSteps from 'pages/Filing/FilingApp/FilingSteps'; import InstitutionHeading from 'pages/Filing/FilingApp/InstitutionHeading'; -import { - formatPointOfContactObject, - scrollToElement, -} from 'pages/ProfileForm/ProfileFormUtils'; +import { scrollToElement } from 'pages/ProfileForm/ProfileFormUtils'; import type React from 'react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -38,20 +40,29 @@ import { useNavigate, useParams } from 'react-router-dom'; import type { FilingType } from 'types/filingTypes'; import type { ContactInfoKeys, + FilingDetailsSchema, FinancialInstitutionRS, - PointOfContactSchema, } from 'types/formTypes'; -import { ContactInfoMap, pointOfContactSchema } from 'types/formTypes'; +import { ContactInfoMap, filingDetailsSchema } from 'types/formTypes'; import { PhoneInputCharLimit, ZipInputCharLimit } from 'utils/constants'; import useAddressStates from 'utils/useAddressStates'; import useFilingStatus from 'utils/useFilingStatus'; import useInstitutionDetails from 'utils/useInstitutionDetails'; import useSubmitPointOfContact from 'utils/useSubmitPointOfContact'; +import useSubmitVoluntaryReporterStatus from 'utils/useSubmitVoluntaryReporterStatus'; +import VoluntaryReporterStatus from './VoluntaryReporterStatus'; +import { + formatPointOfContactObject, + formatVoluntaryReporterStatusObject, +} from './FilingDetailsUtils'; +import FormParagraph from 'components/FormParagraph'; const defaultValuesPOC = { + isVoluntary: undefined, firstName: '', lastName: '', phone: '', + phoneExtension: '', email: '', hq_address_street_1: '', hq_address_street_2: '', @@ -62,13 +73,20 @@ const defaultValuesPOC = { hq_address_zip: '', }; -function PointOfContact(): JSX.Element { - const [previousContactInfoValid, setPreviousContactInfoValid] = +function FilingDetails(): JSX.Element { + const formErrorHeaderId = 'FilingDetailsFormErrors'; + + const [previousFilingDetailsValid, setPreviousFilingDetailsValid] = useState(false); + const [scrollTarget, setScrollTarget] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const queryClient = useQueryClient(); const navigate = useNavigate(); const { lei, year } = useParams(); - const formErrorHeaderId = 'PointOfContactFormErrors'; + + /** Load Filing Status Data */ const { data: filing, isLoading: isFilingLoading, @@ -76,6 +94,8 @@ function PointOfContact(): JSX.Element { isError: isErrorFilingStatus, // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 } = useFilingStatus(lei, year); + + /** Load Institution Details for the Filing Institution */ const { data: institution, isLoading: isLoadingInstitution, @@ -84,7 +104,7 @@ function PointOfContact(): JSX.Element { // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 } = useInstitutionDetails(lei); - // States or Territories -- in options + /** Load States or Territories for Select -- in options */ const { data: stateOptions, isLoading: isLoadingStateOptions, @@ -93,13 +113,24 @@ function PointOfContact(): JSX.Element { isError: isErrorStateOptions, } = useAddressStates(); - const isLoading = [ - isLoadingInstitution, - isFilingLoading, - isLoadingStateOptions, - ].some(Boolean); - const [isSubmitting, setIsSubmitting] = useState(false); + /** Use Voluntary Reporter Status Submission */ + const { mutateAsync: mutateSubmitVoluntaryReporterStatus } = + useSubmitVoluntaryReporterStatus({ + // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 + lei, + // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 + filingPeriod: year, + }); + /** Use Point Of Contact Submission */ + const { mutateAsync: mutateSubmitPointOfContact } = useSubmitPointOfContact({ + // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 + lei, + // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 + filingPeriod: year, + }); + + /** Use Zod Form */ const { register, watch, @@ -108,22 +139,58 @@ function PointOfContact(): JSX.Element { getValues, setValue, formState: { errors: formErrors, isDirty }, - } = useForm({ - resolver: zodResolver(pointOfContactSchema), + } = useForm({ + resolver: zodResolver(filingDetailsSchema), defaultValues: defaultValuesPOC, }); + /* ********************************************************* */ + /* Use Effects */ + /* ********************************************************* */ + + /** Determine total loading state when individual data loading states change */ + useEffect(() => { + setIsLoading( + isLoadingInstitution || isFilingLoading || isLoadingStateOptions, + ); + }, [ + setIsLoading, + isLoadingInstitution, + isFilingLoading, + isLoadingStateOptions, + ]); + + /** Scrolls to configured target when the scroll target changes and is not empty. + * Then resets the scroll target. This is done this way to debounce a bug where + * the scroll doesn't work correctly when you clear the form and it has errors */ + useEffect(() => { + if (scrollTarget) { + scrollToElement(scrollTarget); + setScrollTarget(''); + } + }, [scrollTarget]); + /** Populate form with pre-existing data, when it exists */ useEffect(() => { + let shouldCheck = false; // Checks if the fetched contact info passes validation // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 const checkPreviousContactInfo = async (): void => { const passesValidation = await trigger(); - if (passesValidation) setPreviousContactInfoValid(true); + if (passesValidation) setPreviousFilingDetailsValid(true); }; if (!filing) return; + // Check is Voluntary + const isVoluntary = (filing as FilingType).is_voluntary; + + if (typeof isVoluntary === 'boolean') { + setValue('isVoluntary', isVoluntary); + shouldCheck = true; + } + + // check Contact Info const contactInfo = (filing as FilingType).contact_info; if (contactInfo) { @@ -133,29 +200,25 @@ function PointOfContact(): JSX.Element { setValue(mappedProperty, contactInfo[property]); } } + shouldCheck = true; + } + + // Validate if need to + if (shouldCheck) { // eslint-disable-next-line @typescript-eslint/no-floating-promises checkPreviousContactInfo(); } }, [filing, setValue, trigger]); - const onClearform = (): void => { - reset(); - setValue('hq_address_state', ''); - scrollToElement('firstName'); - setPreviousContactInfoValid(false); // If success alert is visible, this will disable it - }; - - const onPreviousClick = (): void => - navigate(`/filing/${year}/${lei}/warnings`); + /* ********************************************************* */ + /* Change Handlers */ + /* ********************************************************* */ - const onSelectState = ({ value }: { value: string }): void => { - setValue('hq_address_state', value, { shouldDirty: true }); + /** Handle change to Voluntary Reporter Status */ + const onVoluntaryReporterStatusChange = (selected: boolean): void => { + setValue('isVoluntary', selected, { shouldDirty: true }); }; - // Navigate to Sign and Submit - const navigateSignSubmit = (): void => - navigate(`/filing/${year}/${lei}/submit`); - // Note: Design Choice to be made: ignore non-number input or just rely on error handling // const handlePhoneExtensionInput = ( // event: React.ChangeEvent, @@ -163,12 +226,25 @@ function PointOfContact(): JSX.Element { // setValue('phoneExtension', processNumbersOnlyString(event.target.value)); // }; - const { mutateAsync: mutateSubmitPointOfContact } = useSubmitPointOfContact({ - // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 - lei, - // @ts-expect-error Part of code cleanup for post-mvp see: https://github.com/cfpb/sbl-frontend/issues/717 - filingPeriod: year, - }); + /** Handle change to State selection */ + const onSelectState = ({ value }: { value: string }): void => { + setValue('hq_address_state', value, { shouldDirty: true }); + }; + + /* ********************************************************* */ + /* Nav Button Click Handlers */ + /* ********************************************************* */ + + const onClearform = (): void => { + reset(); + setPreviousFilingDetailsValid(false); // If success alert is visible, this will disable it + // Set the scroll target to the voluntary reporter containing div whch then triggers the scroll + setScrollTarget('voluntary-reporter-status'); + }; + + const onPreviousClick = (): void => { + navigate(`/filing/${year}/${lei}/warnings`); + }; // NOTE: This function is used for submitting the multipart/formData const onSubmitButtonAction = async ( @@ -178,7 +254,7 @@ function PointOfContact(): JSX.Element { const passesValidation = await trigger(); if (!passesValidation) { - scrollToElement(formErrorHeaderId); + setScrollTarget(formErrorHeaderId); return; } @@ -187,16 +263,18 @@ function PointOfContact(): JSX.Element { try { setIsSubmitting(true); const preFormattedData = getValues(); - const formattedUserProfileObject = - formatPointOfContactObject(preFormattedData); + const formattedPOCObject = formatPointOfContactObject(preFormattedData); + const formattedVRSObject = + formatVoluntaryReporterStatusObject(preFormattedData); - await mutateSubmitPointOfContact({ data: formattedUserProfileObject }); + await mutateSubmitPointOfContact({ data: formattedPOCObject }); + await mutateSubmitVoluntaryReporterStatus({ data: formattedVRSObject }); await queryClient.invalidateQueries({ queryKey: [`fetch-filing-submission`, lei, year], }); - navigateSignSubmit(); + navigate(`/filing/${year}/${lei}/submit`); } catch (error) { // eslint-disable-next-line no-console console.log(error); @@ -204,7 +282,7 @@ function PointOfContact(): JSX.Element { setIsSubmitting(false); } } else { - navigateSignSubmit(); + navigate(`/filing/${year}/${lei}/submit`); } }; @@ -225,45 +303,56 @@ function PointOfContact(): JSX.Element { - Your financial institution's point of contact information - will not be published with your financial institution's - data. This information is required pursuant to{' '} - . + In order to continue to the next step, you are required to + complete all fields with the exception of the fields labeled + optional. } /> - {previousContactInfoValid && Object.keys(formErrors).length === 0 ? ( + {previousFilingDetailsValid && Object.keys(formErrors).length === 0 ? ( ) : null} - - alertHeading='There was a problem updating your point of contact information' + + alertHeading='There was a problem updating your filing details' errors={formErrors} id={formErrorHeaderId} - formErrorHeaderObject={PocFormHeaderErrors} + formErrorHeaderObject={{ + ...VrsFormHeaderErrors, + ...PocFormHeaderErrors, + }} keyLogicFunc={normalKeyLogic} /> -
- - You are required to complete all fields with the exception of the - street address lines labeled optional. Your point of contact - information will not be saved until you provide all required - information and continue to the next step. - -
{/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} + +
+ + Pursuant to , + provide the name and business contact information of a person who + may be contacted about your financial institution's filing. + This information will not be published with your financial + institution's data. + +
- The Consumer Financial Protection Bureau (CFPB) is collecting data + The Consumer Financial Protection Bureau (CFPB) is accepting data to test the functionality of the Small Business Lending Data Filing Platform.{' '} View Privacy Notice @@ -397,8 +486,8 @@ function PointOfContact(): JSX.Element { ); } -PointOfContact.defaultProps = { +FilingDetails.defaultProps = { onSubmit: undefined, }; -export default PointOfContact; +export default FilingDetails; diff --git a/src/pages/ProfileForm/ProfileFormUtils.ts b/src/pages/ProfileForm/ProfileFormUtils.ts index 3e3411a89..7739a9bc8 100644 --- a/src/pages/ProfileForm/ProfileFormUtils.ts +++ b/src/pages/ProfileForm/ProfileFormUtils.ts @@ -28,28 +28,6 @@ export const formatUserProfileObject = ( return formattedObject; }; -export const formatPointOfContactObject = ( - userProfileObject: PointOfContactSchema, -): FormattedPointOfContactSchema => { - const formattedObject: FormattedPointOfContactSchema = { - // NOTE: 'id' is not necessary - first_name: userProfileObject.firstName, - last_name: userProfileObject.lastName, - phone_number: userProfileObject.phone, - phone_ext: userProfileObject.phoneExtension, - email: userProfileObject.email, - hq_address_street_1: userProfileObject.hq_address_street_1, - hq_address_street_2: userProfileObject.hq_address_street_2, - hq_address_street_3: userProfileObject.hq_address_street_3, - hq_address_street_4: userProfileObject.hq_address_street_4, - hq_address_city: userProfileObject.hq_address_city, - hq_address_state: userProfileObject.hq_address_state, - hq_address_zip: userProfileObject.hq_address_zip, - }; - - return formattedObject; -}; - // Set Checkbox of associated financial institutions to `false`/unchecked export const formatDataCheckedState = ( fiDataInput: InstitutionDetailsApiType[] = [], diff --git a/src/types/filingTypes.ts b/src/types/filingTypes.ts index 9f7f35a05..1ddd42945 100644 --- a/src/types/filingTypes.ts +++ b/src/types/filingTypes.ts @@ -65,6 +65,7 @@ export const FilingSchema = z.object({ }) .array(), institution_snapshot_id: z.string(), + is_voluntary: z.boolean(), contact_info: z.union([ z.object({ id: z.number(), diff --git a/src/types/formTypes.ts b/src/types/formTypes.ts index a3342f673..44a3f2902 100644 --- a/src/types/formTypes.ts +++ b/src/types/formTypes.ts @@ -271,6 +271,36 @@ export interface FormattedUserProfileObjectType { leis?: InstitutionDetailsApiType['lei'][]; } +// Voluntary Reporter Status +export const voluntaryReporterStatusSchema = z.object({ + isVoluntary: z.boolean({ + invalid_type_error: 'You must indicate your voluntary reporter status.', + required_error: 'You must indicate your voluntary reporter status.', + description: 'You must indicate your voluntary reporter status.', + }), +}); + +export type VoluntaryReporterStatusSchema = z.infer< + typeof voluntaryReporterStatusSchema +>; + +export const VoluntaryReporterStatusMap = { + is_voluntary: 'isVoluntary', +} as const; + +export type VoluntaryReporterStatusMapType = typeof VoluntaryReporterStatusMap; +export type VoluntaryReporterStatusKeys = + keyof typeof VoluntaryReporterStatusMap; +export type VoluntaryReporterStatusValues = + (typeof VoluntaryReporterStatusMap)[VoluntaryReporterStatusKeys]; + +export type FormattedVoluntaryReporterStatusSchema = Omit< + VoluntaryReporterStatusSchema, + 'isVoluntary' +> & { + is_voluntary: boolean; +}; + // NOTE: Placeholder for possible future use // eslint-disable-next-line @typescript-eslint/no-unused-vars const internationalPhoneNumberRegex = @@ -343,10 +373,11 @@ export const pointOfContactSchema = z.object({ }), phoneExtension: z .string() + .trim() .max(phoneExtensionNumberLimit, { message: PocZodSchemaErrors.phoneExtension, }) - .regex(/^\d+$/, { + .regex(/^\d*$/, { message: PocZodSchemaErrors.phoneExtension, }) .optional(), @@ -412,3 +443,11 @@ export type FormattedPointOfContactSchema = Omit< phone_number: string; phone_ext: string | undefined; }; + +// Filing Details +export const filingDetailsSchema = z.intersection( + voluntaryReporterStatusSchema, + pointOfContactSchema, +); + +export type FilingDetailsSchema = z.infer; diff --git a/src/utils/useSubmitVoluntaryReporterStatus.tsx b/src/utils/useSubmitVoluntaryReporterStatus.tsx new file mode 100644 index 000000000..7024ebffc --- /dev/null +++ b/src/utils/useSubmitVoluntaryReporterStatus.tsx @@ -0,0 +1,47 @@ +import type { UseMutationResult } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import { getRetries } from 'api/common'; +import submitVoluntaryReporterStatus from 'api/requests/submitVoluntaryReporterStatus'; +import useSblAuth from 'api/useSblAuth'; +import type { AxiosError } from 'axios'; +import type { FilingPeriodType } from 'types/filingTypes'; +import type { + FormattedVoluntaryReporterStatusSchema, + InstitutionDetailsApiType, +} from 'types/formTypes'; +import { UPLOAD_SUBMIT_MAX_RETRIES } from './constants'; + +interface UseSubmitVoluntaryReporterStatusProperties { + lei: InstitutionDetailsApiType['lei']; + filingPeriod: FilingPeriodType; +} + +interface SubmitVoluntaryReporterStatusMutationProperties { + data: FormattedVoluntaryReporterStatusSchema; +} + +const useSubmitVoluntaryReporterStatus = ({ + lei, + filingPeriod, +}: UseSubmitVoluntaryReporterStatusProperties): UseMutationResult< + null, + AxiosError, + SubmitVoluntaryReporterStatusMutationProperties +> => { + const auth = useSblAuth(); + return useMutation< + null, + AxiosError, + SubmitVoluntaryReporterStatusMutationProperties + >({ + mutationFn: async ({ + data, + }: SubmitVoluntaryReporterStatusMutationProperties): Promise => { + return submitVoluntaryReporterStatus(auth, { data, lei, filingPeriod }); + }, + + retry: getRetries(UPLOAD_SUBMIT_MAX_RETRIES), + }); +}; + +export default useSubmitVoluntaryReporterStatus;