Redesign New/Edit Pipeline top bars (#7438)

# Description of Changes
Replace the dev-UI top-bar in the New Pipeline and Edit Pipeline pages
with a redesigned layout appropriate for users. I've got a big list of
extra tweaks I'd like to do to the rest of the page including graph
tweaks etc. but this is the only thing on the New/Edit Pipelines pages
that is blocking for the release.

## Before
### New Pipeline
<img width="1510" height="788" alt="image"
src="https://github.com/user-attachments/assets/23f8a3a6-a8db-4d81-8c23-fd7a78864f2d"
/>

### Edit Pipeline
<img width="1512" height="791" alt="image"
src="https://github.com/user-attachments/assets/4c0a0c3c-81d8-4d15-91c7-6aa4f3de69c7"
/>

## After
### New Pipeline
<img width="757" height="395" alt="image"
src="https://github.com/user-attachments/assets/8a287cce-336c-4624-b4e9-a6a98eabb01d"
/>

### Edit Pipeline
<img width="1512" height="789" alt="image"
src="https://github.com/user-attachments/assets/9e096dbb-0a95-4a03-8d32-d5d71d2b57c5"
/>
This commit is contained in:
James Brunton
2026-08-14 07:44:41 +01:00
committed by GitHub
parent 929ded41a8
commit 4b26797ad8
22 changed files with 1548 additions and 888 deletions
@@ -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."
@@ -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;
}
@@ -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 (
<Tooltip
label={
<div className="portal-pipeline-blockers">
<p className="portal-pipeline-blockers__heading">{heading}</p>
<ul>
{blockers.map((blocker) => (
<li key={blocker}>{blocker}</li>
))}
</ul>
</div>
}
disabled={blockers.length === 0}
position="bottom-end"
withinPortal
multiline
w={280}
>
{children}
</Tooltip>
);
}
@@ -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;
}
@@ -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<typeof PipelineCreateHeader> = {
title: "Portal/Pipelines/PipelineCreateHeader",
component: PipelineCreateHeader,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof PipelineCreateHeader>;
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 (
<PipelineCreateHeader
name={name}
onNameChange={setName}
canSave={blockers.length === 0}
blockers={blockers}
saving={false}
pendingCreateEnabled={null}
onCreate={noop}
onCreatePaused={noop}
onBack={noop}
/>
);
}
/** A new pipeline: create is disabled, and hovering it lists what's still needed. */
export const New: Story = {
render: () => <Playground initialName="" />,
};
/** Named: the create actions become available. */
export const Named: Story = {
render: () => <Playground initialName="Claims redaction" />,
};
@@ -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<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: PortalTestProviders });
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function renderHeader(overrides: Partial<PipelineCreateHeaderProps> = {}) {
const handlers = {
onNameChange: vi.fn(),
onCreate: vi.fn(),
onCreatePaused: vi.fn(),
onBack: vi.fn(),
};
render(
<PipelineCreateHeader
name="Claims redaction"
canSave
blockers={[]}
saving={false}
pendingCreateEnabled={null}
{...handlers}
{...overrides}
/>,
);
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();
});
});
@@ -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 (
<section className="portal-pipeline-create-header">
<ActionIcon
variant="quiet"
size="sm"
onClick={onBack}
aria-label={t("portal.pipelines.builder.back")}
>
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
</ActionIcon>
<Input
className="portal-pipeline-create-header__name"
value={name}
placeholder={t("portal.pipelines.composer.namePlaceholder")}
aria-label={t("portal.pipelines.composer.name")}
onChange={(e) => onNameChange(e.target.value)}
/>
<div className="portal-pipeline-create-header__actions">
{/* The pair share one tooltip target because a disabled button swallows its own hover - the
wrapper is what the pointer lands on. */}
<PipelineBlockerTooltip
heading={t("portal.pipelines.builder.blocker.heading")}
blockers={blockers}
>
<div className="portal-pipeline-create-header__create">
<Button
variant="secondary"
size="sm"
loading={saving && pendingCreateEnabled === false}
disabled={!canSave}
onClick={onCreatePaused}
>
{t("portal.pipelines.composer.createPaused")}
</Button>
<Button
size="sm"
loading={saving && pendingCreateEnabled === true}
disabled={!canSave}
onClick={onCreate}
>
{t("portal.pipelines.composer.create")}
</Button>
</div>
</PipelineBlockerTooltip>
</div>
</section>
);
}
@@ -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);
}
@@ -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<typeof PipelineEditHeader> = {
title: "Portal/Pipelines/PipelineEditHeader",
component: PipelineEditHeader,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof PipelineEditHeader>;
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 (
<PipelineEditHeader
name={name}
onNameChange={setName}
enabled={enabled}
onTogglePause={() => 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: () => <Playground initialName="Claims redaction" />,
};
/** A paused pipeline: the toggle offers to activate it. */
export const Paused: Story = {
render: () => (
<Playground initialName="Claims redaction" initialEnabled={false} />
),
};
/** Edits that cannot yet be saved: Save is disabled and hovering it lists what's still needed. */
export const CannotSave: Story = {
render: () => (
<Playground
initialName="Claims redaction"
canSave={false}
blockers={["Choose a destination", "Finish setting up: Redact"]}
/>
),
};
@@ -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<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: PortalTestProviders });
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function renderHeader(overrides: Partial<PipelineEditHeaderProps> = {}) {
const handlers = {
onNameChange: vi.fn(),
onTogglePause: vi.fn(),
onBack: vi.fn(),
onSave: vi.fn(),
onRun: vi.fn(),
onReprocess: vi.fn(),
onDelete: vi.fn(),
};
render(
<PipelineEditHeader
name="Claims redaction"
enabled
togglingEnabled={false}
canSave
blockers={[]}
saving={false}
running={false}
reprocessing={false}
{...handlers}
{...overrides}
/>,
);
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();
});
});
@@ -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<HTMLInputElement>(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 (
<section className="portal-pipeline-edit-header">
<div className="portal-pipeline-edit-header__identity">
<ActionIcon
variant="quiet"
size="sm"
onClick={onBack}
aria-label={t("portal.pipelines.builder.back")}
>
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
</ActionIcon>
{renaming ? (
<Input
ref={inputRef}
className="portal-pipeline-edit-header__name-input"
value={draft}
aria-label={t("portal.pipelines.composer.name")}
onChange={(e) => setDraft(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === "Enter") finishRename(true);
if (e.key === "Escape") finishRename(false);
}}
/>
) : (
<>
<h1 className="portal-pipeline-edit-header__title">{name}</h1>
<ActionIcon
variant="quiet"
size="sm"
onClick={startRename}
aria-label={t("portal.pipelines.builder.rename")}
>
<EditOutlinedIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</>
)}
</div>
<div className="portal-pipeline-edit-header__actions">
{/* 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. */}
<Button
variant="secondary"
size="sm"
loading={togglingEnabled}
disabled={saving}
onClick={onTogglePause}
leftSection={
enabled ? (
<PauseRoundedIcon style={{ fontSize: "1.125rem" }} />
) : (
<PowerSettingsNewRoundedIcon style={{ fontSize: "1.125rem" }} />
)
}
>
{enabled
? t("portal.pipelines.builder.pause")
: t("portal.pipelines.builder.activate")}
</Button>
{/* 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). */}
<Button
variant="secondary"
size="sm"
loading={running}
disabled={reprocessing}
onClick={onRun}
leftSection={
<PlayArrowRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.run")}
</Button>
{/* Rare and destructive actions kept off the row so they do not compete with running. */}
<Dropdown.Root align="end">
<Dropdown.Trigger>
<ActionIcon
variant="tertiary"
size="sm"
aria-label={t("portal.pipelines.builder.moreActions")}
>
<MoreHorizRoundedIcon style={{ fontSize: "1.125rem" }} />
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item
onSelect={onReprocess}
disabled={reprocessing || running}
leading={<ReplayRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.detail.clearHistory")}
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item
onSelect={onDelete}
className="portal-pipeline-edit-header__delete-item"
leading={
<DeleteOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.delete")}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
{/* Wrapped in a span so the disabled button's hover still reaches the tooltip. */}
<PipelineBlockerTooltip
heading={t("portal.pipelines.builder.blocker.saveHeading")}
blockers={blockers}
>
<span className="portal-pipeline-edit-header__save">
<Button
size="sm"
onClick={onSave}
loading={saving}
disabled={!canSave || togglingEnabled}
>
{t("portal.pipelines.composer.save")}
</Button>
</span>
</PipelineBlockerTooltip>
</div>
</section>
);
}
@@ -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;
}
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar";
const meta: Meta<typeof PipelineGraphToolbar> = {
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<typeof PipelineGraphToolbar>;
/** 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",
},
},
};
@@ -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<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: PortalTestProviders });
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function renderToolbar(overrides: Partial<PipelineGraphToolbarProps> = {}) {
const handlers = {
onTest: vi.fn(),
onDownloadOutput: vi.fn(),
onViewDefinition: vi.fn(),
};
render(
<PipelineGraphToolbar
stepCount={2}
testing={false}
runResult={null}
{...handlers}
{...overrides}
/>,
);
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<HTMLInputElement>('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();
});
});
@@ -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 (
<div className="portal-pipeline-toolbar">
<FilePicker
variant="secondary"
size="sm"
accept="application/pdf"
loading={testing}
// A chain with no steps would hand the file straight back, reading as a silent no-op.
disabled={stepCount === 0}
onChange={(file) => file && onTest(file)}
leftSection={<ScienceOutlinedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.builder.testRun")}
</FilePicker>
{runResult && (
<RunResultStrip result={runResult} onDownload={onDownloadOutput} />
)}
{/* The graph is the visual definition; reading it as JSON/cURL sits at the far end of its bar. */}
<Tooltip
label={t("portal.pipelines.builder.viewDefinition")}
position="bottom"
withinPortal
>
<ActionIcon
variant="tertiary"
size="sm"
className="portal-pipeline-toolbar__definition"
onClick={onViewDefinition}
aria-label={t("portal.pipelines.builder.viewDefinition")}
>
<CodeRoundedIcon style={{ fontSize: "1.125rem" }} />
</ActionIcon>
</Tooltip>
</div>
);
}
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 (
<div className="portal-pipeline-toolbar__result">
<div className="portal-pipeline-toolbar__result-status">
{result.status === "running" && <Spinner size="sm" />}
{result.status === "completed" && (
<CheckCircleOutlineRoundedIcon
className="portal-pipeline-toolbar__result-icon is-ok"
style={{ fontSize: "1.25rem" }}
/>
)}
{result.status === "failed" && (
<ErrorOutlineRoundedIcon
className="portal-pipeline-toolbar__result-icon is-bad"
style={{ fontSize: "1.25rem" }}
/>
)}
<span>
{t(`portal.pipelines.inspector.status.${result.status}`, {
done: result.completedSteps,
count: result.stepCount,
})}
</span>
</div>
{/* 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 && (
<span className="portal-pipeline-toolbar__result-error">
{result.error}
</span>
)}
{outputs.map((output) => (
<Button
key={output.fileId}
variant="tertiary"
size="sm"
onClick={() => onDownload(output)}
leftSection={<DownloadRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{output.fileName ?? output.fileId}
</Button>
))}
</div>
);
}
@@ -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;
}
@@ -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<typeof PipelineHeader> = {
title: "Portal/Pipelines/PipelineHeader",
component: PipelineHeader,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof PipelineHeader>;
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 (
<PipelineHeader
name={name}
onNameChange={setName}
enabled={enabled}
onEnabledChange={setEnabled}
isEdit={isEdit}
stepCount={rest.stepCount ?? 2}
canSave={rest.canSave ?? true}
saving={rest.saving ?? false}
onSave={noop}
onCancel={noop}
onBack={noop}
onTest={noop}
testing={rest.testing ?? false}
onRun={noop}
running={rest.running ?? false}
onClearHistory={noop}
clearingHistory={false}
onDelete={noop}
onViewDefinition={noop}
runResult={runResult}
onDownloadOutput={noop}
/>
);
}
/** An existing pipeline: everything is available. */
export const Editing: Story = {
render: () => <Playground initialName="Claims redaction" isEdit />,
};
/**
* 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: () => <Playground initialName="" isEdit={false} canSave={false} />,
};
/** Paused: the pipeline exists but its trigger will not fire. */
export const Paused: Story = {
render: () => (
<Playground initialName="Claims redaction" isEdit initialEnabled={false} />
),
};
/** Mid test-run: the picker shows its own progress while the graph shows the steps. */
export const Testing: Story = {
render: () => <Playground initialName="Claims redaction" isEdit testing />,
};
/** After a test run: the outcome and its files sit beside the button that started them. */
export const WithRunResult: Story = {
render: () => (
<Playground
initialName="Claims redaction"
isEdit
runResult={{
status: "completed",
completedSteps: 3,
stepCount: 3,
outputs: [
{ fileId: "f1", fileName: "claim-redacted.pdf" },
{ fileId: "f2", fileName: null },
],
}}
/>
),
};
/** A failed run: the summary is here, the failing step's own message is on its node. */
export const WithFailedRun: Story = {
render: () => (
<Playground
initialName="Claims redaction"
isEdit
runResult={{ status: "failed", completedSteps: 1, stepCount: 3 }}
/>
),
};
@@ -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<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: PortalTestProviders });
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function renderHeader(overrides: Partial<PipelineHeaderProps> = {}) {
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(
<PipelineHeader
name="Claims redaction"
enabled
isEdit
stepCount={2}
canSave
saving={false}
testing={false}
running={false}
clearingHistory={false}
runResult={null}
{...handlers}
{...overrides}
/>,
);
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<HTMLInputElement>('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();
});
});
@@ -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 (
<section className="portal-pipeline-header">
<div className="portal-pipeline-header__top">
<Button
variant="quiet"
size="sm"
className="portal-pipeline-header__back"
onClick={onBack}
leftSection={
<ArrowBackRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.builder.back")}
</Button>
<div className="portal-pipeline-header__save">
<Button
variant="tertiary"
size="sm"
onClick={onCancel}
disabled={saving}
>
{t("portal.pipelines.composer.cancel")}
</Button>
<Button
size="sm"
onClick={onSave}
loading={saving}
disabled={!canSave}
>
{isEdit
? t("portal.pipelines.composer.save")
: t("portal.pipelines.composer.create")}
</Button>
</div>
</div>
<div className="portal-pipeline-header__identity">
<Input
className="portal-pipeline-header__name"
value={name}
placeholder={t("portal.pipelines.composer.namePlaceholder")}
aria-label={t("portal.pipelines.composer.name")}
onChange={(e) => 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. */}
<Checkbox
className="portal-pipeline-header__enabled"
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
label={t("portal.pipelines.builder.enabled")}
/>
</div>
<div className="portal-pipeline-header__actions">
<FilePicker
variant="secondary"
size="sm"
accept="application/pdf"
loading={testing}
// A chain with no steps would hand the file straight back, reading as a silent no-op.
disabled={stepCount === 0}
onChange={(file) => file && onTest(file)}
leftSection={<ScienceOutlinedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.builder.testRun")}
</FilePicker>
{isEdit && (
<Button
variant="secondary"
size="sm"
loading={running}
onClick={onRun}
leftSection={
<PlayArrowRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.run")}
</Button>
)}
{/* 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. */}
<Dropdown.Root align="start">
<Dropdown.Trigger>
<ActionIcon
variant="secondary"
size="sm"
aria-label={t("portal.pipelines.builder.moreActions")}
>
<MoreHorizRoundedIcon style={{ fontSize: "1.125rem" }} />
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item
onSelect={onViewDefinition}
leading={<CodeRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.builder.viewDefinition")}
</Dropdown.Item>
{isEdit && (
<Dropdown.Item
onSelect={onClearHistory}
disabled={clearingHistory}
leading={
<HistoryRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.clearHistory")}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown.Root>
{isEdit && (
<Button
variant="tertiary"
size="sm"
accent="danger"
className="portal-pipeline-header__delete"
onClick={onDelete}
leftSection={
<DeleteOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.delete")}
</Button>
)}
</div>
{runResult && (
<RunResultStrip result={runResult} onDownload={onDownloadOutput} />
)}
</section>
);
}
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 (
<div className="portal-pipeline-header__result">
<div className="portal-pipeline-header__result-status">
{result.status === "running" && <Spinner size="sm" />}
{result.status === "completed" && (
<CheckCircleOutlineRoundedIcon
className="portal-pipeline-header__result-icon is-ok"
style={{ fontSize: "1.25rem" }}
/>
)}
{result.status === "failed" && (
<ErrorOutlineRoundedIcon
className="portal-pipeline-header__result-icon is-bad"
style={{ fontSize: "1.25rem" }}
/>
)}
<span>
{t(`portal.pipelines.inspector.status.${result.status}`, {
done: result.completedSteps,
count: result.stepCount,
})}
</span>
</div>
{/* 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 && (
<span className="portal-pipeline-header__result-error">
{result.error}
</span>
)}
{outputs.map((output) => (
<Button
key={output.fileId}
variant="tertiary"
size="sm"
onClick={() => onDownload(output)}
leftSection={<DownloadRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{output.fileName ?? output.fileId}
</Button>
))}
</div>
);
}
@@ -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;
}
}
@@ -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();
});
@@ -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<string | null>(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<RunResult | null>(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 (
<div className="portal-builder">
<PipelineHeader
name={name}
onNameChange={setName}
enabled={enabled}
onEnabledChange={setEnabled}
isEdit={isEdit}
stepCount={steps.length}
canSave={canSave}
saving={submitting}
onSave={() => 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 ? (
<PipelineEditHeader
name={name}
onNameChange={setName}
enabled={enabled}
onTogglePause={handleTogglePause}
togglingEnabled={togglingEnabled}
onBack={() => attemptLeave(listPath)}
canSave={canSave}
blockers={blockers}
saving={submitting}
onSave={() => save(listPath)}
onRun={handleRun}
running={running}
onReprocess={handleReprocessAll}
reprocessing={reprocessing}
onDelete={() => setPendingDelete(true)}
/>
) : (
<PipelineCreateHeader
name={name}
onNameChange={setName}
canSave={canSave}
blockers={blockers}
saving={submitting}
pendingCreateEnabled={pendingCreateEnabled}
onCreate={() => submitCreate(true)}
onCreatePaused={() => submitCreate(false)}
onBack={() => attemptLeave(listPath)}
/>
)}
{error && <Banner tone="danger" description={error} />}
{runResult && (
@@ -1110,44 +1191,54 @@ export function PipelineBuilder() {
)}
<div className="portal-builder__grid">
<PipelineGraph
// An end is on the chain once it has been asked for or already holds a value, so a
// loaded pipeline needs no seeding: its source and destination place themselves.
input={
inputAsked || inputValid
? {
label:
chosenSource?.name ??
t("portal.pipelines.builder.chooseSource"),
detail: chosenSource ? triggerSummary() : undefined,
warning: inputValid
? undefined
: t("portal.pipelines.builder.needsSource"),
}
: null
}
output={
outputAsked || outputValid
? {
label:
chosenDestination?.name ??
t("portal.pipelines.builder.chooseDestination"),
warning: outputValid
? undefined
: t("portal.pipelines.builder.needsDestination"),
}
: null
}
steps={graphSteps}
selected={selected}
onSelect={setSelected}
onAddEnd={addEnd}
onRemoveEnd={removeEnd}
onInsertStep={setPickerAt}
onRemoveSteps={removeSteps}
onReorderSteps={reorderSteps}
onOpenStepError={(index) => setSelected({ steps: [index] })}
/>
<div className="portal-builder__canvas">
<PipelineGraphToolbar
stepCount={steps.length}
onTest={handleTest}
testing={testing}
runResult={testSummary}
onDownloadOutput={downloadOutput}
onViewDefinition={() => setDefinitionOpen(true)}
/>
<PipelineGraph
// An end is on the chain once it has been asked for or already holds a value, so a
// loaded pipeline needs no seeding: its source and destination place themselves.
input={
inputAsked || inputValid
? {
label:
chosenSource?.name ??
t("portal.pipelines.builder.chooseSource"),
detail: chosenSource ? triggerSummary() : undefined,
warning: inputValid
? undefined
: t("portal.pipelines.builder.needsSource"),
}
: null
}
output={
outputAsked || outputValid
? {
label:
chosenDestination?.name ??
t("portal.pipelines.builder.chooseDestination"),
warning: outputValid
? undefined
: t("portal.pipelines.builder.needsDestination"),
}
: null
}
steps={graphSteps}
selected={selected}
onSelect={setSelected}
onAddEnd={addEnd}
onRemoveEnd={removeEnd}
onInsertStep={setPickerAt}
onRemoveSteps={removeSteps}
onReorderSteps={reorderSteps}
onOpenStepError={(index) => setSelected({ steps: [index] })}
/>
</div>
<PipelineInspector
title={