refactor: route console lifecycle workspace

This commit is contained in:
lda
2026-08-04 23:14:31 +07:00 Verified
parent 7f01bd1915
commit f14d9f1278
22 changed files with 813 additions and 1487 deletions
@@ -0,0 +1,154 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LifecycleExplorerController } from "../../lifecycle/useLifecycleExplorer.js";
import type { LifecycleState } from "../../lifecycle/state.js";
import { LifecycleRoute } from "./LifecycleRoute.js";
const { mockCreateLifecycleClients, mockUseLifecycleExplorer } = vi.hoisted(() => ({
mockCreateLifecycleClients: vi.fn(),
mockUseLifecycleExplorer: vi.fn(),
}));
vi.mock("../context.js", () => ({
useConsoleWorkspace: () => ({
readExecutor: { run: vi.fn() },
}),
}));
vi.mock("../domain/lifecycle-clients.js", () => ({
createLifecycleClients: mockCreateLifecycleClients,
}));
vi.mock("../../lifecycle/useLifecycleExplorer.js", () => ({
useLifecycleExplorer: mockUseLifecycleExplorer,
}));
const emptyState = (): LifecycleState => ({
artifactList: {
phase: "loaded",
value: { items: [], total: 0, nextCursor: null },
},
deploymentList: { phase: "loaded", value: { items: [] } },
runList: {
phase: "loaded",
value: { items: [], total: 0, nextCursor: null },
},
selectedArtifactId: null,
artifactDetail: null,
selectedDeploymentId: null,
deploymentDetail: null,
deploymentValidation: null,
selectedRunId: null,
runDetail: null,
trace: null,
errors: [],
});
const controller = (state: LifecycleState = emptyState()): LifecycleExplorerController => ({
state,
selectArtifact: vi.fn(),
selectDeployment: vi.fn(),
selectRun: vi.fn(),
refresh: vi.fn(),
loadMoreArtifacts: vi.fn(),
loadMoreRuns: vi.fn(),
loadTrace: vi.fn(),
});
const LocationProbe = () => <output data-testid="location">{useLocation().pathname}</output>;
const renderRoute = (entry: string) =>
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/console/artifacts" element={<LifecycleRoute kind="artifact" />} />
<Route path="/console/artifacts/:artifactId/:version" element={<LifecycleRoute kind="artifact" />} />
<Route path="/console/deployments" element={<LifecycleRoute kind="deployment" />} />
<Route path="/console/deployments/:deploymentId" element={<LifecycleRoute kind="deployment" />} />
<Route path="/console/runs" element={<LifecycleRoute kind="run" />} />
<Route path="/console/runs/:runId" element={<LifecycleRoute kind="run" />} />
</Routes>
<LocationProbe />
</MemoryRouter>,
);
beforeEach(() => {
mockCreateLifecycleClients.mockReset().mockReturnValue({
artifacts: {},
deployments: {},
runs: {},
});
mockUseLifecycleExplorer.mockReset().mockReturnValue(controller());
});
afterEach(() => cleanup());
describe("LifecycleRoute", () => {
it.each([
["/console/artifacts/report/2", "artifact", "selectArtifact", "report@2", "Artifacts"],
["/console/deployments/report.default", "deployment", "selectDeployment", "report.default", "Deployments"],
["/console/runs/run_123", "run", "selectRun", "run_123", "Runs"],
] as const)("synchronizes %s with the %s selection", async (entry, kind, selector, identity, heading) => {
const currentController = controller();
mockUseLifecycleExplorer.mockReturnValue(currentController);
renderRoute(entry);
expect(await screen.findByRole("heading", { name: heading, level: 1 })).toBeInTheDocument();
await waitFor(() => {
expect(currentController[selector]).toHaveBeenCalledWith(identity);
});
expect(mockCreateLifecycleClients).toHaveBeenCalledTimes(1);
expect(mockUseLifecycleExplorer).toHaveBeenCalledWith(
expect.objectContaining({ artifacts: {}, deployments: {}, runs: {} }),
);
expect(kind).toBeDefined();
});
it("renders a collection without selecting a record", async () => {
const currentController = controller();
mockUseLifecycleExplorer.mockReturnValue(currentController);
renderRoute("/console/artifacts");
expect(await screen.findByRole("heading", { name: "Artifacts", level: 1 })).toBeInTheDocument();
expect(currentController.selectArtifact).not.toHaveBeenCalled();
});
it("navigates to the canonical detail URL before selection follows it", async () => {
const currentController = controller({
...emptyState(),
artifactList: {
phase: "loaded",
value: {
items: [
{
key: "report@2",
artifactId: "report",
version: 2,
kind: "workflow",
displayName: "Report",
description: null,
outcomes: ["ok"],
requiredSources: [],
diagnosticCount: 0,
},
],
total: 1,
nextCursor: null,
},
},
});
mockUseLifecycleExplorer.mockReturnValue(currentController);
renderRoute("/console/artifacts");
await userEvent.click(screen.getByRole("option", { name: /Report version 2/i }));
await waitFor(() => {
expect(screen.getByTestId("location")).toHaveTextContent("/console/artifacts/report/2");
expect(currentController.selectArtifact).toHaveBeenCalledWith("report@2");
});
});
});
@@ -0,0 +1,147 @@
import { useCallback, useEffect, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LifecycleExplorer } from "../../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../../lifecycle/useLifecycleExplorer.js";
import { createLifecycleClients } from "../domain/lifecycle-clients.js";
import { useConsoleWorkspace } from "../context.js";
export type LifecycleRouteKind = "artifact" | "deployment" | "run";
type LifecycleRouteProps = {
readonly kind: LifecycleRouteKind;
};
const labels: Record<LifecycleRouteKind, string> = {
artifact: "Artifacts",
deployment: "Deployments",
run: "Runs",
};
const artifactPathFor = (artifactKey: string): string => {
const separator = artifactKey.lastIndexOf("@");
if (separator <= 0 || separator === artifactKey.length - 1) return "/console/artifacts";
return `/console/artifacts/${encodeURIComponent(artifactKey.slice(0, separator))}/${encodeURIComponent(artifactKey.slice(separator + 1))}`;
};
const decodeRouteParam = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
export const LifecycleRoute = ({ kind }: LifecycleRouteProps) => {
const { readExecutor } = useConsoleWorkspace();
const navigate = useNavigate();
const params = useParams<{
readonly artifactId?: string;
readonly version?: string;
readonly deploymentId?: string;
readonly runId?: string;
}>();
const clients = useMemo(
() => (readExecutor ? createLifecycleClients(readExecutor) : null),
[readExecutor],
);
const controller = useLifecycleExplorer(clients);
const {
state,
selectArtifact,
selectDeployment,
selectRun,
} = controller;
const artifactIdentity =
kind === "artifact" && params.artifactId && params.version
? `${decodeRouteParam(params.artifactId)}@${decodeRouteParam(params.version)}`
: null;
const deploymentIdentity =
kind === "deployment" && params.deploymentId
? decodeRouteParam(params.deploymentId)
: null;
const runIdentity =
kind === "run" && params.runId ? decodeRouteParam(params.runId) : null;
useEffect(() => {
if (kind === "artifact") {
if (artifactIdentity && state.selectedArtifactId !== artifactIdentity) {
selectArtifact(artifactIdentity);
} else if (!artifactIdentity && state.selectedArtifactId !== null) {
selectArtifact(null);
}
return;
}
if (kind === "deployment") {
if (deploymentIdentity && state.selectedDeploymentId !== deploymentIdentity) {
selectDeployment(deploymentIdentity);
} else if (!deploymentIdentity && state.selectedDeploymentId !== null) {
selectDeployment(null);
}
return;
}
if (runIdentity && state.selectedRunId !== runIdentity) {
selectRun(runIdentity);
} else if (!runIdentity && state.selectedRunId !== null) {
selectRun(null);
}
}, [
artifactIdentity,
deploymentIdentity,
kind,
runIdentity,
selectArtifact,
selectDeployment,
selectRun,
state.selectedArtifactId,
state.selectedDeploymentId,
state.selectedRunId,
]);
const onSelectArtifact = useCallback(
(artifactKey: string | null): void => {
navigate(artifactKey ? artifactPathFor(artifactKey) : "/console/artifacts");
},
[navigate],
);
const onSelectDeployment = useCallback(
(deploymentId: string | null): void => {
navigate(
deploymentId
? `/console/deployments/${encodeURIComponent(deploymentId)}`
: "/console/deployments",
);
},
[navigate],
);
const onSelectRun = useCallback(
(runId: string | null): void => {
navigate(runId ? `/console/runs/${encodeURIComponent(runId)}` : "/console/runs");
},
[navigate],
);
return (
<div className="lifecycle-route">
<header className="lifecycle-route__header">
<p className="workspace-route-pending__eyebrow">Workflow lifecycle</p>
<h1>{labels[kind]}</h1>
<p>Inspect workflow records and their linked lifecycle context.</p>
</header>
{!readExecutor && (
<p role="status">
Connect a workflow server to view {labels[kind].toLowerCase()}.
</p>
)}
<LifecycleExplorer
controller={{
...controller,
selectArtifact: onSelectArtifact,
selectDeployment: onSelectDeployment,
selectRun: onSelectRun,
}}
primaryKind={kind}
/>
</div>
);
};