mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 14:29:15 +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'
|
||||
|
||||
// Basic usage
|
||||
const { data, loading, error } = useQuery('/topics')
|
||||
const { data, isLoading, error } = useQuery('/topics')
|
||||
|
||||
// With query parameters
|
||||
const { data: messages } = useQuery('/messages', {
|
||||
@ -23,12 +23,12 @@ const { data: messages } = useQuery('/messages', {
|
||||
const { data: topic } = useQuery('/topics/abc123')
|
||||
|
||||
// Conditional fetching
|
||||
const { data } = useQuery(topicId ? `/topics/${topicId}` : null)
|
||||
const { data } = useQuery('/topics', { enabled: !!topicId })
|
||||
|
||||
// With refresh callback
|
||||
const { data, mutate } = useQuery('/topics')
|
||||
const { data, mutate, refetch } = useQuery('/topics')
|
||||
// Refresh data
|
||||
await mutate()
|
||||
refetch() // or await mutate()
|
||||
```
|
||||
|
||||
### useMutation (POST/PUT/PATCH/DELETE)
|
||||
@ -39,22 +39,68 @@ Perform data modifications with loading states.
|
||||
import { useMutation } from '@data/hooks/useDataApi'
|
||||
|
||||
// Create (POST)
|
||||
const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST')
|
||||
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics')
|
||||
const newTopic = await createTopic({ body: { name: 'New Topic' } })
|
||||
|
||||
// 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: '...' } })
|
||||
|
||||
// Partial Update (PATCH)
|
||||
const { trigger: updateTopic } = useMutation('/topics/abc123', 'PATCH')
|
||||
const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123')
|
||||
await updateTopic({ body: { name: 'New Name' } })
|
||||
|
||||
// Delete
|
||||
const { trigger: deleteTopic } = useMutation('/topics/abc123', 'DELETE')
|
||||
const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123')
|
||||
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
|
||||
|
||||
For non-React code or more control.
|
||||
@ -94,9 +140,9 @@ await dataApiService.delete('/topics/abc123')
|
||||
|
||||
```typescript
|
||||
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.code === ErrorCode.NOT_FOUND) {
|
||||
return <NotFound />
|
||||
@ -146,39 +192,18 @@ if (error instanceof DataApiError && error.isRetryable) {
|
||||
|
||||
## 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
|
||||
|
||||
```typescript
|
||||
function CreateTopicForm() {
|
||||
const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST')
|
||||
const { mutate } = useQuery('/topics') // For revalidation
|
||||
// Use refresh option to auto-refresh /topics after creation
|
||||
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', {
|
||||
refresh: ['/topics']
|
||||
})
|
||||
|
||||
const handleSubmit = async (data: CreateTopicDto) => {
|
||||
try {
|
||||
await createTopic({ body: data })
|
||||
await mutate() // Refresh list
|
||||
toast.success('Topic created')
|
||||
} catch (error) {
|
||||
toast.error('Failed to create topic')
|
||||
@ -188,8 +213,8 @@ function CreateTopicForm() {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* form fields */}
|
||||
<button disabled={isMutating}>
|
||||
{isMutating ? 'Creating...' : 'Create'}
|
||||
<button disabled={isLoading}>
|
||||
{isLoading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
@ -200,26 +225,16 @@ function CreateTopicForm() {
|
||||
|
||||
```typescript
|
||||
function TopicItem({ topic }: { topic: Topic }) {
|
||||
const { trigger: updateTopic } = useMutation(`/topics/${topic.id}`, 'PATCH')
|
||||
const { mutate } = useQuery('/topics')
|
||||
// Use optimisticData for automatic optimistic updates with rollback
|
||||
const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, {
|
||||
optimisticData: { ...topic, starred: !topic.starred }
|
||||
})
|
||||
|
||||
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 {
|
||||
await updateTopic({ body: { starred: !topic.starred } })
|
||||
} catch (error) {
|
||||
// Revert on failure
|
||||
await mutate()
|
||||
// Rollback happens automatically when optimisticData is set
|
||||
toast.error('Failed to update')
|
||||
}
|
||||
}
|
||||
@ -279,7 +294,7 @@ The API is fully typed based on schema definitions:
|
||||
const { data } = useQuery('/topics')
|
||||
// data is typed as PaginatedResponse<Topic>
|
||||
|
||||
const { trigger } = useMutation('/topics', 'POST')
|
||||
const { trigger } = useMutation('POST', '/topics')
|
||||
// trigger expects { body: CreateTopicDto }
|
||||
// returns Topic
|
||||
|
||||
@ -291,8 +306,9 @@ const { data: topic } = useQuery('/topics/abc123')
|
||||
## Best Practices
|
||||
|
||||
1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states
|
||||
2. **Handle loading states**: Always show feedback while data is loading
|
||||
3. **Handle errors gracefully**: Provide meaningful error messages to users
|
||||
4. **Revalidate after mutations**: Keep the UI in sync with the database
|
||||
5. **Use conditional fetching**: Pass `null` to skip queries when dependencies aren't ready
|
||||
6. **Batch related operations**: Consider using transactions for multiple updates
|
||||
2. **Choose the right pagination hook**: Use `useInfiniteQuery` for infinite scroll, `usePaginatedQuery` for page navigation
|
||||
3. **Handle loading states**: Always show feedback while data is loading
|
||||
4. **Handle errors gracefully**: Provide meaningful error messages to users
|
||||
5. **Revalidate after mutations**: Use `refresh` option to keep the UI in sync
|
||||
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 { ConcreteApiPaths } from '@shared/data/api/apiTypes'
|
||||
import {
|
||||
isCursorPaginationResponse,
|
||||
type CursorPaginationResponse,
|
||||
type OffsetPaginationResponse,
|
||||
type PaginationResponse
|
||||
} from '@shared/data/api/apiTypes'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { KeyedMutator } from 'swr'
|
||||
import useSWR, { useSWRConfig } from 'swr'
|
||||
import type { KeyedMutator, SWRConfiguration } from 'swr'
|
||||
import useSWR, { preload, useSWRConfig } from 'swr'
|
||||
import type { SWRInfiniteConfiguration } from 'swr/infinite'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import type { SWRMutationConfiguration } 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
|
||||
@ -24,7 +69,15 @@ type InferPaginatedItem<TPath extends ConcreteApiPaths> = ResponseForPath<TPath,
|
||||
? T
|
||||
: 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> {
|
||||
data?: ResponseForPath<TPath, 'GET'>
|
||||
isLoading: boolean
|
||||
@ -34,12 +87,17 @@ export interface UseQueryResult<TPath extends ConcreteApiPaths> {
|
||||
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<
|
||||
TPath extends ConcreteApiPaths,
|
||||
TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
> {
|
||||
mutate: (data?: {
|
||||
trigger: (data?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}) => Promise<ResponseForPath<TPath, TMethod>>
|
||||
@ -47,24 +105,45 @@ export interface UseMutationResult<
|
||||
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> {
|
||||
items: T[]
|
||||
pages: PaginationResponse<T>[]
|
||||
total: number
|
||||
size: number
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
error?: Error
|
||||
hasNext: boolean
|
||||
loadNext: () => void
|
||||
setSize: (size: number | ((size: number) => number)) => void
|
||||
refresh: () => 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> {
|
||||
items: T[]
|
||||
total: number
|
||||
@ -80,94 +159,50 @@ export interface UsePaginatedQueryResult<T> {
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Unified API fetcher with type-safe method dispatching
|
||||
*/
|
||||
function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
||||
method: TMethod
|
||||
) {
|
||||
return async (
|
||||
path: TPath,
|
||||
options?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: Record<string, any>
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||
switch (method) {
|
||||
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
|
||||
* Data fetching hook with SWR caching and revalidation.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic caching and deduplication
|
||||
* - Background revalidation on focus/reconnect
|
||||
* - Error retry with exponential backoff
|
||||
*
|
||||
* @param path - API endpoint path (e.g., '/topics', '/messages')
|
||||
* @param options - Query options
|
||||
* @param options.query - Query parameters for filtering, pagination, etc.
|
||||
* @param options.enabled - Set to false to disable the request (default: true)
|
||||
* @param options.swrOptions - Override default SWR configuration
|
||||
* @returns Query result with data, loading states, and cache controls
|
||||
*
|
||||
* @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>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
/** Query parameters for filtering, pagination, etc. */
|
||||
query?: QueryParamsForPath<TPath>
|
||||
/** Disable the request */
|
||||
/** Disable the request (default: true) */
|
||||
enabled?: boolean
|
||||
/** Custom SWR options */
|
||||
swrOptions?: Parameters<typeof useSWR>[2]
|
||||
/** Override default SWR configuration */
|
||||
swrOptions?: SWRConfiguration
|
||||
}
|
||||
): UseQueryResult<TPath> {
|
||||
// Internal type conversion for SWR compatibility
|
||||
const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record<string, any>) : null
|
||||
const key = options?.enabled !== false ? buildSWRKey(path, options?.query) : null
|
||||
|
||||
const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, {
|
||||
...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
|
||||
* const { mutate, isLoading } = useMutation('POST', '/items', {
|
||||
* onSuccess: (data) => console.log(data),
|
||||
* revalidate: ['/items']
|
||||
* // Basic POST
|
||||
* const { trigger, isLoading } = useMutation('POST', '/topics')
|
||||
* 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'>(
|
||||
method: TMethod,
|
||||
path: TPath,
|
||||
options?: {
|
||||
/** Called when mutation succeeds */
|
||||
/** Callback when mutation succeeds */
|
||||
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
|
||||
/** Called when mutation fails */
|
||||
/** Callback when mutation fails */
|
||||
onError?: (error: Error) => void
|
||||
/** Automatically revalidate these SWR keys on success */
|
||||
revalidate?: boolean | string[]
|
||||
/** Enable optimistic updates */
|
||||
optimistic?: boolean
|
||||
/** Optimistic data to use for updates */
|
||||
/** API paths to revalidate on success */
|
||||
refresh?: ConcreteApiPaths[]
|
||||
/** If provided, updates cache immediately (with auto-rollback on error) */
|
||||
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> {
|
||||
const { mutate: globalMutate } = useSWRConfig()
|
||||
@ -220,7 +285,7 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
|
||||
optionsRef.current = options
|
||||
}, [options])
|
||||
|
||||
const apiFetcher = createApiFetcher(method)
|
||||
const apiFetcher = createApiFetcher<TPath, TMethod>(method)
|
||||
|
||||
const fetcher = async (
|
||||
_key: string,
|
||||
@ -229,79 +294,87 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
|
||||
}: {
|
||||
arg?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: Record<string, any>
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||
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,
|
||||
revalidate: false,
|
||||
onSuccess: async (data) => {
|
||||
optionsRef.current?.onSuccess?.(data)
|
||||
|
||||
if (optionsRef.current?.revalidate === true) {
|
||||
await globalMutate(() => true)
|
||||
} else if (Array.isArray(optionsRef.current?.revalidate)) {
|
||||
await Promise.all(optionsRef.current.revalidate.map((key) => globalMutate(key)))
|
||||
// Refresh specified keys on success
|
||||
if (optionsRef.current?.refresh?.length) {
|
||||
await Promise.all(optionsRef.current.refresh.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>
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||
const opts = optionsRef.current
|
||||
if (opts?.optimistic && opts?.optimisticData) {
|
||||
await globalMutate(path, opts.optimisticData, false)
|
||||
const hasOptimisticData = opts?.optimisticData !== undefined
|
||||
|
||||
// Apply optimistic update if optimisticData is provided
|
||||
if (hasOptimisticData) {
|
||||
await globalMutate(path, opts!.optimisticData, false)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (opts?.optimistic) {
|
||||
// Revalidate after optimistic update completes
|
||||
if (hasOptimisticData) {
|
||||
await globalMutate(path)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
if (opts?.optimistic && opts?.optimisticData) {
|
||||
// Rollback optimistic update on error
|
||||
if (hasOptimisticData) {
|
||||
await globalMutate(path)
|
||||
}
|
||||
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 {
|
||||
mutate: optionsRef.current?.optimistic ? optimisticMutate : normalMutate,
|
||||
trigger,
|
||||
isLoading: isMutating,
|
||||
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
|
||||
* const invalidate = useInvalidateCache()
|
||||
* await invalidate('/items') // specific key
|
||||
* await invalidate(['/a', '/b']) // multiple keys
|
||||
* await invalidate(true) // all keys
|
||||
*
|
||||
* // Invalidate specific path
|
||||
* await invalidate('/topics')
|
||||
*
|
||||
* // Invalidate multiple paths
|
||||
* await invalidate(['/topics', '/messages'])
|
||||
*
|
||||
* // Invalidate all cached data
|
||||
* await invalidate(true)
|
||||
*/
|
||||
export function useInvalidateCache() {
|
||||
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
|
||||
* 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>(
|
||||
path: TPath,
|
||||
@ -331,8 +419,8 @@ export function prefetch<TPath extends ConcreteApiPaths>(
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, 'GET'>> {
|
||||
const apiFetcher = createApiFetcher('GET')
|
||||
return apiFetcher(path, { query: options?.query as Record<string, any> })
|
||||
const key = buildSWRKey(path, options?.query)
|
||||
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
|
||||
* const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/items', {
|
||||
* limit: 20,
|
||||
* mode: 'cursor' // or 'offset'
|
||||
* // Basic infinite scroll
|
||||
* const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/messages')
|
||||
*
|
||||
* 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>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
/** Additional query parameters (excluding pagination params) */
|
||||
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit' | 'cursor'>
|
||||
/** Additional query parameters (cursor/limit are managed internally) */
|
||||
query?: Omit<QueryParamsForPath<TPath>, 'cursor' | 'limit'>
|
||||
/** Items per page (default: 10) */
|
||||
limit?: number
|
||||
/** Pagination mode (default: 'cursor') */
|
||||
mode?: 'offset' | 'cursor'
|
||||
/** Whether to enable the query (default: true) */
|
||||
/** Set to false to disable fetching (default: true) */
|
||||
enabled?: boolean
|
||||
/** SWR options (including initialSize, revalidateAll, etc.) */
|
||||
swrOptions?: Parameters<typeof useSWRInfinite>[2]
|
||||
/** Override SWR infinite configuration */
|
||||
swrOptions?: SWRInfiniteConfiguration
|
||||
}
|
||||
): UseInfiniteQueryResult<InferPaginatedItem<TPath>> {
|
||||
const limit = options?.limit ?? 10
|
||||
const mode = options?.mode ?? 'cursor' // Default: cursor mode
|
||||
const enabled = options?.enabled !== false
|
||||
|
||||
const getKey = useCallback(
|
||||
(pageIndex: number, previousPageData: PaginationResponse<any> | null) => {
|
||||
(_pageIndex: number, previousPageData: CursorPaginationResponse<unknown> | null) => {
|
||||
if (!enabled) return null
|
||||
|
||||
if (previousPageData) {
|
||||
if (mode === 'cursor') {
|
||||
if (!isCursorPaginationResponse(previousPageData) || !previousPageData.nextCursor) {
|
||||
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
|
||||
}
|
||||
}
|
||||
// Stop if previous page has no nextCursor
|
||||
if (previousPageData && !previousPageData.nextCursor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const paginationQuery: Record<string, any> = {
|
||||
...(options?.query as Record<string, any>),
|
||||
limit
|
||||
const paginationQuery = {
|
||||
...options?.query,
|
||||
limit,
|
||||
...(previousPageData?.nextCursor ? { cursor: previousPageData.nextCursor } : {})
|
||||
}
|
||||
|
||||
if (mode === 'cursor' && previousPageData && isCursorPaginationResponse(previousPageData)) {
|
||||
paginationQuery.cursor = previousPageData.nextCursor
|
||||
} else if (mode === 'offset') {
|
||||
paginationQuery.page = pageIndex + 1
|
||||
}
|
||||
|
||||
return [path, paginationQuery] as [TPath, Record<string, any>]
|
||||
return [path, paginationQuery] as [TPath, typeof paginationQuery]
|
||||
},
|
||||
[path, options?.query, limit, mode, enabled]
|
||||
[path, options?.query, limit, enabled]
|
||||
)
|
||||
|
||||
const infiniteFetcher = (key: [ConcreteApiPaths, Record<string, any>?]) => {
|
||||
return getFetcher(key) as Promise<PaginationResponse<any>>
|
||||
const infiniteFetcher = (key: [TPath, Record<string, unknown>]) => {
|
||||
return getFetcher(key as unknown as [TPath, QueryParamsForPath<TPath>?]) as Promise<
|
||||
CursorPaginationResponse<InferPaginatedItem<TPath>>
|
||||
>
|
||||
}
|
||||
|
||||
const swrResult = useSWRInfinite(getKey, infiniteFetcher, {
|
||||
...DEFAULT_SWR_OPTIONS,
|
||||
initialSize: 1,
|
||||
revalidateAll: false,
|
||||
revalidateFirstPage: true,
|
||||
parallel: false,
|
||||
...options?.swrOptions
|
||||
})
|
||||
|
||||
const { error, isLoading, isValidating, mutate, size, setSize } = swrResult
|
||||
const data = swrResult.data as PaginationResponse<any>[] | undefined
|
||||
const { error, isLoading, isValidating, mutate, setSize } = swrResult
|
||||
const data = swrResult.data as CursorPaginationResponse<InferPaginatedItem<TPath>>[] | undefined
|
||||
|
||||
const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data])
|
||||
|
||||
const hasNext = useMemo(() => {
|
||||
if (!data?.length) return false
|
||||
const last = data[data.length - 1]
|
||||
if (mode === 'cursor') {
|
||||
return isCursorPaginationResponse(last) && !!last.nextCursor
|
||||
}
|
||||
// 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])
|
||||
return !!last.nextCursor
|
||||
}, [data])
|
||||
|
||||
const loadNext = useCallback(() => {
|
||||
if (!hasNext || isValidating) return
|
||||
@ -443,29 +525,17 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
|
||||
const refresh = useCallback(() => mutate(), [mutate])
|
||||
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 {
|
||||
items,
|
||||
pages: data ?? [],
|
||||
total,
|
||||
size,
|
||||
isLoading,
|
||||
isRefreshing: isValidating,
|
||||
error: error as Error | undefined,
|
||||
hasNext,
|
||||
loadNext,
|
||||
setSize,
|
||||
refresh,
|
||||
reset,
|
||||
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
|
||||
* const { items, page, hasNext, nextPage, prevPage } = usePaginatedQuery('/items', {
|
||||
* limit: 20,
|
||||
* query: { search: 'hello' }
|
||||
* // Basic pagination
|
||||
* const { items, page, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics')
|
||||
*
|
||||
* 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>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
/** Additional query parameters (excluding pagination params) */
|
||||
/** Additional query parameters (page/limit are managed internally) */
|
||||
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit'>
|
||||
/** Items per page (default: 10) */
|
||||
limit?: number
|
||||
/** Whether to enable the query (default: true) */
|
||||
/** Set to false to disable fetching (default: true) */
|
||||
enabled?: boolean
|
||||
/** SWR options */
|
||||
swrOptions?: Parameters<typeof useSWR>[2]
|
||||
/** Override SWR configuration */
|
||||
swrOptions?: SWRConfiguration
|
||||
}
|
||||
): UsePaginatedQueryResult<InferPaginatedItem<TPath>> {
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@ -503,13 +598,15 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
|
||||
setCurrentPage(1)
|
||||
}, [queryKey])
|
||||
|
||||
// Build query with pagination params
|
||||
const queryWithPagination = {
|
||||
...options?.query,
|
||||
page: currentPage,
|
||||
limit
|
||||
} as Record<string, any>
|
||||
}
|
||||
|
||||
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>,
|
||||
enabled: options?.enabled,
|
||||
swrOptions: options?.swrOptions
|
||||
@ -555,3 +652,80 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
|
||||
reset
|
||||
} 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
|
||||
* 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(
|
||||
<TPath extends ConcreteApiPaths>(
|
||||
@ -58,7 +58,8 @@ export const mockUseQuery = vi.fn(
|
||||
}
|
||||
): {
|
||||
data?: ResponseForPath<TPath, 'GET'>
|
||||
loading: boolean
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
error?: Error
|
||||
refetch: () => void
|
||||
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
||||
@ -67,7 +68,8 @@ export const mockUseQuery = vi.fn(
|
||||
if (options?.enabled === false) {
|
||||
return {
|
||||
data: undefined,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
||||
@ -78,7 +80,8 @@ export const mockUseQuery = vi.fn(
|
||||
|
||||
return {
|
||||
data: mockData as ResponseForPath<TPath, 'GET'>,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
|
||||
@ -88,7 +91,7 @@ export const mockUseQuery = vi.fn(
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
||||
@ -97,19 +100,19 @@ export const mockUseMutation = vi.fn(
|
||||
_options?: {
|
||||
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
|
||||
onError?: (error: Error) => void
|
||||
revalidate?: boolean | string[]
|
||||
optimistic?: boolean
|
||||
refresh?: ConcreteApiPaths[]
|
||||
optimisticData?: ResponseForPath<TPath, TMethod>
|
||||
swrOptions?: any
|
||||
}
|
||||
): {
|
||||
mutate: (data?: {
|
||||
trigger: (data?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}) => Promise<ResponseForPath<TPath, TMethod>>
|
||||
loading: boolean
|
||||
isLoading: boolean
|
||||
error: Error | undefined
|
||||
} => {
|
||||
const mockMutate = vi.fn(
|
||||
const mockTrigger = vi.fn(
|
||||
async (_data?: { body?: BodyForPath<TPath, TMethod>; query?: QueryParamsForPath<TPath> }) => {
|
||||
// Simulate different responses based on method
|
||||
switch (method) {
|
||||
@ -127,8 +130,8 @@ export const mockUseMutation = vi.fn(
|
||||
)
|
||||
|
||||
return {
|
||||
mutate: mockMutate,
|
||||
loading: false,
|
||||
trigger: mockTrigger,
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
@ -136,7 +139,7 @@ export const mockUseMutation = vi.fn(
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<TPath extends ConcreteApiPaths>(
|
||||
@ -151,9 +154,10 @@ export const mockUsePaginatedQuery = vi.fn(
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
loading: boolean
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
error?: Error
|
||||
hasMore: boolean
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
prevPage: () => void
|
||||
nextPage: () => void
|
||||
@ -173,9 +177,10 @@ export const mockUsePaginatedQuery = vi.fn(
|
||||
items: mockItems,
|
||||
total: mockItems.length,
|
||||
page: 1,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
hasMore: false,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
prevPage: vi.fn(),
|
||||
nextPage: vi.fn(),
|
||||
@ -186,9 +191,10 @@ export const mockUsePaginatedQuery = vi.fn(
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
loading: boolean
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
error?: Error
|
||||
hasMore: boolean
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
prevPage: () => void
|
||||
nextPage: () => void
|
||||
@ -259,7 +265,8 @@ export const MockUseDataApiUtils = {
|
||||
if (queryPath === path) {
|
||||
return {
|
||||
data,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(data)
|
||||
@ -269,7 +276,8 @@ export const MockUseDataApiUtils = {
|
||||
const defaultData = createMockDataForPath(queryPath)
|
||||
return {
|
||||
data: defaultData,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(defaultData)
|
||||
@ -285,7 +293,8 @@ export const MockUseDataApiUtils = {
|
||||
if (queryPath === path) {
|
||||
return {
|
||||
data: undefined,
|
||||
loading: true,
|
||||
isLoading: true,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(undefined)
|
||||
@ -294,7 +303,8 @@ export const MockUseDataApiUtils = {
|
||||
const defaultData = createMockDataForPath(queryPath)
|
||||
return {
|
||||
data: defaultData,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(defaultData)
|
||||
@ -310,7 +320,8 @@ export const MockUseDataApiUtils = {
|
||||
if (queryPath === path) {
|
||||
return {
|
||||
data: undefined,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(undefined)
|
||||
@ -319,7 +330,8 @@ export const MockUseDataApiUtils = {
|
||||
const defaultData = createMockDataForPath(queryPath)
|
||||
return {
|
||||
data: defaultData,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
mutate: vi.fn().mockResolvedValue(defaultData)
|
||||
@ -338,15 +350,15 @@ export const MockUseDataApiUtils = {
|
||||
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
||||
if (mutationPath === path && mutationMethod === method) {
|
||||
return {
|
||||
mutate: vi.fn().mockResolvedValue(result),
|
||||
loading: false,
|
||||
trigger: vi.fn().mockResolvedValue(result),
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
// Default behavior
|
||||
return {
|
||||
mutate: vi.fn().mockResolvedValue({ success: true }),
|
||||
loading: false,
|
||||
trigger: vi.fn().mockResolvedValue({ success: true }),
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
}
|
||||
})
|
||||
@ -363,15 +375,15 @@ export const MockUseDataApiUtils = {
|
||||
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
||||
if (mutationPath === path && mutationMethod === method) {
|
||||
return {
|
||||
mutate: vi.fn().mockRejectedValue(error),
|
||||
loading: false,
|
||||
trigger: vi.fn().mockRejectedValue(error),
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
// Default behavior
|
||||
return {
|
||||
mutate: vi.fn().mockResolvedValue({ success: true }),
|
||||
loading: false,
|
||||
trigger: vi.fn().mockResolvedValue({ success: true }),
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
}
|
||||
})
|
||||
@ -387,15 +399,15 @@ export const MockUseDataApiUtils = {
|
||||
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
|
||||
if (mutationPath === path && mutationMethod === method) {
|
||||
return {
|
||||
mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||
loading: true,
|
||||
trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||
isLoading: true,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
// Default behavior
|
||||
return {
|
||||
mutate: vi.fn().mockResolvedValue({ success: true }),
|
||||
loading: false,
|
||||
trigger: vi.fn().mockResolvedValue({ success: true }),
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
}
|
||||
})
|
||||
@ -407,7 +419,7 @@ export const MockUseDataApiUtils = {
|
||||
mockPaginatedData: <TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
items: any[],
|
||||
options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean }
|
||||
options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean }
|
||||
) => {
|
||||
mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => {
|
||||
if (queryPath === path) {
|
||||
@ -415,9 +427,10 @@ export const MockUseDataApiUtils = {
|
||||
items,
|
||||
total: options?.total ?? items.length,
|
||||
page: options?.page ?? 1,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
hasMore: options?.hasMore ?? false,
|
||||
hasNext: options?.hasNext ?? false,
|
||||
hasPrev: options?.hasPrev ?? false,
|
||||
prevPage: vi.fn(),
|
||||
nextPage: vi.fn(),
|
||||
@ -430,9 +443,10 @@ export const MockUseDataApiUtils = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: undefined,
|
||||
hasMore: false,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
prevPage: vi.fn(),
|
||||
nextPage: vi.fn(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user