From ffa839216455e2bcc6056cb64fa0dec6f42d9d91 Mon Sep 17 00:00:00 2001 From: Yahya Date: Wed, 11 Mar 2026 12:20:35 +0700 Subject: [PATCH 01/13] feat(data-table): add DataTable component with reactive store architecture - Reactive store (useSyncExternalStore) with selector hooks for re-render isolation - Pre-filtering engine with built-in strategies (checkbox, select, date-gte, date-lte) - Self-registering filter components (register strategy on mount) - Client-side mode: filtering, sorting, pagination, search with useTransition - Server-side mode: cursor-based pagination, fetch lifecycle management - nuqs adapter for URL state sync with simple sort format (-column for desc) - Selection column auto-injection with bulk actions - Inline content registration system - React.memo on all leaf components for performance - 320 tests across 48 test files - Storybook stories and docs page - CI: alpha publish workflow for PR previews --- .github/workflows/publish-datum-ui.yml | 120 +++- .gitignore | 4 +- README.md | 17 +- .../docs/components/features/data-table.mdx | 430 ++++++++++++ .../docs/components/features/meta.json | 1 + apps/docs/next-env.d.ts | 2 +- .../stories/features/data-table.stories.tsx | 650 ++++++++++++++++++ packages/datum-ui/package.json | 16 +- .../calendar-date-picker.tsx | 5 +- .../__tests__/checkbox-filter.test.tsx | 127 ++++ .../__tests__/client-provider.test.tsx | 48 ++ .../__tests__/column-header.test.tsx | 92 +++ .../data-table/__tests__/content.test.tsx | 96 +++ .../__tests__/data-table-context.test.tsx | 28 + .../__tests__/date-picker-filter.test.tsx | 93 +++ .../__tests__/filter-engine.test.ts | 167 +++++ .../__tests__/inline-content.test.tsx | 105 +++ .../data-table/__tests__/loading.test.tsx | 22 + .../data-table/__tests__/nuqs-adapter.test.ts | 87 +++ .../data-table/__tests__/pagination.test.tsx | 70 ++ .../data-table/__tests__/row-actions.test.tsx | 73 ++ .../data-table/__tests__/search.test.tsx | 52 ++ .../__tests__/select-filter.test.tsx | 150 ++++ .../__tests__/selection-column.test.tsx | 57 ++ .../__tests__/server-provider.test.tsx | 62 ++ .../data-table/__tests__/store.test.ts | 260 +++++++ .../__tests__/use-data-table-client.test.ts | 156 +++++ .../__tests__/use-data-table-server.test.ts | 122 ++++ .../__tests__/use-selectors.test.tsx | 125 ++++ .../data-table/adapters/nuqs-adapter.ts | 134 ++++ .../data-table/columns/selection-column.tsx | 55 ++ .../data-table/components/bulk-actions.tsx | 16 + .../data-table/components/column-header.tsx | 34 + .../data-table/components/content.tsx | 132 ++++ .../data-table/components/inline-content.tsx | 55 ++ .../data-table/components/loading.tsx | 32 + .../data-table/components/pagination.tsx | 178 +++++ .../data-table/components/row-actions.tsx | 65 ++ .../features/data-table/components/search.tsx | 40 ++ .../features/data-table/constants.ts | 4 + .../data-table/core/client-provider.tsx | 28 + .../data-table/core/data-table-context.tsx | 36 + .../features/data-table/core/filter-engine.ts | 113 +++ .../data-table/core/server-provider.tsx | 28 + .../features/data-table/core/store.ts | 162 +++++ .../features/data-table/data-table.tsx | 31 + .../data-table/filters/checkbox-filter.tsx | 146 ++++ .../data-table/filters/date-picker-filter.tsx | 60 ++ .../data-table/filters/select-filter.tsx | 113 +++ .../features/data-table/hooks/index.ts | 13 + .../data-table/hooks/use-data-table-client.ts | 149 ++++ .../hooks/use-data-table-context.ts | 1 + .../data-table/hooks/use-data-table-server.ts | 206 ++++++ .../data-table/hooks/use-selectors.ts | 200 ++++++ .../components/features/data-table/index.ts | 70 ++ .../components/features/data-table/types.ts | 355 ++++++++++ packages/datum-ui/tsdown.config.ts | 2 + pnpm-lock.yaml | 40 ++ 58 files changed, 5724 insertions(+), 11 deletions(-) create mode 100644 apps/docs/content/docs/components/features/data-table.mdx create mode 100644 apps/storybook/stories/features/data-table.stories.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/checkbox-filter.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/client-provider.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/column-header.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/content.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/date-picker-filter.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/filter-engine.test.ts create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/inline-content.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/loading.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/nuqs-adapter.test.ts create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/pagination.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/row-actions.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/search.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/select-filter.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/selection-column.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/store.test.ts create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/use-selectors.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/adapters/nuqs-adapter.ts create mode 100644 packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/bulk-actions.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/column-header.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/content.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/inline-content.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/loading.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/pagination.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/row-actions.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/search.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/constants.ts create mode 100644 packages/datum-ui/src/components/features/data-table/core/client-provider.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/core/filter-engine.ts create mode 100644 packages/datum-ui/src/components/features/data-table/core/server-provider.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/core/store.ts create mode 100644 packages/datum-ui/src/components/features/data-table/data-table.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/filters/checkbox-filter.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/filters/date-picker-filter.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/filters/select-filter.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/hooks/index.ts create mode 100644 packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts create mode 100644 packages/datum-ui/src/components/features/data-table/hooks/use-data-table-context.ts create mode 100644 packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts create mode 100644 packages/datum-ui/src/components/features/data-table/hooks/use-selectors.ts create mode 100644 packages/datum-ui/src/components/features/data-table/index.ts create mode 100644 packages/datum-ui/src/components/features/data-table/types.ts diff --git a/.github/workflows/publish-datum-ui.yml b/.github/workflows/publish-datum-ui.yml index 1928135d..0b2f47c1 100644 --- a/.github/workflows/publish-datum-ui.yml +++ b/.github/workflows/publish-datum-ui.yml @@ -4,18 +4,134 @@ on: push: branches: - main + pull_request: + types: [labeled, synchronize] + branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - publish-datum-ui: + # Stable publish on push to main (reusable workflow) + publish: + if: github.event_name == 'push' uses: datum-cloud/actions/.github/workflows/publish-npm-package.yaml@main permissions: contents: write id-token: write - with: package-name: "@datum-cloud/datum-ui" package-path: packages/datum-ui + + # Alpha publish on PR with 'alpha' label + alpha: + name: Publish alpha + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'alpha') + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + pull-requests: write + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + + - run: pnpm install --frozen-lockfile + + - name: Determine next version + id: version + working-directory: packages/datum-ui + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + CURRENT=$(node -p "require('./package.json').version") + + # Detect bump type from PR labels + LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}' + if echo "$LABELS" | grep -q "release:major"; then + BUMP="major" + elif echo "$LABELS" | grep -q "release:minor"; then + BUMP="minor" + else + BUMP="patch" + fi + + # Calculate next version + NEXT=$(node -e " + const [major, minor, patch] = '${CURRENT}'.split('.').map(Number); + const bump = '${BUMP}'; + if (bump === 'major') console.log(\`\${major+1}.0.0\`); + else if (bump === 'minor') console.log(\`\${major}.\${minor+1}.0\`); + else console.log(\`\${major}.\${minor}.\${patch+1}\`); + ") + SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) + ALPHA_VERSION="${NEXT}-alpha.${SHORT_SHA}" + + echo "version=${ALPHA_VERSION}" >> "$GITHUB_OUTPUT" + echo "Publishing ${ALPHA_VERSION} (${BUMP} bump from ${CURRENT})" + + - name: Set alpha version + working-directory: packages/datum-ui + run: npm version ${{ steps.version.outputs.version }} --no-git-tag-version + + - name: Build + run: pnpm --filter @datum-cloud/datum-ui build + + - name: Publish to npm + working-directory: packages/datum-ui + run: pnpm publish --no-git-checks --access public --tag alpha --provenance + + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.version.outputs.version }}'; + const body = [ + `### 📦 Alpha published`, + '', + `\`@datum-cloud/datum-ui@${version}\``, + '', + '```bash', + `pnpm add @datum-cloud/datum-ui@${version}`, + '# or use the alpha tag for the latest alpha:', + 'pnpm add @datum-cloud/datum-ui@alpha', + '```', + ].join('\n'); + + // Find and update existing comment, or create new one + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const marker = '### 📦 Alpha published'; + const existing = comments.find(c => c.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.gitignore b/.gitignore index 9b35b5fd..c6652d02 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ coverage/ .claude/ CLAUDE.MD -/docs/ \ No newline at end of file +/docs/ +/specs/ +*.backup diff --git a/README.md b/README.md index db47889a..f5354a86 100644 --- a/README.md +++ b/README.md @@ -210,15 +210,24 @@ pnpm --filter @datum-cloud/datum-ui typecheck pnpm --filter @datum-cloud/datum-ui lint pnpm --filter @datum-cloud/datum-ui test -# 3. Add a changeset -pnpm changeset - -# 4. Commit and push +# 3. Commit and push git add . git commit -m "feat: description of changes" git push -u origin feat/my-feature ``` +### Release Labels + +When your PR is merged to `main`, the publish workflow automatically bumps the package version based on PR labels: + +| Label | Version Bump | Example | +|---|---|---| +| `release:major` | Major | `0.3.0` → `1.0.0` | +| `release:minor` | Minor | `0.2.1` → `0.3.0` | +| _(no label)_ | Patch (default) | `0.2.1` → `0.2.2` | + +Add the appropriate label to your PR before merging to control the version bump. + ## License MIT diff --git a/apps/docs/content/docs/components/features/data-table.mdx b/apps/docs/content/docs/components/features/data-table.mdx new file mode 100644 index 00000000..62fe8148 --- /dev/null +++ b/apps/docs/content/docs/components/features/data-table.mdx @@ -0,0 +1,430 @@ +--- +title: Data Table +description: A composable, type-safe data table with client and server modes, sorting, filtering, pagination, and optional URL state sync. +--- + +# Data Table + +A compound component data table built on TanStack React Table. Supports both client-side and server-side data with sorting, filtering, search, row selection, bulk actions, and pagination. + +## Installation + +The Data Table is included in the `@datum-cloud/datum-ui` package. + +## Dependencies + +This component requires the following packages: + +```package-install +@tanstack/react-table +``` + +Optional, for URL state sync: + +```package-install +nuqs +``` + +## Usage + +```tsx +import { DataTable, useDataTableClient } from '@datum-cloud/datum-ui/data-table' +``` + +### Client Data Table + +For data that is fully loaded in the browser: + +```tsx +import { DataTable, useDataTableClient } from '@datum-cloud/datum-ui/data-table' +import type { ColumnDef } from '@tanstack/react-table' + +interface User { + id: string + name: string + email: string + role: string +} + +const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'email', + header: 'Email', + }, + { + accessorKey: 'role', + header: 'Role', + }, + { + id: 'actions', + cell: ({ row }) => ( + { /* navigate to edit page */ } }, + { label: 'Delete', variant: 'destructive', onClick: (user) => { /* handle delete */ } }, + ]} + /> + ), + }, +] + +function UsersPage({ users }: { users: User[] }) { + const tableState = useDataTableClient({ + data: users, + columns, + pageSize: 20, + getRowId: (row) => row.id, + enableRowSelection: true, + defaultSort: [{ id: 'name', desc: false }], + }) + + return ( + +
+
+ + +
+ + {(selectedRows) => ( + {selectedRows.length} selected + )} + + + +
+
+ ) +} +``` + +### Server Data Table + +For paginated API data (e.g., Kubernetes resources with continuation tokens): + +```tsx +import { DataTable, useDataTableServer } from '@datum-cloud/datum-ui/data-table' + +function ResourcesPage() { + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [hasNextPage, setHasNextPage] = useState(false) + + const tableState = useDataTableServer({ + columns: resourceColumns, + limit: 25, + getRowId: (row) => row.metadata.uid, + }) + + // Fetch data when sorting/filters change + useEffect(() => { + fetchResources({ + sorting: tableState.sorting, + filters: tableState.filters, + limit: tableState.pagination.limit, + }).then((result) => { + setData(result.items) + setHasNextPage(Boolean(result.continue)) + setIsLoading(false) + }) + }, [tableState.sorting, tableState.filters, tableState.pagination.limit]) + + return ( + { /* fetch next page */ }, + prevPage: () => { /* fetch prev page */ }, + }} + > + + {isLoading + ? + : } + + + ) +} +``` + +## Sorting + +Column headers render a click-to-sort control. Click once for ascending, again for descending, then cycles back to ascending. No props required beyond the column definition: + +```tsx +{ + accessorKey: 'name', + header: ({ column }) => ( + + ), +} +``` + +## Filters + +Filters are autonomous composable components. Each filter reads and writes its own key in the shared filter state. + +### Select Filter + +Combobox popover with search. Allows single-value selection with a clear button to reset. Use the `searchable` prop to enable filtering within the options list: + +```tsx + +``` + +### Checkbox Filter + +Multi-value checkbox popover. Selected values display as badges in the trigger button. Includes a clear button to reset the selection. Stores selected values as `string[]`: + +```tsx + +``` + +### Date Picker Filter + +Popover with CalendarDatePicker integration. Supports optional `minDate` and `maxDate` constraints: + +```tsx + +``` + +## Inline Content + +Render custom content inline within the table body — for create forms, edit forms, detail panels, etc. + +### Top Position (Create) + +```tsx +const [isCreating, setIsCreating] = useState(false) + + setIsCreating(false)}> + {({ onClose }) => ( +
+ + + +
+ )} +
+``` + +### Row Position (Edit) + +```tsx +const [editingId, setEditingId] = useState(null) + + setEditingId(null)} +> + {({ onClose, rowData }) => ( +
+ + + +
+ )} +
+``` + +## Customization + +### Sub-Element Class Names + +`DataTable.Content` accepts className props for every sub-element: + +```tsx + +``` + +`rowClassName` and `cellClassName` also accept functions for dynamic styling: + +```tsx + row.original.status === 'inactive' ? 'opacity-50' : ''} +/> +``` + +### Data Slot Selectors + +Every element has a `data-slot` attribute for CSS-only customization: + +| Element | data-slot | +|---------|-----------| +| Wrapper | `dt` | +| Table | `dt-table` | +| Header | `dt-header` | +| Header row | `dt-header-row` | +| Header cell | `dt-header-cell` | +| Body | `dt-body` | +| Row | `dt-row` | +| Cell | `dt-cell` | +| Pagination | `dt-pagination` | +| Search | `dt-search` | +| Filter | `dt-filter` | +| Bulk actions | `dt-bulk-actions` | +| Row actions | `dt-row-actions` | +| Loading | `dt-loading` | +| Column header | `dt-column-header` | +| Inline content | `dt-inline-content` | +| Empty state | `dt-empty` | + +The wrapper has the class `datum-ui-data-table` for scoping: + +```css +.datum-ui-data-table [data-slot="dt-row"]:hover { + background-color: #f1f5f9; +} +``` + +## URL State Sync (nuqs) + +Sync table state to URL query parameters with the `useNuqsAdapter` hook. Requires `nuqs` to be installed. + +```tsx +import { useNuqsAdapter } from '@datum-cloud/datum-ui/data-table' +import { parseAsString } from 'nuqs' + +// Zero config — sorting, search, page, size synced automatically +const stateAdapter = useNuqsAdapter() + +// With custom filter parsers +const stateAdapter = useNuqsAdapter({ + filters: { + status: parseAsString.withDefault(''), + }, +}) + +const tableState = useDataTableClient({ data, columns, stateAdapter }) +``` + +URL params: `?sort=[{"id":"name","desc":false}]&q=hello&page=0&size=20&status=active` + +## API Reference + +### `useDataTableClient(options)` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `data` | `TData[]` | -- | Table data array | +| `columns` | `ColumnDef[]` | -- | TanStack column definitions | +| `pageSize` | `number` | `20` | Initial page size | +| `getRowId` | `(row: TData) => string` | -- | Custom row ID accessor | +| `enableRowSelection` | `boolean \| SelectionColumnOptions` | `false` | Enable row selection with auto-injected checkbox column. Pass `true` for defaults or an options object for customization | +| `defaultSort` | `SortingState` | `[]` | Initial sort state | +| `defaultFilters` | `FilterValue` | `{}` | Initial filter state | +| `searchableColumns` | `string[]` | -- | Columns to search in | +| `searchFn` | `(row, search) => boolean` | -- | Custom search function | +| `stateAdapter` | `StateAdapter` | -- | URL state sync adapter | + +### `useDataTableServer(options)` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `columns` | `ColumnDef[]` | -- | TanStack column definitions | +| `limit` | `number` | `20` | Page size for cursor pagination | +| `getRowId` | `(row: TData) => string` | -- | Custom row ID accessor | +| `enableRowSelection` | `boolean \| SelectionColumnOptions` | `false` | Enable row selection with auto-injected checkbox column. Pass `true` for defaults or an options object for customization | +| `defaultSort` | `SortingState` | `[]` | Initial sort state | +| `defaultFilters` | `FilterValue` | `{}` | Initial filter state | +| `stateAdapter` | `StateAdapter` | -- | URL state sync adapter | + +### `DataTable` Namespace + +| Component | Description | +|-----------|-------------| +| `DataTable.Client` | Client-mode provider with full sorting, filtering, pagination | +| `DataTable.Server` | Server-mode provider with manual pagination/sorting/filtering | +| `DataTable.Content` | Table rendering with header, body, and empty state. Accepts sub-element className props (`tableClassName`, `headerClassName`, `headerRowClassName`, `headerCellClassName`, `bodyClassName`, `rowClassName`, `cellClassName`) | +| `DataTable.ColumnHeader` | Click-to-sort column header | +| `DataTable.Pagination` | Page navigation with page size selector | +| `DataTable.Search` | Debounced search input | +| `DataTable.RowActions` | Per-row action dropdown from `ActionItem[]` | +| `DataTable.BulkActions` | Render prop for selected rows | +| `DataTable.Loading` | Skeleton loading state | +| `DataTable.InlineContent` | Render custom content inline within the table body. Supports `position` (`"top"` or `"row"`), `open`, `onClose`, and `rowId` props | +| `DataTable.SelectFilter` | Combobox popover filter with single-select and optional `searchable` prop | +| `DataTable.CheckboxFilter` | Multi-value checkbox popover filter with badges and clear button | +| `DataTable.DatePickerFilter` | CalendarDatePicker filter with optional `minDate` and `maxDate` constraints | + +### `createSelectionColumn(options?)` + +Creates a selection checkbox column definition. Use when you need to customize or manually position the selection column: + +```tsx +import { createSelectionColumn } from '@datum-cloud/datum-ui/data-table' + +// Custom styling +const cols = [createSelectionColumn({ className: 'size-5' }), ...myColumns] + +// Custom position (not first) +const cols = [nameCol, createSelectionColumn(), emailCol] +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `className` | `string` | -- | Class for cell checkboxes | +| `headerClassName` | `string` | -- | Class for header checkbox | +| `renderHeader` | `(props) => ReactNode` | -- | Custom header render | +| `renderCell` | `(props) => ReactNode` | -- | Custom cell render | + +> **Note:** When `enableRowSelection` is `true`, a selection column is auto-injected as the first column. If your columns already include one with `id: 'select'`, auto-injection is skipped. + +### `useNuqsAdapter(options?)` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `filters` | `Record` | -- | Custom filter parsers to sync to URL | + +Returns a `StateAdapter` that syncs sorting, search, pageIndex, pageSize, and custom filters to URL query parameters. diff --git a/apps/docs/content/docs/components/features/meta.json b/apps/docs/content/docs/components/features/meta.json index 6434df31..687b32f8 100644 --- a/apps/docs/content/docs/components/features/meta.json +++ b/apps/docs/content/docs/components/features/meta.json @@ -18,6 +18,7 @@ "dropzone", "file-input-button", "---Data Display---", + "data-table", "tag-input", "time-range-picker", "---Feedback---", diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/apps/docs/next-env.d.ts +++ b/apps/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/storybook/stories/features/data-table.stories.tsx b/apps/storybook/stories/features/data-table.stories.tsx new file mode 100644 index 00000000..352fee5f --- /dev/null +++ b/apps/storybook/stories/features/data-table.stories.tsx @@ -0,0 +1,650 @@ +import type { ColumnDef } from '@tanstack/react-table' +import type { Meta, StoryObj } from 'storybook-react-rsbuild' +import { + DataTable, + useDataTableClient, + useDataTableLoading, + useDataTableServer, +} from '@datum-cloud/datum-ui/data-table' +import { useState } from 'react' + +const meta: Meta = { + title: 'Features/DataTable', +} + +export default meta + +type Story = StoryObj + +// --------------------------------------------------------------------------- +// Shared types and sample data +// --------------------------------------------------------------------------- + +interface User { + id: string + name: string + email: string + role: string + status: string + createdAt: string +} + +const sampleUsers: User[] = [ + { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin', status: 'active', createdAt: '2024-01-15' }, + { id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Editor', status: 'active', createdAt: '2024-02-20' }, + { id: '3', name: 'Carol Williams', email: 'carol@example.com', role: 'Viewer', status: 'inactive', createdAt: '2024-03-10' }, + { id: '4', name: 'Dave Brown', email: 'dave@example.com', role: 'Editor', status: 'active', createdAt: '2024-04-05' }, + { id: '5', name: 'Eve Davis', email: 'eve@example.com', role: 'Admin', status: 'active', createdAt: '2024-05-12' }, + { id: '6', name: 'Frank Miller', email: 'frank@example.com', role: 'Viewer', status: 'inactive', createdAt: '2024-06-18' }, + { id: '7', name: 'Grace Wilson', email: 'grace@example.com', role: 'Editor', status: 'active', createdAt: '2024-07-22' }, + { id: '8', name: 'Hank Moore', email: 'hank@example.com', role: 'Viewer', status: 'active', createdAt: '2024-08-30' }, + { id: '9', name: 'Ivy Taylor', email: 'ivy@example.com', role: 'Admin', status: 'inactive', createdAt: '2024-09-14' }, + { id: '10', name: 'Jack Anderson', email: 'jack@example.com', role: 'Editor', status: 'active', createdAt: '2024-10-01' }, + { id: '11', name: 'Kate Thomas', email: 'kate@example.com', role: 'Viewer', status: 'active', createdAt: '2024-10-15' }, + { id: '12', name: 'Leo Jackson', email: 'leo@example.com', role: 'Admin', status: 'active', createdAt: '2024-11-01' }, +] + +const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'email', + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'role', + header: 'Role', + }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + }, + { + id: 'actions', + cell: ({ row }) => ( + alert(`View ${row.original.name}`) }, + { label: 'Edit', onClick: () => alert(`Edit ${row.original.name}`) }, + { label: 'Delete', variant: 'destructive', onClick: () => alert(`Delete ${row.original.name}`) }, + ]} + /> + ), + }, +] + +// --------------------------------------------------------------------------- +// Client DataTable -- full-featured with filters, search, pagination +// --------------------------------------------------------------------------- + +function ClientTableDemo() { + const tableState = useDataTableClient({ + data: sampleUsers, + columns, + pageSize: 5, + getRowId: row => row.id, + enableRowSelection: true, + defaultSort: [{ id: 'name', desc: false }], + }) + + return ( + +
+
+ + + +
+ + {selectedRows => ( +
+ + {selectedRows.length} + {' '} + selected + + +
+ )} +
+ + +
+
+ ) +} + +export const ClientTable: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Server DataTable -- fetches from JSONPlaceholder API +// --------------------------------------------------------------------------- + +interface Post { + userId: number + id: number + title: string + body: string +} + +const postColumns: ColumnDef[] = [ + { + accessorKey: 'id', + header: ({ column }) => ( + + ), + size: 80, + }, + { + accessorKey: 'title', + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'body', + header: 'Body', + cell: ({ row }) => ( + {row.original.body} + ), + }, + { + accessorKey: 'userId', + header: 'Author', + cell: ({ row }) => ( + + User + {row.original.userId} + + ), + }, + { + id: 'actions', + cell: ({ row }) => ( + alert(`Post #${row.original.id}: ${row.original.title}`) }, + { label: 'Delete', variant: 'destructive', onClick: () => alert(`Delete post #${row.original.id}`) }, + ]} + /> + ), + }, +] + +function ServerTableContent() { + const { isLoading } = useDataTableLoading() + return ( +
+ + {isLoading + ? + : } + +
+ ) +} + +function ServerTableDemo() { + const tableState = useDataTableServer({ + columns: postColumns, + limit: 10, + getRowId: (row: Post) => String(row.id), + fetchFn: async (args) => { + const start = args.cursor ? Number(args.cursor) : 0 + const res = await fetch( + `https://jsonplaceholder.typicode.com/posts?_start=${start}&_limit=${args.limit}`, + ) + return res.json() + }, + transform: (posts) => { + const start = posts.length > 0 ? (posts[0]?.id ?? 1) - 1 : 0 + const nextStart = start + posts.length + return { + data: posts, + cursor: nextStart < 100 ? String(nextStart) : undefined, + hasNextPage: posts.length === 10 && nextStart < 100, + } + }, + }) + + return ( + + + + ) +} + +export const ServerTable: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Minimal -- simplest possible usage +// --------------------------------------------------------------------------- + +function MinimalDemo() { + const tableState = useDataTableClient({ + data: sampleUsers.slice(0, 5), + columns: columns.filter(c => 'accessorKey' in c && c.accessorKey !== 'createdAt'), + }) + + return ( + + + + ) +} + +export const Minimal: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// With Checkbox Filters +// --------------------------------------------------------------------------- + +function CheckboxFilterDemo() { + const tableState = useDataTableClient({ + data: sampleUsers, + columns, + pageSize: 6, + }) + + return ( + +
+
+ + + +
+ + +
+
+ ) +} + +export const WithCheckboxFilters: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Multi-Select with Bulk Actions +// --------------------------------------------------------------------------- + +function MultiSelectBulkActionsDemo() { + const tableState = useDataTableClient({ + data: sampleUsers, + columns, + pageSize: 6, + getRowId: row => row.id, + enableRowSelection: true, + defaultSort: [{ id: 'name', desc: false }], + }) + + return ( + +
+
+ + + +
+ + {selectedRows => ( +
+ + {selectedRows.length} + {' '} + selected + + + +
+ )} +
+ + +
+
+ ) +} + +export const MultiSelectBulkActions: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Custom Styling -- demonstrates className props and data-slot CSS targeting +// --------------------------------------------------------------------------- + +function CustomStylingDemo() { + const tableState = useDataTableClient({ + data: sampleUsers, + columns: columns.filter(c => !('id' in c && c.id === 'actions')), + pageSize: 6, + getRowId: row => row.id, + }) + + return ( + +
+ {/* Inline styles targeting data-slot attributes */} + +
+ + +
+ + +
+
+ ) +} + +export const CustomStyling: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Inline Content -- create form (top) and edit form (row) +// --------------------------------------------------------------------------- + +function InlineContentDemo() { + const [isCreating, setIsCreating] = useState(false) + const [editingRowId, setEditingRowId] = useState(null) + + const inlineColumns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'email', + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'role', + header: 'Role', + }, + { + accessorKey: 'status', + header: 'Status', + }, + { + id: 'actions', + cell: ({ row }) => ( + setEditingRowId(row.original.id), + }, + { + label: 'Delete', + variant: 'destructive', + onClick: () => alert(`Delete ${row.original.name}`), + }, + ]} + /> + ), + }, + ] + + const tableState = useDataTableClient({ + data: sampleUsers, + columns: inlineColumns, + pageSize: 6, + getRowId: row => row.id, + }) + + return ( + +
+
+ + +
+ + setIsCreating(false)} + > + {({ onClose }) => ( +
+ New User: + + + + + +
+ )} +
+ + setEditingRowId(null)} + > + {({ onClose, rowData }) => ( +
+ Editing: + + + + +
+ )} +
+ + + +
+
+ ) +} + +export const InlineContent: Story = { + render: () => , +} diff --git a/packages/datum-ui/package.json b/packages/datum-ui/package.json index 35f5a1de..71ec26a2 100644 --- a/packages/datum-ui/package.json +++ b/packages/datum-ui/package.json @@ -1,7 +1,7 @@ { "name": "@datum-cloud/datum-ui", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "repository": { "url": "https://github.com/datum-cloud/datum-ui" @@ -235,6 +235,10 @@ "./toast": { "types": "./dist/components/features/toast/index.d.ts", "default": "./dist/toast/index.mjs" + }, + "./data-table": { + "types": "./dist/components/features/data-table/index.d.ts", + "default": "./dist/data-table/index.mjs" } }, "main": "./dist/index.mjs", @@ -277,6 +281,7 @@ "@radix-ui/react-tooltip": ">=1", "@radix-ui/react-visually-hidden": ">=1", "@stepperize/react": ">=4", + "@tanstack/react-table": ">=8", "@tanstack/react-virtual": ">=3", "cmdk": ">=1", "date-fns": ">=4", @@ -287,6 +292,7 @@ "leaflet.markercluster": ">=1.5", "motion": ">=11", "nprogress": ">=0.2", + "nuqs": ">=2", "react": ">=19", "react-day-picker": ">=9", "react-dom": ">=19", @@ -356,6 +362,9 @@ "@stepperize/react": { "optional": true }, + "@tanstack/react-table": { + "optional": true + }, "@tanstack/react-virtual": { "optional": true }, @@ -386,6 +395,9 @@ "nprogress": { "optional": true }, + "nuqs": { + "optional": true + }, "react-day-picker": { "optional": true }, @@ -428,6 +440,7 @@ "@repo/config": "workspace:*", "@repo/shadcn": "workspace:*", "@stepperize/react": "^6.1.0", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", @@ -449,6 +462,7 @@ "leaflet.markercluster": "^1.5.3", "motion": "^12.34.4", "nprogress": "^0.2.0", + "nuqs": "^2.8.9", "react": "^19", "react-day-picker": "^9.14.0", "react-dom": "^19", diff --git a/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx b/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx index 905e518e..11872605 100644 --- a/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx +++ b/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx @@ -83,9 +83,10 @@ interface CalendarDatePickerProps disableFuture?: boolean disablePast?: boolean maxRange?: number // Maximum number of days between start and end date + popoverClassName?: string } -export function CalendarDatePicker({ ref, id = 'calendar-date-picker', className, triggerClassName, date, closeOnSelect = false, numberOfMonths = 2, yearsRange = 10, onDateSelect, variant, placeholder, excludePresets, customPresets, minDate, maxDate, disableFuture = false, disablePast = false, maxRange, ...props }: CalendarDatePickerProps & { ref?: React.RefObject }) { +export function CalendarDatePicker({ ref, id = 'calendar-date-picker', className, triggerClassName, date, closeOnSelect = false, numberOfMonths = 2, yearsRange = 10, onDateSelect, variant, placeholder, excludePresets, customPresets, minDate, maxDate, disableFuture = false, disablePast = false, maxRange, popoverClassName, ...props }: CalendarDatePickerProps & { ref?: React.RefObject }) { const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) const [selectedRange, setSelectedRange] = React.useState( numberOfMonths === 2 ? 'This Year' : 'Today', @@ -734,7 +735,7 @@ export function CalendarDatePicker({ ref, id = 'calendar-date-picker', className {isPopoverOpen && ( +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { DataTableStoreContext, TableInstanceContext } from '../core/data-table-context' +import { createDataTableStore } from '../core/store' +import { CheckboxFilter } from '../filters/checkbox-filter' + +const statusOptions = [ + { label: 'Running', value: 'running' }, + { label: 'Pending', value: 'pending' }, + { label: 'Stopped', value: 'stopped' }, +] + +const mockTable = { + getCanNextPage: () => false, + getCanPreviousPage: () => false, + nextPage: vi.fn(), + previousPage: vi.fn(), + getPageCount: () => 1, + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + getAllColumns: () => [], + getFilteredSelectedRowModel: () => ({ rows: [] }), + getState: () => ({ pagination: { pageIndex: 0, pageSize: 20 } }), + getFilteredRowModel: () => ({ rows: [] }), +} as any + +function renderFilter(props: { + readonly filters?: Record + readonly setFilter?: (...args: any[]) => void + readonly clearFilter?: (...args: any[]) => void +}) { + const { filters = {}, setFilter, clearFilter } = props + + const store = createDataTableStore>({ data: [], mode: 'client', defaultFilters: filters }) + + if (setFilter) { + vi.spyOn(store, 'setFilter').mockImplementation(setFilter as any) + } + if (clearFilter) { + vi.spyOn(store, 'clearFilter').mockImplementation(clearFilter as any) + } + + function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + + ) + } + + return render( + + + , + ) +} + +describe('checkboxFilter', () => { + it('renders trigger button with label when no selection', () => { + renderFilter({}) + + expect(screen.getByRole('button', { name: /status/i })).toBeInTheDocument() + }) + + it('opens popover and shows checkbox options', async () => { + const user = userEvent.setup() + renderFilter({}) + + await user.click(screen.getByRole('button', { name: /status/i })) + + expect(screen.getByText('Running')).toBeInTheDocument() + expect(screen.getByText('Pending')).toBeInTheDocument() + expect(screen.getByText('Stopped')).toBeInTheDocument() + }) + + it('calls setFilter when checkbox toggled', async () => { + const setFilter = vi.fn() + const user = userEvent.setup() + renderFilter({ setFilter }) + + await user.click(screen.getByRole('button', { name: /status/i })) + await user.click(screen.getByText('Running')) + + expect(setFilter).toHaveBeenCalledWith('status', ['running']) + }) + + it('shows badges for selected values', async () => { + const user = userEvent.setup() + renderFilter({ filters: { status: ['running', 'pending'] } }) + + const trigger = screen.getByTestId('dt-filter-trigger') + expect(trigger).toHaveTextContent('Running') + expect(trigger).toHaveTextContent('Pending') + + // Open popover and verify checkboxes are checked + await user.click(trigger) + const runningCheckbox = screen.getByRole('checkbox', { name: 'Running' }) + const pendingCheckbox = screen.getByRole('checkbox', { name: 'Pending' }) + expect(runningCheckbox).toBeChecked() + expect(pendingCheckbox).toBeChecked() + }) + + it('shows +N more when selections exceed MAX_VISIBLE_BADGES', () => { + renderFilter({ filters: { status: ['running', 'pending', 'stopped'] } }) + + const trigger = screen.getByTestId('dt-filter-trigger') + expect(trigger).toHaveTextContent('Running') + expect(trigger).toHaveTextContent('Pending') + expect(trigger).toHaveTextContent('+1 more') + }) + + it('calls clearFilter when Clear button is clicked', async () => { + const clearFilter = vi.fn() + const user = userEvent.setup() + renderFilter({ filters: { status: ['running'] }, clearFilter }) + + await user.click(screen.getByTestId('dt-filter-trigger')) + await user.click(screen.getByText('Clear')) + + expect(clearFilter).toHaveBeenCalledWith('status') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/client-provider.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/client-provider.test.tsx new file mode 100644 index 00000000..cd51f9c5 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/client-provider.test.tsx @@ -0,0 +1,48 @@ +/// +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ClientProvider } from '../core/client-provider' +import { useDataTableClient } from '../hooks/use-data-table-client' +import { useDataTableContext } from '../hooks/use-selectors' + +interface TestRow { + readonly id: string + readonly name: string +} + +const testColumns = [{ accessorKey: 'name' as const, header: 'Name' }] +const testData: TestRow[] = [{ id: '1', name: 'Pod A' }] + +function ContextReader() { + const ctx = useDataTableContext() + return ( +
+ {ctx.mode} + {ctx.search} + {ctx.pagination.pageSize} +
+ ) +} + +function TestWrapper() { + const { store, table } = useDataTableClient({ + data: testData, + columns: testColumns, + pageSize: 20, + }) + return ( + + + + ) +} + +describe('clientProvider', () => { + it('provides context with client mode', () => { + render() + + expect(screen.getByTestId('mode')).toHaveTextContent('client') + expect(screen.getByTestId('search')).toHaveTextContent('') + expect(screen.getByTestId('page-size')).toHaveTextContent('20') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/column-header.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/column-header.test.tsx new file mode 100644 index 00000000..12fd4ae7 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/column-header.test.tsx @@ -0,0 +1,92 @@ +/// +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { DataTableColumnHeader } from '../components/column-header' + +function createMockColumn(overrides: { + canSort?: boolean + isSorted?: false | 'asc' | 'desc' +} = {}) { + const toggleSortingHandler = vi.fn() + return { + getCanSort: () => overrides.canSort ?? true, + getIsSorted: () => overrides.isSorted ?? (false as const), + toggleSorting: vi.fn(), + getToggleSortingHandler: () => toggleSortingHandler, + _toggleSortingHandler: toggleSortingHandler, + } as any +} + +describe('dataTableColumnHeader', () => { + it('renders title as plain text when column is not sortable', () => { + const column = createMockColumn({ canSort: false }) + + render() + + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders a button when column is sortable', () => { + const column = createMockColumn({ canSort: true }) + + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText('Name')).toBeInTheDocument() + }) + + it('calls toggle sorting handler when clicked', async () => { + const user = userEvent.setup() + const column = createMockColumn({ canSort: true, isSorted: false }) + + render() + + await user.click(screen.getByRole('button')) + + expect(column._toggleSortingHandler).toHaveBeenCalled() + }) + + it('calls toggle sorting handler when sorted asc and clicked', async () => { + const user = userEvent.setup() + const column = createMockColumn({ canSort: true, isSorted: 'asc' }) + + render() + + await user.click(screen.getByRole('button')) + + expect(column._toggleSortingHandler).toHaveBeenCalled() + }) + + it('calls toggle sorting handler when sorted desc and clicked', async () => { + const user = userEvent.setup() + const column = createMockColumn({ canSort: true, isSorted: 'desc' }) + + render() + + await user.click(screen.getByRole('button')) + + expect(column._toggleSortingHandler).toHaveBeenCalled() + }) + + it('sets data-slot on non-sortable header', () => { + const column = createMockColumn({ canSort: false }) + + const { container } = render( + , + ) + + expect(container.querySelector('[data-slot="dt-column-header"]')).toBeInTheDocument() + }) + + it('sets data-slot on sortable header', () => { + const column = createMockColumn({ canSort: true }) + + const { container } = render( + , + ) + + expect(container.querySelector('[data-slot="dt-column-header"]')).toBeInTheDocument() + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/content.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/content.test.tsx new file mode 100644 index 00000000..69d9e093 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/content.test.tsx @@ -0,0 +1,96 @@ +/// +import type { ColumnDef } from '@tanstack/react-table' +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { DataTableContent } from '../components/content' +import { ClientProvider } from '../core/client-provider' +import { useDataTableClient } from '../hooks/use-data-table-client' + +interface TestRow { + readonly id: string + readonly name: string +} + +const testColumns: ColumnDef[] = [{ accessorKey: 'name', header: 'Name' }] +const testData: TestRow[] = [ + { id: '1', name: 'Pod A' }, + { id: '2', name: 'Pod B' }, +] + +function TestWrapper({ + data, + columns, + children, +}: { + readonly data: TestRow[] + readonly columns: ColumnDef[] + readonly children: ReactNode +}) { + const { store, table } = useDataTableClient({ data, columns }) + return ( + + {children} + + ) +} + +describe('dataTableContent', () => { + it('renders table with data', () => { + render( + + + , + ) + + expect(screen.getByText('Pod A')).toBeInTheDocument() + expect(screen.getByText('Pod B')).toBeInTheDocument() + }) + + it('renders empty message when no data', () => { + render( + + + , + ) + + expect(screen.getByText('No pods found')).toBeInTheDocument() + }) + + it('renders default empty message', () => { + render( + + + , + ) + + expect(screen.getByText('No results.')).toBeInTheDocument() + }) + + it('renders data-slot attributes on all elements', () => { + const { container } = render( + + + , + ) + + expect(container.querySelector('[data-slot="dt"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="dt-table"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="dt-header"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="dt-header-row"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="dt-header-cell"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="dt-body"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="dt-row"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="dt-cell"]')).toBeInTheDocument() + }) + + it('renders data-slot="dt-empty" when no data', () => { + const { container } = render( + + + , + ) + + expect(container.querySelector('[data-slot="dt-empty"]')).toBeInTheDocument() + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx new file mode 100644 index 00000000..5a98df31 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx @@ -0,0 +1,28 @@ +/// +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useDataTableContext, useDataTableStore, useTableInstance } from '../core/data-table-context' + +describe('useDataTableStore', () => { + it('throws when used outside provider', () => { + expect(() => { + renderHook(() => useDataTableStore()) + }).toThrow('useDataTableStore must be used within a or provider') + }) +}) + +describe('useTableInstance', () => { + it('throws when used outside provider', () => { + expect(() => { + renderHook(() => useTableInstance()) + }).toThrow('useTableInstance must be used within a or provider') + }) +}) + +describe('useDataTableContext (legacy)', () => { + it('throws when used outside provider', () => { + expect(() => { + renderHook(() => useDataTableContext()) + }).toThrow('useDataTableContext must be used within a DataTable provider') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/date-picker-filter.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/date-picker-filter.test.tsx new file mode 100644 index 00000000..69a40e92 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/date-picker-filter.test.tsx @@ -0,0 +1,93 @@ +/// +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { DataTableStoreContext, TableInstanceContext } from '../core/data-table-context' +import { createDataTableStore } from '../core/store' +import { DatePickerFilter } from '../filters/date-picker-filter' + +vi.mock('../../calendar-date-picker/calendar-date-picker', () => ({ + CalendarDatePicker: ({ placeholder, onDateSelect, disableFuture, disablePast }: any) => ( + + ), +})) + +const mockTable = { + getCanNextPage: () => false, + getCanPreviousPage: () => false, + nextPage: vi.fn(), + previousPage: vi.fn(), + getPageCount: () => 1, + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + getAllColumns: () => [], + getFilteredSelectedRowModel: () => ({ rows: [] }), + getState: () => ({ pagination: { pageIndex: 0, pageSize: 20 } }), + getFilteredRowModel: () => ({ rows: [] }), +} as any + +function renderFilter(props: { + readonly setFilter?: (...args: any[]) => void + readonly extraProps?: Record +}) { + const { setFilter, extraProps = {} } = props + const store = createDataTableStore>({ data: [], mode: 'client' }) + + if (setFilter) { + vi.spyOn(store, 'setFilter').mockImplementation(setFilter as any) + } + + function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + + ) + } + + return render( + + + , + ) +} + +describe('datePickerFilter', () => { + it('renders with label', () => { + renderFilter({}) + + expect(screen.getByText('Created')).toBeInTheDocument() + }) + + it('renders CalendarDatePicker component', () => { + renderFilter({}) + + expect(screen.getByTestId('calendar-date-picker')).toBeInTheDocument() + }) + + it('calls setFilter when a date is selected', () => { + const setFilter = vi.fn() + renderFilter({ setFilter }) + + fireEvent.click(screen.getByTestId('calendar-date-picker')) + expect(setFilter).toHaveBeenCalledWith('createdAt', new Date('2025-01-15').toISOString()) + }) + + it('passes date constraint props to CalendarDatePicker', () => { + renderFilter({ extraProps: { disableFuture: true, disablePast: true } }) + + const picker = screen.getByTestId('calendar-date-picker') + expect(picker).toHaveAttribute('data-disable-future', 'true') + expect(picker).toHaveAttribute('data-disable-past', 'true') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/filter-engine.test.ts b/packages/datum-ui/src/components/features/data-table/__tests__/filter-engine.test.ts new file mode 100644 index 00000000..ea46d220 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/filter-engine.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import { applyFilters, FILTER_STRATEGIES } from '../core/filter-engine' + +describe('fILTER_STRATEGIES', () => { + describe('checkbox', () => { + const strategy = FILTER_STRATEGIES.checkbox + + it('returns true when filterValue is empty array', () => { + expect(strategy('Engineering', [])).toBe(true) + }) + + it('returns true when filterValue is null', () => { + expect(strategy('Engineering', null)).toBe(true) + }) + + it('returns true when cellValue is in filterValue array', () => { + expect(strategy('Engineering', ['Engineering', 'Design'])).toBe(true) + }) + + it('returns false when cellValue is not in filterValue array', () => { + expect(strategy('Marketing', ['Engineering', 'Design'])).toBe(false) + }) + + it('handles array cellValue with intersection check', () => { + expect(strategy(['a', 'b'], ['b', 'c'])).toBe(true) + expect(strategy(['a', 'b'], ['c', 'd'])).toBe(false) + }) + }) + + describe('select', () => { + const strategy = FILTER_STRATEGIES.select + + it('returns true when filterValue is null', () => { + expect(strategy('active', null)).toBe(true) + }) + + it('returns true when filterValue is empty string', () => { + expect(strategy('active', '')).toBe(true) + }) + + it('returns true on exact match', () => { + expect(strategy('active', 'active')).toBe(true) + }) + + it('returns false on mismatch', () => { + expect(strategy('active', 'inactive')).toBe(false) + }) + + it('compares non-string values by identity', () => { + expect(strategy(42, 42)).toBe(true) + expect(strategy(42, 43)).toBe(false) + }) + }) + + describe('date-gte', () => { + const strategy = FILTER_STRATEGIES['date-gte'] + + it('returns true when filterValue is null', () => { + expect(strategy('2024-01-01', null)).toBe(true) + }) + + it('returns true when cellDate >= filterDate', () => { + expect(strategy('2024-06-01', '2024-01-01')).toBe(true) + }) + + it('returns false when cellDate < filterDate', () => { + expect(strategy('2023-06-01', '2024-01-01')).toBe(false) + }) + + it('returns true on exact date match', () => { + expect(strategy('2024-01-01', '2024-01-01')).toBe(true) + }) + + it('returns true when cellValue is invalid date', () => { + expect(strategy('not-a-date', '2024-01-01')).toBe(true) + }) + + it('returns true when filterValue is invalid date', () => { + expect(strategy('2024-01-01', 'not-a-date')).toBe(true) + }) + }) + + describe('date-lte', () => { + const strategy = FILTER_STRATEGIES['date-lte'] + + it('returns true when cellDate <= filterDate', () => { + expect(strategy('2023-06-01', '2024-01-01')).toBe(true) + }) + + it('returns false when cellDate > filterDate', () => { + expect(strategy('2024-06-01', '2024-01-01')).toBe(false) + }) + + it('returns true when either date is invalid', () => { + expect(strategy('garbage', '2024-01-01')).toBe(true) + expect(strategy('2024-01-01', 'garbage')).toBe(true) + }) + }) +}) + +describe('applyFilters', () => { + const data = [ + { id: '1', name: 'Alice', department: 'Engineering', status: 'active', joinedAt: '2024-01-01' }, + { id: '2', name: 'Bob', department: 'Design', status: 'inactive', joinedAt: '2023-06-01' }, + { id: '3', name: 'Charlie', department: 'Engineering', status: 'active', joinedAt: '2024-06-01' }, + { id: '4', name: 'Diana', department: 'Marketing', status: 'pending', joinedAt: '2022-01-01' }, + ] + + it('returns all data when no filters and no search', () => { + const result = applyFilters(data, {}, '', new Map(), {}, {}) + expect(result).toBe(data) + }) + + it('applies checkbox filter via registered strategy', () => { + const registered = new Map([['department', 'checkbox' as const]]) + const result = applyFilters(data, { department: ['Engineering'] }, '', registered, {}, {}) + expect(result).toHaveLength(2) + expect(result.map(r => r.name)).toEqual(['Alice', 'Charlie']) + }) + + it('applies select filter via registered strategy', () => { + const registered = new Map([['status', 'select' as const]]) + const result = applyFilters(data, { status: 'active' }, '', registered, {}, {}) + expect(result).toHaveLength(2) + }) + + it('applies date-gte filter', () => { + const registered = new Map([['joinedAt', 'date-gte' as const]]) + const result = applyFilters(data, { joinedAt: '2024-01-01' }, '', registered, {}, {}) + expect(result).toHaveLength(2) + }) + + it('custom filterFn overrides registered strategy', () => { + const registered = new Map([['department', 'checkbox' as const]]) + const customFns = { + department: (cellValue: unknown, filterValue: unknown) => cellValue === filterValue, + } + const result = applyFilters(data, { department: 'Engineering' }, '', registered, customFns, {}) + expect(result).toHaveLength(2) + }) + + it('applies global search with searchableColumns', () => { + const result = applyFilters(data, {}, 'alice', new Map(), {}, { searchableColumns: ['name'] }) + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('Alice') + }) + + it('applies global search with custom searchFn', () => { + const searchFn = (row: typeof data[0], query: string) => + row.name.toLowerCase().startsWith(query.toLowerCase()) + const result = applyFilters(data, {}, 'ch', new Map(), {}, { searchFn }) + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('Charlie') + }) + + it('combines column filters and search', () => { + const registered = new Map([['department', 'checkbox' as const]]) + const result = applyFilters(data, { department: ['Engineering'] }, 'charlie', registered, {}, { searchableColumns: ['name'] }) + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('Charlie') + }) + + it('handles unregistered column filter gracefully (skips it)', () => { + const result = applyFilters(data, { unknownColumn: 'value' }, '', new Map(), {}, {}) + expect(result).toEqual(data) + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/inline-content.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/inline-content.test.tsx new file mode 100644 index 00000000..bf4510c4 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/inline-content.test.tsx @@ -0,0 +1,105 @@ +/// +import type { ColumnDef } from '@tanstack/react-table' +import type { ReactNode } from 'react' +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { DataTableInlineContent } from '../components/inline-content' +import { ClientProvider } from '../core/client-provider' +import { useDataTableClient } from '../hooks/use-data-table-client' + +interface TestRow { + readonly id: string + readonly name: string +} + +const testColumns: ColumnDef[] = [{ accessorKey: 'name', header: 'Name' }] +const testData: TestRow[] = [ + { id: '1', name: 'Row A' }, + { id: '2', name: 'Row B' }, +] + +let capturedStore: any = null + +function TestWrapper({ + data, + columns, + children, +}: { + readonly data: TestRow[] + readonly columns: ColumnDef[] + readonly children: ReactNode +}) { + const { store, table } = useDataTableClient({ data, columns }) + capturedStore = store + return ( + + {children} + + ) +} + +describe('dataTableInlineContent', () => { + it('registers inline content on mount', () => { + const onClose = vi.fn() + const renderFn = vi.fn() + + render( + + + {renderFn} + + , + ) + + const state = capturedStore.getSnapshot() + expect(state.inlineContents).toHaveLength(1) + expect(state.inlineContents[0]).toMatchObject({ + position: 'top', + open: true, + onClose, + render: renderFn, + }) + }) + + it('unregisters inline content on unmount', () => { + const { unmount } = render( + + + {vi.fn()} + + , + ) + + expect(capturedStore.getSnapshot().inlineContents).toHaveLength(1) + + unmount() + + expect(capturedStore.getSnapshot().inlineContents).toHaveLength(0) + }) + + it('renders nothing itself', () => { + const { container } = render( + + + {vi.fn()} + + , + ) + + // InlineContent renders null; the provider wrapper div is still present + expect(container.querySelector('[data-slot="dt-inline-content"]')).toBeNull() + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/loading.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/loading.test.tsx new file mode 100644 index 00000000..802dd2ec --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/loading.test.tsx @@ -0,0 +1,22 @@ +/// +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { DataTableLoading } from '../components/loading' + +describe('dataTableLoading', () => { + it('renders skeleton rows', () => { + const { container } = render() + + // 1 header row + 3 data rows = 4 flex containers with gap-4 + const flexRows = container.querySelectorAll('.flex.gap-4') + expect(flexRows.length).toBe(4) // header + 3 rows + }) + + it('uses default values', () => { + const { container } = render() + + // Default: 5 rows + 1 header = 6 + const flexRows = container.querySelectorAll('.flex.gap-4') + expect(flexRows.length).toBe(6) + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/nuqs-adapter.test.ts b/packages/datum-ui/src/components/features/data-table/__tests__/nuqs-adapter.test.ts new file mode 100644 index 00000000..18d952f0 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/nuqs-adapter.test.ts @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useNuqsAdapter } from '../adapters/nuqs-adapter' + +// Mock nuqs before importing the adapter +const mockSetCoreState = vi.fn() +const mockSetFilterState = vi.fn() + +function createQueryStatesMock() { + let callCount = 0 + return () => { + callCount++ + // First call = core state, second call = filter state + if (callCount % 2 === 1) { + return [ + { sort: '-department,name', q: 'hello', page: 1, size: 20 }, + mockSetCoreState, + ] + } + return [ + { status: 'active' }, + mockSetFilterState, + ] + } +} + +vi.mock('nuqs', () => ({ + parseAsString: { withDefault: (d: unknown) => d }, + parseAsInteger: { withDefault: (d: unknown) => d }, + useQueryStates: createQueryStatesMock(), +})) + +describe('useNuqsAdapter', () => { + it('reads core state from nuqs query params', () => { + const { result } = renderHook(() => useNuqsAdapter()) + const state = result.current.read() + + expect(state.sorting).toEqual([ + { id: 'department', desc: true }, + { id: 'name', desc: false }, + ]) + expect(state.search).toBe('hello') + expect(state.pageIndex).toBe(1) + expect(state.pageSize).toBe(20) + }) + + it('reads filter state when filter parsers provided', () => { + const { result } = renderHook(() => + useNuqsAdapter({ + filters: { status: 'mockParser' }, + }), + ) + const state = result.current.read() + expect(state.filters).toEqual({ status: 'active' }) + }) + + it('writes core state back to nuqs', () => { + const { result } = renderHook(() => + useNuqsAdapter({ + filters: { status: 'mockParser' }, + }), + ) + + result.current.write({ + sorting: [{ id: 'date', desc: true }], + filters: { status: 'inactive' }, + search: 'test', + pageIndex: 3, + pageSize: 50, + }) + + expect(mockSetCoreState).toHaveBeenCalledWith({ + sort: '-date', + q: 'test', + page: 3, + size: 50, + }) + expect(mockSetFilterState).toHaveBeenCalledWith({ status: 'inactive' }) + }) + + it('omits filters from read when no filter parsers provided', () => { + const { result } = renderHook(() => useNuqsAdapter()) + const state = result.current.read() + expect(state.filters).toBeUndefined() + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/pagination.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/pagination.test.tsx new file mode 100644 index 00000000..02e66b83 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/pagination.test.tsx @@ -0,0 +1,70 @@ +/// +import type { ColumnDef } from '@tanstack/react-table' +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { DataTablePagination } from '../components/pagination' +import { ClientProvider } from '../core/client-provider' +import { useDataTableClient } from '../hooks/use-data-table-client' + +interface TestRow { + readonly id: string + readonly name: string +} + +const testColumns: ColumnDef[] = [{ accessorKey: 'name', header: 'Name' }] +const testData: TestRow[] = Array.from({ length: 25 }, (_, i) => ({ id: String(i), name: `Pod ${i}` })) + +function TestWrapper({ + data, + columns, + pageSize, + children, +}: { + readonly data: TestRow[] + readonly columns: ColumnDef[] + readonly pageSize?: number + readonly children: ReactNode +}) { + const { store, table } = useDataTableClient({ data, columns, pageSize }) + return ( + + {children} + + ) +} + +describe('dataTablePagination', () => { + it('renders row info in client mode', () => { + render( + + + , + ) + + expect(screen.getByText(/Showing 1 to 10 of 25 rows/)).toBeInTheDocument() + }) + + it('renders page number buttons in client mode', () => { + render( + + + , + ) + + // 25 items / 10 per page = 3 pages + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument() + }) + + it('renders rows per page selector', () => { + render( + + + , + ) + + expect(screen.getByText('Rows per page')).toBeInTheDocument() + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/row-actions.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/row-actions.test.tsx new file mode 100644 index 00000000..f47bff85 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/row-actions.test.tsx @@ -0,0 +1,73 @@ +/// +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { DataTableRowActions } from '../components/row-actions' + +// Create a minimal row mock +function createMockRow(data: T) { + return { original: data } as any +} + +describe('dataTableRowActions', () => { + it('renders dropdown trigger', () => { + render( + {} }, + { label: 'Delete', onClick: () => {}, variant: 'destructive' as const }, + ]} + />, + ) + + expect(screen.getByRole('button', { name: 'Open menu' })).toBeInTheDocument() + }) + + it('shows actions in dropdown when clicked', async () => { + const user = userEvent.setup() + + render( + {} }, + { label: 'Delete', onClick: () => {}, variant: 'destructive' as const }, + ]} + />, + ) + + await user.click(screen.getByRole('button', { name: 'Open menu' })) + + expect(screen.getByText('Edit')).toBeInTheDocument() + expect(screen.getByText('Delete')).toBeInTheDocument() + }) + + it('hides actions when hidden is true', () => { + render( + {} }, + { label: 'Hidden', onClick: () => {}, hidden: true }, + ]} + />, + ) + + // Only visible action exists, so trigger should render + expect(screen.getByRole('button', { name: 'Open menu' })).toBeInTheDocument() + }) + + it('returns null when all actions hidden', () => { + const { container } = render( + {}, hidden: true }, + ]} + />, + ) + + expect(container.innerHTML).toBe('') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/search.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/search.test.tsx new file mode 100644 index 00000000..5e262c35 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/search.test.tsx @@ -0,0 +1,52 @@ +/// +import type { ColumnDef } from '@tanstack/react-table' +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { DataTableSearch } from '../components/search' +import { ClientProvider } from '../core/client-provider' +import { useDataTableClient } from '../hooks/use-data-table-client' + +interface TestRow { + readonly id: string +} + +const testColumns: ColumnDef[] = [{ accessorKey: 'id', header: 'ID' }] +const emptyData: TestRow[] = [] + +function TestWrapper({ children }: { readonly children: ReactNode }) { + const { store, table } = useDataTableClient({ data: emptyData, columns: testColumns }) + return ( + + {children} + + ) +} + +describe('dataTableSearch', () => { + it('renders with placeholder', () => { + render( + + + , + ) + + expect(screen.getByPlaceholderText('Search pods...')).toBeInTheDocument() + }) + + it('accepts user input', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const input = screen.getByPlaceholderText('Search...') + await user.type(input, 'hello') + + expect(input).toHaveValue('hello') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/select-filter.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/select-filter.test.tsx new file mode 100644 index 00000000..4b88583c --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/select-filter.test.tsx @@ -0,0 +1,150 @@ +/// +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { DataTableStoreContext, TableInstanceContext } from '../core/data-table-context' +import { createDataTableStore } from '../core/store' +import { SelectFilter } from '../filters/select-filter' + +const statusOptions = [ + { label: 'Running', value: 'running' }, + { label: 'Pending', value: 'pending' }, + { label: 'Failed', value: 'failed' }, +] + +const mockTable = { + getCanNextPage: () => false, + getCanPreviousPage: () => false, + nextPage: vi.fn(), + previousPage: vi.fn(), + getPageCount: () => 1, + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + getAllColumns: () => [], + getFilteredSelectedRowModel: () => ({ rows: [] }), + getState: () => ({ pagination: { pageIndex: 0, pageSize: 20 } }), + getFilteredRowModel: () => ({ rows: [] }), +} as any + +function renderWithProvider( + ui: ReactNode, + overrides: { + readonly filters?: Record + readonly setFilter?: (...args: any[]) => void + readonly clearFilter?: (...args: any[]) => void + } = {}, +) { + const { filters = {}, setFilter, clearFilter } = overrides + + const store = createDataTableStore>({ data: [], mode: 'client', defaultFilters: filters }) + + if (setFilter) { + vi.spyOn(store, 'setFilter').mockImplementation(setFilter as any) + } + if (clearFilter) { + vi.spyOn(store, 'clearFilter').mockImplementation(clearFilter as any) + } + + function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + + ) + } + + return render({ui}) +} + +describe('selectFilter', () => { + it('renders trigger button with label', () => { + renderWithProvider( + , + ) + + expect(screen.getByText('Status')).toBeInTheDocument() + }) + + it('renders trigger button with custom placeholder', () => { + renderWithProvider( + , + ) + + expect(screen.getByText('Pick one')).toBeInTheDocument() + }) + + it('shows popover with options on click', async () => { + const user = userEvent.setup() + + renderWithProvider( + , + ) + + await user.click(screen.getByRole('combobox')) + + expect(screen.getByText('Running')).toBeInTheDocument() + expect(screen.getByText('Pending')).toBeInTheDocument() + expect(screen.getByText('Failed')).toBeInTheDocument() + }) + + it('selects an option and closes popover', async () => { + const user = userEvent.setup() + const setFilter = vi.fn() + + renderWithProvider( + , + { setFilter }, + ) + + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByText('Running')) + + expect(setFilter).toHaveBeenCalledWith('status', 'running') + }) + + it('shows clear button when value is set', async () => { + const user = userEvent.setup() + const clearFilter = vi.fn() + + renderWithProvider( + , + { filters: { status: 'running' }, clearFilter }, + ) + + // The selected label should be shown + expect(screen.getByText('Running')).toBeInTheDocument() + + // Click the clear button (X icon) + const clearButton = screen.getByRole('button', { name: /clear status filter/i }) + await user.click(clearButton) + + expect(clearFilter).toHaveBeenCalledWith('status') + }) + + it('shows search input when searchable is true (default)', async () => { + const user = userEvent.setup() + + renderWithProvider( + , + ) + + await user.click(screen.getByRole('combobox')) + + expect(screen.getByPlaceholderText('Search status...')).toBeInTheDocument() + }) + + it('hides search input when searchable is false', async () => { + const user = userEvent.setup() + + renderWithProvider( + , + ) + + await user.click(screen.getByRole('combobox')) + + expect(screen.queryByPlaceholderText('Search status...')).not.toBeInTheDocument() + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/selection-column.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/selection-column.test.tsx new file mode 100644 index 00000000..811873ee --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/selection-column.test.tsx @@ -0,0 +1,57 @@ +/// +import type { ColumnDef } from '@tanstack/react-table' +import { describe, expect, it } from 'vitest' +import { createSelectionColumn, hasSelectionColumn, withSelectionColumn } from '../columns/selection-column' + +describe('selectionColumn', () => { + it('creates a column with id "select"', () => { + const col = createSelectionColumn() + expect(col.id).toBe('select') + expect(col.enableSorting).toBe(false) + expect(col.enableHiding).toBe(false) + expect(col.size).toBe(40) + }) + + it('hasSelectionColumn returns true when select column exists', () => { + const cols: ColumnDef[] = [ + { id: 'select', header: 'Select' }, + { id: 'name', header: 'Name' }, + ] + expect(hasSelectionColumn(cols)).toBe(true) + }) + + it('hasSelectionColumn returns false when no select column', () => { + const cols: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + ] + expect(hasSelectionColumn(cols)).toBe(false) + }) + + it('withSelectionColumn prepends selection column', () => { + const cols: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + ] + const result = withSelectionColumn(cols) + expect(result).toHaveLength(2) + expect(result.at(0)?.id).toBe('select') + }) + + it('withSelectionColumn skips if select column already exists', () => { + const cols: ColumnDef[] = [ + { id: 'select', header: 'Custom Select' }, + { id: 'name', header: 'Name' }, + ] + const result = withSelectionColumn(cols) + expect(result).toHaveLength(2) + expect(result.at(0)).toBe(cols[0]) + }) + + it('withSelectionColumn passes options to createSelectionColumn', () => { + const cols: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + ] + const result = withSelectionColumn(cols, { className: 'custom-class' }) + expect(result).toHaveLength(2) + expect(result.at(0)?.id).toBe('select') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx new file mode 100644 index 00000000..f2a4b7c3 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx @@ -0,0 +1,62 @@ +/// +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ServerProvider } from '../core/server-provider' +import { useDataTableServer } from '../hooks/use-data-table-server' +import { useDataTableContext } from '../hooks/use-selectors' + +interface TestRow { + readonly id: string + readonly name: string +} + +interface TestResponse { + readonly items: TestRow[] +} + +const testColumns = [{ accessorKey: 'name' as const, header: 'Name' }] +const testData: TestRow[] = [{ id: '1', name: 'Pod A' }] + +const mockFetchFn = vi.fn().mockResolvedValue({ items: testData }) + +function mockTransform(response: TestResponse) { + return { data: response.items, hasNextPage: false } +} + +function ContextReader() { + const ctx = useDataTableContext() + return ( +
+ {ctx.mode} + {String(ctx.isLoading)} +
+ ) +} + +function TestWrapper() { + const { store, table } = useDataTableServer({ + columns: testColumns, + fetchFn: mockFetchFn, + transform: mockTransform, + }) + return ( + + + + ) +} + +describe('serverProvider', () => { + it('provides context with server mode', () => { + render() + + expect(screen.getByTestId('mode')).toHaveTextContent('server') + }) + + it('renders children within the provider', () => { + render() + + expect(screen.getByTestId('mode')).toBeInTheDocument() + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/store.test.ts b/packages/datum-ui/src/components/features/data-table/__tests__/store.test.ts new file mode 100644 index 00000000..fd797be9 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/store.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest' +import { createDataTableStore } from '../core/store' + +const sampleData = [ + { id: '1', name: 'Alice', department: 'Engineering', status: 'active' }, + { id: '2', name: 'Bob', department: 'Design', status: 'inactive' }, + { id: '3', name: 'Charlie', department: 'Engineering', status: 'active' }, +] + +describe('createDataTableStore', () => { + it('creates store with initial state', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const state = store.getSnapshot() + expect(state.data).toBe(sampleData) + expect(state.filteredData).toBe(sampleData) + expect(state.sorting).toEqual([]) + expect(state.filters).toEqual({}) + expect(state.search).toBe('') + expect(state.rowSelection).toEqual({}) + expect(state.pageIndex).toBe(0) + expect(state.pageSize).toBe(20) + expect(state.mode).toBe('client') + expect(state.isLoading).toBe(false) + expect(state.error).toBeNull() + expect(state.inlineContents).toEqual([]) + }) + + it('uses default sort and filters from options', () => { + const store = createDataTableStore({ + data: sampleData, + mode: 'client', + defaultSort: [{ id: 'name', desc: false }], + defaultFilters: { status: 'active' }, + pageSize: 10, + }) + const state = store.getSnapshot() + expect(state.sorting).toEqual([{ id: 'name', desc: false }]) + expect(state.filters).toEqual({ status: 'active' }) + expect(state.pageSize).toBe(10) + }) +}) + +describe('store actions', () => { + it('setSorting updates sorting and resets rowSelection', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.setRowSelection({ 1: true }) + store.setSorting([{ id: 'name', desc: true }]) + const state = store.getSnapshot() + expect(state.sorting).toEqual([{ id: 'name', desc: true }]) + expect(state.rowSelection).toEqual({}) + }) + + it('setFilter updates filters and recomputes filteredData', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.registerFilter('department', 'checkbox') + store.setFilter('department', ['Engineering']) + const state = store.getSnapshot() + expect(state.filters).toEqual({ department: ['Engineering'] }) + expect(state.filteredData).toHaveLength(2) + expect(state.rowSelection).toEqual({}) + }) + + it('clearFilter removes a single filter', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.registerFilter('department', 'checkbox') + store.registerFilter('status', 'select') + store.setFilter('department', ['Engineering']) + store.setFilter('status', 'active') + store.clearFilter('department') + const state = store.getSnapshot() + expect(state.filters).toEqual({ status: 'active' }) + }) + + it('clearAllFilters removes all filters', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.setFilter('department', ['Engineering']) + store.setFilter('status', 'active') + store.clearAllFilters() + expect(store.getSnapshot().filters).toEqual({}) + }) + + it('setSearch updates search and resets rowSelection + pageIndex', () => { + const store = createDataTableStore({ + data: sampleData, + mode: 'client', + searchableColumns: ['name'], + }) + store.setPageIndex(2) + store.setRowSelection({ 1: true }) + store.setSearch('alice') + const state = store.getSnapshot() + expect(state.search).toBe('alice') + expect(state.filteredData).toHaveLength(1) + expect(state.rowSelection).toEqual({}) + expect(state.pageIndex).toBe(0) + }) + + it('setPageIndex updates pageIndex and resets rowSelection', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.setRowSelection({ 1: true }) + store.setPageIndex(3) + const state = store.getSnapshot() + expect(state.pageIndex).toBe(3) + expect(state.rowSelection).toEqual({}) + }) + + it('setPageSize updates pageSize, resets pageIndex and rowSelection', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.setPageIndex(2) + store.setRowSelection({ 1: true }) + store.setPageSize(50) + const state = store.getSnapshot() + expect(state.pageSize).toBe(50) + expect(state.pageIndex).toBe(0) + expect(state.rowSelection).toEqual({}) + }) + + it('setData updates data, recomputes filteredData, and resets pageIndex', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.registerFilter('department', 'checkbox') + store.setFilter('department', ['Design']) + store.setPageIndex(2) + expect(store.getSnapshot().filteredData).toHaveLength(1) + + const newData = [...sampleData, { id: '4', name: 'Diana', department: 'Design', status: 'pending' }] + store.setData(newData) + expect(store.getSnapshot().filteredData).toHaveLength(2) + expect(store.getSnapshot().pageIndex).toBe(0) + expect(store.getSnapshot().rowSelection).toEqual({}) + }) + + it('setRowSelection does NOT reset rowSelection', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.setRowSelection({ 1: true, 2: true }) + expect(store.getSnapshot().rowSelection).toEqual({ 1: true, 2: true }) + }) + + it('setLoading and setError update loading state', () => { + const store = createDataTableStore({ data: [], mode: 'server' }) + store.setLoading(true) + expect(store.getSnapshot().isLoading).toBe(true) + store.setError(new Error('fetch failed')) + expect(store.getSnapshot().error?.message).toBe('fetch failed') + store.setLoading(false) + expect(store.getSnapshot().isLoading).toBe(false) + }) +}) + +describe('store subscriptions', () => { + it('notifies subscribers on state change', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const listener = vi.fn() + store.subscribe(listener) + store.setSorting([{ id: 'name', desc: true }]) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('unsubscribe stops notifications', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const listener = vi.fn() + const unsub = store.subscribe(listener) + unsub() + store.setSorting([{ id: 'name', desc: true }]) + expect(listener).not.toHaveBeenCalled() + }) + + it('notifies even when value is unchanged (no dedup at store level)', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const listener = vi.fn() + store.subscribe(listener) + store.setSorting([]) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) + +describe('filter registration', () => { + it('registerFilter affects filteredData computation', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.setFilter('department', ['Engineering']) + expect(store.getSnapshot().filteredData).toHaveLength(3) + + store.registerFilter('department', 'checkbox') + expect(store.getSnapshot().filteredData).toHaveLength(2) + }) + + it('unregisterFilter removes strategy and recomputes', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.registerFilter('department', 'checkbox') + store.setFilter('department', ['Engineering']) + expect(store.getSnapshot().filteredData).toHaveLength(2) + + store.unregisterFilter('department') + expect(store.getSnapshot().filteredData).toHaveLength(3) + }) +}) + +describe('inline content', () => { + it('registerInlineContent adds entry', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const entry = { + id: 'test-1', + position: 'top' as const, + open: true, + onClose: vi.fn(), + render: () => null, + } + store.registerInlineContent(entry) + expect(store.getSnapshot().inlineContents).toHaveLength(1) + expect(store.getSnapshot().inlineContents[0]!.id).toBe('test-1') + }) + + it('registerInlineContent updates existing entry by id', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.registerInlineContent({ + id: 'test-1', + position: 'top' as const, + open: true, + onClose: vi.fn(), + render: () => null, + }) + store.registerInlineContent({ + id: 'test-1', + position: 'top' as const, + open: false, + onClose: vi.fn(), + render: () => null, + }) + expect(store.getSnapshot().inlineContents).toHaveLength(1) + expect(store.getSnapshot().inlineContents[0]!.open).toBe(false) + }) + + it('unregisterInlineContent removes entry', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + store.registerInlineContent({ + id: 'test-1', + position: 'top' as const, + open: true, + onClose: vi.fn(), + render: () => null, + }) + store.unregisterInlineContent('test-1') + expect(store.getSnapshot().inlineContents).toHaveLength(0) + }) +}) + +describe('server mode', () => { + it('does not apply filters in server mode', () => { + const store = createDataTableStore({ data: sampleData, mode: 'server' }) + store.registerFilter('department', 'checkbox') + store.setFilter('department', ['Engineering']) + expect(store.getSnapshot().filteredData).toBe(sampleData) + }) + + it('setData in server mode mirrors data to filteredData', () => { + const store = createDataTableStore({ data: sampleData, mode: 'server' }) + const newData = [{ id: '4', name: 'Diana', department: 'Design', status: 'pending' }] + store.setData(newData) + expect(store.getSnapshot().filteredData).toBe(newData) + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts new file mode 100644 index 00000000..bfed2762 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts @@ -0,0 +1,156 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useDataTableClient } from '../hooks/use-data-table-client' + +interface TestRow { + id: string + name: string + status: string +} + +const testData: TestRow[] = [ + { id: '1', name: 'Pod A', status: 'running' }, + { id: '2', name: 'Pod B', status: 'pending' }, + { id: '3', name: 'Pod C', status: 'running' }, +] + +const testColumns = [ + { accessorKey: 'name' as const, header: 'Name' }, + { accessorKey: 'status' as const, header: 'Status' }, +] + +describe('useDataTableClient', () => { + it('returns initial state with defaults', () => { + const { result } = renderHook(() => + useDataTableClient({ data: testData, columns: testColumns }), + ) + + const state = result.current.store.getSnapshot() + expect(state.data).toEqual(testData) + expect(state.sorting).toEqual([]) + expect(state.filters).toEqual({}) + expect(state.search).toBe('') + expect(state.rowSelection).toEqual({}) + expect(state.pageIndex).toBe(0) + expect(state.pageSize).toBe(20) + }) + + it('updates sorting state', () => { + const { result } = renderHook(() => + useDataTableClient({ data: testData, columns: testColumns }), + ) + + act(() => { + result.current.store.setSorting([{ id: 'name', desc: false }]) + }) + + expect(result.current.store.getSnapshot().sorting).toEqual([{ id: 'name', desc: false }]) + }) + + it('manages filter state immutably', () => { + const { result } = renderHook(() => + useDataTableClient({ data: testData, columns: testColumns }), + ) + + act(() => { + result.current.store.setFilter('status', 'running') + }) + + expect(result.current.store.getSnapshot().filters).toEqual({ status: 'running' }) + + act(() => { + result.current.store.clearFilter('status') + }) + + expect(result.current.store.getSnapshot().filters).toEqual({}) + }) + + it('manages search state', () => { + const { result } = renderHook(() => + useDataTableClient({ data: testData, columns: testColumns }), + ) + + act(() => { + result.current.store.setSearch('Pod A') + }) + + expect(result.current.store.getSnapshot().search).toBe('Pod A') + + act(() => { + result.current.store.clearSearch() + }) + + expect(result.current.store.getSnapshot().search).toBe('') + }) + + it('manages pagination state', () => { + const { result } = renderHook(() => + useDataTableClient({ data: testData, columns: testColumns, pageSize: 2 }), + ) + + expect(result.current.store.getSnapshot().pageSize).toBe(2) + expect(result.current.store.getSnapshot().pageIndex).toBe(0) + + act(() => { + result.current.store.setPageIndex(1) + }) + + expect(result.current.store.getSnapshot().pageIndex).toBe(1) + }) + + it('respects defaultSort and defaultFilters', () => { + const { result } = renderHook(() => + useDataTableClient({ + data: testData, + columns: testColumns, + defaultSort: [{ id: 'name', desc: true }], + defaultFilters: { status: 'running' }, + }), + ) + + const state = result.current.store.getSnapshot() + expect(state.sorting).toEqual([{ id: 'name', desc: true }]) + expect(state.filters).toEqual({ status: 'running' }) + }) + + it('clears all filters', () => { + const { result } = renderHook(() => + useDataTableClient({ + data: testData, + columns: testColumns, + defaultFilters: { status: 'running', name: 'Pod' }, + }), + ) + + act(() => { + result.current.store.clearAllFilters() + }) + + expect(result.current.store.getSnapshot().filters).toEqual({}) + }) + + it('reads initial state from stateAdapter on mount', () => { + const adapter = { + read: () => ({ + sorting: [{ id: 'name', desc: true }], + filters: { status: 'running' }, + search: 'test', + }), + write: () => {}, + } + + const { result } = renderHook(() => + useDataTableClient({ + data: testData, + columns: testColumns, + stateAdapter: adapter, + }), + ) + + // Adapter hydration happens in a mount effect, so state should be synced + const state = result.current.store.getSnapshot() + expect(state.sorting).toEqual([{ id: 'name', desc: true }]) + expect(state.filters).toEqual({ status: 'running' }) + expect(state.search).toBe('test') + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts new file mode 100644 index 00000000..12d7c672 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts @@ -0,0 +1,122 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useDataTableServer } from '../hooks/use-data-table-server' + +interface TestRow { + id: string + name: string +} + +interface TestResponse { + items: TestRow[] + nextCursor?: string +} + +const testColumns = [{ accessorKey: 'name' as const, header: 'Name' }] + +const mockFetchFn = vi.fn().mockResolvedValue({ + items: [{ id: '1', name: 'Pod A' }], + nextCursor: 'abc123', +}) + +function mockTransform(response: TestResponse) { + return { + data: response.items, + nextCursor: response.nextCursor, + hasNextPage: !!response.nextCursor, + } +} + +describe('useDataTableServer', () => { + it('returns initial state with defaults', () => { + const { result } = renderHook(() => + useDataTableServer({ + columns: testColumns, + fetchFn: mockFetchFn, + transform: mockTransform, + }), + ) + + const state = result.current.store.getSnapshot() + expect(state.data).toEqual([]) + expect(state.isLoading).toBe(true) + expect(state.sorting).toEqual([]) + expect(state.filters).toEqual({}) + expect(state.search).toBe('') + expect(state.pageSize).toBe(20) + }) + + it('updates sorting state', () => { + const { result } = renderHook(() => + useDataTableServer({ + columns: testColumns, + fetchFn: mockFetchFn, + transform: mockTransform, + }), + ) + + act(() => { + result.current.store.setSorting([{ id: 'name', desc: false }]) + }) + + expect(result.current.store.getSnapshot().sorting).toEqual([{ id: 'name', desc: false }]) + }) + + it('manages filter state immutably', () => { + const { result } = renderHook(() => + useDataTableServer({ + columns: testColumns, + fetchFn: mockFetchFn, + transform: mockTransform, + }), + ) + + act(() => { + result.current.store.setFilter('status', 'running') + }) + + expect(result.current.store.getSnapshot().filters).toEqual({ status: 'running' }) + + act(() => { + result.current.store.clearFilter('status') + }) + + expect(result.current.store.getSnapshot().filters).toEqual({}) + }) + + it('manages page size', () => { + const { result } = renderHook(() => + useDataTableServer({ + columns: testColumns, + fetchFn: mockFetchFn, + transform: mockTransform, + limit: 10, + }), + ) + + expect(result.current.store.getSnapshot().pageSize).toBe(10) + + act(() => { + result.current.store.setPageSize(25) + }) + + expect(result.current.store.getSnapshot().pageSize).toBe(25) + }) + + it('clears all filters', () => { + const { result } = renderHook(() => + useDataTableServer({ + columns: testColumns, + fetchFn: mockFetchFn, + transform: mockTransform, + defaultFilters: { status: 'running', name: 'Pod' }, + }), + ) + + act(() => { + result.current.store.clearAllFilters() + }) + + expect(result.current.store.getSnapshot().filters).toEqual({}) + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/use-selectors.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/use-selectors.test.tsx new file mode 100644 index 00000000..c730bc4b --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/use-selectors.test.tsx @@ -0,0 +1,125 @@ +import type { ReactNode } from 'react' +/// +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { DataTableStoreContext, TableInstanceContext } from '../core/data-table-context' +import { createDataTableStore } from '../core/store' +import { + useDataTableContext, + useDataTableFilters, + useDataTableLoading, + useDataTableSearch, +} from '../hooks/use-selectors' + +const sampleData = [ + { id: '1', name: 'Alice', department: 'Engineering', status: 'active' }, + { id: '2', name: 'Bob', department: 'Design', status: 'inactive' }, +] + +const mockTable = { + getCanNextPage: () => false, + getCanPreviousPage: () => false, + nextPage: vi.fn(), + previousPage: vi.fn(), + getPageCount: () => 1, + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + getAllColumns: () => [], + getFilteredSelectedRowModel: () => ({ rows: [] }), + getState: () => ({ pagination: { pageIndex: 0, pageSize: 20 } }), +} as any + +function createWrapper(store: any, table = mockTable) { + return ({ children }: { children: ReactNode }) => ( + + + {children} + + + ) +} + +describe('useDataTableFilters', () => { + it('returns current filters and setters', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const { result } = renderHook(() => useDataTableFilters(), { + wrapper: createWrapper(store), + }) + expect(result.current.filters).toEqual({}) + act(() => store.setFilter('status', 'active')) + expect(result.current.filters).toEqual({ status: 'active' }) + }) + + it('does NOT re-render when sorting changes', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const renderCount = { current: 0 } + renderHook(() => { + renderCount.current++ + return useDataTableFilters() + }, { wrapper: createWrapper(store) }) + + const countBefore = renderCount.current + act(() => store.setSorting([{ id: 'name', desc: true }])) + expect(renderCount.current).toBe(countBefore) + }) +}) + +describe('useDataTableSearch', () => { + it('returns current search and setters', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const { result } = renderHook(() => useDataTableSearch(), { + wrapper: createWrapper(store), + }) + expect(result.current.search).toBe('') + act(() => store.setSearch('alice')) + expect(result.current.search).toBe('alice') + }) +}) + +describe('useDataTableLoading', () => { + it('returns loading and error state', () => { + const store = createDataTableStore({ data: [], mode: 'server' }) + const { result } = renderHook(() => useDataTableLoading(), { + wrapper: createWrapper(store), + }) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + + act(() => store.setLoading(true)) + expect(result.current.isLoading).toBe(true) + + act(() => store.setError(new Error('fail'))) + expect(result.current.error?.message).toBe('fail') + }) + + it('does NOT re-render when filters change', () => { + const store = createDataTableStore({ data: sampleData, mode: 'server' }) + const renderCount = { current: 0 } + renderHook(() => { + renderCount.current++ + return useDataTableLoading() + }, { wrapper: createWrapper(store) }) + + const countBefore = renderCount.current + act(() => store.setFilter('status', 'active')) + expect(renderCount.current).toBe(countBefore) + }) +}) + +describe('useDataTableContext (deprecated facade)', () => { + it('returns full context and re-renders on any change', () => { + const store = createDataTableStore({ data: sampleData, mode: 'client' }) + const renderCount = { current: 0 } + const { result } = renderHook(() => { + renderCount.current++ + return useDataTableContext() + }, { wrapper: createWrapper(store) }) + + expect(result.current.mode).toBe('client') + expect(result.current.filters).toEqual({}) + + const countBefore = renderCount.current + act(() => store.setSorting([{ id: 'name', desc: true }])) + expect(renderCount.current).toBeGreaterThan(countBefore) + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/adapters/nuqs-adapter.ts b/packages/datum-ui/src/components/features/data-table/adapters/nuqs-adapter.ts new file mode 100644 index 00000000..91a4cb80 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/adapters/nuqs-adapter.ts @@ -0,0 +1,134 @@ +'use client' + +import type { SortingState } from '@tanstack/react-table' +import type { DataTableState, FilterValue, StateAdapter } from '../types' +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs' +import { useMemo } from 'react' +import { DEFAULT_PAGE_SIZE } from '../constants' + +// Nuqs parser type — using `unknown` as a generic stand-in +// since we don't want to tightly couple to nuqs internal types. +// Consumer passes actual nuqs parsers which are type-checked at their call site. +type NuqsParser = unknown + +export interface UseNuqsAdapterOptions { + /** + * Custom filter parsers to sync to URL. + * Each key becomes a query parameter. + * + * @example + * ```tsx + * import { parseAsString, parseAsArrayOf } from 'nuqs' + * + * useNuqsAdapter({ + * filters: { + * status: parseAsString.withDefault(''), + * regions: parseAsArrayOf(parseAsString).withDefault([]), + * }, + * }) + * ``` + */ + readonly filters?: Record +} + +/** + * Serialize SortingState to URL-friendly string. + * Format: "name" (asc), "-name" (desc), comma-separated for multi-sort. + * Example: "-department,name" → [{id:"department",desc:true},{id:"name",desc:false}] + */ +function serializeSorting(sorting: SortingState): string { + if (sorting.length === 0) + return '' + return sorting.map(s => s.desc ? `-${s.id}` : s.id).join(',') +} + +/** + * Parse URL sort string back to SortingState. + */ +function parseSorting(value: string): SortingState { + if (!value) + return [] + return value.split(',').filter(Boolean).map((part) => { + if (part.startsWith('-')) { + return { id: part.slice(1), desc: true } + } + return { id: part, desc: false } + }) +} + +const coreSearchParams = { + sort: parseAsString.withDefault(''), + q: parseAsString.withDefault(''), + page: parseAsInteger.withDefault(0), + size: parseAsInteger.withDefault(DEFAULT_PAGE_SIZE), +} + +/** + * Hook that creates a StateAdapter backed by nuqs URL query state. + * + * URL format: + * - `?sort=name` (asc) or `?sort=-name` (desc), comma-separated for multi-sort + * - `?q=search` for search text + * - `?page=0&size=20` for pagination + * - Custom filter keys as declared in options + * + * Requires `nuqs` to be installed in the consumer app. + * + * @example + * ```tsx + * const stateAdapter = useNuqsAdapter({ + * filters: { + * status: parseAsString.withDefault(''), + * department: parseAsArrayOf(parseAsString).withDefault([]), + * }, + * }) + * const tableState = useDataTableClient({ data, columns, stateAdapter }) + * ``` + */ +export function useNuqsAdapter( + options: UseNuqsAdapterOptions = {}, +): StateAdapter { + const { filters: filterParsers } = options + + const [coreState, setCoreState] = useQueryStates(coreSearchParams) + + const hasFilters = filterParsers != null && Object.keys(filterParsers).length > 0 + + // Filters use a separate sentinel when empty to satisfy useQueryStates + const resolvedFilterParsers = hasFilters + ? filterParsers + : { _dt: parseAsString.withDefault('') } + const [filterState, setFilterState] = useQueryStates(resolvedFilterParsers as Parameters[0]) + + return useMemo( + () => ({ + read: (): Partial => ({ + sorting: parseSorting(coreState.sort), + search: coreState.q, + pageIndex: coreState.page, + pageSize: coreState.size, + ...(hasFilters ? { filters: filterState as FilterValue } : {}), + }), + write: (state: DataTableState): void => { + setCoreState({ + sort: serializeSorting(state.sorting), + q: state.search, + page: state.pageIndex ?? 0, + size: state.pageSize ?? DEFAULT_PAGE_SIZE, + }) + if (hasFilters && filterParsers) { + // Build a complete update: set all declared filter keys, + // using null for any that aren't in the current state + // so nuqs removes them from the URL. + const update: Record = {} + for (const key of Object.keys(filterParsers)) { + const value = (state.filters as Record)?.[key] + update[key] = value ?? null + } + setFilterState(update) + } + }, + }), + [coreState, filterState, hasFilters, setCoreState, setFilterState, filterParsers], + ) +} diff --git a/packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx b/packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx new file mode 100644 index 00000000..b21f67f2 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { ColumnDef } from '@tanstack/react-table' +import type { SelectionColumnOptions } from '../types' +import { Checkbox } from '@repo/shadcn/ui/checkbox' + +const SELECTION_COLUMN_ID = 'select' + +export function createSelectionColumn( + options: SelectionColumnOptions = {}, +): ColumnDef { + const { className, headerClassName, renderHeader, renderCell } = options + + return { + id: SELECTION_COLUMN_ID, + size: 40, + enableSorting: false, + enableHiding: false, + header: renderHeader + ?? (({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className={headerClassName} + /> + )), + cell: renderCell + ?? (({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className={className} + /> + )), + } +} + +export function hasSelectionColumn(columns: ColumnDef[]): boolean { + return columns.some(col => 'id' in col && col.id === SELECTION_COLUMN_ID) +} + +export function withSelectionColumn( + columns: ColumnDef[], + options: SelectionColumnOptions = {}, +): ColumnDef[] { + if (hasSelectionColumn(columns)) { + return columns + } + return [createSelectionColumn(options), ...columns] +} diff --git a/packages/datum-ui/src/components/features/data-table/components/bulk-actions.tsx b/packages/datum-ui/src/components/features/data-table/components/bulk-actions.tsx new file mode 100644 index 00000000..cc2c1825 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/bulk-actions.tsx @@ -0,0 +1,16 @@ +'use client' + +import type { BulkActionsProps } from '../types' +import { useDataTableSelection } from '../hooks/use-selectors' + +export function DataTableBulkActions({ + children, + className, +}: BulkActionsProps) { + const { selectedRows } = useDataTableSelection() + + if (selectedRows.length === 0) + return null + + return
{children(selectedRows)}
+} diff --git a/packages/datum-ui/src/components/features/data-table/components/column-header.tsx b/packages/datum-ui/src/components/features/data-table/components/column-header.tsx new file mode 100644 index 00000000..354c50ff --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/column-header.tsx @@ -0,0 +1,34 @@ +'use client' + +import type { ColumnHeaderProps } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react' + +export function DataTableColumnHeader({ + column, + title, + className, +}: ColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
+ } + + const sorted = column.getIsSorted() + + return ( +
+ +
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/components/content.tsx b/packages/datum-ui/src/components/features/data-table/components/content.tsx new file mode 100644 index 00000000..c13a2029 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/content.tsx @@ -0,0 +1,132 @@ +'use client' + +import type { Cell, Row } from '@tanstack/react-table' +import type { ContentProps, InlineContentEntry } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@repo/shadcn/ui/table' +import { flexRender } from '@tanstack/react-table' +import { useMemo } from 'react' +import { useDataTableInlineContents, useDataTableRows } from '../hooks/use-selectors' + +function resolveClassName( + value: string | ((item: T) => string) | undefined, + item: T, +): string | undefined { + if (typeof value === 'function') + return value(item) + return value +} + +function renderInlineContentRow( + entry: InlineContentEntry, + colSpan: number, + rows: Row[], +) { + return ( + + + {entry.render({ + onClose: entry.onClose, + rowData: entry.position === 'row' + ? rows.find(r => r.id === entry.rowId)?.original ?? null + : null, + })} + + + ) +} + +export function DataTableContent({ + emptyMessage, + className, + tableClassName, + headerClassName, + headerRowClassName, + headerCellClassName, + bodyClassName, + rowClassName, + cellClassName, +}: ContentProps) { + const { rows, headerGroups, totalColumns } = useDataTableRows() + const { inlineContents } = useDataTableInlineContents() + const openInlineContents = useMemo(() => inlineContents.filter(e => e.open), [inlineContents]) + const colSpan = totalColumns + + return ( +
+ + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {openInlineContents + .filter(e => e.position === 'top') + .map(entry => renderInlineContentRow(entry, colSpan, rows))} + {rows.length > 0 + ? ( + rows.map((row) => { + const rowEntry = openInlineContents.find( + e => e.position === 'row' && e.rowId === row.id, + ) + if (rowEntry) + return renderInlineContentRow(rowEntry, colSpan, rows) + + return ( + ))} + style={{ transitionProperty: 'none' }} + data-slot="dt-row" + data-state={row.getIsSelected() ? 'selected' : undefined} + > + {row.getVisibleCells().map(cell => ( + ))} + data-slot="dt-cell" + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + }) + ) + : ( + + + {emptyMessage ?? 'No results.'} + + + )} + +
+
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/components/inline-content.tsx b/packages/datum-ui/src/components/features/data-table/components/inline-content.tsx new file mode 100644 index 00000000..a963cff1 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/inline-content.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { InlineContentProps } from '../types' +import { useEffect, useId, useRef } from 'react' +import { useDataTableInlineContents } from '../hooks/use-selectors' + +export function DataTableInlineContent({ + position, + rowId, + open, + onClose, + className, + children, +}: InlineContentProps) { + const id = useId() + const { registerInlineContent, unregisterInlineContent } = useDataTableInlineContents() + const initialRender = useRef(true) + + // Mount/unmount: register on mount, unregister on unmount + useEffect(() => { + registerInlineContent({ + id, + position, + rowId, + open, + onClose, + className, + render: children, + }) + + return () => { + unregisterInlineContent(id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount/unmount only + }, [id, registerInlineContent, unregisterInlineContent]) + + // Prop sync: update registration when props change (skip initial render) + useEffect(() => { + if (initialRender.current) { + initialRender.current = false + return + } + registerInlineContent({ + id, + position, + rowId, + open, + onClose, + className, + render: children, + }) + }, [id, position, rowId, open, onClose, className, children, registerInlineContent]) + + return null +} diff --git a/packages/datum-ui/src/components/features/data-table/components/loading.tsx b/packages/datum-ui/src/components/features/data-table/components/loading.tsx new file mode 100644 index 00000000..076927d4 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/loading.tsx @@ -0,0 +1,32 @@ +'use client' + +import type { LoadingProps } from '../types' +import { Skeleton } from '@repo/shadcn/ui/skeleton' +import { DEFAULT_LOADING_ROWS } from '../constants' + +export function DataTableLoading({ + rows = DEFAULT_LOADING_ROWS, + columns = 4, + className, +}: LoadingProps) { + return ( +
+
+
+
+ {Array.from({ length: columns }, (_, i) => ( + + ))} +
+
+ {Array.from({ length: rows }, (_, rowIndex) => ( +
+ {Array.from({ length: columns }, (_, colIndex) => ( + + ))} +
+ ))} +
+
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/components/pagination.tsx b/packages/datum-ui/src/components/features/data-table/components/pagination.tsx new file mode 100644 index 00000000..8f3e9687 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/pagination.tsx @@ -0,0 +1,178 @@ +'use client' + +import type { PaginationProps } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import { Button } from '@repo/shadcn/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@repo/shadcn/ui/select' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useMemo } from 'react' +import { DEFAULT_PAGE_SIZES } from '../constants' +import { useDataTablePagination } from '../hooks/use-selectors' + +/** + * Generates page numbers with ellipsis for large page counts. + * Shows up to 7 items: first, last, current +/- 1 neighbor, and ellipsis gaps. + */ +function getPageNumbers(currentPage: number, totalPages: number): readonly (number | '...')[] { + const maxVisible = 7 + + if (totalPages <= maxVisible) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + const pages: (number | '...')[] = [1] + const current = currentPage + 1 // convert to 1-based + + if (current <= 4) { + // Near the start: 1, 2, 3, 4, 5, ..., last + for (let i = 2; i <= 5; i++) pages.push(i) + pages.push('...') + pages.push(totalPages) + } + else if (current >= totalPages - 3) { + // Near the end: 1, ..., n-4, n-3, n-2, n-1, n + pages.push('...') + for (let i = totalPages - 4; i <= totalPages; i++) pages.push(i) + } + else { + // In the middle: 1, ..., c-1, c, c+1, ..., last + pages.push('...') + for (let i = current - 1; i <= current + 1; i++) pages.push(i) + pages.push('...') + pages.push(totalPages) + } + + return pages +} + +export function DataTablePagination({ + pageSizes = DEFAULT_PAGE_SIZES, + className, +}: PaginationProps) { + const { + canNextPage, + canPrevPage, + nextPage, + prevPage, + pageIndex, + pageCount, + setPageIndex, + pageSize, + setPageSize, + totalRows, + } = useDataTablePagination() + + // pageCount > 0 means client mode (TanStack returns -1 for manual/server pagination) + const isClientMode = pageCount > 0 + + const startRow = pageIndex * pageSize + 1 + const endRow = Math.min((pageIndex + 1) * pageSize, totalRows) + + const pageNumbers = useMemo( + () => getPageNumbers(pageIndex, pageCount), + [pageIndex, pageCount], + ) + + return ( +
+ {/* Left side: row info + rows per page */} +
+ {isClientMode && totalRows > 0 && ( + + Showing + {' '} + {startRow} + {' '} + to + {' '} + {endRow} + {' '} + of + {' '} + {totalRows} + {' '} + rows + + )} +
+ Rows per page + +
+
+ + {/* Right side: page navigation */} +
+ + + {isClientMode && pageCount > 1 + ? pageNumbers.map((page, index) => { + if (page === '...') { + return ( + + ... + + ) + } + const isActive = page === pageIndex + 1 + return ( + + ) + }) + : !isClientMode && ( + + Page + {' '} + {pageIndex + 1} + + )} + + +
+
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/components/row-actions.tsx b/packages/datum-ui/src/components/features/data-table/components/row-actions.tsx new file mode 100644 index 00000000..a2b7fbf4 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/row-actions.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { RowActionsProps } from '../types' +import { Button } from '@repo/shadcn/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@repo/shadcn/ui/dropdown-menu' +import { MoreHorizontal } from 'lucide-react' + +export function DataTableRowActions({ + row, + actions, + isLoading = false, + className, +}: RowActionsProps) { + const data = row.original + const visibleActions = actions.filter((action) => { + if (action.hidden === undefined) + return true + return typeof action.hidden === 'function' ? !action.hidden(data) : !action.hidden + }) + + if (visibleActions.length === 0) + return null + + return ( + + + + + + {visibleActions.map((action) => { + const isDisabled + = typeof action.disabled === 'function' + ? action.disabled(data) + : action.disabled ?? false + + return ( + action.onClick(data)} + className={action.variant === 'destructive' ? 'text-destructive' : undefined} + > + {action.icon && } + {action.label} + + ) + })} + + + ) +} diff --git a/packages/datum-ui/src/components/features/data-table/components/search.tsx b/packages/datum-ui/src/components/features/data-table/components/search.tsx new file mode 100644 index 00000000..39a828a4 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/search.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { SearchProps } from '../types' +import { Input } from '@repo/shadcn/ui/input' +import { useEffect, useState } from 'react' +import { DEFAULT_DEBOUNCE_MS } from '../constants' +import { useDataTableSearch } from '../hooks/use-selectors' + +export function DataTableSearch({ + placeholder = 'Search...', + debounceMs = DEFAULT_DEBOUNCE_MS, + className, +}: SearchProps) { + const { search, setSearch } = useDataTableSearch() + const [inputValue, setInputValue] = useState(search) + + useEffect(() => { + setInputValue(search) + }, [search]) + + useEffect(() => { + const timer = setTimeout(() => { + if (inputValue !== search) { + setSearch(inputValue) + } + }, debounceMs) + + return () => clearTimeout(timer) + }, [inputValue, debounceMs, search, setSearch]) + + return ( + setInputValue(e.target.value)} + className={className} + data-slot="dt-search" + /> + ) +} diff --git a/packages/datum-ui/src/components/features/data-table/constants.ts b/packages/datum-ui/src/components/features/data-table/constants.ts new file mode 100644 index 00000000..f0c6832f --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/constants.ts @@ -0,0 +1,4 @@ +export const DEFAULT_PAGE_SIZE = 20 +export const DEFAULT_PAGE_SIZES = [10, 20, 30, 50] as const +export const DEFAULT_DEBOUNCE_MS = 300 +export const DEFAULT_LOADING_ROWS = 5 diff --git a/packages/datum-ui/src/components/features/data-table/core/client-provider.tsx b/packages/datum-ui/src/components/features/data-table/core/client-provider.tsx new file mode 100644 index 00000000..3aa89c02 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/core/client-provider.tsx @@ -0,0 +1,28 @@ +'use client' + +import type { Table } from '@tanstack/react-table' +import type { ReactNode } from 'react' +import type { DataTableStore } from '../types' +import { DataTableStoreContext, TableInstanceContext } from './data-table-context' + +export interface DataTableClientProviderProps { + readonly store: DataTableStore + readonly table: Table + readonly className?: string + readonly children: ReactNode +} + +export function ClientProvider({ + store, + table, + className, + children, +}: DataTableClientProviderProps) { + return ( + + +
{children}
+
+
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx b/packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx new file mode 100644 index 00000000..42cdb63d --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx @@ -0,0 +1,36 @@ +'use client' + +import type { Table } from '@tanstack/react-table' +import type { DataTableContextValue, DataTableStore } from '../types' +import { createContext, use } from 'react' + +export const DataTableStoreContext = createContext | null>(null) +export const TableInstanceContext = createContext | null>(null) + +// Legacy context — retained for backward compat until providers are migrated (Task 4) +export const DataTableContext = createContext | null>(null) + +/** @deprecated Use selector hooks from hooks/use-selectors instead */ +export function useDataTableContext(): DataTableContextValue { + const context = use(DataTableContext) + if (!context) { + throw new Error('useDataTableContext must be used within a DataTable provider') + } + return context as DataTableContextValue +} + +export function useDataTableStore(): DataTableStore { + const store = use(DataTableStoreContext) + if (!store) { + throw new Error('useDataTableStore must be used within a or provider') + } + return store as DataTableStore +} + +export function useTableInstance(): Table { + const table = use(TableInstanceContext) + if (!table) { + throw new Error('useTableInstance must be used within a or provider') + } + return table as Table +} diff --git a/packages/datum-ui/src/components/features/data-table/core/filter-engine.ts b/packages/datum-ui/src/components/features/data-table/core/filter-engine.ts new file mode 100644 index 00000000..a63e05b4 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/core/filter-engine.ts @@ -0,0 +1,113 @@ +import type { FilterStrategy } from '../types' + +type FilterFn = (cellValue: unknown, filterValue: unknown) => boolean + +interface FilterStrategies { + 'checkbox': FilterFn + 'select': FilterFn + 'date-gte': FilterFn + 'date-lte': FilterFn + [key: string]: FilterFn +} + +export const FILTER_STRATEGIES: FilterStrategies = { + 'checkbox': (cellValue, filterValue) => { + if (filterValue == null) + return true + if (Array.isArray(filterValue) && filterValue.length === 0) + return true + if (!Array.isArray(filterValue)) + return cellValue === filterValue + if (Array.isArray(cellValue)) + return cellValue.some(v => filterValue.includes(v)) + return filterValue.includes(cellValue) + }, + + 'select': (cellValue, filterValue) => { + if (filterValue == null || filterValue === '') + return true + return cellValue === filterValue + }, + + 'date-gte': (cellValue, filterValue) => { + if (!filterValue) + return true + const cell = new Date(cellValue as string) + const filter = new Date(filterValue as string) + if (Number.isNaN(cell.getTime()) || Number.isNaN(filter.getTime())) + return true + return cell >= filter + }, + + 'date-lte': (cellValue, filterValue) => { + if (!filterValue) + return true + const cell = new Date(cellValue as string) + const filter = new Date(filterValue as string) + if (Number.isNaN(cell.getTime()) || Number.isNaN(filter.getTime())) + return true + return cell <= filter + }, +} + +function resolveStrategy(strategy: FilterStrategy | undefined): FilterFn | undefined { + if (!strategy) + return undefined + if (typeof strategy === 'function') + return strategy + return FILTER_STRATEGIES[strategy] +} + +export function applyFilters( + data: TData[], + filters: Record, + search: string, + registeredFilters: Map, + customFilterFns: Record, + searchConfig: { + searchFn?: (row: TData, query: string) => boolean + searchableColumns?: string[] + }, +): TData[] { + const hasFilters = Object.keys(filters).length > 0 + const hasSearch = search.length > 0 + + if (!hasFilters && !hasSearch) + return data + + return data.filter((row) => { + if (hasFilters) { + for (const [column, value] of Object.entries(filters)) { + const fn = customFilterFns[column] ?? resolveStrategy(registeredFilters.get(column)) + if (!fn) { + console.warn(`[DataTable] No filter strategy registered for column "${column}". Filter ignored.`) + continue + } + const cellValue = (row as Record)[column] + if (!fn(cellValue, value)) + return false + } + } + + if (hasSearch) { + const query = search.toLowerCase() + if (searchConfig.searchFn) { + return searchConfig.searchFn(row, search) + } + if (searchConfig.searchableColumns && searchConfig.searchableColumns.length > 0) { + return searchConfig.searchableColumns.some((col) => { + const cellValue = (row as Record)[col] + return cellValue != null && String(cellValue).toLowerCase().includes(query) + }) + } + // Fallback: search all string/number values on the row + return Object.values(row as Record).some((val) => { + if (val == null) + return false + return String(val).toLowerCase().includes(query) + }) + } + + return true + }) +} diff --git a/packages/datum-ui/src/components/features/data-table/core/server-provider.tsx b/packages/datum-ui/src/components/features/data-table/core/server-provider.tsx new file mode 100644 index 00000000..cbe1ad10 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/core/server-provider.tsx @@ -0,0 +1,28 @@ +'use client' + +import type { Table } from '@tanstack/react-table' +import type { ReactNode } from 'react' +import type { DataTableStore } from '../types' +import { DataTableStoreContext, TableInstanceContext } from './data-table-context' + +export interface DataTableServerProviderProps { + readonly store: DataTableStore + readonly table: Table + readonly className?: string + readonly children: ReactNode +} + +export function ServerProvider({ + store, + table, + className, + children, +}: DataTableServerProviderProps) { + return ( + + +
{children}
+
+
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/core/store.ts b/packages/datum-ui/src/components/features/data-table/core/store.ts new file mode 100644 index 00000000..c43926f3 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/core/store.ts @@ -0,0 +1,162 @@ +import type { Table } from '@tanstack/react-table' +import type { + CreateStoreOptions, + DataTableStore, + DataTableStoreState, + FilterStrategy, +} from '../types' +import { DEFAULT_PAGE_SIZE } from '../constants' +import { applyFilters } from './filter-engine' + +export function createDataTableStore( + options: CreateStoreOptions, +): DataTableStore { + const registeredFilters = new Map() + const listeners = new Set<() => void>() + + function computeFilteredData(s: DataTableStoreState): TData[] { + if (s.mode === 'server') + return s.data + return applyFilters( + s.data, + s.filters, + s.search, + registeredFilters, + options.filterFns ?? {}, + { + searchFn: options.searchFn, + searchableColumns: options.searchableColumns, + }, + ) + } + + let state: DataTableStoreState = { + data: options.data, + filteredData: options.data, + sorting: options.defaultSort ?? [], + filters: options.defaultFilters ?? {}, + search: '', + rowSelection: {}, + pageIndex: 0, + pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE, + mode: options.mode, + isLoading: false, + error: null, + inlineContents: [], + } + + // Apply default filters on init if any exist (pageIndex already 0) + if (options.defaultFilters && Object.keys(options.defaultFilters).length > 0) { + state = { ...state, filteredData: computeFilteredData(state) } + } + + function notify() { + for (const listener of listeners) listener() + } + + function setState(next: DataTableStoreState) { + state = next + notify() + } + + const store: DataTableStore = { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + + setData: (data) => { + const next = { ...state, data, pageIndex: 0, rowSelection: {} } + setState({ ...next, filteredData: computeFilteredData(next) }) + }, + + setServerData: (data) => { + setState({ ...state, data, filteredData: data }) + }, + + // Table instance stored for future use by selector hooks (Task 3) + setTable: (_t: Table) => { /* retained for provider wiring */ }, + + setSorting: (sorting) => { + setState({ ...state, sorting, rowSelection: {} }) + }, + + setFilter: (key, value) => { + const next = { ...state, filters: { ...state.filters, [key]: value }, rowSelection: {}, pageIndex: 0 } + setState({ ...next, filteredData: computeFilteredData(next) }) + }, + + clearFilter: (key) => { + const filters = Object.fromEntries( + Object.entries(state.filters).filter(([k]) => k !== key), + ) + const next = { ...state, filters, rowSelection: {}, pageIndex: 0 } + setState({ ...next, filteredData: computeFilteredData(next) }) + }, + + clearAllFilters: () => { + const next = { ...state, filters: {}, rowSelection: {}, pageIndex: 0 } + setState({ ...next, filteredData: computeFilteredData(next) }) + }, + + setSearch: (search) => { + const next = { ...state, search, rowSelection: {}, pageIndex: 0 } + setState({ ...next, filteredData: computeFilteredData(next) }) + }, + + clearSearch: () => { + const next = { ...state, search: '', rowSelection: {}, pageIndex: 0 } + setState({ ...next, filteredData: computeFilteredData(next) }) + }, + + setRowSelection: (rowSelection) => { + setState({ ...state, rowSelection }) + }, + + setPageIndex: (pageIndex) => { + setState({ ...state, pageIndex, rowSelection: {} }) + }, + + setPageSize: (pageSize) => { + setState({ ...state, pageSize, pageIndex: 0, rowSelection: {} }) + }, + + setLoading: (isLoading) => { + setState({ ...state, isLoading }) + }, + + setError: (error) => { + setState({ ...state, error }) + }, + + registerFilter: (column, strategy) => { + registeredFilters.set(column, strategy) + const filteredData = computeFilteredData(state) + setState({ ...state, filteredData }) + }, + + unregisterFilter: (column) => { + registeredFilters.delete(column) + if (column in state.filters) { + const filteredData = computeFilteredData(state) + setState({ ...state, filteredData }) + } + }, + + registerInlineContent: (entry) => { + const existing = state.inlineContents.findIndex(e => e.id === entry.id) + const inlineContents = existing >= 0 + ? state.inlineContents.map((e, i) => i === existing ? entry : e) + : [...state.inlineContents, entry] + setState({ ...state, inlineContents }) + }, + + unregisterInlineContent: (id) => { + const inlineContents = state.inlineContents.filter(e => e.id !== id) + setState({ ...state, inlineContents }) + }, + } + + return store +} diff --git a/packages/datum-ui/src/components/features/data-table/data-table.tsx b/packages/datum-ui/src/components/features/data-table/data-table.tsx new file mode 100644 index 00000000..9bbe9778 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/data-table.tsx @@ -0,0 +1,31 @@ +'use client' + +import { DataTableBulkActions } from './components/bulk-actions' +import { DataTableColumnHeader } from './components/column-header' +import { DataTableContent } from './components/content' +import { DataTableInlineContent } from './components/inline-content' +import { DataTableLoading } from './components/loading' +import { DataTablePagination } from './components/pagination' +import { DataTableRowActions } from './components/row-actions' +import { DataTableSearch } from './components/search' +import { ClientProvider } from './core/client-provider' +import { ServerProvider } from './core/server-provider' +import { CheckboxFilter } from './filters/checkbox-filter' +import { DatePickerFilter } from './filters/date-picker-filter' +import { SelectFilter } from './filters/select-filter' + +export const DataTable = { + Client: ClientProvider, + Server: ServerProvider, + Content: DataTableContent, + InlineContent: DataTableInlineContent, + ColumnHeader: DataTableColumnHeader, + Pagination: DataTablePagination, + Search: DataTableSearch, + RowActions: DataTableRowActions, + BulkActions: DataTableBulkActions, + Loading: DataTableLoading, + SelectFilter, + CheckboxFilter, + DatePickerFilter, +} as const diff --git a/packages/datum-ui/src/components/features/data-table/filters/checkbox-filter.tsx b/packages/datum-ui/src/components/features/data-table/filters/checkbox-filter.tsx new file mode 100644 index 00000000..b3d7a21e --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/filters/checkbox-filter.tsx @@ -0,0 +1,146 @@ +'use client' + +import type { FilterCheckboxProps } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import { Badge } from '@repo/shadcn/ui/badge' +import { Button } from '@repo/shadcn/ui/button' +import { Checkbox } from '@repo/shadcn/ui/checkbox' +import { Label } from '@repo/shadcn/ui/label' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@repo/shadcn/ui/popover' +import { ChevronDown, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useDataTableFilters } from '../hooks/use-selectors' + +const MAX_VISIBLE_BADGES = 2 + +export function CheckboxFilter({ + column, + label, + options, + className, + checkboxPopoverClassName, +}: FilterCheckboxProps) { + const { filters, setFilter, clearFilter, registerFilter, unregisterFilter } = useDataTableFilters() + const [open, setOpen] = useState(false) + + useEffect(() => { + registerFilter(column, 'checkbox') + return () => unregisterFilter(column) + }, [column, registerFilter, unregisterFilter]) + const selectedValues = (filters[column] as string[] | undefined) ?? [] + + const updateValues = (newValues: string[]) => { + if (newValues.length > 0) { + setFilter(column, newValues) + } + else { + clearFilter(column) + } + } + + const handleToggle = (optionValue: string, checked: boolean) => { + const newValues = checked + ? [...selectedValues, optionValue] + : selectedValues.filter(v => v !== optionValue) + updateValues(newValues) + } + + const removeValue = (optionValue: string) => { + updateValues(selectedValues.filter(v => v !== optionValue)) + } + + const visibleBadges = selectedValues.slice(0, MAX_VISIBLE_BADGES) + const remainingCount = selectedValues.length - MAX_VISIBLE_BADGES + + return ( + + + + + +
+ {label} + {selectedValues.length > 0 && ( + + )} +
+
+
+ {options.map(option => ( +
+ handleToggle(option.value, checked === true)} + /> + +
+ ))} +
+
+
+
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/filters/date-picker-filter.tsx b/packages/datum-ui/src/components/features/data-table/filters/date-picker-filter.tsx new file mode 100644 index 00000000..f0ff3735 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/filters/date-picker-filter.tsx @@ -0,0 +1,60 @@ +'use client' + +import type { DateRange } from 'react-day-picker' +import type { FilterDatePickerProps } from '../types' +import { useEffect, useMemo } from 'react' +import { CalendarDatePicker } from '../../calendar-date-picker/calendar-date-picker' +import { useDataTableFilters } from '../hooks/use-selectors' + +export function DatePickerFilter({ + column, + label, + className, + datePickerPopoverClassName, + disableFuture, + disablePast, + minDate, + maxDate, +}: FilterDatePickerProps) { + const { filters, setFilter, clearFilter, registerFilter, unregisterFilter } = useDataTableFilters() + const rawValue = filters[column] as string | undefined + + useEffect(() => { + registerFilter(column, 'date-gte') + return () => unregisterFilter(column) + }, [column, registerFilter, unregisterFilter]) + + const dateRange: DateRange = useMemo( + () => { + const date = rawValue ? new Date(rawValue) : undefined + return { from: date, to: date } + }, + [rawValue], + ) + + return ( +
+ { + if (range?.from) { + setFilter(column, range.from.toISOString()) + } + else { + clearFilter(column) + } + }} + /> +
+ ) +} diff --git a/packages/datum-ui/src/components/features/data-table/filters/select-filter.tsx b/packages/datum-ui/src/components/features/data-table/filters/select-filter.tsx new file mode 100644 index 00000000..2321b630 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/filters/select-filter.tsx @@ -0,0 +1,113 @@ +'use client' + +import type { FilterSelectProps } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import { Button } from '@repo/shadcn/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@repo/shadcn/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@repo/shadcn/ui/popover' +import { Check, ChevronDown, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useDataTableFilters } from '../hooks/use-selectors' + +export function SelectFilter({ + column, + label, + options, + placeholder, + searchable = true, + className, + selectPopoverClassName, +}: FilterSelectProps) { + const { filters, setFilter, clearFilter, registerFilter, unregisterFilter } = useDataTableFilters() + const [open, setOpen] = useState(false) + const value = filters[column] as string | undefined + + useEffect(() => { + registerFilter(column, 'select') + return () => unregisterFilter(column) + }, [column, registerFilter, unregisterFilter]) + + const selectedOption = options.find(o => o.value === value) + + return ( + + + + + + + {searchable && } + + No results found. + + {options.map(option => ( + { + setFilter(column, option.value) + setOpen(false) + }} + > + + {option.label} + + ))} + + + + + + ) +} diff --git a/packages/datum-ui/src/components/features/data-table/hooks/index.ts b/packages/datum-ui/src/components/features/data-table/hooks/index.ts new file mode 100644 index 00000000..ba5c6f09 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/hooks/index.ts @@ -0,0 +1,13 @@ +export { useDataTableClient } from './use-data-table-client' +export { useDataTableServer } from './use-data-table-server' +export { + useDataTableContext, + useDataTableFilters, + useDataTableInlineContents, + useDataTableLoading, + useDataTablePagination, + useDataTableRows, + useDataTableSearch, + useDataTableSelection, + useDataTableSorting, +} from './use-selectors' diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts new file mode 100644 index 00000000..0835fe41 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts @@ -0,0 +1,149 @@ +'use client' + +import type { ColumnDef, SortingState } from '@tanstack/react-table' +import type { CreateStoreOptions, DataTableStore, FilterValue, SelectionColumnOptions, StateAdapter } from '../types' +import { + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { withSelectionColumn } from '../columns/selection-column' +import { createDataTableStore } from '../core/store' + +export interface UseDataTableClientOptions { + readonly data: TData[] + readonly columns: ColumnDef[] + readonly pageSize?: number + readonly getRowId?: (row: TData) => string + readonly enableRowSelection?: boolean | SelectionColumnOptions + readonly defaultSort?: SortingState + readonly defaultFilters?: FilterValue + readonly searchableColumns?: string[] + readonly searchFn?: (row: TData, query: string) => boolean + readonly filterFns?: Record boolean> + readonly stateAdapter?: StateAdapter +} + +export function useDataTableClient(options: UseDataTableClientOptions) { + const { + data, + columns, + pageSize, + getRowId, + enableRowSelection = false, + defaultSort, + defaultFilters, + searchableColumns, + searchFn, + filterFns, + stateAdapter, + } = options + + // 1. Create store (once) + const store = useMemo>( + () => createDataTableStore({ + data, + mode: 'client', + defaultSort, + defaultFilters, + pageSize, + searchableColumns, + searchFn, + filterFns, + } as CreateStoreOptions), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], // intentionally empty — store created once + ) + + // 2. Sync data changes into store (skip initial render — store already has it) + const isInitialRender = useRef(true) + useEffect(() => { + if (isInitialRender.current) { + isInitialRender.current = false + return + } + store.setData(data) + }, [data, store]) + + // 3. Resolve columns (auto-inject selection column) + const resolvedColumns = useMemo( + () => enableRowSelection + ? withSelectionColumn(columns, typeof enableRowSelection === 'object' ? enableRowSelection : {}) + : columns, + [columns, enableRowSelection], + ) + + // 4. Read store state for TanStack table + const { filteredData, sorting, rowSelection, pageIndex, pageSize: storePageSize, filters, search } + = useSyncExternalStore(store.subscribe, store.getSnapshot) + + // 5. Create TanStack table (sort + paginate only, no filtering) + const table = useReactTable({ + data: filteredData, + columns: resolvedColumns, + state: { + sorting, + rowSelection, + pagination: { pageIndex, pageSize: storePageSize }, + }, + onSortingChange: (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater + store.setSorting(next) + }, + onRowSelectionChange: (updater) => { + const next = typeof updater === 'function' ? updater(rowSelection) : updater + store.setRowSelection(next) + }, + onPaginationChange: (updater) => { + const prev = { pageIndex, pageSize: storePageSize } + const next = typeof updater === 'function' ? updater(prev) : updater + store.setPageIndex(next.pageIndex) + store.setPageSize(next.pageSize) + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId, + enableRowSelection: !!enableRowSelection, + }) + + // 6. Hydrate from adapter on mount (must run before write effect) + const hydratedRef = useRef(false) + useEffect(() => { + if (stateAdapter && !hydratedRef.current) { + hydratedRef.current = true + const persisted = stateAdapter.read() + if (persisted.sorting && persisted.sorting.length > 0) + store.setSorting(persisted.sorting) + if (persisted.filters) { + for (const [key, value] of Object.entries(persisted.filters)) { + if (value != null) + store.setFilter(key, value) + } + } + if (persisted.search) + store.setSearch(persisted.search) + if (persisted.pageIndex != null && persisted.pageIndex > 0) + store.setPageIndex(persisted.pageIndex) + if (persisted.pageSize != null) + store.setPageSize(persisted.pageSize) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // mount only + + // 7. State adapter sync (skip first render to avoid overwriting hydrated state) + const isFirstWrite = useRef(true) + useEffect(() => { + if (!stateAdapter) + return + if (isFirstWrite.current) { + isFirstWrite.current = false + return + } + stateAdapter.write({ sorting, filters, search, pageIndex, pageSize: storePageSize }) + }, [sorting, filters, search, pageIndex, storePageSize, stateAdapter]) + + return { store, table } +} diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-context.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-context.ts new file mode 100644 index 00000000..efdb8e7d --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-context.ts @@ -0,0 +1 @@ +export { useDataTableContext } from './use-selectors' diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts new file mode 100644 index 00000000..ccaabf46 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts @@ -0,0 +1,206 @@ +'use client' + +import type { ColumnDef, SortingState } from '@tanstack/react-table' +import type { + DataTableStore, + FilterValue, + SelectionColumnOptions, + StateAdapter, +} from '../types' +import { + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { withSelectionColumn } from '../columns/selection-column' +import { createDataTableStore } from '../core/store' + +export interface ServerFetchArgs { + readonly sorting: SortingState + readonly filters: FilterValue + readonly search: string + readonly cursor: string | undefined + readonly limit: number +} + +export interface ServerTransformResult { + readonly data: TData[] + readonly nextCursor?: string + readonly hasNextPage: boolean +} + +export interface UseDataTableServerOptions { + readonly columns: ColumnDef[] + readonly fetchFn: (args: ServerFetchArgs) => Promise + readonly transform: (response: TResponse) => ServerTransformResult + readonly limit?: number + readonly getRowId?: (row: TData) => string + readonly enableRowSelection?: boolean | SelectionColumnOptions + readonly defaultSort?: SortingState + readonly defaultFilters?: FilterValue + readonly stateAdapter?: StateAdapter +} + +export function useDataTableServer( + options: UseDataTableServerOptions, +) { + const { + columns, + fetchFn, + transform, + limit = 20, + getRowId, + enableRowSelection = false, + defaultSort, + defaultFilters, + stateAdapter, + } = options + + // Stable refs for callbacks + const fetchRef = useRef(fetchFn) + const transformRef = useRef(transform) + useEffect(() => { + fetchRef.current = fetchFn + }, [fetchFn]) + useEffect(() => { + transformRef.current = transform + }, [transform]) + + // Cursor map: pageIndex → cursor string for that page + // Page 0 has undefined cursor (start from beginning) + const cursorMapRef = useRef>(new Map()) + + // Track hasNextPage to control the Next button + const [hasNextPage, setHasNextPage] = useState(false) + + // 1. Create store + const store = useMemo>( + () => createDataTableStore({ + data: [] as TData[], + mode: 'server', + defaultSort, + defaultFilters, + pageSize: limit, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + // 2. Read store state + const { sorting, filters, search, rowSelection, pageSize, pageIndex } + = useSyncExternalStore(store.subscribe, store.getSnapshot) + + // 3. Fetch on query change (including page changes) + useEffect(() => { + let cancelled = false + store.setLoading(true) + + const cursor = cursorMapRef.current.get(pageIndex) + + fetchRef.current({ + sorting, + filters, + search, + cursor, + limit: pageSize, + }) + .then((response) => { + if (cancelled) + return + const result = transformRef.current(response) + store.setServerData(result.data) + store.setError(null) + + // Track cursor for the next page + if (result.nextCursor) { + cursorMapRef.current.set(pageIndex + 1, result.nextCursor) + } + setHasNextPage(result.hasNextPage) + }) + .catch((error) => { + if (cancelled) + return + store.setServerData([] as TData[]) + store.setError(error instanceof Error ? error : new Error(String(error))) + setHasNextPage(false) + }) + .finally(() => { + if (!cancelled) + store.setLoading(false) + }) + + return () => { + cancelled = true + } + }, [sorting, filters, search, pageSize, pageIndex, store]) + + // Reset cursors when query params change (not page changes) + const prevQueryRef = useRef({ sorting, filters, search, pageSize }) + useEffect(() => { + const prev = prevQueryRef.current + const queryChanged + = prev.sorting !== sorting + || prev.filters !== filters + || prev.search !== search + || prev.pageSize !== pageSize + + if (queryChanged) { + cursorMapRef.current = new Map() + store.setPageIndex(0) + setHasNextPage(false) + } + prevQueryRef.current = { sorting, filters, search, pageSize } + }, [sorting, filters, search, pageSize, store]) + + // 4. Resolve columns + const resolvedColumns = useMemo( + () => enableRowSelection + ? withSelectionColumn(columns, typeof enableRowSelection === 'object' ? enableRowSelection : {}) + : columns, + [columns, enableRowSelection], + ) + + // 5. Create TanStack table (display only — no sorting/filtering/pagination) + const table = useReactTable({ + data: store.getSnapshot().data, + columns: resolvedColumns, + state: { + sorting, + rowSelection, + pagination: { pageIndex, pageSize }, + }, + manualPagination: true, + manualSorting: true, + manualFiltering: true, + // Tell TanStack about page capabilities so pagination component works + pageCount: hasNextPage ? pageIndex + 2 : pageIndex + 1, + getCoreRowModel: getCoreRowModel(), + getRowId, + enableRowSelection: !!enableRowSelection, + onSortingChange: (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater + store.setSorting(next) + }, + onRowSelectionChange: (updater) => { + const next = typeof updater === 'function' ? updater(rowSelection) : updater + store.setRowSelection(next) + }, + onPaginationChange: (updater) => { + const prev = { pageIndex, pageSize } + const next = typeof updater === 'function' ? updater(prev) : updater + if (next.pageIndex !== pageIndex) + store.setPageIndex(next.pageIndex) + if (next.pageSize !== pageSize) + store.setPageSize(next.pageSize) + }, + }) + + // 7. State adapter sync + useEffect(() => { + if (stateAdapter) { + stateAdapter.write({ sorting, filters, search, pageSize }) + } + }, [sorting, filters, search, pageSize, stateAdapter]) + + return { store, table } +} diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-selectors.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-selectors.ts new file mode 100644 index 00000000..db718e96 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-selectors.ts @@ -0,0 +1,200 @@ +'use client' + +import type { HeaderGroup, Row, RowSelectionState } from '@tanstack/react-table' +import type { DataTableStoreState, InlineContentEntry } from '../types' +import { useCallback, useRef, useSyncExternalStore } from 'react' +import { useDataTableStore, useTableInstance } from '../core/data-table-context' + +function shallowEqual(a: Record, b: Record): boolean { + const keysA = Object.keys(a) + const keysB = Object.keys(b) + if (keysA.length !== keysB.length) + return false + for (const key of keysA) { + const va = a[key] + const vb = b[key] + if (va === vb) + continue + // Treat arrays as equal if they have the same length and same items by reference + if (Array.isArray(va) && Array.isArray(vb)) { + if (va.length !== vb.length) + return false + for (let i = 0; i < va.length; i++) { + if (va[i] !== vb[i]) + return false + } + continue + } + return false + } + return true +} + +function useSliceSelector>( + selector: (state: DataTableStoreState) => TSlice, +): TSlice { + const store = useDataTableStore() + const cachedRef = useRef(null) + + const getSnapshot = useCallback(() => { + const next = selector(store.getSnapshot()) + if (cachedRef.current && shallowEqual(cachedRef.current, next)) { + return cachedRef.current + } + cachedRef.current = next + return next + }, [store, selector]) + + return useSyncExternalStore(store.subscribe, getSnapshot) +} + +export function useDataTableFilters() { + const store = useDataTableStore() + return useSliceSelector( + useCallback((state: DataTableStoreState) => ({ + filters: state.filters, + setFilter: store.setFilter, + clearFilter: store.clearFilter, + clearAllFilters: store.clearAllFilters, + registerFilter: store.registerFilter, + unregisterFilter: store.unregisterFilter, + }), [store]), + ) +} + +export function useDataTableSearch() { + const store = useDataTableStore() + return useSliceSelector( + useCallback((state: DataTableStoreState) => ({ + search: state.search, + setSearch: store.setSearch, + clearSearch: store.clearSearch, + }), [store]), + ) +} + +export function useDataTableSorting() { + const store = useDataTableStore() + return useSliceSelector( + useCallback((state: DataTableStoreState) => ({ + sorting: state.sorting, + setSorting: store.setSorting, + }), [store]), + ) +} + +export function useDataTableSelection() { + const store = useDataTableStore() + const table = useTableInstance() + return useSliceSelector void + selectedRows: TData[] + }>( + useCallback((state: DataTableStoreState) => ({ + rowSelection: state.rowSelection, + setRowSelection: store.setRowSelection, + selectedRows: table.getFilteredSelectedRowModel().rows.map(r => r.original), + }), [store, table]), + ) +} + +export function useDataTablePagination() { + const store = useDataTableStore() + const table = useTableInstance() + + // Stable function references — do not recreate on every snapshot call + const nextPage = useCallback(() => table.nextPage(), [table]) + const prevPage = useCallback(() => table.previousPage(), [table]) + + return useSliceSelector( + useCallback((state: DataTableStoreState) => ({ + canNextPage: table.getCanNextPage(), + canPrevPage: table.getCanPreviousPage(), + nextPage, + prevPage, + pageIndex: state.pageIndex, + pageCount: table.getPageCount(), + setPageIndex: store.setPageIndex, + pageSize: state.pageSize, + setPageSize: store.setPageSize, + totalRows: state.filteredData.length, + }), [store, table, nextPage, prevPage]), + ) +} + +export function useDataTableRows() { + const table = useTableInstance() + return useSliceSelector[] + headerGroups: HeaderGroup[] + totalColumns: number + }>( + useCallback((_state: DataTableStoreState) => ({ + rows: table.getRowModel().rows, + headerGroups: table.getHeaderGroups(), + totalColumns: table.getAllColumns().length, + }), [table]), + ) +} + +export function useDataTableLoading() { + return useSliceSelector( + useCallback((state: DataTableStoreState) => ({ + isLoading: state.isLoading, + error: state.error, + }), []), + ) +} + +export function useDataTableInlineContents() { + const store = useDataTableStore() + return useSliceSelector[] + registerInlineContent: (entry: InlineContentEntry) => void + unregisterInlineContent: (id: string) => void + }>( + useCallback((state: DataTableStoreState) => ({ + inlineContents: state.inlineContents, + registerInlineContent: store.registerInlineContent, + unregisterInlineContent: store.unregisterInlineContent, + }), [store]), + ) +} + +// Deprecated facade — subscribes to entire snapshot (no re-render isolation) +export function useDataTableContext() { + const store = useDataTableStore() + const table = useTableInstance() + const state = useSyncExternalStore(store.subscribe, store.getSnapshot) + return { + table, + mode: state.mode, + sorting: state.sorting, + filters: state.filters, + search: state.search, + rowSelection: state.rowSelection, + isLoading: state.isLoading, + setSorting: store.setSorting, + setFilter: store.setFilter, + clearFilter: store.clearFilter, + clearAllFilters: store.clearAllFilters, + setSearch: store.setSearch, + clearSearch: store.clearSearch, + setRowSelection: store.setRowSelection, + pagination: { + canNextPage: table.getCanNextPage(), + canPrevPage: table.getCanPreviousPage(), + nextPage: () => table.nextPage(), + prevPage: () => table.previousPage(), + pageIndex: state.pageIndex, + pageCount: table.getPageCount(), + setPageIndex: store.setPageIndex, + pageSize: state.pageSize, + setPageSize: store.setPageSize, + }, + inlineContents: state.inlineContents, + registerInlineContent: store.registerInlineContent, + unregisterInlineContent: store.unregisterInlineContent, + } +} diff --git a/packages/datum-ui/src/components/features/data-table/index.ts b/packages/datum-ui/src/components/features/data-table/index.ts new file mode 100644 index 00000000..26cc80ac --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/index.ts @@ -0,0 +1,70 @@ +// Adapters +export { useNuqsAdapter } from './adapters/nuqs-adapter' + +export type { UseNuqsAdapterOptions } from './adapters/nuqs-adapter' +// Columns +export { createSelectionColumn } from './columns/selection-column' + +// Constants +export { + DEFAULT_DEBOUNCE_MS, + DEFAULT_LOADING_ROWS, + DEFAULT_PAGE_SIZE, + DEFAULT_PAGE_SIZES, +} from './constants' +// Store +export { createDataTableStore } from './core/store' + +// Namespace (primary API) +export { DataTable } from './data-table' +// Hooks +export { useDataTableClient } from './hooks/use-data-table-client' +export { useDataTableServer } from './hooks/use-data-table-server' + +export { + useDataTableContext, + useDataTableFilters, + useDataTableInlineContents, + useDataTableLoading, + useDataTablePagination, + useDataTableRows, + useDataTableSearch, + useDataTableSelection, + useDataTableSorting, +} from './hooks/use-selectors' + +// Types +export type { + ActionItem, + BulkActionsProps, + ClientPaginationState, + ColumnHeaderProps, + ContentProps, + CreateStoreOptions, + DataTableClientProps, + DataTableContextValue, + DataTableServerProps, + DataTableState, + DataTableStore, + DataTableStoreState, + FilterCheckboxProps, + FilterDatePickerProps, + FilterSelectProps, + FilterStrategy, + FilterValue, + InlineContentEntry, + InlineContentProps, + InlineContentRenderParams, + LoadingProps, + PaginationProps, + PaginationState, + RowActionsProps, + SearchProps, + SelectionColumnOptions, + ServerFetchArgs, + ServerPaginationState, + ServerTransformResult, + StateAdapter, + UseDataTableClientOptions, + UseDataTableServerOptions, +} from './types' diff --git a/packages/datum-ui/src/components/features/data-table/types.ts b/packages/datum-ui/src/components/features/data-table/types.ts new file mode 100644 index 00000000..95d856c9 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/types.ts @@ -0,0 +1,355 @@ +import type { + Cell, + Column, + ColumnDef, + Row, + RowSelectionState, + SortingState, + Table, +} from '@tanstack/react-table' +import type { ComponentType, ReactNode } from 'react' + +// ── Action Types ── + +export interface ActionItem { + readonly label: string + readonly icon?: ComponentType<{ className?: string }> + readonly variant?: 'default' | 'destructive' + readonly onClick: (row: TData) => void | Promise + readonly disabled?: boolean | ((row: TData) => boolean) + readonly hidden?: boolean | ((row: TData) => boolean) +} + +// ── Filter Types ── + +export type FilterValue = Record + +// ── Pagination Types ── + +export interface ClientPaginationState { + readonly pageIndex: number + readonly pageSize: number + readonly setPageIndex: (index: number) => void + readonly setPageSize: (size: number) => void +} + +export interface ServerPaginationState { + readonly cursor: string | undefined + readonly limit: number + readonly hasNextPage: boolean + readonly hasPrevPage: boolean + readonly nextPage: () => void + readonly prevPage: () => void + readonly setLimit: (limit: number) => void +} + +export interface PaginationState { + readonly canNextPage: boolean + readonly canPrevPage: boolean + readonly nextPage: () => void + readonly prevPage: () => void + readonly pageIndex?: number + readonly pageCount?: number + readonly setPageIndex?: (index: number) => void + readonly pageSize: number + readonly setPageSize: (size: number) => void +} + +// ── State Adapter ── + +export interface DataTableState { + readonly sorting: SortingState + readonly filters: FilterValue + readonly search: string + readonly pageIndex?: number + readonly pageSize?: number + readonly cursor?: string + readonly limit?: number +} + +export interface StateAdapter { + readonly read: () => Partial + readonly write: (state: DataTableState) => void +} + +// ── Server Fetch ── + +export interface ServerFetchArgs { + readonly cursor?: string + readonly limit: number + readonly sorting: SortingState + readonly filters: FilterValue + readonly search: string +} + +export interface ServerTransformResult { + readonly data: TData[] + readonly cursor?: string + readonly hasNextPage: boolean +} + +// ── Inline Content ── + +export interface InlineContentRenderParams { + readonly onClose: () => void + readonly rowData: TData | null +} + +export interface InlineContentEntry { + readonly id: string + readonly position: 'top' | 'row' + readonly rowId?: string + readonly open: boolean + readonly onClose: () => void + readonly className?: string + readonly render: (params: InlineContentRenderParams) => ReactNode +} + +export interface InlineContentProps { + readonly position: 'top' | 'row' + readonly rowId?: string + readonly open: boolean + readonly onClose: () => void + readonly className?: string + readonly children: (params: InlineContentRenderParams) => ReactNode +} + +// ── Selection Column ── + +export interface SelectionColumnOptions { + /** CSS class for cell checkboxes */ + readonly className?: string + /** CSS class for header checkbox */ + readonly headerClassName?: string + /** Custom header render (replaces default "select all" checkbox) */ + readonly renderHeader?: (props: { table: Table }) => ReactNode + /** Custom cell render (replaces default row checkbox) */ + readonly renderCell?: (props: { row: Row }) => ReactNode +} + +// ── Context ── + +export interface DataTableContextValue { + readonly table: Table + readonly mode: 'client' | 'server' + readonly sorting: SortingState + readonly filters: FilterValue + readonly search: string + readonly rowSelection: RowSelectionState + readonly isLoading: boolean + readonly setSorting: (sorting: SortingState) => void + readonly setFilter: (key: string, value: unknown) => void + readonly clearFilter: (key: string) => void + readonly clearAllFilters: () => void + readonly setSearch: (search: string) => void + readonly clearSearch: () => void + readonly setRowSelection: (selection: RowSelectionState) => void + readonly pagination: PaginationState + readonly inlineContents: readonly InlineContentEntry[] + readonly registerInlineContent: (entry: InlineContentEntry) => void + readonly unregisterInlineContent: (id: string) => void +} + +// ── Hook Options ── + +export interface UseDataTableClientOptions { + readonly data: TData[] + readonly columns: ColumnDef[] + readonly pageSize?: number + readonly getRowId?: (row: TData) => string + readonly enableRowSelection?: boolean | SelectionColumnOptions + readonly defaultSort?: SortingState + readonly defaultFilters?: FilterValue + readonly searchableColumns?: string[] + readonly searchFn?: (row: TData, search: string) => boolean + readonly stateAdapter?: StateAdapter +} + +export interface UseDataTableServerOptions { + readonly columns: ColumnDef[] + readonly fetchFn: (args: ServerFetchArgs) => Promise + readonly transform: (response: TResponse) => ServerTransformResult + readonly limit?: number + readonly getRowId?: (row: TData) => string + readonly enableRowSelection?: boolean | SelectionColumnOptions + readonly defaultSort?: SortingState + readonly defaultFilters?: FilterValue + readonly stateAdapter?: StateAdapter +} + +// ── Component Props ── + +export interface DataTableClientProps { + readonly data: TData[] + readonly columns: ColumnDef[] + readonly sorting: SortingState + readonly setSorting: (sorting: SortingState) => void + readonly filters: FilterValue + readonly setFilter: (key: string, value: unknown) => void + readonly clearFilter: (key: string) => void + readonly clearAllFilters: () => void + readonly search: string + readonly setSearch: (search: string) => void + readonly clearSearch: () => void + readonly rowSelection: RowSelectionState + readonly setRowSelection: (selection: RowSelectionState) => void + readonly pagination: ClientPaginationState + readonly getRowId?: (row: TData) => string + readonly enableRowSelection?: boolean | SelectionColumnOptions + readonly searchableColumns?: string[] + readonly searchFn?: (row: TData, search: string) => boolean + readonly className?: string + readonly children: ReactNode +} + +export interface DataTableServerProps { + readonly data: TData[] + readonly columns: ColumnDef[] + readonly isLoading: boolean + readonly sorting: SortingState + readonly setSorting: (sorting: SortingState) => void + readonly filters: FilterValue + readonly setFilter: (key: string, value: unknown) => void + readonly clearFilter: (key: string) => void + readonly clearAllFilters: () => void + readonly search: string + readonly setSearch: (search: string) => void + readonly clearSearch: () => void + readonly rowSelection: RowSelectionState + readonly setRowSelection: (selection: RowSelectionState) => void + readonly pagination: ServerPaginationState + readonly getRowId?: (row: TData) => string + readonly enableRowSelection?: boolean | SelectionColumnOptions + readonly className?: string + readonly children: ReactNode +} + +export interface SearchProps { + readonly placeholder?: string + readonly debounceMs?: number + readonly className?: string +} + +export interface FilterSelectProps { + readonly column: string + readonly label: string + readonly options: readonly { readonly label: string, readonly value: string }[] + readonly placeholder?: string + readonly searchable?: boolean + readonly className?: string + readonly selectPopoverClassName?: string +} + +export interface FilterDatePickerProps { + readonly column: string + readonly label: string + readonly className?: string + readonly datePickerPopoverClassName?: string + readonly disableFuture?: boolean + readonly disablePast?: boolean + readonly minDate?: Date + readonly maxDate?: Date +} + +export interface FilterCheckboxProps { + readonly column: string + readonly label: string + readonly options: readonly { readonly label: string, readonly value: string }[] + readonly className?: string + readonly checkboxPopoverClassName?: string +} + +export interface ColumnHeaderProps { + readonly column: Column + readonly title: string + readonly className?: string +} + +export interface RowActionsProps { + readonly row: Row + readonly actions: readonly ActionItem[] + readonly isLoading?: boolean + readonly className?: string +} + +export interface BulkActionsProps { + readonly children: (selectedRows: TData[]) => ReactNode + readonly className?: string +} + +export interface ContentProps { + readonly emptyMessage?: ReactNode + readonly className?: string + readonly tableClassName?: string + readonly headerClassName?: string + readonly headerRowClassName?: string + readonly headerCellClassName?: string + readonly bodyClassName?: string + readonly rowClassName?: string | ((row: Row) => string) + readonly cellClassName?: string | ((cell: Cell) => string) +} + +export interface PaginationProps { + readonly pageSizes?: readonly number[] + readonly className?: string +} + +export interface LoadingProps { + readonly rows?: number + readonly columns?: number + readonly className?: string +} + +// ── Store Types ── + +export type FilterStrategy = 'checkbox' | 'select' | 'date-gte' | 'date-lte' + | ((cellValue: unknown, filterValue: unknown) => boolean) + +export interface DataTableStoreState { + readonly data: TData[] + readonly filteredData: TData[] + readonly sorting: SortingState + readonly filters: FilterValue + readonly search: string + readonly rowSelection: RowSelectionState + readonly pageIndex: number + readonly pageSize: number + readonly mode: 'client' | 'server' + readonly isLoading: boolean + readonly error: Error | null + readonly inlineContents: readonly InlineContentEntry[] +} + +export interface DataTableStore { + readonly getSnapshot: () => DataTableStoreState + readonly subscribe: (listener: () => void) => () => void + readonly setData: (data: TData[]) => void + readonly setServerData: (data: TData[]) => void + readonly setTable: (table: Table) => void + readonly setSorting: (sorting: SortingState) => void + readonly setFilter: (key: string, value: unknown) => void + readonly clearFilter: (key: string) => void + readonly clearAllFilters: () => void + readonly setSearch: (search: string) => void + readonly clearSearch: () => void + readonly setRowSelection: (selection: RowSelectionState) => void + readonly setPageIndex: (index: number) => void + readonly setPageSize: (size: number) => void + readonly setLoading: (loading: boolean) => void + readonly setError: (error: Error | null) => void + readonly registerFilter: (column: string, strategy: FilterStrategy) => void + readonly unregisterFilter: (column: string) => void + readonly registerInlineContent: (entry: InlineContentEntry) => void + readonly unregisterInlineContent: (id: string) => void +} + +export interface CreateStoreOptions { + readonly data: TData[] + readonly mode: 'client' | 'server' + readonly defaultSort?: SortingState + readonly defaultFilters?: FilterValue + readonly pageSize?: number + readonly searchableColumns?: string[] + readonly searchFn?: (row: TData, query: string) => boolean + readonly filterFns?: Record boolean> +} diff --git a/packages/datum-ui/tsdown.config.ts b/packages/datum-ui/tsdown.config.ts index e5d21e82..dcf0aca5 100644 --- a/packages/datum-ui/tsdown.config.ts +++ b/packages/datum-ui/tsdown.config.ts @@ -72,6 +72,7 @@ export default defineConfig({ 'tag-input/index': 'src/components/features/tag-input/index.ts', 'task-queue/index': 'src/components/features/task-queue/index.ts', 'toast/index': 'src/components/features/toast/index.ts', + 'data-table/index': 'src/components/features/data-table/index.ts', }, format: ['esm'], dts: false, @@ -93,6 +94,7 @@ export default defineConfig({ 'react-day-picker', 'react-dropzone', 'react-number-format', + 'nuqs', 'zod', // Transitive deps from @repo/shadcn /^@radix-ui\//, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d8c2535..b79eda6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,6 +332,9 @@ importers: '@stepperize/react': specifier: ^6.1.0 version: 6.1.0(react@19.2.4)(typescript@5.9.3) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-virtual': specifier: ^3.13.19 version: 3.13.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -395,6 +398,9 @@ importers: nprogress: specifier: ^0.2.0 version: 0.2.0 + nuqs: + specifier: ^2.8.9 + version: 2.8.9(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) react: specifier: ^19 version: 19.2.4 @@ -2700,6 +2706,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -5363,6 +5372,27 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.8.9: + resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} @@ -8825,6 +8855,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -11942,6 +11974,14 @@ snapshots: dependencies: boolbase: 1.0.0 + nuqs@2.8.9(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.2.4 + optionalDependencies: + next: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nwsapi@2.2.23: {} object-assign@4.1.1: {} From ed27480e18e98554377282c8c4d9febf9d65b558 Mon Sep 17 00:00:00 2001 From: Yahya Date: Wed, 11 Mar 2026 12:57:22 +0700 Subject: [PATCH 02/13] feat(data-table): add ActiveFilters component, migrate to datum-ui base components - Add DataTable.ActiveFilters compound component with grouped filter badges, customizable label, clear-all variants (icon/button/text), and className/data-slot support for styling - Migrate all data-table components from @repo/shadcn imports to datum-ui base components (Button, Input, Badge, Select, etc.) for Alpha theme consistency - Fix Badge variant prop in checkbox-filter (variant -> type/theme) - Update storybook stories with ActiveFilters demos and customization - Update fumadocs docs with ActiveFilters section, tri-state sorting, server fetchFn/transform pattern, and new data-slot entries --- .../docs/components/features/data-table.mdx | 138 +++++--- .../stories/features/data-table.stories.tsx | 140 ++++++++ .../__tests__/active-filters.test.tsx | 330 ++++++++++++++++++ .../data-table/columns/selection-column.tsx | 2 +- .../data-table/components/active-filters.tsx | 203 +++++++++++ .../data-table/components/column-header.tsx | 2 +- .../data-table/components/content.tsx | 8 +- .../data-table/components/loading.tsx | 2 +- .../data-table/components/pagination.tsx | 18 +- .../data-table/components/row-actions.tsx | 6 +- .../features/data-table/components/search.tsx | 2 +- .../features/data-table/data-table.tsx | 2 + .../data-table/filters/checkbox-filter.tsx | 24 +- .../data-table/filters/select-filter.tsx | 14 +- .../components/features/data-table/index.ts | 1 + .../components/features/data-table/types.ts | 19 + 16 files changed, 832 insertions(+), 79 deletions(-) create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/active-filters.test.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/components/active-filters.tsx diff --git a/apps/docs/content/docs/components/features/data-table.mdx b/apps/docs/content/docs/components/features/data-table.mdx index 62fe8148..c3c23c7c 100644 --- a/apps/docs/content/docs/components/features/data-table.mdx +++ b/apps/docs/content/docs/components/features/data-table.mdx @@ -1,6 +1,6 @@ --- title: Data Table -description: A composable, type-safe data table with client and server modes, sorting, filtering, pagination, and optional URL state sync. +description: A composable, type-safe data table with client and server modes, sorting, filtering, active filters, pagination, and optional URL state sync. --- # Data Table @@ -117,50 +117,44 @@ function UsersPage({ users }: { users: User[] }) { For paginated API data (e.g., Kubernetes resources with continuation tokens): ```tsx -import { DataTable, useDataTableServer } from '@datum-cloud/datum-ui/data-table' +import { DataTable, useDataTableServer, useDataTableLoading } from '@datum-cloud/datum-ui/data-table' -function ResourcesPage() { - const [data, setData] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [hasNextPage, setHasNextPage] = useState(false) +function ServerContent() { + const { isLoading } = useDataTableLoading() + return ( +
+ + {isLoading + ? + : } + +
+ ) +} - const tableState = useDataTableServer({ +function ResourcesPage() { + const tableState = useDataTableServer({ columns: resourceColumns, limit: 25, - getRowId: (row) => row.metadata.uid, + getRowId: (row) => row.id, + fetchFn: async (args) => { + const params = new URLSearchParams({ + limit: String(args.limit), + ...(args.cursor && { cursor: args.cursor }), + }) + const res = await fetch(`/api/resources?${params}`) + return res.json() + }, + transform: (response) => ({ + data: response.items, + cursor: response.nextCursor, + hasNextPage: Boolean(response.nextCursor), + }), }) - // Fetch data when sorting/filters change - useEffect(() => { - fetchResources({ - sorting: tableState.sorting, - filters: tableState.filters, - limit: tableState.pagination.limit, - }).then((result) => { - setData(result.items) - setHasNextPage(Boolean(result.continue)) - setIsLoading(false) - }) - }, [tableState.sorting, tableState.filters, tableState.pagination.limit]) - return ( - { /* fetch next page */ }, - prevPage: () => { /* fetch prev page */ }, - }} - > - - {isLoading - ? - : } - + + ) } @@ -168,7 +162,7 @@ function ResourcesPage() { ## Sorting -Column headers render a click-to-sort control. Click once for ascending, again for descending, then cycles back to ascending. No props required beyond the column definition: +Column headers render a click-to-sort control. Click once for ascending, again for descending, and a third click removes the sort (tri-state cycle). No props required beyond the column definition: ```tsx { @@ -228,6 +222,63 @@ Popover with CalendarDatePicker integration. Supports optional `minDate` and `ma /> ``` +## Active Filters + +Display currently active filters as removable badges, grouped by category. Supports customizable label, clear-all behavior, and value formatting. + +### Basic Usage + +```tsx + +``` + +### Customization + +```tsx +{/* Custom label and button-style clear */} + + +{/* No label, text-style clear */} + + +{/* Custom value formatting */} + { + if (column === 'status') return String(value).toUpperCase() + return undefined // fallback to default + }} +/> +``` + +### ActiveFilters Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `label` | `string \| null` | `"Selected Filters"` | Label text before filter groups. Set to `null` to hide | +| `filterLabels` | `Record` | `{}` | Maps column keys to display names | +| `formatFilterValue` | `(column, value) => string \| undefined` | -- | Custom value formatter | +| `clearAll` | `'icon' \| 'button' \| 'text'` | `'icon'` | Clear-all action style | +| `clearAllLabel` | `string` | `"Clear all"` | Label for the clear-all action | +| `className` | `string` | -- | Root container class | +| `groupClassName` | `string` | -- | Filter group box class | +| `badgeClassName` | `string` | -- | Badge class | + ## Inline Content Render custom content inline within the table body — for create forms, edit forms, detail panels, etc. @@ -319,6 +370,10 @@ Every element has a `data-slot` attribute for CSS-only customization: | Column header | `dt-column-header` | | Inline content | `dt-inline-content` | | Empty state | `dt-empty` | +| Active filters | `dt-active-filters` | +| Active filters label | `dt-active-filters-label` | +| Filter group | `dt-filter-group` | +| Clear all filters | `dt-clear-all-filters` | The wrapper has the class `datum-ui-data-table` for scoping: @@ -349,7 +404,7 @@ const stateAdapter = useNuqsAdapter({ const tableState = useDataTableClient({ data, columns, stateAdapter }) ``` -URL params: `?sort=[{"id":"name","desc":false}]&q=hello&page=0&size=20&status=active` +URL params: `?sort=-name&q=hello&page=0&size=20&status=active` ## API Reference @@ -379,6 +434,8 @@ URL params: `?sort=[{"id":"name","desc":false}]&q=hello&page=0&size=20&status=ac | `defaultSort` | `SortingState` | `[]` | Initial sort state | | `defaultFilters` | `FilterValue` | `{}` | Initial filter state | | `stateAdapter` | `StateAdapter` | -- | URL state sync adapter | +| `fetchFn` | `(args: ServerFetchArgs) => Promise` | -- | Async function called when table state changes | +| `transform` | `(response: TResponse) => ServerTransformResult` | -- | Transform API response to table data format | ### `DataTable` Namespace @@ -397,6 +454,7 @@ URL params: `?sort=[{"id":"name","desc":false}]&q=hello&page=0&size=20&status=ac | `DataTable.SelectFilter` | Combobox popover filter with single-select and optional `searchable` prop | | `DataTable.CheckboxFilter` | Multi-value checkbox popover filter with badges and clear button | | `DataTable.DatePickerFilter` | CalendarDatePicker filter with optional `minDate` and `maxDate` constraints | +| `DataTable.ActiveFilters` | Displays active filters as grouped removable badges with customizable label and clear-all action | ### `createSelectionColumn(options?)` diff --git a/apps/storybook/stories/features/data-table.stories.tsx b/apps/storybook/stories/features/data-table.stories.tsx index 352fee5f..71b4e0be 100644 --- a/apps/storybook/stories/features/data-table.stories.tsx +++ b/apps/storybook/stories/features/data-table.stories.tsx @@ -134,6 +134,9 @@ function ClientTableDemo() { ]} /> + {selectedRows => (
@@ -224,6 +227,7 @@ function ServerTableContent() { return (
+ {isLoading ? : } @@ -365,6 +369,9 @@ function MultiSelectBulkActionsDemo() { ]} />
+ {selectedRows => (
@@ -648,3 +655,136 @@ function InlineContentDemo() { export const InlineContent: Story = { render: () => , } + +// --------------------------------------------------------------------------- +// Active Filters -- showcases all customization options +// --------------------------------------------------------------------------- + +function ActiveFiltersDemo() { + const filterLabels = { role: 'Role', status: 'Status' } + + const sharedOptions = { + data: sampleUsers, + columns, + pageSize: 5, + defaultFilters: { + role: ['Admin', 'Editor'], + status: 'active', + }, + } + + const defaultState = useDataTableClient(sharedOptions) + const customLabelState = useDataTableClient(sharedOptions) + const noLabelState = useDataTableClient(sharedOptions) + + return ( +
+ {/* Default variant */} +
+

Default

+ +
+
+ + +
+ + + +
+
+
+ + {/* Custom label + button clear */} +
+

Custom label with button clear

+ +
+
+ + +
+ + + +
+
+
+ + {/* No label + text clear */} +
+

No label with text clear

+ +
+
+ + +
+ + + +
+
+
+
+ ) +} + +export const ActiveFilters: Story = { + render: () => , +} diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/active-filters.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/active-filters.test.tsx new file mode 100644 index 00000000..2faa3abe --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/__tests__/active-filters.test.tsx @@ -0,0 +1,330 @@ +import type { ReactNode } from 'react' +/// +import type { ActiveFiltersProps } from '../types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { DataTableActiveFilters } from '../components/active-filters' +import { DataTableStoreContext, TableInstanceContext } from '../core/data-table-context' +import { createDataTableStore } from '../core/store' + +const mockTable = { + getCanNextPage: () => false, + getCanPreviousPage: () => false, + nextPage: vi.fn(), + previousPage: vi.fn(), + getPageCount: () => 1, + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + getAllColumns: () => [], + getFilteredSelectedRowModel: () => ({ rows: [] }), + getState: () => ({ pagination: { pageIndex: 0, pageSize: 20 } }), + getFilteredRowModel: () => ({ rows: [] }), +} as any + +interface RenderOpts { + readonly filters?: Record + readonly search?: string + readonly clearFilter?: (...args: any[]) => void + readonly clearAllFilters?: () => void + readonly clearSearch?: () => void + readonly setFilter?: (...args: any[]) => void + // Component props forwarded directly + readonly props?: Partial +} + +function renderActiveFilters(opts: RenderOpts = {}) { + const { filters = {}, search = '', props = {} } = opts + + const store = createDataTableStore>({ + data: [], + mode: 'client', + defaultFilters: filters, + }) + + if (search) + store.setSearch(search) + if (opts.clearFilter) + vi.spyOn(store, 'clearFilter').mockImplementation(opts.clearFilter as any) + if (opts.clearAllFilters) + vi.spyOn(store, 'clearAllFilters').mockImplementation(opts.clearAllFilters) + if (opts.clearSearch) + vi.spyOn(store, 'clearSearch').mockImplementation(opts.clearSearch) + if (opts.setFilter) + vi.spyOn(store, 'setFilter').mockImplementation(opts.setFilter as any) + + function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + + ) + } + + return render(, { wrapper: Wrapper }) +} + +describe('dataTableActiveFilters', () => { + it('renders nothing when no filters or search are active', () => { + const { container } = renderActiveFilters() + expect(container.querySelector('[data-slot="dt-active-filters"]')).toBeNull() + }) + + it('shows a grouped box for a select filter', () => { + renderActiveFilters({ + filters: { status: 'active' }, + props: { filterLabels: { status: 'Status' } }, + }) + + expect(screen.getByTestId('dt-active-filters')).toBeInTheDocument() + expect(screen.getByText('Selected Filters')).toBeInTheDocument() + expect(screen.getByText('Status')).toBeInTheDocument() + expect(screen.getByText('active')).toBeInTheDocument() + }) + + it('shows grouped badges for array (checkbox) filters', () => { + renderActiveFilters({ + filters: { department: ['engineering', 'sales'] }, + props: { filterLabels: { department: 'Department' } }, + }) + + expect(screen.getByText('Department')).toBeInTheDocument() + expect(screen.getByText('engineering')).toBeInTheDocument() + expect(screen.getByText('sales')).toBeInTheDocument() + expect(screen.getAllByTestId('dt-filter-group')).toHaveLength(1) + }) + + it('shows search group when search is active', () => { + renderActiveFilters({ search: 'hello' }) + + expect(screen.getByText('Search')).toBeInTheDocument() + expect(screen.getByText('hello')).toBeInTheDocument() + }) + + it('calls clearFilter when removing a select filter badge', async () => { + const user = userEvent.setup() + const clearFilter = vi.fn() + + renderActiveFilters({ + filters: { status: 'active' }, + clearFilter, + props: { filterLabels: { status: 'Status' } }, + }) + + await user.click(screen.getByLabelText('Clear Status filter')) + expect(clearFilter).toHaveBeenCalledWith('status') + }) + + it('removes individual value from array filter', async () => { + const user = userEvent.setup() + const setFilter = vi.fn() + + renderActiveFilters({ + filters: { department: ['engineering', 'sales'] }, + setFilter, + props: { filterLabels: { department: 'Department' } }, + }) + + await user.click(screen.getByLabelText('Remove engineering from Department')) + expect(setFilter).toHaveBeenCalledWith('department', ['sales']) + }) + + it('calls clearFilter when removing last array value', async () => { + const user = userEvent.setup() + const clearFilter = vi.fn() + + renderActiveFilters({ + filters: { department: ['engineering'] }, + clearFilter, + props: { filterLabels: { department: 'Department' } }, + }) + + await user.click(screen.getByLabelText('Remove engineering from Department')) + expect(clearFilter).toHaveBeenCalledWith('department') + }) + + it('calls clearSearch when removing search badge', async () => { + const user = userEvent.setup() + const clearSearch = vi.fn() + + renderActiveFilters({ search: 'hello', clearSearch }) + + await user.click(screen.getByLabelText('Clear search')) + expect(clearSearch).toHaveBeenCalled() + }) + + it('shows clear-all X icon when multiple filter groups are active', () => { + renderActiveFilters({ + filters: { status: 'active', department: ['engineering'] }, + props: { filterLabels: { status: 'Status', department: 'Department' } }, + }) + + expect(screen.getByTestId('dt-clear-all-filters')).toBeInTheDocument() + }) + + it('does not show clear-all with only one filter group', () => { + renderActiveFilters({ filters: { status: 'active' } }) + + expect(screen.queryByTestId('dt-clear-all-filters')).toBeNull() + }) + + it('calls clearAllFilters and clearSearch when clicking clear-all', async () => { + const user = userEvent.setup() + const clearAllFilters = vi.fn() + const clearSearch = vi.fn() + + renderActiveFilters({ + filters: { status: 'active' }, + search: 'hello', + clearAllFilters, + clearSearch, + }) + + await user.click(screen.getByTestId('dt-clear-all-filters')) + expect(clearAllFilters).toHaveBeenCalled() + expect(clearSearch).toHaveBeenCalled() + }) + + it('uses formatFilterValue when provided', () => { + renderActiveFilters({ + filters: { status: 'active' }, + props: { + filterLabels: { status: 'Status' }, + formatFilterValue: (_col, val) => String(val).toUpperCase(), + }, + }) + + expect(screen.getByText('ACTIVE')).toBeInTheDocument() + }) + + it('falls back to column key when filterLabels not provided', () => { + renderActiveFilters({ filters: { status: 'active' } }) + + expect(screen.getByText('status')).toBeInTheDocument() + }) + + // ── Customization tests ── + + describe('label customization', () => { + it('uses custom label text', () => { + renderActiveFilters({ + filters: { status: 'active' }, + props: { label: 'Active Filters' }, + }) + + expect(screen.getByText('Active Filters')).toBeInTheDocument() + expect(screen.queryByText('Selected Filters')).toBeNull() + }) + + it('hides label when set to null', () => { + renderActiveFilters({ + filters: { status: 'active' }, + props: { label: null }, + }) + + expect(screen.getByTestId('dt-active-filters')).toBeInTheDocument() + expect(screen.queryByText('Selected Filters')).toBeNull() + expect(screen.queryByText(/data-slot="dt-active-filters-label"/)).toBeNull() + }) + }) + + describe('clearAll variants', () => { + it('renders icon mode by default with title tooltip', () => { + renderActiveFilters({ + filters: { status: 'active', department: ['eng'] }, + }) + + const btn = screen.getByTestId('dt-clear-all-filters') + expect(btn).toHaveAttribute('title', 'Clear all') + }) + + it('renders button mode with label', () => { + renderActiveFilters({ + filters: { status: 'active', department: ['eng'] }, + props: { clearAll: 'button', clearAllLabel: 'Reset' }, + }) + + const btn = screen.getByTestId('dt-clear-all-filters') + expect(btn).toHaveTextContent('Reset') + }) + + it('renders text mode with label', () => { + renderActiveFilters({ + filters: { status: 'active', department: ['eng'] }, + props: { clearAll: 'text', clearAllLabel: 'Clear filters' }, + }) + + const btn = screen.getByTestId('dt-clear-all-filters') + expect(btn).toHaveTextContent('Clear filters') + }) + + it('uses default "Clear all" label when clearAllLabel not set', () => { + renderActiveFilters({ + filters: { status: 'active', department: ['eng'] }, + props: { clearAll: 'text' }, + }) + + expect(screen.getByTestId('dt-clear-all-filters')).toHaveTextContent('Clear all') + }) + }) + + describe('data-slot attributes', () => { + it('has data-slot on root container', () => { + const { container } = renderActiveFilters({ filters: { status: 'active' } }) + expect(container.querySelector('[data-slot="dt-active-filters"]')).toBeInTheDocument() + }) + + it('has data-slot on label', () => { + const { container } = renderActiveFilters({ filters: { status: 'active' } }) + expect(container.querySelector('[data-slot="dt-active-filters-label"]')).toBeInTheDocument() + }) + + it('has data-slot on filter groups', () => { + const { container } = renderActiveFilters({ filters: { status: 'active' } }) + expect(container.querySelector('[data-slot="dt-filter-group"]')).toBeInTheDocument() + }) + + it('has data-slot on clear-all button', () => { + const { container } = renderActiveFilters({ + filters: { status: 'active', department: ['eng'] }, + }) + expect(container.querySelector('[data-slot="dt-clear-all-filters"]')).toBeInTheDocument() + }) + }) + + describe('className props', () => { + it('applies groupClassName to filter groups', () => { + renderActiveFilters({ + filters: { status: 'active' }, + props: { groupClassName: 'custom-group' }, + }) + + const group = screen.getByTestId('dt-filter-group') + expect(group.className).toContain('custom-group') + }) + + it('applies badgeClassName to badges', () => { + renderActiveFilters({ + filters: { status: 'active' }, + props: { badgeClassName: 'custom-badge' }, + }) + + // Badge is inside the filter group, after the label span + const group = screen.getByTestId('dt-filter-group') + const badge = group.querySelector('.custom-badge') + expect(badge).toBeInTheDocument() + }) + + it('applies className to root container', () => { + renderActiveFilters({ + filters: { status: 'active' }, + props: { className: 'custom-root' }, + }) + + const root = screen.getByTestId('dt-active-filters') + expect(root.className).toContain('custom-root') + }) + }) +}) diff --git a/packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx b/packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx index b21f67f2..a065107f 100644 --- a/packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx +++ b/packages/datum-ui/src/components/features/data-table/columns/selection-column.tsx @@ -2,7 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table' import type { SelectionColumnOptions } from '../types' -import { Checkbox } from '@repo/shadcn/ui/checkbox' +import { Checkbox } from '../../../base/checkbox' const SELECTION_COLUMN_ID = 'select' diff --git a/packages/datum-ui/src/components/features/data-table/components/active-filters.tsx b/packages/datum-ui/src/components/features/data-table/components/active-filters.tsx new file mode 100644 index 00000000..47f00a90 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/components/active-filters.tsx @@ -0,0 +1,203 @@ +'use client' + +import type { ActiveFiltersProps } from '../types' +import { X } from 'lucide-react' +import { memo } from 'react' +import { cn } from '../../../../utils/cn' +import { Badge } from '../../../base/badge' +import { Button } from '../../../base/button' +import { useDataTableFilters, useDataTableSearch } from '../hooks/use-selectors' + +function formatValue(column: string, value: unknown, formatter?: ActiveFiltersProps['formatFilterValue']): string { + if (formatter) { + const result = formatter(column, value) + if (result !== undefined) + return result + } + return String(value) +} + +function FilterGroup({ + label, + children, + className, +}: { + readonly label: string + readonly children: React.ReactNode + readonly className?: string +}) { + return ( +
+ {label} + {children} +
+ ) +} + +const EMPTY_LABELS: Record = {} + +function ActiveFiltersInner({ + label = 'Selected Filters', + filterLabels = EMPTY_LABELS, + formatFilterValue: formatter, + clearAll = 'icon', + clearAllLabel = 'Clear all', + className, + groupClassName, + badgeClassName, +}: ActiveFiltersProps) { + const { filters, setFilter, clearFilter, clearAllFilters } = useDataTableFilters() + const { search, clearSearch } = useDataTableSearch() + + const activeFilterEntries = Object.entries(filters).filter( + ([, value]) => value != null && value !== '' && !(Array.isArray(value) && value.length === 0), + ) + + const hasSearch = search.length > 0 + const hasFilters = activeFilterEntries.length > 0 + if (!hasSearch && !hasFilters) + return null + + const totalGroups = activeFilterEntries.length + (hasSearch ? 1 : 0) + + const removeArrayItem = (column: string, items: string[], item: string) => { + const remaining = items.filter(v => v !== item) + if (remaining.length > 0) { + setFilter(column, remaining) + } + else { + clearFilter(column) + } + } + + const handleClearAll = () => { + clearAllFilters() + if (hasSearch) + clearSearch() + } + + const badgeCn = cn('flex items-center gap-1.5 px-2 py-0.5 text-xs', badgeClassName) + + return ( +
+ {label !== null && ( + + {label} + + )} + + {hasSearch && ( + + + {search} + + + + )} + + {activeFilterEntries.map(([column, value]) => { + const groupLabel = filterLabels[column] ?? column + + if (Array.isArray(value)) { + return ( + + {value.map(item => ( + + {formatValue(column, item, formatter)} + + + ))} + + ) + } + + return ( + + + {formatValue(column, value, formatter)} + + + + ) + })} + + {totalGroups > 1 && ( + <> + {clearAll === 'icon' && ( + + )} + {clearAll === 'button' && ( + + )} + {clearAll === 'text' && ( + + )} + + )} +
+ ) +} + +export const DataTableActiveFilters = memo(ActiveFiltersInner) diff --git a/packages/datum-ui/src/components/features/data-table/components/column-header.tsx b/packages/datum-ui/src/components/features/data-table/components/column-header.tsx index 354c50ff..82c64cc1 100644 --- a/packages/datum-ui/src/components/features/data-table/components/column-header.tsx +++ b/packages/datum-ui/src/components/features/data-table/components/column-header.tsx @@ -1,8 +1,8 @@ 'use client' import type { ColumnHeaderProps } from '../types' -import { cn } from '@repo/shadcn/lib/utils' import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react' +import { cn } from '../../../../utils/cn' export function DataTableColumnHeader({ column, diff --git a/packages/datum-ui/src/components/features/data-table/components/content.tsx b/packages/datum-ui/src/components/features/data-table/components/content.tsx index c13a2029..26781592 100644 --- a/packages/datum-ui/src/components/features/data-table/components/content.tsx +++ b/packages/datum-ui/src/components/features/data-table/components/content.tsx @@ -2,7 +2,9 @@ import type { Cell, Row } from '@tanstack/react-table' import type { ContentProps, InlineContentEntry } from '../types' -import { cn } from '@repo/shadcn/lib/utils' +import { flexRender } from '@tanstack/react-table' +import { useMemo } from 'react' +import { cn } from '../../../../utils/cn' import { Table, TableBody, @@ -10,9 +12,7 @@ import { TableHead, TableHeader, TableRow, -} from '@repo/shadcn/ui/table' -import { flexRender } from '@tanstack/react-table' -import { useMemo } from 'react' +} from '../../../base/table' import { useDataTableInlineContents, useDataTableRows } from '../hooks/use-selectors' function resolveClassName( diff --git a/packages/datum-ui/src/components/features/data-table/components/loading.tsx b/packages/datum-ui/src/components/features/data-table/components/loading.tsx index 076927d4..3e3e7af9 100644 --- a/packages/datum-ui/src/components/features/data-table/components/loading.tsx +++ b/packages/datum-ui/src/components/features/data-table/components/loading.tsx @@ -1,7 +1,7 @@ 'use client' import type { LoadingProps } from '../types' -import { Skeleton } from '@repo/shadcn/ui/skeleton' +import { Skeleton } from '../../../base/skeleton' import { DEFAULT_LOADING_ROWS } from '../constants' export function DataTableLoading({ diff --git a/packages/datum-ui/src/components/features/data-table/components/pagination.tsx b/packages/datum-ui/src/components/features/data-table/components/pagination.tsx index 8f3e9687..caad0fdf 100644 --- a/packages/datum-ui/src/components/features/data-table/components/pagination.tsx +++ b/packages/datum-ui/src/components/features/data-table/components/pagination.tsx @@ -1,17 +1,17 @@ 'use client' import type { PaginationProps } from '../types' -import { cn } from '@repo/shadcn/lib/utils' -import { Button } from '@repo/shadcn/ui/button' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useMemo } from 'react' +import { cn } from '../../../../utils/cn' +import { Button } from '../../../base/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@repo/shadcn/ui/select' -import { ChevronLeft, ChevronRight } from 'lucide-react' -import { useMemo } from 'react' +} from '../../../base/select' import { DEFAULT_PAGE_SIZES } from '../constants' import { useDataTablePagination } from '../hooks/use-selectors' @@ -123,7 +123,7 @@ export function DataTablePagination({ {/* Right side: page navigation */}
{/* Right side: page navigation */} -
+
+
) } diff --git a/packages/datum-ui/src/components/features/data-table/components/search.tsx b/packages/datum-ui/src/components/features/data-table/components/search.tsx index 713bcaa5..baf3d144 100644 --- a/packages/datum-ui/src/components/features/data-table/components/search.tsx +++ b/packages/datum-ui/src/components/features/data-table/components/search.tsx @@ -34,6 +34,7 @@ export function DataTableSearch({ value={inputValue} onChange={e => setInputValue(e.target.value)} className={className} + aria-label={placeholder} data-slot="dt-search" /> ) diff --git a/packages/datum-ui/src/components/features/data-table/core/store.ts b/packages/datum-ui/src/components/features/data-table/core/store.ts index c43926f3..ba624673 100644 --- a/packages/datum-ui/src/components/features/data-table/core/store.ts +++ b/packages/datum-ui/src/components/features/data-table/core/store.ts @@ -11,7 +11,7 @@ import { applyFilters } from './filter-engine' export function createDataTableStore( options: CreateStoreOptions, ): DataTableStore { - const registeredFilters = new Map() + let registeredFilters = new Map() const listeners = new Set<() => void>() function computeFilteredData(s: DataTableStoreState): TData[] { @@ -115,11 +115,21 @@ export function createDataTableStore( }, setPageIndex: (pageIndex) => { - setState({ ...state, pageIndex, rowSelection: {} }) + if (!Number.isFinite(pageIndex) || pageIndex < 0) + return + setState({ ...state, pageIndex: Math.floor(pageIndex), rowSelection: {} }) }, setPageSize: (pageSize) => { - setState({ ...state, pageSize, pageIndex: 0, rowSelection: {} }) + if (!Number.isFinite(pageSize) || pageSize < 1) + return + setState({ ...state, pageSize: Math.floor(pageSize), pageIndex: 0, rowSelection: {} }) + }, + + setPagination: (pageIndex, pageSize) => { + const safeIndex = Number.isFinite(pageIndex) ? Math.max(0, Math.floor(pageIndex)) : state.pageIndex + const safeSize = Number.isFinite(pageSize) ? Math.max(1, Math.floor(pageSize)) : state.pageSize + setState({ ...state, pageIndex: safeIndex, pageSize: safeSize, rowSelection: {} }) }, setLoading: (isLoading) => { @@ -131,13 +141,17 @@ export function createDataTableStore( }, registerFilter: (column, strategy) => { - registeredFilters.set(column, strategy) + const next = new Map(registeredFilters) + next.set(column, strategy) + registeredFilters = next const filteredData = computeFilteredData(state) setState({ ...state, filteredData }) }, unregisterFilter: (column) => { - registeredFilters.delete(column) + const next = new Map(registeredFilters) + next.delete(column) + registeredFilters = next if (column in state.filters) { const filteredData = computeFilteredData(state) setState({ ...state, filteredData }) diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts index 0835fe41..fcfe8325 100644 --- a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts @@ -99,8 +99,7 @@ export function useDataTableClient(options: UseDataTableClientOptions { const prev = { pageIndex, pageSize: storePageSize } const next = typeof updater === 'function' ? updater(prev) : updater - store.setPageIndex(next.pageIndex) - store.setPageSize(next.pageSize) + store.setPagination(next.pageIndex, next.pageSize) }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts index ccaabf46..5be8fd34 100644 --- a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts @@ -11,7 +11,7 @@ import { getCoreRowModel, useReactTable, } from '@tanstack/react-table' -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { withSelectionColumn } from '../columns/selection-column' import { createDataTableStore } from '../core/store' @@ -70,8 +70,8 @@ export function useDataTableServer( // Page 0 has undefined cursor (start from beginning) const cursorMapRef = useRef>(new Map()) - // Track hasNextPage to control the Next button - const [hasNextPage, setHasNextPage] = useState(false) + // Track hasNextPage via ref to avoid extra state/render cycle + const hasNextPageRef = useRef(false) // 1. Create store const store = useMemo>( @@ -90,8 +90,30 @@ export function useDataTableServer( const { sorting, filters, search, rowSelection, pageSize, pageIndex } = useSyncExternalStore(store.subscribe, store.getSnapshot) - // 3. Fetch on query change (including page changes) + // 3. Fetch on query change — also resets cursors when query params change + const prevQueryRef = useRef({ sorting, filters, search, pageSize }) useEffect(() => { + // Detect query param changes (vs page-only changes) + const prev = prevQueryRef.current + const queryChanged + = prev.sorting !== sorting + || prev.filters !== filters + || prev.search !== search + || prev.pageSize !== pageSize + + if (queryChanged) { + cursorMapRef.current = new Map() + hasNextPageRef.current = false + // Reset to first page — the store update will re-trigger this effect + // with pageIndex=0, which then performs the actual fetch + if (pageIndex !== 0) { + prevQueryRef.current = { sorting, filters, search, pageSize } + store.setPageIndex(0) + return + } + } + prevQueryRef.current = { sorting, filters, search, pageSize } + let cancelled = false store.setLoading(true) @@ -115,14 +137,14 @@ export function useDataTableServer( if (result.nextCursor) { cursorMapRef.current.set(pageIndex + 1, result.nextCursor) } - setHasNextPage(result.hasNextPage) + hasNextPageRef.current = result.hasNextPage }) .catch((error) => { if (cancelled) return store.setServerData([] as TData[]) store.setError(error instanceof Error ? error : new Error(String(error))) - setHasNextPage(false) + hasNextPageRef.current = false }) .finally(() => { if (!cancelled) @@ -134,24 +156,6 @@ export function useDataTableServer( } }, [sorting, filters, search, pageSize, pageIndex, store]) - // Reset cursors when query params change (not page changes) - const prevQueryRef = useRef({ sorting, filters, search, pageSize }) - useEffect(() => { - const prev = prevQueryRef.current - const queryChanged - = prev.sorting !== sorting - || prev.filters !== filters - || prev.search !== search - || prev.pageSize !== pageSize - - if (queryChanged) { - cursorMapRef.current = new Map() - store.setPageIndex(0) - setHasNextPage(false) - } - prevQueryRef.current = { sorting, filters, search, pageSize } - }, [sorting, filters, search, pageSize, store]) - // 4. Resolve columns const resolvedColumns = useMemo( () => enableRowSelection @@ -173,7 +177,7 @@ export function useDataTableServer( manualSorting: true, manualFiltering: true, // Tell TanStack about page capabilities so pagination component works - pageCount: hasNextPage ? pageIndex + 2 : pageIndex + 1, + pageCount: hasNextPageRef.current ? pageIndex + 2 : pageIndex + 1, getCoreRowModel: getCoreRowModel(), getRowId, enableRowSelection: !!enableRowSelection, @@ -188,10 +192,7 @@ export function useDataTableServer( onPaginationChange: (updater) => { const prev = { pageIndex, pageSize } const next = typeof updater === 'function' ? updater(prev) : updater - if (next.pageIndex !== pageIndex) - store.setPageIndex(next.pageIndex) - if (next.pageSize !== pageSize) - store.setPageSize(next.pageSize) + store.setPagination(next.pageIndex, next.pageSize) }, }) diff --git a/packages/datum-ui/src/components/features/data-table/types.ts b/packages/datum-ui/src/components/features/data-table/types.ts index 8bd412ac..09fc89dd 100644 --- a/packages/datum-ui/src/components/features/data-table/types.ts +++ b/packages/datum-ui/src/components/features/data-table/types.ts @@ -354,6 +354,7 @@ export interface DataTableStore { readonly setRowSelection: (selection: RowSelectionState) => void readonly setPageIndex: (index: number) => void readonly setPageSize: (size: number) => void + readonly setPagination: (pageIndex: number, pageSize: number) => void readonly setLoading: (loading: boolean) => void readonly setError: (error: Error | null) => void readonly registerFilter: (column: string, strategy: FilterStrategy) => void From 2d60c1543ec05f91bd5d7795d4797fb31a239c56 Mon Sep 17 00:00:00 2001 From: Yahya Date: Thu, 12 Mar 2026 04:44:36 +0700 Subject: [PATCH 04/13] fix(data-table): SSR-safe providers with inline-only API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add getServerSnapshot to all useSyncExternalStore calls for React 18+ SSR compatibility. Refactor providers to accept hook options directly as props, creating store/table internally with a two-layer SSR gate (useIsClient → conditional render). Breaking changes: - useDataTableClient and useDataTableServer removed from public exports - useDataTableContext deprecated facade removed - Providers no longer accept store/table props; pass data/columns/etc. directly Changes: - Add useIsClient hook for SSR/hydration detection - Rewrite ClientProvider and ServerProvider with inner/outer pattern - Add ssrFallback prop for custom loading state during SSR - Remove useDataTableContext deprecated facade from use-selectors - Delete hooks/use-data-table-context.ts re-export file - Update all tests and storybook stories to inline API --- .../stories/features/data-table.stories.tsx | 244 +++++++----------- .../__tests__/client-provider.test.tsx | 38 ++- .../data-table/__tests__/content.test.tsx | 4 +- .../__tests__/inline-content.test.tsx | 26 +- .../data-table/__tests__/pagination.test.tsx | 4 +- .../data-table/__tests__/search.test.tsx | 4 +- .../__tests__/server-provider.test.tsx | 45 ++-- .../__tests__/use-is-client.test.ts | 11 + .../__tests__/use-selectors.test.tsx | 18 -- .../data-table/core/client-provider.tsx | 32 ++- .../data-table/core/server-provider.tsx | 34 ++- .../features/data-table/hooks/index.ts | 1 - .../data-table/hooks/use-data-table-client.ts | 2 +- .../hooks/use-data-table-context.ts | 1 - .../data-table/hooks/use-data-table-server.ts | 2 +- .../data-table/hooks/use-is-client.ts | 16 ++ .../data-table/hooks/use-selectors.ts | 38 +-- .../components/features/data-table/index.ts | 4 - 18 files changed, 233 insertions(+), 291 deletions(-) create mode 100644 packages/datum-ui/src/components/features/data-table/__tests__/use-is-client.test.ts delete mode 100644 packages/datum-ui/src/components/features/data-table/hooks/use-data-table-context.ts create mode 100644 packages/datum-ui/src/components/features/data-table/hooks/use-is-client.ts diff --git a/apps/storybook/stories/features/data-table.stories.tsx b/apps/storybook/stories/features/data-table.stories.tsx index 71b4e0be..9dacd722 100644 --- a/apps/storybook/stories/features/data-table.stories.tsx +++ b/apps/storybook/stories/features/data-table.stories.tsx @@ -2,9 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table' import type { Meta, StoryObj } from 'storybook-react-rsbuild' import { DataTable, - useDataTableClient, useDataTableLoading, - useDataTableServer, } from '@datum-cloud/datum-ui/data-table' import { useState } from 'react' @@ -102,17 +100,15 @@ const columns: ColumnDef[] = [ // --------------------------------------------------------------------------- function ClientTableDemo() { - const tableState = useDataTableClient({ - data: sampleUsers, - columns, - pageSize: 5, - getRowId: row => row.id, - enableRowSelection: true, - defaultSort: [{ id: 'name', desc: false }], - }) - return ( - + row.id} + enableRowSelection + defaultSort={[{ id: 'name', desc: false }]} + >
@@ -237,30 +233,28 @@ function ServerTableContent() { } function ServerTableDemo() { - const tableState = useDataTableServer({ - columns: postColumns, - limit: 10, - getRowId: (row: Post) => String(row.id), - fetchFn: async (args) => { - const start = args.cursor ? Number(args.cursor) : 0 - const res = await fetch( - `https://jsonplaceholder.typicode.com/posts?_start=${start}&_limit=${args.limit}`, - ) - return res.json() - }, - transform: (posts) => { - const start = posts.length > 0 ? (posts[0]?.id ?? 1) - 1 : 0 - const nextStart = start + posts.length - return { - data: posts, - cursor: nextStart < 100 ? String(nextStart) : undefined, - hasNextPage: posts.length === 10 && nextStart < 100, - } - }, - }) - return ( - + String(row.id)} + fetchFn={async (args) => { + const start = args.cursor ? Number(args.cursor) : 0 + const res = await fetch( + `https://jsonplaceholder.typicode.com/posts?_start=${start}&_limit=${args.limit}`, + ) + return res.json() + }} + transform={(posts: Post[]) => { + const start = posts.length > 0 ? (posts[0]?.id ?? 1) - 1 : 0 + const nextStart = start + posts.length + return { + data: posts, + cursor: nextStart < 100 ? String(nextStart) : undefined, + hasNextPage: posts.length === 10 && nextStart < 100, + } + }} + > ) @@ -275,13 +269,11 @@ export const ServerTable: Story = { // --------------------------------------------------------------------------- function MinimalDemo() { - const tableState = useDataTableClient({ - data: sampleUsers.slice(0, 5), - columns: columns.filter(c => 'accessorKey' in c && c.accessorKey !== 'createdAt'), - }) - return ( - + 'accessorKey' in c && c.accessorKey !== 'createdAt')} + > ) @@ -296,14 +288,8 @@ export const Minimal: Story = { // --------------------------------------------------------------------------- function CheckboxFilterDemo() { - const tableState = useDataTableClient({ - data: sampleUsers, - columns, - pageSize: 6, - }) - return ( - +
@@ -337,17 +323,15 @@ export const WithCheckboxFilters: Story = { // --------------------------------------------------------------------------- function MultiSelectBulkActionsDemo() { - const tableState = useDataTableClient({ - data: sampleUsers, - columns, - pageSize: 6, - getRowId: row => row.id, - enableRowSelection: true, - defaultSort: [{ id: 'name', desc: false }], - }) - return ( - + row.id} + enableRowSelection + defaultSort={[{ id: 'name', desc: false }]} + >
@@ -413,15 +397,13 @@ export const MultiSelectBulkActions: Story = { // --------------------------------------------------------------------------- function CustomStylingDemo() { - const tableState = useDataTableClient({ - data: sampleUsers, - columns: columns.filter(c => !('id' in c && c.id === 'actions')), - pageSize: 6, - getRowId: row => row.id, - }) - return ( - + !('id' in c && c.id === 'actions'))} + pageSize={6} + getRowId={row => row.id} + >
{/* Inline styles targeting data-slot attributes */}