diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index bebe1b392d..1948b71bf4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7878,6 +7878,7 @@ title = "Pipelines" newPipeline = "New pipeline" [portal.pipelines.builder] +activate = "Activate" back = "Back to pipelines" cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" @@ -7885,7 +7886,6 @@ chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" chooseSource = "Choose a source" discard = "Discard changes" -enabled = "Enabled" inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" @@ -7896,6 +7896,8 @@ needsDestination = "No destination chosen" needsSource = "No source chosen" needsUpload = "Needs an uploaded file" noToolMatches = "No tools match your search." +pause = "Pause" +rename = "Rename pipeline" searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." @@ -7908,6 +7910,17 @@ uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these usesDefaults = "Runs with default settings" viewDefinition = "View definition" +[portal.pipelines.builder.blocker] +destination = "Choose a destination" +heading = "To create this pipeline:" +incompatible = "Fix steps that can't run in order: {{tools}}" +name = "Give the pipeline a name" +saveHeading = "To save your changes:" +schedule = "Set how often it runs" +setup = "Finish setting up: {{tools}}" +source = "Choose an input source" +upload = "Remove steps that need an uploaded file: {{tools}}" + [portal.pipelines.builder.diagnostic] fan-in = "Combines every incoming file" fan-out = "Runs once per incoming file" @@ -7918,12 +7931,12 @@ undeclared-operation = "Can't check what this step accepts" [portal.pipelines.composer] addTool = "Add a tool" -cancel = "Cancel" create = "Create pipeline" +createPaused = "Create paused" editingUnsupported = "Displaying these tool params for editing is not supported yet." editSource = "Edit source" name = "Name" -namePlaceholder = "e.g. Redaction sweep" +namePlaceholder = "Pipeline name" noToolSettings = "This tool has no configurable settings." output = "Destination" save = "Save changes" @@ -7955,7 +7968,7 @@ confirm = "Delete" title = "Delete pipeline?" [portal.pipelines.detail] -clearHistory = "Clear history" +clearHistory = "Process ignored files in source" delete = "Delete pipeline" run = "Run now" @@ -8008,10 +8021,9 @@ completed_one = "Run completed." completed_other = "All {{count}} runs completed." empty = "Nothing to run: the sources had no documents to process." failed = "Run failed: {{error}}" -historyCleared = "History cleared. The next run reprocesses everything currently in the sources." inFlight = "Nothing new to run: documents are still being processed from an earlier run." -parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then clear history to retry it." -parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then clear history to retry them." +parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then reprocess the source to retry it." +parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then reprocess the source to retry them." running = "Run started; still in progress." timeout = "Run is taking longer than expected; it may still finish in the background." diff --git a/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css new file mode 100644 index 0000000000..c0779b9aeb --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css @@ -0,0 +1,22 @@ +/** + * The "why is this disabled" list, inside a save/create button's tooltip. Left-aligned (a bulleted + * list reads oddly centred) and inheriting the tooltip's own colours. + */ + +.portal-pipeline-blockers { + text-align: left; +} + +.portal-pipeline-blockers__heading { + margin: 0 0 0.25rem; + font-weight: 600; +} + +.portal-pipeline-blockers ul { + margin: 0; + padding-left: 1.1rem; +} + +.portal-pipeline-blockers li { + margin: 0.125rem 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx new file mode 100644 index 0000000000..adba6ecd26 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from "react"; +import { Tooltip } from "@mantine/core"; +import "@portal/components/pipelines/PipelineBlockerTooltip.css"; + +export interface PipelineBlockerTooltipProps { + /** Short line above the list, e.g. "To create this pipeline:" / "To save your changes:". */ + heading: string; + /** Everything still owed before the action is possible; empty means the action is allowed. */ + blockers: string[]; + /** The disabled control (wrapped so its hover still reaches the tooltip - see below). */ + children: ReactElement; +} + +/** + * Explains why a disabled save/create control can't be used yet, by listing what is still owed. + * + * A disabled button swallows its own pointer events, so the caller must pass a NON-disabled wrapper + * (a span/div around the button) as the child - that wrapper is what the pointer lands on. The + * tooltip hides itself when there is nothing to list (the action is allowed, or it is only + * mid-save), so callers can wire it unconditionally. + */ +export function PipelineBlockerTooltip({ + heading, + blockers, + children, +}: PipelineBlockerTooltipProps) { + return ( + +

{heading}

+ + + } + disabled={blockers.length === 0} + position="bottom-end" + withinPortal + multiline + w={280} + > + {children} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css new file mode 100644 index 0000000000..d3e5e4827c --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css @@ -0,0 +1,47 @@ +/** + * Create mode's toolbar: name on the left, commit actions on the right - the same shape as the edit + * header, so the two modes feel like one page. + */ + +.portal-pipeline-create-header { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +/* Sized to a name, not the width of the page: a title-length field reads as a title, where the old + full-bleed input read as a search bar. It gives a little on narrow viewports but never grows to + fill the row - the actions on the right anchor the far end instead. */ +.portal-pipeline-create-header__name { + flex: 0 1 22rem; + min-width: 12rem; +} + +.portal-pipeline-create-header__name input { + font-size: 1rem; + font-weight: 500; +} + +/* Pinned to the right, mirroring the edit header. Buttons hold their width and the row wraps rather + than clipping. */ +.portal-pipeline-create-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +/* The create buttons hold their width, so their labels never squash. */ +.portal-pipeline-create-header .sui-btn { + flex: none; + white-space: nowrap; +} + +/* The two create buttons share one tooltip target, so they sit in their own inline group. */ +.portal-pipeline-create-header__create { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx new file mode 100644 index 0000000000..b9717c0848 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineCreateHeader } from "@portal/components/pipelines/PipelineCreateHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineCreateHeader", + component: PipelineCreateHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** + * The name is live, so the toolbar can be seen as it is filled in. Until it is named (a stand-in for + * the app's full validity check) the create buttons are disabled and carry a tooltip of what's owed. + */ +function Playground({ initialName }: { initialName: string }) { + const [name, setName] = useState(initialName); + const blockers = + name.trim() === "" + ? [ + "Give the pipeline a name", + "Choose an input source", + "Choose a destination", + ] + : []; + return ( + + ); +} + +/** A new pipeline: create is disabled, and hovering it lists what's still needed. */ +export const New: Story = { + render: () => , +}; + +/** Named: the create actions become available. */ +export const Named: Story = { + render: () => , +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx new file mode 100644 index 0000000000..c2f1826136 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineCreateHeader, + type PipelineCreateHeaderProps, +} from "@portal/components/pipelines/PipelineCreateHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onCreate: vi.fn(), + onCreatePaused: vi.fn(), + onBack: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineCreateHeader", () => { + it("edits the pipeline's name", () => { + const handlers = renderHeader(); + fireEvent.change( + screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), + { target: { value: "Renamed" } }, + ); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("creates the pipeline, live or paused, and backs out", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.composer.create")); + expect(handlers.onCreate).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.composer.createPaused")); + expect(handlers.onCreatePaused).toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back")); + expect(handlers.onBack).toHaveBeenCalled(); + }); + + it("blocks both create actions until the pipeline is valid", () => { + renderHeader({ canSave: false, blockers: ["Choose a destination"] }); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + expect( + screen + .getByText("portal.pipelines.composer.createPaused") + .closest("button"), + ).toBeDisabled(); + }); + + it("explains, on hover, why the create buttons are disabled", async () => { + renderHeader({ + canSave: false, + blockers: ["Give the pipeline a name", "Choose a destination"], + }); + const group = document.querySelector( + ".portal-pipeline-create-header__create", + ) as HTMLElement; + fireEvent.pointerEnter(group); + fireEvent.mouseEnter(group); + expect(await screen.findByText("Choose a destination")).toBeInTheDocument(); + expect(screen.getByText("Give the pipeline a name")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx new file mode 100644 index 0000000000..b4e3fa7951 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import { ActionIcon, Button, Input } from "@app/ui"; +import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip"; +import "@portal/components/pipelines/PipelineCreateHeader.css"; + +export interface PipelineCreateHeaderProps { + name: string; + onNameChange: (name: string) => void; + + canSave: boolean; + /** Everything still owed before the pipeline can be created, shown on the disabled create button. */ + blockers: string[]; + saving: boolean; + /** Which create action is mid-save, so only the button that was clicked shows its spinner. */ + pendingCreateEnabled: boolean | null; + onCreate: () => void; + onCreatePaused: () => void; + onBack: () => void; +} + +/** + * The create-mode toolbar. Mirrors the edit header's shape - a back arrow and the name on the left, + * actions on the right - so the two modes read as the same page in two states rather than two + * different screens. The right commits the pipeline live or paused; while it can't yet, the disabled + * create buttons carry a tooltip listing exactly what is still owed, so "disabled" is never a dead end. + */ +export function PipelineCreateHeader({ + name, + onNameChange, + canSave, + blockers, + saving, + pendingCreateEnabled, + onCreate, + onCreatePaused, + onBack, +}: PipelineCreateHeaderProps) { + const { t } = useTranslation(); + + return ( +
+ + + + + onNameChange(e.target.value)} + /> + +
+ {/* The pair share one tooltip target because a disabled button swallows its own hover - the + wrapper is what the pointer lands on. */} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css new file mode 100644 index 0000000000..a83d3ff6ae --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css @@ -0,0 +1,67 @@ +/** + * Edit mode's toolbar: identity on the left, operational actions on the right. + */ + +.portal-pipeline-edit-header { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.portal-pipeline-edit-header__identity { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + flex: 1 1 16rem; +} + +/* The name is the page's title. It takes the room the identity row leaves and truncates rather than + wrapping, so a long name never pushes the pencil out of reach. */ +.portal-pipeline-edit-header__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.portal-pipeline-edit-header__name-input { + flex: 1 1 16rem; + min-width: 12rem; +} + +.portal-pipeline-edit-header__name-input input { + font-size: 1.125rem; + font-weight: 600; +} + +/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ +.portal-pipeline-edit-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex: none; +} + +.portal-pipeline-edit-header__actions .sui-btn { + flex: none; + white-space: nowrap; +} + +/* Save is wrapped so its disabled hover reaches the blocker tooltip; the wrapper must not shrink. */ +.portal-pipeline-edit-header__save { + display: inline-flex; + flex: none; +} + +/* Destructive item in the overflow tray: red label and icon, so it reads as the exception among + the neutral entries above it. */ +.portal-pipeline-edit-header__delete-item .sui-dd__item-label, +.portal-pipeline-edit-header__delete-item .sui-dd__item-leading { + color: var(--c-danger); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx new file mode 100644 index 0000000000..538024e933 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineEditHeader", + component: PipelineEditHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** The name and the pause/activate state are live, so both can be exercised. */ +function Playground({ + initialName, + initialEnabled = true, + canSave = true, + blockers = [], +}: { + initialName: string; + initialEnabled?: boolean; + canSave?: boolean; + blockers?: string[]; +}) { + const [name, setName] = useState(initialName); + const [enabled, setEnabled] = useState(initialEnabled); + return ( + setEnabled((e) => !e)} + togglingEnabled={false} + onBack={noop} + canSave={canSave} + blockers={blockers} + saving={false} + onSave={noop} + onRun={noop} + running={false} + onReprocess={noop} + reprocessing={false} + onDelete={noop} + /> + ); +} + +/** A live pipeline: the toggle offers to pause it. */ +export const Active: Story = { + render: () => , +}; + +/** A paused pipeline: the toggle offers to activate it. */ +export const Paused: Story = { + render: () => ( + + ), +}; + +/** Edits that cannot yet be saved: Save is disabled and hovering it lists what's still needed. */ +export const CannotSave: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx new file mode 100644 index 0000000000..e216dbb292 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineEditHeader, + type PipelineEditHeaderProps, +} from "@portal/components/pipelines/PipelineEditHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onTogglePause: vi.fn(), + onBack: vi.fn(), + onSave: vi.fn(), + onRun: vi.fn(), + onReprocess: vi.fn(), + onDelete: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineEditHeader", () => { + it("shows the name as the title and renames it in place", () => { + const handlers = renderHeader(); + expect(screen.getByText("Claims redaction")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("abandons a rename on Escape, keeping the old name", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Discarded" } }); + fireEvent.keyDown(input, { key: "Escape" }); + // Escape must not commit, even via the blur that unmounting the field fires in a real browser. + fireEvent.blur(input); + expect(handlers.onNameChange).not.toHaveBeenCalled(); + expect(screen.getByText("Claims redaction")).toBeInTheDocument(); + }); + + it("commits a rename when focus leaves the field", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.blur(input); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("offers to pause a live pipeline and to activate a paused one", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.builder.pause")); + expect(handlers.onTogglePause).toHaveBeenCalled(); + + renderHeader({ enabled: false }); + expect( + screen.getByText("portal.pipelines.builder.activate"), + ).toBeInTheDocument(); + }); + + it("runs the saved pipeline from the row", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.detail.run")); + expect(handlers.onRun).toHaveBeenCalled(); + }); + + it("keeps clear-history and delete behind the overflow tray", () => { + const handlers = renderHeader(); + // Not in the row itself... + expect( + screen.queryByText("portal.pipelines.detail.clearHistory"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.delete"), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + expect(handlers.onReprocess).toHaveBeenCalled(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); + expect(handlers.onDelete).toHaveBeenCalled(); + }); + + it("blocks saving until the edits are valid", () => { + renderHeader({ canSave: false }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("cannot pause while a save is committing", () => { + renderHeader({ saving: true }); + expect( + screen.getByText("portal.pipelines.builder.pause").closest("button"), + ).toBeDisabled(); + }); + + it("cannot save while a pause is committing", () => { + renderHeader({ togglingEnabled: true }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("explains, on hover, why Save is disabled", async () => { + renderHeader({ canSave: false, blockers: ["Choose a destination"] }); + const save = document.querySelector( + ".portal-pipeline-edit-header__save", + ) as HTMLElement; + fireEvent.pointerEnter(save); + fireEvent.mouseEnter(save); + expect(await screen.findByText("Choose a destination")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx new file mode 100644 index 0000000000..7ee2fbd173 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx @@ -0,0 +1,235 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import PauseRoundedIcon from "@mui/icons-material/PauseRounded"; +import PowerSettingsNewRoundedIcon from "@mui/icons-material/PowerSettingsNewRounded"; +import ReplayRoundedIcon from "@mui/icons-material/ReplayRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; +import { ActionIcon, Button, Dropdown, Input } from "@app/ui"; +import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip"; +import "@portal/components/pipelines/PipelineEditHeader.css"; + +export interface PipelineEditHeaderProps { + name: string; + onNameChange: (name: string) => void; + + /** The pipeline's live state. Toggling it takes effect immediately, not on save. */ + enabled: boolean; + onTogglePause: () => void; + togglingEnabled: boolean; + + onBack: () => void; + + canSave: boolean; + /** Everything still owed before the edits can be saved, shown on the disabled Save button. */ + blockers: string[]; + saving: boolean; + onSave: () => void; + + /** Run the saved pipeline against its real input, delivering to its real destination. */ + onRun: () => void; + running: boolean; + /** Reprocess everything in the sources: clears the processed record, then runs at once. */ + onReprocess: () => void; + reprocessing: boolean; + onDelete: () => void; +} + +/** + * Edit mode's toolbar over an existing, live pipeline. The left is what it *is* - a back arrow, its + * name as the page title, a pencil to rename in place. The right is what you can *do to it*: pause + * or activate it (an operational toggle that acts at once, matching the Policies vocabulary), run it + * now, and - behind an overflow, since they are rare or destructive - reprocess its sources or delete + * it. Saving the chain edits is the primary action, on the far right. (Reading the definition is an + * inspect action, so it lives in the graph toolbar beside Test, not here.) + */ +export function PipelineEditHeader({ + name, + onNameChange, + enabled, + onTogglePause, + togglingEnabled, + onBack, + canSave, + blockers, + saving, + onSave, + onRun, + running, + onReprocess, + reprocessing, + onDelete, +}: PipelineEditHeaderProps) { + const { t } = useTranslation(); + const [renaming, setRenaming] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef(null); + // Enter and Escape both end the rename, which unmounts the input - and unmounting a focused input + // fires blur in a real browser (jsdom does not). Without this guard that blur would re-run the + // commit, so Escape would save the very draft it was meant to discard. The key handler sets this so + // the trailing blur is ignored; a plain click-away leaves it false and blur commits as normal. + const keyHandledRef = useRef(false); + + useEffect(() => { + if (renaming) inputRef.current?.select(); + }, [renaming]); + + function startRename() { + keyHandledRef.current = false; + setDraft(name); + setRenaming(true); + } + + // End the rename, committing the draft only when asked and only if non-empty (an all-whitespace + // rename would leave the pipeline titleless). + function finishRename(commit: boolean) { + keyHandledRef.current = true; + if (commit) { + const next = draft.trim(); + if (next) onNameChange(next); + } + setRenaming(false); + } + + // Clicking away commits; the unmount-triggered blur that follows a key press does not (the key + // already decided the outcome). + function handleBlur() { + if (keyHandledRef.current) { + keyHandledRef.current = false; + return; + } + finishRename(true); + } + + return ( +
+
+ + + + + {renaming ? ( + setDraft(e.target.value)} + onBlur={handleBlur} + onKeyDown={(e) => { + if (e.key === "Enter") finishRename(true); + if (e.key === "Escape") finishRename(false); + }} + /> + ) : ( + <> +

{name}

+ + + + + )} +
+ +
+ {/* Pause and Save both write the whole policy, so they are mutually exclusive: neither can + start while the other is committing, or the two writes race and the loser's version wins. */} + + + {/* Run and Reprocess both start a run, so only one at a time: each is disabled while the + other is in flight, matching the handler guards (a click otherwise silently no-ops). */} + + + {/* Rare and destructive actions kept off the row so they do not compete with running. */} + + + + + + + + } + > + {t("portal.pipelines.detail.clearHistory")} + + + + } + > + {t("portal.pipelines.detail.delete")} + + + + + {/* Wrapped in a span so the disabled button's hover still reaches the tooltip. */} + + + + + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css new file mode 100644 index 0000000000..9dc2d53bec --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css @@ -0,0 +1,48 @@ +/** + * The test control and the last run's outcome, directly above the graph. + */ + +.portal-pipeline-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Reading the definition sits at the far end of the bar, opposite Test. */ +.portal-pipeline-toolbar__definition { + margin-left: auto; +} + +/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the + backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ +.portal-pipeline-toolbar__result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-pipeline-toolbar__result-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--c-text); +} + +.portal-pipeline-toolbar__result-icon.is-ok { + color: var(--c-success); +} + +.portal-pipeline-toolbar__result-icon.is-bad { + color: var(--c-danger); +} + +/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); + it may wrap to keep a long backend message readable rather than clipping it. */ +.portal-pipeline-toolbar__result-error { + font-size: 0.8125rem; + color: var(--c-text-muted); + min-width: 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx new file mode 100644 index 0000000000..359aa9bfa1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineGraphToolbar", + component: PipelineGraphToolbar, + parameters: { layout: "padded" }, + args: { + stepCount: 2, + testing: false, + runResult: null, + onTest: () => {}, + onDownloadOutput: () => {}, + onViewDefinition: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Idle: just the test control. */ +export const Idle: Story = {}; + +/** A chain with no steps cannot be tested. */ +export const NoSteps: Story = { args: { stepCount: 0 } }; + +/** Mid test-run. */ +export const Testing: Story = { args: { testing: true } }; + +/** After a completed run: the outcome and its files sit beside the button. */ +export const Completed: Story = { + args: { + runResult: { + status: "completed", + completedSteps: 3, + stepCount: 3, + outputs: [ + { fileId: "f1", fileName: "claim-redacted.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }, +}; + +/** A failed run: the summary and the failure reason. */ +export const Failed: Story = { + args: { + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx new file mode 100644 index 0000000000..4aafb27766 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineGraphToolbar, + type PipelineGraphToolbarProps, +} from "@portal/components/pipelines/PipelineGraphToolbar"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderToolbar(overrides: Partial = {}) { + const handlers = { + onTest: vi.fn(), + onDownloadOutput: vi.fn(), + onViewDefinition: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineGraphToolbar", () => { + it("hands the chosen file to the test run", () => { + const handlers = renderToolbar(); + const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); + const input = + document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + expect(handlers.onTest).toHaveBeenCalledWith(file); + }); + + it("will not offer a test run on a chain with no steps", () => { + renderToolbar({ stepCount: 0 }); + expect( + screen.getByText("portal.pipelines.builder.testRun").closest("button"), + ).toBeDisabled(); + }); + + it("opens the definition from its icon", () => { + const handlers = renderToolbar(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.viewDefinition"), + ); + expect(handlers.onViewDefinition).toHaveBeenCalled(); + }); + + it("shows no result strip until a test has been run", () => { + renderToolbar(); + expect( + screen.queryByText(/portal.pipelines.inspector.status/), + ).not.toBeInTheDocument(); + }); + + it("shows why a test run failed, not only that it did", () => { + renderToolbar({ + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }); + expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); + }); + + it("reports a finished run and downloads the file clicked", () => { + const handlers = renderToolbar({ + runResult: { + status: "completed", + completedSteps: 2, + stepCount: 2, + outputs: [ + { fileId: "f1", fileName: "claim.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }); + fireEvent.click(screen.getByText("claim.pdf")); + expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ + fileId: "f1", + fileName: "claim.pdf", + }); + // A file the backend did not name still has to be reachable. + expect(screen.getByText("f2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx new file mode 100644 index 0000000000..30513f6a83 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx @@ -0,0 +1,147 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; +import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; +import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; +import { ActionIcon, Button, FilePicker, Spinner } from "@app/ui"; +import { type RunOutputFile } from "@portal/api/pipelines"; +import "@portal/components/pipelines/PipelineGraphToolbar.css"; + +/** + * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files + * plus the step it stopped at, so there is no per-node output to attach to a node. + */ +export interface RunResultSummary { + status: "running" | "completed" | "failed"; + completedSteps: number; + stepCount: number; + error?: string | null; + outputs?: RunOutputFile[]; +} + +export interface PipelineGraphToolbarProps { + /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ + stepCount: number; + /** Run the steps as they stand against one uploaded file, without saving or delivering. */ + onTest: (file: File) => void; + testing: boolean; + /** The last test run in this session, or null if there has not been one. */ + runResult: RunResultSummary | null; + onDownloadOutput: (output: RunOutputFile) => void; + /** Opens the definition (JSON + cURL) - an inspect action, sibling to Test, hence its home here. */ + onViewDefinition: () => void; +} + +/** + * The graph's own toolbar, above the canvas in both create and edit. It gathers the two ways to + * *inspect* what you are building - testing the chain against one file, and reading its definition - + * as opposed to committing (Save/Create) or operating on the live pipeline (Run now). A test run's + * progress shows on the graph's nodes, so the strip that summarises it belongs next to the graph too. + */ +export function PipelineGraphToolbar({ + stepCount, + onTest, + testing, + runResult, + onDownloadOutput, + onViewDefinition, +}: PipelineGraphToolbarProps) { + const { t } = useTranslation(); + + return ( +
+ file && onTest(file)} + leftSection={} + > + {t("portal.pipelines.builder.testRun")} + + + {runResult && ( + + )} + + {/* The graph is the visual definition; reading it as JSON/cURL sits at the far end of its bar. */} + + + + + +
+ ); +} + +interface RunResultStripProps { + result: RunResultSummary; + onDownload: (output: RunOutputFile) => void; +} + +/** What the last test run did, beside the button that started it. */ +function RunResultStrip({ result, onDownload }: RunResultStripProps) { + const { t } = useTranslation(); + const outputs = result.outputs ?? []; + + return ( +
+
+ {result.status === "running" && } + {result.status === "completed" && ( + + )} + {result.status === "failed" && ( + + )} + + {t(`portal.pipelines.inspector.status.${result.status}`, { + done: result.completedSteps, + count: result.stepCount, + })} + +
+ + {/* The reason it failed, where the failure is announced - not only on the node, which the user + has to know to click. */} + {result.status === "failed" && result.error && ( + + {result.error} + + )} + + {outputs.map((output) => ( + + ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css deleted file mode 100644 index f559e42795..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css +++ /dev/null @@ -1,133 +0,0 @@ -/** - * The builder's opening section: identity above the rule, actions below it. - */ - -.portal-pipeline-header { - display: flex; - flex-direction: column; - gap: 0.875rem; - padding: 1.125rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); -} - -/* Leaving the page and saving it are the same kind of decision, so they share a row - and the back - link is short, so the save pair always has room beside it. */ -.portal-pipeline-header__top { - display: flex; - align-items: center; - gap: 1rem; - flex-wrap: wrap; -} - -/* The back link is the shared Button restyled to a plain link, so re-assert that over the - design-system base (which imposes a fixed height, its own padding and an accent colour). */ -.portal-pipeline-header__back.sui-btn { - height: auto; - min-height: 0; - padding: 0; - font-size: 0.8125rem; - font-weight: 400; - color: var(--c-text-muted); -} - -.portal-pipeline-header__back.sui-btn:hover { - background: none; - color: var(--c-text); -} - -.portal-pipeline-header__identity { - display: flex; - align-items: center; - gap: 1.25rem; - flex-wrap: wrap; -} - -/* The shared Checkbox aligns its box to the top of the first text line, with a nudge tuned for its - own font size - that is for the label-plus-description case. This one is a single line, so centre - the box on it and leave the component's sizing alone (overriding the font size shifts the line - box and leaves the tick floating high). */ -.portal-pipeline-header__enabled.sui-check { - flex: none; - align-items: center; -} - -.portal-pipeline-header__enabled.sui-check .sui-check__box { - margin-top: 0; -} - -/* The name is the page's title, so it takes the room and reads at title size. */ -.portal-pipeline-header__name { - flex: 1 1 16rem; - min-width: 12rem; -} - -.portal-pipeline-header__name input { - font-size: 1rem; - font-weight: 500; -} - -/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ -.portal-pipeline-header__save { - display: flex; - align-items: center; - gap: 0.5rem; - margin-left: auto; - flex: none; -} - -.portal-pipeline-header__save .sui-btn { - flex: none; - white-space: nowrap; -} - -/* Operational actions: what you can do to this pipeline, kept off the identity row. */ -.portal-pipeline-header__actions { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.875rem; - border-top: 1px solid var(--c-border-subtle); -} - -/* Destructive, so it sits away from the rest rather than next in line. */ -.portal-pipeline-header__delete.sui-btn { - margin-left: auto; -} - -/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the - backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ -.portal-pipeline-header__result { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.875rem; - border-top: 1px solid var(--c-border-subtle); -} - -.portal-pipeline-header__result-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; - color: var(--c-text); -} - -.portal-pipeline-header__result-icon.is-ok { - color: var(--c-success); -} - -.portal-pipeline-header__result-icon.is-bad { - color: var(--c-danger); -} - -/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); - it may wrap to keep a long backend message readable rather than clipping it. */ -.portal-pipeline-header__result-error { - font-size: 0.8125rem; - color: var(--c-text-muted); - min-width: 0; -} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx deleted file mode 100644 index 41c73a5ade..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useState } from "react"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { - PipelineHeader, - type RunResultSummary, -} from "@portal/components/pipelines/PipelineHeader"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineHeader", - component: PipelineHeader, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -const noop = () => {}; - -/** The name and the enabled switch are live, so the section can be seen in both states. */ -function Playground({ - initialName, - isEdit, - initialEnabled = true, - runResult = null, - ...rest -}: { - initialName: string; - isEdit: boolean; - initialEnabled?: boolean; - runResult?: RunResultSummary | null; - saving?: boolean; - testing?: boolean; - running?: boolean; - canSave?: boolean; - stepCount?: number; -}) { - const [name, setName] = useState(initialName); - const [enabled, setEnabled] = useState(initialEnabled); - return ( - - ); -} - -/** An existing pipeline: everything is available. */ -export const Editing: Story = { - render: () => , -}; - -/** - * A pipeline that has never been saved. It can still be tested against a file, but there is - * nothing yet to run on a schedule, clear history for, or delete. - */ -export const New: Story = { - render: () => , -}; - -/** Paused: the pipeline exists but its trigger will not fire. */ -export const Paused: Story = { - render: () => ( - - ), -}; - -/** Mid test-run: the picker shows its own progress while the graph shows the steps. */ -export const Testing: Story = { - render: () => , -}; - -/** After a test run: the outcome and its files sit beside the button that started them. */ -export const WithRunResult: Story = { - render: () => ( - - ), -}; - -/** A failed run: the summary is here, the failing step's own message is on its node. */ -export const WithFailedRun: Story = { - render: () => ( - - ), -}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx deleted file mode 100644 index 2c5552be07..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - fireEvent, - render as baseRender, - screen, -} from "@testing-library/react"; -import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -import { - PipelineHeader, - type PipelineHeaderProps, -} from "@portal/components/pipelines/PipelineHeader"; - -const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: PortalTestProviders }); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -function renderHeader(overrides: Partial = {}) { - const handlers = { - onNameChange: vi.fn(), - onEnabledChange: vi.fn(), - onSave: vi.fn(), - onCancel: vi.fn(), - onBack: vi.fn(), - onTest: vi.fn(), - onRun: vi.fn(), - onClearHistory: vi.fn(), - onDelete: vi.fn(), - onViewDefinition: vi.fn(), - onDownloadOutput: vi.fn(), - }; - render( - , - ); - return handlers; -} - -describe("PipelineHeader", () => { - it("edits the pipeline's name and enabled state", () => { - const handlers = renderHeader(); - fireEvent.change( - screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), - { target: { value: "Renamed" } }, - ); - expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); - - fireEvent.click(screen.getByRole("checkbox")); - expect(handlers.onEnabledChange).toHaveBeenCalledWith(false); - }); - - it("offers run, clear history and delete only once the pipeline exists", () => { - renderHeader({ isEdit: false }); - expect( - screen.queryByText("portal.pipelines.detail.run"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.detail.delete"), - ).not.toBeInTheDocument(); - // A test run needs no saved record, so it stays: it is how you check the steps as you build. - expect( - screen.getByText("portal.pipelines.builder.testRun"), - ).toBeInTheDocument(); - }); - - it("labels the save action for what it will do", () => { - renderHeader({ isEdit: false }); - expect( - screen.getByText("portal.pipelines.composer.create"), - ).toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.composer.save"), - ).not.toBeInTheDocument(); - }); - - it("blocks saving until the pipeline is valid", () => { - renderHeader({ canSave: false }); - expect( - screen.getByText("portal.pipelines.composer.save").closest("button"), - ).toBeDisabled(); - }); - - it("hands the chosen file to the test run", () => { - const handlers = renderHeader(); - const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); - const input = - document.querySelector('input[type="file"]'); - expect(input).not.toBeNull(); - fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); - expect(handlers.onTest).toHaveBeenCalledWith(file); - }); - - it("will not offer a test run on a chain with no steps", () => { - renderHeader({ stepCount: 0 }); - expect( - screen.getByText("portal.pipelines.builder.testRun").closest("button"), - ).toBeDisabled(); - }); - - it("shows why a test run failed, not only that it did", () => { - renderHeader({ - runResult: { - status: "failed", - completedSteps: 1, - stepCount: 3, - error: "OCR failed: unreadable page", - }, - }); - expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); - }); - - it("runs and deletes from the row, clears history from the tray", () => { - const handlers = renderHeader(); - fireEvent.click(screen.getByText("portal.pipelines.detail.run")); - expect(handlers.onRun).toHaveBeenCalled(); - fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); - expect(handlers.onDelete).toHaveBeenCalled(); - - fireEvent.click( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ); - fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); - expect(handlers.onClearHistory).toHaveBeenCalled(); - }); - - it("leaves the page through cancel and back", () => { - const handlers = renderHeader(); - fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); - expect(handlers.onCancel).toHaveBeenCalled(); - fireEvent.click(screen.getByText("portal.pipelines.builder.back")); - expect(handlers.onBack).toHaveBeenCalled(); - }); - - it("keeps the occasional actions out of the row, behind a tray", () => { - renderHeader(); - // Running and testing earn a button each; reading the definition and wiping history do not. - expect( - screen.queryByText("portal.pipelines.builder.viewDefinition"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.detail.clearHistory"), - ).not.toBeInTheDocument(); - expect( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ).toBeInTheDocument(); - }); - - it("opens the definition from the tray", () => { - const handlers = renderHeader(); - fireEvent.click( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ); - fireEvent.click( - screen.getByText("portal.pipelines.builder.viewDefinition"), - ); - expect(handlers.onViewDefinition).toHaveBeenCalled(); - }); - - it("shows no run strip until a test has been run", () => { - renderHeader(); - expect( - screen.queryByText(/portal.pipelines.inspector.status/), - ).not.toBeInTheDocument(); - }); - - it("reports a finished run and downloads the file clicked", () => { - const handlers = renderHeader({ - runResult: { - status: "completed", - completedSteps: 2, - stepCount: 2, - outputs: [ - { fileId: "f1", fileName: "claim.pdf" }, - { fileId: "f2", fileName: null }, - ], - }, - }); - fireEvent.click(screen.getByText("claim.pdf")); - expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ - fileId: "f1", - fileName: "claim.pdf", - }); - // A file the backend did not name still has to be reachable. - expect(screen.getByText("f2")).toBeInTheDocument(); - }); -}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx deleted file mode 100644 index 25bfbd044f..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; -import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; -import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; -import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; -import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; -import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; -import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; -import { - ActionIcon, - Button, - Checkbox, - Dropdown, - FilePicker, - Input, - Spinner, -} from "@app/ui"; -import "@portal/components/pipelines/PipelineHeader.css"; - -/** One file a test run produced, downloadable from the result strip. */ -export interface RunOutputFile { - fileId: string; - fileName: string | null; -} - -/** - * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files - * plus the step it stopped at, so there is no per-node output to attach to a node. - */ -export interface RunResultSummary { - status: "running" | "completed" | "failed"; - completedSteps: number; - stepCount: number; - error?: string | null; - outputs?: RunOutputFile[]; -} - -export interface PipelineHeaderProps { - name: string; - onNameChange: (name: string) => void; - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - /** False for a pipeline that has never been saved: it cannot yet be run, cleared or deleted. */ - isEdit: boolean; - /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ - stepCount: number; - - canSave: boolean; - saving: boolean; - onSave: () => void; - onCancel: () => void; - onBack: () => void; - - /** Run the steps as they stand against one uploaded file, without saving or delivering. */ - onTest: (file: File) => void; - testing: boolean; - /** Run the saved pipeline against its real input, delivering to its real destination. */ - onRun: () => void; - running: boolean; - onClearHistory: () => void; - clearingHistory: boolean; - onDelete: () => void; - - /** Opens the definition (JSON + cURL), which is pipeline-scoped like the rest of this row. */ - onViewDefinition: () => void; - /** The last test run in this session, or null if there has not been one. */ - runResult: RunResultSummary | null; - onDownloadOutput: (output: RunOutputFile) => void; -} - -/** - * The pipeline's identity and its whole-pipeline actions, at the top of the builder. - * - * Split in two so neither half gets lost in a single crowded row: what the pipeline *is* (name, - * whether it is live) sits with the actions that leave the page, and what you can *do to it* sits - * below the rule. A test run is part of building, so it lives here rather than off in a corner - - * its progress shows on the graph's nodes and its results in the inspector. - */ -export function PipelineHeader({ - name, - onNameChange, - enabled, - onEnabledChange, - isEdit, - stepCount, - canSave, - saving, - onSave, - onCancel, - onBack, - onTest, - testing, - onRun, - running, - onClearHistory, - clearingHistory, - onDelete, - onViewDefinition, - runResult, - onDownloadOutput, -}: PipelineHeaderProps) { - const { t } = useTranslation(); - - return ( -
-
- -
- - -
-
- -
- onNameChange(e.target.value)} - /> - {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch - would imply it applies the moment it is flipped. No description - a second line beside - the single-line name field leaves the row ragged. */} - onEnabledChange(e.target.checked)} - label={t("portal.pipelines.builder.enabled")} - /> -
- -
- file && onTest(file)} - leftSection={} - > - {t("portal.pipelines.builder.testRun")} - - - {isEdit && ( - - )} - - {/* Occasional things - reading the definition, wiping the processed history - kept behind a - tray so they do not compete with running and testing, which is what this row is for. */} - - - - - - - - } - > - {t("portal.pipelines.builder.viewDefinition")} - - {isEdit && ( - - } - > - {t("portal.pipelines.detail.clearHistory")} - - )} - - - - {isEdit && ( - - )} -
- - {runResult && ( - - )} -
- ); -} - -interface RunResultStripProps { - result: RunResultSummary; - onDownload: (output: RunOutputFile) => void; -} - -/** What the last test run did, beside the button that started it. */ -function RunResultStrip({ result, onDownload }: RunResultStripProps) { - const { t } = useTranslation(); - const outputs = result.outputs ?? []; - - return ( -
-
- {result.status === "running" && } - {result.status === "completed" && ( - - )} - {result.status === "failed" && ( - - )} - - {t(`portal.pipelines.inspector.status.${result.status}`, { - done: result.completedSteps, - count: result.stepCount, - })} - -
- - {/* The reason it failed, where the failure is announced - not only on the node, which the user - has to know to click. */} - {result.status === "failed" && result.error && ( - - {result.error} - - )} - - {outputs.map((output) => ( - - ))} -
- ); -} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index 464d3fad55..343aea95f6 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -225,3 +225,43 @@ gap: 0.5rem; width: 100%; } + +/* The graph column IS the card: the test control is its header and the graph its body, so the test + control reads as the graph's toolbar and - crucially - the card's top lines up with the inspector + beside it (a toolbar sitting *above* the card pushed the graph down out of alignment). Caps at the + row height and clips its rounded corners; the body scrolls inside while the header stays put. */ +.portal-builder__canvas { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; + overflow: hidden; + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); + background: var(--c-surface-sunken); +} + +/* The test control as the card's header, ruled off from the graph below it. */ +.portal-builder__canvas > .portal-pipeline-toolbar { + flex: none; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--c-border-subtle); +} + +/* The graph as the card's body: it drops its own frame (the canvas provides it now) and scrolls + inside while the header stays put. */ +.portal-builder__canvas > .portal-graph { + flex: 1 1 auto; + min-height: 0; + max-height: none; + border: none; + border-radius: 0; + background: transparent; +} + +/* Stacked (short viewport): the page scrolls, so the column must not cap or nest its own scroll. */ +@media (max-width: 60rem) { + .portal-builder__canvas { + max-height: none; + } +} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index b1ff80682d..52fb5d516c 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -456,7 +456,7 @@ describe("PipelineBuilder", () => { expect( await screen.findByText("portal.pipelines.builder.needsSource"), ).toBeInTheDocument(); - // Still nothing chosen, so the pipeline cannot be saved. + // Still nothing chosen, so the pipeline cannot be created. expect( screen.getByText("portal.pipelines.composer.create").closest("button"), ).toBeDisabled(); @@ -601,15 +601,15 @@ describe("PipelineBuilder", () => { target: { value: "Needs both" }, }, ); - const saveButton = () => + const createButton = () => screen.getByText("portal.pipelines.composer.create").closest("button"); // Name only: blocked (no source, no destination). - expect(saveButton()).toBeDisabled(); + expect(createButton()).toBeDisabled(); // An input with a source but still no destination: blocked. await pickInputSource("Claims intake"); - expect(saveButton()).toBeDisabled(); + expect(createButton()).toBeDisabled(); // Both chosen: allowed, and both are sent. await pickDestination(); @@ -782,17 +782,19 @@ describe("PipelineBuilder", () => { ).toBeInTheDocument(); }); - it("clears processed history from the header and confirms", async () => { + it("reprocesses the source: clears the processed record, runs, and reports", async () => { renderBuilder("/processor/pipelines/plc-1"); await openTray(); fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + // It forgets what was processed, then triggers a run so those files go through now. await waitFor(() => expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"), ); + await waitFor(() => expect(triggerPipeline).toHaveBeenCalledWith("plc-1")); expect( - await screen.findByText("portal.pipelines.run.historyCleared"), + await screen.findByText("portal.pipelines.run.completed"), ).toBeInTheDocument(); }); @@ -846,6 +848,8 @@ describe("PipelineBuilder", () => { it("deletes an existing pipeline after confirmation", async () => { renderBuilder("/processor/pipelines/plc-1"); + // Delete is a rare, destructive action, so it lives behind the overflow tray. + await openTray(); fireEvent.click(await screen.findByText("portal.pipelines.detail.delete")); fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm")); @@ -853,6 +857,45 @@ describe("PipelineBuilder", () => { expect(await screen.findByText("pipelines list")).toBeInTheDocument(); }); + it("pauses a live pipeline at once, in place, and re-saves the persisted policy", async () => { + renderBuilder("/processor/pipelines/plc-1"); + + // POLICY.enabled is true, so the toggle offers to pause it. + fireEvent.click(await screen.findByText("portal.pipelines.builder.pause")); + + await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ id: "plc-1", enabled: false }), + ); + // It acts in place: the builder stays open and the control now offers to activate again. + expect( + await screen.findByText("portal.pipelines.builder.activate"), + ).toBeInTheDocument(); + expect(screen.queryByText("pipelines list")).not.toBeInTheDocument(); + }); + + it("creates a paused pipeline when Create paused is chosen", async () => { + renderBuilder("/processor/pipelines/new"); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "portal.pipelines.composer.name", + }), + { target: { value: "Paused draft" } }, + ); + await addTool("Compress"); + await pickInputSource("Claims intake"); + await pickDestination(); + + fireEvent.click(screen.getByText("portal.pipelines.composer.createPaused")); + + await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ name: "Paused draft", enabled: false }), + ); + expect(await screen.findByText("pipelines list")).toBeInTheDocument(); + }); + it("prompts to save or discard when leaving with unsaved edits", async () => { renderBuilder("/processor/pipelines/new"); @@ -864,7 +907,7 @@ describe("PipelineBuilder", () => { target: { value: "Draft" }, }, ); - fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back")); expect( await screen.findByText("portal.pipelines.builder.unsavedTitle"), @@ -928,7 +971,7 @@ describe("PipelineBuilder", () => { await screen.findByRole("textbox", { name: "portal.pipelines.composer.name", }); - fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back")); expect(await screen.findByText("pipelines list")).toBeInTheDocument(); }); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index 746fd61ab0..d5092f5c88 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -67,7 +67,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; -import { PipelineHeader } from "@portal/components/pipelines/PipelineHeader"; +import { PipelineCreateHeader } from "@portal/components/pipelines/PipelineCreateHeader"; +import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader"; +import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar"; import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; import { @@ -258,10 +260,19 @@ export function PipelineBuilder() { const [inputAsked, setInputAsked] = useState(false); const [outputAsked, setOutputAsked] = useState(false); const [submitting, setSubmitting] = useState(false); + // Which create action is in flight, so only the button that was clicked (Create / Create paused) + // shows its spinner. Null in edit and while idle. + const [pendingCreateEnabled, setPendingCreateEnabled] = useState< + boolean | null + >(null); const [error, setError] = useState(null); const [seeded, setSeeded] = useState(false); const [running, setRunning] = useState(false); - const [clearingHistory, setClearingHistory] = useState(false); + // Pausing/activating an existing pipeline acts immediately (a separate save), not on the next + // "Save changes"; this tracks that in-flight toggle. + const [togglingEnabled, setTogglingEnabled] = useState(false); + // Clearing the processed record then running, so already-handled files go through again. + const [reprocessing, setReprocessing] = useState(false); const [runResult, setRunResult] = useState(null); const [pendingDelete, setPendingDelete] = useState(false); const [deleting, setDeleting] = useState(false); @@ -587,10 +598,11 @@ export function PipelineBuilder() { } // Track unsaved edits: snapshot the form and compare against the state captured just after - // seeding, so leaving the builder can prompt to save or discard. + // seeding, so leaving the builder can prompt to save or discard. `enabled` is deliberately left + // out: in edit it is toggled and persisted at once (never an unsaved edit), and in create it is + // chosen at submit - so it can never be the thing that makes the form dirty. const snapshot = JSON.stringify({ name: name.trim(), - enabled, input, steps: steps.map((step) => serializeToolStep(step, allTools)), uploads: steps.map(stepRequiresUpload), @@ -602,20 +614,46 @@ export function PipelineBuilder() { }, [seeded, snapshot]); const dirty = baseline.current !== null && baseline.current !== snapshot; - // The input needs a source, and a scheduled input needs a positive interval; the pipeline - // needs exactly one output destination. - const inputValid = - input.sourceId !== "" && - (input.triggerType !== "schedule" || Number(input.scheduleCount) > 0); + // Each validity condition is defined exactly once here, then consumed both by the graph (which + // flags each end) and by the blocker list below. + const sourceChosen = input.sourceId !== ""; + const scheduleValid = + input.triggerType !== "schedule" || Number(input.scheduleCount) > 0; + const inputValid = sourceChosen && scheduleValid; const outputValid = outputIds.length === 1; - const canSave = - name.trim() !== "" && - inputValid && - outputValid && - !hasUploadSteps && - !hasUnconfiguredSteps && - !hasIncompatibleSteps && - !submitting; + + // The single source of truth for "can this be committed": every reason it can't be, in the order + // they appear down the form, so a disabled Create / Save button can say exactly what is still owed. + const blockers: string[] = []; + if (name.trim() === "") + blockers.push(t("portal.pipelines.builder.blocker.name")); + if (!sourceChosen) + blockers.push(t("portal.pipelines.builder.blocker.source")); + else if (!scheduleValid) + blockers.push(t("portal.pipelines.builder.blocker.schedule")); + if (!outputValid) + blockers.push(t("portal.pipelines.builder.blocker.destination")); + if (hasUnconfiguredSteps) + blockers.push( + t("portal.pipelines.builder.blocker.setup", { + tools: unconfiguredStepLabels.join(", "), + }), + ); + if (hasUploadSteps) + blockers.push( + t("portal.pipelines.builder.blocker.upload", { + tools: uploadStepLabels.join(", "), + }), + ); + if (hasIncompatibleSteps) + blockers.push( + t("portal.pipelines.builder.blocker.incompatible", { + tools: blockingSteps.join(", "), + }), + ); + + // Nothing left to fix, and not already committing. + const canSave = blockers.length === 0 && !submitting; const listPath = toPortalPath(VIEW_PATHS.pipelines); @@ -629,14 +667,14 @@ export function PipelineBuilder() { else navigate(destination); } - async function save(destination: string) { + async function save(destination: string, enabledOverride?: boolean) { if (!canSave) return; setSubmitting(true); setError(null); const policy: Policy = { id: policyState.data?.id ?? undefined, name: name.trim(), - enabled, + enabled: enabledOverride ?? enabled, // The wire shape stays a list; canSave guarantees the one input has a source. inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], steps: steps.map((step) => serializeToolStep(step, allTools)), @@ -652,6 +690,39 @@ export function PipelineBuilder() { } catch (e) { setError(errorMessage(e)); setSubmitting(false); + setPendingCreateEnabled(null); + } + } + + // Create live or paused. The buttons disable until the pipeline is valid, so this only fires on a + // saveable pipeline; the flag records which button spins and whether it starts live or paused. + function submitCreate(enabledValue: boolean) { + setPendingCreateEnabled(enabledValue); + void save(listPath, enabledValue); + } + + /** + * Pause or activate the saved pipeline now, without leaving the builder. It re-saves the + * persisted policy with the flag flipped - deliberately NOT the working form - so a pending chain + * edit is not silently committed by a pause. The dirty tracker ignores `enabled`, so this never + * looks like an unsaved change. + */ + async function handleTogglePause() { + // Never run alongside a Save: both write the whole policy, and a concurrent pair would race + // (the pause carries the persisted steps, so it could clobber the edits Save is committing). + if (togglingEnabled || submitting || !policyState.data) return; + const next = !enabled; + setTogglingEnabled(true); + setError(null); + try { + await savePipeline({ ...policyState.data, enabled: next }); + if (!mounted.current) return; + setEnabled(next); + await invalidatePipelines(); + } catch (e) { + if (mounted.current) setError(errorMessage(e)); + } finally { + if (mounted.current) setTogglingEnabled(false); } } @@ -741,39 +812,45 @@ export function PipelineBuilder() { return { tone: "info", text: t("portal.pipelines.run.empty") }; } + // Trigger the saved pipeline and report the outcome: what the sweep started (or why it started + // nothing), then each run's terminal state. Shared by Run now and the reprocess action. + async function reportRun(policyId: string) { + const outcome = await triggerPipeline(policyId); + const runIds = outcome.runIds; + if (runIds.length === 0) { + if (mounted.current) setRunResult(emptySweepResult(outcome)); + return; + } + const finals = await Promise.all(runIds.map((runId) => awaitRun(runId))); + if (!mounted.current) return; + const failed = finals.find((r) => r?.status === "FAILED"); + if (failed) { + setRunResult({ + tone: "danger", + text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }), + }); + } else if (finals.some((r) => r === null)) { + // Gave up polling before a terminal status; the run may still finish server-side. + setRunResult({ + tone: "warning", + text: t("portal.pipelines.run.timeout"), + }); + } else if (finals.every((r) => r?.status === "COMPLETED")) { + setRunResult({ + tone: "success", + text: t("portal.pipelines.run.completed", { count: finals.length }), + }); + } else { + setRunResult({ tone: "info", text: t("portal.pipelines.run.running") }); + } + } + async function handleRun() { - if (running || !id) return; + if (running || reprocessing || !id) return; setRunning(true); setRunResult(null); try { - const outcome = await triggerPipeline(id); - const runIds = outcome.runIds; - if (runIds.length === 0) { - if (mounted.current) setRunResult(emptySweepResult(outcome)); - return; - } - const finals = await Promise.all(runIds.map((runId) => awaitRun(runId))); - if (!mounted.current) return; - const failed = finals.find((r) => r?.status === "FAILED"); - if (failed) { - setRunResult({ - tone: "danger", - text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }), - }); - } else if (finals.some((r) => r === null)) { - // Gave up polling before a terminal status; the run may still finish server-side. - setRunResult({ - tone: "warning", - text: t("portal.pipelines.run.timeout"), - }); - } else if (finals.every((r) => r?.status === "COMPLETED")) { - setRunResult({ - tone: "success", - text: t("portal.pipelines.run.completed", { count: finals.length }), - }); - } else { - setRunResult({ tone: "info", text: t("portal.pipelines.run.running") }); - } + await reportRun(id); } catch (e) { if (mounted.current) setRunResult({ tone: "danger", text: errorMessage(e) }); @@ -783,26 +860,22 @@ export function PipelineBuilder() { } /** - * Forget which source files this pipeline has processed, so the next sweep - * reprocesses everything currently in its sources (the standard retry for a - * parked-by-failure file). Does not touch the files themselves. + * Reprocess everything currently in the sources: forget which files the pipeline already handled, + * then run at once so those files - which a normal run skips - go through now. Reports the run's + * outcome exactly like Run now; does not touch the files themselves. */ - async function handleClearHistory() { - if (clearingHistory || !id) return; - setClearingHistory(true); + async function handleReprocessAll() { + if (running || reprocessing || !id) return; + setReprocessing(true); setRunResult(null); try { await clearProcessedHistory(id); - if (mounted.current) - setRunResult({ - tone: "success", - text: t("portal.pipelines.run.historyCleared"), - }); + await reportRun(id); } catch (e) { if (mounted.current) setRunResult({ tone: "danger", text: errorMessage(e) }); } finally { - if (mounted.current) setClearingHistory(false); + if (mounted.current) setReprocessing(false); } } @@ -1056,29 +1129,37 @@ export function PipelineBuilder() { return (
- save(listPath)} - onCancel={() => attemptLeave(listPath)} - onBack={() => attemptLeave(listPath)} - onTest={handleTest} - testing={testing} - onRun={handleRun} - running={running} - onClearHistory={handleClearHistory} - clearingHistory={clearingHistory} - onDelete={() => setPendingDelete(true)} - onViewDefinition={() => setDefinitionOpen(true)} - runResult={testSummary} - onDownloadOutput={downloadOutput} - /> + {isEdit ? ( + attemptLeave(listPath)} + canSave={canSave} + blockers={blockers} + saving={submitting} + onSave={() => save(listPath)} + onRun={handleRun} + running={running} + onReprocess={handleReprocessAll} + reprocessing={reprocessing} + onDelete={() => setPendingDelete(true)} + /> + ) : ( + submitCreate(true)} + onCreatePaused={() => submitCreate(false)} + onBack={() => attemptLeave(listPath)} + /> + )} {error && } {runResult && ( @@ -1110,44 +1191,54 @@ export function PipelineBuilder() { )}
- setSelected({ steps: [index] })} - /> +
+ setDefinitionOpen(true)} + /> + setSelected({ steps: [index] })} + /> +