mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user