mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Enforce shared template registry for platform tables
This commit is contained in:
parent
93420753aa
commit
048db1c91c
7 changed files with 369 additions and 49 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)})`,
|
||||
);
|
||||
|
|
|
|||
118
frontend-modern/scripts/shared-template-registry.json
Normal file
118
frontend-modern/scripts/shared-template-registry.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(');
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<TableCard class={props.cardClass ?? PLATFORM_TABLE_CARD_CLASS}>
|
||||
<TableCardHeader title={props.title} actions={props.actions} />
|
||||
<Table class={props.tableClass} wrapperClass={props.tableWrapperClass}>
|
||||
{props.colgroup}
|
||||
<TableHeader>
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>{props.header}</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>{props.body}</TableBody>
|
||||
</Table>
|
||||
</TableCard>
|
||||
);
|
||||
}
|
||||
|
||||
const getPlatformTableAlignClass = (align: PlatformTableCellAlign = 'left'): string => {
|
||||
if (align === 'right') return 'text-right';
|
||||
if (align === 'center') return 'text-center';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue