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 {
-
+
+
+