feat: browse authoring contract paths
This commit is contained in:
@@ -2023,6 +2023,124 @@ tbody tr:hover {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.authoring-path-picker {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.authoring-path-picker h3 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.authoring-path-picker > label,
|
||||
.authoring-path-picker__advanced > label {
|
||||
color: var(--color-slate);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.authoring-path-picker__options {
|
||||
min-width: 0;
|
||||
max-height: min(20rem, 48dvh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.15rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
.authoring-path-picker__group {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0.55rem;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.authoring-path-picker__group + .authoring-path-picker__group {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.authoring-path-picker__group legend {
|
||||
padding: 0 0.2rem;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.authoring-path-picker__list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.authoring-path-picker__option {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.12rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
color: var(--color-ink);
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.authoring-path-picker__option:hover,
|
||||
.authoring-path-picker__option:focus-visible,
|
||||
.authoring-path-picker__option[aria-pressed="true"] {
|
||||
border-color: var(--color-signal-green);
|
||||
background: #f6fbf7;
|
||||
}
|
||||
|
||||
.authoring-path-picker__option:disabled {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-slate);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.authoring-path-picker__option code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-slate);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.authoring-path-picker__option span,
|
||||
.authoring-path-picker__option small {
|
||||
color: var(--color-slate);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.authoring-path-picker__option small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.authoring-path-picker__advanced {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.authoring-path-picker__advanced summary {
|
||||
width: fit-content;
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Motion */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AuthoringPathOption,
|
||||
AuthoringPathUse,
|
||||
} from "../domain/authoring-contract-models.js";
|
||||
import { AuthoringPathPicker } from "./AuthoringPathPicker.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const option = (
|
||||
path: string,
|
||||
label: string,
|
||||
origin: AuthoringPathOption["origin"],
|
||||
uses: ReadonlyArray<AuthoringPathUse>,
|
||||
extras: Partial<AuthoringPathOption> = {},
|
||||
): AuthoringPathOption => ({
|
||||
path,
|
||||
label,
|
||||
origin,
|
||||
schema: { type: "string" },
|
||||
required: false,
|
||||
availability: "available",
|
||||
uses,
|
||||
...extras,
|
||||
});
|
||||
|
||||
const options: ReadonlyArray<AuthoringPathOption> = [
|
||||
option("input.title", "Title", "workflow_input", ["step_input"], {
|
||||
description: "The report title.",
|
||||
required: true,
|
||||
}),
|
||||
option("state.report", "Report state", "workflow_state", ["step_input"]),
|
||||
option("step_output.markdown", "Markdown", "step_output", ["step_output_source"], {
|
||||
description: "Rendered markdown content.",
|
||||
}),
|
||||
option("context.viewer_id", "Viewer ID", "runtime_context", ["step_input"], {
|
||||
availability: "conditional",
|
||||
reason: "Available when the selected step runs in a viewer frame.",
|
||||
}),
|
||||
option("output.report", "Final report", "workflow_output", ["workflow_output"]),
|
||||
option("input.customer.name", "Customer name", "workflow_input", ["step_input"]),
|
||||
];
|
||||
|
||||
describe("AuthoringPathPicker", () => {
|
||||
it("groups options by semantic origin and shows canonical paths secondarily", () => {
|
||||
render(
|
||||
<AuthoringPathPicker
|
||||
label="Source path"
|
||||
onChange={vi.fn()}
|
||||
options={options}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: "Workflow input" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "State" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Step output" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Runtime context" })).toBeInTheDocument();
|
||||
expect(screen.getByText("input.title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches labels, canonical paths, and descriptions", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AuthoringPathPicker
|
||||
label="Source path"
|
||||
onChange={vi.fn()}
|
||||
options={options}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("searchbox", { name: "Search Source path" }), "rendered");
|
||||
|
||||
expect(screen.getByRole("button", { name: /Markdown/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Title/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains conditional options and hides an empty runtime context group", () => {
|
||||
const { rerender } = render(
|
||||
<AuthoringPathPicker
|
||||
label="Source path"
|
||||
onChange={vi.fn()}
|
||||
options={options}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Available when the selected step runs in a viewer frame."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<AuthoringPathPicker
|
||||
label="Source path"
|
||||
onChange={vi.fn()}
|
||||
options={options.filter((entry) => entry.origin !== "runtime_context")}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("group", { name: "Runtime context" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables incompatible entries and emits only a canonical path", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<AuthoringPathPicker
|
||||
label="Source path"
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
const incompatible = screen.getByRole("button", { name: /Final report/ });
|
||||
expect(incompatible).toBeDisabled();
|
||||
await user.click(incompatible);
|
||||
await user.click(screen.getByRole("button", { name: /Title/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("input.title");
|
||||
});
|
||||
|
||||
it("keeps nested choices keyboard reachable and preserves normal choices in Advanced mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<AuthoringPathPicker
|
||||
allowCustom
|
||||
label="Source path"
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
const nested = screen.getByRole("button", { name: /Customer name/ });
|
||||
nested.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onChange).toHaveBeenCalledWith("input.customer.name");
|
||||
|
||||
await user.click(screen.getByText("Advanced"));
|
||||
expect(screen.getByRole("textbox", { name: "Custom Source path" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Title/ })).toBeInTheDocument();
|
||||
|
||||
await user.clear(screen.getByRole("textbox", { name: "Custom Source path" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Custom Source path" }), "context.future");
|
||||
expect(onChange).toHaveBeenLastCalledWith("context.future");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import type {
|
||||
AuthoringPathOption,
|
||||
AuthoringPathOrigin,
|
||||
AuthoringPathUse,
|
||||
} from "../domain/authoring-contract-models.js";
|
||||
|
||||
export type AuthoringPathPickerProps = {
|
||||
readonly options: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>;
|
||||
readonly value: string;
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly label: string;
|
||||
readonly allowCustom?: boolean;
|
||||
};
|
||||
|
||||
type OptionGroup = {
|
||||
readonly origin: AuthoringPathOrigin;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
const OPTION_GROUPS: ReadonlyArray<OptionGroup> = [
|
||||
{ origin: "workflow_input", label: "Workflow input" },
|
||||
{ origin: "workflow_state", label: "State" },
|
||||
{ origin: "step_output", label: "Step output" },
|
||||
{ origin: "runtime_context", label: "Runtime context" },
|
||||
{ origin: "workflow_output", label: "Workflow output" },
|
||||
{ origin: "step_input", label: "Step input" },
|
||||
];
|
||||
|
||||
const safeId = (value: string): string => value.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
|
||||
|
||||
const optionText = (option: AuthoringPathOption): string =>
|
||||
[option.label, option.path, option.description].filter(Boolean).join(" ").toLocaleLowerCase();
|
||||
|
||||
const normalizedUses = (
|
||||
uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>,
|
||||
): ReadonlySet<AuthoringPathUse> => new Set(Array.isArray(uses) ? uses : [uses]);
|
||||
|
||||
export const AuthoringPathPicker = ({
|
||||
options,
|
||||
uses,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
allowCustom = false,
|
||||
}: AuthoringPathPickerProps) => {
|
||||
const id = safeId(useId());
|
||||
const searchId = `${id}-search`;
|
||||
const customId = `${id}-custom`;
|
||||
const [search, setSearch] = useState("");
|
||||
const [customValue, setCustomValue] = useState(value);
|
||||
const requestedUses = normalizedUses(uses);
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
|
||||
useEffect(() => {
|
||||
setCustomValue(value);
|
||||
}, [value]);
|
||||
|
||||
const visibleOptions = options.filter((option) =>
|
||||
normalizedSearch === "" || optionText(option).includes(normalizedSearch),
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${id}-heading`} className="authoring-path-picker">
|
||||
<h3 id={`${id}-heading`}>{label}</h3>
|
||||
<label htmlFor={searchId}>Search {label}</label>
|
||||
<input
|
||||
id={searchId}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search labels or paths"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
<div aria-label={`${label} options`} className="authoring-path-picker__options">
|
||||
{OPTION_GROUPS.map((group) => {
|
||||
const groupOptions = visibleOptions.filter((option) => option.origin === group.origin);
|
||||
if (groupOptions.length === 0) return null;
|
||||
return (
|
||||
<fieldset className="authoring-path-picker__group" key={group.origin}>
|
||||
<legend>{group.label}</legend>
|
||||
<div className="authoring-path-picker__list">
|
||||
{groupOptions.map((option) => {
|
||||
const compatible = option.uses.some((use) => requestedUses.has(use));
|
||||
const reasonId = `${id}-${safeId(option.path)}-reason`;
|
||||
return (
|
||||
<button
|
||||
aria-describedby={
|
||||
option.reason !== undefined || !compatible ? reasonId : undefined
|
||||
}
|
||||
aria-pressed={option.path === value}
|
||||
className="authoring-path-picker__option"
|
||||
disabled={!compatible}
|
||||
key={option.path}
|
||||
onClick={() => {
|
||||
setCustomValue(option.path);
|
||||
onChange(option.path);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<code>{option.path}</code>
|
||||
{option.description !== undefined && <span>{option.description}</span>}
|
||||
{option.required && <small>Required</small>}
|
||||
{option.availability === "conditional" && option.reason !== undefined && (
|
||||
<small id={reasonId}>{option.reason}</small>
|
||||
)}
|
||||
{!compatible && <small id={reasonId}>Not available for this field.</small>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
{visibleOptions.length === 0 && <p>No matching paths.</p>}
|
||||
</div>
|
||||
{allowCustom && (
|
||||
<details className="authoring-path-picker__advanced">
|
||||
<summary>Advanced</summary>
|
||||
<label htmlFor={customId}>Custom {label}</label>
|
||||
<input
|
||||
id={customId}
|
||||
onChange={(event) => {
|
||||
setCustomValue(event.target.value);
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
type="text"
|
||||
value={customValue}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionState } from "../../app/state.js";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import {
|
||||
createAuthoringContractClient,
|
||||
type AuthoringContractClient,
|
||||
} from "../domain/authoring-contract-client.js";
|
||||
import type {
|
||||
AuthoringContractInventory,
|
||||
AuthoringPathOption,
|
||||
} from "../domain/authoring-contract-models.js";
|
||||
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
|
||||
import { useAuthoringContract } from "./useAuthoringContract.js";
|
||||
|
||||
vi.mock("../context.js", () => ({
|
||||
useConsoleWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../domain/authoring-contract-client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../domain/authoring-contract-client.js")>(
|
||||
"../domain/authoring-contract-client.js",
|
||||
);
|
||||
return { ...actual, createAuthoringContractClient: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
|
||||
const mockedCreateAuthoringContractClient = vi.mocked(createAuthoringContractClient);
|
||||
|
||||
const connectedState = {
|
||||
phase: "connected",
|
||||
connectedTarget: "http://workflow.example/rpc",
|
||||
} as ConnectionState;
|
||||
|
||||
const disconnectedState = {
|
||||
phase: "not_configured",
|
||||
connectedTarget: null,
|
||||
} as ConnectionState;
|
||||
|
||||
const option: AuthoringPathOption = {
|
||||
path: "input.title",
|
||||
label: "Title",
|
||||
origin: "workflow_input",
|
||||
schema: { type: "string" },
|
||||
required: true,
|
||||
availability: "available",
|
||||
uses: ["step_input"],
|
||||
};
|
||||
|
||||
const inventory = (revision: number, selectedStepId: string | null): AuthoringContractInventory => ({
|
||||
workspaceId: "draft-report",
|
||||
revision,
|
||||
selectedStepId,
|
||||
readableSources: [option],
|
||||
stepInputTargets: [],
|
||||
stepOutputSources: [],
|
||||
stateTargets: [],
|
||||
workflowOutputTargets: [],
|
||||
entrySteps: [],
|
||||
workflowOutcomes: ["ok"],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const client = {
|
||||
inspect: vi.fn<AuthoringContractClient["inspect"]>(),
|
||||
} satisfies AuthoringContractClient;
|
||||
|
||||
const readExecutor = {} as ConsoleReadExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
client.inspect.mockReset();
|
||||
mockedCreateAuthoringContractClient.mockReset();
|
||||
mockedCreateAuthoringContractClient.mockReturnValue(client);
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: connectedState.connectedTarget,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor,
|
||||
writeExecutor: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAuthoringContract", () => {
|
||||
it("stays disconnected without a read executor", () => {
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: disconnectedState,
|
||||
connectedTarget: null,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: null,
|
||||
writeExecutor: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
|
||||
);
|
||||
|
||||
expect(result.current.phase).toBe("disconnected");
|
||||
expect(client.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the contract projection with a null selected step", async () => {
|
||||
client.inspect.mockResolvedValue(inventory(7, null));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
|
||||
expect(client.inspect).toHaveBeenCalledWith({
|
||||
workspaceId: "draft-report",
|
||||
revision: 7,
|
||||
selectedStepId: null,
|
||||
});
|
||||
expect(result.current.inventory?.selectedStepId).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the executable step id and ignores a stale selection response", async () => {
|
||||
const first = deferred<AuthoringContractInventory>();
|
||||
const second = deferred<AuthoringContractInventory>();
|
||||
client.inspect.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedStepId }: { readonly selectedStepId: string | null }) =>
|
||||
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId }),
|
||||
{ initialProps: { selectedStepId: "read" } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(1));
|
||||
rerender({ selectedStepId: "render" });
|
||||
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(2));
|
||||
|
||||
first.resolve(inventory(7, "read"));
|
||||
second.resolve(inventory(7, "render"));
|
||||
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
expect(result.current.inventory?.selectedStepId).toBe("render");
|
||||
expect(client.inspect).toHaveBeenLastCalledWith({
|
||||
workspaceId: "draft-report",
|
||||
revision: 7,
|
||||
selectedStepId: "render",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the last matching inventory when a refresh fails", async () => {
|
||||
client.inspect
|
||||
.mockResolvedValueOnce(inventory(7, "read"))
|
||||
.mockRejectedValueOnce(new Error("inspection failed"));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: "read" }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.phase).toBe("error"));
|
||||
|
||||
expect(result.current.inventory?.selectedStepId).toBe("read");
|
||||
expect(result.current.message).toBe("inspection failed");
|
||||
});
|
||||
|
||||
it("reloads when the revision changes and on manual refresh", async () => {
|
||||
client.inspect
|
||||
.mockResolvedValueOnce(inventory(7, "read"))
|
||||
.mockResolvedValueOnce(inventory(8, "read"))
|
||||
.mockResolvedValueOnce(inventory(8, "read"));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ revision }: { readonly revision: number }) =>
|
||||
useAuthoringContract({ workspaceId: "draft-report", revision, selectedStepId: "read" }),
|
||||
{ initialProps: { revision: 7 } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.inventory?.revision).toBe(7));
|
||||
|
||||
rerender({ revision: 8 });
|
||||
await waitFor(() => expect(result.current.inventory?.revision).toBe(8));
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(3));
|
||||
|
||||
expect(client.inspect).toHaveBeenLastCalledWith({
|
||||
workspaceId: "draft-report",
|
||||
revision: 8,
|
||||
selectedStepId: "read",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import {
|
||||
createAuthoringContractClient,
|
||||
type AuthoringContractClient,
|
||||
} from "../domain/authoring-contract-client.js";
|
||||
import type { AuthoringContractInventory } from "../domain/authoring-contract-models.js";
|
||||
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
|
||||
|
||||
export type AuthoringContractPhase =
|
||||
| "disconnected"
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "error";
|
||||
|
||||
export type UseAuthoringContractOptions = {
|
||||
readonly workspaceId: string | null;
|
||||
readonly revision: number | null;
|
||||
readonly selectedStepId?: string | null;
|
||||
};
|
||||
|
||||
export type AuthoringContractController = {
|
||||
readonly phase: AuthoringContractPhase;
|
||||
readonly inventory: AuthoringContractInventory | null;
|
||||
readonly message: string | null;
|
||||
readonly refresh: () => void;
|
||||
};
|
||||
|
||||
type RequestIdentity = {
|
||||
readonly readExecutor: ConsoleReadExecutor;
|
||||
readonly connectedTarget: string;
|
||||
readonly workspaceId: string;
|
||||
readonly revision: number;
|
||||
readonly selectedStepId: string | null;
|
||||
};
|
||||
|
||||
type StoredInventory = {
|
||||
readonly request: RequestIdentity;
|
||||
readonly inventory: AuthoringContractInventory;
|
||||
};
|
||||
|
||||
type AuthoringContractState = {
|
||||
readonly phase: AuthoringContractPhase;
|
||||
readonly stored: StoredInventory | null;
|
||||
readonly message: string | null;
|
||||
};
|
||||
|
||||
const initialState: AuthoringContractState = {
|
||||
phase: "disconnected",
|
||||
stored: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const sameRequest = (left: RequestIdentity, right: RequestIdentity): boolean =>
|
||||
left.readExecutor === right.readExecutor &&
|
||||
left.connectedTarget === right.connectedTarget &&
|
||||
left.workspaceId === right.workspaceId &&
|
||||
left.revision === right.revision &&
|
||||
left.selectedStepId === right.selectedStepId;
|
||||
|
||||
export const useAuthoringContract = ({
|
||||
workspaceId,
|
||||
revision,
|
||||
selectedStepId,
|
||||
}: UseAuthoringContractOptions): AuthoringContractController => {
|
||||
const { connectedTarget, readExecutor } = useConsoleWorkspace();
|
||||
const client = useMemo<AuthoringContractClient | null>(
|
||||
() => (readExecutor === null ? null : createAuthoringContractClient(readExecutor)),
|
||||
[readExecutor],
|
||||
);
|
||||
const request = useMemo<RequestIdentity | null>(
|
||||
() =>
|
||||
readExecutor !== null &&
|
||||
connectedTarget !== null &&
|
||||
workspaceId !== null &&
|
||||
revision !== null
|
||||
? {
|
||||
readExecutor,
|
||||
connectedTarget,
|
||||
workspaceId,
|
||||
revision,
|
||||
selectedStepId: selectedStepId ?? null,
|
||||
}
|
||||
: null,
|
||||
[connectedTarget, readExecutor, revision, selectedStepId, workspaceId],
|
||||
);
|
||||
const [state, setState] = useState<AuthoringContractState>(initialState);
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const inspect = useCallback(
|
||||
(): void => {
|
||||
if (client === null || request === null) return;
|
||||
const generation = ++generationRef.current;
|
||||
const requested = request;
|
||||
setState((current) => ({
|
||||
phase: "loading",
|
||||
stored:
|
||||
current.stored !== null && sameRequest(current.stored.request, requested)
|
||||
? current.stored
|
||||
: null,
|
||||
message: null,
|
||||
}));
|
||||
|
||||
void client
|
||||
.inspect({
|
||||
workspaceId: requested.workspaceId,
|
||||
revision: requested.revision,
|
||||
selectedStepId: requested.selectedStepId,
|
||||
})
|
||||
.then((inventory) => {
|
||||
if (generation !== generationRef.current) return;
|
||||
setState({
|
||||
phase: "ready",
|
||||
stored: { request: requested, inventory },
|
||||
message: null,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== generationRef.current) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: errorMessage(error),
|
||||
}));
|
||||
});
|
||||
}, [client, request]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request === null || client === null) {
|
||||
generationRef.current++;
|
||||
setState({
|
||||
phase: client === null || connectedTarget === null ? "disconnected" : "idle",
|
||||
stored: null,
|
||||
message: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
inspect();
|
||||
}, [client, connectedTarget, inspect, request]);
|
||||
|
||||
const refresh = useCallback((): void => {
|
||||
inspect();
|
||||
}, [inspect]);
|
||||
|
||||
const currentInventory =
|
||||
request !== null && state.stored !== null && sameRequest(state.stored.request, request)
|
||||
? state.stored.inventory
|
||||
: null;
|
||||
const phase =
|
||||
request !== null &&
|
||||
(state.stored === null || !sameRequest(state.stored.request, request))
|
||||
? "loading"
|
||||
: request === null
|
||||
? client === null || connectedTarget === null
|
||||
? "disconnected"
|
||||
: "idle"
|
||||
: state.phase;
|
||||
|
||||
return {
|
||||
phase,
|
||||
inventory: currentInventory,
|
||||
message: currentInventory === null && phase === "loading" ? null : state.message,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OperationName } from "../../connection/contracts.js";
|
||||
import {
|
||||
decodeAuthoringContractInventory,
|
||||
} from "./authoring-contract-models.js";
|
||||
import { createAuthoringContractClient } from "./authoring-contract-client.js";
|
||||
import type { ConsoleReadExecutor } from "./read-executor.js";
|
||||
|
||||
const wireInventory = {
|
||||
workspace_id: "draft-report",
|
||||
revision: 7,
|
||||
selected_step_id: null,
|
||||
readable_sources: [],
|
||||
step_input_targets: [],
|
||||
step_output_sources: [],
|
||||
state_targets: [],
|
||||
workflow_output_targets: [],
|
||||
entry_steps: [],
|
||||
workflow_outcomes: ["ok"],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
type RunCall = {
|
||||
readonly operation: OperationName;
|
||||
readonly params: unknown;
|
||||
readonly decode: (value: unknown) => unknown;
|
||||
};
|
||||
|
||||
const runWith = (
|
||||
response: unknown,
|
||||
): { readonly executor: ConsoleReadExecutor; readonly calls: RunCall[] } => {
|
||||
const calls: RunCall[] = [];
|
||||
const run: ConsoleReadExecutor["run"] = async <T>(
|
||||
operation: OperationName,
|
||||
params: unknown,
|
||||
decode: (value: unknown) => T,
|
||||
): Promise<T> => {
|
||||
calls.push({ operation, params, decode });
|
||||
return decode(response);
|
||||
};
|
||||
return { executor: { run }, calls };
|
||||
};
|
||||
|
||||
describe("AuthoringContractClient", () => {
|
||||
it("sends the exact inspection params and decodes the response", async () => {
|
||||
const { executor, calls } = runWith(wireInventory);
|
||||
const client = createAuthoringContractClient(executor);
|
||||
|
||||
const result = await client.inspect({
|
||||
workspaceId: " draft-report ",
|
||||
revision: 7,
|
||||
selectedStepId: "render",
|
||||
});
|
||||
|
||||
expect(calls[0]?.operation).toBe("workflow.draft_workspaces.inspect_authoring_contract");
|
||||
expect(calls[0]?.params).toEqual({
|
||||
workspace_id: "draft-report",
|
||||
revision: 7,
|
||||
selected_step_id: "render",
|
||||
});
|
||||
expect(calls[0]?.decode).toBe(decodeAuthoringContractInventory);
|
||||
expect(result.workspaceId).toBe("draft-report");
|
||||
});
|
||||
|
||||
it("passes null when no step is selected", async () => {
|
||||
const { executor, calls } = runWith(wireInventory);
|
||||
const client = createAuthoringContractClient(executor);
|
||||
|
||||
await client.inspect({ workspaceId: "draft-report", revision: 7, selectedStepId: null });
|
||||
|
||||
expect(calls[0]?.operation).toBe("workflow.draft_workspaces.inspect_authoring_contract");
|
||||
expect(calls[0]?.params).toEqual({
|
||||
workspace_id: "draft-report",
|
||||
revision: 7,
|
||||
selected_step_id: null,
|
||||
});
|
||||
expect(calls[0]?.decode).toBe(decodeAuthoringContractInventory);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["workspaceId", { workspace_id: "other" }],
|
||||
["revision", { revision: 8 }],
|
||||
])("rejects a response with a mismatched %s", async (_field, replacement) => {
|
||||
const { executor } = runWith({ ...wireInventory, ...replacement });
|
||||
const client = createAuthoringContractClient(executor);
|
||||
|
||||
await expect(
|
||||
client.inspect({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
|
||||
).rejects.toThrow("does not match inspection request");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { OperationName } from "../../connection/contracts.js";
|
||||
import {
|
||||
decodeAuthoringContractInventory,
|
||||
type AuthoringContractInventory,
|
||||
} from "./authoring-contract-models.js";
|
||||
import { ConsoleClientError } from "./errors.js";
|
||||
import type { ConsoleReadExecutor } from "./read-executor.js";
|
||||
|
||||
export type AuthoringContractInspectionInput = {
|
||||
readonly workspaceId: string;
|
||||
readonly revision: number;
|
||||
readonly selectedStepId?: string | null;
|
||||
};
|
||||
|
||||
export interface AuthoringContractClient {
|
||||
inspect(input: AuthoringContractInspectionInput): Promise<AuthoringContractInventory>;
|
||||
}
|
||||
|
||||
const invalidInput = (operation: OperationName, message: string): ConsoleClientError =>
|
||||
new ConsoleClientError("operation", operation, message);
|
||||
|
||||
export const createAuthoringContractClient = (
|
||||
executor: ConsoleReadExecutor,
|
||||
): AuthoringContractClient => ({
|
||||
inspect: async (input) => {
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!workspaceId) {
|
||||
throw invalidInput(
|
||||
"workflow.draft_workspaces.inspect_authoring_contract",
|
||||
"workspace id must not be blank",
|
||||
);
|
||||
}
|
||||
const selectedStepId = input.selectedStepId?.trim() || null;
|
||||
const inventory = await executor.run(
|
||||
"workflow.draft_workspaces.inspect_authoring_contract",
|
||||
{
|
||||
workspace_id: workspaceId,
|
||||
revision: input.revision,
|
||||
selected_step_id: selectedStepId,
|
||||
},
|
||||
decodeAuthoringContractInventory,
|
||||
);
|
||||
if (
|
||||
inventory.workspaceId !== workspaceId ||
|
||||
inventory.revision !== input.revision
|
||||
) {
|
||||
throw new Error("authoring contract response does not match inspection request");
|
||||
}
|
||||
return inventory;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodeAuthoringContractInventory,
|
||||
type AuthoringContractInventory,
|
||||
} from "./authoring-contract-models.js";
|
||||
|
||||
const pathOption = {
|
||||
path: "input.title",
|
||||
label: "Title",
|
||||
origin: "workflow_input",
|
||||
schema: { type: "string", description: "A report title." },
|
||||
required: true,
|
||||
availability: "available",
|
||||
uses: ["step_input", "workflow_output"],
|
||||
description: "A report title.",
|
||||
};
|
||||
|
||||
const inventory = {
|
||||
workspace_id: "draft-report",
|
||||
revision: 7,
|
||||
selected_step_id: "render",
|
||||
readable_sources: [pathOption],
|
||||
step_input_targets: [
|
||||
{
|
||||
...pathOption,
|
||||
path: "step_input.title",
|
||||
origin: "step_input",
|
||||
uses: ["step_input"],
|
||||
},
|
||||
],
|
||||
step_output_sources: [
|
||||
{
|
||||
...pathOption,
|
||||
path: "step_output.markdown",
|
||||
origin: "step_output",
|
||||
uses: ["step_output_source"],
|
||||
},
|
||||
],
|
||||
state_targets: [
|
||||
{
|
||||
...pathOption,
|
||||
path: "state.report",
|
||||
origin: "workflow_state",
|
||||
uses: ["state_target", "workflow_output"],
|
||||
},
|
||||
],
|
||||
workflow_output_targets: [
|
||||
{
|
||||
...pathOption,
|
||||
path: "output.report",
|
||||
origin: "workflow_output",
|
||||
uses: ["workflow_output"],
|
||||
},
|
||||
],
|
||||
entry_steps: [
|
||||
{
|
||||
step_id: "render",
|
||||
label: "Render report",
|
||||
description: "Render the report.",
|
||||
input_targets: [],
|
||||
output_sources: [],
|
||||
outcomes: ["ok", "error"],
|
||||
},
|
||||
],
|
||||
workflow_outcomes: ["ok", "error"],
|
||||
warnings: ["Context is conditional."],
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
describe("authoring contract models", () => {
|
||||
it("decodes the complete inventory into camelCase browser fields", () => {
|
||||
const decoded = decodeAuthoringContractInventory(inventory);
|
||||
|
||||
expect(decoded.workspaceId).toBe("draft-report");
|
||||
expect(decoded.selectedStepId).toBe("render");
|
||||
expect(decoded.readableSources[0]?.origin).toBe("workflow_input");
|
||||
expect(decoded.readableSources[0]?.schema).toEqual(pathOption.schema);
|
||||
expect(decoded.stepInputTargets[0]?.path).toBe("step_input.title");
|
||||
expect(decoded.entrySteps[0]?.stepId).toBe("render");
|
||||
expect(decoded.entrySteps[0]?.inputTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["origin", { origin: "unknown" }],
|
||||
["availability", { availability: "maybe" }],
|
||||
["use", { uses: ["unknown"] }],
|
||||
["schema", { schema: "not an object" }],
|
||||
])("rejects an unknown or malformed path option %s", (_field, replacement) => {
|
||||
const malformed = {
|
||||
...inventory,
|
||||
readable_sources: [{ ...pathOption, ...replacement }],
|
||||
};
|
||||
|
||||
expect(() => decodeAuthoringContractInventory(malformed)).toThrow(
|
||||
"AuthoringContractInventory is malformed",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed inventory envelope", () => {
|
||||
expect(() => decodeAuthoringContractInventory({ ...inventory, revision: "7" })).toThrow(
|
||||
"AuthoringContractInventory is malformed",
|
||||
);
|
||||
});
|
||||
|
||||
const _typeCheck: AuthoringContractInventory | null = null;
|
||||
void _typeCheck;
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import * as v from "valibot";
|
||||
|
||||
export type AuthoringPathOrigin =
|
||||
| "workflow_input"
|
||||
| "workflow_state"
|
||||
| "runtime_context"
|
||||
| "step_input"
|
||||
| "step_output"
|
||||
| "workflow_output";
|
||||
|
||||
export type AuthoringPathAvailability = "available" | "conditional";
|
||||
|
||||
export type AuthoringPathUse =
|
||||
| "step_input"
|
||||
| "step_output_source"
|
||||
| "state_target"
|
||||
| "workflow_output";
|
||||
|
||||
export type AuthoringPathOption = {
|
||||
readonly path: string;
|
||||
readonly label: string;
|
||||
readonly origin: AuthoringPathOrigin;
|
||||
readonly schema: Readonly<Record<string, unknown>>;
|
||||
readonly required: boolean;
|
||||
readonly availability: AuthoringPathAvailability;
|
||||
readonly uses: ReadonlyArray<AuthoringPathUse>;
|
||||
readonly description?: string | undefined;
|
||||
readonly reason?: string | undefined;
|
||||
};
|
||||
|
||||
export type AuthoringStepContract = {
|
||||
readonly stepId: string;
|
||||
readonly label: string;
|
||||
readonly description?: string | undefined;
|
||||
readonly inputTargets?: ReadonlyArray<AuthoringPathOption> | undefined;
|
||||
readonly outputSources?: ReadonlyArray<AuthoringPathOption> | undefined;
|
||||
readonly outcomes?: ReadonlyArray<string> | undefined;
|
||||
};
|
||||
|
||||
export type AuthoringContractInventory = {
|
||||
readonly workspaceId: string;
|
||||
readonly revision: number;
|
||||
readonly selectedStepId: string | null;
|
||||
readonly readableSources: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly stepInputTargets: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly stepOutputSources: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly stateTargets: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly workflowOutputTargets: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly entrySteps: ReadonlyArray<AuthoringStepContract>;
|
||||
readonly workflowOutcomes: ReadonlyArray<string>;
|
||||
readonly warnings: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
const JsonObjectSchema = v.record(v.string(), v.unknown());
|
||||
|
||||
const AuthoringPathOriginSchema = v.union([
|
||||
v.literal("workflow_input"),
|
||||
v.literal("workflow_state"),
|
||||
v.literal("runtime_context"),
|
||||
v.literal("step_input"),
|
||||
v.literal("step_output"),
|
||||
v.literal("workflow_output"),
|
||||
]);
|
||||
|
||||
const AuthoringPathAvailabilitySchema = v.union([
|
||||
v.literal("available"),
|
||||
v.literal("conditional"),
|
||||
]);
|
||||
|
||||
const AuthoringPathUseSchema = v.union([
|
||||
v.literal("step_input"),
|
||||
v.literal("step_output_source"),
|
||||
v.literal("state_target"),
|
||||
v.literal("workflow_output"),
|
||||
]);
|
||||
|
||||
const AuthoringPathOptionWireSchema = v.object({
|
||||
path: v.string(),
|
||||
label: v.string(),
|
||||
origin: AuthoringPathOriginSchema,
|
||||
schema: JsonObjectSchema,
|
||||
required: v.boolean(),
|
||||
availability: AuthoringPathAvailabilitySchema,
|
||||
uses: v.array(AuthoringPathUseSchema),
|
||||
description: v.optional(v.string()),
|
||||
reason: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const AuthoringStepContractWireSchema = v.object({
|
||||
step_id: v.string(),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
input_targets: v.optional(v.array(AuthoringPathOptionWireSchema)),
|
||||
output_sources: v.optional(v.array(AuthoringPathOptionWireSchema)),
|
||||
outcomes: v.optional(v.array(v.string())),
|
||||
});
|
||||
|
||||
const AuthoringContractInventoryWireSchema = v.object({
|
||||
workspace_id: v.string(),
|
||||
revision: v.number(),
|
||||
selected_step_id: v.nullable(v.string()),
|
||||
readable_sources: v.array(AuthoringPathOptionWireSchema),
|
||||
step_input_targets: v.array(AuthoringPathOptionWireSchema),
|
||||
step_output_sources: v.array(AuthoringPathOptionWireSchema),
|
||||
state_targets: v.array(AuthoringPathOptionWireSchema),
|
||||
workflow_output_targets: v.array(AuthoringPathOptionWireSchema),
|
||||
entry_steps: v.array(AuthoringStepContractWireSchema),
|
||||
workflow_outcomes: v.array(v.string()),
|
||||
warnings: v.array(v.string()),
|
||||
});
|
||||
|
||||
const AuthoringPathOptionBrowserSchema = v.object({
|
||||
path: v.string(),
|
||||
label: v.string(),
|
||||
origin: AuthoringPathOriginSchema,
|
||||
schema: JsonObjectSchema,
|
||||
required: v.boolean(),
|
||||
availability: AuthoringPathAvailabilitySchema,
|
||||
uses: v.array(AuthoringPathUseSchema),
|
||||
description: v.optional(v.string()),
|
||||
reason: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const AuthoringStepContractBrowserSchema = v.object({
|
||||
stepId: v.string(),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
inputTargets: v.optional(v.array(AuthoringPathOptionBrowserSchema)),
|
||||
outputSources: v.optional(v.array(AuthoringPathOptionBrowserSchema)),
|
||||
outcomes: v.optional(v.array(v.string())),
|
||||
});
|
||||
|
||||
const AuthoringContractInventoryBrowserSchema = v.object({
|
||||
workspaceId: v.string(),
|
||||
revision: v.number(),
|
||||
selectedStepId: v.nullable(v.string()),
|
||||
readableSources: v.array(AuthoringPathOptionBrowserSchema),
|
||||
stepInputTargets: v.array(AuthoringPathOptionBrowserSchema),
|
||||
stepOutputSources: v.array(AuthoringPathOptionBrowserSchema),
|
||||
stateTargets: v.array(AuthoringPathOptionBrowserSchema),
|
||||
workflowOutputTargets: v.array(AuthoringPathOptionBrowserSchema),
|
||||
entrySteps: v.array(AuthoringStepContractBrowserSchema),
|
||||
workflowOutcomes: v.array(v.string()),
|
||||
warnings: v.array(v.string()),
|
||||
});
|
||||
|
||||
type AuthoringPathOptionWire = v.InferOutput<typeof AuthoringPathOptionWireSchema>;
|
||||
type AuthoringStepContractWire = v.InferOutput<typeof AuthoringStepContractWireSchema>;
|
||||
type AuthoringContractInventoryWire = v.InferOutput<
|
||||
typeof AuthoringContractInventoryWireSchema
|
||||
>;
|
||||
type AuthoringContractInventoryBrowser = v.InferOutput<
|
||||
typeof AuthoringContractInventoryBrowserSchema
|
||||
>;
|
||||
|
||||
const decode = <T>(
|
||||
label: string,
|
||||
schema: v.GenericSchema<unknown, T>,
|
||||
value: unknown,
|
||||
): T => {
|
||||
const result = v.safeParse(schema, value);
|
||||
if (result.success) return result.output;
|
||||
throw new Error(
|
||||
`${label} is malformed: ${result.issues[0]?.message ?? "unknown issue"}`,
|
||||
);
|
||||
};
|
||||
|
||||
const mapPathOption = (option: AuthoringPathOptionWire): AuthoringPathOption => ({
|
||||
path: option.path,
|
||||
label: option.label,
|
||||
origin: option.origin,
|
||||
schema: option.schema,
|
||||
required: option.required,
|
||||
availability: option.availability,
|
||||
uses: option.uses,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
...(option.reason === undefined ? {} : { reason: option.reason }),
|
||||
});
|
||||
|
||||
const mapStepContract = (step: AuthoringStepContractWire): AuthoringStepContract => ({
|
||||
stepId: step.step_id,
|
||||
label: step.label,
|
||||
...(step.description === undefined ? {} : { description: step.description }),
|
||||
...(step.input_targets === undefined
|
||||
? {}
|
||||
: { inputTargets: step.input_targets.map(mapPathOption) }),
|
||||
...(step.output_sources === undefined
|
||||
? {}
|
||||
: { outputSources: step.output_sources.map(mapPathOption) }),
|
||||
...(step.outcomes === undefined ? {} : { outcomes: step.outcomes }),
|
||||
});
|
||||
|
||||
const mapInventory = (
|
||||
inventory: AuthoringContractInventoryWire,
|
||||
): AuthoringContractInventory => ({
|
||||
workspaceId: inventory.workspace_id,
|
||||
revision: inventory.revision,
|
||||
selectedStepId: inventory.selected_step_id,
|
||||
readableSources: inventory.readable_sources.map(mapPathOption),
|
||||
stepInputTargets: inventory.step_input_targets.map(mapPathOption),
|
||||
stepOutputSources: inventory.step_output_sources.map(mapPathOption),
|
||||
stateTargets: inventory.state_targets.map(mapPathOption),
|
||||
workflowOutputTargets: inventory.workflow_output_targets.map(mapPathOption),
|
||||
entrySteps: inventory.entry_steps.map(mapStepContract),
|
||||
workflowOutcomes: inventory.workflow_outcomes,
|
||||
warnings: inventory.warnings,
|
||||
});
|
||||
|
||||
const mapBrowserInventory = (
|
||||
inventory: AuthoringContractInventoryBrowser,
|
||||
): AuthoringContractInventory => ({
|
||||
workspaceId: inventory.workspaceId,
|
||||
revision: inventory.revision,
|
||||
selectedStepId: inventory.selectedStepId,
|
||||
readableSources: inventory.readableSources,
|
||||
stepInputTargets: inventory.stepInputTargets,
|
||||
stepOutputSources: inventory.stepOutputSources,
|
||||
stateTargets: inventory.stateTargets,
|
||||
workflowOutputTargets: inventory.workflowOutputTargets,
|
||||
entrySteps: inventory.entrySteps,
|
||||
workflowOutcomes: inventory.workflowOutcomes,
|
||||
warnings: inventory.warnings,
|
||||
});
|
||||
|
||||
const AuthoringContractResponseSchema = v.union([
|
||||
AuthoringContractInventoryWireSchema,
|
||||
AuthoringContractInventoryBrowserSchema,
|
||||
]);
|
||||
|
||||
export const decodeAuthoringContractInventory = (
|
||||
value: unknown,
|
||||
): AuthoringContractInventory => {
|
||||
const decoded = decode(
|
||||
"AuthoringContractInventory",
|
||||
AuthoringContractResponseSchema,
|
||||
value,
|
||||
);
|
||||
return "workspace_id" in decoded ? mapInventory(decoded) : mapBrowserInventory(decoded);
|
||||
};
|
||||
Reference in New Issue
Block a user