feat: implement governed service directory
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/portal-webui",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/portal.css": "./src/styles/portal.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type PortalServiceBinding = {
|
||||
kind: string;
|
||||
reference: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type PortalServiceDefinition = {
|
||||
reference: {
|
||||
object_id: string;
|
||||
version?: string | null;
|
||||
};
|
||||
key: string;
|
||||
title: string;
|
||||
audience: string[];
|
||||
prerequisites: string[];
|
||||
required_evidence_types: string[];
|
||||
fee_refs: string[];
|
||||
deadline_refs: string[];
|
||||
channels: string[];
|
||||
publication_state: string;
|
||||
};
|
||||
|
||||
export type PortalServiceEntry = {
|
||||
definition: PortalServiceDefinition;
|
||||
state: "available" | "unavailable";
|
||||
reason_codes: string[];
|
||||
entry_binding?: PortalServiceBinding | null;
|
||||
availability_evidence: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type PortalServiceListResponse = {
|
||||
services: PortalServiceEntry[];
|
||||
};
|
||||
|
||||
export type PortalServiceLaunchResult = {
|
||||
service_ref: PortalServiceDefinition["reference"];
|
||||
binding: PortalServiceBinding;
|
||||
state: "started" | "redirect";
|
||||
target_ref?: Record<string, unknown> | null;
|
||||
href?: string | null;
|
||||
replayed: boolean;
|
||||
evidence: Array<Record<string, unknown>>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function listPortalServices(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
query?: string;
|
||||
includeUnavailable?: boolean;
|
||||
limit?: number;
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<PortalServiceListResponse> {
|
||||
return apiFetch<PortalServiceListResponse>(
|
||||
settings,
|
||||
apiPath("/api/v1/portal/services", {
|
||||
q: options.query,
|
||||
include_unavailable: options.includeUnavailable,
|
||||
limit: options.limit
|
||||
}),
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function launchPortalService(
|
||||
settings: ApiSettings,
|
||||
serviceId: string,
|
||||
payload: {
|
||||
service_version: string;
|
||||
idempotency_key: string;
|
||||
requested_at: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<PortalServiceLaunchResult> {
|
||||
return apiFetch<PortalServiceLaunchResult>(
|
||||
settings,
|
||||
`/api/v1/portal/services/${encodeURIComponent(serviceId)}/launch`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ArrowUpRight, Search } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import {
|
||||
DismissibleAlert,
|
||||
Button,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
useGuardedNavigate,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
launchPortalService,
|
||||
listPortalServices,
|
||||
type PortalServiceEntry
|
||||
} from "../../api/portal";
|
||||
|
||||
|
||||
export default function PortalPage({ settings }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||
const [includeUnavailable, setIncludeUnavailable] = useState(true);
|
||||
const [services, setServices] = useState<PortalServiceEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [launchingId, setLaunchingId] = useState("");
|
||||
const launchAttempts = useRef(new Map<string, {
|
||||
idempotencyKey: string;
|
||||
requestedAt: string;
|
||||
}>());
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
listPortalServices(
|
||||
settings,
|
||||
{
|
||||
query: submittedQuery,
|
||||
includeUnavailable,
|
||||
limit: 200
|
||||
},
|
||||
controller.signal
|
||||
).
|
||||
then((response) => setServices(response.services)).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Services could not be loaded.");
|
||||
}
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [includeUnavailable, settings, submittedQuery]);
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
available: services.filter((entry) => entry.state === "available").length,
|
||||
unavailable: services.filter((entry) => entry.state === "unavailable").length
|
||||
}), [services]);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmittedQuery(query.trim());
|
||||
}
|
||||
|
||||
async function launch(entry: PortalServiceEntry) {
|
||||
const serviceId = entry.definition.reference.object_id;
|
||||
const version = entry.definition.reference.version;
|
||||
if (!version) {
|
||||
setError("This service has no exact launchable revision.");
|
||||
return;
|
||||
}
|
||||
const attemptKey = `${serviceId}:${version}`;
|
||||
const attempt = launchAttempts.current.get(attemptKey) ?? {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
requestedAt: new Date().toISOString()
|
||||
};
|
||||
launchAttempts.current.set(attemptKey, attempt);
|
||||
setLaunchingId(attemptKey);
|
||||
setError("");
|
||||
try {
|
||||
const result = await launchPortalService(settings, serviceId, {
|
||||
service_version: version,
|
||||
idempotency_key: attempt.idempotencyKey,
|
||||
requested_at: attempt.requestedAt
|
||||
});
|
||||
launchAttempts.current.delete(attemptKey);
|
||||
if (!result.href) {
|
||||
throw new Error("The service started without returning a destination.");
|
||||
}
|
||||
if (result.href.startsWith("/") && !result.href.startsWith("//")) {
|
||||
navigate(result.href);
|
||||
} else {
|
||||
window.location.assign(result.href);
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Service could not be started.");
|
||||
} finally {
|
||||
setLaunchingId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="portal-page">
|
||||
<div className="portal-shell">
|
||||
<div className="portal-toolbar">
|
||||
<form className="portal-search" onSubmit={submit}>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label="Search services"
|
||||
placeholder="Search services"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">Search</button>
|
||||
</form>
|
||||
<ToggleSwitch
|
||||
label="Show unavailable services"
|
||||
checked={includeUnavailable}
|
||||
onChange={setIncludeUnavailable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-result-summary" aria-live="polite">
|
||||
<strong>{counts.available}</strong> available
|
||||
{includeUnavailable && <><span aria-hidden="true">/</span><strong>{counts.unavailable}</strong> unavailable</>}
|
||||
</div>
|
||||
|
||||
<PageScrollViewport className="portal-results">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{loading && <LoadingIndicator label="Loading services" />}
|
||||
{!loading && !error && services.length === 0 &&
|
||||
<div className="portal-empty">No matching services.</div>
|
||||
}
|
||||
{!loading && services.length > 0 &&
|
||||
<div className="portal-service-list">
|
||||
{services.map((entry) =>
|
||||
<ServiceEntry
|
||||
key={`${entry.definition.reference.object_id}:${entry.definition.reference.version ?? "current"}`}
|
||||
entry={entry}
|
||||
launching={launchingId === `${entry.definition.reference.object_id}:${entry.definition.reference.version ?? ""}`}
|
||||
onLaunch={() => void launch(entry)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceEntry({
|
||||
entry,
|
||||
launching,
|
||||
onLaunch
|
||||
}: {
|
||||
entry: PortalServiceEntry;
|
||||
launching: boolean;
|
||||
onLaunch: () => void;
|
||||
}) {
|
||||
const reasons = userFacingReasons(entry.reason_codes);
|
||||
return (
|
||||
<article className={`portal-service-entry is-${entry.state}`}>
|
||||
<div className="portal-service-heading">
|
||||
<div>
|
||||
<h2>{entry.definition.title}</h2>
|
||||
<span className="portal-service-key">{entry.definition.key}</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={entry.state === "available" ? "active" : "warning"}
|
||||
label={entry.state === "available" ? "Available" : "Unavailable"}
|
||||
/>
|
||||
</div>
|
||||
<div className="portal-service-metadata">
|
||||
{entry.definition.channels.map((channel) =>
|
||||
<span key={channel}>{humanize(channel)}</span>
|
||||
)}
|
||||
{entry.definition.required_evidence_types.map((evidence) =>
|
||||
<span key={evidence}>{humanize(evidence)}</span>
|
||||
)}
|
||||
</div>
|
||||
{reasons.length > 0 &&
|
||||
<ul className="portal-service-reasons">
|
||||
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
}
|
||||
<div className="portal-service-actions">
|
||||
{entry.entry_binding && entry.state === "available" ?
|
||||
<Button variant="primary" disabled={launching} 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>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function userFacingReasons(codes: string[]): string[] {
|
||||
const values = codes.
|
||||
filter((code) => !code.startsWith("service.explanation:")).
|
||||
map((code) => {
|
||||
if (code === "service.publication.suspended") return "This service is temporarily suspended.";
|
||||
if (code.includes("required_module.missing") || code.includes("required_capability.missing")) {
|
||||
return "A required system component is unavailable.";
|
||||
}
|
||||
if (code.includes("evaluator_failed")) return "Availability could not be confirmed.";
|
||||
if (code.includes("requirement.failed")) return "An availability requirement is not met.";
|
||||
if (code.includes("requirement.unknown")) return "An availability requirement could not be confirmed.";
|
||||
return "This service is currently unavailable.";
|
||||
});
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, portalModule } from "./module";
|
||||
export * from "./api/portal";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/portal.css";
|
||||
|
||||
|
||||
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
|
||||
|
||||
export const portalModule: PlatformWebModule = {
|
||||
id: "portal",
|
||||
label: "Services",
|
||||
version: "0.1.8",
|
||||
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
|
||||
routes: [
|
||||
{
|
||||
path: "/portal",
|
||||
anyOf: ["portal:service:read"],
|
||||
order: 25,
|
||||
surfaceId: "portal.directory",
|
||||
render: (context) => createElement(PortalPage, context)
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/portal",
|
||||
label: "Services",
|
||||
iconName: "landmark",
|
||||
anyOf: ["portal:service:read"],
|
||||
order: 25,
|
||||
surfaceId: "portal.navigation"
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "portal.navigation",
|
||||
moduleId: "portal",
|
||||
kind: "navigation",
|
||||
label: "Services navigation",
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "portal.directory",
|
||||
moduleId: "portal",
|
||||
kind: "route",
|
||||
label: "Service directory",
|
||||
order: 20
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default portalModule;
|
||||
@@ -0,0 +1,146 @@
|
||||
.portal-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.portal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 58px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.portal-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(620px, 100%);
|
||||
}
|
||||
|
||||
.portal-search input {
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.portal-result-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
color: var(--text-soft);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.portal-results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px 18px 24px;
|
||||
}
|
||||
|
||||
.portal-service-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portal-service-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 190px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.portal-service-entry.is-unavailable {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
|
||||
.portal-service-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portal-service-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.portal-service-key,
|
||||
.portal-entry-kind {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.portal-service-metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.portal-service-metadata span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.portal-service-reasons {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--warning-text-strong);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.portal-service-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 34px;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.portal-service-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.portal-empty {
|
||||
padding: 36px 0;
|
||||
color: var(--text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.portal-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-search {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user