Skip to content

Commit e9db445

Browse files
Add DataTable component with nuqs and zod filters
1 parent 7a41b7c commit e9db445

File tree

13 files changed

+543
-1
lines changed

13 files changed

+543
-1
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"framer-motion": "^6.3.3",
5151
"lodash": "^4.17.21",
5252
"luxon": "^1.25.0",
53+
"nuqs": "^1.17.1",
5354
"notistack": "^2.0.4",
5455
"react": "18.2.0",
5556
"react-chartjs-2": "^2.11.1",
@@ -61,7 +62,8 @@
6162
"react-modal": "^3.15.1",
6263
"react-quill": "2.0.0-beta.4",
6364
"regenerator-runtime": "0.13.7",
64-
"yup": "^0.32.9"
65+
"yup": "^0.32.9",
66+
"zod": "^3.22.4"
6567
},
6668
"devDependencies": {
6769
"@babel/core": "^7.15.5",
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import React, { FC, ReactNode, useMemo } from 'react';
2+
import classNames from 'classnames';
3+
import { z } from 'zod';
4+
import { useQueryState } from 'nuqs';
5+
import { Table, TableProps } from '../table';
6+
import { Pagination } from '../pagination';
7+
8+
import './data-table.css';
9+
10+
export interface DataTableColumn<T> {
11+
header: string;
12+
accessor: keyof T;
13+
render?: (value: any, row: T) => ReactNode;
14+
}
15+
16+
export interface DataTableFilter<T> {
17+
name: string;
18+
label: string;
19+
schema: z.ZodType<any>;
20+
component: FC<{
21+
value: any;
22+
onChange: (value: any) => void;
23+
label: string;
24+
}>;
25+
defaultValue?: any;
26+
}
27+
28+
export interface DataTableProps<T> extends Omit<TableProps, 'children'> {
29+
data: T[];
30+
columns: DataTableColumn<T>[];
31+
filters?: DataTableFilter<T>[];
32+
itemsPerPage?: number;
33+
onFilterChange?: (filters: Record<string, any>) => void;
34+
}
35+
36+
export function DataTable<T>({
37+
data,
38+
columns,
39+
filters = [],
40+
className,
41+
itemsPerPage = 10,
42+
onFilterChange,
43+
...props
44+
}: DataTableProps<T>) {
45+
// Set up filter state with nuqs
46+
const filterStates = useMemo(() => {
47+
return filters.map(filter => {
48+
const [value, setValue] = useQueryState(
49+
filter.name,
50+
{
51+
defaultValue: filter.defaultValue ?? null,
52+
parse: (value) => {
53+
try {
54+
const parsed = filter.schema.parse(JSON.parse(value));
55+
return parsed;
56+
} catch (e) {
57+
return filter.defaultValue ?? null;
58+
}
59+
},
60+
serialize: (value) => {
61+
return JSON.stringify(value);
62+
}
63+
}
64+
);
65+
66+
return { filter, value, setValue };
67+
});
68+
}, [filters]);
69+
70+
// Set up pagination
71+
const [page, setPage] = useQueryState('page', {
72+
defaultValue: 1,
73+
parse: (value) => {
74+
const parsed = parseInt(value, 10);
75+
return isNaN(parsed) || parsed < 1 ? 1 : parsed;
76+
},
77+
serialize: (value) => value.toString()
78+
});
79+
80+
// Apply filters to data
81+
const filteredData = useMemo(() => {
82+
let result = [...data];
83+
84+
// Apply each active filter
85+
const activeFilters: Record<string, any> = {};
86+
87+
filterStates.forEach(({ filter, value }) => {
88+
if (value !== null && value !== undefined) {
89+
activeFilters[filter.name] = value;
90+
}
91+
});
92+
93+
// Notify parent component about filter changes
94+
if (onFilterChange) {
95+
onFilterChange(activeFilters);
96+
}
97+
98+
return result;
99+
}, [data, filterStates, onFilterChange]);
100+
101+
// Paginate data
102+
const paginatedData = useMemo(() => {
103+
const startIndex = (page - 1) * itemsPerPage;
104+
return filteredData.slice(startIndex, startIndex + itemsPerPage);
105+
}, [filteredData, page, itemsPerPage]);
106+
107+
// Calculate total pages
108+
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
109+
110+
return (
111+
<div className={classNames('lc-data-table', className)}>
112+
{filters.length > 0 && (
113+
<div className="lc-data-table-filters">
114+
{filterStates.map(({ filter, value, setValue }) => (
115+
<div key={filter.name} className="lc-data-table-filter">
116+
<filter.component
117+
value={value}
118+
onChange={setValue}
119+
label={filter.label}
120+
/>
121+
</div>
122+
))}
123+
</div>
124+
)}
125+
126+
<div className="lc-data-table-wrapper">
127+
<Table {...props}>
128+
<thead>
129+
<tr>
130+
{columns.map((column) => (
131+
<th key={column.accessor as string}>{column.header}</th>
132+
))}
133+
</tr>
134+
</thead>
135+
<tbody>
136+
{paginatedData.map((row, rowIndex) => (
137+
<tr key={rowIndex}>
138+
{columns.map((column) => (
139+
<td key={`${rowIndex}-${column.accessor as string}`}>
140+
{column.render
141+
? column.render(row[column.accessor], row)
142+
: row[column.accessor]}
143+
</td>
144+
))}
145+
</tr>
146+
))}
147+
</tbody>
148+
</Table>
149+
</div>
150+
151+
{totalPages > 1 && (
152+
<div className="lc-data-table-pagination">
153+
<Pagination
154+
currentPage={page}
155+
totalPages={totalPages}
156+
onPageChange={setPage}
157+
/>
158+
</div>
159+
)}
160+
</div>
161+
);
162+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React, { FC } from 'react';
2+
import { DataTable } from '..';
3+
import { createTextFilter, createSelectFilter, createNumberFilter, createDateFilter } from '../filters';
4+
5+
interface User {
6+
id: number;
7+
name: string;
8+
email: string;
9+
role: string;
10+
age: number;
11+
joinDate: string;
12+
}
13+
14+
const mockUsers: User[] = [
15+
{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin', age: 32, joinDate: '2022-01-15' },
16+
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'Editor', age: 28, joinDate: '2022-03-22' },
17+
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: 'User', age: 45, joinDate: '2021-11-05' },
18+
{ id: 4, name: 'Alice Brown', email: 'alice@example.com', role: 'Admin', age: 37, joinDate: '2022-02-18' },
19+
{ id: 5, name: 'Charlie Wilson', email: 'charlie@example.com', role: 'User', age: 29, joinDate: '2022-04-10' },
20+
{ id: 6, name: 'Diana Miller', email: 'diana@example.com', role: 'Editor', age: 41, joinDate: '2021-10-30' },
21+
{ id: 7, name: 'Edward Davis', email: 'edward@example.com', role: 'User', age: 33, joinDate: '2022-05-05' },
22+
{ id: 8, name: 'Fiona Clark', email: 'fiona@example.com', role: 'Admin', age: 39, joinDate: '2021-12-12' },
23+
{ id: 9, name: 'George White', email: 'george@example.com', role: 'User', age: 26, joinDate: '2022-06-20' },
24+
{ id: 10, name: 'Hannah Lee', email: 'hannah@example.com', role: 'Editor', age: 31, joinDate: '2022-01-25' },
25+
{ id: 11, name: 'Ian Taylor', email: 'ian@example.com', role: 'User', age: 44, joinDate: '2021-09-15' },
26+
{ id: 12, name: 'Julia Martin', email: 'julia@example.com', role: 'Admin', age: 35, joinDate: '2022-03-05' }
27+
];
28+
29+
export const DataTableExample: FC = () => {
30+
const columns = [
31+
{ header: 'ID', accessor: 'id' as keyof User },
32+
{ header: 'Name', accessor: 'name' as keyof User },
33+
{ header: 'Email', accessor: 'email' as keyof User },
34+
{ header: 'Role', accessor: 'role' as keyof User },
35+
{ header: 'Age', accessor: 'age' as keyof User },
36+
{ header: 'Join Date', accessor: 'joinDate' as keyof User }
37+
];
38+
39+
const filters = [
40+
createTextFilter('name', 'Name', 'Search by name...'),
41+
createSelectFilter('role', 'Role', [
42+
{ value: 'Admin', label: 'Admin' },
43+
{ value: 'Editor', label: 'Editor' },
44+
{ value: 'User', label: 'User' }
45+
]),
46+
createNumberFilter('age', 'Age', { min: 18, max: 100 }),
47+
createDateFilter('joinDate', 'Join Date')
48+
];
49+
50+
return (
51+
<DataTable
52+
data={mockUsers}
53+
columns={columns}
54+
filters={filters}
55+
itemsPerPage={5}
56+
footnote="This is an example of a data table with filtering and pagination."
57+
/>
58+
);
59+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs';
2+
import { DataTable } from '..';
3+
import { DataTableExample } from './data-table.examples';
4+
5+
<Meta title="Components/DataTable" component={DataTable} />
6+
7+
# DataTable
8+
9+
The DataTable component is a powerful table component that supports filtering, pagination, and sorting.
10+
It uses `nuqs` for URL-based state management and `zod` for schema validation of filter values.
11+
12+
## Features
13+
14+
- URL-based filtering with `nuqs`
15+
- Schema validation with `zod`
16+
- Pagination
17+
- Custom filter components
18+
- Responsive design
19+
20+
## Basic Usage
21+
22+
<Canvas>
23+
<Story name="Default">
24+
<DataTableExample />
25+
</Story>
26+
</Canvas>
27+
28+
## Props
29+
30+
<ArgsTable of={DataTable} />
31+
32+
## Filter Types
33+
34+
The DataTable comes with several built-in filter types:
35+
36+
- **Text Filter**: For filtering text fields
37+
- **Select Filter**: For selecting from predefined options
38+
- **Number Filter**: For filtering numeric values
39+
- **Date Filter**: For filtering date values
40+
41+
Each filter type has a corresponding schema defined with `zod` for validation.
42+
43+
## Creating Custom Filters
44+
45+
You can create custom filters by implementing the `DataTableFilter` interface:
46+
47+
```tsx
48+
import { z } from 'zod';
49+
50+
const myCustomFilter = {
51+
name: 'myFilter',
52+
label: 'My Filter',
53+
schema: z.string().nullable(),
54+
component: ({ value, onChange, label }) => (
55+
<MyCustomComponent
56+
value={value}
57+
onChange={onChange}
58+
label={label}
59+
/>
60+
),
61+
defaultValue: null
62+
};
63+
```
64+
65+
## URL State Management
66+
67+
The DataTable uses `nuqs` to manage filter state in the URL. This allows for:
68+
69+
- Shareable filtered views
70+
- Browser history navigation
71+
- Persistence of filters across page reloads
72+
73+
## Example Implementation
74+
75+
```tsx
76+
import { DataTable } from '@lambdacurry/component-library';
77+
import { createTextFilter, createSelectFilter } from '@lambdacurry/component-library';
78+
79+
const MyDataTable = () => {
80+
const data = [...]; // Your data array
81+
82+
const columns = [
83+
{ header: 'Name', accessor: 'name' },
84+
{ header: 'Email', accessor: 'email' },
85+
{ header: 'Role', accessor: 'role' }
86+
];
87+
88+
const filters = [
89+
createTextFilter('name', 'Name', 'Search by name...'),
90+
createSelectFilter('role', 'Role', [
91+
{ value: 'admin', label: 'Admin' },
92+
{ value: 'user', label: 'User' }
93+
])
94+
];
95+
96+
return (
97+
<DataTable
98+
data={data}
99+
columns={columns}
100+
filters={filters}
101+
itemsPerPage={10}
102+
/>
103+
);
104+
};
105+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './data-table.examples';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.lc-data-table {
2+
&-wrapper {
3+
width: 100%;
4+
overflow-x: auto;
5+
}
6+
7+
&-filters {
8+
display: flex;
9+
flex-wrap: wrap;
10+
gap: theme('spacing.16');
11+
margin-bottom: theme('spacing.16');
12+
}
13+
14+
&-filter {
15+
min-width: 200px;
16+
}
17+
18+
&-pagination {
19+
margin-top: theme('spacing.16');
20+
display: flex;
21+
justify-content: flex-end;
22+
}
23+
}

0 commit comments

Comments
 (0)