diff --git a/.gitignore b/.gitignore index 96fab4fe..b082d458 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ yarn-error.log* # Misc .DS_Store *.pem + + +# Repomix +repomix-output.txt \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..7e4c92ad --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "bun dev", + "cwd": "${workspaceFolder}/apps/web" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug client-side (Firefox)", + "type": "firefox", + "request": "launch", + "url": "http://localhost:3000", + "reAttach": true, + "pathMappings": [ + { + "url": "webpack://_N_E", + "path": "${workspaceFolder}" + } + ] + }, + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/next", + "cwd": "${workspaceFolder}/apps/web", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "debugWithEdge", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d7359e00 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## [Unreleased] + +### Added + +- **feat(data-table-filter)**: server-side filtering ([#53](https://github.com/kianbazza/ui/pull/53)) +- **feat(data-table-filter)**: the great decoupling ([#47](https://github.com/kianbazza/ui/pull/47)) + +### Changed + +- **feat(data-table-filter)**: use muted text color for operator display ([#55](https://github.com/kianbazza/ui/pull/55)) +- **refactor(data-table-filter)**: registry item files re-organization ([#54](https://github.com/kianbazza/ui/pull/54)) + +### Fixed + +- ... + +### Other + +- ... \ No newline at end of file diff --git a/apps/web/app/demos/server/tst-query/_/columns.tsx b/apps/web/app/demos/server/tst-query/_/columns.tsx new file mode 100644 index 00000000..5292bb08 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/columns.tsx @@ -0,0 +1,184 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Checkbox } from '@/components/ui/checkbox' +import { cn } from '@/lib/utils' +import { createColumnHelper } from '@tanstack/react-table' +import { format } from 'date-fns' +import { CircleDashedIcon } from 'lucide-react' +import type { Issue } from './types' + +export const LABEL_STYLES_MAP = { + red: 'bg-red-500 border-red-500', + orange: 'bg-orange-500 border-orange-500', + amber: 'bg-amber-500 border-amber-500', + yellow: 'bg-yellow-500 border-yellow-500', + lime: 'bg-lime-500 border-lime-500', + green: 'bg-green-500 border-green-500', + emerald: 'bg-emerald-500 border-emerald-500', + teal: 'bg-teal-500 border-teal-500', + cyan: 'bg-cyan-500 border-cyan-500', + sky: 'bg-sky-500 border-sky-500', + blue: 'bg-blue-500 border-blue-500', + indigo: 'bg-indigo-500 border-indigo-500', + violet: 'bg-violet-500 border-violet-500', + purple: 'bg-purple-500 border-purple-500', + fuchsia: 'bg-fuchsia-500 border-fuchsia-500', + pink: 'bg-pink-500 border-pink-500', + rose: 'bg-rose-500 border-rose-500', + neutral: 'bg-neutral-500 border-neutral-500', +} as const + +export type TW_COLOR = keyof typeof LABEL_STYLES_MAP + +const columnHelper = createColumnHelper() + +export const tstColumnDefs = [ + columnHelper.display({ + id: 'select', + header: ({ table }) => ( + table.toggleAllRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + enableColumnFilter: false, + }), + columnHelper.accessor((row) => row.status.id, { + id: 'status', + header: 'Status', + enableColumnFilter: true, + cell: ({ row }) => { + const { status } = row.original + const StatusIcon = status.icon + + return ( +
+ + {status.name} +
+ ) + }, + }), + columnHelper.accessor((row) => row.title, { + id: 'title', + header: 'Title', + enableColumnFilter: true, + cell: ({ row }) =>
{row.getValue('title')}
, + }), + columnHelper.accessor((row) => row.assignee?.id, { + id: 'assignee', + header: 'Assignee', + enableColumnFilter: true, + cell: ({ row }) => { + const user = row.original.assignee + + if (!user) { + return + } + + const initials = user.name + .split(' ') + .map((x) => x[0]) + .join('') + .toUpperCase() + + return ( + + + {initials} + + ) + }, + }), + columnHelper.accessor((row) => row.estimatedHours, { + id: 'estimatedHours', + header: 'Estimated Hours', + enableColumnFilter: true, + cell: ({ row }) => { + const estimatedHours = row.getValue('estimatedHours') + + if (!estimatedHours) { + return null + } + + return ( + + + {estimatedHours} + + h + + ) + }, + }), + columnHelper.accessor((row) => row.startDate, { + id: 'startDate', + header: 'Start Date', + enableColumnFilter: true, + cell: ({ row }) => { + const startDate = row.getValue('startDate') + + if (!startDate) { + return null + } + + const formatted = format(startDate, 'MMM dd') + + return {formatted} + }, + }), + columnHelper.accessor((row) => row.endDate, { + id: 'endDate', + header: 'End Date', + cell: ({ row }) => { + const endDate = row.getValue('endDate') + + if (!endDate) { + return null + } + + const formatted = format(endDate, 'MMM dd') + + return {formatted} + }, + }), + columnHelper.accessor((row) => row.labels?.map((l) => l.id), { + id: 'labels', + header: 'Labels', + enableColumnFilter: true, + cell: ({ row }) => { + const labels = row.original.labels + + if (!labels) return null + + return ( +
+ {labels.map((l) => ( +
+ {l.name} +
+ ))} +
+ ) + }, + }), +] diff --git a/apps/web/app/demos/server/tst-query/_/data-table.tsx b/apps/web/app/demos/server/tst-query/_/data-table.tsx new file mode 100644 index 00000000..01824c45 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/data-table.tsx @@ -0,0 +1,95 @@ +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { type Table as TanStackTable, flexRender } from '@tanstack/react-table' + +export function DataTable({ table }: { table: TanStackTable }) { + return ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s) selected.{' '} + + Total row count: {table.getCoreRowModel().rows.length} + +
+
+ + +
+
+ + ) +} diff --git a/apps/web/app/demos/server/tst-query/_/data.ts b/apps/web/app/demos/server/tst-query/_/data.ts new file mode 100644 index 00000000..a6c900cb --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/data.ts @@ -0,0 +1,535 @@ +import { lorem } from '@ndaidong/txtgen' +import { sub } from 'date-fns' +import { + CircleCheckIcon, + CircleDashedIcon, + CircleDotIcon, + CircleIcon, +} from 'lucide-react' +import { nanoid } from 'nanoid' +import { randomInteger, sample } from 'remeda' +import type { Issue, IssueLabel, IssueStatus, User } from './types' +import { calculateEndDate, isAnyOf } from './utils' + +export const USERS: User[] = [ + { + id: 'rmraOG0B-2xSUyJ3e73mv', + name: 'John Smith', + picture: '/avatars/john-smith.png', + }, + { + id: '4_vjFSibR5YkkQiHafaLx', + name: 'Rose Eve', + picture: '/avatars/rose-eve.png', + }, + { + id: 'RC9mraUl-oJECORIjeNbd', + name: 'Adam Young', + picture: '/avatars/adam-young.png', + }, + { + id: 'hhkjn-3L9bKDbl1FKuZW0', + name: 'Michael Scott', + picture: '/avatars/michael-scott.png', + }, +] as const + +export const ISSUE_STATUSES: IssueStatus[] = [ + { + id: 'backlog', + name: 'Backlog', + icon: CircleDashedIcon, + order: 1, + }, + { + id: 'todo', + name: 'Todo', + icon: CircleIcon, + order: 2, + }, + { + id: 'in-progress', + name: 'In Progress', + icon: CircleDotIcon, + order: 3, + }, + { + id: 'done', + name: 'Done', + icon: CircleCheckIcon, + order: 4, + }, +] as const + +export const ISSUE_LABELS: IssueLabel[] = [ + { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Bug', color: 'red' }, + { + id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + name: 'Enhancement', + color: 'green', + }, + { id: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', name: 'Task', color: 'blue' }, + { id: '6ba7b812-9dad-11d1-80b4-00c04fd430c8', name: 'Urgent', color: 'pink' }, + { + id: '6ba7b813-9dad-11d1-80b4-00c04fd430c8', + name: 'Low Priority', + color: 'lime', + }, + { + id: '6ba7b814-9dad-11d1-80b4-00c04fd430c8', + name: 'Frontend', + color: 'orange', + }, + { + id: '6ba7b815-9dad-11d1-80b4-00c04fd430c8', + name: 'Backend', + color: 'teal', + }, + { + id: '6ba7b816-9dad-11d1-80b4-00c04fd430c8', + name: 'Database', + color: 'violet', + }, + { id: '6ba7b817-9dad-11d1-80b4-00c04fd430c8', name: 'API', color: 'red' }, + { + id: '6ba7b818-9dad-11d1-80b4-00c04fd430c8', + name: 'AI Model', + color: 'cyan', + }, + { + id: '6ba7b819-9dad-11d1-80b4-00c04fd430c8', + name: 'Data Pipeline', + color: 'amber', + }, + { + id: '6ba7b81a-9dad-11d1-80b4-00c04fd430c8', + name: 'Inference', + color: 'emerald', + }, + { + id: '6ba7b81b-9dad-11d1-80b4-00c04fd430c8', + name: 'AI Integration', + color: 'purple', + }, + { + id: '6ba7b81c-9dad-11d1-80b4-00c04fd430c8', + name: 'Ethics', + color: 'fuchsia', + }, + { + id: '6ba7b81d-9dad-11d1-80b4-00c04fd430c8', + name: 'Refactor', + color: 'lime', + }, + { + id: '6ba7b81e-9dad-11d1-80b4-00c04fd430c8', + name: 'Performance', + color: 'red', + }, + { + id: '6ba7b81f-9dad-11d1-80b4-00c04fd430c8', + name: 'Security', + color: 'sky', + }, + { + id: '6ba7b820-9dad-11d1-80b4-00c04fd430c8', + name: 'Testing', + color: 'yellow', + }, + { + id: '6ba7b821-9dad-11d1-80b4-00c04fd430c8', + name: 'Documentation', + color: 'rose', + }, + { + id: '6ba7b822-9dad-11d1-80b4-00c04fd430c8', + name: 'In Progress', + color: 'green', + }, + { + id: '6ba7b823-9dad-11d1-80b4-00c04fd430c8', + name: 'Blocked', + color: 'indigo', + }, + { + id: '6ba7b824-9dad-11d1-80b4-00c04fd430c8', + name: 'Needs Review', + color: 'orange', + }, + { id: '6ba7b825-9dad-11d1-80b4-00c04fd430c8', name: 'Done', color: 'teal' }, + { id: '6ba7b826-9dad-11d1-80b4-00c04fd430c8', name: 'UI', color: 'red' }, + { id: '6ba7b827-9dad-11d1-80b4-00c04fd430c8', name: 'UX', color: 'sky' }, + { + id: '6ba7b828-9dad-11d1-80b4-00c04fd430c8', + name: 'Accessibility', + color: 'red', + }, + { + id: '6ba7b829-9dad-11d1-80b4-00c04fd430c8', + name: 'Deployment', + color: 'emerald', + }, + { + id: '6ba7b82a-9dad-11d1-80b4-00c04fd430c8', + name: 'Infrastructure', + color: 'purple', + }, + { + id: '6ba7b82b-9dad-11d1-80b4-00c04fd430c8', + name: 'Monitoring', + color: 'pink', + }, + { + id: '6ba7b82c-9dad-11d1-80b4-00c04fd430c8', + name: 'Real-Time', + color: 'lime', + }, + { + id: '6ba7b82d-9dad-11d1-80b4-00c04fd430c8', + name: 'Scalability', + color: 'amber', + }, + { + id: '6ba7b82e-9dad-11d1-80b4-00c04fd430c8', + name: 'Third-Party', + color: 'cyan', + }, + { + id: '6ba7b82f-9dad-11d1-80b4-00c04fd430c8', + name: 'Authentication', + color: 'rose', + }, + { + id: '6ba7b830-9dad-11d1-80b4-00c04fd430c8', + name: 'Authorization', + color: 'green', + }, + { + id: '6ba7b831-9dad-11d1-80b4-00c04fd430c8', + name: 'Caching', + color: 'lime', + }, + { id: '6ba7b832-9dad-11d1-80b4-00c04fd430c8', name: 'Logging', color: 'red' }, + { + id: '6ba7b833-9dad-11d1-80b4-00c04fd430c8', + name: 'Analytics', + color: 'sky', + }, + { + id: '6ba7b834-9dad-11d1-80b4-00c04fd430c8', + name: 'Feature Request', + color: 'orange', + }, + { + id: '6ba7b835-9dad-11d1-80b4-00c04fd430c8', + name: 'Regression', + color: 'teal', + }, + { id: '6ba7b836-9dad-11d1-80b4-00c04fd430c8', name: 'Hotfix', color: 'red' }, + { + id: '6ba7b837-9dad-11d1-80b4-00c04fd430c8', + name: 'Code Review', + color: 'emerald', + }, + { + id: '6ba7b838-9dad-11d1-80b4-00c04fd430c8', + name: 'Tech Debt', + color: 'purple', + }, + { + id: '6ba7b839-9dad-11d1-80b4-00c04fd430c8', + name: 'Migration', + color: 'pink', + }, + { + id: '6ba7b83a-9dad-11d1-80b4-00c04fd430c8', + name: 'Configuration', + color: 'lime', + }, + { + id: '6ba7b83b-9dad-11d1-80b4-00c04fd430c8', + name: 'Validation', + color: 'amber', + }, + { + id: '6ba7b83c-9dad-11d1-80b4-00c04fd430c8', + name: 'Input Handling', + color: 'cyan', + }, + { + id: '6ba7b83d-9dad-11d1-80b4-00c04fd430c8', + name: 'Error Handling', + color: 'rose', + }, + { + id: '6ba7b83e-9dad-11d1-80b4-00c04fd430c8', + name: 'Session Management', + color: 'green', + }, + { + id: '6ba7b83f-9dad-11d1-80b4-00c04fd430c8', + name: 'Concurrency', + color: 'lime', + }, + { + id: '6ba7b840-9dad-11d1-80b4-00c04fd430c8', + name: 'Load Balancing', + color: 'red', + }, + { + id: '6ba7b841-9dad-11d1-80b4-00c04fd430c8', + name: 'Data Migration', + color: 'sky', + }, + { + id: '6ba7b842-9dad-11d1-80b4-00c04fd430c8', + name: 'Model Training', + color: 'orange', + }, + { + id: '6ba7b843-9dad-11d1-80b4-00c04fd430c8', + name: 'Hyperparameters', + color: 'teal', + }, + { + id: '6ba7b844-9dad-11d1-80b4-00c04fd430c8', + name: 'Overfitting', + color: 'red', + }, + { + id: '6ba7b845-9dad-11d1-80b4-00c04fd430c8', + name: 'Underfitting', + color: 'emerald', + }, + { + id: '6ba7b846-9dad-11d1-80b4-00c04fd430c8', + name: 'Feature Engineering', + color: 'purple', + }, + { + id: '6ba7b847-9dad-11d1-80b4-00c04fd430c8', + name: 'Data Quality', + color: 'pink', + }, + { + id: '6ba7b848-9dad-11d1-80b4-00c04fd430c8', + name: 'Preprocessing', + color: 'lime', + }, + { + id: '6ba7b849-9dad-11d1-80b4-00c04fd430c8', + name: 'Model Deployment', + color: 'amber', + }, + { + id: '6ba7b84a-9dad-11d1-80b4-00c04fd430c8', + name: 'Latency', + color: 'cyan', + }, + { + id: '6ba7b84b-9dad-11d1-80b4-00c04fd430c8', + name: 'Throughput', + color: 'rose', + }, + { + id: '6ba7b84c-9dad-11d1-80b4-00c04fd430c8', + name: 'API Versioning', + color: 'green', + }, + { + id: '6ba7b84d-9dad-11d1-80b4-00c04fd430c8', + name: 'Rate Limiting', + color: 'lime', + }, + { + id: '6ba7b84e-9dad-11d1-80b4-00c04fd430c8', + name: 'Throttling', + color: 'red', + }, + { + id: '6ba7b84f-9dad-11d1-80b4-00c04fd430c8', + name: 'Retry Logic', + color: 'sky', + }, + { + id: '6ba7b850-9dad-11d1-80b4-00c04fd430c8', + name: 'Fallback', + color: 'orange', + }, + { + id: '6ba7b851-9dad-11d1-80b4-00c04fd430c8', + name: 'Circuit Breaker', + color: 'teal', + }, + { + id: '6ba7b852-9dad-11d1-80b4-00c04fd430c8', + name: 'Queue Management', + color: 'red', + }, + { + id: '6ba7b853-9dad-11d1-80b4-00c04fd430c8', + name: 'Batch Processing', + color: 'emerald', + }, + { + id: '6ba7b854-9dad-11d1-80b4-00c04fd430c8', + name: 'Streaming', + color: 'purple', + }, + { + id: '6ba7b855-9dad-11d1-80b4-00c04fd430c8', + name: 'Event Handling', + color: 'pink', + }, + { + id: '6ba7b856-9dad-11d1-80b4-00c04fd430c8', + name: 'WebSocket', + color: 'lime', + }, + { + id: '6ba7b857-9dad-11d1-80b4-00c04fd430c8', + name: 'Cron Job', + color: 'amber', + }, + { + id: '6ba7b858-9dad-11d1-80b4-00c04fd430c8', + name: 'Scheduled Task', + color: 'cyan', + }, + { + id: '6ba7b859-9dad-11d1-80b4-00c04fd430c8', + name: 'File Upload', + color: 'rose', + }, + { + id: '6ba7b85a-9dad-11d1-80b4-00c04fd430c8', + name: 'File Processing', + color: 'green', + }, + { id: '6ba7b85b-9dad-11d1-80b4-00c04fd430c8', name: 'Export', color: 'lime' }, + { id: '6ba7b85c-9dad-11d1-80b4-00c04fd430c8', name: 'Import', color: 'red' }, + { + id: '6ba7b85d-9dad-11d1-80b4-00c04fd430c8', + name: 'Localization', + color: 'sky', + }, + { + id: '6ba7b85e-9dad-11d1-80b4-00c04fd430c8', + name: 'Internationalization', + color: 'orange', + }, + { + id: '6ba7b85f-9dad-11d1-80b4-00c04fd430c8', + name: 'Notifications', + color: 'teal', + }, + { id: '6ba7b860-9dad-11d1-80b4-00c04fd430c8', name: 'Email', color: 'red' }, + { + id: '6ba7b861-9dad-11d1-80b4-00c04fd430c8', + name: 'Push Notifications', + color: 'emerald', + }, + { id: '6ba7b862-9dad-11d1-80b4-00c04fd430c8', name: 'SMS', color: 'purple' }, + { + id: '6ba7b863-9dad-11d1-80b4-00c04fd430c8', + name: 'Audit Log', + color: 'pink', + }, + { id: '6ba7b864-9dad-11d1-80b4-00c04fd430c8', name: 'Backup', color: 'lime' }, + { + id: '6ba7b865-9dad-11d1-80b4-00c04fd430c8', + name: 'Restore', + color: 'amber', + }, + { + id: '6ba7b866-9dad-11d1-80b4-00c04fd430c8', + name: 'Disaster Recovery', + color: 'cyan', + }, + { + id: '6ba7b867-9dad-11d1-80b4-00c04fd430c8', + name: 'Compliance', + color: 'rose', + }, + { id: '6ba7b868-9dad-11d1-80b4-00c04fd430c8', name: 'GDPR', color: 'green' }, + { id: '6ba7b869-9dad-11d1-80b4-00c04fd430c8', name: 'HIPAA', color: 'lime' }, + { + id: '6ba7b86a-9dad-11d1-80b4-00c04fd430c8', + name: 'Debugging', + color: 'red', + }, + { + id: '6ba7b86b-9dad-11d1-80b4-00c04fd430c8', + name: 'Profiling', + color: 'sky', + }, + { + id: '6ba7b86c-9dad-11d1-80b4-00c04fd430c8', + name: 'Optimization', + color: 'orange', + }, + { + id: '6ba7b86d-9dad-11d1-80b4-00c04fd430c8', + name: 'Research', + color: 'teal', + }, + { + id: '6ba7b86e-9dad-11d1-80b4-00c04fd430c8', + name: 'Experiment', + color: 'red', + }, + { + id: '6ba7b86f-9dad-11d1-80b4-00c04fd430c8', + name: 'Proof of Concept', + color: 'emerald', + }, +] + +export function generateSampleIssue(): Issue { + const title = lorem(4, 8) + const description = lorem(4, 8) + + const labelsCount = randomInteger(0, 5) + const labels = + labelsCount > 0 + ? (sample(ISSUE_LABELS, labelsCount) as IssueLabel[]) + : undefined + + let [assignee] = sample(USERS, 1) + if (!assignee) throw new Error('No assignee found') + assignee = Math.random() > 0.5 ? assignee : undefined + + const [status] = sample(ISSUE_STATUSES, 1) + if (!status) throw new Error('No status found') + + const startDate = isAnyOf(status.id, ['backlog', 'todo']) + ? undefined + : sub(new Date(), { days: randomInteger(10, 60) }) + + const endDate = + !startDate || status.id !== 'done' ? undefined : calculateEndDate(startDate) + + const estimatedHours = randomInteger(1, 16) + + return { + id: nanoid(), + title, + description, + status, + labels, + assignee, + startDate, + endDate, + estimatedHours, + } +} + +export function generateIssues(count: number) { + const arr: Issue[] = [] + + for (let i = 0; i < count; i++) { + arr.push(generateSampleIssue()) + } + + return arr +} diff --git a/apps/web/app/demos/server/tst-query/_/fetch.ts b/apps/web/app/demos/server/tst-query/_/fetch.ts new file mode 100644 index 00000000..a6c2ed47 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/fetch.ts @@ -0,0 +1,129 @@ +import type { FiltersState } from '@/registry/data-table-filter-v2/core/types' +import { + dateFilterFn, + multiOptionFilterFn, + numberFilterFn, + optionFilterFn, + textFilterFn, +} from '@/registry/data-table-filter-v2/lib/filter-fns' +import { ISSUE_LABELS, ISSUE_STATUSES, USERS, generateIssues } from './data' +import type { Issue } from './types' +import { isAnyOf, sleep } from './utils' + +const ISSUES_COUNT = process.env.NODE_ENV === 'production' ? 100000 : 1000 + +const ISSUES = generateIssues(ISSUES_COUNT) + +export async function fetchIssues(filters?: FiltersState) { + await sleep(1500) + + if (!filters || filters.length === 0) return ISSUES + + // Apply filters using AND logic + // You can use a provided filterFn function (prefixed with __) from @/registry/data-table-filter-v2/lib/filter-fns + const filteredIssues = ISSUES.filter((issue) => { + return filters.every((filter) => { + const columnId = filter.columnId as keyof Issue + + if (isAnyOf(columnId, ['id', 'title', 'description'])) { + const value = issue[columnId] as string | undefined + + if (!value) return false + return textFilterFn(value, filter) + } + + if (isAnyOf(columnId, ['status', 'assignee'])) { + const value = (issue[columnId] as any)?.id + if (!value) return false + return optionFilterFn(value, filter) + } + + if (isAnyOf(columnId, ['labels'])) { + const value = ((issue[columnId] as any) ?? []).map((l: any) => l.id) + if (!value) return false + return multiOptionFilterFn(value, filter) + } + + if (isAnyOf(columnId, ['estimatedHours'])) { + const value = issue[columnId] as number + return numberFilterFn(value, filter) + } + + if (isAnyOf(columnId, ['startDate', 'endDate'])) { + const value = issue[columnId] as Date | undefined + if (!value) return false + return dateFilterFn(value, filter) + } + + throw new Error(`Unknown columnId: ${columnId}`) + }) + }) + + return filteredIssues +} + +export async function fetchLabels() { + await sleep(1500) + return ISSUE_LABELS +} + +export async function fetchUsers() { + await sleep(1500) + return USERS +} + +export async function fetchStatuses() { + await sleep(1500) + return ISSUE_STATUSES +} + +export async function fetchFacetedLabels() { + const map = new Map() + + for (const label of ISSUE_LABELS) { + map.set(label.id, 0) + } + + for (const issue of ISSUES) { + const labelIds = issue.labels?.map((l) => l.id) ?? [] + + for (const labelId of labelIds) { + const curr = map.get(labelId) ?? 0 + map.set(labelId, curr + 1) + } + } + + return map +} + +export async function fetchFacetedStatuses() { + const map = new Map() + + for (const status of ISSUE_STATUSES) { + map.set(status.id, 0) + } + + for (const issue of ISSUES) { + const statusId = issue.status.id + const curr = map.get(statusId) ?? 0 + map.set(statusId, curr + 1) + } + + return map +} + +export async function fetchFacetedUsers() { + const map = new Map() + + for (const user of USERS) { + map.set(user.id, 0) + } + + for (const issue of ISSUES) { + const userId = issue.assignee?.id ?? '' + const curr = map.get(userId) ?? 0 + map.set(userId, curr + 1) + } + + return map +} diff --git a/apps/web/app/demos/server/tst-query/_/filters.tsx b/apps/web/app/demos/server/tst-query/_/filters.tsx new file mode 100644 index 00000000..919c45e9 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/filters.tsx @@ -0,0 +1,59 @@ +import { createColumnConfigHelper } from '@/registry/data-table-filter-v2/core/filters' +import { + CalendarArrowUpIcon, + CircleDotDashedIcon, + ClockIcon, + Heading1Icon, + TagsIcon, + UserCheckIcon, +} from 'lucide-react' +import type { Issue } from './types' + +const dtf = createColumnConfigHelper() + +export const columnsConfig = [ + dtf + .text() + .id('title') + .accessor((row) => row.title) + .displayName('Title') + .icon(Heading1Icon) + .build(), + dtf + .option() + .accessor((row) => row.status.id) + .id('status') + .displayName('Status') + .icon(CircleDotDashedIcon) + .build(), + dtf + .option() + .accessor((row) => row.assignee?.id) + .id('assignee') + .displayName('Assignee') + .icon(UserCheckIcon) + .build(), + dtf + .multiOption() + .accessor((row) => row.labels?.map((l) => l.id)) + .id('labels') + .displayName('Labels') + .icon(TagsIcon) + .build(), + dtf + .number() + .accessor((row) => row.estimatedHours) + .id('estimatedHours') + .displayName('Estimated hours') + .icon(ClockIcon) + .min(0) + .max(100) + .build(), + dtf + .date() + .accessor((row) => row.startDate) + .id('startDate') + .displayName('Start Date') + .icon(CalendarArrowUpIcon) + .build(), +] as const diff --git a/apps/web/app/demos/server/tst-query/_/issues-table.tsx b/apps/web/app/demos/server/tst-query/_/issues-table.tsx new file mode 100644 index 00000000..248c4549 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/issues-table.tsx @@ -0,0 +1,165 @@ +'use client' + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { cn } from '@/lib/utils' +import { + DataTableFilter, + useDataTableFilters, +} from '@/registry/data-table-filter-v2' +import type { FiltersState } from '@/registry/data-table-filter-v2/core/types' +import { createTSTColumns } from '@/registry/data-table-filter-v2/integrations/tanstack-table' +import { useQuery } from '@tanstack/react-query' +import { + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import { LABEL_STYLES_MAP, type TW_COLOR, tstColumnDefs } from './columns' +import { DataTable } from './data-table' +import { columnsConfig } from './filters' +import { queries } from './queries' +import { TableFilterSkeleton, TableSkeleton } from './table-skeleton' +import type { IssueLabel, IssueStatus, User } from './types' + +function createLabelOptions(labels: IssueLabel[] | undefined) { + return labels?.map((l) => ({ + value: l.id, + label: l.name, + icon: ( +
+ ), + })) +} + +function createStatusOptions(statuses: IssueStatus[] | undefined) { + return statuses?.map((s) => ({ + value: s.id, + label: s.name, + icon: s.icon, + })) +} + +function createUserOptions(users: User[] | undefined) { + return users?.map((u) => ({ + value: u.id, + label: u.name, + icon: ( + + + + {u.name + .split('') + .map((x) => x[0]) + .join('') + .toUpperCase()} + + + ), + })) +} + +export function IssuesTable({ + state, +}: { + state: { + filters: FiltersState + setFilters: React.Dispatch> + } +}) { + // Step 1: Fetch data from the server + const labels = useQuery(queries.labels.all()) + const statuses = useQuery(queries.statuses.all()) + const users = useQuery(queries.users.all()) + + const facetedLabels = useQuery(queries.labels.faceted()) + const facetedStatuses = useQuery(queries.statuses.faceted()) + const facetedUsers = useQuery(queries.users.faceted()) + + const issues = useQuery(queries.issues.all(state.filters)) + + // Step 2: Create ColumnOption[] for each option-based column + const labelOptions = createLabelOptions(labels.data) + const statusOptions = createStatusOptions(statuses.data) + const userOptions = createUserOptions(users.data) + + const isOptionsDataPending = + labels.isPending || + statuses.isPending || + users.isPending || + facetedLabels.isPending || + facetedStatuses.isPending || + facetedUsers.isPending + + // Step 3: Create our data table filters instance + // + // This instance will handle the logic for filtering the data and updating the filters state. + // We expose an `options` prop to provide the options for each column dynamically, after fetching them above. + // It exposes our filters state, for you to pass on as you wish - e.g. to a TanStack Table instance. + const { columns, filters, actions, strategy } = useDataTableFilters({ + strategy: 'server', + data: issues.data ?? [], + columnsConfig, + controlledState: [state.filters, state.setFilters], + options: { + status: [statusOptions, facetedStatuses.data], + assignee: [userOptions, facetedUsers.data], + labels: [labelOptions, facetedLabels.data], + }, + }) + + // Step 4: Extend our TanStack Table columns with custom filter functions (and more!) + // using our integration hook. + const tstColumns = useMemo( + () => + createTSTColumns({ + columns: tstColumnDefs, + configs: columns, + }), + [columns], + ) + + // Step 5: Create our TanStack Table instance + const [rowSelection, setRowSelection] = useState({}) + const table = useReactTable({ + data: issues.data ?? [], + columns: tstColumns, + getRowId: (row) => row.id, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + }) + + // Step 6: Render the table! + return ( +
+
+ {isOptionsDataPending ? ( + + ) : ( + + )} +
+ {issues.isLoading ? ( +
+ +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/app/demos/server/tst-query/_/queries.ts b/apps/web/app/demos/server/tst-query/_/queries.ts new file mode 100644 index 00000000..a10dc20b --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/queries.ts @@ -0,0 +1,55 @@ +import type { FiltersState } from '@/registry/data-table-filter-v2/core/types' +import type { QueryOptions } from '@tanstack/react-query' +import { + fetchFacetedLabels, + fetchFacetedStatuses, + fetchFacetedUsers, + fetchIssues, + fetchLabels, + fetchStatuses, + fetchUsers, +} from './fetch' + +export const queries = { + issues: { + all: (filters?: FiltersState) => + ({ + queryKey: ['issues', filters], + queryFn: () => fetchIssues(filters), + }) satisfies QueryOptions, + }, + statuses: { + all: () => ({ + queryKey: ['statuses'], + queryFn: () => fetchStatuses(), + }), + faceted: () => + ({ + queryKey: ['statuses', 'faceted'], + queryFn: () => fetchFacetedStatuses(), + }) satisfies QueryOptions, + }, + labels: { + all: () => ({ + queryKey: ['labels'], + queryFn: () => fetchLabels(), + }), + faceted: () => + ({ + queryKey: ['labels', 'faceted'], + queryFn: () => fetchFacetedLabels(), + }) satisfies QueryOptions, + }, + users: { + all: () => + ({ + queryKey: ['users'], + queryFn: () => fetchUsers(), + }) satisfies QueryOptions, + faceted: () => + ({ + queryKey: ['users', 'faceted'], + queryFn: () => fetchFacetedUsers(), + }) satisfies QueryOptions, + }, +} diff --git a/apps/web/app/demos/server/tst-query/_/query-client-provider.tsx b/apps/web/app/demos/server/tst-query/_/query-client-provider.tsx new file mode 100644 index 00000000..2a0c02e6 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/query-client-provider.tsx @@ -0,0 +1,17 @@ +'use client' + +import { + QueryClient, + QueryClientProvider as ReactQueryClientProvider, +} from '@tanstack/react-query' + +export default function QueryClientProvider({ + children, +}: { children: React.ReactNode }) { + const queryClient = new QueryClient() + return ( + + {children} + + ) +} diff --git a/apps/web/app/demos/server/tst-query/_/table-skeleton.tsx b/apps/web/app/demos/server/tst-query/_/table-skeleton.tsx new file mode 100644 index 00000000..1f8aeac2 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/table-skeleton.tsx @@ -0,0 +1,78 @@ +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { FilterIcon } from 'lucide-react' + +export interface TableSkeletonProps { + numCols: number + numRows: number +} + +export function TableSkeleton({ numRows, numCols }: TableSkeletonProps) { + const rows = Array.from(Array(numRows).keys()) + const cols = Array.from(Array(numCols).keys()) + + return ( + <> +
+ + + + {cols.map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + + + ))} + + + + {rows.map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + {cols.map((_, index2) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + + + ))} + + ))} + +
+
+
+
+ + +
+
+ + +
+
+ + ) +} + +export function TableFilterSkeleton() { + return ( +
+ +
+ ) +} diff --git a/apps/web/app/demos/server/tst-query/_/types.ts b/apps/web/app/demos/server/tst-query/_/types.ts new file mode 100644 index 00000000..878483f2 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/types.ts @@ -0,0 +1,32 @@ +import type { LucideIcon } from 'lucide-react' + +export type Issue = { + id: string + title: string + description?: string + status: IssueStatus + labels?: IssueLabel[] + assignee?: User + startDate?: Date + endDate?: Date + estimatedHours?: number +} + +export type User = { + id: string + name: string + picture: string +} + +export type IssueLabel = { + id: string + name: string + color: string +} + +export type IssueStatus = { + id: 'backlog' | 'todo' | 'in-progress' | 'done' + name: string + order: number + icon: LucideIcon +} diff --git a/apps/web/app/demos/server/tst-query/_/utils.ts b/apps/web/app/demos/server/tst-query/_/utils.ts new file mode 100644 index 00000000..4a85c166 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/_/utils.ts @@ -0,0 +1,17 @@ +import { add, differenceInDays } from 'date-fns' +import { randomInteger } from 'remeda' + +export const calculateEndDate = (start: Date) => { + const diff = differenceInDays(new Date(), start) + const offset = randomInteger(0, diff + 1) + + return add(start, { days: offset }) +} + +export function isAnyOf(value: T, array: T[]) { + return array.includes(value) +} + +export async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/apps/web/app/demos/server/tst-query/layout.tsx b/apps/web/app/demos/server/tst-query/layout.tsx new file mode 100644 index 00000000..9377db73 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/layout.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react' +import QueryClientProvider from './_/query-client-provider' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/app/demos/server/tst-query/page.tsx b/apps/web/app/demos/server/tst-query/page.tsx new file mode 100644 index 00000000..8e8ce8e5 --- /dev/null +++ b/apps/web/app/demos/server/tst-query/page.tsx @@ -0,0 +1,46 @@ +'use client' + +import { CodeBlock } from '@/components/code-block' +import { NavBar } from '@/components/nav-bar' +import type { FiltersState } from '@/registry/data-table-filter-v2/core/types' +import { parseAsJson, useQueryState } from 'nuqs' +import { z } from 'zod' +import { IssuesTable } from './_/issues-table' + +const filtersSchema = z.custom() + +export default function SSRPage() { + const [filters, setFilters] = useQueryState( + 'filters', + parseAsJson(filtersSchema.parse).withDefault([]), + ) + + return ( +
+
+
+ +
+
+
+
+
+

+ Server-side filtering{' '} + + (TanStack Query + nuqs) + +

+
+ + +
+
+
+
+
+ ) +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 3754693d..85429723 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,6 +5,8 @@ import { env } from '@/lib/env' import { berkeleyMono, inter } from '@/lib/fonts' import { ThemeProvider } from '@/providers/theme-provider' import type { Viewport } from 'next' +import Head from 'next/head' +import Script from 'next/script' import { NuqsAdapter } from 'nuqs/adapters/next/app' const title = 'bazza/ui — Hand-crafted, modern React components' @@ -75,7 +77,8 @@ export default function RootLayout({ }>) { return ( - +