refactor: route console lifecycle workspace
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user