From 877be65cc897d84fd10eb661ed30676063f433c9 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Fri, 9 May 2025 13:02:08 +0000 Subject: [PATCH] Fix data table filter issues: add counts, persist filters, and real-time updates --- .../src/ui/data-table-filter/README.md | 252 ++++++++++++++++++ .../examples/data-table-filter-example.tsx | 225 ++++++++++++++++ .../examples/simplified-example.tsx | 119 +++++++++ packages/components/src/ui/utils/debounce.ts | 161 +++++++++-- .../src/ui/utils/use-filter-sync.ts | 12 +- .../src/ui/utils/use-filtered-data.ts | 165 ++++++++++++ .../src/ui/utils/use-issues-query.ts | 65 ++++- 7 files changed, 964 insertions(+), 35 deletions(-) create mode 100644 packages/components/src/ui/data-table-filter/README.md create mode 100644 packages/components/src/ui/data-table-filter/examples/data-table-filter-example.tsx create mode 100644 packages/components/src/ui/data-table-filter/examples/simplified-example.tsx create mode 100644 packages/components/src/ui/utils/use-filtered-data.ts diff --git a/packages/components/src/ui/data-table-filter/README.md b/packages/components/src/ui/data-table-filter/README.md new file mode 100644 index 00000000..d1d234be --- /dev/null +++ b/packages/components/src/ui/data-table-filter/README.md @@ -0,0 +1,252 @@ +# Data Table Filter Component + +This component provides a powerful filtering system for data tables, with support for various filter types, URL synchronization, and real-time data updates. + +## Features + +- **Filter Types**: Support for text, option, multi-option, number, and date filters +- **URL Synchronization**: Filters are synchronized with URL query parameters for shareable and bookmarkable filter states +- **Real-time Data Updates**: Data is updated in real-time as filters change +- **Faceted Counts**: Display counts for each filter option based on the current data +- **Responsive Design**: Works on both desktop and mobile devices + +## Usage + +### Basic Usage with `useFilteredData` Hook (Recommended) + +The easiest way to use the data table filter is with the `useFilteredData` hook, which handles all the complexity for you: + +```tsx +import { useFilteredData } from '@lambdacurry/forms/ui/utils/use-filtered-data'; +import { DataTableFilter } from '@lambdacurry/forms/ui/data-table-filter/components/data-table-filter'; + +function MyDataTable() { + // Define your column configurations + const columnsConfig = [ + { + id: 'status', + type: 'option', + displayName: 'Status', + accessor: (item) => item.status, + icon: () => null, + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + ], + }, + // Add more columns as needed + ]; + + // Use the hook to handle everything + const { + filters, + columns, + actions, + data, + isLoading, + } = useFilteredData({ + endpoint: '/api/items', // Your API endpoint + columnsConfig, + initialData: [], // Optional initial data + }); + + return ( +
+ {/* Render the filter component */} + + + {/* Render your data table with the filtered data */} + {isLoading ? ( +
Loading...
+ ) : ( + + {/* Your table implementation */} +
+ )} +
+ ); +} +``` + +### Advanced Usage with Manual Setup + +If you need more control, you can set up the filters manually: + +```tsx +import { useFilterSync } from '@lambdacurry/forms/ui/utils/use-filter-sync'; +import { useDataQuery } from '@lambdacurry/forms/ui/utils/use-issues-query'; +import { createColumns } from '@lambdacurry/forms/ui/data-table-filter/core/filters'; +import { DataTableFilter } from '@lambdacurry/forms/ui/data-table-filter/components/data-table-filter'; + +function MyDataTable() { + // Sync filters with URL + const [filters, setFilters] = useFilterSync(); + + // Fetch data with filters + const { data, isLoading } = useDataQuery('/api/items', filters); + + // Define column configurations + const columnsConfig = [/* your column configs */]; + + // Create columns with faceted counts + const columns = useMemo(() => { + if (!data) return createColumns([], columnsConfig, 'client'); + + // Apply faceted counts from the API + const enhancedConfig = columnsConfig.map(config => { + if (config.type === 'option' && data.facetedCounts?.[config.id]) { + return { + ...config, + facetedOptions: new Map( + Object.entries(data.facetedCounts[config.id]) + .map(([key, count]) => [key, count]) + ) + }; + } + return config; + }); + + return createColumns(data.data || [], enhancedConfig, 'client'); + }, [data, columnsConfig]); + + // Create filter actions + const actions = useMemo(() => { + return { + addFilterValue: (column, values) => { + // Implementation + }, + removeFilterValue: (column, values) => { + // Implementation + }, + setFilterValue: (column, values) => { + // Implementation + }, + setFilterOperator: (columnId, operator) => { + // Implementation + }, + removeFilter: (columnId) => { + // Implementation + }, + removeAllFilters: () => { + // Implementation + } + }; + }, [setFilters]); + + return ( +
+ + + {/* Your table implementation */} +
+ ); +} +``` + +## API Reference + +### `DataTableFilter` Component + +```tsx + +``` + +#### Props + +- `columns`: Array of column definitions with filter options +- `filters`: Current filter state +- `actions`: Object containing filter action functions +- `strategy`: Filter strategy, either "client" or "server" +- `locale`: Optional locale for internationalization (default: "en") + +### `useFilteredData` Hook + +```tsx +const { + filters, + setFilters, + columns, + actions, + data, + facetedCounts, + isLoading, + isError, + error, + refetch, +} = useFilteredData({ + endpoint, + columnsConfig, + strategy, + initialData, + queryOptions, +}); +``` + +#### Parameters + +- `endpoint`: API endpoint to fetch data from +- `columnsConfig`: Array of column configurations +- `strategy`: Filter strategy, either "client" or "server" (default: "client") +- `initialData`: Optional initial data to use before API data is loaded +- `queryOptions`: Additional options for the query + +#### Returns + +- `filters`: Current filter state +- `setFilters`: Function to update filters +- `columns`: Columns with faceted counts +- `actions`: Filter action functions +- `data`: Filtered data +- `facetedCounts`: Counts for each filter option +- `isLoading`: Whether data is currently loading +- `isError`: Whether an error occurred +- `error`: Error object if an error occurred +- `refetch`: Function to manually refetch data + +## Server-Side Implementation + +For the filters to work correctly with faceted counts, your API should return data in the following format: + +```json +{ + "data": [ + // Your data items + ], + "facetedCounts": { + "status": { + "active": 10, + "inactive": 5 + }, + "category": { + "electronics": 7, + "clothing": 8 + } + } +} +``` + +The `facetedCounts` object should contain counts for each filter option, organized by column ID. + +## Examples + +See the `examples` directory for complete working examples: + +- `data-table-filter-example.tsx`: Comprehensive example with API integration +- `simplified-example.tsx`: Simplified example using the `useFilteredData` hook + diff --git a/packages/components/src/ui/data-table-filter/examples/data-table-filter-example.tsx b/packages/components/src/ui/data-table-filter/examples/data-table-filter-example.tsx new file mode 100644 index 00000000..b411da1c --- /dev/null +++ b/packages/components/src/ui/data-table-filter/examples/data-table-filter-example.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { DataTableFilter } from '../components/data-table-filter'; +import { createColumns } from '../core/filters'; +import type { Column, FiltersState } from '../core/types'; +import { useFilterSync } from '../../utils/use-filter-sync'; + +// Example data interface +interface User { + id: string; + name: string; + email: string; + role: 'admin' | 'user' | 'editor'; + status: 'active' | 'inactive' | 'pending'; + createdAt: string; +} + +// Mock API function to fetch data with filters +async function fetchUsers(filters: FiltersState): Promise<{ + data: User[]; + facetedCounts: Record>; +}> { + // In a real app, this would be an API call with the filters + // For demo purposes, we'll simulate a delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // This would normally come from your API + return { + data: [ + { id: '1', name: 'John Doe', email: 'john@example.com', role: 'admin', status: 'active', createdAt: '2023-01-01' }, + { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'user', status: 'active', createdAt: '2023-01-15' }, + { id: '3', name: 'Bob Johnson', email: 'bob@example.com', role: 'editor', status: 'inactive', createdAt: '2023-02-01' }, + // Add more mock data as needed + ], + facetedCounts: { + role: { admin: 1, user: 1, editor: 1 }, + status: { active: 2, inactive: 1, pending: 0 }, + } + }; +} + +export function DataTableFilterExample() { + // Use the filter sync hook to sync filters with URL query params + const [filters, setFilters] = useFilterSync(); + + // Fetch data with the current filters + const { data, isLoading } = useQuery({ + queryKey: ['users', filters], + queryFn: () => fetchUsers(filters), + placeholderData: (previousData) => previousData, + }); + + // Define column configurations + const columnsConfig = useMemo(() => [ + { + id: 'role', + type: 'option' as const, + displayName: 'Role', + accessor: (user: User) => user.role, + icon: () => null, + options: [ + { value: 'admin', label: 'Admin' }, + { value: 'user', label: 'User' }, + { value: 'editor', label: 'Editor' }, + ], + }, + { + id: 'status', + type: 'option' as const, + displayName: 'Status', + accessor: (user: User) => user.status, + icon: () => null, + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'pending', label: 'Pending' }, + ], + }, + ], []); + + // Create columns with faceted counts from the API + const columns = useMemo(() => { + if (!data) return [] as Column[]; + + // Apply faceted counts from the API to the columns + const enhancedConfig = columnsConfig.map(config => { + if (config.type === 'option' && data.facetedCounts[config.id]) { + return { + ...config, + facetedOptions: new Map( + Object.entries(data.facetedCounts[config.id]).map(([key, count]) => [key, count]) + ) + }; + } + return config; + }); + + return createColumns(data.data || [], enhancedConfig, 'client'); + }, [data, columnsConfig]); + + // Create filter actions + const actions = useMemo(() => { + return { + addFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values: [...new Set([...f.values, ...values])] } + : f + ); + }); + }, + removeFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) return prev; + + const newValues = filter.values.filter(v => !values.includes(v)); + if (newValues.length === 0) { + return prev.filter(f => f.columnId !== column.id); + } + + return prev.map(f => + f.columnId === column.id + ? { ...f, values: newValues } + : f + ); + }); + }, + setFilterValue: (column, values) => { + setFilters(prev => { + const exists = prev.some(f => f.columnId === column.id); + if (!exists) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values } + : f + ); + }); + }, + setFilterOperator: (columnId, operator) => { + setFilters(prev => + prev.map(f => + f.columnId === columnId + ? { ...f, operator } + : f + ) + ); + }, + removeFilter: (columnId) => { + setFilters(prev => prev.filter(f => f.columnId !== columnId)); + }, + removeAllFilters: () => { + setFilters([]); + } + }; + }, [setFilters]); + + return ( +
+

Users

+ + {/* Data Table Filter Component */} + + + {/* Display the filtered data */} + {isLoading ? ( +
Loading...
+ ) : ( +
+ + + + + + + + + + + {data?.data.map(user => ( + + + + + + + ))} + +
NameEmailRoleStatus
{user.name}{user.email}{user.role}{user.status}
+
+ )} + + {/* Display current filter state for debugging */} +
+

Current Filter State:

+
{JSON.stringify(filters, null, 2)}
+
+
+ ); +} + diff --git a/packages/components/src/ui/data-table-filter/examples/simplified-example.tsx b/packages/components/src/ui/data-table-filter/examples/simplified-example.tsx new file mode 100644 index 00000000..b1f8f047 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/examples/simplified-example.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useMemo } from 'react'; +import { DataTableFilter } from '../components/data-table-filter'; +import { useFilteredData } from '../../utils/use-filtered-data'; + +// Example data interface +interface User { + id: string; + name: string; + email: string; + role: 'admin' | 'user' | 'editor'; + status: 'active' | 'inactive' | 'pending'; + createdAt: string; +} + +// Mock initial data +const initialUsers: User[] = [ + { id: '1', name: 'John Doe', email: 'john@example.com', role: 'admin', status: 'active', createdAt: '2023-01-01' }, + { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'user', status: 'active', createdAt: '2023-01-15' }, + { id: '3', name: 'Bob Johnson', email: 'bob@example.com', role: 'editor', status: 'inactive', createdAt: '2023-02-01' }, +]; + +export function SimplifiedExample() { + // Define column configurations + const columnsConfig = useMemo(() => [ + { + id: 'role', + type: 'option' as const, + displayName: 'Role', + accessor: (user: User) => user.role, + icon: () => null, + options: [ + { value: 'admin', label: 'Admin' }, + { value: 'user', label: 'User' }, + { value: 'editor', label: 'Editor' }, + ], + }, + { + id: 'status', + type: 'option' as const, + displayName: 'Status', + accessor: (user: User) => user.status, + icon: () => null, + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'pending', label: 'Pending' }, + ], + }, + ], []); + + // Use our custom hook to handle everything + const { + filters, + columns, + actions, + data, + isLoading, + } = useFilteredData({ + endpoint: '/api/users', + columnsConfig, + initialData: initialUsers, + queryOptions: { + // In a real app, you'd set this to true + // For demo purposes, we'll disable actual API calls + enabled: false, + } + }); + + return ( +
+

