From 49db7e14975913f187e3d303a0d271e616d71377 Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Tue, 4 Jul 2023 14:57:34 -0700 Subject: [PATCH 1/5] Working error wrapper with notification --- app/src/App.tsx | 31 ++++--- .../custom/notifications/ErrorWrapper.tsx | 86 +++++++++++++++++++ 2 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 app/src/components/custom/notifications/ErrorWrapper.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index e969421..9bf9a3a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import { normalFont } from './constants/fonts'; import Login from './pages/Login'; import UserRequests from './pages/UserRequests'; import './App.css'; +import ErrorWrapper from './components/custom/notifications/ErrorWrapper'; /** * @returns Main element containing various providers and routes. @@ -30,20 +31,22 @@ const App = () => {
- - -
- {user ? ( - - } /> - } /> - } /> - - ) : ( - - )} -
-
+ + + +
+ {user ? ( + + } /> + } /> + } /> + + ) : ( + + )} +
+
+
diff --git a/app/src/components/custom/notifications/ErrorWrapper.tsx b/app/src/components/custom/notifications/ErrorWrapper.tsx new file mode 100644 index 0000000..62b59f8 --- /dev/null +++ b/app/src/components/custom/notifications/ErrorWrapper.tsx @@ -0,0 +1,86 @@ +import { + ReactNode, + SyntheticEvent, + createContext, + useState, + useMemo, + Dispatch, + SetStateAction, + CSSProperties, +} from 'react'; +import Snackbar from '@mui/material/Snackbar'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import { bcgov } from '../../../constants/colours'; +import { SnackbarContent } from '@mui/material'; + +interface IErrorWrapper { + children: ReactNode; +} + +interface ErrorState { + text: string; + open: boolean; + style?: CSSProperties; +} + +const initialState: ErrorState = { + text: '', + open: false, +}; + +const initialContext = { + errorState: initialState, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setErrorState: (() => {}) as Dispatch>, +}; + +const errorStyles = { + error: { + backgroundColor: bcgov.error, + } as CSSProperties, + warning: { + backgroundColor: bcgov.primaryHighlight, + color: bcgov.text, + } as CSSProperties, +}; + +export const ErrorContext = createContext(initialContext); + +const ErrorWrapper = (props: IErrorWrapper) => { + const [errorState, setErrorState] = useState(initialState); + const value = useMemo(() => ({ errorState, setErrorState }), [errorState]); + + const { children } = props; + + const handleClose = (event: SyntheticEvent | Event, reason?: string) => { + if (reason === 'clickaway') { + return; + } + setErrorState({ + text: '', + open: false, + }); + }; + + const action = ( + + + + ); + + return ( + + {children} + + + + + ); +}; + +export default ErrorWrapper; From 2f8e2a2facc4cc70663d4c52f69a5430ff4f6ace Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Tue, 4 Jul 2023 15:15:04 -0700 Subject: [PATCH 2/5] Added error styles --- app/src/components/custom/notifications/ErrorWrapper.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/components/custom/notifications/ErrorWrapper.tsx b/app/src/components/custom/notifications/ErrorWrapper.tsx index 62b59f8..5a5d84f 100644 --- a/app/src/components/custom/notifications/ErrorWrapper.tsx +++ b/app/src/components/custom/notifications/ErrorWrapper.tsx @@ -35,7 +35,7 @@ const initialContext = { setErrorState: (() => {}) as Dispatch>, }; -const errorStyles = { +export const errorStyles = { error: { backgroundColor: bcgov.error, } as CSSProperties, @@ -43,6 +43,9 @@ const errorStyles = { backgroundColor: bcgov.primaryHighlight, color: bcgov.text, } as CSSProperties, + success: { + backgroundColor: bcgov.success, + } as CSSProperties, }; export const ErrorContext = createContext(initialContext); From e5df3b83de03a05e995037361b35a0f22916405e Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Tue, 4 Jul 2023 15:15:35 -0700 Subject: [PATCH 3/5] Using error notification during failed REST calls. --- .../components/custom/forms/RequestForm.tsx | 16 ++++++++++++- app/src/pages/Home.tsx | 13 +++++++++- app/src/pages/IndividualRequest.tsx | 24 ++++++++++++++++++- app/src/pages/UserRequests.tsx | 24 ++++++++++++++++++- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/app/src/components/custom/forms/RequestForm.tsx b/app/src/components/custom/forms/RequestForm.tsx index 8a3522e..5001a3d 100644 --- a/app/src/components/custom/forms/RequestForm.tsx +++ b/app/src/components/custom/forms/RequestForm.tsx @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction, useState, MouseEvent, CSSProperties } from 'react'; +import { Dispatch, SetStateAction, useContext, useState, MouseEvent, CSSProperties } from 'react'; import { RequestStates, convertStateToStatus } from '../../../utils/convertState'; import { useNavigate } from 'react-router-dom'; import { ReimbursementRequest } from '../../../interfaces/ReimbursementRequest'; @@ -31,6 +31,7 @@ import Constants from '../../../constants/Constants'; import { useAuthService } from '../../../keycloak'; import BackButton from '../../bcgov/BackButton'; import DeletePrompt from '../modals/DeletePrompt'; +import { ErrorContext, errorStyles } from '../notifications/ErrorWrapper'; /** * @interface @@ -98,6 +99,9 @@ const RequestForm = (props: RequestFormProps) => { setAnchorEl(null); }; + // Error notification + const { setErrorState } = useContext(ErrorContext); + const handleDelete = async () => { try { const axiosReqConfig = { @@ -114,11 +118,21 @@ const RequestForm = (props: RequestFormProps) => { const response = await axios(axiosReqConfig); if (response.status === 200) { sessionStorage.removeItem('target-page'); + setErrorState({ + text: 'Request Deleted.', + open: true, + style: errorStyles.success, + }); // Return to home page navigate(-1); } } catch (e) { console.warn(e); + setErrorState({ + text: 'Deletion Unsuccessful.', + open: true, + style: errorStyles.error, + }); } }; diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx index 2679c4c..3c2fa06 100644 --- a/app/src/pages/Home.tsx +++ b/app/src/pages/Home.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import Constants from '../constants/Constants'; import axios from 'axios'; import RequestsTable from '../components/custom/tables/RequestsTable'; @@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { Switch } from '@mui/material'; import LinkButton from '../components/bcgov/LinkButton'; import { buttonStyles } from '../components/bcgov/ButtonStyles'; +import { ErrorContext } from '../components/custom/notifications/ErrorWrapper'; /** * @description The Home page, showing a list of reimbursement requests. @@ -22,6 +23,8 @@ const Home = () => { isAdmin && sessionStorage.getItem('adminView') === 'true', ); const navigate = useNavigate(); + // For ErrorWrapper notification + const { setErrorState } = useContext(ErrorContext); // Fires on page load. useEffect(() => { @@ -52,11 +55,19 @@ const Home = () => { switch (status) { case 401: console.warn('User is unauthenticated. Redirecting to login.'); + setErrorState({ + text: 'User is unauthenticated. Redirecting to login.', + open: true, + }); window.location.reload(); break; case 404: // User has no records. setRequests([]); + setErrorState({ + text: 'No records for this user.', + open: true, + }); break; default: console.warn(e); diff --git a/app/src/pages/IndividualRequest.tsx b/app/src/pages/IndividualRequest.tsx index f1b8394..ab932ac 100644 --- a/app/src/pages/IndividualRequest.tsx +++ b/app/src/pages/IndividualRequest.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useContext } from 'react'; import { RequestStates } from '../utils/convertState'; import { useNavigate, useParams } from 'react-router-dom'; import axios from 'axios'; @@ -11,6 +11,7 @@ import { Approval } from '../interfaces/Approval'; import RequestForm from '../components/custom/forms/RequestForm'; import BackButton from '../components/bcgov/BackButton'; import { marginBlock } from '../constants/styles'; +import { ErrorContext, errorStyles } from '../components/custom/notifications/ErrorWrapper'; /** * @description A page showing an individual reimbursement requests and all its fields. @@ -34,6 +35,9 @@ const IndividualRequest = () => { const [locked, setLocked] = useState(false); const [showRecord, setShowRecord] = useState(true); + // Error notification + const { setErrorState } = useContext(ErrorContext); + // Fired when page is loaded. useEffect(() => { getReimbursementRequest(); @@ -93,10 +97,18 @@ const IndividualRequest = () => { switch (status) { case 401: console.warn('User is unauthenticated. Redirecting to login.'); + setErrorState({ + text: 'User is unauthenticated. Redirecting to login.', + open: true, + }); window.location.reload(); break; case 403: console.warn('User is not authorized to view record.'); + setErrorState({ + text: 'User is not authorized to view record.', + open: true, + }); // Request was not a success. User didn't get a record back, so don't show the form. setShowRecord(false); break; @@ -147,11 +159,21 @@ const IndividualRequest = () => { const response = await axios(axiosReqConfig); if (response.status === 200) { sessionStorage.removeItem('target-page'); + setErrorState({ + text: 'Update Successful', + open: true, + style: errorStyles.success, + }); // Return to home page navigate(-1); } } catch (e) { console.warn('Record could not be updated.'); + setErrorState({ + text: 'Update was unsuccessful.', + open: true, + style: errorStyles.error, + }); } }; diff --git a/app/src/pages/UserRequests.tsx b/app/src/pages/UserRequests.tsx index aa434a0..9b39bf0 100644 --- a/app/src/pages/UserRequests.tsx +++ b/app/src/pages/UserRequests.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useContext } from 'react'; import axios from 'axios'; import { useParams } from 'react-router-dom'; import RequestsTable from '../components/custom/tables/RequestsTable'; @@ -8,6 +8,7 @@ import BackButton from '../components/bcgov/BackButton'; import { marginBlock } from '../constants/styles'; import Constants from '../constants/Constants'; import { ReimbursementRequest } from '../interfaces/ReimbursementRequest'; +import { ErrorContext, errorStyles } from '../components/custom/notifications/ErrorWrapper'; const UserRequests = () => { const [requests, setRequests] = useState([]); @@ -16,12 +17,20 @@ const UserRequests = () => { const isAdmin = authState.userInfo.client_roles?.includes('admin'); const { idir } = useParams(); + // Error notification + const { setErrorState } = useContext(ErrorContext); + // Fires on page load. useEffect(() => { if (isAdmin) { getRequests(); } else { console.warn('User is not permitted to view these records.'); + setErrorState({ + text: 'User is not permitted to view these records.', + open: true, + style: errorStyles.error, + }); } }, []); @@ -41,14 +50,27 @@ const UserRequests = () => { switch (status) { case 401: console.warn('User is unauthenticated. Redirecting to login.'); + setErrorState({ + text: 'User is unauthenticated. Redirecting to login.', + open: true, + }); window.location.reload(); break; case 404: // User has no records. + setErrorState({ + text: 'User has no available records.', + open: true, + }); setRequests([]); break; default: console.warn(e); + setErrorState({ + text: 'Could not retrieve records.', + open: true, + style: errorStyles.error, + }); break; } } else { From d9f301593ec4f994bdbf8fb1029c64b8f5cc890d Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Tue, 4 Jul 2023 15:27:24 -0700 Subject: [PATCH 4/5] Added commenting to ErrorWrapper --- .../custom/notifications/ErrorWrapper.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/src/components/custom/notifications/ErrorWrapper.tsx b/app/src/components/custom/notifications/ErrorWrapper.tsx index 5a5d84f..d6a8877 100644 --- a/app/src/components/custom/notifications/ErrorWrapper.tsx +++ b/app/src/components/custom/notifications/ErrorWrapper.tsx @@ -14,27 +14,51 @@ import CloseIcon from '@mui/icons-material/Close'; import { bcgov } from '../../../constants/colours'; import { SnackbarContent } from '@mui/material'; +/** + * @interface + * @description Properties passed to ErrorWrapper. + * @property {ReactNode} children The child elements within the ErrorWrapper. + */ interface IErrorWrapper { children: ReactNode; } +/** + * @interface + * @description Defines the properties of an Error State. + * @property {string} text The text displayed in the notification. + * @property {boolean} open Whether the notification is open and visible. + * @property {CSSProperties} style Optional: Styling properties for the notification. + */ interface ErrorState { text: string; open: boolean; style?: CSSProperties; } +/** + * @constant + * @description The initial state of the component. ErrorState + */ const initialState: ErrorState = { text: '', open: false, }; +/** + * @constant + * @description The initial context passed down from the context provider. + */ const initialContext = { errorState: initialState, // eslint-disable-next-line @typescript-eslint/no-empty-function setErrorState: (() => {}) as Dispatch>, }; +/** + * @constant + * @description An object containing styles for various types of error messages. + */ export const errorStyles = { error: { backgroundColor: bcgov.error, @@ -48,14 +72,25 @@ export const errorStyles = { } as CSSProperties, }; +/** + * @constant + * @description The context provided by the ErrorWrapper. + */ export const ErrorContext = createContext(initialContext); +/** + * @description Wraps the application and provides a popup notification that can be used with the supplied ErrorContext. + * @param {IErrorWrapper} props Properties passed to the component. + * @returns A React component + */ const ErrorWrapper = (props: IErrorWrapper) => { const [errorState, setErrorState] = useState(initialState); + // Value passed into context later const value = useMemo(() => ({ errorState, setErrorState }), [errorState]); const { children } = props; + // When the closing X is clicked. const handleClose = (event: SyntheticEvent | Event, reason?: string) => { if (reason === 'clickaway') { return; @@ -66,6 +101,7 @@ const ErrorWrapper = (props: IErrorWrapper) => { }); }; + // The X element in the notification. const action = ( From ceb7b5afd114231830243516921c1d300fd32303 Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Wed, 5 Jul 2023 11:42:39 -0700 Subject: [PATCH 5/5] Error messages for file uploads --- .../custom/uploaders/FileUpload.tsx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/app/src/components/custom/uploaders/FileUpload.tsx b/app/src/components/custom/uploaders/FileUpload.tsx index 7eb1f79..773a702 100644 --- a/app/src/components/custom/uploaders/FileUpload.tsx +++ b/app/src/components/custom/uploaders/FileUpload.tsx @@ -1,6 +1,6 @@ import { Button } from '@mui/material'; import { IFile } from '../../../interfaces/IFile'; -import { Dispatch, SetStateAction, useRef } from 'react'; +import { Dispatch, SetStateAction, useContext, useRef } from 'react'; import { buttonStyles } from '../../bcgov/ButtonStyles'; import { bcgov } from '../../../constants/colours'; import { normalFont } from '../../../constants/fonts'; @@ -9,6 +9,7 @@ import axios from 'axios'; import Constants from '../../../constants/Constants'; import { useAuthService } from '../../../keycloak'; import { useParams } from 'react-router-dom'; +import { ErrorContext, errorStyles } from '../notifications/ErrorWrapper'; /** * @interface @@ -39,20 +40,30 @@ const FileUpload = (props: FileUploadProps) => { const fileString = useRef(''); const { state: authState } = useAuthService(); + // Error notification + const { setErrorState } = useContext(ErrorContext); + // Gets the file from the API and returns the base64 string of the file // Assumption: no file for this request will have exactly the same upload date down to the millisecond const retrieveFile = async () => { const { BACKEND_URL } = Constants; - console.log(files[index].date); - const axiosReqConfig = { - url: `${BACKEND_URL}/api/requests/${id}/files?date=${files[index].date}`, - method: `get`, - headers: { - Authorization: `Bearer ${authState.access_token}`, - }, - }; - const file: string = await axios(axiosReqConfig).then((response) => response.data.file); - return file; + try { + const axiosReqConfig = { + url: `${BACKEND_URL}/api/requests/${id}/files?date=${files[index].date}`, + method: `get`, + headers: { + Authorization: `Bearer ${authState.access_token}`, + }, + }; + const file: string = await axios(axiosReqConfig).then((response) => response.data.file); + return file; + } catch (e: any) { + setErrorState({ + text: 'File could not be retrieved.', + open: true, + style: errorStyles.error, + }); + } }; // If the file isn't already stored, retrieves the file and uses a false anchor link to download @@ -77,9 +88,14 @@ const FileUpload = (props: FileUploadProps) => { // When a file is uploaded. Checks size and updates file list. const handleFilesChange = async (e: any) => { - if (e.target.files[0].size > 10485760) { - // TODO: Replace with error text for user. - alert('File size is over 10MB and will not be uploaded.'); + // File size must be below 10MB + const maxFileSize = 10485760; + if (e.target.files[0].size > maxFileSize) { + setErrorState({ + text: 'File size is over 10MB and will not be uploaded.', + open: true, + style: errorStyles.warning, + }); } else { const tempFiles = [...files]; const tempFile: IFile = {