Table component
- State management is done via
useTable React hook.
- Table rendering is done using
Table component.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33 | import { rolesList } from 'waldur-js-client';
import { translate } from '@/i18n';
import Table from '@/table/Table';
import { createFetcher } from '@/table/api';
import { useTable } from '@/table/useTable';
export const RolesList = () => {
const tableProps = useTable({
table: `RolesList`,
fetchData: createFetcher(rolesList),
});
return (
<Table
{...tableProps}
columns={[
{
title: translate('Name'),
render: ({ row }) => row.name,
},
{
title: translate('Description'),
render: ({ row }) => row.description,
},
{
title: translate('Assigned users count'),
render: ({ row }) => row.users_count,
},
]}
/>
);
};
|
Column definition consists of two mandatory fields: title and render.
fetchData property
The fetchData property is a function that retrieves data for the table. It should return a promise that resolves to an object containing rows (required) and optionally resultCount and nextPage for pagination.
Using createFetcher with SDK functions
The recommended way is to use createFetcher with SDK functions from waldur-js-client:
| import { usersList } from 'waldur-js-client';
import { createFetcher } from '@/table/api';
const tableProps = useTable({
table: 'UsersList',
fetchData: createFetcher(usersList),
});
|
You can pass options to customize the request:
| import { projectsList } from 'waldur-js-client';
const tableProps = useTable({
table: 'ProjectsList',
fetchData: createFetcher(projectsList, {
// Additional query parameters
query: { is_active: true },
// Path parameters for nested resources
path: { customer_uuid: customerId },
}),
});
|
When the API returns data in a nested structure, use the parser option:
| import { checklistRetrieve } from 'waldur-js-client';
const tableProps = useTable({
table: 'QuestionsList',
fetchData: createFetcher(checklistRetrieve, {
path: { uuid: checklistId },
// Extract questions array from the response object
parser: (data) => data.questions,
}),
});
|
Custom fetchData function
For static data or custom data sources, create a custom fetcher:
| const fetchData = () => Promise.resolve({
rows: resource.items,
resultCount: resource.items.length,
});
const tableProps = useTable({
table: 'StaticList',
fetchData,
});
|
Type safety
The Table component supports TypeScript type inference. When using createFetcher with SDK functions, the row type is automatically inferred from the SDK function's return type.
Automatic type inference
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 | import { Project, projectsList } from 'waldur-js-client';
const tableProps = useTable({
table: 'ProjectsList',
fetchData: createFetcher(projectsList),
});
// columns are type-checked against Project type
<Table
{...tableProps}
columns={[
{
title: 'Name',
render: ({ row }) => row.name, // row is typed as Project
},
{
title: 'Invalid',
render: ({ row }) => row.invalid_field, // TypeScript error!
},
]}
/>
|
Explicit type parameter
For custom fetchers or when you need explicit typing, use the generic parameter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | interface MyRow {
id: string;
name: string;
}
<Table<MyRow>
{...tableProps}
columns={[
{
title: 'Name',
render: ({ row }) => row.name, // row is typed as MyRow
},
]}
/>
|
Export feature
Table component supports data export functionality. To enable it:
- Add
enableExport prop to the Table component
- Configure export options in column definitions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 | {
title: 'Name',
render: ({ row }) => row.name,
export: 'name' // Use field directly
}
{
title: 'Status',
render: ({ row }) => row.status,
export: row => formatStatus(row.status) // Custom formatter
}
{
title: 'Actions',
render: ({ row }) => <Button/>,
export: false // Exclude from export
}
|
Optionally, specify exportTitle property for columns to customize the header in the exported file:
| {
title: 'Name',
render: ({ row }) => row.name,
export: 'name',
exportTitle: 'User Name' // Custom header for export
}
|
Optional columns
Table component supports optional columns that can be toggled by users. Optional columns allow users to customize their view by showing/hiding specific columns.
- Add
hasOptionalColumns prop to enable optional columns functionality
-
Configure columns with:
id - unique column identifier
keys - defines which fields should be requested from API (allows optimization by fetching only needed fields)
optional - mark column as optional to allow toggling
-
For actions column, you can specify mandatory fields that should always be fetched from API using mandatoryFields prop.
Example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 | export const UsersTable = () => {
const tableProps = useTable({
table: 'users',
fetchData: createFetcher(usersList),
});
return (
<Table
{...tableProps}
hasOptionalColumns
mandatoryFields={['uuid', 'name']}
columns={[
{
id: 'name',
title: translate('Name'),
render: ({ row }) => row.name,
optional: true,
keys: ['name'],
},
{
id: 'email',
title: translate('Email'),
render: ({ row }) => row.email,
optional: true,
keys: ['email'],
},
{
id: 'role',
title: translate('Role'),
render: ({ row }) => row.role,
optional: true,
keys: ['role', 'permissions'],
},
]}
/>
);
};
|
Ordering feature
Table component supports column ordering. To enable it:
- Add
orderField property to columns that should be sortable
- Clicking on column headers will toggle between ascending and descending order
Example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 | export const UsersTable = () => {
const tableProps = useTable({
table: 'users',
fetchData: createFetcher(usersList),
});
return (
<Table
{...tableProps}
columns={[
{
title: translate('Name'),
render: ({ row }) => row.name,
orderField: 'name',
},
{
title: translate('Email'),
render: ({ row }) => row.email,
orderField: 'email',
},
]}
/>
);
};
|
Filters feature
Filters are defined using the filters prop of the Table component. This prop accepts a React component that renders the filter UI.
The recommended way to implement filters is using autonomous filter components from @/table. These components automatically connect to the table's Redux state.
Example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 | import { StringFilter } from '@/table';
import { useFilterValues } from '@/table/useFilterValues';
export const FilterSet = () => (
<StringFilter
title={translate('Filter field')}
name="custom"
placeholder={translate('Search...')}
/>
);
export const FilteredList = () => {
const values = useFilterValues('FilteredList');
const tableProps = useTable({
table: 'FilteredList',
fetchData: createFetcher(hooksList),
filter: values,
});
return (
<Table
{...tableProps}
filters={<FilterSet />}
/>
);
};
|
Filter components
The following autonomous filter components are available in @/table:
StringFilter: Simple text input
SelectFilter: Dropdown for static options
AsyncSelectFilter: Dropdown for API-based options
BooleanFilter: Checkbox for boolean values
DateFilter: Date picker
DateTimeFilter: Date and time picker
NumberFilter: Number input
NumberRangeFilter: Range input for numbers
Filter positions
The filterPosition prop controls where filters are displayed:
| Position |
Behavior |
header |
Filters always visible in card header |
menu |
Filters in dropdown menu, toggled with filter button |
sidebar |
Filters in drawer/sidebar panel |
Filter storage and badges
When filters are applied, they are stored in filtersStorage (Redux state) which is used to:
- Display filter badges/chips in the filter bar
- Show filter count on the filter toggle button
- Auto-expand the filter bar when filters are loaded from URL
The flow:
| User changes autonomous filter (e.g. StringFilter)
↓
Component dispatches SET_FILTER to Redux
↓
Table state updated in Redux (filtersStorage)
↓
useTable hook receives new state
↓
Table re-fetches with new filter
↓
Filter badges rendered from filtersStorage
|
URL query parameter sync
Filters can be synced to URL query parameters for shareable links. useTable provides built-in support for this via syncFiltersToURL option.
| const tableProps = useTable({
table: 'MyList',
fetchData: createFetcher(myList),
syncFiltersToURL: true,
initialFilters: { status: 'active' }, // Default values
});
|
When syncFiltersToURL is enabled:
- Initial load:
useTable reads URL parameters on mount and populates filtersStorage.
- Automatic updates: Any changes to filters are automatically reflected in the URL.
- Default values:
initialFilters are used if corresponding URL parameters are missing.
Filters are stored in URL query parameters with compact encoding:
| Value type |
URL format |
Example |
| String |
Direct value |
?name=test |
| Object with uuid |
uuid::name |
?org=abc123::My+Org |
| Array of objects |
[uuid1::name1, uuid2::name2] |
?tags=["a1::T1","a2::T2"] |
| Boolean |
true/false |
?active=true |
The compact uuid::name format keeps URLs shorter while preserving display names for filter badges without needing additional API calls.
Navigation behavior
When navigating between pages in the SPA:
- URL params persist - query string is preserved during navigation.
- Table initialization -
useTable reads the URL to populate the Redux state on mount.
- Auto-show filter bar - when filters are present in Redux, the filter bar auto-expands.
| Page A with filter → Navigate to Page B → Navigate back to Page A
↓ ↓ ↓
URL updated URL preserved URL read, state populated
with filters (SPA routing) Table re-fetches
|
Default/initial filters
To set default filter values that can be overridden by URL params, use the initialFilters option in useTable:
| const tableProps = useTable({
table: 'MyList',
fetchData: createFetcher(myList),
syncFiltersToURL: true,
initialFilters: { status: 'active' }, // URL params take precedence over defaults
});
|
The Resources sidebar filter (src/navigation/sidebar/resources-filter/) is a special case that:
- Syncs
organization and project filters across multiple resource tables
- Persists to localStorage for session persistence
- Syncs to URL for shareable links
Priority order:
- URL params (highest) - for shareable links
- localStorage - for session persistence
- No filter - shows all resources
Inline filters
The feature allows users to quickly filter table data by clicking values directly in the table cells, without manually setting filters. When column has inlineFilter property enabled:
- A filter icon appears when hovering over cells in that column
- Clicking the icon adds a filter using the cell's value
- The
inlineFilter function transforms row data into the filter value format
Example:
| {
title: translate('Organization'),
render: ({ row }) => row.customer_name,
filter: 'organization', // Enable filtering
inlineFilter: (row) => ({
// Transform row data into filter value
name: row.customer_name,
uuid: row.customer_uuid
})
}
|
Grid mode
Table component supports switching between table and grid views. In grid mode, data is displayed as cards in a responsive grid layout.
To enable grid mode:
- Add
gridItem prop to specify the component used to render each item in grid view
- Optionally customize grid layout using
gridSize prop which accepts Bootstrap column properties
- Set
initialMode to 'grid' if you want grid view by default (table view is default)
Example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 | export const GridListItem = ({ row }) => (
<Card>
<CardHeader>{row.name}</CardHeader>
<CardBody>
<p>{row.description}</p>
<small>Users: {row.users_count}</small>
</CardBody>
</Card>
);
export const GridList = () => {
const tableProps = useTable({
table: 'GridList',
fetchData: createFetcher(itemsList),
});
return (
<Table
{...tableProps}
gridItem={GridListItem}
gridSize="col-sm-6 col-md-4"
initialMode="grid"
/>
);
};
|
Table tabs
Table component supports tabs for switching between different views or data sets within the same table context. Tabs can navigate to different routes or update URL parameters.
Basic tabs
Define tabs using an array of TableTab objects:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | import { TableTab } from '@/table/types';
const tabs: TableTab[] = [
{
key: 'active',
title: translate('Active'),
params: { status: 'active' },
},
{
key: 'archived',
title: translate('Archived'),
params: { status: 'archived' },
},
];
<Table {...tableProps} tabs={tabs} />
|
Tab properties
| Property |
Type |
Description |
key |
string or number |
Unique identifier for the tab |
title |
ReactNode |
Tab label (can include badges, icons) |
params |
Record<string, any> |
URL parameters to set when tab is selected |
state |
string |
Optional UI-router state to navigate to |
default |
boolean |
Mark as default tab when no other tab matches |
Default tab
When navigating to a page without specific tab parameters, no tab will be highlighted by default. Use the default: true property to specify which tab should be selected when no URL parameters match:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 | export const useReviewerPoolTabs = (): TableTab[] => {
return useMemo(
() => [
{
key: 'pool',
title: translate('Pool'),
params: { tab: 'reviewer-pool', pool_tab: 'pool' },
default: true, // Selected when no pool_tab parameter
},
{
key: 'discovery',
title: translate('Discovery'),
params: { tab: 'reviewer-pool', pool_tab: 'discovery' },
},
{
key: 'assignments',
title: translate('Assignments'),
params: { tab: 'reviewer-pool', pool_tab: 'assignments' },
},
],
[],
);
};
|
Preserving parent tab context
When using subtabs within a parent tab context, you must include all parent tab parameters in each subtab's params. The TableTabs component replaces all URL parameters when navigating, so omitting parent params will cause navigation to lose context.
Wrong - loses parent tab context:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | // Parent tab is selected via ?tab=reviewer-pool
// These subtabs will navigate to ?pool_tab=xxx, losing tab=reviewer-pool
const tabs: TableTab[] = [
{
key: 'pool',
title: translate('Pool'),
params: { pool_tab: 'pool' }, // Missing tab: 'reviewer-pool'!
},
{
key: 'assignments',
title: translate('Assignments'),
params: { pool_tab: 'assignments' }, // Will redirect to wrong parent tab
},
];
|
Correct - preserves parent tab context:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | // Include the parent tab parameter in all subtabs
const tabs: TableTab[] = [
{
key: 'pool',
title: translate('Pool'),
params: { tab: 'reviewer-pool', pool_tab: 'pool' },
default: true,
},
{
key: 'assignments',
title: translate('Assignments'),
params: { tab: 'reviewer-pool', pool_tab: 'assignments' },
},
];
|
Tabs with route navigation
Tabs can navigate to different routes using the state property:
1
2
3
4
5
6
7
8
9
10
11
12 | const tabs: TableTab[] = [
{
key: 'reviews',
title: translate('Reviews'),
state: 'reviews-all-reviews',
},
{
key: 'invitations',
title: translate('Invitations'),
state: 'reviews-invitations',
},
];
|
Tabs with counts
Use custom components in title to show counts or badges:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | const TabWithCount = ({ label, count }) => (
<span className="d-flex align-items-center gap-2">
{label}
{count > 0 && (
<Badge variant="primary" size="sm" pill>
{count}
</Badge>
)}
</span>
);
const tabs = [
{
key: 'pending',
title: <TabWithCount label={translate('Pending')} count={pendingCount} />,
params: { status: 'pending' },
},
];
|
Expandable rows
Table supports expandable rows to show additional details for each row. When enabled, rows can be expanded/collapsed by clicking on them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | const ExpandableRowContent = ({ row }) => (
<div className="p-4">
<h5>{row.name}</h5>
<p>{row.description}</p>
<dl>
<dt>Created</dt>
<dd>{formatDate(row.created)}</dd>
<dt>Status</dt>
<dd>{row.status}</dd>
</dl>
</div>
);
<Table
{...tableProps}
columns={columns}
expandableRow={ExpandableRowContent}
/>
|
The expandable row component receives the full row object as a prop.
Row actions
Row actions add action buttons to each row. Use rowActions prop with a component that receives the row data.
For a clean UI, use ActionsDropdownComponent with ActionItem to create a 3-dots dropdown menu:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70 | import { FC, useCallback } from 'react';
import { PencilSimple, Trash, EnvelopeSimple } from '@phosphor-icons/react';
import { translate } from '@/i18n';
import { useManagedMutation } from '@/modal/useManagedMutation';
import { ActionItem } from '@/resource/actions/ActionItem';
import { ActionsDropdownComponent } from '@/table/ActionsDropdown';
interface RowActionsProps {
row: MyRowType;
refetch: () => void;
}
// Usage with useManagedMutation
export const RowActions: FC<RowActionsProps> = ({ row, refetch }) => {
const { mutate: handleDelete, isPending: isDeleting } = useManagedMutation({
mutationFn: () => deleteItem({ path: { uuid: row.uuid } }),
successMessage: translate('Item deleted successfully.'),
errorMessage: translate('Failed to delete item.'),
refetch,
confirmation: {
title: translate('Delete item'),
body: translate('Are you sure you want to delete {name}?', {
name: row.name,
}),
},
});
// NOTE: Use .mutate() for button actions, not .mutateAsync()
const onDelete = () => handleDelete({});
// Conditionally show actions based on row state
const canEdit = row.status === 'draft';
const canDelete = row.status !== 'completed';
// Return null if no actions available
if (!canEdit && !canDelete) {
return null;
}
return (
<ActionsDropdownComponent>
{canEdit && (
<ActionItem
title={translate('Edit')}
action={() => handleEdit(row)}
iconNode={<PencilSimple weight="bold" />}
/>
)}
{canDelete && (
<ActionItem
title={translate('Delete')}
action={onDelete}
iconNode={<Trash weight="bold" />}
className="text-danger"
iconColor="danger"
disabled={isDeleting}
/>
)}
</ActionsDropdownComponent>
);
};
// Usage in table
<Table
{...tableProps}
columns={columns}
rowActions={({ row }) => <RowActions row={row} refetch={tableProps.fetch} />}
/>
|
ActionItem properties
| Property |
Type |
Description |
title |
string |
Action label text |
action |
() => void |
Click handler |
iconNode |
ReactNode |
Icon component (use Phosphor icons with weight="bold") |
disabled |
boolean |
Disable the action |
className |
string |
Additional CSS classes (e.g., text-danger) |
iconColor |
string |
Icon color variant (e.g., danger, success) |
For simple cases with few actions, use inline buttons:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 | const RowActions = ({ row }) => (
<div className="d-flex gap-2">
<ActionButton
action={() => handleEdit(row)}
title={translate('Edit')}
iconNode={<PencilIcon />}
/>
<ActionButton
action={() => handleDelete(row)}
title={translate('Delete')}
iconNode={<TrashIcon />}
variant="danger"
/>
</div>
);
<Table {...tableProps} columns={columns} rowActions={RowActions} />
|
Table actions
Table actions appear in the table header toolbar. Use for actions that affect the whole table or for creating new items:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | <Table
{...tableProps}
columns={columns}
tableActions={
<>
<button className="btn btn-primary btn-sm" onClick={handleCreate}>
<PlusIcon className="me-1" />
{translate('Create new')}
</button>
<button className="btn btn-outline-secondary btn-sm" onClick={handleExport}>
<DownloadIcon className="me-1" />
{translate('Export')}
</button>
</>
}
/>
|
Complex table example
Here's an example of a complex table combining multiple features:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120 | import { useMemo } from 'react';
import { SelectFilter } from '@/table';
import { useFilterValues } from '@/table/useFilterValues';
// Filter component
const ReviewerPoolFilter = () => (
<SelectFilter
title={translate('Status')}
name="status"
options={[
{ label: translate('Accepted'), value: 'accepted' },
{ label: translate('Pending'), value: 'pending' },
]}
/>
);
// Tabs hook - defines sub-navigation within the table
const usePoolTabs = (): TableTab[] => {
return useMemo(
() => [
{
key: 'pool',
title: translate('Pool'),
params: { tab: 'reviewer-pool', pool_tab: 'pool' },
default: true,
},
{
key: 'assignments',
title: translate('Assignments'),
params: { tab: 'reviewer-pool', pool_tab: 'assignments' },
},
],
[],
);
};
// Expandable row component
const ExpandableRow = ({ row }) => (
<div className="p-4">
<h6>{row.reviewer_name}</h6>
<p>Email: {row.reviewer_email}</p>
<p>Expertise: {row.expertise_areas?.join(', ')}</p>
</div>
);
// Main table component
export const ReviewerPoolSection = ({ call }) => {
const values = useFilterValues('ReviewerPool');
const tabs = usePoolTabs();
// Memoize filter to prevent unnecessary re-renders
const filter = useMemo(
() => ({
call_uuid: call.uuid,
invitation_status: values.status?.value,
}),
[call.uuid, values.status],
);
const tableProps = useTable({
table: 'ReviewerPool',
fetchData: createFetcher(reviewerPoolList),
filter,
syncFiltersToURL: true,
});
const columns = useMemo(
() => [
{
id: 'reviewer',
title: translate('Reviewer'),
render: ({ row }) => (
<div>
<div className="fw-bold">{row.reviewer_name}</div>
<small className="text-muted">{row.reviewer_email}</small>
</div>
),
keys: ['reviewer_name', 'reviewer_email'],
},
{
id: 'status',
title: translate('Status'),
render: ({ row }) => (
<Badge variant={row.status === 'accepted' ? 'success' : 'warning'}>
{row.status_display}
</Badge>
),
keys: ['status', 'status_display'],
},
{
id: 'assignments',
title: translate('Assignments'),
render: ({ row }) => `${row.current} / ${row.max}`,
keys: ['current_assignments', 'max_assignments'],
optional: true,
},
],
[],
);
return (
<Table
{...tableProps}
columns={columns}
title={translate('Reviewer pool')}
tabs={tabs}
verboseName={translate('reviewers')}
hasQuery
hasOptionalColumns
showPageSizeSelector
filters={<ReviewerPoolFilter />}
expandableRow={ExpandableRow}
tableActions={
<button className="btn btn-primary btn-sm" onClick={handleInvite}>
{translate('Invite reviewer')}
</button>
}
/>
);
};
|
Key patterns in complex tables
- Memoize filters - Always wrap filter objects in
useMemo to prevent infinite re-renders when using values from useFilterValues.
- Memoize columns - Define columns in
useMemo for performance.
- Use tabs for sub-navigation - Tabs update URL params, allowing deep linking.
- Mark default tab - Use
default: true to highlight the correct tab on initial load.
- Preserve parent tab context - When using nested subtabs, include parent tab params (e.g.,
{ tab: 'reviewer-pool', pool_tab: 'pool' }) to prevent navigation from losing context.
- Expandable rows for details - Show additional information without navigating away.
- Optional columns - Let users customize their view with
optional: true and hasOptionalColumns.
- Autonomous filters - Use components like
SelectFilter and StringFilter for automatic Redux integration.
- URL synchronization - Enable
syncFiltersToURL: true in useTable for shared links and session persistence.