type safety updates for mutation

This commit is contained in:
Nate Kelley 2025-02-12 14:19:51 -07:00
parent ad02a021ac
commit e91e0d9f86
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
12 changed files with 327 additions and 115 deletions

View File

@ -18,7 +18,7 @@ export const useUpdateMetricAssosciations = ({
}) => { }) => {
const busterSocket = useBusterWebSocket(); const busterSocket = useBusterWebSocket();
const userFavorites = useUserConfigContextSelector((state) => state.userFavorites); const userFavorites = useUserConfigContextSelector((state) => state.userFavorites);
const forceGetFavoritesList = useUserConfigContextSelector((x) => x.forceGetFavoritesList); const refreshFavoritesList = useUserConfigContextSelector((x) => x.refreshFavoritesList);
const removeItemFromIndividualDashboard = useBusterDashboardContextSelector( const removeItemFromIndividualDashboard = useBusterDashboardContextSelector(
(state) => state.removeItemFromIndividualDashboard (state) => state.removeItemFromIndividualDashboard
); );
@ -108,7 +108,7 @@ export const useUpdateMetricAssosciations = ({
if (addToPromises.length) await Promise.all(addToPromises); if (addToPromises.length) await Promise.all(addToPromises);
if (collectionIsInFavorites) { if (collectionIsInFavorites) {
await forceGetFavoritesList(); await refreshFavoritesList();
} }
} }
); );
@ -205,7 +205,7 @@ export const useUpdateMetricAssosciations = ({
} }
}); });
if (collectionIsInFavorites && ignoreFavoriteUpdates !== true) { if (collectionIsInFavorites && ignoreFavoriteUpdates !== true) {
await forceGetFavoritesList(); await refreshFavoritesList();
} }
} }
); );

View File

