Adopt the shared interface pattern language
This commit is contained in:
@@ -13,6 +13,9 @@
|
||||
},
|
||||
"./styles/portal.css": "./src/styles/portal.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
const page = fs.readFileSync("src/features/portal/PortalPage.tsx", "utf8");
|
||||
const styles = fs.readFileSync("src/styles/portal.css", "utf8");
|
||||
|
||||
assert.ok(page.includes("DocumentationHelpLink"), "Portal exposes configured-system help");
|
||||
assert.ok(page.includes("ActionBlockerHint"), "Unavailable launches expose the shared structured blocker");
|
||||
assert.ok(page.includes("disabledReason={entry.state !== \"available\" ? blocker.summary : undefined}"), "The launch action remains keyboard-explainable");
|
||||
assert.ok(page.includes("PageScrollViewport"), "Portal owns bounded directory scrolling");
|
||||
assert.ok(page.includes('aria-live="polite"'), "Changing result counts are announced");
|
||||
assert.ok(page.includes("useGuardedNavigate"), "Internal launch handoffs respect unsaved-work navigation");
|
||||
assert.ok(!page.includes("window.alert("), "Portal must not use browser alerts");
|
||||
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Portal uses semantic interactive elements");
|
||||
assert.ok(styles.includes("@media (max-width: 720px)"), "Portal retains a narrow-viewport toolbar layout");
|
||||
|
||||
console.log("Portal interface pattern contract passed.");
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
DismissibleAlert,
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
@@ -119,8 +121,12 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
||||
aria-label="Search services"
|
||||
placeholder="Search services"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">Search</button>
|
||||
<Button type="submit" variant="primary">Search</Button>
|
||||
</form>
|
||||
<DocumentationHelpLink
|
||||
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
|
||||
label="Open service directory documentation"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Show unavailable services"
|
||||
checked={includeUnavailable}
|
||||
@@ -171,6 +177,7 @@ function ServiceEntry({
|
||||
onLaunch: () => void;
|
||||
}) {
|
||||
const reasons = userFacingReasons(entry.reason_codes);
|
||||
const blocker = serviceBlocker(entry.reason_codes, reasons);
|
||||
return (
|
||||
<article className={`portal-service-entry is-${entry.state}`}>
|
||||
<div className="portal-service-heading">
|
||||
@@ -196,20 +203,60 @@ function ServiceEntry({
|
||||
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
}
|
||||
{entry.entry_binding && entry.state !== "available" &&
|
||||
<ActionBlockerHint reason={blocker} tone="warning" />
|
||||
}
|
||||
<div className="portal-service-actions">
|
||||
{entry.entry_binding && entry.state === "available" ?
|
||||
<Button variant="primary" disabled={launching} onClick={onLaunch}>
|
||||
{entry.entry_binding ?
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={launching || entry.state !== "available"}
|
||||
disabledReason={entry.state !== "available" ? blocker.summary : undefined}
|
||||
onClick={onLaunch}>
|
||||
{launching ? "Starting" : "Open"}
|
||||
<ArrowUpRight size={15} aria-hidden="true" />
|
||||
</Button> :
|
||||
entry.entry_binding &&
|
||||
<span className="portal-entry-kind">{humanize(entry.entry_binding.kind)}</span>
|
||||
<span className="portal-entry-kind">No launch destination</span>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function serviceBlocker(codes: string[], reasons: string[]) {
|
||||
const reasonCode = codes.find((code) => !code.startsWith("service.explanation:")) ?? "";
|
||||
if (reasonCode === "service.publication.suspended") {
|
||||
return {
|
||||
summary: reasons[0] ?? "This service is temporarily suspended.",
|
||||
requiredAction: "Resume the published service revision.",
|
||||
actor: "Service owner",
|
||||
target: "Service administration"
|
||||
};
|
||||
}
|
||||
if (reasonCode.includes("required_module.missing") || reasonCode.includes("required_capability.missing")) {
|
||||
return {
|
||||
summary: reasons[0] ?? "A required system component is unavailable.",
|
||||
requiredAction: "Install, enable, configure, or restore the required component.",
|
||||
actor: "System or module administrator",
|
||||
target: "Module administration"
|
||||
};
|
||||
}
|
||||
if (reasonCode.includes("evaluator_failed") || reasonCode.includes("requirement.unknown")) {
|
||||
return {
|
||||
summary: reasons[0] ?? "Availability could not be confirmed.",
|
||||
requiredAction: "Restore the availability evaluator and check the service again.",
|
||||
actor: "System operator",
|
||||
target: "Operations and service diagnostics"
|
||||
};
|
||||
}
|
||||
return {
|
||||
summary: reasons[0] ?? "This service is currently unavailable.",
|
||||
requiredAction: "Review and fulfil the service availability requirements.",
|
||||
actor: "Service owner or responsible authority",
|
||||
target: "Service details"
|
||||
};
|
||||
}
|
||||
|
||||
function userFacingReasons(codes: string[]): string[] {
|
||||
const values = codes.
|
||||
filter((code) => !code.startsWith("service.explanation:")).
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.portal-service-entry .action-blocker-hint {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.portal-service-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user