FSAI Design System
Patterns

Data Display

Patterns for tables, lists, cards, and data visualization.

Overview

Data display patterns ensure consistent presentation of information across tables, lists, grids, and detail views.

For the brand dashboard, the most important display pairing is:

  • DataTable for collections of entities
  • Panel for a single selected entity and its deeper detail workflow

Those two patterns work together constantly. DataTable is usually the primary collection surface, and Panel is usually the primary single-entity surface.


Core Dashboard Pair

DataTable + Panel

This is the default operational data-display pattern in the platform:

  1. show a collection in DataTable
  2. let the user search, filter, sort, and scan that collection
  3. open the selected entity in a Panel for deeper inspection or editing

Use this pairing when:

  • users work through many entities of the same type
  • the list needs scanning, sorting, filtering, or bulk actions
  • the selected entity needs richer detail than a row can comfortably show

This is more than a table pattern and more than a panel pattern. It is the main dashboard master-detail rule.

Collection Views

DataTable

Use DataTable for entity collections, operational lists, tables with filters, and datasets that may also support grid or map views.

DataTable Patterns

Standard List Page

The standard dashboard list page is built with DataTable.Root, DataTable.Toolbar, and DataTable.Content.

<DataTable.Root
  itemName="Lead"
  items={items}
  isLoading={isLoading}
  isFetching={isFetching}
  totalResultsCount={totalCount}
  hasNextPage={hasNextPage}
  fetchNextPage={fetchNextPage}
  isFetchingNextPage={isFetchingNextPage}
  localStorageKey="items-table-preferences"
  enableColumnHiding
>
  <DataTable.Toolbar>
    <DataTable.Toolbar.Left>
      <DataTable.Search onChange={setSearch} placeholder="Search items..." />
      <DataTable.Filters
        available={availableFilters}
        applied={appliedFilters}
        onApply={setAppliedFilters}
      />
      <DataTable.Sorting
        available={availableSorting}
        applied={appliedSorting}
        onApply={setAppliedSorting}
      />
      <DataTable.ColumnsModifier columns={columns} />
    </DataTable.Toolbar.Left>
    <DataTable.Toolbar.Right>
      <DataTable.ResultsCount total={totalCount} itemName="Item" />
      <DataTable.PrimaryAction
        action={{ type: 'single', label: 'Create Item', action: handleCreate }}
      />
    </DataTable.Toolbar.Right>
  </DataTable.Toolbar>

  <DataTable.Content
    view="table"
    columns={columns}
    onRowClick={handleRowClick}
    searchValue={searchValue}
    appliedFiltersCount={appliedFilters.length}
    modifiers={bulkActions}
    emptyState={{
      default: {
        title: 'No items yet',
        description: 'Create your first item to get started.',
        primaryAction: { label: 'Create Item', onClick: handleCreate },
      },
      search: {
        title: 'No results',
        description: 'Try adjusting your search or filters.',
      },
    }}
    table={{ resizableColumns: true, reorderableColumns: true }}
  />
</DataTable.Root>

This is the primary list-page pattern used across the brand dashboard.

Toolbar Responsibilities

The split of responsibility matters:

  • DataTable.Root owns items, loading, view mode, selection state, totals, and persistence
  • DataTable.Toolbar owns search/filter/sort/action controls
  • DataTable.Content owns the actual table, grid, or map rendering

Do not document or build new list pages around a fictional one-component DataTable API.

Power-User Table Features

The richer operational tables in leads, marketing, vendors, and franchisees commonly add:

  • DataTable.Filters
  • DataTable.Sorting
  • DataTable.ColumnsModifier
  • DataTable.SavedViews
  • enableColumnHiding
  • modifiers for bulk actions
  • activeItem plus onRowClick for row-to-panel workflows

These are part of the standard platform table pattern, not edge cases.

Toolbar Actions