@ -1,39 +1,31 @@
import React, { useRef } from 'react';
import { useBusterWebSocket } from '../BusterWebSocket'; import { useBusterWebSocket } from '../BusterWebSocket';
import { useMemoizedFn, useMount } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import type { BusterUserFavorite } from '@/api/asset_interfaces'; import { BusterUserFavorite, ShareAssetType } from '@/api/asset_interfaces';
import { ShareAssetType } from '@/api/asset_interfaces'; import { createQueryKey, useSocketQueryEmitOn } from '@/hooks';
import { useQueryClient } from '@tanstack/react-query';
import { useHotkeys } from 'react-hotkeys-hook';
export const useFavoriteProvider = () => { export const useFavoriteProvider = () => {
const busterSocket = useBusterWebSocket(); const busterSocket = useBusterWebSocket();
const useMountedUserFavorites = useRef(false); const queryClient = useQueryClient();
const [userFavorites, setUserFavorites] = React.useState<BusterUserFavorite[]>([]);
const _onSetInitialFavoritesList = useMemoizedFn((favorites: BusterUserFavorite[]) => { const favoritesQueryKey = createQueryKey(
setUserFavorites(favorites); { route: '/users/favorites/list:listFavorites' },
}); { route: '/users/favorites/list', payload: {} }
);
const forceGetFavoritesList = useMemoizedFn(() => { const { data: userFavorites, refetch: refreshFavoritesList } = useSocketQueryEmitOn(
useMountedUserFavorites.current = false; { route: '/users/favorites/list', payload: {} },
busterSocket.off({ { route: '/users/favorites/list:listFavorites' }
route: '/users/favorites/list:listFavorites', );
callback: _onSetInitialFavoritesList
});
return _onGetFavoritesList();
});
const _onGetFavoritesList = useMemoizedFn(() => { const setUserFavorites = useMemoizedFn(
if (useMountedUserFavorites.current) return; (updater: (v: BusterUserFavorite[]) => BusterUserFavorite[]) => {
useMountedUserFavorites.current = true; queryClient.setQueryData(favoritesQueryKey, (v: BusterUserFavorite[] | undefined) => {
busterSocket.emit({ return updater(v || []);
route: '/users/favorites/list',
payload: {}
});
busterSocket.on({
route: '/users/favorites/list:listFavorites',
callback: _onSetInitialFavoritesList
});
}); });
}
);
const addItemToFavorite = useMemoizedFn( const addItemToFavorite = useMemoizedFn(
async ({ async ({
@ -48,77 +40,81 @@ export const useFavoriteProvider = () => {
}) => { }) => {
setUserFavorites((v) => [{ id, type: asset_type, name }, ...v]); setUserFavorites((v) => [{ id, type: asset_type, name }, ...v]);
await busterSocket.emitAndOnce({ // busterSocket.emit({
emitEvent: { // route: '/users/favorites/post',
route: '/users/favorites/post', // payload: {
payload: { // id,
id, // asset_type
asset_type // }
} // });
},
responseEvent: { // await busterSocket.emitAndOnce({
route: '/users/favorites/post:createFavorite', // emitEvent: {
callback: _onSetInitialFavoritesList // route: '/users/favorites/post',
} // payload: {
}); // id,
// asset_type
// }
// },
// responseEvent: {
// route: '/users/favorites/post:createFavorite',
// callback: _onSetInitialFavoritesList
// }
// });
} }
); );
const removeItemFromFavorite = useMemoizedFn( const removeItemFromFavorite = useMemoizedFn(
async ({ id, asset_type }: { id: string; asset_type: ShareAssetType }) => { async ({ id, asset_type }: { id: string; asset_type: ShareAssetType }) => {
setUserFavorites(userFavorites.filter((f) => f.id !== id)); // setUserFavorites(userFavorites.filter((f) => f.id !== id));
await busterSocket.emitAndOnce({ // await busterSocket.emitAndOnce({
emitEvent: { // emitEvent: {
route: '/users/favorites/delete', // route: '/users/favorites/delete',
payload: { // payload: {
id, // id,
asset_type // asset_type
} // }
}, // },
responseEvent: { // responseEvent: {
route: '/users/favorites/post:createFavorite', // route: '/users/favorites/post:createFavorite',
callback: _onSetInitialFavoritesList // callback: _onSetInitialFavoritesList
} // }
}); // });
} }
); );
const reorderFavorites = useMemoizedFn(async (favorites: string[]) => { const reorderFavorites = useMemoizedFn(async (favorites: string[]) => {
requestAnimationFrame(() => { // requestAnimationFrame(() => {
setUserFavorites((v) => { // setUserFavorites((v) => {
return favorites.map((id, index) => { // return favorites.map((id, index) => {
let favorite = v.find((f) => f.id === id || f.collection_id === id)!; // let favorite = v.find((f) => f.id === id || f.collection_id === id)!;
return { ...favorite, index }; // return { ...favorite, index };
}); // });
}); // });
}); // });
await busterSocket.emitAndOnce({ // await busterSocket.emitAndOnce({
emitEvent: { // emitEvent: {
route: '/users/favorites/update', // route: '/users/favorites/update',
payload: { // payload: {
favorites // favorites
} // }
}, // },
responseEvent: { // responseEvent: {
route: '/users/favorites/update:updateFavorite', // route: '/users/favorites/update:updateFavorite',
callback: _onSetInitialFavoritesList // callback: _onSetInitialFavoritesList
} // }
}); // });
}); });
const bulkEditFavorites = useMemoizedFn(async (favorites: string[]) => { const bulkEditFavorites = useMemoizedFn(async (favorites: string[]) => {
return reorderFavorites(favorites); return reorderFavorites(favorites);
}); });
useMount(async () => {
_onGetFavoritesList();
});
return { return {
bulkEditFavorites, bulkEditFavorites,
forceGetFavoritesList, refreshFavoritesList,
reorderFavorites, reorderFavorites,
userFavorites, userFavorites: userFavorites || [],
addItemToFavorite, addItemToFavorite,
removeItemFromFavorite removeItemFromFavorite
}; };

View File

@ -1,4 +1,4 @@
export * from './react'; export * from './react';
export * from './dom'; export * from './dom';
export * from './useDebounceSearch'; export * from './useDebounceSearch';
export * from './useBusterWebSocketQuery'; export * from './useSocketQuery';

View File

@ -1 +0,0 @@
export * from './useBusterWebSocketQuery';

View File

@ -0,0 +1,11 @@
import { BusterSocketRequest, BusterSocketResponseRoute } from '@/api/buster_socket';
import { BusterSocketResponseConfig } from './types';
import { QueryKey } from '@tanstack/react-query';
export const createQueryKey: <TRoute extends BusterSocketResponseRoute>(
socketResponse: BusterSocketResponseConfig<TRoute>,
socketRequest?: BusterSocketRequest
) => QueryKey = (socketResponse, socketRequest) => {
if (socketRequest) return [socketResponse.route, socketRequest.route, socketRequest.payload];
return [socketResponse.route];
};

View File

@ -0,0 +1,8 @@
import { createQueryKey } from './helpers';
import { BusterSocketResponseConfig } from './types';
export * from './useSocketQueryEmitAndOnce';
export * from './useSocketQueryEmitOn';
export * from './useSocketQueryOn';
export { createQueryKey, type BusterSocketResponseConfig };

View File

@ -11,18 +11,24 @@ import type {
BusterSocketResponseConfig BusterSocketResponseConfig
} from './types'; } from './types';
import { useCreateReactQuery } from '@/api/createReactQuery'; import { useCreateReactQuery } from '@/api/createReactQuery';
import { createQueryKey } from './helpers';
import { useMemo } from 'react';
export function useBusterWebSocketQuery<TRoute extends BusterSocketResponseRoute, TError = unknown>( export function useSocketQueryEmitAndOnce<
queryKey: QueryKey, TRoute extends BusterSocketResponseRoute,
TError = unknown
>(
socketRequest: BusterSocketRequest, socketRequest: BusterSocketRequest,
socketResponse: BusterSocketResponseConfig<TRoute>, socketResponse: BusterSocketResponseConfig<TRoute>,
options?: Omit< options?: Partial<Omit<UseQueryOptions<InferBusterSocketResponseData<TRoute>, TError>, 'queryFn'>>
UseQueryOptions<InferBusterSocketResponseData<TRoute>, TError>,
'queryKey' | 'queryFn'
>
): UseBusterSocketQueryResult<InferBusterSocketResponseData<TRoute>, TError> { ): UseBusterSocketQueryResult<InferBusterSocketResponseData<TRoute>, TError> {
const busterSocket = useBusterWebSocket(); const busterSocket = useBusterWebSocket();
const queryKey = useMemo(
() => options?.queryKey || createQueryKey(socketResponse, socketRequest),
[options?.queryKey, socketResponse?.route, socketRequest?.route]
);
const queryFn = async (): Promise<InferBusterSocketResponseData<TRoute>> => { const queryFn = async (): Promise<InferBusterSocketResponseData<TRoute>> => {
try { try {
const result = await busterSocket.emitAndOnce({ const result = await busterSocket.emitAndOnce({
@ -44,15 +50,8 @@ export function useBusterWebSocketQuery<TRoute extends BusterSocketResponseRoute
queryKey, queryKey,
queryFn, queryFn,
isUseSession: false, isUseSession: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
options options
}); });
} }
// Example usage with automatic type inference
const ExampleUsage = () => {
const { data, isLoading, error } = useBusterWebSocketQuery(
['chats', 'get', '123'],
{ route: '/chats/get', payload: { id: '123' } },
{ route: '/chats/get:getChat' }
);
};

View File

@ -0,0 +1,63 @@
import {
BusterSocketRequest,
BusterSocketResponse,
BusterSocketResponseRoute
} from '@/api/buster_socket';
import { useQueryClient, UseQueryOptions } from '@tanstack/react-query';
import {
BusterSocketResponseConfig,
InferBusterSocketResponseData,
UseBusterSocketQueryResult
} from './types';
import { useSockeQueryOn } from './useSocketQueryOn';
import { useBusterWebSocket } from '@/context/BusterWebSocket';
import { useMemoizedFn, useMount } from 'ahooks';
import { createQueryKey } from './helpers';
/**
* A hook that emits a socket request on mount and listens for responses.
*
* @template TRoute - The type of socket response route
* @template TError - The type of error that can occur
*
* @param socketRequest - The socket request to emit
* @param socketResponse - Configuration for the socket response including route and error handler
* @param options - Additional options for the React Query hook
*
* @returns A React Query result containing the response data and status
*/
export const useSocketQueryEmitOn = <TRoute extends BusterSocketResponseRoute, TError = unknown>(
socketRequest: BusterSocketRequest,
socketResponse: BusterSocketResponseConfig<TRoute>,
options?: Omit<
UseQueryOptions<InferBusterSocketResponseData<TRoute>, TError>,
'queryKey' | 'queryFn'
>
): UseBusterSocketQueryResult<InferBusterSocketResponseData<TRoute>, TError> => {
const busterSocket = useBusterWebSocket();
const queryClient = useQueryClient();
const queryKey = createQueryKey(socketResponse, socketRequest);
const emitEvent = useMemoizedFn(async () => {
const res = await busterSocket.emitAndOnce({
emitEvent: socketRequest,
responseEvent: {
...socketResponse,
callback: (d: unknown) => d
} as BusterSocketResponse
});
return res;
}) as () => Promise<InferBusterSocketResponseData<TRoute>>;
useMount(() => {
emitEvent();
});
return useSockeQueryOn(socketResponse, {
...options,
queryKey,
queryFn: emitEvent
});
};

View File

@ -0,0 +1,130 @@
import {
BusterSocketRequest,
BusterSocketResponse,
BusterSocketResponseRoute
} from '@/api/buster_socket';
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import { useBusterWebSocket } from '@/context/BusterWebSocket';
import { useMemoizedFn } from 'ahooks';
import { BusterSocketResponseConfig, InferBusterSocketResponseData } from './types';
import { ShareAssetType } from '@/api/asset_interfaces';
import { createQueryKey } from './helpers';
/**
* A hook that creates a mutation for emitting socket requests and handling responses.
*
* @template TRoute - The type of socket response route
* @template TData - The type of data returned by the socket response
* @template TVariables - The type of variables passed to the mutation function
* @template TError - The type of error that can occur
*
* @param socketRequest - The base socket request to emit (variables will be merged with this)
* @param socketResponse - Configuration for the socket response including route and error handler
* @param options - Additional options for the React Query mutation hook
*
* @returns A React Query mutation result for handling socket requests
*/
export const useSocketQueryMutation = <
TRoute extends BusterSocketResponseRoute,
TVariables = void,
TError = unknown
>(
socketRequest: BusterSocketRequest,
socketResponse: BusterSocketResponseConfig<TRoute> & {
callback?: (d: unknown) => InferBusterSocketResponseData<TRoute>;
},
optionsProps?: Omit<
UseMutationOptions<InferBusterSocketResponseData<TRoute>, TError, TVariables>,
'mutationFn'
> & {
preSetQueryData?: (
d: InferBusterSocketResponseData<TRoute> | undefined,
variables: TVariables
) => InferBusterSocketResponseData<TRoute>;
queryDataStrategy?: 'replace' | 'append' | 'prepend' | 'merge' | 'ignore';
}
) => {
const busterSocket = useBusterWebSocket();
const queryClient = useQueryClient();
const { preSetQueryData, queryDataStrategy = 'ignore', ...options } = optionsProps || {};
const mutationFn = useMemoizedFn(async (variables: TVariables) => {
const queryKey = createQueryKey(socketResponse, socketRequest);
if (preSetQueryData) {
await queryClient.setQueryData<InferBusterSocketResponseData<TRoute>>(queryKey, (d) => {
return preSetQueryData(d, variables);
});
}
const res = await busterSocket.emitAndOnce({
emitEvent: socketRequest,
responseEvent: {
...socketResponse,
callback: (d: unknown) => {
if (socketResponse.callback) {
socketResponse.callback(d);
}
return d;
}
} as BusterSocketResponse
});
if (queryDataStrategy === 'replace') {
await queryClient.setQueryData<InferBusterSocketResponseData<TRoute>>(queryKey, () => {
return res as InferBusterSocketResponseData<TRoute>;
});
} else if (queryDataStrategy === 'append') {
await queryClient.setQueryData<InferBusterSocketResponseData<TRoute>[]>(queryKey, (d) => {
return [...(Array.isArray(d) ? d : []), res as InferBusterSocketResponseData<TRoute>];
});
} else if (queryDataStrategy === 'prepend') {
await queryClient.setQueryData<InferBusterSocketResponseData<TRoute>[]>(queryKey, (d) => {
return [res as InferBusterSocketResponseData<TRoute>, ...(Array.isArray(d) ? d : [])];
});
} else if (queryDataStrategy === 'merge') {
await queryClient.setQueryData<Record<string, InferBusterSocketResponseData<TRoute>>>(
queryKey,
(d) => {
if (typeof res === 'object' && res !== null && 'id' in res) {
const typedRes = res as InferBusterSocketResponseData<TRoute> & { id: string };
return {
...(d || {}),
[typedRes.id]: typedRes
};
} else {
console.warn('response is not an object with an id', res);
}
return d;
}
);
}
return res as InferBusterSocketResponseData<TRoute>;
});
return useMutation<InferBusterSocketResponseData<TRoute>, TError, TVariables>({
...options,
mutationFn
});
};
const Example = () => {
const { mutate, data, ...rest } = useSocketQueryMutation(
{
route: '/users/favorites/post',
payload: {
id: '1',
asset_type: ShareAssetType.DASHBOARD
}
},
{
route: '/users/favorites/post:createFavorite'
},
{
preSetQueryData: (d) => {
return d || [];
}
}
);
};

View File

@ -1,7 +1,11 @@
'use client'; 'use client';
import { QueryKey, useQuery, useQueryClient } from '@tanstack/react-query'; import { QueryKey, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query';
import type { BusterSocketResponse, BusterSocketResponseRoute } from '@/api/buster_socket'; import type {
BusterSocketRequest,
BusterSocketResponse,
BusterSocketResponseRoute
} from '@/api/buster_socket';
import { useBusterWebSocket } from '@/context/BusterWebSocket'; import { useBusterWebSocket } from '@/context/BusterWebSocket';
import type { import type {
UseBusterSocketQueryResult, UseBusterSocketQueryResult,
@ -9,32 +13,34 @@ import type {
BusterSocketResponseConfig BusterSocketResponseConfig
} from './types'; } from './types';
import { useMount } from 'ahooks'; import { useMount } from 'ahooks';
import { createQueryKey } from './helpers';
import { useMemo } from 'react';
export const useBusterWebSocketOn = <TRoute extends BusterSocketResponseRoute, TError = unknown>( export const useSockeQueryOn = <TRoute extends BusterSocketResponseRoute, TError = unknown>(
queryKey: QueryKey, socketResponse: BusterSocketResponseConfig<TRoute>,
socketResponse: BusterSocketResponseConfig<TRoute> options?: Partial<UseQueryOptions<InferBusterSocketResponseData<TRoute>, TError>>
): UseBusterSocketQueryResult<InferBusterSocketResponseData<TRoute>, TError> => { ): UseBusterSocketQueryResult<InferBusterSocketResponseData<TRoute>, TError> => {
const busterSocket = useBusterWebSocket(); const busterSocket = useBusterWebSocket();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const queryKey = useMemo(
() => options?.queryKey || createQueryKey(socketResponse),
[options?.queryKey, socketResponse?.route]
);
useMount(() => { useMount(() => {
busterSocket.on({ busterSocket.on({
route: socketResponse.route, route: socketResponse.route,
onError: socketResponse.onError, onError: socketResponse.onError,
callback: (d: unknown) => { callback: (d: unknown) => {
queryClient.setQueryData(queryKey, d as InferBusterSocketResponseData<TRoute>); queryClient.setQueryData(queryKey, d as InferBusterSocketResponseData<TRoute>);
queryClient.invalidateQueries({ queryKey });
} }
} as BusterSocketResponse); } as BusterSocketResponse);
}); });
return useQuery({ return useQuery({
queryKey queryKey,
}); ...options,
}; enabled: false
const ExampleUsage = () => {
const { data, isFetched } = useBusterWebSocketOn(['chats', 'get', '123'], {
route: '/chats/get:getChat'
}); });
}; };