Adopt the shared interface pattern language

This commit is contained in:
2026-08-04 08:21:50 +02:00
parent 49cbe258e0
commit 075b6df175
7 changed files with 112 additions and 5 deletions
+52 -5
View File
@@ -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:")).
+4
View File
@@ -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;