diff --git a/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx b/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx index 15048c5314..7d1c564b80 100644 --- a/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx +++ b/services/app-web/src/pages/LinkYourRates/LinkRateSelect.tsx @@ -34,9 +34,9 @@ export interface LinkRateOptionType { export type LinkRateSelectPropType = { name: string initialValue: string | undefined - alreadySelected?: string[], // used for multi-rate, array of rate IDs helps ensure we can't select rates already selected elsewhere on page + alreadySelected?: string[] // used for multi-rate, array of rate IDs helps ensure we can't select rates already selected elsewhere on page autofill?: (rateForm: FormikRateForm) => void // used for multi-rates, when called will FieldArray replace the existing form fields with new data - label?: string, + label?: string stateCode?: string //used to limit rates by state } @@ -51,20 +51,22 @@ export const LinkRateSelect = ({ }: LinkRateSelectPropType & Props) => { // const input:IndexRatesInput | undefined ={stateCode} // TODO figure out why this isn't working - useIndexRatesQuery({variables: { input }}) - const { data, loading, error } = useIndexRatesQuery() + const { data, loading, error } = useIndexRatesQuery() const { getKey } = useS3() const { logDropdownSelectionEvent } = useTealium() - const [ _field, _meta, helpers] = useField({ name }) // useField only relevant for non-autofill implementations + const [_field, _meta, helpers] = useField({ name }) // useField only relevant for non-autofill implementations const rates = data?.indexRates.edges.map((e) => e.node) || [] // Sort rates by latest submission in desc order and remove withdrawn - rates.sort( - (a, b) => - new Date(b.revisions[0].submitInfo?.updatedAt).getTime() - - new Date(a.revisions[0].submitInfo?.updatedAt).getTime() - ).filter( (rate)=> rate.withdrawInfo === undefined) + rates + .sort( + (a, b) => + new Date(b.revisions[0].submitInfo?.updatedAt).getTime() - + new Date(a.revisions[0].submitInfo?.updatedAt).getTime() + ) + .filter((rate) => rate.withdrawInfo === undefined) const rateNames: LinkRateOptionType[] = rates.map((rate) => { const revision = rate.revisions[0] @@ -123,9 +125,11 @@ export const LinkRateSelect = ({ heading: label, }) - if(autofill) { + if (autofill) { const linkedRateID = newValue.value - const linkedRate = rates.find((rate) => rate.id === linkedRateID) + const linkedRate = rates.find( + (rate) => rate.id === linkedRateID + ) const linkedRateForm: FormikRateForm = convertGQLRateToRateForm( getKey, linkedRate @@ -136,20 +140,23 @@ export const LinkRateSelect = ({ autofill(linkedRateForm) } else { // this path is used for replace/withdraw redundant rates - // we are not autofilling form data, we are just returning the IDs of the rate selectred - await helpers.setValue( - newValue.value) + // we are not autofilling form data, we are just returning the IDs of the rate selected + await helpers.setValue(newValue.value) } } else if (action === 'clear') { logDropdownSelectionEvent({ text: 'clear', heading: label, }) - if(autofill){ + if (autofill) { const emptyRateForm = convertGQLRateToRateForm(getKey) // put already selected fields back in place emptyRateForm.ratePreviouslySubmitted = 'YES' autofill(emptyRateForm) + } else { + // this path is used for replace/withdraw redundant rates + // we are not autofilling form data, we are just returning the IDs of the rate selected + await helpers.setValue('') } } } @@ -183,11 +190,11 @@ export const LinkRateSelect = ({ options={ error || loading ? undefined - : alreadySelected? - rateNames.filter( - (rate) => !alreadySelected.includes(rate.value) - ) - : rateNames + : alreadySelected + ? rateNames.filter( + (rate) => !alreadySelected.includes(rate.value) + ) + : rateNames } formatOptionLabel={formatOptionLabel} isSearchable diff --git a/services/app-web/src/pages/ReplaceRate/ReplaceRate.test.tsx b/services/app-web/src/pages/ReplaceRate/ReplaceRate.test.tsx index 123b0aba24..7e4716e6c1 100644 --- a/services/app-web/src/pages/ReplaceRate/ReplaceRate.test.tsx +++ b/services/app-web/src/pages/ReplaceRate/ReplaceRate.test.tsx @@ -1,26 +1,29 @@ import { screen, waitFor } from '@testing-library/react' -import { renderWithProviders, } from '../../testHelpers' +import { renderWithProviders } from '../../testHelpers' import { fetchCurrentUserMock, mockValidCMSUser, mockValidAdminUser, fetchContractMockSuccess, mockContractPackageSubmitted, - indexRatesMockSuccess + indexRatesMockSuccess, + withdrawAndReplaceRedundantRateMock, + rateDataMock, } from '../../testHelpers/apolloMocks' import { RoutesRecord } from '../../constants' import { Location, Route, Routes } from 'react-router-dom' import { ReplaceRate } from './ReplaceRate' import userEvent from '@testing-library/user-event' +import { Rate } from '../../gen/gqlClient' // Wrap test component in some top level routes to allow getParams to be tested const wrapInRoutes = (children: React.ReactNode) => { return ( - Summary page placeholder} - /> + Summary page placeholder} + /> ) @@ -31,7 +34,6 @@ describe('ReplaceRate', () => { vi.resetAllMocks() }) - it('does not render for CMS user', async () => { const contract = mockContractPackageSubmitted() renderWithProviders(wrapInRoutes(), { @@ -41,7 +43,7 @@ describe('ReplaceRate', () => { user: mockValidCMSUser(), statusCode: 200, }), - fetchContractMockSuccess({ contract}) + fetchContractMockSuccess({ contract }), ], }, routerProvider: { @@ -49,11 +51,12 @@ describe('ReplaceRate', () => { }, }) - const forbidden = await screen.findByText('You do not have permission to view the requested file or resource.') + const forbidden = await screen.findByText( + 'You do not have permission to view the requested file or resource.' + ) expect(forbidden).toBeInTheDocument() }) - it('renders without errors for admin user', async () => { const contract = mockContractPackageSubmitted() renderWithProviders(wrapInRoutes(), { @@ -63,8 +66,8 @@ describe('ReplaceRate', () => { user: mockValidAdminUser(), statusCode: 200, }), - fetchContractMockSuccess({ contract}), - indexRatesMockSuccess() + fetchContractMockSuccess({ contract }), + indexRatesMockSuccess(), ], }, routerProvider: { @@ -74,22 +77,24 @@ describe('ReplaceRate', () => { await screen.findByRole('form') expect( - screen.getByRole('heading', { name: 'Replace a rate review' }) + screen.getByRole('heading', { name: 'Replace a rate review' }) ).toBeInTheDocument() expect( - screen.getByRole('form', {name: 'Withdraw and replace rate on contract'}) + screen.getByRole('form', { + name: 'Withdraw and replace rate on contract', + }) ).toBeInTheDocument() expect( - screen.getByRole('textbox', {name: 'Reason for revoking'}) + screen.getByRole('textbox', { name: 'Reason for revoking' }) ).toBeInTheDocument() expect( - screen.getByText('Select a replacement rate')).toBeInTheDocument() + screen.getByText('Select a replacement rate') + ).toBeInTheDocument() expect( - screen.getByRole('button', {name: 'Replace rate'}) + screen.getByRole('button', { name: 'Replace rate' }) ).toBeInTheDocument() }) - it('cancel button moves admin user back to parent contract summary', async () => { let testLocation: Location // set up location to track URL change const contract = mockContractPackageSubmitted() @@ -100,8 +105,8 @@ describe('ReplaceRate', () => { user: mockValidAdminUser(), statusCode: 200, }), - fetchContractMockSuccess({ contract}), - indexRatesMockSuccess() + fetchContractMockSuccess({ contract }), + indexRatesMockSuccess(), ], }, routerProvider: { @@ -109,13 +114,104 @@ describe('ReplaceRate', () => { }, location: (location) => (testLocation = location), }) - await screen.findByRole('form') - await userEvent.click(screen.getByText('Cancel')) - await waitFor(() => { - expect(testLocation.pathname).toBe( - `/submissions/${contract.id}` - ) + await screen.findByRole('form') + await userEvent.click(screen.getByText('Cancel')) + await waitFor(() => { + expect(testLocation.pathname).toBe(`/submissions/${contract.id}`) + }) }) + it('shows errors when required fields are not filled in', async () => { + let testLocation: Location // set up location to track URL change + const contract = mockContractPackageSubmitted() + const replacementRates: Rate[] = [ + { ...rateDataMock(), id: 'test-id-123', stateNumber: 3 }, + ] + const withdrawnRateID = + contract.packageSubmissions[0].rateRevisions[0].rateID + const replaceReason = 'This is a good reason' + renderWithProviders(wrapInRoutes(), { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidAdminUser(), + statusCode: 200, + }), + fetchContractMockSuccess({ contract }), + indexRatesMockSuccess(replacementRates), + withdrawAndReplaceRedundantRateMock({ + contract, + input: { + replaceReason, + withdrawnRateID, + contractID: contract.id, + replacementRateID: replacementRates[0].id, + }, + }), + ], + }, + routerProvider: { + route: `/submissions/${contract.id}/replace-rate/${withdrawnRateID}`, + }, + location: (location) => (testLocation = location), + }) + + // Find replace button + const replaceRateButton = await screen.findByRole('button', { + name: 'Replace rate', + }) + expect(replaceRateButton).toBeInTheDocument() + + // Click replace button to show errors + await userEvent.click(replaceRateButton) + + // Check for both errors inline and error summary + expect( + screen.queryAllByText( + 'You must provide a reason for revoking this rate certification.' + ) + ).toHaveLength(2) + expect( + screen.queryAllByText( + 'You must select a replacement rate certification.' + ) + ).toHaveLength(2) + + // Fill withdraw reason + const replaceReasonInput = screen.getByRole('textbox', { + name: 'Reason for revoking', + }) + expect(replaceReasonInput).toBeInTheDocument() + await userEvent.type(replaceReasonInput, replaceReason) + + // Select a replacement rate + const rateDropdown = screen.getByRole('combobox') + expect(rateDropdown).toBeInTheDocument() + await userEvent.click(rateDropdown) + + const rateDropdownOptions = screen.getAllByRole('option') + expect(rateDropdownOptions).toHaveLength(1) + + await userEvent.click(rateDropdownOptions[0]) + + // Check errors are gone + expect( + screen.queryAllByText( + 'You must provide a reason for revoking this rate certification.' + ) + ).toHaveLength(0) + expect( + screen.queryAllByText( + 'You must select a replacement rate certification.' + ) + ).toHaveLength(0) + + // Click replace rate button + await userEvent.click(replaceRateButton) + + // Wait for redirect + await waitFor(() => { + expect(testLocation.pathname).toBe(`/submissions/${contract.id}`) + }) }) }) diff --git a/services/app-web/src/pages/ReplaceRate/ReplaceRate.tsx b/services/app-web/src/pages/ReplaceRate/ReplaceRate.tsx index 1443a1145c..4292c41792 100644 --- a/services/app-web/src/pages/ReplaceRate/ReplaceRate.tsx +++ b/services/app-web/src/pages/ReplaceRate/ReplaceRate.tsx @@ -1,57 +1,89 @@ -import { ButtonGroup, FormGroup, GridContainer, Label } from '@trussworks/react-uswds' -import * as Yup from 'yup' -import React, { useEffect, } from 'react' +import { + ButtonGroup, + FormGroup, + GridContainer, + Label, +} from '@trussworks/react-uswds' +import React, { useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { usePage } from '../../contexts/PageContext' -import { useFetchContractQuery, useWithdrawAndReplaceRedundantRateMutation } from '../../gen/gqlClient' +import { + useFetchContractQuery, + useWithdrawAndReplaceRedundantRateMutation, +} from '../../gen/gqlClient' import styles from './ReplaceRate.module.scss' -import { ErrorOrLoadingPage, handleAndReturnErrorState } from '../../pages/StateSubmission/ErrorOrLoadingPage' +import { + ErrorOrLoadingPage, + handleAndReturnErrorState, +} from '../../pages/StateSubmission/ErrorOrLoadingPage' import { useAuth } from '../../contexts/AuthContext' import { recordJSExceptionWithContext } from '../../otelHelpers' -import { - Form as UswdsForm, -} from '@trussworks/react-uswds' +import { Form as UswdsForm } from '@trussworks/react-uswds' import { Formik, FormikErrors } from 'formik' -import { ActionButton, DataDetail, FieldTextarea, GenericApiErrorBanner, PoliteErrorMessage } from '../../components' +import { + ActionButton, + DataDetail, + FieldTextarea, + GenericApiErrorBanner, + PoliteErrorMessage, +} from '../../components' import { LinkRateSelect } from '../LinkYourRates/LinkRateSelect' import { PageActionsContainer } from '../StateSubmission/PageActions' +import { ReplaceRateSchema } from './ReplaceRateSchema' +import { useErrorSummary } from '../../hooks/useErrorSummary' +import { + ErrorSummaryProps, + ErrorSummary, +} from '../../components/Form/ErrorSummary/ErrorSummary' export interface ReplaceRateFormValues { replacementRateID: string - replaceReason: string + replaceReason: string } + type FormError = FormikErrors[keyof FormikErrors] export const ReplaceRate = (): React.ReactElement => { // Page level state - const [shouldValidate] = React.useState(false) + const [shouldValidate, setShouldValidate] = React.useState(false) const { loggedInUser } = useAuth() const navigate = useNavigate() const { updateHeading } = usePage() - const { id, rateID} = useParams() - if (!id ||!rateID) { + const { setFocusErrorSummaryHeading, errorSummaryHeadingRef } = + useErrorSummary() + + const { id, rateID } = useParams() + if (!id || !rateID) { throw new Error('PROGRAMMING ERROR: proper url params not set') } // API handling - const { data: initialData, loading: initialRequestLoading, error: initialRequestError } = useFetchContractQuery({ + const { + data: initialData, + loading: initialRequestLoading, + error: initialRequestError, + } = useFetchContractQuery({ variables: { input: { contractID: id ?? 'unknown contract', }, }, }) - const [replaceRate, {loading: replaceLoading, error: replaceError}] = useWithdrawAndReplaceRedundantRateMutation() + const [replaceRate, { loading: replaceLoading, error: replaceError }] = + useWithdrawAndReplaceRedundantRateMutation() - const contract = initialData?.fetchContract.contract - const contractName = contract?.packageSubmissions[0]?.contractRevision.contractName - const withdrawnRateRevisionName = contract?.packageSubmissions[0]?.rateRevisions.find( rateRev => rateRev.rateID == rateID)?.formData.rateCertificationName + const contract = initialData?.fetchContract.contract + const contractName = + contract?.packageSubmissions[0]?.contractRevision.contractName + const withdrawnRateRevisionName = + contract?.packageSubmissions[0]?.rateRevisions.find( + (rateRev) => rateRev.rateID == rateID + )?.formData.rateCertificationName useEffect(() => { updateHeading({ customHeading: contractName }) }, [contractName, updateHeading]) - if (loggedInUser?.role != 'ADMIN_USER') { return } @@ -68,120 +100,143 @@ export const ReplaceRate = (): React.ReactElement => { ) } - if(!withdrawnRateRevisionName){ - recordJSExceptionWithContext(`rate ID: ${rateID} not found on contract ${id}`, 'ReplaceRate.withdrawnRateRevisionName') - return + if (!withdrawnRateRevisionName) { + recordJSExceptionWithContext( + `rate ID: ${rateID} not found on contract ${id}`, + 'ReplaceRate.withdrawnRateRevisionName' + ) + return } // Form setup const formInitialValues: ReplaceRateFormValues = { - replacementRateID:'', - replaceReason: 'Admin has decided to replace this rate' - + replacementRateID: '', + replaceReason: '', } const showFieldErrors = (error?: FormError) => shouldValidate && Boolean(error) - const onSubmit = async (values : ReplaceRateFormValues) => { - try { - await replaceRate({ variables: { - input: { - replacementRateID: values.replacementRateID, - replaceReason: values.replaceReason, - withdrawnRateID: rateID, - contractID: id, - } - } - }) - navigate( - `/submissions/${id}` - ) - } catch (err) { - recordJSExceptionWithContext( - err, - 'ReplaceRate.onSubmit' - ) + const onSubmit = async (values: ReplaceRateFormValues) => { + try { + await replaceRate({ + variables: { + input: { + replacementRateID: values.replacementRateID, + replaceReason: values.replaceReason, + withdrawnRateID: rateID, + contractID: id, + }, + }, + }) + navigate(`/submissions/${id}`) + } catch (err) { + recordJSExceptionWithContext(err, 'ReplaceRate.onSubmit') + } } + + const generateErrorSummaryErrors = ( + errors: FormikErrors + ): ErrorSummaryProps['errors'] => { + const errorObject: ErrorSummaryProps['errors'] = {} + Object.entries(errors).forEach(([field, value]) => { + // select dropdown component error messages needs a # proceeding the key name because this is the only way to be able to link to react-select based components. See comments in ErrorSummaryMessage component. + if (field === 'replacementRateID') { + errorObject[`#${field}`] = value + } else { + errorObject[field] = value + } + }) + + return errorObject } return (
- - {replaceError && } - + {replaceError && } + onSubmit(values)} - validationSchema={() => - Yup.object().shape({ - replaceReason: Yup.string(), - replacementRateID: Yup.string(), - }) - } + validationSchema={ReplaceRateSchema} > - {({ - errors, - values, - handleSubmit, - }) => ( - { - - return handleSubmit(e)}} - > -

Replace a rate review

- - {withdrawnRateRevisionName} - - -
- - Withdraw and replace rate on contract - - -

- Provide a reason for revoking the rate review. -

- - } - /> - + {({ errors, values, handleSubmit }) => ( + { + setShouldValidate(true) + setFocusErrorSummaryHeading(true) + return handleSubmit(e) + }} + > + {shouldValidate && ( + + )} + +

Replace a rate review

+ + + {withdrawnRateRevisionName} + + +
+ + Withdraw and replace rate on contract + + +

+ Provide a reason for revoking + the rate review. +

+ + } + /> + - + Required - - {showFieldErrors('replacementRateID')} - -
- Selecting a rate below will withdraw the current rate from review. -
+ {showFieldErrors( + errors.replacementRateID + ) && ( + + {errors.replacementRateID} + + )} +
+ Selecting a rate below will withdraw the + current rate from review. +
{ label="Which rate certification was it?" alreadySelected={[rateID]} /> +
+
+ + + + navigate(`/submissions/${id}`) + } + > + Cancel + -
- -
- - - navigate(`/submissions/${id}`)} - > - Cancel - - - - Replace rate - - - -
- )} -
+ + Replace rate + + + + + )} +
) diff --git a/services/app-web/src/pages/ReplaceRate/ReplaceRateSchema.ts b/services/app-web/src/pages/ReplaceRate/ReplaceRateSchema.ts new file mode 100644 index 0000000000..5b5dfa532c --- /dev/null +++ b/services/app-web/src/pages/ReplaceRate/ReplaceRateSchema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup' + +const ReplaceRateSchema = + Yup.object().shape({ + replaceReason: Yup.string().required('You must provide a reason for revoking this rate certification.'), + replacementRateID: Yup.string().required('You must select a replacement rate certification.'), + }) + +export { ReplaceRateSchema } diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.test.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.test.tsx index 48e7c2d777..441dcc7944 100644 --- a/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.test.tsx +++ b/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.test.tsx @@ -12,7 +12,6 @@ import { fetchContractMockSuccess, updateDraftContractRatesMockSuccess, mockContractWithLinkedRateDraft, - mockContractAndRatesDraft, mockContractPackageDraft, } from '../../../testHelpers/apolloMocks' import { Route, Routes, Location } from 'react-router-dom' @@ -363,8 +362,7 @@ describe('RateDetails', () => { apolloProvider: { mocks: [ fetchCurrentUserMock({ statusCode: 200 }), - fetchContractMockSuccess( - { + fetchContractMockSuccess({ contract: { ...mockContractPackageDraft(), id: 'test-abc-123', diff --git a/services/app-web/src/testHelpers/apolloMocks/contractGQLMock.ts b/services/app-web/src/testHelpers/apolloMocks/contractGQLMock.ts index 5d477e1ff3..49e38f89f4 100644 --- a/services/app-web/src/testHelpers/apolloMocks/contractGQLMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/contractGQLMock.ts @@ -18,7 +18,7 @@ const fetchContractMockSuccess = ({ }: { contract?: Contract }): MockedResponse => { - const contractData = contract? contract : mockContractPackageDraft() + const contractData = contract ? contract : mockContractPackageDraft() return { request: { diff --git a/services/app-web/src/testHelpers/apolloMocks/index.ts b/services/app-web/src/testHelpers/apolloMocks/index.ts index 7301f0d6d4..f4f3921f7b 100644 --- a/services/app-web/src/testHelpers/apolloMocks/index.ts +++ b/services/app-web/src/testHelpers/apolloMocks/index.ts @@ -79,3 +79,5 @@ export { export { rateDataMock } from './rateDataMock' export { fetchContractMockSuccess, updateDraftContractRatesMockSuccess } from './contractGQLMock' export { indexRatesMockSuccess, indexRatesMockFailure } from './rateGQLMocks' + +export { withdrawAndReplaceRedundantRateMock } from './replaceRateGQLMocks' diff --git a/services/app-web/src/testHelpers/apolloMocks/replaceRateGQLMocks.ts b/services/app-web/src/testHelpers/apolloMocks/replaceRateGQLMocks.ts new file mode 100644 index 0000000000..ed073bf3dd --- /dev/null +++ b/services/app-web/src/testHelpers/apolloMocks/replaceRateGQLMocks.ts @@ -0,0 +1,37 @@ +import { MockedResponse } from '@apollo/client/testing'; +import { + WithdrawAndReplaceRedundantRateMutation, + WithdrawAndReplaceRedundantRateDocument, Contract +} from '../../gen/gqlClient'; +import {mockContractPackageSubmitted} from './contractPackageDataMock'; + + +export const withdrawAndReplaceRedundantRateMock = ({ + contract, + input +}: { + contract: Partial, + input: { + replacementRateID: string, + replaceReason: string, + withdrawnRateID: string, + contractID: string, + } +}): MockedResponse => { + const mockContract = mockContractPackageSubmitted(contract) + return { + request: { + query: WithdrawAndReplaceRedundantRateDocument, + variables: { + input + } + }, + result: { + data: { + withdrawAndReplaceRedundantRate: { + contract: mockContract + } + } + } + } +}