From 048db1c91c80342404b9822b68b65930d23cb755 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Jun 2026 10:44:11 +0100 Subject: [PATCH] Enforce shared template registry for platform tables --- docs/release-control/v6/internal/status.json | 10 + .../subsystems/frontend-primitives.md | 27 ++- .../v6/internal/subsystems/registry.json | 18 ++ .../scripts/shared-template-audit.mjs | 180 +++++++++++++----- .../scripts/shared-template-registry.json | 118 ++++++++++++ .../SharedPrimitives.guardrails.test.ts | 37 ++++ .../platformPage/sharedPlatformPage.tsx | 28 +++ 7 files changed, 369 insertions(+), 49 deletions(-) create mode 100644 frontend-modern/scripts/shared-template-registry.json diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 0d31822b85..d40801c26f 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -3772,6 +3772,11 @@ "kind": "file", "evidence_tier": "test-proof" }, + { + "repo": "pulse", + "path": "frontend-modern/scripts/shared-template-registry.json", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", @@ -3788,6 +3793,11 @@ "path": "frontend-modern/src/components/Workloads/GuestRow.tsx", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/features/standalone/agentMachineTableModel.ts", diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0f90c98a0f..8490f5d5c5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -153,11 +153,14 @@ work extends shared components instead of creating new local variants. 126. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` 127. `frontend-modern/scripts/canonical-platform-audit.mjs` 128. `frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs` -129. `frontend-modern/src/utils/platformSupportManifest.generated.ts` -130. `frontend-modern/src/utils/platformSupportManifest.ts` -131. `frontend-modern/src/utils/sourcePlatformOptions.ts` -132. `frontend-modern/src/utils/sourcePlatforms.ts` -133. `frontend-modern/src/utils/infrastructureOnboardingPresentation.ts` +129. `frontend-modern/scripts/shared-template-audit.mjs` +130. `frontend-modern/scripts/shared-template-registry.json` +131. `frontend-modern/src/features/platformPage/sharedPlatformPage.tsx` +132. `frontend-modern/src/utils/platformSupportManifest.generated.ts` +133. `frontend-modern/src/utils/platformSupportManifest.ts` +134. `frontend-modern/src/utils/sourcePlatformOptions.ts` +135. `frontend-modern/src/utils/sourcePlatforms.ts` +136. `frontend-modern/src/utils/infrastructureOnboardingPresentation.ts` ## Shared Boundaries @@ -2362,6 +2365,20 @@ resource-name link shell, new-tab safety attributes, row-click/key propagation containment, fallback text, and accessible launch labels. Workload guest rows and standalone machine rows compose that primitive so a saved URL is opened by clicking the resource name on every comparable runtime table. +Shared-template drift enforcement is registry-backed: +`frontend-modern/scripts/shared-template-registry.json` is the canonical list of +standardized repeated affordances, required consumers, and forbidden local +patterns, while `frontend-modern/scripts/shared-template-audit.mjs` enforces +that registry. Future repeated-affordance migrations must add or extend a +registry rule as part of the same change that extracts or adopts the shared +primitive. +Platform table frames are one of those registry-backed templates. +`frontend-modern/src/features/platformPage/sharedPlatformPage.tsx` owns +`PlatformTableShell`, including the canonical table card, header row, and body +divide styling. New platform tables must start from `PlatformTableShell`; the +legacy table-frame allowlist in `shared-template-registry.json` is migration +debt, not precedent, and must shrink as existing tables are moved onto the +shared shell. The shared help icon now follows that same owner split. `frontend-modern/src/components/shared/HelpIcon.tsx` stays the render shell, `frontend-modern/src/components/shared/useHelpIconState.ts` owns open state, diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 8f9d19d947..97a7096c29 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3059,6 +3059,8 @@ "frontend-modern/scripts/canonical-platform-audit.mjs", "frontend-modern/scripts/header-audit.mjs", "frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs", + "frontend-modern/scripts/shared-template-audit.mjs", + "frontend-modern/scripts/shared-template-registry.json", "frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx", "frontend-modern/src/components/Settings/AIModelSelectionSection.tsx", "frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx", @@ -3124,6 +3126,7 @@ "frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx", "frontend-modern/src/components/SetupWizard/SetupWizard.tsx", "frontend-modern/src/components/Toast/Toast.tsx", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx", "frontend-modern/src/hooks/createNonSuspendingQuery.ts", "frontend-modern/src/index.css", "frontend-modern/src/routing/routePreload.ts", @@ -3550,6 +3553,21 @@ "frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx" ] }, + { + "id": "shared-template-drift-guardrails", + "label": "shared template drift guardrail proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/scripts/shared-template-audit.mjs", + "frontend-modern/scripts/shared-template-registry.json", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts" + ] + }, { "id": "data-grid-and-summary", "label": "data grid and summary proof", diff --git a/frontend-modern/scripts/shared-template-audit.mjs b/frontend-modern/scripts/shared-template-audit.mjs index 933b167a84..2f430d6616 100644 --- a/frontend-modern/scripts/shared-template-audit.mjs +++ b/frontend-modern/scripts/shared-template-audit.mjs @@ -1,62 +1,92 @@ -import { readFileSync } from 'node:fs'; -import { relative, resolve } from 'node:path'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { extname, relative, resolve } from 'node:path'; const root = resolve(new URL('..', import.meta.url).pathname); +const registryPath = 'scripts/shared-template-registry.json'; const read = (path) => readFileSync(resolve(root, path), 'utf8'); +const readJson = (path) => JSON.parse(read(path)); +const fileExists = (path) => existsSync(resolve(root, path)); +const toRegistryPath = (path) => relative(root, path).split('\\').join('/'); -const rules = [ - { - id: 'runtime-web-interface-name-link', - summary: - 'Runtime resource rows must launch saved web-interface URLs through the shared name-link template, not a page-local link or separate web column.', - canonical: 'src/components/shared/WebInterfaceNameLink.tsx', - requiredConsumers: [ - 'src/components/Workloads/GuestRow.tsx', - 'src/features/standalone/AgentsMachinesTable.tsx', - ], - forbiddenPatterns: [ - { - path: 'src/components/Workloads/GuestRow.tsx', - patterns: ['target="_blank"', 'rel="noopener noreferrer"'], - }, - { - path: 'src/features/standalone/AgentsMachinesTable.tsx', - patterns: [ - 'target="_blank"', - 'rel="noopener noreferrer"', - 'AgentMachineWebLinkCell', - 'data-agent-machine-web-link', - ], - }, - { - path: 'src/features/standalone/agentMachineTableModel.ts', - patterns: ["id: 'web'", "label: 'Web'"], - }, - ], - }, -]; +const registry = readJson(registryPath); +const rules = Array.isArray(registry.rules) ? registry.rules : []; +const patternGuards = Array.isArray(registry.patternGuards) ? registry.patternGuards : []; const failures = []; -for (const rule of rules) { - const canonicalSource = read(rule.canonical); - const canonicalExport = rule.canonical +const getCanonicalExport = (canonical) => + canonical.export ?? + canonical.path .split('/') .at(-1) ?.replace(/\.[^.]+$/, ''); - if (canonicalExport && !canonicalSource.includes(canonicalExport)) { - failures.push(`${rule.id}: canonical template ${rule.canonical} does not export itself`); + +const verifyCanonicalExport = (id, canonical) => { + if (!canonical?.path) { + failures.push(`${id}: missing canonical.path`); + return undefined; } - for (const consumer of rule.requiredConsumers) { - const source = read(consumer); + if (!fileExists(canonical.path)) { + failures.push(`${id}: canonical template ${canonical.path} does not exist`); + return undefined; + } + + const canonicalSource = read(canonical.path); + const canonicalExport = getCanonicalExport(canonical); + if (canonicalExport && !canonicalSource.includes(canonicalExport)) { + failures.push(`${id}: canonical template ${canonical.path} must expose ${canonicalExport}`); + } + return canonicalExport; +}; + +const collectFiles = (scope, extensions) => { + const scopeRoot = resolve(root, scope); + if (!existsSync(scopeRoot)) { + failures.push(`pattern guard scope ${scope} does not exist`); + return []; + } + + const files = []; + const visit = (absolutePath) => { + const stat = statSync(absolutePath); + if (stat.isDirectory()) { + for (const entry of readdirSync(absolutePath)) { + visit(resolve(absolutePath, entry)); + } + return; + } + + if (extensions.includes(extname(absolutePath))) { + files.push(toRegistryPath(absolutePath)); + } + }; + + visit(scopeRoot); + return files; +}; + +const getAllowedPath = (entry) => (typeof entry === 'string' ? entry : entry?.path); + +for (const rule of rules) { + if (!rule.id) failures.push('registry rule is missing id'); + if (!rule.summary) failures.push(`${rule.id}: missing summary`); + if (!rule.category) failures.push(`${rule.id}: missing category`); + if (!rule.canonical?.path) failures.push(`${rule.id}: missing canonical.path`); + + if (!rule.canonical?.path) continue; + + const canonicalExport = verifyCanonicalExport(rule.id, rule.canonical); + + for (const consumer of rule.requiredConsumers ?? []) { + const source = read(consumer.path); if (canonicalExport && !source.includes(canonicalExport)) { - failures.push(`${rule.id}: ${consumer} must compose ${canonicalExport}`); + failures.push(`${rule.id}: ${consumer.path} must compose ${canonicalExport}`); } } - for (const check of rule.forbiddenPatterns) { + for (const check of rule.forbiddenPatterns ?? []) { const source = read(check.path); for (const pattern of check.patterns) { if (source.includes(pattern)) { @@ -66,6 +96,66 @@ for (const rule of rules) { } } +for (const guard of patternGuards) { + if (!guard.id) failures.push('pattern guard is missing id'); + if (!guard.summary) failures.push(`${guard.id}: missing summary`); + if (!guard.category) failures.push(`${guard.id}: missing category`); + if (!guard.canonical?.path) failures.push(`${guard.id}: missing canonical.path`); + + if (!guard.id || !guard.canonical?.path) continue; + + verifyCanonicalExport(guard.id, guard.canonical); + + const patterns = Array.isArray(guard.allPatterns) ? guard.allPatterns : []; + if (patterns.length === 0) { + failures.push(`${guard.id}: missing allPatterns`); + continue; + } + + const scopes = Array.isArray(guard.scopes) ? guard.scopes : []; + if (scopes.length === 0) { + failures.push(`${guard.id}: missing scopes`); + continue; + } + + const extensions = guard.extensions ?? ['.ts', '.tsx']; + const allowedEntries = guard.allowedPaths ?? []; + const allowedPaths = new Set(allowedEntries.map(getAllowedPath).filter(Boolean)); + const files = [...new Set(scopes.flatMap((scope) => collectFiles(scope, extensions)))].sort(); + + for (const file of files) { + if (file === guard.canonical.path) continue; + + const source = read(file); + const matches = patterns.every((pattern) => source.includes(pattern)); + if (!matches) continue; + + if (allowedPaths.has(file)) continue; + + failures.push( + `${guard.id}: ${file} recreates the shared template; compose ${guard.canonical.path} instead`, + ); + } + + for (const allowedPath of allowedPaths) { + if (!fileExists(allowedPath)) { + failures.push(`${guard.id}: allowed path ${allowedPath} does not exist`); + continue; + } + + if (allowedPath === guard.canonical.path) { + failures.push(`${guard.id}: canonical path ${allowedPath} must not be allowlisted`); + continue; + } + + const source = read(allowedPath); + const stillMatches = patterns.every((pattern) => source.includes(pattern)); + if (!stillMatches) { + failures.push(`${guard.id}: allowed path ${allowedPath} is stale; remove it from the guard`); + } + } +} + if (failures.length > 0) { console.error('Shared template audit failed:'); for (const failure of failures) { @@ -75,8 +165,10 @@ if (failures.length > 0) { } console.log( - `Shared template audit passed (${rules.length} rule${rules.length === 1 ? '' : 's'} from ${relative( + `Shared template audit passed (${rules.length} rule${rules.length === 1 ? '' : 's'}, ${ + patternGuards.length + } pattern guard${patternGuards.length === 1 ? '' : 's'} from ${relative( process.cwd(), - import.meta.url.replace('file://', ''), + resolve(root, registryPath), )})`, ); diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json new file mode 100644 index 0000000000..e47fe3cc61 --- /dev/null +++ b/frontend-modern/scripts/shared-template-registry.json @@ -0,0 +1,118 @@ +{ + "version": 1, + "rules": [ + { + "id": "runtime-web-interface-name-link", + "category": "resource-launch", + "summary": "Runtime resource rows must launch saved web-interface URLs through the shared name-link template, not a page-local link or separate web column.", + "canonical": { + "path": "src/components/shared/WebInterfaceNameLink.tsx", + "export": "WebInterfaceNameLink" + }, + "requiredConsumers": [ + { + "path": "src/components/Workloads/GuestRow.tsx" + }, + { + "path": "src/features/standalone/AgentsMachinesTable.tsx" + } + ], + "forbiddenPatterns": [ + { + "path": "src/components/Workloads/GuestRow.tsx", + "patterns": ["target=\"_blank\"", "rel=\"noopener noreferrer\""] + }, + { + "path": "src/features/standalone/AgentsMachinesTable.tsx", + "patterns": [ + "target=\"_blank\"", + "rel=\"noopener noreferrer\"", + "AgentMachineWebLinkCell", + "data-agent-machine-web-link" + ] + }, + { + "path": "src/features/standalone/agentMachineTableModel.ts", + "patterns": ["id: 'web'", "label: 'Web'"] + } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/features/standalone/__tests__/AgentsMachinesTable.test.tsx", + "src/components/Workloads/__tests__/GuestRow.test.tsx" + ] + } + ], + "patternGuards": [ + { + "id": "platform-table-shell-local-frame", + "category": "platform-table-shell", + "summary": "Platform tables must compose the shared PlatformTableShell frame instead of recreating the TableCard/TableHeader/TableBody shell locally.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "PlatformTableShell" + }, + "scopes": ["src/features", "src/components"], + "extensions": [".tsx"], + "allPatterns": [ + "TableCard class={PLATFORM_TABLE_CARD_CLASS}", + "TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}", + "TableBody class={PLATFORM_TABLE_BODY_CLASS}" + ], + "legacyReason": "Existing platform tables predate PlatformTableShell and are registered legacy migration debt. New tables must use the canonical shell.", + "allowedPaths": [ + "src/features/docker/DockerAlertsTable.tsx", + "src/features/docker/DockerConfigsTable.tsx", + "src/features/docker/DockerContainersTable.tsx", + "src/features/docker/DockerHostsTable.tsx", + "src/features/docker/DockerImagesTable.tsx", + "src/features/docker/DockerNetworksTable.tsx", + "src/features/docker/DockerSecretsTable.tsx", + "src/features/docker/DockerServicesTable.tsx", + "src/features/docker/DockerStorageUsageTable.tsx", + "src/features/docker/DockerSwarmNodesTable.tsx", + "src/features/docker/DockerTasksTable.tsx", + "src/features/docker/DockerVolumesTable.tsx", + "src/features/kubernetes/KubernetesAlertsTable.tsx", + "src/features/kubernetes/KubernetesAutoscalingTable.tsx", + "src/features/kubernetes/KubernetesClustersTable.tsx", + "src/features/kubernetes/KubernetesConfigTable.tsx", + "src/features/kubernetes/KubernetesControllersTable.tsx", + "src/features/kubernetes/KubernetesDeploymentsTable.tsx", + "src/features/kubernetes/KubernetesEventsTable.tsx", + "src/features/kubernetes/KubernetesNetworkingTable.tsx", + "src/features/kubernetes/KubernetesNodesTable.tsx", + "src/features/kubernetes/KubernetesPodsTable.tsx", + "src/features/kubernetes/KubernetesPolicyTable.tsx", + "src/features/kubernetes/KubernetesServicesTable.tsx", + "src/features/kubernetes/KubernetesStorageTable.tsx", + "src/features/proxmox/ProxmoxBackupServersTable.tsx", + "src/features/proxmox/ProxmoxCephTable.tsx", + "src/features/proxmox/ProxmoxCoverageTable.tsx", + "src/features/proxmox/ProxmoxMailGatewayTable.tsx", + "src/features/proxmox/ProxmoxNodesTable.tsx", + "src/features/proxmox/ProxmoxRecoverableTable.tsx", + "src/features/proxmox/ProxmoxReplicationTable.tsx", + "src/features/standalone/AgentsMachinesTable.tsx", + "src/features/standalone/AvailabilityChecksTable.tsx", + "src/features/truenas/TrueNASAlertsTable.tsx", + "src/features/truenas/TrueNASAppsTable.tsx", + "src/features/truenas/TrueNASNetworkSharesTable.tsx", + "src/features/truenas/TrueNASProtectionTable.tsx", + "src/features/truenas/TrueNASServicesTable.tsx", + "src/features/truenas/TrueNASStorageTopologyTable.tsx", + "src/features/truenas/TrueNASSystemsTable.tsx", + "src/features/truenas/TrueNASVirtualMachinesTable.tsx", + "src/features/vmware/VsphereActivityTable.tsx", + "src/features/vmware/VsphereAlertsTable.tsx", + "src/features/vmware/VsphereDatastoresTable.tsx", + "src/features/vmware/VsphereHostsTable.tsx", + "src/features/vmware/VsphereNetworksTable.tsx" + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "scripts/shared-template-audit.mjs" + ] + } + ] +} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 478c52f381..c3e4df0a59 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -90,6 +90,8 @@ import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw'; import webInterfaceUrlFieldStateSource from '@/components/shared/useWebInterfaceUrlFieldState.ts?raw'; import webInterfaceNameLinkSource from '@/components/shared/WebInterfaceNameLink.tsx?raw'; +import sharedTemplateRegistrySource from '../../../scripts/shared-template-registry.json?raw'; +import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.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'; @@ -913,6 +915,18 @@ describe('shared primitive guardrails', () => { }); it('keeps runtime web-interface launch on the shared resource-name template', () => { + const registry = JSON.parse(sharedTemplateRegistrySource) as { + rules: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + }>; + }; + const registeredRule = registry.rules.find( + (rule) => rule.id === 'runtime-web-interface-name-link', + ); + + expect(registeredRule?.canonical?.path).toBe('src/components/shared/WebInterfaceNameLink.tsx'); + expect(registeredRule?.canonical?.export).toBe('WebInterfaceNameLink'); expect(webInterfaceNameLinkSource).toContain('export const WebInterfaceNameLink'); expect(webInterfaceNameLinkSource).toContain('target="_blank"'); expect(webInterfaceNameLinkSource).toContain('rel="noopener noreferrer"'); @@ -929,6 +943,29 @@ describe('shared primitive guardrails', () => { expect(agentMachineTableModelSource).not.toContain("label: 'Web'"); }); + it('keeps platform table frames on the shared shell template', () => { + const registry = JSON.parse(sharedTemplateRegistrySource) as { + patternGuards: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + allowedPaths?: string[]; + }>; + }; + const registeredGuard = registry.patternGuards.find( + (guard) => guard.id === 'platform-table-shell-local-frame', + ); + + expect(registeredGuard?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(registeredGuard?.canonical?.export).toBe('PlatformTableShell'); + expect(registeredGuard?.allowedPaths?.length).toBeGreaterThan(0); + expect(sharedPlatformPageSource).toContain('export function PlatformTableShell'); + expect(sharedPlatformPageSource).toContain('TableCard class={props.cardClass'); + expect(sharedPlatformPageSource).toContain('TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}'); + expect(sharedPlatformPageSource).toContain('TableBody class={PLATFORM_TABLE_BODY_CLASS}'); + }); + it('keeps help icon on shell, runtime, and model owners', () => { expect(helpIconSource).toContain('useHelpIconState'); expect(helpIconSource).not.toContain('getHelpContent('); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index ba836f20ba..f7215c6b81 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -6,7 +6,9 @@ import { EmptyState } from '@/components/shared/EmptyState'; import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup'; import { FilterBar, type FilterDef } from '@/components/shared/FilterBar'; import { type SearchInputProps } from '@/components/shared/SearchInput'; +import { Table, TableBody, TableHeader, TableRow } from '@/components/shared/Table'; import { TableCard } from '@/components/shared/TableCard'; +import { TableCardHeader } from '@/components/shared/TableCardHeader'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; import type { Resource } from '@/types/resource'; @@ -89,6 +91,32 @@ export const PLATFORM_TABLE_CARD_CLASS = 'rounded-md'; export const PLATFORM_TABLE_HEADER_ROW_CLASS = 'bg-surface-alt text-muted border-b border-border'; export const PLATFORM_TABLE_BODY_CLASS = 'divide-y divide-border'; +export type PlatformTableShellProps = { + title?: JSX.Element; + actions?: JSX.Element; + tableClass?: string; + tableWrapperClass?: string; + cardClass?: string; + colgroup?: JSX.Element; + header: JSX.Element; + body: JSX.Element; +}; + +export function PlatformTableShell(props: PlatformTableShellProps) { + return ( + + + + {props.colgroup} + + {props.header} + + {props.body} +
+
+ ); +} + const getPlatformTableAlignClass = (align: PlatformTableCellAlign = 'left'): string => { if (align === 'right') return 'text-right'; if (align === 'center') return 'text-center';