diff --git a/.vscode/settings.json b/.vscode/settings.json index f8b9823a..ddbcdfeb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "autodocs", + "Bazza", "biomejs", "cleanbuild", "Filenaming", diff --git a/apps/docs/src/remix-hook-form/data-table-bazza-filters.stories.tsx b/apps/docs/src/remix-hook-form/data-table-bazza-filters.stories.tsx new file mode 100644 index 00000000..bea9a478 --- /dev/null +++ b/apps/docs/src/remix-hook-form/data-table-bazza-filters.stories.tsx @@ -0,0 +1,671 @@ +// --- NEW IMPORTS for Router Form data handling --- +import { dataTableRouterParsers } from '@lambdacurry/forms/remix-hook-form/data-table-router-parsers'; // Use parsers +// --- Corrected Hook Import Paths --- +import { DataTableFilter } from '@lambdacurry/forms/ui/data-table-filter'; // Use the barrel file export +// --- NEW IMPORTS for Bazza UI Filters --- +import { createColumnConfigHelper } from '@lambdacurry/forms/ui/data-table-filter/core/filters'; // Assuming path +import type { DataTableColumnConfig } from '@lambdacurry/forms/ui/data-table-filter/core/types'; +import { DataTable } from '@lambdacurry/forms/ui/data-table/data-table'; +import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; +// Import the filters schema and types from the new location +import type { FiltersState } from '@lambdacurry/forms/ui/utils/filters'; // Assuming path alias +import { filtersArraySchema } from '@lambdacurry/forms/ui/utils/filters'; // Assuming path alias +// --- Re-add useDataTableFilters import --- +import { useDataTableFilters } from '@lambdacurry/forms/ui/utils/use-data-table-filters'; +import { useFilterSync } from '@lambdacurry/forms/ui/utils/use-filter-sync'; // Ensure this is the correct path for filter sync +// Add icon imports +import { CalendarIcon, CheckCircledIcon, PersonIcon, StarIcon, TextIcon } from '@radix-ui/react-icons'; +import type { Meta, StoryContext, StoryObj } from '@storybook/react'; // FIX: Add Meta, StoryObj, StoryContext +import { expect, userEvent, within } from '@storybook/test'; // Add storybook test imports +import type { ColumnDef, PaginationState, SortingState } from '@tanstack/react-table'; // Added PaginationState, SortingState +import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; +import type { OnChangeFn } from '@tanstack/react-table'; +import { useMemo } from 'react'; // Added useState, useEffect +import { type LoaderFunctionArgs, useLoaderData, useLocation, useNavigate } from 'react-router'; // Added LoaderFunctionArgs, useLoaderData, useNavigate, useLocation +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; // FIX: Add withReactRouterStubDecorator + +// --- Use MockIssue Schema and Data --- +interface MockIssue { + id: string; + title: string; + status: 'todo' | 'in progress' | 'done' | 'backlog'; + assignee: string; + priority: 'low' | 'medium' | 'high'; + createdDate: Date; +} + +// --- NEW Data Response Interface --- +interface DataResponse { + data: MockIssue[]; + meta: { + total: number; + page: number; + pageSize: number; + pageCount: number; + }; + facetedCounts: Record>; // Include faceted counts here +} +// --- END Data Response Interface --- + +// --- Mock Database (copied from deleted API route) --- +const mockDatabase: MockIssue[] = [ + { + id: 'TASK-1', + title: 'Fix login bug', + status: 'todo', + assignee: 'Alice', + priority: 'high', + createdDate: new Date('2024-01-15'), + }, + { + id: 'TASK-2', + title: 'Add dark mode', + status: 'in progress', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-01-20'), + }, + { + id: 'TASK-3', + title: 'Improve dashboard performance', + status: 'in progress', + assignee: 'Alice', + priority: 'high', + createdDate: new Date('2024-02-01'), + }, + { + id: 'TASK-4', + title: 'Update documentation', + status: 'done', + assignee: 'Charlie', + priority: 'low', + createdDate: new Date('2024-02-10'), + }, + { + id: 'TASK-5', + title: 'Refactor auth module', + status: 'backlog', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-02-15'), + }, + { + id: 'TASK-6', + title: 'Implement user profile page', + status: 'todo', + assignee: 'Charlie', + priority: 'medium', + createdDate: new Date('2024-03-01'), + }, + { + id: 'TASK-7', + title: 'Design new landing page', + status: 'todo', + assignee: 'Alice', + priority: 'high', + createdDate: new Date('2024-03-05'), + }, + { + id: 'TASK-8', + title: 'Write API integration tests', + status: 'in progress', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-03-10'), + }, + { + id: 'TASK-9', + title: 'Deploy to staging environment', + status: 'todo', + assignee: 'Charlie', + priority: 'high', + createdDate: new Date('2024-03-15'), + }, + { + id: 'TASK-10', + title: 'User feedback session', + status: 'done', + assignee: 'Alice', + priority: 'low', + createdDate: new Date('2024-03-20'), + }, + { + id: 'TASK-11', + title: 'Fix critical bug in payment module', + status: 'in progress', + assignee: 'Bob', + priority: 'high', + createdDate: new Date('2024-03-22'), + }, + { + id: 'TASK-12', + title: 'Update third-party libraries', + status: 'backlog', + assignee: 'Charlie', + priority: 'low', + createdDate: new Date('2024-03-25'), + }, + { + id: 'TASK-13', + title: 'Onboard new developer', + status: 'done', + assignee: 'Alice', + priority: 'medium', + createdDate: new Date('2024-04-01'), + }, + { + id: 'TASK-14', + title: 'Research new caching strategy', + status: 'todo', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-04-05'), + }, + { + id: 'TASK-15', + title: 'Accessibility audit', + status: 'in progress', + assignee: 'Charlie', + priority: 'high', + createdDate: new Date('2024-04-10'), + }, + // --- END ADDED DATA --- +]; + +// Function to calculate faceted counts based on the *original* data +// --- FIX: Ensure all defined options have counts (even 0) --- +function calculateFacetedCounts( + data: MockIssue[], + countColumns: Array, // Expect specific keys + allOptions: Record, // Pass defined options +): Record> { + const counts: Record> = {}; + + countColumns.forEach((columnId) => { + counts[columnId] = {}; + // Initialize counts for all defined options for this column to 0 + const definedOptions = allOptions[columnId]; + if (definedOptions) { + definedOptions.forEach((option) => { + counts[columnId][option.value] = 0; + }); + } + + // Count occurrences from the actual data + data.forEach((item) => { + const value = item[columnId] as string; + // Ensure value exists before incrementing (might be null/undefined) + if (value !== null && value !== undefined) { + counts[columnId][value] = (counts[columnId][value] || 0) + 1; + } + }); + }); + return counts; +} +// --- End Helper Functions --- + +// --- Define Columns with Bazza UI DSL (Task 4) --- +// Explicitly type the helper +const dtf = createColumnConfigHelper(); + +// 1. Bazza UI Filter Configurations +const columnConfigs = [ + // Use accessor functions instead of strings + dtf + .text() + .id('title') + .accessor((row) => row.title) + .displayName('Title') + .icon(TextIcon) + .build(), + dtf + .option() + .id('status') + .accessor((row) => row.status) // Use accessor function + .displayName('Status') + .icon(CheckCircledIcon) + .options([ + { value: 'todo', label: 'Todo' }, + { value: 'in progress', label: 'In Progress' }, + { value: 'done', label: 'Done' }, + { value: 'backlog', label: 'Backlog' }, + ]) + .build(), + dtf + .option() + .id('assignee') + .accessor((row) => row.assignee) // Use accessor function + .displayName('Assignee') + .icon(PersonIcon) + .options([ + { value: 'Alice', label: 'Alice' }, + { value: 'Bob', label: 'Bob' }, + { value: 'Charlie', label: 'Charlie' }, + ]) + .build(), + dtf + .option() + .id('priority') + .accessor((row) => row.priority) // Use accessor function + .displayName('Priority') + .icon(StarIcon) + .options([ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + ]) + .build(), + dtf + .date() + .id('createdDate') + .accessor((row) => row.createdDate) + .displayName('Created Date') + .icon(CalendarIcon) + .build(), // Use accessor function +]; + +// --- FIX: Extract defined options for faceted counting --- +const allDefinedOptions: Record = { + id: undefined, + title: undefined, + status: columnConfigs.find((c) => c.id === 'status')?.options, + assignee: columnConfigs.find((c) => c.id === 'assignee')?.options, + priority: columnConfigs.find((c) => c.id === 'priority')?.options, + createdDate: undefined, +}; + +// 2. TanStack Table Column Definitions (for rendering) +const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('id')}
, + enableSorting: false, + }, + { + accessorKey: 'title', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('title')}
, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('status')}
, + }, + { + accessorKey: 'assignee', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('assignee')}
, + }, + { + accessorKey: 'priority', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('priority')}
, + }, + { + accessorKey: 'createdDate', + header: ({ column }) => , + cell: ({ row }) =>
{new Date(row.getValue('createdDate')).toLocaleDateString()}
, + enableSorting: true, // Enable sorting for date + }, +]; +// --- END Column Definitions --- + +// --- NEW Wrapper Component using Loader Data --- +function DataTableWithBazzaFilters() { + // Get the loader data (filtered/paginated/sorted data from server) + const loaderData = useLoaderData(); + const navigate = useNavigate(); + const location = useLocation(); + + // Initialize data from loader response + const data = loaderData?.data ?? []; + const pageCount = loaderData?.meta.pageCount ?? 0; + const facetedCounts = loaderData?.facetedCounts ?? {}; + + // Convert facetedCounts to the correct type for useDataTableFilters (Map-based) + const facetedOptionCounts = useMemo(() => { + const result: Partial>> = {}; + Object.entries(facetedCounts).forEach(([col, valueObj]) => { + result[col] = new Map(Object.entries(valueObj)); + }); + return result; + }, [facetedCounts]); + + // --- Bazza UI Filter Setup --- + // 1. Initialize filters state with useFilterSync (syncs with URL) + const [filters, setFilters] = useFilterSync(); + + // --- Read pagination and sorting directly from URL --- + const searchParams = new URLSearchParams(location.search); + const pageIndex = Number.parseInt(searchParams.get('page') ?? '0', 10); + const pageSize = Number.parseInt(searchParams.get('pageSize') ?? '10', 10); + const sortField = searchParams.get('sortField'); + const sortOrder = (searchParams.get('sortOrder') || 'asc') as 'asc' | 'desc'; // 'asc' or 'desc' + + // --- Pagination and Sorting State --- + const pagination = { pageIndex, pageSize }; + const sorting = sortField ? [{ id: sortField, desc: sortOrder === 'desc' }] : []; + + // --- Event Handlers: update URL directly --- + const handlePaginationChange: OnChangeFn = (updaterOrValue) => { + const next = typeof updaterOrValue === 'function' ? updaterOrValue(pagination) : updaterOrValue; + searchParams.set('page', next.pageIndex.toString()); + searchParams.set('pageSize', next.pageSize.toString()); + navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true }); + }; + + const handleSortingChange: OnChangeFn = (updaterOrValue) => { + const next = typeof updaterOrValue === 'function' ? updaterOrValue(sorting) : updaterOrValue; + if (next.length > 0) { + searchParams.set('sortField', next[0].id); + searchParams.set('sortOrder', next[0].desc ? 'desc' : 'asc'); + } else { + searchParams.delete('sortField'); + searchParams.delete('sortOrder'); + } + navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true }); + }; + + // --- Bazza UI Filter Setup --- + const bazzaProcessedColumns = useMemo>(() => columnConfigs, []); + + // Define a filter strategy (replace with your actual strategy if needed) + const filterStrategy = 'server' as const; + + // Setup filter actions and strategy (controlled mode) + const { + columns: filterColumns, + actions, + strategy, + } = useDataTableFilters< + MockIssue, + DataTableColumnConfig, + import('@lambdacurry/forms/ui/data-table-filter/core/types').FilterStrategy + >({ + columnsConfig: bazzaProcessedColumns, + filters, + onFiltersChange: setFilters, + faceted: facetedOptionCounts, + strategy: filterStrategy, + data, + }); + + // --- Table Setup --- + const table = useReactTable({ + data, + columns, + state: { + pagination, + sorting, + }, + pageCount, + onPaginationChange: handlePaginationChange, + onSortingChange: handleSortingChange, + manualPagination: true, + manualFiltering: true, + manualSorting: true, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+

Issues Table (Bazza UI Server Filters via Loader)

+

This example demonstrates server-driven filtering using Bazza UI and React Router Loader:

+
    +
  • Filter state managed by Bazza UI filters component and synced to URL.
  • +
  • Pagination and sorting state managed by the URL.
  • +
  • Data fetched via `loader` based on URL parameters (filters, pagination, sorting).
  • +
  • Server provides filtered/paginated/sorted data and faceted counts.
  • +
+ + +
+ ); +} +// --- END Wrapper Component --- + +// Updated Loader function to return fake data matching DataResponse structure +const handleDataFetch = async ({ request }: LoaderFunctionArgs): Promise => { + await new Promise((resolve) => setTimeout(resolve, 300)); // Simulate latency + + const url = new URL(request.url); + const params = url.searchParams; + + console.log('handleDataFetch - URL:', url.toString()); + console.log('handleDataFetch - Search Params:', Object.fromEntries(params.entries())); + + // Parse pagination, sorting, and filters from URL using helpers/schemas + const page = dataTableRouterParsers.page.parse(params.get('page')) ?? 0; + let pageSize = dataTableRouterParsers.pageSize.parse(params.get('pageSize')) ?? 10; + const sortField = params.get('sortField'); // Get raw string or null + const sortOrder = (params.get('sortOrder') || 'asc') as 'asc' | 'desc'; // 'asc' or 'desc' + const filtersParam = params.get('filters'); + + console.log('handleDataFetch - Parsed Parameters:', { page, pageSize, sortField, sortOrder, filtersParam }); + + if (!pageSize || pageSize <= 0) { + console.log(`[Loader] - Invalid or missing pageSize (${pageSize}), defaulting to 10.`); + pageSize = 10; + } + + let parsedFilters: FiltersState = []; + try { + if (filtersParam) { + // Parse and validate filters strictly according to Bazza v0.2 model + parsedFilters = filtersArraySchema.parse(JSON.parse(filtersParam)); + console.log('handleDataFetch - Parsed Filters:', parsedFilters); + } + } catch (error) { + console.error('[Loader] - Filter parsing/validation error (expecting Bazza v0.2 model):', error); + parsedFilters = []; + } + + // --- Apply filtering, sorting, pagination --- + let processedData = [...mockDatabase]; + + // 1. Apply filters (support option and text types) + if (parsedFilters.length > 0) { + parsedFilters.forEach((filter) => { + processedData = processedData.filter((item) => { + switch (filter.type) { + case 'option': { + // Option filter: support multi-value (is any of) + if (Array.isArray(filter.values) && filter.values.length > 0) { + const value = item[filter.columnId as keyof MockIssue]; + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) { + return filter.values.includes(value); + } + // If value is not a supported type (e.g., Date), skip filtering + return true; + } + return true; + } + case 'text': { + // Text filter: support contains + if (Array.isArray(filter.values) && filter.values.length > 0 && typeof filter.values[0] === 'string') { + const value = item[filter.columnId as keyof MockIssue]; + return typeof value === 'string' && value.toLowerCase().includes(String(filter.values[0]).toLowerCase()); + } + return true; + } + // Add more filter types as needed (number, date, etc.) + default: + return true; + } + }); + }); + } + + // 2. Apply sorting + if (sortField && sortField in mockDatabase[0]) { + processedData.sort((a, b) => { + const aValue = a[sortField as keyof MockIssue]; + const bValue = b[sortField as keyof MockIssue]; + let comparison = 0; + if (aValue < bValue) comparison = -1; + if (aValue > bValue) comparison = 1; + return sortOrder === 'desc' ? comparison * -1 : comparison; + }); + } + + const totalItems = processedData.length; + const totalPages = Math.ceil(totalItems / pageSize); + + // 3. Apply pagination + const start = page * pageSize; + const paginatedData = processedData.slice(start, start + pageSize); + + // Calculate faceted counts based on the filtered data (not the original database) + // This ensures counts reflect the current filtered dataset + const facetedColumns: Array = ['status', 'assignee', 'priority']; + const facetedCounts = calculateFacetedCounts(processedData, facetedColumns, allDefinedOptions); + + console.log(`Returning ${paginatedData.length} items, page ${page}, total ${totalItems}`); + + const response: DataResponse = { + data: paginatedData, + meta: { + total: totalItems, + page: page, + pageSize: pageSize, + pageCount: totalPages, + }, + facetedCounts: facetedCounts, + }; + + return response; +}; + +const meta = { + title: 'Data Table/Bazza UI Filters', + component: DataTableWithBazzaFilters, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: DataTableWithBazzaFilters, + loader: handleDataFetch, + }, + ], + }), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Test functions for the data table with Bazza filters +const testInitialRender = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Check if the table is rendered with the correct title + const title = canvas.getByText('Issues Table (Bazza UI Server Filters via Loader)'); + expect(title).toBeInTheDocument(); + + // Check if the table has the correct number of rows initially (should be pageSize) + const rows = canvas.getAllByRole('row'); + // First row is header, so we expect pageSize + 1 rows + expect(rows.length).toBeGreaterThan(1); // At least header + 1 data row + + // Check if pagination is rendered + const paginationControls = canvas.getByRole('navigation'); + expect(paginationControls).toBeInTheDocument(); +}; + +const testFiltering = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Open the filter dropdown + const filterButton = canvas.getByRole('button', { name: /filter/i }); + await userEvent.click(filterButton); + + // Select a filter type (e.g., Status) + const statusFilter = await canvas.findByText('Status'); + await userEvent.click(statusFilter); + + // Select a filter value (e.g., "Todo") + const todoOption = await canvas.findByText('Todo'); + await userEvent.click(todoOption); + + // Apply the filter + const applyButton = canvas.getByRole('button', { name: /apply/i }); + await userEvent.click(applyButton); + + // Wait for the table to update + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Check if the URL has been updated with the filter + expect(window.location.search).toContain('filters'); + + // Check if the filter chip is displayed + const filterChip = await canvas.findByText('Status: Todo'); + expect(filterChip).toBeInTheDocument(); +}; + +const testPagination = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Get the initial page number + const initialPageButton = canvas.getByLabelText(/page 1/i); + expect(initialPageButton).toHaveAttribute('aria-current', 'page'); + + // Click on the next page button + const nextPageButton = canvas.getByLabelText(/go to next page/i); + await userEvent.click(nextPageButton); + + // Wait for the table to update + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Check if the URL has been updated with the new page + expect(window.location.search).toContain('page=1'); + + // Check if the page 2 button is now selected + const page2Button = canvas.getByLabelText(/page 2/i); + expect(page2Button).toHaveAttribute('aria-current', 'page'); +}; + +const testFilterPersistence = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Simulate a page refresh by manually setting the URL with filters + // This is done by checking if the filter chip is still present after pagination + const filterChips = canvas.getAllByRole('button', { name: /remove filter/i }); + expect(filterChips.length).toBeGreaterThan(0); + + // Check if the filtered data is still displayed correctly + // We can verify this by checking if the filter chip is still present + const statusFilterChip = canvas.getByText(/Status:/i); + expect(statusFilterChip).toBeInTheDocument(); +}; + +export const ServerDriven: Story = { + args: {}, + parameters: { + docs: { + description: { + story: + 'Demonstrates server-side filtering (via loader), pagination, and sorting with Bazza UI components and URL state synchronization.', + }, + }, + }, + play: async (context) => { + // Run the tests in sequence + await testInitialRender(context); + await testFiltering(context); + await testPagination(context); + await testFilterPersistence(context); + }, +}; diff --git a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx index d24c5b72..b917de2c 100644 --- a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx +++ b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx @@ -1,12 +1,22 @@ import { DataTableRouterForm } from '@lambdacurry/forms/remix-hook-form/data-table-router-form'; -import { dataTableRouterParsers } from '@lambdacurry/forms/remix-hook-form/data-table-router-parsers'; +import { + type BazzaFilterItem, + type BazzaFiltersState, + dataTableRouterParsers, +} from '@lambdacurry/forms/remix-hook-form/data-table-router-parsers'; import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; import type { Meta, StoryObj } from '@storybook/react'; import type { ColumnDef } from '@tanstack/react-table'; +import { ActivityIcon, ShieldIcon, UserIcon } from 'lucide-react'; +import type { ComponentType } from 'react'; import { type LoaderFunctionArgs, useLoaderData } from 'react-router'; import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; +// Assuming createColumnConfigHelper is available from bazza/ui +// For the story, we'll simulate its output if direct import is problematic. +// import { createColumnConfigHelper } from '@lambdacurry/forms/ui/data-table-filter/core/column-config-helper'; // Example path + // Define the data schema const userSchema = z.object({ id: z.string(), @@ -40,8 +50,8 @@ interface DataResponse { }; } -// Define the columns -const columns: ColumnDef[] = [ +// TanStack Table Column Definitions (for display) +const tanstackTableColumns: ColumnDef[] = [ { accessorKey: 'id', header: ({ column }) => , @@ -63,19 +73,12 @@ const columns: ColumnDef[] = [ accessorKey: 'role', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('role')}
, - enableColumnFilter: true, - filterFn: (row, id, value: string[]) => { - return value.includes(row.getValue(id)); - }, + // Filter-related properties like enableColumnFilter, filterFn are now handled by Bazza UI config }, { accessorKey: 'status', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('status')}
, - enableColumnFilter: true, - filterFn: (row, id, value: string[]) => { - return value.includes(row.getValue(id)); - }, }, { accessorKey: 'createdAt', @@ -84,19 +87,90 @@ const columns: ColumnDef[] = [ }, ]; -// Component to display the data table with router form integration +interface BazzaFilterColumnConfig { + id: string; + type: string; + displayName: string; + filterType: string; + options?: { label: string; value: string }[]; + icon: ComponentType<{ className?: string }>; +} + +// Updated Bazza UI Filter Column Configurations +const bazzaFilterColumnConfigs: BazzaFilterColumnConfig[] = [ + { + id: 'name', + type: 'text', + displayName: 'Name', + filterType: 'text', + icon: UserIcon, + }, + { + id: 'role', + type: 'option', + displayName: 'Role', + filterType: 'option', + icon: ShieldIcon, + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + { label: 'Editor', value: 'editor' }, + ], + }, + { + id: 'status', + type: 'option', + displayName: 'Status', + filterType: 'option', + icon: ActivityIcon, + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + { label: 'Pending', value: 'pending' }, + ], + }, + // Add more configs for other filterable columns as needed +]; + +// Log all options for each config to help debug undefined labels +bazzaFilterColumnConfigs.forEach((config) => { + console.log('>>>>> config', config); + // Log the options array for each config, if present + console.log(`Config id: ${config.id}, options:`, config.options); + if (config.options) { + config.options.forEach((opt, idx) => { + // Log if label is missing or undefined + if (!opt || typeof opt.label !== 'string') { + // eslint-disable-next-line no-console + console.warn(`Option label is missing or not a string in config '${config.id}' at index ${idx}:`, opt); + } + }); + } +}); + function DataTableRouterFormExample() { const loaderData = useLoaderData(); - - // Ensure we have data even if loaderData is undefined const data = loaderData?.data ?? []; const pageCount = loaderData?.meta.pageCount ?? 0; - console.log('DataTableRouterFormExample - loaderData:', loaderData); + // Log options before rendering to catch runtime issues + bazzaFilterColumnConfigs.forEach((config) => { + if (config.options) { + config.options.forEach((opt, idx) => { + if (!opt || typeof opt.label !== 'string') { + // eslint-disable-next-line no-console + console.warn( + `[Render] Option label is missing or not a string in config '${config.id}' at index ${idx}:`, + opt, + ); + } + }); + } + }); return (
-

Users Table (React Router Form Integration)

+

Users Table (Bazza UI Filters)

This example demonstrates integration with React Router forms, including:

  • Form-based filtering with automatic submission
  • @@ -105,66 +179,33 @@ function DataTableRouterFormExample() {
  • URL-based state management with React Router
- columns={columns} + columns={tanstackTableColumns} // For table display data={data} pageCount={pageCount} - filterableColumns={[ - { - id: 'role' as keyof User, - title: 'Role', - options: [ - { label: 'Admin', value: 'admin' }, - { label: 'User', value: 'user' }, - { label: 'Editor', value: 'editor' }, - ], - }, - { - id: 'status' as keyof User, - title: 'Status', - options: [ - { label: 'Active', value: 'active' }, - { label: 'Inactive', value: 'inactive' }, - { label: 'Pending', value: 'pending' }, - ], - }, - ]} - searchableColumns={[ - { - id: 'name' as keyof User, - title: 'Name', - }, - ]} + filterColumnConfigs={bazzaFilterColumnConfigs} // Pass Bazza UI config + // dtfOptions and dtfFacetedData would be fetched and passed for server-driven options/facets />
); } -// Loader function to handle data fetching based on URL parameters const handleDataFetch = async ({ request }: LoaderFunctionArgs) => { - // Add a small delay to simulate network latency await new Promise((resolve) => setTimeout(resolve, 300)); - - // Ensure we have a valid URL object const url = request?.url ? new URL(request.url) : new URL('http://localhost?page=0&pageSize=10'); const params = url.searchParams; - console.log('handleDataFetch - URL:', url.toString()); - console.log('handleDataFetch - Search Params:', Object.fromEntries(params.entries())); - - // Use our custom parsers to parse URL search parameters const page = dataTableRouterParsers.page.parse(params.get('page')); const pageSize = dataTableRouterParsers.pageSize.parse(params.get('pageSize')); const sortField = dataTableRouterParsers.sortField.parse(params.get('sortField')); const sortOrder = dataTableRouterParsers.sortOrder.parse(params.get('sortOrder')); const search = dataTableRouterParsers.search.parse(params.get('search')); - const parsedFilters = dataTableRouterParsers.filters.parse(params.get('filters')); - console.log('handleDataFetch - Parsed Parameters:', { page, pageSize, sortField, sortOrder, search, parsedFilters }); + // Parse BazzaFiltersState + const bazzaFilters = dataTableRouterParsers.filters.parse(params.get('filters')) as BazzaFiltersState; - // Apply filters let filteredData = [...users]; - // 1. Apply global search filter + // 1. Apply global search (if still used) if (search) { const searchLower = search.toLowerCase(); filteredData = filteredData.filter( @@ -172,23 +213,40 @@ const handleDataFetch = async ({ request }: LoaderFunctionArgs) => { ); } - // 2. Apply faceted filters from the parsed 'filters' array - if (parsedFilters && parsedFilters.length > 0) { - // Check if parsedFilters is not null - parsedFilters.forEach((filter) => { - if (filter.id in users[0] && Array.isArray(filter.value) && filter.value.length > 0) { - const filterValues = filter.value as string[]; - filteredData = filteredData.filter((user) => { - const userValue = user[filter.id as keyof User]; - return filterValues.includes(userValue); - }); - } else { - console.warn(`Invalid filter encountered: ${JSON.stringify(filter)}`); - } + // 2. Apply Bazza UI filters + if (bazzaFilters && bazzaFilters.length > 0) { + bazzaFilters.forEach((filter: BazzaFilterItem) => { + const { columnId, type, operator, values } = filter; + if (!values || values.length === 0) return; + + filteredData = filteredData.filter((user) => { + const userValue = user[columnId as keyof User]; + + switch (type) { + case 'text': { + if (operator === 'contains' && typeof userValue === 'string' && typeof values[0] === 'string') { + return userValue.toLowerCase().includes(values[0].toLowerCase()); + } + // Add other text operators: equals, startsWith, etc. + return true; // Default pass if operator not handled + } + + case 'option': { + if (operator === 'is any of' && Array.isArray(values)) return values.includes(userValue as string); + if (operator === 'is' && typeof values[0] === 'string') return userValue === values[0]; + // Add other option operators + return true; + } + + // Add cases for 'number', 'date' filters based on bazza/ui operators + default: + return true; + } + }); }); } - // 3. Apply sorting + // 3. Apply sorting (same as before) if (sortField && sortOrder && sortField in users[0]) { filteredData.sort((a, b) => { const aValue = a[sortField as keyof User]; @@ -199,17 +257,12 @@ const handleDataFetch = async ({ request }: LoaderFunctionArgs) => { }); } - // 4. Apply pagination - // Determine safe values for page and pageSize using defaultValue when params are missing + // 4. Apply pagination (same as before) const safePage = params.has('page') ? page : dataTableRouterParsers.page.defaultValue; const safePageSize = params.has('pageSize') ? pageSize : dataTableRouterParsers.pageSize.defaultValue; const start = safePage * safePageSize; const paginatedData = filteredData.slice(start, start + safePageSize); - // Log the data being returned for debugging - console.log(`Returning ${paginatedData.length} items, page ${safePage}, total ${filteredData.length}`); - - // Return the data response return { data: paginatedData, meta: { @@ -246,7 +299,7 @@ type Story = StoryObj; export const Default: Story = { // biome-ignore lint/suspicious/noExplicitAny: - args: {} as any, + args: {} as any, // Args for DataTableRouterForm if needed, handled by Example component render: () => , parameters: { docs: { diff --git a/packages/components/package.json b/packages/components/package.json index e93a811a..ced9a034 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -42,17 +42,19 @@ "@hookform/resolvers": "^3.9.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.4", - "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-checkbox": "^1.3.1", + "@radix-ui/react-dialog": "^1.1.13", + "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-label": "^2.1.6", + "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-separator": "^1.1.6", + "@radix-ui/react-slider": "^1.3.4", + "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tooltip": "^1.1.6", "@tanstack/react-table": "^8.21.2", "class-variance-authority": "^0.7.1", diff --git a/packages/components/src/remix-hook-form/data-table-router-form.tsx b/packages/components/src/remix-hook-form/data-table-router-form.tsx index 9535d103..edc0d418 100644 --- a/packages/components/src/remix-hook-form/data-table-router-form.tsx +++ b/packages/components/src/remix-hook-form/data-table-router-form.tsx @@ -1,6 +1,6 @@ import { type ColumnDef, - type ColumnFilter, + // type ColumnFilter, // No longer directly used for state.columnFilters type VisibilityState, flexRender, getCoreRowModel, @@ -14,95 +14,110 @@ import { import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigation } from 'react-router-dom'; import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; -import { z } from 'zod'; +// import { z } from 'zod'; // Schema is now more for URL state structure import { DataTablePagination } from '../ui/data-table/data-table-pagination'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; -import { DataTableRouterToolbar, type DataTableRouterToolbarProps } from './data-table-router-toolbar'; +import { DataTableRouterToolbar } from './data-table-router-toolbar'; -// Import the parsers and the inferred type -import type { DataTableRouterState, FilterValue } from './data-table-router-parsers'; -import { getDefaultDataTableState, useDataTableUrlState } from './use-data-table-url-state'; +// Bazza UI imports - assuming types for ColumnConfig and output of useDataTableFilters +// For now, using 'any' for some bazza types if not precisely known. +import { + useDataTableFilters, // The hook from bazza/ui + // createColumnConfigHelper, // Assume columnsConfig is pre-built and passed in +} from '../ui/data-table-filter'; // Adjusted path -// Schema for form data validation and type safety -const dataTableSchema = z.object({ - search: z.string().optional(), - filters: z.array(z.object({ id: z.string(), value: z.any() })).optional(), - page: z.number().min(0).optional(), - pageSize: z.number().min(1).optional(), - sortField: z.string().optional(), - sortOrder: z.enum(['asc', 'desc']).optional(), -}); +import type { BazzaFiltersState, DataTableRouterState } from './data-table-router-parsers'; +import { getDefaultDataTableState, useDataTableUrlState } from './use-data-table-url-state'; -type DataTableFormData = z.infer; +// dataTableSchema can remain to validate the shape of URL params if desired, but RemixForm doesn't use a resolver here. export interface DataTableRouterFormProps { - columns: ColumnDef[]; - data: TData[]; - filterableColumns?: DataTableRouterToolbarProps['filterableColumns']; - searchableColumns?: DataTableRouterToolbarProps['searchableColumns']; + columns: ColumnDef[]; // For TanStack Table display + data: TData[]; // Data from server (already filtered/sorted/paginated) pageCount?: number; defaultStateValues?: Partial; + // New prop for Bazza UI filter configurations + // This should be typed according to bazza/ui's ColumnConfig type (e.g., from createColumnConfigHelper(...).build()) + filterColumnConfigs: any[]; // Placeholder type for Bazza UI ColumnConfig[] + // Props for server-fetched options/faceted data for bazza/ui, if needed for server strategy + dtfOptions?: Record; + dtfFacetedData?: Record; } export function DataTableRouterForm({ columns, data, - filterableColumns = [], - searchableColumns = [], pageCount, defaultStateValues, + filterColumnConfigs, + dtfOptions, + dtfFacetedData, }: DataTableRouterFormProps) { const navigation = useNavigation(); const isLoading = navigation.state === 'loading'; - // Use our custom hook for URL state management const { urlState, setUrlState } = useDataTableUrlState(); - // Initialize RHF to *reflect* the URL state const methods = useRemixForm({ - // No resolver needed if Zod isn't primary validation driver here - defaultValues: urlState, // Initialize with current URL state + defaultValues: urlState, }); + const { + columns: dtfGeneratedColumns, + filters: dtfInternalFilters, // Filters state internal to useDataTableFilters + actions: dtfActions, + strategy: dtfStrategyReturned, + } = useDataTableFilters({ + strategy: 'server', + data: data, + columnsConfig: filterColumnConfigs, + options: dtfOptions, + faceted: dtfFacetedData, + filters: urlState.filters, // Use URL filters as the source of truth + onFiltersChange: (newFilters) => { + // Update URL state when filters change + setUrlState({ filters: newFilters as BazzaFiltersState, page: 0 }); + }, + }); + + // Sync URL filters TO Bazza internal filters (e.g., on back/forward nav) + // This is now handled by the controlled state pattern with filters and onFiltersChange + // Sync RHF state if urlState changes (e.g., back/forward, external link) useEffect(() => { - // Only reset if the urlState differs from current RHF values if (JSON.stringify(urlState) !== JSON.stringify(methods.getValues())) { methods.reset(urlState); } }, [urlState, methods]); - // Local UI state (column visibility, row selection) const [columnVisibility, setColumnVisibility] = useState({}); const [rowSelection, setRowSelection] = useState({}); - // Table instance uses RHF state (which mirrors URL state) const table = useReactTable({ data, columns, state: { sorting: [{ id: urlState.sortField, desc: urlState.sortOrder === 'desc' }], - columnFilters: urlState.filters as ColumnFilter[], + // columnFilters: urlState.filters as ColumnFilter[], // REMOVED: Filtering is server-side pagination: { pageIndex: urlState.page, pageSize: urlState.pageSize }, columnVisibility, rowSelection, }, manualPagination: true, manualSorting: true, - manualFiltering: true, + manualFiltering: true, // Crucial for server-side filtering pageCount, enableRowSelection: true, onRowSelectionChange: setRowSelection, onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), + getFilteredRowModel: getFilteredRowModel(), // Still useful for table structure getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), - getFacetedRowModel: getFacetedRowModel(), + getFacetedRowModel: getFacetedRowModel(), // If using any client-side faceting with TST getFacetedUniqueValues: getFacetedUniqueValues(), - // Define callbacks inline onSortingChange: (updater) => { const currentSorting = table.getState().sorting; const sorting = typeof updater === 'function' ? updater(currentSorting) : updater; @@ -112,26 +127,16 @@ export function DataTableRouterForm({ page: 0, }); }, - onColumnFiltersChange: (updater) => { - const currentFilters = table.getState().columnFilters; - const filters = typeof updater === 'function' ? updater(currentFilters) : updater; - setUrlState({ - filters: filters as FilterValue[], - page: 0, - }); - }, + // onColumnFiltersChange: // REMOVED: Not used for server-side filtering control }); - // Determine default pageSize and visible columns for skeleton loader const defaultDataTableState = getDefaultDataTableState(defaultStateValues); const visibleColumns = table.getVisibleFlatColumns(); - // Generate stable IDs for skeleton rows based on current pageSize or fallback const skeletonRowIds = useMemo(() => { const count = urlState.pageSize > 0 ? urlState.pageSize : defaultDataTableState.pageSize; return Array.from({ length: count }, () => window.crypto.randomUUID()); }, [urlState.pageSize, defaultDataTableState.pageSize]); - // Pagination handler updates URL state const handlePaginationChange = useCallback( (pageIndex: number, newPageSize: number) => { setUrlState({ page: pageIndex, pageSize: newPageSize }); @@ -139,24 +144,47 @@ export function DataTableRouterForm({ [setUrlState], ); - // Get default state values using our utility function const standardStateValues = getDefaultDataTableState(defaultStateValues); - // Handle pagination props separately const paginationProps = { pageCount: pageCount || 0, onPaginationChange: handlePaginationChange, }; + const handleSearchChange = (newSearch: string) => { + setUrlState({ search: newSearch, page: 0 }); + }; + + const handleResetFiltersAndSearch = () => { + if (dtfActions.removeAllFilters) { + dtfActions.removeAllFilters(); // Use the action from useDataTableFilters + } + // Then update URL, which will also clear Bazza filters via the effect if setFiltersState was not called + setUrlState({ + ...standardStateValues, + search: '', + filters: [], + }); + }; + + const hasActiveBazzaFilters = dtfInternalFilters && dtfInternalFilters.length > 0; + const hasActiveSearch = !!urlState.search; + const hasActiveFiltersOrSearch = hasActiveBazzaFilters || hasActiveSearch; + return (
table={table} - filterableColumns={filterableColumns} - searchableColumns={searchableColumns} - setUrlState={setUrlState} - defaultStateValues={standardStateValues} + search={urlState.search} + onSearchChange={handleSearchChange} + onResetFiltersAndSearch={handleResetFiltersAndSearch} + hasActiveFiltersOrSearch={hasActiveFiltersOrSearch} + // Pass Bazza UI filter props + dtfColumns={dtfGeneratedColumns} // Generated by useDataTableFilters + dtfFilters={dtfInternalFilters as BazzaFiltersState} // Display Bazza's current internal state + dtfActions={dtfActions} + dtfStrategy={dtfStrategyReturned || 'server'} /> {/* Table Rendering */} @@ -175,7 +203,6 @@ export function DataTableRouterForm({ {isLoading ? ( - // Skeleton rows matching pageSize with zebra background skeletonRowIds.map((rowId) => ( {visibleColumns.map((column) => ( diff --git a/packages/components/src/remix-hook-form/data-table-router-parsers.ts b/packages/components/src/remix-hook-form/data-table-router-parsers.ts index cce39518..52a641ab 100644 --- a/packages/components/src/remix-hook-form/data-table-router-parsers.ts +++ b/packages/components/src/remix-hook-form/data-table-router-parsers.ts @@ -1,26 +1,43 @@ - -// Define and export the shape of a single filter -export interface FilterValue { - // Export the interface - id: string; - value: unknown; // Keep unknown for flexibility, JSON handles serialization +// Define and export the shape of a single filter for Bazza UI +export interface BazzaFilterItem { + columnId: string; + type: string; // e.g., 'text', 'option', 'number' + operator: string; // e.g., 'contains', 'is', 'isAnyOf', 'equals', 'between' + values: unknown[]; } -// Runtime parser for FilterValue[] -const parseFilterValueArray = (value: unknown): FilterValue[] => { - if (!Array.isArray(value)) throw new Error('Expected array'); - return value.map((item) => { +export type BazzaFiltersState = BazzaFilterItem[]; + +// Runtime parser for BazzaFiltersState +const parseBazzaFiltersState = (value: unknown): BazzaFiltersState => { + if (!Array.isArray(value)) { + // console.warn('Expected array for filters, got:', value); + return []; // Return empty array or throw error based on desired strictness + } + return value.reduce((acc: BazzaFiltersState, item) => { if ( - typeof item !== 'object' || - item === null || - !('id' in item) || - typeof item.id !== 'string' || - !('value' in item) + typeof item === 'object' && + item !== null && + 'columnId' in item && + typeof item.columnId === 'string' && + 'type' in item && + typeof item.type === 'string' && + 'operator' in item && + typeof item.operator === 'string' && + 'values' in item && + Array.isArray(item.values) ) { - throw new Error('Invalid filter value'); + acc.push({ + columnId: item.columnId, + type: item.type, + operator: item.operator, + values: item.values, + }); + } else { + // console.warn('Invalid filter item:', item); } - return { id: item.id, value: item.value }; - }); + return acc; + }, []); }; // Custom parsers to replace nuqs parsers @@ -36,8 +53,8 @@ export const parseAsString = { export const parseAsInteger = { parse: (value: string | null): number => { if (!value) return 0; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? 0 : parsed; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; }, serialize: (value: number | null): string | null => { return value === 0 ? null : value?.toString() || null; @@ -69,11 +86,11 @@ export const dataTableRouterParsers = { defaultValue: '', }, filters: { - parse: (value: string | null) => parseAsJson(parseFilterValueArray).parse(value) || [], - serialize: (value: FilterValue[] | null) => { - return value && value.length > 0 ? parseAsJson(parseFilterValueArray).serialize(value) : null; + parse: (value: string | null) => parseAsJson(parseBazzaFiltersState).parse(value) || [], + serialize: (value: BazzaFiltersState | null) => { + return value && value.length > 0 ? parseAsJson(parseBazzaFiltersState).serialize(value) : null; }, - defaultValue: [] as FilterValue[], + defaultValue: [] as BazzaFiltersState, }, page: { parse: parseAsInteger.parse, @@ -100,9 +117,9 @@ export const dataTableRouterParsers = { // Export the inferred type for convenience export type DataTableRouterState = { search: string; - filters: FilterValue[]; + filters: BazzaFiltersState; // Updated to use BazzaFiltersState page: number; pageSize: number; sortField: string; - sortOrder: string; + sortOrder: string; // 'asc' or 'desc' }; diff --git a/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx b/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx index ec6f964d..6ace1c29 100644 --- a/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx +++ b/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx @@ -1,149 +1,88 @@ import { Cross2Icon } from '@radix-ui/react-icons'; import type { Table } from '@tanstack/react-table'; import { type ChangeEvent, useCallback } from 'react'; -import { useRemixFormContext } from 'remix-hook-form'; import { Button } from '../ui/button'; -import { DataTableFacetedFilter } from '../ui/data-table/data-table-faceted-filter'; +import { DataTableFilter } from '../ui/data-table-filter'; import { DataTableViewOptions } from '../ui/data-table/data-table-view-options'; -import type { DataTableRouterState, FilterValue } from './data-table-router-parsers'; +import type { BazzaFiltersState } from './data-table-router-parsers'; import { TextField } from './text-field'; -export interface DataTableFilterOption { - label: string; - value: string; - icon?: React.ComponentType<{ className?: string }>; -} - -export interface DataTableFilterableColumn { - id: keyof TData | string; - title: string; - options: DataTableFilterOption[]; -} - -export interface DataTableSearchableColumn { - id: keyof TData | string; - title: string; -} - export interface DataTableRouterToolbarProps { table: Table; - filterableColumns?: DataTableFilterableColumn[]; - searchableColumns?: DataTableSearchableColumn[]; - setUrlState: (state: Partial) => void; - defaultStateValues: DataTableRouterState; + search?: string; + onSearchChange: (newSearch: string) => void; + onResetFiltersAndSearch: () => void; + hasActiveFiltersOrSearch: boolean; + + dtfColumns: any[]; + dtfFilters: BazzaFiltersState; + dtfActions: any; + dtfStrategy: 'client' | 'server'; } export function DataTableRouterToolbar({ table, - filterableColumns = [], - searchableColumns = [], - setUrlState, - defaultStateValues, + search, + onSearchChange, + onResetFiltersAndSearch, + hasActiveFiltersOrSearch, + dtfColumns, + dtfFilters, + dtfActions, + dtfStrategy, }: DataTableRouterToolbarProps) { - const { watch } = useRemixFormContext(); - const watchedFilters = (watch('filters') || []) as FilterValue[]; - const watchedSearch = watch('search') || ''; - - const handleSearchChange = useCallback( + const handleSearchInputChange = useCallback( (event: ChangeEvent) => { - setUrlState({ search: event.target.value || '', page: 0 }); + onSearchChange(event.target.value || ''); }, - [setUrlState], + [onSearchChange], ); - const handleFilterChange = useCallback( - (columnId: string, value: string[]) => { - const currentFilters = [...watchedFilters]; - const existingFilterIndex = currentFilters.findIndex((filter: FilterValue) => filter.id === columnId); - let newFilters: FilterValue[]; - - if (value.length === 0 && existingFilterIndex !== -1) { - newFilters = currentFilters.filter((_, i) => i !== existingFilterIndex); - } else if (value.length === 0) { - newFilters = currentFilters; - } else if (existingFilterIndex !== -1) { - newFilters = [...currentFilters]; - newFilters[existingFilterIndex] = { id: columnId, value }; - } else { - newFilters = [...currentFilters, { id: columnId, value }]; - } - setUrlState({ filters: newFilters, page: 0 }); - }, - [setUrlState, watchedFilters], - ); - - const handleReset = useCallback(() => { - setUrlState({ - ...defaultStateValues, - search: '', - filters: [], - }); - }, [setUrlState, defaultStateValues]); - - const hasFiltersOrSearch = watchedFilters.length > 0 || watchedSearch.length > 0; + const handleClearSearch = useCallback(() => { + onSearchChange(''); + }, [onSearchChange]); return (
- {/* Search */} - {searchableColumns.length > 0 && ( -
-
- column.title).join(', ')}...`} - value={watchedSearch} - onChange={handleSearchChange} - className="w-full" - suffix={ - watchedSearch ? ( - - ) : null - } - /> -
-
- )} + {/* Global Search (kept separate from bazza/ui column filters) */} +
+ + + Clear search + + ) : null + } + /> +
- {/* Filters */} + {/* Bazza UI Filters */}
- {filterableColumns.length > 0 && ( -
- {filterableColumns.map((column) => { - // Find the current filter value for this column - const currentFilter = watchedFilters.find((filter: FilterValue) => filter.id === column.id); - const selectedValues = (currentFilter?.value as string[]) || []; - - return ( - handleFilterChange(String(column.id), values)} - /> - ); - })} -
- )} + - {/* Reset Button */} - {hasFiltersOrSearch && ( - )} - {/* View Options */} + {/* View Options - uses TanStack table instance */}
diff --git a/packages/components/src/remix-hook-form/use-data-table-url-state.ts b/packages/components/src/remix-hook-form/use-data-table-url-state.ts index 56d3562b..12030bf5 100644 --- a/packages/components/src/remix-hook-form/use-data-table-url-state.ts +++ b/packages/components/src/remix-hook-form/use-data-table-url-state.ts @@ -20,6 +20,7 @@ export function useDataTableUrlState() { page: dataTableRouterParsers.page.parse(searchParams.get('page')), pageSize: dataTableRouterParsers.pageSize.parse(searchParams.get('pageSize')), sortField: dataTableRouterParsers.sortField.parse(searchParams.get('sortField')), + // 'asc' or 'desc' sortOrder: dataTableRouterParsers.sortOrder.parse(searchParams.get('sortOrder')), }; @@ -83,6 +84,7 @@ export function getDefaultDataTableState(defaultStateValues?: Partial, - VariantProps { +export interface ButtonProps extends ButtonHTMLAttributes, VariantProps { asChild?: boolean; } export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { const Comp = asChild ? Slot : 'button'; - return ( - - ); + return ; } Button.displayName = 'Button'; -export { buttonVariants }; \ No newline at end of file +export { buttonVariants }; diff --git a/packages/components/src/ui/calendar.tsx b/packages/components/src/ui/calendar.tsx new file mode 100644 index 00000000..07fd5b71 --- /dev/null +++ b/packages/components/src/ui/calendar.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import type * as React from 'react'; +import { DayPicker } from 'react-day-picker'; +import { buttonVariants } from './button'; +import { cn } from './utils'; + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md' + : '[&:has([aria-selected])]:rounded-md', + ), + day: cn(buttonVariants({ variant: 'ghost' }), 'size-8 p-0 font-normal aria-selected:opacity-100'), + day_range_start: 'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground', + day_range_end: 'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground', + day_selected: + 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', + day_today: 'bg-accent text-accent-foreground', + day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground', + day_disabled: 'text-muted-foreground opacity-50', + day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground', + day_hidden: 'invisible', + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => , + IconRight: ({ className, ...props }) => , + }} + {...props} + /> + ); +} + +export { Calendar }; diff --git a/packages/components/src/ui/checkbox.tsx b/packages/components/src/ui/checkbox.tsx new file mode 100644 index 00000000..3872cb09 --- /dev/null +++ b/packages/components/src/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '../ui'; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/packages/components/src/ui/data-table-filter/components/active-filters.tsx b/packages/components/src/ui/data-table-filter/components/active-filters.tsx new file mode 100644 index 00000000..773c2572 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/active-filters.tsx @@ -0,0 +1,156 @@ +import { X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from '../../button'; +import { Separator } from '../../separator'; +import type { + Column, + ColumnDataType, + DataTableFilterActions, + FilterModel, + FilterStrategy, + FiltersState, +} from '../core/types'; +import { getColumn } from '../lib/helpers'; +import type { Locale } from '../lib/i18n'; +import { FilterOperator } from './filter-operator'; +import { FilterSubject } from './filter-subject'; +import { FilterValue } from './filter-value'; + +interface ActiveFiltersProps { + columns: Column[]; + filters: FiltersState; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export function ActiveFilters({ + columns, + filters, + actions, + strategy, + locale = 'en', +}: ActiveFiltersProps) { + return ( + <> + {filters.map((filter) => { + const id = filter.columnId; + + const column = getColumn(columns, id); + + // Skip if no filter value + if (!filter.values) return null; + + return ( + + ); + })} + + ); +} + +interface ActiveFilterProps { + filter: FilterModel; + column: Column; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +// Generic render function for a filter with type-safe value +export function ActiveFilter({ + filter, + column, + actions, + strategy, + locale = 'en', +}: ActiveFilterProps) { + return ( +
+ + + + + + + +
+ ); +} + +export function ActiveFiltersMobileContainer({ children }: { children: React.ReactNode }) { + const scrollContainerRef = useRef(null); + const [showLeftBlur, setShowLeftBlur] = useState(false); + const [showRightBlur, setShowRightBlur] = useState(true); + + // Check if there's content to scroll and update blur states + const checkScroll = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current; + + // Show left blur if scrolled to the right + setShowLeftBlur(scrollLeft > 0); + + // Show right blur if there's more content to scroll to the right + // Add a small buffer (1px) to account for rounding errors + setShowRightBlur(scrollLeft + clientWidth < scrollWidth - 1); + } + }; + + // Log blur states for debugging + // useEffect(() => { + // console.log('left:', showLeftBlur, ' right:', showRightBlur) + // }, [showLeftBlur, showRightBlur]) + + // Set up ResizeObserver to monitor container size + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (scrollContainerRef.current) { + const resizeObserver = new ResizeObserver(() => { + checkScroll(); + }); + resizeObserver.observe(scrollContainerRef.current); + return () => { + resizeObserver.disconnect(); + }; + } + }, []); + + // Update blur states when children change + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + checkScroll(); + }, [children]); + + return ( +
+ {/* Left blur effect */} + {showLeftBlur && ( +
+ )} + + {/* Scrollable container */} +
+ {children} +
+ + {/* Right blur effect */} + {showRightBlur && ( +
+ )} +
+ ); +} diff --git a/packages/components/src/ui/data-table-filter/components/data-table-filter.tsx b/packages/components/src/ui/data-table-filter/components/data-table-filter.tsx new file mode 100644 index 00000000..c8c37495 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/data-table-filter.tsx @@ -0,0 +1,44 @@ +import type { Column, DataTableFilterActions, FilterStrategy, FiltersState } from '../core/types'; +import type { Locale } from '../lib/i18n'; +import { ActiveFilters, ActiveFiltersMobileContainer } from './active-filters'; +import { FilterActions } from './filter-actions'; +import { FilterSelector } from './filter-selector'; + +interface DataTableFilterProps { + columns: Column[]; + filters: FiltersState; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export function DataTableFilter({ + columns, + filters, + actions, + strategy, + locale = 'en', +}: DataTableFilterProps) { + return ( + <> + {/* Mobile layout */} +
+
+ + 0} actions={actions} locale={locale} /> +
+ + + +
+ {/* Desktop layout */} +
+
+ + +
+ 0} actions={actions} locale={locale} /> +
+ + ); +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-actions.tsx b/packages/components/src/ui/data-table-filter/components/filter-actions.tsx new file mode 100644 index 00000000..cd4a3901 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-actions.tsx @@ -0,0 +1,26 @@ +import { FilterXIcon } from 'lucide-react'; +import { memo } from 'react'; +import { Button } from '../../button'; +import { cn } from '../../utils'; +import type { DataTableFilterActions } from '../core/types'; +import { type Locale, t } from '../lib/i18n'; + +interface FilterActionsProps { + hasFilters: boolean; + actions?: DataTableFilterActions; + locale?: Locale; +} + +export const FilterActions = memo(__FilterActions); +function __FilterActions({ hasFilters, actions, locale = 'en' }: FilterActionsProps) { + return ( + + ); +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-operator.tsx b/packages/components/src/ui/data-table-filter/components/filter-operator.tsx new file mode 100644 index 00000000..8997ed04 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-operator.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { Button } from '../../button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../../popover'; +import { + dateFilterOperators, + filterTypeOperatorDetails, + multiOptionFilterOperators, + numberFilterOperators, + optionFilterOperators, + textFilterOperators, +} from '../core/operators'; +import type { Column, ColumnDataType, DataTableFilterActions, FilterModel, FilterOperators } from '../core/types'; +import { type Locale, t } from '../lib/i18n'; + +interface FilterOperatorProps { + column: Column; + filter: FilterModel; + actions: DataTableFilterActions; + locale?: Locale; +} + +// Renders the filter operator display and menu for a given column filter +// The filter operator display is the label and icon for the filter operator +// The filter operator menu is the dropdown menu for the filter operator +export function FilterOperator({ + column, + filter, + actions, + locale = 'en', +}: FilterOperatorProps) { + const [open, setOpen] = useState(false); + + const close = () => setOpen(false); + + return ( + + + + + + + + {t('noresults', locale)} + + + + + + + ); +} + +interface FilterOperatorDisplayProps { + filter: FilterModel; + columnType: TType; + locale?: Locale; +} + +export function FilterOperatorDisplay({ + filter, + columnType, + locale = 'en', +}: FilterOperatorDisplayProps) { + const operator = filterTypeOperatorDetails[columnType][filter.operator]; + const label = t(operator.key, locale); + + return {label}; +} + +interface FilterOperatorControllerProps { + filter: FilterModel; + column: Column; + actions: DataTableFilterActions; + closeController: () => void; + locale?: Locale; +} + +/* + * + * TODO: Reduce into a single component. Each data type does not need it's own controller. + * + */ +export function FilterOperatorController({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps) { + switch (column.type) { + case 'option': + return ( + } + column={column as Column} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'multiOption': + return ( + } + column={column as Column} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'date': + return ( + } + column={column as Column} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'text': + return ( + } + column={column as Column} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'number': + return ( + } + column={column as Column} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + default: + return null; + } +} + +function FilterOperatorOptionController({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps) { + const filterDetails = optionFilterOperators[filter.operator]; + + const relatedFilters = Object.values(optionFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['option']); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {t(r.key, locale)} + + ); + })} + + ); +} + +function FilterOperatorMultiOptionController({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps) { + const filterDetails = multiOptionFilterOperators[filter.operator]; + + const relatedFilters = Object.values(multiOptionFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['multiOption']); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {t(r.key, locale)} + + ); + })} + + ); +} + +function FilterOperatorDateController({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps) { + const filterDetails = dateFilterOperators[filter.operator]; + + const relatedFilters = Object.values(dateFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['date']); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {t(r.key, locale)} + + ); + })} + + ); +} + +export function FilterOperatorTextController({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps) { + const filterDetails = textFilterOperators[filter.operator]; + + const relatedFilters = Object.values(textFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['text']); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {t(r.key, locale)} + + ); + })} + + ); +} + +function FilterOperatorNumberController({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps) { + const filterDetails = numberFilterOperators[filter.operator]; + + const relatedFilters = Object.values(numberFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['number']); + closeController(); + }; + + return ( +
+ + {relatedFilters.map((r) => ( + changeOperator(r.value)} value={r.value} key={r.value}> + {t(r.key, locale)} + + ))} + +
+ ); +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-selector.tsx b/packages/components/src/ui/data-table-filter/components/filter-selector.tsx new file mode 100644 index 00000000..4fcd2166 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-selector.tsx @@ -0,0 +1,293 @@ +import { ArrowRightIcon, ChevronRightIcon, FilterIcon } from 'lucide-react'; +import { isValidElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React from 'react'; +import { Button } from '../../button'; +import { Checkbox } from '../../checkbox'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../../popover'; +import { cn } from '../../utils'; +import type { + Column, + ColumnDataType, + DataTableFilterActions, + FilterModel, + FilterStrategy, + FiltersState, +} from '../core/types'; +import { isAnyOf } from '../lib/array'; +import { getColumn } from '../lib/helpers'; +import { type Locale, t } from '../lib/i18n'; +import { FilterValueController } from './filter-value'; + +interface FilterSelectorProps { + filters: FiltersState; + columns: Column[]; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const FilterSelector = memo(__FilterSelector) as typeof __FilterSelector; + +function __FilterSelector({ filters, columns, actions, strategy, locale = 'en' }: FilterSelectorProps) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); + const [property, setProperty] = useState(undefined); + const inputRef = useRef(null); + + const column = property ? getColumn(columns, property) : undefined; + const filter = property ? filters.find((f) => f.columnId === property) : undefined; + + const hasFilters = filters.length > 0; + + useEffect(() => { + if (property && inputRef) { + inputRef.current?.focus(); + setValue(''); + } + }, [property]); + + useEffect(() => { + if (!open) setTimeout(() => setValue(''), 150); + }, [open]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: need filters to be updated + const content = useMemo( + () => + property && column ? ( + } + column={column as Column} + actions={actions} + strategy={strategy} + locale={locale} + /> + ) : ( + { + const extendValue = `${value} ${keywords?.join(' ')}`; + return extendValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }} + > + + {t('noresults', locale)} + + + {columns.map((column) => ( + + ))} + + + + + ), + [property, column, filter, filters, columns, actions, value], + ); + + return ( + { + setOpen(value); + if (!value) setTimeout(() => setProperty(undefined), 100); + }} + > + + + + + {content} + + + ); +} + +export function FilterableColumn({ + column, + setProperty, +}: { + column: Column; + setProperty: (value: string) => void; +}) { + const displayName = column.displayName; + if (typeof displayName !== 'string') { + // eslint-disable-next-line no-console + console.warn('FilterableColumn: displayName is not a string', column); + } + if (typeof column.id !== 'string') { + // eslint-disable-next-line no-console + console.warn('FilterableColumn: id is not a string', column); + } + console.log('FilterableColumn CommandItem value:', column.id, 'keywords:', [displayName]); + const itemRef = useRef(null); + + const prefetch = useCallback(() => { + column.prefetchOptions(); + column.prefetchValues(); + column.prefetchFacetedUniqueValues(); + column.prefetchFacetedMinMaxValues(); + }, [column]); + + useEffect(() => { + const target = itemRef.current; + + if (!target) return; + + // Set up MutationObserver + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + const isSelected = target.getAttribute('data-selected') === 'true'; + if (isSelected) prefetch(); + } + } + }); + + // Set up observer + observer.observe(target, { + attributes: true, + attributeFilter: ['data-selected'], + }); + + // Cleanup on unmount + return () => observer.disconnect(); + }, [prefetch]); + + return ( + setProperty(column.id)} + className="group" + onMouseEnter={prefetch} + > +
+
+ {} + {displayName} +
+ +
+
+ ); +} + +interface QuickSearchFiltersProps { + search?: string; + filters: FiltersState; + columns: Column[]; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const QuickSearchFilters = memo(__QuickSearchFilters) as typeof __QuickSearchFilters; + +function __QuickSearchFilters({ + search, + filters, + columns, + actions, + strategy, + locale = 'en', +}: QuickSearchFiltersProps) { + const cols = useMemo( + () => columns.filter((c) => isAnyOf(c.type, ['option', 'multiOption'])), + [columns], + ); + + if (!search || search.trim().length < 2) return null; + + return ( + <> + {cols.map((column) => { + const filter = filters.find((f) => f.columnId === column.id); + const options = column.getOptions(); + const optionsCount = column.getFacetedUniqueValues(); + + function handleOptionSelect(value: string, check: boolean) { + if (check) actions.addFilterValue(column, [value]); + else actions.removeFilterValue(column, [value]); + } + + return ( + + {options.map((v) => { + const checked = Boolean(filter?.values.includes(v.value)); + const count = optionsCount?.get(v.value) ?? 0; + + console.log('QuickSearchFilters option value:', v.value, 'label:', v.label); + + // Defensive check for label + if (typeof v.label !== 'string') { + // eslint-disable-next-line no-console + console.warn(`Option label is not a string for column '${column.id}', value:`, v); + } + + return ( + { + handleOptionSelect(v.value, !checked); + }} + className="group" + > +
+ +
+ {v.icon && (isValidElement(v.icon) ? v.icon : )} +
+
+ {column.displayName} + + + {typeof v.label === 'string' ? v.label : ''} + + {count < 100 ? count : '100+'} + + +
+
+
+ ); + })} +
+ ); + })} + + ); +} + +console.log('=== filter-selector.tsx loaded ==='); diff --git a/packages/components/src/ui/data-table-filter/components/filter-subject.tsx b/packages/components/src/ui/data-table-filter/components/filter-subject.tsx new file mode 100644 index 00000000..60b3217c --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-subject.tsx @@ -0,0 +1,17 @@ +import type { Column, ColumnDataType } from '../core/types' + +interface FilterSubjectProps { + column: Column +} + +export function FilterSubject({ + column, +}: FilterSubjectProps) { + const hasIcon = !!column.icon + return ( + + {hasIcon && } + {column.displayName} + + ) +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-value.tsx b/packages/components/src/ui/data-table-filter/components/filter-value.tsx new file mode 100644 index 00000000..22f497c9 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-value.tsx @@ -0,0 +1,745 @@ +import { isEqual } from 'date-fns'; +import { format } from 'date-fns'; +import { Ellipsis } from 'lucide-react'; +import { type ElementType, cloneElement, isValidElement, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { DateRange } from 'react-day-picker'; +import { Button } from '../../button'; +import { Calendar } from '../../calendar'; +import { Checkbox } from '../../checkbox'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '../../command'; +import { DebouncedInput } from '../../debounced-input'; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '../../popover'; +import { Slider } from '../../slider'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../tabs'; +import { cn } from '../../utils'; +import { numberFilterOperators } from '../core/operators'; +import type { + Column, + ColumnDataType, + ColumnOptionExtended, + DataTableFilterActions, + FilterModel, + FilterStrategy, +} from '../core/types'; +import { useDebounceCallback } from '../hooks/use-debounce-callback'; +import { take } from '../lib/array'; +import { createNumberRange } from '../lib/helpers'; +import { type Locale, t } from '../lib/i18n'; + +interface FilterValueProps { + filter: FilterModel; + column: Column; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const FilterValue = memo(__FilterValue) as typeof __FilterValue; + +function __FilterValue({ + filter, + column, + actions, + strategy, + locale, +}: FilterValueProps) { + return ( + + + + + + + + + + ); +} + +interface FilterValueDisplayProps { + filter: FilterModel; + column: Column; + actions: DataTableFilterActions; + locale?: Locale; +} + +export function FilterValueDisplay({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps) { + switch (column.type) { + case 'option': + return ( + } + column={column as Column} + actions={actions} + locale={locale} + /> + ); + case 'multiOption': + return ( + } + column={column as Column} + actions={actions} + locale={locale} + /> + ); + case 'date': + return ( + } + column={column as Column} + actions={actions} + locale={locale} + /> + ); + case 'text': + return ( + } + column={column as Column} + actions={actions} + locale={locale} + /> + ); + case 'number': + return ( + } + column={column as Column} + actions={actions} + locale={locale} + /> + ); + default: + return null; + } +} + +export function FilterValueOptionDisplay({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps) { + const options = useMemo(() => column.getOptions(), [column]); + const selected = options.filter((o) => filter?.values.includes(o.value)); + + // We display the selected options based on how many are selected + // + // If there is only one option selected, we display its icon and label + // + // If there are multiple options selected, we display: + // 1) up to 3 icons of the selected options + // 2) the number of selected options + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + + {hasIcon && (isValidElement(Icon) ? Icon : )} + {label} + + ); + } + const name = column.displayName.toLowerCase(); + // TODO: Better pluralization for different languages + const pluralName = name.endsWith('s') ? `${name}es` : `${name}s`; + + const hasOptionIcons = !options?.some((o) => !o.icon); + + return ( +
+ {hasOptionIcons && + take(selected, 3).map(({ value, icon }) => { + const Icon = icon as ElementType; + return isValidElement(Icon) ? Icon : ; + })} + + {selected.length} {pluralName} + +
+ ); +} + +export function FilterValueMultiOptionDisplay({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps) { + const options = useMemo(() => column.getOptions(), [column]); + const selected = options.filter((o) => filter.values.includes(o.value)); + + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + + {hasIcon && (isValidElement(Icon) ? Icon : )} + + {label} + + ); + } + + const name = column.displayName.toLowerCase(); + + const hasOptionIcons = !options?.some((o) => !o.icon); + + return ( +
+ {hasOptionIcons && ( +
+ {take(selected, 3).map(({ value, icon }) => { + const Icon = icon as ElementType; + return isValidElement(Icon) ? cloneElement(Icon, { key: value }) : ; + })} +
+ )} + + {selected.length} {name} + +
+ ); +} + +function formatDateRange(start: Date, end: Date) { + const sameMonth = start.getMonth() === end.getMonth(); + const sameYear = start.getFullYear() === end.getFullYear(); + + if (sameMonth && sameYear) { + return `${format(start, 'MMM d')} - ${format(end, 'd, yyyy')}`; + } + + if (sameYear) { + return `${format(start, 'MMM d')} - ${format(end, 'MMM d, yyyy')}`; + } + + return `${format(start, 'MMM d, yyyy')} - ${format(end, 'MMM d, yyyy')}`; +} + +export function FilterValueDateDisplay({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps) { + if (!filter) return null; + if (filter.values.length === 0) return ; + if (filter.values.length === 1) { + const value = filter.values[0]; + + const formattedDateStr = format(value, 'MMM d, yyyy'); + + return {formattedDateStr}; + } + + const formattedRangeStr = formatDateRange(filter.values[0], filter.values[1]); + + return {formattedRangeStr}; +} + +export function FilterValueTextDisplay({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps) { + if (!filter) return null; + if (filter.values.length === 0 || filter.values[0].trim() === '') return ; + + const value = filter.values[0]; + + return {value}; +} + +export function FilterValueNumberDisplay({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps) { + if (!filter || !filter.values || filter.values.length === 0) return null; + + if (filter.operator === 'is between' || filter.operator === 'is not between') { + const minValue = filter.values[0]; + const maxValue = filter.values[1]; + + return ( + + {minValue} {t('and', locale)} {maxValue} + + ); + } + + const value = filter.values[0]; + return {value}; +} + +/****** Property Filter Value Controller ******/ + +interface FilterValueControllerProps { + filter: FilterModel; + column: Column; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const FilterValueController = memo(__FilterValueController) as typeof __FilterValueController; + +function __FilterValueController({ + filter, + column, + actions, + strategy, + locale = 'en', +}: FilterValueControllerProps) { + switch (column.type) { + case 'option': + return ( + } + column={column as Column} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'multiOption': + return ( + } + column={column as Column} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'date': + return ( + } + column={column as Column} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'text': + return ( + } + column={column as Column} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'number': + return ( + } + column={column as Column} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + default: + return null; + } +} + +interface OptionItemProps { + option: ColumnOptionExtended & { initialSelected: boolean }; + onToggle: (value: string, checked: boolean) => void; +} + +// Memoized option item to prevent re-renders unless its own props change +const OptionItem = memo(function OptionItem({ option, onToggle }: OptionItemProps) { + const { value, label, icon: Icon, selected, count } = option; + const handleSelect = useCallback(() => { + onToggle(value, !selected); + }, [onToggle, value, selected]); + + return ( + +
+ + {Icon && (isValidElement(Icon) ? Icon : )} + + {label} + + {typeof count === 'number' ? (count < 100 ? count : '100+') : ''} + + +
+
+ ); +}); + +export function FilterValueOptionController({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps) { + // Compute initial options once per mount + // biome-ignore lint/correctness/useExhaustiveDependencies: from Bazza UI + const initialOptions = useMemo(() => { + const counts = column.getFacetedUniqueValues(); + return column.getOptions().map((o) => ({ + ...o, + selected: filter?.values.includes(o.value), + initialSelected: filter?.values.includes(o.value), + count: counts?.get(o.value) ?? 0, + })); + }, []); + + const [options, setOptions] = useState(initialOptions); + + // Update selected state when filter values change + useEffect(() => { + setOptions((prev) => prev.map((o) => ({ ...o, selected: filter?.values.includes(o.value) }))); + }, [filter?.values]); + + const handleToggle = useCallback( + (value: string, checked: boolean) => { + if (checked) actions.addFilterValue(column, [value]); + else actions.removeFilterValue(column, [value]); + }, + [actions, column], + ); + + // Derive groups based on `initialSelected` only + const { selectedOptions, unselectedOptions } = useMemo(() => { + const sel: typeof options = []; + const unsel: typeof options = []; + for (const o of options) { + if (o.initialSelected) sel.push(o); + else unsel.push(o); + } + return { selectedOptions: sel, unselectedOptions: unsel }; + }, [options]); + + return ( + + + {t('noresults', locale)} + + + {selectedOptions.map((option) => ( + + ))} + + + + {unselectedOptions.map((option) => ( + + ))} + + + + ); +} + +export function FilterValueMultiOptionController({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps) { + // Compute initial options once per mount + // biome-ignore lint/correctness/useExhaustiveDependencies: from Bazza UI + const initialOptions = useMemo(() => { + const counts = column.getFacetedUniqueValues(); + return column.getOptions().map((o) => { + const selected = filter?.values.includes(o.value); + return { + ...o, + selected, + initialSelected: selected, + count: counts?.get(o.value) ?? 0, + }; + }); + }, []); + + const [options, setOptions] = useState(initialOptions); + + // Update selected state when filter values change + useEffect(() => { + setOptions((prev) => prev.map((o) => ({ ...o, selected: filter?.values.includes(o.value) }))); + }, [filter?.values]); + + const handleToggle = useCallback( + (value: string, checked: boolean) => { + if (checked) actions.addFilterValue(column, [value]); + else actions.removeFilterValue(column, [value]); + }, + [actions, column], + ); + + // Derive groups based on `initialSelected` only + const { selectedOptions, unselectedOptions } = useMemo(() => { + const sel: typeof options = []; + const unsel: typeof options = []; + for (const o of options) { + if (o.initialSelected) sel.push(o); + else unsel.push(o); + } + return { selectedOptions: sel, unselectedOptions: unsel }; + }, [options]); + + return ( + + + {t('noresults', locale)} + + + {selectedOptions.map((option) => ( + + ))} + + + + {unselectedOptions.map((option) => ( + + ))} + + + + ); +} + +export function FilterValueDateController({ + filter, + column, + actions, +}: FilterValueControllerProps) { + const [date, setDate] = useState({ + from: filter?.values[0] ?? new Date(), + to: filter?.values[1] ?? undefined, + }); + + function changeDateRange(value: DateRange | undefined) { + const start = value?.from; + const end = start && value && value.to && !isEqual(start, value.to) ? value.to : undefined; + + setDate({ from: start, to: end }); + + const isRange = start && end; + const newValues = isRange ? [start, end] : start ? [start] : []; + + actions.setFilterValue(column, newValues); + } + + return ( + + + +
+ +
+
+
+
+ ); +} + +export function FilterValueTextController({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps) { + const changeText = (value: string | number) => { + actions.setFilterValue(column, [String(value)]); + }; + + return ( + + + + + + + + + + ); +} + +export function FilterValueNumberController({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps) { + const minMax = useMemo(() => column.getFacetedMinMaxValues(), [column]); + const [sliderMin, sliderMax] = [minMax ? minMax[0] : 0, minMax ? minMax[1] : 0]; + + // Local state for values + const [values, setValues] = useState(filter?.values ?? [0, 0]); + + // Sync with parent filter changes + useEffect(() => { + if (filter?.values && filter.values.length === values.length && filter.values.every((v, i) => v === values[i])) { + setValues(filter.values); + } + }, [filter?.values, values]); + + const isNumberRange = + // filter && values.length === 2 + filter && numberFilterOperators[filter.operator].target === 'multiple'; + + const setFilterOperatorDebounced = useDebounceCallback(actions.setFilterOperator, 500); + const setFilterValueDebounced = useDebounceCallback(actions.setFilterValue, 500); + + const changeNumber = (value: number[]) => { + setValues(value); + setFilterValueDebounced(column as Column, value); + }; + + const changeMinNumber = (value: number) => { + const newValues = createNumberRange([value, values[1]]); + setValues(newValues); + setFilterValueDebounced(column as Column, newValues); + }; + + const changeMaxNumber = (value: number) => { + const newValues = createNumberRange([values[0], value]); + setValues(newValues); + setFilterValueDebounced(column as Column, newValues); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: from Bazza UI + const changeType = useCallback( + (type: 'single' | 'range') => { + let newValues: number[] = []; + if (type === 'single') + newValues = [values[0]]; // Keep the first value for single mode + else if (minMax) { + const value = values[0]; + newValues = + value - minMax[0] < minMax[1] - value + ? createNumberRange([value, minMax[1]]) + : createNumberRange([minMax[0], value]); + } else newValues = createNumberRange([values[0], values[1] ?? 0]); + + const newOperator = type === 'single' ? 'is' : 'is between'; + + // Update local state + setValues(newValues); + + // Cancel in-flight debounced calls to prevent flicker/race conditions + setFilterOperatorDebounced.cancel(); + setFilterValueDebounced.cancel(); + + // Update global filter state atomically + actions.setFilterOperator(column.id, newOperator); + actions.setFilterValue(column, newValues); + }, + [values, column, actions, minMax], + ); + + return ( + + + +
+ changeType(v as 'single' | 'range')}> + + {t('single', locale)} + {t('range', locale)} + + + {minMax && ( + changeNumber(value)} + min={sliderMin} + max={sliderMax} + step={1} + aria-orientation="horizontal" + /> + )} +
+ {t('value', locale)} + changeNumber([Number(v)])} + /> +
+
+ + {minMax && ( + + )} +
+
+ {t('min', locale)} + changeMinNumber(Number(v))} /> +
+
+ {t('max', locale)} + changeMaxNumber(Number(v))} /> +
+
+
+
+
+
+
+
+ ); +} diff --git a/packages/components/src/ui/data-table-filter/core/filters.ts b/packages/components/src/ui/data-table-filter/core/filters.ts new file mode 100644 index 00000000..d2c2dc47 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/core/filters.ts @@ -0,0 +1,397 @@ +import { isAnyOf, uniq } from '../lib/array'; +import { isColumnOptionArray } from '../lib/helpers'; +import { memo } from '../lib/memo'; +import type { + Column, + ColumnConfig, + ColumnDataType, + ColumnOption, + ElementType, + FilterStrategy, + Nullable, + TAccessorFn, + TOrderFn, + TTransformOptionFn, +} from './types'; + +class ColumnConfigBuilder< + TData, + TType extends ColumnDataType = any, + TVal = unknown, + TId extends string = string, // Add TId generic +> { + private config: Partial>; + + constructor(type: TType) { + this.config = { type } as Partial>; + } + + private clone(): ColumnConfigBuilder { + const newInstance = new ColumnConfigBuilder(this.config.type as TType); + newInstance.config = { ...this.config }; + return newInstance; + } + + id(value: TNewId): ColumnConfigBuilder { + const newInstance = this.clone() as any; // We'll refine this + newInstance.config.id = value; + return newInstance as ColumnConfigBuilder; + } + + accessor(accessor: TAccessorFn): ColumnConfigBuilder { + const newInstance = this.clone() as any; + newInstance.config.accessor = accessor; + return newInstance as ColumnConfigBuilder; + } + + displayName(value: string): ColumnConfigBuilder { + const newInstance = this.clone(); + newInstance.config.displayName = value; + return newInstance; + } + + icon(value: any): ColumnConfigBuilder { + const newInstance = this.clone(); + newInstance.config.icon = value; + return newInstance; + } + + min(value: number): ColumnConfigBuilder { + if (this.config.type !== 'number') { + throw new Error('min() is only applicable to number columns'); + } + const newInstance = this.clone() as any; + newInstance.config.min = value; + return newInstance; + } + + max(value: number): ColumnConfigBuilder { + if (this.config.type !== 'number') { + throw new Error('max() is only applicable to number columns'); + } + const newInstance = this.clone() as any; + newInstance.config.max = value; + return newInstance; + } + + options( + value: ColumnOption[], + ): ColumnConfigBuilder { + if (!isAnyOf(this.config.type, ['option', 'multiOption'])) { + throw new Error('options() is only applicable to option or multiOption columns'); + } + const newInstance = this.clone() as any; + newInstance.config.options = value; + return newInstance; + } + + transformOptionFn( + fn: TTransformOptionFn, + ): ColumnConfigBuilder { + if (!isAnyOf(this.config.type, ['option', 'multiOption'])) { + throw new Error('transformOptionFn() is only applicable to option or multiOption columns'); + } + const newInstance = this.clone() as any; + newInstance.config.transformOptionFn = fn; + return newInstance; + } + + orderFn( + fn: TOrderFn, + ): ColumnConfigBuilder { + if (!isAnyOf(this.config.type, ['option', 'multiOption'])) { + throw new Error('orderFn() is only applicable to option or multiOption columns'); + } + const newInstance = this.clone() as any; + newInstance.config.orderFn = fn; + return newInstance; + } + + build(): ColumnConfig { + if (!this.config.id) throw new Error('id is required'); + if (!this.config.accessor) throw new Error('accessor is required'); + if (!this.config.displayName) throw new Error('displayName is required'); + if (!this.config.icon) throw new Error('icon is required'); + return this.config as ColumnConfig; + } +} + +// Update the helper interface +interface FluentColumnConfigHelper { + text: () => ColumnConfigBuilder; + number: () => ColumnConfigBuilder; + date: () => ColumnConfigBuilder; + option: () => ColumnConfigBuilder; + multiOption: () => ColumnConfigBuilder; +} + +// Factory function remains mostly the same +export function createColumnConfigHelper(): FluentColumnConfigHelper { + return { + text: () => new ColumnConfigBuilder('text'), + number: () => new ColumnConfigBuilder('number'), + date: () => new ColumnConfigBuilder('date'), + option: () => new ColumnConfigBuilder('option'), + multiOption: () => new ColumnConfigBuilder('multiOption'), + }; +} + +export function getColumnOptions( + column: ColumnConfig, + data: TData[], + strategy: FilterStrategy, +): ColumnOption[] { + if (!isAnyOf(column.type, ['option', 'multiOption'])) { + console.warn('Column options can only be retrieved for option and multiOption columns'); + return []; + } + + if (strategy === 'server' && !column.options) { + throw new Error('column options are required for server-side filtering'); + } + + if (column.options) { + return column.options; + } + + const filtered = data.flatMap(column.accessor).filter((v): v is NonNullable => v !== undefined && v !== null); + + let models = uniq(filtered); + + if (column.orderFn) { + models = models.sort((m1, m2) => + column.orderFn!(m1 as ElementType>, m2 as ElementType>), + ); + } + + if (column.transformOptionFn) { + // Memoize transformOptionFn calls + const memoizedTransform = memo( + () => [models], + (deps) => deps[0].map((m) => column.transformOptionFn!(m as ElementType>)), + { key: `transform-${column.id}` }, + ); + return memoizedTransform(); + } + + if (isColumnOptionArray(models)) return models; + + throw new Error( + `[data-table-filter] [${column.id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type`, + ); +} + +export function getColumnValues( + column: ColumnConfig, + data: TData[], +) { + // Memoize accessor calls + const memoizedAccessor = memo( + () => [data], + (deps) => + deps[0] + .flatMap(column.accessor) + .filter((v): v is NonNullable => v !== undefined && v !== null) as ElementType>[], + { key: `accessor-${column.id}` }, + ); + + const raw = memoizedAccessor(); + + if (!isAnyOf(column.type, ['option', 'multiOption'])) { + return raw; + } + + if (column.options) { + return raw + .map((v) => column.options?.find((o) => o.value === v)?.value) + .filter((v) => v !== undefined && v !== null); + } + + if (column.transformOptionFn) { + const memoizedTransform = memo( + () => [raw], + (deps) => deps[0].map((v) => column.transformOptionFn?.(v) as ElementType>), + { key: `transform-values-${column.id}` }, + ); + return memoizedTransform(); + } + + if (isColumnOptionArray(raw)) { + return raw; + } + + throw new Error( + `[data-table-filter] [${column.id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type`, + ); +} + +export function getFacetedUniqueValues( + column: ColumnConfig, + values: string[] | ColumnOption[], + strategy: FilterStrategy, +): Map | undefined { + if (!isAnyOf(column.type, ['option', 'multiOption'])) { + console.warn('Faceted unique values can only be retrieved for option and multiOption columns'); + return new Map(); + } + + if (strategy === 'server') { + return column.facetedOptions; + } + + const acc = new Map(); + + if (isColumnOptionArray(values)) { + for (const option of values) { + const curr = acc.get(option.value) ?? 0; + acc.set(option.value, curr + 1); + } + } else { + for (const option of values) { + const curr = acc.get(option as string) ?? 0; + acc.set(option as string, curr + 1); + } + } + + return acc; +} + +export function getFacetedMinMaxValues( + column: ColumnConfig, + data: TData[], + strategy: FilterStrategy, +): [number, number] | undefined { + if (column.type !== 'number') return undefined; // Only applicable to number columns + + if (typeof column.min === 'number' && typeof column.max === 'number') { + return [column.min, column.max]; + } + + if (strategy === 'server') { + return undefined; + } + + const values = data + .flatMap((row) => column.accessor(row) as Nullable) + .filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)); + + if (values.length === 0) { + return [0, 0]; // Fallback to config or reasonable defaults + } + + const min = Math.min(...values); + const max = Math.max(...values); + + return [min, max]; +} + +export function createColumns( + data: TData[], + columnConfigs: ReadonlyArray>, + strategy: FilterStrategy, +): Column[] { + return columnConfigs.map((columnConfig) => { + const getOptions: () => ColumnOption[] = memo( + () => [data, strategy, columnConfig.options], + ([data, strategy]) => getColumnOptions(columnConfig, data as any, strategy as any), + { key: `options-${columnConfig.id}` }, + ); + + const getValues: () => ElementType>[] = memo( + () => [data, strategy], + () => (strategy === 'client' ? getColumnValues(columnConfig, data) : []), + { key: `values-${columnConfig.id}` }, + ); + + const getUniqueValues: () => Map | undefined = memo( + () => [getValues(), strategy], + ([values, strategy]) => getFacetedUniqueValues(columnConfig, values as any, strategy as any), + { key: `faceted-${columnConfig.id}` }, + ); + + const getMinMaxValues: () => [number, number] | undefined = memo( + () => [data, strategy], + () => getFacetedMinMaxValues(columnConfig, data, strategy), + { key: `minmax-${columnConfig.id}` }, + ); + + // Create the Column instance + const column: Column = { + ...columnConfig, + getOptions, + getValues, + getFacetedUniqueValues: getUniqueValues, + getFacetedMinMaxValues: getMinMaxValues, + // Prefetch methods will be added below + prefetchOptions: async () => {}, // Placeholder, defined below + prefetchValues: async () => {}, + prefetchFacetedUniqueValues: async () => {}, + prefetchFacetedMinMaxValues: async () => {}, + _prefetchedOptionsCache: null, // Initialize private cache + _prefetchedValuesCache: null, + _prefetchedFacetedUniqueValuesCache: null, + _prefetchedFacetedMinMaxValuesCache: null, + }; + + if (strategy === 'client') { + // Define prefetch methods with access to the column instance + column.prefetchOptions = async (): Promise => { + if (!column._prefetchedOptionsCache) { + await new Promise((resolve) => + setTimeout(() => { + const options = getOptions(); + column._prefetchedOptionsCache = options; + // console.log(`Prefetched options for ${columnConfig.id}`) + resolve(undefined); + }, 0), + ); + } + }; + + column.prefetchValues = async (): Promise => { + if (!column._prefetchedValuesCache) { + await new Promise((resolve) => + setTimeout(() => { + const values = getValues(); + column._prefetchedValuesCache = values; + // console.log(`Prefetched values for ${columnConfig.id}`) + resolve(undefined); + }, 0), + ); + } + }; + + column.prefetchFacetedUniqueValues = async (): Promise => { + if (!column._prefetchedFacetedUniqueValuesCache) { + await new Promise((resolve) => + setTimeout(() => { + const facetedMap = getUniqueValues(); + column._prefetchedFacetedUniqueValuesCache = facetedMap ?? null; + // console.log( + // `Prefetched faceted unique values for ${columnConfig.id}`, + // ) + resolve(undefined); + }, 0), + ); + } + }; + + column.prefetchFacetedMinMaxValues = async (): Promise => { + if (!column._prefetchedFacetedMinMaxValuesCache) { + await new Promise((resolve) => + setTimeout(() => { + const value = getMinMaxValues(); + column._prefetchedFacetedMinMaxValuesCache = value ?? null; + // console.log( + // `Prefetched faceted min/max values for ${columnConfig.id}`, + // ) + resolve(undefined); + }, 0), + ); + } + }; + } + + return column; + }); +} diff --git a/packages/components/src/ui/data-table-filter/core/operators.ts b/packages/components/src/ui/data-table-filter/core/operators.ts new file mode 100644 index 00000000..7f5b39a7 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/core/operators.ts @@ -0,0 +1,407 @@ +import { type Locale, t } from '../lib/i18n' +import type { + ColumnDataType, + FilterDetails, + FilterOperatorTarget, + FilterOperators, + FilterTypeOperatorDetails, + FilterValues, +} from './types' + +export const DEFAULT_OPERATORS: Record< + ColumnDataType, + Record +> = { + text: { + single: 'contains', + multiple: 'contains', + }, + number: { + single: 'is', + multiple: 'is between', + }, + date: { + single: 'is', + multiple: 'is between', + }, + option: { + single: 'is', + multiple: 'is any of', + }, + multiOption: { + single: 'include', + multiple: 'include any of', + }, +} + +/* Details for all the filter operators for option data type */ +export const optionFilterOperators = { + is: { + key: 'filters.option.is', + value: 'is', + target: 'single', + singularOf: 'is any of', + relativeOf: 'is not', + isNegated: false, + negation: 'is not', + }, + 'is not': { + key: 'filters.option.isNot', + value: 'is not', + target: 'single', + singularOf: 'is none of', + relativeOf: 'is', + isNegated: true, + negationOf: 'is', + }, + 'is any of': { + key: 'filters.option.isAnyOf', + value: 'is any of', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is none of', + isNegated: false, + negation: 'is none of', + }, + 'is none of': { + key: 'filters.option.isNoneOf', + value: 'is none of', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is any of', + isNegated: true, + negationOf: 'is any of', + }, +} as const satisfies FilterDetails<'option'> + +/* Details for all the filter operators for multi-option data type */ +export const multiOptionFilterOperators = { + include: { + key: 'filters.multiOption.include', + value: 'include', + target: 'single', + singularOf: 'include any of', + relativeOf: 'exclude', + isNegated: false, + negation: 'exclude', + }, + exclude: { + key: 'filters.multiOption.exclude', + value: 'exclude', + target: 'single', + singularOf: 'exclude if any of', + relativeOf: 'include', + isNegated: true, + negationOf: 'include', + }, + 'include any of': { + key: 'filters.multiOption.includeAnyOf', + value: 'include any of', + target: 'multiple', + pluralOf: 'include', + relativeOf: ['exclude if all', 'include all of', 'exclude if any of'], + isNegated: false, + negation: 'exclude if all', + }, + 'exclude if all': { + key: 'filters.multiOption.excludeIfAll', + value: 'exclude if all', + target: 'multiple', + pluralOf: 'exclude', + relativeOf: ['include any of', 'include all of', 'exclude if any of'], + isNegated: true, + negationOf: 'include any of', + }, + 'include all of': { + key: 'filters.multiOption.includeAllOf', + value: 'include all of', + target: 'multiple', + pluralOf: 'include', + relativeOf: ['include any of', 'exclude if all', 'exclude if any of'], + isNegated: false, + negation: 'exclude if any of', + }, + 'exclude if any of': { + key: 'filters.multiOption.excludeIfAnyOf', + value: 'exclude if any of', + target: 'multiple', + pluralOf: 'exclude', + relativeOf: ['include any of', 'exclude if all', 'include all of'], + isNegated: true, + negationOf: 'include all of', + }, +} as const satisfies FilterDetails<'multiOption'> + +/* Details for all the filter operators for date data type */ +export const dateFilterOperators = { + is: { + key: 'filters.date.is', + value: 'is', + target: 'single', + singularOf: 'is between', + relativeOf: 'is after', + isNegated: false, + negation: 'is before', + }, + 'is not': { + key: 'filters.date.isNot', + value: 'is not', + target: 'single', + singularOf: 'is not between', + relativeOf: [ + 'is', + 'is before', + 'is on or after', + 'is after', + 'is on or before', + ], + isNegated: true, + negationOf: 'is', + }, + 'is before': { + key: 'filters.date.isBefore', + value: 'is before', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is on or after', + 'is after', + 'is on or before', + ], + isNegated: false, + negation: 'is on or after', + }, + 'is on or after': { + key: 'filters.date.isOnOrAfter', + value: 'is on or after', + target: 'single', + singularOf: 'is between', + relativeOf: ['is', 'is not', 'is before', 'is after', 'is on or before'], + isNegated: false, + negation: 'is before', + }, + 'is after': { + key: 'filters.date.isAfter', + value: 'is after', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is before', + 'is on or after', + 'is on or before', + ], + isNegated: false, + negation: 'is on or before', + }, + 'is on or before': { + key: 'filters.date.isOnOrBefore', + value: 'is on or before', + target: 'single', + singularOf: 'is between', + relativeOf: ['is', 'is not', 'is after', 'is on or after', 'is before'], + isNegated: false, + negation: 'is after', + }, + 'is between': { + key: 'filters.date.isBetween', + value: 'is between', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is not between', + isNegated: false, + negation: 'is not between', + }, + 'is not between': { + key: 'filters.date.isNotBetween', + value: 'is not between', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is between', + isNegated: true, + negationOf: 'is between', + }, +} as const satisfies FilterDetails<'date'> + +/* Details for all the filter operators for text data type */ +export const textFilterOperators = { + contains: { + key: 'filters.text.contains', + value: 'contains', + target: 'single', + relativeOf: 'does not contain', + isNegated: false, + negation: 'does not contain', + }, + 'does not contain': { + key: 'filters.text.doesNotContain', + value: 'does not contain', + target: 'single', + relativeOf: 'contains', + isNegated: true, + negationOf: 'contains', + }, +} as const satisfies FilterDetails<'text'> + +/* Details for all the filter operators for number data type */ +export const numberFilterOperators = { + is: { + key: 'filters.number.is', + value: 'is', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is not', + 'is greater than', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is not', + }, + 'is not': { + key: 'filters.number.isNot', + value: 'is not', + target: 'single', + singularOf: 'is not between', + relativeOf: [ + 'is', + 'is greater than', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: true, + negationOf: 'is', + }, + 'is greater than': { + key: 'filters.number.greaterThan', + value: 'is greater than', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is less than or equal to', + }, + 'is greater than or equal to': { + key: 'filters.number.greaterThanOrEqual', + value: 'is greater than or equal to', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than or equal to', + 'is less than', + ], + isNegated: false, + negation: 'is less than or equal to', + }, + 'is less than': { + key: 'filters.number.lessThan', + value: 'is less than', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than or equal to', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is greater than', + }, + 'is less than or equal to': { + key: 'filters.number.lessThanOrEqual', + value: 'is less than or equal to', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is greater than or equal to', + }, + 'is between': { + key: 'filters.number.isBetween', + value: 'is between', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is not between', + isNegated: false, + negation: 'is not between', + }, + 'is not between': { + key: 'filters.number.isNotBetween', + value: 'is not between', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is between', + isNegated: true, + negationOf: 'is between', + }, +} as const satisfies FilterDetails<'number'> + +export const filterTypeOperatorDetails: FilterTypeOperatorDetails = { + text: textFilterOperators, + number: numberFilterOperators, + date: dateFilterOperators, + option: optionFilterOperators, + multiOption: multiOptionFilterOperators, +} + +/* + * + * Determines the new operator for a filter based on the current operator, old and new filter values. + * + * This handles cases where the filter values have transitioned from a single value to multiple values (or vice versa), + * and the current operator needs to be transitioned to its plural form (or singular form). + * + * For example, if the current operator is 'is', and the new filter values have a length of 2, the + * new operator would be 'is any of'. + * + */ +export function determineNewOperator( + type: TType, + oldVals: FilterValues, + nextVals: FilterValues, + currentOperator: FilterOperators[TType], +): FilterOperators[TType] { + const a = + Array.isArray(oldVals) && Array.isArray(oldVals[0]) + ? oldVals[0].length + : oldVals.length + const b = + Array.isArray(nextVals) && Array.isArray(nextVals[0]) + ? nextVals[0].length + : nextVals.length + + // If filter size has not transitioned from single to multiple (or vice versa) + // or is unchanged, return the current operator. + if (a === b || (a >= 2 && b >= 2) || (a <= 1 && b <= 1)) + return currentOperator + + const opDetails = filterTypeOperatorDetails[type][currentOperator] + + // Handle transition from single to multiple filter values. + if (a < b && b >= 2) return opDetails.singularOf ?? currentOperator + // Handle transition from multiple to single filter values. + if (a > b && b <= 1) return opDetails.pluralOf ?? currentOperator + return currentOperator +} diff --git a/packages/components/src/ui/data-table-filter/core/types.ts b/packages/components/src/ui/data-table-filter/core/types.ts new file mode 100644 index 00000000..82e9f849 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/core/types.ts @@ -0,0 +1,305 @@ +import type { LucideIcon } from 'lucide-react'; +import type { ReactElement } from 'react'; + +/* + * # GENERAL NOTES: + * + * ## GENERICS: + * + * TData is the shape of a single row in your data table. + * TVal is the shape of the underlying value for a column. + * TType is the type (kind) of the column. + * + */ + +export type ElementType = T extends (infer U)[] ? U : T; + +export type Nullable = T | null | undefined; + +/* + * The model of a column option. + * Used for representing underlying column values of type `option` or `multiOption`. + */ +export interface ColumnOption { + /* The label to display for the option. */ + label: string; + /* The internal value of the option. */ + value: string; + /* An optional icon to display next to the label. */ + // biome-ignore lint/suspicious/noExplicitAny: any for flexibility + icon?: ReactElement | ElementType; +} + +export interface ColumnOptionExtended extends ColumnOption { + selected?: boolean; + count?: number; +} + +/* + * Represents the data type (kind) of a column. + */ +export type ColumnDataType = + /* The column value is a string that should be searchable. */ + | 'text' + | 'number' + | 'date' + /* The column value can be a single value from a list of options. */ + | 'option' + /* The column value can be zero or more values from a list of options. */ + | 'multiOption'; + +/* + * Represents the data type (kind) of option and multi-option columns. + */ +export type OptionBasedColumnDataType = Extract; + +/* + * Maps a ColumnDataType to it's primitive type (i.e. string, number, etc.). + */ +export type ColumnDataNativeMap = { + text: string; + number: number; + date: Date; + option: string; + multiOption: string[]; +}; + +/* + * Represents the value of a column filter. + * Contigent on the filtered column's data type. + */ +export type FilterValues = Array>; + +/* + * An accessor function for a column's data. + * Uses the original row data as an argument. + */ +export type TAccessorFn = (data: TData) => TVal; + +/* + * Used by `option` and `multiOption` columns. + * Transforms the underlying column value into a valid ColumnOption. + */ +export type TTransformOptionFn = (value: ElementType>) => ColumnOption; + +/* + * Used by `option` and `multiOption` columns. + * A custom ordering function when sorting a column's options. + */ +export type TOrderFn = (a: ElementType>, b: ElementType>) => number; + +/* + * The configuration for a column. + */ +export type ColumnConfig = { + id: TId; + accessor: TAccessorFn; + displayName: string; + icon: LucideIcon; + type: TType; + options?: TType extends OptionBasedColumnDataType ? ColumnOption[] : never; + facetedOptions?: TType extends OptionBasedColumnDataType ? Map : never; + min?: TType extends 'number' ? number : never; + max?: TType extends 'number' ? number : never; + transformOptionFn?: TType extends OptionBasedColumnDataType ? TTransformOptionFn : never; + orderFn?: TType extends OptionBasedColumnDataType ? TOrderFn : never; +}; + +export type OptionColumnId = T extends ColumnConfig + ? TId + : never; + +export type OptionColumnIds>> = { + [K in keyof T]: OptionColumnId; +}[number]; + +export type NumberColumnId = T extends ColumnConfig ? TId : never; + +export type NumberColumnIds>> = { + [K in keyof T]: NumberColumnId; +}[number]; + +/* + * Describes a helper function for creating column configurations. + */ +export type ColumnConfigHelper = { + accessor: , TType extends ColumnDataType, TVal extends ReturnType>( + accessor: TAccessor, + config?: Omit, 'accessor'>, + ) => ColumnConfig; +}; + +export type DataTableFilterConfig = { + data: TData[]; + columns: ColumnConfig[]; +}; + +export type ColumnProperties = { + getOptions: () => ColumnOption[]; + getValues: () => ElementType>[]; + getFacetedUniqueValues: () => Map | undefined; + getFacetedMinMaxValues: () => [number, number] | undefined; + prefetchOptions: () => Promise; // Prefetch options + prefetchValues: () => Promise; // Prefetch values + prefetchFacetedUniqueValues: () => Promise; // Prefetch faceted unique values + prefetchFacetedMinMaxValues: () => Promise; // Prefetch faceted min/max values +}; + +export type ColumnPrivateProperties = { + _prefetchedOptionsCache: ColumnOption[] | null; + _prefetchedValuesCache: ElementType>[] | null; + _prefetchedFacetedUniqueValuesCache: Map | null; + _prefetchedFacetedMinMaxValuesCache: [number, number] | null; +}; + +export type Column = ColumnConfig & + ColumnProperties & + ColumnPrivateProperties; + +/* + * Describes the available actions on column filters. + * Includes both column-specific and global actions, ultimately acting on the column filters. + */ +export interface DataTableFilterActions { + addFilterValue: ( + column: Column, + values: FilterModel['values'], + ) => void; + + removeFilterValue: ( + column: Column, + value: FilterModel['values'], + ) => void; + + setFilterValue: ( + column: Column, + values: FilterModel['values'], + ) => void; + + setFilterOperator: (columnId: string, operator: FilterModel['operator']) => void; + + removeFilter: (columnId: string) => void; + + removeAllFilters: () => void; +} + +export type FilterStrategy = 'client' | 'server'; + +/* Operators for text data */ +export type TextFilterOperator = 'contains' | 'does not contain'; + +/* Operators for number data */ +export type NumberFilterOperator = + | 'is' + | 'is not' + | 'is less than' + | 'is greater than or equal to' + | 'is greater than' + | 'is less than or equal to' + | 'is between' + | 'is not between'; + +/* Operators for date data */ +export type DateFilterOperator = + | 'is' + | 'is not' + | 'is before' + | 'is on or after' + | 'is after' + | 'is on or before' + | 'is between' + | 'is not between'; + +/* Operators for option data */ +export type OptionFilterOperator = 'is' | 'is not' | 'is any of' | 'is none of'; + +/* Operators for multi-option data */ +export type MultiOptionFilterOperator = + | 'include' + | 'exclude' + | 'include any of' + | 'include all of' + | 'exclude if any of' + | 'exclude if all'; + +/* Maps filter operators to their respective data types */ +export type FilterOperators = { + text: TextFilterOperator; + number: NumberFilterOperator; + date: DateFilterOperator; + option: OptionFilterOperator; + multiOption: MultiOptionFilterOperator; +}; + +/* + * + * FilterValue is a type that represents a filter value for a specific column. + * + * It consists of: + * - Operator: The operator to be used for the filter. + * - Values: An array of values to be used for the filter. + * + */ +export type FilterModel = { + columnId: string; + type: TType; + operator: FilterOperators[TType]; + values: FilterValues; +}; + +export type FiltersState = Array; + +/* + * FilterDetails is a type that represents the details of all the filter operators for a specific column data type. + */ +export type FilterDetails = { + [key in FilterOperators[T]]: FilterOperatorDetails; +}; + +export type FilterOperatorTarget = 'single' | 'multiple'; + +export type FilterOperatorDetailsBase = { + /* The i18n key for the operator. */ + key: string; + /* The operator value. Usually the string representation of the operator. */ + value: OperatorValue; + /* How much data the operator applies to. */ + target: FilterOperatorTarget; + /* The plural form of the operator, if applicable. */ + singularOf?: FilterOperators[T]; + /* The singular form of the operator, if applicable. */ + pluralOf?: FilterOperators[T]; + /* All related operators. Normally, all the operators which share the same target. */ + relativeOf: FilterOperators[T] | Array; + /* Whether the operator is negated. */ + isNegated: boolean; + /* If the operator is not negated, this provides the negated equivalent. */ + negation?: FilterOperators[T]; + /* If the operator is negated, this provides the positive equivalent. */ + negationOf?: FilterOperators[T]; +}; + +/* + * + * FilterOperatorDetails is a type that provides details about a filter operator for a specific column data type. + * It extends FilterOperatorDetailsBase with additional logic and contraints on the defined properties. + * + */ +export type FilterOperatorDetails = FilterOperatorDetailsBase< + OperatorValue, + T +> & + ( + | { singularOf?: never; pluralOf?: never } + | { target: 'single'; singularOf: FilterOperators[T]; pluralOf?: never } + | { target: 'multiple'; singularOf?: never; pluralOf: FilterOperators[T] } + ) & + ( + | { isNegated: false; negation: FilterOperators[T]; negationOf?: never } + | { isNegated: true; negation?: never; negationOf: FilterOperators[T] } + ); + +/* Maps column data types to their respective filter operator details */ +export type FilterTypeOperatorDetails = { + [key in ColumnDataType]: FilterDetails; +}; diff --git a/packages/components/src/ui/data-table-filter/hooks/use-data-table-filters.tsx b/packages/components/src/ui/data-table-filter/hooks/use-data-table-filters.tsx new file mode 100644 index 00000000..043574c0 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/hooks/use-data-table-filters.tsx @@ -0,0 +1,352 @@ +'use client' + +import type React from 'react' +import { useMemo, useState } from 'react' +import { createColumns } from '../core/filters' +import { DEFAULT_OPERATORS, determineNewOperator } from '../core/operators' +import type { + ColumnConfig, + ColumnDataType, + ColumnOption, + DataTableFilterActions, + FilterModel, + FilterStrategy, + FiltersState, + NumberColumnIds, + OptionBasedColumnDataType, + OptionColumnIds, +} from '../core/types' +import { uniq } from '../lib/array' +import { addUniq, removeUniq } from '../lib/array' +import { + createDateFilterValue, + createNumberFilterValue, + isColumnOptionArray, + isColumnOptionMap, + isMinMaxTuple, +} from '../lib/helpers' + +export interface DataTableFiltersOptions< + TData, + TColumns extends ReadonlyArray>, + TStrategy extends FilterStrategy, +> { + strategy: TStrategy + data: TData[] + columnsConfig: TColumns + defaultFilters?: FiltersState + filters?: FiltersState + onFiltersChange?: React.Dispatch> + options?: Partial< + Record, ColumnOption[] | undefined> + > + faceted?: Partial< + | Record, Map | undefined> + | Record, [number, number] | undefined> + > +} + +export function useDataTableFilters< + TData, + TColumns extends ReadonlyArray>, + TStrategy extends FilterStrategy, +>({ + strategy, + data, + columnsConfig, + defaultFilters, + filters: externalFilters, + onFiltersChange, + options, + faceted, +}: DataTableFiltersOptions) { + const [internalFilters, setInternalFilters] = useState( + defaultFilters ?? [], + ) + + if ( + (externalFilters && !onFiltersChange) || + (!externalFilters && onFiltersChange) + ) { + throw new Error( + 'If using controlled state, you must specify both filters and onFiltersChange.', + ) + } + + const filters = externalFilters ?? internalFilters + const setFilters = onFiltersChange ?? setInternalFilters + + // Convert ColumnConfig to Column, applying options and faceted options if provided + const columns = useMemo(() => { + const enhancedConfigs = columnsConfig.map((config) => { + let final = config + + // Set options, if exists + if ( + options && + (config.type === 'option' || config.type === 'multiOption') + ) { + const optionsInput = options[config.id as OptionColumnIds] + if (!optionsInput || !isColumnOptionArray(optionsInput)) return config + + final = { ...final, options: optionsInput } + } + + // Set faceted options, if exists + if ( + faceted && + (config.type === 'option' || config.type === 'multiOption') + ) { + const facetedOptionsInput = + faceted[config.id as OptionColumnIds] + if (!facetedOptionsInput || !isColumnOptionMap(facetedOptionsInput)) + return config + + final = { ...final, facetedOptions: facetedOptionsInput } + } + + // Set faceted min/max values, if exists + if (config.type === 'number' && faceted) { + const minMaxTuple = faceted[config.id as NumberColumnIds] + if (!minMaxTuple || !isMinMaxTuple(minMaxTuple)) return config + + final = { + ...final, + min: minMaxTuple[0], + max: minMaxTuple[1], + } + } + + return final + }) + + return createColumns(data, enhancedConfigs, strategy) + }, [data, columnsConfig, options, faceted, strategy]) + + const actions: DataTableFilterActions = useMemo( + () => ({ + addFilterValue( + column: ColumnConfig, + values: FilterModel['values'], + ) { + if (column.type === 'option') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id) + const isColumnFiltered = filter && filter.values.length > 0 + if (!isColumnFiltered) { + return [ + ...prev, + { + columnId: column.id, + type: column.type, + operator: + values.length > 1 + ? DEFAULT_OPERATORS[column.type].multiple + : DEFAULT_OPERATORS[column.type].single, + values, + }, + ] + } + const oldValues = filter.values + const newValues = addUniq(filter.values, values) + const newOperator = determineNewOperator( + 'option', + oldValues, + newValues, + filter.operator, + ) + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ) + }) + return + } + if (column.type === 'multiOption') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id) + const isColumnFiltered = filter && filter.values.length > 0 + if (!isColumnFiltered) { + return [ + ...prev, + { + columnId: column.id, + type: column.type, + operator: + values.length > 1 + ? DEFAULT_OPERATORS[column.type].multiple + : DEFAULT_OPERATORS[column.type].single, + values, + }, + ] + } + const oldValues = filter.values + const newValues = addUniq(filter.values, values) + const newOperator = determineNewOperator( + 'multiOption', + oldValues, + newValues, + filter.operator, + ) + if (newValues.length === 0) { + return prev.filter((f) => f.columnId !== column.id) + } + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ) + }) + return + } + throw new Error( + '[data-table-filter] addFilterValue() is only supported for option columns', + ) + }, + removeFilterValue( + column: ColumnConfig, + value: FilterModel['values'], + ) { + if (column.type === 'option') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id) + const isColumnFiltered = filter && filter.values.length > 0 + if (!isColumnFiltered) { + return [...prev] + } + const newValues = removeUniq(filter.values, value) + const oldValues = filter.values + const newOperator = determineNewOperator( + 'option', + oldValues, + newValues, + filter.operator, + ) + if (newValues.length === 0) { + return prev.filter((f) => f.columnId !== column.id) + } + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ) + }) + return + } + if (column.type === 'multiOption') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id) + const isColumnFiltered = filter && filter.values.length > 0 + if (!isColumnFiltered) { + return [...prev] + } + const newValues = removeUniq(filter.values, value) + const oldValues = filter.values + const newOperator = determineNewOperator( + 'multiOption', + oldValues, + newValues, + filter.operator, + ) + if (newValues.length === 0) { + return prev.filter((f) => f.columnId !== column.id) + } + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ) + }) + return + } + throw new Error( + '[data-table-filter] removeFilterValue() is only supported for option columns', + ) + }, + setFilterValue( + column: ColumnConfig, + values: FilterModel['values'], + ) { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id) + const isColumnFiltered = filter && filter.values.length > 0 + const newValues = + column.type === 'number' + ? createNumberFilterValue(values as number[]) + : column.type === 'date' + ? createDateFilterValue( + values as [Date, Date] | [Date] | [] | undefined, + ) + : uniq(values) + if (newValues.length === 0) return prev + if (!isColumnFiltered) { + return [ + ...prev, + { + columnId: column.id, + type: column.type, + operator: + values.length > 1 + ? DEFAULT_OPERATORS[column.type].multiple + : DEFAULT_OPERATORS[column.type].single, + values: newValues, + }, + ] + } + const oldValues = filter.values + const newOperator = determineNewOperator( + column.type, + oldValues, + newValues, + filter.operator, + ) + const newFilter = { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues as any, + } satisfies FilterModel + return prev.map((f) => (f.columnId === column.id ? newFilter : f)) + }) + }, + setFilterOperator( + columnId: string, + operator: FilterModel['operator'], + ) { + setFilters((prev) => + prev.map((f) => (f.columnId === columnId ? { ...f, operator } : f)), + ) + }, + removeFilter(columnId: string) { + setFilters((prev) => prev.filter((f) => f.columnId !== columnId)) + }, + removeAllFilters() { + setFilters([]) + }, + }), + [setFilters], + ) + + return { columns, filters, actions, strategy } // columns is Column[] +} diff --git a/packages/components/src/ui/data-table-filter/hooks/use-debounce-callback.tsx b/packages/components/src/ui/data-table-filter/hooks/use-debounce-callback.tsx new file mode 100644 index 00000000..d3e65b97 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/hooks/use-debounce-callback.tsx @@ -0,0 +1,63 @@ +import { useEffect, useMemo, useRef } from 'react' +import { debounce } from '../lib/debounce' +import { useUnmount } from './use-unmount' + +type DebounceOptions = { + leading?: boolean + trailing?: boolean + maxWait?: number +} + +type ControlFunctions = { + cancel: () => void + flush: () => void + isPending: () => boolean +} + +export type DebouncedState ReturnType> = (( + ...args: Parameters +) => ReturnType | undefined) & + ControlFunctions + +export function useDebounceCallback ReturnType>( + func: T, + delay = 500, + options?: DebounceOptions, +): DebouncedState { + const debouncedFunc = useRef>(null) + + useUnmount(() => { + if (debouncedFunc.current) { + debouncedFunc.current.cancel() + } + }) + + const debounced = useMemo(() => { + const debouncedFuncInstance = debounce(func, delay, options) + + const wrappedFunc: DebouncedState = (...args: Parameters) => { + return debouncedFuncInstance(...args) + } + + wrappedFunc.cancel = () => { + debouncedFuncInstance.cancel() + } + + wrappedFunc.isPending = () => { + return !!debouncedFunc.current + } + + wrappedFunc.flush = () => { + return debouncedFuncInstance.flush() + } + + return wrappedFunc + }, [func, delay, options]) + + // Update the debounced function ref whenever func, wait, or options change + useEffect(() => { + debouncedFunc.current = debounce(func, delay, options) + }, [func, delay, options]) + + return debounced +} diff --git a/packages/components/src/ui/data-table-filter/hooks/use-unmount.tsx b/packages/components/src/ui/data-table-filter/hooks/use-unmount.tsx new file mode 100644 index 00000000..7411d585 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/hooks/use-unmount.tsx @@ -0,0 +1,14 @@ +import { useEffect, useRef } from 'react' + +export function useUnmount(func: () => void) { + const funcRef = useRef(func) + + funcRef.current = func + + useEffect( + () => () => { + funcRef.current() + }, + [], + ) +} diff --git a/packages/components/src/ui/data-table-filter/index.tsx b/packages/components/src/ui/data-table-filter/index.tsx new file mode 100644 index 00000000..38f2e6c3 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/index.tsx @@ -0,0 +1,2 @@ +export { useDataTableFilters } from './hooks/use-data-table-filters' +export { DataTableFilter } from './components/data-table-filter' diff --git a/packages/components/src/ui/data-table-filter/lib/array.ts b/packages/components/src/ui/data-table-filter/lib/array.ts new file mode 100644 index 00000000..0168a7cd --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/array.ts @@ -0,0 +1,144 @@ +export function intersection(a: T[], b: T[]): T[] { + return a.filter((x) => b.includes(x)) +} + +/** + * Computes a stable hash string for any value using deep inspection. + * This function recursively builds a string for primitives, arrays, and objects. + * It uses a cache (WeakMap) to avoid rehashing the same object twice, which is + * particularly beneficial if an object appears in multiple places. + */ +function deepHash(value: any, cache = new WeakMap()): string { + // Handle primitives and null/undefined. + if (value === null) return 'null' + if (value === undefined) return 'undefined' + const type = typeof value + if (type === 'number' || type === 'boolean' || type === 'string') { + return `${type}:${value.toString()}` + } + if (type === 'function') { + // Note: using toString for functions. + return `function:${value.toString()}` + } + + // For objects and arrays, use caching to avoid repeated work. + if (type === 'object') { + // If we’ve seen this object before, return the cached hash. + if (cache.has(value)) { + return cache.get(value)! + } + let hash: string + if (Array.isArray(value)) { + // Compute hash for each element in order. + hash = `array:[${value.map((v) => deepHash(v, cache)).join(',')}]` + } else { + // For objects, sort keys to ensure the representation is stable. + const keys = Object.keys(value).sort() + const props = keys + .map((k) => `${k}:${deepHash(value[k], cache)}`) + .join(',') + hash = `object:{${props}}` + } + cache.set(value, hash) + return hash + } + + // Fallback if no case matched. + return `${type}:${value.toString()}` +} + +/** + * Performs deep equality check for any two values. + * This recursively checks primitives, arrays, and plain objects. + */ +function deepEqual(a: any, b: any): boolean { + // Check strict equality first. + if (a === b) return true + // If types differ, they’re not equal. + if (typeof a !== typeof b) return false + if (a === null || b === null || a === undefined || b === undefined) + return false + + // Check arrays. + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + + // Check objects. + if (typeof a === 'object') { + if (typeof b !== 'object') return false + const aKeys = Object.keys(a).sort() + const bKeys = Object.keys(b).sort() + if (aKeys.length !== bKeys.length) return false + for (let i = 0; i < aKeys.length; i++) { + if (aKeys[i] !== bKeys[i]) return false + if (!deepEqual(a[aKeys[i]], b[bKeys[i]])) return false + } + return true + } + + // For any other types (should be primitives by now), use strict equality. + return false +} + +/** + * Returns a new array containing only the unique values from the input array. + * Uniqueness is determined by deep equality. + * + * @param arr - The array of values to be filtered. + * @returns A new array with duplicates removed. + */ +export function uniq(arr: T[]): T[] { + // Use a Map where key is the deep hash and value is an array of items sharing the same hash. + const seen = new Map() + const result: T[] = [] + + for (const item of arr) { + const hash = deepHash(item) + if (seen.has(hash)) { + // There is a potential duplicate; check the stored items with the same hash. + const itemsWithHash = seen.get(hash)! + let duplicateFound = false + for (const existing of itemsWithHash) { + if (deepEqual(existing, item)) { + duplicateFound = true + break + } + } + if (!duplicateFound) { + itemsWithHash.push(item) + result.push(item) + } + } else { + // First time this hash appears. + seen.set(hash, [item]) + result.push(item) + } + } + + return result +} + +export function take(a: T[], n: number): T[] { + return a.slice(0, n) +} + +export function flatten(a: T[][]): T[] { + return a.flat() +} + +export function addUniq(arr: T[], values: T[]): T[] { + return uniq([...arr, ...values]) +} + +export function removeUniq(arr: T[], values: T[]): T[] { + return arr.filter((v) => !values.includes(v)) +} + +export function isAnyOf(value: T, values: T[]): boolean { + return values.includes(value) +} diff --git a/packages/components/src/ui/data-table-filter/lib/debounce.ts b/packages/components/src/ui/data-table-filter/lib/debounce.ts new file mode 100644 index 00000000..4b6252a7 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/debounce.ts @@ -0,0 +1,74 @@ +/** + * Debounce function for handling user input + * @param fn Function to debounce + * @param delay Delay in milliseconds + * @returns Debounced function + */ +export function debounce any>( + fn: T, + delay: number +): (...args: Parameters) => void { + /** + * Timeout ID for the debounced function + * Using ReturnType instead of NodeJS.Timeout for better compatibility + */ + let timeout: ReturnType | null = null + + /** + * Debounced function + * @param args Arguments to pass to the original function + */ + return function (this: any, ...args: Parameters): void { + const context = this + + if (timeout) { + clearTimeout(timeout) + } + + timeout = setTimeout(() => { + fn.apply(context, args) + timeout = null + }, delay) + } +} + +/** + * Debounce function that returns a promise + * @param fn Function to debounce + * @param delay Delay in milliseconds + * @returns Debounced function that returns a promise + */ +export function debouncePromise Promise>( + fn: T, + delay: number +): (...args: Parameters) => Promise> { + /** + * Timeout ID for the debounced function + * Using ReturnType instead of NodeJS.Timeout for better compatibility + */ + let timeout: ReturnType | null = null + + /** + * Debounced function that returns a promise + * @param args Arguments to pass to the original function + * @returns Promise that resolves with the result of the original function + */ + return function ( + this: any, + ...args: Parameters + ): ReturnType { + const context = this + + return new Promise((resolve) => { + if (timeout) { + clearTimeout(timeout) + } + + timeout = setTimeout(() => { + resolve(fn.apply(context, args)) + timeout = null + }, delay) + }) as unknown as ReturnType + } +} + diff --git a/packages/components/src/ui/data-table-filter/lib/filter-fns.ts b/packages/components/src/ui/data-table-filter/lib/filter-fns.ts new file mode 100644 index 00000000..2b030522 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/filter-fns.ts @@ -0,0 +1,175 @@ +import { + endOfDay, + isAfter, + isBefore, + isSameDay, + isWithinInterval, + startOfDay, +} from 'date-fns' +import { dateFilterOperators } from '../core/operators' +import type { FilterModel } from '../core/types' +import { intersection } from './array' + +export function optionFilterFn( + inputData: string, + filterValue: FilterModel<'option'>, +) { + if (!inputData) return false + if (filterValue.values.length === 0) return true + + const value = inputData.toString().toLowerCase() + + const found = !!filterValue.values.find((v) => v.toLowerCase() === value) + + switch (filterValue.operator) { + case 'is': + case 'is any of': + return found + case 'is not': + case 'is none of': + return !found + } +} + +export function multiOptionFilterFn( + inputData: string[], + filterValue: FilterModel<'multiOption'>, +) { + if (!inputData) return false + + if ( + filterValue.values.length === 0 || + !filterValue.values[0] || + filterValue.values[0].length === 0 + ) + return true + + const values = inputData + const filterValues = filterValue.values + + switch (filterValue.operator) { + case 'include': + case 'include any of': + return intersection(values, filterValues).length > 0 + case 'exclude': + return intersection(values, filterValues).length === 0 + case 'exclude if any of': + return !(intersection(values, filterValues).length > 0) + case 'include all of': + return intersection(values, filterValues).length === filterValues.length + case 'exclude if all': + return !( + intersection(values, filterValues).length === filterValues.length + ) + } +} + +export function dateFilterFn( + inputData: Date, + filterValue: FilterModel<'date'>, +) { + if (!filterValue || filterValue.values.length === 0) return true + + if ( + dateFilterOperators[filterValue.operator].target === 'single' && + filterValue.values.length > 1 + ) + throw new Error('Singular operators require at most one filter value') + + if ( + filterValue.operator in ['is between', 'is not between'] && + filterValue.values.length !== 2 + ) + throw new Error('Plural operators require two filter values') + + const filterVals = filterValue.values + const d1 = filterVals[0] + const d2 = filterVals[1] + + const value = inputData + + switch (filterValue.operator) { + case 'is': + return isSameDay(value, d1) + case 'is not': + return !isSameDay(value, d1) + case 'is before': + return isBefore(value, startOfDay(d1)) + case 'is on or after': + return isSameDay(value, d1) || isAfter(value, startOfDay(d1)) + case 'is after': + return isAfter(value, startOfDay(d1)) + case 'is on or before': + return isSameDay(value, d1) || isBefore(value, startOfDay(d1)) + case 'is between': + return isWithinInterval(value, { + start: startOfDay(d1), + end: endOfDay(d2), + }) + case 'is not between': + return !isWithinInterval(value, { + start: startOfDay(filterValue.values[0]), + end: endOfDay(filterValue.values[1]), + }) + } +} + +export function textFilterFn( + inputData: string, + filterValue: FilterModel<'text'>, +) { + if (!filterValue || filterValue.values.length === 0) return true + + const value = inputData.toLowerCase().trim() + const filterStr = filterValue.values[0].toLowerCase().trim() + + if (filterStr === '') return true + + const found = value.includes(filterStr) + + switch (filterValue.operator) { + case 'contains': + return found + case 'does not contain': + return !found + } +} + +export function numberFilterFn( + inputData: number, + filterValue: FilterModel<'number'>, +) { + if (!filterValue || !filterValue.values || filterValue.values.length === 0) { + return true + } + + const value = inputData + const filterVal = filterValue.values[0] + + switch (filterValue.operator) { + case 'is': + return value === filterVal + case 'is not': + return value !== filterVal + case 'is greater than': + return value > filterVal + case 'is greater than or equal to': + return value >= filterVal + case 'is less than': + return value < filterVal + case 'is less than or equal to': + return value <= filterVal + case 'is between': { + const lowerBound = filterValue.values[0] + const upperBound = filterValue.values[1] + return value >= lowerBound && value <= upperBound + } + case 'is not between': { + const lowerBound = filterValue.values[0] + const upperBound = filterValue.values[1] + return value < lowerBound || value > upperBound + } + default: + return true + } +} diff --git a/packages/components/src/ui/data-table-filter/lib/helpers.ts b/packages/components/src/ui/data-table-filter/lib/helpers.ts new file mode 100644 index 00000000..0d3c8384 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/helpers.ts @@ -0,0 +1,99 @@ +import { isBefore } from 'date-fns' +import type { Column, ColumnOption } from '../core/types' + +export function getColumn(columns: Column[], id: string) { + const column = columns.find((c) => c.id === id) + + if (!column) { + throw new Error(`Column with id ${id} not found`) + } + + return column +} + +export function createNumberFilterValue( + values: number[] | undefined, +): number[] { + if (!values || values.length === 0) return [] + if (values.length === 1) return [values[0]] + if (values.length === 2) return createNumberRange(values) + return [values[0], values[1]] +} + +export function createDateFilterValue( + values: [Date, Date] | [Date] | [] | undefined, +) { + if (!values || values.length === 0) return [] + if (values.length === 1) return [values[0]] + if (values.length === 2) return createDateRange(values) + throw new Error('Cannot create date filter value from more than 2 values') +} + +export function createDateRange(values: [Date, Date]) { + const [a, b] = values + const [min, max] = isBefore(a, b) ? [a, b] : [b, a] + + return [min, max] +} + +export function createNumberRange(values: number[] | undefined) { + let a = 0 + let b = 0 + + if (!values || values.length === 0) return [a, b] + if (values.length === 1) { + a = values[0] + } else { + a = values[0] + b = values[1] + } + + const [min, max] = a < b ? [a, b] : [b, a] + + return [min, max] +} + +export function isColumnOption(value: unknown): value is ColumnOption { + return ( + typeof value === 'object' && + value !== null && + 'value' in value && + 'label' in value + ) +} + +export function isColumnOptionArray(value: unknown): value is ColumnOption[] { + return Array.isArray(value) && value.every(isColumnOption) +} + +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === 'string') +} + +export function isColumnOptionMap( + value: unknown, +): value is Map { + if (!(value instanceof Map)) { + return false + } + for (const key of value.keys()) { + if (typeof key !== 'string') { + return false + } + } + for (const val of value.values()) { + if (typeof val !== 'number') { + return false + } + } + return true +} + +export function isMinMaxTuple(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' + ) +} diff --git a/packages/components/src/ui/data-table-filter/lib/i18n.ts b/packages/components/src/ui/data-table-filter/lib/i18n.ts new file mode 100644 index 00000000..75c9988e --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/i18n.ts @@ -0,0 +1,13 @@ +import en from '../locales/en.json' + +export type Locale = 'en' + +type Translations = Record + +const translations: Record = { + en, +} + +export function t(key: string, locale: Locale): string { + return translations[locale][key] ?? key +} diff --git a/packages/components/src/ui/data-table-filter/lib/memo.ts b/packages/components/src/ui/data-table-filter/lib/memo.ts new file mode 100644 index 00000000..528c371f --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/memo.ts @@ -0,0 +1,35 @@ +export function memo( + getDeps: () => TDeps, + compute: (deps: TDeps) => TResult, + options: { key: string }, +): () => TResult { + let prevDeps: TDeps | undefined + let cachedResult: TResult | undefined + + return () => { + // console.log(`[memo] Calling memoized function: ${options.key}`) + + const deps = getDeps() + + // If no previous deps or deps have changed, recompute + if (!prevDeps || !shallowEqual(prevDeps, deps)) { + // console.log(`[memo] Cache MISS - ${options.key}`) + cachedResult = compute(deps) + prevDeps = deps + } else { + // console.log(`[memo] Cache HIT - ${options.key}`) + } + + return cachedResult! + } +} + +function shallowEqual(arr1: readonly T[], arr2: readonly T[]): boolean { + if (arr1 === arr2) return true + if (arr1.length !== arr2.length) return false + + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) return false + } + return true +} diff --git a/packages/components/src/ui/data-table-filter/locales/en.json b/packages/components/src/ui/data-table-filter/locales/en.json new file mode 100644 index 00000000..695c2910 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/locales/en.json @@ -0,0 +1,42 @@ +{ + "clear": "Clear", + "search": "Search...", + "noresults": "No results.", + "operators": "Operators", + "filter": "Filter", + "and": "and", + "single": "Single", + "range": "Range", + "value": "Value", + "min": "Min", + "max": "Max", + "filters.option.is": "is", + "filters.option.isNot": "is not", + "filters.option.isAnyOf": "is any of", + "filters.option.isNoneOf": "is none of", + "filters.multiOption.include": "includes", + "filters.multiOption.exclude": "excludes", + "filters.multiOption.includeAnyOf": "includes any of", + "filters.multiOption.excludeAllOf": "excludes all of", + "filters.multiOption.includeAllOf": "includes all of", + "filters.multiOption.excludeIfAnyOf": "excludes if any of", + "filters.multiOption.excludeIfAll": "excludes if all of", + "filters.date.is": "is", + "filters.date.isNot": "is not", + "filters.date.isBefore": "is before", + "filters.date.isOnOrAfter": "is on or after", + "filters.date.isAfter": "is after", + "filters.date.isOnOrBefore": "is on or before", + "filters.date.isBetween": "is between", + "filters.date.isNotBetween": "is not between", + "filters.text.contains": "contains", + "filters.text.doesNotContain": "does not contain", + "filters.number.is": "is", + "filters.number.isNot": "is not", + "filters.number.greaterThan": "greater than", + "filters.number.greaterThanOrEqual": "greater than or equal", + "filters.number.lessThan": "less than", + "filters.number.lessThanOrEqual": "less than or equal", + "filters.number.isBetween": "is between", + "filters.number.isNotBetween": "is not between" +} diff --git a/packages/components/src/ui/debounced-input.tsx b/packages/components/src/ui/debounced-input.tsx new file mode 100644 index 00000000..23aae73d --- /dev/null +++ b/packages/components/src/ui/debounced-input.tsx @@ -0,0 +1,38 @@ +import { type ChangeEvent, type InputHTMLAttributes, useCallback, useEffect, useState } from 'react'; +import { debounce } from './data-table-filter/lib/debounce'; +import { TextInput } from './text-input'; + +export function DebouncedInput({ + value: initialValue, + onChange, + debounceMs = 500, // This is the wait time, not the function + ...props +}: { + value: string | number; + onChange: (value: string | number) => void; + debounceMs?: number; +} & Omit, 'onChange'>) { + const [value, setValue] = useState(initialValue); + + // Sync with initialValue when it changes + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + // Define the debounced function with useCallback + // biome-ignore lint/correctness/useExhaustiveDependencies: from Bazza UI + const debouncedOnChange = useCallback( + debounce((newValue: string | number) => { + onChange(newValue); + }, debounceMs), // Pass the wait time here + [debounceMs, onChange], // Dependencies + ); + + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value; + setValue(newValue); // Update local state immediately + debouncedOnChange(newValue); // Call debounced version + }; + + return ; +} diff --git a/packages/components/src/ui/dialog.tsx b/packages/components/src/ui/dialog.tsx new file mode 100644 index 00000000..d89678fd --- /dev/null +++ b/packages/components/src/ui/dialog.tsx @@ -0,0 +1,109 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { XIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '../ui'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ className, children, ...props }: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/packages/components/src/ui/popover.tsx b/packages/components/src/ui/popover.tsx index 3f0c1087..c514166d 100644 --- a/packages/components/src/ui/popover.tsx +++ b/packages/components/src/ui/popover.tsx @@ -1,6 +1,4 @@ -// biome-ignore lint/style/noNamespaceImport: from Radix import * as PopoverPrimitive from '@radix-ui/react-popover'; -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library import type * as React from 'react'; import { cn } from './utils'; @@ -8,6 +6,8 @@ const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + function PopoverContent({ className, align = 'center', @@ -30,4 +30,4 @@ function PopoverContent({ ); } -export { Popover, PopoverTrigger, PopoverContent }; +export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }; diff --git a/packages/components/src/ui/slider.tsx b/packages/components/src/ui/slider.tsx new file mode 100644 index 00000000..1e01328a --- /dev/null +++ b/packages/components/src/ui/slider.tsx @@ -0,0 +1,57 @@ +'use client'; + +import * as SliderPrimitive from '@radix-ui/react-slider'; +import * as React from 'react'; + +import { cn } from '../ui/'; + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), + [value, defaultValue, min, max], + ); + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ); +} + +export { Slider }; diff --git a/packages/components/src/ui/tabs.tsx b/packages/components/src/ui/tabs.tsx new file mode 100644 index 00000000..a41d20dd --- /dev/null +++ b/packages/components/src/ui/tabs.tsx @@ -0,0 +1,42 @@ +'use client'; + +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import type * as React from 'react'; + +import { cn } from '../ui'; + +function Tabs({ className, ...props }: React.ComponentProps) { + return ; +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ; +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/components/src/ui/utils/debounce.ts b/packages/components/src/ui/utils/debounce.ts index 815e3dec..a56e42a3 100644 --- a/packages/components/src/ui/utils/debounce.ts +++ b/packages/components/src/ui/utils/debounce.ts @@ -1,27 +1,25 @@ /** * 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. - * + * * @param func The function to debounce * @param wait The number of milliseconds to delay * @returns A debounced version of the provided function */ -export function debounce any>( - func: T, - wait: number -): (...args: Parameters) => void { +// biome-ignore lint/suspicious/noExplicitAny: any for flexibility +export function debounce any>(func: T, wait: number): (...args: Parameters) => void { let timeout: ReturnType | null = null; - - return function(...args: Parameters): void { + + return (...args: Parameters): void => { const later = () => { timeout = null; func(...args); }; - + if (timeout !== null) { clearTimeout(timeout); } - + timeout = setTimeout(later, wait); }; -} \ No newline at end of file +} diff --git a/packages/components/src/ui/utils/filters.ts b/packages/components/src/ui/utils/filters.ts new file mode 100644 index 00000000..89e615d1 --- /dev/null +++ b/packages/components/src/ui/utils/filters.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +// Define the shape of a single filter item +export const filterItemSchema = z.object({ + columnId: z.string(), + type: z.string(), + operator: z.string(), + values: z.array(z.unknown()), +}); + +// Define the shape of the filters array +export const filtersArraySchema = z.array(filterItemSchema); + +// Export the type for the filters state +export type FiltersState = z.infer; + diff --git a/packages/components/src/ui/utils/index.ts b/packages/components/src/ui/utils/index.ts index 9ad0df42..0c8b6801 100644 --- a/packages/components/src/ui/utils/index.ts +++ b/packages/components/src/ui/utils/index.ts @@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export * from './debounce'; +export * from './filters'; +export * from './use-filter-sync'; diff --git a/packages/components/src/ui/utils/use-data-table-filters.ts b/packages/components/src/ui/utils/use-data-table-filters.ts new file mode 100644 index 00000000..8780d0cf --- /dev/null +++ b/packages/components/src/ui/utils/use-data-table-filters.ts @@ -0,0 +1,4 @@ +// Re-export the hook from its actual location +export { useDataTableFilters } from '../data-table-filter/hooks/use-data-table-filters' +export type { DataTableFiltersOptions } from '../data-table-filter/hooks/use-data-table-filters' + diff --git a/packages/components/src/ui/utils/use-filter-sync.ts b/packages/components/src/ui/utils/use-filter-sync.ts new file mode 100644 index 00000000..f338ff63 --- /dev/null +++ b/packages/components/src/ui/utils/use-filter-sync.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { BazzaFiltersState } from '../../remix-hook-form/data-table-router-parsers'; +import { dataTableRouterParsers } from '../../remix-hook-form/data-table-router-parsers'; + +/** + * Custom hook for synchronizing filter state with URL parameters + * + * This hook provides a clean interface for working with filter state in data table components, + * automatically syncing the state with URL search parameters. + * + * @returns A tuple containing the current filter state and a function to update it + */ +export function useFilterSync(): [BazzaFiltersState, (newFilters: BazzaFiltersState | ((prev: BazzaFiltersState) => BazzaFiltersState)) => void] { + const [searchParams, setSearchParams] = useSearchParams(); + + // Parse filters from URL + const filtersFromUrl = dataTableRouterParsers.filters.parse(searchParams.get('filters')); + + // Function to update filters in URL + const setFilters = useCallback((newFilters: BazzaFiltersState | ((prev: BazzaFiltersState) => BazzaFiltersState)) => { + // Handle functional updates by resolving the function with current filters + const resolvedFilters = typeof newFilters === 'function' + ? newFilters(filtersFromUrl) + : newFilters; + + const newParams = new URLSearchParams(searchParams); + + // Update or remove the filters parameter + if (resolvedFilters.length > 0) { + const serialized = dataTableRouterParsers.filters.serialize(resolvedFilters); + if (serialized !== null) { + newParams.set('filters', serialized); + } + } else { + newParams.delete('filters'); + } + + // Update the URL with the new search parameters + setSearchParams(newParams, { replace: true }); + }, [searchParams, setSearchParams, filtersFromUrl]); + + return [filtersFromUrl, setFilters]; +} diff --git a/yarn.lock b/yarn.lock index bd60f85e..54072c4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1693,17 +1693,19 @@ __metadata: "@hookform/resolvers": "npm:^3.9.1" "@radix-ui/react-alert-dialog": "npm:^1.1.4" "@radix-ui/react-avatar": "npm:^1.1.2" - "@radix-ui/react-checkbox": "npm:^1.1.3" - "@radix-ui/react-dialog": "npm:^1.1.4" - "@radix-ui/react-dropdown-menu": "npm:^2.1.4" + "@radix-ui/react-checkbox": "npm:^1.3.1" + "@radix-ui/react-dialog": "npm:^1.1.13" + "@radix-ui/react-dropdown-menu": "npm:^2.1.14" "@radix-ui/react-icons": "npm:^1.3.2" - "@radix-ui/react-label": "npm:^2.1.1" - "@radix-ui/react-popover": "npm:^1.1.4" + "@radix-ui/react-label": "npm:^2.1.6" + "@radix-ui/react-popover": "npm:^1.1.13" "@radix-ui/react-radio-group": "npm:^1.2.2" "@radix-ui/react-scroll-area": "npm:^1.2.2" - "@radix-ui/react-separator": "npm:^1.1.2" - "@radix-ui/react-slot": "npm:^1.1.2" + "@radix-ui/react-separator": "npm:^1.1.6" + "@radix-ui/react-slider": "npm:^1.3.4" + "@radix-ui/react-slot": "npm:^1.2.2" "@radix-ui/react-switch": "npm:^1.1.2" + "@radix-ui/react-tabs": "npm:^1.1.11" "@radix-ui/react-tooltip": "npm:^1.1.6" "@react-router/dev": "npm:^7.0.0" "@react-router/node": "npm:^7.0.0" @@ -2005,6 +2007,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-arrow@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-arrow@npm:1.1.6" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/7a17b719d38e9013dc9e7eafd24786d3bc890d84fa5f092a567d014429a26d3c10777ae41db6dc080980d9f8b3bad2d625ce6e0a370cf533da59607d97e45757 + languageName: node + linkType: hard + "@radix-ui/react-avatar@npm:^1.1.2": version: 1.1.7 resolution: "@radix-ui/react-avatar@npm:1.1.7" @@ -2028,15 +2049,15 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-checkbox@npm:^1.1.3": - version: 1.2.3 - resolution: "@radix-ui/react-checkbox@npm:1.2.3" +"@radix-ui/react-checkbox@npm:^1.3.1": + version: 1.3.1 + resolution: "@radix-ui/react-checkbox@npm:1.3.1" dependencies: "@radix-ui/primitive": "npm:1.1.2" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" "@radix-ui/react-presence": "npm:1.1.4" - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-primitive": "npm:2.1.2" "@radix-ui/react-use-controllable-state": "npm:1.2.2" "@radix-ui/react-use-previous": "npm:1.1.1" "@radix-ui/react-use-size": "npm:1.1.1" @@ -2050,7 +2071,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/bd589957e56da325b73199e4adeae11271ddbb21f9f88b98b8e0870254d746091f7b5c97c41bb03b188ddbf08300c69118ddbe250869bebe35b8eaa20a14f0c2 + checksum: 10c0/e2360ce7c0d894f196e0a4aaa7fe7482a5f1ca035e62d5bee364c6828d30a6024c3de4ea0ed77067489ccab1a583d09f23211ddeda78fa092e09a2026035b86c languageName: node linkType: hard @@ -2076,6 +2097,28 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-collection@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-collection@npm:1.1.6" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-slot": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/eb3faf1cdc55d0dca7bc0567254a5f4c0ee271a836a1d89a68f36950f12bbd10260b039722c46af7449a8282d833d5afcd6b7745da27be72662ffb0d4108211c + languageName: node + linkType: hard + "@radix-ui/react-compose-refs@npm:1.1.2, @radix-ui/react-compose-refs@npm:^1.1.1": version: 1.1.2 resolution: "@radix-ui/react-compose-refs@npm:1.1.2" @@ -2102,7 +2145,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dialog@npm:1.1.11, @radix-ui/react-dialog@npm:^1.1.4, @radix-ui/react-dialog@npm:^1.1.6": +"@radix-ui/react-dialog@npm:1.1.11, @radix-ui/react-dialog@npm:^1.1.6": version: 1.1.11 resolution: "@radix-ui/react-dialog@npm:1.1.11" dependencies: @@ -2134,6 +2177,38 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dialog@npm:^1.1.13": + version: 1.1.13 + resolution: "@radix-ui/react-dialog@npm:1.1.13" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.9" + "@radix-ui/react-focus-guards": "npm:1.1.2" + "@radix-ui/react-focus-scope": "npm:1.1.6" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-portal": "npm:1.1.8" + "@radix-ui/react-presence": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-slot": "npm:1.2.2" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/7a5c2ca98eb5a4de8028e4f284790d2db470af6a814a6cb7bd20a6841c1ab8ec98f3a089e952cff9ed7c83be8cad4f99143bd1f037712dba080baac9013baadb + languageName: node + linkType: hard + "@radix-ui/react-direction@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-direction@npm:1.1.1" @@ -2170,16 +2245,39 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dropdown-menu@npm:^2.1.4": - version: 2.1.12 - resolution: "@radix-ui/react-dropdown-menu@npm:2.1.12" +"@radix-ui/react-dismissable-layer@npm:1.1.9": + version: 1.1.9 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.9" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-escape-keydown": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/945332ce097e86ac6904b12012ac9c4bb8b539688752f43c25de911fce2dd68b4f0a45b31df6eb8d038246ec0be897af988fef90ad9e12db126e93736dfa8b76 + languageName: node + linkType: hard + +"@radix-ui/react-dropdown-menu@npm:^2.1.14": + version: 2.1.14 + resolution: "@radix-ui/react-dropdown-menu@npm:2.1.14" dependencies: "@radix-ui/primitive": "npm:1.1.2" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" "@radix-ui/react-id": "npm:1.1.1" - "@radix-ui/react-menu": "npm:2.1.12" - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-menu": "npm:2.1.14" + "@radix-ui/react-primitive": "npm:2.1.2" "@radix-ui/react-use-controllable-state": "npm:1.2.2" peerDependencies: "@types/react": "*" @@ -2191,7 +2289,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/1a02ff19d580672c815d4e682211be42fe86aad4fb7ca44d4d093232f2436e9e80a127a6ead7f469655bf36d68b3dfa915c45dbb833f783dfe7fcc0502ac05d0 + checksum: 10c0/c590fff74c2ac1022abc3b6e87fed922d34db7513488119a212eb23a1ae8951d2be45f0124dde7b092f038e452f890d0d279b9da24311aebdb652e650dfce074 languageName: node linkType: hard @@ -2229,6 +2327,27 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-scope@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-focus-scope@npm:1.1.6" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/c4a3d12e2c45908113e3a2b9bd59666c2bcc40bde611133a5d67c8d248ddd7bfdfee66c7150dceb1acc6b894ebd44da1b08fab116bbf00fb7bb047be1ec0ec8d + languageName: node + linkType: hard + "@radix-ui/react-icons@npm:^1.3.2": version: 1.3.2 resolution: "@radix-ui/react-icons@npm:1.3.2" @@ -2253,11 +2372,11 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-label@npm:^2.1.1": - version: 2.1.4 - resolution: "@radix-ui/react-label@npm:2.1.4" +"@radix-ui/react-label@npm:^2.1.6": + version: 2.1.6 + resolution: "@radix-ui/react-label@npm:2.1.6" dependencies: - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-primitive": "npm:2.1.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2268,29 +2387,29 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/700f5907492c16718e8bd8cf7d05fb9b5797f0d6b6a3fe9783d63e1d0e50320263f9107af415ca105b165d4245b6489f965902b53f8cc82288fa19c18f8b23c6 + checksum: 10c0/1c94bd363b965aeeb6010539399da4bb894c29bcb777d11f6e9a0ab22c43621be59529f1a23cfbda1f3c0ba3d8a6fdd2a50200b6e9b5839a3fbf0c2299de163e languageName: node linkType: hard -"@radix-ui/react-menu@npm:2.1.12": - version: 2.1.12 - resolution: "@radix-ui/react-menu@npm:2.1.12" +"@radix-ui/react-menu@npm:2.1.14": + version: 2.1.14 + resolution: "@radix-ui/react-menu@npm:2.1.14" dependencies: "@radix-ui/primitive": "npm:1.1.2" - "@radix-ui/react-collection": "npm:1.1.4" + "@radix-ui/react-collection": "npm:1.1.6" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" "@radix-ui/react-direction": "npm:1.1.1" - "@radix-ui/react-dismissable-layer": "npm:1.1.7" + "@radix-ui/react-dismissable-layer": "npm:1.1.9" "@radix-ui/react-focus-guards": "npm:1.1.2" - "@radix-ui/react-focus-scope": "npm:1.1.4" + "@radix-ui/react-focus-scope": "npm:1.1.6" "@radix-ui/react-id": "npm:1.1.1" - "@radix-ui/react-popper": "npm:1.2.4" - "@radix-ui/react-portal": "npm:1.1.6" + "@radix-ui/react-popper": "npm:1.2.6" + "@radix-ui/react-portal": "npm:1.1.8" "@radix-ui/react-presence": "npm:1.1.4" - "@radix-ui/react-primitive": "npm:2.1.0" - "@radix-ui/react-roving-focus": "npm:1.1.7" - "@radix-ui/react-slot": "npm:1.2.0" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-roving-focus": "npm:1.1.9" + "@radix-ui/react-slot": "npm:1.2.2" "@radix-ui/react-use-callback-ref": "npm:1.1.1" aria-hidden: "npm:^1.2.4" react-remove-scroll: "npm:^2.6.3" @@ -2304,26 +2423,26 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/fad42d6b999954b655878c78ea401e7f06d36d22f0213cd9f66e91bca31c8891447ca66021a5a7bce36f45dfa4100aaa3e8be74715338849dd9cae3c000d2546 + checksum: 10c0/88d5fd986b8d56ce587109507d272f726fd6f45c42886559c1240e868890fc91e3f15e889b69a5db23851c6b576f606cb80640ada6b728261073ee6914fcb422 languageName: node linkType: hard -"@radix-ui/react-popover@npm:^1.1.4": - version: 1.1.11 - resolution: "@radix-ui/react-popover@npm:1.1.11" +"@radix-ui/react-popover@npm:^1.1.13": + version: 1.1.13 + resolution: "@radix-ui/react-popover@npm:1.1.13" dependencies: "@radix-ui/primitive": "npm:1.1.2" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" - "@radix-ui/react-dismissable-layer": "npm:1.1.7" + "@radix-ui/react-dismissable-layer": "npm:1.1.9" "@radix-ui/react-focus-guards": "npm:1.1.2" - "@radix-ui/react-focus-scope": "npm:1.1.4" + "@radix-ui/react-focus-scope": "npm:1.1.6" "@radix-ui/react-id": "npm:1.1.1" - "@radix-ui/react-popper": "npm:1.2.4" - "@radix-ui/react-portal": "npm:1.1.6" + "@radix-ui/react-popper": "npm:1.2.6" + "@radix-ui/react-portal": "npm:1.1.8" "@radix-ui/react-presence": "npm:1.1.4" - "@radix-ui/react-primitive": "npm:2.1.0" - "@radix-ui/react-slot": "npm:1.2.0" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-slot": "npm:1.2.2" "@radix-ui/react-use-controllable-state": "npm:1.2.2" aria-hidden: "npm:^1.2.4" react-remove-scroll: "npm:^2.6.3" @@ -2337,7 +2456,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/0d15550d9127726b5a815ce04e51e3ccdf80f696e484d0b4a1683e6349600c4d62369759d612f429866d37e314b6fec41a0eebdc1c66ffd894affe63bee95a72 + checksum: 10c0/22a0ab372a741b2db9d48e5251104a2c48f6d246c107409a11c7c237dc68a3850d1e7fb626fac79c008958b8067a8c9428e3d0bf8c58b88977494a1c0106a5e3 languageName: node linkType: hard @@ -2369,6 +2488,34 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-popper@npm:1.2.6": + version: 1.2.6 + resolution: "@radix-ui/react-popper@npm:1.2.6" + dependencies: + "@floating-ui/react-dom": "npm:^2.0.0" + "@radix-ui/react-arrow": "npm:1.1.6" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-rect": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + "@radix-ui/rect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/b166c609a9475ffcdc65a0fd4bb9cf2cd67e7d24240dba9b954ec97496970783966e5e9f52cf9b12aff363d24f5112970e80813cf0eb8d4a1d989afdad59e0d8 + languageName: node + linkType: hard + "@radix-ui/react-portal@npm:1.1.6": version: 1.1.6 resolution: "@radix-ui/react-portal@npm:1.1.6" @@ -2389,6 +2536,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-portal@npm:1.1.8": + version: 1.1.8 + resolution: "@radix-ui/react-portal@npm:1.1.8" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/53590f70a2b0280cab07cb1354d0061889ecff06f04f2518ef562a30b7cea67093a1d4e2d58a6338e8d004646dd72e1211a2d47e3e0b3fc2d77317d79187d2f2 + languageName: node + linkType: hard + "@radix-ui/react-presence@npm:1.1.4": version: 1.1.4 resolution: "@radix-ui/react-presence@npm:1.1.4" @@ -2428,6 +2595,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:2.1.2": + version: 2.1.2 + resolution: "@radix-ui/react-primitive@npm:2.1.2" + dependencies: + "@radix-ui/react-slot": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/0c1b4b5d2f225dc85e02a915b362e38383eb3bd6d150a72cb9183485c066156caad1a9f530202b84a5ad900d365302c0843fdcabb13100808872b3655709099d + languageName: node + linkType: hard + "@radix-ui/react-radio-group@npm:^1.2.2": version: 1.3.4 resolution: "@radix-ui/react-radio-group@npm:1.3.4" @@ -2483,6 +2669,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-roving-focus@npm:1.1.9": + version: 1.1.9 + resolution: "@radix-ui/react-roving-focus@npm:1.1.9" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-collection": "npm:1.1.6" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/7794036199245d3d153f2c3f79fc0f36ba1eec81ba9dca28927c4693f3cca1ddbd3e54d60372cd506c82b826371519d9dc5280ffbe8a3cb0220e141e37718d46 + languageName: node + linkType: hard + "@radix-ui/react-scroll-area@npm:^1.2.2": version: 1.2.6 resolution: "@radix-ui/react-scroll-area@npm:1.2.6" @@ -2510,11 +2723,11 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-separator@npm:^1.1.2": - version: 1.1.4 - resolution: "@radix-ui/react-separator@npm:1.1.4" +"@radix-ui/react-separator@npm:^1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-separator@npm:1.1.6" dependencies: - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-primitive": "npm:2.1.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2525,11 +2738,40 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/79ce54baceeaff30a518bf99cc6cbc292767ee730eb20d276664791d1530e991f870440a4dbf82b93710fdd165d18be1f8e44a0bd3ffd1a0c52e49d71838e49c + checksum: 10c0/498c581d6f712a1a2a5f956fd415c41e85769b0891cf8253fcc84bacb3e344dc4c0b8fa416283b46d04d5d7511044bc41bc448591b2bb39338864f277d915d16 languageName: node linkType: hard -"@radix-ui/react-slot@npm:1.2.0, @radix-ui/react-slot@npm:^1.1.2": +"@radix-ui/react-slider@npm:^1.3.4": + version: 1.3.4 + resolution: "@radix-ui/react-slider@npm:1.3.4" + dependencies: + "@radix-ui/number": "npm:1.1.1" + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-collection": "npm:1.1.6" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/cdb1e19f91699d3f698ce1b30ff7570c62282e891e0eb098621499084fd6ac3a68e88a8ea746bd21f7f4995e5a3afec5dca5ee221dd72d44212a99d3cf399b71 + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:1.2.0": version: 1.2.0 resolution: "@radix-ui/react-slot@npm:1.2.0" dependencies: @@ -2544,6 +2786,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slot@npm:1.2.2, @radix-ui/react-slot@npm:^1.2.2": + version: 1.2.2 + resolution: "@radix-ui/react-slot@npm:1.2.2" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/74489f5ad11b17444560a1cdd664c01308206ce5cb9fcd46121b45281ece20273948479711411223c1081f709c15409242d51ca6cc57ff81f6335d70e0cbe0b5 + languageName: node + linkType: hard + "@radix-ui/react-switch@npm:^1.1.2": version: 1.2.2 resolution: "@radix-ui/react-switch@npm:1.2.2" @@ -2569,6 +2826,32 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-tabs@npm:^1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-tabs@npm:1.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-presence": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-roving-focus": "npm:1.1.9" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/eebecb25f4e245c4abf0968b86bb4ee468965e4d3524d48147298715cd54a58dab8b813cd65ed12ceb2a3e1410f80097e2b0532da01de79e78fb398e002578a3 + languageName: node + linkType: hard + "@radix-ui/react-tooltip@npm:^1.1.6": version: 1.2.4 resolution: "@radix-ui/react-tooltip@npm:1.2.4"