diff --git a/apps/frontend/app/empty-toastify.css b/apps/frontend/app/empty-toastify.css new file mode 100644 index 0000000..251126f --- /dev/null +++ b/apps/frontend/app/empty-toastify.css @@ -0,0 +1 @@ +/* intentionally empty: used to stub react-toastify CSS in production builds */ diff --git a/apps/frontend/app/hooks/ResponseHelper.tsx b/apps/frontend/app/hooks/ResponseHelper.tsx index be1560a..6275db9 100644 --- a/apps/frontend/app/hooks/ResponseHelper.tsx +++ b/apps/frontend/app/hooks/ResponseHelper.tsx @@ -1,8 +1,11 @@ import { Text } from '@radix-ui/themes'; import { AxiosError } from 'axios'; import { useLocation, useNavigate } from 'react-router'; -import { toast } from 'react-toastify'; +import { toast } from 'react-toastify/unstyled'; import { SearchParamKeys } from '../lib/constants'; +import { useQueryMessage } from './useQueryMessage'; +import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages'; +import { useCallback } from 'react'; export enum ResponseErrorToastId { NetworkError = 'network-error', @@ -19,95 +22,99 @@ export type DefaultResponseErrorHandlerOptions = { * @param err error value * @returns {boolean} true if the error was handled, false otherwise */ -const defaultResponseErrorHandler = - (navigate: ReturnType, location: ReturnType) => - (err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => { - if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) { - toast.error( -
- Unexpected Error: -
An unexpected error occurred. Please try again later. -
, - { - position: 'top-center', - autoClose: false, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: false, - progress: undefined, - theme: 'colored', - } - ); - return true; - } - - if (!(err instanceof AxiosError)) return false; - - if (err.message === 'canceled') { - // request was aborted, ignore but return true to indicate it was handled - return !options?.disableIgnoreCanceledRequests; - } - - if (err.message === 'Network Error') { - toast.error( -
- Network Error: -
Unable to reach the server. Please check your internet connection and try again. -
, - { - toastId: ResponseErrorToastId.NetworkError, - position: 'top-center', - autoClose: false, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: false, - progress: undefined, - theme: 'colored', - } - ); - return true; - } - - // handle 401 Unauthorized globally - if (err.status === 401 && !options?.disableUnauthorizedHandling) { - // store current path for redirect after login - const currentPath = location.pathname + location.search; - const searchParam = new URLSearchParams(); - searchParam.set(SearchParamKeys.Redirect, currentPath); - searchParam.set(SearchParamKeys.Message, 'Session expired, please log in again'); - navigate(`/login?${searchParam.toString()}`); - return true; - } - - if (err.status === 403) { - toast.error( -
- Forbidden: -
You do not have permission to perform this action. -
, - { - position: 'top-center', - autoClose: 5000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: false, - progress: undefined, - theme: 'colored', - } - ); - return true; - } - - return false; - }; export function useResponseErrorHandler(): { - defaultResponseErrorHandler: ReturnType; + defaultResponseErrorHandler: typeof defaultResponseErrorHandler; } { const navigate = useNavigate(); const location = useLocation(); - return { defaultResponseErrorHandler: defaultResponseErrorHandler(navigate, location) }; + const { toSearchParamQueryMessage } = useQueryMessage(); + + const defaultResponseErrorHandler = useCallback( + (err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => { + if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) { + toast.error( +
+ Unexpected Error: +
An unexpected error occurred. Please try again later. +
, + { + position: 'top-center', + autoClose: false, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: false, + progress: undefined, + theme: 'colored', + } + ); + return true; + } + + if (!(err instanceof AxiosError)) return false; + + if (err.message === 'canceled') { + // request was aborted, ignore but return true to indicate it was handled + return !options?.disableIgnoreCanceledRequests; + } + + if (err.message === 'Network Error') { + toast.error( +
+ Network Error: +
Unable to reach the server. Please check your internet connection and try again. +
, + { + toastId: ResponseErrorToastId.NetworkError, + position: 'top-center', + autoClose: false, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: false, + progress: undefined, + theme: 'colored', + } + ); + return true; + } + + // handle 401 Unauthorized globally + if (err.status === 401 && !options?.disableUnauthorizedHandling) { + // store current path for redirect after login + const currentPath = location.pathname + location.search; + const searchParam = new URLSearchParams(); + searchParam.set(SearchParamKeys.Redirect, currentPath); + searchParam.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.SessionExpired, QueryMessageType.Info)); + navigate(`/login?${searchParam.toString()}`); + return true; + } + + if (err.status === 403) { + toast.error( +
+ Forbidden: +
You do not have permission to perform this action. +
, + { + position: 'top-center', + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: false, + progress: undefined, + theme: 'colored', + } + ); + return true; + } + + return false; + }, + [location, navigate, toSearchParamQueryMessage] + ); + + return { defaultResponseErrorHandler }; } diff --git a/apps/frontend/app/hooks/useQueryMessage.tsx b/apps/frontend/app/hooks/useQueryMessage.tsx new file mode 100644 index 0000000..6681b93 --- /dev/null +++ b/apps/frontend/app/hooks/useQueryMessage.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useRef, type ReactNode } from 'react'; +import { useLocation, useSearchParams } from 'react-router'; +import { toast } from 'react-toastify/unstyled'; +import { SearchParamKeys } from '../lib/constants'; +import { CODE_TO_MESSAGE_MAP, QueryMessageCode, QueryMessageType } from '../lib/QueryMessages'; + +type QueryMessageString = `${QueryMessageCode}__${QueryMessageType}`; + +export type QueryMessage = { + type: QueryMessageType; + code: QueryMessageCode; + message: ReactNode; +}; + +export type UseQueryMessageOptions = { + displayMessages?: boolean; +}; + +export type UseQueryMessageReturn = { + setQueryMessage: (messageCode: QueryMessageCode, messageType: QueryMessageType) => void; + clearQueryMessage: () => void; + toSearchParamQueryMessage: (message: QueryMessageCode, type: QueryMessageType) => QueryMessageString; +}; + +export function useQueryMessage( + { displayMessages }: UseQueryMessageOptions = { + displayMessages: true, + } +): UseQueryMessageReturn { + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const messageStr = useRef(null); + + useEffect(() => { + // Reset messageStr when location changes to allow re-displaying the same message on navigation + messageStr.current = null; + }, [location.pathname]); + + useEffect(() => { + const queryMessageStr = searchParams.get(SearchParamKeys.Message); + if (!(queryMessageStr && queryMessageStr !== messageStr.current)) return; + const [queryMessage, queryMessageString] = toQueryMessage(queryMessageStr) ?? [null, null]; + if (!queryMessage) return; + + messageStr.current = queryMessageString; + if (displayMessages) { + toast[queryMessage.type](queryMessage.code, { + position: 'top-center', + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: false, + progress: undefined, + theme: 'colored', + toastId: 'login-route-info-message', + }); + } + }, [displayMessages, searchParams]); + + const setQueryMessage = useCallback( + (messageCode: QueryMessageCode, messageType: QueryMessageType) => { + const queryMessageString: QueryMessageString = `${messageCode}__${messageType}`; + messageStr.current = queryMessageString; + setSearchParams((prev) => { + prev.set(SearchParamKeys.Message, queryMessageString); + return prev; + }); + }, + [setSearchParams] + ); + + const clearQueryMessage = useCallback(() => { + messageStr.current = null; + setSearchParams((prev) => { + prev.delete(SearchParamKeys.Message); + return prev; + }); + }, [setSearchParams]); + + const toSearchParamQueryMessage = useCallback((message: QueryMessageCode, type: QueryMessageType): QueryMessageString => { + return `${message}__${type}`; + }, []); + + return { + setQueryMessage, + clearQueryMessage, + toSearchParamQueryMessage, + }; +} + +function isValidQueryMessageCode(code: string): code is QueryMessageCode { + return Object.values(QueryMessageCode).includes(code as QueryMessageCode); +} + +function isValidQueryMessageType(type: string): type is QueryMessageType { + return Object.values(QueryMessageType).includes(type as QueryMessageType); +} + +function toQueryMessage(value: string): [QueryMessage, QueryMessageString] | null { + const [code, type] = value.split('__'); + if (!isValidQueryMessageCode(code) || !isValidQueryMessageType(type)) return null; + return [ + { + code: code, + type: type, + message: CODE_TO_MESSAGE_MAP[code], + }, + `${code}__${type}`, + ]; +} diff --git a/apps/frontend/app/lib/QueryMessages.tsx b/apps/frontend/app/lib/QueryMessages.tsx new file mode 100644 index 0000000..85dff06 --- /dev/null +++ b/apps/frontend/app/lib/QueryMessages.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; + +export enum QueryMessageType { + Info = 'info', + Success = 'success', + Warning = 'warning', + Error = 'error', +} + +export enum QueryMessageCode { + SessionExpired = 'SESSION_EXPIRED', + InitializationRequired = 'INITIALIZATION_REQUIRED', + InitializationSuccessful = 'INITIALIZATION_SUCCESSFUL', +} + +export const CODE_TO_MESSAGE_MAP: Record = { + [QueryMessageCode.SessionExpired]: 'Your session has expired. Please log in again.', + [QueryMessageCode.InitializationRequired]: 'The application requires initialization. Please follow the setup instructions.', + [QueryMessageCode.InitializationSuccessful]: 'Initialization successful. Please log in.', +} as const; diff --git a/apps/frontend/app/root.tsx b/apps/frontend/app/root.tsx index cf027bb..4000cf7 100644 --- a/apps/frontend/app/root.tsx +++ b/apps/frontend/app/root.tsx @@ -2,6 +2,11 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } import type { Route } from './+types/root'; import '@radix-ui/themes/styles.css'; import './app.css'; +// start: react-toastify special import +// ! MUST use unstyled version for dev server build, styled version for production build is handled in vite.config.ts +import { ToastContainer } from 'react-toastify/unstyled'; +import 'react-toastify/ReactToastify.css'; +// end: react-toastify special import import AppTheme from './components/theme'; import { ApiProvider } from './providers/ApiProvider'; import { LayoutProvider } from './providers/LayoutProvider'; @@ -19,8 +24,6 @@ export function Layout({ children }: { children: React.ReactNode }) { - {/* Required for react-toastify */} -