Adopt the shared interface pattern language
This commit is contained in:
@@ -4,6 +4,10 @@
|
|||||||
**Repository type:** module (domain).
|
**Repository type:** module (domain).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
The service-directory route, state, blocker, and accessibility mapping is
|
||||||
|
recorded in
|
||||||
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
Portal owns service discovery, presentation, and channel entry. Its
|
Portal owns service discovery, presentation, and channel entry. Its
|
||||||
`portal.service_directory` capability projects provider-owned, versioned
|
`portal.service_directory` capability projects provider-owned, versioned
|
||||||
service definitions into available, explainably unavailable, or undiscoverable
|
service definitions into available, explainably unavailable, or undiscoverable
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Portal Interface Pattern Migration
|
||||||
|
|
||||||
|
Portal is a public-service-style directory for authenticated institutional
|
||||||
|
users. Services owns definitions, Policy/provider capabilities own availability,
|
||||||
|
and Cases, Forms Runtime, or Workflow Engine owns the launch effect. Portal
|
||||||
|
does not import those modules or infer their state.
|
||||||
|
|
||||||
|
| Surface | Task and archetype | Consequence and state contract |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/portal` toolbar | Find and filter services | Search and the unavailable-service toggle affect only the directory result. Counts are announced as the result changes. |
|
||||||
|
| Service directory | Public service/entry catalogue | Loading, empty, failed, available, unavailable, and capability-unknown states remain distinct. Only discoverable service metadata is rendered. |
|
||||||
|
| Availability blocker | Explained disabled action | Open remains in its stable action position. The blocker states why launch is unavailable, what must happen, the responsible actor, and the destination. |
|
||||||
|
| Open service | Consequential handoff | Portal re-evaluates the exact service revision with a replay-safe request, then hands off to the provider-owned route. It never reports success without a destination. |
|
||||||
|
|
||||||
|
The directory uses Core buttons, toggle, status, loading, alerts, scrolling,
|
||||||
|
blocker explanation, guarded navigation, and documentation help. Availability
|
||||||
|
is expressed in text as well as color. The responsive toolbar stacks before the
|
||||||
|
service list and every action remains keyboard-operable. Optional owner modules
|
||||||
|
remain capability-based and an unavailable provider does not become a broken
|
||||||
|
link.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `npm run test:interface-pattern`
|
||||||
|
- Portal service-directory and manifest tests
|
||||||
|
- the Core TypeScript graph, structural localization audit, theme check, module
|
||||||
|
permutations, and full-product bundle budget
|
||||||
@@ -182,6 +182,11 @@ manifest = ModuleManifest(
|
|||||||
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
|
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Portal interface pattern audit",
|
||||||
|
href="govoplan-portal/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
},
|
},
|
||||||
"./styles/portal.css": "./src/styles/portal.css"
|
"./styles/portal.css": "./src/styles/portal.css"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
"lucide-react": "^1.23.0",
|
"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
|
type FormEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
Button,
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -119,8 +121,12 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
aria-label="Search services"
|
aria-label="Search services"
|
||||||
placeholder="Search services"
|
placeholder="Search services"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">Search</button>
|
<Button type="submit" variant="primary">Search</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
|
||||||
|
label="Open service directory documentation"
|
||||||
|
/>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Show unavailable services"
|
label="Show unavailable services"
|
||||||
checked={includeUnavailable}
|
checked={includeUnavailable}
|
||||||
@@ -171,6 +177,7 @@ function ServiceEntry({
|
|||||||
onLaunch: () => void;
|
onLaunch: () => void;
|
||||||
}) {
|
}) {
|
||||||
const reasons = userFacingReasons(entry.reason_codes);
|
const reasons = userFacingReasons(entry.reason_codes);
|
||||||
|
const blocker = serviceBlocker(entry.reason_codes, reasons);
|
||||||
return (
|
return (
|
||||||
<article className={`portal-service-entry is-${entry.state}`}>
|
<article className={`portal-service-entry is-${entry.state}`}>
|
||||||
<div className="portal-service-heading">
|
<div className="portal-service-heading">
|
||||||
@@ -196,20 +203,60 @@ function ServiceEntry({
|
|||||||
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
{entry.entry_binding && entry.state !== "available" &&
|
||||||
|
<ActionBlockerHint reason={blocker} tone="warning" />
|
||||||
|
}
|
||||||
<div className="portal-service-actions">
|
<div className="portal-service-actions">
|
||||||
{entry.entry_binding && entry.state === "available" ?
|
{entry.entry_binding ?
|
||||||
<Button variant="primary" disabled={launching} onClick={onLaunch}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={launching || entry.state !== "available"}
|
||||||
|
disabledReason={entry.state !== "available" ? blocker.summary : undefined}
|
||||||
|
onClick={onLaunch}>
|
||||||
{launching ? "Starting" : "Open"}
|
{launching ? "Starting" : "Open"}
|
||||||
<ArrowUpRight size={15} aria-hidden="true" />
|
<ArrowUpRight size={15} aria-hidden="true" />
|
||||||
</Button> :
|
</Button> :
|
||||||
entry.entry_binding &&
|
<span className="portal-entry-kind">No launch destination</span>
|
||||||
<span className="portal-entry-kind">{humanize(entry.entry_binding.kind)}</span>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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[] {
|
function userFacingReasons(codes: string[]): string[] {
|
||||||
const values = codes.
|
const values = codes.
|
||||||
filter((code) => !code.startsWith("service.explanation:")).
|
filter((code) => !code.startsWith("service.explanation:")).
|
||||||
|
|||||||
@@ -113,6 +113,10 @@
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-service-entry .action-blocker-hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-service-actions {
|
.portal-service-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user