@codeleap/query
Advanced tools
| import { QueryManager } from '../lib' | ||
| import { QueryItem, QueryManagerOptions } from '../types' | ||
| /** | ||
| * Factory function to create a new QueryManager instance | ||
| * @template T - The query item type that extends QueryItem | ||
| * @template F - The filter type used for list queries | ||
| * @param options - Configuration options for the query manager | ||
| * @returns New QueryManager instance | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * interface User extends QueryItem { | ||
| * name: string | ||
| * email: string | ||
| * status: 'active' | 'inactive' | ||
| * } | ||
| * | ||
| * interface UserFilters { | ||
| * status?: 'active' | 'inactive' | ||
| * search?: string | ||
| * } | ||
| * | ||
| * const userQueryManager = createQueryManager<User, UserFilters>({ | ||
| * name: 'users', | ||
| * queryClient, | ||
| * listFn: (limit, offset, filters) => api.getUsers({ limit, offset, ...filters }), | ||
| * retrieveFn: (id) => api.getUser(id), | ||
| * createFn: (data) => api.createUser(data), | ||
| * updateFn: (data) => api.updateUser(data.id, data), | ||
| * deleteFn: (id) => api.deleteUser(id), | ||
| * listLimit: 20 | ||
| * }) | ||
| * ``` | ||
| */ | ||
| export const createQueryManager = <T extends QueryItem, F = {}>(options: QueryManagerOptions<T, F>) => { | ||
| return new QueryManager<T, F>(options) | ||
| } |
| import { QueryOperations } from '../lib' | ||
| import { QueryOperationsOptions } from '../lib/QueryOperations/types' | ||
| /** | ||
| * Factory function to create a new QueryOperations builder instance | ||
| * @param options - Configuration options including the QueryClient | ||
| * @returns New QueryOperations instance ready for operation registration | ||
| * | ||
| * @description | ||
| * This is the entry point for creating a new QueryOperations instance. Use this | ||
| * function to start building your collection of queries and mutations. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * import { QueryClient } from '@tanstack/react-query' | ||
| * | ||
| * const queryClient = new QueryClient() | ||
| * | ||
| * const userOperations = createQueryOperations({ queryClient }) | ||
| * .query('getUser', async (id: string) => fetchUser(id)) | ||
| * .query('getUsers', async (filters?: UserFilters) => fetchUsers(filters)) | ||
| * .mutation('createUser', async (data: CreateUserData) => createUser(data)) | ||
| * .mutation('updateUser', async (data: UpdateUserData) => updateUser(data)) | ||
| * .mutation('deleteUser', async (id: string) => deleteUser(id)) | ||
| * | ||
| * // In components | ||
| * function UserList() { | ||
| * const usersQuery = userOperations.useQuery('getUsers', { active: true }) | ||
| * const createMutation = userOperations.useMutation('createUser') | ||
| * | ||
| * // Both hooks are fully type-safe | ||
| * } | ||
| * ``` | ||
| */ | ||
| export function createQueryOperations(options: QueryOperationsOptions) { | ||
| return new QueryOperations(options) | ||
| } |
| export * from './createQueryManager' | ||
| export * from './createQueryOperations' |
| export * from './QueryClientEnhanced' | ||
| export * from './QueryKeys' | ||
| export * from './Mutations' | ||
| export * from './QueryManager' | ||
| export * from './QueryOperations' |
| import { InfiniteData } from '@tanstack/query-core' | ||
| import { QueryKeys } from './QueryKeys' | ||
| import { ItemPosition, ListPaginationResponse, PageParam, QueryClient, QueryItem, RemovedItemMap, WithTempId } from '../types' | ||
| import deepEqual from 'fast-deep-equal' | ||
| /** | ||
| * Class for managing mutations and cache updates for React Query list data | ||
| * @template T - The query item type that extends QueryItem | ||
| * @template F - The filter type used for list queries | ||
| */ | ||
| export class Mutations<T extends QueryItem, F> { | ||
| /** | ||
| * Creates a new Mutations instance | ||
| * @param queryKeys - The QueryKeys instance for managing query keys | ||
| * @param queryClient - The React Query client instance | ||
| * @param queryName - The name of the query used for identification | ||
| */ | ||
| constructor( | ||
| private queryKeys: QueryKeys<T, F>, | ||
| private queryClient: QueryClient, | ||
| private queryName: string | ||
| ) { } | ||
| /** | ||
| * Adds a new item to the cached list data | ||
| * @param newItem - The new item to add to the list | ||
| * @param position - Where to add the item: 'start', 'end', or a RemovedItemMap for specific positions | ||
| * @param listFilters - Optional filters to target specific list queries | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Add item to the beginning | ||
| * mutations.addItem(newUser, 'start') | ||
| * | ||
| * // Add item to the end with filters | ||
| * mutations.addItem(newUser, 'end', { status: 'active' }) | ||
| * | ||
| * // Add item to specific positions (restore from removed item map) | ||
| * mutations.addItem(newUser, removedItemMap) | ||
| * ``` | ||
| */ | ||
| addItem(newItem: T, position: 'start' | 'end' | RemovedItemMap = 'start', listFilters?: F) { | ||
| const isMultiQueryKeys = Array.isArray(position) && position?.length >= 1 | ||
| if (isMultiQueryKeys) { | ||
| for (const [queryKey, itemPosition] of position) { | ||
| const currentData = this.queryClient.getQueryData<InfiniteData<ListPaginationResponse<T>, PageParam>>(queryKey) | ||
| const updatedPages = [...currentData.pages] | ||
| if (itemPosition.pageIndex < updatedPages.length) { | ||
| const targetPage = [...updatedPages[itemPosition.pageIndex]] | ||
| const insertIndex = Math.min(itemPosition.itemIndex, targetPage.length) | ||
| targetPage.splice(insertIndex, 0, newItem) | ||
| updatedPages[itemPosition.pageIndex] = targetPage | ||
| } else { | ||
| const lastPageIndex = updatedPages.length - 1 | ||
| if (lastPageIndex >= 0) { | ||
| updatedPages[lastPageIndex] = [...updatedPages[lastPageIndex], newItem] | ||
| } else { | ||
| updatedPages.push([newItem]) | ||
| } | ||
| } | ||
| const newData = { | ||
| ...currentData, | ||
| pages: updatedPages | ||
| } | ||
| this.queryClient.setQueryData(queryKey, newData) | ||
| } | ||
| return | ||
| } | ||
| const queryKey = this.queryKeys.listKeyWithFilters(listFilters) | ||
| const currentData = this.queryClient.getQueryData<InfiniteData<ListPaginationResponse<T>, PageParam>>(queryKey) | ||
| if (!currentData) { | ||
| const newData = { | ||
| pageParams: [0], | ||
| pages: [[newItem]] | ||
| } | ||
| this.queryClient.setQueryData(queryKey, newData) | ||
| return | ||
| } | ||
| const updatedPages = [...currentData.pages] | ||
| if (position === 'start') { | ||
| if (updatedPages.length > 0) { | ||
| updatedPages[0] = [newItem, ...updatedPages[0]] | ||
| } else { | ||
| updatedPages.push([newItem]) | ||
| } | ||
| } else if (position === 'end') { | ||
| if (updatedPages.length > 0) { | ||
| const lastPageIndex = updatedPages.length - 1 | ||
| updatedPages[lastPageIndex] = [...updatedPages[lastPageIndex], newItem] | ||
| } else { | ||
| updatedPages.push([newItem]) | ||
| } | ||
| } | ||
| const newData = { | ||
| ...currentData, | ||
| pages: updatedPages | ||
| } | ||
| this.queryClient.setQueryData(queryKey, newData) | ||
| } | ||
| /** | ||
| * Removes an item from all cached list queries and returns the positions where it was found | ||
| * @param itemId - The ID of the item to remove | ||
| * @returns A RemovedItemMap containing the query keys and positions where the item was found, or null if not found | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const removedPositions = mutations.removeItem('user-123') | ||
| * | ||
| * // Later, restore the item to its original positions | ||
| * if (removedPositions) { | ||
| * mutations.addItem(restoredUser, removedPositions) | ||
| * } | ||
| * ``` | ||
| */ | ||
| removeItem(itemId: QueryItem['id']): RemovedItemMap | null { | ||
| this.queryKeys.removeRetrieveQueryData(itemId) | ||
| const listQueries = this.queryKeys.getAllListQueries() | ||
| const removedItemMap: RemovedItemMap = [] | ||
| for (const query of listQueries) { | ||
| const currentData = query.state?.data | ||
| const queryKey = query?.queryKey | ||
| if (!currentData) continue | ||
| let removedItemPosition: ItemPosition | null = null | ||
| for (let pageIndex = 0; pageIndex < currentData?.pages?.length; pageIndex++) { | ||
| const page = currentData.pages[pageIndex] | ||
| const itemIndex = page.findIndex((item: T) => item?.id === itemId) | ||
| if (itemIndex !== -1) { | ||
| removedItemPosition = { | ||
| pageIndex, | ||
| itemIndex, | ||
| } | ||
| break | ||
| } | ||
| } | ||
| if (!removedItemPosition) continue | ||
| removedItemMap.push([queryKey, removedItemPosition]) | ||
| const updatedPages = currentData.pages.map(page => | ||
| page.filter((item: T) => item?.id !== itemId) | ||
| ) | ||
| const filteredPages = updatedPages.filter(page => page?.length > 0) | ||
| const finalPages = filteredPages?.length > 0 ? filteredPages : [[]] | ||
| const newPageParams = currentData?.pageParams?.slice(0, finalPages?.length) | ||
| const newData = { | ||
| ...currentData, | ||
| pages: finalPages, | ||
| pageParams: newPageParams | ||
| } | ||
| this.queryClient.setQueryData(queryKey, newData) | ||
| } | ||
| return removedItemMap | ||
| } | ||
| /** | ||
| * Updates existing items in all cached list queries and individual retrieve queries | ||
| * @param data - Single item or array of items to update. Items can have tempId for temporary identification | ||
| * | ||
| * @description | ||
| * This method: | ||
| * - Finds items by their ID or tempId in all list queries | ||
| * - Updates the items only if the data has actually changed (uses deep equality check) | ||
| * - Updates both list cache and individual retrieve cache | ||
| * - Removes tempId from the final cached data | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Update single item | ||
| * mutations.updateItems({ id: 'user-123', name: 'Updated Name', tempId: 'temp-1' }) | ||
| * | ||
| * // Update multiple items | ||
| * mutations.updateItems([ | ||
| * { id: 'user-123', name: 'Updated Name' }, | ||
| * { id: 'user-456', status: 'active' } | ||
| * ]) | ||
| * ``` | ||
| */ | ||
| updateItems(data: WithTempId<T> | WithTempId<T>[]) { | ||
| const listQueries = this.queryKeys.getAllListQueries() | ||
| const dataArray = Array.isArray(data) ? data : [data] | ||
| for (const item of dataArray) { | ||
| const { tempId, ...updateData } = item | ||
| const cachedQueryKey = this.queryKeys.keys.retrieve(updateData.id) | ||
| const cachedItemData = this.queryKeys.getRetrieveData(updateData.id) | ||
| if (!deepEqual(cachedItemData, updateData)) { | ||
| this.queryClient.setQueryData(cachedQueryKey, updateData) | ||
| } | ||
| } | ||
| const dataMap = Object.fromEntries(dataArray.map(item => [item?.tempId ?? item?.id, item])) | ||
| for (const query of listQueries) { | ||
| const oldData = query.state?.data | ||
| const queryKey = query?.queryKey | ||
| if (!oldData) continue | ||
| let hasChanges = false | ||
| const updatedPages = oldData.pages.map(page => { | ||
| let pageChanged = false | ||
| const updatedPage = page.map((item) => { | ||
| if (dataMap?.[item?.id]) { | ||
| const { tempId, ...updateData } = dataMap?.[item?.id] | ||
| const needsUpdate = !deepEqual(item, updateData) | ||
| if (needsUpdate) { | ||
| pageChanged = true | ||
| hasChanges = true | ||
| return updateData | ||
| } | ||
| } | ||
| return item | ||
| }) | ||
| return pageChanged ? updatedPage : page | ||
| }) | ||
| if (hasChanges) { | ||
| this.queryClient.setQueryData(queryKey, { | ||
| ...oldData, | ||
| pages: updatedPages | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Factory function to create a new Mutations instance | ||
| * @template T - The query item type that extends QueryItem | ||
| * @template F - The filter type used for list queries | ||
| * @param queryKeys - The QueryKeys instance for managing query keys | ||
| * @param queryClient - The React Query client instance | ||
| * @param queryName - The name of the query used for identification | ||
| * @returns New Mutations instance | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const userQueryKeys = createQueryKeys<User, UserFilters>('users', queryClient) | ||
| * const userMutations = createMutations(userQueryKeys, queryClient, 'users') | ||
| * ``` | ||
| */ | ||
| export const createMutations = <T extends QueryItem, F>(queryKeys: QueryKeys<T, F>, queryClient: QueryClient, queryName: string) => { | ||
| return new Mutations<T, F>(queryKeys, queryClient, queryName) | ||
| } |
| import { waitFor } from '@codeleap/utils' | ||
| import { QueryClient, QueryKey, Query, hashKey, QueryCacheNotifyEvent, matchQuery, QueryOptions } from '@tanstack/react-query' | ||
| import { DynamicEnhancedQuery, EnhancedQuery, PollQueryOptions, PollingResult, QueryKeyBuilder } from './types' | ||
| export class QueryClientEnhanced { | ||
| constructor(public client: QueryClient) { } | ||
| listenToQuery(key: QueryKey, callback: (e: QueryCacheNotifyEvent) => void) { | ||
| const cache = this.client.getQueryCache() | ||
| const query = cache.find({ exact: true, queryKey: key }) | ||
| if (!query) { | ||
| return | ||
| } | ||
| const removeListener = cache.subscribe((e) => { | ||
| const matches = matchQuery({ exact: true, queryKey: key }, e.query) | ||
| if (matches) { | ||
| callback(e) | ||
| } | ||
| }) | ||
| return removeListener | ||
| } | ||
| async pollQuery<T, R>( | ||
| key: QueryKey, | ||
| options: PollQueryOptions<T, R>, | ||
| ) { | ||
| const { interval, callback, initialData, leading = false } = options | ||
| const cache = this.client.getQueryCache() | ||
| const initialQuery = cache.find({ exact: true, queryKey: key }) | ||
| if (!initialQuery) { | ||
| return Promise.reject(new Error('Query not found')) | ||
| } | ||
| let count = 0 | ||
| let result: PollingResult<R> = { | ||
| stop: false, | ||
| data: initialData, | ||
| } | ||
| while (!result?.stop) { | ||
| const shouldWait = count > 0 || leading | ||
| if (shouldWait) { | ||
| await waitFor(interval) | ||
| } | ||
| this.client.refetchQueries({ | ||
| exact: true, | ||
| queryKey: key, | ||
| }) | ||
| const newQuery = await this.waitForRefresh<T>(key) | ||
| const newResult = await callback(newQuery, count, result?.data) | ||
| count += 1 | ||
| result = newResult | ||
| } | ||
| return result?.data | ||
| } | ||
| queryProxy<T>(key: QueryKey) { | ||
| const getClient = () => this | ||
| return new Proxy<EnhancedQuery<T>>({} as EnhancedQuery<T>, { | ||
| get(target, p, receiver) { | ||
| const client = getClient() | ||
| // these don't need the actual query | ||
| switch (p) { | ||
| case 'key': | ||
| return key | ||
| case 'getData': | ||
| return () => { | ||
| return client.client.getQueryData<T>(key) | ||
| } | ||
| default: | ||
| break | ||
| } | ||
| const cache = client.client.getQueryCache() | ||
| const query = cache.find({ exact: true, queryKey: key }) | ||
| if (!query) { | ||
| console.warn(`Attempt to access property ${String(p)} on undefined query with key`, key) | ||
| return undefined | ||
| } | ||
| switch (p) { | ||
| case 'waitForRefresh': | ||
| return () => { | ||
| return client.waitForRefresh<T>(key) | ||
| } | ||
| case 'listen': | ||
| return (callback: (e: QueryCacheNotifyEvent) => void) => { | ||
| return client.listenToQuery(key, callback) | ||
| } | ||
| case 'ensureData': | ||
| return (options) => { | ||
| return client.client.ensureQueryData<T>({ | ||
| queryKey: key, | ||
| ...options | ||
| }) | ||
| } | ||
| case 'refresh': | ||
| return async () => { | ||
| client.client.refetchQueries({ | ||
| exact: true, | ||
| queryKey: key, | ||
| }) | ||
| const newQuery = await client.waitForRefresh<T>(key) | ||
| return newQuery.state.data | ||
| } | ||
| case 'poll': | ||
| return (options: PollQueryOptions<T, any>) => { | ||
| return client.pollQuery(key, options) | ||
| } | ||
| default: | ||
| return Reflect.get(query, p, receiver) | ||
| } | ||
| }, | ||
| }) | ||
| } | ||
| waitForRefresh<T>(key: QueryKey) { | ||
| const initialQuery = this.client.getQueryCache().find({ exact: true, queryKey: key }) | ||
| if (!initialQuery) { | ||
| return Promise.reject(new Error('Query not found')) | ||
| } | ||
| const updateTime = initialQuery.state.dataUpdatedAt | ||
| const errorTime = initialQuery.state.errorUpdatedAt | ||
| return new Promise<Query<T>>((resolve, reject) => { | ||
| const removeListener = this.listenToQuery(key, (e) => { | ||
| const query = e.query | ||
| const isNewer = query.state.dataUpdatedAt > updateTime || query.state.errorUpdatedAt > errorTime | ||
| const isIdle = query.state.fetchStatus === 'idle' | ||
| const isSuccess = query.state.status === 'success' | ||
| const isError = query.state.status === 'error' | ||
| const isResolved = isSuccess || isError | ||
| if (isNewer && isIdle && isResolved) { | ||
| if (isSuccess) { | ||
| resolve(query) | ||
| } else { | ||
| reject() | ||
| } | ||
| removeListener() | ||
| } | ||
| }) | ||
| }) | ||
| } | ||
| queryKey<Data>(k: QueryKey, options?: QueryOptions<Data>) { | ||
| if (options) { | ||
| this.client.setQueryDefaults(k, options) | ||
| const cache = this.client.getQueryCache() | ||
| const q = new Query({ | ||
| client: this.client, | ||
| queryKey: k, | ||
| queryHash: hashKey(k), | ||
| ...options, | ||
| }) | ||
| cache.add(q) | ||
| } | ||
| return this.queryProxy<Data>(k) | ||
| } | ||
| dynamicQueryKey<Data, BuilderArgs extends any[] = any[]>(k: QueryKeyBuilder<BuilderArgs>) { | ||
| const getClient = () => this | ||
| return new Proxy<DynamicEnhancedQuery<Data, BuilderArgs>>({} as DynamicEnhancedQuery<Data, BuilderArgs>, { | ||
| get(target, p, receiver) { | ||
| return (...params: BuilderArgs) => { | ||
| const key = k(...params) | ||
| const proxy = getClient().queryProxy<Data>(key) | ||
| return Reflect.get(proxy, p, receiver) | ||
| } | ||
| }, | ||
| }) | ||
| } | ||
| } |
| import { | ||
| QueryKey, | ||
| Query, | ||
| QueryCacheNotifyEvent, | ||
| EnsureQueryDataOptions, | ||
| } from '@tanstack/react-query' | ||
| export type QueryKeyBuilder<Args extends any[] = any[]> = (...args: Args) => QueryKey | ||
| export type PollingResult<T> = { | ||
| stop: boolean | ||
| data: T | ||
| } | ||
| export type PollingCallback<T, R> = (query: Query<T>, count: number, prev?: R) => Promise<PollingResult<R>> | ||
| export type PollQueryOptions<T, R> = { | ||
| interval: number | ||
| callback: PollingCallback<T, R> | ||
| leading?: boolean | ||
| initialData?: R | ||
| } | ||
| export interface EnhancedQuery<T> extends Query<T> { | ||
| waitForRefresh(): Promise<Query<T>> | ||
| listen(callback: (e: QueryCacheNotifyEvent) => void): () => void | ||
| refresh(): Promise<T> | ||
| poll<R>( | ||
| options: PollQueryOptions<T, R> | ||
| ): Promise<R> | ||
| getData(): T | ||
| ensureData(options?: Partial<EnsureQueryDataOptions<T, Error, T, QueryKey, never>>): Promise<T> | ||
| key: QueryKey | ||
| } | ||
| export type DynamicEnhancedQuery<T, BuilderArgs extends any[]> = { | ||
| [P in keyof EnhancedQuery<T>]: (...args: BuilderArgs) => EnhancedQuery<T>[P] | ||
| } |
| import { CancelOptions, InfiniteData, InvalidateOptions, InvalidateQueryFilters, Query, QueryFilters, QueryKey, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query' | ||
| import { useMemo } from 'react' | ||
| import { TypeGuards } from '@codeleap/types' | ||
| import { ListPaginationResponse, ListSelector, PageParam, QueryClient, QueryItem } from '../types' | ||
| import deepEqual from 'fast-deep-equal' | ||
| /** | ||
| * Class for managing React Query keys and operations for a specific query type | ||
| * @template T - The query item type that extends QueryItem | ||
| * @template F - The filter type used for list queries | ||
| */ | ||
| export class QueryKeys<T extends QueryItem, F> { | ||
| /** | ||
| * Creates a new QueryKeys instance | ||
| * @param queryName - The name of the query used as base for all keys | ||
| * @param queryClient - The React Query client instance | ||
| */ | ||
| constructor( | ||
| private queryName: string, | ||
| private queryClient: QueryClient | ||
| ) { } | ||
| /** | ||
| * Gets the base query keys for different operations | ||
| * @returns Object containing base query keys for list, retrieve, create, update, and delete operations | ||
| */ | ||
| get keys() { | ||
| return { | ||
| // queries | ||
| list: [this.queryName, 'list'] as QueryKey, | ||
| retrieve: (id: QueryItem['id']) => [this.queryName, 'retrieve', id] as QueryKey, | ||
| // mutations | ||
| create: [this.queryName, 'create'] as QueryKey, | ||
| update: [this.queryName, 'update'] as QueryKey, | ||
| delete: [this.queryName, 'delete'] as QueryKey, | ||
| } | ||
| } | ||
| /** | ||
| * Generates a list query key with optional filters | ||
| * @param filters - Optional filters to include in the query key | ||
| * @returns Query key array with or without filters | ||
| */ | ||
| listKeyWithFilters(filters?: F): QueryKey { | ||
| const hasValidFilters = filters != null && ( | ||
| typeof filters !== 'object' | ||
| ? Boolean(filters) | ||
| : Object.values(filters).some(value => | ||
| value != null && (typeof value !== 'string' || value.trim() !== '') | ||
| ) | ||
| ) | ||
| return hasValidFilters ? [...this.keys.list, filters] : this.keys.list | ||
| } | ||
| /** | ||
| * React hook that returns a memoized list query key with filters | ||
| * @param filters - Optional filters to include in the query key | ||
| * @returns Memoized query key array | ||
| */ | ||
| useListKeyWithFilters(filters?: F) { | ||
| return useMemo(() => { | ||
| return this.listKeyWithFilters(filters) | ||
| }, [filters]) | ||
| } | ||
| /** | ||
| * React hook that returns a memoized retrieve query key | ||
| * @param id - The ID of the item to retrieve | ||
| * @returns Memoized query key array for retrieve operation | ||
| */ | ||
| useRetrieveKey(id: QueryItem['id']) { | ||
| return useMemo(() => { | ||
| return this.keys.retrieve(id) | ||
| }, [id]) | ||
| } | ||
| /** | ||
| * Predicate function to check if a query belongs to this query name (all operations) | ||
| * @private | ||
| * @param queryName - The query name to match against | ||
| * @param query - The query object to check | ||
| * @returns True if the query matches the query name | ||
| */ | ||
| private predicateQueryKeyAll(queryName: string, query: Query<unknown, Error, unknown, QueryKey>) { | ||
| const queryKey = query?.queryKey?.join('/') | ||
| return queryKey?.includes(queryName) | ||
| } | ||
| /** | ||
| * Predicate function to check if a query is a list query for this query name | ||
| * @private | ||
| * @param queryName - The query name to match against | ||
| * @param query - The query object to check | ||
| * @param toIgnoreQueryKeys - Query keys to ignore in the matching | ||
| * @returns True if the query is a list query and not in the ignore list | ||
| */ | ||
| private predicateQueryKeyList(queryName: string, query: Query<unknown, Error, unknown, QueryKey>, toIgnoreQueryKeys?: QueryKey | QueryKey[]) { | ||
| const queryKey = query?.queryKey?.join('/') | ||
| if (!TypeGuards.isNil(toIgnoreQueryKeys)) { | ||
| const ignoreQueryKeys = Array.isArray(toIgnoreQueryKeys?.[0]) ? toIgnoreQueryKeys : [toIgnoreQueryKeys] | ||
| if (ignoreQueryKeys.some(key => deepEqual(query?.queryKey, key))) { | ||
| return false | ||
| } | ||
| } | ||
| const isListQueryKey = queryKey?.includes(queryName) && queryKey?.includes('list') | ||
| return isListQueryKey | ||
| } | ||
| /** | ||
| * Invalidates all queries for this query name | ||
| * @param filters - Optional filters to apply to the invalidation | ||
| * @param options - Optional invalidation options | ||
| * @returns Promise that resolves when invalidation is complete | ||
| */ | ||
| async invalidateAll(filters?: InvalidateQueryFilters<QueryKey>, options?: InvalidateOptions) { | ||
| return this.queryClient.invalidateQueries({ | ||
| ...filters, | ||
| predicate: (query) => this.predicateQueryKeyAll(this.queryName, query), | ||
| }, options) | ||
| } | ||
| /** | ||
| * Invalidates list queries, optionally with specific filters | ||
| * @param listFilters - Optional filters to target specific list queries | ||
| * @param ignoreQueryKeys - Query keys to ignore during invalidation | ||
| * @param filters - Optional filters to apply to the invalidation | ||
| * @param options - Optional invalidation options | ||
| * @returns Promise that resolves when invalidation is complete | ||
| */ | ||
| async invalidateList(listFilters?: F, ignoreQueryKeys?: QueryKey | QueryKey[], filters?: InvalidateQueryFilters<QueryKey>, options?: InvalidateOptions) { | ||
| if (!!listFilters) { | ||
| const queryKey = this.listKeyWithFilters(listFilters) | ||
| return this.queryClient.invalidateQueries({ ...filters, queryKey }, options) | ||
| } | ||
| return this.queryClient.invalidateQueries({ | ||
| ...filters, | ||
| predicate: (query) => this.predicateQueryKeyList(this.queryName, query, ignoreQueryKeys), | ||
| }, options) | ||
| } | ||
| /** | ||
| * Invalidates a specific retrieve query by ID | ||
| * @param id - The ID of the item to invalidate | ||
| * @param filters - Optional filters to apply to the invalidation | ||
| * @param options - Optional invalidation options | ||
| * @returns Promise that resolves when invalidation is complete | ||
| */ | ||
| async invalidateRetrieve(id: QueryItem['id'], filters?: InvalidateQueryFilters<QueryKey>, options?: InvalidateOptions) { | ||
| const queryKey = this.keys.retrieve(id) | ||
| return this.queryClient.invalidateQueries({ | ||
| ...filters, | ||
| queryKey, | ||
| }, options) | ||
| } | ||
| /** | ||
| * Refetches all queries for this query name | ||
| * @param filters - Optional filters to apply to the refetch | ||
| * @param options - Optional refetch options | ||
| * @returns Promise that resolves when refetch is complete | ||
| */ | ||
| async refetchAll(filters?: RefetchQueryFilters<QueryKey>, options?: RefetchOptions) { | ||
| return this.queryClient.refetchQueries({ | ||
| ...filters, | ||
| predicate: (query) => this.predicateQueryKeyAll(this.queryName, query), | ||
| }, options) | ||
| } | ||
| /** | ||
| * Refetches list queries, optionally with specific filters | ||
| * @param listFilters - Optional filters to target specific list queries | ||
| * @param ignoreQueryKeys - Query keys to ignore during refetch | ||
| * @param filters - Optional filters to apply to the refetch | ||
| * @param options - Optional refetch options | ||
| * @returns Promise that resolves when refetch is complete | ||
| */ | ||
| async refetchList(listFilters?: F, ignoreQueryKeys?: QueryKey | QueryKey[], filters?: RefetchQueryFilters<QueryKey>, options?: RefetchOptions) { | ||
| if (!!listFilters) { | ||
| const queryKey = this.listKeyWithFilters(listFilters) | ||
| return this.queryClient.refetchQueries({ ...filters, queryKey }, options) | ||
| } | ||
| return this.queryClient.refetchQueries({ | ||
| ...filters, | ||
| predicate: (query) => this.predicateQueryKeyList(this.queryName, query, ignoreQueryKeys), | ||
| }, options) | ||
| } | ||
| /** | ||
| * Refetches a specific retrieve query by ID | ||
| * @param id - The ID of the item to refetch | ||
| * @param filters - Optional filters to apply to the refetch | ||
| * @param options - Optional refetch options | ||
| * @returns Promise that resolves when refetch is complete | ||
| */ | ||
| async refetchRetrieve(id: QueryItem['id'], filters?: RefetchQueryFilters<QueryKey>, options?: RefetchOptions) { | ||
| const queryKey = this.keys.retrieve(id) | ||
| return this.queryClient.refetchQueries({ | ||
| ...filters, | ||
| queryKey, | ||
| }, options) | ||
| } | ||
| /** | ||
| * Removes a specific retrieve query data from the cache | ||
| * @param id - The ID of the item to remove from cache | ||
| * @param filters - Optional filters to apply to the removal | ||
| * @returns Promise that resolves when removal is complete | ||
| */ | ||
| async removeRetrieveQueryData(id: QueryItem['id'], filters?: QueryFilters<QueryKey>) { | ||
| const queryKey = this.keys.retrieve(id) | ||
| return this.queryClient.removeQueries({ | ||
| ...filters, | ||
| queryKey, | ||
| }) | ||
| } | ||
| /** | ||
| * Cancels list queries that are currently in flight | ||
| * @param listFilters - Optional filters to target specific list queries | ||
| * @param ignoreQueryKeys - Query keys to ignore during cancellation | ||
| * @param filters - Optional filters to apply to the cancellation | ||
| * @param options - Optional cancellation options | ||
| * @returns Promise that resolves when cancellation is complete | ||
| */ | ||
| async cancelListQueries(listFilters?: F, ignoreQueryKeys?: QueryKey | QueryKey[], filters?: QueryFilters<QueryKey>, options?: CancelOptions) { | ||
| if (!!listFilters) { | ||
| const queryKey = this.listKeyWithFilters(listFilters) | ||
| return this.queryClient.cancelQueries({ ...filters, queryKey }, options) | ||
| } | ||
| return this.queryClient.cancelQueries({ | ||
| ...filters, | ||
| predicate: (query) => this.predicateQueryKeyList(this.queryName, query, ignoreQueryKeys), | ||
| }, options) | ||
| } | ||
| /** | ||
| * Gets list data from the cache, including both items array and item map | ||
| * @param filters - Optional filters to target specific list data | ||
| * @returns Object containing items array and itemMap (keyed by ID) | ||
| */ | ||
| getListData(filters?: F) { | ||
| const queryKey = this.listKeyWithFilters(filters) | ||
| const data = this.queryClient.getQueryData<InfiniteData<ListPaginationResponse<T>, PageParam>>(queryKey) | ||
| const items = (data?.pages ?? []).flat() | ||
| const itemMap = items.reduce((acc, item) => { | ||
| acc[item?.id] = item | ||
| return acc | ||
| }, {} as Record<QueryItem['id'], T>) | ||
| return { | ||
| items, | ||
| itemMap, | ||
| } | ||
| } | ||
| /** | ||
| * Gets retrieve data from cache, with fallback to list data | ||
| * @param id - The ID of the item to retrieve | ||
| * @param onlyQueryData - If true, only returns data from the specific retrieve query, not from list data | ||
| * @returns The item data or undefined if not found | ||
| */ | ||
| getRetrieveData(id: QueryItem['id'], onlyQueryData = false): T | undefined { | ||
| if (TypeGuards.isNil(id)) return undefined | ||
| const queryKey = this.keys.retrieve(id) | ||
| const queryData = this.queryClient.getQueryData<T>(queryKey) | ||
| if (!queryData?.id && !onlyQueryData) { | ||
| const { itemMap } = this.getListData() | ||
| return itemMap?.[id] | ||
| } | ||
| return queryData | ||
| } | ||
| /** | ||
| * Gets all list queries from the query cache | ||
| * @returns Array of list queries for this query name | ||
| */ | ||
| getAllListQueries() { | ||
| const queries = this.queryClient.getQueryCache().findAll({ | ||
| predicate: (query) => this.predicateQueryKeyList(this.queryName, query), | ||
| }) | ||
| return queries as Query<ListPaginationResponse<T>, Error, Omit<ListSelector<T>, 'allItems'>, QueryKey>[] | ||
| } | ||
| } | ||
| /** | ||
| * Factory function to create a new QueryKeys instance | ||
| * @template T - The query item type that extends QueryItem | ||
| * @template F - The filter type used for list queries | ||
| * @param name - The name of the query used as base for all keys | ||
| * @param queryClient - The React Query client instance | ||
| * @returns New QueryKeys instance | ||
| */ | ||
| export const createQueryKeys = <T extends QueryItem, F>(name: string, queryClient: QueryClient) => { | ||
| return new QueryKeys<T, F>(name, queryClient) | ||
| } |
| import { FetchQueryOptions, InfiniteData, MutationFunctionContext, QueryKey, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' | ||
| import { useCallback } from 'react' | ||
| import { createQueryKeys, QueryKeys } from './QueryKeys' | ||
| import { createMutations, Mutations } from './Mutations' | ||
| import { CreateMutationCtx, CreateMutationOptions, ListPaginationResponse, ListQueryOptions, PageParam, QueryItem, QueryManagerOptions, RetrieveQueryOptions, UpdateMutationCtx, UpdateMutationOptions, DeleteMutationCtx, DeleteMutationOptions } from '../types' | ||
| import { generateTempId } from '../utils' | ||
| import { TypeGuards } from '@codeleap/types' | ||
| /** | ||
| * Comprehensive query manager class that provides hooks and utilities for managing CRUD operations with React Query | ||
| * @template T - The query item type that extends QueryItem | ||
| * @template F - The filter type used for list queries | ||
| * | ||
| * @description | ||
| * QueryManager provides a complete solution for managing list and individual item queries with: | ||
| * - Infinite scroll pagination for lists | ||
| * - Optimistic updates for create, update, and delete operations | ||
| * - Automatic cache synchronization between list and individual queries | ||
| * - Built-in error handling and rollback mechanisms | ||
| */ | ||
| export class QueryManager<T extends QueryItem, F> { | ||
| /** | ||
| * Creates a new QueryManager instance | ||
| * @param options - Configuration options for the query manager | ||
| */ | ||
| constructor(private options: QueryManagerOptions<T, F>) { | ||
| this.queryKeys = createQueryKeys<T, F>(options.name, options.queryClient) | ||
| this.mutations = createMutations<T, F>(this.queryKeys, options.queryClient, options.name) | ||
| } | ||
| /** | ||
| * Gets the name of this query manager | ||
| * @returns The query name used for identification | ||
| */ | ||
| get name() { | ||
| return this.options.name | ||
| } | ||
| /** | ||
| * Gets the configured CRUD functions | ||
| * @returns Object containing all configured function handlers | ||
| */ | ||
| get functions() { | ||
| return { | ||
| list: this.options.listFn, | ||
| retrieve: this.options.retrieveFn, | ||
| create: this.options.createFn, | ||
| update: this.options.updateFn, | ||
| delete: this.options.deleteFn, | ||
| } | ||
| } | ||
| /** QueryKeys instance for managing query keys and cache operations */ | ||
| queryKeys: QueryKeys<T, F> | ||
| /** Mutations instance for managing cache mutations */ | ||
| mutations: Mutations<T, F> | ||
| /** | ||
| * React hook for infinite scroll list queries with pagination | ||
| * @param options - Configuration options for the list query | ||
| * @returns Object containing items array, query key, and query object | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const { items, query } = queryManager.useList({ | ||
| * filters: { status: 'active' }, | ||
| * limit: 20 | ||
| * }) | ||
| * ``` | ||
| */ | ||
| useList(options: ListQueryOptions<T, F> = {}) { | ||
| const { | ||
| limit, | ||
| filters, | ||
| ...queryOptions | ||
| } = options | ||
| const listLimit = limit ?? this.options?.listLimit ?? 10 | ||
| const queryKey = this.queryKeys.useListKeyWithFilters(filters) | ||
| const onSelect = useCallback((data: InfiniteData<ListPaginationResponse<T>, PageParam>) => { | ||
| const pages = data?.pages ?? [] | ||
| return { | ||
| pageParams: data?.pageParams, | ||
| pages, | ||
| allItems: pages.flat(), | ||
| } | ||
| }, []) | ||
| const query = useInfiniteQuery({ | ||
| queryKey, | ||
| queryFn: async (query) => { | ||
| const listOffset = query?.pageParam ?? 0 | ||
| return this.options?.listFn?.(listLimit, listOffset, filters) | ||
| }, | ||
| initialPageParam: 0, | ||
| getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { | ||
| if (!lastPage?.length || lastPage.length < listLimit) { | ||
| return undefined | ||
| } | ||
| return (lastPageParam ?? 0) + lastPage.length | ||
| }, | ||
| getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { | ||
| if ((firstPageParam ?? 0) <= 0) { | ||
| return undefined | ||
| } | ||
| return (firstPageParam ?? 0) - listLimit | ||
| }, | ||
| select(data) { | ||
| return onSelect(data) | ||
| }, | ||
| ...queryOptions, | ||
| }) | ||
| const useListEffect = (this.options.useListEffect ?? ((args: any) => null)) | ||
| useListEffect(query) | ||
| const items = query.data?.allItems ?? [] | ||
| return { | ||
| items, | ||
| queryKey, | ||
| query, | ||
| } | ||
| } | ||
| /** | ||
| * React hook for retrieving a single item by ID | ||
| * @param id - The ID of the item to retrieve | ||
| * @param options - Configuration options for the retrieve query | ||
| * @returns Object containing the item data, query key, and query object | ||
| * | ||
| * @description | ||
| * This hook automatically: | ||
| * - Uses list cache as initial data if available | ||
| * - Updates list cache when retrieve data changes | ||
| * - Synchronizes data between list and individual caches | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const { item, query } = queryManager.useRetrieve('user-123', { | ||
| * enabled: !!userId | ||
| * }) | ||
| * ``` | ||
| */ | ||
| useRetrieve(id: T['id'], options: RetrieveQueryOptions<T> = {}) { | ||
| const { | ||
| select, | ||
| ...queryOptions | ||
| } = options | ||
| const onSelect = useCallback((data: T) => { | ||
| this.mutations.updateItems(data) | ||
| if (select) select?.(data) | ||
| return data | ||
| }, [select]) | ||
| const getInitialData = useCallback(() => { | ||
| const { itemMap } = this.queryKeys.getListData() | ||
| return itemMap?.[id] | ||
| }, [id]) | ||
| const queryKey = this.queryKeys.useRetrieveKey(id) | ||
| const query = useQuery({ | ||
| initialData: getInitialData, | ||
| ...queryOptions, | ||
| queryKey, | ||
| queryFn: () => { | ||
| return this.options.retrieveFn(id) | ||
| }, | ||
| select: (data) => { | ||
| return onSelect(data) | ||
| }, | ||
| }) | ||
| return { | ||
| item: query.data, | ||
| queryKey, | ||
| query, | ||
| } | ||
| } | ||
| /** | ||
| * React hook for creating new items with optimistic updates | ||
| * @param options - Configuration options for the create mutation | ||
| * @returns React Query mutation object for create operations | ||
| * | ||
| * @description | ||
| * This hook supports optimistic updates by: | ||
| * - Immediately adding a temporary item to the cache | ||
| * - Rolling back on error by removing the temporary item | ||
| * - Replacing temporary item with real data on success | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const createMutation = queryManager.useCreate({ | ||
| * optimistic: true, | ||
| * appendTo: 'start', | ||
| * listFilters: { status: 'active' } | ||
| * }) | ||
| * | ||
| * // Usage | ||
| * createMutation.mutate({ name: 'New User', email: 'user@example.com' }) | ||
| * ``` | ||
| */ | ||
| useCreate(options: CreateMutationOptions<T, F> = {}) { | ||
| const { | ||
| optimistic, | ||
| listFilters, | ||
| appendTo, | ||
| onMutate: providedOnMutate, | ||
| onError: providedOnError, | ||
| onSuccess: providedOnSuccess, | ||
| ...mutationOptions | ||
| } = options | ||
| const onMutate = useCallback(async (data: Partial<T>, context: MutationFunctionContext) => { | ||
| if (providedOnMutate) providedOnMutate?.(data, context) | ||
| if (optimistic) { | ||
| await this.queryKeys.cancelListQueries(listFilters) | ||
| const tempId = generateTempId() | ||
| const newItem = { ...data, id: tempId } as T | ||
| this.mutations.addItem(newItem, appendTo, listFilters) | ||
| return { | ||
| tempId, | ||
| } | ||
| } | ||
| }, [providedOnMutate, optimistic, listFilters]) | ||
| const onError = useCallback((error: Error, variables: Partial<T>, onMutateResult: CreateMutationCtx, context: MutationFunctionContext) => { | ||
| if (providedOnError) providedOnError?.(error, variables, onMutateResult, context) | ||
| if (!TypeGuards.isNil(onMutateResult?.tempId)) { | ||
| this.mutations.removeItem(onMutateResult?.tempId) | ||
| } | ||
| }, [providedOnError]) | ||
| const onSuccess = useCallback((data: T, variables: Partial<T>, onMutateResult: CreateMutationCtx, context: MutationFunctionContext) => { | ||
| if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context) | ||
| if (TypeGuards.isNil(onMutateResult?.tempId)) { | ||
| this.mutations.addItem(data, appendTo, listFilters) | ||
| } else { | ||
| this.mutations.updateItems({ ...data, tempId: onMutateResult?.tempId }) | ||
| } | ||
| }, [providedOnSuccess, listFilters]) | ||
| const mutation = useMutation<T, Error, Partial<T>, CreateMutationCtx>({ | ||
| ...mutationOptions, | ||
| mutationKey: this.queryKeys.keys.create, | ||
| mutationFn: (data: Partial<T>) => { | ||
| return this.options.createFn(data) | ||
| }, | ||
| onMutate, | ||
| onError, | ||
| onSuccess, | ||
| }) | ||
| return mutation | ||
| } | ||
| /** | ||
| * React hook for updating existing items with optimistic updates | ||
| * @param options - Configuration options for the update mutation | ||
| * @returns React Query mutation object for update operations | ||
| * | ||
| * @description | ||
| * This hook supports optimistic updates by: | ||
| * - Immediately updating the item in cache with new data | ||
| * - Rolling back to previous data on error | ||
| * - Confirming updates with server response on success | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const updateMutation = queryManager.useUpdate({ | ||
| * optimistic: true, | ||
| * onSuccess: (data) => console.log('Updated:', data) | ||
| * }) | ||
| * | ||
| * // Usage | ||
| * updateMutation.mutate({ id: 'user-123', name: 'Updated Name' }) | ||
| * ``` | ||
| */ | ||
| useUpdate(options: UpdateMutationOptions<T, F> = {}) { | ||
| const { | ||
| optimistic, | ||
| onMutate: providedOnMutate, | ||
| onError: providedOnError, | ||
| onSuccess: providedOnSuccess, | ||
| ...mutationOptions | ||
| } = options | ||
| const onMutate = useCallback(async (data: Partial<T>, context: MutationFunctionContext) => { | ||
| if (providedOnMutate) providedOnMutate?.(data, context) | ||
| if (optimistic) { | ||
| const previousItem = this.queryKeys.getRetrieveData(data?.id) | ||
| if (!previousItem) return | ||
| const optimisticItem = { | ||
| ...previousItem, | ||
| ...data, | ||
| } as T | ||
| this.mutations.updateItems(optimisticItem) | ||
| return { | ||
| previousItem, | ||
| optimisticItem, | ||
| } | ||
| } | ||
| }, [providedOnMutate, optimistic]) | ||
| const onError = useCallback((error: Error, variables: Partial<T>, onMutateResult: UpdateMutationCtx<T>, context: MutationFunctionContext) => { | ||
| if (providedOnError) providedOnError?.(error, variables, onMutateResult, context) | ||
| if (!TypeGuards.isNil(onMutateResult?.previousItem?.id)) { | ||
| this.mutations.updateItems(onMutateResult?.previousItem) | ||
| } | ||
| }, [providedOnError]) | ||
| const onSuccess = useCallback((data: T, variables: Partial<T>, onMutateResult: UpdateMutationCtx<T>, context: MutationFunctionContext) => { | ||
| if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context) | ||
| this.mutations.updateItems(data) | ||
| }, [providedOnSuccess]) | ||
| const mutation = useMutation<T, Error, Partial<T>, UpdateMutationCtx<T>>({ | ||
| ...mutationOptions, | ||
| mutationKey: this.queryKeys.keys.update, | ||
| mutationFn: (data: Partial<T>) => { | ||
| return this.options.updateFn(data) | ||
| }, | ||
| onMutate, | ||
| onError, | ||
| onSuccess, | ||
| }) | ||
| return mutation | ||
| } | ||
| /** | ||
| * React hook for deleting items with optimistic updates | ||
| * @param options - Configuration options for the delete mutation | ||
| * @returns React Query mutation object for delete operations | ||
| * | ||
| * @description | ||
| * This hook supports optimistic updates by: | ||
| * - Immediately removing the item from cache | ||
| * - Storing the removal positions for potential rollback | ||
| * - Restoring the item to original positions on error | ||
| * - Confirming deletion on success | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const deleteMutation = queryManager.useDelete({ | ||
| * optimistic: true, | ||
| * onSuccess: () => console.log('Item deleted successfully') | ||
| * }) | ||
| * | ||
| * // Usage | ||
| * deleteMutation.mutate('user-123') | ||
| * ``` | ||
| */ | ||
| useDelete(options: DeleteMutationOptions<T, F> = {}) { | ||
| const { | ||
| optimistic, | ||
| onMutate: providedOnMutate, | ||
| onError: providedOnError, | ||
| onSuccess: providedOnSuccess, | ||
| ...mutationOptions | ||
| } = options | ||
| const onMutate = useCallback(async (id: T['id'], context: MutationFunctionContext) => { | ||
| if (providedOnMutate) providedOnMutate?.(id, context) | ||
| if (optimistic) { | ||
| const previousItem = this.queryKeys.getRetrieveData(id) | ||
| if (!previousItem) return | ||
| const removedAt = this.mutations.removeItem(id) | ||
| return { | ||
| previousItem, | ||
| removedAt, | ||
| } | ||
| } | ||
| }, [providedOnMutate, optimistic]) | ||
| const onError = useCallback((error: Error, variables: T['id'], onMutateResult: DeleteMutationCtx<T>, context: MutationFunctionContext) => { | ||
| if (providedOnError) providedOnError?.(error, variables, onMutateResult, context) | ||
| if (!TypeGuards.isNil(onMutateResult?.previousItem?.id)) { | ||
| this.mutations.addItem( | ||
| onMutateResult?.previousItem, | ||
| onMutateResult?.removedAt, | ||
| ) | ||
| } | ||
| }, [providedOnError]) | ||
| const onSuccess = useCallback((data: T['id'], variables: T['id'], onMutateResult: DeleteMutationCtx<T>, context: MutationFunctionContext) => { | ||
| if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context) | ||
| if (TypeGuards.isNil(onMutateResult?.previousItem?.id)) { | ||
| this.mutations.removeItem(data) | ||
| } | ||
| }, [providedOnSuccess]) | ||
| const mutation = useMutation<unknown, Error, T['id'], DeleteMutationCtx<T>>({ | ||
| ...mutationOptions, | ||
| mutationKey: this.queryKeys.keys.delete, | ||
| mutationFn: async (id: QueryItem['id']) => { | ||
| return this.options.deleteFn(id) | ||
| }, | ||
| onMutate, | ||
| onError, | ||
| onSuccess, | ||
| }) | ||
| return mutation | ||
| } | ||
| /** | ||
| * Prefetches a single item by ID for improved performance | ||
| * @param id - The ID of the item to prefetch | ||
| * @param options - Prefetch options compatible with React Query | ||
| * @returns Promise that resolves when prefetch is complete | ||
| * | ||
| * @description | ||
| * Use this method to preload data that users are likely to need soon, | ||
| * such as when hovering over links or preparing for navigation. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Prefetch on hover | ||
| * const handleHover = (userId: string) => { | ||
| * queryManager.prefetchRetrieve(userId, { staleTime: 5 * 60 * 1000 }) | ||
| * } | ||
| * ``` | ||
| */ | ||
| prefetchRetrieve(id: T['id'], options: Omit<FetchQueryOptions<T, Error, T, QueryKey, never>, 'queryKey' | 'queryFn'> = {}) { | ||
| return this.options.queryClient.prefetchQuery({ | ||
| ...options, | ||
| queryKey: this.queryKeys.keys.retrieve(id), | ||
| queryFn: () => this.options.retrieveFn(id), | ||
| }) | ||
| } | ||
| } |
| import { useMutation, useQuery, UseQueryOptions, UseMutationOptions, QueryKey, FetchQueryOptions } from '@tanstack/react-query' | ||
| import { QueryOperationsOptions, MutationFn, QueryFn, InferMutationParams, InferMutationReturn, InferQueryParams, InferQueryReturn } from './types' | ||
| /** | ||
| * Builder class for creating type-safe query and mutation operations | ||
| * @template TMutations - Record type containing all registered mutation functions | ||
| * @template TQueries - Record type containing all registered query functions | ||
| * | ||
| * @description | ||
| * QueryOperations provides a fluent interface for building collections of queries and mutations | ||
| * with full type safety. It acts as a centralized registry for all data operations and provides | ||
| * corresponding React hooks that are automatically typed based on the registered functions. | ||
| * | ||
| * Key features: | ||
| * - Fluent builder pattern for registering operations | ||
| * - Automatic type inference for parameters and return types | ||
| * - Type-safe React hooks generation | ||
| * - Immutable operation registration (returns new instances) | ||
| */ | ||
| export class QueryOperations< | ||
| TMutations, | ||
| TQueries, | ||
| > { | ||
| /** | ||
| * Creates a new QueryOperations instance | ||
| * @param _options - Configuration options including QueryClient | ||
| * @param _mutations - Record of registered mutation functions (internal) | ||
| * @param _queries - Record of registered query functions (internal) | ||
| */ | ||
| constructor( | ||
| private _options: QueryOperationsOptions, | ||
| private _mutations: TMutations = {} as TMutations, | ||
| private _queries: TQueries = {} as TQueries | ||
| ) { } | ||
| /** | ||
| * Gets all registered mutation functions | ||
| * @returns Readonly record of mutation functions | ||
| */ | ||
| get mutations(): Readonly<TMutations> { | ||
| return this._mutations | ||
| } | ||
| /** | ||
| * Gets all registered query functions | ||
| * @returns Readonly record of query functions | ||
| */ | ||
| get queries(): Readonly<TQueries> { | ||
| return this._queries | ||
| } | ||
| /** | ||
| * Registers a new mutation function | ||
| * @template K - The name/key for the mutation | ||
| * @template T - The input data type for the mutation | ||
| * @template R - The return data type for the mutation | ||
| * @param name - Unique name identifier for the mutation | ||
| * @param fn - The mutation function that performs the operation | ||
| * @returns New QueryOperations instance with the mutation added | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const operations = createQueryOperations({ queryClient }) | ||
| * .mutation('createUser', async (userData: CreateUserData) => { | ||
| * return api.post('/users', userData) | ||
| * }) | ||
| * .mutation('updateUser', async (userData: UpdateUserData) => { | ||
| * return api.put(`/users/${userData.id}`, userData) | ||
| * }) | ||
| * ``` | ||
| */ | ||
| mutation<K extends string, T = any, R = any>( | ||
| name: K, | ||
| fn: MutationFn<T, R> | ||
| ): QueryOperations<TMutations & Record<K, MutationFn<T, R>>, TQueries> { | ||
| return new QueryOperations( | ||
| this._options, | ||
| { ...this._mutations, [name]: fn } as TMutations & Record<K, MutationFn<T, R>>, | ||
| this._queries | ||
| ) | ||
| } | ||
| /** | ||
| * Registers a new query function | ||
| * @template K - The name/key for the query | ||
| * @template T - The parameters type for the query | ||
| * @template R - The return data type for the query | ||
| * @param name - Unique name identifier for the query | ||
| * @param fn - The query function that fetches the data | ||
| * @returns New QueryOperations instance with the query added | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const operations = createQueryOperations({ queryClient }) | ||
| * .query('getUser', async (userId: string) => { | ||
| * return api.get(`/users/${userId}`) | ||
| * }) | ||
| * .query('getUsers', async (filters?: UserFilters) => { | ||
| * return api.get('/users', { params: filters }) | ||
| * }) | ||
| * ``` | ||
| */ | ||
| query<K extends string, T = any, R = any>( | ||
| name: K, | ||
| fn: QueryFn<T, R> | ||
| ): QueryOperations<TMutations, TQueries & Record<K, QueryFn<T, R>>> { | ||
| return new QueryOperations( | ||
| this._options, | ||
| this._mutations, | ||
| { ...this._queries, [name]: fn } as TQueries & Record<K, QueryFn<T, R>> | ||
| ) | ||
| } | ||
| /** | ||
| * React hook for executing mutations with full type safety | ||
| * @template K - The mutation key type | ||
| * @param mutationKey - The name of the registered mutation to use | ||
| * @param options - React Query mutation options (excluding mutationFn and mutationKey) | ||
| * @returns React Query mutation object with inferred types | ||
| * | ||
| * @description | ||
| * This hook automatically provides type-safe parameters and return types based on the | ||
| * registered mutation function. It handles error cases and provides proper TypeScript | ||
| * inference for the mutation data and variables. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const createUserMutation = operations.useMutation('createUser', { | ||
| * onSuccess: (user) => { | ||
| * // 'user' is automatically typed as the return type of createUser | ||
| * console.log('Created user:', user.id) | ||
| * } | ||
| * }) | ||
| * | ||
| * // Usage - parameters are type-checked | ||
| * createUserMutation.mutate({ name: 'John', email: 'john@example.com' }) | ||
| * ``` | ||
| */ | ||
| useMutation<K extends keyof TMutations>( | ||
| mutationKey: K, | ||
| options?: Omit< | ||
| UseMutationOptions< | ||
| InferMutationReturn<TMutations[K]>, | ||
| Error, | ||
| InferMutationParams<TMutations[K]> | ||
| >, | ||
| 'mutationFn' | ||
| > | ||
| ) { | ||
| const mutationFn = this._mutations[mutationKey] as MutationFn | ||
| type TData = InferMutationReturn<TMutations[K]> | ||
| type TVariables = InferMutationParams<TMutations[K]> | ||
| return useMutation<TData, Error, TVariables>({ | ||
| mutationKey: this.getMutationKey(mutationKey), | ||
| mutationFn: async (data: TVariables): Promise<TData> => { | ||
| if (!mutationFn) { | ||
| throw new Error(`Mutation "${String(mutationKey)}" not found`) | ||
| } | ||
| return mutationFn(data) as Promise<TData> | ||
| }, | ||
| ...options | ||
| }) | ||
| } | ||
| /** | ||
| * React hook for executing queries with full type safety | ||
| * @template K - The query key type | ||
| * @param queryKey - The name of the registered query to use | ||
| * @param params - Parameters to pass to the query function (optional if query doesn't require params) | ||
| * @param options - React Query options (excluding queryKey and queryFn) | ||
| * @returns React Query query object with inferred types | ||
| * | ||
| * @description | ||
| * This hook automatically provides type-safe parameters and return types based on the | ||
| * registered query function. It generates appropriate query keys and handles parameter | ||
| * validation. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Query with parameters | ||
| * const userQuery = operations.useQuery('getUser', 'user-123', { | ||
| * enabled: !!userId | ||
| * }) | ||
| * | ||
| * // Query without parameters | ||
| * const usersQuery = operations.useQuery('getUsers', undefined, { | ||
| * refetchInterval: 30000 | ||
| * }) | ||
| * | ||
| * // Query with optional parameters | ||
| * const filteredUsersQuery = operations.useQuery('getUsers', { status: 'active' }) | ||
| * ``` | ||
| */ | ||
| useQuery<K extends keyof TQueries, T = InferQueryReturn<TQueries[K]>>( | ||
| queryKey: K, | ||
| params?: InferQueryParams<TQueries[K]>, | ||
| options?: Omit< | ||
| Partial<UseQueryOptions< | ||
| InferQueryReturn<TQueries[K]>, | ||
| Error, | ||
| T, | ||
| QueryKey | ||
| >>, | ||
| 'queryFn' | ||
| > | ||
| ) { | ||
| const queryFn = this._queries[queryKey] as QueryFn | ||
| type TData = InferQueryReturn<TQueries[K]> | ||
| return useQuery<TData, Error, T, QueryKey>({ | ||
| queryKey: this.getQueryKey(queryKey, params), | ||
| queryFn: async (): Promise<TData> => { | ||
| if (!queryFn) { | ||
| throw new Error(`Query "${String(queryKey)}" not found`) | ||
| } | ||
| return queryFn(params as any) as Promise<TData> | ||
| }, | ||
| ...options | ||
| } as any) | ||
| } | ||
| /** | ||
| * Generates a properly typed query key for React Query | ||
| * @template K - The query key type | ||
| * @param queryKey - The name of the query | ||
| * @param params - Optional parameters for the query | ||
| * @returns Query key array, with params included only when necessary | ||
| * | ||
| * @description | ||
| * This method creates React Query compatible keys that include parameters when present. | ||
| * The return type is conditionally typed based on whether the query requires parameters. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Returns ['getUser', 'user-123'] | ||
| * const keyWithParams = operations.getQueryKey('getUser', 'user-123') | ||
| * | ||
| * // Returns ['getUsers'] | ||
| * const keyWithoutParams = operations.getQueryKey('getUsers') | ||
| * ``` | ||
| */ | ||
| getQueryKey<K extends keyof TQueries>( | ||
| queryKey: K, | ||
| params?: InferQueryParams<TQueries[K]> | ||
| ): QueryKey { | ||
| return (params !== undefined ? [queryKey, params] : [queryKey]) as QueryKey | ||
| } | ||
| /** | ||
| * Generates a mutation key for React Query | ||
| * @template K - The mutation key type | ||
| * @param mutationKey - The name of the mutation | ||
| * @returns Mutation key array containing only the mutation name | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Returns ['createUser'] | ||
| * const mutationKey = operations.getMutationKey('createUser') | ||
| * ``` | ||
| */ | ||
| getMutationKey<K extends keyof TMutations>(mutationKey: K): QueryKey { | ||
| return [mutationKey] as QueryKey | ||
| } | ||
| /** | ||
| * Prefetches a query to populate the cache ahead of time | ||
| * @template K - The query key type | ||
| * @param queryKey - The name of the registered query to prefetch | ||
| * @param params - Parameters to pass to the query function (optional if query doesn't require params) | ||
| * @param options - React Query prefetch options | ||
| * @returns Promise that resolves when the prefetch is complete | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Prefetch user data when hovering over a user link | ||
| * const handleUserHover = async (userId: string) => { | ||
| * await operations.prefetchQuery('getUser', userId, { | ||
| * staleTime: 5 * 60 * 1000 // 5 minutes | ||
| * }) | ||
| * } | ||
| * | ||
| * // Prefetch data on route change | ||
| * useEffect(() => { | ||
| * operations.prefetchQuery('getUsers', { status: 'active' }) | ||
| * }, []) | ||
| * ``` | ||
| */ | ||
| prefetchQuery<K extends keyof TQueries, T = InferQueryReturn<TQueries[K]>>( | ||
| queryKey: K, | ||
| params?: InferQueryParams<TQueries[K]>, | ||
| options?: FetchQueryOptions<InferQueryReturn<TQueries[K]>, Error, T, QueryKey, never> | ||
| ) { | ||
| const prefetchQueryKey = this.getQueryKey(queryKey, params) | ||
| const queryFn = this._queries[queryKey] as QueryFn | ||
| return this._options.queryClient.prefetchQuery<InferQueryReturn<TQueries[K]>, Error, T, QueryKey>({ | ||
| queryKey: prefetchQueryKey, | ||
| queryFn: queryFn, | ||
| ...options, | ||
| }) | ||
| } | ||
| /** | ||
| * Retrieves cached query data if it exists | ||
| * @template K - The query key type | ||
| * @template T - The expected return type (defaults to inferred query return type) | ||
| * @param queryKey - The name of the registered query | ||
| * @param params - Parameters used when the query was cached (optional if query doesn't require params) | ||
| * @returns The cached data if it exists, undefined otherwise | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Get cached user data | ||
| * const cachedUser = operations.getQueryData('getUser', 'user-123') | ||
| * if (cachedUser) { | ||
| * console.log('User already in cache:', cachedUser.name) | ||
| * } | ||
| * | ||
| * // Check if users list is cached before showing loading state | ||
| * const cachedUsers = operations.getQueryData('getUsers') | ||
| * const showSkeleton = !cachedUsers | ||
| * | ||
| * // Access cached data in event handlers | ||
| * const handleUserAction = () => { | ||
| * const currentUser = operations.getQueryData('getCurrentUser') | ||
| * if (currentUser?.role === 'admin') { | ||
| * // Perform admin action | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| async getQueryData<K extends keyof TQueries, T = InferQueryReturn<TQueries[K]>>( | ||
| queryKey: K, | ||
| params?: InferQueryParams<TQueries[K]>, | ||
| options?: FetchQueryOptions<InferQueryReturn<TQueries[K]>, Error, T, QueryKey, never>, | ||
| ) { | ||
| const prefetchQueryKey = this.getQueryKey(queryKey, params) | ||
| const cachedData = this._options.queryClient.getQueryData<T, QueryKey>(prefetchQueryKey) | ||
| if (!cachedData) { | ||
| await this.prefetchQuery(queryKey, params, options) | ||
| } | ||
| return this._options.queryClient.getQueryData<T, QueryKey>(prefetchQueryKey) | ||
| } | ||
| } |
| import { QueryClient } from '../../types' | ||
| /** | ||
| * Configuration options for QueryOperations | ||
| */ | ||
| export type QueryOperationsOptions = { | ||
| /** The React Query client instance */ | ||
| queryClient: QueryClient | ||
| } | ||
| /** | ||
| * Generic mutation function type | ||
| * @template T - The input data type | ||
| * @template R - The return data type | ||
| */ | ||
| export type MutationFn<T = any, R = any> = (data: T) => Promise<R> | R | ||
| /** | ||
| * Generic query function type | ||
| * @template T - The parameters type | ||
| * @template R - The return data type | ||
| */ | ||
| export type QueryFn<T = any, R = any> = (params?: T) => Promise<R> | R | ||
| /** | ||
| * Utility type to infer mutation function parameters | ||
| * @template T - The mutation function type | ||
| */ | ||
| export type InferMutationParams<T> = T extends MutationFn<infer P, any> ? P : never | ||
| /** | ||
| * Utility type to infer mutation function return type | ||
| * @template T - The mutation function type | ||
| */ | ||
| export type InferMutationReturn<T> = T extends MutationFn<any, infer R> ? R : never | ||
| /** | ||
| * Utility type to infer query function parameters | ||
| * @template T - The query function type | ||
| */ | ||
| export type InferQueryParams<T> = T extends QueryFn<infer P, any> ? P : never | ||
| /** | ||
| * Utility type to infer query function return type | ||
| * @template T - The query function type | ||
| */ | ||
| export type InferQueryReturn<T> = T extends QueryFn<any, infer R> ? R : never |
| import React from 'react' | ||
| import { describe, it, expect, beforeEach, jest } from 'bun:test' | ||
| import { renderHook, waitFor, act } from '@testing-library/react' | ||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||
| import { createQueryManager } from '../factors/createQueryManager' | ||
| import { createTestQueryClient, TestUser, TestUserFilters, createMockUsers, createMockApiFunctions } from './setup' | ||
| const createWrapper = (queryClient: QueryClient) => { | ||
| return ({ children }: { children: React.ReactNode }) => ( | ||
| <QueryClientProvider client={queryClient}> | ||
| {children} | ||
| </QueryClientProvider> | ||
| ) | ||
| } | ||
| describe('Integration Tests', () => { | ||
| let queryClient: ReturnType<typeof createTestQueryClient> | ||
| let mockApi: ReturnType<typeof createMockApiFunctions> | ||
| let queryManager: ReturnType<typeof createQueryManager<TestUser, TestUserFilters>> | ||
| beforeEach(() => { | ||
| queryClient = createTestQueryClient() | ||
| mockApi = createMockApiFunctions() | ||
| queryManager = createQueryManager<TestUser, TestUserFilters>({ | ||
| name: 'users', | ||
| queryClient, | ||
| listFn: mockApi.listFn, | ||
| retrieveFn: mockApi.retrieveFn, | ||
| createFn: mockApi.createFn, | ||
| updateFn: mockApi.updateFn, | ||
| deleteFn: mockApi.deleteFn, | ||
| listLimit: 5 | ||
| }) | ||
| }) | ||
| describe('Complete CRUD Workflow', () => { | ||
| it('should handle complete user lifecycle', async () => { | ||
| // Start with some existing users | ||
| const initialUsers = createMockUsers(3) | ||
| mockApi.setUsers(initialUsers) | ||
| // 1. Load initial list | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(listResult.current.items).toHaveLength(3) | ||
| // 2. Retrieve specific user | ||
| const targetUser = listResult.current.items[0] | ||
| const { result: retrieveResult } = renderHook( | ||
| () => queryManager.useRetrieve(targetUser.id), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(retrieveResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(retrieveResult.current.item).toEqual(targetUser) | ||
| // 3. Create new user | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager.useCreate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| act(() => { | ||
| createResult.current.mutate({ | ||
| name: 'New User', | ||
| email: 'new@example.com', | ||
| status: 'active' | ||
| }) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(createResult.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(listResult.current.items).toHaveLength(4) | ||
| expect(listResult.current.items[0].name).toBe('New User') | ||
| // 4. Update existing user | ||
| const { result: updateResult } = renderHook( | ||
| () => queryManager.useUpdate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| act(() => { | ||
| updateResult.current.mutate({ | ||
| id: targetUser.id, | ||
| name: 'Updated Name', | ||
| status: 'inactive' | ||
| }) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(updateResult.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(retrieveResult.current.item?.name).toBe('Updated Name') | ||
| expect(retrieveResult.current.item?.status).toBe('inactive') | ||
| // 5. Delete user | ||
| const { result: deleteResult } = renderHook( | ||
| () => queryManager.useDelete({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const userToDelete = listResult.current.items[1] | ||
| act(() => { | ||
| deleteResult.current.mutate(userToDelete.id) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(deleteResult.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(listResult.current.items).toHaveLength(3) | ||
| expect(listResult.current.items.find(u => u.id === userToDelete.id)).toBeUndefined() | ||
| }) | ||
| }) | ||
| describe('Pagination and Infinite Scroll', () => { | ||
| beforeEach(() => { | ||
| const manyUsers = createMockUsers(25) | ||
| mockApi.setUsers(manyUsers) | ||
| }) | ||
| it('should handle pagination correctly', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useList({ limit: 5 }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // First page | ||
| expect(result.current.items).toHaveLength(5) | ||
| expect(result.current.query.hasNextPage).toBe(true) | ||
| // Load next page | ||
| act(() => { | ||
| result.current.query.fetchNextPage() | ||
| }) | ||
| await waitFor(() => { | ||
| expect(result.current.items).toHaveLength(10) | ||
| }) | ||
| // Load more pages | ||
| act(() => { | ||
| result.current.query.fetchNextPage() | ||
| }) | ||
| await waitFor(() => { | ||
| expect(result.current.items).toHaveLength(15) | ||
| }) | ||
| // Continue until all pages loaded | ||
| while (result.current.query.hasNextPage) { | ||
| act(() => { | ||
| result.current.query.fetchNextPage() | ||
| }) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isFetchingNextPage).toBe(false) | ||
| }) | ||
| } | ||
| expect(result.current.items).toHaveLength(25) | ||
| expect(result.current.query.hasNextPage).toBe(false) | ||
| }) | ||
| it('should maintain pagination after mutations', async () => { | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList({ limit: 5 }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Load multiple pages | ||
| act(() => { | ||
| listResult.current.query.fetchNextPage() | ||
| }) | ||
| await waitFor(() => { | ||
| expect(listResult.current.items).toHaveLength(10) | ||
| }) | ||
| // Create new item | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager.useCreate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| act(() => { | ||
| createResult.current.mutate({ | ||
| name: 'Paginated New User', | ||
| email: 'paginated@example.com', | ||
| status: 'active' | ||
| }) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(createResult.current.isSuccess).toBe(true) | ||
| }) | ||
| // Should maintain pagination structure | ||
| expect(listResult.current.items).toHaveLength(11) // +1 new item | ||
| expect(listResult.current.items[0].name).toBe('Paginated New User') | ||
| }) | ||
| }) | ||
| describe('Filtering and Search', () => { | ||
| beforeEach(() => { | ||
| const users = [ | ||
| ...createMockUsers(5).map(u => ({ ...u, status: 'active' as const })), | ||
| ...createMockUsers(3).map(u => ({ ...u, status: 'inactive' as const })) | ||
| ] | ||
| mockApi.setUsers(users) | ||
| }) | ||
| it('should filter list results correctly', async () => { | ||
| const { result: activeResult } = renderHook( | ||
| () => queryManager.useList({ filters: { status: 'active' } }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const { result: inactiveResult } = renderHook( | ||
| () => queryManager.useList({ filters: { status: 'inactive' } }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(activeResult.current.query.isSuccess).toBe(true) | ||
| expect(inactiveResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(activeResult.current.items).toHaveLength(5) | ||
| expect(inactiveResult.current.items).toHaveLength(3) | ||
| expect(activeResult.current.items.every(u => u.status === 'active')).toBe(true) | ||
| expect(inactiveResult.current.items.every(u => u.status === 'inactive')).toBe(true) | ||
| }) | ||
| it('should handle mutations with filtered lists', async () => { | ||
| const { result: activeListResult } = renderHook( | ||
| () => queryManager.useList({ filters: { status: 'active' } }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(activeListResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Create with matching filter | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager.useCreate({ | ||
| optimistic: true, | ||
| listFilters: { status: 'active' } | ||
| }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| act(() => { | ||
| createResult.current.mutate({ | ||
| name: 'New Active User', | ||
| email: 'active@example.com', | ||
| status: 'active' | ||
| }) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(createResult.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(activeListResult.current.items).toHaveLength(6) | ||
| expect(activeListResult.current.items[0].name).toBe('New Active User') | ||
| }) | ||
| it('should handle search filtering', async () => { | ||
| const users = createMockUsers(10).map((user, index) => ({ | ||
| ...user, | ||
| name: index % 2 === 0 ? `John ${index}` : `Jane ${index}`, | ||
| email: index % 2 === 0 ? `john${index}@example.com` : `jane${index}@example.com` | ||
| })) | ||
| mockApi.setUsers(users) | ||
| const { result } = renderHook( | ||
| () => queryManager.useList({ filters: { search: 'John' } }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.items.every(u => u.name.includes('John'))).toBe(true) | ||
| }) | ||
| }) | ||
| describe('Cache Synchronization', () => { | ||
| beforeEach(() => { | ||
| const users = createMockUsers(10) | ||
| mockApi.setUsers(users) | ||
| }) | ||
| it('should keep list and retrieve caches synchronized', async () => { | ||
| // Load list first | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const targetUser = listResult.current.items[2] | ||
| // Load specific user | ||
| const { result: retrieveResult } = renderHook( | ||
| () => queryManager.useRetrieve(targetUser.id), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(retrieveResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Update the user through retrieve cache | ||
| const { result: updateResult } = renderHook( | ||
| () => queryManager.useUpdate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| act(() => { | ||
| updateResult.current.mutate({ | ||
| id: targetUser.id, | ||
| name: 'Synchronized Update' | ||
| }) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(updateResult.current.isSuccess).toBe(true) | ||
| }) | ||
| // Both caches should reflect the change | ||
| expect(retrieveResult.current.item?.name).toBe('Synchronized Update') | ||
| expect(listResult.current.items.find(u => u.id === targetUser.id)?.name).toBe('Synchronized Update') | ||
| }) | ||
| it('should handle multiple filtered lists correctly', async () => { | ||
| const users = [ | ||
| { id: '1', name: 'Active User 1', email: 'a1@test.com', status: 'active' as const, createdAt: '2024-01-01' }, | ||
| { id: '2', name: 'Active User 2', email: 'a2@test.com', status: 'active' as const, createdAt: '2024-01-02' }, | ||
| { id: '3', name: 'Inactive User', email: 'i1@test.com', status: 'inactive' as const, createdAt: '2024-01-03' } | ||
| ] | ||
| mockApi.setUsers(users) | ||
| // Load different filtered lists | ||
| const { result: allListResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const { result: activeListResult } = renderHook( | ||
| () => queryManager.useList({ filters: { status: 'active' } }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(allListResult.current.query.isSuccess).toBe(true) | ||
| expect(activeListResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Update a user | ||
| const { result: updateResult } = renderHook( | ||
| () => queryManager.useUpdate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| act(() => { | ||
| updateResult.current.mutate({ | ||
| id: '1', | ||
| status: 'inactive' | ||
| }) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(updateResult.current.isSuccess).toBe(true) | ||
| }) | ||
| // All lists should be updated | ||
| const updatedUserInAll = allListResult.current.items.find(u => u.id === '1') | ||
| const updatedUserInActive = activeListResult.current.items.find(u => u.id === '1') | ||
| expect(updatedUserInAll?.status).toBe('inactive') | ||
| expect(updatedUserInActive?.status).toBe('inactive') | ||
| }) | ||
| }) | ||
| describe('Performance and Memory', () => { | ||
| it('should handle large datasets efficiently', async () => { | ||
| // Create a large dataset | ||
| const largeDataset = createMockUsers(1000) | ||
| mockApi.setUsers(largeDataset) | ||
| const { result } = renderHook( | ||
| () => queryManager.useList({ limit: 50 }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Should load only the requested amount | ||
| expect(result.current.items).toHaveLength(50) | ||
| // Load next page efficiently | ||
| const startTime = performance.now() | ||
| act(() => { | ||
| result.current.query.fetchNextPage() | ||
| }) | ||
| await waitFor(() => { | ||
| expect(result.current.items).toHaveLength(100) | ||
| }) | ||
| const endTime = performance.now() | ||
| const loadTime = endTime - startTime | ||
| // Should be reasonably fast (less than 100ms) | ||
| expect(loadTime).toBeLessThan(100) | ||
| }) | ||
| it('should properly clean up resources', async () => { | ||
| const { result, unmount } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Unmount component | ||
| unmount() | ||
| // Query should still be in cache but can be garbage collected | ||
| expect(queryClient.getQueryCache().getAll()).toHaveLength(1) | ||
| }) | ||
| }) | ||
| describe('Real-world Scenarios', () => { | ||
| it('should handle concurrent user sessions', async () => { | ||
| const users = createMockUsers(5) | ||
| mockApi.setUsers(users) | ||
| // Simulate two different user sessions | ||
| const queryClient1 = createTestQueryClient() | ||
| const queryClient2 = createTestQueryClient() | ||
| const queryManager1 = createQueryManager<TestUser, TestUserFilters>({ | ||
| name: 'users', | ||
| queryClient: queryClient1, | ||
| listFn: mockApi.listFn, | ||
| retrieveFn: mockApi.retrieveFn, | ||
| createFn: mockApi.createFn, | ||
| updateFn: mockApi.updateFn, | ||
| deleteFn: mockApi.deleteFn | ||
| }) | ||
| const queryManager2 = createQueryManager<TestUser, TestUserFilters>({ | ||
| name: 'users', | ||
| queryClient: queryClient2, | ||
| listFn: mockApi.listFn, | ||
| retrieveFn: mockApi.retrieveFn, | ||
| createFn: mockApi.createFn, | ||
| updateFn: mockApi.updateFn, | ||
| deleteFn: mockApi.deleteFn | ||
| }) | ||
| // Load data in both sessions | ||
| const { result: session1Result } = renderHook( | ||
| () => queryManager1.useList(), | ||
| { wrapper: createWrapper(queryClient1) } | ||
| ) | ||
| const { result: session2Result } = renderHook( | ||
| () => queryManager2.useList(), | ||
| { wrapper: createWrapper(queryClient2) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(session1Result.current.query.isSuccess).toBe(true) | ||
| expect(session2Result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Both sessions should have independent caches | ||
| expect(session1Result.current.items).toEqual(session2Result.current.items) | ||
| // Create in session 1 | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager1.useCreate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient1) } | ||
| ) | ||
| act(() => { | ||
| createResult.current.mutate({ | ||
| name: 'Session 1 User', | ||
| email: 'session1@example.com', | ||
| status: 'active' | ||
| }) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(createResult.current.isSuccess).toBe(true) | ||
| }) | ||
| // Session 1 should see the new user | ||
| expect(session1Result.current.items.some(u => u.name === 'Session 1 User')).toBe(true) | ||
| // Session 2 should not see it until refetch | ||
| expect(session2Result.current.items.some(u => u.name === 'Session 1 User')).toBe(false) | ||
| // Refetch in session 2 | ||
| act(() => { | ||
| session2Result.current.query.refetch() | ||
| }) | ||
| await waitFor(() => { | ||
| expect(session2Result.current.items.some(u => u.name === 'Session 1 User')).toBe(true) | ||
| }) | ||
| }) | ||
| }) | ||
| }) |
| import { describe, it, expect, beforeEach, spyOn, mock } from 'bun:test' | ||
| import { Mutations, createMutations } from '../lib/Mutations' | ||
| import { QueryKeys } from '../lib/QueryKeys' | ||
| import { createTestQueryClient, TestUser, TestUserFilters, createMockUsers } from './setup' | ||
| import { InfiniteData } from '@tanstack/react-query' | ||
| describe('Mutations', () => { | ||
| let queryClient: ReturnType<typeof createTestQueryClient> | ||
| let queryKeys: QueryKeys<TestUser, TestUserFilters> | ||
| let mutations: Mutations<TestUser, TestUserFilters> | ||
| beforeEach(() => { | ||
| queryClient = createTestQueryClient() | ||
| queryKeys = new QueryKeys('users', queryClient) | ||
| mutations = new Mutations(queryKeys, queryClient, 'users') | ||
| }) | ||
| describe('constructor', () => { | ||
| it('should create Mutations instance with correct parameters', () => { | ||
| expect(mutations).toBeInstanceOf(Mutations) | ||
| expect(mutations['queryKeys']).toBe(queryKeys) | ||
| expect(mutations['queryClient']).toBe(queryClient) | ||
| expect(mutations['queryName']).toBe('users') | ||
| }) | ||
| }) | ||
| describe('addItem', () => { | ||
| const mockUsers = createMockUsers(3) | ||
| beforeEach(() => { | ||
| const mockData: InfiniteData<TestUser[], number> = { | ||
| pages: [mockUsers.slice(0, 2), mockUsers.slice(2)], | ||
| pageParams: [0, 2] | ||
| } | ||
| queryClient.setQueryData(['users', 'list'], mockData) | ||
| }) | ||
| it('should add item to start by default', () => { | ||
| const newUser = createMockUsers(1)[0] | ||
| mutations.addItem(newUser) | ||
| const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number> | ||
| expect(data.pages[0][0]).toEqual(newUser) | ||
| expect(data.pages[0]).toHaveLength(3) // original 2 + new 1 | ||
| }) | ||
| it('should add item to start when position is "start"', () => { | ||
| const newUser = createMockUsers(1)[0] | ||
| mutations.addItem(newUser, 'start') | ||
| const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number> | ||
| expect(data.pages[0][0]).toEqual(newUser) | ||
| }) | ||
| it('should add item to end when position is "end"', () => { | ||
| const newUser = createMockUsers(1)[0] | ||
| mutations.addItem(newUser, 'end') | ||
| const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number> | ||
| const lastPage = data.pages[data.pages.length - 1] | ||
| expect(lastPage[lastPage.length - 1]).toEqual(newUser) | ||
| }) | ||
| it('should create new data when no cache exists', () => { | ||
| queryClient.clear() | ||
| const newUser = createMockUsers(1)[0] | ||
| mutations.addItem(newUser) | ||
| const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number> | ||
| expect(data).toEqual({ | ||
| pageParams: [0], | ||
| pages: [[newUser]] | ||
| }) | ||
| }) | ||
| it('should add item with specific list filters', () => { | ||
| const filters = { status: 'active' as const } | ||
| const newUser = createMockUsers(1)[0] | ||
| // Set up filtered cache | ||
| queryClient.setQueryData(['users', 'list', filters], { | ||
| pages: [mockUsers.slice(0, 1)], | ||
| pageParams: [0] | ||
| }) | ||
| mutations.addItem(newUser, 'start', filters) | ||
| const data = queryClient.getQueryData(['users', 'list', filters]) as InfiniteData<TestUser[], number> | ||
| expect(data.pages[0][0]).toEqual(newUser) | ||
| }) | ||
| it('should handle multiple query keys (RemovedItemMap)', () => { | ||
| const newUser = createMockUsers(1)[0] | ||
| const queryKey1 = ['users', 'list'] | ||
| const queryKey2 = ['users', 'list', { status: 'active' }] | ||
| // Set up multiple caches | ||
| queryClient.setQueryData(queryKey1, { | ||
| pages: [mockUsers.slice(0, 2)], | ||
| pageParams: [0] | ||
| }) | ||
| queryClient.setQueryData(queryKey2, { | ||
| pages: [mockUsers.slice(0, 1)], | ||
| pageParams: [0] | ||
| }) | ||
| const removedItemMap = [ | ||
| [queryKey1, { pageIndex: 0, itemIndex: 1 }], | ||
| [queryKey2, { pageIndex: 0, itemIndex: 0 }] | ||
| ] as any | ||
| mutations.addItem(newUser, removedItemMap) | ||
| const data1 = queryClient.getQueryData(queryKey1) as InfiniteData<TestUser[], number> | ||
| const data2 = queryClient.getQueryData(queryKey2) as InfiniteData<TestUser[], number> | ||
| expect(data1.pages[0][1]).toEqual(newUser) | ||
| expect(data2.pages[0][0]).toEqual(newUser) | ||
| }) | ||
| it('should handle item position beyond page length', () => { | ||
| const newUser = createMockUsers(1)[0] | ||
| const queryKey = ['users', 'list'] | ||
| queryClient.setQueryData(queryKey, { | ||
| pages: [mockUsers.slice(0, 1)], | ||
| pageParams: [0] | ||
| }) | ||
| const removedItemMap = [ | ||
| [queryKey, { pageIndex: 0, itemIndex: 10 }] // Beyond page length | ||
| ] as any | ||
| mutations.addItem(newUser, removedItemMap) | ||
| const data = queryClient.getQueryData(queryKey) as InfiniteData<TestUser[], number> | ||
| expect(data.pages[0][data.pages[0].length - 1]).toEqual(newUser) | ||
| }) | ||
| it('should handle page index beyond pages length', () => { | ||
| const newUser = createMockUsers(1)[0] | ||
| const queryKey = ['users', 'list'] | ||
| queryClient.setQueryData(queryKey, { | ||
| pages: [mockUsers.slice(0, 1)], | ||
| pageParams: [0] | ||
| }) | ||
| const removedItemMap = [ | ||
| [queryKey, { pageIndex: 5, itemIndex: 0 }] // Beyond pages length | ||
| ] as any | ||
| mutations.addItem(newUser, removedItemMap) | ||
| const data = queryClient.getQueryData(queryKey) as InfiniteData<TestUser[], number> | ||
| const lastPage = data.pages[data.pages.length - 1] | ||
| expect(lastPage[lastPage.length - 1]).toEqual(newUser) | ||
| }) | ||
| it('should handle empty pages array', () => { | ||
| const newUser = createMockUsers(1)[0] | ||
| const queryKey = ['users', 'list'] | ||
| queryClient.setQueryData(queryKey, { | ||
| pages: [], | ||
| pageParams: [] | ||
| }) | ||
| const removedItemMap = [ | ||
| [queryKey, { pageIndex: 0, itemIndex: 0 }] | ||
| ] as any | ||
| mutations.addItem(newUser, removedItemMap) | ||
| const data = queryClient.getQueryData(queryKey) as InfiniteData<TestUser[], number> | ||
| expect(data.pages[0]).toEqual([newUser]) | ||
| }) | ||
| }) | ||
| describe('removeItem', () => { | ||
| const mockUsers = createMockUsers(3) | ||
| beforeEach(() => { | ||
| // Set up multiple list queries | ||
| queryClient.setQueryData(['users', 'list'], { | ||
| pages: [mockUsers.slice(0, 2), [mockUsers[2]]], | ||
| pageParams: [0, 2] | ||
| }) | ||
| queryClient.setQueryData(['users', 'list', { status: 'active' }], { | ||
| pages: [[mockUsers[0]]], | ||
| pageParams: [0] | ||
| }) | ||
| // Set up retrieve cache | ||
| queryClient.setQueryData(['users', 'retrieve', mockUsers[0].id], mockUsers[0]) | ||
| }) | ||
| it('should remove item from all list queries and return removal map', () => { | ||
| const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([ | ||
| { queryKey: ['users', 'list'], state: { data: queryClient.getQueryData(['users', 'list']) } }, | ||
| { queryKey: ['users', 'list', { status: 'active' }], state: { data: queryClient.getQueryData(['users', 'list', { status: 'active' }]) } } | ||
| ] as any) | ||
| const removedMap = mutations.removeItem(mockUsers[0].id) | ||
| expect(removedMap).toHaveLength(2) | ||
| expect(removedMap![0]).toEqual([['users', 'list'], { pageIndex: 0, itemIndex: 0 }]) | ||
| expect(removedMap![1]).toEqual([['users', 'list', { status: 'active' }], { pageIndex: 0, itemIndex: 0 }]) | ||
| // Verify item was removed from caches | ||
| const data1 = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number> | ||
| const data2 = queryClient.getQueryData(['users', 'list', { status: 'active' }]) as InfiniteData<TestUser[], number> | ||
| expect(data1.pages[0]).not.toContain(mockUsers[0]) | ||
| expect(data2.pages[0]).toHaveLength(0) | ||
| }) | ||
| it('should remove retrieve query data', () => { | ||
| const removeRetrieveQueryDataSpy = spyOn(queryKeys, 'removeRetrieveQueryData') | ||
| const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([]) | ||
| mutations.removeItem(mockUsers[0].id) | ||
| expect(removeRetrieveQueryDataSpy).toHaveBeenCalledWith(mockUsers[0].id) | ||
| }) | ||
| it('should return empty array when item not found in any list', () => { | ||
| const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([ | ||
| { queryKey: ['users', 'list'], state: { data: queryClient.getQueryData(['users', 'list']) } } | ||
| ] as any) | ||
| const removedMap = mutations.removeItem('non-existent-id') | ||
| expect(removedMap).toEqual([]) | ||
| }) | ||
| it('should handle empty pages after removal', () => { | ||
| const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([ | ||
| { queryKey: ['users', 'list'], state: { data: { pages: [[mockUsers[0]]], pageParams: [0] } } } | ||
| ] as any) | ||
| mutations.removeItem(mockUsers[0].id) | ||
| const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number> | ||
| expect(data.pages).toEqual([[]]) | ||
| expect(data.pageParams).toEqual([0]) | ||
| }) | ||
| it('should filter out empty pages', () => { | ||
| const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([ | ||
| { queryKey: ['users', 'list'], state: { data: { pages: [[mockUsers[0]], [mockUsers[1]]], pageParams: [0, 1] } } } | ||
| ] as any) | ||
| mutations.removeItem(mockUsers[0].id) | ||
| const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number> | ||
| expect(data.pages).toEqual([[mockUsers[1]]]) | ||
| expect(data.pageParams).toEqual([0]) | ||
| }) | ||
| it('should handle queries with no current data', () => { | ||
| const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([ | ||
| { queryKey: ['users', 'list'], state: { data: null } } | ||
| ] as any) | ||
| const removedMap = mutations.removeItem(mockUsers[0].id) | ||
| expect(removedMap).toEqual([]) | ||
| }) | ||
| }) | ||
| describe('updateItems', () => { | ||
| const mockUsers = createMockUsers(3) | ||
| beforeEach(() => { | ||
| const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([ | ||
| { | ||
| queryKey: ['users', 'list'], | ||
| state: { | ||
| data: { | ||
| pages: [mockUsers.slice(0, 2), [mockUsers[2]]], | ||
| pageParams: [0, 2] | ||
| } | ||
| } | ||
| } | ||
| ] as any) | ||
| }) | ||
| it('should update single item by id', () => { | ||
| const updateData = { ...mockUsers[0], name: 'Updated Name' } | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(updateData) | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'retrieve', updateData.id], | ||
| updateData | ||
| ) | ||
| }) | ||
| it('should update single item by tempId', () => { | ||
| const updateData = { ...mockUsers[0], name: 'Updated Name', tempId: 'temp-1' } | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(updateData) | ||
| const expectedData = { ...updateData } | ||
| // @ts-ignore | ||
| delete expectedData.tempId | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'retrieve', updateData.id], | ||
| expectedData | ||
| ) | ||
| }) | ||
| it('should update multiple items', () => { | ||
| const updateData = [ | ||
| { ...mockUsers[0], name: 'Updated Name 1' }, | ||
| { ...mockUsers[1], name: 'Updated Name 2' } | ||
| ] | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(updateData) | ||
| expect(setQueryDataSpy).toHaveBeenCalledTimes(3) // 2 retrieve + 1 list update | ||
| }) | ||
| it('should not update if data is the same (deep equal)', () => { | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| // Update with same data | ||
| mutations.updateItems(mockUsers[0]) | ||
| // Should only set retrieve cache, not update list (no changes) | ||
| expect(setQueryDataSpy).toHaveBeenCalledTimes(1) | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'retrieve', mockUsers[0].id], | ||
| mockUsers[0] | ||
| ) | ||
| }) | ||
| it('should update list cache when item data changes', () => { | ||
| const updateData = { ...mockUsers[0], name: 'Updated Name' } | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(updateData) | ||
| // Should update both retrieve and list cache | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'retrieve', updateData.id], | ||
| updateData | ||
| ) | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'list'], | ||
| expect.objectContaining({ | ||
| pages: expect.arrayContaining([ | ||
| expect.arrayContaining([updateData]) | ||
| ]) | ||
| }) | ||
| ) | ||
| }) | ||
| it('should handle queries with no data', () => { | ||
| // Override the spy for this test | ||
| spyOn(queryKeys, 'getAllListQueries').mockReturnValue([ | ||
| { queryKey: ['users', 'list'], state: { data: null } } | ||
| ] as any) | ||
| const updateData = { ...mockUsers[0], name: 'Updated Name' } | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(updateData) | ||
| // Should only update retrieve cache | ||
| expect(setQueryDataSpy).toHaveBeenCalledTimes(1) | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'retrieve', updateData.id], | ||
| updateData | ||
| ) | ||
| }) | ||
| it('should preserve page structure when updating items', () => { | ||
| const updateData = { ...mockUsers[2], name: 'Updated Name' } // Item in second page | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(updateData) | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'list'], | ||
| expect.objectContaining({ | ||
| pages: [ | ||
| mockUsers.slice(0, 2), // First page unchanged | ||
| [updateData] // Second page with updated item | ||
| ], | ||
| pageParams: [0, 2] | ||
| }) | ||
| ) | ||
| }) | ||
| it('should handle items not found in list', () => { | ||
| const nonExistentItem = { id: 'non-existent', name: 'New Name' } as TestUser | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(nonExistentItem) | ||
| // Should only update retrieve cache | ||
| expect(setQueryDataSpy).toHaveBeenCalledTimes(1) | ||
| expect(setQueryDataSpy).toHaveBeenCalledWith( | ||
| ['users', 'retrieve', nonExistentItem.id], | ||
| nonExistentItem | ||
| ) | ||
| }) | ||
| it('should remove tempId from cached data', () => { | ||
| const updateData = { ...mockUsers[0], name: 'Updated Name', tempId: 'temp-123' } | ||
| const setQueryDataSpy = spyOn(queryClient, 'setQueryData') | ||
| mutations.updateItems(updateData) | ||
| // Check retrieve cache call - tempId should be removed | ||
| const retrieveCall = setQueryDataSpy.mock.calls.find(call => | ||
| Array.isArray(call[0]) && call[0].includes('retrieve') | ||
| ) | ||
| expect(retrieveCall).toBeDefined() | ||
| const cachedData = retrieveCall![1] as any | ||
| expect(cachedData).not.toHaveProperty('tempId') | ||
| expect(cachedData.name).toBe('Updated Name') | ||
| // Check list cache call - tempId should be removed from items | ||
| const listCall = setQueryDataSpy.mock.calls.find(call => | ||
| Array.isArray(call[0]) && call[0].includes('list') | ||
| ) | ||
| if (listCall) { | ||
| const updatedListData = listCall[1] as any | ||
| const updatedItem = updatedListData.pages.flat().find((item: any) => item.id === updateData.id) | ||
| expect(updatedItem).not.toHaveProperty('tempId') | ||
| } | ||
| }) | ||
| }) | ||
| describe('createMutations factory', () => { | ||
| it('should create Mutations instance', () => { | ||
| const mutations = createMutations(queryKeys, queryClient, 'test') | ||
| expect(mutations).toBeInstanceOf(Mutations) | ||
| expect(mutations['queryKeys']).toBe(queryKeys) | ||
| expect(mutations['queryClient']).toBe(queryClient) | ||
| expect(mutations['queryName']).toBe('test') | ||
| }) | ||
| }) | ||
| }) |
| import { describe, it, expect, beforeEach, jest, spyOn } from 'bun:test' | ||
| import { renderHook, waitFor } from '@testing-library/react' | ||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||
| import React from 'react' | ||
| import { QueryManager } from '../lib/QueryManager' | ||
| import { createTestQueryClient, TestUser, TestUserFilters, createMockUsers, createMockApiFunctions } from './setup' | ||
| const createWrapper = (queryClient: QueryClient) => { | ||
| return ({ children }: { children: React.ReactNode }) => ( | ||
| <QueryClientProvider client={queryClient}> | ||
| {children} | ||
| </QueryClientProvider> | ||
| ) | ||
| } | ||
| describe('QueryManager', () => { | ||
| let queryClient: ReturnType<typeof createTestQueryClient> | ||
| let mockApi: ReturnType<typeof createMockApiFunctions> | ||
| let queryManager: QueryManager<TestUser, TestUserFilters> | ||
| beforeEach(() => { | ||
| queryClient = createTestQueryClient() | ||
| mockApi = createMockApiFunctions() | ||
| queryManager = new QueryManager({ | ||
| name: 'users', | ||
| queryClient, | ||
| listFn: mockApi.listFn, | ||
| retrieveFn: mockApi.retrieveFn, | ||
| createFn: mockApi.createFn, | ||
| updateFn: mockApi.updateFn, | ||
| deleteFn: mockApi.deleteFn, | ||
| listLimit: 10 | ||
| }) | ||
| }) | ||
| describe('constructor', () => { | ||
| it('should create QueryManager with correct configuration', () => { | ||
| expect(queryManager.name).toBe('users') | ||
| expect(queryManager.functions).toEqual({ | ||
| list: mockApi.listFn, | ||
| retrieve: mockApi.retrieveFn, | ||
| create: mockApi.createFn, | ||
| update: mockApi.updateFn, | ||
| delete: mockApi.deleteFn | ||
| }) | ||
| expect(queryManager.queryKeys).toBeDefined() | ||
| expect(queryManager.mutations).toBeDefined() | ||
| }) | ||
| }) | ||
| describe('useList', () => { | ||
| beforeEach(() => { | ||
| const mockUsers = createMockUsers(25) // More than one page | ||
| mockApi.setUsers(mockUsers) | ||
| }) | ||
| it('should fetch and return list items', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.items).toHaveLength(10) // Default limit | ||
| expect(result.current.queryKey).toEqual(['users', 'list']) | ||
| }) | ||
| it('should use custom limit', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useList({ limit: 5 }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.items).toHaveLength(5) | ||
| }) | ||
| it('should apply filters', async () => { | ||
| const activeUsers = createMockUsers(3).map(user => ({ ...user, status: 'active' as const })) | ||
| const inactiveUsers = createMockUsers(2).map(user => ({ ...user, status: 'inactive' as const })) | ||
| mockApi.setUsers([...activeUsers, ...inactiveUsers]) | ||
| const { result } = renderHook( | ||
| () => queryManager.useList({ filters: { status: 'active' } }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.items).toHaveLength(3) | ||
| expect(result.current.items.every(user => user.status === 'active')).toBe(true) | ||
| }) | ||
| it('should handle infinite scroll pagination', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useList({ limit: 5 }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.items).toHaveLength(5) | ||
| expect(result.current.query.hasNextPage).toBe(true) | ||
| // Fetch next page | ||
| result.current.query.fetchNextPage() | ||
| await waitFor(() => { | ||
| expect(result.current.items).toHaveLength(10) | ||
| }) | ||
| }) | ||
| it('should call useListEffect if provided', async () => { | ||
| const useListEffect = jest.fn() | ||
| const queryManagerWithEffect = new QueryManager({ | ||
| name: 'users', | ||
| queryClient, | ||
| listFn: mockApi.listFn, | ||
| retrieveFn: mockApi.retrieveFn, | ||
| createFn: mockApi.createFn, | ||
| updateFn: mockApi.updateFn, | ||
| deleteFn: mockApi.deleteFn, | ||
| useListEffect | ||
| }) | ||
| renderHook( | ||
| () => queryManagerWithEffect.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(useListEffect).toHaveBeenCalled() | ||
| }) | ||
| }) | ||
| it('should handle empty results', async () => { | ||
| mockApi.setUsers([]) | ||
| const { result } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.items).toEqual([]) | ||
| }) | ||
| it('should determine next page correctly', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useList({ limit: 10 }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Has next page when more items available | ||
| expect(result.current.query.hasNextPage).toBe(true) | ||
| // Fetch all pages | ||
| while (result.current.query.hasNextPage) { | ||
| result.current.query.fetchNextPage() | ||
| await waitFor(() => { | ||
| expect(result.current.query.isFetchingNextPage).toBe(false) | ||
| }) | ||
| } | ||
| expect(result.current.query.hasNextPage).toBe(false) | ||
| }) | ||
| }) | ||
| describe('useRetrieve', () => { | ||
| beforeEach(() => { | ||
| const mockUsers = createMockUsers(5) | ||
| mockApi.setUsers(mockUsers) | ||
| }) | ||
| it('should fetch and return single item', async () => { | ||
| const users = mockApi.getUsersArray() | ||
| const targetUser = users[0] | ||
| const { result } = renderHook( | ||
| () => queryManager.useRetrieve(targetUser.id), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.item).toEqual(targetUser) | ||
| expect(result.current.queryKey).toEqual(['users', 'retrieve', targetUser.id]) | ||
| }) | ||
| it('should use initial data from list cache', async () => { | ||
| const users = createMockUsers(3) | ||
| mockApi.setUsers(users) | ||
| // First, populate list cache | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Then retrieve specific item - should use cache as initial data | ||
| const { result: retrieveResult } = renderHook( | ||
| () => queryManager.useRetrieve(users[0].id), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| // Should have initial data immediately | ||
| expect(retrieveResult.current.item).toEqual(users[0]) | ||
| }) | ||
| it('should handle item not found error', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useRetrieve('non-existent-id'), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isError).toBe(true) | ||
| }) | ||
| expect(result.current.item).toBeUndefined() | ||
| expect(result.current.query.error).toEqual(new Error('User not found')) | ||
| }) | ||
| it('should call custom select function', async () => { | ||
| const users = mockApi.getUsersArray() | ||
| const targetUser = users[0] | ||
| const customSelect = jest.fn((user: TestUser) => ({ ...user, selected: true })) | ||
| const { result } = renderHook( | ||
| () => queryManager.useRetrieve(targetUser.id, { select: customSelect }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(customSelect).toHaveBeenCalledWith(targetUser) | ||
| }) | ||
| it('should update list cache when retrieve data changes', async () => { | ||
| const users = createMockUsers(3) | ||
| mockApi.setUsers(users) | ||
| // Populate list cache first | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Update a user via API BEFORE retrieving | ||
| const updatedUser = { ...users[0], name: 'Updated Name' } | ||
| mockApi.setUsers([updatedUser, ...users.slice(1)]) | ||
| await queryManager.queryKeys.refetchList() | ||
| // Retrieve the updated user | ||
| const { result: retrieveResult } = renderHook( | ||
| () => queryManager.useRetrieve(users[0].id), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(retrieveResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| expect(retrieveResult.current.item).toEqual(updatedUser) | ||
| }) | ||
| }) | ||
| describe('useCreate', () => { | ||
| beforeEach(() => { | ||
| mockApi.setUsers(createMockUsers(3)) | ||
| }) | ||
| it('should create new item', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useCreate(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const } | ||
| result.current.mutate(newUserData) | ||
| await waitFor(() => { | ||
| expect(result.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.data).toMatchObject(newUserData) | ||
| expect(mockApi.getUsersArray()).toHaveLength(4) | ||
| }) | ||
| it('should handle optimistic updates', async () => { | ||
| // First, populate list cache | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager.useCreate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const } | ||
| createResult.current.mutate(newUserData) | ||
| // Should immediately appear in list cache | ||
| await waitFor(() => { | ||
| expect(listResult.current.items.some(item => item.name === 'New User')).toBe(true) | ||
| }) | ||
| await waitFor(() => { | ||
| expect(createResult.current.isSuccess).toBe(true) | ||
| }) | ||
| // Should replace temp item with real data | ||
| expect(listResult.current.items.some(item => | ||
| item.name === 'New User' && !item.id.startsWith('temp-') | ||
| )).toBe(true) | ||
| }) | ||
| // it('should rollback optimistic update on error', async () => { | ||
| // // Mock API to throw error BEFORE creating hook | ||
| // const originalCreateFn = mockApi.createFn | ||
| // mockApi.createFn = jest.fn().mockRejectedValue(new Error('Creation failed')) | ||
| // // First, populate list cache | ||
| // const { result: listResult } = renderHook( | ||
| // () => queryManager.useList(), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // await waitFor(() => { | ||
| // expect(listResult.current.query.isSuccess).toBe(true) | ||
| // }) | ||
| // const initialItemCount = listResult.current.items.length | ||
| // const { result: createResult } = renderHook( | ||
| // () => queryManager.useCreate({ | ||
| // optimistic: true, | ||
| // onError: (error) => { | ||
| // // Handle error silently for test | ||
| // console.log('Expected error:', error.message) | ||
| // } | ||
| // }), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const } | ||
| // await createResult.current.mutateAsync(newUserData) | ||
| // // Should temporarily appear in list | ||
| // await waitFor(() => { | ||
| // expect(listResult.current.items.length).toBeGreaterThan(initialItemCount) | ||
| // }) | ||
| // // Should be removed on error | ||
| // await waitFor(() => { | ||
| // expect(createResult.current.isError).toBe(true) | ||
| // }) | ||
| // expect(listResult.current.items).toHaveLength(initialItemCount) | ||
| // // Restore original function | ||
| // mockApi.createFn = originalCreateFn | ||
| // }) | ||
| it('should call custom mutation callbacks', async () => { | ||
| const onMutate = jest.fn() | ||
| const onSuccess = jest.fn() | ||
| const onError = jest.fn() | ||
| const { result } = renderHook( | ||
| () => queryManager.useCreate({ onMutate, onSuccess, onError }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const } | ||
| await result.current.mutateAsync(newUserData) | ||
| await waitFor(() => { | ||
| expect(result.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(onMutate).toHaveBeenCalled() | ||
| expect(onSuccess).toHaveBeenCalled() | ||
| expect(onError).not.toHaveBeenCalled() | ||
| }) | ||
| it('should append to start by default', async () => { | ||
| // First, populate list cache | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager.useCreate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const } | ||
| createResult.current.mutate(newUserData) | ||
| await waitFor(() => { | ||
| expect(listResult.current.items[0].name).toBe('New User') | ||
| }) | ||
| }) | ||
| it('should append to end when specified', async () => { | ||
| // First, populate list cache | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager.useCreate({ optimistic: true, appendTo: 'end' }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const } | ||
| createResult.current.mutate(newUserData) | ||
| await waitFor(() => { | ||
| const items = listResult.current.items | ||
| expect(items[items.length - 1].name).toBe('New User') | ||
| }) | ||
| }) | ||
| it('should handle list filters', async () => { | ||
| // First, populate filtered list cache | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList({ filters: { status: 'active' } }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const { result: createResult } = renderHook( | ||
| () => queryManager.useCreate({ | ||
| optimistic: true, | ||
| listFilters: { status: 'active' } | ||
| }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const } | ||
| createResult.current.mutate(newUserData) | ||
| await waitFor(() => { | ||
| expect(listResult.current.items.some(item => item.name === 'New User')).toBe(true) | ||
| }) | ||
| }) | ||
| }) | ||
| describe('useUpdate', () => { | ||
| let existingUsers: TestUser[] | ||
| beforeEach(() => { | ||
| existingUsers = createMockUsers(3) | ||
| mockApi.setUsers(existingUsers) | ||
| }) | ||
| it('should update existing item', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useUpdate(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const updateData = { id: existingUsers[0].id, name: 'Updated Name' } | ||
| result.current.mutate(updateData) | ||
| await waitFor(() => { | ||
| expect(result.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.data).toMatchObject(updateData) | ||
| const updatedUsers = mockApi.getUsersArray() | ||
| expect(updatedUsers.find(u => u.id === existingUsers[0].id)?.name).toBe('Updated Name') | ||
| }) | ||
| it('should handle optimistic updates', async () => { | ||
| // First, populate retrieve cache | ||
| const { result: retrieveResult } = renderHook( | ||
| () => queryManager.useRetrieve(existingUsers[0].id), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(retrieveResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const { result: updateResult } = renderHook( | ||
| () => queryManager.useUpdate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const updateData = { id: existingUsers[0].id, name: 'Optimistically Updated' } | ||
| updateResult.current.mutate(updateData) | ||
| // Should immediately appear updated | ||
| await waitFor(() => { | ||
| expect(retrieveResult.current.item?.name).toBe('Optimistically Updated') | ||
| }) | ||
| await waitFor(() => { | ||
| expect(updateResult.current.isSuccess).toBe(true) | ||
| }) | ||
| }) | ||
| // it('should rollback optimistic update on error', async () => { | ||
| // // Mock API to throw error BEFORE creating hook | ||
| // const originalUpdateFn = mockApi.updateFn | ||
| // mockApi.updateFn = jest.fn().mockRejectedValue(new Error('Update failed')) | ||
| // // First, populate retrieve cache | ||
| // const { result: retrieveResult } = renderHook( | ||
| // () => queryManager.useRetrieve(existingUsers[0].id), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // await waitFor(() => { | ||
| // expect(retrieveResult.current.query.isSuccess).toBe(true) | ||
| // }) | ||
| // const originalName = retrieveResult.current.item?.name | ||
| // const { result: updateResult } = renderHook( | ||
| // () => queryManager.useUpdate({ | ||
| // optimistic: true, | ||
| // onError: (error) => { | ||
| // console.log('Expected error:', error.message) | ||
| // } | ||
| // }), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // const updateData = { id: existingUsers[0].id, name: 'Failed Update' } | ||
| // updateResult.current.mutate(updateData) | ||
| // // Should temporarily show updated name | ||
| // await waitFor(() => { | ||
| // expect(retrieveResult.current.item?.name).toBe('Failed Update') | ||
| // }) | ||
| // // Should rollback to original name on error | ||
| // await waitFor(() => { | ||
| // expect(updateResult.current.isError).toBe(true) | ||
| // }) | ||
| // expect(retrieveResult.current.item?.name).toBe(originalName!) | ||
| // // Restore original function | ||
| // mockApi.updateFn = originalUpdateFn | ||
| // }) | ||
| it('should call custom mutation callbacks', async () => { | ||
| const onMutate = jest.fn() | ||
| const onSuccess = jest.fn() | ||
| const onError = jest.fn() | ||
| const { result } = renderHook( | ||
| () => queryManager.useUpdate({ onMutate, onSuccess, onError }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const updateData = { id: existingUsers[0].id, name: 'Updated Name' } | ||
| result.current.mutate(updateData) | ||
| await waitFor(() => { | ||
| expect(result.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(onMutate).toHaveBeenCalled() | ||
| expect(onSuccess).toHaveBeenCalled() | ||
| expect(onError).not.toHaveBeenCalled() | ||
| }) | ||
| }) | ||
| describe('useDelete', () => { | ||
| let existingUsers: TestUser[] | ||
| beforeEach(() => { | ||
| existingUsers = createMockUsers(3) | ||
| mockApi.setUsers(existingUsers) | ||
| }) | ||
| it('should delete existing item', async () => { | ||
| const { result } = renderHook( | ||
| () => queryManager.useDelete(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const targetId = existingUsers[0].id | ||
| result.current.mutate(targetId) | ||
| await waitFor(() => { | ||
| expect(result.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(result.current.data).toBe(targetId) | ||
| expect(mockApi.getUsersArray()).toHaveLength(2) | ||
| expect(mockApi.getUsersArray().find(u => u.id === targetId)).toBeUndefined() | ||
| }) | ||
| it('should handle optimistic updates', async () => { | ||
| // First, populate list cache | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const { result: deleteResult } = renderHook( | ||
| () => queryManager.useDelete({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const targetId = existingUsers[0].id | ||
| deleteResult.current.mutate(targetId) | ||
| // Should immediately disappear from list | ||
| await waitFor(() => { | ||
| expect(listResult.current.items.find(item => item.id === targetId)).toBeUndefined() | ||
| }) | ||
| await waitFor(() => { | ||
| expect(deleteResult.current.isSuccess).toBe(true) | ||
| }) | ||
| }) | ||
| // it('should rollback optimistic delete on error', async () => { | ||
| // // Mock API to throw error BEFORE creating hook | ||
| // const originalDeleteFn = mockApi.deleteFn | ||
| // mockApi.deleteFn = jest.fn().mockRejectedValue(new Error('Delete failed')) | ||
| // // First, populate list cache | ||
| // const { result: listResult } = renderHook( | ||
| // () => queryManager.useList(), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // await waitFor(() => { | ||
| // expect(listResult.current.query.isSuccess).toBe(true) | ||
| // }) | ||
| // const initialItemCount = listResult.current.items.length | ||
| // const targetId = existingUsers[0].id | ||
| // const { result: deleteResult } = renderHook( | ||
| // () => queryManager.useDelete({ | ||
| // optimistic: true, | ||
| // onError: (error) => { | ||
| // console.log('Expected error:', error.message) | ||
| // } | ||
| // }), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // deleteResult.current.mutate(targetId) | ||
| // // Should temporarily disappear from list | ||
| // await waitFor(() => { | ||
| // expect(listResult.current.items.length).toBeLessThan(initialItemCount) | ||
| // }) | ||
| // // Should reappear on error | ||
| // await waitFor(() => { | ||
| // expect(deleteResult.current.isError).toBe(true) | ||
| // }) | ||
| // expect(listResult.current.items).toHaveLength(initialItemCount) | ||
| // expect(listResult.current.items.find(item => item.id === targetId)).toBeDefined() | ||
| // // Restore original function | ||
| // mockApi.deleteFn = originalDeleteFn | ||
| // }) | ||
| // it('should handle error in delete operation', async () => { | ||
| // // Mock API to throw error BEFORE creating hook | ||
| // const originalDeleteFn = mockApi.deleteFn | ||
| // mockApi.deleteFn = jest.fn().mockRejectedValue(new Error('Delete failed')) | ||
| // const { result } = renderHook( | ||
| // () => queryManager.useDelete({ | ||
| // onError: (error) => { | ||
| // console.log('Expected error:', error.message) | ||
| // } | ||
| // }), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // const targetId = existingUsers[0].id | ||
| // result.current.mutate(targetId) | ||
| // await waitFor(() => { | ||
| // expect(result.current.isError).toBe(true) | ||
| // }) | ||
| // expect(result.current.error).toEqual(new Error('Delete failed')) | ||
| // // Restore original function | ||
| // mockApi.deleteFn = originalDeleteFn | ||
| // }) | ||
| it('should call custom mutation callbacks', async () => { | ||
| const onMutate = jest.fn() | ||
| const onSuccess = jest.fn() | ||
| const onError = jest.fn() | ||
| const { result } = renderHook( | ||
| () => queryManager.useDelete({ onMutate, onSuccess, onError }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const targetId = existingUsers[0].id | ||
| result.current.mutate(targetId) | ||
| await waitFor(() => { | ||
| expect(result.current.isSuccess).toBe(true) | ||
| }) | ||
| expect(onMutate).toHaveBeenCalled() | ||
| expect(onSuccess).toHaveBeenCalled() | ||
| expect(onError).not.toHaveBeenCalled() | ||
| }) | ||
| // it('should restore item to original position on rollback', async () => { | ||
| // // Mock API to throw error BEFORE creating hook | ||
| // const originalDeleteFn = mockApi.deleteFn | ||
| // mockApi.deleteFn = jest.fn().mockRejectedValue(new Error('Delete failed')) | ||
| // // First, populate list cache | ||
| // const { result: listResult } = renderHook( | ||
| // () => queryManager.useList(), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // await waitFor(() => { | ||
| // expect(listResult.current.query.isSuccess).toBe(true) | ||
| // }) | ||
| // const originalItems = [...listResult.current.items] | ||
| // const targetId = existingUsers[1].id // Delete middle item | ||
| // const { result: deleteResult } = renderHook( | ||
| // () => queryManager.useDelete({ | ||
| // optimistic: true, | ||
| // onError: (error) => { | ||
| // console.log('Expected error:', error.message) | ||
| // } | ||
| // }), | ||
| // { wrapper: createWrapper(queryClient) } | ||
| // ) | ||
| // deleteResult.current.mutate(targetId) | ||
| // // Wait for error and rollback | ||
| // await waitFor(() => { | ||
| // expect(deleteResult.current.isError).toBe(true) | ||
| // }) | ||
| // // Items should be in original order | ||
| // expect(listResult.current.items.length).toBe(originalItems.length) | ||
| // expect(listResult.current.items.find(item => item.id === targetId)).toBeDefined() | ||
| // // Restore original function | ||
| // mockApi.deleteFn = originalDeleteFn | ||
| // }) | ||
| }) | ||
| describe('prefetchRetrieve', () => { | ||
| beforeEach(() => { | ||
| const mockUsers = createMockUsers(3) | ||
| mockApi.setUsers(mockUsers) | ||
| }) | ||
| it('should prefetch retrieve query', async () => { | ||
| const users = mockApi.getUsersArray() | ||
| const targetUser = users[0] | ||
| const prefetchSpy = spyOn(queryClient, 'prefetchQuery') | ||
| await queryManager.prefetchRetrieve(targetUser.id, { staleTime: 5000 }) | ||
| expect(prefetchSpy).toHaveBeenCalledWith({ | ||
| staleTime: 5000, | ||
| queryKey: ['users', 'retrieve', targetUser.id], | ||
| queryFn: expect.any(Function) | ||
| }) | ||
| }) | ||
| it('should execute prefetch query function correctly', async () => { | ||
| const users = mockApi.getUsersArray() | ||
| const targetUser = users[0] | ||
| await queryManager.prefetchRetrieve(targetUser.id) | ||
| // Verify data was prefetched correctly | ||
| const cachedData = queryClient.getQueryData(['users', 'retrieve', targetUser.id]) | ||
| expect(cachedData).toEqual(targetUser) | ||
| }) | ||
| }) | ||
| describe('integration scenarios', () => { | ||
| beforeEach(() => { | ||
| const mockUsers = createMockUsers(10) | ||
| mockApi.setUsers(mockUsers) | ||
| }) | ||
| it('should synchronize data between list and retrieve caches', async () => { | ||
| // This test needs to ensure proper synchronization implementation | ||
| // The issue is likely that updating one cache doesn't automatically sync the other | ||
| // First, populate list cache | ||
| const { result: listResult } = renderHook( | ||
| () => queryManager.useList(), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(listResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| const targetUser = listResult.current.items[0] | ||
| // Then retrieve specific item | ||
| const { result: retrieveResult } = renderHook( | ||
| () => queryManager.useRetrieve(targetUser.id), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(retrieveResult.current.query.isSuccess).toBe(true) | ||
| }) | ||
| // Update the user via mutations (not direct API) | ||
| const { result: updateResult } = renderHook( | ||
| () => queryManager.useUpdate({ optimistic: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| const updateData = { id: targetUser.id, name: 'Synchronized Update' } | ||
| updateResult.current.mutate(updateData) | ||
| await waitFor(() => { | ||
| expect(updateResult.current.isSuccess).toBe(true) | ||
| }) | ||
| // Both caches should reflect the update | ||
| await waitFor(() => { | ||
| expect(retrieveResult.current.item?.name).toBe('Synchronized Update') | ||
| expect(listResult.current.items.find(u => u.id === targetUser.id)?.name).toBe('Synchronized Update') | ||
| }) | ||
| }) | ||
| }) | ||
| }) |
| import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test" | ||
| import React from "react" | ||
| import { QueryClient, QueryClientProvider } from "@tanstack/react-query" | ||
| import { renderHook, act, waitFor } from "@testing-library/react" | ||
| import { QueryOperations } from "../lib/QueryOperations" | ||
| const createWrapper = (queryClient: QueryClient) => { | ||
| return ({ children }: { children: React.ReactNode }) => ( | ||
| <QueryClientProvider client={queryClient}> | ||
| {children} | ||
| </QueryClientProvider> | ||
| ) | ||
| } | ||
| describe("QueryOperations", () => { | ||
| let queryClient: QueryClient | ||
| let operations: QueryOperations<{}, {}> | ||
| beforeEach(() => { | ||
| queryClient = new QueryClient() | ||
| operations = new QueryOperations({ queryClient }) | ||
| }) | ||
| it("registers a mutation correctly", () => { | ||
| const fn = mock(() => Promise.resolve("ok")) | ||
| const ops = operations.mutation("createUser", fn) | ||
| expect(Object.keys(ops.mutations)).toContain("createUser") | ||
| expect(typeof ops.mutations.createUser).toBe("function") | ||
| }) | ||
| it("registers a query correctly", () => { | ||
| const fn = mock((id: string) => Promise.resolve({ id })) | ||
| const ops = operations.query("getUser", fn) | ||
| expect(Object.keys(ops.queries)).toContain("getUser") | ||
| expect(typeof ops.queries.getUser).toBe("function") | ||
| }) | ||
| it("getQueryKey includes params when passed", () => { | ||
| const ops = operations.query("getUser", async (id: string) => ({ id })) | ||
| expect(ops.getQueryKey("getUser", "123")).toEqual(["getUser", "123"]) | ||
| expect(ops.getQueryKey("getUser")).toEqual(["getUser"]) | ||
| }) | ||
| it("getMutationKey returns mutation key", () => { | ||
| const ops = operations.mutation("createUser", async (data: any) => data) | ||
| expect(ops.getMutationKey("createUser")).toEqual(["createUser"]) | ||
| }) | ||
| it("prefetchQuery calls queryClient.prefetchQuery", async () => { | ||
| const fn = mock((id: string) => Promise.resolve({ id })) | ||
| const ops = operations.query("getUser", fn) | ||
| const spy = spyOn(queryClient, "prefetchQuery") | ||
| await ops.prefetchQuery("getUser", "abc") | ||
| expect(spy).toHaveBeenCalled() | ||
| expect(spy.mock.calls[0][0].queryKey).toEqual(["getUser", "abc"]) | ||
| }) | ||
| it("getQueryData fetches from cache or prefetches", async () => { | ||
| const fn = async (id: string) => ({ id }) | ||
| const ops = operations.query("getUser", fn) | ||
| const spyPrefetch = spyOn(queryClient, "prefetchQuery") | ||
| const data = await ops.getQueryData("getUser", "123") | ||
| expect(data).toBeDefined() | ||
| expect(spyPrefetch).toHaveBeenCalled() | ||
| }) | ||
| describe("useMutation hook", () => { | ||
| it("runs a mutation and returns expected result", async () => { | ||
| const ops = operations.mutation("createUser", async (user: { name: string }) => { | ||
| return { id: "1", ...user } | ||
| }) | ||
| const { result } = renderHook( | ||
| () => ops.useMutation("createUser"), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| let response: any | ||
| await act(async () => { | ||
| response = await result.current.mutateAsync({ name: "John" }) | ||
| }) | ||
| expect(response).toEqual({ id: "1", name: "John" }) | ||
| }) | ||
| }) | ||
| describe("useQuery hook", () => { | ||
| it("fetches query result successfully", async () => { | ||
| const ops = operations.query("getUser", async (id: string) => { | ||
| return { id, name: "Alice" } | ||
| }) | ||
| const { result } = renderHook( | ||
| () => ops.useQuery("getUser", "42", { enabled: true }), | ||
| { wrapper: createWrapper(queryClient) } | ||
| ) | ||
| await waitFor(() => { | ||
| expect(result.current.data).toEqual({ id: "42", name: "Alice" }) | ||
| }) | ||
| }) | ||
| }) | ||
| }) |
| import { afterEach, beforeEach } from 'bun:test' | ||
| import { QueryClient } from '@tanstack/react-query' | ||
| import { cleanup } from '@testing-library/react' | ||
| export interface TestUser { | ||
| id: string | ||
| name: string | ||
| email: string | ||
| status: 'active' | 'inactive' | ||
| createdAt: string | ||
| } | ||
| export interface TestUserFilters { | ||
| status?: 'active' | 'inactive' | ||
| search?: string | ||
| } | ||
| export const createTestQueryClient = () => { | ||
| return new QueryClient({ | ||
| defaultOptions: { | ||
| queries: { | ||
| retry: false, | ||
| gcTime: 0, | ||
| }, | ||
| mutations: { | ||
| retry: false, | ||
| }, | ||
| }, | ||
| }) | ||
| } | ||
| export const createMockUser = (overrides: Partial<TestUser> = {}): TestUser => ({ | ||
| id: `user-${Date.now()}-${Math.random()}`, | ||
| name: 'John Doe', | ||
| email: 'john@example.com', | ||
| status: 'active', | ||
| createdAt: new Date().toISOString(), | ||
| ...overrides, | ||
| }) | ||
| export const createMockUsers = (count: number): TestUser[] => { | ||
| return Array.from({ length: count }, (_, i) => createMockUser({ | ||
| id: `user-${i + 1}`, | ||
| name: `User ${i + 1}`, | ||
| email: `user${i + 1}@example.com`, | ||
| })) | ||
| } | ||
| export const createMockApiFunctions = () => { | ||
| const users: TestUser[] = [] | ||
| return { | ||
| listFn: async (limit: number, offset: number, filters?: TestUserFilters) => { | ||
| let filteredUsers = [...users] | ||
| if (filters?.status) { | ||
| filteredUsers = filteredUsers.filter(user => user.status === filters.status) | ||
| } | ||
| if (filters?.search) { | ||
| filteredUsers = filteredUsers.filter(user => | ||
| user.name.toLowerCase().includes(filters.search!.toLowerCase()) || | ||
| user.email.toLowerCase().includes(filters.search!.toLowerCase()) | ||
| ) | ||
| } | ||
| return filteredUsers.slice(offset, offset + limit) | ||
| }, | ||
| retrieveFn: async (id: string) => { | ||
| const user = users.find(u => u.id === id) | ||
| if (!user) throw new Error('User not found') | ||
| return user | ||
| }, | ||
| createFn: async (data: Partial<TestUser>) => { | ||
| const newUser: TestUser = { | ||
| id: `user-${users?.length + 1}`, | ||
| name: data.name || 'New User', | ||
| email: data.email || 'new@example.com', | ||
| status: data.status || 'active', | ||
| createdAt: new Date().toISOString(), | ||
| } | ||
| users.push(newUser) | ||
| return newUser | ||
| }, | ||
| updateFn: async (data: Partial<TestUser>) => { | ||
| const userIndex = users.findIndex(u => u.id === data.id) | ||
| if (userIndex === -1) throw new Error('User not found') | ||
| users[userIndex] = { ...users[userIndex], ...data } | ||
| return users[userIndex] | ||
| }, | ||
| deleteFn: async (id: string) => { | ||
| const userIndex = users.findIndex(u => u.id === id) | ||
| if (userIndex === -1) throw new Error('User not found') | ||
| users.splice(userIndex, 1) | ||
| return id | ||
| }, | ||
| getUsersArray: () => [...users], | ||
| addUser: (user: TestUser) => users.push(user), | ||
| clearUsers: () => users.splice(0, users.length), | ||
| setUsers: (newUsers: TestUser[]) => { | ||
| users.splice(0, users.length, ...newUsers) | ||
| } | ||
| } | ||
| } | ||
| beforeEach(() => { }) | ||
| afterEach(() => { | ||
| cleanup() | ||
| }) | ||
| export const waitFor = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) |
| import { UseInfiniteQueryResult, useQueryClient } from '@tanstack/react-query' | ||
| export type QueryItem = { | ||
| id: string | number | ||
| } | ||
| export type PageParam = number | ||
| export type ListPaginationResponse<T extends QueryItem> = T[] | ||
| export type QueryManagerOptions<T extends QueryItem, F> = { | ||
| name: string | ||
| queryClient: ReturnType<typeof useQueryClient> | ||
| listFn?: (limit: number, offset: number, filters: F) => Promise<ListPaginationResponse<T>> | ||
| listLimit?: number | ||
| useListEffect?: (listQuery: UseInfiniteQueryResult<{ | ||
| pageParams: number[] | ||
| pages: ListPaginationResponse<T>[] | ||
| allItems: T[] | ||
| }, Error>) => void | ||
| retrieveFn?: (id: T['id']) => Promise<T> | ||
| createFn?: (data: Partial<T>) => Promise<T> | ||
| updateFn?: (data: Partial<T>) => Promise<T> | ||
| deleteFn?: (id: T['id']) => Promise<T['id']> | ||
| } |
| import { UseMutationOptions } from '@tanstack/react-query' | ||
| import { QueryItem } from './core' | ||
| import { RemovedItemMap } from './utility' | ||
| export type CreateMutationCtx = { | ||
| tempId?: QueryItem['id'] | ||
| } | ||
| export type CreateMutationOptions<T extends QueryItem, F> = Omit< | ||
| UseMutationOptions<T, Error, Partial<T>, CreateMutationCtx>, | ||
| 'mutationKey' | 'mutationFn' | ||
| > & { | ||
| optimistic?: boolean | ||
| listFilters?: F | ||
| appendTo?: RemovedItemMap | 'start' | 'end' | ||
| } |
| import { UseMutationOptions } from '@tanstack/react-query' | ||
| import { QueryItem } from './core' | ||
| import { RemovedItemMap } from './utility' | ||
| export type DeleteMutationCtx<T> = { | ||
| previousItem: T | undefined | ||
| removedAt: RemovedItemMap | ||
| } | ||
| export type DeleteMutationOptions<T extends QueryItem, F> = Omit< | ||
| UseMutationOptions<unknown, Error, T['id'], DeleteMutationCtx<T>>, | ||
| 'mutationKey' | 'mutationFn' | ||
| > & { | ||
| optimistic?: boolean | ||
| } |
| export * from './core' | ||
| export * from './list' | ||
| export * from './utility' | ||
| export * from './retrieve' | ||
| export * from './create' | ||
| export * from './update' | ||
| export * from './delete' |
| import { QueryKey, UseInfiniteQueryOptions } from '@tanstack/react-query' | ||
| import { ListPaginationResponse, PageParam, QueryItem } from './core' | ||
| export type ListSelector<T extends QueryItem> = { | ||
| pageParams: PageParam[] | ||
| pages: ListPaginationResponse<T>[] | ||
| allItems: T[] | ||
| } | ||
| type InfiniteQueryOptions<T extends QueryItem> = UseInfiniteQueryOptions< | ||
| ListPaginationResponse<T>, | ||
| Error, | ||
| ListSelector<T>, | ||
| QueryKey, | ||
| PageParam | ||
| > | ||
| export type ListQueryOptions<T extends QueryItem, F> = Omit< | ||
| InfiniteQueryOptions<T>, | ||
| 'queryKey' | 'queryFn' | 'initialPageParam' | 'getNextPageParam' | 'getPreviousPageParam' | 'select' | ||
| > & { | ||
| limit?: number | ||
| filters?: F | ||
| } |
| import { QueryKey, UndefinedInitialDataOptions } from '@tanstack/react-query' | ||
| import { QueryItem } from './core' | ||
| export type RetrieveQueryOptions<T extends QueryItem> = Omit< | ||
| UndefinedInitialDataOptions<T, Error, T, QueryKey>, | ||
| 'queryKey' | 'queryFn' | ||
| > |
| import { UseMutationOptions } from '@tanstack/react-query' | ||
| import { QueryItem } from './core' | ||
| export type UpdateMutationCtx<T> = { | ||
| previousItem: T | undefined | ||
| optimisticItem: T | undefined | ||
| } | ||
| export type UpdateMutationOptions<T extends QueryItem, F> = Omit< | ||
| UseMutationOptions<T, Error, Partial<T>, UpdateMutationCtx<T>>, | ||
| 'mutationKey' | 'mutationFn' | ||
| > & { | ||
| optimistic?: boolean | ||
| } |
| import { QueryItem } from './core'; | ||
| import { QueryKey, useQueryClient } from '@tanstack/react-query' | ||
| export type QueryClient = ReturnType<typeof useQueryClient> | ||
| export type WithTempId<T extends QueryItem> = T & { | ||
| tempId?: QueryItem['id'] | ||
| } | ||
| export type ItemPosition = { | ||
| pageIndex: number | ||
| itemIndex: number | ||
| } | ||
| export type RemovedItemMap = [QueryKey, ItemPosition][] | ||
| export type PaginationResponse<T extends QueryItem> = { | ||
| count: number | ||
| next: string | ||
| previous: string | ||
| results: T[] | ||
| } |
| export * from './misc' |
| function uuidV4() { | ||
| function randomHex() { | ||
| return Math.floor(Math.random() * 16).toString(16); | ||
| } | ||
| let uuid = '' | ||
| for (let i = 0; i < 8; i++) { | ||
| uuid += randomHex() | ||
| } | ||
| uuid += '-' | ||
| for (let i = 0; i < 4; i++) { | ||
| uuid += randomHex() | ||
| } | ||
| uuid += '-' | ||
| uuid += '4'; | ||
| for (let i = 0; i < 3; i++) { | ||
| uuid += randomHex() | ||
| } | ||
| uuid += '-' | ||
| uuid += ['8', '9', 'a', 'b'][Math.floor(Math.random() * 4)]; | ||
| for (let i = 0; i < 3; i++) { | ||
| uuid += randomHex() | ||
| } | ||
| uuid += '-' | ||
| for (let i = 0; i < 12; i++) { | ||
| uuid += randomHex() | ||
| } | ||
| return uuid | ||
| } | ||
| export const tempIdPrefix = 'temp-id' | ||
| export const generateTempId = () => { | ||
| return tempIdPrefix + '-' + uuidV4() | ||
| } |
+6
-10
| { | ||
| "name": "@codeleap/query", | ||
| "version": "5.8.3", | ||
| "version": "5.8.4", | ||
| "main": "src/index.ts", | ||
@@ -12,6 +12,4 @@ "license": "UNLICENSED", | ||
| "devDependencies": { | ||
| "@codeleap/config": "5.8.3", | ||
| "@codeleap/types": "5.8.3", | ||
| "@codeleap/utils": "5.8.3", | ||
| "@codeleap/hooks": "5.8.3", | ||
| "@codeleap/config": "5.8.4", | ||
| "@codeleap/types": "5.8.4", | ||
| "ts-node-dev": "1.1.8" | ||
@@ -23,11 +21,9 @@ }, | ||
| "peerDependencies": { | ||
| "@codeleap/types": "5.8.3", | ||
| "@codeleap/utils": "5.8.3", | ||
| "@codeleap/hooks": "5.8.3", | ||
| "@codeleap/types": "5.8.4", | ||
| "typescript": "5.5.2", | ||
| "@tanstack/react-query": "5.60.6" | ||
| "@tanstack/react-query": "5.89.0" | ||
| }, | ||
| "dependencies": { | ||
| "immer": "10.1.1" | ||
| "fast-deep-equal": "3.1.3" | ||
| } | ||
| } |
+2
-8
@@ -1,9 +0,3 @@ | ||
| export * from './QueryManager' | ||
| export * from './queryClient' | ||
| export * from './lib' | ||
| export * from './types' | ||
| import * as ReactQuery from '@tanstack/react-query' | ||
| export { | ||
| ReactQuery | ||
| } | ||
| export * from './factors' |
| import * as ReactQuery from '@tanstack/react-query' | ||
| import { waitFor } from '@codeleap/utils' | ||
| import { QueryManagerOptions, QueryManagerItem } from './types' | ||
| import { QueryManager } from './QueryManager' | ||
| export type QueryKeyBuilder<Args extends any[] = any[]> = (...args:Args) => ReactQuery.QueryKey | ||
| type PollingResult<T> = { | ||
| stop: boolean | ||
| data: T | ||
| } | ||
| type PollingCallback<T, R> = (query: ReactQuery.Query<T>, count: number, prev?: R) => Promise<PollingResult<R>> | ||
| type PollQueryOptions<T, R> = { | ||
| interval: number | ||
| callback: PollingCallback<T, R> | ||
| leading?: boolean | ||
| initialData?: R | ||
| } | ||
| interface EnhancedQuery<T> extends ReactQuery.Query<T> { | ||
| waitForRefresh(): Promise<ReactQuery.Query<T>> | ||
| listen(callback: (e: ReactQuery.QueryCacheNotifyEvent) => void): () => void | ||
| refresh(): Promise<T> | ||
| poll<R>( | ||
| options: PollQueryOptions<T, R> | ||
| ): Promise<R> | ||
| getData(): T | ||
| ensureData(options?: Partial<ReactQuery.EnsureQueryDataOptions<T, Error, T, ReactQuery.QueryKey, never>>): Promise<T> | ||
| key: ReactQuery.QueryKey | ||
| } | ||
| type DynamicEnhancedQuery<T, BuilderArgs extends any[]> = { | ||
| [P in keyof EnhancedQuery<T>]: (...args: BuilderArgs) => EnhancedQuery<T>[P] | ||
| } | ||
| export class CodeleapQueryClient { | ||
| constructor(public client: ReactQuery.QueryClient) { | ||
| } | ||
| listenToQuery(key: ReactQuery.QueryKey, callback: (e: ReactQuery.QueryCacheNotifyEvent) => void) { | ||
| const cache = this.client.getQueryCache() | ||
| const query = cache.find({ exact: true, queryKey: key }) | ||
| if (!query) { | ||
| return | ||
| } | ||
| const removeListener = cache.subscribe((e) => { | ||
| const matches = ReactQuery.matchQuery({ exact: true, queryKey: key }, e.query) | ||
| if (matches) { | ||
| callback(e) | ||
| } | ||
| }) | ||
| return removeListener | ||
| } | ||
| async pollQuery<T, R>( | ||
| key: ReactQuery.QueryKey, | ||
| options: PollQueryOptions<T, R>, | ||
| ) { | ||
| const { interval, callback, initialData, leading = false } = options | ||
| const cache = this.client.getQueryCache() | ||
| const initialQuery = cache.find({ exact: true, queryKey: key }) | ||
| if (!initialQuery) { | ||
| return Promise.reject(new Error('Query not found')) | ||
| } | ||
| let count = 0 | ||
| let result: PollingResult<R> = { | ||
| stop: false, | ||
| data: initialData, | ||
| } | ||
| while (!result?.stop) { | ||
| const shouldWait = count > 0 || leading | ||
| if (shouldWait) { | ||
| await waitFor(interval) | ||
| } | ||
| this.client.refetchQueries({ | ||
| exact: true, | ||
| queryKey: key, | ||
| }) | ||
| const newQuery = await this.waitForRefresh<T>(key) | ||
| const newResult = await callback(newQuery, count, result?.data) | ||
| count += 1 | ||
| result = newResult | ||
| } | ||
| return result?.data | ||
| } | ||
| queryProxy<T>(key: ReactQuery.QueryKey) { | ||
| const getClient = () => this | ||
| return new Proxy<EnhancedQuery<T>>({} as EnhancedQuery<T>, { | ||
| get(target, p, receiver) { | ||
| const client = getClient() | ||
| // these don't need the actual query | ||
| switch (p) { | ||
| case 'key': | ||
| return key | ||
| case 'getData': | ||
| return () => { | ||
| return client.client.getQueryData<T>(key) | ||
| } | ||
| default: | ||
| break | ||
| } | ||
| const cache = client.client.getQueryCache() | ||
| const query = cache.find({ exact: true, queryKey: key }) | ||
| if (!query) { | ||
| console.warn(`Attempt to access property ${String(p)} on undefined query with key`, key) | ||
| return undefined | ||
| } | ||
| switch (p) { | ||
| case 'waitForRefresh': | ||
| return () => { | ||
| return client.waitForRefresh<T>(key) | ||
| } | ||
| case 'listen': | ||
| return (callback: (e: ReactQuery.QueryCacheNotifyEvent) => void) => { | ||
| return client.listenToQuery(key, callback) | ||
| } | ||
| case 'ensureData': | ||
| return (options) => { | ||
| return client.client.ensureQueryData<T>({ | ||
| queryKey: key, | ||
| ...options | ||
| }) | ||
| } | ||
| case 'refresh': | ||
| return async () => { | ||
| client.client.refetchQueries({ | ||
| exact: true, | ||
| queryKey: key, | ||
| }) | ||
| const newQuery = await client.waitForRefresh<T>(key) | ||
| return newQuery.state.data | ||
| } | ||
| case 'poll': | ||
| return (options: PollQueryOptions<T, any>) => { | ||
| return client.pollQuery(key, options) | ||
| } | ||
| default: | ||
| return Reflect.get(query, p, receiver) | ||
| } | ||
| }, | ||
| }) | ||
| } | ||
| waitForRefresh<T>(key: ReactQuery.QueryKey) { | ||
| const initialQuery = this.client.getQueryCache().find({ exact: true, queryKey: key }) | ||
| if (!initialQuery) { | ||
| return Promise.reject(new Error('Query not found')) | ||
| } | ||
| const updateTime = initialQuery.state.dataUpdatedAt | ||
| const errorTime = initialQuery.state.errorUpdatedAt | ||
| return new Promise<ReactQuery.Query<T>>((resolve, reject) => { | ||
| const removeListener = this.listenToQuery(key, (e) => { | ||
| const query = e.query | ||
| const isNewer = query.state.dataUpdatedAt > updateTime || query.state.errorUpdatedAt > errorTime | ||
| const isIdle = query.state.fetchStatus === 'idle' | ||
| const isSuccess = query.state.status === 'success' | ||
| const isError = query.state.status === 'error' | ||
| const isResolved = isSuccess || isError | ||
| if (isNewer && isIdle && isResolved) { | ||
| if (isSuccess) { | ||
| resolve(query) | ||
| } else { | ||
| reject() | ||
| } | ||
| removeListener() | ||
| } | ||
| }) | ||
| }) | ||
| } | ||
| queryKey<Data>(k: ReactQuery.QueryKey, options?: ReactQuery.QueryOptions<Data>) { | ||
| if(options){ | ||
| this.client.setQueryDefaults(k, options) | ||
| const cache = this.client.getQueryCache() | ||
| const q = new ReactQuery.Query({ | ||
| cache, | ||
| queryKey: k, | ||
| queryHash: ReactQuery.hashKey(k), | ||
| ...options, | ||
| }) | ||
| cache.add(q) | ||
| } | ||
| return this.queryProxy<Data>(k) | ||
| } | ||
| dynamicQueryKey<Data, BuilderArgs extends any[] = any[]>(k: QueryKeyBuilder<BuilderArgs>) { | ||
| const getClient = () => this | ||
| return new Proxy<DynamicEnhancedQuery<Data, BuilderArgs>>({} as DynamicEnhancedQuery<Data, BuilderArgs>, { | ||
| get(target, p, receiver) { | ||
| return (...params:BuilderArgs) => { | ||
| const key = k(...params) | ||
| const proxy = getClient().queryProxy<Data>(key) | ||
| return Reflect.get(proxy, p, receiver) | ||
| } | ||
| }, | ||
| }) | ||
| } | ||
| queryManager<T extends QueryManagerItem, Args>(name:string, options: Partial<QueryManagerOptions<T, Args>>) { | ||
| // @ts-expect-error | ||
| const m = new QueryManager<T, Args>({ | ||
| name, | ||
| queryClient: this.client, | ||
| ...options, | ||
| }) | ||
| return m | ||
| } | ||
| } |
| /* eslint-disable max-lines */ | ||
| import { | ||
| useQuery, | ||
| useMutation, | ||
| useInfiniteQuery, | ||
| hashKey, | ||
| QueryKey, | ||
| } from '@tanstack/react-query' | ||
| import { Draft, produce } from 'immer' | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react' | ||
| import { deepMerge } from '@codeleap/utils' | ||
| import { usePromise } from '@codeleap/hooks' | ||
| import { TypeGuards } from '@codeleap/types' | ||
| import { | ||
| QueryManagerItem, | ||
| AppendToPagination, | ||
| CreateOptions, | ||
| QueryManagerOptions, | ||
| MutationCtx, | ||
| QueryStateValue, | ||
| InfinitePaginationData, | ||
| QueryManagerActions, | ||
| UpdateOptions, | ||
| UseManagerArgs, | ||
| GetItemOptions, | ||
| DeleteOptions, | ||
| SettableOptions, | ||
| QueryManagerMeta, | ||
| ListOptions, | ||
| RetrieveOptions, | ||
| OptionChangeListener, | ||
| QueryManagerActionTriggers, | ||
| UseActionOptions, | ||
| PaginationResponse, | ||
| UseListSelector, | ||
| PageParam, | ||
| } from './types' | ||
| export * from './types' | ||
| export class QueryManager< | ||
| T extends QueryManagerItem, | ||
| ExtraArgs extends Record<string, any> = any, | ||
| Meta extends QueryManagerMeta = QueryManagerMeta, | ||
| Actions extends QueryManagerActions<T, ExtraArgs, Meta> = QueryManagerActions<T, ExtraArgs, Meta> | ||
| > { | ||
| options: QueryManagerOptions<T, ExtraArgs, Meta, Actions> | ||
| meta: Meta | ||
| itemMap: Record<T['id'], T> | ||
| queryStates: Record<string, QueryStateValue<T>> = {} | ||
| optionListeners: OptionChangeListener<QueryManagerOptions<T, ExtraArgs, Meta, Actions>>[] | ||
| constructor(options: QueryManagerOptions<T, ExtraArgs, Meta, Actions>) { | ||
| this.options = options | ||
| this.itemMap = {} as Record<T['id'], T> | ||
| this.meta = options?.initialMeta | ||
| this.optionListeners = [] | ||
| this.queryStates = { | ||
| [hashKey(this.filteredQueryKey())]: { | ||
| itemIndexes: {}, | ||
| pagesById: {}, | ||
| key: this.queryKeys.list, | ||
| } as unknown as QueryStateValue<T>, | ||
| } | ||
| } | ||
| extractKey(item:T) { | ||
| return this.options?.keyExtractor?.(item) ?? item.id | ||
| } | ||
| get keySuffixes() { | ||
| return { | ||
| list: 'list', | ||
| infiniteList: 'infinite-list', | ||
| create: 'create', | ||
| update: 'update', | ||
| delete: 'delete', | ||
| retrieve: 'retrieve', | ||
| } | ||
| } | ||
| get actions() { | ||
| const actions = this.options.actions ?? {} as Actions | ||
| const actionKeys = Object.keys(actions) | ||
| const actionFunctions = actionKeys.reduce((acc, key) => { | ||
| const action = actions[key] | ||
| // @ts-ignore | ||
| acc[key] = (...args: any[]) => { | ||
| return action(this, ...args) | ||
| } | ||
| return acc | ||
| }, {} as QueryManagerActionTriggers<Actions>) | ||
| return actionFunctions | ||
| } | ||
| generateId() { | ||
| return this.options.generateId?.() ?? this.knownItemCount()?.count + 1 | ||
| } | ||
| useOptions(cb?: OptionChangeListener<QueryManagerOptions<T, ExtraArgs, Meta, Actions>>) { | ||
| const [options, setOptions] = useState(this.options) | ||
| const [meta, setMeta] = useState(this.meta) | ||
| useEffect(() => { | ||
| const idx = this.optionListeners.push((o, meta) => { | ||
| setOptions(o) | ||
| setMeta(meta) | ||
| cb?.(o, meta) | ||
| }) - 1 | ||
| return () => { | ||
| this.optionListeners.splice(idx, 1) | ||
| } | ||
| }) | ||
| return [options, meta] as const | ||
| } | ||
| async updateItems(items: T | T[], settingIds?: T['id'] | T['id'][]) { | ||
| const itemArr = Array.isArray(items) ? items : [items] | ||
| const setIdsTo = TypeGuards.isNil(settingIds) ? settingIds : TypeGuards.isArray(settingIds) ? settingIds : [settingIds] | ||
| const newItems = [] | ||
| const ids = itemArr.map((i, idx) => { | ||
| const id = this.extractKey(i) | ||
| let newId = id | ||
| let withNewId = i | ||
| if (setIdsTo?.[idx]) { | ||
| newId = setIdsTo[idx] | ||
| withNewId = { | ||
| ...(this.itemMap[id] ?? {}), | ||
| ...i, | ||
| id: newId, | ||
| } | ||
| delete this.itemMap[id] | ||
| } | ||
| this.itemMap[withNewId.id] = withNewId | ||
| this.queryClient.setQueryData<T>(this.queryKeyFor(id), (old) => { | ||
| return withNewId | ||
| }) | ||
| if (!TypeGuards.isNil(newId)) { | ||
| this.queryClient.setQueryData<T>(this.queryKeyFor(newId), (old) => { | ||
| return withNewId | ||
| }) | ||
| } | ||
| newItems.push(withNewId) | ||
| return id | ||
| }) | ||
| const promises = Object.values(this.queryStates).map(async ({ key, pagesById }) => { | ||
| this.queryClient.setQueryData<InfinitePaginationData<T>>(key, (old) => { | ||
| if (!old) return old | ||
| ids.forEach((id, idx) => { | ||
| if (!pagesById[id]) return | ||
| const [pageIdx, itemIdx] = pagesById[id] | ||
| const newItem = newItems[idx] | ||
| old.pages[pageIdx].results[itemIdx] = newItem | ||
| }) | ||
| this.transformData(old, key) | ||
| return old | ||
| }) | ||
| }) | ||
| await Promise.all(promises) | ||
| } | ||
| async getItem(itemId: T['id'], options?: GetItemOptions<T>) { | ||
| const i = this.itemMap[itemId] | ||
| if ((!i && !!options?.fetchOnNotFoud) || options?.forceRefetch) { | ||
| const item = await this.options.retrieveItem(itemId) | ||
| this.updateItems(item) | ||
| return item | ||
| } | ||
| return i | ||
| } | ||
| addItem: AppendToPagination<T, ExtraArgs> = async (args) => { | ||
| const updateOnList = TypeGuards.isUndefined(args?.onListsWithFilters) ? undefined : hashKey( | ||
| this.filteredQueryKey(args.onListsWithFilters), | ||
| ) | ||
| const promises = Object.entries(this.queryStates).map(async ([hashedKey, { key }]) => { | ||
| if (!TypeGuards.isUndefined(updateOnList)) { | ||
| if (updateOnList !== hashedKey) { | ||
| return | ||
| } | ||
| } | ||
| this.queryClient.setQueryData<InfinitePaginationData<T>>(key, (_old) => { | ||
| const newData = produce(_old, (old) => { | ||
| if (!old?.pages?.length || (old?.pages?.length > 1 && old?.pages?.every(page => page.results.length <= 0))) { | ||
| old = { | ||
| pageParams: [], | ||
| pages: [ | ||
| { | ||
| results: [], | ||
| count: 0, | ||
| next: null, | ||
| previous: null, | ||
| }, | ||
| ], | ||
| } | ||
| } | ||
| const itemsToAppend = (TypeGuards.isArray(args.item) ? args.item : [args.item]) as Draft<T>[] | ||
| if (args.to === 'end') { | ||
| const idx = old.pages.length - 1 | ||
| old.pages[idx].results.push(...itemsToAppend) | ||
| old.pages[idx].count += itemsToAppend.length | ||
| if (old.pageParams[idx]) { | ||
| // @ts-ignore | ||
| old.pageParams[idx].limit += itemsToAppend.length | ||
| } else { | ||
| old.pageParams[idx] = { | ||
| limit: this.options?.limit ?? itemsToAppend.length, | ||
| offset: 0, | ||
| } | ||
| } | ||
| } else if (args.to === 'start') { | ||
| old.pages[0].results.unshift(...itemsToAppend) | ||
| // @ts-ignore | ||
| if (old.pageParams[0]) { | ||
| // @ts-ignore | ||
| old.pageParams[0].offset -= itemsToAppend.length | ||
| // @ts-ignore | ||
| old.pageParams[0].limit += itemsToAppend.length | ||
| } else { | ||
| old.pageParams[0] = { | ||
| limit: itemsToAppend.length, | ||
| offset: -itemsToAppend.length, | ||
| } | ||
| } | ||
| } else if (!!args.to) { | ||
| const appendTo = TypeGuards.isArray(args.to) ? args.to : args.to[hashedKey] | ||
| const [pageIdx, itemIdx] = appendTo | ||
| old.pages[pageIdx].results.splice(itemIdx, 0, ...itemsToAppend) | ||
| if (old.pageParams[pageIdx]) { | ||
| // @ts-ignore | ||
| old.pageParams[pageIdx].offset -= itemsToAppend.length | ||
| // @ts-ignore | ||
| old.pageParams[pageIdx].limit += itemsToAppend.length | ||
| } else { | ||
| old.pageParams[pageIdx] = { | ||
| limit: itemsToAppend.length, | ||
| offset: -itemsToAppend.length, | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| this.transformData(newData, key) | ||
| return newData | ||
| }) | ||
| }) | ||
| await Promise.all(promises) | ||
| } | ||
| useAction<T extends keyof Actions>( | ||
| action: T, | ||
| options?: UseActionOptions<Actions[T]>, | ||
| ) { | ||
| const mut = useMutation({ | ||
| mutationKey: [this.name, action, options?.mutationKey], | ||
| mutationFn: (vars) => this.options.actions[action](this, vars), | ||
| ...options, | ||
| }) | ||
| return mut | ||
| } | ||
| async removeItem(itemId: T['id']) { | ||
| const removedPositions = {} as Record<string, [number, number]> | ||
| const promises = Object.entries(this.queryStates).map(async ([hashedKey, { key, pagesById }]) => { | ||
| this.queryClient.setQueryData<InfinitePaginationData<T>>(key, (old) => { | ||
| const [itemPage, itemIdx] = pagesById[itemId] | ||
| old.pages[itemPage].results.splice(itemIdx, 1) | ||
| // @ts-ignore | ||
| old.pageParams[itemPage].limit -= 1 | ||
| removedPositions[hashedKey] = [itemPage, itemIdx] | ||
| return old | ||
| }) | ||
| }) | ||
| await Promise.all(promises) | ||
| return removedPositions | ||
| } | ||
| transformData(data: InfinitePaginationData<T>, key: QueryKey) { | ||
| const pagesById = {} as Record<T['id'], [number, number]> | ||
| const flatItems = [] as T[] | ||
| const itemIndexes = {} as Record<T['id'], number> | ||
| const hashedKey = hashKey(key) | ||
| let pageIdx = 0 | ||
| for (const page of data?.pages ?? []) { | ||
| page.results.forEach((i, itemIdx) => { | ||
| const flatIdx = flatItems.length | ||
| const itemId = i.id | ||
| const include = true | ||
| if (include) { | ||
| flatItems.push(i) | ||
| } | ||
| pagesById[itemId] = [pageIdx, itemIdx] | ||
| this.itemMap[itemId] = i | ||
| itemIndexes[itemId] = flatIdx | ||
| }) | ||
| pageIdx += 1 | ||
| } | ||
| this.queryStates[hashedKey] = { | ||
| itemIndexes: { ...this.queryStates[hashedKey]?.itemIndexes, ...itemIndexes }, | ||
| pagesById: { ...this.queryStates[hashedKey]?.pagesById, ...pagesById }, | ||
| key, | ||
| } | ||
| return { | ||
| itemMap: this.itemMap, | ||
| pagesById, | ||
| itemIndexes, | ||
| itemList: flatItems, | ||
| } | ||
| } | ||
| get queryKeys() { | ||
| return { | ||
| list: [this.name, this.keySuffixes.list], | ||
| // infiniteList: [this.name, this.keySuffixes.infiniteList], | ||
| create: [this.name, this.keySuffixes.create], | ||
| update: [this.name, this.keySuffixes.update], | ||
| delete: [this.name, this.keySuffixes.delete], | ||
| retrieve: [this.name, this.keySuffixes.retrieve], | ||
| } | ||
| } | ||
| get name() { | ||
| return this.options.name | ||
| } | ||
| get standardLimit() { | ||
| return this.options.limit ?? 10 | ||
| } | ||
| get queryClient() { | ||
| return this.options.queryClient | ||
| } | ||
| getList(forFilters?: ExtraArgs) { | ||
| return this.queryClient.getQueryData<InfinitePaginationData<T>>(this.filteredQueryKey(forFilters)) | ||
| } | ||
| getListState(forFilters?: ExtraArgs) { | ||
| return this.queryClient.getQueryState<InfinitePaginationData<T>>(this.filteredQueryKey(forFilters)) | ||
| } | ||
| getItemCount(forFilters?: ExtraArgs) { | ||
| return this.getList(forFilters)?.pages.reduce((acc, p) => Math.max(p.count, acc), 0) | ||
| } | ||
| knownItemCount() { | ||
| const res = Object.values(this.queryStates).reduce<{ count: number; lastDatetime?: number }>((acc, { key }) => { | ||
| const state = this.queryClient.getQueryState<InfinitePaginationData<T>>(key) | ||
| const lastDatetime = acc.lastDatetime ?? 0 | ||
| if (state?.dataUpdatedAt > lastDatetime) { | ||
| acc.lastDatetime = state.dataUpdatedAt | ||
| const totals = state.data?.pages.reduce((acc, p) => Math.max(p.count, acc), 0) | ||
| acc.count = totals | ||
| } | ||
| return acc | ||
| }, { | ||
| count: 0, | ||
| lastDatetime: null, | ||
| }) | ||
| return res | ||
| } | ||
| queryKeyFor(itemId: T['id']) { | ||
| return [this.name, this.keySuffixes.retrieve, itemId] | ||
| } | ||
| filteredQueryKey(filters = {} as ExtraArgs) { | ||
| return [...this.queryKeys.list, filters] | ||
| } | ||
| useList(options: ListOptions<T, ExtraArgs> = {}) { | ||
| const [isRefreshing, setRefreshing] = useState(false) | ||
| const { | ||
| filter = {} as ExtraArgs, | ||
| queryOptions, | ||
| limit = this.standardLimit, | ||
| } = options | ||
| const queryKey = this.filteredQueryKey(filter) | ||
| const hashedKey = hashKey(queryKey) | ||
| const useListEffect = this.options?.useListEffect ?? (() => null) | ||
| const select:ListOptions<T, ExtraArgs>['queryOptions']['select'] = useCallback((data) => { | ||
| const { itemList } = this.transformData(data, queryKey) | ||
| return { | ||
| pageParams: data.pageParams, | ||
| pages: data?.pages ?? [], | ||
| flatItems: itemList, | ||
| } | ||
| }, []) | ||
| const query = useInfiniteQuery<PaginationResponse<T>, Error, UseListSelector<T>, QueryKey, PageParam>({ | ||
| queryKey, | ||
| initialPageParam: { | ||
| limit, | ||
| offset: 0, | ||
| }, | ||
| queryFn: async (query) => { | ||
| return this.options.listItems(limit, query.pageParam?.offset ?? 0, filter) | ||
| }, | ||
| refetchOnMount: (query) => { | ||
| if (TypeGuards.isBoolean(queryOptions?.refetchOnMount) || TypeGuards.isString(queryOptions?.refetchOnMount)) { | ||
| return queryOptions?.refetchOnMount | ||
| } | ||
| return query.state.dataUpdateCount === 0 || query.isStaleByTime() | ||
| }, | ||
| getNextPageParam: (lastPage, pages) => { | ||
| const currentTotal = pages.reduce((acc, p) => p.results.length + acc, 0) | ||
| if (currentTotal >= (lastPage?.count || Infinity)) { | ||
| return undefined | ||
| } | ||
| return { | ||
| limit: limit, | ||
| offset: currentTotal, | ||
| } | ||
| }, | ||
| getPreviousPageParam: (lastPage, pages) => { | ||
| const currentTotal = pages.reduce((acc, p) => p.results.length + acc, 0) | ||
| if (currentTotal >= (lastPage?.count || Infinity)) { | ||
| return undefined | ||
| } | ||
| return { | ||
| limit: limit, | ||
| offset: currentTotal, | ||
| } | ||
| }, | ||
| select, | ||
| ...queryOptions, | ||
| }) | ||
| const refresh = async () => { | ||
| setRefreshing(true) | ||
| await this.refresh(filter) | ||
| setRefreshing(false) | ||
| } | ||
| const listEffect = useListEffect({ | ||
| query, | ||
| refreshQuery: (silent = true) => silent ? this.refresh(filter) : refresh(), | ||
| cancelQuery: () => this.queryClient.cancelQueries({ queryKey }), | ||
| }) | ||
| const itemCount = useMemo(() => { | ||
| return query.data?.pages.reduce((acc, p) => Math.max(p.count, acc), 0) | ||
| }, [query.data]) | ||
| // @ts-ignore | ||
| const items = query.data?.flatItems ?? [] | ||
| return { | ||
| items: items as T[], | ||
| query, | ||
| getNextPage: query.fetchNextPage, | ||
| getPreviousPage: query.fetchPreviousPage, | ||
| refresh, | ||
| isRefreshing, | ||
| itemMap: this.itemMap, | ||
| pagesById: this.queryStates[hashedKey]?.pagesById ?? {}, | ||
| itemIndexes: this.queryStates[hashedKey]?.itemIndexes ?? {}, | ||
| itemCount, | ||
| } | ||
| } | ||
| useRetrieve(options?: RetrieveOptions<T>) { | ||
| const [isRefreshing, setRefreshing] = useState(false) | ||
| const itemId = options?.id | ||
| const select:RetrieveOptions<T>['queryOptions']['select'] = useCallback((data) => { | ||
| this.updateItems(data) | ||
| return data | ||
| }, []) | ||
| const query = useQuery({ | ||
| queryKey: this.queryKeyFor(itemId), | ||
| initialData: () => { | ||
| return this.itemMap[itemId] | ||
| }, | ||
| queryFn: () => { | ||
| return this.options.retrieveItem(itemId) | ||
| }, | ||
| select, | ||
| ...options?.queryOptions, | ||
| }) | ||
| const refresh = async () => { | ||
| setRefreshing(true) | ||
| await this.refreshItem(itemId) | ||
| setRefreshing(false) | ||
| } | ||
| return { | ||
| data: query.data, | ||
| query, | ||
| refresh, | ||
| isRefreshing, | ||
| } | ||
| } | ||
| useItem(options?: RetrieveOptions<T>) { | ||
| return this.useRetrieve(options) | ||
| } | ||
| useCreate(options?: CreateOptions<T>) { | ||
| const [managerOptions, meta] = this.useOptions() | ||
| const tmpOptions = useRef<CreateOptions<T>>(options ?? managerOptions.creation ?? { | ||
| appendTo: 'start', | ||
| optimistic: false, | ||
| }) | ||
| const getOptimisticItem = usePromise<T>({ | ||
| timeout: 1200, | ||
| }) | ||
| const query = useMutation({ | ||
| ...options?.mutationOptions, | ||
| mutationFn: (data: Partial<T>) => { | ||
| return this.options.createItem(data) | ||
| }, | ||
| mutationKey: this.queryKeys.create, | ||
| onMutate: async (data) => { | ||
| options?.mutationOptions?.onMutate?.(data) | ||
| if (!!tmpOptions?.current?.optimistic) { | ||
| await this.queryClient.cancelQueries({ queryKey: this.queryKeys.list }) | ||
| const addedItem = { | ||
| id: this.generateId(), | ||
| ...data, | ||
| } as T | ||
| getOptimisticItem.resolve(addedItem) | ||
| const addedId = this.extractKey(addedItem) | ||
| this.addItem({ | ||
| item: addedItem, | ||
| to: tmpOptions?.current?.appendTo || managerOptions.creation?.appendTo || 'start', | ||
| onListsWithFilters: tmpOptions.current?.onListsWithFilters, | ||
| }) | ||
| return { | ||
| // previousData, | ||
| addedId, | ||
| } | ||
| } | ||
| }, | ||
| onError: (error, data, ctx: MutationCtx<T>) => { | ||
| const isOptimistic = tmpOptions.current?.optimistic | ||
| if (isOptimistic) { | ||
| this.removeItem(ctx.addedId) | ||
| } | ||
| }, | ||
| onSuccess: (data) => { | ||
| if (!tmpOptions.current?.optimistic) { | ||
| this.addItem({ | ||
| item: data, | ||
| to: tmpOptions?.current?.appendTo || managerOptions.creation?.appendTo || 'start', | ||
| onListsWithFilters: tmpOptions?.current?.onListsWithFilters, | ||
| }) | ||
| } else { | ||
| this.updateItems(data) | ||
| } | ||
| }, | ||
| }) | ||
| const createItem = async (data: Partial<T>, options?: CreateOptions<T>) => { | ||
| const prevOptions = { ...(tmpOptions.current ?? {}) } | ||
| if (!!options) { | ||
| tmpOptions.current = options | ||
| } | ||
| let res: T = null | ||
| if (tmpOptions.current?.optimistic) { | ||
| query.mutateAsync(data) | ||
| res = await getOptimisticItem._await() | ||
| } else { | ||
| res = await query.mutateAsync(data) | ||
| } | ||
| if (!!options) { | ||
| tmpOptions.current = prevOptions | ||
| } | ||
| return res | ||
| } | ||
| return { | ||
| item: query.data, | ||
| create: createItem, | ||
| query, | ||
| } | ||
| } | ||
| useUpdate(options?: UpdateOptions<T>) { | ||
| const [managerOptions] = this.useOptions() | ||
| const tmpOptions = useRef<UpdateOptions<T>>(options ?? managerOptions.update ?? { | ||
| optimistic: false, | ||
| }) | ||
| const getOptimisticItem = usePromise<T>({ | ||
| timeout: 1200, | ||
| }) | ||
| const query = useMutation({ | ||
| ...options?.mutationOptions, | ||
| mutationKey: this.queryKeys.update, | ||
| onMutate: async (data) => { | ||
| options?.mutationOptions?.onMutate?.(data) | ||
| if (tmpOptions.current?.optimistic) { | ||
| const prevItem = await this.getItem(data.id, { | ||
| fetchOnNotFoud: false, | ||
| }) | ||
| if (!prevItem) return | ||
| const optimisticItem:T = { | ||
| ...prevItem, | ||
| ...data, | ||
| } | ||
| getOptimisticItem.resolve(optimisticItem) | ||
| this.updateItems(optimisticItem) | ||
| return { | ||
| previousItem: prevItem, | ||
| optimisticItem, | ||
| } as MutationCtx<T> | ||
| } | ||
| }, | ||
| onError: (error, data, ctx: MutationCtx<T>) => { | ||
| if (tmpOptions.current?.optimistic && !!ctx?.previousItem?.id) { | ||
| this.updateItems( | ||
| ctx.previousItem, | ||
| ) | ||
| } | ||
| }, | ||
| mutationFn: (data: Partial<T>) => { | ||
| return this.options.updateItem(data) | ||
| }, | ||
| onSuccess: (data) => { | ||
| this.updateItems(data) | ||
| }, | ||
| }) | ||
| const update = async (data: Partial<T>, options?: UpdateOptions<T>) => { | ||
| const prevOptions = tmpOptions.current | ||
| if (!!options) { | ||
| tmpOptions.current = options | ||
| } | ||
| let res: T = null | ||
| if (tmpOptions.current?.optimistic) { | ||
| query.mutateAsync(data) | ||
| res = await getOptimisticItem._await() | ||
| } else { | ||
| res = await query.mutateAsync(data) | ||
| } | ||
| if (!!options) { | ||
| tmpOptions.current = prevOptions | ||
| } | ||
| return res | ||
| } | ||
| return { | ||
| update, | ||
| query, | ||
| item: query.data, | ||
| } | ||
| } | ||
| useDelete(options?: DeleteOptions<T>) { | ||
| const [managerOptions] = this.useOptions() | ||
| const tmpOptions = useRef<DeleteOptions<T>>(options ?? managerOptions?.deletion ?? { | ||
| optimistic: false, | ||
| }) | ||
| const getOptimisticItem = usePromise<T>({ | ||
| timeout: 1200, | ||
| }) | ||
| const query = useMutation({ | ||
| ...options?.mutationOptions, | ||
| mutationKey: this.queryKeys.delete, | ||
| onMutate: async (data) => { | ||
| options?.mutationOptions?.onMutate?.(data) | ||
| if (tmpOptions.current?.optimistic) { | ||
| const prevItem = await this.getItem(data.id, { | ||
| fetchOnNotFoud: false, | ||
| }) | ||
| getOptimisticItem.resolve(prevItem) | ||
| const removedAt = await this.removeItem(data.id) | ||
| if (!prevItem) return | ||
| return { | ||
| previousItem: prevItem, | ||
| prevItemPages: removedAt, | ||
| } as MutationCtx<T> | ||
| } | ||
| }, | ||
| mutationFn: (data: T) => { | ||
| return this.options.deleteItem(data) | ||
| }, | ||
| onError: (error, data, ctx: MutationCtx<T>) => { | ||
| if (!!ctx?.previousItem?.id && tmpOptions.current?.optimistic) { | ||
| this.addItem({ | ||
| item: ctx.previousItem, | ||
| to: ctx.prevItemPages, | ||
| }) | ||
| } | ||
| }, | ||
| onSuccess: (data) => { | ||
| if (!tmpOptions.current?.optimistic) { | ||
| this.removeItem(data.id) | ||
| } | ||
| }, | ||
| }) | ||
| const _delete = async (data: T, options?: UpdateOptions<T>) => { | ||
| const prevOptions = tmpOptions.current | ||
| if (!!options) { | ||
| tmpOptions.current = options | ||
| } | ||
| let prevItem = null | ||
| if (tmpOptions.current?.optimistic) { | ||
| query.mutateAsync(data) | ||
| prevItem = await getOptimisticItem._await() | ||
| } else { | ||
| prevItem = await query.mutateAsync(data) | ||
| } | ||
| if (!!options) { | ||
| tmpOptions.current = prevOptions | ||
| } | ||
| return prevItem | ||
| } | ||
| return { | ||
| delete: _delete, | ||
| query, | ||
| } | ||
| } | ||
| async refreshItem(itemId: T['id']) { | ||
| const newItem = await this.getItem(itemId, { | ||
| fetchOnNotFoud: true, | ||
| forceRefetch: true, | ||
| }) | ||
| this.queryClient.setQueryData(this.queryKeyFor(itemId), old => { | ||
| return newItem | ||
| }) | ||
| this.updateItems(newItem) | ||
| return newItem | ||
| } | ||
| async refresh(filters?: ExtraArgs) { | ||
| if (!!filters) { | ||
| const key = this.filteredQueryKey(filters) | ||
| this.queryClient.refetchQueries({ | ||
| queryKey: key, | ||
| }) | ||
| } else { | ||
| this.queryClient.refetchQueries({ | ||
| queryKey: this.queryKeys.list, | ||
| }) | ||
| } | ||
| } | ||
| setItem(item: T) { | ||
| return this.updateItems(item) | ||
| } | ||
| use(options?: UseManagerArgs<T, ExtraArgs>) { | ||
| const list = this.useList({ | ||
| filter: options?.filter, | ||
| queryOptions: options?.listOptions?.queryOptions, | ||
| limit: options?.limit, | ||
| }) | ||
| const create = this.useCreate(options?.creation) | ||
| const update = this.useUpdate(options?.update) | ||
| const del = this.useDelete(options?.deletion) | ||
| const queries = { | ||
| create, | ||
| update, | ||
| del, | ||
| } | ||
| return { | ||
| items: list.items, | ||
| list, | ||
| itemMap: list.itemMap, | ||
| create: create.create, | ||
| update: update.update, | ||
| delete: del.delete, | ||
| getNextPage: list.getNextPage, | ||
| getPreviousPage: list.getPreviousPage, | ||
| refreshItem: this.refreshItem.bind(this), | ||
| setItem: this.setItem.bind(this), | ||
| refresh: list.refresh, | ||
| isRefreshing: list.isRefreshing, | ||
| actions: this.actions, | ||
| updatedAt: list.query.dataUpdatedAt, | ||
| queries, | ||
| } | ||
| } | ||
| setOptions(to: SettableOptions<QueryManagerOptions<T, ExtraArgs, Meta, Actions>>) { | ||
| const { | ||
| creation = {}, | ||
| update = {}, | ||
| deletion = {}, | ||
| limit, | ||
| } = this.options | ||
| const currentOptions = { | ||
| creation, | ||
| update, | ||
| deletion, | ||
| limit, | ||
| } | ||
| const o = deepMerge(currentOptions, to) | ||
| this.options = { | ||
| ...this.options, | ||
| ...o, | ||
| } | ||
| this.meta = deepMerge(this.meta, to.meta) | ||
| this.optionListeners.forEach((l) => l(this.options, this.meta)) | ||
| } | ||
| } | ||
-199
| import { DefinedInitialDataInfiniteOptions, InfiniteData, QueryKey, UseInfiniteQueryResult, UseMutationOptions, useQueryClient, UseQueryOptions } from '@tanstack/react-query' | ||
| import { QueryManager } from './QueryManager' | ||
| export type PageParam = {limit: number; offset: number} | ||
| export type PaginationResponse<T> = { | ||
| count: number | ||
| next: string | null | ||
| previous: string | null | ||
| results: T[] | ||
| } | ||
| type OmitMutationKeys<O> = Omit<O, 'mutationFn'|'mutationKey'> | ||
| export type QueryManagerMeta = Record<string, any> | ||
| export type CreateOptions<T extends QueryManagerItem> = { | ||
| appendTo?: 'start' | 'end' | [number, number] | Record<string, [number, number]> | ||
| optimistic?: boolean | ||
| mutationOptions?: Partial<OmitMutationKeys<UseMutationOptions<T, unknown, Partial<T>, MutationCtx<T>>>> | ||
| onListsWithFilters?: any | ||
| } | ||
| export type UpdateOptions<T extends QueryManagerItem> = { | ||
| optimistic?: boolean | ||
| mutationOptions?: Partial<OmitMutationKeys<UseMutationOptions<T, unknown, Partial<T>, MutationCtx<T>>>> | ||
| } | ||
| export type DeleteOptions<T extends QueryManagerItem> = { | ||
| optimistic?: boolean | ||
| mutationOptions?: Partial<OmitMutationKeys<UseMutationOptions<T, unknown, T, MutationCtx<T>>>> | ||
| } | ||
| export type RetrieveOptions<T extends QueryManagerItem> = { | ||
| queryOptions?: Partial<UseQueryOptions<T, unknown, T>> | ||
| id?: T['id'] | ||
| } | ||
| export type ListOptions<T extends QueryManagerItem, ExtraArgs = any> = { | ||
| queryOptions?: Partial< | ||
| DefinedInitialDataInfiniteOptions<PaginationResponse<T>, Error, UseListSelector<T>, QueryKey, PageParam> | ||
| > | ||
| filter?: ExtraArgs | ||
| limit?: number | ||
| } | ||
| export type QueryManagerAction< | ||
| T extends QueryManagerItem, | ||
| ExtraArgs = any, | ||
| Meta extends QueryManagerMeta = QueryManagerMeta, | ||
| Args extends any[] = any[] | ||
| > = ( | ||
| manager: QueryManager<T, ExtraArgs, Meta>, ...args: Args | ||
| ) => any | ||
| export type QueryManagerActions< | ||
| T extends QueryManagerItem, | ||
| ExtraArgs = any, | ||
| Meta extends QueryManagerMeta = QueryManagerMeta | ||
| > = Record< | ||
| string, QueryManagerAction<T, ExtraArgs, Meta> | ||
| > | ||
| export type UseListEffect<T extends QueryManagerItem = any> = ( | ||
| listQuery: { | ||
| query: UseInfiniteQueryResult<UseListSelector<T>, Error> | ||
| refreshQuery: (silent?: boolean) => void | ||
| cancelQuery: () => void | ||
| } | ||
| ) => void | ||
| export type QueryManagerOptions< | ||
| T extends QueryManagerItem, | ||
| ExtraArgs = any, | ||
| Meta extends QueryManagerMeta = QueryManagerMeta, | ||
| Actions extends QueryManagerActions<T, ExtraArgs, Meta> = QueryManagerActions<T, ExtraArgs, Meta> | ||
| > = { | ||
| name: string | ||
| itemType: T | ||
| queryClient: ReturnType<typeof useQueryClient> | ||
| listItems?: (limit: number, offset: number, args?: ExtraArgs) => Promise<PaginationResponse<T>> | ||
| createItem?: (data: Partial<T>, args?: ExtraArgs) => Promise<T> | ||
| updateItem?: (data: Partial<T>, args?: ExtraArgs) => Promise<T> | ||
| deleteItem?: (data: T, args?: ExtraArgs) => Promise<T> | ||
| retrieveItem?: (id: T['id']) => Promise<T> | ||
| useListEffect?: UseListEffect<T> | ||
| limit?: number | ||
| creation?: CreateOptions<T> | ||
| update?: UpdateOptions<T> | ||
| deletion?: DeleteOptions<T> | ||
| generateId?: () => T['id'] | ||
| actions?: Actions | ||
| keyExtractor?: (item: T) => string | ||
| initialMeta?: Meta | ||
| } | ||
| export type QueryManagerActionTrigger< | ||
| A extends QueryManagerAction<any, any, any>, | ||
| Args extends any[] = A extends QueryManagerAction<any, any, any, infer _Args> ? _Args : any[] | ||
| > = (...args: Args) => any | ||
| export type QueryManagerActionTriggers< | ||
| Actions extends QueryManagerActions<any, any, any> | ||
| > = { | ||
| [K in keyof Actions]: QueryManagerActionTrigger<Actions[K]> | ||
| } | ||
| export type InfinitePaginationData<T> = InfiniteData<PaginationResponse<T>, PageParam> | ||
| export type UseManagerArgs<T extends QueryManagerItem, ExtraArgs = any> = { | ||
| filter?: ExtraArgs | ||
| limit?: number | ||
| offset?: number | ||
| creation?: CreateOptions<T> | ||
| update?: UpdateOptions<T> | ||
| deletion?: DeleteOptions<T> | ||
| listOptions?: Pick<ListOptions<T, ExtraArgs>, 'queryOptions'> | ||
| } | ||
| export type QueryManagerItem = { | ||
| id: string | number | ||
| } | ||
| export type AppendToPaginationParams<TItem extends QueryManagerItem, Filters = any> = { | ||
| item: TItem|TItem[] | ||
| to?: CreateOptions<TItem>['appendTo'] | ||
| refreshKey?: QueryKey | ||
| onListsWithFilters?: Filters | ||
| } | ||
| export type AppendToPaginationReturn<TItem = any> = InfiniteData<TItem> | ||
| export type AppendToPagination<TItem extends QueryManagerItem, ExtraArgs = any> = (params: AppendToPaginationParams<TItem, ExtraArgs>) => Promise<void> | ||
| export type MutationCtx<T extends QueryManagerItem> = null | { | ||
| previousData?: InfinitePaginationData<T> | ||
| addedId?: T['id'] | ||
| previousItem?: T | ||
| optimisticItem?: T | ||
| prevItemPages?:Record<string, [number, number]> | ||
| } | ||
| export const isInfiniteQueryData = <T>(data: any): data is InfinitePaginationData<T> => { | ||
| return !!data?.pages && !!data?.pageParams | ||
| } | ||
| export type QueryStateValue<T extends QueryManagerItem> = { | ||
| pagesById: Record<T['id'], [number, number]> | ||
| itemIndexes: Record<T['id'], number> | ||
| key: QueryKey | ||
| } | ||
| export type QueryStateSubscriber<T extends QueryManagerItem> = (data: QueryStateValue<T>) => void | ||
| export type FilterKeyOrder = string[] | ||
| export type GetItemOptions<T extends QueryManagerItem> = { | ||
| forceRefetch?: boolean | ||
| fetchOnNotFoud?: boolean | ||
| } | ||
| export type SettableOptions<O extends QueryManagerOptions<any, any, any, any>> = Partial< | ||
| Pick< | ||
| O, | ||
| 'limit' | | ||
| 'creation' | | ||
| 'update' | | ||
| 'deletion' | ||
| > & { | ||
| meta: O['initialMeta'] | ||
| } | ||
| > | ||
| export type OptionChangeListener<O extends QueryManagerOptions<any, any, any, any>> = ( | ||
| options: O, | ||
| meta: O['initialMeta'], | ||
| ) => any | ||
| export type UseActionOptions<T extends QueryManagerAction<any, any, any>> = UseMutationOptions< | ||
| Awaited<ReturnType<T>>, | ||
| unknown, | ||
| Parameters<T>[1] | ||
| > | ||
| export type UseListSelector<T> = { | ||
| pageParams: PageParam[] | ||
| pages: PaginationResponse<T>[] | ||
| flatItems: T[] | ||
| } | ||
Sorry, the diff of this file is not supported yet
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
131875
246.47%4
-33.33%3
-40%29
383.33%3398
206.13%1
Infinity%3
Infinity%+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed