diff --git a/package.json b/package.json index 8b9beffc..b9f4fddb 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "framer-motion": "^6.3.3", "lodash": "^4.17.21", "luxon": "^1.25.0", + "nuqs": "^1.17.1", "notistack": "^2.0.4", "react": "18.2.0", "react-chartjs-2": "^2.11.1", @@ -61,7 +62,8 @@ "react-modal": "^3.15.1", "react-quill": "2.0.0-beta.4", "regenerator-runtime": "0.13.7", - "yup": "^0.32.9" + "yup": "^0.32.9", + "zod": "^3.22.4" }, "devDependencies": { "@babel/core": "^7.15.5", diff --git a/packages/ui-components/src/lib/data-table/DataTable.tsx b/packages/ui-components/src/lib/data-table/DataTable.tsx new file mode 100644 index 00000000..3057ff49 --- /dev/null +++ b/packages/ui-components/src/lib/data-table/DataTable.tsx @@ -0,0 +1,162 @@ +import React, { FC, ReactNode, useMemo } from 'react'; +import classNames from 'classnames'; +import { z } from 'zod'; +import { useQueryState } from 'nuqs'; +import { Table, TableProps } from '../table'; +import { Pagination } from '../pagination'; + +import './data-table.css'; + +export interface DataTableColumn { + header: string; + accessor: keyof T; + render?: (value: any, row: T) => ReactNode; +} + +export interface DataTableFilter { + name: string; + label: string; + schema: z.ZodType; + component: FC<{ + value: any; + onChange: (value: any) => void; + label: string; + }>; + defaultValue?: any; +} + +export interface DataTableProps extends Omit { + data: T[]; + columns: DataTableColumn[]; + filters?: DataTableFilter[]; + itemsPerPage?: number; + onFilterChange?: (filters: Record) => void; +} + +export function DataTable({ + data, + columns, + filters = [], + className, + itemsPerPage = 10, + onFilterChange, + ...props +}: DataTableProps) { + // Set up filter state with nuqs + const filterStates = useMemo(() => { + return filters.map(filter => { + const [value, setValue] = useQueryState( + filter.name, + { + defaultValue: filter.defaultValue ?? null, + parse: (value) => { + try { + const parsed = filter.schema.parse(JSON.parse(value)); + return parsed; + } catch (e) { + return filter.defaultValue ?? null; + } + }, + serialize: (value) => { + return JSON.stringify(value); + } + } + ); + + return { filter, value, setValue }; + }); + }, [filters]); + + // Set up pagination + const [page, setPage] = useQueryState('page', { + defaultValue: 1, + parse: (value) => { + const parsed = parseInt(value, 10); + return isNaN(parsed) || parsed < 1 ? 1 : parsed; + }, + serialize: (value) => value.toString() + }); + + // Apply filters to data + const filteredData = useMemo(() => { + let result = [...data]; + + // Apply each active filter + const activeFilters: Record = {}; + + filterStates.forEach(({ filter, value }) => { + if (value !== null && value !== undefined) { + activeFilters[filter.name] = value; + } + }); + + // Notify parent component about filter changes + if (onFilterChange) { + onFilterChange(activeFilters); + } + + return result; + }, [data, filterStates, onFilterChange]); + + // Paginate data + const paginatedData = useMemo(() => { + const startIndex = (page - 1) * itemsPerPage; + return filteredData.slice(startIndex, startIndex + itemsPerPage); + }, [filteredData, page, itemsPerPage]); + + // Calculate total pages + const totalPages = Math.ceil(filteredData.length / itemsPerPage); + + return ( +
+ {filters.length > 0 && ( +
+ {filterStates.map(({ filter, value, setValue }) => ( +
+ +
+ ))} +
+ )} + +
+ + + + {columns.map((column) => ( + + ))} + + + + {paginatedData.map((row, rowIndex) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
{column.header}
+ {column.render + ? column.render(row[column.accessor], row) + : row[column.accessor]} +
+
+ + {totalPages > 1 && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/data-table-documentation/data-table.examples.tsx b/packages/ui-components/src/lib/data-table/data-table-documentation/data-table.examples.tsx new file mode 100644 index 00000000..08a81c42 --- /dev/null +++ b/packages/ui-components/src/lib/data-table/data-table-documentation/data-table.examples.tsx @@ -0,0 +1,59 @@ +import React, { FC } from 'react'; +import { DataTable } from '..'; +import { createTextFilter, createSelectFilter, createNumberFilter, createDateFilter } from '../filters'; + +interface User { + id: number; + name: string; + email: string; + role: string; + age: number; + joinDate: string; +} + +const mockUsers: User[] = [ + { id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin', age: 32, joinDate: '2022-01-15' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'Editor', age: 28, joinDate: '2022-03-22' }, + { id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: 'User', age: 45, joinDate: '2021-11-05' }, + { id: 4, name: 'Alice Brown', email: 'alice@example.com', role: 'Admin', age: 37, joinDate: '2022-02-18' }, + { id: 5, name: 'Charlie Wilson', email: 'charlie@example.com', role: 'User', age: 29, joinDate: '2022-04-10' }, + { id: 6, name: 'Diana Miller', email: 'diana@example.com', role: 'Editor', age: 41, joinDate: '2021-10-30' }, + { id: 7, name: 'Edward Davis', email: 'edward@example.com', role: 'User', age: 33, joinDate: '2022-05-05' }, + { id: 8, name: 'Fiona Clark', email: 'fiona@example.com', role: 'Admin', age: 39, joinDate: '2021-12-12' }, + { id: 9, name: 'George White', email: 'george@example.com', role: 'User', age: 26, joinDate: '2022-06-20' }, + { id: 10, name: 'Hannah Lee', email: 'hannah@example.com', role: 'Editor', age: 31, joinDate: '2022-01-25' }, + { id: 11, name: 'Ian Taylor', email: 'ian@example.com', role: 'User', age: 44, joinDate: '2021-09-15' }, + { id: 12, name: 'Julia Martin', email: 'julia@example.com', role: 'Admin', age: 35, joinDate: '2022-03-05' } +]; + +export const DataTableExample: FC = () => { + const columns = [ + { header: 'ID', accessor: 'id' as keyof User }, + { header: 'Name', accessor: 'name' as keyof User }, + { header: 'Email', accessor: 'email' as keyof User }, + { header: 'Role', accessor: 'role' as keyof User }, + { header: 'Age', accessor: 'age' as keyof User }, + { header: 'Join Date', accessor: 'joinDate' as keyof User } + ]; + + const filters = [ + createTextFilter('name', 'Name', 'Search by name...'), + createSelectFilter('role', 'Role', [ + { value: 'Admin', label: 'Admin' }, + { value: 'Editor', label: 'Editor' }, + { value: 'User', label: 'User' } + ]), + createNumberFilter('age', 'Age', { min: 18, max: 100 }), + createDateFilter('joinDate', 'Join Date') + ]; + + return ( + + ); +}; \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/data-table-documentation/data-table.stories.mdx b/packages/ui-components/src/lib/data-table/data-table-documentation/data-table.stories.mdx new file mode 100644 index 00000000..430cd57a --- /dev/null +++ b/packages/ui-components/src/lib/data-table/data-table-documentation/data-table.stories.mdx @@ -0,0 +1,105 @@ +import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs'; +import { DataTable } from '..'; +import { DataTableExample } from './data-table.examples'; + + + +# DataTable + +The DataTable component is a powerful table component that supports filtering, pagination, and sorting. +It uses `nuqs` for URL-based state management and `zod` for schema validation of filter values. + +## Features + +- URL-based filtering with `nuqs` +- Schema validation with `zod` +- Pagination +- Custom filter components +- Responsive design + +## Basic Usage + + + + + + + +## Props + + + +## Filter Types + +The DataTable comes with several built-in filter types: + +- **Text Filter**: For filtering text fields +- **Select Filter**: For selecting from predefined options +- **Number Filter**: For filtering numeric values +- **Date Filter**: For filtering date values + +Each filter type has a corresponding schema defined with `zod` for validation. + +## Creating Custom Filters + +You can create custom filters by implementing the `DataTableFilter` interface: + +```tsx +import { z } from 'zod'; + +const myCustomFilter = { + name: 'myFilter', + label: 'My Filter', + schema: z.string().nullable(), + component: ({ value, onChange, label }) => ( + + ), + defaultValue: null +}; +``` + +## URL State Management + +The DataTable uses `nuqs` to manage filter state in the URL. This allows for: + +- Shareable filtered views +- Browser history navigation +- Persistence of filters across page reloads + +## Example Implementation + +```tsx +import { DataTable } from '@lambdacurry/component-library'; +import { createTextFilter, createSelectFilter } from '@lambdacurry/component-library'; + +const MyDataTable = () => { + const data = [...]; // Your data array + + const columns = [ + { header: 'Name', accessor: 'name' }, + { header: 'Email', accessor: 'email' }, + { header: 'Role', accessor: 'role' } + ]; + + const filters = [ + createTextFilter('name', 'Name', 'Search by name...'), + createSelectFilter('role', 'Role', [ + { value: 'admin', label: 'Admin' }, + { value: 'user', label: 'User' } + ]) + ]; + + return ( + + ); +}; +``` \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/data-table-documentation/index.tsx b/packages/ui-components/src/lib/data-table/data-table-documentation/index.tsx new file mode 100644 index 00000000..ac49bd55 --- /dev/null +++ b/packages/ui-components/src/lib/data-table/data-table-documentation/index.tsx @@ -0,0 +1 @@ +export * from './data-table.examples'; \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/data-table.css b/packages/ui-components/src/lib/data-table/data-table.css new file mode 100644 index 00000000..0e793ca6 --- /dev/null +++ b/packages/ui-components/src/lib/data-table/data-table.css @@ -0,0 +1,23 @@ +.lc-data-table { + &-wrapper { + width: 100%; + overflow-x: auto; + } + + &-filters { + display: flex; + flex-wrap: wrap; + gap: theme('spacing.16'); + margin-bottom: theme('spacing.16'); + } + + &-filter { + min-width: 200px; + } + + &-pagination { + margin-top: theme('spacing.16'); + display: flex; + justify-content: flex-end; + } +} \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/filters/date-filter.tsx b/packages/ui-components/src/lib/data-table/filters/date-filter.tsx new file mode 100644 index 00000000..af795b9c --- /dev/null +++ b/packages/ui-components/src/lib/data-table/filters/date-filter.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { z } from 'zod'; +import { DatePicker } from '../../inputs'; + +export interface DateFilterProps { + value: string | null; + onChange: (value: string | null) => void; + label: string; + minDate?: Date; + maxDate?: Date; +} + +export const DateFilter: FC = ({ + value, + onChange, + label, + minDate, + maxDate +}) => { + return ( + onChange(date || null)} + minDate={minDate} + maxDate={maxDate} + /> + ); +}; + +export const dateFilterSchema = z.string().nullable(); + +export const createDateFilter = ( + name: string, + label: string, + options?: { minDate?: Date; maxDate?: Date } +) => ({ + name, + label, + schema: dateFilterSchema, + component: (props: DateFilterProps) => ( + + ), + defaultValue: null +}); \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/filters/index.ts b/packages/ui-components/src/lib/data-table/filters/index.ts new file mode 100644 index 00000000..6869c7af --- /dev/null +++ b/packages/ui-components/src/lib/data-table/filters/index.ts @@ -0,0 +1,4 @@ +export * from './text-filter'; +export * from './select-filter'; +export * from './number-filter'; +export * from './date-filter'; \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/filters/number-filter.tsx b/packages/ui-components/src/lib/data-table/filters/number-filter.tsx new file mode 100644 index 00000000..a93500fa --- /dev/null +++ b/packages/ui-components/src/lib/data-table/filters/number-filter.tsx @@ -0,0 +1,57 @@ +import React, { FC } from 'react'; +import { z } from 'zod'; +import { Input } from '../../inputs'; + +export interface NumberFilterProps { + value: number | null; + onChange: (value: number | null) => void; + label: string; + min?: number; + max?: number; + step?: number; +} + +export const NumberFilter: FC = ({ + value, + onChange, + label, + min, + max, + step = 1 +}) => { + return ( + { + const val = e.target.value; + onChange(val === '' ? null : Number(val)); + }} + min={min} + max={max} + step={step} + /> + ); +}; + +export const numberFilterSchema = z.number().nullable(); + +export const createNumberFilter = ( + name: string, + label: string, + options?: { min?: number; max?: number; step?: number } +) => ({ + name, + label, + schema: numberFilterSchema, + component: (props: NumberFilterProps) => ( + + ), + defaultValue: null +}); \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/filters/select-filter.tsx b/packages/ui-components/src/lib/data-table/filters/select-filter.tsx new file mode 100644 index 00000000..3b5aa357 --- /dev/null +++ b/packages/ui-components/src/lib/data-table/filters/select-filter.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import { z } from 'zod'; +import { Select, SelectOption } from '../../inputs'; + +export interface SelectFilterProps { + value: string | null; + onChange: (value: string | null) => void; + label: string; + options: SelectOption[]; + placeholder?: string; +} + +export const SelectFilter: FC = ({ + value, + onChange, + label, + options, + placeholder = 'Select...' +}) => { + return ( + onChange(e.target.value || null)} + placeholder={placeholder} + /> + ); +}; + +export const textFilterSchema = z.string().nullable(); + +export const createTextFilter = (name: string, label: string, placeholder?: string) => ({ + name, + label, + schema: textFilterSchema, + component: (props: TextFilterProps) => , + defaultValue: null +}); \ No newline at end of file diff --git a/packages/ui-components/src/lib/data-table/index.ts b/packages/ui-components/src/lib/data-table/index.ts new file mode 100644 index 00000000..95c648ea --- /dev/null +++ b/packages/ui-components/src/lib/data-table/index.ts @@ -0,0 +1 @@ +export * from './DataTable'; \ No newline at end of file diff --git a/packages/ui-components/src/lib/index.ts b/packages/ui-components/src/lib/index.ts index eebaedc6..ce1235f6 100644 --- a/packages/ui-components/src/lib/index.ts +++ b/packages/ui-components/src/lib/index.ts @@ -18,6 +18,8 @@ export * from './tabs'; export * from './table'; export * from './theme'; export * from './tooltips'; +export * from './data-table'; +export * from './data-table/filters'; // Hooks export * from './hooks';