Refactor query message toast
This commit is contained in:
1
apps/frontend/app/empty-toastify.css
Normal file
1
apps/frontend/app/empty-toastify.css
Normal file
@@ -0,0 +1 @@
|
||||
/* intentionally empty: used to stub react-toastify CSS in production builds */
|
||||
@@ -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,8 +22,15 @@ export type DefaultResponseErrorHandlerOptions = {
|
||||
* @param err error value
|
||||
* @returns {boolean} true if the error was handled, false otherwise
|
||||
*/
|
||||
const defaultResponseErrorHandler =
|
||||
(navigate: ReturnType<typeof useNavigate>, location: ReturnType<typeof useLocation>) =>
|
||||
|
||||
export function useResponseErrorHandler(): {
|
||||
defaultResponseErrorHandler: typeof defaultResponseErrorHandler;
|
||||
} {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { toSearchParamQueryMessage } = useQueryMessage();
|
||||
|
||||
const defaultResponseErrorHandler = useCallback(
|
||||
(err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => {
|
||||
if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) {
|
||||
toast.error(
|
||||
@@ -76,7 +86,7 @@ const defaultResponseErrorHandler =
|
||||
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');
|
||||
searchParam.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.SessionExpired, QueryMessageType.Info));
|
||||
navigate(`/login?${searchParam.toString()}`);
|
||||
return true;
|
||||
}
|
||||
@@ -102,12 +112,9 @@ const defaultResponseErrorHandler =
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
},
|
||||
[location, navigate, toSearchParamQueryMessage]
|
||||
);
|
||||
|
||||
export function useResponseErrorHandler(): {
|
||||
defaultResponseErrorHandler: ReturnType<typeof defaultResponseErrorHandler>;
|
||||
} {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return { defaultResponseErrorHandler: defaultResponseErrorHandler(navigate, location) };
|
||||
return { defaultResponseErrorHandler };
|
||||
}
|
||||
|
||||
111
apps/frontend/app/hooks/useQueryMessage.tsx
Normal file
111
apps/frontend/app/hooks/useQueryMessage.tsx
Normal file
@@ -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<QueryMessageString | null>(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}`,
|
||||
];
|
||||
}
|
||||
20
apps/frontend/app/lib/QueryMessages.tsx
Normal file
20
apps/frontend/app/lib/QueryMessages.tsx
Normal file
@@ -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, ReactNode> = {
|
||||
[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;
|
||||
@@ -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 }) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
{/* Required for react-toastify */}
|
||||
<style />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
@@ -33,6 +36,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<AppTheme>
|
||||
<ApiProvider>
|
||||
<Tooltip.Provider delayDuration={250}>
|
||||
@@ -46,6 +50,9 @@ export default function App() {
|
||||
</Tooltip.Provider>
|
||||
</ApiProvider>
|
||||
</AppTheme>
|
||||
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Box, Container, Flex, Heading } from '@radix-ui/themes';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { Slide, toast, ToastContainer } from 'react-toastify';
|
||||
import { toast } from 'react-toastify/unstyled';
|
||||
import * as v from 'valibot';
|
||||
import { useResponseErrorHandler } from '../../hooks/ResponseHelper';
|
||||
import { useApi } from '../../providers/ApiProvider';
|
||||
import { formHook } from '../../providers/FormProvider';
|
||||
import type { Route } from './+types/login';
|
||||
import { SearchParamKeys } from '../../lib/constants';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useQueryMessage } from '../../hooks/useQueryMessage';
|
||||
|
||||
const loginFormSchema = v.object({
|
||||
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
||||
@@ -27,7 +27,7 @@ export default function LoginRoute() {
|
||||
const location = useLocation();
|
||||
const { tanstackApiClient } = useApi();
|
||||
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||
const [previousSearchParamMessage, setPreviousSearchParamMessage] = useState<string>('');
|
||||
useQueryMessage();
|
||||
|
||||
const { mutateAsync: login, isPending } = useMutation({
|
||||
...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions,
|
||||
@@ -75,25 +75,6 @@ export default function LoginRoute() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const message = searchParams.get(SearchParamKeys.Message);
|
||||
if (message && message !== previousSearchParamMessage) {
|
||||
setPreviousSearchParamMessage(message);
|
||||
toast.info(message, {
|
||||
position: 'top-center',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: false,
|
||||
progress: undefined,
|
||||
theme: 'colored',
|
||||
toastId: 'login-route-info-message',
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
|
||||
@@ -167,17 +148,6 @@ export default function LoginRoute() {
|
||||
</Box>
|
||||
</Container>
|
||||
</Flex>
|
||||
<ToastContainer
|
||||
position="top-center"
|
||||
autoClose={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable={false}
|
||||
theme="colored"
|
||||
transition={Slide}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Box, Container, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Slide, toast, ToastContainer } from 'react-toastify';
|
||||
import { toast } from 'react-toastify/unstyled';
|
||||
import * as v from 'valibot';
|
||||
import { useResponseErrorHandler } from '../hooks/ResponseHelper';
|
||||
import { useApi } from '../providers/ApiProvider';
|
||||
import { formHook } from '../providers/FormProvider';
|
||||
import { TooltipContentContainer } from '../components/info';
|
||||
import { SearchParamKeys } from '../lib/constants';
|
||||
import { useQueryMessage } from '../hooks/useQueryMessage';
|
||||
import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
|
||||
|
||||
const initFormSchema = v.object({
|
||||
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
||||
@@ -19,12 +21,13 @@ export default function InitRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { tanstackApiClient } = useApi();
|
||||
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||
const { toSearchParamQueryMessage } = useQueryMessage();
|
||||
|
||||
const { mutateAsync: initAdmin, isPending } = useMutation({
|
||||
...tanstackApiClient.mutation('post', '/api/auth/init_admin').mutationOptions,
|
||||
onSuccess: async () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set(SearchParamKeys.Message, 'Initialization successful. Please log in.');
|
||||
searchParams.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.InitializationSuccessful, QueryMessageType.Success));
|
||||
navigate(`/login?${searchParams.toString()}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -40,7 +43,7 @@ export default function InitRoute() {
|
||||
try {
|
||||
const data = await healthInfoQuery.queryFn!(...args);
|
||||
if (data.is_initialized) {
|
||||
navigate('/');
|
||||
navigate('/login', { replace: true });
|
||||
return data;
|
||||
}
|
||||
return data;
|
||||
@@ -153,18 +156,6 @@ export default function InitRoute() {
|
||||
</Box>
|
||||
</Container>
|
||||
</Flex>
|
||||
|
||||
<ToastContainer
|
||||
position="top-center"
|
||||
autoClose={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable={false}
|
||||
theme="colored"
|
||||
transition={Slide}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
// @ts-expect-error vite-plugin-eslint has no types
|
||||
import eslint from 'vite-plugin-eslint';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ command }) => {
|
||||
const isBuild = command === 'build';
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
reactRouter(),
|
||||
@@ -14,5 +17,22 @@ export default defineConfig({
|
||||
failOnError: false,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: isBuild
|
||||
? [
|
||||
{
|
||||
// replace unstyled import with styled for SPA build
|
||||
find: 'react-toastify/unstyled',
|
||||
replacement: 'react-toastify',
|
||||
},
|
||||
{
|
||||
// point to the empty CSS file to stub out the import during build, SPA build does not require extra CSS imports
|
||||
find: 'react-toastify/ReactToastify.css',
|
||||
replacement: '~/empty-toastify.css',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
appType: 'spa',
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user