feat: compose draft authoring workbench
This commit is contained in:
@@ -97,13 +97,14 @@ describe("DraftDetailRoute", () => {
|
||||
expect(screen.getByText("Route summarize.ok to __end__.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the raw draft closed by default and exposes no mutation controls", () => {
|
||||
it("keeps the raw draft closed and marks deferred mutation controls unavailable", () => {
|
||||
const { container } = renderRoute();
|
||||
|
||||
const details = container.querySelector("details");
|
||||
expect(details).not.toBeNull();
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Undo — Later" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete node — Later" })).toBeDisabled();
|
||||
expect(screen.queryByRole("link", { name: /compile|artifact|save|edit|mutate/i })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,194 +1,17 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import type {
|
||||
DraftDiagnostic,
|
||||
DraftWorkspace,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
|
||||
export { formatBoundedJson } from "../authoring/ContextInspector.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
const MAX_RAW_DRAFT_CHARS = 12_000;
|
||||
const TRUNCATION_MARKER = "... truncated ...";
|
||||
|
||||
const titleFor = (workspace: DraftWorkspace): string =>
|
||||
workspace.title?.trim() || workspace.workspaceId;
|
||||
|
||||
const formatStatus = (status: DraftWorkspace["status"]): string =>
|
||||
status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === "string") return value;
|
||||
const encoded = JSON.stringify(value);
|
||||
return encoded ?? String(value);
|
||||
};
|
||||
|
||||
// Traverse until the display budget is exhausted so a large remote object is
|
||||
// never fully materialized just to produce a clipped escape-hatch preview.
|
||||
export const formatBoundedJson = (value: unknown, maxChars = MAX_RAW_DRAFT_CHARS): string => {
|
||||
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
|
||||
const contentLimit = Math.max(0, maxChars);
|
||||
let output = "";
|
||||
let truncated = false;
|
||||
const activeObjects = new WeakSet<object>();
|
||||
|
||||
const append = (chunk: string): void => {
|
||||
if (truncated) return;
|
||||
if (output.length + chunk.length > contentLimit) {
|
||||
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
output += chunk;
|
||||
};
|
||||
|
||||
const appendJsonString = (value: string): void => {
|
||||
append('"');
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (truncated) return;
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 0x22) append('\\"');
|
||||
else if (code === 0x5c) append("\\\\");
|
||||
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const nextCode = value.charCodeAt(index + 1);
|
||||
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
||||
append(value.slice(index, index + 2));
|
||||
index++;
|
||||
} else {
|
||||
append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
} else {
|
||||
append(value.charAt(index));
|
||||
}
|
||||
}
|
||||
if (!truncated) append('"');
|
||||
};
|
||||
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
if (truncated) return;
|
||||
if (current === null || typeof current !== "object") {
|
||||
if (typeof current === "string") {
|
||||
appendJsonString(current);
|
||||
} else if (typeof current === "number") {
|
||||
append(Number.isFinite(current) ? String(current) : "null");
|
||||
} else if (typeof current === "boolean") {
|
||||
append(current ? "true" : "false");
|
||||
} else {
|
||||
append("null");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeObjects.has(current)) {
|
||||
append('"[Circular]"');
|
||||
return;
|
||||
}
|
||||
activeObjects.add(current);
|
||||
const indent = " ".repeat(depth);
|
||||
const childIndent = " ".repeat(depth + 1);
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
append("[");
|
||||
let first = true;
|
||||
for (const item of current) {
|
||||
if (truncated) break;
|
||||
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
|
||||
visit(item, depth + 1);
|
||||
first = false;
|
||||
}
|
||||
if (!truncated) append(first ? "]" : `\n${indent}]`);
|
||||
} else {
|
||||
const record = current as Record<string, unknown>;
|
||||
append("{");
|
||||
let first = true;
|
||||
for (const key in record) {
|
||||
if (!Object.prototype.hasOwnProperty.call(current, key) || truncated) continue;
|
||||
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
|
||||
appendJsonString(key);
|
||||
append(": ");
|
||||
visit(record[key], depth + 1);
|
||||
first = false;
|
||||
}
|
||||
if (!truncated) append(first ? "}" : `\n${indent}}`);
|
||||
}
|
||||
activeObjects.delete(current);
|
||||
};
|
||||
|
||||
visit(value, 0);
|
||||
if (!truncated) return output;
|
||||
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
|
||||
return `${output.slice(0, markerStart)}${truncationMarker}`;
|
||||
};
|
||||
|
||||
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Diagnostic = ({ diagnostic }: { readonly diagnostic: DraftDiagnostic }) => (
|
||||
<li className="draft-detail__diagnostic">
|
||||
<dl>
|
||||
<Fact label="Code" value={diagnostic.code} />
|
||||
<Fact label="Path" value={diagnostic.path} />
|
||||
<Fact label="Message" value={diagnostic.message} />
|
||||
<Fact label="Step id" value={diagnostic.stepId ?? "none"} />
|
||||
<Fact label="Repair hint" value={diagnostic.repairHint ?? "none"} />
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
|
||||
const DraftFacts = ({ draft }: { readonly draft: DraftWorkspace }) => (
|
||||
<section aria-labelledby="draft-detail-summary-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-summary-heading">Draft summary</h2>
|
||||
<dl className="draft-detail__facts">
|
||||
<Fact label="Status" value={formatStatus(draft.status)} />
|
||||
<Fact label="Revision" value={`Revision ${draft.revision}`} />
|
||||
<Fact label="Start step" value={formatValue(draft.summary.start)} />
|
||||
<Fact label="Step count" value={String(draft.summary.stepCount)} />
|
||||
<Fact label="Route count" value={String(draft.summary.routeCount)} />
|
||||
</dl>
|
||||
|
||||
<h3>Step ids</h3>
|
||||
<ul className="draft-detail__steps">
|
||||
{draft.summary.steps.map((stepId) => <li key={stepId}>{stepId}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<DraftDiagnostic> }) => (
|
||||
<section aria-labelledby="draft-detail-diagnostics-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
|
||||
{diagnostics.length > 0 ? (
|
||||
<ol className="draft-detail__diagnostics">
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p>No diagnostics reported.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const RawDraft = ({ draft }: { readonly draft: Record<string, unknown> | null }) => (
|
||||
<details className="draft-detail__raw">
|
||||
<summary>Raw draft document</summary>
|
||||
{draft ? (
|
||||
<pre
|
||||
aria-label="Raw draft JSON, horizontally scrollable"
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
>
|
||||
{formatBoundedJson(draft)}
|
||||
</pre>
|
||||
) : (
|
||||
<p>Full draft document was not returned</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
|
||||
export const DraftDetailRoute = () => {
|
||||
const { workspaceId = null } = useParams<{ workspaceId: string }>();
|
||||
const drafts = useDraftWorkspace(workspaceId);
|
||||
@@ -215,7 +38,7 @@ export const DraftDetailRoute = () => {
|
||||
{draft && (
|
||||
<>
|
||||
<header className="draft-detail__header">
|
||||
<p className="workspace-route-pending__eyebrow">Read-only draft</p>
|
||||
<p className="workspace-route-pending__eyebrow">Draft authoring workbench</p>
|
||||
<h1>{titleFor(draft)}</h1>
|
||||
<p className="draft-detail__workspace-id">{draft.workspaceId}</p>
|
||||
<p className="draft-detail__status-line">
|
||||
@@ -226,11 +49,7 @@ export const DraftDetailRoute = () => {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="draft-detail__panels">
|
||||
<DraftFacts draft={draft} />
|
||||
<Diagnostics diagnostics={draft.diagnostics} />
|
||||
</div>
|
||||
<RawDraft draft={draft.draft} />
|
||||
<DraftWorkbench draft={draft} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user