diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3b391d9282..a790716874 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2393,6 +2393,11 @@ 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. +Platform table loading states are registry-backed too. +`PlatformTableLoadingState` owns the repeated table-card compact +`role="status"` loading row for platform pages and tables; platform consumers +own only the title and description copy. Platform feature surfaces must not +recreate the `TableCard` plus compact status-row shell locally. Filter bars are registry-backed too. `FilterBar` owns resource-list filtering as a catalog of `FilterDef` entries, while `filterChipStatusDot` owns the small leading status-dot glyph used by filter options. Page and feature diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 54c54ec44d..2a6e496e89 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -114,6 +114,29 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "platform-table-loading-state", + "category": "platform-table-loading-state", + "summary": "Platform page and table loading states must compose PlatformTableLoadingState instead of recreating local TableCard status rows.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "PlatformTableLoadingState" + }, + "requiredConsumers": [ + { "path": "src/features/docker/DockerPageSurface.tsx" }, + { "path": "src/features/kubernetes/KubernetesPageSurface.tsx" }, + { "path": "src/features/proxmox/ProxmoxPageSurface.tsx" }, + { "path": "src/features/standalone/StandalonePageSurface.tsx" }, + { "path": "src/features/truenas/TrueNASPageSurface.tsx" }, + { "path": "src/features/truenas/TrueNASProtectionTable.tsx" }, + { "path": "src/features/vmware/VmwarePageSurface.tsx" } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "filter-bar-catalog-shell", "category": "filter-bar", @@ -217,6 +240,31 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "platform-table-loading-state-local-status-row", + "category": "platform-table-loading-state", + "summary": "Platform feature loading states must compose PlatformTableLoadingState instead of recreating the compact TableCard status row locally.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "PlatformTableLoadingState" + }, + "scopes": [ + "src/features/docker", + "src/features/kubernetes", + "src/features/proxmox", + "src/features/standalone", + "src/features/truenas", + "src/features/vmware" + ], + "extensions": [".tsx"], + "allPatterns": ["role=\"status\"", "px-3 py-2 text-xs text-muted"], + "legacyReason": "Retired migration debt. Platform table loading states must use PlatformTableLoadingState; no compact local status-row exceptions are currently allowed.", + "allowedPaths": [], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "filter-bar-consumer-legacy-page-controls", "category": "filter-bar", diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 000b01c533..2bbb1be5c3 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -92,8 +92,15 @@ import webInterfaceUrlFieldStateSource from '@/components/shared/useWebInterface import webInterfaceNameLinkSource from '@/components/shared/WebInterfaceNameLink.tsx?raw'; import inlineDetailTableRowSource from '@/components/shared/InlineDetailTableRow.tsx?raw'; import sharedTemplateRegistrySource from '../../../scripts/shared-template-registry.json?raw'; +import dockerPageSurfaceSource from '@/features/docker/DockerPageSurface.tsx?raw'; +import kubernetesPageSurfaceSource from '@/features/kubernetes/KubernetesPageSurface.tsx?raw'; +import proxmoxPageSurfaceSource from '@/features/proxmox/ProxmoxPageSurface.tsx?raw'; +import standalonePageSurfaceSource from '@/features/standalone/StandalonePageSurface.tsx?raw'; import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.tsx?raw'; import platformResourceDetailTableRowSource from '@/features/platformPage/PlatformResourceDetailTableRow.tsx?raw'; +import truenasPageSurfaceSource from '@/features/truenas/TrueNASPageSurface.tsx?raw'; +import truenasProtectionTableSource from '@/features/truenas/TrueNASProtectionTable.tsx?raw'; +import vmwarePageSurfaceSource from '@/features/vmware/VmwarePageSurface.tsx?raw'; import upgradeNavigationSource from '@/utils/upgradeNavigation.ts?raw'; import guestRowSource from '@/components/Workloads/GuestRow.tsx?raw'; import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw'; @@ -1065,6 +1072,82 @@ describe('shared primitive guardrails', () => { } }); + it('keeps platform table loading states on the shared status-row template', () => { + const registry = JSON.parse(sharedTemplateRegistrySource) as { + rules?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + requiredConsumers?: Array<{ path?: string }>; + }>; + patternGuards?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + allPatterns?: string[]; + scopes?: string[]; + allowedPaths?: string[]; + }>; + }; + const registeredRule = registry.rules?.find( + (rule) => rule.id === 'platform-table-loading-state', + ); + const registeredGuard = registry.patternGuards?.find( + (guard) => guard.id === 'platform-table-loading-state-local-status-row', + ); + const requiredConsumerPaths = [ + 'src/features/docker/DockerPageSurface.tsx', + 'src/features/kubernetes/KubernetesPageSurface.tsx', + 'src/features/proxmox/ProxmoxPageSurface.tsx', + 'src/features/standalone/StandalonePageSurface.tsx', + 'src/features/truenas/TrueNASPageSurface.tsx', + 'src/features/truenas/TrueNASProtectionTable.tsx', + 'src/features/vmware/VmwarePageSurface.tsx', + ]; + + expect(registeredRule?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(registeredRule?.canonical?.export).toBe('PlatformTableLoadingState'); + expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual( + expect.arrayContaining(requiredConsumerPaths), + ); + expect(registeredGuard?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(registeredGuard?.canonical?.export).toBe('PlatformTableLoadingState'); + expect(registeredGuard?.allPatterns).toEqual([ + 'role="status"', + 'px-3 py-2 text-xs text-muted', + ]); + 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 PlatformTableLoadingState'); + expect(sharedPlatformPageSource).toContain('role="status"'); + expect(sharedPlatformPageSource).toContain('px-3 py-2 text-xs text-muted'); + + for (const source of [ + dockerPageSurfaceSource, + kubernetesPageSurfaceSource, + proxmoxPageSurfaceSource, + standalonePageSurfaceSource, + truenasPageSurfaceSource, + truenasProtectionTableSource, + vmwarePageSurfaceSource, + ]) { + expect(source).toContain('PlatformTableLoadingState'); + expect(source).not.toContain('