Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SPR-159 Error Notifications #73

Merged
merged 7 commits into from
Jul 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -30,20 +31,22 @@ const App = () => {
<LocalizationProvider dateAdapter={AdapterDayjs}>
<BrowserRouter>
<div style={{ ...normalFont }} className='App'>
<NavigationBar />
<KeycloakWrapper>
<div style={container}>
{user ? (
<Routes>
<Route index element={<Home />} />
<Route path='request/:id' element={<IndividualRequest />} />
<Route path='user/:idir' element={<UserRequests />} />
</Routes>
) : (
<Login />
)}
</div>
</KeycloakWrapper>
<ErrorWrapper>
<NavigationBar />
<KeycloakWrapper>
<div style={container}>
{user ? (
<Routes>
<Route index element={<Home />} />
<Route path='request/:id' element={<IndividualRequest />} />
<Route path='user/:idir' element={<UserRequests />} />
</Routes>
) : (
<Login />
)}
</div>
</KeycloakWrapper>
</ErrorWrapper>
</div>
</BrowserRouter>
</LocalizationProvider>
Expand Down
16 changes: 15 additions & 1 deletion app/src/components/custom/forms/RequestForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -98,6 +99,9 @@ const RequestForm = (props: RequestFormProps) => {
setAnchorEl(null);
};

// Error notification
const { setErrorState } = useContext(ErrorContext);

const handleDelete = async () => {
try {
const axiosReqConfig = {
Expand All @@ -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,
});
}
};

Expand Down
125 changes: 125 additions & 0 deletions app/src/components/custom/notifications/ErrorWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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
* @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<SetStateAction<ErrorState>>,
};

/**
* @constant
* @description An object containing styles for various types of error messages.
*/
export const errorStyles = {
error: {
backgroundColor: bcgov.error,
} as CSSProperties,
warning: {
backgroundColor: bcgov.primaryHighlight,
color: bcgov.text,
} as CSSProperties,
success: {
backgroundColor: bcgov.success,
} 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;
}
setErrorState({
text: '',
open: false,
});
};

// The X element in the notification.
const action = (
<IconButton size='small' aria-label='close' color='inherit' onClick={handleClose}>
<CloseIcon fontSize='small' />
</IconButton>
);

return (
<ErrorContext.Provider value={value}>
{children}
<Snackbar open={errorState.open} autoHideDuration={6000} onClose={handleClose}>
<SnackbarContent
sx={errorState.style || errorStyles.warning}
message={errorState.text}
action={action}
/>
</Snackbar>
</ErrorContext.Provider>
);
};

export default ErrorWrapper;
43 changes: 30 additions & 13 deletions app/src/components/custom/uploaders/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -39,19 +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;
const axiosReqConfig = {
url: `${BACKEND_URL}/api/requests/${id}/files?date=${files[index].date}`,
method: `get`,
headers: {
Authorization: `Bearer ${authState.accessToken}`,
},
};
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.accessToken}`,
},
};
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
Expand All @@ -76,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 = {
Expand Down
13 changes: 12 additions & 1 deletion app/src/pages/Home.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 23 additions & 1 deletion app/src/pages/IndividualRequest.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand All @@ -34,6 +35,9 @@ const IndividualRequest = () => {
const [locked, setLocked] = useState<boolean>(false);
const [showRecord, setShowRecord] = useState<boolean>(true);

// Error notification
const { setErrorState } = useContext(ErrorContext);

// Fired when page is loaded.
useEffect(() => {
getReimbursementRequest();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -145,11 +157,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,
});
}
};

Expand Down
Loading
Loading