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:
DataTablefor collections of entitiesPanelfor 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:
- show a collection in
DataTable - let the user search, filter, sort, and scan that collection
- open the selected entity in a
Panelfor 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.Rootowns items, loading, view mode, selection state, totals, and persistenceDataTable.Toolbarowns search/filter/sort/action controlsDataTable.Contentowns 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.FiltersDataTable.SortingDataTable.ColumnsModifierDataTable.SavedViewsenableColumnHidingmodifiersfor bulk actionsactiveItemplusonRowClickfor 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.Menudropdown - 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:
createEntityColumncreateEntityReferenceColumncreateEntityMultiReferenceColumncreateTextColumncreateDateColumncreateMoneyColumncreateBadgeColumncreateDynamicBadgeColumn
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
DataTablecolumns - 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
Badgeis still aspan, 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
grayfor neutral metadata and other colors when the state meaning is deliberate - use specialized abstractions like
VendorTypeBadgeorInlineEntityManager.Badgewhen the feature already has a typed wrapper
Related Entity References
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
Badgefor 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:
DetailsGridis a layout wrapper for explicit child cells, not anitemsAPI- use
CollapsableDetailsGridfor repeated titled sections in longer detail views - use
className="sm:col-span-2"when a cell needs to span the full row - prefer
DetailsGridFormSubmissionCellwhen the field type already maps to aPortalFieldType
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
localStorageKeyon DataTables so column preferences persist - Do use the composable
DataTable.Root+ toolbar +DataTable.Contentstructure for new list pages - Do use infinite scroll for lists longer than 50 items
- Do show
totalResultsCountwhen 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
DataTableandPaneltogether as the default dashboard collection-to-entity pattern - Do use
Panelfor dashboard-style single-entity detail views instead of inventing a custom side drawer - Do use
Badgefor 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
DataTableAPI - Don't place several peer action buttons in the toolbar when a single actions menu is clearer
- Don't show raw dates — use
date-fnsformat 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