feat: browse authoring contract paths
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user