Skip to content

TableResource

Paulo Gomes da Cruz Junior edited this page Apr 13, 2026 · 1 revision

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.

Features

  • Generic Type Support: Works with any data type T through TypeScript generics
  • Custom Column Rendering: Define custom render functions for any column via the renderAs prop
  • 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

Type Definitions

TableHeaderType<T>

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 same key with different visualizations
  • If id is omitted, the component uses String(key) as the unique identifier

PageableResponse<T>

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
  };
};

SortingKeys<T>

Sorting configuration passed to onSortChange:

type SortingKeys<T> = Record<NestedKeyOf<T>, "ASC" | "DESC">;

Only contains entries for actively sorted columns (non-'NONE' values).

TableRowAction<T>

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.
  • onClick can return a Promise for async operations.
  • When isLoading is true, the action is automatically disabled.

Props

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.

Usage

Basic Table

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);
      }}
    />
  );
}

With Custom Rendering

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}
      />
    ),
  },
];

Multiple Columns with Same Key

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>,
  },
];

With Expandable Rows

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 onRowExpanded is 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

With Row Actions

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 maxInlineRowActions actions render as IconButton components (default: 2)
  • Remaining actions are placed in a Carbon OverflowMenu
  • label, icon, isDisabled, and isLoading accept either a static value or a function (row) => value for 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

With Custom Toolbar Entries

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 toolbarEntries is rendered alongside the column customization menu in the toolbar
  • Use unique key props for each toolbar entry
  • Style custom toolbar buttons with toolbar-action-button class or your own styles
  • The toolbar respects responsive behavior—consider hiding buttons on smaller screens if needed

Complete Example with All Features

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`),
        },
      ]}
    />
  );
}

Advanced Patterns

Nested Object Access

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 },
];

Conditional Rendering

Render values conditionally based on their content:

{
  key: 'multiMark',
  header: 'Multi-mark',
  renderAs: (value) => (
    value ? <YesNoTag value={value} /> : <EmptyValueTag value={value} />
  ),
}

Complex Object Rendering

For code/description pairs:

{
  key: 'sampling',
  header: 'Sampling',
  renderAs: (value) => (
    <CodeDescriptionTag value={value as CodeDescriptionDto} />
  ),
}

Styling

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 receives column-menu-button)
  • .column-menu-button - Secondary class applied alongside table-action-menu-button on 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; overrides height: auto

Customize via your own SCSS overrides.

Preferences Persistence

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.

Testing

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>,
);

Performance Considerations

  • 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 renderAs functions lightweight. Complex calculations should happen before passing data.

Child Components

TableResourceRow

Simple wrapper around Carbon's TableRow. Used for non-expandable rows.

TableResourceExpandRow

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.

TableResourceActions

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 of TableRowAction<T> for this row
  • maxInlineRowActions: How many actions to show as inline icon buttons

Behavior:

  • Inline actions render as IconButton with kind="ghost" and size="sm"
  • Overflow actions render inside an OverflowMenu with align="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

Internal Architecture

Extracted Components

ColumnCustomizationMenu

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

Extracted Hooks

useTableToolbar(id, headers)

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

Module Structure

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)

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally