feat(frontend): contextual DataTable empty state via shadcn <Empty>

Resolves QA report observation O9. Every list page used to render the
bare "No results." label when its DataTable had no rows — both for a
truly empty dataset and when a filter narrowed the result set to zero.
The Resources page already had a better pattern (shadcn <Empty> block
with a filter-aware copy that cited the query), so propagate it to the
shared DataTable.

API
- New optional `empty?: { entityName?: string }` prop on `DataTable`.
- When `entityName` is provided, the body cell renders an `<Empty>`
  block:
    filter active → Search icon + "No matches" + "No <entity> match
    <code>{query}</code>. Try a different query."
    no filter      → Inbox icon + "No <entity> yet"
  and the pagination footer mirrors the entity name ("No <entity>"
  instead of "No results").
- When `entityName` is omitted, the existing bare "No results." cell
  and "No results" footer are kept verbatim — preserves backward-compat
  for call sites that have not migrated and for the existing test that
  asserts the legacy copy.

Per-page wiring
Plural lowercase names match how a screen reader reads them mid-
sentence:
  flows                  → "flows"
  templates              → "templates"
  knowledges             → "knowledge documents"
  /settings/providers    → "providers"
  /settings/api-tokens   → "API tokens"
  /settings/prompts ×2   → "agent prompts" / "tool prompts"

Tests
- Existing `'No results.'` assertion stays valid: it renders a
  DataTable without the `empty` prop, exercising the legacy fallback
  path.
- Added 3 new cases under `DataTable — empty results`:
    1. legacy fallback when `empty.entityName` is omitted
    2. data-empty "No <entity> yet" when no filter is active
    3. filter-empty "No <entity> match <query>. Try a different
       query." with the query cited verbatim

Verified
- 0 ESLint errors, 44 pre-existing warnings.
- 511/511 vitest pass in sequential mode and when the affected files
  run isolated. A pre-existing concurrency flake in
  `detail-navigation-sheet.test.tsx` (`typing narrows the listbox
  immediately when searchDebounceMs=0`) occasionally surfaces under
  vitest's default `--file-parallelism` — unrelated to this change,
  passes deterministically with `--no-file-parallelism` and when the
  file is run in isolation.
- Browser sweep across 7 tables (`/flows`, `/templates`,
  `/knowledges`, `/settings/{providers,api-tokens,prompts(×2)}`) all
  render the new copy with `?q=ZZZZZZ`.

References
- shadcn `Empty` component (already vendored in
  `components/ui/empty.tsx`)
- shadcn.io `tables-empty` block FAQ (best-practice: distinguish
  filter-empty vs data-empty via `hasActiveFilters` boolean, ground
  the empty cell in a Lucide icon, surface a "next-step" hint).
- Existing precedent in `pages/resources/resources.tsx` —
  `No resources match <code>{q}</code>. Try a different query.`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-22 18:31:30 +07:00
parent 25e19e5be8
commit afb9b637ef
8 changed files with 128 additions and 2 deletions
@@ -342,6 +342,61 @@ describe('DataTable — empty results', () => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
it('renders the bare "No results." cell when `empty.entityName` is omitted (legacy fallback)', () => {
render(
<DataTable<Row>
columns={COLUMNS}
data={[]}
/>,
{ wrapper: Wrapper },
);
expect(screen.getByText('No results.')).toBeInTheDocument();
// The shadcn Empty title is NOT rendered without `entityName`.
expect(screen.queryByText('No matches')).not.toBeInTheDocument();
});
it('renders a data-empty Empty block ("No <entity> yet") when `entityName` is set and no filter is active', () => {
render(
<DataTable<Row>
columns={COLUMNS}
data={[]}
empty={{ entityName: 'flows' }}
/>,
{ wrapper: Wrapper },
);
expect(screen.getByText('No flows yet')).toBeInTheDocument();
// No filter ⇒ no "match" description.
expect(screen.queryByText(/Try a different query/)).not.toBeInTheDocument();
// Pagination footer also uses the entity copy.
expect(screen.getByText('No flows')).toBeInTheDocument();
});
it('renders a filter-empty Empty block ("No <entity> match …") when `entityName` is set and a filter is active', async () => {
const user = userEvent.setup();
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
empty={{ entityName: 'flows' }}
filterColumn="name"
filterPlaceholder="Filter..."
/>,
{ wrapper: Wrapper },
);
const input = screen.getByPlaceholderText('Filter...');
// No row in ROWS has the substring "ZZZZZZ" — guarantees an empty subset.
await user.type(input, 'ZZZZZZ');
expect(await screen.findByText('No matches')).toBeInTheDocument();
// Description cites the query and offers the next-step hint.
const description = await screen.findByText(/Try a different query/);
expect(description).toHaveTextContent('ZZZZZZ');
expect(description.textContent).toMatch(/^No flows match/);
});
it('assigns a non-empty unique id and matching name to the filter field', () => {
const { unmount } = render(
<DataTable<Row>
+66 -2
View File
@@ -22,7 +22,9 @@ import {
ChevronsLeft,
ChevronsRight,
ColumnsSettings,
Inbox,
ListFilter,
Search,
X,
} from 'lucide-react';
import {
@@ -47,6 +49,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -71,6 +74,19 @@ interface DataTableProps<TData, TValue = unknown> {
columns: ColumnDef<TData, TValue>[];
columnVisibility?: VisibilityState;
data: TData[];
/**
* Empty-state copy. When provided, the bare "No results." cell is replaced
* with a shadcn `<Empty>` block whose title and description adapt to whether
* a filter is currently active:
* - filter empty + filter active → "No matches" + `No <entityName> match "<query>". Try a different query.`
* - filter empty + no filter → `No <entityName> yet.`
*
* Pass the plural lowercase form (`"flows"`, `"knowledge documents"`,
* `"API tokens"`) so the generated copy reads naturally. Omitting the prop
* preserves the legacy `"No results."` fallback for callers that have not
* migrated yet.
*/
empty?: { entityName?: string };
/**
* Search target(s) for the filter input. Three modes:
* - `string` (legacy single-column): the input searches only this column;
@@ -148,12 +164,54 @@ const getColumnId = <TData, TValue>(column: ColumnDef<TData, TValue>): string |
return typeof withAccessor.accessorKey === 'string' ? withAccessor.accessorKey : undefined;
};
interface DataTableEmptyStateProps {
entityName?: string;
filterValue: string;
}
interface DataTableFilterProps {
onQueryChange: (value: string) => void;
placeholder: string;
query: string;
}
/**
* Renders the body-row "no data" cell content. Two axes:
* - `filterValue` empty/non-empty: distinguishes a truly empty dataset from a
* filter that excluded every row.
* - `entityName` provided/omitted: callers that pass `empty.entityName` get the
* shadcn `<Empty>` block with a filter-aware copy; callers that don't keep
* the legacy bare `"No results."` label so existing tests stay green.
*
* Kept inline (rather than exported) because the props are tightly coupled to
* `DataTable`'s internal `effectiveQuery` and the layout assumes it lives
* inside a `<TableCell>` with `colSpan={columns.length}`.
*/
function DataTableEmptyState({ entityName, filterValue }: DataTableEmptyStateProps) {
if (!entityName) {
return <>No results.</>;
}
const hasFilter = filterValue.length > 0;
const Icon = hasFilter ? Search : Inbox;
return (
<Empty className="border-0 p-0 md:p-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon />
</EmptyMedia>
<EmptyTitle>{hasFilter ? 'No matches' : `No ${entityName} yet`}</EmptyTitle>
{hasFilter ? (
<EmptyDescription>
No {entityName} match <code>{filterValue}</code>. Try a different query.
</EmptyDescription>
) : null}
</EmptyHeader>
</Empty>
);
}
const FILTER_DEBOUNCE_MS = 150;
// Hard cap on the filter query length. 200 chars is more than any realistic
// search term and protects against pathological inputs (paste of a multi-KB
@@ -199,6 +257,7 @@ function DataTable<TData, TValue = unknown>({
columns,
columnVisibility: externalColumnVisibility,
data,
empty,
filterColumn,
filterPlaceholder = 'Filter...',
filterValue: externalFilterValue,
@@ -760,10 +819,13 @@ function DataTable<TData, TValue = unknown>({
) : (
<TableRow>
<TableCell
className="h-24 text-center"
className={cn('text-center', empty?.entityName ? 'py-12' : 'h-24')}
colSpan={columns.length}
>
No results.
<DataTableEmptyState
entityName={empty?.entityName}
filterValue={effectiveQuery.trim()}
/>
</TableCell>
</TableRow>
)}
@@ -776,6 +838,8 @@ function DataTable<TData, TValue = unknown>({
<>
Showing {rangeStart}{rangeEnd} of {totalRows}
</>
) : empty?.entityName ? (
`No ${empty.entityName}`
) : (
'No results'
)}
+1
View File
@@ -637,6 +637,7 @@ function Flows() {
<DataTable<Flow>
columns={columns}
data={flows}
empty={{ entityName: 'flows' }}
filterPlaceholder="Filter flows..."
filterValue={filter}
onFilterChange={setFilter}
@@ -484,6 +484,7 @@ function Knowledges() {
<DataTable
columns={columns}
data={knowledges}
empty={{ entityName: 'knowledge documents' }}
filterPlaceholder="Filter knowledge documents..."
filterValue={filter}
onFilterChange={setFilter}
@@ -908,6 +908,7 @@ function SettingsAPITokens() {
<DataTable<APIToken>
columns={columns}
data={creatingToken ? [createNewTokenPlaceholder, ...tokens] : tokens}
empty={{ entityName: 'API tokens' }}
filterPlaceholder="Filter tokens..."
filterValue={filter}
onFilterChange={setFilter}
@@ -830,6 +830,7 @@ function SettingsPrompts() {
<DataTable<AgentPromptTableData>
columns={agentColumns}
data={agentPrompts}
empty={{ entityName: 'agent prompts' }}
filterPlaceholder="Filter agents..."
initialPageSize={1000}
renderRowContextMenu={renderAgentRowContextMenu}
@@ -850,6 +851,7 @@ function SettingsPrompts() {
<DataTable<ToolPromptTableData>
columns={toolColumns}
data={toolPrompts}
empty={{ entityName: 'tool prompts' }}
filterPlaceholder="Filter tools..."
initialPageSize={1000}
renderRowContextMenu={renderToolRowContextMenu}
@@ -418,6 +418,7 @@ function SettingsProviders() {
<DataTable<Provider>
columns={columns}
data={providers}
empty={{ entityName: 'providers' }}
filterPlaceholder="Filter providers..."
filterValue={filter}
onFilterChange={setFilter}
@@ -302,6 +302,7 @@ function Templates() {
<DataTable
columns={columns}
data={templates}
empty={{ entityName: 'templates' }}
filterPlaceholder="Filter templates..."
filterValue={filter}
onFilterChange={setFilter}