mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-11 08:19:01 +08:00
feat: update useDataApi hook and documentation for improved loading states and refetch logic
- Refactored `useQuery` and `useMutation` hooks to replace `loading` with `isLoading` for consistency in naming conventions. - Enhanced `useQuery` to include `isRefreshing` state for better tracking of background revalidation. - Updated documentation and examples to reflect changes in hook signatures and loading state management. - Improved mock implementations in tests to align with the new hook signatures, ensuring accurate testing of loading states.
This commit is contained in:
parent
7cac5b55f6
commit
6567d9d255
@ -12,7 +12,7 @@ Fetch data with automatic caching and revalidation via SWR.
|
|||||||
import { useQuery } from '@data/hooks/useDataApi'
|
import { useQuery } from '@data/hooks/useDataApi'
|
||||||
|
|
||||||
// Basic usage
|
// Basic usage
|
||||||
const { data, loading, error } = useQuery('/topics')
|
const { data, isLoading, error } = useQuery('/topics')
|
||||||
|
|
||||||
// With query parameters
|
// With query parameters
|
||||||
const { data: messages } = useQuery('/messages', {
|
const { data: messages } = useQuery('/messages', {
|
||||||
@ -23,12 +23,12 @@ const { data: messages } = useQuery('/messages', {
|
|||||||
const { data: topic } = useQuery('/topics/abc123')
|
const { data: topic } = useQuery('/topics/abc123')
|
||||||
|
|
||||||
// Conditional fetching
|
// Conditional fetching
|
||||||
const { data } = useQuery(topicId ? `/topics/${topicId}` : null)
|
const { data } = useQuery('/topics', { enabled: !!topicId })
|
||||||
|
|
||||||
// With refresh callback
|
// With refresh callback
|
||||||
const { data, mutate } = useQuery('/topics')
|
const { data, mutate, refetch } = useQuery('/topics')
|
||||||
// Refresh data
|
// Refresh data
|
||||||
await mutate()
|
refetch() // or await mutate()
|
||||||
```
|
```
|
||||||
|
|
||||||
### useMutation (POST/PUT/PATCH/DELETE)
|
### useMutation (POST/PUT/PATCH/DELETE)
|
||||||
@ -39,22 +39,68 @@ Perform data modifications with loading states.
|
|||||||
import { useMutation } from '@data/hooks/useDataApi'
|
import { useMutation } from '@data/hooks/useDataApi'
|
||||||
|
|
||||||
// Create (POST)
|
// Create (POST)
|
||||||
const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST')
|
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics')
|
||||||
const newTopic = await createTopic({ body: { name: 'New Topic' } })
|
const newTopic = await createTopic({ body: { name: 'New Topic' } })
|
||||||
|
|
||||||
// Update (PUT - full replacement)
|
// Update (PUT - full replacement)
|
||||||
const { trigger: replaceTopic } = useMutation('/topics/abc123', 'PUT')
|
const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123')
|
||||||
await replaceTopic({ body: { name: 'Updated Name', description: '...' } })
|
await replaceTopic({ body: { name: 'Updated Name', description: '...' } })
|
||||||
|
|
||||||
// Partial Update (PATCH)
|
// Partial Update (PATCH)
|
||||||
const { trigger: updateTopic } = useMutation('/topics/abc123', 'PATCH')
|
const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123')
|
||||||
await updateTopic({ body: { name: 'New Name' } })
|
await updateTopic({ body: { name: 'New Name' } })
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
const { trigger: deleteTopic } = useMutation('/topics/abc123', 'DELETE')
|
const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123')
|
||||||
await deleteTopic()
|
await deleteTopic()
|
||||||
|
|
||||||
|
// With auto-refresh of other queries
|
||||||
|
const { trigger } = useMutation('POST', '/topics', {
|
||||||
|
refresh: ['/topics'], // Refresh these keys on success
|
||||||
|
onSuccess: (data) => console.log('Created:', data)
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### useInfiniteQuery (Cursor-based Infinite Scroll)
|
||||||
|
|
||||||
|
For infinite scroll UIs with "Load More" pattern.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useInfiniteQuery } from '@data/hooks/useDataApi'
|
||||||
|
|
||||||
|
const { items, isLoading, hasNext, loadNext } = useInfiniteQuery('/messages', {
|
||||||
|
query: { topicId: 'abc123' },
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
|
|
||||||
|
// items: all loaded items flattened
|
||||||
|
// loadNext(): load next page
|
||||||
|
// hasNext: true if more pages available
|
||||||
|
```
|
||||||
|
|
||||||
|
### usePaginatedQuery (Offset-based Pagination)
|
||||||
|
|
||||||
|
For page-by-page navigation with previous/next controls.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { usePaginatedQuery } from '@data/hooks/useDataApi'
|
||||||
|
|
||||||
|
const { items, page, total, hasNext, hasPrev, nextPage, prevPage } =
|
||||||
|
usePaginatedQuery('/topics', { limit: 10 })
|
||||||
|
|
||||||
|
// items: current page items
|
||||||
|
// page/total: current page number and total count
|
||||||
|
// nextPage()/prevPage(): navigate between pages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Choosing Pagination Hooks
|
||||||
|
|
||||||
|
| Use Case | Hook |
|
||||||
|
|----------|------|
|
||||||
|
| Infinite scroll, chat, feeds | `useInfiniteQuery` |
|
||||||
|
| Page navigation, tables | `usePaginatedQuery` |
|
||||||
|
| Manual control | `useQuery` |
|
||||||
|
|
||||||
## DataApiService Direct Usage
|
## DataApiService Direct Usage
|
||||||
|
|
||||||
For non-React code or more control.
|
For non-React code or more control.
|
||||||
@ -94,9 +140,9 @@ await dataApiService.delete('/topics/abc123')
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function TopicList() {
|
function TopicList() {
|
||||||
const { data, loading, error } = useQuery('/topics')
|
const { data, isLoading, error } = useQuery('/topics')
|
||||||
|
|
||||||
if (loading) return <Loading />
|
if (isLoading) return <Loading />
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code === ErrorCode.NOT_FOUND) {
|
if (error.code === ErrorCode.NOT_FOUND) {
|
||||||
return <NotFound />
|
return <NotFound />
|
||||||
@ -146,39 +192,18 @@ if (error instanceof DataApiError && error.isRetryable) {
|
|||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
|
|
||||||
### List with Pagination
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function TopicListWithPagination() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const { data, loading } = useQuery('/topics', {
|
|
||||||
query: { page, limit: 20 }
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List items={data?.items ?? []} />
|
|
||||||
<Pagination
|
|
||||||
current={page}
|
|
||||||
total={data?.total ?? 0}
|
|
||||||
onChange={setPage}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Form
|
### Create Form
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function CreateTopicForm() {
|
function CreateTopicForm() {
|
||||||
const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST')
|
// Use refresh option to auto-refresh /topics after creation
|
||||||
const { mutate } = useQuery('/topics') // For revalidation
|
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', {
|
||||||
|
refresh: ['/topics']
|
||||||
|
})
|
||||||
|
|
||||||
const handleSubmit = async (data: CreateTopicDto) => {
|
const handleSubmit = async (data: CreateTopicDto) => {
|
||||||
try {
|
try {
|
||||||
await createTopic({ body: data })
|
await createTopic({ body: data })
|
||||||
await mutate() // Refresh list
|
|
||||||
toast.success('Topic created')
|
toast.success('Topic created')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to create topic')
|
toast.error('Failed to create topic')
|
||||||
@ -188,8 +213,8 @@ function CreateTopicForm() {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* form fields */}
|
{/* form fields */}
|
||||||
<button disabled={isMutating}>
|
<button disabled={isLoading}>
|
||||||
{isMutating ? 'Creating...' : 'Create'}
|
{isLoading ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
@ -200,26 +225,16 @@ function CreateTopicForm() {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function TopicItem({ topic }: { topic: Topic }) {
|
function TopicItem({ topic }: { topic: Topic }) {
|
||||||
const { trigger: updateTopic } = useMutation(`/topics/${topic.id}`, 'PATCH')
|
// Use optimisticData for automatic optimistic updates with rollback
|
||||||
const { mutate } = useQuery('/topics')
|
const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, {
|
||||||
|
optimisticData: { ...topic, starred: !topic.starred }
|
||||||
|
})
|
||||||
|
|
||||||
const handleToggleStar = async () => {
|
const handleToggleStar = async () => {
|
||||||
// Optimistically update the cache
|
|
||||||
await mutate(
|
|
||||||
current => ({
|
|
||||||
...current,
|
|
||||||
items: current.items.map(t =>
|
|
||||||
t.id === topic.id ? { ...t, starred: !t.starred } : t
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
{ revalidate: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTopic({ body: { starred: !topic.starred } })
|
await updateTopic({ body: { starred: !topic.starred } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert on failure
|
// Rollback happens automatically when optimisticData is set
|
||||||
await mutate()
|
|
||||||
toast.error('Failed to update')
|
toast.error('Failed to update')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -279,7 +294,7 @@ The API is fully typed based on schema definitions:
|
|||||||
const { data } = useQuery('/topics')
|
const { data } = useQuery('/topics')
|
||||||
// data is typed as PaginatedResponse<Topic>
|
// data is typed as PaginatedResponse<Topic>
|
||||||
|
|
||||||
const { trigger } = useMutation('/topics', 'POST')
|
const { trigger } = useMutation('POST', '/topics')
|
||||||
// trigger expects { body: CreateTopicDto }
|
// trigger expects { body: CreateTopicDto }
|
||||||
// returns Topic
|
// returns Topic
|
||||||
|
|
||||||
@ -291,8 +306,9 @@ const { data: topic } = useQuery('/topics/abc123')
|
|||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states
|
1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states
|
||||||
2. **Handle loading states**: Always show feedback while data is loading
|
2. **Choose the right pagination hook**: Use `useInfiniteQuery` for infinite scroll, `usePaginatedQuery` for page navigation
|
||||||
3. **Handle errors gracefully**: Provide meaningful error messages to users
|
3. **Handle loading states**: Always show feedback while data is loading
|
||||||
4. **Revalidate after mutations**: Keep the UI in sync with the database
|
4. **Handle errors gracefully**: Provide meaningful error messages to users
|
||||||
5. **Use conditional fetching**: Pass `null` to skip queries when dependencies aren't ready
|
5. **Revalidate after mutations**: Use `refresh` option to keep the UI in sync
|
||||||
6. **Batch related operations**: Consider using transactions for multiple updates
|
6. **Use conditional fetching**: Set `enabled: false` to skip queries when dependencies aren't ready
|
||||||
|
7. **Batch related operations**: Consider using transactions for multiple updates
|
||||||
|
|||||||
@ -1,17 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview React hooks for data fetching with SWR integration.
|
||||||
|
*
|
||||||
|
* This module provides type-safe hooks for interacting with the DataApi:
|
||||||
|
*
|
||||||
|
* - {@link useQuery} - Fetch data with automatic caching and revalidation
|
||||||
|
* - {@link useMutation} - Perform POST/PUT/PATCH/DELETE operations
|
||||||
|
* - {@link useInfiniteQuery} - Cursor-based infinite scrolling
|
||||||
|
* - {@link usePaginatedQuery} - Offset-based pagination with navigation
|
||||||
|
* - {@link useInvalidateCache} - Manual cache invalidation
|
||||||
|
* - {@link prefetch} - Warm up cache before user interactions
|
||||||
|
*
|
||||||
|
* All hooks use SWR under the hood for caching, deduplication, and revalidation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Basic data fetching
|
||||||
|
* const { data, isLoading } = useQuery('/topics')
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Create with auto-refresh
|
||||||
|
* const { trigger } = useMutation('POST', '/topics', { refresh: ['/topics'] })
|
||||||
|
* await trigger({ body: { name: 'New Topic' } })
|
||||||
|
*
|
||||||
|
* @see {@link https://swr.vercel.app SWR Documentation}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dataApiService } from '@data/DataApiService'
|
||||||
import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
|
import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
|
||||||
import type { ConcreteApiPaths } from '@shared/data/api/apiTypes'
|
import type { ConcreteApiPaths } from '@shared/data/api/apiTypes'
|
||||||
import {
|
import {
|
||||||
isCursorPaginationResponse,
|
type CursorPaginationResponse,
|
||||||
type OffsetPaginationResponse,
|
type OffsetPaginationResponse,
|
||||||
type PaginationResponse
|
type PaginationResponse
|
||||||
} from '@shared/data/api/apiTypes'
|
} from '@shared/data/api/apiTypes'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { KeyedMutator } from 'swr'
|
import type { KeyedMutator, SWRConfiguration } from 'swr'
|
||||||
import useSWR, { useSWRConfig } from 'swr'
|
import useSWR, { preload, useSWRConfig } from 'swr'
|
||||||
|
import type { SWRInfiniteConfiguration } from 'swr/infinite'
|
||||||
import useSWRInfinite from 'swr/infinite'
|
import useSWRInfinite from 'swr/infinite'
|
||||||
|
import type { SWRMutationConfiguration } from 'swr/mutation'
|
||||||
import useSWRMutation from 'swr/mutation'
|
import useSWRMutation from 'swr/mutation'
|
||||||
|
|
||||||
import { dataApiService } from '../DataApiService'
|
/**
|
||||||
|
* Default SWR configuration shared across all hooks.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* - `revalidateOnFocus: false` - Prevents refetch when window regains focus
|
||||||
|
* - `revalidateOnReconnect: true` - Refetch when network reconnects
|
||||||
|
* - `dedupingInterval: 5000` - Dedupe requests within 5 seconds
|
||||||
|
* - `errorRetryCount: 3` - Retry failed requests up to 3 times
|
||||||
|
* - `errorRetryInterval: 1000` - Wait 1 second between retries
|
||||||
|
*/
|
||||||
|
const DEFAULT_SWR_OPTIONS = {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: true,
|
||||||
|
dedupingInterval: 5000,
|
||||||
|
errorRetryCount: 3,
|
||||||
|
errorRetryInterval: 1000
|
||||||
|
} as const
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Hook Result Types
|
// Hook Result Types
|
||||||
@ -24,7 +69,15 @@ type InferPaginatedItem<TPath extends ConcreteApiPaths> = ResponseForPath<TPath,
|
|||||||
? T
|
? T
|
||||||
: unknown
|
: unknown
|
||||||
|
|
||||||
/** useQuery result type */
|
/**
|
||||||
|
* useQuery result type
|
||||||
|
* @property data - The fetched data, undefined while loading or on error
|
||||||
|
* @property isLoading - True during initial load (no cached data)
|
||||||
|
* @property isRefreshing - True during background revalidation (has cached data)
|
||||||
|
* @property error - Error object if the request failed
|
||||||
|
* @property refetch - Trigger a revalidation from the server
|
||||||
|
* @property mutate - SWR mutator for advanced cache control (optimistic updates, manual cache manipulation)
|
||||||
|
*/
|
||||||
export interface UseQueryResult<TPath extends ConcreteApiPaths> {
|
export interface UseQueryResult<TPath extends ConcreteApiPaths> {
|
||||||
data?: ResponseForPath<TPath, 'GET'>
|
data?: ResponseForPath<TPath, 'GET'>
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@ -34,12 +87,17 @@ export interface UseQueryResult<TPath extends ConcreteApiPaths> {
|
|||||||
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** useMutation result type */
|
/**
|
||||||
|
* useMutation result type
|
||||||
|
* @property trigger - Execute the mutation with optional body and query params
|
||||||
|
* @property isLoading - True while the mutation is in progress
|
||||||
|
* @property error - Error object if the last mutation failed
|
||||||
|
*/
|
||||||
export interface UseMutationResult<
|
export interface UseMutationResult<
|
||||||
TPath extends ConcreteApiPaths,
|
TPath extends ConcreteApiPaths,
|
||||||
TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||||
> {
|
> {
|
||||||
mutate: (data?: {
|
trigger: (data?: {
|
||||||
body?: BodyForPath<TPath, TMethod>
|
body?: BodyForPath<TPath, TMethod>
|
||||||
query?: QueryParamsForPath<TPath>
|
query?: QueryParamsForPath<TPath>
|
||||||
}) => Promise<ResponseForPath<TPath, TMethod>>
|
}) => Promise<ResponseForPath<TPath, TMethod>>
|
||||||
@ -47,24 +105,45 @@ export interface UseMutationResult<
|
|||||||
error: Error | undefined
|
error: Error | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** useInfiniteQuery result type */
|
/**
|
||||||
|
* useInfiniteQuery result type (cursor-based pagination)
|
||||||
|
* @property items - All loaded items flattened from all pages
|
||||||
|
* @property isLoading - True during initial load
|
||||||
|
* @property isRefreshing - True during background revalidation
|
||||||
|
* @property error - Error object if the request failed
|
||||||
|
* @property hasNext - True if more pages are available (nextCursor exists)
|
||||||
|
* @property loadNext - Load the next page of items
|
||||||
|
* @property refresh - Revalidate all loaded pages from the server
|
||||||
|
* @property reset - Reset to first page only
|
||||||
|
* @property mutate - SWR mutator for advanced cache control
|
||||||
|
*/
|
||||||
export interface UseInfiniteQueryResult<T> {
|
export interface UseInfiniteQueryResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
pages: PaginationResponse<T>[]
|
|
||||||
total: number
|
|
||||||
size: number
|
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isRefreshing: boolean
|
isRefreshing: boolean
|
||||||
error?: Error
|
error?: Error
|
||||||
hasNext: boolean
|
hasNext: boolean
|
||||||
loadNext: () => void
|
loadNext: () => void
|
||||||
setSize: (size: number | ((size: number) => number)) => void
|
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
mutate: KeyedMutator<PaginationResponse<T>[]>
|
mutate: KeyedMutator<CursorPaginationResponse<T>[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** usePaginatedQuery result type */
|
/**
|
||||||
|
* usePaginatedQuery result type (offset-based pagination)
|
||||||
|
* @property items - Items on the current page
|
||||||
|
* @property total - Total number of items across all pages
|
||||||
|
* @property page - Current page number (1-indexed)
|
||||||
|
* @property isLoading - True during initial load
|
||||||
|
* @property isRefreshing - True during background revalidation
|
||||||
|
* @property error - Error object if the request failed
|
||||||
|
* @property hasNext - True if next page exists
|
||||||
|
* @property hasPrev - True if previous page exists (page > 1)
|
||||||
|
* @property prevPage - Navigate to previous page
|
||||||
|
* @property nextPage - Navigate to next page
|
||||||
|
* @property refresh - Revalidate current page from the server
|
||||||
|
* @property reset - Reset to page 1
|
||||||
|
*/
|
||||||
export interface UsePaginatedQueryResult<T> {
|
export interface UsePaginatedQueryResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
@ -80,94 +159,50 @@ export interface UsePaginatedQueryResult<T> {
|
|||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Utilities
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified API fetcher with type-safe method dispatching
|
* Data fetching hook with SWR caching and revalidation.
|
||||||
*/
|
*
|
||||||
function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
* Features:
|
||||||
method: TMethod
|
* - Automatic caching and deduplication
|
||||||
) {
|
* - Background revalidation on focus/reconnect
|
||||||
return async (
|
* - Error retry with exponential backoff
|
||||||
path: TPath,
|
*
|
||||||
options?: {
|
* @param path - API endpoint path (e.g., '/topics', '/messages')
|
||||||
body?: BodyForPath<TPath, TMethod>
|
* @param options - Query options
|
||||||
query?: Record<string, any>
|
* @param options.query - Query parameters for filtering, pagination, etc.
|
||||||
}
|
* @param options.enabled - Set to false to disable the request (default: true)
|
||||||
): Promise<ResponseForPath<TPath, TMethod>> => {
|
* @param options.swrOptions - Override default SWR configuration
|
||||||
switch (method) {
|
* @returns Query result with data, loading states, and cache controls
|
||||||
case 'GET':
|
|
||||||
return dataApiService.get(path, { query: options?.query })
|
|
||||||
case 'POST':
|
|
||||||
return dataApiService.post(path, { body: options?.body, query: options?.query })
|
|
||||||
case 'PUT':
|
|
||||||
return dataApiService.put(path, { body: options?.body || {}, query: options?.query })
|
|
||||||
case 'DELETE':
|
|
||||||
return dataApiService.delete(path, { query: options?.query })
|
|
||||||
case 'PATCH':
|
|
||||||
return dataApiService.patch(path, { body: options?.body, query: options?.query })
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported method: ${method}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build SWR cache key from path and query
|
|
||||||
*/
|
|
||||||
function buildSWRKey<TPath extends ConcreteApiPaths>(
|
|
||||||
path: TPath,
|
|
||||||
query?: Record<string, any>
|
|
||||||
): [TPath, Record<string, any>?] | null {
|
|
||||||
if (query && Object.keys(query).length > 0) {
|
|
||||||
return [path, query]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [path]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET request fetcher for SWR
|
|
||||||
*/
|
|
||||||
function getFetcher<TPath extends ConcreteApiPaths>([path, query]: [TPath, Record<string, any>?]): Promise<
|
|
||||||
ResponseForPath<TPath, 'GET'>
|
|
||||||
> {
|
|
||||||
const apiFetcher = createApiFetcher('GET')
|
|
||||||
return apiFetcher(path, { query })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default SWR configuration options shared across hooks
|
|
||||||
*/
|
|
||||||
const DEFAULT_SWR_OPTIONS = {
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
revalidateOnReconnect: true,
|
|
||||||
dedupingInterval: 5000,
|
|
||||||
errorRetryCount: 3,
|
|
||||||
errorRetryInterval: 1000
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data fetching hook with SWR caching and revalidation
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const { data, isLoading, error } = useQuery('/items', { query: { page: 1 } })
|
* // Basic usage
|
||||||
|
* const { data, isLoading, error } = useQuery('/topics')
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With query parameters
|
||||||
|
* const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } })
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Conditional fetching
|
||||||
|
* const { data } = useQuery('/topics', { enabled: !!userId })
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Manual cache update
|
||||||
|
* const { data, mutate } = useQuery('/topics')
|
||||||
|
* mutate({ ...data, name: 'Updated' }, { revalidate: false })
|
||||||
*/
|
*/
|
||||||
export function useQuery<TPath extends ConcreteApiPaths>(
|
export function useQuery<TPath extends ConcreteApiPaths>(
|
||||||
path: TPath,
|
path: TPath,
|
||||||
options?: {
|
options?: {
|
||||||
/** Query parameters for filtering, pagination, etc. */
|
/** Query parameters for filtering, pagination, etc. */
|
||||||
query?: QueryParamsForPath<TPath>
|
query?: QueryParamsForPath<TPath>
|
||||||
/** Disable the request */
|
/** Disable the request (default: true) */
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/** Custom SWR options */
|
/** Override default SWR configuration */
|
||||||
swrOptions?: Parameters<typeof useSWR>[2]
|
swrOptions?: SWRConfiguration
|
||||||
}
|
}
|
||||||
): UseQueryResult<TPath> {
|
): UseQueryResult<TPath> {
|
||||||
// Internal type conversion for SWR compatibility
|
const key = options?.enabled !== false ? buildSWRKey(path, options?.query) : null
|
||||||
const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record<string, any>) : null
|
|
||||||
|
|
||||||
const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, {
|
const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, {
|
||||||
...DEFAULT_SWR_OPTIONS,
|
...DEFAULT_SWR_OPTIONS,
|
||||||
@ -187,29 +222,59 @@ export function useQuery<TPath extends ConcreteApiPaths>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutation hook for POST, PUT, DELETE, PATCH operations
|
* Mutation hook for POST, PUT, DELETE, PATCH operations.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Automatic cache invalidation via refresh option
|
||||||
|
* - Optimistic updates with automatic rollback on error
|
||||||
|
* - Success/error callbacks
|
||||||
|
*
|
||||||
|
* @param method - HTTP method ('POST' | 'PUT' | 'DELETE' | 'PATCH')
|
||||||
|
* @param path - API endpoint path
|
||||||
|
* @param options - Mutation options
|
||||||
|
* @param options.onSuccess - Callback when mutation succeeds
|
||||||
|
* @param options.onError - Callback when mutation fails
|
||||||
|
* @param options.refresh - API paths to revalidate on success
|
||||||
|
* @param options.optimisticData - If provided, updates cache immediately before request completes
|
||||||
|
* @param options.swrOptions - Override SWR mutation configuration
|
||||||
|
* @returns Mutation result with trigger function and loading state
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const { mutate, isLoading } = useMutation('POST', '/items', {
|
* // Basic POST
|
||||||
* onSuccess: (data) => console.log(data),
|
* const { trigger, isLoading } = useMutation('POST', '/topics')
|
||||||
* revalidate: ['/items']
|
* await trigger({ body: { name: 'New Topic' } })
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With auto-refresh and callbacks
|
||||||
|
* const { trigger } = useMutation('POST', '/topics', {
|
||||||
|
* refresh: ['/topics'],
|
||||||
|
* onSuccess: (data) => toast.success('Created!'),
|
||||||
|
* onError: (error) => toast.error(error.message)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Optimistic update (UI updates immediately, rolls back on error)
|
||||||
|
* const { trigger } = useMutation('PATCH', '/topics/abc', {
|
||||||
|
* optimisticData: { ...topic, starred: true }
|
||||||
* })
|
* })
|
||||||
* await mutate({ body: { title: 'New Item' } })
|
|
||||||
*/
|
*/
|
||||||
export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
||||||
method: TMethod,
|
method: TMethod,
|
||||||
path: TPath,
|
path: TPath,
|
||||||
options?: {
|
options?: {
|
||||||
/** Called when mutation succeeds */
|
/** Callback when mutation succeeds */
|
||||||
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
|
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
|
||||||
/** Called when mutation fails */
|
/** Callback when mutation fails */
|
||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
/** Automatically revalidate these SWR keys on success */
|
/** API paths to revalidate on success */
|
||||||
revalidate?: boolean | string[]
|
refresh?: ConcreteApiPaths[]
|
||||||
/** Enable optimistic updates */
|
/** If provided, updates cache immediately (with auto-rollback on error) */
|
||||||
optimistic?: boolean
|
|
||||||
/** Optimistic data to use for updates */
|
|
||||||
optimisticData?: ResponseForPath<TPath, TMethod>
|
optimisticData?: ResponseForPath<TPath, TMethod>
|
||||||
|
/** Override SWR mutation configuration (fetcher, onSuccess, onError are handled internally) */
|
||||||
|
swrOptions?: Omit<
|
||||||
|
SWRMutationConfiguration<ResponseForPath<TPath, TMethod>, Error>,
|
||||||
|
'fetcher' | 'onSuccess' | 'onError'
|
||||||
|
>
|
||||||
}
|
}
|
||||||
): UseMutationResult<TPath, TMethod> {
|
): UseMutationResult<TPath, TMethod> {
|
||||||
const { mutate: globalMutate } = useSWRConfig()
|
const { mutate: globalMutate } = useSWRConfig()
|
||||||
@ -220,7 +285,7 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
|
|||||||
optionsRef.current = options
|
optionsRef.current = options
|
||||||
}, [options])
|
}, [options])
|
||||||
|
|
||||||
const apiFetcher = createApiFetcher(method)
|
const apiFetcher = createApiFetcher<TPath, TMethod>(method)
|
||||||
|
|
||||||
const fetcher = async (
|
const fetcher = async (
|
||||||
_key: string,
|
_key: string,
|
||||||
@ -229,79 +294,87 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
|
|||||||
}: {
|
}: {
|
||||||
arg?: {
|
arg?: {
|
||||||
body?: BodyForPath<TPath, TMethod>
|
body?: BodyForPath<TPath, TMethod>
|
||||||
query?: Record<string, any>
|
query?: QueryParamsForPath<TPath>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
): Promise<ResponseForPath<TPath, TMethod>> => {
|
): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||||
return apiFetcher(path, { body: arg?.body, query: arg?.query })
|
return apiFetcher(path, { body: arg?.body, query: arg?.query })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { trigger, isMutating, error } = useSWRMutation(path as string, fetcher, {
|
const {
|
||||||
|
trigger: swrTrigger,
|
||||||
|
isMutating,
|
||||||
|
error
|
||||||
|
} = useSWRMutation(path as string, fetcher, {
|
||||||
populateCache: false,
|
populateCache: false,
|
||||||
revalidate: false,
|
revalidate: false,
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
optionsRef.current?.onSuccess?.(data)
|
optionsRef.current?.onSuccess?.(data)
|
||||||
|
|
||||||
if (optionsRef.current?.revalidate === true) {
|
// Refresh specified keys on success
|
||||||
await globalMutate(() => true)
|
if (optionsRef.current?.refresh?.length) {
|
||||||
} else if (Array.isArray(optionsRef.current?.revalidate)) {
|
await Promise.all(optionsRef.current.refresh.map((key) => globalMutate(key)))
|
||||||
await Promise.all(optionsRef.current.revalidate.map((key) => globalMutate(key)))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => optionsRef.current?.onError?.(error)
|
onError: (error) => optionsRef.current?.onError?.(error),
|
||||||
|
...options?.swrOptions
|
||||||
})
|
})
|
||||||
|
|
||||||
const optimisticMutate = async (data?: {
|
const trigger = async (data?: {
|
||||||
body?: BodyForPath<TPath, TMethod>
|
body?: BodyForPath<TPath, TMethod>
|
||||||
query?: QueryParamsForPath<TPath>
|
query?: QueryParamsForPath<TPath>
|
||||||
}): Promise<ResponseForPath<TPath, TMethod>> => {
|
}): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||||
const opts = optionsRef.current
|
const opts = optionsRef.current
|
||||||
if (opts?.optimistic && opts?.optimisticData) {
|
const hasOptimisticData = opts?.optimisticData !== undefined
|
||||||
await globalMutate(path, opts.optimisticData, false)
|
|
||||||
|
// Apply optimistic update if optimisticData is provided
|
||||||
|
if (hasOptimisticData) {
|
||||||
|
await globalMutate(path, opts!.optimisticData, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const convertedData = data ? { body: data.body, query: data.query as Record<string, any> } : undefined
|
const result = await swrTrigger(data)
|
||||||
|
|
||||||
const result = await trigger(convertedData)
|
// Revalidate after optimistic update completes
|
||||||
|
if (hasOptimisticData) {
|
||||||
if (opts?.optimistic) {
|
|
||||||
await globalMutate(path)
|
await globalMutate(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (opts?.optimistic && opts?.optimisticData) {
|
// Rollback optimistic update on error
|
||||||
|
if (hasOptimisticData) {
|
||||||
await globalMutate(path)
|
await globalMutate(path)
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalMutate = async (data?: {
|
|
||||||
body?: BodyForPath<TPath, TMethod>
|
|
||||||
query?: QueryParamsForPath<TPath>
|
|
||||||
}): Promise<ResponseForPath<TPath, TMethod>> => {
|
|
||||||
const convertedData = data ? { body: data.body, query: data.query as Record<string, any> } : undefined
|
|
||||||
|
|
||||||
return trigger(convertedData)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutate: optionsRef.current?.optimistic ? optimisticMutate : normalMutate,
|
trigger,
|
||||||
isLoading: isMutating,
|
isLoading: isMutating,
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to invalidate SWR cache entries
|
* Hook to invalidate SWR cache entries and trigger revalidation.
|
||||||
|
*
|
||||||
|
* Use this to manually clear cached data and force a fresh fetch.
|
||||||
|
*
|
||||||
|
* @returns Invalidate function that accepts keys to invalidate
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const invalidate = useInvalidateCache()
|
* const invalidate = useInvalidateCache()
|
||||||
* await invalidate('/items') // specific key
|
*
|
||||||
* await invalidate(['/a', '/b']) // multiple keys
|
* // Invalidate specific path
|
||||||
* await invalidate(true) // all keys
|
* await invalidate('/topics')
|
||||||
|
*
|
||||||
|
* // Invalidate multiple paths
|
||||||
|
* await invalidate(['/topics', '/messages'])
|
||||||
|
*
|
||||||
|
* // Invalidate all cached data
|
||||||
|
* await invalidate(true)
|
||||||
*/
|
*/
|
||||||
export function useInvalidateCache() {
|
export function useInvalidateCache() {
|
||||||
const { mutate } = useSWRConfig()
|
const { mutate } = useSWRConfig()
|
||||||
@ -320,10 +393,25 @@ export function useInvalidateCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefetch data for warming up before user interactions
|
* Prefetch data to warm up the cache before user interactions.
|
||||||
|
*
|
||||||
|
* Uses SWR preload to fetch and cache data. Subsequent useQuery calls
|
||||||
|
* with the same path and query will use the cached data immediately.
|
||||||
|
*
|
||||||
|
* @param path - API endpoint path to prefetch
|
||||||
|
* @param options - Prefetch options
|
||||||
|
* @param options.query - Query parameters (must match useQuery call)
|
||||||
|
* @returns Promise resolving to the fetched data
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* prefetch('/items', { query: { page: 1 } })
|
* // Prefetch on hover
|
||||||
|
* onMouseEnter={() => prefetch('/topics/abc')}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Prefetch with query params
|
||||||
|
* await prefetch('/messages', { query: { topicId: 'abc', limit: 20 } })
|
||||||
|
* // Later, this will be instant:
|
||||||
|
* const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } })
|
||||||
*/
|
*/
|
||||||
export function prefetch<TPath extends ConcreteApiPaths>(
|
export function prefetch<TPath extends ConcreteApiPaths>(
|
||||||
path: TPath,
|
path: TPath,
|
||||||
@ -331,8 +419,8 @@ export function prefetch<TPath extends ConcreteApiPaths>(
|
|||||||
query?: QueryParamsForPath<TPath>
|
query?: QueryParamsForPath<TPath>
|
||||||
}
|
}
|
||||||
): Promise<ResponseForPath<TPath, 'GET'>> {
|
): Promise<ResponseForPath<TPath, 'GET'>> {
|
||||||
const apiFetcher = createApiFetcher('GET')
|
const key = buildSWRKey(path, options?.query)
|
||||||
return apiFetcher(path, { query: options?.query as Record<string, any> })
|
return preload(key, getFetcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -340,100 +428,94 @@ export function prefetch<TPath extends ConcreteApiPaths>(
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infinite scrolling hook with cursor/offset pagination
|
* Infinite scrolling hook with cursor-based pagination.
|
||||||
|
*
|
||||||
|
* Automatically loads pages using cursor tokens. Items from all loaded pages
|
||||||
|
* are flattened into a single array for easy rendering.
|
||||||
|
*
|
||||||
|
* @param path - API endpoint path (must return CursorPaginationResponse)
|
||||||
|
* @param options - Infinite query options
|
||||||
|
* @param options.query - Additional query parameters (cursor/limit are managed internally)
|
||||||
|
* @param options.limit - Items per page (default: 10)
|
||||||
|
* @param options.enabled - Set to false to disable fetching (default: true)
|
||||||
|
* @param options.swrOptions - Override SWR infinite configuration
|
||||||
|
* @returns Infinite query result with items, pagination controls, and loading states
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/items', {
|
* // Basic infinite scroll
|
||||||
* limit: 20,
|
* const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/messages')
|
||||||
* mode: 'cursor' // or 'offset'
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* {items.map(item => <Item key={item.id} {...item} />)}
|
||||||
|
* {hasNext && <button onClick={loadNext}>Load More</button>}
|
||||||
|
* </div>
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With filters and custom limit
|
||||||
|
* const { items, loadNext } = useInfiniteQuery('/messages', {
|
||||||
|
* query: { topicId: 'abc' },
|
||||||
|
* limit: 50
|
||||||
* })
|
* })
|
||||||
*/
|
*/
|
||||||
export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
|
export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
|
||||||
path: TPath,
|
path: TPath,
|
||||||
options?: {
|
options?: {
|
||||||
/** Additional query parameters (excluding pagination params) */
|
/** Additional query parameters (cursor/limit are managed internally) */
|
||||||
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit' | 'cursor'>
|
query?: Omit<QueryParamsForPath<TPath>, 'cursor' | 'limit'>
|
||||||
/** Items per page (default: 10) */
|
/** Items per page (default: 10) */
|
||||||
limit?: number
|
limit?: number
|
||||||
/** Pagination mode (default: 'cursor') */
|
/** Set to false to disable fetching (default: true) */
|
||||||
mode?: 'offset' | 'cursor'
|
|
||||||
/** Whether to enable the query (default: true) */
|
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/** SWR options (including initialSize, revalidateAll, etc.) */
|
/** Override SWR infinite configuration */
|
||||||
swrOptions?: Parameters<typeof useSWRInfinite>[2]
|
swrOptions?: SWRInfiniteConfiguration
|
||||||
}
|
}
|
||||||
): UseInfiniteQueryResult<InferPaginatedItem<TPath>> {
|
): UseInfiniteQueryResult<InferPaginatedItem<TPath>> {
|
||||||
const limit = options?.limit ?? 10
|
const limit = options?.limit ?? 10
|
||||||
const mode = options?.mode ?? 'cursor' // Default: cursor mode
|
|
||||||
const enabled = options?.enabled !== false
|
const enabled = options?.enabled !== false
|
||||||
|
|
||||||
const getKey = useCallback(
|
const getKey = useCallback(
|
||||||
(pageIndex: number, previousPageData: PaginationResponse<any> | null) => {
|
(_pageIndex: number, previousPageData: CursorPaginationResponse<unknown> | null) => {
|
||||||
if (!enabled) return null
|
if (!enabled) return null
|
||||||
|
|
||||||
if (previousPageData) {
|
// Stop if previous page has no nextCursor
|
||||||
if (mode === 'cursor') {
|
if (previousPageData && !previousPageData.nextCursor) {
|
||||||
if (!isCursorPaginationResponse(previousPageData) || !previousPageData.nextCursor) {
|
return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Offset mode: check if we've reached the end
|
|
||||||
if (isCursorPaginationResponse(previousPageData)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const offsetData = previousPageData as OffsetPaginationResponse<any>
|
|
||||||
// No more pages if items returned is less than limit or we've fetched all
|
|
||||||
if (offsetData.items.length < limit || pageIndex * limit >= offsetData.total) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const paginationQuery: Record<string, any> = {
|
const paginationQuery = {
|
||||||
...(options?.query as Record<string, any>),
|
...options?.query,
|
||||||
limit
|
limit,
|
||||||
|
...(previousPageData?.nextCursor ? { cursor: previousPageData.nextCursor } : {})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'cursor' && previousPageData && isCursorPaginationResponse(previousPageData)) {
|
return [path, paginationQuery] as [TPath, typeof paginationQuery]
|
||||||
paginationQuery.cursor = previousPageData.nextCursor
|
|
||||||
} else if (mode === 'offset') {
|
|
||||||
paginationQuery.page = pageIndex + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return [path, paginationQuery] as [TPath, Record<string, any>]
|
|
||||||
},
|
},
|
||||||
[path, options?.query, limit, mode, enabled]
|
[path, options?.query, limit, enabled]
|
||||||
)
|
)
|
||||||
|
|
||||||
const infiniteFetcher = (key: [ConcreteApiPaths, Record<string, any>?]) => {
|
const infiniteFetcher = (key: [TPath, Record<string, unknown>]) => {
|
||||||
return getFetcher(key) as Promise<PaginationResponse<any>>
|
return getFetcher(key as unknown as [TPath, QueryParamsForPath<TPath>?]) as Promise<
|
||||||
|
CursorPaginationResponse<InferPaginatedItem<TPath>>
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
const swrResult = useSWRInfinite(getKey, infiniteFetcher, {
|
const swrResult = useSWRInfinite(getKey, infiniteFetcher, {
|
||||||
...DEFAULT_SWR_OPTIONS,
|
...DEFAULT_SWR_OPTIONS,
|
||||||
initialSize: 1,
|
|
||||||
revalidateAll: false,
|
|
||||||
revalidateFirstPage: true,
|
|
||||||
parallel: false,
|
|
||||||
...options?.swrOptions
|
...options?.swrOptions
|
||||||
})
|
})
|
||||||
|
|
||||||
const { error, isLoading, isValidating, mutate, size, setSize } = swrResult
|
const { error, isLoading, isValidating, mutate, setSize } = swrResult
|
||||||
const data = swrResult.data as PaginationResponse<any>[] | undefined
|
const data = swrResult.data as CursorPaginationResponse<InferPaginatedItem<TPath>>[] | undefined
|
||||||
|
|
||||||
const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data])
|
const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data])
|
||||||
|
|
||||||
const hasNext = useMemo(() => {
|
const hasNext = useMemo(() => {
|
||||||
if (!data?.length) return false
|
if (!data?.length) return false
|
||||||
const last = data[data.length - 1]
|
const last = data[data.length - 1]
|
||||||
if (mode === 'cursor') {
|
return !!last.nextCursor
|
||||||
return isCursorPaginationResponse(last) && !!last.nextCursor
|
}, [data])
|
||||||
}
|
|
||||||
// Offset mode: check if there are more items
|
|
||||||
if (isCursorPaginationResponse(last)) return false
|
|
||||||
const offsetData = last as OffsetPaginationResponse<any>
|
|
||||||
return offsetData.page * limit < offsetData.total
|
|
||||||
}, [data, mode, limit])
|
|
||||||
|
|
||||||
const loadNext = useCallback(() => {
|
const loadNext = useCallback(() => {
|
||||||
if (!hasNext || isValidating) return
|
if (!hasNext || isValidating) return
|
||||||
@ -443,29 +525,17 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
|
|||||||
const refresh = useCallback(() => mutate(), [mutate])
|
const refresh = useCallback(() => mutate(), [mutate])
|
||||||
const reset = useCallback(() => setSize(1), [setSize])
|
const reset = useCallback(() => setSize(1), [setSize])
|
||||||
|
|
||||||
// Total is only available in offset mode
|
|
||||||
const total = useMemo(() => {
|
|
||||||
if (!data?.length) return 0
|
|
||||||
const first = data[0]
|
|
||||||
if (isCursorPaginationResponse(first)) return 0
|
|
||||||
return (first as OffsetPaginationResponse<any>).total
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
pages: data ?? [],
|
|
||||||
total,
|
|
||||||
size,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isRefreshing: isValidating,
|
isRefreshing: isValidating,
|
||||||
error: error as Error | undefined,
|
error: error as Error | undefined,
|
||||||
hasNext,
|
hasNext,
|
||||||
loadNext,
|
loadNext,
|
||||||
setSize,
|
|
||||||
refresh,
|
refresh,
|
||||||
reset,
|
reset,
|
||||||
mutate
|
mutate
|
||||||
} as UseInfiniteQueryResult<InferPaginatedItem<TPath>>
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -473,25 +543,50 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paginated data fetching hook with navigation controls
|
* Paginated data fetching hook with offset-based navigation.
|
||||||
|
*
|
||||||
|
* Provides page-by-page navigation with previous/next controls.
|
||||||
|
* Automatically resets to page 1 when query parameters change.
|
||||||
|
*
|
||||||
|
* @param path - API endpoint path (must return OffsetPaginationResponse)
|
||||||
|
* @param options - Pagination options
|
||||||
|
* @param options.query - Additional query parameters (page/limit are managed internally)
|
||||||
|
* @param options.limit - Items per page (default: 10)
|
||||||
|
* @param options.enabled - Set to false to disable fetching (default: true)
|
||||||
|
* @param options.swrOptions - Override SWR configuration
|
||||||
|
* @returns Paginated query result with items, page info, and navigation controls
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const { items, page, hasNext, nextPage, prevPage } = usePaginatedQuery('/items', {
|
* // Basic pagination
|
||||||
* limit: 20,
|
* const { items, page, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics')
|
||||||
* query: { search: 'hello' }
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* {items.map(item => <Item key={item.id} {...item} />)}
|
||||||
|
* <button onClick={prevPage} disabled={!hasPrev}>Prev</button>
|
||||||
|
* <span>Page {page}</span>
|
||||||
|
* <button onClick={nextPage} disabled={!hasNext}>Next</button>
|
||||||
|
* </div>
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With search filter
|
||||||
|
* const { items, total } = usePaginatedQuery('/topics', {
|
||||||
|
* query: { search: searchTerm },
|
||||||
|
* limit: 20
|
||||||
* })
|
* })
|
||||||
*/
|
*/
|
||||||
export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
|
export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
|
||||||
path: TPath,
|
path: TPath,
|
||||||
options?: {
|
options?: {
|
||||||
/** Additional query parameters (excluding pagination params) */
|
/** Additional query parameters (page/limit are managed internally) */
|
||||||
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit'>
|
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit'>
|
||||||
/** Items per page (default: 10) */
|
/** Items per page (default: 10) */
|
||||||
limit?: number
|
limit?: number
|
||||||
/** Whether to enable the query (default: true) */
|
/** Set to false to disable fetching (default: true) */
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/** SWR options */
|
/** Override SWR configuration */
|
||||||
swrOptions?: Parameters<typeof useSWR>[2]
|
swrOptions?: SWRConfiguration
|
||||||
}
|
}
|
||||||
): UsePaginatedQueryResult<InferPaginatedItem<TPath>> {
|
): UsePaginatedQueryResult<InferPaginatedItem<TPath>> {
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
@ -503,13 +598,15 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
|
|||||||
setCurrentPage(1)
|
setCurrentPage(1)
|
||||||
}, [queryKey])
|
}, [queryKey])
|
||||||
|
|
||||||
|
// Build query with pagination params
|
||||||
const queryWithPagination = {
|
const queryWithPagination = {
|
||||||
...options?.query,
|
...options?.query,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
limit
|
limit
|
||||||
} as Record<string, any>
|
}
|
||||||
|
|
||||||
const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, {
|
const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, {
|
||||||
|
// Type assertion needed: we're adding pagination params to a partial query type
|
||||||
query: queryWithPagination as QueryParamsForPath<TPath>,
|
query: queryWithPagination as QueryParamsForPath<TPath>,
|
||||||
enabled: options?.enabled,
|
enabled: options?.enabled,
|
||||||
swrOptions: options?.swrOptions
|
swrOptions: options?.swrOptions
|
||||||
@ -555,3 +652,80 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
|
|||||||
reset
|
reset
|
||||||
} as UsePaginatedQueryResult<InferPaginatedItem<TPath>>
|
} as UsePaginatedQueryResult<InferPaginatedItem<TPath>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Internal Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a type-safe API fetcher for the specified HTTP method.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @param method - HTTP method to use
|
||||||
|
* @returns Async function that makes the API request
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* Type assertion at dataApiService boundary is intentional since dataApiService
|
||||||
|
* accepts 'any' for maximum flexibility.
|
||||||
|
*/
|
||||||
|
function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
||||||
|
method: TMethod
|
||||||
|
) {
|
||||||
|
return async (
|
||||||
|
path: TPath,
|
||||||
|
options?: {
|
||||||
|
body?: BodyForPath<TPath, TMethod>
|
||||||
|
query?: QueryParamsForPath<TPath>
|
||||||
|
}
|
||||||
|
): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||||
|
// Internal type assertion for dataApiService boundary (accepts any)
|
||||||
|
const query = options?.query as Record<string, unknown> | undefined
|
||||||
|
switch (method) {
|
||||||
|
case 'GET':
|
||||||
|
return dataApiService.get(path, { query })
|
||||||
|
case 'POST':
|
||||||
|
return dataApiService.post(path, { body: options?.body, query })
|
||||||
|
case 'PUT':
|
||||||
|
return dataApiService.put(path, { body: options?.body || {}, query })
|
||||||
|
case 'DELETE':
|
||||||
|
return dataApiService.delete(path, { query })
|
||||||
|
case 'PATCH':
|
||||||
|
return dataApiService.patch(path, { body: options?.body, query })
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported method: ${method}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SWR cache key from path and optional query parameters.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @param path - API endpoint path
|
||||||
|
* @param query - Optional query parameters
|
||||||
|
* @returns Tuple of [path] or [path, query] for SWR cache key
|
||||||
|
*/
|
||||||
|
function buildSWRKey<TPath extends ConcreteApiPaths, TQuery extends QueryParamsForPath<TPath>>(
|
||||||
|
path: TPath,
|
||||||
|
query?: TQuery
|
||||||
|
): [TPath] | [TPath, TQuery] {
|
||||||
|
if (query && Object.keys(query).length > 0) {
|
||||||
|
return [path, query]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [path]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SWR fetcher function for GET requests.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @param key - SWR cache key tuple [path, query?]
|
||||||
|
* @returns Promise resolving to the API response
|
||||||
|
*/
|
||||||
|
function getFetcher<TPath extends ConcreteApiPaths>([path, query]: [TPath, QueryParamsForPath<TPath>?]): Promise<
|
||||||
|
ResponseForPath<TPath, 'GET'>
|
||||||
|
> {
|
||||||
|
const apiFetcher = createApiFetcher<TPath, 'GET'>('GET')
|
||||||
|
return apiFetcher(path, { query })
|
||||||
|
}
|
||||||
|
|||||||
@ -46,7 +46,7 @@ function createMockDataForPath(path: ConcreteApiPaths): any {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock useQuery hook
|
* Mock useQuery hook
|
||||||
* Matches actual signature: useQuery(path, options?) => { data, loading, error, refetch, mutate }
|
* Matches actual signature: useQuery(path, options?) => { data, isLoading, isRefreshing, error, refetch, mutate }
|
||||||
*/
|
*/
|
||||||
export const mockUseQuery = vi.fn(
|
export const mockUseQuery = vi.fn(
|
||||||
<TPath extends ConcreteApiPaths>(
|
<TPath extends ConcreteApiPaths>(
|
||||||
@ -58,7 +58,8 @@ export const mockUseQuery = vi.fn(
|
|||||||
}
|
}
|
||||||
): {
|
): {
|
||||||
data?: ResponseForPath<TPath, 'GET'>
|
data?: ResponseForPath<TPath, 'GET'>
|
||||||
loading: boolean
|
isLoading: boolean
|
||||||
|
isRefreshing: boolean
|
||||||
error?: Error
|
error?: Error
|
||||||
refetch: () => void
|
refetch: () => void
|
||||||
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
||||||
@ -67,7 +68,8 @@ export const mockUseQuery = vi.fn(
|
|||||||
if (options?.enabled === false) {
|
if (options?.enabled === false) {
|
||||||
return {
|
return {
|
||||||
data: undefined,
|
data: undefined,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
||||||
@ -78,7 +80,8 @@ export const mockUseQuery = vi.fn(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data: mockData as ResponseForPath<TPath, 'GET'>,
|
data: mockData as ResponseForPath<TPath, 'GET'>,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
||||||
@ -88,7 +91,7 @@ export const mockUseQuery = vi.fn(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock useMutation hook
|
* Mock useMutation hook
|
||||||
* Matches actual signature: useMutation(method, path, options?) => { mutate, loading, error }
|
* Matches actual signature: useMutation(method, path, options?) => { trigger, isLoading, error }
|
||||||
*/
|
*/
|
||||||
export const mockUseMutation = vi.fn(
|
export const mockUseMutation = vi.fn(
|
||||||
<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
||||||
@ -97,19 +100,19 @@ export const mockUseMutation = vi.fn(
|
|||||||
_options?: {
|
_options?: {
|
||||||
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
|
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
|
||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
revalidate?: boolean | string[]
|
refresh?: ConcreteApiPaths[]
|
||||||
optimistic?: boolean
|
|
||||||
optimisticData?: ResponseForPath<TPath, TMethod>
|
optimisticData?: ResponseForPath<TPath, TMethod>
|
||||||
|
swrOptions?: any
|
||||||
}
|
}
|
||||||
): {
|
): {
|
||||||
mutate: (data?: {
|
trigger: (data?: {
|
||||||
body?: BodyForPath<TPath, TMethod>
|
body?: BodyForPath<TPath, TMethod>
|
||||||
query?: QueryParamsForPath<TPath>
|
query?: QueryParamsForPath<TPath>
|
||||||
}) => Promise<ResponseForPath<TPath, TMethod>>
|
}) => Promise<ResponseForPath<TPath, TMethod>>
|
||||||
loading: boolean
|
isLoading: boolean
|
||||||
error: Error | undefined
|
error: Error | undefined
|
||||||
} => {
|
} => {
|
||||||
const mockMutate = vi.fn(
|
const mockTrigger = vi.fn(
|
||||||
async (_data?: { body?: BodyForPath<TPath, TMethod>; query?: QueryParamsForPath<TPath> }) => {
|
async (_data?: { body?: BodyForPath<TPath, TMethod>; query?: QueryParamsForPath<TPath> }) => {
|
||||||
// Simulate different responses based on method
|
// Simulate different responses based on method
|
||||||
switch (method) {
|
switch (method) {
|
||||||
@ -127,8 +130,8 @@ export const mockUseMutation = vi.fn(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutate: mockMutate,
|
trigger: mockTrigger,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
error: undefined
|
error: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -136,7 +139,7 @@ export const mockUseMutation = vi.fn(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock usePaginatedQuery hook
|
* Mock usePaginatedQuery hook
|
||||||
* Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset }
|
* Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, isLoading, isRefreshing, error, hasNext, hasPrev, prevPage, nextPage, refresh, reset }
|
||||||
*/
|
*/
|
||||||
export const mockUsePaginatedQuery = vi.fn(
|
export const mockUsePaginatedQuery = vi.fn(
|
||||||
<TPath extends ConcreteApiPaths>(
|
<TPath extends ConcreteApiPaths>(
|
||||||
@ -151,9 +154,10 @@ export const mockUsePaginatedQuery = vi.fn(
|
|||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
loading: boolean
|
isLoading: boolean
|
||||||
|
isRefreshing: boolean
|
||||||
error?: Error
|
error?: Error
|
||||||
hasMore: boolean
|
hasNext: boolean
|
||||||
hasPrev: boolean
|
hasPrev: boolean
|
||||||
prevPage: () => void
|
prevPage: () => void
|
||||||
nextPage: () => void
|
nextPage: () => void
|
||||||
@ -173,9 +177,10 @@ export const mockUsePaginatedQuery = vi.fn(
|
|||||||
items: mockItems,
|
items: mockItems,
|
||||||
total: mockItems.length,
|
total: mockItems.length,
|
||||||
page: 1,
|
page: 1,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
hasMore: false,
|
hasNext: false,
|
||||||
hasPrev: false,
|
hasPrev: false,
|
||||||
prevPage: vi.fn(),
|
prevPage: vi.fn(),
|
||||||
nextPage: vi.fn(),
|
nextPage: vi.fn(),
|
||||||
@ -186,9 +191,10 @@ export const mockUsePaginatedQuery = vi.fn(
|
|||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
loading: boolean
|
isLoading: boolean
|
||||||
|
isRefreshing: boolean
|
||||||
error?: Error
|
error?: Error
|
||||||
hasMore: boolean
|
hasNext: boolean
|
||||||
hasPrev: boolean
|
hasPrev: boolean
|
||||||
prevPage: () => void
|
prevPage: () => void
|
||||||
nextPage: () => void
|
nextPage: () => void
|
||||||
@ -259,7 +265,8 @@ export const MockUseDataApiUtils = {
|
|||||||
if (queryPath === path) {
|
if (queryPath === path) {
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(data)
|
mutate: vi.fn().mockResolvedValue(data)
|
||||||
@ -269,7 +276,8 @@ export const MockUseDataApiUtils = {
|
|||||||
const defaultData = createMockDataForPath(queryPath)
|
const defaultData = createMockDataForPath(queryPath)
|
||||||
return {
|
return {
|
||||||
data: defaultData,
|
data: defaultData,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(defaultData)
|
mutate: vi.fn().mockResolvedValue(defaultData)
|
||||||
@ -285,7 +293,8 @@ export const MockUseDataApiUtils = {
|
|||||||
if (queryPath === path) {
|
if (queryPath === path) {
|
||||||
return {
|
return {
|
||||||
data: undefined,
|
data: undefined,
|
||||||
loading: true,
|
isLoading: true,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(undefined)
|
mutate: vi.fn().mockResolvedValue(undefined)
|
||||||
@ -294,7 +303,8 @@ export const MockUseDataApiUtils = {
|
|||||||
const defaultData = createMockDataForPath(queryPath)
|
const defaultData = createMockDataForPath(queryPath)
|
||||||
return {
|
return {
|
||||||
data: defaultData,
|
data: defaultData,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(defaultData)
|
mutate: vi.fn().mockResolvedValue(defaultData)
|
||||||
@ -310,7 +320,8 @@ export const MockUseDataApiUtils = {
|
|||||||
if (queryPath === path) {
|
if (queryPath === path) {
|
||||||
return {
|
return {
|
||||||
data: undefined,
|
data: undefined,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error,
|
error,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(undefined)
|
mutate: vi.fn().mockResolvedValue(undefined)
|
||||||
@ -319,7 +330,8 @@ export const MockUseDataApiUtils = {
|
|||||||
const defaultData = createMockDataForPath(queryPath)
|
const defaultData = createMockDataForPath(queryPath)
|
||||||
return {
|
return {
|
||||||
data: defaultData,
|
data: defaultData,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
mutate: vi.fn().mockResolvedValue(defaultData)
|
mutate: vi.fn().mockResolvedValue(defaultData)
|
||||||
@ -338,15 +350,15 @@ export const MockUseDataApiUtils = {
|
|||||||
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
||||||
if (mutationPath === path && mutationMethod === method) {
|
if (mutationPath === path && mutationMethod === method) {
|
||||||
return {
|
return {
|
||||||
mutate: vi.fn().mockResolvedValue(result),
|
trigger: vi.fn().mockResolvedValue(result),
|
||||||
loading: false,
|
isLoading: false,
|
||||||
error: undefined
|
error: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default behavior
|
// Default behavior
|
||||||
return {
|
return {
|
||||||
mutate: vi.fn().mockResolvedValue({ success: true }),
|
trigger: vi.fn().mockResolvedValue({ success: true }),
|
||||||
loading: false,
|
isLoading: false,
|
||||||
error: undefined
|
error: undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -363,15 +375,15 @@ export const MockUseDataApiUtils = {
|
|||||||
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
||||||
if (mutationPath === path && mutationMethod === method) {
|
if (mutationPath === path && mutationMethod === method) {
|
||||||
return {
|
return {
|
||||||
mutate: vi.fn().mockRejectedValue(error),
|
trigger: vi.fn().mockRejectedValue(error),
|
||||||
loading: false,
|
isLoading: false,
|
||||||
error: undefined
|
error: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default behavior
|
// Default behavior
|
||||||
return {
|
return {
|
||||||
mutate: vi.fn().mockResolvedValue({ success: true }),
|
trigger: vi.fn().mockResolvedValue({ success: true }),
|
||||||
loading: false,
|
isLoading: false,
|
||||||
error: undefined
|
error: undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -387,15 +399,15 @@ export const MockUseDataApiUtils = {
|
|||||||
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
||||||
if (mutationPath === path && mutationMethod === method) {
|
if (mutationPath === path && mutationMethod === method) {
|
||||||
return {
|
return {
|
||||||
mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||||
loading: true,
|
isLoading: true,
|
||||||
error: undefined
|
error: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default behavior
|
// Default behavior
|
||||||
return {
|
return {
|
||||||
mutate: vi.fn().mockResolvedValue({ success: true }),
|
trigger: vi.fn().mockResolvedValue({ success: true }),
|
||||||
loading: false,
|
isLoading: false,
|
||||||
error: undefined
|
error: undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -407,7 +419,7 @@ export const MockUseDataApiUtils = {
|
|||||||
mockPaginatedData: <TPath extends ConcreteApiPaths>(
|
mockPaginatedData: <TPath extends ConcreteApiPaths>(
|
||||||
path: TPath,
|
path: TPath,
|
||||||
items: any[],
|
items: any[],
|
||||||
options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean }
|
options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean }
|
||||||
) => {
|
) => {
|
||||||
mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => {
|
mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => {
|
||||||
if (queryPath === path) {
|
if (queryPath === path) {
|
||||||
@ -415,9 +427,10 @@ export const MockUseDataApiUtils = {
|
|||||||
items,
|
items,
|
||||||
total: options?.total ?? items.length,
|
total: options?.total ?? items.length,
|
||||||
page: options?.page ?? 1,
|
page: options?.page ?? 1,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
hasMore: options?.hasMore ?? false,
|
hasNext: options?.hasNext ?? false,
|
||||||
hasPrev: options?.hasPrev ?? false,
|
hasPrev: options?.hasPrev ?? false,
|
||||||
prevPage: vi.fn(),
|
prevPage: vi.fn(),
|
||||||
nextPage: vi.fn(),
|
nextPage: vi.fn(),
|
||||||
@ -430,9 +443,10 @@ export const MockUseDataApiUtils = {
|
|||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
loading: false,
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
hasMore: false,
|
hasNext: false,
|
||||||
hasPrev: false,
|
hasPrev: false,
|
||||||
prevPage: vi.fn(),
|
prevPage: vi.fn(),
|
||||||
nextPage: vi.fn(),
|
nextPage: vi.fn(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user