mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-07-21 08:15:23 -04:00
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:
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user