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

Feature/40 add error handling via notifications #125

Merged
merged 15 commits into from
Nov 14, 2024
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@
"react-responsive-modal": "^6.4.2",
"react-router-dom": "^6.2.2",
"react-scripts": "5.0.1",
"react-toastify": "^10.0.5",
"react-toastify": "^10.0.6",
"react-tooltip": "^4.2.21",
"redux-thunk": "^2.4.1",
"styled-components": "^5.3.11",
"typescript": "3.3.1",
"url": "^0.11.3",
"util": "^0.12.5",
"uuid": "^9.0.0",
"vis-network": "^9.1.9",
"web-vitals": "^2.1.4"
},
Expand Down
18 changes: 18 additions & 0 deletions src/common/exceptions/BusinessException.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { notify } from '../../components/notification/Notification';
import { NotificationMessageType } from '../../types/notification.model';

export abstract class BusinessException extends Error {
messageType: NotificationMessageType;

protected constructor(message: string, messageType?: NotificationMessageType) {
super(message);
this.messageType = messageType ? messageType : 'ERROR';
}

handleNotification(publish?: () => void): void {
notify({
messageType: this.messageType,
message: this.message,
})
}
}
10 changes: 6 additions & 4 deletions src/common/exceptions/BusinessObjectNotFound.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
class BusinessObjectNotFound extends Error {
import { BusinessException } from './BusinessException';

class BusinessObjectNotFound extends BusinessException {
uri: string;

constructor(massage: string, uri: string) {
super(massage);
constructor(message: string, uri: string) {
super(message);
this.uri = uri;
this.name = 'BusinessObjectNotFound'
this.name = 'BusinessObjectNotFound';
}
}

Expand Down
6 changes: 4 additions & 2 deletions src/common/exceptions/MultipleBusinessObjectFound.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
class MultipleBusinessObjectFound extends Error {
import { BusinessException } from './BusinessException';

class MultipleBusinessObjectFound extends BusinessException {
uri: string;

constructor(massage: string, uri: string) {
super(massage);
this.uri = uri;
this.name = 'MultipleBusinessObjectFound'
this.name = 'MultipleBusinessObjectFound';
}
}

Expand Down
68 changes: 68 additions & 0 deletions src/components/error-boundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { useEffect } from 'react';

import { BusinessException } from '../../common/exceptions/BusinessException';
import { notify } from '../notification/Notification';

import { useErrorContext } from './ErrorContext';

type ErrorBoundaryProps = {
children: React.ReactNode;
};

const ErrorBoundary: React.FC<ErrorBoundaryProps> = ({ children }) => {
const { publish } = useErrorContext(); // Use the error context

const handleGlobalError = (event: ErrorEvent) => {
event.preventDefault();
handleException(event.error);
};

const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
event.preventDefault();
handleException(event.reason);
};

const handleException = (error: Error | string) => {
if (error instanceof BusinessException) {
error.handleNotification(() => {
publish(error); // Add the error to the context
});
} else {
const errorMessage = getErrorMessage(error);
showNotification(errorMessage);
}
};

const getErrorMessage = (error: string | { message?: string }): string => {
let errorMessage = '';
if (typeof error === 'string') {
errorMessage = error;
} else if (typeof error === 'object') {
errorMessage = error.message || 'Unknown error';
} else {
errorMessage = 'Unknown error';
}
return errorMessage;
};

const showNotification = (errorMessage: string) => {
notify({
messageType: 'ERROR',
message: errorMessage,
});
};

useEffect(() => {
window.addEventListener('error', handleGlobalError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);

return () => {
window.removeEventListener('error', handleGlobalError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);

return <>{children}</>;
};

export default ErrorBoundary;
56 changes: 56 additions & 0 deletions src/components/error-boundary/ErrorContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { createContext, ReactNode, useContext, useState } from 'react';
import { v4 } from 'uuid';

import { BusinessException } from '../../common/exceptions/BusinessException';

// Define the shape of the context
interface ErrorContextType {
errorMessages: ErrorMessage[];
publish: (exception: BusinessException) => void;
removeMessage: (messageId: string) => void;
}

// Create context with a default value of `undefined`
const ErrorContext = createContext<ErrorContextType | undefined>(undefined);

// Custom hook to access ErrorContext
export const useErrorContext = (): ErrorContextType => {
const context = useContext(ErrorContext);
if (!context) {
throw new Error('useErrorContext must be used within an ErrorProvider');
}
return context;
};

// Define the props type for ErrorProvider
interface ErrorProviderProps {
children: ReactNode;
}

type ErrorMessage = {
messageId: string,
exception: BusinessException,
}

// ErrorProvider component
export const ErrorProvider: React.FC<ErrorProviderProps> = ({ children }) => {
const [errorMessages, setErrorMessages] = useState<ErrorMessage[]>([]);

const publish = (exception: BusinessException) => {
const messageWithId = {
messageId: v4(),
exception: exception,
};
setErrorMessages((prevList) => [...prevList, messageWithId]);
};

const removeMessage = (messageId: string) => {
setErrorMessages((prevList) => prevList.filter((msg) => msg.messageId !== messageId));
};

return (
<ErrorContext.Provider value={{ errorMessages, publish, removeMessage }}>
{children}
</ErrorContext.Provider>
);
};
10 changes: 10 additions & 0 deletions src/components/notification/Notification.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
:root {
--toastify-color-progressbar: #ddd;

--toastify-color-progress-info: var(--toastify-color-progressbar);
--toastify-color-progress-success: var(--toastify-color-progressbar);
--toastify-color-progress-warning: var(--toastify-color-progressbar);
--toastify-color-progress-error: var(--toastify-color-progressbar);
--toastify-color-progress-light: var(--toastify-color-progressbar);
--toastify-color-progress-dark: var(--toastify-color-progressbar);
}
46 changes: 46 additions & 0 deletions src/components/notification/Notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { toast } from 'react-toastify';
import { ToastOptions } from 'react-toastify/dist/types';

import { Notification } from '../../types/notification.model';

export const notify = (notification: Notification) => {

function getAutoCloseOption(toastOptions?: ToastOptions): number | false {
return toastOptions && toastOptions.autoClose ? toastOptions.autoClose : 15000;
}

const toastOptions = {
...notification.options,
autoClose: getAutoCloseOption(notification.options),
}

switch (notification.messageType) {
case 'SUCCESS':
toast.success(notification.message, {
...toastOptions,
});
break;
case 'ERROR':
toast.error(notification.message, {
...toastOptions,
autoClose: false
});
break;
case 'WARNING':
toast.warn(notification.message, {
...toastOptions,
});
break;
case 'INFO':
toast.info(notification.message, {
...toastOptions,
});
break;
default:
toast(notification.message, {
...toastOptions,
});
break
}
};

8 changes: 8 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

/* TODO
Remove this workaround once a solution is found.
This workaround prevents the error overlay from showing in React.
*/
body > iframe {

This comment was marked as resolved.

display: none;
}
13 changes: 12 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
/* test coverage not required */
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { ToastContainer } from 'react-toastify';

import './i18n';
import App from './App';
import ErrorBoundary from './components/error-boundary/ErrorBoundary';
import { ErrorProvider } from './components/error-boundary/ErrorContext';
import { configureStore } from './configureStore';
import AuthContextProvider from './context/AuthContextProvider';
import { ResourceContextProvider } from './context/ResourceContext';

import './index.css';
import 'react-toastify/dist/ReactToastify.css';
import './components/notification/Notification.css'

const saveToLocalStorage = (state) => {
try {
Expand Down Expand Up @@ -43,7 +49,12 @@ root.render(
<Provider store={store}>
<AuthContextProvider>
<ResourceContextProvider>
<App/>
<ErrorProvider>
<ErrorBoundary>
<App/>
</ErrorBoundary>
</ErrorProvider>
<ToastContainer/>
</ResourceContextProvider>
</AuthContextProvider>
</Provider>
Expand Down
3 changes: 3 additions & 0 deletions src/types/exception.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* test coverage not required */
export type ExceptionType = 'BusinessException';

14 changes: 14 additions & 0 deletions src/types/notification.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* test coverage not required */
import { ToastContent, ToastOptions } from 'react-toastify/dist/types';

export type NotificationMessageType =
| 'SUCCESS'
| 'ERROR'
| 'WARNING'
| 'INFO'

export interface Notification {
messageType: NotificationMessageType,
message: ToastContent;
options?: ToastOptions;
}
48 changes: 48 additions & 0 deletions tests/common/exceptions/BusinessException.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import '@testing-library/jest-dom';
import { BusinessException } from '../../../src/common/exceptions/BusinessException';
import { notify } from '../../../src/components/notification/Notification';
import { NotificationMessageType } from '../../../src/types/notification.model';

// Mock the `notify` function
jest.mock('../../../src/components/notification/Notification', () => ({
notify: jest.fn(),
}));

// Define a concrete subclass for testing
class DummyException extends BusinessException {
constructor(message: string, messageType?: NotificationMessageType) {
super(message, messageType);
this.name = 'DummyException';
}
}

describe('BusinessException', () => {
it('should call notify with correct arguments in handleNotification', () => {
const message = 'Test error message';
const messageType: NotificationMessageType = 'ERROR';
const error = new DummyException(message, messageType);

// Call handleNotification
error.handleNotification();

// Verify that notify was called with the correct parameters
expect(notify).toHaveBeenCalledWith({
messageType,
message,
});
});

it('should use default messageType if not provided', () => {
const message = 'Another test error message';
const error = new DummyException(message);

// Call handleNotification
error.handleNotification();

// Verify that notify was called with the default messageType 'ERROR'
expect(notify).toHaveBeenCalledWith({
messageType: 'ERROR',
message,
});
});
});
Loading