feat: add separator support to data table (#24752)
* feat: add separator support to data table * chore: remove unnecessary comments * chore: move files to more appropriate places * fix: border bottom
This commit is contained in:
@@ -129,7 +129,7 @@ const columns = [
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
header: "Role",
|
||||
header: "Role",
|
||||
accessorKey: "role",
|
||||
meta: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
@@ -146,7 +146,7 @@ function UserTable() {
|
||||
});
|
||||
|
||||
const pathname = usePathname();
|
||||
const tableIdentifier = "hard-coded idenfidier" // or pathname;
|
||||
const tableIdentifier = "hard-coded idenfidier"; // or pathname;
|
||||
|
||||
return (
|
||||
<DataTableProvider tableIdentifier={tableIdentifier}>
|
||||
@@ -182,13 +182,13 @@ The context provider that manages all table state including filters, sorting, pa
|
||||
|
||||
```tsx
|
||||
interface DataTableProviderProps {
|
||||
tableIdentifier: string; // Unique identifier for the table (throws if empty)
|
||||
tableIdentifier: string; // Unique identifier for the table (throws if empty)
|
||||
children: React.ReactNode;
|
||||
useSegments?: UseSegments; // Custom segment hook
|
||||
defaultPageSize?: number; // Default: 10
|
||||
ctaContainerClassName?: string; // CSS class for CTA container
|
||||
segments?: FilterSegmentOutput[]; // Provided segments
|
||||
timeZone?: string; // Timezone for date filters
|
||||
useSegments?: UseSegments; // Custom segment hook
|
||||
defaultPageSize?: number; // Default: 10
|
||||
ctaContainerClassName?: string; // CSS class for CTA container
|
||||
segments?: FilterSegmentOutput[]; // Provided segments
|
||||
timeZone?: string; // Timezone for date filters
|
||||
preferredSegmentId?: SegmentIdentifier | null;
|
||||
systemSegments?: SystemFilterSegment[];
|
||||
}
|
||||
@@ -264,19 +264,18 @@ type DataTableWrapperProps<TData> = {
|
||||
// Toolbar slots
|
||||
ToolbarLeft?: React.ReactNode;
|
||||
ToolbarRight?: React.ReactNode;
|
||||
|
||||
|
||||
// Loading states
|
||||
EmptyView?: React.ReactNode;
|
||||
LoaderView?: React.ReactNode;
|
||||
} & (
|
||||
// Infinite pagination
|
||||
} & ( // Infinite pagination
|
||||
| {
|
||||
paginationMode: "infinite";
|
||||
hasNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
isFetching: boolean;
|
||||
}
|
||||
// Standard pagination
|
||||
// Standard pagination
|
||||
| {
|
||||
paginationMode: "standard";
|
||||
hasNextPage?: never;
|
||||
@@ -298,6 +297,150 @@ The core table component with column resizing and pinning support.
|
||||
- **Accessibility** - Full keyboard navigation support
|
||||
- **Optional virtualization** - Available for infinite mode (use with caution)
|
||||
|
||||
## Separator Rows
|
||||
|
||||
The DataTable supports separator rows that act as visual group headers between regular data rows. Separators are useful for grouping related data and providing visual organization.
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```tsx
|
||||
export type SeparatorRow = {
|
||||
type: "separator";
|
||||
label: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type DataTableRow<TData> = TData | SeparatorRow;
|
||||
|
||||
export function isSeparatorRow<TData>(row: DataTableRow<TData>): row is SeparatorRow {
|
||||
return typeof row === "object" && row !== null && "type" in row && row.type === "separator";
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Separator rows are defined in your data array alongside regular rows:
|
||||
|
||||
```tsx
|
||||
const data = [
|
||||
{ type: "separator", label: "Active Users" },
|
||||
{ id: 1, name: "Alice", status: "active" },
|
||||
{ id: 2, name: "Bob", status: "active" },
|
||||
{ type: "separator", label: "Inactive Users" },
|
||||
{ id: 3, name: "Charlie", status: "inactive" },
|
||||
];
|
||||
|
||||
<DataTableWrapper
|
||||
table={table}
|
||||
hideSeparatorsOnSort={true}
|
||||
separatorClassName="custom-separator"
|
||||
// ... other props
|
||||
/>;
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Props
|
||||
|
||||
- `hideSeparatorsOnSort?: boolean` (default: `true`) - Hide separators when sorting is active
|
||||
- `hideSeparatorsOnFilter?: boolean` (default: `false`) - Hide separators when filtering is active
|
||||
- `separatorClassName?: string` - Global CSS class applied to all separator rows
|
||||
|
||||
#### Per-Separator Styling
|
||||
|
||||
Each separator can have its own styling:
|
||||
|
||||
```tsx
|
||||
const data = [
|
||||
{
|
||||
type: "separator",
|
||||
label: "Active Users",
|
||||
className: "bg-green-50 text-green-900",
|
||||
},
|
||||
{ id: 1, name: "Alice" },
|
||||
{
|
||||
type: "separator",
|
||||
label: "Inactive Users",
|
||||
className: "bg-red-50 text-red-900",
|
||||
},
|
||||
{ id: 2, name: "Bob" },
|
||||
];
|
||||
```
|
||||
|
||||
### Behavior with Sorting and Filtering
|
||||
|
||||
**Default Behavior:**
|
||||
|
||||
- Separators are visible when no sorting/filtering is applied
|
||||
- Separators automatically hide when sorting is applied (if `hideSeparatorsOnSort` is true)
|
||||
- Separators reappear when sorting is cleared
|
||||
|
||||
**Why Hide on Sort?**
|
||||
When users sort by a column, the logical grouping may no longer make sense. For example, if you group by status but then sort by name, the status groups become meaningless. Hiding separators during sorting provides a cleaner, more intuitive experience.
|
||||
|
||||
**Custom Behavior:**
|
||||
You can disable automatic hiding by setting `hideSeparatorsOnSort={false}` and handle separator positioning in your data preparation logic.
|
||||
|
||||
### Styling
|
||||
|
||||
Separator rows use the same styling as table headers and span the full width of the table:
|
||||
|
||||
```css
|
||||
.separator-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.separator-row > div {
|
||||
background-color: var(--muted);
|
||||
font-weight: 600;
|
||||
color: var(--emphasis);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
```
|
||||
|
||||
You can customize styling using:
|
||||
|
||||
- `separatorClassName` prop for global styling
|
||||
- `className` property on individual separator objects
|
||||
- CSS targeting `.separator-row` class
|
||||
|
||||
### Example: User Management with Status Groups
|
||||
|
||||
```tsx
|
||||
function UserTable() {
|
||||
const data = useMemo(() => {
|
||||
const users = getUsers();
|
||||
const groupedUsers = [];
|
||||
|
||||
// Add active users group
|
||||
const activeUsers = users.filter((u) => u.status === "active");
|
||||
if (activeUsers.length > 0) {
|
||||
groupedUsers.push({ type: "separator", label: "Active Users" });
|
||||
groupedUsers.push(...activeUsers);
|
||||
}
|
||||
|
||||
// Add inactive users group
|
||||
const inactiveUsers = users.filter((u) => u.status === "inactive");
|
||||
if (inactiveUsers.length > 0) {
|
||||
groupedUsers.push({ type: "separator", label: "Inactive Users" });
|
||||
groupedUsers.push(...inactiveUsers);
|
||||
}
|
||||
|
||||
return groupedUsers;
|
||||
}, [users]);
|
||||
|
||||
return (
|
||||
<DataTableWrapper
|
||||
table={table}
|
||||
hideSeparatorsOnSort={true}
|
||||
separatorClassName="uppercase text-xs tracking-wide"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Filter System
|
||||
|
||||
The DataTable supports 5 filter types with various operators and options.
|
||||
@@ -343,6 +486,7 @@ The DataTable supports 5 filter types with various operators and options.
|
||||
```
|
||||
|
||||
**Available operators:**
|
||||
|
||||
- `equals` - Exact match
|
||||
- `notEquals` - Not equal
|
||||
- `contains` - Contains substring
|
||||
@@ -361,6 +505,7 @@ The DataTable supports 5 filter types with various operators and options.
|
||||
```
|
||||
|
||||
**Available operators:**
|
||||
|
||||
- `eq` - Equal to
|
||||
- `neq` - Not equal to
|
||||
- `gt` - Greater than
|
||||
@@ -422,20 +567,20 @@ const table = useReactTable({
|
||||
switch (columnId) {
|
||||
case "teamId":
|
||||
return convertFacetedValuesToMap(
|
||||
teams.map(team => ({
|
||||
teams.map((team) => ({
|
||||
label: team.name,
|
||||
value: team.id
|
||||
value: team.id,
|
||||
}))
|
||||
);
|
||||
case "role":
|
||||
return convertFacetedValuesToMap([
|
||||
{ label: "Admin", value: "admin" },
|
||||
{ label: "Member", value: "member" }
|
||||
{ label: "Member", value: "member" },
|
||||
]);
|
||||
default:
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -448,7 +593,7 @@ export function useFacetedUniqueValues() {
|
||||
const eventTypes = useEventTypes();
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||
const { data: members } = trpc.viewer.teams.listSimpleMembers.useQuery();
|
||||
|
||||
|
||||
return useCallback(
|
||||
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
|
||||
if (columnId === "eventTypeId") {
|
||||
@@ -495,7 +640,7 @@ const table = useReactTable({
|
||||
}
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -556,19 +701,15 @@ const systemSegments: SystemFilterSegment[] = [
|
||||
f: "status",
|
||||
v: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: "active"
|
||||
}
|
||||
}
|
||||
data: "active",
|
||||
},
|
||||
},
|
||||
],
|
||||
sorting: [{ id: "lastLogin", desc: true }],
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
<DataTableProvider
|
||||
systemSegments={systemSegments}
|
||||
>
|
||||
{/* ... */}
|
||||
</DataTableProvider>
|
||||
<DataTableProvider systemSegments={systemSegments}>{/* ... */}</DataTableProvider>;
|
||||
```
|
||||
|
||||
### User Segments
|
||||
@@ -607,14 +748,11 @@ Segments saved by users with personal or team scope:
|
||||
Traditional page-based pagination is the recommended approach:
|
||||
|
||||
```tsx
|
||||
<DataTableWrapper
|
||||
paginationMode="standard"
|
||||
totalRowCount={totalCount}
|
||||
table={table}
|
||||
/>
|
||||
<DataTableWrapper paginationMode="standard" totalRowCount={totalCount} table={table} />
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Page numbers and navigation
|
||||
- Configurable page sizes
|
||||
- Total count display
|
||||
@@ -640,11 +778,13 @@ Alternative infinite scroll mode with known limitations:
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Automatic loading on scroll
|
||||
- Virtualized rendering
|
||||
- Fixed container height (80dvh)
|
||||
|
||||
**Known Issues:**
|
||||
|
||||
- Virtualized infinite loading has several problems
|
||||
- Can cause performance and UX issues
|
||||
- Standard mode was introduced to address these problems
|
||||
@@ -672,8 +812,8 @@ Container and utility components for table toolbars:
|
||||
<DataTableToolbar.ClearFiltersButton />
|
||||
|
||||
// Custom action button
|
||||
<DataTableToolbar.CTA
|
||||
color="secondary"
|
||||
<DataTableToolbar.CTA
|
||||
color="secondary"
|
||||
StartIcon="download"
|
||||
onClick={handleExport}
|
||||
>
|
||||
@@ -686,19 +826,17 @@ Container and utility components for table toolbars:
|
||||
For bulk actions when rows are selected:
|
||||
|
||||
```tsx
|
||||
{numberOfSelectedRows > 0 && (
|
||||
<DataTableSelectionBar.Root>
|
||||
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
|
||||
|
||||
<DataTableSelectionBar.Button
|
||||
color="destructive"
|
||||
icon="trash-2"
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
Delete Selected
|
||||
</DataTableSelectionBar.Button>
|
||||
</DataTableSelectionBar.Root>
|
||||
)}
|
||||
{
|
||||
numberOfSelectedRows > 0 && (
|
||||
<DataTableSelectionBar.Root>
|
||||
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
|
||||
|
||||
<DataTableSelectionBar.Button color="destructive" icon="trash-2" onClick={handleBulkDelete}>
|
||||
Delete Selected
|
||||
</DataTableSelectionBar.Button>
|
||||
</DataTableSelectionBar.Root>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
@@ -719,7 +857,7 @@ const { data } = trpc.users.list.useQuery({
|
||||
limit,
|
||||
offset,
|
||||
sorting,
|
||||
filters: columnFilters
|
||||
filters: columnFilters,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -758,10 +896,11 @@ const whereClause: Prisma.MembershipWhereInput = {
|
||||
})),
|
||||
},
|
||||
teamId: organizationId,
|
||||
...(roleFilter && makeWhereClause({
|
||||
columnName: "role",
|
||||
filterValue: roleFilter.value,
|
||||
})),
|
||||
...(roleFilter &&
|
||||
makeWhereClause({
|
||||
columnName: "role",
|
||||
filterValue: roleFilter.value,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
@@ -774,7 +913,7 @@ For performance-critical queries, use raw SQL with `makeSqlCondition`:
|
||||
async getFilterConditions(): Promise<Prisma.Sql | null> {
|
||||
const conditions: Prisma.Sql[] = [];
|
||||
const columnFilters = this.filters.columnFilters || [];
|
||||
|
||||
|
||||
// Convert columnFilters array to object for easier access
|
||||
const filtersMap = columnFilters.reduce((acc, filter) => {
|
||||
acc[filter.id] = filter;
|
||||
@@ -815,7 +954,7 @@ For complex cases where you need to manipulate filter data before sending to the
|
||||
// packages/features/insights/hooks/useInsightsRoutingParameters.ts
|
||||
export function useInsightsRoutingParameters() {
|
||||
const { scope, selectedTeamId } = useInsightsOrgTeams();
|
||||
|
||||
|
||||
// Get date range filter and manipulate it
|
||||
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
|
||||
const startDate = useChangeTimeZoneWithPreservedLocalTime(
|
||||
@@ -825,7 +964,7 @@ export function useInsightsRoutingParameters() {
|
||||
.toISOString();
|
||||
}, [createdAtRange?.startDate])
|
||||
);
|
||||
|
||||
|
||||
// Get other column filters excluding the manipulated ones
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["createdAt"],
|
||||
@@ -863,15 +1002,8 @@ The DataTable system provides utility functions for both Prisma and raw SQL appr
|
||||
Access the DataTable context:
|
||||
|
||||
```tsx
|
||||
const {
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
selectedSegment,
|
||||
} = useDataTable();
|
||||
const { activeFilters, sorting, columnVisibility, pageIndex, pageSize, searchTerm, selectedSegment } =
|
||||
useDataTable();
|
||||
```
|
||||
|
||||
#### useColumnFilters
|
||||
@@ -903,16 +1035,15 @@ Create custom filter implementations:
|
||||
```tsx
|
||||
function CustomStatusFilter({ column }: { column: Column<any> }) {
|
||||
const { updateFilter } = useDataTable();
|
||||
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
onValueChange={(value) =>
|
||||
updateFilter(column.id, {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: value
|
||||
data: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
}>
|
||||
{/* Custom filter UI */}
|
||||
</Select>
|
||||
);
|
||||
@@ -926,12 +1057,15 @@ Use portals for toolbar actions:
|
||||
```tsx
|
||||
const { ctaContainerRef } = useDataTable();
|
||||
|
||||
{ctaContainerRef.current && createPortal(
|
||||
<div className="flex gap-2">
|
||||
<Button>Custom Action</Button>
|
||||
</div>,
|
||||
ctaContainerRef.current
|
||||
)}
|
||||
{
|
||||
ctaContainerRef.current &&
|
||||
createPortal(
|
||||
<div className="flex gap-2">
|
||||
<Button>Custom Action</Button>
|
||||
</div>,
|
||||
ctaContainerRef.current
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Real-world Examples
|
||||
@@ -960,14 +1094,13 @@ From `packages/features/users/components/UserTable/UserListTable.tsx`:
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</>
|
||||
}
|
||||
>
|
||||
}>
|
||||
{/* Selection bar for bulk actions */}
|
||||
{numberOfSelectedRows > 0 && (
|
||||
<DataTableSelectionBar.Root>
|
||||
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
|
||||
<DeleteBulkUsers
|
||||
users={table.getSelectedRowModel().flatRows.map(row => row.original)}
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
/>
|
||||
</DataTableSelectionBar.Root>
|
||||
@@ -991,9 +1124,7 @@ From `apps/web/modules/bookings/components/BookingsList.tsx`:
|
||||
totalRowCount={query.data?.totalCount}
|
||||
variant="compact"
|
||||
paginationMode="standard"
|
||||
ToolbarLeft={
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
}
|
||||
ToolbarLeft={<DataTableFilters.FilterBar table={table} />}
|
||||
ToolbarRight={
|
||||
<>
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
@@ -1038,8 +1169,7 @@ From `packages/features/ee/teams/components/MemberList.tsx`:
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</>
|
||||
}
|
||||
>
|
||||
}>
|
||||
{/* Bulk selection and actions */}
|
||||
{numberOfSelectedRows > 0 && (
|
||||
<DataTableSelectionBar.Root>
|
||||
@@ -1056,23 +1186,21 @@ From `packages/features/ee/teams/components/MemberList.tsx`:
|
||||
|
||||
```tsx
|
||||
// Filter value types
|
||||
type FilterValue =
|
||||
type FilterValue =
|
||||
| SingleSelectFilterValue
|
||||
| MultiSelectFilterValue
|
||||
| MultiSelectFilterValue
|
||||
| TextFilterValue
|
||||
| NumberFilterValue
|
||||
| DateRangeFilterValue;
|
||||
|
||||
// Active filter structure
|
||||
type ActiveFilter = {
|
||||
f: string; // field/column ID
|
||||
v?: FilterValue; // filter value
|
||||
f: string; // field/column ID
|
||||
v?: FilterValue; // filter value
|
||||
};
|
||||
|
||||
// Segment types
|
||||
type SegmentIdentifier =
|
||||
| { id: string; type: "system" }
|
||||
| { id: number; type: "user" };
|
||||
type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
|
||||
|
||||
// Column filter metadata
|
||||
type ColumnFilterMeta = {
|
||||
@@ -1187,29 +1315,29 @@ type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
|
||||
|
||||
```tsx
|
||||
// ✅ Good: Memoize column definitions
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
meta: { type: ColumnFilterType.TEXT },
|
||||
},
|
||||
], []);
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
meta: { type: ColumnFilterType.TEXT },
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// ✅ Good: Extract filter logic
|
||||
const useUserFilters = () => {
|
||||
const columnFilters = useColumnFilters();
|
||||
return useMemo(() =>
|
||||
transformFiltersForAPI(columnFilters),
|
||||
[columnFilters]
|
||||
);
|
||||
return useMemo(() => transformFiltersForAPI(columnFilters), [columnFilters]);
|
||||
};
|
||||
|
||||
// ✅ Good: Separate concerns
|
||||
function UserTableContainer() {
|
||||
const filters = useUserFilters();
|
||||
const { data, isPending } = useUsers(filters);
|
||||
|
||||
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<UserTable data={data} isPending={isPending} />
|
||||
@@ -1226,17 +1354,20 @@ function UserTableContainer() {
|
||||
function useTableData() {
|
||||
const columnFilters = useColumnFilters();
|
||||
const { sorting, pageIndex, pageSize, searchTerm } = useDataTable();
|
||||
|
||||
const queryParams = useMemo(() => ({
|
||||
filters: columnFilters,
|
||||
sorting,
|
||||
page: pageIndex,
|
||||
limit: pageSize,
|
||||
search: searchTerm,
|
||||
}), [columnFilters, sorting, pageIndex, pageSize, searchTerm]);
|
||||
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
filters: columnFilters,
|
||||
sorting,
|
||||
page: pageIndex,
|
||||
limit: pageSize,
|
||||
search: searchTerm,
|
||||
}),
|
||||
[columnFilters, sorting, pageIndex, pageSize, searchTerm]
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['table-data', queryParams],
|
||||
queryKey: ["table-data", queryParams],
|
||||
queryFn: () => fetchData(queryParams),
|
||||
});
|
||||
}
|
||||
@@ -1247,13 +1378,14 @@ function useTableData() {
|
||||
```tsx
|
||||
function useStatusFilterOptions() {
|
||||
const { data: statuses } = useStatuses();
|
||||
|
||||
return useMemo(() =>
|
||||
statuses?.map(status => ({
|
||||
label: status.name,
|
||||
value: status.id,
|
||||
section: status.category,
|
||||
})) || [],
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
statuses?.map((status) => ({
|
||||
label: status.name,
|
||||
value: status.id,
|
||||
section: status.category,
|
||||
})) || [],
|
||||
[statuses]
|
||||
);
|
||||
}
|
||||
@@ -1270,15 +1402,15 @@ const BOOKING_SEGMENTS: SystemFilterSegment[] = [
|
||||
activeFilters: [
|
||||
{
|
||||
f: "status",
|
||||
v: { type: ColumnFilterType.SINGLE_SELECT, data: "confirmed" }
|
||||
v: { type: ColumnFilterType.SINGLE_SELECT, data: "confirmed" },
|
||||
},
|
||||
{
|
||||
f: "startTime",
|
||||
v: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
data: { preset: "future", startDate: null, endDate: null }
|
||||
}
|
||||
}
|
||||
v: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
data: { preset: "future", startDate: null, endDate: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
sorting: [{ id: "startTime", desc: false }],
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Row } from "@tanstack/react-table";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import type { Table as ReactTableType, Header, HeaderGroup } from "@tanstack/react-table";
|
||||
import { useVirtualizer, type Virtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import kebabCase from "lodash/kebabCase";
|
||||
import { useEffect, useState, memo, useMemo } from "react";
|
||||
|
||||
@@ -24,6 +23,8 @@ import {
|
||||
|
||||
import { useColumnSizingVars } from "../hooks";
|
||||
import { useColumnResizing } from "../hooks/useColumnResizing";
|
||||
import type { SeparatorRow } from "../lib/separator";
|
||||
import { isSeparatorRow } from "../lib/separator";
|
||||
|
||||
export type DataTablePropsFromWrapper<TData> = {
|
||||
table: ReactTableType<TData>;
|
||||
@@ -40,6 +41,9 @@ export type DataTablePropsFromWrapper<TData> = {
|
||||
rowClassName?: string;
|
||||
paginationMode?: "infinite" | "standard";
|
||||
hasWrapperContext?: boolean;
|
||||
hideSeparatorsOnSort?: boolean;
|
||||
hideSeparatorsOnFilter?: boolean;
|
||||
separatorClassName?: string;
|
||||
};
|
||||
|
||||
export type DataTableProps<TData> = DataTablePropsFromWrapper<TData> & {
|
||||
@@ -66,6 +70,9 @@ export function DataTable<TData>({
|
||||
rowClassName,
|
||||
paginationMode = "infinite",
|
||||
hasWrapperContext = false,
|
||||
hideSeparatorsOnSort = true,
|
||||
hideSeparatorsOnFilter = false,
|
||||
separatorClassName,
|
||||
...rest
|
||||
}: DataTableProps<TData> & React.ComponentPropsWithoutRef<"div">) {
|
||||
const { rows } = table.getRowModel();
|
||||
@@ -196,6 +203,10 @@ export function DataTable<TData>({
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
paginationMode={paginationMode}
|
||||
rowClassName={rowClassName}
|
||||
hideSeparatorsOnSort={hideSeparatorsOnSort}
|
||||
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
|
||||
separatorClassName={separatorClassName}
|
||||
tableContainerRef={tableContainerRef}
|
||||
/>
|
||||
) : (
|
||||
<DataTableBody
|
||||
@@ -208,6 +219,10 @@ export function DataTable<TData>({
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
paginationMode={paginationMode}
|
||||
rowClassName={rowClassName}
|
||||
hideSeparatorsOnSort={hideSeparatorsOnSort}
|
||||
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
|
||||
separatorClassName={separatorClassName}
|
||||
tableContainerRef={tableContainerRef}
|
||||
/>
|
||||
)}
|
||||
</TableNew>
|
||||
@@ -228,7 +243,11 @@ const MemoizedTableBody = memo(
|
||||
prev.isPending === next.isPending &&
|
||||
prev.onRowMouseclick === next.onRowMouseclick &&
|
||||
prev.paginationMode === next.paginationMode &&
|
||||
prev.rowClassName === next.rowClassName
|
||||
prev.rowClassName === next.rowClassName &&
|
||||
prev.hideSeparatorsOnSort === next.hideSeparatorsOnSort &&
|
||||
prev.hideSeparatorsOnFilter === next.hideSeparatorsOnFilter &&
|
||||
prev.separatorClassName === next.separatorClassName &&
|
||||
prev.tableContainerRef === next.tableContainerRef
|
||||
) as typeof DataTableBody;
|
||||
|
||||
type DataTableBodyProps<TData> = {
|
||||
@@ -241,6 +260,10 @@ type DataTableBodyProps<TData> = {
|
||||
onRowMouseclick?: (row: Row<TData>) => void;
|
||||
paginationMode?: "infinite" | "standard";
|
||||
rowClassName?: string;
|
||||
hideSeparatorsOnSort?: boolean;
|
||||
hideSeparatorsOnFilter?: boolean;
|
||||
separatorClassName?: string;
|
||||
tableContainerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type RowToRender<TData> = {
|
||||
@@ -248,9 +271,22 @@ type RowToRender<TData> = {
|
||||
virtualItem?: VirtualItem;
|
||||
};
|
||||
|
||||
function SeparatorRowRenderer({ separator, className }: { separator: SeparatorRow; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-muted text-emphasis w-full px-3 py-2 font-semibold",
|
||||
separator.className,
|
||||
className
|
||||
)}>
|
||||
{separator.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableBody<TData>({
|
||||
table,
|
||||
rowVirtualizer,
|
||||
rowVirtualizer: _rowVirtualizer,
|
||||
rows,
|
||||
testId,
|
||||
variant,
|
||||
@@ -258,21 +294,45 @@ function DataTableBody<TData>({
|
||||
onRowMouseclick,
|
||||
paginationMode,
|
||||
rowClassName,
|
||||
hideSeparatorsOnSort = true,
|
||||
hideSeparatorsOnFilter = false,
|
||||
separatorClassName,
|
||||
tableContainerRef,
|
||||
}: DataTableBodyProps<TData> & { paginationMode?: "infinite" | "standard" }) {
|
||||
const { t } = useLocale();
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const tableHeight = paginationMode === "infinite" ? rowVirtualizer.getTotalSize() : "auto";
|
||||
|
||||
const rowsToRender = useMemo<RowToRender<TData>[]>(
|
||||
() =>
|
||||
paginationMode === "infinite"
|
||||
? virtualItems.map((virtualItem) => ({
|
||||
row: rows[virtualItem.index] as Row<TData>,
|
||||
virtualItem,
|
||||
}))
|
||||
: rows.map((row) => ({ row })),
|
||||
[paginationMode, virtualItems, rows]
|
||||
);
|
||||
const hasActiveSorting = table.getState().sorting.length > 0;
|
||||
const hasActiveFilters = table.getState().columnFilters.length > 0;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if ((hideSeparatorsOnSort && hasActiveSorting) || (hideSeparatorsOnFilter && hasActiveFilters)) {
|
||||
return rows.filter((row) => !isSeparatorRow(row.original));
|
||||
}
|
||||
return rows;
|
||||
}, [rows, hideSeparatorsOnSort, hideSeparatorsOnFilter, hasActiveSorting, hasActiveFilters]);
|
||||
|
||||
const filteredRowVirtualizer = useVirtualizer({
|
||||
count: filteredRows.length,
|
||||
estimateSize: () => 100,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
measureElement:
|
||||
typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1
|
||||
? (element) => element?.getBoundingClientRect().height
|
||||
: undefined,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualItems = filteredRowVirtualizer.getVirtualItems();
|
||||
const tableHeight = paginationMode === "infinite" ? filteredRowVirtualizer.getTotalSize() : "auto";
|
||||
|
||||
const rowsToRender = useMemo<RowToRender<TData>[]>(() => {
|
||||
return paginationMode === "infinite"
|
||||
? virtualItems.map((virtualItem) => ({
|
||||
row: filteredRows[virtualItem.index] as Row<TData>,
|
||||
virtualItem,
|
||||
}))
|
||||
: filteredRows.map((row) => ({ row }));
|
||||
}, [paginationMode, virtualItems, filteredRows]);
|
||||
|
||||
if (!isPending && rowsToRender.length === 0) {
|
||||
return (
|
||||
@@ -291,50 +351,74 @@ function DataTableBody<TData>({
|
||||
className="border-subtle relative grid border-t"
|
||||
data-testid={testId}
|
||||
style={{ height: tableHeight }}>
|
||||
{rowsToRender.map(({ row, virtualItem }) => (
|
||||
<TableRow
|
||||
ref={virtualItem ? (node) => rowVirtualizer.measureElement(node) : undefined}
|
||||
key={row.id}
|
||||
data-index={virtualItem?.index} //needed for dynamic row height measurement
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => onRowMouseclick && onRowMouseclick(row)}
|
||||
style={{
|
||||
display: "flex",
|
||||
...(virtualItem && {
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
}),
|
||||
}}
|
||||
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const column = cell.column;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-testid={`data-table-td-${cell.column.id}`}
|
||||
style={{
|
||||
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
|
||||
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
|
||||
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
|
||||
}}
|
||||
className={classNames(
|
||||
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle flex shrink-0 items-center overflow-hidden",
|
||||
variant === "compact" && "p-0",
|
||||
column.getIsPinned() &&
|
||||
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
|
||||
)}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
{rowsToRender.map(({ row, virtualItem }) => {
|
||||
const isSeparator = isSeparatorRow(row.original);
|
||||
|
||||
if (isSeparator) {
|
||||
return (
|
||||
<TableRow
|
||||
ref={virtualItem ? (node) => filteredRowVirtualizer.measureElement(node) : undefined}
|
||||
key={row.id}
|
||||
data-index={virtualItem?.index}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
...(virtualItem && {
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}),
|
||||
}}
|
||||
className="hover:bg-subtle border-subtle flex w-full border-b">
|
||||
<SeparatorRowRenderer separator={row.original as SeparatorRow} className={separatorClassName} />
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={virtualItem ? (node) => filteredRowVirtualizer.measureElement(node) : undefined}
|
||||
key={row.id}
|
||||
data-index={virtualItem?.index} // needed for dynamic row height measurement
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => onRowMouseclick && onRowMouseclick(row)}
|
||||
style={{
|
||||
display: "flex",
|
||||
...(virtualItem && {
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
}),
|
||||
}}
|
||||
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const column = cell.column;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-testid={`data-table-td-${cell.column.id}`}
|
||||
style={{
|
||||
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
|
||||
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
|
||||
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
|
||||
}}
|
||||
className={classNames(
|
||||
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle flex shrink-0 items-center overflow-hidden",
|
||||
variant === "compact" && "p-0",
|
||||
column.getIsPinned() &&
|
||||
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
|
||||
)}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
const TableHeadLabel = ({ header }: { header: Header<any, any> }) => {
|
||||
const TableHeadLabel = <TData,>({ header }: { header: Header<TData, unknown> }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Row, VisibilityState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
@@ -63,6 +62,9 @@ export function DataTableWrapper<TData>({
|
||||
tableContainerRef: externalRef,
|
||||
paginationMode,
|
||||
onRowMouseclick,
|
||||
hideSeparatorsOnSort,
|
||||
hideSeparatorsOnFilter,
|
||||
separatorClassName,
|
||||
}: DataTableWrapperProps<TData>) {
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const tableContainerRef = externalRef || internalRef;
|
||||
@@ -78,7 +80,7 @@ export function DataTableWrapper<TData>({
|
||||
|
||||
useEffect(() => {
|
||||
const mergedColumnVisibility = {
|
||||
...(table.initialState?.columnVisibility || {}),
|
||||
...table.initialState?.columnVisibility,
|
||||
...columnVisibility,
|
||||
} satisfies VisibilityState;
|
||||
|
||||
@@ -134,6 +136,9 @@ export function DataTableWrapper<TData>({
|
||||
paginationMode={paginationMode}
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
hasWrapperContext={true}
|
||||
hideSeparatorsOnSort={hideSeparatorsOnSort}
|
||||
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
|
||||
separatorClassName={separatorClassName}
|
||||
onScroll={
|
||||
paginationMode === "infinite"
|
||||
? (e: Pick<React.UIEvent<HTMLDivElement, UIEvent>, "target">) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./components";
|
||||
export * from "./lib/types";
|
||||
export * from "./lib/utils";
|
||||
export * from "./lib/serializers";
|
||||
export * from "./lib/separator";
|
||||
export * from "./DataTableProvider";
|
||||
export * from "./hooks/useColumnResizing";
|
||||
export * from "./hooks";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type SeparatorRow = {
|
||||
type: "separator";
|
||||
label: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type DataTableRow<TData> = TData | SeparatorRow;
|
||||
|
||||
export function isSeparatorRow<TData>(row: DataTableRow<TData>): row is SeparatorRow {
|
||||
return typeof row === "object" && row !== null && "type" in row && row.type === "separator";
|
||||
}
|
||||
Reference in New Issue
Block a user