feat: add tenant semantic documentation lifecycle
This commit is contained in:
@@ -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: {}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user