Users (Simplified Example)

+ + {/* Data Table Filter Component */} + + + {/* Display the filtered data */} + {isLoading ? ( +
Loading...
+ ) : ( +
+ + + + + + + + + + + {data.map(user => ( + + + + + + + ))} + +
NameEmailRoleStatus
{user.name}{user.email}{user.role}{user.status}
+
+ )} + + {/* Display current filter state for debugging */} +
+

Current Filter State:

+
{JSON.stringify(filters, null, 2)}
+
+
+ ); +} + diff --git a/packages/components/src/ui/utils/debounce.ts b/packages/components/src/ui/utils/debounce.ts index 815e3dec..6e222b0c 100644 --- a/packages/components/src/ui/utils/debounce.ts +++ b/packages/components/src/ui/utils/debounce.ts @@ -1,27 +1,148 @@ /** - * Creates a debounced function that delays invoking the provided function - * until after the specified wait time has elapsed since the last time it was invoked. - * + * Creates a debounced function that delays invoking `func` until after `wait` milliseconds + * have elapsed since the last time the debounced function was invoked. + * * @param func The function to debounce * @param wait The number of milliseconds to delay - * @returns A debounced version of the provided function + * @param options The options object + * @returns The debounced function */ export function debounce any>( func: T, - wait: number -): (...args: Parameters) => void { - let timeout: ReturnType | null = null; - - return function(...args: Parameters): void { - const later = () => { - timeout = null; - func(...args); - }; - - if (timeout !== null) { - clearTimeout(timeout); + wait = 300, + options: { + leading?: boolean; + trailing?: boolean; + maxWait?: number; + } = {} +): { + (...args: Parameters): ReturnType | undefined; + cancel: () => void; + flush: () => ReturnType | undefined; +} { + let lastArgs: Parameters | undefined; + let lastThis: any; + let maxWait: number | undefined = options.maxWait; + let result: ReturnType | undefined; + let timerId: ReturnType | undefined; + let lastCallTime: number | undefined; + let lastInvokeTime = 0; + let leading = !!options.leading; + let trailing = 'trailing' in options ? !!options.trailing : true; + + function invokeFunc(time: number) { + const args = lastArgs; + const thisArg = lastThis; + + lastArgs = lastThis = undefined; + lastInvokeTime = time; + result = func.apply(thisArg, args as Parameters); + return result; + } + + function startTimer(pendingFunc: () => void, wait: number) { + return setTimeout(pendingFunc, wait); + } + + function cancelTimer(id: ReturnType) { + clearTimeout(id); + } + + function leadingEdge(time: number) { + // Reset any `maxWait` timer. + lastInvokeTime = time; + // Start the timer for the trailing edge. + timerId = startTimer(timerExpired, wait); + // Invoke the leading edge. + return leading ? invokeFunc(time) : result; + } + + function remainingWait(time: number) { + const timeSinceLastCall = time - (lastCallTime as number); + const timeSinceLastInvoke = time - lastInvokeTime; + const timeWaiting = wait - timeSinceLastCall; + + return maxWait !== undefined + ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) + : timeWaiting; + } + + function shouldInvoke(time: number) { + const timeSinceLastCall = time - (lastCallTime as number); + const timeSinceLastInvoke = time - lastInvokeTime; + + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge, or we've hit the `maxWait` limit. + return ( + lastCallTime === undefined || + timeSinceLastCall >= wait || + timeSinceLastCall < 0 || + (maxWait !== undefined && timeSinceLastInvoke >= maxWait) + ); + } + + function timerExpired() { + const time = Date.now(); + if (shouldInvoke(time)) { + return trailingEdge(time); } - - timeout = setTimeout(later, wait); - }; -} \ No newline at end of file + // Restart the timer. + timerId = startTimer(timerExpired, remainingWait(time)); + return undefined; + } + + function trailingEdge(time: number) { + timerId = undefined; + + // Only invoke if we have `lastArgs` which means `func` has been + // debounced at least once. + if (trailing && lastArgs) { + return invokeFunc(time); + } + lastArgs = lastThis = undefined; + return result; + } + + function cancel() { + if (timerId !== undefined) { + cancelTimer(timerId); + } + lastInvokeTime = 0; + lastArgs = lastCallTime = lastThis = timerId = undefined; + } + + function flush() { + return timerId === undefined ? result : trailingEdge(Date.now()); + } + + function debounced(this: any, ...args: Parameters) { + const time = Date.now(); + const isInvoking = shouldInvoke(time); + + lastArgs = args; + lastThis = this; + lastCallTime = time; + + if (isInvoking) { + if (timerId === undefined) { + return leadingEdge(lastCallTime); + } + if (maxWait !== undefined) { + // Handle invocations in a tight loop. + timerId = startTimer(timerExpired, wait); + return invokeFunc(lastCallTime); + } + } + if (timerId === undefined) { + timerId = startTimer(timerExpired, wait); + } + return result; + } + + debounced.cancel = cancel; + debounced.flush = flush; + + return debounced; +} + diff --git a/packages/components/src/ui/utils/use-filter-sync.ts b/packages/components/src/ui/utils/use-filter-sync.ts index 2d296649..8dd0a7c6 100644 --- a/packages/components/src/ui/utils/use-filter-sync.ts +++ b/packages/components/src/ui/utils/use-filter-sync.ts @@ -1,4 +1,5 @@ import { parseAsJson, useQueryState } from 'nuqs'; +import { useEffect } from 'react'; import { type FiltersState, filtersArraySchema } from './filters'; /** @@ -11,9 +12,16 @@ export function useFilterSync() { const [filters, setFilters] = useQueryState( 'filters', // The query parameter key parseAsJson(filtersArraySchema.parse) // Now filtersArraySchema should be defined - .withDefault([]), - // TODO: Add debouncing if needed, e.g., .withOptions({ history: 'push', shallow: false, debounce: 300 }) + .withDefault([]) + .withOptions({ history: 'push', shallow: false, debounce: 300 }) // Add debouncing and history options ); + // This effect ensures that when the component mounts, it immediately + // applies any filters from the URL to the data fetching logic + useEffect(() => { + // The filters are already loaded from the URL via useQueryState + // This is just a placeholder for any additional initialization if needed + }, []); + return [filters, setFilters] as const; } diff --git a/packages/components/src/ui/utils/use-filtered-data.ts b/packages/components/src/ui/utils/use-filtered-data.ts new file mode 100644 index 00000000..cde51e06 --- /dev/null +++ b/packages/components/src/ui/utils/use-filtered-data.ts @@ -0,0 +1,165 @@ +import { useMemo } from 'react'; +import type { Column, ColumnConfig, DataTableFilterActions, FilterStrategy, FiltersState } from '../data-table-filter/core/types'; +import { createColumns } from '../data-table-filter/core/filters'; +import { useFilterSync } from './use-filter-sync'; +import { useDataQuery } from './use-issues-query'; + +interface UseFilteredDataOptions { + endpoint: string; + columnsConfig: ReadonlyArray>; + strategy?: FilterStrategy; + initialData?: TData[]; + queryOptions?: { + enabled?: boolean; + refetchInterval?: number | false; + onSuccess?: (data: { data: TData[]; facetedCounts: Record> }) => void; + onError?: (error: Error) => void; + }; +} + +/** + * A hook that combines filter state management with data fetching. + * It handles: + * 1. Syncing filters with URL query parameters + * 2. Fetching data based on current filters + * 3. Creating columns with faceted counts from the API + * 4. Providing filter actions + * + * @returns Everything needed to implement a filtered data table + */ +export function useFilteredData({ + endpoint, + columnsConfig, + strategy = 'client', + initialData = [], + queryOptions, +}: UseFilteredDataOptions) { + // Sync filters with URL query parameters + const [filters, setFilters] = useFilterSync(); + + // Fetch data with current filters + const { data, isLoading, isError, error, refetch } = useDataQuery( + endpoint, + filters, + queryOptions + ); + + // Create columns with faceted counts from the API + const columns = useMemo(() => { + if (!data) return createColumns(initialData, columnsConfig, strategy); + + // Apply faceted counts from the API to the columns + const enhancedConfig = columnsConfig.map(config => { + if ((config.type === 'option' || config.type === 'multiOption') && data.facetedCounts?.[config.id]) { + return { + ...config, + facetedOptions: new Map( + Object.entries(data.facetedCounts[config.id]).map(([key, count]) => [key, count]) + ) + }; + } + if (config.type === 'number' && data.facetedCounts?.[config.id]) { + // For number columns, we might have min/max values + const values = Object.values(data.facetedCounts[config.id]); + if (values.length === 2) { + return { + ...config, + min: values[0], + max: values[1], + }; + } + } + return config; + }); + + return createColumns(data.data || initialData, enhancedConfig, strategy); + }, [data, columnsConfig, initialData, strategy]); + + // Create filter actions + const actions: DataTableFilterActions = useMemo(() => { + return { + addFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values: [...new Set([...f.values, ...values])] } + : f + ); + }); + }, + removeFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) return prev; + + const newValues = filter.values.filter(v => !values.includes(v)); + if (newValues.length === 0) { + return prev.filter(f => f.columnId !== column.id); + } + + return prev.map(f => + f.columnId === column.id + ? { ...f, values: newValues } + : f + ); + }); + }, + setFilterValue: (column, values) => { + setFilters(prev => { + const exists = prev.some(f => f.columnId === column.id); + if (!exists) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values } + : f + ); + }); + }, + setFilterOperator: (columnId, operator) => { + setFilters(prev => + prev.map(f => + f.columnId === columnId + ? { ...f, operator } + : f + ) + ); + }, + removeFilter: (columnId) => { + setFilters(prev => prev.filter(f => f.columnId !== columnId)); + }, + removeAllFilters: () => { + setFilters([]); + } + }; + }, [setFilters]); + + return { + filters, + setFilters, + columns, + actions, + data: data?.data || initialData, + facetedCounts: data?.facetedCounts, + isLoading, + isError, + error, + refetch, + }; +} + diff --git a/packages/components/src/ui/utils/use-issues-query.ts b/packages/components/src/ui/utils/use-issues-query.ts index 1c3e1fc0..42401488 100644 --- a/packages/components/src/ui/utils/use-issues-query.ts +++ b/packages/components/src/ui/utils/use-issues-query.ts @@ -2,37 +2,76 @@ import { useQuery } from '@tanstack/react-query'; import type { FiltersState } from './filters'; // Define the expected shape of the API response -interface IssuesApiResponse { - data: { id: string; title: string; status: string; assignee: string }[]; // TODO: Define a proper Issue type +interface IssuesApiResponse { + data: T[]; facetedCounts: Record>; } -// Placeholder function to fetch data - replace with actual API call -async function fetchIssues(filters: FiltersState): Promise { +// Generic function to fetch data with filters +async function fetchData( + endpoint: string, + filters: FiltersState +): Promise> { // Encode filters for URL const filterParam = filters.length > 0 ? `filters=${encodeURIComponent(JSON.stringify(filters))}` : ''; - const response = await fetch(`/api/issues?${filterParam}`); // Adjust API path if needed + const response = await fetch(`${endpoint}?${filterParam}`); if (!response.ok) { const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response' })); throw new Error(errorData.error || `HTTP error! status: ${response.status}`); } - const data: IssuesApiResponse = await response.json(); + const data: IssuesApiResponse = await response.json(); return data; } /** - * Custom hook to fetch issues data using TanStack Query, based on filter state. + * Custom hook to fetch data using TanStack Query, based on filter state. * - * @param filters The current filter state. - * @returns The TanStack Query result object for the issues query. + * @param endpoint The API endpoint to fetch data from + * @param filters The current filter state + * @param options Additional query options + * @returns The TanStack Query result object for the data query */ -export function useIssuesQuery(filters: FiltersState) { +export function useDataQuery( + endpoint: string, + filters: FiltersState, + options?: { + enabled?: boolean; + refetchInterval?: number | false; + onSuccess?: (data: IssuesApiResponse) => void; + onError?: (error: Error) => void; + } +) { return useQuery({ - queryKey: ['issues', filters], // Use filters in the query key for caching - queryFn: () => fetchIssues(filters), + queryKey: [endpoint, filters], // Use endpoint and filters in the query key for caching + queryFn: () => fetchData(endpoint, filters), placeholderData: (previousData) => previousData, // Keep previous data while fetching - // Consider adding options like staleTime, gcTime, refetchOnWindowFocus, etc. + enabled: options?.enabled !== false, // Enabled by default unless explicitly disabled + refetchInterval: options?.refetchInterval, // Optional refetch interval + onSuccess: options?.onSuccess, + onError: options?.onError, + // Reduced stale time to ensure more frequent updates + staleTime: 30 * 1000, // 30 seconds }); } + +/** + * Custom hook to fetch issues data using TanStack Query, based on filter state. + * This is a specialized version of useDataQuery for issues. + * + * @param filters The current filter state + * @param options Additional query options + * @returns The TanStack Query result object for the issues query + */ +export function useIssuesQuery( + filters: FiltersState, + options?: { + enabled?: boolean; + refetchInterval?: number | false; + onSuccess?: (data: IssuesApiResponse) => void; + onError?: (error: Error) => void; + } +) { + return useDataQuery('/api/issues', filters, options); +}