@codeleap/query 5.8.3 → 5.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ import { FetchQueryOptions, InfiniteData, MutationFunctionContext, QueryKey, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'
2
+ import { useCallback } from 'react'
3
+ import { createQueryKeys, QueryKeys } from './QueryKeys'
4
+ import { createMutations, Mutations } from './Mutations'
5
+ import { CreateMutationCtx, CreateMutationOptions, ListPaginationResponse, ListQueryOptions, PageParam, QueryItem, QueryManagerOptions, RetrieveQueryOptions, UpdateMutationCtx, UpdateMutationOptions, DeleteMutationCtx, DeleteMutationOptions } from '../types'
6
+ import { generateTempId } from '../utils'
7
+ import { TypeGuards } from '@codeleap/types'
8
+
9
+ /**
10
+ * Comprehensive query manager class that provides hooks and utilities for managing CRUD operations with React Query
11
+ * @template T - The query item type that extends QueryItem
12
+ * @template F - The filter type used for list queries
13
+ *
14
+ * @description
15
+ * QueryManager provides a complete solution for managing list and individual item queries with:
16
+ * - Infinite scroll pagination for lists
17
+ * - Optimistic updates for create, update, and delete operations
18
+ * - Automatic cache synchronization between list and individual queries
19
+ * - Built-in error handling and rollback mechanisms
20
+ */
21
+ export class QueryManager<T extends QueryItem, F> {
22
+ /**
23
+ * Creates a new QueryManager instance
24
+ * @param options - Configuration options for the query manager
25
+ */
26
+ constructor(private options: QueryManagerOptions<T, F>) {
27
+ this.queryKeys = createQueryKeys<T, F>(options.name, options.queryClient)
28
+ this.mutations = createMutations<T, F>(this.queryKeys, options.queryClient, options.name)
29
+ }
30
+
31
+ /**
32
+ * Gets the name of this query manager
33
+ * @returns The query name used for identification
34
+ */
35
+ get name() {
36
+ return this.options.name
37
+ }
38
+
39
+ /**
40
+ * Gets the configured CRUD functions
41
+ * @returns Object containing all configured function handlers
42
+ */
43
+ get functions() {
44
+ return {
45
+ list: this.options.listFn,
46
+ retrieve: this.options.retrieveFn,
47
+ create: this.options.createFn,
48
+ update: this.options.updateFn,
49
+ delete: this.options.deleteFn,
50
+ }
51
+ }
52
+
53
+ /** QueryKeys instance for managing query keys and cache operations */
54
+ queryKeys: QueryKeys<T, F>
55
+
56
+ /** Mutations instance for managing cache mutations */
57
+ mutations: Mutations<T, F>
58
+
59
+ /**
60
+ * React hook for infinite scroll list queries with pagination
61
+ * @param options - Configuration options for the list query
62
+ * @returns Object containing items array, query key, and query object
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const { items, query } = queryManager.useList({
67
+ * filters: { status: 'active' },
68
+ * limit: 20
69
+ * })
70
+ * ```
71
+ */
72
+ useList(options: ListQueryOptions<T, F> = {}) {
73
+ const {
74
+ limit,
75
+ filters,
76
+ ...queryOptions
77
+ } = options
78
+
79
+ const listLimit = limit ?? this.options?.listLimit ?? 10
80
+
81
+ const queryKey = this.queryKeys.useListKeyWithFilters(filters)
82
+
83
+ const onSelect = useCallback((data: InfiniteData<ListPaginationResponse<T>, PageParam>) => {
84
+ const pages = data?.pages ?? []
85
+
86
+ return {
87
+ pageParams: data?.pageParams,
88
+ pages,
89
+ allItems: pages.flat(),
90
+ }
91
+ }, [])
92
+
93
+ const query = useInfiniteQuery({
94
+ queryKey,
95
+
96
+ queryFn: async (query) => {
97
+ const listOffset = query?.pageParam ?? 0
98
+
99
+ return this.options?.listFn?.(listLimit, listOffset, filters)
100
+ },
101
+
102
+ initialPageParam: 0,
103
+
104
+ getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
105
+ if (!lastPage?.length || lastPage.length < listLimit) {
106
+ return undefined
107
+ }
108
+
109
+ return (lastPageParam ?? 0) + lastPage.length
110
+ },
111
+
112
+ getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
113
+ if ((firstPageParam ?? 0) <= 0) {
114
+ return undefined
115
+ }
116
+
117
+ return (firstPageParam ?? 0) - listLimit
118
+ },
119
+
120
+ select(data) {
121
+ return onSelect(data)
122
+ },
123
+
124
+ ...queryOptions,
125
+ })
126
+
127
+ const useListEffect = (this.options.useListEffect ?? ((args: any) => null))
128
+
129
+ useListEffect(query)
130
+
131
+ const items = query.data?.allItems ?? []
132
+
133
+ return {
134
+ items,
135
+ queryKey,
136
+ query,
137
+ }
138
+ }
139
+
140
+ /**
141
+ * React hook for retrieving a single item by ID
142
+ * @param id - The ID of the item to retrieve
143
+ * @param options - Configuration options for the retrieve query
144
+ * @returns Object containing the item data, query key, and query object
145
+ *
146
+ * @description
147
+ * This hook automatically:
148
+ * - Uses list cache as initial data if available
149
+ * - Updates list cache when retrieve data changes
150
+ * - Synchronizes data between list and individual caches
151
+ *
152
+ * @example
153
+ * ```typescript
154
+ * const { item, query } = queryManager.useRetrieve('user-123', {
155
+ * enabled: !!userId
156
+ * })
157
+ * ```
158
+ */
159
+ useRetrieve(id: T['id'], options: RetrieveQueryOptions<T> = {}) {
160
+ const {
161
+ select,
162
+ ...queryOptions
163
+ } = options
164
+
165
+ const onSelect = useCallback((data: T) => {
166
+ this.mutations.updateItems(data)
167
+ if (select) select?.(data)
168
+ return data
169
+ }, [select])
170
+
171
+ const getInitialData = useCallback(() => {
172
+ const { itemMap } = this.queryKeys.getListData()
173
+ return itemMap?.[id]
174
+ }, [id])
175
+
176
+ const queryKey = this.queryKeys.useRetrieveKey(id)
177
+
178
+ const query = useQuery({
179
+ initialData: getInitialData,
180
+
181
+ ...queryOptions,
182
+
183
+ queryKey,
184
+
185
+ queryFn: () => {
186
+ return this.options.retrieveFn(id)
187
+ },
188
+
189
+ select: (data) => {
190
+ return onSelect(data)
191
+ },
192
+ })
193
+
194
+ return {
195
+ item: query.data,
196
+ queryKey,
197
+ query,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * React hook for creating new items with optimistic updates
203
+ * @param options - Configuration options for the create mutation
204
+ * @returns React Query mutation object for create operations
205
+ *
206
+ * @description
207
+ * This hook supports optimistic updates by:
208
+ * - Immediately adding a temporary item to the cache
209
+ * - Rolling back on error by removing the temporary item
210
+ * - Replacing temporary item with real data on success
211
+ *
212
+ * @example
213
+ * ```typescript
214
+ * const createMutation = queryManager.useCreate({
215
+ * optimistic: true,
216
+ * appendTo: 'start',
217
+ * listFilters: { status: 'active' }
218
+ * })
219
+ *
220
+ * // Usage
221
+ * createMutation.mutate({ name: 'New User', email: 'user@example.com' })
222
+ * ```
223
+ */
224
+ useCreate(options: CreateMutationOptions<T, F> = {}) {
225
+ const {
226
+ optimistic,
227
+ listFilters,
228
+ appendTo,
229
+ onMutate: providedOnMutate,
230
+ onError: providedOnError,
231
+ onSuccess: providedOnSuccess,
232
+ ...mutationOptions
233
+ } = options
234
+
235
+ const onMutate = useCallback(async (data: Partial<T>, context: MutationFunctionContext) => {
236
+ if (providedOnMutate) providedOnMutate?.(data, context)
237
+
238
+ if (optimistic) {
239
+ await this.queryKeys.cancelListQueries(listFilters)
240
+
241
+ const tempId = generateTempId()
242
+
243
+ const newItem = { ...data, id: tempId } as T
244
+
245
+ this.mutations.addItem(newItem, appendTo, listFilters)
246
+
247
+ return {
248
+ tempId,
249
+ }
250
+ }
251
+ }, [providedOnMutate, optimistic, listFilters])
252
+
253
+ const onError = useCallback((error: Error, variables: Partial<T>, onMutateResult: CreateMutationCtx, context: MutationFunctionContext) => {
254
+ if (providedOnError) providedOnError?.(error, variables, onMutateResult, context)
255
+
256
+ if (!TypeGuards.isNil(onMutateResult?.tempId)) {
257
+ this.mutations.removeItem(onMutateResult?.tempId)
258
+ }
259
+ }, [providedOnError])
260
+
261
+ const onSuccess = useCallback((data: T, variables: Partial<T>, onMutateResult: CreateMutationCtx, context: MutationFunctionContext) => {
262
+ if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context)
263
+
264
+ if (TypeGuards.isNil(onMutateResult?.tempId)) {
265
+ this.mutations.addItem(data, appendTo, listFilters)
266
+ } else {
267
+ this.mutations.updateItems({ ...data, tempId: onMutateResult?.tempId })
268
+ }
269
+ }, [providedOnSuccess, listFilters])
270
+
271
+ const mutation = useMutation<T, Error, Partial<T>, CreateMutationCtx>({
272
+ ...mutationOptions,
273
+
274
+ mutationKey: this.queryKeys.keys.create,
275
+
276
+ mutationFn: (data: Partial<T>) => {
277
+ return this.options.createFn(data)
278
+ },
279
+
280
+ onMutate,
281
+
282
+ onError,
283
+
284
+ onSuccess,
285
+ })
286
+
287
+ return mutation
288
+ }
289
+
290
+ /**
291
+ * React hook for updating existing items with optimistic updates
292
+ * @param options - Configuration options for the update mutation
293
+ * @returns React Query mutation object for update operations
294
+ *
295
+ * @description
296
+ * This hook supports optimistic updates by:
297
+ * - Immediately updating the item in cache with new data
298
+ * - Rolling back to previous data on error
299
+ * - Confirming updates with server response on success
300
+ *
301
+ * @example
302
+ * ```typescript
303
+ * const updateMutation = queryManager.useUpdate({
304
+ * optimistic: true,
305
+ * onSuccess: (data) => console.log('Updated:', data)
306
+ * })
307
+ *
308
+ * // Usage
309
+ * updateMutation.mutate({ id: 'user-123', name: 'Updated Name' })
310
+ * ```
311
+ */
312
+ useUpdate(options: UpdateMutationOptions<T, F> = {}) {
313
+ const {
314
+ optimistic,
315
+ onMutate: providedOnMutate,
316
+ onError: providedOnError,
317
+ onSuccess: providedOnSuccess,
318
+ ...mutationOptions
319
+ } = options
320
+
321
+ const onMutate = useCallback(async (data: Partial<T>, context: MutationFunctionContext) => {
322
+ if (providedOnMutate) providedOnMutate?.(data, context)
323
+
324
+ if (optimistic) {
325
+ const previousItem = this.queryKeys.getRetrieveData(data?.id)
326
+
327
+ if (!previousItem) return
328
+
329
+ const optimisticItem = {
330
+ ...previousItem,
331
+ ...data,
332
+ } as T
333
+
334
+ this.mutations.updateItems(optimisticItem)
335
+
336
+ return {
337
+ previousItem,
338
+ optimisticItem,
339
+ }
340
+ }
341
+ }, [providedOnMutate, optimistic])
342
+
343
+ const onError = useCallback((error: Error, variables: Partial<T>, onMutateResult: UpdateMutationCtx<T>, context: MutationFunctionContext) => {
344
+ if (providedOnError) providedOnError?.(error, variables, onMutateResult, context)
345
+
346
+ if (!TypeGuards.isNil(onMutateResult?.previousItem?.id)) {
347
+ this.mutations.updateItems(onMutateResult?.previousItem)
348
+ }
349
+ }, [providedOnError])
350
+
351
+ const onSuccess = useCallback((data: T, variables: Partial<T>, onMutateResult: UpdateMutationCtx<T>, context: MutationFunctionContext) => {
352
+ if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context)
353
+
354
+ this.mutations.updateItems(data)
355
+ }, [providedOnSuccess])
356
+
357
+ const mutation = useMutation<T, Error, Partial<T>, UpdateMutationCtx<T>>({
358
+ ...mutationOptions,
359
+
360
+ mutationKey: this.queryKeys.keys.update,
361
+
362
+ mutationFn: (data: Partial<T>) => {
363
+ return this.options.updateFn(data)
364
+ },
365
+
366
+ onMutate,
367
+
368
+ onError,
369
+
370
+ onSuccess,
371
+ })
372
+
373
+ return mutation
374
+ }
375
+
376
+ /**
377
+ * React hook for deleting items with optimistic updates
378
+ * @param options - Configuration options for the delete mutation
379
+ * @returns React Query mutation object for delete operations
380
+ *
381
+ * @description
382
+ * This hook supports optimistic updates by:
383
+ * - Immediately removing the item from cache
384
+ * - Storing the removal positions for potential rollback
385
+ * - Restoring the item to original positions on error
386
+ * - Confirming deletion on success
387
+ *
388
+ * @example
389
+ * ```typescript
390
+ * const deleteMutation = queryManager.useDelete({
391
+ * optimistic: true,
392
+ * onSuccess: () => console.log('Item deleted successfully')
393
+ * })
394
+ *
395
+ * // Usage
396
+ * deleteMutation.mutate('user-123')
397
+ * ```
398
+ */
399
+ useDelete(options: DeleteMutationOptions<T, F> = {}) {
400
+ const {
401
+ optimistic,
402
+ onMutate: providedOnMutate,
403
+ onError: providedOnError,
404
+ onSuccess: providedOnSuccess,
405
+ ...mutationOptions
406
+ } = options
407
+
408
+ const onMutate = useCallback(async (id: T['id'], context: MutationFunctionContext) => {
409
+ if (providedOnMutate) providedOnMutate?.(id, context)
410
+
411
+ if (optimistic) {
412
+ const previousItem = this.queryKeys.getRetrieveData(id)
413
+
414
+ if (!previousItem) return
415
+
416
+ const removedAt = this.mutations.removeItem(id)
417
+
418
+ return {
419
+ previousItem,
420
+ removedAt,
421
+ }
422
+ }
423
+ }, [providedOnMutate, optimistic])
424
+
425
+ const onError = useCallback((error: Error, variables: T['id'], onMutateResult: DeleteMutationCtx<T>, context: MutationFunctionContext) => {
426
+ if (providedOnError) providedOnError?.(error, variables, onMutateResult, context)
427
+
428
+ if (!TypeGuards.isNil(onMutateResult?.previousItem?.id)) {
429
+ this.mutations.addItem(
430
+ onMutateResult?.previousItem,
431
+ onMutateResult?.removedAt,
432
+ )
433
+ }
434
+ }, [providedOnError])
435
+
436
+ const onSuccess = useCallback((data: T['id'], variables: T['id'], onMutateResult: DeleteMutationCtx<T>, context: MutationFunctionContext) => {
437
+ if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context)
438
+
439
+ if (TypeGuards.isNil(onMutateResult?.previousItem?.id)) {
440
+ this.mutations.removeItem(data)
441
+ }
442
+ }, [providedOnSuccess])
443
+
444
+ const mutation = useMutation<unknown, Error, T['id'], DeleteMutationCtx<T>>({
445
+ ...mutationOptions,
446
+
447
+ mutationKey: this.queryKeys.keys.delete,
448
+
449
+ mutationFn: async (id: QueryItem['id']) => {
450
+ return this.options.deleteFn(id)
451
+ },
452
+
453
+ onMutate,
454
+
455
+ onError,
456
+
457
+ onSuccess,
458
+ })
459
+
460
+ return mutation
461
+ }
462
+
463
+ /**
464
+ * Prefetches a single item by ID for improved performance
465
+ * @param id - The ID of the item to prefetch
466
+ * @param options - Prefetch options compatible with React Query
467
+ * @returns Promise that resolves when prefetch is complete
468
+ *
469
+ * @description
470
+ * Use this method to preload data that users are likely to need soon,
471
+ * such as when hovering over links or preparing for navigation.
472
+ *
473
+ * @example
474
+ * ```typescript
475
+ * // Prefetch on hover
476
+ * const handleHover = (userId: string) => {
477
+ * queryManager.prefetchRetrieve(userId, { staleTime: 5 * 60 * 1000 })
478
+ * }
479
+ * ```
480
+ */
481
+ prefetchRetrieve(id: T['id'], options: Omit<FetchQueryOptions<T, Error, T, QueryKey, never>, 'queryKey' | 'queryFn'> = {}) {
482
+ return this.options.queryClient.prefetchQuery({
483
+ ...options,
484
+ queryKey: this.queryKeys.keys.retrieve(id),
485
+ queryFn: () => this.options.retrieveFn(id),
486
+ })
487
+ }
488
+ }