diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index 207703e46a..8f17294fd5 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -134,15 +134,24 @@ import type { SerializedDataApiError } from './apiErrors' // Re-export for backwards compatibility in DataResponse export type { SerializedDataApiError } from './apiErrors' +// ============================================================================ +// Pagination Types +// ============================================================================ + +/** + * Pagination mode + */ +export type PaginationMode = 'offset' | 'cursor' + /** * Pagination parameters for list operations */ export interface PaginationParams { - /** Page number (1-based) */ - page?: number /** Items per page */ limit?: number - /** Cursor for cursor-based pagination */ + /** Page number (offset mode, 1-based) */ + page?: number + /** Cursor (cursor mode) */ cursor?: string /** Sort field and direction */ sort?: { @@ -152,14 +161,20 @@ export interface PaginationParams { } /** - * Paginated response wrapper + * Base paginated response (shared fields) */ -export interface PaginatedResponse { +export interface BasePaginatedResponse { /** Items for current page */ items: T[] /** Total number of items */ total: number - /** Current page number */ +} + +/** + * Offset-based paginated response + */ +export interface OffsetPaginatedResponse extends BasePaginatedResponse { + /** Current page number (1-based) */ page: number /** Total number of pages */ pageCount: number @@ -167,12 +182,37 @@ export interface PaginatedResponse { hasNext: boolean /** Whether there are previous pages */ hasPrev: boolean - /** Next cursor for cursor-based pagination */ +} + +/** + * Cursor-based paginated response + */ +export interface CursorPaginatedResponse extends BasePaginatedResponse { + /** Next cursor (undefined means no more data) */ nextCursor?: string - /** Previous cursor for cursor-based pagination */ + /** Previous cursor */ prevCursor?: string } +/** + * Unified paginated response (union type) + */ +export type PaginatedResponse = OffsetPaginatedResponse | CursorPaginatedResponse + +/** + * Type guard: check if response is offset-based + */ +export function isOffsetPaginatedResponse(response: PaginatedResponse): response is OffsetPaginatedResponse { + return 'page' in response && 'pageCount' in response +} + +/** + * Type guard: check if response is cursor-based + */ +export function isCursorPaginatedResponse(response: PaginatedResponse): response is CursorPaginatedResponse { + return 'nextCursor' in response || !('page' in response) +} + /** * Subscription options for real-time data updates */ diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index 599a7aee42..96d3ec15ff 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -1,9 +1,14 @@ import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' -import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' -import type { PaginatedResponse } from '@shared/data/api/apiTypes' -import { useState } from 'react' +import type { ConcreteApiPaths, PaginationMode } from '@shared/data/api/apiTypes' +import { + isCursorPaginatedResponse, + type OffsetPaginatedResponse, + type PaginatedResponse +} from '@shared/data/api/apiTypes' +import { useCallback, useMemo, useState } from 'react' import type { KeyedMutator } from 'swr' import useSWR, { useSWRConfig } from 'swr' +import useSWRInfinite from 'swr/infinite' import useSWRMutation from 'swr/mutation' import { dataApiService } from '../DataApiService' @@ -146,8 +151,10 @@ export function useQuery( ): { /** The fetched data */ data?: ResponseForPath - /** Loading state */ - loading: boolean + /** True during initial load (no data yet) */ + isLoading: boolean + /** True during any request (including background refresh) */ + isRefreshing: boolean /** Error if request failed */ error?: Error /** Function to manually refetch data */ @@ -173,7 +180,8 @@ export function useQuery( return { data, - loading: isLoading || isValidating, + isLoading, + isRefreshing: isValidating, error: error as Error | undefined, refetch, mutate @@ -275,7 +283,7 @@ export function useMutation }) => Promise> /** True while the mutation is in progress */ - loading: boolean + isLoading: boolean /** Error object if the mutation failed */ error: Error | undefined } { @@ -367,7 +375,7 @@ export function useMutation( return apiFetcher(path, { query: options?.query as Record }) } +// ============================================================================ +// Infinite Query Hook +// ============================================================================ + +/** + * Options for useInfiniteQuery hook + * SWR-related options are consolidated in swrOptions + */ +export interface UseInfiniteQueryOptions { + /** Additional query parameters (excluding pagination params) */ + query?: Omit, 'page' | 'limit' | 'cursor'> + /** Items per page (default: 10) */ + limit?: number + /** Pagination mode (default: 'cursor') */ + mode?: PaginationMode + /** Whether to enable the query (default: true) */ + enabled?: boolean + /** SWR options (including initialSize, revalidateAll, etc.) */ + swrOptions?: Parameters[2] +} + +/** + * React hook for infinite scrolling data fetching + * Uses useSWRInfinite internally for efficient page management + * + * @template TPath - The concrete API path type + * @param path - API endpoint path that returns paginated data + * @param options - Configuration options for infinite query + * @returns Object containing accumulated items, loading states, and controls + * + * @example + * ```typescript + * // Basic usage with cursor mode (default) + * const { items, hasNext, loadMore, isLoadingMore } = useInfiniteQuery('/test/items', { + * limit: 20, + * query: { search: 'hello' } + * }) + * + * // Offset mode + * const { items, hasNext, loadMore } = useInfiniteQuery('/test/items', { + * mode: 'offset', + * limit: 20 + * }) + * + * // Custom SWR options + * const { items, hasNext, loadMore } = useInfiniteQuery('/test/items', { + * limit: 20, + * swrOptions: { + * initialSize: 2, // Load 2 pages initially + * revalidateFirstPage: false // Don't auto-refresh first page + * } + * }) + * + * // With InfiniteScroll component + * } + * > + * {items.map(item => )} + * + * ``` + */ +export function useInfiniteQuery( + path: TPath, + options?: UseInfiniteQueryOptions +): ResponseForPath extends PaginatedResponse + ? { + /** Accumulated items from all loaded pages */ + items: T[] + /** Raw page data array */ + pages: PaginatedResponse[] + /** Total number of items */ + total: number + /** Number of pages loaded */ + size: number + /** True during initial load (no data yet) */ + isLoading: boolean + /** True during any request (including background refresh) */ + isRefreshing: boolean + /** Error if request failed */ + error?: Error + /** Whether there are more pages to load */ + hasNext: boolean + /** Load the next page */ + loadNext: () => void + /** Set number of pages to load */ + setSize: (size: number | ((size: number) => number)) => void + /** Refresh all loaded pages */ + refresh: () => void + /** Reset to first page only */ + reset: () => void + /** SWR mutate function */ + mutate: KeyedMutator[]> + } + : never { + const limit = options?.limit ?? 10 + const mode = options?.mode ?? 'cursor' // Default: cursor mode + const enabled = options?.enabled !== false + + // getKey: Generate SWR key for each page + const getKey = useCallback( + (pageIndex: number, previousPageData: PaginatedResponse | null) => { + if (!enabled) return null + + // Check if we've reached the end + if (previousPageData) { + if (mode === 'cursor') { + if (!isCursorPaginatedResponse(previousPageData) || !previousPageData.nextCursor) { + return null + } + } else { + // offset mode + if (isCursorPaginatedResponse(previousPageData)) { + // Response doesn't match expected mode + return null + } + if (!previousPageData.hasNext) { + return null + } + } + } + + // Build pagination query + const paginationQuery: Record = { + ...(options?.query as Record), + limit + } + + if (mode === 'cursor' && previousPageData && isCursorPaginatedResponse(previousPageData)) { + paginationQuery.cursor = previousPageData.nextCursor + } else if (mode === 'offset') { + paginationQuery.page = pageIndex + 1 + } + + return [path, paginationQuery] as [TPath, Record] + }, + [path, options?.query, limit, mode, enabled] + ) + + // Fetcher for infinite query - wraps getFetcher with proper types + const infiniteFetcher = (key: [ConcreteApiPaths, Record?]) => { + return getFetcher(key) as Promise> + } + + const swrResult = useSWRInfinite(getKey, infiniteFetcher, { + // Default configuration + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 5000, + errorRetryCount: 3, + errorRetryInterval: 1000, + initialSize: 1, + revalidateAll: false, + revalidateFirstPage: true, + parallel: false, + // User overrides + ...options?.swrOptions + }) + + const { error, isLoading, isValidating, mutate, size, setSize } = swrResult + const data = swrResult.data as PaginatedResponse[] | undefined + + // Compute derived state + 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 isCursorPaginatedResponse(last) && !!last.nextCursor + } + return !isCursorPaginatedResponse(last) && (last as OffsetPaginatedResponse).hasNext + }, [data, mode]) + + // Action methods + const loadNext = useCallback(() => { + if (!hasNext || isValidating) return + setSize((s) => s + 1) + }, [hasNext, isValidating, setSize]) + + const refresh = useCallback(() => mutate(), [mutate]) + const reset = useCallback(() => setSize(1), [setSize]) + + return { + items, + pages: data ?? [], + total: data?.[0]?.total ?? 0, + size, + isLoading, + isRefreshing: isValidating, + error: error as Error | undefined, + hasNext, + loadNext, + setSize, + refresh, + reset, + mutate + } as unknown as ResponseForPath extends PaginatedResponse + ? { + items: T[] + pages: PaginatedResponse[] + 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[]> + } + : never +} + +// ============================================================================ +// Paginated Query Hook +// ============================================================================ + +/** + * Options for usePaginatedQuery hook + */ +export interface UsePaginatedQueryOptions { + /** Additional query parameters (excluding pagination params) */ + query?: Omit, 'page' | 'limit'> + /** Items per page (default: 10) */ + limit?: number + /** Whether to enable the query (default: true) */ + enabled?: boolean + /** SWR options */ + swrOptions?: Parameters[2] +} + /** * React hook for paginated data fetching with type safety * Automatically manages pagination state and provides navigation controls @@ -482,7 +726,7 @@ export function prefetch( * loading, * total, * page, - * hasMore, + * hasNext, * nextPage, * prevPage * } = usePaginatedQuery('/test/items', { @@ -508,7 +752,7 @@ export function prefetch( * Previous * * Page {page} of {Math.ceil(total / 20)} - * * @@ -537,12 +781,14 @@ export function usePaginatedQuery( total: number /** Current page number (1-based) */ page: number - /** Loading state */ - loading: boolean + /** True during initial load (no data yet) */ + isLoading: boolean + /** True during any request (including background refresh) */ + isRefreshing: boolean /** Error if request failed */ error?: Error /** Whether there are more pages available */ - hasMore: boolean + hasNext: boolean /** Whether there are previous pages available */ hasPrev: boolean /** Navigate to previous page */ @@ -565,7 +811,7 @@ export function usePaginatedQuery( limit } as Record - const { data, loading, error, refetch } = useQuery(path, { + const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, { query: queryWithPagination as QueryParamsForPath, swrOptions: options?.swrOptions }) @@ -576,11 +822,11 @@ export function usePaginatedQuery( const total = paginatedData?.total || 0 const totalPages = Math.ceil(total / limit) - const hasMore = currentPage < totalPages + const hasNext = currentPage < totalPages const hasPrev = currentPage > 1 const nextPage = () => { - if (hasMore) { + if (hasNext) { setCurrentPage((prev) => prev + 1) } } @@ -599,9 +845,10 @@ export function usePaginatedQuery( items, total, page: currentPage, - loading, + isLoading, + isRefreshing, error, - hasMore, + hasNext, hasPrev, prevPage, nextPage, @@ -612,9 +859,10 @@ export function usePaginatedQuery( items: T[] total: number page: number - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error - hasMore: boolean + hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void