318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
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);
|
|
} else {
|
|
const requestedSubject = nextSubjects.find((item) => (
|
|
item.reference.module_id === searchParams.get("module")
|
|
&& item.reference.subject_kind === searchParams.get("subjectKind")
|
|
&& item.reference.subject_id === searchParams.get("subjectId")
|
|
&& (item.route_anchor ?? "") === (searchParams.get("routeAnchor") ?? "")
|
|
));
|
|
if (requestedSubject) selectNew(requestedSubject, nextEntries);
|
|
}
|
|
} 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, availableEntries = entries) {
|
|
const existing = availableEntries.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 helpContextId="docs.semantic-documentation.publish" helpModuleId="docs" 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: {}
|
|
};
|
|
}
|