wip query/mutation hooks
This commit is contained in:
132
src/lib/tanstack-query/hooks/use-server-result.ts
Normal file
132
src/lib/tanstack-query/hooks/use-server-result.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ServerResult } from '@/lib/tanstack-query/types';
|
||||||
|
|
||||||
|
export function useServerQuery<TData>(
|
||||||
|
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
|
||||||
|
queryFn: () => Promise<ServerResult<TData>>;
|
||||||
|
showErrorToast?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { queryFn, showErrorToast = true, ...queryOptions } = options;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
...queryOptions,
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await queryFn();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
if (showErrorToast) {
|
||||||
|
toast.error(result.error.userMessage);
|
||||||
|
}
|
||||||
|
throw new Error(result.error.userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerSuspenseQuery<TData>(
|
||||||
|
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
|
||||||
|
queryFn: () => Promise<ServerResult<TData>>;
|
||||||
|
showErrorToast?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { queryFn, showErrorToast = true, ...queryOptions } = options;
|
||||||
|
|
||||||
|
return useSuspenseQuery({
|
||||||
|
...queryOptions,
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await queryFn();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
if (showErrorToast) {
|
||||||
|
toast.error(result.error.userMessage);
|
||||||
|
}
|
||||||
|
throw new Error(result.error.userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerMutation<TData, TVariables = unknown>(
|
||||||
|
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
|
||||||
|
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
|
||||||
|
successMessage?: string;
|
||||||
|
showErrorToast?: boolean;
|
||||||
|
showSuccessToast?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
mutationFn,
|
||||||
|
successMessage,
|
||||||
|
showErrorToast = true,
|
||||||
|
showSuccessToast = true,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
...mutationOptions
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
...mutationOptions,
|
||||||
|
mutationFn: async (variables: TVariables) => {
|
||||||
|
const result = await mutationFn(variables);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
if (showErrorToast) {
|
||||||
|
toast.error(result.error.userMessage);
|
||||||
|
}
|
||||||
|
throw new Error(result.error.userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
if (showSuccessToast && successMessage) {
|
||||||
|
toast.success(successMessage);
|
||||||
|
}
|
||||||
|
onSuccess?.(data, variables, context);
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
onError?.(error, variables, context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptimisticMutation<TData, TVariables = unknown>(
|
||||||
|
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
|
||||||
|
queryKey: readonly (string | number)[];
|
||||||
|
optimisticUpdate?: (oldData: any, variables: TVariables) => any;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { queryKey, optimisticUpdate, ...mutationOptions } = options;
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
...mutationOptions,
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
|
||||||
|
const previousData = queryClient.getQueryData(queryKey);
|
||||||
|
|
||||||
|
if (optimisticUpdate && previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, (old: any) => optimisticUpdate(old, variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousData };
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, context.previousData);
|
||||||
|
}
|
||||||
|
mutationOptions.onError?.(error, variables, context);
|
||||||
|
},
|
||||||
|
onSettled: (data, error, variables, context) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
mutationOptions.onSettled?.(data, error, variables, context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
22
src/lib/tanstack-query/types.ts
Normal file
22
src/lib/tanstack-query/types.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export type ServerResult<T> =
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; error: ServerError };
|
||||||
|
|
||||||
|
export type ServerError = {
|
||||||
|
code: ErrorType;
|
||||||
|
message: string;
|
||||||
|
userMessage: string;
|
||||||
|
statusCode?: number;
|
||||||
|
context?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum ErrorType {
|
||||||
|
VALIDATION = 'VALIDATION',
|
||||||
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||||
|
FORBIDDEN = 'FORBIDDEN',
|
||||||
|
NETWORK = 'NETWORK',
|
||||||
|
DATABASE = 'DATABASE',
|
||||||
|
POCKETBASE = 'POCKETBASE',
|
||||||
|
UNKNOWN = 'UNKNOWN'
|
||||||
|
}
|
||||||
114
src/lib/tanstack-query/utils.ts
Normal file
114
src/lib/tanstack-query/utils.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { logger } from "../logger";
|
||||||
|
import { ErrorType, ServerError, ServerResult } from "./types";
|
||||||
|
|
||||||
|
export const createServerError = (
|
||||||
|
type: ErrorType,
|
||||||
|
message: string,
|
||||||
|
userMessage: string,
|
||||||
|
statusCode?: number,
|
||||||
|
context?: Record<string, any>
|
||||||
|
): ServerError => ({
|
||||||
|
code: type,
|
||||||
|
message,
|
||||||
|
userMessage,
|
||||||
|
statusCode,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const withErrorHandling = async <T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string,
|
||||||
|
customErrorMap?: (error: unknown) => ServerError | null
|
||||||
|
): Promise<ServerResult<T>> => {
|
||||||
|
try {
|
||||||
|
const data = await operation();
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${context} failed`, { error, context });
|
||||||
|
|
||||||
|
if (customErrorMap) {
|
||||||
|
const customError = customErrorMap(error);
|
||||||
|
if (customError) {
|
||||||
|
return { success: false, error: customError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedError = mapKnownError(error, context);
|
||||||
|
return { success: false, error: mappedError };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapKnownError = (error: unknown, context: string): ServerError => {
|
||||||
|
if (error && typeof error === "object" && "status" in error) {
|
||||||
|
const pbError = error as {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (pbError.status) {
|
||||||
|
case 400:
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.VALIDATION,
|
||||||
|
pbError.message,
|
||||||
|
"Invalid request",
|
||||||
|
400,
|
||||||
|
{ originalError: pbError.data }
|
||||||
|
);
|
||||||
|
case 401:
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.UNAUTHORIZED,
|
||||||
|
pbError.message,
|
||||||
|
"You are not authorized to perform this action",
|
||||||
|
401
|
||||||
|
);
|
||||||
|
case 403:
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.FORBIDDEN,
|
||||||
|
pbError.message,
|
||||||
|
"Access denied",
|
||||||
|
403
|
||||||
|
);
|
||||||
|
case 404:
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.NOT_FOUND,
|
||||||
|
pbError.message,
|
||||||
|
"The requested resource was not found",
|
||||||
|
404
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.POCKETBASE,
|
||||||
|
pbError.message,
|
||||||
|
"Something went wrong. Please try again.",
|
||||||
|
pbError.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.NETWORK,
|
||||||
|
error.message,
|
||||||
|
"Network error. Please check your connection and try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.UNKNOWN,
|
||||||
|
error.message,
|
||||||
|
"An unexpected error occurred. Please try again.",
|
||||||
|
undefined,
|
||||||
|
{ stack: error.stack, context }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createServerError(
|
||||||
|
ErrorType.UNKNOWN,
|
||||||
|
String(error),
|
||||||
|
"An unexpected error occurred. Please try again.",
|
||||||
|
undefined,
|
||||||
|
{ originalError: error, context }
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user