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...
+ ) : (
+
+
+
+
+ | Name |
+ Email |
+ Role |
+ Status |
+
+
+
+ {data?.data.map(user => (
+
+ | {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...
+ ) : (
+
+
+
+
+ | Name |
+ Email |
+ Role |
+ Status |
+
+
+
+ {data.map(user => (
+
+ | {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);
+}