Files
govoplan-wiki/webui/src/features/wiki/WikiPage.tsx
T
zemion 705255f378
Module Package Release / publish-packages (push) Successful in 11s
fix(webui): bind wiki publication to help
2026-08-24 11:47:25 +02:00

283 lines
34 KiB
TypeScript

import { Archive, BookOpen, FilePlus2, History, Link2, MessageSquarePlus, Pencil, Plus, Search, Send, Settings2, Upload } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FieldLabel, FilterBar, FormLayout, LoadingIndicator, PageScrollViewport, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceFrame, hasScope, type PlatformRouteContext } from "@govoplan/core-webui";
import { addWikiComment, archiveWikiPage, archiveWikiSpace, compareWikiRevisions, createWikiPage, createWikiSpace, getWikiAvailability, getWikiPage, listWikiComments, listWikiPages, listWikiRevisions, listWikiSpaces, publishWikiPage, updateWikiPage, updateWikiSpace, type WikiAvailability, type WikiComment, type WikiLink, type WikiPageRecord, type WikiRevision, type WikiSpace } from "../../api/wiki";
type ArchiveTarget = { kind: "page"; record: WikiPageRecord } | { kind: "space"; record: WikiSpace } | null;
export default function WikiPage({ settings, auth }: PlatformRouteContext) {
const [spaces, setSpaces] = useState<WikiSpace[]>([]);
const [pages, setPages] = useState<WikiPageRecord[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [selectedPageId, setSelectedPageId] = useState("");
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [revisions, setRevisions] = useState<WikiRevision[]>([]);
const [comments, setComments] = useState<WikiComment[]>([]);
const [availability, setAvailability] = useState<WikiAvailability | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [dialogError, setDialogError] = useState("");
const [spaceDialog, setSpaceDialog] = useState<WikiSpace | "new" | null>(null);
const [pageDialog, setPageDialog] = useState<WikiPageRecord | "new" | null>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [compareText, setCompareText] = useState("");
const [archiveTarget, setArchiveTarget] = useState<ArchiveTarget>(null);
const canWrite = hasScope(auth, "wiki:page:write");
const canComment = canWrite || hasScope(auth, "wiki:page:comment");
const canPublish = hasScope(auth, "wiki:page:publish") || hasScope(auth, "wiki:space:admin");
const canAdmin = hasScope(auth, "wiki:space:admin");
async function reload(signal?: AbortSignal, preferredSpaceId?: string) {
setLoading(true); setError("");
try {
const [spaceResult, integrationState] = await Promise.all([listWikiSpaces(settings, signal), getWikiAvailability(settings, signal)]);
const activeSpace = preferredSpaceId && spaceResult.spaces.some((item) => item.space_id === preferredSpaceId)
? preferredSpaceId
: selectedSpaceId && spaceResult.spaces.some((item) => item.space_id === selectedSpaceId) ? selectedSpaceId : spaceResult.spaces[0]?.space_id ?? "";
const pageResult = activeSpace ? await listWikiPages(settings, { spaceId: activeSpace, query: submittedQuery }, signal) : { pages: [], total: 0 };
setSpaces(spaceResult.spaces); setAvailability(integrationState); setSelectedSpaceId(activeSpace); setPages(pageResult.pages);
setSelectedPageId((current) => pageResult.pages.some((item) => item.page_id === current) ? current : pageResult.pages[0]?.page_id ?? "");
} catch (reason) {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki content could not be loaded."));
} finally { setLoading(false); }
}
useEffect(() => {
const controller = new AbortController(); void reload(controller.signal);
return () => controller.abort();
}, [settings, submittedQuery]);
useEffect(() => {
if (!selectedSpaceId) return;
const controller = new AbortController();
setLoading(true);
void listWikiPages(settings, { spaceId: selectedSpaceId, query: submittedQuery }, controller.signal).then((result) => {
setPages(result.pages); setSelectedPageId((current) => result.pages.some((item) => item.page_id === current) ? current : result.pages[0]?.page_id ?? "");
}).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki pages could not be loaded.")); }).finally(() => setLoading(false));
return () => controller.abort();
}, [settings, selectedSpaceId]);
const selectedSpace = useMemo(() => spaces.find((item) => item.space_id === selectedSpaceId) ?? null, [spaces, selectedSpaceId]);
const selectedPage = useMemo(() => pages.find((item) => item.page_id === selectedPageId) ?? null, [pages, selectedPageId]);
useEffect(() => {
if (!selectedPageId) { setRevisions([]); setComments([]); return; }
const controller = new AbortController();
const requests: Promise<unknown>[] = [getWikiPage(settings, selectedPageId, controller.signal).then((record) => setPages((current) => current.map((item) => item.page_id === record.page_id ? record : item))), listWikiComments(settings, selectedPageId, controller.signal).then((result) => setComments(result.comments))];
if (canWrite || canPublish) requests.push(listWikiRevisions(settings, selectedPageId, controller.signal).then((result) => setRevisions(result.revisions)));
void Promise.all(requests).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki page details could not be loaded.")); });
return () => controller.abort();
}, [settings, selectedPageId, canWrite, canPublish]);
function submitSearch(event: FormEvent) { event.preventDefault(); setSubmittedQuery(query.trim()); }
async function runAction(action: () => Promise<WikiPageRecord>, close?: () => void) {
setSaving(true); setDialogError("");
try {
const saved = await action(); close?.(); await reload(undefined, saved.space_id); setSelectedPageId(saved.page_id);
} catch (reason) { setDialogError(message(reason, "The Wiki action could not be completed.")); }
finally { setSaving(false); }
}
async function saveSpace(values: SpaceValues) {
setSaving(true); setDialogError("");
try {
const saved = spaceDialog !== "new" && spaceDialog
? await updateWikiSpace(settings, spaceDialog, { title: values.title, description: values.description, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.changeReason)
: await createWikiSpace(settings, { space_id: crypto.randomUUID(), space_key: values.spaceKey, title: values.title, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.description);
setSpaceDialog(null); await reload(undefined, saved.space_id);
} catch (reason) { setDialogError(message(reason, "The Wiki space could not be saved.")); }
finally { setSaving(false); }
}
async function savePage(values: PageValues) {
if (!selectedSpace) return;
if (pageDialog !== "new" && pageDialog) {
await runAction(() => updateWikiPage(settings, pageDialog, { title: values.title, body: values.body, summary: values.summary, slug: values.slug, parent_page_id: values.parentPageId || null, inherits_access: values.inheritsAccess, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), labels: tokens(values.labels), redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null));
return;
}
await runAction(() => createWikiPage(settings, { page_id: crypto.randomUUID(), space_id: selectedSpace.space_id, parent_page_id: values.parentPageId || null, slug: values.slug, title: values.title, body: values.body, summary: values.summary, inherits_access: values.inheritsAccess, visibility: values.inheritsAccess ? "inherit" : values.visibility, acl_tokens: values.inheritsAccess ? [] : tokens(values.aclTokens), labels: tokens(values.labels), links: [], redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null));
}
async function addReference(link: WikiLink, reason: string) {
if (!selectedPage) return;
await runAction(() => updateWikiPage(settings, selectedPage, { links: [...selectedPage.links, link] }, reason), () => setLinkOpen(false));
}
async function removeReference(linkId: string) {
if (!selectedPage) return;
await runAction(() => updateWikiPage(settings, selectedPage, { links: selectedPage.links.filter((item) => item.link_id !== linkId) }, "Removed a stale Wiki reference."));
}
async function addComment(body: string) {
if (!selectedPage) return;
setSaving(true); setDialogError("");
try { await addWikiComment(settings, selectedPage, body); setComments((await listWikiComments(settings, selectedPage.page_id)).comments); }
catch (reason) { setDialogError(message(reason, "The comment could not be added.")); }
finally { setSaving(false); }
}
async function showComparison(from: number, to: number) {
if (!selectedPage) return;
setCompareText("Loading comparison...");
try { setCompareText((await compareWikiRevisions(settings, selectedPage.page_id, from, to)).diff || "The selected revisions have identical text."); }
catch (reason) { setCompareText(message(reason, "Revisions could not be compared.")); }
}
async function confirmArchive() {
const target = archiveTarget; if (!target) return;
setSaving(true); setDialogError("");
try {
if (target.kind === "page") { const saved = await archiveWikiPage(settings, target.record); setSelectedPageId(saved.page_id); }
else await archiveWikiSpace(settings, target.record);
setArchiveTarget(null); await reload();
} catch (reason) { setDialogError(message(reason, "Wiki content could not be archived.")); }
finally { setSaving(false); }
}
return <main className="wiki-page">
<WorkspaceFrame className="wiki-shell" label="Wiki workspace" interfaceId="wiki.route.workspace" helpContextId="wiki.workspace" helpModuleId="wiki">
<WorkspaceActionBar scope="workspace" variant="collection" refreshable reloadAction={{ onReload: () => void reload(), loading }} contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="wiki-search" onSubmit={submitSearch}><Search size={17} aria-hidden="true" /><input value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search Wiki pages" placeholder="Search pages in this space" /><Button type="submit" variant="primary">Search</Button></FilterBar>
<label className="wiki-space-picker"><span>Space</span><select value={selectedSpaceId} onChange={(event) => setSelectedSpaceId(event.target.value)}>{spaces.map((space) => <option key={space.space_id} value={space.space_id}>{space.title}</option>)}</select></label>
<span className="wiki-count">{pages.length} pages</span>
</>} helpAction={<DocumentationHelpLink reference={{ topicId: "wiki.workflow.author-publish", documentationType: "user" }} label="Open Wiki documentation" />} createAction={<div className="wiki-create-actions">
{canAdmin && <Button type="button" onClick={() => { setDialogError(""); setSpaceDialog("new"); }} interfaceId="wiki.action.create-space"><Plus size={16} /> New space</Button>}
{canWrite && selectedSpace && <Button type="button" variant="primary" onClick={() => { setDialogError(""); setPageDialog("new"); }} interfaceId="wiki.action.create-page"><FilePlus2 size={16} /> New page</Button>}
</div>} />
{error && <DismissibleAlert tone="danger" onDismiss={() => setError("")}>{error}</DismissibleAlert>}
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError} onDismiss={() => setDialogError("")}>{dialogError}</DismissibleAlert>}
{availability && (!availability.search || !availability.files && !availability.dms) && <div className="wiki-availability" role="status">
{!availability.search && <span>{availability.consequences.search}</span>}
{!availability.files && !availability.dms && <span>{availability.consequences.files}</span>}
</div>}
<div className="wiki-workspace">
<PageScrollViewport className="wiki-tree-viewport">
{selectedSpace && <header className="wiki-space-header"><div><span>Space</span><h2>{selectedSpace.title}</h2><p>{selectedSpace.description}</p></div>{canAdmin && <Button type="button" variant="ghost" onClick={() => setSpaceDialog(selectedSpace)}><Settings2 size={16} /> Configure</Button>}</header>}
{loading && <LoadingIndicator label="Loading Wiki pages" />}
{!loading && pages.length === 0 && <StatePanel size="compact" description="No matching Wiki pages." />}
<SelectionList variant="navigation" label="Wiki page tree">{pages.map((item) => <SelectionListItem key={item.page_id} selected={item.page_id === selectedPageId} onClick={() => setSelectedPageId(item.page_id)}><SelectionListItemContent leading={<BookOpen size={17} aria-hidden="true" />} title={item.title} description={`${item.path} · revision ${item.revision}`} /><StatusBadge status={stateTone(item.state)} label={humanize(item.state)} /></SelectionListItem>)}</SelectionList>
{canAdmin && selectedSpace && <div className="wiki-space-danger"><Button type="button" variant="danger" onClick={() => setArchiveTarget({ kind: "space", record: selectedSpace })}><Archive size={16} /> Archive space</Button></div>}
</PageScrollViewport>
<PageScrollViewport className="wiki-detail-viewport">
{selectedPage ? <WikiDetail record={selectedPage} revisions={revisions} comments={comments} canWrite={canWrite} canPublish={canPublish || selectedSpace?.publish_mode === "editors" && canWrite} canComment={canComment} saving={saving} onEdit={() => setPageDialog(selectedPage)} onPublish={() => void runAction(() => publishWikiPage(settings, selectedPage))} onAddLink={() => setLinkOpen(true)} onRemoveLink={removeReference} onHistory={() => { setCompareText(""); setHistoryOpen(true); }} onComment={addComment} onArchive={() => setArchiveTarget({ kind: "page", record: selectedPage })} /> : <StatePanel size="fill" title="Wiki" description="Select a page to read its published knowledge or continue its draft." />}
</PageScrollViewport>
</div>
</WorkspaceFrame>
<SpaceDialog open={spaceDialog !== null} record={spaceDialog === "new" ? null : spaceDialog} saving={saving} error={dialogError} onClose={() => setSpaceDialog(null)} onSave={saveSpace} />
<PageEditorDialog open={pageDialog !== null} record={pageDialog === "new" ? null : pageDialog} pages={pages} saving={saving} error={dialogError} onClose={() => setPageDialog(null)} onSave={savePage} />
<ReferenceDialog open={linkOpen} saving={saving} error={dialogError} onClose={() => setLinkOpen(false)} onSave={addReference} />
<RevisionDialog open={historyOpen} revisions={revisions} comparison={compareText} onClose={() => setHistoryOpen(false)} onCompare={showComparison} />
<ConfirmDialog open={archiveTarget !== null} title={archiveTarget?.kind === "space" ? "Archive Wiki space" : "Archive Wiki page"} message={archiveTarget?.kind === "space" ? "Archive this space? New pages and revisions will be blocked, while governed history remains retained." : "Archive this page? It will leave global Search and reader navigation, while immutable revisions and comments remain retained."} confirmLabel="Archive" tone="danger" busy={saving} interfaceId="wiki.action.archive" helpContextId="wiki.admin.data-subject-requests" helpModuleId="wiki" onCancel={() => setArchiveTarget(null)} onConfirm={() => void confirmArchive()} />
</main>;
}
function WikiDetail({ record, revisions, comments, canWrite, canPublish, canComment, saving, onEdit, onPublish, onAddLink, onRemoveLink, onHistory, onComment, onArchive }: { record: WikiPageRecord; revisions: WikiRevision[]; comments: WikiComment[]; canWrite: boolean; canPublish: boolean; canComment: boolean; saving: boolean; onEdit: () => void; onPublish: () => void; onAddLink: () => void; onRemoveLink: (id: string) => Promise<void>; onHistory: () => void; onComment: (body: string) => Promise<void>; onArchive: () => void }) {
const [comment, setComment] = useState("");
return <article className="wiki-detail" data-interface-id="wiki.section.page">
<header className="wiki-detail-header"><div><span className="wiki-eyebrow">{record.path} · revision {record.revision}</span><h1>{record.title}</h1><p>{record.summary}</p></div><div className="wiki-badges"><StatusBadge status={stateTone(record.state)} label={humanize(record.state)} /><StatusBadge status={record.effective_visibility === "restricted" ? "attention" : "neutral"} label={humanize(record.effective_visibility)} /></div></header>
<div className="wiki-detail-actions" aria-label="Wiki page actions">
{canWrite && record.state !== "archived" && <Button type="button" onClick={onEdit} interfaceId="wiki.action.edit"><Pencil size={16} /> Edit</Button>}
{canWrite && record.state !== "archived" && <Button type="button" onClick={onAddLink}><Link2 size={16} /> Add reference</Button>}
{(canWrite || canPublish) && <Button type="button" onClick={onHistory} interfaceId="wiki.section.revisions"><History size={16} /> Revisions ({revisions.length})</Button>}
{canPublish && record.state === "draft" && <Button type="button" variant="primary" helpContextId="wiki.editor" helpModuleId="wiki" disabled={saving} onClick={onPublish} interfaceId="wiki.action.publish"><Upload size={16} /> Publish</Button>}
</div>
{record.published_revision && record.state === "draft" && <div className="wiki-publication-note" role="status">Readers and Search still receive published revision {record.published_revision} until this draft is published.</div>}
<div className="wiki-body">{record.body || <span className="wiki-muted">This page has no body text yet.</span>}</div>
{record.labels.length > 0 && <ul className="wiki-labels" aria-label="Page labels">{record.labels.map((label) => <li key={label}>{label}</li>)}</ul>}
<section className="wiki-section"><h2>References and attachments <span>{record.links.length}</span></h2>{record.links.length === 0 ? <p className="wiki-muted">No typed references are attached.</p> : <ul className="wiki-links">{record.links.map((link) => <li key={link.link_id}><div>{link.url ? <a href={link.url}>{link.label || link.resource_id}</a> : <strong>{link.label || link.resource_id}</strong>}<small>{humanize(link.kind)} · {link.owner_module} · {link.resource_type}</small></div>{canWrite && <Button type="button" variant="ghost" disabled={saving} onClick={() => void onRemoveLink(link.link_id)}>Remove</Button>}</li>)}</ul>}</section>
<section className="wiki-section" data-interface-id="wiki.action.comment"><h2>Comments <span>{comments.length}</span></h2>{comments.length === 0 ? <p className="wiki-muted">No comments have been recorded.</p> : <ol className="wiki-comments">{comments.map((item) => <li key={item.comment_id}><p>{item.body}</p><small>{item.created_by || "System actor"} · revision {item.page_revision} · {formatDate(item.recorded_at)}</small></li>)}</ol>}{canComment && record.state !== "archived" && <form className="wiki-comment-form" onSubmit={(event) => { event.preventDefault(); const body = comment.trim(); if (!body) return; void onComment(body).then(() => setComment("")); }}><textarea value={comment} rows={3} maxLength={20_000} aria-label="Wiki page comment" placeholder="Add factual review context" onChange={(event) => setComment(event.target.value)} /><Button type="submit" disabled={saving || !comment.trim()}><MessageSquarePlus size={16} /> Add comment</Button></form>}</section>
{canPublish && record.state !== "archived" && <section className="wiki-destructive-actions" aria-label="Destructive Wiki page actions"><h2>Archive page</h2><p>Archiving removes the page from reader navigation and Search but retains revisions, links, and comments.</p><Button type="button" variant="danger" onClick={onArchive}><Archive size={16} /> Archive page</Button></section>}
</article>;
}
type SpaceValues = { spaceKey: string; title: string; description: string; visibility: "tenant" | "restricted"; aclTokens: string; publishMode: "publishers" | "editors"; changeReason: string };
function SpaceDialog({ open, record, saving, error, onClose, onSave }: { open: boolean; record: WikiSpace | null; saving: boolean; error: string; onClose: () => void; onSave: (values: SpaceValues) => Promise<void> }) {
const [values, setValues] = useState<SpaceValues>(() => spaceValues(record));
useEffect(() => { if (open) setValues(spaceValues(record)); }, [open, record]);
function set<K extends keyof SpaceValues>(key: K, value: SpaceValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
return <Dialog open={open} title={record ? "Configure Wiki space" : "Create Wiki space"} size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-space-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-space-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel help="Stable lower-case key; it cannot be renamed.">Space key</FieldLabel><input value={values.spaceKey} required disabled={Boolean(record)} pattern="[a-z0-9]+(?:-[a-z0-9]+)*" maxLength={120} onChange={(event) => set("spaceKey", event.target.value)} /></label>
<label><FieldLabel>Publishing authority</FieldLabel><select value={values.publishMode} onChange={(event) => set("publishMode", event.target.value as SpaceValues["publishMode"])}><option value="publishers">Publish permission required</option><option value="editors">Editors may publish</option></select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Description</FieldLabel><textarea value={values.description} rows={4} maxLength={20_000} onChange={(event) => set("description", event.target.value)} /></label>
<label><FieldLabel>Visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as SpaceValues["visibility"])}><option value="tenant">Entire tenant</option><option value="restricted">ACL subjects only</option></select></label>
<label><FieldLabel help="Comma-separated account:, identity:, membership:, group:, role:, or function_assignment: tokens.">ACL subjects</FieldLabel><input value={values.aclTokens} required={values.visibility === "restricted"} onChange={(event) => set("aclTokens", event.target.value)} /></label>
{record && <label className="wide"><FieldLabel help="Stored with immutable administration history.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>}
</FormLayout>
</Dialog>;
}
type PageValues = { slug: string; title: string; summary: string; body: string; parentPageId: string; redirectPageId: string; inheritsAccess: boolean; visibility: "tenant" | "restricted"; aclTokens: string; labels: string; changeReason: string };
function PageEditorDialog({ open, record, pages, saving, error, onClose, onSave }: { open: boolean; record: WikiPageRecord | null; pages: WikiPageRecord[]; saving: boolean; error: string; onClose: () => void; onSave: (values: PageValues) => Promise<void> }) {
const [values, setValues] = useState<PageValues>(() => pageValues(record));
useEffect(() => { if (open) setValues(pageValues(record)); }, [open, record]);
function set<K extends keyof PageValues>(key: K, value: PageValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
const candidates = pages.filter((item) => item.page_id !== record?.page_id && item.state !== "archived");
return <Dialog open={open} title={record ? "Edit Wiki page" : "Create Wiki page"} size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-page-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save draft"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-page-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel>Slug</FieldLabel><input value={values.slug} required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" maxLength={160} onChange={(event) => set("slug", event.target.value)} /></label>
<label><FieldLabel>Parent page</FieldLabel><select value={values.parentPageId} onChange={(event) => set("parentPageId", event.target.value)}><option value="">Space root</option>{candidates.map((item) => <option key={item.page_id} value={item.page_id}>{item.path}</option>)}</select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Summary</FieldLabel><textarea value={values.summary} rows={2} maxLength={10_000} onChange={(event) => set("summary", event.target.value)} /></label>
<label className="wide"><FieldLabel help="This first editor stores governed plain text. Every save appends an immutable revision.">Page body</FieldLabel><textarea value={values.body} rows={14} maxLength={500_000} onChange={(event) => set("body", event.target.value)} /></label>
<label><FieldLabel>Labels</FieldLabel><input value={values.labels} placeholder="guide, citizen-service" onChange={(event) => set("labels", event.target.value)} /></label>
<label><FieldLabel help="Publishing turns this page into a governed redirect.">Redirect target</FieldLabel><select value={values.redirectPageId} onChange={(event) => set("redirectPageId", event.target.value)}><option value="">No redirect</option>{candidates.map((item) => <option key={item.page_id} value={item.page_id}>{item.path}</option>)}</select></label>
<label className="wiki-checkbox"><input type="checkbox" checked={values.inheritsAccess} onChange={(event) => set("inheritsAccess", event.target.checked)} /><span>Inherit access from the space</span></label>
{!values.inheritsAccess && <label><FieldLabel>Page visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as PageValues["visibility"])}><option value="tenant">Entire tenant</option><option value="restricted">ACL subjects only</option></select></label>}
{!values.inheritsAccess && <label className="wide"><FieldLabel>Page ACL subjects</FieldLabel><input value={values.aclTokens} required={values.visibility === "restricted"} onChange={(event) => set("aclTokens", event.target.value)} /></label>}
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function ReferenceDialog({ open, saving, error, onClose, onSave }: { open: boolean; saving: boolean; error: string; onClose: () => void; onSave: (link: WikiLink, reason: string) => Promise<void> }) {
const [kind, setKind] = useState<WikiLink["kind"]>("related"); const [owner, setOwner] = useState(""); const [resourceType, setResourceType] = useState(""); const [resourceId, setResourceId] = useState(""); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); const [externalSystem, setExternalSystem] = useState(""); const [externalId, setExternalId] = useState(""); const [reason, setReason] = useState("Added a governed Wiki reference.");
useEffect(() => { if (open) { setKind("related"); setOwner(""); setResourceType(""); setResourceId(""); setLabel(""); setUrl(""); setExternalSystem(""); setExternalId(""); setReason("Added a governed Wiki reference."); } }, [open]);
return <Dialog open={open} title="Add reference or attachment" size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-reference-form" variant="primary" disabled={saving}>Add reference</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-reference-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave({ link_id: crypto.randomUUID(), kind, owner_module: owner, resource_type: resourceType, resource_id: resourceId, label: label || null, url: url || null, external_system: externalSystem || null, external_id: externalId || null, provenance: {} }, reason); }}>
<label><FieldLabel>Reference kind</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value as WikiLink["kind"])}><option value="related">Related resource</option><option value="reference">Reference</option><option value="attachment">Files/DMS attachment</option><option value="external">External Wiki page</option></select></label>
<label><FieldLabel help="Attachment references must use files or dms.">Owning module</FieldLabel><input value={owner} required maxLength={120} onChange={(event) => setOwner(event.target.value)} /></label>
<label><FieldLabel>Resource type</FieldLabel><input value={resourceType} required maxLength={120} onChange={(event) => setResourceType(event.target.value)} /></label>
<label><FieldLabel>Resource identifier</FieldLabel><input value={resourceId} required maxLength={255} onChange={(event) => setResourceId(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label><FieldLabel>Local or HTTP(S) link</FieldLabel><input value={url} maxLength={1_500} onChange={(event) => setUrl(event.target.value)} /></label>
{kind === "external" && <><label><FieldLabel>External system</FieldLabel><input value={externalSystem} maxLength={120} onChange={(event) => setExternalSystem(event.target.value)} /></label><label><FieldLabel>External identifier</FieldLabel><input value={externalId} maxLength={255} onChange={(event) => setExternalId(event.target.value)} /></label></>}
<label className="wide"><FieldLabel>Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function RevisionDialog({ open, revisions, comparison, onClose, onCompare }: { open: boolean; revisions: WikiRevision[]; comparison: string; onClose: () => void; onCompare: (from: number, to: number) => Promise<void> }) {
const [from, setFrom] = useState(0); const [to, setTo] = useState(0);
useEffect(() => { if (open) { setTo(revisions[0]?.revision ?? 0); setFrom(revisions[1]?.revision ?? revisions[0]?.revision ?? 0); } }, [open, revisions]);
return <Dialog open={open} title="Immutable page revisions" size="large" onClose={onClose} footer={<Button type="button" onClick={onClose}>Close</Button>}>
<div className="wiki-revision-controls"><label><FieldLabel>From revision</FieldLabel><select value={from} onChange={(event) => setFrom(Number(event.target.value))}>{revisions.map((item) => <option key={item.revision} value={item.revision}>{item.revision} · {humanize(item.event_type)}</option>)}</select></label><label><FieldLabel>To revision</FieldLabel><select value={to} onChange={(event) => setTo(Number(event.target.value))}>{revisions.map((item) => <option key={item.revision} value={item.revision}>{item.revision} · {humanize(item.event_type)}</option>)}</select></label><Button type="button" disabled={!from || !to || from === to} onClick={() => void onCompare(from, to)}>Compare</Button></div>
<ol className="wiki-revisions">{revisions.map((item) => <li key={item.revision}><strong>Revision {item.revision} · {humanize(item.event_type)}</strong><span>{formatDate(item.recorded_at)} · {item.actor_id || "System actor"}</span><p>{item.change_reason}</p></li>)}</ol>
{comparison && <pre className="wiki-diff" aria-label="Revision text comparison">{comparison}</pre>}
</Dialog>;
}
function spaceValues(record: WikiSpace | null): SpaceValues { return { spaceKey: record?.space_key ?? "", title: record?.title ?? "", description: record?.description ?? "", visibility: record?.visibility ?? "tenant", aclTokens: (record?.acl_tokens ?? []).join(", "), publishMode: record?.publish_mode ?? "publishers", changeReason: record ? "Updated Wiki space governance." : "Created governed Wiki space." }; }
function pageValues(record: WikiPageRecord | null): PageValues { return { slug: record?.slug ?? "", title: record?.title ?? "", summary: record?.summary ?? "", body: record?.body ?? "", parentPageId: record?.parent_page_id ?? "", redirectPageId: record?.redirect_page_id ?? "", inheritsAccess: record?.inherits_access ?? true, visibility: record?.visibility === "restricted" ? "restricted" : "tenant", aclTokens: (record?.acl_tokens ?? []).join(", "), labels: (record?.labels ?? []).join(", "), changeReason: record ? "Revised Wiki page content." : "Created Wiki page draft." }; }
function tokens(value: string): string[] { return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; }
function humanize(value: string): string { return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function stateTone(value: string): "positive" | "attention" | "neutral" | "negative" { if (value === "published") return "positive"; if (value === "draft" || value === "redirected") return "attention"; if (value === "archived") return "negative"; return "neutral"; }
function formatDate(value?: string | null): string { if (!value) return "Not recorded"; const parsed = new Date(value); return Number.isNaN(parsed.valueOf()) ? value : parsed.toLocaleString(); }
function message(reason: unknown, fallback: string): string { return reason instanceof Error ? reason.message : fallback; }