diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index 54096740..c8deae22 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -60,6 +60,7 @@ import { runDetailPath } from "../insights-deeplinks"; import { Link } from "../navigation"; import { ROUTINES_PATH_PREFIX } from "../path-ids"; import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; +import { useOpenRoutineInCanvas } from "../shell/canvas-availability"; import { StageTopBar } from "../shell/stage-top-bar"; import { nextRunLabel, RunStatusCell, TriggeredByCell } from "./routines-page"; import { listWorkflowDefinitions, useTenantQuery } from "../routines-api"; @@ -341,6 +342,7 @@ export function RoutineDetailPage({ onRunNow, onToggleEnabled, onSaveSchedule, + onEdit, }: { readonly row: GlobalRoutineRow; readonly now: number; @@ -348,6 +350,7 @@ export function RoutineDetailPage({ readonly onRunNow: () => Promise; readonly onToggleEnabled: (enabled: boolean) => void; readonly onSaveSchedule: (expression: string) => Promise; + readonly onEdit: () => void; }) { const health = routineHealth(row.routine, row.runs); const latestRunId = @@ -370,6 +373,9 @@ export function RoutineDetailPage({ > {row.routine.enabled ? "Pause" : "Resume"} + } /> @@ -499,6 +505,7 @@ export function RoutineDetailRoute({ }) { const routinesQuery = useGlobalRoutines(); const actions = useRoutineActions(); + const openRoutine = useOpenRoutineInCanvas(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; const resolution = resolveRoutineSegment(rows, segment); const row = resolution.kind === "found" ? resolution.row : undefined; @@ -582,6 +589,7 @@ export function RoutineDetailRoute({ onSaveSchedule={(expression) => actions.saveCronSchedule(resolved, expression) } + onEdit={() => openRoutine({ routineId: resolved.routine.id })} /> ); } diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index e6961e2d..4ae29ab0 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -33,7 +33,7 @@ import { TableRow, } from "@corbits/react-ui"; import type { BadgeTone, RunStatus } from "@corbits/react-ui"; -import { Clock, PlayCircle, Plus } from "@corbits/icons"; +import { Clock, PencilSimple, PlayCircle, Plus } from "@corbits/icons"; import type { KeyboardEvent } from "react"; import { routineHealth, @@ -243,12 +243,14 @@ export function GlobalRoutinesList({ onToggleEnabled, onRunNow, onOpenWorkbench, + onEditRoutine, }: { readonly rows: readonly GlobalRoutineRow[]; readonly now: number; readonly onToggleEnabled: (row: GlobalRoutineRow, enabled: boolean) => void; readonly onRunNow: (row: GlobalRoutineRow) => Promise; readonly onOpenWorkbench: (workbenchId: string) => void; + readonly onEditRoutine: (row: GlobalRoutineRow) => void; }) { if (rows.length === 0) { return ( @@ -341,22 +343,32 @@ export function GlobalRoutinesList({ /> - {health.state === "paused" || !row.routine.enabled ? ( +
+ {health.state === "paused" || !row.routine.enabled ? ( + + ) : ( + onRunNow(row)} + /> + )} - ) : ( - onRunNow(row)} - /> - )} +
); @@ -411,6 +423,7 @@ export function RoutinesRoute({ void actions.setEnabled(row, enabled); }} onRunNow={(row) => actions.runNow(row)} + onEditRoutine={(row) => openRoutine({ routineId: row.routine.id })} onOpenWorkbench={(workbenchId) => { const row = rows.find( (r) => r.routine.deliveryWorkbenchId === workbenchId, diff --git a/apps/web/test/routine-detail-page.test.tsx b/apps/web/test/routine-detail-page.test.tsx index 8763b3e8..3fa06960 100644 --- a/apps/web/test/routine-detail-page.test.tsx +++ b/apps/web/test/routine-detail-page.test.tsx @@ -80,6 +80,7 @@ const pageProps = { onRunNow: () => Promise.resolve(), onToggleEnabled: (_enabled: boolean) => {}, onSaveSchedule: (_expression: string) => Promise.resolve(), + onEdit: () => {}, }; function renderPage(overrides: Partial = {}): string { @@ -251,6 +252,22 @@ describe("RoutineDetailPage lifecycle actions", () => { container.remove(); } }); + + test("Edit opens the routine's editor", () => { + let edits = 0; + const { container, root } = mount({ + onEdit: () => { + edits += 1; + }, + }); + try { + clickButton(container, "Edit"); + expect(edits).toBe(1); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); }); describe("RoutineScheduleSection", () => { @@ -653,4 +670,66 @@ describe("RoutineDetailRoute", () => { cleanup(container, root); } }); + + test("Edit opens this routine's canvas editor by id", async () => { + const { CanvasAvailabilityProvider } = + await import("../src/shell/canvas-availability"); + const { RoutineDetailRoute } = + await import("../src/pages/routine-detail-page"); + const { BenchProvider } = await import("../src/bench-context"); + const { TestQueryProvider } = await import("./test-query-provider"); + + globalThis.fetch = mockFetch({ + tnt_1: [routineRecord({ id: "rtn_mine", name: "My digest" })], + }); + const opened: { routineId?: string | null }[] = []; + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + await act(async () => { + root.render( + + {}}> + + {}} + openArtifact={() => {}} + openRoutine={(subject) => opened.push(subject)} + toggleFocus={() => {}} + close={() => {}} + > + {createElement(RoutineDetailRoute, { + segment: "rtn_mine", + navigate: () => {}, + })} + + + + , + ); + }); + for (let i = 0; i < 8; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + try { + const editButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Edit", + ); + expect(editButton).not.toBeUndefined(); + act(() => { + editButton?.click(); + }); + expect(opened).toEqual([{ routineId: "rtn_mine" }]); + } finally { + cleanup(container, root); + } + }); }); diff --git a/apps/web/test/routines-page.test.tsx b/apps/web/test/routines-page.test.tsx index cafc6bab..87811f3f 100644 --- a/apps/web/test/routines-page.test.tsx +++ b/apps/web/test/routines-page.test.tsx @@ -61,6 +61,7 @@ const listProps = { onToggleEnabled: (_row: GlobalRoutineRow, _enabled: boolean) => {}, onRunNow: (_row: GlobalRoutineRow) => Promise.resolve(), onOpenWorkbench: (_workbenchId: string) => {}, + onEditRoutine: (_row: GlobalRoutineRow) => {}, }; function renderList(rows: readonly GlobalRoutineRow[]): string { @@ -347,6 +348,41 @@ describe("GlobalRoutinesList", () => { } }); + test("Edit calls onEditRoutine with the row", () => { + const calls: GlobalRoutineRow[] = []; + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render( + createElement(NavigationProvider, { + navigate: noop, + children: createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onEditRoutine: (r: GlobalRoutineRow) => { + calls.push(r); + }, + }), + }), + ); + }); + try { + const editButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Edit", + ); + expect(editButton).not.toBeUndefined(); + act(() => { + editButton?.click(); + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.routine.id).toBe("rtn_1"); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + test("clicking the delivery workbench opens it", () => { const opened: string[] = []; const container = document.createElement("div"); @@ -460,6 +496,7 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { async function renderRoute( navigate: (to: string) => void, + openRoutine: (subject: { routineId?: string | null }) => void = () => {}, ): Promise<{ container: HTMLDivElement; root: Root }> { const { BenchProvider } = await import("../src/bench-context"); const { CanvasAvailabilityProvider } = @@ -484,7 +521,7 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { focus={false} openProfile={() => {}} openArtifact={() => {}} - openRoutine={() => {}} + openRoutine={openRoutine} toggleFocus={() => {}} close={() => {}} > @@ -541,4 +578,33 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { window.localStorage.clear(); } }); + + test("Edit opens the routine's editor with its id, not a fresh draft", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = mockFetch(); + const opened: { routineId?: string | null }[] = []; + const { container, root } = await renderRoute( + () => {}, + (subject) => { + opened.push(subject); + }, + ); + try { + const editButtons = [...container.querySelectorAll("button")].filter( + (button) => button.textContent?.trim() === "Edit", + ); + expect(editButtons.length).toBeGreaterThan(0); + act(() => { + editButtons[0]?.click(); + }); + expect(opened).toHaveLength(1); + expect(opened[0]?.routineId).not.toBeNull(); + expect(opened[0]?.routineId).not.toBeUndefined(); + } finally { + act(() => root.unmount()); + container.remove(); + globalThis.fetch = realFetch; + window.localStorage.clear(); + } + }); });