Canonicalize platform table empty states

This commit is contained in:
rcourtman
2026-06-12 13:08:40 +01:00
parent 97bb7035d9
commit 1e634709a2
13 changed files with 206 additions and 126 deletions
@@ -2386,6 +2386,12 @@ divide frame locally. Platform table consumers must preserve the owner split:
frontend-primitives owns the repeated `PlatformTableShell` frame and guardrail
registry, while platform and unified-resource consumers own the source-specific
row fields, drawers, and resource semantics.
Platform table empty states follow the same registry-backed ownership.
`PlatformTableEmptyState` owns the repeated table-card empty-state shell for
Docker, Kubernetes, Proxmox, Standalone, TrueNAS, vSphere, and future platform
feature tables; source-specific consumers own only the empty-state icon, title,
description, and actions. Platform feature tables must not import
`EmptyState` directly or recreate a `Card`-wrapped empty-state shell.
Inline detail table rows are also registry-backed. `InlineDetailTableRow`
owns the row/cell/content shell and row-click containment for platform,
workload, and infrastructure inline drawers; callers may pass row-specific
@@ -124,6 +124,11 @@ explicit uncovered and failed-latest-task states, rather than from source-detail
row counts alone. PBS server/datastore rows may display backup counts, but the
counts must come from the PBS backup API artifact identity, not from a
datastore-capacity approximation.
Those Proxmox backup workflows own recovery semantics, source-specific
empty-state copy, and retry/filter actions, but their table empty-state frame
must compose the frontend-primitives-owned `PlatformTableEmptyState` shell
instead of importing `EmptyState` directly or wrapping it in a backup-local
card.
The Proxmox overview workload table's Backup column is the primary
glance-level protection signal for current guests. It must remain visible,
dense, and backed by the canonical guest `LastBackup` synchronization from PVE
@@ -215,6 +215,11 @@ filter semantics, drawer handoffs, and resource projections, while
Future platform tables must keep that split: row data and platform semantics
stay in the unified-resource consumer, and the repeated table shell stays in the
shared frontend primitive.
Platform table empty states use that same split. Unified-resource consumers own
the source-specific empty-state vocabulary, action choice, and evidence rule
that decides why a table is empty, but the table-card empty-state shell itself
must compose the frontend-primitives-owned `PlatformTableEmptyState` primitive
instead of importing `EmptyState` directly or wrapping it in a page-local card.
The same split applies to inline detail row shells on platform, native-resource,
workload, and infrastructure tables. Unified-resource consumers own row
identity, source-specific drawer content, expansion state, and resource
@@ -137,6 +137,31 @@
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-table-empty-state-local-empty-state",
"category": "platform-table-empty-state",
"summary": "Platform feature tables must compose PlatformTableEmptyState for their table/card empty-state shell instead of importing EmptyState directly.",
"canonical": {
"path": "src/features/platformPage/sharedPlatformPage.tsx",
"export": "PlatformTableEmptyState"
},
"scopes": [
"src/features/docker",
"src/features/kubernetes",
"src/features/proxmox",
"src/features/standalone",
"src/features/truenas",
"src/features/vmware"
],
"extensions": [".tsx"],
"allPatterns": ["@/components/shared/EmptyState"],
"legacyReason": "Retired migration debt. Platform table empty states must use PlatformTableEmptyState; no platform feature exceptions are currently allowed.",
"allowedPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
}
],
"requiredPatternGuards": [
@@ -102,9 +102,14 @@ import guestRowStateSource from '@/components/Workloads/useGuestRowState.ts?raw'
import workloadSelectionStateSource from '@/components/Workloads/useWorkloadSelectionState.ts?raw';
import dockerHostsTableSource from '@/features/docker/DockerHostsTable.tsx?raw';
import kubernetesNodesTableSource from '@/features/kubernetes/KubernetesNodesTable.tsx?raw';
import proxmoxBackupsTableSource from '@/features/proxmox/ProxmoxBackupsTable.tsx?raw';
import proxmoxCephTableSource from '@/features/proxmox/ProxmoxCephTable.tsx?raw';
import proxmoxCoverageTableSource from '@/features/proxmox/ProxmoxCoverageTable.tsx?raw';
import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
import proxmoxHostTableModelSource from '@/features/proxmox/proxmoxHostTableModel.ts?raw';
import proxmoxRecoverableTableSource from '@/features/proxmox/ProxmoxRecoverableTable.tsx?raw';
import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplicationTable.tsx?raw';
import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw';
import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw';
import agentMachineTableModelSource from '@/features/standalone/agentMachineTableModel.ts?raw';
@@ -1005,6 +1010,59 @@ describe('shared primitive guardrails', () => {
}
});
it('keeps platform table empty states on the shared shell template', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
patternGuards: Array<{
id: string;
canonical?: { path?: string; export?: string };
allPatterns?: string[];
scopes?: string[];
allowedPaths?: string[];
}>;
};
const registeredGuard = registry.patternGuards.find(
(guard) => guard.id === 'platform-table-empty-state-local-empty-state',
);
expect(registeredGuard?.canonical?.path).toBe(
'src/features/platformPage/sharedPlatformPage.tsx',
);
expect(registeredGuard?.canonical?.export).toBe('PlatformTableEmptyState');
expect(registeredGuard?.allPatterns).toEqual(['@/components/shared/EmptyState']);
expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0);
expect(registeredGuard?.scopes).toEqual(
expect.arrayContaining([
'src/features/docker',
'src/features/kubernetes',
'src/features/proxmox',
'src/features/standalone',
'src/features/truenas',
'src/features/vmware',
]),
);
expect(sharedPlatformPageSource).toContain('export function PlatformTableEmptyState');
expect(sharedPlatformPageSource).toContain('icon?: JSX.Element');
expect(sharedPlatformPageSource).toContain("from '@/components/shared/EmptyState'");
for (const source of [
dockerHostsTableSource,
kubernetesNodesTableSource,
proxmoxBackupsTableSource,
proxmoxCephTableSource,
proxmoxCoverageTableSource,
proxmoxMailGatewayTableSource,
proxmoxRecoverableTableSource,
proxmoxReplicationTableSource,
proxmoxNodesTableSource,
vsphereHostsTableSource,
]) {
expect(source).toContain('PlatformTableEmptyState');
expect(source).not.toContain("@/components/shared/EmptyState");
expect(source).not.toContain('<EmptyState');
expect(source).not.toContain('<Card padding="lg">');
}
});
it('keeps platform row-detail disclosure controls on the shared toggle primitive', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
requiredPatternGuards?: Array<{
@@ -55,7 +55,7 @@ export function PlatformSectionTabs<TabId extends string>(props: {
}
export function PlatformTableEmptyState(props: {
icon: JSX.Element;
icon?: JSX.Element;
title: string;
description: string;
actions?: JSX.Element;
@@ -2,12 +2,11 @@ import { Show, createMemo, createResource, createSignal, type Component, type JS
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import CalendarIcon from 'lucide-solid/icons/calendar';
import ShieldCheckIcon from 'lucide-solid/icons/shield-check';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { useSearchParams } from '@solidjs/router';
import { FilterBar, type FilterDef, type FilterSelectOption } from '@/components/shared/FilterBar';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { apiFetch } from '@/utils/apiClient';
import { PlatformTableEmptyState } from '@/features/platformPage/sharedPlatformPage';
import {
getRecoveryFilterDateLabel,
recoveryDateKeyFromTimestamp,
@@ -438,34 +437,30 @@ export const ProxmoxBackupsTable: Component<{
<Show
when={!backups.error}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="Could not load Proxmox backup inventory"
description={(backups.error as Error | undefined)?.message ?? 'Refresh to retry.'}
actions={
<button
type="button"
onClick={() => void refetch()}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title="Could not load Proxmox backup inventory"
description={(backups.error as Error | undefined)?.message ?? 'Refresh to retry.'}
actions={
<button
type="button"
onClick={() => void refetch()}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
/>
}
>
<Show
when={backups() !== undefined}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="Loading Proxmox backup inventory"
description="Reading PBS snapshots, PVE backup files, guest snapshots, and recent backup tasks."
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title="Loading Proxmox backup inventory"
description="Reading PBS snapshots, PVE backup files, guest snapshots, and recent backup tasks."
/>
}
>
<div class="space-y-3">
@@ -7,8 +7,6 @@ import {
type Component,
type JSX,
} from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import type { StatusIndicatorVariant } from '@/utils/status';
@@ -20,6 +18,7 @@ import {
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
PlatformTableEmptyState,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
@@ -207,13 +206,11 @@ export const ProxmoxCephTable: Component<{
<Show
when={total() > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
@@ -232,13 +229,11 @@ export const ProxmoxCephTable: Component<{
<Show
when={filtered().length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="No clusters match current filters"
description="Adjust the search or status filter to see more clusters."
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No clusters match current filters"
description="Adjust the search or status filter to see more clusters."
/>
}
>
<PlatformTableShell
@@ -1,7 +1,5 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableRow } from '@/components/shared/Table';
@@ -9,6 +7,7 @@ import { formatBytes } from '@/utils/format';
import {
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
PlatformTableEmptyState,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
@@ -101,21 +100,17 @@ export function ProxmoxCoverageTable(props: {
<Show
when={props.rows.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={
!props.hasAnyRows
? props.emptyTitle
: 'No workload coverage rows match current filters'
}
description={
!props.hasAnyRows
? props.emptyDescription
: 'Adjust the search, posture filter, or selected day to see more workloads.'
}
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title={
!props.hasAnyRows ? props.emptyTitle : 'No workload coverage rows match current filters'
}
description={
!props.hasAnyRows
? props.emptyDescription
: 'Adjust the search, posture filter, or selected day to see more workloads.'
}
/>
}
>
<PlatformTableShell
@@ -1,6 +1,4 @@
import { For, Show, createSignal, type Component, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
@@ -14,6 +12,7 @@ import {
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
PlatformTableEmptyState,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
@@ -58,9 +57,7 @@ export const ProxmoxMailGatewayTable: Component<{
<Show
when={props.resources.length > 0}
fallback={
<Card padding="lg">
<EmptyState title={props.emptyTitle} description={props.emptyDescription} />
</Card>
<PlatformTableEmptyState title={props.emptyTitle} description={props.emptyDescription} />
}
>
<div class="space-y-3">
@@ -79,12 +76,10 @@ export const ProxmoxMailGatewayTable: Component<{
<Show
when={tableState.filtered().length > 0}
fallback={
<Card padding="lg">
<EmptyState
title="No instances match current filters"
description="Adjust the search or status filter to see more instances."
/>
</Card>
<PlatformTableEmptyState
title="No instances match current filters"
description="Adjust the search or status filter to see more instances."
/>
}
>
<PlatformTableShell
@@ -1,7 +1,5 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { formatBytes } from '@/utils/format';
import {
@@ -11,6 +9,7 @@ import {
import {
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
PlatformTableEmptyState,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -136,21 +135,19 @@ export function ProxmoxRecoverableTable(props: {
<Show
when={props.artifacts.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={
!props.hasAnyArtifacts
? props.emptyTitle
: 'No recoverable artifacts match current filters'
}
description={
!props.hasAnyArtifacts
? props.emptyDescription
: 'Adjust the search, source filter, or selected day to see more artifacts.'
}
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title={
!props.hasAnyArtifacts
? props.emptyTitle
: 'No recoverable artifacts match current filters'
}
description={
!props.hasAnyArtifacts
? props.emptyDescription
: 'Adjust the search, source filter, or selected day to see more artifacts.'
}
/>
}
>
<PlatformTableShell
@@ -1,7 +1,5 @@
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import type { StatusIndicatorVariant } from '@/utils/status';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
@@ -12,6 +10,7 @@ import {
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
PlatformTableEmptyState,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import type { ReplicationJob, ReplicationJobsResponse } from '@/types/api';
@@ -184,46 +183,40 @@ export const ProxmoxReplicationTable: Component<{
<Show
when={!props.error}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="Could not load replication jobs"
description={(props.error as Error | undefined)?.message ?? 'Refresh to retry.'}
actions={
<button
type="button"
onClick={() => props.onRetry()}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title="Could not load replication jobs"
description={(props.error as Error | undefined)?.message ?? 'Refresh to retry.'}
actions={
<button
type="button"
onClick={() => props.onRetry()}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
/>
}
>
<Show
when={props.jobs !== undefined}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="Loading replication jobs"
description="Reading scheduled replication state from PVE."
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title="Loading replication jobs"
description="Reading scheduled replication state from PVE."
/>
}
>
<Show
when={total() > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
@@ -242,13 +235,11 @@ export const ProxmoxReplicationTable: Component<{
<Show
when={filtered().length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="No replication jobs match current filters"
description="Adjust the search or status filter to see more jobs."
/>
</Card>
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No replication jobs match current filters"
description="Adjust the search or status filter to see more jobs."
/>
}
>
<PlatformTableShell
@@ -8,6 +8,7 @@ import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
} from '@/features/platformPage/sharedPlatformPage';
import { TABLE_CARD_FRAME_CLASS } from '@/components/shared/TableCard';
import type { Resource } from '@/types/resource';
import { getRecoveryFullDateLabel } from '@/utils/recoveryDatePresentation';
@@ -264,5 +265,17 @@ describe('ProxmoxBackupsTable', () => {
await fireEvent.input(searchInput, { target: { value: 'no-such-guest' } });
expect(screen.queryByText('pbs-docker')).not.toBeInTheDocument();
const emptyStateHeading = screen.getByRole('heading', {
name: /no recoverable artifacts match current filters/i,
});
expect(emptyStateHeading).toBeInTheDocument();
expect(
screen.getByText('Adjust the search, source filter, or selected day to see more artifacts.'),
).toBeInTheDocument();
expectClassTokens(
emptyStateHeading.closest(`.${TABLE_CARD_FRAME_CLASS}`),
TABLE_CARD_FRAME_CLASS,
);
expect(emptyStateHeading.closest('.border-dashed')).not.toBeNull();
});
});