Toolbar actions should follow a simple rule:

  • if there is only one main action, render it directly in the toolbar
  • if there is more than one main action, use a DataTable.Menu dropdown
  • when using a menu, the first item should always be creation-oriented
  • if there are multiple creation paths, that first creation-oriented item can branch into a submenu

Single action example:

<DataTable.Toolbar.Right>
  <DataTable.ResultsCount total={courses.length} itemName="Course" />
  <DataTable.PrimaryAction
    action={{
      type: 'single',
      label: 'Create Course',
      action: () => setIsCreating(true),
      leftAdornment: 'PlusSmall',
    }}
  />
</DataTable.Toolbar.Right>

Multiple actions example:

<DataTable.Toolbar.Right>
  <DataTable.ResultsCount total={totalCount} itemName="Location" />
  <DataTable.Menu
    actions={[
      {
        type: 'single',
        label: 'Add Location',
        action: () => setIsCreateLocationModalOpen(true),
        leftAdornment: 'PlusAdd',
      },
      {
        type: 'single',
        label: 'Imports in Progress',
        action: () => openLocationImportsDialog({}),
        leftAdornment: 'Pop',
      },
      {
        type: 'single',
        label: 'Import From CSV',
        action: () => setIsImportFromCsvModalOpen(true),
        leftAdornment: 'SquareArrowTopRight',
      },
    ]}
  />
</DataTable.Toolbar.Right>

This keeps the toolbar readable while still making the creation path obvious.

Table And Grid View Switching

Some datasets support more than one useful presentation. In those cases, switch views from the same DataTable.Root:

<DataTable.Root
  items={courses}
  itemName="Course"
  viewModes={['grid', 'table']}
  initialViewMode="grid"
  localStorageKey="learning-courses"
>
  <DataTable.Toolbar>
    <DataTable.Toolbar.Left>
      <DataTable.Search value={searchQuery} onChange={setSearchQuery} />
      <DataTable.Filters
        available={availableFilters}
        applied={appliedFilters}
        onApply={setAppliedFilters}
      />
      <DataTable.ViewToggle
        modes={viewModes}
        value={viewMode}
        onChange={setViewMode}
      />
      <DataTable.GridDensityToggle
        value={gridColumns}
        onChange={setGridColumns}
      />
    </DataTable.Toolbar.Left>
  </DataTable.Toolbar>

  <DataTable.Content
    view={viewMode}
    {...(viewMode === 'table'
      ? { columns: tableColumns }
      : {
          gridItemRenderer: (props) => <CourseGridItem {...props} />,
          grid: { fixedGridColumns: gridColumns },
        })}
    onRowClick={handleOpenCourse}
    emptyState={emptyState}
    searchValue={searchQuery}
    appliedFiltersCount={appliedFilters.length}
  />
</DataTable.Root>

Only do this when the dataset genuinely benefits from both views.

Master-Detail Pattern

Table with a side panel for viewing or editing the selected row. In the brand dashboard, Panel is the standard single-entity detail container for this pattern.

<DataTable.Content
  view="table"
  columns={columns}
  activeItem={selected}
  onRowClick={(item) => openItemPanel(item)}
/>

Then let the surrounding screen or app shell own the actual Panel.

When the application already uses a shared panel shell such as PanelStack, the detail component should return Panel.Content and related subcomponents rather than opening a nested Panel.

Bulk Actions And Modifiers

Use modifiers when selection should unlock bulk actions:

<DataTable.Content
  view="table"
  columns={columns}
  modifiers={[
    {
      type: 'simple',
      label: 'Delete',
      variant: 'danger',
      showConfirmationModal: true,
      confirmationModalTitle: 'Delete selected items',
      confirmationModalDescription:
        'Are you sure you want to delete the selected items?',
      action: async ({ checkedItems, clearSelection }) => {
        await deleteItems(checkedItems.map((item) => item.id));
        clearSelection?.();
      },
    },
  ]}
/>

