refactor(frontend): useMutationWithToast + useModal hooks, migrate admin surfaces

- 92 mutations across 40 files moved to useMutationWithToast
  (success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
  to avoid introducing new error toasts
This commit is contained in:
Paul Nothaft
2026-07-03 07:49:45 +02:00
parent 6eb46d8c31
commit 0f230f53fb
46 changed files with 680 additions and 768 deletions
+3 -1
View File
@@ -2,4 +2,6 @@ export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './usePermission';
export * from './usePublicSettings';
export * from './usePublicSettings';
export * from './useMutationWithToast';
export * from './useModal';
+20
View File
@@ -0,0 +1,20 @@
import { useCallback, useState } from 'react';
export interface UseModalResult {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
}
/**
* Small helper for the ubiquitous `const [showX, setShowX] = useState(false)`
* modal open/close flag.
*/
export function useModal(initialOpen = false): UseModalResult {
const [isOpen, setIsOpen] = useState(initialOpen);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
return { isOpen, open, close, toggle };
}
@@ -0,0 +1,73 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryKey, UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { toast } from 'react-toastify';
/**
* Extracts the server-provided error message from an axios error response,
* matching the `error.response?.data?.error` pattern used across admin pages.
*/
const extractServerError = (error: unknown): string | undefined => {
const serverError = (error as { response?: { data?: { error?: unknown; message?: unknown } } })
?.response?.data;
if (typeof serverError?.error === 'string') return serverError.error;
if (typeof serverError?.message === 'string') return serverError.message;
return undefined;
};
export interface UseMutationWithToastOptions<TData, TError, TVariables, TContext>
extends UseMutationOptions<TData, TError, TVariables, TContext> {
/** Toast shown on success. Omit to show no success toast. */
successMessage?: string | ((data: TData, variables: TVariables) => string);
/**
* Fallback toast shown on error when the server response carries no error
* message. Pass a function to take full control of the error text.
*/
errorMessage?: string | ((error: TError) => string);
/** Query keys invalidated on success, before the passthrough `onSuccess` runs. */
invalidateKeys?: QueryKey[];
}
/**
* `useMutation` wrapper for the common admin mutation shape:
* invalidate queries + success toast on success, error toast (server message
* first, then `errorMessage` fallback) on error. Passthrough `onSuccess` /
* `onError` still run after the built-in handling.
*/
export function useMutationWithToast<
TData = unknown,
TError = Error,
TVariables = void,
TContext = unknown,
>(
options: UseMutationWithToastOptions<TData, TError, TVariables, TContext>
): UseMutationResult<TData, TError, TVariables, TContext> {
const queryClient = useQueryClient();
const { successMessage, errorMessage, invalidateKeys, onSuccess, onError, ...mutationOptions } =
options;
return useMutation<TData, TError, TVariables, TContext>({
...mutationOptions,
onSuccess: (data, variables, context) => {
invalidateKeys?.forEach((queryKey) => {
queryClient.invalidateQueries({ queryKey });
});
if (successMessage) {
toast.success(
typeof successMessage === 'function' ? successMessage(data, variables) : successMessage
);
}
onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
const message =
typeof errorMessage === 'function'
? errorMessage(error)
: extractServerError(error) ||
errorMessage ||
(error instanceof Error ? error.message : undefined) ||
'An unexpected error occurred';
toast.error(message);
onError?.(error, variables, context);
},
});
}