From b6a4e72693e04b8ee01e452d7f22f217afb291c4 Mon Sep 17 00:00:00 2001 From: R Ranathunga Date: Sun, 9 Jun 2024 18:10:58 -0700 Subject: [PATCH 1/2] feat: add toast after file upload RFI template 1 and 2 --- .../Analyst/RFI/RFIAnalystUpload.tsx | 37 +++++ app/components/AppProvider.tsx | 60 +++++++ app/components/Toast.tsx | 2 +- app/lib/theme/widgets/FileWidget.tsx | 57 +++++-- app/pages/_app.tsx | 7 +- .../Analyst/RFI/RFIAnalystUpload.test.ts | 157 ++++++++++++++++++ app/tests/components/Form/FileWidget.test.tsx | 50 ++++++ app/tests/utils/componentTestingHelper.tsx | 11 +- app/tests/utils/pageTestingHelper.tsx | 11 +- 9 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 app/components/AppProvider.tsx diff --git a/app/components/Analyst/RFI/RFIAnalystUpload.tsx b/app/components/Analyst/RFI/RFIAnalystUpload.tsx index 882da3027e..de29413583 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,15 @@ const RfiAnalystUpload = ({ query }) => { } ); } + if ( + templateData?.templateNumber === 1 || + templateData?.templateNumber === 2 + ) { + showToast(getToastMessage(), 'success', 100000000); + } + router.push( + `/analyst/application/${router.query.applicationId}/rfi` + ); }, }); } diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx new file mode 100644 index 0000000000..bc03c7dd53 --- /dev/null +++ b/app/components/AppProvider.tsx @@ -0,0 +1,60 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, + ReactNode, +} from 'react'; +import Toast, { ToastType } from 'components/Toast'; + +type ToastContextType = { + showToast?: (message: ReactNode, type?: ToastType, timeout?: number) => void; + hideToast?: () => void; +}; + +const ToastContext = createContext({}); + +export const useToast = () => { + return useContext(ToastContext); +}; + +export const AppProvider = ({ children }) => { + 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..342018abf8 100644 --- a/app/lib/theme/widgets/FileWidget.tsx +++ b/app/lib/theme/widgets/FileWidget.tsx @@ -13,6 +13,7 @@ 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'; type File = { id: string | number; @@ -65,6 +66,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,27 +75,29 @@ const FileWidget: React.FC = ({ }, [rawErrors, setErrors]); const getValidatedFile = async (file: any, formId: number) => { + let isTemplateValid = true; if (templateValidate) { const fileFormData = new FormData(); if (file) { fileFormData.append('file', file); if (setTemplateData) { - await fetch( + const response = await fetch( `/api/applicant/template?templateNumber=${templateNumber}`, { method: 'POST', body: fileFormData, } - ).then((response) => { - if (response.ok) { - response.json().then((data) => { - setTemplateData({ - templateNumber, - data, - }); + ); + if (response.ok) { + response.json().then((data) => { + setTemplateData({ + templateNumber, + data, }); - } - }); + }); + } else { + isTemplateValid = false; + } } } } @@ -109,6 +113,7 @@ const FileWidget: React.FC = ({ } return { + isTemplateValid, input: { attachment: { file, @@ -153,6 +158,17 @@ const FileWidget: React.FC = ({ }); }; + const getToastMessage = (files: any[], isSuccess: boolean = true) => { + const fields = + templateNumber === 1 + ? 'Total Households and Indigenous Households data' + : 'Total eligible costs and Total project costs data'; + if (isSuccess) { + return `Template ${templateNumber} validation successful, new values for ${fields} data in the application will update upon 'Save'`; + } + return `Template ${templateNumber} validation failed : ${files.join(', ')} did not validate due to formatting issues. ${fields} in the application will not update.`; + }; + const handleChange = async (e: React.ChangeEvent) => { const transaction = Sentry.startTransaction({ name: 'ccbc.function' }); const span = transaction.startChild({ @@ -161,6 +177,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 +194,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 +209,24 @@ const FileWidget: React.FC = ({ } else { span.setStatus('ok'); } + + if (templateValidate) { + if (validationErrors.length > 0) { + showToast( + getToastMessage( + validationErrors.map( + (error) => error.fileName || error.input?.attachment?.fileName + ), + false + ), + 'error', + 100000000 + ); + } else if (validationErrors.length === 0 && uploadErrors.length === 0) { + showToast(getToastMessage(fileDetails), 'success', 100000000); + } + } + 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 { - + + + From ddd4c92c926c4e5d7b800ee1d4a3e734f99e29af Mon Sep 17 00:00:00 2001 From: R Ranathunga Date: Mon, 10 Jun 2024 08:06:06 -0700 Subject: [PATCH 2/2] refactor: toast hide and show --- .../Analyst/RFI/RFIAnalystUpload.tsx | 3 -- app/components/AppProvider.tsx | 13 +++-- app/lib/theme/widgets/FileWidget.tsx | 50 +++++++++---------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/app/components/Analyst/RFI/RFIAnalystUpload.tsx b/app/components/Analyst/RFI/RFIAnalystUpload.tsx index de29413583..832a689593 100644 --- a/app/components/Analyst/RFI/RFIAnalystUpload.tsx +++ b/app/components/Analyst/RFI/RFIAnalystUpload.tsx @@ -163,9 +163,6 @@ const RfiAnalystUpload = ({ query }) => { ) { showToast(getToastMessage(), 'success', 100000000); } - router.push( - `/analyst/application/${router.query.applicationId}/rfi` - ); }, }); } diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index bc03c7dd53..91ccd6d17b 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -8,18 +8,21 @@ import React, { } from 'react'; import Toast, { ToastType } from 'components/Toast'; -type ToastContextType = { +type AppContextType = { showToast?: (message: ReactNode, type?: ToastType, timeout?: number) => void; hideToast?: () => void; }; -const ToastContext = createContext({}); +const AppContext = createContext({}); export const useToast = () => { - return useContext(ToastContext); + return useContext(AppContext); }; export const AppProvider = ({ children }) => { + /** + * handling global toast messages + */ const [toast, setToast] = useState<{ visible: boolean; message?: ReactNode; @@ -48,13 +51,13 @@ export const AppProvider = ({ children }) => { ); return ( - + {children} {toast?.visible && ( {toast.message} )} - + ); }; diff --git a/app/lib/theme/widgets/FileWidget.tsx b/app/lib/theme/widgets/FileWidget.tsx index 342018abf8..9dd1a5bcb7 100644 --- a/app/lib/theme/widgets/FileWidget.tsx +++ b/app/lib/theme/widgets/FileWidget.tsx @@ -14,6 +14,7 @@ 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; @@ -81,23 +82,24 @@ const FileWidget: React.FC = ({ if (file) { fileFormData.append('file', file); if (setTemplateData) { - const response = await fetch( + await fetch( `/api/applicant/template?templateNumber=${templateNumber}`, { method: 'POST', body: fileFormData, } - ); - if (response.ok) { - response.json().then((data) => { - setTemplateData({ - templateNumber, - data, + ).then((response) => { + if (response.ok) { + response.json().then((data) => { + setTemplateData({ + templateNumber, + data, + }); }); - }); - } else { - isTemplateValid = false; - } + } else { + isTemplateValid = false; + } + }); } } } @@ -158,15 +160,17 @@ const FileWidget: React.FC = ({ }); }; - const getToastMessage = (files: any[], isSuccess: boolean = true) => { + const showToastMessage = (files, type: ToastType = 'success') => { const fields = templateNumber === 1 ? 'Total Households and Indigenous Households data' : 'Total eligible costs and Total project costs data'; - if (isSuccess) { - return `Template ${templateNumber} validation successful, new values for ${fields} data in the application will update upon 'Save'`; - } - return `Template ${templateNumber} validation failed : ${files.join(', ')} did not validate due to formatting issues. ${fields} in the application will not update.`; + 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) => { @@ -212,18 +216,14 @@ const FileWidget: React.FC = ({ if (templateValidate) { if (validationErrors.length > 0) { - showToast( - getToastMessage( - validationErrors.map( - (error) => error.fileName || error.input?.attachment?.fileName - ), - false + showToastMessage( + validationErrors.map( + (error) => error.fileName || error.input?.attachment?.fileName ), - 'error', - 100000000 + 'error' ); } else if (validationErrors.length === 0 && uploadErrors.length === 0) { - showToast(getToastMessage(fileDetails), 'success', 100000000); + showToastMessage(fileDetails.map((file) => file.name)); } }