This is the standard bulk-action pattern. Do not build parallel custom selection bars unless the view genuinely needs a different interaction model.

Infinite Scroll

For large datasets, use infinite scroll rather than pagination:

<DataTable.Root
  items={allLoadedItems}
  hasNextPage={hasNextPage}
  fetchNextPage={fetchNextPage}
  isFetchingNextPage={isFetchingNextPage}
  totalResultsCount={totalCount}
>
  <DataTable.Content view="table" columns={columns} />
</DataTable.Root>

totalResultsCount belongs on DataTable.Root. DataTable.ResultsCount in the toolbar usually mirrors the server total.

Factory Column Helpers

The brand dashboard relies heavily on the DataTable factory helpers for consistent cells:

  • createEntityColumn
  • createEntityReferenceColumn
  • createEntityMultiReferenceColumn
  • createTextColumn
  • createDateColumn
  • createMoneyColumn
  • createBadgeColumn
  • createDynamicBadgeColumn

Use those helpers by default when they already match the column pattern you need.


List Patterns

Simple List

For compact displays without full table features:

<div className="flex flex-col divide-y divide-default">
  {items.map((item) => (
    <div key={item.id} className="flex items-center justify-between py-3 px-4">
      <div className="flex items-center gap-3">
        <Avatar user={item.user} size="sm" />
        <div>
          <p className="text-base font-medium text-strong">{item.name}</p>
          <p className="text-sm text-subtle">{item.description}</p>
        </div>
      </div>
      <Badge label={item.status} color={item.statusColor} />
    </div>
  ))}
</div>

Status And Metadata Badges

Use Badge for short status chips and metadata pills that help users scan a row, card, or panel header quickly.

Typical uses in the brand dashboard:

  • workflow or entity status
  • result quality or lifecycle state
  • roles, categories, and types
  • compact icon-plus-label qualifiers
const PROJECT_STATUS_CONFIG = {
  active: { label: 'Active', color: 'green' },
  paused: { label: 'Paused', color: 'orange' },
  archived: { label: 'Archived', color: 'gray' },
} as const;

<Badge
  label={PROJECT_STATUS_CONFIG[project.status].label}
  color={PROJECT_STATUS_CONFIG[project.status].color}
/>

Good fit:

  • read-only status display in DataTable columns
  • compact metadata in lists, cards, and panel headers
  • icon-plus-label pills that help users parse state faster

Use carefully:

  • clickable badges are acceptable for secondary interactions, but Badge is still a span, not a button
  • long labels truncate quickly, so badge text should stay short

Conventions:

  • map business meaning to badge colors in a single helper or config object
  • use gray for neutral metadata and other colors when the state meaning is deliberate
  • use specialized abstractions like VendorTypeBadge or InlineEntityManager.Badge when the feature already has a typed wrapper

If a data display surface shows a known related entity, the user should usually be able to navigate directly to it.

Examples:

  • location -> owning organization
  • location -> territory
  • deal -> lead or franchisee organization
  • task -> linked entity

Use the lightest affordance that still makes the relationship clear:

  • a clickable Badge for compact secondary metadata
  • an avatar + name row for richer table cells
  • a button or card row when the relationship deserves more context

In the dashboard, the destination should usually be the related entity's Panel if one exists.

See the Related Entity Navigation pattern for the full rule.


Empty States

Every data display should handle three states:

Loading State

Use the isLoading prop on DataTable, or Skeleton for custom layouts:

import { Skeleton } from '@fsai/shared-ui';

<div className="flex flex-col gap-3">
  <Skeleton className="h-10 w-full" />
  <Skeleton className="h-10 w-full" />
  <Skeleton className="h-10 w-full" />
</div>

Empty State (No Data)

Show an illustration, message, and call-to-action:

emptyState={{
  default: {
    title: 'No franchisees yet',
    description: 'Add your first franchisee to get started.',
    action: { label: 'Add Franchisee', onClick: handleCreate },
  },
}}

