From ec52f098973b6aacd3f094589f6e5c9bcdc243d4 Mon Sep 17 00:00:00 2001 From: Griffin-Sullivan Date: Mon, 28 Oct 2024 15:22:56 -0400 Subject: [PATCH] Add NotificationContext and useNotification hook Signed-off-by: Griffin-Sullivan --- clients/ui/frontend/src/app/App.tsx | 2 + .../src/app/context/NotificationContext.tsx | 82 +++++++++++++ .../frontend/src/app/hooks/useNotification.ts | 109 ++++++++++++++++++ .../components/ArchiveModelVersionModal.tsx | 5 +- .../ArchiveRegisteredModelModal.tsx | 9 +- .../components/RestoreModelVersionModal.tsx | 5 +- .../components/RestoreRegisteredModel.tsx | 9 +- .../src/components/ToastNotification.tsx | 50 ++++++++ .../src/components/ToastNotifications.tsx | 18 +++ clients/ui/frontend/src/index.tsx | 5 +- clients/ui/frontend/src/utilities/utils.ts | 17 +++ 11 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 clients/ui/frontend/src/app/context/NotificationContext.tsx create mode 100644 clients/ui/frontend/src/app/hooks/useNotification.ts create mode 100644 clients/ui/frontend/src/components/ToastNotification.tsx create mode 100644 clients/ui/frontend/src/components/ToastNotifications.tsx create mode 100644 clients/ui/frontend/src/utilities/utils.ts diff --git a/clients/ui/frontend/src/app/App.tsx b/clients/ui/frontend/src/app/App.tsx index 97e644f6..430a9ff0 100644 --- a/clients/ui/frontend/src/app/App.tsx +++ b/clients/ui/frontend/src/app/App.tsx @@ -19,6 +19,7 @@ import { StackItem, } from '@patternfly/react-core'; import { BarsIcon } from '@patternfly/react-icons'; +import ToastNotifications from '~/components/ToastNotifications'; import NavSidebar from './NavSidebar'; import AppRoutes from './AppRoutes'; import { AppContext } from './AppContext'; @@ -112,6 +113,7 @@ const App: React.FC = () => { + ); diff --git a/clients/ui/frontend/src/app/context/NotificationContext.tsx b/clients/ui/frontend/src/app/context/NotificationContext.tsx new file mode 100644 index 00000000..70d42fd4 --- /dev/null +++ b/clients/ui/frontend/src/app/context/NotificationContext.tsx @@ -0,0 +1,82 @@ +import { AlertVariant } from '@patternfly/react-core'; +import React, { createContext } from 'react'; + +export type Notification = { + id?: number; + status: AlertVariant; + title: string; + message?: React.ReactNode; + hidden?: boolean; + read?: boolean; + timestamp: Date; +}; + +type NotificationAction = + | { + type: 'add_notification'; + payload: Notification; + } + | { + type: 'delete_notification'; + payload: { id: Notification['id'] }; + }; + +type NotificationContextProps = { + notifications: Notification[]; + dispatch: React.Dispatch; +}; + +export const NotificationContext = createContext({ + notifications: [], + // eslint-disable-next-line @typescript-eslint/no-empty-function + dispatch: () => {}, +}); + +const notificationReducer: React.Reducer = ( + notifications, + action, +) => { + switch (action.type) { + case 'add_notification': { + return [ + ...notifications, + { + status: action.payload.status, + title: action.payload.title, + timestamp: action.payload.timestamp, + id: action.payload.id, + }, + ]; + } + case 'delete_notification': { + return notifications.filter((t) => t.id !== action.payload.id); + } + default: { + return notifications; + } + } +}; + +type NotificationContextProviderProps = { + children: React.ReactNode; +}; + +export const NotificationContextProvider: React.FC = ({ + children, +}) => { + const [notifications, dispatch] = React.useReducer(notificationReducer, []); + + return ( + ({ + notifications, + dispatch, + }), + [notifications, dispatch], + )} + > + {children} + + ); +}; diff --git a/clients/ui/frontend/src/app/hooks/useNotification.ts b/clients/ui/frontend/src/app/hooks/useNotification.ts new file mode 100644 index 00000000..a7f64790 --- /dev/null +++ b/clients/ui/frontend/src/app/hooks/useNotification.ts @@ -0,0 +1,109 @@ +import React, { useContext } from 'react'; +import { AlertVariant } from '@patternfly/react-core'; +import { NotificationContext } from '~/app/context/NotificationContext'; + +enum NotificationTypes { + SUCCESS = 'success', + ERROR = 'error', + INFO = 'info', + WARNING = 'warning', +} + +type NotificationProps = (title: string, message?: React.ReactNode) => void; + +type NotificationRemoveProps = (id: number | undefined) => void; + +type NotificationTypeFunc = { + [key in NotificationTypes]: NotificationProps; +}; + +interface NotificationFunc extends NotificationTypeFunc { + remove: NotificationRemoveProps; +} + +export const useNotification = (): NotificationFunc => { + const { dispatch } = useContext(NotificationContext); + // need to move this count somewhere else since it will reset on every new useNotification instance (like switching pages) + let notificationCount = 0; + + const success: NotificationProps = React.useCallback( + (title, message?) => { + dispatch({ + type: 'add_notification', + payload: { + status: AlertVariant.success, + title, + timestamp: new Date(), + message, + id: ++notificationCount, + }, + }); + }, + [dispatch, notificationCount], + ); + + const warning: NotificationProps = React.useCallback( + (title, message?) => { + dispatch({ + type: 'add_notification', + payload: { + status: AlertVariant.warning, + title, + timestamp: new Date(), + message, + id: ++notificationCount, + }, + }); + }, + [dispatch, notificationCount], + ); + + const error: NotificationProps = React.useCallback( + (title, message?) => { + dispatch({ + type: 'add_notification', + payload: { + status: AlertVariant.danger, + title, + timestamp: new Date(), + message, + id: ++notificationCount, + }, + }); + }, + [dispatch, notificationCount], + ); + + const info: NotificationProps = React.useCallback( + (title, message?) => { + dispatch({ + type: 'add_notification', + payload: { + status: AlertVariant.info, + title, + timestamp: new Date(), + message, + id: ++notificationCount, + }, + }); + }, + [dispatch, notificationCount], + ); + + const remove: NotificationRemoveProps = React.useCallback( + (id) => { + dispatch({ + type: 'delete_notification', + payload: { id }, + }); + }, + [dispatch], + ); + + const notification = React.useMemo( + () => ({ success, error, info, warning, remove }), + [success, error, info, warning, remove], + ); + + return notification; +}; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx index afdf19b8..4e1f8f59 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx @@ -9,6 +9,7 @@ import { TextInput, } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; +import { useNotification } from '~/app/hooks/useNotification'; interface ArchiveModelVersionModalProps { onCancel: () => void; @@ -27,6 +28,7 @@ export const ArchiveModelVersionModal: React.FC = const [error, setError] = React.useState(); const [confirmInputValue, setConfirmInputValue] = React.useState(''); const isDisabled = confirmInputValue.trim() !== modelVersionName || isSubmitting; + const notification = useNotification(); const onClose = React.useCallback(() => { setConfirmInputValue(''); @@ -39,6 +41,7 @@ export const ArchiveModelVersionModal: React.FC = try { await onSubmit(); onClose(); + notification.success(`${modelVersionName} archived.`); } catch (e) { if (e instanceof Error) { setError(e); @@ -46,7 +49,7 @@ export const ArchiveModelVersionModal: React.FC = } finally { setIsSubmitting(false); } - }, [onSubmit, onClose]); + }, [notification, modelVersionName, onSubmit, onClose]); const description = ( <> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx index 8809db58..3729255d 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx @@ -9,8 +9,7 @@ import { TextInput, } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; - -// import useNotification from '~/utilities/useNotification'; // TODO: Implement useNotification +import { useNotification } from '~/app/hooks/useNotification'; interface ArchiveRegisteredModelModalProps { onCancel: () => void; @@ -25,7 +24,7 @@ export const ArchiveRegisteredModelModal: React.FC { - // const notification = useNotification(); + const notification = useNotification(); const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); const [confirmInputValue, setConfirmInputValue] = React.useState(''); @@ -42,7 +41,7 @@ export const ArchiveRegisteredModelModal: React.FC diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx index dad086a0..b8310509 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Form, Modal, ModalHeader, ModalBody, Alert } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; +import { useNotification } from '~/app/hooks/useNotification'; interface RestoreModelVersionModalProps { onCancel: () => void; @@ -17,6 +18,7 @@ export const RestoreModelVersionModal: React.FC = }) => { const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); + const notification = useNotification(); const onClose = React.useCallback(() => { onCancel(); @@ -28,6 +30,7 @@ export const RestoreModelVersionModal: React.FC = try { await onSubmit(); onClose(); + notification.success(`${modelVersionName} restored.`); } catch (e) { if (e instanceof Error) { setError(e); @@ -35,7 +38,7 @@ export const RestoreModelVersionModal: React.FC = } finally { setIsSubmitting(false); } - }, [onSubmit, onClose]); + }, [notification, modelVersionName, onSubmit, onClose]); const description = ( <> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx index 6f97248a..ee5a23f0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { Alert, Form, ModalHeader, Modal, ModalBody } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; - -// import useNotification from '~/utilities/useNotification'; TODO: Implement useNotification +import { useNotification } from '~/app/hooks/useNotification'; interface RestoreRegisteredModelModalProps { onCancel: () => void; @@ -17,7 +16,7 @@ export const RestoreRegisteredModelModal: React.FC { - // const notification = useNotification(); + const notification = useNotification(); const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); @@ -31,7 +30,7 @@ export const RestoreRegisteredModelModal: React.FC diff --git a/clients/ui/frontend/src/components/ToastNotification.tsx b/clients/ui/frontend/src/components/ToastNotification.tsx new file mode 100644 index 00000000..fca23b9f --- /dev/null +++ b/clients/ui/frontend/src/components/ToastNotification.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Alert, AlertActionCloseButton, AlertVariant } from '@patternfly/react-core'; +import { Notification } from '~/app/context/NotificationContext'; +import { asEnumMember } from '~/utilities/utils'; +import { useNotification } from '~/app/hooks/useNotification'; + +const TOAST_NOTIFICATION_TIMEOUT = 8 * 1000; + +interface ToastNotificationProps { + notification: Notification; +} + +const ToastNotification: React.FC = ({ notification }) => { + const notifications = useNotification(); + const [timedOut, setTimedOut] = React.useState(false); + const [mouseOver, setMouseOver] = React.useState(false); + + React.useEffect(() => { + const handle = setTimeout(() => { + setTimedOut(true); + }, TOAST_NOTIFICATION_TIMEOUT); + return () => { + clearTimeout(handle); + }; + }, [setTimedOut]); + + React.useEffect(() => { + if (!notification.hidden && timedOut && !mouseOver) { + notifications.remove(notification.id); + } + }, [mouseOver, notification, timedOut, notifications]); + + if (notification.hidden) { + return null; + } + + return ( + notifications.remove(notification.id)} />} + onMouseEnter={() => setMouseOver(true)} + onMouseLeave={() => setMouseOver(false)} + > + {notification.message} + + ); +}; + +export default ToastNotification; diff --git a/clients/ui/frontend/src/components/ToastNotifications.tsx b/clients/ui/frontend/src/components/ToastNotifications.tsx new file mode 100644 index 00000000..35da1bef --- /dev/null +++ b/clients/ui/frontend/src/components/ToastNotifications.tsx @@ -0,0 +1,18 @@ +import React, { useContext } from 'react'; +import { AlertGroup } from '@patternfly/react-core'; +import { NotificationContext } from '~/app/context/NotificationContext'; +import ToastNotification from './ToastNotification'; + +const ToastNotifications: React.FC = () => { + const { notifications } = useContext(NotificationContext); + + return ( + + {notifications.map((notification) => ( + + ))} + + ); +}; + +export default ToastNotifications; diff --git a/clients/ui/frontend/src/index.tsx b/clients/ui/frontend/src/index.tsx index 333ea347..060875bd 100644 --- a/clients/ui/frontend/src/index.tsx +++ b/clients/ui/frontend/src/index.tsx @@ -4,6 +4,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import App from './app/App'; import { BrowserStorageContextProvider } from './components/browserStorage/BrowserStorageContext'; +import { NotificationContextProvider } from './app/context/NotificationContext'; const theme = createTheme({ cssVariables: true }); const root = ReactDOM.createRoot(document.getElementById('root')!); @@ -13,7 +14,9 @@ root.render( - + + + diff --git a/clients/ui/frontend/src/utilities/utils.ts b/clients/ui/frontend/src/utilities/utils.ts new file mode 100644 index 00000000..2652d996 --- /dev/null +++ b/clients/ui/frontend/src/utilities/utils.ts @@ -0,0 +1,17 @@ +export const asEnumMember = ( + member: T[keyof T] | string | number | undefined | null, + e: T, +): T[keyof T] | null => (isEnumMember(member, e) ? member : null); + +export const isEnumMember = ( + member: T[keyof T] | string | number | undefined | unknown | null, + e: T, +): member is T[keyof T] => { + if (member != null) { + return Object.entries(e) + .filter(([key]) => Number.isNaN(Number(key))) + .map(([, value]) => value) + .includes(member); + } + return false; +};