diff --git a/app/components/Analyst/RFI/RFIAnalystUpload.tsx b/app/components/Analyst/RFI/RFIAnalystUpload.tsx index 882da3027e..832a689593 100644 --- a/app/components/Analyst/RFI/RFIAnalystUpload.tsx +++ b/app/components/Analyst/RFI/RFIAnalystUpload.tsx @@ -13,6 +13,8 @@ import styled from 'styled-components'; import { useCreateNewFormDataMutation } from 'schema/mutations/application/createNewFormData'; import useHHCountUpdateEmail from 'lib/helpers/useHHCountUpdateEmail'; import useRfiCoverageMapKmzUploadedEmail from 'lib/helpers/useRfiCoverageMapKmzUploadedEmail'; +import { useToast } from 'components/AppProvider'; +import Link from 'next/link'; const Flex = styled('header')` display: flex; @@ -20,6 +22,10 @@ const Flex = styled('header')` width: 100%; `; +const StyledLink = styled(Link)` + color: ${(props) => props.theme.color.white}; +`; + const RfiAnalystUpload = ({ query }) => { const queryFragment = useFragment( graphql` @@ -52,6 +58,7 @@ const RfiAnalystUpload = ({ query }) => { rowId: applicationId, ccbcNumber, } = applicationByRowId; + const { showToast, hideToast } = useToast(); const { rfiNumber } = rfiDataByRowId; @@ -94,9 +101,30 @@ const RfiAnalystUpload = ({ query }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [templateData]); + const getToastMessage = () => { + return ( + <> + {' '} + `Template {templateData?.templateNumber} data changed successfully, new + values for{' '} + {templateData?.templateNumber === 1 + ? 'Total Households and Indigenous Households' + : 'Total eligible costs and Total project costs'}{' '} + data in the application now reflect template uploads. Please see{' '} + + history page + {' '} + for details.` + + ); + }; + const handleSubmit = () => { const updatedExcelFields = excelImportFields.join(', '); const reasonForChange = `Auto updated from upload of ${updatedExcelFields} for RFI: ${rfiNumber}`; + hideToast(); updateRfi({ variables: { input: { @@ -129,6 +157,12 @@ const RfiAnalystUpload = ({ query }) => { } ); } + if ( + templateData?.templateNumber === 1 || + templateData?.templateNumber === 2 + ) { + showToast(getToastMessage(), 'success', 100000000); + } }, }); } diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx new file mode 100644 index 0000000000..91ccd6d17b --- /dev/null +++ b/app/components/AppProvider.tsx @@ -0,0 +1,63 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, + ReactNode, +} from 'react'; +import Toast, { ToastType } from 'components/Toast'; + +type AppContextType = { + showToast?: (message: ReactNode, type?: ToastType, timeout?: number) => void; + hideToast?: () => void; +}; + +const AppContext = createContext({}); + +export const useToast = () => { + return useContext(AppContext); +}; + +export const AppProvider = ({ children }) => { + /** + * handling global toast messages + */ + const [toast, setToast] = useState<{ + visible: boolean; + message?: ReactNode; + type?: ToastType; + timeout?: number; + }>({ visible: false }); + + const showToast = useCallback( + ( + message: ReactNode, + type: ToastType = 'success', + timeout: number = 10000 + ) => { + setToast({ visible: true, message, type, timeout }); + }, + [] + ); + + const hideToast = useCallback(() => { + setToast({ visible: false }); + }, []); + + const contextValue = useMemo( + () => ({ showToast, hideToast }), + [showToast, hideToast] + ); + + return ( + + {children} + {toast?.visible && ( + + {toast.message} + + )} + + ); +}; diff --git a/app/components/Toast.tsx b/app/components/Toast.tsx index d14edf9f04..c1f8c6333a 100644 --- a/app/components/Toast.tsx +++ b/app/components/Toast.tsx @@ -9,7 +9,7 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { theme } from 'styles/GlobalTheme'; -type ToastType = 'success' | 'warning' | 'error'; +export type ToastType = 'success' | 'warning' | 'error'; type ToastDirection = 'left' | 'right'; diff --git a/app/lib/theme/widgets/FileWidget.tsx b/app/lib/theme/widgets/FileWidget.tsx index c0b7bca6ad..9dd1a5bcb7 100644 --- a/app/lib/theme/widgets/FileWidget.tsx +++ b/app/lib/theme/widgets/FileWidget.tsx @@ -13,6 +13,8 @@ import bytesToSize from 'utils/bytesToText'; import FileComponent from 'lib/theme/components/FileComponent'; import useDisposeOnRouteChange from 'lib/helpers/useDisposeOnRouteChange'; import { DateTime } from 'luxon'; +import { useToast } from 'components/AppProvider'; +import { ToastType } from 'components/Toast'; type File = { id: string | number; @@ -65,6 +67,7 @@ const FileWidget: React.FC = ({ const maxFileSizeInBytes = 104857600; const fileId = isFiles && value[0].id; const { setTemplateData } = formContext; + const { showToast, hideToast } = useToast(); useEffect(() => { if (rawErrors?.length > 0) { @@ -73,6 +76,7 @@ const FileWidget: React.FC = ({ }, [rawErrors, setErrors]); const getValidatedFile = async (file: any, formId: number) => { + let isTemplateValid = true; if (templateValidate) { const fileFormData = new FormData(); if (file) { @@ -92,6 +96,8 @@ const FileWidget: React.FC = ({ data, }); }); + } else { + isTemplateValid = false; } }); } @@ -109,6 +115,7 @@ const FileWidget: React.FC = ({ } return { + isTemplateValid, input: { attachment: { file, @@ -153,6 +160,19 @@ const FileWidget: React.FC = ({ }); }; + const showToastMessage = (files, type: ToastType = 'success') => { + const fields = + templateNumber === 1 + ? 'Total Households and Indigenous Households data' + : 'Total eligible costs and Total project costs data'; + const message = + type === 'success' + ? `Template ${templateNumber} validation successful, new values for ${fields} data in the application will update upon 'Save'` + : `Template ${templateNumber} validation failed: ${files.join(', ')} did not validate due to formatting issues. ${fields} in the application will not update.`; + + showToast(message, type, 100000000); + }; + const handleChange = async (e: React.ChangeEvent) => { const transaction = Sentry.startTransaction({ name: 'ccbc.function' }); const span = transaction.startChild({ @@ -161,6 +181,7 @@ const FileWidget: React.FC = ({ }); if (loading) return; + hideToast(); const formId = parseInt(router?.query?.id as string, 10) || parseInt(router?.query?.applicationId as string, 10); @@ -177,6 +198,8 @@ const FileWidget: React.FC = ({ const validatedFiles = resp.filter((file) => file.input); setErrors(resp.filter((file) => file.error)); + const validationErrors = resp.filter((file) => !file.isTemplateValid); + const uploadResponse = await Promise.all( validatedFiles.map(async (payload) => handleUpload(payload)) ); @@ -190,6 +213,20 @@ const FileWidget: React.FC = ({ } else { span.setStatus('ok'); } + + if (templateValidate) { + if (validationErrors.length > 0) { + showToastMessage( + validationErrors.map( + (error) => error.fileName || error.input?.attachment?.fileName + ), + 'error' + ); + } else if (validationErrors.length === 0 && uploadErrors.length === 0) { + showToastMessage(fileDetails.map((file) => file.name)); + } + } + span.finish(); transaction.finish(); diff --git a/app/pages/_app.tsx b/app/pages/_app.tsx index 0736cdfc67..ae8816b045 100644 --- a/app/pages/_app.tsx +++ b/app/pages/_app.tsx @@ -18,6 +18,7 @@ import GlobalStyle from 'styles/GobalStyles'; import GlobalTheme from 'styles/GlobalTheme'; import BCGovTypography from 'components/BCGovTypography'; import { SessionExpiryHandler } from 'components'; +import { AppProvider } from 'components/AppProvider'; config.autoAddCss = false; @@ -94,8 +95,10 @@ const MyApp = ({ Component, pageProps }: AppProps) => { }> - {typeof window !== 'undefined' && } - {component} + + {typeof window !== 'undefined' && } + {component} + diff --git a/app/tests/components/Analyst/RFI/RFIAnalystUpload.test.ts b/app/tests/components/Analyst/RFI/RFIAnalystUpload.test.ts index 2ef65710e1..c47c6179b4 100644 --- a/app/tests/components/Analyst/RFI/RFIAnalystUpload.test.ts +++ b/app/tests/components/Analyst/RFI/RFIAnalystUpload.test.ts @@ -200,6 +200,8 @@ describe('The RFIAnalystUpload component', () => { expect(screen.getByText('template_one.xlsx')).toBeInTheDocument(); + expect(screen.getByText(/Template 1 validation successful/)).toBeVisible(); + const saveButton = screen.getByRole('button', { name: 'Save', }); @@ -318,5 +320,160 @@ describe('The RFIAnalystUpload component', () => { ); expect(mockNotifyRfiCoverageMapKmzUploaded).toHaveBeenCalledTimes(1); + expect( + screen.getByText(/Template 1 data changed successfully/) + ).toBeVisible(); + }); + + it('should render success toast for template two when upload successful', async () => { + componentTestingHelper.loadQuery(); + componentTestingHelper.renderComponent(); + + // @ts-ignore + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + result: { + totalEligibleCosts: 92455, + totalProjectCosts: 101230, + }, + }), + }) + ); + + const dateInput = screen.getAllByPlaceholderText('YYYY-MM-DD')[0]; + + await act(async () => { + fireEvent.change(dateInput, { + target: { + value: '2025-07-01', + }, + }); + }); + + const file = new File([new ArrayBuffer(1)], 'template_two.xlsx', { + type: 'application/excel', + }); + + const inputFile = screen.getAllByTestId('file-test')[1]; + + await act(async () => { + fireEvent.change(inputFile, { target: { files: [file] } }); + }); + + componentTestingHelper.expectMutationToBeCalled( + 'createAttachmentMutation', + { + input: { + attachment: { + file: expect.anything(), + fileName: 'template_two.xlsx', + fileSize: '1 Bytes', + fileType: 'application/excel', + applicationId: expect.anything(), + }, + }, + } + ); + + await act(async () => { + componentTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + createAttachment: { + attachment: { + rowId: 1, + file: 'string', + }, + }, + }, + }); + }); + + expect(screen.getByText('template_two.xlsx')).toBeInTheDocument(); + + expect(screen.getByText(/Template 2 validation successful/)).toBeVisible(); + + const saveButton = screen.getByRole('button', { + name: 'Save', + }); + + await act(async () => { + fireEvent.click(saveButton); + }); + + componentTestingHelper.expectMutationToBeCalled( + 'updateWithTrackingRfiMutation', + { + input: { + jsonData: { + rfiType: [], + rfiAdditionalFiles: { + detailedBudgetRfi: true, + eligibilityAndImpactsCalculatorRfi: true, + detailedBudget: expect.anything(), + geographicCoverageMapRfi: true, + geographicCoverageMap: expect.anything(), + }, + }, + rfiRowId: 1, + }, + } + ); + + act(() => { + componentTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + updateWithTrackingRfi: { + rfiData: { + rowId: 1, + jsonData: { + rfiAdditionalFiles: { + detailedBudgetRfi: true, + eligibilityAndImpactsCalculatorRfi: true, + detailedBudget: expect.anything(), + geographicCoverageMapRfi: true, + geographicCoverageMap: expect.anything(), + }, + }, + }, + }, + }, + }); + }); + + componentTestingHelper.expectMutationToBeCalled( + 'createNewFormDataMutation', + { + input: { + applicationRowId: 1, + jsonData: { + benefits: { + householdsImpactedIndigenous: 13, + numberOfHouseholds: 12, + }, + budgetDetails: { + totalEligibleCosts: 92455, + totalProjectCost: 101230, + }, + }, + reasonForChange: + 'Auto updated from upload of Template 2 for RFI: RFI-01', + formSchemaId: 1, + }, + } + ); + + act(() => { + componentTestingHelper.environment.mock.resolveMostRecentOperation({ + data: {}, + }); + }); + + expect( + screen.getByText(/Template 2 data changed successfully/) + ).toBeVisible(); }); }); diff --git a/app/tests/components/Form/FileWidget.test.tsx b/app/tests/components/Form/FileWidget.test.tsx index 6ce3a775c6..108c70c09d 100644 --- a/app/tests/components/Form/FileWidget.test.tsx +++ b/app/tests/components/Form/FileWidget.test.tsx @@ -52,6 +52,7 @@ const componentTestingHelper = new ComponentTestingHelper({ application: data.application, pageNumber: getFormPage(uiSchema['ui:order'], 'coverage'), query: data.query, + formContext: { setTemplateData: jest.fn() }, }), }); @@ -535,6 +536,55 @@ describe('The FileWidget', () => { { body: formData, method: 'POST' } ); }); + + it('displays an error toast when template validation fails', async () => { + componentTestingHelper.loadQuery(); + componentTestingHelper.renderComponent((data) => ({ + application: data.application, + pageNumber: 11, + query: data.query, + })); + + const mockFetchPromiseTemplateOne = Promise.resolve({ + json: () => Promise.resolve(null), + }); + + global.fetch = jest.fn(() => { + return mockFetchPromiseTemplateOne; + }); + + const file = new File([new ArrayBuffer(1)], 'file.xlsx', { + type: 'application/vnd.ms-excel', + }); + + const inputFile = screen.getAllByTestId('file-test')[0]; + await act(async () => { + fireEvent.change(inputFile, { target: { files: [file] } }); + }); + const formData = new FormData(); + formData.append('file', file); + expect(global.fetch).toHaveBeenCalledOnce(); + expect(global.fetch).toHaveBeenCalledWith( + '/api/applicant/template?templateNumber=1', + { body: formData, method: 'POST' } + ); + + await act(async () => { + componentTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + createAttachment: { + attachment: { + rowId: 1, + file: 'string', + }, + }, + }, + }); + }); + + expect(screen.getByText(/Template 1 validation failed/)).toBeVisible(); + }); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/app/tests/utils/componentTestingHelper.tsx b/app/tests/utils/componentTestingHelper.tsx index ce2fc8871b..df545a65a8 100644 --- a/app/tests/utils/componentTestingHelper.tsx +++ b/app/tests/utils/componentTestingHelper.tsx @@ -10,6 +10,7 @@ import { MockResolvers } from 'relay-test-utils/lib/RelayMockPayloadGenerator'; import GlobalTheme from 'styles/GlobalTheme'; import GlobalStyle from 'styles/GobalStyles'; import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; +import { AppProvider } from 'components/AppProvider'; import TestingHelper from './TestingHelper'; interface ComponentTestingHelperOptions { @@ -85,10 +86,12 @@ class ComponentTestingHelper< - + + + diff --git a/app/tests/utils/pageTestingHelper.tsx b/app/tests/utils/pageTestingHelper.tsx index 4ca685c3bb..0fc9d94a6c 100644 --- a/app/tests/utils/pageTestingHelper.tsx +++ b/app/tests/utils/pageTestingHelper.tsx @@ -10,6 +10,7 @@ import { ConcreteRequest, OperationType } from 'relay-runtime'; import { MockResolvers } from 'relay-test-utils/lib/RelayMockPayloadGenerator'; import GlobalTheme from 'styles/GlobalTheme'; import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; +import { AppProvider } from 'components/AppProvider'; import TestingHelper from './TestingHelper'; interface PageTestingHelperOptions { @@ -66,10 +67,12 @@ class PageTestingHelper extends TestingHelper { - + + +