diff --git a/services/app-web/src/components/Header/PageHeadingRow/PageHeadingRow.tsx b/services/app-web/src/components/Header/PageHeadingRow/PageHeadingRow.tsx index 54b9736700..3e831c5c23 100644 --- a/services/app-web/src/components/Header/PageHeadingRow/PageHeadingRow.tsx +++ b/services/app-web/src/components/Header/PageHeadingRow/PageHeadingRow.tsx @@ -18,7 +18,7 @@ const CMSUserRow = ({ heading, }: { user: CmsUser | AdminUser | HelpdeskUser | BusinessOwnerUser - heading?: string + heading?: string | React.ReactElement }) => { return (
@@ -46,7 +46,7 @@ const StateUserRow = ({ heading, }: { user: StateUser - heading?: string + heading?: string | React.ReactElement }) => { return (
@@ -99,7 +99,7 @@ const LandingRow = ({ isLoading }: { isLoading: boolean }) => { type PageHeadingProps = { isLoading?: boolean loggedInUser?: User - heading?: string + heading?: string | React.ReactElement route?: string } diff --git a/services/app-web/src/components/SectionHeader/SectionHeader.module.scss b/services/app-web/src/components/SectionHeader/SectionHeader.module.scss index 5f2e119edb..d566e49b51 100644 --- a/services/app-web/src/components/SectionHeader/SectionHeader.module.scss +++ b/services/app-web/src/components/SectionHeader/SectionHeader.module.scss @@ -12,6 +12,16 @@ align-items: center; padding-bottom: units(1); min-height: 3rem; + + &.alignTop { + align-items: start; + } + + a { + display: block; + margin-top: 17.5px; + padding-bottom: units(1); + } } .summarySectionHeaderBorder { diff --git a/services/app-web/src/components/SectionHeader/SectionHeader.tsx b/services/app-web/src/components/SectionHeader/SectionHeader.tsx index d77533ac53..acd740209d 100644 --- a/services/app-web/src/components/SectionHeader/SectionHeader.tsx +++ b/services/app-web/src/components/SectionHeader/SectionHeader.tsx @@ -7,6 +7,7 @@ export type SectionHeaderProps = { header: string navigateTo?: string children?: React.ReactNode + subHeaderComponent?: React.ReactNode sectionId?: string headerId?: string hideBorder?: boolean @@ -14,6 +15,7 @@ export type SectionHeaderProps = { export const SectionHeader = ({ header, + subHeaderComponent, navigateTo, children, sectionId, @@ -23,10 +25,14 @@ export const SectionHeader = ({ const classes = classNames({ [styles.summarySectionHeader]: true, [styles.summarySectionHeaderBorder]: !hideBorder, + [styles.alignTop]: subHeaderComponent, }) return (
-

{header}

+
+

{header}

+ {subHeaderComponent} +
{navigateTo && ( diff --git a/services/app-web/src/constants/routes.ts b/services/app-web/src/constants/routes.ts index 78581264a9..b92105e250 100644 --- a/services/app-web/src/constants/routes.ts +++ b/services/app-web/src/constants/routes.ts @@ -24,6 +24,7 @@ const ROUTES = [ 'SUBMISSIONS_REVIEW_SUBMIT', 'SUBMISSIONS_REVISION', 'SUBMISSIONS_SUMMARY', + 'SUBMISSIONS_MCCRSID', 'SUBMISSIONS_QUESTIONS_AND_ANSWERS', 'SUBMISSIONS_UPLOAD_QUESTION', 'SUBMISSIONS_UPLOAD_RESPONSE', @@ -58,6 +59,7 @@ const RoutesRecord: Record = { SUBMISSIONS_DOCUMENTS: '/submissions/:id/edit/documents', SUBMISSIONS_REVIEW_SUBMIT: '/submissions/:id/edit/review-and-submit', SUBMISSIONS_SUMMARY: '/submissions/:id', + SUBMISSIONS_MCCRSID: '/submissions/:id/mccrs-record-number', SUBMISSIONS_REVISION: '/submissions/:id/revisions/:revisionVersion', SUBMISSIONS_QUESTIONS_AND_ANSWERS: '/submissions/:id/question-and-answers', SUBMISSIONS_UPLOAD_QUESTION: @@ -131,6 +133,7 @@ const PageTitlesRecord: Record = { SUBMISSIONS_RATE_DETAILS: 'Rate details', SUBMISSIONS_CONTACTS: 'Contacts', SUBMISSIONS_DOCUMENTS: 'Supporting documents', + SUBMISSIONS_MCCRSID: 'Add MC-CRS record number', SUBMISSIONS_REVIEW_SUBMIT: 'Review and submit', SUBMISSIONS_REVISION: 'Submission revision', SUBMISSIONS_SUMMARY: 'Submission summary', diff --git a/services/app-web/src/constants/tealium.ts b/services/app-web/src/constants/tealium.ts index 8e60ba373a..ce0cb008a6 100644 --- a/services/app-web/src/constants/tealium.ts +++ b/services/app-web/src/constants/tealium.ts @@ -51,6 +51,7 @@ const CONTENT_TYPE_BY_ROUTE: Record = { SUBMISSIONS_SUMMARY: 'summary', SUBMISSIONS_REVISION: 'summary', SUBMISSIONS_QUESTIONS_AND_ANSWERS: 'summary', + SUBMISSIONS_MCCRSID: 'form', SUBMISSIONS_UPLOAD_QUESTION: 'form', SUBMISSIONS_UPLOAD_RESPONSE: 'form', UNKNOWN_ROUTE: '404', @@ -83,7 +84,7 @@ const getTealiumPageName = ({ user, }: { route: RouteT | 'UNKNOWN_ROUTE' - heading: string | undefined + heading: string | React.ReactElement | undefined user: User | undefined }) => { const addSubmissionNameHeading = @@ -95,7 +96,7 @@ const getTealiumPageName = ({ title, }: { title: string - heading?: string + heading?: string | React.ReactElement }) => { const headingPrefix = heading && addSubmissionNameHeading ? `${heading}: ` : '' diff --git a/services/app-web/src/contexts/PageContext.tsx b/services/app-web/src/contexts/PageContext.tsx index 470304e9b3..a64ab2daa4 100644 --- a/services/app-web/src/contexts/PageContext.tsx +++ b/services/app-web/src/contexts/PageContext.tsx @@ -7,8 +7,8 @@ import { useCurrentRoute } from '../hooks/useCurrentRoute' Intended for page specific context that must be shared across the application, not for data that can be fetched from the api. */ type PageContextType = { - heading?: string - updateHeading: ({ customHeading }: { customHeading?: string }) => void + heading?: string | React.ReactElement + updateHeading: ({ customHeading }: { customHeading?: string | React.ReactElement }) => void } const PageContext = React.createContext(null as unknown as PageContextType) @@ -21,14 +21,14 @@ const PageProvider: React.FC< React.PropsWithChildren> > > = ({ children }) => { - const [heading, setHeading] = React.useState(undefined) + const [heading, setHeading] = React.useState(undefined) const { currentRoute: routeName } = useCurrentRoute() /* Set headings in priority order 1. If there a custom heading, use that (relevant for heading related to the api loaded resource, such as the submission name) 2. Otherwise, use default static headings for the current location when defined. */ - const updateHeading = ({ customHeading }: { customHeading?: string }) => { + const updateHeading = ({ customHeading }: { customHeading?: string | React.ReactElement }) => { const defaultHeading = PageHeadingsRecord[routeName] ? PageHeadingsRecord[routeName] : undefined diff --git a/services/app-web/src/pages/App/AppRoutes.tsx b/services/app-web/src/pages/App/AppRoutes.tsx index 1bd9df2376..9d9186ddb0 100644 --- a/services/app-web/src/pages/App/AppRoutes.tsx +++ b/services/app-web/src/pages/App/AppRoutes.tsx @@ -22,6 +22,7 @@ import { AuthenticatedRouteWrapper } from '../Wrapper/AuthenticatedRouteWrapper' import { Error404 } from '../Errors/Error404Page' import { Help } from '../Help/Help' import { Landing } from '../Landing/Landing' +import { MccrsId } from '../MccrsId/MccrsId' import { NewStateSubmissionForm, StateSubmissionForm } from '../StateSubmission' import { SubmissionSummary } from '../SubmissionSummary' import { SubmissionRevisionSummary } from '../SubmissionRevisionSummary' @@ -158,6 +159,7 @@ const CMSUserRoutes = ({ featureFlags.RATE_REVIEWS_DASHBOARD.flag, featureFlags.RATE_REVIEWS_DASHBOARD.defaultValue ) + return ( @@ -205,6 +207,12 @@ const CMSUserRoutes = ({ path={RoutesRecord.SUBMISSIONS_SUMMARY} element={} /> + { + } + /> + } [class^='usa-fieldset'] { + padding: units(4); + margin-bottom: units(2); + margin-top: units(2); + background: $cms-color-white; + border: 1px solid $theme-color-base-lighter; + @include u-radius('md'); + } + + h3 { + font-size: 22px; + } + + > div[class^='usa-form-group']:not(:first-of-type) { + margin-top: 2.5rem; + } + + &[class^='usa-form'] { + + min-width: 100%; + max-width: 100%; + + @include at-media(tablet){ + min-width: 40rem; + max-width: 20rem; + margin: 0 auto; + } + } +} + +[class^='usa-legend'] { + margin-top: 0; +} + +div[class^='usa-form-group'] { + margin-top: 24px; + label[class^='usa-label'] { + font-size: 16px; + max-width: 100%; + } +} + +.customHeading { + font-weight: 700; + + span { + font-weight: normal; + margin-left: 16px; + } +} + +div[class^='usa-hint'] span { + span { + display: inline; + font-weight: bold; + } +} + +ul[class^='usa-button-group'] { + justify-content: end; + li[class^='usa-button-group__item'] { + margin-top: 0; + button { + margin-top: 16px; + margin-bottom: 44px; + margin-right: .25rem; + } + } +} + diff --git a/services/app-web/src/pages/MccrsId/MccrsId.test.tsx b/services/app-web/src/pages/MccrsId/MccrsId.test.tsx new file mode 100644 index 0000000000..85d999b869 --- /dev/null +++ b/services/app-web/src/pages/MccrsId/MccrsId.test.tsx @@ -0,0 +1,129 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Route, Routes } from 'react-router' +import { SubmissionSideNav } from '../SubmissionSideNav' +import { RoutesRecord } from '../../constants/routes' +import { + fetchCurrentUserMock, + fetchStateHealthPlanPackageWithQuestionsMockSuccess, + mockValidCMSUser, +} from '../../testHelpers/apolloMocks' +import { renderWithProviders } from '../../testHelpers/jestHelpers' +import { MccrsId } from './MccrsId' + +describe('MCCRSID', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + it('renders without errors', async () => { + renderWithProviders( + + }> + } + /> + + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidCMSUser(), + statusCode: 200, + }), + fetchStateHealthPlanPackageWithQuestionsMockSuccess({ + id: '15', + }), + ], + }, + routerProvider: { + route: '/submissions/15/mccrs-record-number', + }, + } + ) + + expect( + await screen.findByRole('heading', { name: 'MC-CRS record number' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Save MC-CRS number' }) + ).not.toHaveAttribute('aria-disabled') + }) + + it('displays the text field for mccrs id', async () => { + renderWithProviders( + + }> + } + /> + + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidCMSUser(), + statusCode: 200, + }), + fetchStateHealthPlanPackageWithQuestionsMockSuccess({ + id: '15', + }), + ], + }, + routerProvider: { + route: '/submissions/15/mccrs-record-number', + }, + } + ) + + expect(await screen.findByTestId('textInput')).toBeInTheDocument() + }) + + it('cannot continue with MCCRS ID with non number input', async () => { + renderWithProviders( + + }> + } + /> + + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidCMSUser(), + statusCode: 200, + }), + fetchStateHealthPlanPackageWithQuestionsMockSuccess({ + id: '15', + }), + ], + }, + routerProvider: { + route: '/submissions/15/mccrs-record-number', + }, + } + ) + + const input = await screen.findByTestId('textInput') + input.focus() + + await userEvent.paste('123a') + const continueButton = screen.getByRole('button', { + name: 'Save MC-CRS number', + }) + continueButton.click() + await waitFor(() => { + expect(screen.getAllByText('You must enter a number')).toHaveLength( + 1 + ) + expect(continueButton).toHaveAttribute('aria-disabled', 'true') + }) + }) +}) diff --git a/services/app-web/src/pages/MccrsId/MccrsId.tsx b/services/app-web/src/pages/MccrsId/MccrsId.tsx new file mode 100644 index 0000000000..167ae92bfc --- /dev/null +++ b/services/app-web/src/pages/MccrsId/MccrsId.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { Form as UswdsForm, ButtonGroup } from '@trussworks/react-uswds' +import { Formik, FormikErrors } from 'formik' +import { FieldTextInput } from '../../components/Form' +import { MccrsIdFormSchema } from './MccrsIdSchema' +import { recordJSException } from '../../otelHelpers/tracingHelper' +import { useNavigate, useParams, useOutletContext } from 'react-router-dom' +import { GenericApiErrorBanner } from '../../components/Banner/GenericApiErrorBanner/GenericApiErrorBanner' +import { ActionButton } from '../../components/ActionButton' +import { usePage } from '../../contexts/PageContext' +import { SideNavOutletContextType } from '../SubmissionSideNav/SubmissionSideNav' + +import { + HealthPlanPackage, + useUpdateContractMutation, +} from '../../gen/gqlClient' +import styles from './MccrsId.module.scss' + +export interface MccrsIdFormValues { + mccrsId: number | undefined +} +type FormError = + FormikErrors[keyof FormikErrors] + +type RouteParams = { + id: string +} + +export const MccrsId = (): React.ReactElement => { + const [shouldValidate, setShouldValidate] = React.useState(true) + const { id } = useParams() + if (!id) { + throw new Error( + 'PROGRAMMING ERROR: id param not set in state submission form.' + ) + } + const navigate = useNavigate() + const { pkg, packageName } = useOutletContext() + + // page context + const { updateHeading } = usePage() + + const customHeading = useMemo(() => { + return ( + + {packageName} + MC-CRS record number + + ) + }, [packageName]) + useEffect(() => { + updateHeading({ customHeading }) + }, [customHeading, updateHeading]) + + const [showPageErrorMessage, setShowPageErrorMessage] = useState< + boolean | string + >(false) // string is a custom error message, defaults to generic of true + + const [updateFormData] = useUpdateContractMutation() + + const mccrsIDInitialValues: MccrsIdFormValues = { + mccrsId: pkg.mccrsID ? Number(pkg.mccrsID) : undefined, + } + + const showFieldErrors = (error?: FormError) => + shouldValidate && Boolean(error) + + const handleFormSubmit = async (values: MccrsIdFormValues) => { + setShowPageErrorMessage(false) + try { + const updateResult = await updateFormData({ + variables: { + input: { + mccrsID: values?.mccrsId?.toString(), + id: id, + }, + }, + }) + + const updatedSubmission: HealthPlanPackage | undefined = + updateResult?.data?.updateContract.pkg + + if (!updatedSubmission) { + setShowPageErrorMessage(true) + + console.info('Failed to update form data', updateResult) + recordJSException( + `MCCRSIDForm: Apollo error reported. Error message: Failed to update form data ${updateResult}` + ) + return new Error('Failed to update form data') + } + navigate(`/submissions/${updatedSubmission.id}`) + } catch (serverError) { + setShowPageErrorMessage(true) + recordJSException( + `MCCRSIDForm: Apollo error reported. Error message: ${serverError.message}` + ) + return new Error(serverError) + } + } + + return ( + <> + {showPageErrorMessage && } + { + return handleFormSubmit(values) + }} + validationSchema={() => MccrsIdFormSchema()} + > + {({ values, errors, handleSubmit, isSubmitting }) => ( + <> + { + setShouldValidate(true) + handleSubmit(e) + }} + > +
+

MC-CRS record number

+ + Add MC-CRS record number + + + (i.e 4375) + + } + /> +
+ + handleFormSubmit(values)} + animationTimeout={1000} + loading={ + isSubmitting && + shouldValidate && + !errors.mccrsId + } + > + Save MC-CRS number + + +
+ + )} +
+ + ) +} diff --git a/services/app-web/src/pages/MccrsId/MccrsIdSchema.ts b/services/app-web/src/pages/MccrsId/MccrsIdSchema.ts new file mode 100644 index 0000000000..e1cff27b23 --- /dev/null +++ b/services/app-web/src/pages/MccrsId/MccrsIdSchema.ts @@ -0,0 +1,7 @@ +import * as Yup from 'yup' + +export const MccrsIdFormSchema = () => { + return Yup.object().shape({ + mccrsId: Yup.number().typeError('You must enter a number'), + }) +} diff --git a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.module.scss b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.module.scss index b428edeae3..023f272cea 100644 --- a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.module.scss +++ b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.module.scss @@ -17,6 +17,21 @@ padding: 2rem 0; } +.subHeader { + margin-top: 17.5px; + span { + font-weight: bold; + a { + display: inline; + margin-left: 5px; + } + + } + .editLink { + margin-top: 0; + } +} + .banner { margin: units(2) auto; } diff --git a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx index 25d888500c..ffc0d84e57 100644 --- a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx +++ b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx @@ -22,7 +22,6 @@ import { } from '../../testHelpers/jestHelpers' import { SubmissionSummary } from './SubmissionSummary' import { SubmissionSideNav } from '../SubmissionSideNav' -import React from 'react' import { testS3Client } from '../../testHelpers/s3Helpers' describe('SubmissionSummary', () => { @@ -161,6 +160,78 @@ describe('SubmissionSummary', () => { }) }) + it('renders add mccrs-id link for CMS user', async () => { + const submissionsWithRevisions = mockUnlockedHealthPlanPackage() + renderWithProviders( + + }> + } + /> + + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidCMSUser(), + statusCode: 200, + }), + fetchStateHealthPlanPackageWithQuestionsMockSuccess({ + stateSubmission: submissionsWithRevisions, + id: '15', + }), + ], + }, + routerProvider: { + route: '/submissions/15', + }, + } + ) + await waitFor(() => { + expect( + screen.getByText('Add MC-CRS record number') + ).toBeInTheDocument() + }) + }) + + it('does not render an add mccrs-id link for state user', async () => { + const submissionsWithRevisions = mockUnlockedHealthPlanPackage() + renderWithProviders( + + }> + } + /> + + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidUser(), + statusCode: 200, + }), + fetchStateHealthPlanPackageWithQuestionsMockSuccess({ + stateSubmission: submissionsWithRevisions, + id: '15', + }), + ], + }, + routerProvider: { + route: '/submissions/15', + }, + } + ) + await waitFor(() => { + expect( + screen.queryByText('Add MC-CRS record number') + ).not.toBeInTheDocument() + }) + }) + it('renders submission unlocked banner for State user', async () => { const submissionsWithRevisions = mockUnlockedHealthPlanPackage() renderWithProviders( diff --git a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx index afdaf632d3..21fed8f71c 100644 --- a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx +++ b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx @@ -161,6 +161,35 @@ export const SubmissionSummary = (): React.ReactElement => { )} + {pkg.mccrsID && ( + + MC-CRS record number: + + {pkg.mccrsID} + + + )} + + {pkg.mccrsID + ? 'Edit MC-CRS number' + : 'Add MC-CRS record number'} + +
+ ) : undefined + } submission={packageData} submissionName={name} headerChildComponent={