Empty Search Results

emptyState={{
  search: {
    title: 'No results found',
    description: 'Try adjusting your search or filters.',
  },
}}

Detail Views

Panel

Use Panel as the default single-entity data-display surface in the dashboard.

This is the primary pattern when the user has moved beyond scanning a collection and now needs to inspect or work with one specific entity in depth.

Good fit:

  • entity inspection with actions, metadata, and tabs
  • edit flows that need their own top bar and scroll container
  • master-detail experiences where the selected row opens a dedicated detail workspace
  • workflows where a list or board is only the entry point and the real work happens in the entity detail view

Not a good fit:

  • simple confirmations
  • small forms with limited context
  • static inline details that can live directly on the page

In the brand dashboard, Panel is usually the canonical destination for row clicks, related-entity cross-links, and collection-to-entity transitions.

See the Panel component page for the full API and dashboard usage patterns.

DetailsGrid

Use DetailsGrid for dense labeled details where the layout needs to stay standardized across cards, panels, and settings surfaces.

import {
  DetailsGrid,
  DetailsGridCellWrapper,
  DetailsGridFormSubmissionCell,
  CollapsableDetailsGrid,
} from '@fsai/shared-ui';

<CollapsableDetailsGrid
  title="Basic Information"
  defaultOpen
  localStorageKey={`brand-${brand.id}-basic-information`}
>
  <DetailsGridFormSubmissionCell
    type="short-text"
    label="Brand Name"
    value={brand.name}
    hasEditPermission={canEdit}
    onBlur={(value) => updateBrand({ name: value?.toString() })}
  />

  <DetailsGridCellWrapper label="Status">
    <StatusTag label={brand.status} color={brand.color} />
  </DetailsGridCellWrapper>

  <DetailsGridCellWrapper className="sm:col-span-2" label="Notes">
    <p className="text-regular">{brand.notes || '-'}</p>
  </DetailsGridCellWrapper>
</CollapsableDetailsGrid>

Good fit:

  • settings and workspace details with many labeled fields
  • panel sections that mix editable and read-only metadata
  • repeated accordion sections that should look identical across features
  • structured fields that sometimes need full-width rows

Not a good fit:

  • tiny 2-3 field summaries that read better as a simple list
  • highly custom freeform layouts with no repeated cell structure
  • tabular data where row comparison matters more than field grouping

Conventions:

  • DetailsGrid is a layout wrapper for explicit child cells, not an items API
  • use CollapsableDetailsGrid for repeated titled sections in longer detail views
  • use className="sm:col-span-2" when a cell needs to span the full row
  • prefer DetailsGridFormSubmissionCell when the field type already maps to a PortalFieldType

See the DetailsGrid component page for the full cell API and dashboard references.


Guidelines

  • Do always provide empty states for every data display
  • Do use localStorageKey on DataTables so column preferences persist
  • Do use the composable DataTable.Root + toolbar + DataTable.Content structure for new list pages
  • Do use infinite scroll for lists longer than 50 items
  • Do show totalResultsCount when using server-side filtering
  • Do use the DataTable factory helpers for common entity, date, money, text, and badge columns
  • Do render a single top-level toolbar action directly instead of hiding it in a menu
  • Do use a menu for multiple top-level table actions, with the first item creation-oriented
  • Do treat DataTable and Panel together as the default dashboard collection-to-entity pattern
  • Do use Panel for dashboard-style single-entity detail views instead of inventing a custom side drawer
  • Do use Badge for short scan-friendly status and metadata labels instead of custom chip styles
  • Don't document or implement new tables using the old fictional one-component DataTable API
  • Don't place several peer action buttons in the toolbar when a single actions menu is clearer
  • Don't show raw dates — use date-fns format consistently
  • Don't truncate text without a tooltip showing the full value
  • Don't use DataTable for simple 2-3 field lists — use a simple list instead

On this page