Skip to content

Commit 4702cdb

Browse files
committed
fix(data-table): SSR fallback removal, loading prop, and formatFilterValue API
Remove the SSR hydration gate (useIsClient) and make the store always available during SSR. Table instance is deferred to after hydration via a tableReady state gate, with null-safe selector hooks throughout. - Remove useIsClient hook and SSR fallback rendering - Add useTableInstanceOrNull for null-safe table access during SSR - Refactor ClientProvider into outer (store) + inner (table) components - Extract useClientTable and useServerTable hooks from providers - Add loading prop to DataTable.Client for consumer-controlled skeleton state - Add columnCount to store so Content derives skeleton columns from columns.length - Fix loading effect ordering to prevent empty-message flash - Refactor formatFilterValue from function to Record<string, (value) => string> with dot-notation support - Remove dead setTable from store interface - Deprecate useTableInstance with accurate hydration error message - Update docs, storybook, and tests for all changes
1 parent 7487874 commit 4702cdb

21 files changed

Lines changed: 791 additions & 223 deletions

apps/docs/content/docs/components/features/data-table.mdx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,13 @@ function UsersPage({ users }: { users: User[] }) {
115115
For paginated API data (e.g., Kubernetes resources with continuation tokens). Like the client mode, pass all options as props directly to `DataTable.Server`:
116116

117117
```tsx
118-
import { DataTable, useDataTableLoading } from '@datum-cloud/datum-ui/data-table'
118+
import { DataTable } from '@datum-cloud/datum-ui/data-table'
119119

120120
function ServerContent() {
121-
const { isLoading } = useDataTableLoading()
122121
return (
123122
<div className="flex flex-col gap-4">
124123
<DataTable.Search placeholder="Search..." />
125-
{isLoading
126-
? <DataTable.Loading rows={5} columns={4} />
127-
: <DataTable.Content emptyMessage="No results." />}
124+
<DataTable.Content emptyMessage="No results." />
128125
<DataTable.Pagination />
129126
</div>
130127
)
@@ -324,12 +321,15 @@ Display currently active filters as removable badges, grouped by category. Suppo
324321
filterLabels={{ status: 'Status' }}
325322
/>
326323

327-
{/* Custom value formatting */}
324+
{/* Custom value formatting — per-column record, consumer-typed values */}
328325
<DataTable.ActiveFilters
329-
filterLabels={{ status: 'Status' }}
330-
formatFilterValue={(column, value) => {
331-
if (column === 'status') return String(value).toUpperCase()
332-
return undefined // fallback to default
326+
filterLabels={{ status: 'Status', 'status.approval': 'Approval' }}
327+
formatFilterValue={{
328+
status: (value: string) => value.toUpperCase(),
329+
'status.approval': (value: string) => {
330+
const labels = { Approved: 'Approved', Rejected: 'Rejected', Pending: 'Pending' }
331+
return labels[value] ?? value
332+
},
333333
}}
334334
/>
335335
```
@@ -365,7 +365,7 @@ All filter and search components accept a `disabled` prop. Use this to prevent i
365365
```tsx
366366
const { data, isLoading } = useFetchData()
367367

368-
<DataTable.Client data={data} columns={columns}>
368+
<DataTable.Client data={data} columns={columns} loading={isLoading}>
369369
<DataTable.Search placeholder="Search..." disabled={isLoading} />
370370
<DataTable.SelectFilter
371371
column="status"
@@ -384,7 +384,7 @@ const { data, isLoading } = useFetchData()
384384
label="Created"
385385
disabled={isLoading}
386386
/>
387-
{isLoading ? <DataTable.Loading /> : <DataTable.Content />}
387+
<DataTable.Content emptyMessage="No results." />
388388
</DataTable.Client>
389389
```
390390

@@ -395,7 +395,7 @@ const { data, isLoading } = useFetchData()
395395
| `label` | `string \| null` | `"Selected Filters"` | Label text before filter groups. Set to `null` to hide |
396396
| `excludeFilters` | `string[]` | -- | Keys to hide from display. Use column data paths for filters, `'search'` for the search badge |
397397
| `filterLabels` | `Record<string, string>` | `{}` | Maps column keys to display names |
398-
| `formatFilterValue` | `(column, value) => string \| undefined` | -- | Custom value formatter |
398+
| `formatFilterValue` | `Record<string, (value) => string>` | -- | Per-column value formatters. Keys support dot notation. |
399399
| `clearAll` | `'icon' \| 'button' \| 'text'` | `'icon'` | Clear-all action style |
400400
| `clearAllLabel` | `string` | `"Clear all"` | Label for the clear-all action |
401401
| `className` | `string` | -- | Root container class |
@@ -552,9 +552,9 @@ All options are passed as props directly to the provider component. The provider
552552
| `searchableColumns` | `string[]` | -- | Data paths to search in. Supports dot notation (e.g. `['metadata.name', 'spec.email']`) |
553553
| `searchFn` | `(row, search) => boolean` | -- | Custom search function |
554554
| `filterFns` | `Record<string, (cellValue, filterValue) => boolean>` | -- | Custom filter functions per column. Keys are data paths |
555+
| `loading` | `boolean` | -- | Consumer-controlled loading state. When `true`, `DataTable.Content` shows skeleton rows instead of the empty message |
555556
| `stateAdapter` | `StateAdapter` | -- | URL state sync adapter |
556557
| `className` | `string` | -- | Class for the provider wrapper div |
557-
| `ssrFallback` | `ReactNode \| null` | `<DataTable.Loading>` | Rendered during SSR and first client paint. Defaults to a skeleton table matching column count. Set to `null` to render nothing |
558558
| `children` | `ReactNode` | -- | Table content |
559559

560560
### `DataTable.Server` Props
@@ -571,22 +571,21 @@ All options are passed as props directly to the provider component. The provider
571571
| `defaultFilters` | `FilterValue` | `{}` | Initial filter state |
572572
| `stateAdapter` | `StateAdapter` | -- | URL state sync adapter |
573573
| `className` | `string` | -- | Class for the provider wrapper div |
574-
| `ssrFallback` | `ReactNode \| null` | `<DataTable.Loading>` | Rendered during SSR and first client paint. Defaults to a skeleton table matching column count. Set to `null` to render nothing |
575574
| `children` | `ReactNode` | -- | Table content |
576575

577576
### `DataTable` Namespace
578577

579578
| Component | Description |
580579
|-----------|-------------|
581-
| `DataTable.Client` | Client-mode provider — pass `data`, `columns`, and all options as props. SSR-safe with automatic skeleton fallback |
582-
| `DataTable.Server` | Server-mode provider — pass `columns`, `fetchFn`, `transform`, and all options as props. SSR-safe with automatic skeleton fallback |
583-
| `DataTable.Content` | Table rendering with header, body, and empty state. Accepts sub-element className props (`tableClassName`, `headerClassName`, `headerRowClassName`, `headerCellClassName`, `bodyClassName`, `rowClassName`, `cellClassName`) |
580+
| `DataTable.Client` | Client-mode provider — pass `data`, `columns`, and all options as props. Accepts `loading` prop for consumer-controlled skeleton state |
581+
| `DataTable.Server` | Server-mode provider — pass `columns`, `fetchFn`, `transform`, and all options as props. Manages loading state internally via fetch lifecycle |
582+
| `DataTable.Content` | Table rendering with header, body, empty state, and skeleton loading. Shows skeleton rows (matching `pageSize` and column count) when loading. Accepts sub-element className props (`tableClassName`, `headerClassName`, `headerRowClassName`, `headerCellClassName`, `bodyClassName`, `rowClassName`, `cellClassName`) |
584583
| `DataTable.ColumnHeader` | Click-to-sort column header |
585584
| `DataTable.Pagination` | Page navigation with page size selector |
586585
| `DataTable.Search` | Debounced search input. Accepts `disabled` prop |
587586
| `DataTable.RowActions` | Per-row action dropdown from `ActionItem[]` |
588587
| `DataTable.BulkActions` | Render prop for selected rows |
589-
| `DataTable.Loading` | Skeleton loading state |
588+
| `DataTable.Loading` | Standalone skeleton table. Use for custom loading layouts outside `DataTable.Content` |
590589
| `DataTable.InlineContent` | Render custom content inline within the table body. Supports `position` (`"top"` or `"row"`), `open`, `onClose`, and `rowId` props |
591590
| `DataTable.SelectFilter` | Combobox popover filter with single-select. Accepts `searchable` and `disabled` props |
592591
| `DataTable.CheckboxFilter` | Multi-value checkbox popover filter with badges and clear button. Accepts `disabled` prop |

apps/storybook/stories/features/data-table.stories.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import type { ColumnDef } from '@tanstack/react-table'
22
import type { Meta, StoryObj } from 'storybook-react-rsbuild'
3-
import {
4-
DataTable,
5-
useDataTableLoading,
6-
} from '@datum-cloud/datum-ui/data-table'
3+
import { DataTable } from '@datum-cloud/datum-ui/data-table'
74
import { useState } from 'react'
85

96
const meta: Meta = {
@@ -219,14 +216,11 @@ const postColumns: ColumnDef<Post>[] = [
219216
]
220217

221218
function ServerTableContent() {
222-
const { isLoading } = useDataTableLoading()
223219
return (
224220
<div className="flex flex-col gap-4">
225221
<DataTable.Search placeholder="Search posts..." />
226222
<DataTable.ActiveFilters />
227-
{isLoading
228-
? <DataTable.Loading rows={5} columns={4} />
229-
: <DataTable.Content emptyMessage="No posts found." />}
223+
<DataTable.Content emptyMessage="No posts found." />
230224
<DataTable.Pagination />
231225
</div>
232226
)
@@ -690,15 +684,19 @@ function ActiveFiltersDemo() {
690684
</DataTable.Client>
691685
</div>
692686

693-
{/* Custom label + button clear */}
687+
{/* Custom label + button clear + formatFilterValue */}
694688
<div>
695-
<h3 className="mb-2 text-sm font-medium text-muted-foreground">Custom label with button clear</h3>
689+
<h3 className="mb-2 text-sm font-medium text-muted-foreground">Custom label with button clear + formatted values</h3>
696690
<DataTable.Client {...sharedFilterOptions}>
697691
<div className="flex flex-col gap-4">
698692
{filterControls}
699693
<DataTable.ActiveFilters
700694
label="Filters Applied"
701695
filterLabels={filterLabels}
696+
formatFilterValue={{
697+
status: (value: string) => value === 'active' ? 'Active' : 'Inactive',
698+
role: (value: string) => `Role: ${value}`,
699+
}}
702700
clearAll="button"
703701
clearAllLabel="Reset"
704702
/>

packages/datum-ui/src/components/features/data-table/__tests__/active-filters.test.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,42 @@ describe('dataTableActiveFilters', () => {
142142
filters: { status: 'active' },
143143
props: {
144144
filterLabels: { status: 'Status' },
145-
formatFilterValue: (_col, val) => String(val).toUpperCase(),
145+
formatFilterValue: {
146+
status: (value: string) => value.toUpperCase(),
147+
},
148+
},
149+
})
150+
151+
expect(screen.getByText('ACTIVE')).toBeInTheDocument()
152+
})
153+
154+
it('falls back to String(value) for columns without a formatter', () => {
155+
renderActiveFilters({
156+
filters: { status: 'active', department: 'engineering' },
157+
props: {
158+
filterLabels: { status: 'Status', department: 'Department' },
159+
formatFilterValue: {
160+
status: (value: string) => value.toUpperCase(),
161+
},
146162
},
147163
})
148164

149165
expect(screen.getByText('ACTIVE')).toBeInTheDocument()
166+
expect(screen.getByText('engineering')).toBeInTheDocument()
167+
})
168+
169+
it('supports dot-notation keys in formatFilterValue', () => {
170+
renderActiveFilters({
171+
filters: { 'status.approval': 'Pending' },
172+
props: {
173+
filterLabels: { 'status.approval': 'Approval' },
174+
formatFilterValue: {
175+
'status.approval': (value: string) => `[${value}]`,
176+
},
177+
},
178+
})
179+
180+
expect(screen.getByText('[Pending]')).toBeInTheDocument()
150181
})
151182

152183
it('falls back to column key when filterLabels not provided', () => {

packages/datum-ui/src/components/features/data-table/__tests__/client-provider.test.tsx

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/// <reference types="@testing-library/jest-dom/vitest" />
22
import { render, screen } from '@testing-library/react'
3-
import { describe, expect, it } from 'vitest'
3+
import userEvent from '@testing-library/user-event'
4+
import { describe, expect, it, vi } from 'vitest'
45
import { ClientProvider } from '../core/client-provider'
56
import { useDataTableStore } from '../core/data-table-context'
6-
import { useDataTablePagination, useDataTableSearch } from '../hooks/use-selectors'
7+
import { useDataTableFilters, useDataTableLoading, useDataTablePagination, useDataTableSearch } from '../hooks/use-selectors'
78

89
interface TestRow {
910
readonly id: string
@@ -43,4 +44,82 @@ describe('clientProvider', () => {
4344
expect(screen.getByTestId('search')).toHaveTextContent('')
4445
expect(screen.getByTestId('page-size')).toHaveTextContent('20')
4546
})
47+
48+
it('always renders children (no SSR gate)', () => {
49+
render(
50+
<ClientProvider data={testData} columns={testColumns}>
51+
<span data-testid="child">visible</span>
52+
</ClientProvider>,
53+
)
54+
expect(screen.getByTestId('child')).toBeInTheDocument()
55+
})
56+
57+
it('accepts filters set before table is ready', async () => {
58+
function FilterReader() {
59+
const { filters, setFilter } = useDataTableFilters()
60+
return (
61+
<div>
62+
<span data-testid="filter-count">{Object.keys(filters).length}</span>
63+
<button type="button" onClick={() => setFilter('status', 'active')}>Set Filter</button>
64+
</div>
65+
)
66+
}
67+
68+
render(
69+
<ClientProvider data={testData} columns={testColumns}>
70+
<FilterReader />
71+
</ClientProvider>,
72+
)
73+
74+
expect(screen.getByTestId('filter-count')).toHaveTextContent('0')
75+
await userEvent.click(screen.getByText('Set Filter'))
76+
expect(screen.getByTestId('filter-count')).toHaveTextContent('1')
77+
})
78+
79+
it('syncs loading prop into store', async () => {
80+
function LoadingReader() {
81+
const { isLoading } = useDataTableLoading()
82+
return <span data-testid="loading">{String(isLoading)}</span>
83+
}
84+
85+
const { rerender } = render(
86+
<ClientProvider data={[]} columns={testColumns} loading>
87+
<LoadingReader />
88+
</ClientProvider>,
89+
)
90+
91+
// After hydration, loading=true should be synced to store
92+
await vi.waitFor(() => {
93+
expect(screen.getByTestId('loading')).toHaveTextContent('true')
94+
})
95+
96+
// When loading changes to false, store updates
97+
rerender(
98+
<ClientProvider data={testData} columns={testColumns} loading={false}>
99+
<LoadingReader />
100+
</ClientProvider>,
101+
)
102+
103+
await vi.waitFor(() => {
104+
expect(screen.getByTestId('loading')).toHaveTextContent('false')
105+
})
106+
})
107+
108+
it('defaults isLoading to false when loading prop is omitted', async () => {
109+
function LoadingReader() {
110+
const { isLoading } = useDataTableLoading()
111+
return <span data-testid="loading">{String(isLoading)}</span>
112+
}
113+
114+
render(
115+
<ClientProvider data={testData} columns={testColumns}>
116+
<LoadingReader />
117+
</ClientProvider>,
118+
)
119+
120+
// Without loading prop, store should eventually settle to false
121+
await vi.waitFor(() => {
122+
expect(screen.getByTestId('loading')).toHaveTextContent('false')
123+
})
124+
})
46125
})

0 commit comments

Comments
 (0)