feat: add continuous authoring workflow diagram
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { projectPreparedLifecycleStep } from "./authoring-projection.js";
|
||||||
|
import { AuthoringWorkflowDiagram } from "./AuthoringWorkflowDiagram.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const renderDiagram = (step: "draft" | "diagnose" | "repair") => {
|
||||||
|
const projection = projectPreparedLifecycleStep(step);
|
||||||
|
if (
|
||||||
|
projection.evidence.kind !== "draft"
|
||||||
|
&& projection.evidence.kind !== "diagnostic"
|
||||||
|
&& projection.evidence.kind !== "repair"
|
||||||
|
) {
|
||||||
|
throw new Error(`unexpected evidence for ${step}`);
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
<AuthoringWorkflowDiagram mode={step === "diagnose" ? "diagnostic" : step} evidence={projection.evidence} />,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AuthoringWorkflowDiagram", () => {
|
||||||
|
it.each(["draft", "diagnose", "repair"] as const)(
|
||||||
|
"preserves workflow node identity in %s mode",
|
||||||
|
(step) => {
|
||||||
|
renderDiagram(step);
|
||||||
|
const diagram = screen.getByRole("img", { name: /authoring workflow diagram/i });
|
||||||
|
|
||||||
|
expect(within(diagram).getByText("read_documents")).toBeInTheDocument();
|
||||||
|
expect(within(diagram).getByText("analyze")).toBeInTheDocument();
|
||||||
|
expect(within(diagram).getByText("END")).toBeInTheDocument();
|
||||||
|
expect(diagram.querySelectorAll("[data-authoring-node-id]")).toHaveLength(3);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("shows an absent analyze.ok route as the diagnostic headline", () => {
|
||||||
|
renderDiagram("diagnose");
|
||||||
|
|
||||||
|
expect(screen.getByText("Missing route")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("img", { name: /analyze ok route is missing/i })).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('[data-authoring-edge-id="analyze.ok"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores analyze.ok without retaining the missing-route marker", () => {
|
||||||
|
renderDiagram("repair");
|
||||||
|
|
||||||
|
expect(screen.getByRole("img", { name: /analyze ok route restored/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Missing route")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector('[data-authoring-edge-id="analyze.ok"]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
ReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
type Edge,
|
||||||
|
type Node,
|
||||||
|
type NodeProps,
|
||||||
|
type NodeTypes,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { CircleStop, Database, SquareFunction, TriangleAlert } from "lucide-react";
|
||||||
|
import type { ReviewedAuthoringEvidence } from "./reviewed-authoring-evidence.js";
|
||||||
|
|
||||||
|
type WorkflowEvidence = Extract<
|
||||||
|
ReviewedAuthoringEvidence,
|
||||||
|
{ readonly kind: "draft" | "diagnostic" | "repair" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type AuthoringWorkflowMode = "draft" | "diagnostic" | "repair";
|
||||||
|
|
||||||
|
type AuthoringNodeData = {
|
||||||
|
readonly label: string;
|
||||||
|
readonly role: "source" | "action" | "outcome";
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthoringWorkflowDiagramProps = {
|
||||||
|
readonly mode: AuthoringWorkflowMode;
|
||||||
|
readonly evidence: WorkflowEvidence;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthoringFlowNode = ({ id, data }: NodeProps<Node<AuthoringNodeData>>) => {
|
||||||
|
const Icon = data.role === "source" ? Database : data.role === "outcome" ? CircleStop : SquareFunction;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.role !== "source" && <Handle type="target" position={Position.Left} />}
|
||||||
|
<div
|
||||||
|
className="authoring-workflow-diagram__node"
|
||||||
|
data-authoring-node-id={id}
|
||||||
|
data-node-role={data.role}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
<strong>{data.label}</strong>
|
||||||
|
</div>
|
||||||
|
{data.role !== "outcome" && <Handle type="source" position={Position.Right} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeTypes: NodeTypes = { authoring: AuthoringFlowNode };
|
||||||
|
|
||||||
|
// Stable positions make Draft -> Diagnose -> Repair read as one changing object.
|
||||||
|
const workflowNodes: Node<AuthoringNodeData>[] = [
|
||||||
|
{
|
||||||
|
id: "read_documents",
|
||||||
|
type: "authoring",
|
||||||
|
position: { x: 0, y: 60 },
|
||||||
|
data: { label: "read_documents", role: "source" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analyze",
|
||||||
|
type: "authoring",
|
||||||
|
position: { x: 310, y: 60 },
|
||||||
|
data: { label: "analyze", role: "action" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "__end__",
|
||||||
|
type: "authoring",
|
||||||
|
position: { x: 620, y: 60 },
|
||||||
|
data: { label: "END", role: "outcome" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const edgesForMode = (mode: AuthoringWorkflowMode): Edge[] => [
|
||||||
|
{
|
||||||
|
id: "read_documents.ok",
|
||||||
|
source: "read_documents",
|
||||||
|
target: "analyze",
|
||||||
|
label: "ok",
|
||||||
|
className: "authoring-workflow-diagram__edge",
|
||||||
|
},
|
||||||
|
...(mode === "diagnostic"
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: "analyze.ok",
|
||||||
|
source: "analyze",
|
||||||
|
target: "__end__",
|
||||||
|
label: mode === "repair" ? "restored · ok" : "ok",
|
||||||
|
animated: mode === "repair",
|
||||||
|
className: `authoring-workflow-diagram__edge${mode === "repair" ? " authoring-workflow-diagram__edge--restored" : ""}`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const accessibleLabelForMode = (mode: AuthoringWorkflowMode): string => {
|
||||||
|
if (mode === "diagnostic") return "Authoring workflow diagram: analyze ok route is missing";
|
||||||
|
if (mode === "repair") return "Authoring workflow diagram: analyze ok route restored";
|
||||||
|
return "Authoring workflow diagram: valid draft routes";
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthoringWorkflowDiagramInner = ({ mode }: AuthoringWorkflowDiagramProps) => (
|
||||||
|
<div
|
||||||
|
className="authoring-workflow-diagram"
|
||||||
|
role="img"
|
||||||
|
aria-label={accessibleLabelForMode(mode)}
|
||||||
|
data-workflow-mode={mode}
|
||||||
|
>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={workflowNodes}
|
||||||
|
edges={edgesForMode(mode)}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.16, minZoom: 0.7, maxZoom: 1.25 }}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable={false}
|
||||||
|
panOnDrag={false}
|
||||||
|
zoomOnScroll={false}
|
||||||
|
zoomOnPinch={false}
|
||||||
|
preventScrolling={false}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
/>
|
||||||
|
<div className="authoring-workflow-diagram__route-state" aria-hidden="true">
|
||||||
|
{mode === "diagnostic" ? (
|
||||||
|
<span className="authoring-workflow-diagram__missing-route">
|
||||||
|
<TriangleAlert />
|
||||||
|
<strong>Missing route</strong>
|
||||||
|
<small>analyze · ok</small>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
data-authoring-edge-id="analyze.ok"
|
||||||
|
data-route-state={mode === "repair" ? "restored" : "present"}
|
||||||
|
>
|
||||||
|
{mode === "repair" ? "Route restored" : "Complete route"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Renders the same authored workflow while its route state changes between beats. */
|
||||||
|
export const AuthoringWorkflowDiagram = (props: AuthoringWorkflowDiagramProps) => (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<AuthoringWorkflowDiagramInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user