feat: add tenant semantic documentation lifecycle
This commit is contained in:
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -12,6 +12,9 @@
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
@@ -275,3 +275,118 @@ export function fetchDocsSource(
|
||||
`/api/v1/docs/sources/${encodeURIComponent(sourceId)}${query ? `?${query}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
export type SemanticSubjectReference = {
|
||||
module_id: string;
|
||||
tenant_id: string;
|
||||
subject_kind: string;
|
||||
subject_id: string;
|
||||
anchor?: { kind: string; id: string } | null;
|
||||
observed_revision?: string | null;
|
||||
observed_fingerprint?: string | null;
|
||||
};
|
||||
|
||||
export type SemanticSubjectDescriptor = {
|
||||
reference: SemanticSubjectReference;
|
||||
labels: Record<string, string>;
|
||||
descriptions: Record<string, string>;
|
||||
route?: string | null;
|
||||
route_anchor?: string | null;
|
||||
};
|
||||
|
||||
export type SemanticContent = {
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
meaning: string;
|
||||
intended_use: string;
|
||||
non_intended_use: string;
|
||||
examples: string[];
|
||||
owner_account_id: string | null;
|
||||
steward_account_id: string | null;
|
||||
audience: string[];
|
||||
classification: "internal" | "restricted";
|
||||
links: Array<{ label: string; href: string }>;
|
||||
};
|
||||
|
||||
export type SemanticEntry = {
|
||||
id: string;
|
||||
subject: SemanticSubjectReference;
|
||||
subject_resolution: {
|
||||
availability: "available" | "changed" | "superseded" | "missing" | "temporarily_unavailable";
|
||||
reason_code?: string | null;
|
||||
};
|
||||
locale: string;
|
||||
requested_locale: string;
|
||||
locale_fallback: boolean;
|
||||
lifecycle_state: "draft" | "published" | "superseded" | "retired";
|
||||
pending_draft: boolean;
|
||||
current_revision: number;
|
||||
published_revision?: number | null;
|
||||
content: SemanticContent;
|
||||
content_redacted: boolean;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export async function fetchSemanticSubjects(
|
||||
settings: ApiSettings,
|
||||
query = ""
|
||||
): Promise<SemanticSubjectDescriptor[]> {
|
||||
const params = new URLSearchParams({ query });
|
||||
const response = await apiFetch<{ providers: Array<{ subjects: SemanticSubjectDescriptor[] }> }>(
|
||||
settings,
|
||||
`/api/v1/docs/semantic/subjects?${params}`
|
||||
);
|
||||
return response.providers.flatMap((provider) => provider.subjects);
|
||||
}
|
||||
|
||||
export async function fetchSemanticEntries(
|
||||
settings: ApiSettings,
|
||||
locale: string,
|
||||
includeDrafts = true
|
||||
): Promise<SemanticEntry[]> {
|
||||
const params = new URLSearchParams({ locale, include_drafts: String(includeDrafts) });
|
||||
const response = await apiFetch<{ items: SemanticEntry[] }>(
|
||||
settings,
|
||||
`/api/v1/docs/semantic/entries?${params}`
|
||||
);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function createSemanticEntry(
|
||||
settings: ApiSettings,
|
||||
payload: { subject: SemanticSubjectReference; locale: string; content: SemanticContent; change_reason: string }
|
||||
): Promise<SemanticEntry> {
|
||||
return apiFetch(settings, "/api/v1/docs/semantic/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSemanticEntry(
|
||||
settings: ApiSettings,
|
||||
entryId: string,
|
||||
payload: { expected_revision: number; content: SemanticContent; change_reason: string }
|
||||
): Promise<SemanticEntry> {
|
||||
return apiFetch(settings, `/api/v1/docs/semantic/entries/${encodeURIComponent(entryId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function transitionSemanticEntry(
|
||||
settings: ApiSettings,
|
||||
entryId: string,
|
||||
transition: "publish" | "retire",
|
||||
expectedRevision: number,
|
||||
changeReason: string
|
||||
): Promise<SemanticEntry> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/docs/semantic/entries/${encodeURIComponent(entryId)}/${transition}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_revision: expectedRevision, change_reason: changeReason })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { ChevronDown, ChevronRight, Eye, RefreshCw } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Eye } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { Archive, Check, Plus } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ContentGrid,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
FormGrid,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
WorkspaceLayout,
|
||||
adminErrorMessage,
|
||||
usePlatformLanguage,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createSemanticEntry,
|
||||
fetchSemanticEntries,
|
||||
fetchSemanticSubjects,
|
||||
transitionSemanticEntry,
|
||||
updateSemanticEntry,
|
||||
type SemanticContent,
|
||||
type SemanticEntry,
|
||||
type SemanticSubjectDescriptor
|
||||
} from "../../api/docs";
|
||||
|
||||
const emptyContent = (): SemanticContent => ({
|
||||
title: "",
|
||||
summary: "",
|
||||
body: "",
|
||||
meaning: "",
|
||||
intended_use: "",
|
||||
non_intended_use: "",
|
||||
examples: [],
|
||||
owner_account_id: null,
|
||||
steward_account_id: null,
|
||||
audience: [],
|
||||
classification: "internal",
|
||||
links: []
|
||||
});
|
||||
|
||||
export default function SemanticDocumentationPage({ settings }: { settings: ApiSettings }) {
|
||||
const { language } = usePlatformLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [subjects, setSubjects] = useState<SemanticSubjectDescriptor[]>([]);
|
||||
const [entries, setEntries] = useState<SemanticEntry[]>([]);
|
||||
const [selectedSubject, setSelectedSubject] = useState<SemanticSubjectDescriptor | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<SemanticEntry | null>(null);
|
||||
const [content, setContent] = useState<SemanticContent>(emptyContent);
|
||||
const [locale, setLocale] = useState(searchParams.get("locale") || language || "de");
|
||||
const [reason, setReason] = useState("Document configured meaning");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const baseline = selectedEntry?.content ?? emptyContent();
|
||||
const dirty = Boolean(selectedSubject) && (
|
||||
JSON.stringify(content) !== JSON.stringify(baseline)
|
||||
|| (!selectedEntry && Boolean(content.title || content.body || content.meaning))
|
||||
);
|
||||
const valid = Boolean(selectedSubject && content.title.trim() && reason.trim());
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: resetDraft
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextSubjects, nextEntries] = await Promise.all([
|
||||
fetchSemanticSubjects(settings),
|
||||
fetchSemanticEntries(settings, locale, true)
|
||||
]);
|
||||
setSubjects(nextSubjects);
|
||||
setEntries(nextEntries);
|
||||
const requestedEntry = searchParams.get("entryId");
|
||||
const entry = nextEntries.find((item) => item.id === requestedEntry) ?? selectedEntry;
|
||||
if (entry) selectExisting(entry, nextSubjects);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!selectedSubject || !valid) return false;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = selectedEntry
|
||||
? await updateSemanticEntry(settings, selectedEntry.id, {
|
||||
expected_revision: selectedEntry.current_revision,
|
||||
content,
|
||||
change_reason: reason
|
||||
})
|
||||
: await createSemanticEntry(settings, {
|
||||
subject: selectedSubject.reference,
|
||||
locale,
|
||||
content,
|
||||
change_reason: reason
|
||||
});
|
||||
setSelectedEntry(next);
|
||||
setContent(next.content);
|
||||
setEntries((current) => [next, ...current.filter((item) => item.id !== next.id)]);
|
||||
setSearchParams({ entryId: next.id, locale: next.locale }, { replace: true });
|
||||
setSuccess(`Saved immutable revision ${next.current_revision}.`);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(kind: "publish" | "retire") {
|
||||
if (!selectedEntry || dirty) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await transitionSemanticEntry(
|
||||
settings,
|
||||
selectedEntry.id,
|
||||
kind,
|
||||
selectedEntry.current_revision,
|
||||
reason
|
||||
);
|
||||
setSelectedEntry(next);
|
||||
setEntries((current) => [next, ...current.filter((item) => item.id !== next.id)]);
|
||||
setSuccess(kind === "publish" ? "Semantic documentation published." : "Semantic documentation retired.");
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectExisting(entry: SemanticEntry, availableSubjects = subjects) {
|
||||
const subject = availableSubjects.find((item) => sameSubject(item.reference, entry.subject));
|
||||
setSelectedEntry(entry);
|
||||
setSelectedSubject(subject ?? descriptorFromEntry(entry));
|
||||
setContent(entry.content_redacted ? emptyContent() : entry.content);
|
||||
setLocale(entry.locale);
|
||||
setSearchParams({ entryId: entry.id, locale: entry.locale }, { replace: true });
|
||||
}
|
||||
|
||||
function selectNew(subject: SemanticSubjectDescriptor) {
|
||||
const existing = entries.find((entry) => entry.locale === locale && sameSubject(entry.subject, subject.reference));
|
||||
if (existing) {
|
||||
selectExisting(existing);
|
||||
return;
|
||||
}
|
||||
setSelectedSubject(subject);
|
||||
setSelectedEntry(null);
|
||||
setContent({ ...emptyContent(), title: localized(subject.labels, locale) });
|
||||
setSearchParams({ locale }, { replace: true });
|
||||
}
|
||||
|
||||
function resetDraft() {
|
||||
setContent(selectedEntry?.content ?? emptyContent());
|
||||
}
|
||||
|
||||
const sourceStatus = selectedEntry?.subject_resolution.availability ?? "available";
|
||||
const sortedEntries = useMemo(
|
||||
() => [...entries].sort((left, right) => left.content.title.localeCompare(right.content.title)),
|
||||
[entries]
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceLayout
|
||||
className="module-workspace docs-semantic-workspace"
|
||||
primaryLabel="Semantic documentation"
|
||||
contentLabel="Semantic documentation editor"
|
||||
primary={(
|
||||
<aside className="section-sidebar" aria-label="Semantic documentation subjects">
|
||||
<div className="section-title">Documented subjects</div>
|
||||
<nav className="section-nav">
|
||||
{sortedEntries.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.id}
|
||||
className={selectedEntry?.id === entry.id ? "active" : ""}
|
||||
onClick={() => selectExisting(entry)}
|
||||
>
|
||||
<span>{entry.content.title}</span>
|
||||
<small>{entry.subject.subject_kind} · {entry.locale}</small>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="section-title">Available configured subjects</div>
|
||||
<nav className="section-nav">
|
||||
{subjects.map((subject) => (
|
||||
<button type="button" key={subjectKey(subject)} onClick={() => selectNew(subject)}>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
<span>{localized(subject.labels, locale)}</span>
|
||||
<small>{subject.reference.module_id} · {subject.reference.subject_kind}</small>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)}
|
||||
>
|
||||
<PageLayout
|
||||
archetype="editor"
|
||||
title="Semantic documentation"
|
||||
description="Explain the tenant-specific meaning and intended use of stable configured subjects."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={(
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
state={saving ? "saving" : !valid && dirty ? "invalid" : dirty ? "dirty" : "clean"}
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), loading }}
|
||||
primaryActions={selectedEntry?.lifecycle_state === "draft" && !dirty ? (
|
||||
<Button onClick={() => void transition("publish")}>
|
||||
<Check size={16} /> Publish
|
||||
</Button>
|
||||
) : null}
|
||||
destructiveActions={selectedEntry && !["retired", "superseded"].includes(selectedEntry.lifecycle_state) && !dirty ? (
|
||||
<Button variant="danger" onClick={() => void transition("retire")}>
|
||||
<Archive size={16} /> Retire
|
||||
</Button>
|
||||
) : null}
|
||||
discardAction={{ label: "Discard", onClick: resetDraft }}
|
||||
saveAction={{ label: selectedEntry ? "Save revision" : "Create entry", onClick: () => void save() }}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{!selectedSubject ? (
|
||||
<StatePanel size="fill" title="Select a configured subject" description="Choose an existing entry or an authorized subject from an installed module." />
|
||||
) : (
|
||||
<ContentGrid columns={2} collapseAt="workspace">
|
||||
<Card
|
||||
title={localized(selectedSubject.labels, locale)}
|
||||
actions={<><StatusBadge status={selectedEntry?.lifecycle_state ?? "draft"} /><StatusBadge status={sourceStatus} /></>}
|
||||
>
|
||||
<FormGrid columns={1}>
|
||||
<FormField label="Locale" help="Entries are locale-specific. Visible fallback is applied at read time.">
|
||||
<input value={locale} onChange={(event) => setLocale(event.target.value)} maxLength={20} />
|
||||
</FormField>
|
||||
<FormField label="Title"><input value={content.title} onChange={(event) => setContent({ ...content, title: event.target.value })} /></FormField>
|
||||
<FormField label="Summary"><textarea value={content.summary} onChange={(event) => setContent({ ...content, summary: event.target.value })} rows={3} /></FormField>
|
||||
<FormField label="Meaning"><textarea value={content.meaning} onChange={(event) => setContent({ ...content, meaning: event.target.value })} rows={5} /></FormField>
|
||||
<FormField label="Body"><textarea value={content.body} onChange={(event) => setContent({ ...content, body: event.target.value })} rows={10} /></FormField>
|
||||
</FormGrid>
|
||||
</Card>
|
||||
<Card title="Governance and use">
|
||||
<FormGrid columns={1}>
|
||||
<FormField label="Intended use"><textarea value={content.intended_use} onChange={(event) => setContent({ ...content, intended_use: event.target.value })} rows={5} /></FormField>
|
||||
<FormField label="Not intended for"><textarea value={content.non_intended_use} onChange={(event) => setContent({ ...content, non_intended_use: event.target.value })} rows={5} /></FormField>
|
||||
<FormField label="Classification">
|
||||
<select value={content.classification} onChange={(event) => setContent({ ...content, classification: event.target.value as SemanticContent["classification"] })}>
|
||||
<option value="internal">Internal</option>
|
||||
<option value="restricted">Restricted</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Audience selectors" help="One per line: authenticated, account:, group:, role:, function:, or scope:.">
|
||||
<textarea value={content.audience.join("\n")} onChange={(event) => setContent({ ...content, audience: event.target.value.split("\n").map((item) => item.trim()).filter(Boolean) })} rows={5} />
|
||||
</FormField>
|
||||
<FormField label="Change reason"><input value={reason} onChange={(event) => setReason(event.target.value)} maxLength={1000} /></FormField>
|
||||
</FormGrid>
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
)}
|
||||
</PageLayout>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function sameSubject(left: SemanticEntry["subject"], right: SemanticEntry["subject"]): boolean {
|
||||
return left.module_id === right.module_id
|
||||
&& left.subject_kind === right.subject_kind
|
||||
&& left.subject_id === right.subject_id
|
||||
&& left.anchor?.kind === right.anchor?.kind
|
||||
&& left.anchor?.id === right.anchor?.id;
|
||||
}
|
||||
|
||||
function subjectKey(subject: SemanticSubjectDescriptor): string {
|
||||
const reference = subject.reference;
|
||||
return [reference.module_id, reference.subject_kind, reference.subject_id, reference.anchor?.kind, reference.anchor?.id].filter(Boolean).join(":");
|
||||
}
|
||||
|
||||
function localized(values: Record<string, string>, locale: string): string {
|
||||
return values[locale] ?? values[locale.split("-")[0]] ?? values.de ?? values.en ?? Object.values(values)[0] ?? "Configured subject";
|
||||
}
|
||||
|
||||
function descriptorFromEntry(entry: SemanticEntry): SemanticSubjectDescriptor {
|
||||
return {
|
||||
reference: entry.subject,
|
||||
labels: { [entry.locale]: entry.content.title },
|
||||
descriptions: {}
|
||||
};
|
||||
}
|
||||
+5
-2
@@ -3,8 +3,10 @@ import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const DocsPage = lazy(() => import("./features/docs/DocsPage"));
|
||||
const SemanticDocumentationPage = lazy(() => import("./features/docs/SemanticDocumentationPage"));
|
||||
|
||||
const docsReadScopes = ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"];
|
||||
const semanticEditorScopes = ["docs:semantic:create", "docs:semantic:edit", "docs:semantic:publish", "docs:semantic:supersede", "docs:semantic:retire"];
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
@@ -14,13 +16,14 @@ const translations = {
|
||||
export const docsModule: PlatformWebModule = {
|
||||
id: "docs",
|
||||
label: "i18n:govoplan-docs.docs.68a41942",
|
||||
version: "0.1.10",
|
||||
version: "0.1.19",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
|
||||
translations,
|
||||
navItems: [{ to: "/docs", label: "i18n:govoplan-docs.docs.68a41942", iconName: "reports", anyOf: docsReadScopes, order: 880 }],
|
||||
routes: [
|
||||
{ path: "/docs", anyOf: docsReadScopes, order: 880, render: ({ settings }) => createElement(DocsPage, { settings }) }]
|
||||
{ path: "/docs", anyOf: docsReadScopes, order: 880, render: ({ settings }) => createElement(DocsPage, { settings }) },
|
||||
{ path: "/docs/semantic", anyOf: semanticEditorScopes, order: 881, render: ({ settings }) => createElement(SemanticDocumentationPage, { settings }) }]
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"preserveSymlinks": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
|
||||
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
|
||||
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
|
||||
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
|
||||
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/production/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "../../govoplan-core/webui/src/vite-env.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user