diff --git a/services/app-web/src/components/ActionButton/ActionButton.module.scss b/services/app-web/src/components/ActionButton/ActionButton.module.scss index bd3a137486..86463694a4 100644 --- a/services/app-web/src/components/ActionButton/ActionButton.module.scss +++ b/services/app-web/src/components/ActionButton/ActionButton.module.scss @@ -11,6 +11,10 @@ margin-left: .5rem } +.buttonTextWithoutIcon { + vertical-align: middle; +} + .successButton { background: $theme-color-success; &:hover { diff --git a/services/app-web/src/components/ActionButton/ActionButton.tsx b/services/app-web/src/components/ActionButton/ActionButton.tsx index a496e46018..8e553e683f 100644 --- a/services/app-web/src/components/ActionButton/ActionButton.tsx +++ b/services/app-web/src/components/ActionButton/ActionButton.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, ComponentProps } from 'react' +import React, { useState, ComponentProps, useLayoutEffect } from 'react' import { Button as UswdsButton } from '@trussworks/react-uswds' import classnames from 'classnames' @@ -26,8 +26,10 @@ export const ActionButton = ({ animationTimeout = 750, onClick, ...inheritedProps -}: ActionButtonProps): React.ReactElement => { - const [showLoading, setShowLoading] = useState(false) +}: ActionButtonProps): React.ReactElement | null => { + const [showLoading, setShowLoading] = useState( + undefined + ) const isDisabled = disabled || inheritedProps['aria-disabled'] const isLinkStyled = variant === 'linkStyle' const isOutline = variant === 'outline' @@ -39,14 +41,17 @@ export const ActionButton = ({ 'CODING ERROR: Incompatible props on ActionButton are being used. Button should not be both loading and disabled at the same time.' ) - useEffect(() => { - if (loading) { + useLayoutEffect(() => { + // If there is no animationTimeout, do not use setTimeout else you get flickering UI. + if (animationTimeout > 0) { const timeout = setTimeout(() => { - setShowLoading(true) + setShowLoading(loading) }, animationTimeout) return function cleanup() { clearTimeout(timeout) } + } else { + setShowLoading(loading) } }, [loading, animationTimeout]) @@ -87,7 +92,13 @@ export const ActionButton = ({ className={classes} > {showLoading && } - + {showLoading ? 'Loading' : children} diff --git a/services/app-web/src/components/Banner/DocumentWarningBanner/DocumentWarningBanner.tsx b/services/app-web/src/components/Banner/DocumentWarningBanner/DocumentWarningBanner.tsx new file mode 100644 index 0000000000..963bedbe4b --- /dev/null +++ b/services/app-web/src/components/Banner/DocumentWarningBanner/DocumentWarningBanner.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import styles from '../Banner.module.scss' +import { Alert } from '@trussworks/react-uswds' +import { useStringConstants } from '../../../hooks/useStringConstants' +import classnames from 'classnames' + +export const DocumentWarningBanner = (): React.ReactElement => { + const stringConstants = useStringConstants() + const MAIL_TO_SUPPORT = stringConstants.MAIL_TO_SUPPORT + return ( + + + Some documents aren’t available right now. Refresh the page to + try again. If you still see this message,  + + + email the help desk. + + + ) +} diff --git a/services/app-web/src/components/Banner/index.ts b/services/app-web/src/components/Banner/index.ts index b3bce9eb84..8fde9e1bf4 100644 --- a/services/app-web/src/components/Banner/index.ts +++ b/services/app-web/src/components/Banner/index.ts @@ -4,3 +4,4 @@ export { SubmissionUpdatedBanner } from './SubmissionUpdatedBanner/SubmissionUpd export { GenericApiErrorBanner } from './GenericApiErrorBanner/GenericApiErrorBanner' export { QuestionResponseSubmitBanner } from './QuestionResponseSubmitBanner' export { UserAccountWarningBanner } from './UserAccountWarningBanner/UserAccountWarningBanner' +export { DocumentWarningBanner } from './DocumentWarningBanner/DocumentWarningBanner' diff --git a/services/app-web/src/components/DocumentWarning/InlineDocumentWarning/InlineDocumentWarning.module.scss b/services/app-web/src/components/DocumentWarning/InlineDocumentWarning/InlineDocumentWarning.module.scss new file mode 100644 index 0000000000..327b5a9543 --- /dev/null +++ b/services/app-web/src/components/DocumentWarning/InlineDocumentWarning/InlineDocumentWarning.module.scss @@ -0,0 +1,8 @@ +@import '../../../styles/uswdsImports.scss'; +@import '../../../styles/custom.scss'; + +.missingInfo { + color: $theme-color-warning-darker; + font-weight: 700; + display: flex; +} diff --git a/services/app-web/src/components/DocumentWarning/InlineDocumentWarning/InlineDocumentWarning.tsx b/services/app-web/src/components/DocumentWarning/InlineDocumentWarning/InlineDocumentWarning.tsx new file mode 100644 index 0000000000..f90f1c0aa8 --- /dev/null +++ b/services/app-web/src/components/DocumentWarning/InlineDocumentWarning/InlineDocumentWarning.tsx @@ -0,0 +1,34 @@ +import { Icon as UswdsIcon } from '@trussworks/react-uswds' +import styles from './InlineDocumentWarning.module.scss' + +interface USWDSIconProps { + focusable?: boolean + role?: string + size?: 3 | 4 | 5 | 6 | 7 | 8 | 9 + className?: string +} + +type IconProps = USWDSIconProps & React.JSX.IntrinsicElements['svg'] + +type IconType = keyof typeof UswdsIcon +export const InlineDocumentWarning = ({ + message, + iconType, +}: { + message?: string + iconType?: IconType +}): React.ReactElement | null => { + const requiredFieldMissingText = + message || 'Document download is unavailable' + const type = iconType || 'Warning' + const Icon = UswdsIcon[type] as React.ComponentType + + return ( + + + + + {requiredFieldMissingText} + + ) +} diff --git a/services/app-web/src/components/DocumentWarning/index.ts b/services/app-web/src/components/DocumentWarning/index.ts new file mode 100644 index 0000000000..ae5e924b15 --- /dev/null +++ b/services/app-web/src/components/DocumentWarning/index.ts @@ -0,0 +1 @@ +export { InlineDocumentWarning } from './InlineDocumentWarning/InlineDocumentWarning' diff --git a/services/app-web/src/components/DownloadButton/DownloadButton.module.scss b/services/app-web/src/components/DownloadButton/DownloadButton.module.scss new file mode 100644 index 0000000000..ea9cd52e5c --- /dev/null +++ b/services/app-web/src/components/DownloadButton/DownloadButton.module.scss @@ -0,0 +1,15 @@ +@import '../../styles/uswdsImports.scss'; +@import '../../styles/custom.scss'; + +.disabledCursor { + cursor: not-allowed; +} + +.buttonTextWithIcon { + vertical-align: middle; + margin-left: .5rem +} + +.buttonTextWithoutIcon { + vertical-align: middle; +} diff --git a/services/app-web/src/components/DownloadButton/DownloadButton.test.tsx b/services/app-web/src/components/DownloadButton/DownloadButton.test.tsx index d55ea6fb38..f30d726ad0 100644 --- a/services/app-web/src/components/DownloadButton/DownloadButton.test.tsx +++ b/services/app-web/src/components/DownloadButton/DownloadButton.test.tsx @@ -9,9 +9,18 @@ describe('DownloadButton', () => { zippedFilesURL="https://example.com" /> ) - expect(screen.getByRole('link', {name: 'Download all documents'})).toHaveClass( - 'usa-button usa-button--small' - ) + expect( + screen.getByRole('link', { name: 'Download all documents' }) + ).toHaveClass('usa-button') expect(screen.getByText('Download all documents')).toBeInTheDocument() }) + it('renders loading button', () => { + render( + + ) + expect(screen.getByText('Loading')).toBeInTheDocument() + }) }) diff --git a/services/app-web/src/components/DownloadButton/DownloadButton.tsx b/services/app-web/src/components/DownloadButton/DownloadButton.tsx index 8c9a6be247..f6d1bf763b 100644 --- a/services/app-web/src/components/DownloadButton/DownloadButton.tsx +++ b/services/app-web/src/components/DownloadButton/DownloadButton.tsx @@ -1,25 +1,43 @@ -import React from 'react' +import React, { ComponentProps } from 'react' import { Link } from '@trussworks/react-uswds' +import { Spinner } from '../Spinner' +import styles from '../ActionButton/ActionButton.module.scss' +import classnames from 'classnames' type DownloadButtonProps = { text: string - zippedFilesURL: string -} + zippedFilesURL: string | undefined +} & Partial> export const DownloadButton = ({ text, zippedFilesURL, }: DownloadButtonProps): React.ReactElement => { + const classes = classnames( + { + 'usa-button--active': !zippedFilesURL, + [styles.disabledCursor]: !zippedFilesURL, + }, + 'usa-button usa-button' + ) + return (
- - {text} - + {zippedFilesURL ? ( + + {text} + + ) : ( +
+ + Loading +
+ )}
) } diff --git a/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx b/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx index 7a7a67bbf3..5288bcbdd7 100644 --- a/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx @@ -7,6 +7,7 @@ import { mockStateSubmission, } from '../../../testHelpers/apolloMocks' import { UnlockedHealthPlanFormDataType } from '../../../common-code/healthPlanFormDataType' +import { testS3Client } from '../../../testHelpers/s3Helpers' describe('ContractDetailsSummarySection', () => { it('can render draft submission without errors (review and submit behavior)', async () => { @@ -64,7 +65,7 @@ describe('ContractDetailsSummarySection', () => { ).toBeNull() }) - it('can render state submission on summary page without errors (submission summary behavior)', () => { + it('can render state submission on summary page without errors (submission summary behavior)', async () => { renderWithProviders( { }) ).toBeInTheDocument() expect(screen.queryByText('Edit')).not.toBeInTheDocument() - expect( - screen.getByRole('link', { - name: 'Download all contract documents', - }) - ).toBeInTheDocument() + + //expects loading button on component load + expect(screen.getByText('Loading')).toBeInTheDocument() + + // expects download all button after loading has completed + await waitFor(() => { + expect( + screen.getByRole('link', { + name: 'Download all contract documents', + }) + ).toBeInTheDocument() + }) }) it('can render all contract details fields', () => { @@ -336,6 +344,36 @@ describe('ContractDetailsSummarySection', () => { await screen.queryByText('1937 Benchmark Authority') ).not.toBeInTheDocument() }) + it('renders inline error when bulk URL is unavailable', async () => { + const s3Provider = { + ...testS3Client(), + getBulkDlURL: async ( + keys: string[], + fileName: string + ): Promise => { + return new Error('Error: getBulkDlURL encountered an error') + }, + } + renderWithProviders( + , + { + s3Provider, + } + ) + + await waitFor(() => { + expect( + screen.getByText('Contract document download is unavailable') + ).toBeInTheDocument() + }) + }) describe('contract provisions', () => { it('renders provisions and MLR references for a medicaid amendment', () => { renderWithProviders( diff --git a/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.tsx b/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.tsx index 451c242124..b6ecff7f95 100644 --- a/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import React, { useState } from 'react' import { DataDetail } from '../../../components/DataDetail' import { SectionHeader } from '../../../components/SectionHeader' import { UploadedDocumentsTable } from '../../../components/SubmissionSummarySection' @@ -30,6 +30,9 @@ import { federalAuthorityKeysForCHIP, } from '../../../common-code/healthPlanFormDataType' import { DocumentDateLookupTableType } from '../../../documentHelpers/makeDocumentDateLookupTable' +import { recordJSException } from '../../../otelHelpers' +import useDeepCompareEffect from 'use-deep-compare-effect' +import { InlineDocumentWarning } from '../../DocumentWarning' export type ContractDetailsSummarySectionProps = { submission: HealthPlanFormDataType @@ -37,6 +40,21 @@ export type ContractDetailsSummarySectionProps = { documentDateLookupTable: DocumentDateLookupTableType isCMSUser?: boolean submissionName: string + onDocumentError?: (error: true) => void +} + +function renderDownloadButton(zippedFilesURL: string | undefined | Error) { + if (zippedFilesURL instanceof Error) { + return ( + + ) + } + return ( + + ) } export const ContractDetailsSummarySection = ({ @@ -45,12 +63,15 @@ export const ContractDetailsSummarySection = ({ documentDateLookupTable, isCMSUser, submissionName, + onDocumentError, }: ContractDetailsSummarySectionProps): React.ReactElement => { // Checks if submission is a previous submission const isPreviousSubmission = usePreviousSubmission() // Get the zip file for the contract const { getKey, getBulkDlURL } = useS3() - const [zippedFilesURL, setZippedFilesURL] = useState('') + const [zippedFilesURL, setZippedFilesURL] = useState< + string | undefined | Error + >(undefined) const contractSupportingDocuments = submission.documents.filter((doc) => doc.documentCategories.includes('CONTRACT_RELATED' as const) ) @@ -64,7 +85,10 @@ export const ContractDetailsSummarySection = ({ sortModifiedProvisions(submission) const provisionsAreInvalid = isMissingProvisions(submission) && isEditing - useEffect(() => { + useDeepCompareEffect(() => { + // skip getting urls of this if this is a previous submission or draft + if (!isSubmitted(submission) || isPreviousSubmission) return + // get all the keys for the documents we want to zip async function fetchZipUrl() { const keysFromDocs = submission.contractDocuments @@ -83,8 +107,14 @@ export const ContractDetailsSummarySection = ({ 'HEALTH_PLAN_DOCS' ) if (zippedURL instanceof Error) { - console.info('ERROR: TODO: DISPLAY AN ERROR MESSAGE') - return + const msg = `ERROR: getBulkDlURL failed to generate contract document URL. ID: ${submission.id} Message: ${zippedURL}` + console.info(msg) + + if (onDocumentError) { + onDocumentError(true) + } + + recordJSException(msg) } setZippedFilesURL(zippedURL) @@ -97,16 +127,15 @@ export const ContractDetailsSummarySection = ({ submission, contractSupportingDocuments, submissionName, + isPreviousSubmission, ]) + return (
- {isSubmitted(submission) && !isPreviousSubmission && ( - - )} + {isSubmitted(submission) && + !isPreviousSubmission && + renderDownloadButton(zippedFilesURL)}
diff --git a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx index 4a6e099b27..54fcfb043e 100644 --- a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx @@ -8,6 +8,7 @@ import { renderWithProviders } from '../../../testHelpers/jestHelpers' import * as usePreviousSubmission from '../../../hooks/usePreviousSubmission' import { RateDetailsSummarySection } from './RateDetailsSummarySection' import { RateInfoType } from '../../../common-code/healthPlanFormDataType' +import { testS3Client } from '../../../testHelpers/s3Helpers' describe('RateDetailsSummarySection', () => { const draftSubmission = mockContractAndRatesDraft() @@ -98,7 +99,7 @@ describe('RateDetailsSummarySection', () => { ).toHaveAttribute('href', '/rate-details') }) - it('can render state submission without errors', () => { + it('can render state submission without errors', async () => { renderWithProviders( { ).toBeInTheDocument() // Is this the best way to check that the link is not present? expect(screen.queryByText('Edit')).not.toBeInTheDocument() + + //expects loading button on component load + expect(screen.getByText('Loading')).toBeInTheDocument() + + // expects download all button after loading has completed + await waitFor(() => { + expect( + screen.getByRole('link', { + name: 'Download all rate documents', + }) + ).toBeInTheDocument() + }) }) it('can render all rate details fields for amendment to prior rate certification submission', () => { @@ -790,4 +803,33 @@ describe('RateDetailsSummarySection', () => { screen.queryByText(/You must provide this information/) ).toBeNull() }) + + it('renders inline error when bulk URL is unavailable', async () => { + const s3Provider = { + ...testS3Client(), + getBulkDlURL: async ( + keys: string[], + fileName: string + ): Promise => { + return new Error('Error: getBulkDlURL encountered an error') + }, + } + renderWithProviders( + , + { + s3Provider, + } + ) + + await waitFor(() => { + expect( + screen.getByText('Rate document download is unavailable') + ).toBeInTheDocument() + }) + }) }) diff --git a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.tsx b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.tsx index 0ddf89235d..b986eecf34 100644 --- a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.tsx @@ -24,7 +24,8 @@ import { v4 as uuidv4 } from 'uuid' import { useLDClient } from 'launchdarkly-react-client-sdk' import { featureFlags } from '../../../common-code/featureFlags' import { DocumentDateLookupTableType } from '../../../documentHelpers/makeDocumentDateLookupTable' - +import useDeepCompareEffect from 'use-deep-compare-effect' +import { InlineDocumentWarning } from '../../DocumentWarning' // Used for refreshed packages names keyed by their package id // package name includes (Draft) for draft packages. type PackageNameType = string @@ -42,6 +43,21 @@ export type RateDetailsSummarySectionProps = { isCMSUser?: boolean submissionName: string statePrograms: Program[] + onDocumentError?: (error: true) => void +} + +function renderDownloadButton(zippedFilesURL: string | undefined | Error) { + if (zippedFilesURL instanceof Error) { + return ( + + ) + } + return ( + + ) } export const RateDetailsSummarySection = ({ @@ -51,6 +67,7 @@ export const RateDetailsSummarySection = ({ isCMSUser, submissionName, statePrograms, + onDocumentError, }: RateDetailsSummarySectionProps): React.ReactElement => { // feature flags state management const ldClient = useLDClient() @@ -70,7 +87,9 @@ export const RateDetailsSummarySection = ({ ) const { getKey, getBulkDlURL } = useS3() - const [zippedFilesURL, setZippedFilesURL] = useState('') + const [zippedFilesURL, setZippedFilesURL] = useState< + string | undefined | Error + >(undefined) // Return refreshed package names for state - used rates across submissions feature // Use package name from api if available, otherwise use packageName coming down from proto as fallback @@ -174,7 +193,10 @@ export const RateDetailsSummarySection = ({ } } - useEffect(() => { + useDeepCompareEffect(() => { + // skip getting urls of this if this is a previous submission or draft + if (!isSubmitted || isPreviousSubmission) return + // get all the keys for the documents we want to zip async function fetchZipUrl() { const keysFromDocs = submission.rateInfos @@ -204,8 +226,14 @@ export const RateDetailsSummarySection = ({ 'HEALTH_PLAN_DOCS' ) if (zippedURL instanceof Error) { - console.info('ERROR: TODO: DISPLAY AN ERROR MESSAGE') - return + const msg = `ERROR: getBulkDlURL failed to generate supporting document URL. ID: ${submission.id} Message: ${zippedURL}` + console.info(msg) + + if (onDocumentError) { + onDocumentError(true) + } + + recordJSException(msg) } setZippedFilesURL(zippedURL) @@ -219,18 +247,17 @@ export const RateDetailsSummarySection = ({ submissionLevelRateSupportingDocuments, submissionName, supportingDocsByRate, + isSubmitted, + isPreviousSubmission, ]) return (
- {isSubmitted && !isPreviousSubmission && ( - - )} + {isSubmitted && + !isPreviousSubmission && + renderDownloadButton(zippedFilesURL)} {submission.rateInfos.length > 0 ? ( submission.rateInfos.map((rateInfo) => { diff --git a/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.tsx b/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.tsx index dc0f9c444b..286786a5bd 100644 --- a/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.tsx @@ -8,6 +8,9 @@ import { HealthPlanFormDataType, SubmissionDocument, } from '../../../common-code/healthPlanFormDataType' +import { recordJSException } from '../../../otelHelpers' +import useDeepCompareEffect from 'use-deep-compare-effect' +import { InlineDocumentWarning } from '../../DocumentWarning' type DocumentWithLink = { url: string | null } & SubmissionDocument @@ -15,6 +18,7 @@ export type SupportingDocumentsSummarySectionProps = { submission: HealthPlanFormDataType navigateTo?: string submissionName?: string + onDocumentError?: (error: true) => void } const getUncategorizedDocuments = ( documents: SubmissionDocument[] @@ -22,16 +26,34 @@ const getUncategorizedDocuments = ( documents.filter( (doc) => !doc.documentCategories || doc.documentCategories.length === 0 ) + +function renderDownloadButton(zippedFilesURL: string | undefined | Error) { + if (zippedFilesURL instanceof Error) { + return ( + + ) + } + return ( + + ) +} + // This component is only used for supporting docs that are not categorized (not expected behavior but still possible) // since supporting documents are now displayed in the rate and contract sections export const SupportingDocumentsSummarySection = ({ submission, navigateTo, submissionName, + onDocumentError, }: SupportingDocumentsSummarySectionProps): React.ReactElement | null => { const { getURL, getKey, getBulkDlURL } = useS3() const [refreshedDocs, setRefreshedDocs] = useState([]) - const [zippedFilesURL, setZippedFilesURL] = useState('') + const [zippedFilesURL, setZippedFilesURL] = useState< + string | undefined | Error + >(undefined) const isSubmitted = submission.status === 'SUBMITTED' useEffect(() => { const refreshDocuments = async () => { @@ -65,7 +87,10 @@ export const SupportingDocumentsSummarySection = ({ void refreshDocuments() }, [submission, getKey, getURL]) - useEffect(() => { + useDeepCompareEffect(() => { + // skip getting urls of this if this is a previous submission, draft or no uncategorized supporting documents + if (!isSubmitted || refreshedDocs.length === 0) return + // get all the keys for the documents we want to zip const uncategorizedDocuments = getUncategorizedDocuments( submission.documents @@ -86,16 +111,23 @@ export const SupportingDocumentsSummarySection = ({ submissionName + '-supporting-documents.zip', 'HEALTH_PLAN_DOCS' ) + if (zippedURL instanceof Error) { - console.info('ERROR: TODO: DISPLAY AN ERROR MESSAGE') - return + const msg = `ERROR: getBulkDlURL failed to generate contract document URL. ID: ${submission.id} Message: ${zippedURL}` + console.info(msg) + + if (onDocumentError) { + onDocumentError(true) + } + + recordJSException(msg) } setZippedFilesURL(zippedURL) } void fetchZipUrl() - }, [getKey, getBulkDlURL, submission, submissionName]) + }, [getKey, getBulkDlURL, submission, submissionName, isSubmitted]) const documentsSummary = `${refreshedDocs.length} ${ refreshedDocs.length === 1 ? 'file' : 'files' @@ -109,12 +141,7 @@ export const SupportingDocumentsSummarySection = ({ header="Supporting documents" navigateTo={navigateTo} > - {isSubmitted && ( - - )} + {isSubmitted && renderDownloadButton(zippedFilesURL)} {documentsSummary}
    diff --git a/services/app-web/src/components/index.ts b/services/app-web/src/components/index.ts index c993f26e4b..668b1f3b1a 100644 --- a/services/app-web/src/components/index.ts +++ b/services/app-web/src/components/index.ts @@ -54,6 +54,7 @@ export { SubmissionUpdatedBanner, GenericApiErrorBanner, QuestionResponseSubmitBanner, + DocumentWarningBanner, } from './Banner' export { Modal } from './Modal' @@ -82,3 +83,5 @@ export type { FilterAccordionPropType } from './FilterAccordion' export { ActionButton } from './ActionButton' export { Breadcrumbs } from './Breadcrumbs' + +export { InlineDocumentWarning } from './DocumentWarning' diff --git a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx index a12b16471e..25d888500c 100644 --- a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx +++ b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.test.tsx @@ -13,6 +13,8 @@ import { mockUnlockedHealthPlanPackageWithOldProtos, indexHealthPlanPackagesMockSuccess, mockValidUser, + mockStateSubmission, + mockSubmittedHealthPlanPackage, } from '../../testHelpers/apolloMocks' import { ldUseClientSpy, @@ -21,6 +23,7 @@ import { import { SubmissionSummary } from './SubmissionSummary' import { SubmissionSideNav } from '../SubmissionSideNav' import React from 'react' +import { testS3Client } from '../../testHelpers/s3Helpers' describe('SubmissionSummary', () => { beforeEach(() => { @@ -204,6 +207,59 @@ describe('SubmissionSummary', () => { }) }) + it('renders document download warning banner', async () => { + const s3Provider = { + ...testS3Client(), + getBulkDlURL: async ( + keys: string[], + fileName: string + ): Promise => { + return new Error('Error: getBulkDlURL encountered an error') + }, + } + const contractAndRate = mockSubmittedHealthPlanPackage( + mockStateSubmission() + ) + renderWithProviders( + + }> + } + /> + + , + { + apolloProvider: { + mocks: [ + fetchCurrentUserMock({ + user: mockValidCMSUser(), + statusCode: 200, + }), + fetchStateHealthPlanPackageWithQuestionsMockSuccess({ + id: '15', + stateSubmission: contractAndRate, + }), + ], + }, + routerProvider: { + route: '/submissions/15', + }, + s3Provider, + } + ) + + await waitFor(() => { + expect(screen.getByTestId('warning-alert')).toBeInTheDocument() + expect(screen.getByTestId('warning-alert')).toHaveClass( + 'usa-alert--warning' + ) + expect(screen.getByTestId('warning-alert')).toHaveTextContent( + 'Document download unavailable' + ) + }) + }) + it('renders back to dashboard link for state users', async () => { renderWithProviders( @@ -513,6 +569,7 @@ describe('SubmissionSummary', () => { ).toHaveTextContent('Reason for unlock: Test unlock reason') }) }) + describe('Outdated submissions', () => { it('Jest timezone should already be UTC', () => { expect(new Date().getTimezoneOffset()).toBe(0) diff --git a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx index 5069614102..c47f5b80e5 100644 --- a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx +++ b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx @@ -18,6 +18,7 @@ import { import { SubmissionUnlockedBanner, SubmissionUpdatedBanner, + DocumentWarningBanner, } from '../../components' import { usePage } from '../../contexts/PageContext' import { UpdateInformation } from '../../gen/gqlClient' @@ -54,6 +55,7 @@ export const SubmissionSummary = (): React.ReactElement => { const { updateHeading } = usePage() const modalRef = useRef(null) const [pkgName, setPkgName] = useState(undefined) + const [documentError, setDocumentError] = useState(false) useEffect(() => { updateHeading({ customHeading: pkgName }) @@ -93,6 +95,9 @@ export const SubmissionSummary = (): React.ReactElement => { const isContractActionAndRateCertification = packageData.submissionType === 'CONTRACT_AND_RATES' + const handleDocumentDownloadError = (error: boolean) => + setDocumentError(error) + return (
    { /> )} + {documentError && } + {!showQuestionResponse && ( { submission={packageData} isCMSUser={isCMSUser} submissionName={packageName(packageData, statePrograms)} + onDocumentError={handleDocumentDownloadError} /> {isContractActionAndRateCertification && ( @@ -173,12 +181,16 @@ export const SubmissionSummary = (): React.ReactElement => { submissionName={packageName(packageData, statePrograms)} isCMSUser={isCMSUser} statePrograms={statePrograms} + onDocumentError={handleDocumentDownloadError} /> )} - + { diff --git a/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts b/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts index 89a47374e3..18c02dfd2a 100644 --- a/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts @@ -232,7 +232,7 @@ function mockStateSubmission(): LockedHealthPlanFormDataType { { s3URL: 's3://bucketname/key/supporting-documents', name: 'supporting documents', - documentCategories: ['RATES_RELATED' as const], + documentCategories: ['CONTRACT_RELATED' as const], }, ], contractType: 'BASE', @@ -271,7 +271,13 @@ function mockStateSubmission(): LockedHealthPlanFormDataType { documentCategories: ['RATES' as const], }, ], - supportingDocuments: [], + supportingDocuments: [ + { + s3URL: 's3://bucketname/key/supporting-documents', + name: 'supporting documents', + documentCategories: ['RATES_RELATED' as const], + }, + ], rateDateStart: new Date(), rateDateEnd: new Date(), rateDateCertified: new Date(), @@ -425,7 +431,9 @@ function mockDraftHealthPlanPackage( } function mockSubmittedHealthPlanPackage( - submissionData?: Partial, + submissionData?: Partial< + UnlockedHealthPlanFormDataType | LockedHealthPlanFormDataType + >, submitInfo?: Partial ): HealthPlanPackage { // get a submitted DomainModel submission