Repository navigation
TableResource
The TableResource component is a generic, reusable table for displaying paginated data with optional loading, error, and empty states. It supports custom column rendering, sorting, column customization, expandable rows, and per-row actions. The component integrates with Carbon table and pagination primitives and persists user column-visibility preferences.
-
Generic Type Support: Works with any data type
Tthrough TypeScript generics -
Custom Column Rendering: Define custom render functions for any column via the
renderAsprop - Column Customization: Users can toggle column visibility via toolbar menu; preferences are persisted
- Sorting: Built-in support for sortable columns with state management
- Pagination: Integrated pagination with configurable page sizes (10, 20, 30)
- Expandable Rows: Optional row expansion for detailed data loading
- Row Actions: Per-row inline icon buttons and overflow menu for additional actions
- Loading & Error States: Skeleton loader and error handling built-in
- Preference Persistence: User column selections are saved per table id
- Stale Expansion Guarding: Async row expansion responses are ignored if the row is collapsed or a newer request supersedes them
Defines a column header configuration:
type TableHeaderType<T, K extends NestedKeyOf<T> = NestedKeyOf<T>> = {
key: K; // Property path to access data (e.g., 'name' or 'client.code')
header: string; // Column header display text
selected?: boolean; // Whether column is initially visible (default: false)
sortable?: boolean; // Whether column supports sorting (default: false)
id?: string; // Unique identifier for this column. If not provided, defaults to key.
// Use this when you need multiple columns with the same key but different renderings.
renderAs?: (value: ValueByPath<T, K>) => React.ReactNode; // Custom render function for cell values
};Key Notes:
-
key: Uses dot notation for nested properties (e.g.,client.description) -
id: Allows multiple columns to use the samekeywith different visualizations - If
idis omitted, the component usesString(key)as the unique identifier
Response structure for paginated data:
type PageableResponse<T> = {
content: IdentifiableContent<T>[]; // Array of data rows (must have an `id` field)
page: {
size: number; // Items per page
number: number; // Current page (0-indexed)
totalElements: number; // Total number of items
totalPages: number; // Total number of pages
};
};Sorting configuration passed to onSortChange:
type SortingKeys<T> = Record<NestedKeyOf<T>, "ASC" | "DESC">;Only contains entries for actively sorted columns (non-'NONE' values).
Defines an action button that appears in a row's Actions column:
type TableRowActionValue<T, R> = R | ((row: IdentifiableContent<T>) => R);
type TableRowAction<T> = {
id: string; // Unique identifier for this action
label: TableRowActionValue<T, string>; // Button label — static string or function of the row
icon: TableRowActionValue<T, React.ReactNode>; // Icon element — static or function of the row
onClick: (row: IdentifiableContent<T>) => void | Promise<void>; // Click handler, receives the full row
isDisabled?: TableRowActionValue<T, boolean>; // Disable the action — static or function of the row
isLoading?: TableRowActionValue<T, boolean>; // Loading state — disables the action when true
};Key Notes:
-
TableRowActionValue<T, R>lets you supply either a static value or a function that receives the current row and returns the value. This enables row-aware labels, icons, and disabled states. -
onClickcan return aPromisefor async operations. - When
isLoadingistrue, the action is automatically disabled.
| Name | Type | Required | Description |
|---|---|---|---|
id |
string |
✅ | Unique table identifier. Used for preference persistence and internal key generation. |
headers |
TableHeaderType<T>[] |
✅ | Array of column definitions with renderers and configuration |
content |
PageableResponse<T> |
✅ | Paginated data to display. Entities must have an id field. |
loading |
boolean |
✅ | If true, displays a skeleton loader instead of table content |
error |
boolean |
✅ | If true, displays an error message in empty state |
displayRange |
boolean |
❌ | If true, shows item range text in pagination (e.g., "1-10 of 42 items") |
displayToolbar |
boolean |
❌ | If true, displays the toolbar with the built-in column customization menu |
toolbarEntries |
ReactNode[] |
❌ | Optional extra toolbar entries rendered beside the column menu when displayToolbar is enabled |
onPageChange |
(params: { page: number; pageSize: number }) => void |
❌ | Fired when page or page size changes. page is 0-indexed. |
onSortChange |
(sortKeys: SortingKeys<T>) => void |
❌ | Fired when user clicks column headers to sort |
onRowExpanded |
(rowId: string | number) => Promise<ReactNode> |
❌ | When provided, enables row expansion. Must return a Promise resolving to expandable content. Does not render for normal rows. |
getRowActions |
(row: IdentifiableContent<T>) => TableRowAction<T>[] |
❌ | When provided, adds an "Actions" column. Receives each row and returns the actions to display for it. |
maxInlineRowActions |
number |
❌ | Maximum number of actions shown as inline icon buttons per row. Extra actions go into an overflow menu. Default: 2. |
import TableResource from '@/components/Form/TableResource';
import type { ReportingUnitSearchResultDto } from '@/services/types';
import type { PageableResponse, TableHeaderType } from '@/components/Form/TableResource/types';
type Unit = ReportingUnitSearchResultDto;
const headers: TableHeaderType<Unit>[] = [
{ key: 'cutBlockId', header: 'Block ID', sortable: true, selected: true },
{ key: 'ruNumber', header: 'RU No.', sortable: true, selected: true },
{ key: 'status', header: 'Status', sortable: true, selected: true },
];
export function UnitsTable() {
const [content, setContent] = useState<PageableResponse<Unit>>({...});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
return (
<TableResource
id="units-table"
headers={headers}
content={content}
loading={loading}
error={error}
displayToolbar
displayRange
onPageChange={({ page, pageSize }) => {
// Fetch new page of data with page sizing
fetchUnits(page, pageSize);
}}
onSortChange={(sortKeys) => {
// Handle sorting - sortKeys contains only active sorts
console.log(sortKeys); // e.g., { status: 'ASC', ruNumber: 'DESC' }
fetchUnits(0, 10, sortKeys);
}}
/>
);
}const headers: TableHeaderType<Unit>[] = [
{
key: "cutBlockId",
header: "Block ID",
sortable: true,
selected: true,
renderAs: (value) => <EmptyValueTag value={value} />,
},
{
key: "ruNumber",
header: "RU No.",
sortable: true,
selected: true,
renderAs: (value) => (
<TooltipRedirectLinkTag
text={value as string}
url={`/details/${value}`}
tooltip="View RU details"
/>
),
},
{
key: "status",
header: "Status",
sortable: true,
selected: true,
renderAs: (value) => (
<ColorTag
value={value as { code: string; description: string }}
colorMap={statusColorMap}
/>
),
},
];Use the id prop to display the same data property in multiple columns with different renderings:
const headers: TableHeaderType<Unit>[] = [
{
id: "client-code",
key: "client.code",
header: "Client No.",
sortable: true,
selected: true,
renderAs: (value) => (
<TooltipRoleBasedRedirectLinkTag
text={value as string}
url={`/clients/${value}`}
allowedRoles={[Role.IDIR]}
/>
),
},
{
id: "client-name",
key: "client.description",
header: "Client Name",
sortable: true,
selected: true,
},
{
id: "client-code-plain", // Different id, same key
key: "client.code", // Reuse the data
header: "Client Code (Plain)",
sortable: false,
selected: false,
renderAs: (value) => <span className="monospace">{value}</span>,
},
];Expandable rows allow users to click a row to reveal additional details:
export function UnitsTableWithExpansion() {
const [content, setContent] = useState<PageableResponse<Unit>>({...});
const handleRowExpanded = async (rowId: string | number): Promise<ReactNode> => {
// Fetch detailed data for this row
const details = await fetchUnitDetails(rowId);
return (
<div className="row-expansion-content">
<h4>Unit Details</h4>
<p>Status: {details.status}</p>
<p>Updated: {details.lastUpdated}</p>
</div>
);
};
return (
<TableResource
id="units-table-expandable"
headers={headers}
content={content}
loading={false}
error={false}
displayToolbar
onRowExpanded={handleRowExpanded} // Enable row expansion
/>
);
}Expansion Behavior:
- When
onRowExpandedis provided, a chevron icon appears in the first column - Clicking expands/collapses the row with a loading skeleton during data fetch
- Multiple rows can be expanded simultaneously
- Collapsing a row clears its cached expanded content for the current page
- Stale async responses are ignored if the row is no longer expanded or a newer request replaces them
Row actions add an "Actions" column with inline icon buttons and an optional overflow menu for each row:
import { Edit, TrashCan, CheckmarkFilled, CloseFilled } from '@carbon/icons-react';
import TableResource from '@/components/Form/TableResource';
import type { TableRowAction } from '@/components/Form/TableResource/types';
type Unit = { name: string; active: boolean };
export function UnitsTableWithActions() {
const [content, setContent] = useState<PageableResponse<Unit>>({...});
const getRowActions = (row: Unit & { id: string | number }): TableRowAction<Unit>[] => [
{
id: 'toggle',
label: (r) => (r.active ? 'Deactivate' : 'Activate'),
icon: (r) => (r.active ? <CheckmarkFilled size={16} /> : <CloseFilled size={16} />),
onClick: async (r) => {
await toggleUnit(r.id);
},
},
{
id: 'edit',
label: 'Edit',
icon: <Edit size={16} />,
onClick: (r) => navigate(`/units/${r.id}/edit`),
},
{
id: 'delete',
label: 'Delete',
icon: <TrashCan size={16} />,
isDisabled: (r) => r.active,
onClick: async (r) => {
await deleteUnit(r.id);
},
},
];
return (
<TableResource
id="units-table-actions"
headers={headers}
content={content}
loading={false}
error={false}
getRowActions={getRowActions}
maxInlineRowActions={2} // First 2 actions are inline buttons; the rest go into an overflow menu
/>
);
}Row Actions Behavior:
- An "Actions" column header is appended after all data columns
- The first
maxInlineRowActionsactions render asIconButtoncomponents (default: 2) - Remaining actions are placed in a Carbon
OverflowMenu -
label,icon,isDisabled, andisLoadingaccept either a static value or a function(row) => valuefor row-aware rendering - Click events on action buttons stop propagation, so they do not trigger row-level click handlers
- The skeleton loader automatically accounts for the extra Actions column
Add custom buttons or controls to the toolbar alongside the built-in column customization menu:
import { Button } from '@carbon/react';
import { Download } from '@carbon/react/icons';
export function TableWithCustomToolbar() {
const [content, setContent] = useState<PageableResponse<Unit>>({...});
const handleExport = () => {
// Export table data
};
const toolbarButtons = [
<Button
key="export"
kind="tertiary"
size="sm"
renderIcon={Download}
onClick={handleExport}
className="toolbar-action-button"
>
Export
</Button>,
// Add more buttons as needed
];
return (
<TableResource
id="units-table"
headers={headers}
content={content}
loading={false}
error={false}
displayToolbar
toolbarEntries={toolbarButtons}
/>
);
}Notes:
- Each entry in
toolbarEntriesis rendered alongside the column customization menu in the toolbar - Use unique
keyprops for each toolbar entry - Style custom toolbar buttons with
toolbar-action-buttonclass or your own styles - The toolbar respects responsive behavior—consider hiding buttons on smaller screens if needed
import { useState } from "react";
import TableResource from "@/components/Form/TableResource";
import type { ReportingUnitSearchResultDto } from "@/services/types";
import type {
PageableResponse,
TableHeaderType,
} from "@/components/Form/TableResource/types";
import APIs from "@/services/APIs";
type Unit = ReportingUnitSearchResultDto;
const headers: TableHeaderType<Unit>[] = [
{
key: "cutBlockId",
header: "Block ID",
sortable: true,
selected: true,
renderAs: (value) => <EmptyValueTag value={value} />,
},
{
key: "ruNumber",
header: "RU No.",
sortable: true,
selected: true,
renderAs: (value) => (
<TooltipRedirectLinkTag
text={value as string}
url={`/waste101ReportUnitDetailsAction.do?dataBean.p_reporting_unit_id=${value}`}
tooltip="Go to RU details"
/>
),
},
{
key: "client.code",
header: "Client No.",
sortable: true,
selected: true,
renderAs: (value) => (
<TooltipRoleBasedRedirectLinkTag
text={value as string}
url={`/clients/${value}`}
allowedRoles={[Role.IDIR]}
/>
),
},
{
key: "status",
header: "Status",
sortable: true,
selected: true,
renderAs: (value) => (
<ColorTag value={value as any} colorMap={statusColorMap} />
),
},
];
export function SearchResultsTable() {
const [content, setContent] = useState<PageableResponse<Unit>>({
content: [],
page: { number: 0, size: 10, totalElements: 0, totalPages: 0 },
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const handlePageChange = async ({
page,
pageSize,
}: {
page: number;
pageSize: number;
}) => {
setLoading(true);
try {
const response = await APIs.waste.searchReportingUnits({
page,
pageSize,
});
setContent(response);
setError(false);
} catch {
setError(true);
} finally {
setLoading(false);
}
};
const handleSortChange = async (sortKeys: SortingKeys<Unit>) => {
setLoading(true);
try {
const response = await APIs.waste.searchReportingUnits({
page: 0,
pageSize: 10,
sort: sortKeys,
});
setContent(response);
setError(false);
} catch {
setError(true);
} finally {
setLoading(false);
}
};
const handleRowExpanded = async (
rowId: string | number,
): Promise<ReactNode> => {
const details = await APIs.waste.getUnitDetails(rowId);
return (
<div className="expansion-details">
<p>
<strong>Status:</strong> {details.status.description}
</p>
<p>
<strong>Updated:</strong> {details.lastUpdated}
</p>
</div>
);
};
return (
<TableResource
id="search-results-table"
headers={headers}
content={content}
loading={loading}
error={error}
displayToolbar
displayRange
onPageChange={handlePageChange}
onSortChange={handleSortChange}
onRowExpanded={handleRowExpanded}
getRowActions={(row) => [
{
id: "edit",
label: "Edit",
icon: <Edit size={16} />,
onClick: (r) => navigate(`/units/${r.id}/edit`),
},
]}
/>
);
}The key prop supports dot notation for accessing nested properties:
headers: [
{ key: "client.code", header: "Client Code", sortable: true },
{ key: "client.description", header: "Client Name", sortable: true },
{ key: "district.code", header: "District Code", sortable: true },
];Render values conditionally based on their content:
{
key: 'multiMark',
header: 'Multi-mark',
renderAs: (value) => (
value ? <YesNoTag value={value} /> : <EmptyValueTag value={value} />
),
}For code/description pairs:
{
key: 'sampling',
header: 'Sampling',
renderAs: (value) => (
<CodeDescriptionTag value={value as CodeDescriptionDto} />
),
}The component uses SCSS from index.scss with the following CSS classes:
-
.default-table-skeleton- Skeleton loader styling -
.table-action-toolbar-content- Toolbar content area -
.table-action-menu-button- Column menu button (also receivescolumn-menu-button) -
.column-menu-button- Secondary class applied alongsidetable-action-menu-buttonon the toolbar menu button -
.table-search-action-menu-option- Column menu dropdown container styling -
.table-action-menu-option-item- Inner content wrapper div inside the column menu dropdown -
.column-checkbox- Column toggle checkboxes -
.table-header-tooltip-trigger- Sortable header tooltip trigger -
.table-row-actions-wrapper- Flex container for inline action buttons and overflow menu -
.toolbar-menu-columns-display- Flex wrapper for the "Edit columns" button label and icon -
.toolbar-menu-columns-display-text- Text portion of the "Edit columns" button label -
.toolbar-menu-columns-display-icon- Icon portion of the "Edit columns" button label -
.tb-resource-pg- Applied to the pagination component; overridesheight: auto
Customize via your own SCSS overrides.
Column visibility preferences are automatically persisted via the @/context/preference context. The persistence key is the id prop value, allowing different tables to maintain independent user preferences.
When a user:
- Toggles a column in the toolbar menu
These changes are automatically saved to their user preferences and restored on next load.
Sorting is not persisted. Sort state lives inside the rendered table instance and is exposed outward through onSortChange so the parent can refetch or reorder data.
When testing components using TableResource, mock the preference API:
import APIs from "@/services/APIs";
import { vi } from "vitest";
vi.mock("@/services/APIs", () => ({
default: {
user: {
getUserPreferences: vi.fn().mockResolvedValue({ theme: "g10" }),
updateUserPreferences: vi.fn().mockResolvedValue({}),
},
},
}));Wrap your test component in PreferenceProvider:
import { QueryClientProvider } from "@tanstack/react-query";
import { PreferenceProvider } from "@/context/preference/PreferenceProvider";
const qc = new QueryClient();
render(
<QueryClientProvider client={qc}>
<PreferenceProvider>
<TableResource {...props} />
</PreferenceProvider>
</QueryClientProvider>,
);- Large Datasets: Pagination is required. Don't load all data at once.
-
Sort State: Sorting only affects columns with
sortable: true. Implement server-side sorting for best performance. - Row Expansion: Each expanded row makes an async request. Consider debouncing/caching for frequently expanded rows.
-
Custom Renderers: Keep
renderAsfunctions lightweight. Complex calculations should happen before passing data.
Simple wrapper around Carbon's TableRow. Used for non-expandable rows.
Wrapper for expandable rows. Handles:
- Expand/collapse chevron
- Loading skeleton during data fetch
- Expanded row content rendering
Requires columns prop to set correct colspan for expanded content.
Renders per-row action controls. Splits actions into inline IconButtons and an OverflowMenu based on maxInlineRowActions.
Props (TableResourceActionsProps<T>):
-
row: The current data row (IdentifiableContent<T>) -
actions: Array ofTableRowAction<T>for this row -
maxInlineRowActions: How many actions to show as inline icon buttons
Behavior:
- Inline actions render as
IconButtonwithkind="ghost"andsize="sm" - Overflow actions render inside an
OverflowMenuwithalign="top-start" - All click handlers call
event.stopPropagation()to avoid triggering row-level events - Uses
resolveTableRowActionValue()to resolve static or row-aware values for labels, icons, disabled, and loading states
Renders the toolbar menu button for customizing column visibility. Extracted to support modular composition and allow other toolbar entries to coexist.
Props:
-
headers: Array of column definitions with selected state -
onToggleHeader: Callback function for toggling column visibility
Custom hook for managing table column visibility and preferences persistence. Handles loading and saving user preferences automatically.
Parameters:
-
id: Unique table identifier for preference persistence -
headers: Array of default column headers
The component is now organized with small focused modules:
-
index.tsx(main orchestration) -
types.ts(shared types and helpers:TableRowAction,resolveTableRowActionValue, etc.) -
useTableToolbar.ts(column preference logic) -
ColumnCustomizationMenu/index.tsx(edit columns menu) -
TableResourceActions/index.tsx(row action buttons and overflow menu) -
TableResourceRow/index.tsx(non-expandable row) -
TableResourceExpandRow/index.tsx(expandable row)
This wiki documents NR Waste Plus for users and developers. Verify security and deployment guidance against the current application configuration before applying it elsewhere.
- Report an issue or ask a question in the GitHub repository.
- Follow the Contributing Guidelines to propose an update.
- Read the Code of Conduct.
Collection indexes support human browsing and machine retrieval. Keep both current when adding or moving wiki pages.
NR Waste Plus Wiki | GitHub Repository