New Pipeline UI redesign (#7202)

# Description of Changes
Supersedes #7144.

Redesign the Processor New Pipeline page to use a graph-based interface.
Far from perfect at this stage but I'm pretty happy with the
interactions on the graph itself. The bar at the top needs some work to
make it prettier and more clear what everything is for, but I'd rather
get this in and do changes in a follow-up PR because this is big enough
on its own and leaves us better than where we were before.

<img width="1270" height="598" alt="image"
src="https://github.com/user-attachments/assets/fa16f7d0-058d-4929-83a2-3c8e42859970"
/>

<img width="1511" height="788" alt="image"
src="https://github.com/user-attachments/assets/bbeb411f-dbf9-43c9-9b34-28b3a5917e5b"
/>

<img width="1261" height="754" alt="image"
src="https://github.com/user-attachments/assets/380fca1f-eed6-44fe-819d-c10598472583"
/>

<img width="1256" height="653" alt="image"
src="https://github.com/user-attachments/assets/59e9a673-b026-43a9-b7f1-ebaf89236df2"
/>

<img width="1216" height="726" alt="image"
src="https://github.com/user-attachments/assets/b34ae323-6842-44d5-b054-eacf08647ad7"
/>
This commit is contained in:
James Brunton
2026-08-10 16:05:05 +00:00
committed by GitHub
parent 35a861f4f8
commit 0ff4ef629c
51 changed files with 5310 additions and 913 deletions
+27 -2
View File
@@ -1828,6 +1828,21 @@
"editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Multiple Selected": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Nothing Selected": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [
"color-contrast"
],
@@ -1845,6 +1860,12 @@
"editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Incompatible Preceding Output": [
"color-contrast"
],
"editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: No Matches": [
"color-contrast"
],
"editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [
"color-contrast"
],
@@ -2235,9 +2256,13 @@
"color-contrast"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Default": [
"color-contrast"
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": [
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"],
"editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"],
"editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"],
+31 -7
View File
@@ -1404,8 +1404,7 @@
"color-contrast"
],
"editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [
"color-contrast",
"scrollable-region-focusable"
"color-contrast"
],
"editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"],
"editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [
@@ -1977,6 +1976,30 @@
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [
"color-contrast"
],
@@ -2294,13 +2317,14 @@
"editor/src/portal/views/Integrations.stories.tsx :: No Connections": [
"color-contrast"
],
"editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Default": [
"color-contrast"
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": [
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"],
"editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [
@@ -7800,7 +7800,6 @@ title = "Pipelines"
newPipeline = "New pipeline"
[portal.pipelines.builder]
addStep = "Add tool"
back = "Back to pipelines"
cannotFollow = "Can't take {{produced}}"
chooseAccount = "Choose an account"
@@ -7813,59 +7812,64 @@ inputs = "Input"
inputSource = "Input source"
inputTrigger = "Trigger"
keepEditing = "Keep editing"
moreActions = "More actions"
needsConfiguring = "Needs setting up"
needsDestination = "No destination chosen"
needsSource = "No source chosen"
needsUpload = "Needs an uploaded file"
noSources = "No sources connected yet. Create one to use it as an input."
noToolMatches = "No tools match your search."
pipelineSettings = "Pipeline settings"
searchTools = "Search tools"
selectToolBody = "Add a tool to build your pipeline."
selectToolTitle = "No tools yet"
sendToSystem = "Send to another system"
stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}."
stepsNeedSetup = "These steps still need setting up before saving: {{tools}}."
toolSettings = "Tool settings"
testRun = "Test with a file"
unknownStep = "Unrecognized operation, kept as-is."
unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?"
unsavedTitle = "Unsaved changes"
uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
usesDefaults = "Runs with default settings"
viewDefinition = "View definition"
[portal.pipelines.builder.diagnostic]
fan-in = "Combines every file from the previous step"
fan-out = "Runs once per file from the previous step"
format-mismatch = "Needs {{accepts}}, but the previous step produces {{produced}}"
output-uncertain = "May not run: the previous step's output depends on how it's set up"
source-mismatch = "Needs {{accepts}}, but this pipeline's input is {{produced}}"
fan-in = "Combines every incoming file"
fan-out = "Runs once per incoming file"
format-mismatch = "Sends {{produced}}, needs {{accepts}}"
output-uncertain = "May not run: output depends on setup"
source-mismatch = "Input is {{produced}}, needs {{accepts}}"
undeclared-operation = "Can't check what this step accepts"
[portal.pipelines.composer]
addTool = "Add tool"
addTool = "Add a tool"
cancel = "Cancel"
chainEmpty = "Add a tool to start building your pipeline."
create = "Create pipeline"
editingUnsupported = "Displaying these tool params for editing is not supported yet."
moveDown = "Move down"
moveUp = "Move up"
editSource = "Edit source"
name = "Name"
namePlaceholder = "e.g. Redaction sweep"
noToolSettings = "This tool has no configurable settings."
operations_one = "Operation ({{count}})"
operations_other = "Operations ({{count}})"
output = "Destination"
removeStep = "Remove operation"
save = "Save changes"
scheduleEvery = "Run every"
sources = "Sources"
sourcesLoading = "Loading sources..."
trigger = "Trigger"
triggerManual = "Manual only"
[portal.pipelines.composer.runsEvery]
days_one = "Runs every day"
days_other = "Runs every {{count}} days"
hours_one = "Runs every hour"
hours_other = "Runs every {{count}} hours"
minutes_one = "Runs every minute"
minutes_other = "Runs every {{count}} minutes"
[portal.pipelines.composer.unit]
days = "days"
hours = "hours"
minutes = "minutes"
[portal.pipelines.definition]
subtitle = "The pipeline as it would be saved."
title = "Definition"
[portal.pipelines.delete]
body = "Delete \"{{name}}\"? This can't be undone."
cancel = "Cancel"
@@ -7883,6 +7887,37 @@ connectSource = "Connect a source"
description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
title = "No pipelines yet"
[portal.pipelines.graph]
addFirstTool = "Add a tool"
dragHint = "Drop on a line to move it"
insertHere = "Add a tool here"
removeNode = "Remove {{name}}"
showError = "Show why {{name}} failed"
[portal.pipelines.graph.add]
input = "Add a source"
output = "Add a destination"
[portal.pipelines.graph.run]
done = "Done"
failed = "Failed"
running = "Running"
[portal.pipelines.inspector]
multipleBody = "Drag any of them onto a line to move them together, or press Delete to remove them."
multipleSelected_one = "{{count}} step selected"
multipleSelected_other = "{{count}} steps selected"
noSelectionBody = "Pick a node in the graph to change what it does."
noSelectionTitle = "Nothing selected"
[portal.pipelines.inspector.status]
completed_one = "Finished the only step"
completed_other = "Finished all {{count}} steps"
failed_one = "Failed on the only step"
failed_other = "Failed after {{done}} of {{count}} steps"
running_one = "Running the only step"
running_other = "Running step {{done}} of {{count}}"
[portal.pipelines.kpi]
active = "Active"
paused = "Paused"
@@ -147,6 +147,15 @@ describe("useAddPasswordOperation", () => {
});
describe("addPassword mappers", () => {
test("falls back to the default key length when the stored step omits it", () => {
// A pipeline step saved without keyLength must not deserialize to
// undefined: the settings UI calls keyLength.toString() on it.
const restored = addPasswordFromApiParams({
password: "user-pw",
} as never);
expect(restored.keyLength).toBe(128);
});
test("round-trips backend params, including the flattened permissions", () => {
// Baseline differs from the configured values so the round trip fails if
// fromApiParams drops a field instead of reconstructing it.
@@ -48,7 +48,7 @@ export const addPasswordFromApiParams = (
): Partial<AddPasswordFullParameters> => ({
password: apiParams.password ?? defaultParameters.password,
ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword,
keyLength: apiParams.keyLength,
keyLength: apiParams.keyLength ?? defaultParameters.keyLength,
permissions: {
preventAssembly:
apiParams.preventAssembly ?? permissionsDefaults.preventAssembly,
@@ -153,6 +153,21 @@ describe("serialize/deserialize round-trip", () => {
});
});
test("a stored step missing fields falls back to defaults, not undefined", () => {
// Mappers echo absent stored fields as explicit undefined; settings UIs
// then crash on things like keyLength.toString(). Defaults must win.
const back = deserializeToolStep(
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
registry,
);
expect(back.params.compressionLevel).toBe(
compressDefaults.compressionLevel,
);
expect(
Object.values(back.params).every((value) => value !== undefined),
).toBe(true);
});
test("an unknown endpoint is preserved as an unmapped step", () => {
const step = deserializeToolStep(
{ operation: "/api/v1/unknown/thing", parameters: { keep: true } },
@@ -291,12 +291,22 @@ export function deserializeToolStep(
if (!match) return unmappedStep(step);
const [toolId, entry] = match;
const config = entry.operationConfig;
const params: ErasedToolParams = config?.fromApiParams
? {
...(config.defaultParameters ?? {}),
...config.fromApiParams(step.parameters as never),
}
: { ...(config?.defaultParameters ?? {}) };
// Mappers echo missing stored fields as explicit `undefined`, which would
// clobber the default underneath; strip those so defaults always win.
const mapped = config?.fromApiParams
? Object.fromEntries(
Object.entries(
config.fromApiParams(step.parameters as never) as Record<
string,
unknown
>,
).filter(([, value]) => value !== undefined),
)
: {};
const params: ErasedToolParams = {
...(config?.defaultParameters ?? {}),
...mapped,
} as ErasedToolParams;
// Validate against the generated endpoint set instead of casting the matched string.
const operation =
resolveEndpoint(config, params) ??
@@ -173,7 +173,7 @@ test.describe("Files page", () => {
await gotoFilesPage(page);
const cards = page.locator(".files-page-card:not(.is-folder)");
await cards.nth(0).click();
await cards.nth(1).click({ modifiers: ["Control"] });
await cards.nth(1).click({ modifiers: ["ControlOrMeta"] });
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
// In multi-select (2+), plain-click ADDS instead of replacing.
@@ -198,7 +198,7 @@ test.describe("Files page", () => {
await expect(page.locator(".files-page-card-selector")).toHaveCount(0);
// 2+ selected: checkboxes appear on every file card.
await cards.nth(1).click({ modifiers: ["Control"] });
await cards.nth(1).click({ modifiers: ["ControlOrMeta"] });
await expect(
page.locator(".files-page-card-selector").first(),
).toBeVisible();
@@ -488,7 +488,7 @@ test.describe("Files page", () => {
const cards = page.locator(".files-page-card:not(.is-folder)");
await cards.nth(0).click();
// Drawer stays closed so the second click reaches the card.
await cards.nth(1).click({ modifiers: ["Control"] });
await cards.nth(1).click({ modifiers: ["ControlOrMeta"] });
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
});
});
+1 -1
View File
@@ -70,7 +70,7 @@ export function CodeBlock({
</Button>
)}
</div>
<pre className="sui-code__pre" style={{ maxHeight }}>
<pre className="sui-code__pre" style={{ maxHeight }} tabIndex={0}>
<code>{code}</code>
</pre>
</div>
+87
View File
@@ -0,0 +1,87 @@
/**
* NodeCard — a selectable labelled tile (icon badge + title + sub-line) on a raised surface.
* Shared surface, selection ring and content layout; feature-specific state is layered by callers.
*/
.sui-node-card {
position: relative;
display: flex;
align-items: stretch;
box-sizing: border-box;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition:
border-color var(--motion-fast),
box-shadow var(--motion-fast),
opacity var(--motion-fast);
}
/* The whole card selects. Re-assert the tile look over the shared Button base (which otherwise
imposes a fixed height, its own padding and an accent text colour). */
.sui-node-card__select.sui-btn {
flex: 1;
min-width: 0;
height: auto;
min-height: 0;
border: none;
background: none;
padding: 0.625rem 0.75rem;
text-align: left;
font-weight: 400;
color: var(--c-text);
border-radius: inherit;
}
/* Mantine wraps a button's children in its label element, so the glyph and text are laid out
there - a gap on the button root would only space the wrapper, not what is inside it. */
.sui-node-card__select.sui-btn .mantine-Button-label {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.625rem;
min-width: 0;
overflow: visible;
}
.sui-node-card__text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 0.0625rem;
}
.sui-node-card__title {
font-size: 0.875rem;
font-weight: 500;
color: var(--c-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sui-node-card__detail {
font-size: 0.6875rem;
/* --c-text-subtle does not clear 4.5:1 at this size in either theme (axe: 4.39 light, 3.66
dark); --c-text-muted is the next rung up and does. */
color: var(--c-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Selected: the primary ring. Wins over hover and the warning tone. */
.sui-node-card.is-selected {
border-color: var(--c-primary);
box-shadow: 0 0 0 1px var(--c-primary);
}
.sui-node-card:hover:not(.is-selected) {
border-color: var(--c-border-strong);
}
.sui-node-card--warning:not(.is-selected) {
border-color: var(--c-warning);
}
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
import { NodeCard } from "@app/ui/NodeCard";
import { ActionIcon } from "@app/ui/ActionIcon";
const meta = {
title: "UI/NodeCard",
component: NodeCard,
parameters: { layout: "padded" },
args: {
icon: <TuneRoundedIcon style={{ fontSize: "1.125rem" }} />,
title: "Compress",
detail: "level 7",
onSelect: () => {},
},
decorators: [
(Story) => (
<div style={{ width: "16rem" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof NodeCard>;
export default meta;
type Story = StoryObj<typeof meta>;
/** The default tile: icon badge, title, one-line sub-detail, selectable. */
export const Default: Story = {};
/** Selected — the primary ring the inspector points at. */
export const Selected: Story = { args: { selected: true } };
/** Warning tone — an amber border, for a tile that needs attention. */
export const Warning: Story = {
args: { tone: "warning", detail: "Needs setting up" },
};
/** A trailing control (here a remove button) sits beside the select target, not nested in it. */
export const WithTrailing: Story = {
args: {
trailing: (
<ActionIcon
variant="tertiary"
size="sm"
shape="circle"
aria-label="Remove"
>
<CloseRoundedIcon style={{ fontSize: "0.875rem" }} />
</ActionIcon>
),
},
};
+81
View File
@@ -0,0 +1,81 @@
import type { HTMLAttributes, MouseEvent, ReactNode, Ref } from "react";
import { Button } from "@app/ui/Button";
import { IconBadge, type IconBadgeAccent } from "@app/ui/IconBadge";
import "@app/ui/NodeCard.css";
/** Border tone. `selected` (a separate prop) overrides this with the primary ring. */
export type NodeCardTone = "default" | "warning";
export interface NodeCardProps extends Omit<
HTMLAttributes<HTMLDivElement>,
"title" | "onSelect"
> {
/** Glyph shown in a tone-tinted badge at the leading edge. */
icon: ReactNode;
iconAccent?: IconBadgeAccent;
title: ReactNode;
/** One-line summary under the title. Any node - a plain string, or a richer line. */
detail?: ReactNode;
tone?: NodeCardTone;
selected?: boolean;
/**
* When given, the whole card is a single select button (aria-pressed tracks `selected`). Trailing
* controls stay siblings of that button, never nested inside it, so the card holds no invalid
* nested interactive elements.
*/
onSelect?: (event: MouseEvent) => void;
/** Controls rendered over the card's trailing edge (a remove button, a status glyph, ...). */
trailing?: ReactNode;
ref?: Ref<HTMLDivElement>;
}
/**
* A labelled tile: an icon badge, a title, and an optional sub-line, on a raised card surface that
* can be selected. The recurring "node" motif - a step in a graph, an item in a board - lifted into
* a primitive so its surface, selection ring and content layout are shared rather than re-styled per
* feature. Callers layer their own state (drag, run status, ...) via `className` and `trailing`.
*/
export function NodeCard({
icon,
iconAccent,
title,
detail,
tone = "default",
selected = false,
onSelect,
trailing,
className,
ref,
...rest
}: NodeCardProps) {
return (
<div
{...rest}
ref={ref}
className={[
"sui-node-card",
tone === "warning" ? "sui-node-card--warning" : "",
selected ? "is-selected" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
<Button
variant="quiet"
className="sui-node-card__select"
aria-pressed={onSelect ? selected : undefined}
onClick={onSelect}
>
<IconBadge accent={iconAccent} size="sm">
{icon}
</IconBadge>
<span className="sui-node-card__text">
<span className="sui-node-card__title">{title}</span>
{detail && <span className="sui-node-card__detail">{detail}</span>}
</span>
</Button>
{trailing}
</div>
);
}
+1
View File
@@ -8,6 +8,7 @@ export * from "@app/ui/MethodBadge";
export * from "@app/ui/ToggleSwitch";
export * from "@app/ui/ProgressBar";
export * from "@app/ui/MetricCard";
export * from "@app/ui/NodeCard";
export * from "@app/ui/NavItem";
export * from "@app/ui/NavSurface";
export * from "@app/ui/PanelHeader";
+15
View File
@@ -212,6 +212,20 @@ async function localForm<T>(
return unwrap<T>(res);
}
/** POST a multipart/form-data body (file uploads), via the localBackend seam. The Content-Type is
* deliberately left unset so the browser writes it with the multipart boundary. */
async function localMultipart<T>(path: string, body: FormData): Promise<T> {
const res = await fetch(`${localBaseUrl()}${path}`, {
method: "POST",
headers: { Accept: "application/json", ...(await localAuthHeader()) },
body,
});
if (res.status === 401) {
onLocalUnauthorized();
}
return unwrap<T>(res);
}
// ────────────────────────────────────────────────────────────────────────────
// saas — hosted SaaS Java, admin's Supabase JWT
// ────────────────────────────────────────────────────────────────────────────
@@ -302,6 +316,7 @@ export const apiClient = {
local: {
json: localJson,
form: localForm,
multipart: localMultipart,
blob: localBlob,
},
/** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */
+51 -1
View File
@@ -1,4 +1,5 @@
import { apiClient } from "@portal/api/http";
import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation";
/**
* Pipelines service layer: the backend contract.
@@ -122,7 +123,13 @@ export type PolicyRunStatus =
| "FAILED"
| "CANCELLED";
/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */
/** One file a run produced, downloadable via /api/v1/general/files/{fileId}. */
export interface RunOutputFile {
fileId: string;
fileName: string | null;
}
/** A run's current state. Mirrors the backend `PolicyRunView`. */
export interface PolicyRunView {
runId: string;
policyId: string | null;
@@ -132,6 +139,11 @@ export interface PolicyRunView {
/** Human-readable failure message; set when status is FAILED. */
error: string | null;
errorCode: string | null;
/**
* Files the run produced, present once it completes. Whole-run, not per step: the backend keeps
* one flat list, so nothing here can be attributed to an individual step.
*/
outputs?: RunOutputFile[] | null;
createdAt: number;
}
@@ -198,6 +210,44 @@ export async function triggerPipeline(id: string): Promise<TriggerOutcome> {
);
}
/** What an ad-hoc test run posts: the steps as they stand, with no source and no trigger. */
export interface TestRunDefinition {
name: string;
steps: ToolApiStep[];
output: OutputSpec;
}
/**
* POST /api/v1/policies/run: run a definition against one uploaded file now. The builder's test
* path - callers force an inline output so nothing reaches the pipeline's real destination, and
* the pipeline need not be saved first.
*/
export async function runPipelineTest(
definition: TestRunDefinition,
file: File,
): Promise<{ runId: string }> {
const form = new FormData();
form.append(
"json",
new Blob([JSON.stringify(definition)], { type: "application/json" }),
);
form.append("fileInput", file);
// The POST returns the identifier as `jobId`, but it is the same run id every other endpoint
// (fetchRun, fetchRunOutput) calls `runId`; normalise to that here so callers see one name.
const res = await apiClient.local.multipart<{ jobId: string }>(
"/api/v1/policies/run",
form,
);
return { runId: res.jobId };
}
/** GET /api/v1/general/files/{id}: download one of a run's outputs. */
export async function fetchRunOutput(fileId: string): Promise<Blob> {
return apiClient.local.blob(
`/api/v1/general/files/${encodeURIComponent(fileId)}`,
);
}
/** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */
export async function fetchRun(runId: string): Promise<PolicyRunView> {
return apiClient.local.json<PolicyRunView>(
@@ -26,6 +26,10 @@
flex: 1 1 auto;
min-height: 0; /* scroll instead of growing past the viewport */
overflow-y: auto;
/* Hold the scrollbar's width whether or not it is showing. Without this, a page that grows past
the viewport (an editor panel filling in, say) makes the bar appear and shunts everything
sideways as it does. */
scrollbar-gutter: stable;
animation: fadeInUp var(--motion-enter) both;
}
@@ -1,15 +1,16 @@
import { useTranslation } from "react-i18next";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Button, Select } from "@app/ui";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { ActionIcon, Button, FormField, Select } from "@app/ui";
/**
* Picks the saved source a pipeline delivers its output to. A destination is just a
* source used as a write target. The value stays a list ({@code outputIds}) because
* the model supports several, but the product caps a pipeline at one destination
* today, so this renders a single dropdown over the same locations the builder
* loaded (filtered to writable types by the caller). Creating a new one is delegated
* to {@code onCreateNew} (the builder navigates to the source builder, prompting
* about unsaved edits first).
* loaded (filtered to writable types by the caller). Creating and editing one are
* delegated to {@code onCreateNew} / {@code onEdit}, which open the source modal
* over the builder - mirroring the input row.
*/
interface DestinationOption {
id: string;
@@ -20,8 +21,10 @@ interface DestinationPickerProps {
sources: DestinationOption[];
value: string[];
onChange: (outputIds: string[]) => void;
/** Leave the builder to create a new source location (navigate-away, like inputs). */
/** Create a new source location to write to (opens the source modal). */
onCreateNew: () => void;
/** Edit the chosen destination's own settings (opens the source modal on it). */
onEdit: (sourceId: string) => void;
}
export function DestinationPicker({
@@ -29,25 +32,45 @@ export function DestinationPicker({
value,
onChange,
onCreateNew,
onEdit,
}: DestinationPickerProps) {
const { t } = useTranslation();
const chosen = value[0] ?? "";
const hasSources = sources.length > 0;
// Mirrors the input row: the dropdown-plus-edit sits in a field, and "Connect source" lives on its
// own line below rather than inline. With nowhere to write to yet, only the connect button shows.
return (
<div className="portal-builder__input-row">
<div className="portal-builder__input-field">
<Select
inputSize="sm"
aria-label={t("portal.pipelines.composer.output")}
placeholder={t("portal.pipelines.builder.chooseDestination")}
value={value[0] ?? null}
invalid={value.length !== 1}
onChange={(id) => onChange(id ? [id] : [])}
options={sources.map((source) => ({
value: source.id,
label: source.name,
}))}
/>
</div>
<>
{hasSources && (
<FormField label={t("portal.pipelines.composer.output")}>
<div className="portal-builder__input-row">
<div className="portal-builder__input-field">
<Select
inputSize="sm"
aria-label={t("portal.pipelines.composer.output")}
placeholder={t("portal.pipelines.builder.chooseDestination")}
value={value[0] ?? null}
invalid={value.length !== 1}
onChange={(id) => onChange(id ? [id] : [])}
options={sources.map((source) => ({
value: source.id,
label: source.name,
}))}
/>
</div>
<ActionIcon
variant="tertiary"
className="portal-builder__source-edit"
aria-label={t("portal.pipelines.composer.editSource")}
disabled={chosen === ""}
onClick={() => onEdit(chosen)}
>
<EditOutlinedIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
</FormField>
)}
<Button
variant="tertiary"
size="sm"
@@ -56,6 +79,6 @@ export function DestinationPicker({
>
{t("portal.sources.actions.connectSource")}
</Button>
</div>
</>
);
}
@@ -0,0 +1,26 @@
/* The code is this modal's entire content, so it *is* the body rather than a window sitting inside
one. Framed, it drew a second box inside the panel's box - and the panel's own header already
says what the code is, which the code window's chrome was repeating. */
.portal-definition__modal .sui-modal__body {
padding: 0;
}
.portal-definition__code {
border: none;
border-radius: 0;
box-shadow: none;
}
/* Traffic-light dots imitate a window frame; this code already sits in a real one. */
.portal-definition__code .sui-code__dots {
display: none;
}
/* Line the toolbar and the code up with the modal header's text. */
.portal-definition__code .sui-code__chrome {
padding: 0.5rem 1.125rem;
}
.portal-definition__code .sui-code__pre {
padding: 0.875rem 1.125rem;
}
@@ -0,0 +1,48 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@app/ui";
import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal";
const meta: Meta<typeof PipelineDefinitionModal> = {
title: "Portal/Pipelines/PipelineDefinitionModal",
component: PipelineDefinitionModal,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof PipelineDefinitionModal>;
const JSON_BODY = JSON.stringify(
{
name: "Claims redaction",
enabled: true,
inputs: [{ sourceId: "src-in", trigger: { type: "schedule" } }],
steps: [
{ operation: "/api/v1/misc/ocr-pdf", parameters: { language: "eng" } },
{ operation: "/api/v1/security/redact", parameters: { terms: 2 } },
],
outputIds: ["src-out"],
},
null,
2,
);
/** Starts closed so the trigger can be exercised; click through to the tabs. */
function Playground({ initialOpen = false }: { initialOpen?: boolean }) {
const [open, setOpen] = useState(initialOpen);
return (
<>
<Button onClick={() => setOpen(true)}>View definition</Button>
<PipelineDefinitionModal
open={open}
onClose={() => setOpen(false)}
json={JSON_BODY}
/>
</>
);
}
/** The definition as it opens from the header: JSON first, cURL a tab away. */
export const Default: Story = { render: () => <Playground initialOpen /> };
/** The trigger it opens from, so the closed state can be exercised too. */
export const FromTrigger: Story = { render: () => <Playground /> };
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { render as baseRender, screen } from "@testing-library/react";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: PortalTestProviders });
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const JSON_BODY = '{\n "name": "Claims"\n}';
describe("PipelineDefinitionModal", () => {
it("renders nothing while closed", () => {
render(
<PipelineDefinitionModal
open={false}
onClose={vi.fn()}
json={JSON_BODY}
/>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens on the JSON tab", () => {
render(<PipelineDefinitionModal open onClose={vi.fn()} json={JSON_BODY} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText(/"name": "Claims"/)).toBeInTheDocument();
});
it("shows the definition alone - no tab strip to choose between", () => {
render(<PipelineDefinitionModal open onClose={vi.fn()} json={JSON_BODY} />);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import { CodeBlock, Modal } from "@app/ui";
import "@portal/components/pipelines/PipelineDefinitionModal.css";
export interface PipelineDefinitionModalProps {
open: boolean;
onClose: () => void;
/** The pipeline as it would be saved, pretty-printed. Re-read while the modal is open. */
json: string;
}
/**
* The pipeline's definition as it would be saved.
*
* Pipeline-scoped, so it opens from the header rather than the node inspector, and a modal rather
* than a panel because a definition grows with the chain and needs the width.
*/
export function PipelineDefinitionModal({
open,
onClose,
json,
}: PipelineDefinitionModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
onClose={onClose}
width="lg"
title={t("portal.pipelines.definition.title")}
subtitle={t("portal.pipelines.definition.subtitle")}
className="portal-definition__modal"
>
<CodeBlock
code={json}
lang="json"
maxHeight={420}
className="portal-definition__code"
/>
</Modal>
);
}
@@ -0,0 +1,133 @@
/**
* 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;
}
@@ -0,0 +1,118 @@
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 }}
/>
),
};
@@ -0,0 +1,200 @@
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();
});
});
@@ -0,0 +1,301 @@
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>
);
}
@@ -0,0 +1,34 @@
/**
* The builder's right-hand panel: the selected node's settings.
*/
.portal-inspector {
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);
/* The builder caps its columns so the page itself does not scroll, which means a settings form
taller than the viewport has to scroll in here - otherwise its lower half is unreachable. */
max-height: 100%;
}
.portal-inspector__body {
display: flex;
flex-direction: column;
gap: 0.875rem;
min-height: 0;
overflow-y: auto;
}
/* Names the node being edited, so the panel is not just a nameless form. */
.portal-inspector__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--c-text);
}
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormField, Input, Select } from "@app/ui";
import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector";
const meta: Meta<typeof PipelineInspector> = {
title: "Portal/Pipelines/PipelineInspector",
component: PipelineInspector,
parameters: { layout: "padded" },
decorators: [
(Story) => (
<div style={{ maxWidth: "22rem" }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineInspector>;
/** Stands in for a node's real editor, which the builder supplies. */
function StubSettings() {
return (
<>
<FormField label="Language">
<Input defaultValue="eng" />
</FormField>
<FormField label="Mode">
<Select
value="skip"
onChange={() => {}}
options={[
{ value: "skip", label: "Skip pages with text" },
{ value: "force", label: "Re-OCR every page" },
]}
/>
</FormField>
</>
);
}
/** A step is selected, so its editor is on show. */
export const Settings: Story = {
render: () => (
<PipelineInspector title="OCR">
<StubSettings />
</PipelineInspector>
),
};
/** Nothing selected: the panel says so rather than showing an empty form. */
export const NothingSelected: Story = { render: () => <PipelineInspector /> };
/**
* The step a test run stopped at. The error belongs to this node, so it reads here - the run's
* files are pipeline-wide and live in the header instead.
*/
export const FailedStep: Story = {
render: () => (
<PipelineInspector
title="Redact"
error="Redact: no search terms were configured"
>
<StubSettings />
</PipelineInspector>
),
};
/** Several steps at once: nothing single to configure, so the panel just says what is selected. */
export const MultipleSelected: Story = {
render: () => <PipelineInspector message="3 steps selected" />,
};
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { render as baseRender, screen } from "@testing-library/react";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import {
PipelineInspector,
type PipelineInspectorProps,
} from "@portal/components/pipelines/PipelineInspector";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: PortalTestProviders });
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function renderInspector(overrides: Partial<PipelineInspectorProps> = {}) {
render(
<PipelineInspector title="OCR" {...overrides}>
{/* `in` rather than ??, so a case of "nothing selected" can pass no children at all. */}
{"children" in overrides ? overrides.children : <p>step editor</p>}
</PipelineInspector>,
);
}
describe("PipelineInspector", () => {
it("shows the selected node's editor, named", () => {
renderInspector();
expect(screen.getByText("OCR")).toBeInTheDocument();
expect(screen.getByText("step editor")).toBeInTheDocument();
});
it("says so when nothing is selected, rather than showing an empty form", () => {
renderInspector({ children: null });
expect(
screen.getByText("portal.pipelines.inspector.noSelectionTitle"),
).toBeInTheDocument();
expect(screen.queryByText("OCR")).not.toBeInTheDocument();
});
it("surfaces the error for the step a run stopped at, above its settings", () => {
renderInspector({ error: "Redact: no search terms" });
expect(screen.getByText("Redact: no search terms")).toBeInTheDocument();
expect(screen.getByText("step editor")).toBeInTheDocument();
});
it("says how many are selected when there is no single thing to configure", () => {
renderInspector({ message: "3 steps selected" });
expect(screen.getByText("3 steps selected")).toBeInTheDocument();
// The message replaces the editor rather than sitting above it.
expect(screen.queryByText("step editor")).not.toBeInTheDocument();
expect(screen.queryByText("OCR")).not.toBeInTheDocument();
});
it("stays node-scoped: no definition or run-wide results here", () => {
renderInspector();
// The definition and a run's files are pipeline-wide and belong to the header.
expect(screen.queryByText(/definition/i)).not.toBeInTheDocument();
expect(screen.queryByRole("radiogroup")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,79 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
import { Banner, EmptyState } from "@app/ui";
import "@portal/components/pipelines/PipelineInspector.css";
export interface PipelineInspectorProps {
/** Names what is selected, e.g. the step's tool or "Input". Absent when nothing is. */
title?: string;
icon?: ReactNode;
/**
* Why the last test run failed on this step. Only ever set for the one step the run stopped at -
* the run reports a single failing step, so no other node can claim the error.
*/
error?: string | null;
/**
* Shown instead of any editor - for a selection with no single thing to configure, such as
* several steps at once.
*/
message?: string;
/** The selected node's own editor. Absent when nothing is selected. */
children?: ReactNode;
}
/**
* The builder's right-hand panel: the settings of whatever node is selected, and nothing else.
*
* Deliberately node-scoped. The definition and a test run's produced files belong to the whole
* pipeline, so they live with the other pipeline-scoped controls in the header - putting them in a
* switch here implied they were scoped to the selected node, which they never were.
*/
export function PipelineInspector({
title,
icon,
error,
message,
children,
}: PipelineInspectorProps) {
const { t } = useTranslation();
if (message) {
return (
<aside className="portal-inspector">
<EmptyState
size="compact"
title={message}
description={t("portal.pipelines.inspector.multipleBody")}
/>
</aside>
);
}
if (!children) {
return (
<aside className="portal-inspector">
<EmptyState
size="compact"
title={t("portal.pipelines.inspector.noSelectionTitle")}
description={t("portal.pipelines.inspector.noSelectionBody")}
/>
</aside>
);
}
return (
<aside className="portal-inspector">
{/* The title stays put while the settings scroll: it names the node being edited, which is
what makes a long form legible. */}
<div className="portal-inspector__title">
{icon ?? <TuneRoundedIcon style={{ fontSize: "1.125rem" }} />}
<span>{title}</span>
</div>
<div className="portal-inspector__body">
{error && <Banner tone="danger">{error}</Banner>}
{children}
</div>
</aside>
);
}
@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Modal } from "@app/ui";
import { SubcategoryId } from "@app/data/toolsTaxonomy";
import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation";
import { ToolPicker } from "@portal/components/pipelines/ToolPicker";
import "@portal/views/PipelineBuilder.css";
const tools: ExecutableTool[] = [
{
@@ -55,6 +57,21 @@ const meta = {
onPick: () => {},
onClose: () => {},
},
// The picker has no frame of its own - it fills the modal that hosts it, which is where its
// edges, width and scrolling come from. Shown bare, it would be a shape the app never renders.
decorators: [
(Story) => (
<Modal
open
onClose={() => {}}
width="md"
title="Add tool"
className="portal-pipelines__picker-modal"
>
<Story />
</Modal>
),
],
} satisfies Meta<typeof ToolPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -68,3 +85,14 @@ export const NoMatches: Story = {
tools: [],
},
};
/**
* The step before this one produces an encrypted PDF, so tools that only take a plain PDF are
* flagged (dimmed, with a sub-line reason) rather than hidden - the order stays the user's choice.
* Remove password still takes it, so it is not flagged.
*/
export const IncompatiblePrecedingOutput: Story = {
args: {
precedingOutput: "PDF_ENCRYPTED",
},
};
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import SearchRoundedIcon from "@mui/icons-material/SearchRounded";
import { Button, Input } from "@app/ui";
import {
getSubcategoryLabel,
SUBCATEGORY_ORDER,
@@ -77,16 +78,15 @@ export function ToolPicker({
}, [tools, query, t]);
return (
<div
className="portal-pipelines__picker"
role="dialog"
aria-label={t("portal.pipelines.builder.addStep")}
>
<div className="portal-pipelines__picker">
<div className="portal-pipelines__picker-search">
<input
<Input
autoFocus
inputSize="sm"
value={query}
aria-label={t("portal.pipelines.builder.searchTools")}
placeholder={t("portal.pipelines.builder.searchTools")}
leadingIcon={<SearchRoundedIcon style={{ fontSize: "1.125rem" }} />}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
@@ -104,36 +104,48 @@ export function ToolPicker({
<div className="portal-pipelines__picker-group-label">
{group.label}
</div>
{group.tools.map((tool) => (
<Button
key={tool.toolId}
variant="quiet"
justify="start"
fullWidth
className="portal-pipelines__picker-item"
onClick={() => onPick(tool)}
leftSection={
<span
className="portal-pipelines__picker-icon"
aria-hidden="true"
>
{tool.icon}
{group.tools.map((tool) => {
const incompatible = Boolean(
precedingOutput &&
!toolAcceptsFormat(tool.endpoint, precedingOutput),
);
return (
<Button
key={tool.toolId}
variant="quiet"
justify="start"
fullWidth
className={[
"portal-pipelines__picker-item",
incompatible ? "is-incompatible" : "",
]
.filter(Boolean)
.join(" ")}
onClick={() => onPick(tool)}
leftSection={
<span
className="portal-pipelines__picker-icon"
aria-hidden="true"
>
{tool.icon}
</span>
}
>
<span className="portal-pipelines__picker-text">
<span className="portal-pipelines__picker-name">
{tool.name}
</span>
{incompatible && precedingOutput && (
<span className="portal-pipelines__picker-note">
{t("portal.pipelines.builder.cannotFollow", {
produced: getToolFormatLabel(t, precedingOutput),
})}
</span>
)}
</span>
}
>
<span className="portal-pipelines__picker-name">
{tool.name}
</span>
{precedingOutput &&
!toolAcceptsFormat(tool.endpoint, precedingOutput) ? (
<span className="portal-pipelines__picker-note">
{t("portal.pipelines.builder.cannotFollow", {
produced: getToolFormatLabel(t, precedingOutput),
})}
</span>
) : null}
</Button>
))}
</Button>
);
})}
</div>
))
)}
@@ -0,0 +1,180 @@
/**
* One wire between two nodes, plus its insert affordance. The graph positions it; the wire is a
* 1px rule centred on the column with a filled arrowhead at the arriving end.
*/
.portal-graph-edge {
position: absolute;
/* A wide drop target: the wire is a thin line, but a dragged step can be released anywhere across
the row, so the whole band between the nodes catches it. `left` is the column centre, so pull
back by half to keep the band centred on it. Line, insert and warning are placed absolutely
within. */
width: 16rem;
transform: translateX(-50%);
--edge-color: var(--c-border-strong);
}
.portal-graph-edge__line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: var(--edge-color);
}
/**
* Arrowhead at the arriving end, so the chain reads as directed.
*/
.portal-graph-edge__line::after {
content: "";
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 0;
transform: translateX(-50%);
border-left: 0.1875rem solid transparent;
border-right: 0.1875rem solid transparent;
border-top: 0.3125rem solid var(--edge-color);
}
/* Insert: the shared ActionIcon restyled to a small dot beside the wire (not on top of it, where it
hid the line). Hidden at rest - a solid plus on every wire reads as busy - and revealed only when
the pointer is over this wire's drop band (see the reveal rule below). When shown it is solid, not
faint: off to one side on the canvas it needs a real border and glyph to be seen at all. */
.portal-graph-edge__insert.sui-ai {
position: absolute;
top: 50%;
/* Beside the wire: the column centre is 50%, nudge clear of the line and centre on the row. */
left: 50%;
transform: translate(0.6rem, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
min-width: 1.25rem;
min-height: 1.25rem;
border: 1px solid var(--c-border-strong);
background: var(--c-surface);
color: var(--c-text-muted);
opacity: 0;
transition:
opacity var(--motion-fast),
color var(--motion-fast),
border-color var(--motion-fast),
background var(--motion-fast);
}
/* Optically centre the glyph in the circle: MUI's Add icon carries a hair of bottom bias. */
.portal-graph-edge__insert.sui-ai svg {
display: block;
}
/* On a warning wire the insert sits just past the pill, out of flow so the pill stays centred on the
wire whether or not the insert is showing (it reveals on hover like every other wire's). */
.portal-graph-edge__note .portal-graph-edge__insert.sui-ai {
left: 100%;
margin-left: 0.375rem;
transform: translateY(-50%);
}
/* Reveal the insert when the pointer is anywhere in this wire's drop band, or it has keyboard focus.
Revealing is not highlighting: it comes in at its resting weight and only goes primary once the
pointer is on the button itself (below) - not from anywhere in the wide band. */
.portal-graph-edge:hover .portal-graph-edge__insert.sui-ai,
.portal-graph-edge__insert.sui-ai:focus-visible {
opacity: 1;
}
.portal-graph-edge__insert.sui-ai:hover,
.portal-graph-edge__insert.sui-ai:focus-visible {
color: var(--c-accent-fg, var(--c-primary));
border-color: var(--c-primary);
}
/* A wire with no slot of its own (either side of the placeholder): line only. */
.portal-graph-edge.is-plain {
--edge-color: var(--c-border-subtle);
}
/**
* The pairing does not make much sense. Advisory: the wire still accepts drops and the chain still
* runs - the order stays the user's choice.
*/
.portal-graph-edge.has-warning {
--edge-color: var(--c-warning);
}
/* The note and its insert ride together, centred on the wire, so a warned pairing keeps a way to
take a fixing step between its ends. Grows to its content and may overhang the band, which the
wider graph column absorbs. */
.portal-graph-edge__note {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.portal-graph-edge__warning {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.0625rem 0.375rem;
border-radius: var(--radius-pill);
border: 1px solid var(--c-warning);
/* Matches the shared Banner's warning treatment: tinted ground, amber border and glyph, ordinary
text. Amber words would not clear 4.5:1 at this size, and the tint is what makes the neutral
text read as part of a warning rather than as stray body copy. */
background: color-mix(in srgb, var(--c-warning) 12%, var(--c-surface));
color: var(--c-text);
font-size: 0.6875rem;
line-height: 1.4;
}
.portal-graph-edge__warning svg {
color: var(--c-warning);
flex: none;
}
/* A blocking pairing: the chain cannot run in this order, so it must not read as the same gentle
advice as an odd-but-workable one. Same shape, danger tone - including the wire and its head,
which follow --edge-color. */
.portal-graph-edge.is-blocking {
--edge-color: var(--c-danger);
}
.portal-graph-edge.is-blocking .portal-graph-edge__warning {
border-color: var(--c-danger);
background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface));
}
.portal-graph-edge.is-blocking .portal-graph-edge__warning svg {
color: var(--c-danger);
}
.portal-graph-edge__warning-label {
white-space: nowrap;
}
/* While a step is being dragged, the insert is beside the point - the wire itself is the target -
and it would only clutter the row and collide with the drag hint. Hide it until the drag ends.
The wire itself stays at rest until the step is actually over it: lighting every wire the moment
a drag starts is noise, not a cue. */
.portal-graph-edge.is-available .portal-graph-edge__insert.sui-ai {
display: none;
}
/* The step is over this wire and would land here on release: only then does the wire go primary. */
.portal-graph-edge.is-over {
--edge-color: var(--c-primary);
}
.portal-graph-edge.is-over .portal-graph-edge__line {
width: 2px;
}
@@ -0,0 +1,109 @@
import { useTranslation } from "react-i18next";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { ActionIcon } from "@app/ui";
import type { LaidOutEdge } from "@portal/components/pipelines/graph/pipelineLayout";
import { useEdgeDrop } from "@portal/components/pipelines/graph/useChainDragDrop";
import "@portal/components/pipelines/graph/GraphEdge.css";
/**
* A note on the wire arriving at a node: why what flows in will not suit it.
*
* `blocking` separates "this cannot run in this order" from "this is probably not what you meant".
* Both are shown on the wire and neither refuses the edit - the order stays the user's to choose -
* but only a blocking one stops the pipeline being saved, so it must not read as mere advice.
*/
export interface ChainWarning {
text: string;
blocking?: boolean;
}
export interface GraphEdgeProps {
edge: LaidOutEdge;
/** Add a new step in the slot this wire opens. */
onInsert: (index: number) => void;
stepCount: number;
/** Given the chain's new order as original step indices, and which steps the drag carried. */
onReorder: (order: number[], moved: readonly number[]) => void;
/** A step is in flight, so open wires advertise themselves as landing spots. */
dragActive: boolean;
/**
* Why what flows along this wire will not be much use to the node it arrives at (encrypting
* before an OCR, say). Never refuses the edit - the order stays the user's to choose - but a
* blocking one means the chain cannot run at all, and is coloured apart from mere advice.
*/
warning?: ChainWarning;
}
/**
* One wire between two nodes: a directed line carrying an insert affordance, and the drop target
* that catches a step dragged onto it. Where the pairing does not make sense the wire says so,
* rather than refusing it.
*/
export function GraphEdge({
edge,
onInsert,
stepCount,
onReorder,
dragActive,
warning,
}: GraphEdgeProps) {
const { t } = useTranslation();
const { ref, over } = useEdgeDrop({
insertIndex: edge.insertIndex,
stepCount,
onReorder,
});
const open = edge.insertIndex !== null;
// The insert affordance is shown whenever the wire opens a slot - including on a warned wire, so a
// bad pairing can still take a fixing step between its ends rather than losing its only way in.
const insertButton = open ? (
<ActionIcon
variant="tertiary"
size="sm"
shape="circle"
className="portal-graph-edge__insert"
aria-label={t("portal.pipelines.graph.insertHere")}
onClick={() => onInsert(edge.insertIndex as number)}
>
<AddRoundedIcon style={{ fontSize: "0.875rem" }} />
</ActionIcon>
) : null;
return (
<div
ref={ref}
className={[
"portal-graph-edge",
open ? "" : "is-plain",
dragActive && open ? "is-available" : "",
over ? "is-over" : "",
warning ? "has-warning" : "",
warning?.blocking ? "is-blocking" : "",
]
.filter(Boolean)
.join(" ")}
style={{
left: `${edge.x}px`,
top: `${edge.y1}px`,
height: `${edge.y2 - edge.y1}px`,
}}
>
<span className="portal-graph-edge__line" aria-hidden />
{warning ? (
<span className="portal-graph-edge__note">
<span className="portal-graph-edge__warning" title={warning.text}>
<WarningAmberRoundedIcon style={{ fontSize: "0.875rem" }} />
<span className="portal-graph-edge__warning-label">
{warning.text}
</span>
</span>
{insertButton}
</span>
) : (
insertButton
)}
</div>
);
}
@@ -0,0 +1,140 @@
/**
* Graph-only extras layered on the shared NodeCard tile (see @app/ui/NodeCard): the config warning
* line, drag dimming, the remove control and run-state glyphs. The surface, selection ring and
* icon/title/detail layout all live in NodeCard.
*/
/* Amber carries the tone on the glyph; the words stay body-coloured. --c-warning is amber-600,
which is only 3.18:1 on a light surface at this size (axe) - readable as an icon, not as text. */
.portal-graph-node__warning {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--c-text);
}
.portal-graph-node__warning svg {
color: var(--c-warning);
flex: none;
}
/* Lifted out of the chain: the origin dims so the drop target reads as the real position. */
.portal-graph-node.is-dragging {
opacity: 0.4;
}
/* Steps can be picked up and moved; the input and output are fixed ends. */
.portal-graph-node--step .sui-node-card__select.sui-btn {
cursor: grab;
}
.portal-graph-node--step.is-dragging .sui-node-card__select.sui-btn {
cursor: grabbing;
}
/* Remove: quiet until the node is hovered or focused, so the chain stays calm. */
.portal-graph-node__remove.sui-ai {
position: absolute;
top: -0.4375rem;
/* Logical, so in RTL the remove sits on the card's trailing (left) corner rather than on top of
the leading icon badge. */
inset-inline-end: -0.4375rem;
width: 1.25rem;
height: 1.25rem;
min-width: 1.25rem;
min-height: 1.25rem;
border: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-subtle);
opacity: 0;
transition:
opacity var(--motion-fast),
color var(--motion-fast),
border-color var(--motion-fast);
}
.portal-graph-node:hover .portal-graph-node__remove.sui-ai,
.portal-graph-node.is-selected .portal-graph-node__remove.sui-ai,
.portal-graph-node__remove.sui-ai:focus-visible {
opacity: 1;
}
.portal-graph-node__remove.sui-ai:hover {
color: var(--c-danger);
border-color: var(--c-danger);
}
/* Run state: a status glyph on the card's trailing edge, inside the node. The state is carried by
the icon's shape as well as its colour, with the wording kept for assistive tech. */
.portal-graph-node__run {
flex: none;
align-self: center;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
margin-inline-end: 0.625rem;
color: var(--c-text-subtle);
}
/* A failed step's glyph is a button (it opens the error), so re-assert the plain glyph look over
the shared ActionIcon base. */
.portal-graph-node__run--open.sui-ai {
width: 1.25rem;
height: 1.25rem;
min-width: 1.25rem;
min-height: 1.25rem;
border: none;
background: none;
color: var(--c-danger);
}
.portal-graph-node__run-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.portal-graph-node.is-done .portal-graph-node__run {
color: var(--c-success);
}
.portal-graph-node.is-failed .portal-graph-node__run {
color: var(--c-danger);
}
/* Running is carried by the pulsing glyph alone: a primary border here would be the selected
treatment, and "the step I am editing" must stay distinguishable from "the step running now". */
.portal-graph-node.is-running .portal-graph-node__run {
color: var(--c-primary);
}
.portal-graph-node__pulse {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentColor;
animation: portal-graph-pulse 1.2s ease-in-out infinite;
}
@keyframes portal-graph-pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.portal-graph-node__pulse {
animation: none;
}
}
@@ -0,0 +1,183 @@
import type { MouseEvent, ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded";
import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded";
import SendRoundedIcon from "@mui/icons-material/SendRounded";
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { ActionIcon, NodeCard } from "@app/ui";
import { type IconBadgeAccent } from "@app/ui/IconBadge";
import type { GraphNodeKind } from "@portal/components/pipelines/graph/pipelineLayout";
import "@portal/components/pipelines/graph/GraphNode.css";
/** How a node is faring in the current or last test run. */
export type NodeRunState = "running" | "done" | "failed";
/** The kinds this card renders. The placeholder is its own component, not a node variant. */
type CardKind = Exclude<GraphNodeKind, "placeholder">;
const KIND_ICON: Record<CardKind, ReactNode> = {
input: <MoveToInboxRoundedIcon style={{ fontSize: "1.125rem" }} />,
step: <TuneRoundedIcon style={{ fontSize: "1.125rem" }} />,
output: <SendRoundedIcon style={{ fontSize: "1.125rem" }} />,
};
const KIND_ACCENT: Record<CardKind, IconBadgeAccent> = {
input: "green",
step: "blue",
output: "purple",
};
export interface GraphNodeProps {
kind: CardKind;
title: string;
/** One-line summary under the title (the source's path, a step's parameters). */
detail?: string;
/**
* Problem with this node's configuration, shown in place of the detail. Distinct from a run
* failure: this is why the pipeline cannot be saved yet.
*/
warning?: string;
/** The step's own tool glyph; falls back to a per-kind default. */
icon?: ReactNode;
selected: boolean;
runState?: NodeRunState;
onOpenRunState?: () => void;
onSelect: (event: MouseEvent) => void;
/** Takes the node off the chain. For an end, that returns its row to a placeholder. */
onRemove?: () => void;
/** True while this node is being dragged to another place in the chain. */
dragging?: boolean;
/**
* The step's place in the chain, so a multi-step drag preview can find the other selected cards
* in the DOM. Absent for the input and output, which are never dragged.
*/
stepIndex?: number;
/** The card element, for the drag adapter to register against. */
ref?: Ref<HTMLDivElement>;
}
/**
* One node in the pipeline graph: the shared {@link NodeCard} tile carrying its glyph, title and a
* one-line summary, plus the graph-only extras layered on top - run status, a remove control, drag
* dimming, and the "why this cannot be saved" warning line. Position is applied by the graph, so the
* node itself knows nothing about layout.
*/
export function GraphNode({
kind,
title,
detail,
warning,
icon,
selected,
runState,
onOpenRunState,
onSelect,
onRemove,
dragging,
stepIndex,
ref,
}: GraphNodeProps) {
const { t } = useTranslation();
const runStatus = runState && (
<RunStatus
runState={runState}
title={title}
onOpenRunState={onOpenRunState}
/>
);
const remove = onRemove && (
<ActionIcon
variant="tertiary"
size="sm"
shape="circle"
className="portal-graph-node__remove"
aria-label={t("portal.pipelines.graph.removeNode", { name: title })}
onClick={onRemove}
>
<CloseRoundedIcon style={{ fontSize: "0.875rem" }} />
</ActionIcon>
);
return (
<NodeCard
ref={ref}
data-graph-node={kind}
data-step-index={stepIndex}
className={[
"portal-graph-node",
`portal-graph-node--${kind}`,
dragging ? "is-dragging" : "",
runState ? `is-${runState}` : "",
]
.filter(Boolean)
.join(" ")}
icon={icon ?? KIND_ICON[kind]}
iconAccent={KIND_ACCENT[kind]}
title={title}
tone={warning ? "warning" : "default"}
selected={selected}
onSelect={onSelect}
detail={
warning ? (
<span className="portal-graph-node__warning">
<WarningAmberRoundedIcon style={{ fontSize: "0.875rem" }} />
{warning}
</span>
) : (
detail
)
}
trailing={
<>
{runStatus}
{remove}
</>
}
/>
);
}
interface RunStatusProps {
runState: NodeRunState;
title: string;
onOpenRunState?: () => void;
}
/** The run glyph on the card's trailing edge; a button when it opens a failure, else a status. */
function RunStatus({ runState, title, onOpenRunState }: RunStatusProps) {
const { t } = useTranslation();
if (runState === "failed" && onOpenRunState) {
return (
<ActionIcon
variant="tertiary"
size="sm"
shape="circle"
className="portal-graph-node__run portal-graph-node__run--open"
aria-label={t("portal.pipelines.graph.showError", { name: title })}
onClick={onOpenRunState}
>
<ErrorOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
</ActionIcon>
);
}
return (
<span className="portal-graph-node__run" role="status">
{runState === "running" && (
<span className="portal-graph-node__pulse" aria-hidden />
)}
{runState === "done" && (
<CheckRoundedIcon style={{ fontSize: "1.125rem" }} />
)}
{runState === "failed" && (
<ErrorOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
)}
<span className="portal-graph-node__run-label">
{t(`portal.pipelines.graph.run.${runState}`)}
</span>
</span>
);
}
@@ -0,0 +1,44 @@
/**
* The empty-pipeline stand-in: a dashed node in the row the first step will take. Dashed rather
* than solid so it reads as "not yet a step", and full width so it is an obvious target.
*/
.portal-graph-placeholder.sui-btn {
width: 100%;
height: auto;
min-height: 0;
padding: 0.625rem 0.75rem;
background: none;
border: 1px dashed var(--c-border-strong);
border-radius: var(--radius-lg);
color: var(--c-text-muted);
font-weight: 400;
text-align: left;
transition:
border-color var(--motion-fast),
color var(--motion-fast),
background var(--motion-fast);
}
.portal-graph-placeholder.sui-btn:hover {
border-color: var(--c-primary);
border-style: solid;
color: var(--c-accent-fg, var(--c-primary));
background: var(--c-surface);
}
/* Mantine lays a button's children out inside its label element, not on the root. */
.portal-graph-placeholder.sui-btn .mantine-Button-label {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.625rem;
min-width: 0;
overflow: visible;
}
.portal-graph-placeholder__title {
font-size: 0.875rem;
font-weight: 500;
}
@@ -0,0 +1,30 @@
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Button } from "@app/ui";
import "@portal/components/pipelines/graph/GraphPlaceholderNode.css";
export interface GraphPlaceholderNodeProps {
label: string;
onAdd: () => void;
}
/**
* The stand-in for a row the pipeline has not filled yet - the first step, or either end of the
* chain on a new pipeline. It sits in the row that thing will occupy, so the chain reads as
* input -> something -> output straight away, and it is the thing you click to fill it: a full-width
* target, rather than a caption pointing at a small plus on a wire.
*/
export function GraphPlaceholderNode({
label,
onAdd,
}: GraphPlaceholderNodeProps) {
return (
<Button
variant="quiet"
className="portal-graph-placeholder"
onClick={onAdd}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
<span className="portal-graph-placeholder__title">{label}</span>
</Button>
);
}
@@ -0,0 +1,79 @@
/**
* The graph surface. The canvas is sized by the derived layout and centred in the scroll area, so
* the chain stays put as steps are added or removed.
*/
.portal-graph {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 1rem;
/* Grows with the chain up to whatever the page gives it, then scrolls rather than pushing the
inspector out of reach. A short chain still hugs its content, so there is no empty canvas. */
max-height: 100%;
overflow: auto;
/* The canvas must sit below the node cards on the surface ladder so they read as raised off it in
both themes. --c-surface-sunken is the only rung darker than --c-surface in light *and* dark;
the legacy --color-bg-subtle collapsed into the page in dark, and --c-bg-raised is lighter than
the cards in light. */
background: var(--c-surface-sunken);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
}
/* Sits beside the wire it is describing. Absolute, so appearing mid-drag moves nothing. */
.portal-graph__drag-hint {
position: absolute;
margin: 0;
/* `left` is the column centre; clear the wire's hit area before the text starts. */
transform: translate(1.75rem, -50%);
white-space: nowrap;
font-size: 0.75rem;
font-weight: 500;
color: var(--c-accent-fg, var(--c-primary));
pointer-events: none;
}
/* transform has no logical form, so mirror it by hand: in RTL the hint clears the wire on the other
side rather than reaching back across it onto the chain. */
[dir="rtl"] .portal-graph__drag-hint {
transform: translate(-1.75rem, -50%);
}
/* What follows the cursor when several steps are dragged at once: a copy of each card, stacked, so
the drag shows what is actually moving rather than only the card that was grabbed. Cloned nodes
keep their own styling; they are inert copies, hence no pointer events. */
.portal-graph__drag-preview {
display: flex;
flex-direction: column;
gap: 0.375rem;
pointer-events: none;
}
.portal-graph__drag-preview .portal-graph-node {
opacity: 0.9;
}
.portal-graph__canvas {
position: relative;
flex: none;
}
/* Nodes are placed by the layout; the slot carries the position, the card fills it. */
.portal-graph__slot {
position: absolute;
display: flex;
}
.portal-graph__slot > * {
flex: 1;
min-width: 0;
}
.portal-graph__hint {
margin: 0;
font-size: 0.8125rem;
color: var(--c-text-subtle);
text-align: center;
}
@@ -0,0 +1,207 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
PipelineGraph,
selectedSteps,
type ChainEnd,
type GraphNodeContent,
type GraphSelection,
type GraphStepContent,
} from "@portal/components/pipelines/graph/PipelineGraph";
const meta: Meta<typeof PipelineGraph> = {
title: "Portal/Pipelines/PipelineGraph",
component: PipelineGraph,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof PipelineGraph>;
const INPUT = {
label: "Claims intake",
detail: "/srv/claims/in - every hour",
};
const OUTPUT = {
label: "Archive bucket",
detail: "s3://claims-archive/done",
};
/**
* The builder owns the chain in the app, so the stories own it here - otherwise adding, removing
* and dragging would fire their handlers and visibly do nothing. Everything in these stories is
* live: click a wire's plus to insert, the node's X to remove, and drag a step onto a wire to move
* it there.
*/
function Playground({
initialSteps,
output = OUTPUT,
/** Start with neither end on the chain, the way a brand new pipeline opens. */
unplacedEnds = false,
}: {
initialSteps: GraphStepContent[];
output?: { label: string; detail?: string; warning?: string };
unplacedEnds?: boolean;
}) {
const [steps, setSteps] = useState<GraphStepContent[]>(initialSteps);
const [selected, setSelected] = useState<GraphSelection>(null);
const [added, setAdded] = useState(0);
const [inputEnd, setInputEnd] = useState<GraphNodeContent | null>(
unplacedEnds ? null : INPUT,
);
const [outputEnd, setOutputEnd] = useState<GraphNodeContent | null>(
unplacedEnds ? null : output,
);
// Placing an end leaves it owing a choice, which is the warning state the builder shows until the
// user picks a source or destination.
function addEnd(end: ChainEnd) {
if (end === "input") {
setInputEnd({ label: "Choose a source", warning: "No source chosen" });
} else {
setOutputEnd({
label: "Choose a destination",
warning: "No destination chosen",
});
}
setSelected(end);
}
function removeEnd(end: ChainEnd) {
if (end === "input") setInputEnd(null);
else setOutputEnd(null);
setSelected((current) => (current === end ? null : current));
}
function insert(at: number) {
const label = `New tool ${added + 1}`;
setAdded((n) => n + 1);
setSteps((current) => {
const next = [...current];
next.splice(at, 0, { label });
return next;
});
setSelected({ steps: [at] });
}
function remove(indices: number[]) {
const gone = new Set(indices);
setSteps((current) => current.filter((_, i) => !gone.has(i)));
setSelected(null);
}
function reorder(order: number[]) {
const moving = new Set(selectedSteps(selected));
setSteps((current) => order.map((i) => current[i]));
const landed = order
.map((original, position) => ({ original, position }))
.filter(({ original }) => moving.has(original))
.map(({ position }) => position);
setSelected(landed.length > 0 ? { steps: landed } : null);
}
return (
<PipelineGraph
input={inputEnd}
output={outputEnd}
steps={steps}
selected={selected}
onSelect={setSelected}
onAddEnd={addEnd}
onRemoveEnd={removeEnd}
onInsertStep={insert}
onRemoveSteps={remove}
onReorderSteps={reorder}
/>
);
}
/** A typical chain. Drag a step onto any wire to move it there. */
export const Default: Story = {
render: () => (
<Playground
initialSteps={[
{ label: "OCR", detail: "eng, skip text pages" },
{ label: "Redact", detail: "2 terms" },
{ label: "Compress", detail: "level 7" },
]}
/>
),
};
/** A pipeline with its ends settled but no steps yet: the placeholder holds the first step's place. */
export const Empty: Story = {
render: () => <Playground initialSteps={[]} />,
};
/**
* A brand new pipeline, before anything has been chosen. Every row is an invitation rather than a
* complaint - nothing is wrong yet, because nothing has been asked of the user. Click an end to
* place it (it then owes a choice, and says so), and its X puts it back.
*/
export const NewPipeline: Story = {
render: () => <Playground initialSteps={[]} unplacedEnds />,
};
/**
* An order that will not do what the user probably meant: OCR cannot read a file that the previous
* step encrypted. The wire says so and the chain still runs - nothing is refused, and the step can
* still be dragged anywhere.
*/
export const OddOrdering: Story = {
render: () => (
<Playground
initialSteps={[
{ label: "Add Password", detail: "128-bit" },
{
label: "OCR",
detail: "eng",
inputWarning: { text: "OCR cannot read an encrypted file" },
},
]}
/>
),
};
/** Mid test-run: finished steps carry a tick, the current one pulses. */
export const Running: Story = {
render: () => (
<Playground
initialSteps={[
{ label: "OCR", detail: "eng", runState: "done" },
{ label: "Redact", detail: "2 terms", runState: "running" },
{ label: "Compress", detail: "level 7" },
]}
/>
),
};
/** A failed run, and a step that cannot be saved: the warning replaces the detail line. */
export const Problems: Story = {
render: () => (
<Playground
output={{ label: "Choose a destination", warning: "No destination set" }}
initialSteps={[
{ label: "OCR", detail: "eng", runState: "done" },
{ label: "Redact", detail: "2 terms", runState: "failed" },
{ label: "Watermark", warning: "Needs an uploaded file" },
]}
/>
),
};
/**
* Multi-selection: cmd/ctrl-click to add a step, shift-click for a run of them, then drag any one
* onto a line to move the whole set together.
*/
export const MultiSelect: Story = {
render: () => (
<Playground
initialSteps={[
{ label: "OCR", detail: "eng" },
{ label: "Redact", detail: "2 terms" },
{ label: "Compress", detail: "level 7" },
{ label: "Stamp", detail: "footer" },
]}
/>
),
};
@@ -0,0 +1,455 @@
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
} from "@testing-library/react";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import {
PipelineGraph,
type GraphSelection,
type PipelineGraphProps,
} from "@portal/components/pipelines/graph/PipelineGraph";
// The nodes and wires are built from the shared Mantine-backed controls, so they need the provider.
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: PortalTestProviders });
// Deterministic i18n: keys returned verbatim, interpolation applied so aria-labels stay distinct.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars?.name ? `${key}:${String(vars.name)}` : key,
}),
}));
function renderGraph(overrides: Partial<PipelineGraphProps> = {}) {
const handlers = {
onSelect: vi.fn(),
onAddEnd: vi.fn(),
onRemoveEnd: vi.fn(),
onInsertStep: vi.fn(),
onRemoveSteps: vi.fn(),
onReorderSteps: vi.fn(),
};
render(
<PipelineGraph
input={{ label: "Claims intake", detail: "/in - every hour" }}
output={{ label: "Archive bucket", detail: "s3://claims/done" }}
steps={[
{ label: "OCR", detail: "eng" },
{ label: "Redact", detail: "2 terms" },
]}
selected={null}
{...handlers}
{...overrides}
/>,
);
return handlers;
}
describe("PipelineGraph", () => {
it("renders the chain: input, each step in order, output", () => {
renderGraph();
const titles = screen
.getAllByRole("button", { pressed: false })
.map((node) => node.textContent);
expect(titles[0]).toContain("Claims intake");
expect(titles[1]).toContain("OCR");
expect(titles[2]).toContain("Redact");
expect(titles[3]).toContain("Archive bucket");
});
it("shows each node's one-line detail", () => {
renderGraph();
expect(screen.getByText("/in - every hour")).toBeInTheDocument();
expect(screen.getByText("s3://claims/done")).toBeInTheDocument();
});
it("selects the ends by their kind and steps by index", () => {
const handlers = renderGraph();
fireEvent.click(screen.getByText("Claims intake"));
expect(handlers.onSelect).toHaveBeenCalledWith("input");
fireEvent.click(screen.getByText("Archive bucket"));
expect(handlers.onSelect).toHaveBeenCalledWith("output");
fireEvent.click(screen.getByText("Redact"));
expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [1] });
});
it("adds and removes steps from the selection with cmd/ctrl-click", () => {
const handlers = renderGraph({ selected: { steps: [0] } });
fireEvent.click(screen.getByText("Redact"), { metaKey: true });
expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1] });
});
it("cmd/ctrl-clicking the only selected step clears the selection", () => {
const handlers = renderGraph({ selected: { steps: [1] } });
fireEvent.click(screen.getByText("Redact"), { ctrlKey: true });
expect(handlers.onSelect).toHaveBeenCalledWith(null);
});
it("shift-click takes everything between the anchor and the clicked step", () => {
const handlers = renderGraph({
selected: { steps: [0] },
steps: [
{ label: "OCR" },
{ label: "Redact" },
{ label: "Compress" },
{ label: "Stamp" },
],
});
fireEvent.click(screen.getByText("Stamp"), { shiftKey: true });
expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1, 2, 3] });
});
it("marks every selected step as pressed, not just one", () => {
renderGraph({ selected: { steps: [0, 1] } });
for (const label of ["OCR", "Redact"]) {
expect(screen.getByText(label).closest("button")).toHaveAttribute(
"aria-pressed",
"true",
);
}
});
it("keeps the drag hint silent until a drag starts", () => {
// Only the silent half is testable here: starting a real drag needs native HTML5 drag events,
// which jsdom does not implement, so the visible half is checked in a browser.
renderGraph();
expect(
screen.queryByText("portal.pipelines.graph.dragHint"),
).not.toBeInTheDocument();
});
it("clears the selection when the canvas itself is clicked", () => {
const handlers = renderGraph({ selected: { steps: [1] } });
fireEvent.click(document.querySelector(".portal-graph") as HTMLElement);
expect(handlers.onSelect).toHaveBeenCalledWith(null);
});
it("does not clear the selection when a node is clicked", () => {
// The node's own handler runs; the background handler must not undo it.
const handlers = renderGraph({ selected: { steps: [1] } });
fireEvent.click(screen.getByText("OCR"));
expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0] });
expect(handlers.onSelect).not.toHaveBeenCalledWith(null);
});
it("marks the selected node as pressed", () => {
renderGraph({ selected: { steps: [0] } });
expect(screen.getByText("OCR").closest("button")).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("puts an insert on every wire, reporting the slot it opens", () => {
const handlers = renderGraph();
// input->OCR, OCR->Redact, Redact->output
const inserts = screen.getAllByLabelText(
"portal.pipelines.graph.insertHere",
);
expect(inserts).toHaveLength(3);
fireEvent.click(inserts[1]);
expect(handlers.onInsertStep).toHaveBeenCalledWith(1);
});
it("holds the first step's place with a placeholder when the chain is empty", () => {
const handlers = renderGraph({ steps: [] });
// The placeholder is the affordance, so the wires either side of it carry no plus of their
// own - two ways to fill the same slot would be a choice with no difference.
expect(
screen.queryByLabelText("portal.pipelines.graph.insertHere"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByText("portal.pipelines.graph.addFirstTool"));
expect(handlers.onInsertStep).toHaveBeenCalledWith(0);
});
it("drops the placeholder once the chain has a step", () => {
renderGraph({ steps: [{ label: "OCR" }] });
expect(
screen.queryByText("portal.pipelines.graph.addFirstTool"),
).not.toBeInTheDocument();
});
it("warns on the wire arriving at a step it makes little sense to feed", () => {
renderGraph({
steps: [
{ label: "Add Password" },
{
label: "OCR",
inputWarning: { text: "OCR cannot read an encrypted file" },
},
],
});
expect(
screen.getByText("OCR cannot read an encrypted file"),
).toBeInTheDocument();
});
it("still allows the odd pairing: warned wires keep taking inserts", () => {
// Advisory, not a block - the order stays the user's to choose.
const handlers = renderGraph({
steps: [
{ label: "Add Password" },
{
label: "OCR",
inputWarning: { text: "OCR cannot read an encrypted file" },
},
],
});
// The warned wire shows its note and keeps its plus, so a fixing step can still go between the
// ends that do not suit each other - every wire takes an insert.
const inserts = screen.getAllByLabelText(
"portal.pipelines.graph.insertHere",
);
expect(inserts).toHaveLength(3);
// The middle wire is the warned one (Add Password -> OCR); inserting there lands between them.
fireEvent.click(inserts[1]);
expect(handlers.onInsertStep).toHaveBeenCalledWith(1);
});
it("marks a blocking pairing apart from a merely odd one", () => {
// Both sit on the wire and neither refuses the edit, but one means the chain cannot run at
// all - so it must not read as the same gentle advice.
renderGraph({
steps: [
{ label: "Extract images" },
{
label: "Compress",
inputWarning: { text: "Compress needs a PDF", blocking: true },
},
],
});
const wire = screen
.getByText("Compress needs a PDF")
.closest(".portal-graph-edge");
expect(wire).toHaveClass("is-blocking");
});
it("leaves an advisory pairing unblocked", () => {
renderGraph({
steps: [
{ label: "Add Password" },
{ label: "OCR", inputWarning: { text: "OCR cannot read this" } },
],
});
expect(
screen.getByText("OCR cannot read this").closest(".portal-graph-edge"),
).not.toHaveClass("is-blocking");
});
it("warns on the wire into the output too", () => {
renderGraph({
output: {
label: "Archive",
inputWarning: { text: "Nothing writes a folder here" },
},
});
expect(
screen.getByText("Nothing writes a folder here"),
).toBeInTheDocument();
});
it("removes a step from the node itself", () => {
const handlers = renderGraph();
fireEvent.click(
screen.getByLabelText("portal.pipelines.graph.removeNode:Redact"),
);
expect(handlers.onRemoveSteps).toHaveBeenCalledWith([1]);
});
it("every node on the chain carries its own remove, ends included", () => {
renderGraph();
// Two steps plus both ends: an end can be taken back off to its placeholder.
expect(screen.getAllByLabelText(/graph.removeNode/)).toHaveLength(4);
});
it("takes an end back off the chain", () => {
const handlers = renderGraph();
fireEvent.click(
screen.getByLabelText("portal.pipelines.graph.removeNode:Claims intake"),
);
expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input");
});
it("deletes every selected step with the Delete key", () => {
const handlers = renderGraph({ selected: { steps: [0, 1] } });
fireEvent.keyDown(screen.getByText("Redact"), { key: "Delete" });
expect(handlers.onRemoveSteps).toHaveBeenCalledWith([0, 1]);
});
it("takes a selected end off the chain with the Delete key, like its X does", () => {
const handlers = renderGraph({ selected: "input" });
fireEvent.keyDown(screen.getByText("Claims intake"), { key: "Delete" });
expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input");
// An end is not a step, so the step remover stays out of it.
expect(handlers.onRemoveSteps).not.toHaveBeenCalled();
});
it("ignores Delete when nothing is selected", () => {
const handlers = renderGraph({ selected: null });
fireEvent.keyDown(screen.getByText("OCR"), { key: "Delete" });
expect(handlers.onRemoveSteps).not.toHaveBeenCalled();
expect(handlers.onRemoveEnd).not.toHaveBeenCalled();
});
it("moves the selected step down the chain with Alt+ArrowDown", () => {
const handlers = renderGraph({ selected: { steps: [0] } });
fireEvent.keyDown(screen.getByText("OCR"), {
key: "ArrowDown",
altKey: true,
});
// [OCR, Redact] with OCR moved down -> [Redact, OCR]; the dragged step is the reorder payload.
expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [0]);
});
it("moves the selected step up the chain with Alt+ArrowUp", () => {
const handlers = renderGraph({ selected: { steps: [1] } });
fireEvent.keyDown(screen.getByText("Redact"), {
key: "ArrowUp",
altKey: true,
});
// [OCR, Redact] with Redact moved up -> [Redact, OCR].
expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [1]);
});
it("moves a multi-step selection together, keeping it as the payload", () => {
const handlers = renderGraph({
selected: { steps: [0, 1] },
steps: [{ label: "OCR" }, { label: "Redact" }, { label: "Compress" }],
});
fireEvent.keyDown(screen.getByText("OCR"), {
key: "ArrowDown",
altKey: true,
});
// [OCR, Redact, Compress] with [OCR, Redact] moved down -> [Compress, OCR, Redact].
expect(handlers.onReorderSteps).toHaveBeenCalledWith([2, 0, 1], [0, 1]);
});
it("does not reorder past the end of the chain", () => {
const handlers = renderGraph({ selected: { steps: [0] } });
fireEvent.keyDown(screen.getByText("OCR"), {
key: "ArrowUp",
altKey: true,
});
expect(handlers.onReorderSteps).not.toHaveBeenCalled();
});
it("leaves a bare arrow alone, so only the modifier reorders", () => {
const handlers = renderGraph({ selected: { steps: [0] } });
fireEvent.keyDown(screen.getByText("OCR"), { key: "ArrowDown" });
expect(handlers.onReorderSteps).not.toHaveBeenCalled();
});
it("carries focus to the moved step so it can be walked several slots", () => {
// A real reorder renumbers the nodes, so focus has to follow the step or a second key press
// would act on whatever now sits where it started. Drive it through a stateful host.
function Host() {
const [steps, setSteps] = useState([
{ label: "OCR" },
{ label: "Redact" },
{ label: "Compress" },
]);
const [selected, setSelected] = useState<GraphSelection>({ steps: [0] });
return (
<PipelineGraph
input={{ label: "Claims intake" }}
output={{ label: "Archive bucket" }}
steps={steps}
selected={selected}
onSelect={setSelected}
onAddEnd={vi.fn()}
onRemoveEnd={vi.fn()}
onInsertStep={vi.fn()}
onRemoveSteps={vi.fn()}
onReorderSteps={(order, moved) => {
setSteps((current) => order.map((i) => current[i]));
const landed = order
.map((original, position) => ({ original, position }))
.filter(({ original }) => moved.includes(original))
.map(({ position }) => position);
setSelected({ steps: landed });
}}
/>
);
}
render(<Host />);
fireEvent.keyDown(screen.getByText("OCR"), {
key: "ArrowDown",
altKey: true,
});
// OCR now sits at position 1, and its card's select button holds focus.
expect(document.activeElement?.textContent).toContain("OCR");
expect(
document.activeElement?.closest("[data-step-index]"),
).toHaveAttribute("data-step-index", "1");
});
describe("an end the pipeline has not asked for yet", () => {
it("offers to add it instead of naming it", () => {
renderGraph({ input: null });
expect(
screen.getByText("portal.pipelines.graph.add.input"),
).toBeInTheDocument();
expect(screen.queryByText("Claims intake")).not.toBeInTheDocument();
});
it("greets a brand new pipeline with no warnings at all", () => {
// The whole point: an end nobody has been offered yet is not a problem to report.
renderGraph({
input: null,
output: null,
steps: [],
});
expect(screen.queryByText(/warning|chosen/i)).not.toBeInTheDocument();
expect(screen.getAllByText(/graph.add\./)).toHaveLength(2);
});
it("asks for the end when its placeholder is clicked", () => {
const handlers = renderGraph({ output: null });
fireEvent.click(screen.getByText("portal.pipelines.graph.add.output"));
expect(handlers.onAddEnd).toHaveBeenCalledWith("output");
});
it("has nothing to remove until it is placed", () => {
renderGraph({ input: null, output: null, steps: [] });
expect(
screen.queryByLabelText(/graph.removeNode/),
).not.toBeInTheDocument();
});
it("carries no warning onto the wire that arrives at it", () => {
renderGraph({ output: null });
expect(
screen.queryByText("Nothing writes a folder here"),
).not.toBeInTheDocument();
});
});
it("shows a node's warning in place of its detail", () => {
renderGraph({
steps: [
{ label: "Watermark", detail: "logo.png", warning: "Needs a file" },
],
});
expect(screen.getByText("Needs a file")).toBeInTheDocument();
expect(screen.queryByText("logo.png")).not.toBeInTheDocument();
});
it("reports a run's progress on the steps it touched", () => {
renderGraph({
steps: [
{ label: "OCR", runState: "done" },
{ label: "Redact", runState: "running" },
],
});
expect(
screen.getByText("portal.pipelines.graph.run.done"),
).toBeInTheDocument();
expect(
screen.getByText("portal.pipelines.graph.run.running"),
).toBeInTheDocument();
});
});
@@ -0,0 +1,379 @@
import {
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
GraphNode,
type NodeRunState,
} from "@portal/components/pipelines/graph/GraphNode";
import {
GraphEdge,
type ChainWarning,
} from "@portal/components/pipelines/graph/GraphEdge";
import { GraphPlaceholderNode } from "@portal/components/pipelines/graph/GraphPlaceholderNode";
import {
NODE_HEIGHT,
NODE_WIDTH,
layoutChain,
reorderMany,
stepIndexOf,
} from "@portal/components/pipelines/graph/pipelineLayout";
import { useStepDraggable } from "@portal/components/pipelines/graph/useChainDragDrop";
import "@portal/components/pipelines/graph/PipelineGraph.css";
// The wire renders it, but callers build it, so it is re-exported from the graph they talk to.
export type { ChainWarning };
/**
* What is selected: an end of the chain, one or more steps, or nothing. Only steps come in sets -
* the input and output are fixed ends, so there is nothing to gather or move.
*/
export type GraphSelection = "input" | "output" | { steps: number[] } | null;
/** The selected step indices, in chain order. Empty unless steps are what is selected. */
export function selectedSteps(selection: GraphSelection): number[] {
return selection !== null && typeof selection === "object"
? selection.steps
: [];
}
/** A node's display content. The graph never derives copy - the builder owns every label. */
export interface GraphNodeContent {
label: string;
/** One-line summary: the source's path, a step's parameters, the destination. */
detail?: string;
/** Why this node blocks saving, shown in place of the detail. */
warning?: string;
/** Why the input will not be much use. */
inputWarning?: ChainWarning;
}
export interface GraphStepContent extends GraphNodeContent {
icon?: ReactNode;
runState?: NodeRunState;
}
/** Which end of the chain: the two nodes every finished pipeline has, one of each. */
export type ChainEnd = "input" | "output";
export interface PipelineGraphProps {
/**
* The chain's ends, or null while a new pipeline has yet to ask for one. Null renders the row as a
* placeholder to fill rather than a node owing a choice, which is what keeps a brand new pipeline
* from opening on a pair of warnings about decisions its author has not been offered yet.
*/
input: GraphNodeContent | null;
output: GraphNodeContent | null;
steps: GraphStepContent[];
selected: GraphSelection;
onSelect: (selection: GraphSelection) => void;
/** Put an end on the chain, ready to be configured. */
onAddEnd: (end: ChainEnd) => void;
/** Take an end back off, returning its row to a placeholder. */
onRemoveEnd: (end: ChainEnd) => void;
/** Add a step in the slot the clicked wire opens. */
onInsertStep: (index: number) => void;
/** Remove every step given, in one go. */
onRemoveSteps: (indices: number[]) => void;
/** Reorder the chain to the given original step indices; `moved` is what the drag carried. */
onReorderSteps: (order: number[], moved: readonly number[]) => void;
onOpenStepError?: (index: number) => void;
}
/**
* The pipeline as a graph: one input, the steps in run order, one output.
*
* Layout is derived from the chain (see pipelineLayout), so there is nothing to lock, nothing to
* re-tidy and no stored positions - a node is always where its place in the order says it is.
* Dragging a step onto a wire moves it into that slot; clicking a node opens its settings in the
* inspector; the wires carry the insert affordance.
*/
export function PipelineGraph({
input,
output,
steps,
selected,
onSelect,
onAddEnd,
onRemoveEnd,
onInsertStep,
onRemoveSteps,
onReorderSteps,
onOpenStepError,
}: PipelineGraphProps) {
const { t } = useTranslation();
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const { nodes, edges, width, height } = layoutChain({
stepCount: steps.length,
});
const graphRef = useRef<HTMLDivElement | null>(null);
// A keyboard reorder renumbers the nodes, so the focused card is no longer under the cursor's
// hand: without moving focus to where the step landed, a second Alt+Arrow would act on whatever
// now sits at the old position. Set by the handler, applied once the new order has rendered.
const focusStepAfterRender = useRef<number | null>(null);
useLayoutEffect(() => {
const position = focusStepAfterRender.current;
if (position === null) return;
focusStepAfterRender.current = null;
graphRef.current
?.querySelector<HTMLElement>(
`[data-step-index="${position}"] .sui-node-card__select`,
)
?.focus();
});
// A wire carries the warning belonging to the node it arrives at.
const arrivalWarning = (nodeId: string): ChainWarning | undefined => {
if (nodeId === "output") return output?.inputWarning;
const index = stepIndexOf(nodeId);
return index === null ? undefined : steps[index]?.inputWarning;
};
/**
* Clicking the canvas itself clears the selection. Anything that is part of a node, a wire or the
* placeholder handles its own click, so only bare background gets here.
*/
function onBackgroundClick(event: ReactMouseEvent<HTMLDivElement>) {
const target = event.target as HTMLElement;
if (
target.closest(
"[data-graph-node], .portal-graph-edge, .portal-graph-placeholder",
)
) {
return;
}
onSelect(null);
}
const chosen = selectedSteps(selected);
/**
* Plain click selects one step. Cmd/Ctrl toggles a step in or out of the selection; Shift takes
* everything between the first selected step and this one. The ends of the chain are single-only.
*/
function selectStep(index: number, event: ReactMouseEvent) {
if (event.metaKey || event.ctrlKey) {
const next = chosen.includes(index)
? chosen.filter((i) => i !== index)
: [...chosen, index].sort((a, b) => a - b);
onSelect(next.length > 0 ? { steps: next } : null);
return;
}
if (event.shiftKey && chosen.length > 0) {
const anchor = chosen[0];
const [from, to] = anchor <= index ? [anchor, index] : [index, anchor];
const span = [];
for (let i = from; i <= to; i++) span.push(i);
onSelect({ steps: span });
return;
}
onSelect({ steps: [index] });
}
/**
* Move the selected step(s) one slot along the chain - the keyboard alternative to dragging, which
* pointer-only users cannot reach. Alt with an arrow, so a plain arrow is still free for anything
* that later wants it. The moved block stays selected and takes focus with it, so it can be walked
* several slots in a row.
*/
function moveSelection(direction: "up" | "down"): boolean {
if (chosen.length === 0) return false;
const min = chosen[0];
const max = chosen[chosen.length - 1];
// reorderMany's slot is against the original chain: a step's own neighbouring slots are no-ops,
// so up aims one before the block and down one past it.
const slot = direction === "up" ? min - 1 : max + 2;
const order = reorderMany(steps.length, chosen, slot);
if (order === null) return false; // already at that end of the chain
focusStepAfterRender.current = order.indexOf(min);
onReorderSteps(order, chosen);
return true;
}
/**
* Delete removes whatever is selected: every selected step, or an end of the chain (which returns
* its row to a placeholder, exactly as that node's X does). Alt + Up/Down reorders the selection.
* Scoped to the graph, so typing in the inspector's fields is never intercepted.
*/
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (
event.altKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
// Ends do not reorder (chosen is empty for them), so this only fires for a step selection.
if (moveSelection(event.key === "ArrowUp" ? "up" : "down")) {
event.preventDefault();
}
return;
}
if (event.key !== "Delete" && event.key !== "Backspace") return;
if (selected === "input" || selected === "output") {
event.preventDefault();
onRemoveEnd(selected);
return;
}
if (chosen.length === 0) return;
event.preventDefault();
onRemoveSteps(chosen);
}
return (
<div
ref={graphRef}
className="portal-graph"
onKeyDown={onKeyDown}
onClick={onBackgroundClick}
>
<div
className="portal-graph__canvas"
style={{ width: `${width}px`, height: `${height}px` }}
>
{edges.map((edge) => (
<GraphEdge
key={edge.id}
edge={edge}
onInsert={onInsertStep}
stepCount={steps.length}
onReorder={onReorderSteps}
dragActive={draggingIndex !== null}
warning={arrivalWarning(edge.to)}
/>
))}
{draggingIndex !== null && edges.length > 0 && (
<p
className="portal-graph__drag-hint"
aria-live="polite"
style={{
left: `${edges[0].x}px`,
top: `${(edges[0].y1 + edges[0].y2) / 2}px`,
}}
>
{t("portal.pipelines.graph.dragHint")}
</p>
)}
{nodes.map((node) => {
const style = {
left: `${node.x}px`,
top: `${node.y}px`,
width: `${NODE_WIDTH}px`,
minHeight: `${NODE_HEIGHT}px`,
};
if (node.kind === "placeholder") {
return (
<div className="portal-graph__slot" key={node.id} style={style}>
<GraphPlaceholderNode
label={t("portal.pipelines.graph.addFirstTool")}
onAdd={() => onInsertStep(0)}
/>
</div>
);
}
if (node.kind === "input" || node.kind === "output") {
const kind = node.kind;
const content = kind === "input" ? input : output;
return (
<div className="portal-graph__slot" key={node.id} style={style}>
{content === null ? (
<GraphPlaceholderNode
label={t(`portal.pipelines.graph.add.${kind}`)}
onAdd={() => onAddEnd(kind)}
/>
) : (
<GraphNode
kind={kind}
title={content.label}
detail={content.detail}
warning={content.warning}
selected={selected === kind}
onSelect={() => onSelect(kind)}
onRemove={() => onRemoveEnd(kind)}
/>
)}
</div>
);
}
const index = node.stepIndex ?? 0;
return (
<div className="portal-graph__slot" key={node.id} style={style}>
<ChainStepNode
index={index}
step={steps[index]}
selected={chosen.includes(index)}
// Dragging any node of a selection lifts the whole selection, so they all dim.
dragging={
draggingIndex !== null &&
(draggingIndex === index ||
(chosen.includes(draggingIndex) && chosen.includes(index)))
}
moving={chosen.includes(index) ? chosen : [index]}
onSelect={(event) => selectStep(index, event)}
onRemove={() => onRemoveSteps([index])}
onDragChange={(dragging) =>
setDraggingIndex(dragging ? index : null)
}
onOpenRunState={
steps[index].runState === "failed" && onOpenStepError
? () => onOpenStepError(index)
: undefined
}
/>
</div>
);
})}
</div>
</div>
);
}
interface ChainStepNodeProps {
index: number;
step: GraphStepContent;
selected: boolean;
dragging: boolean;
/** The steps this node's drag carries: the selection when it is part of it, else just itself. */
moving: number[];
onSelect: (event: ReactMouseEvent) => void;
onRemove: () => void;
onDragChange: (dragging: boolean) => void;
onOpenRunState?: () => void;
}
/** A step node plus its drag wiring, which needs a hook per node and so a component per node. */
function ChainStepNode({
index,
step,
selected,
dragging,
moving,
onSelect,
onRemove,
onDragChange,
onOpenRunState,
}: ChainStepNodeProps) {
const { ref, guardClick } = useStepDraggable({ moving, onDragChange });
return (
<GraphNode
ref={ref}
kind="step"
stepIndex={index}
title={step.label}
detail={step.detail}
warning={step.warning}
icon={step.icon}
runState={step.runState}
selected={selected}
dragging={dragging}
onSelect={guardClick(onSelect)}
onRemove={onRemove}
onOpenRunState={onOpenRunState}
/>
);
}
@@ -0,0 +1,147 @@
import { describe, expect, test } from "vitest";
import {
EDGE_LENGTH,
NODE_HEIGHT,
NODE_WIDTH,
layoutChain,
reorderMany,
stepIndexOf,
stepNodeId,
} from "@portal/components/pipelines/graph/pipelineLayout";
describe("layoutChain", () => {
test("an empty chain reserves the first step's row for the placeholder", () => {
const { nodes, edges } = layoutChain({ stepCount: 0 });
expect(nodes.map((n) => n.kind)).toEqual([
"input",
"placeholder",
"output",
]);
// The placeholder is the affordance, so neither wire around it offers a plus as well.
expect(edges.map((e) => e.insertIndex)).toEqual([null, null]);
});
test("the empty chain is as tall as a one-step chain", () => {
expect(layoutChain({ stepCount: 0 }).height).toBe(
layoutChain({ stepCount: 1 }).height,
);
});
test("steps sit between input and output, in order", () => {
const { nodes } = layoutChain({ stepCount: 3 });
expect(nodes.map((n) => n.id)).toEqual([
"input",
"step:0",
"step:1",
"step:2",
"output",
]);
expect(nodes.map((n) => n.stepIndex)).toEqual([null, 0, 1, 2, null]);
});
test("rows are evenly pitched down one column", () => {
const { nodes, width } = layoutChain({ stepCount: 2 });
expect(nodes.every((n) => n.x === 0)).toBe(true);
const ys = nodes.map((n) => n.y);
const pitch = NODE_HEIGHT + EDGE_LENGTH;
expect(ys).toEqual([0, pitch, pitch * 2, pitch * 3]);
expect(width).toBe(NODE_WIDTH);
});
test("the canvas is tall enough for the last node", () => {
const { nodes, height } = layoutChain({ stepCount: 4 });
const last = nodes[nodes.length - 1];
expect(height).toBe(last.y + NODE_HEIGHT);
});
test("wires span exactly from one node's bottom border to the next node's top", () => {
const { nodes, edges } = layoutChain({ stepCount: 1 });
expect(edges).toHaveLength(2);
for (const edge of edges) {
expect(edge.x).toBe(NODE_WIDTH / 2);
expect(edge.y2 - edge.y1).toBe(EDGE_LENGTH);
}
expect(edges[0].y1).toBe(nodes[0].y + NODE_HEIGHT);
expect(edges[0].y2).toBe(nodes[1].y);
});
test("each wire opens the slot it sits above", () => {
const { edges } = layoutChain({ stepCount: 3 });
// input->0, 0->1, 1->2, 2->output
expect(edges.map((e) => e.insertIndex)).toEqual([0, 1, 2, 3]);
});
test("every wire between real nodes stays open", () => {
// Ordering is the user's to choose: no pairing is refused, however odd it is.
const { edges } = layoutChain({ stepCount: 4 });
expect(edges.every((e) => e.insertIndex !== null)).toBe(true);
});
});
describe("node ids", () => {
test("step ids round-trip through their index", () => {
expect(stepIndexOf(stepNodeId(7))).toBe(7);
});
test("the input and output nodes have no step index", () => {
expect(stepIndexOf("input")).toBeNull();
expect(stepIndexOf("output")).toBeNull();
});
});
describe("reorderMany", () => {
test("moving one step below its own place accounts for it lifting out first", () => {
// [a b c], drag a onto the wire above c (slot 2) -> [b a c].
expect(reorderMany(3, [0], 2)).toEqual([1, 0, 2]);
});
test("moving one step above its own place lands on the slot as given", () => {
// [a b c], drag c onto the wire above b (slot 1) -> [a c b].
expect(reorderMany(3, [2], 1)).toEqual([0, 2, 1]);
});
test("the wires either side of a lone step are no-ops", () => {
expect(reorderMany(3, [1], 1)).toBeNull();
expect(reorderMany(3, [1], 2)).toBeNull();
});
test("moves to either end", () => {
expect(reorderMany(3, [2], 0)).toEqual([2, 0, 1]);
expect(reorderMany(3, [0], 3)).toEqual([1, 2, 0]);
});
test("a set of steps lands together, keeping its own order", () => {
// [a b c d], move a+c to the end -> [b d a c].
expect(reorderMany(4, [0, 2], 4)).toEqual([1, 3, 0, 2]);
});
test("a set gathers from apart into one run", () => {
// [a b c d e], move a+e above c (slot 2) -> [b a e c d].
expect(reorderMany(5, [0, 4], 2)).toEqual([1, 0, 4, 2, 3]);
});
test("a contiguous set dropped back where it already is, is a no-op", () => {
expect(reorderMany(4, [1, 2], 1)).toBeNull();
expect(reorderMany(4, [1, 2], 3)).toBeNull();
});
test("order of the given indices does not matter", () => {
expect(reorderMany(4, [2, 0], 4)).toEqual(reorderMany(4, [0, 2], 4));
});
test("moving every step is a no-op wherever it lands", () => {
expect(reorderMany(3, [0, 1, 2], 0)).toBeNull();
expect(reorderMany(3, [2, 1, 0], 3)).toBeNull();
});
test("nothing selected moves nothing", () => {
expect(reorderMany(3, [], 1)).toBeNull();
});
test("ignores out-of-range indices rather than injecting undefined steps", () => {
// A stray index (negative or past the end) must be dropped, not carried into the new order.
expect(reorderMany(3, [0, 9], 2)).toEqual([1, 0, 2]);
expect(reorderMany(3, [-1], 2)).toBeNull();
expect(reorderMany(3, [5], 0)).toBeNull();
});
});
@@ -0,0 +1,184 @@
/**
* Geometry for the pipeline graph.
*
* A pipeline is a strict sequence - one input, an ordered run of steps, one output - so a node's
* position carries no information that its place in the chain does not already carry. Layout is
* therefore *derived* here on every render rather than owned by the user and persisted: there are
* no stored coordinates to drift, nothing to lock, and nothing to re-tidy. Dragging a node is free
* to mean "move it in the chain" instead of "move it on screen" (see useChainDragDrop).
*
* Everything is a single centred column, which makes each wire a straight vertical line. When the
* model grows past one input/output and a pipeline can branch, x stops being constant and this is
* the module that changes - callers only ever read the result.
*/
/**
* What a node represents. The chain always has exactly one input and one output. `placeholder` is
* the stand-in shown when a pipeline has no steps yet: it occupies the row the first step will take
* so the chain's shape is visible, and it is the affordance for adding that step.
*/
export type GraphNodeKind = "input" | "step" | "output" | "placeholder";
/** Node id: `"input"`, `"output"`, or `"step:<index>"`. Stable for a given chain position. */
export type GraphNodeId = string;
export function stepNodeId(index: number): GraphNodeId {
return `step:${index}`;
}
/** The step index a node id refers to, or null for the input/output nodes. */
export function stepIndexOf(id: GraphNodeId): number | null {
const match = /^step:(\d+)$/.exec(id);
return match ? Number(match[1]) : null;
}
export interface LaidOutNode {
id: GraphNodeId;
kind: GraphNodeKind;
/** Position in the chain's step list; null for input/output. */
stepIndex: number | null;
/** Top-left corner, in canvas coordinates. */
x: number;
y: number;
}
export interface LaidOutEdge {
id: string;
from: GraphNodeId;
to: GraphNodeId;
/**
* Where a step dropped on this wire lands in the step list. Null only for the wires either side
* of the placeholder, which is itself the affordance for adding the first step.
*/
insertIndex: number | null;
/** Straight vertical wire, from the upper node's bottom port to the lower node's top port. */
x: number;
y1: number;
y2: number;
}
export interface LaidOutChain {
nodes: LaidOutNode[];
edges: LaidOutEdge[];
/** Canvas extent, so the scroll container can size itself without measuring. */
width: number;
height: number;
}
/** Node box, and the vertical room a wire plus its insert affordance needs between two of them. */
export const NODE_WIDTH = 260;
export const NODE_HEIGHT = 64;
export const EDGE_LENGTH = 48;
const ROW_PITCH = NODE_HEIGHT + EDGE_LENGTH;
export interface LayoutChainOptions {
stepCount: number;
}
/**
* Lay the chain out top to bottom: input, each step in order, output. Rows are evenly pitched and
* share one x, so wires are vertical and always aligned.
*/
export function layoutChain({ stepCount }: LayoutChainOptions): LaidOutChain {
const nodes: LaidOutNode[] = [];
const row = (index: number) => index * ROW_PITCH;
// An empty pipeline still shows a step row, filled by the placeholder, so the chain reads as
// input -> something -> output rather than as a bare wire.
const rows = Math.max(stepCount, 1);
nodes.push({ id: "input", kind: "input", stepIndex: null, x: 0, y: row(0) });
if (stepCount === 0) {
nodes.push({
id: "placeholder",
kind: "placeholder",
stepIndex: null,
x: 0,
y: row(1),
});
}
for (let i = 0; i < stepCount; i++) {
nodes.push({
id: stepNodeId(i),
kind: "step",
stepIndex: i,
x: 0,
y: row(i + 1),
});
}
nodes.push({
id: "output",
kind: "output",
stepIndex: null,
x: 0,
y: row(rows + 1),
});
const centreX = NODE_WIDTH / 2;
const edges: LaidOutEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
const upper = nodes[i];
const lower = nodes[i + 1];
// A wire's insert index is the step slot it sits above: the wire below the input opens slot 0,
// the wire below step i opens slot i+1. A final-only step closes the wire beneath it.
const above = upper.stepIndex;
// The placeholder is itself the "add the first step" affordance, so the wires either side of it
// stay plain - two pluses for the same slot would be a choice with no difference.
const placeholderRow =
upper.kind === "placeholder" || lower.kind === "placeholder";
const insertIndex = placeholderRow ? null : (above ?? -1) + 1;
edges.push({
id: `${upper.id}->${lower.id}`,
from: upper.id,
to: lower.id,
insertIndex,
x: centreX,
// Exactly node-bottom to node-top: the wire meets both borders. The arrowhead is kept inside
// this box (see GraphEdge.css) so the node, drawn after it, cannot paint over the tip.
y1: upper.y + NODE_HEIGHT,
y2: lower.y,
});
}
return {
nodes,
edges,
width: NODE_WIDTH,
height: row(rows + 1) + NODE_HEIGHT,
};
}
/**
* The chain's new order after dropping `moving` on the wire that opens `insertIndex`.
*
* Returns the original step indices in their new positions, or null when the move changes nothing
* (dropping a step on either of its own wires, say). The moved steps land together in the target
* slot, keeping their order relative to each other; the slot is expressed against the *original*
* chain, so lifting the moved steps out first has to be accounted for - which is done by counting
* how many of the steps that stay put sit above the slot.
*/
export function reorderMany(
stepCount: number,
moving: readonly number[],
insertIndex: number,
): number[] | null {
// Range-check: a stray index would survive into `next` and then read as an undefined step, so
// keep only positions that exist in the chain before lifting anything out.
const lifted = [...new Set(moving)]
.filter((i) => i >= 0 && i < stepCount)
.sort((a, b) => a - b);
if (lifted.length === 0) return null;
const staying: number[] = [];
for (let i = 0; i < stepCount; i++) {
if (!lifted.includes(i)) staying.push(i);
}
const landing = staying.filter((i) => i < insertIndex).length;
const next = [
...staying.slice(0, landing),
...lifted,
...staying.slice(landing),
];
return next.every((value, i) => value === i) ? null : next;
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "vitest";
import {
createDragClickGuard,
fillDragPreview,
} from "@portal/components/pipelines/graph/useChainDragDrop";
/** Stands in for the graph's rendered cards, which the preview clones out of the DOM. */
function renderCards(labels: string[]) {
document.body.innerHTML = labels
.map(
(label, i) =>
`<div class="portal-graph-node is-dragging" data-step-index="${i}">${label}</div>`,
)
.join("");
}
afterEach(() => {
document.body.innerHTML = "";
});
describe("fillDragPreview", () => {
it("stacks a copy of every dragged card, in the order given", () => {
renderCards(["OCR", "Redact", "Compress"]);
const container = document.createElement("div");
fillDragPreview(container, [0, 2]);
expect([...container.children].map((c) => c.textContent)).toEqual([
"OCR",
"Compress",
]);
});
it("leaves the originals alone", () => {
renderCards(["OCR", "Redact"]);
fillDragPreview(document.createElement("div"), [0, 1]);
expect(document.querySelectorAll("[data-step-index]")).toHaveLength(2);
});
it("copies are solid, not dimmed like the cards they came from", () => {
renderCards(["OCR"]);
const container = document.createElement("div");
fillDragPreview(container, [0]);
expect(document.querySelector("[data-step-index]")).toHaveClass(
"is-dragging",
);
expect(container.firstElementChild).not.toHaveClass("is-dragging");
});
it("skips an index with no card rather than throwing", () => {
renderCards(["OCR"]);
const container = document.createElement("div");
fillDragPreview(container, [0, 7]);
expect(container.children).toHaveLength(1);
});
});
// The drag itself needs native HTML5 drag events, which jsdom does not implement, so the guard's
// state machine is exercised here directly - it is the half that decides whether a click selects.
describe("createDragClickGuard", () => {
it("lets a plain press through", () => {
const guard = createDragClickGuard();
guard.beginGesture();
expect(guard.swallowsClick()).toBe(false);
});
it("swallows the click that trails a drag", () => {
const guard = createDragClickGuard();
guard.beginGesture();
guard.noteDrag();
expect(guard.swallowsClick()).toBe(true);
});
it("still selects on the next press after a drag left no click behind", () => {
// The regression: native drag usually emits no trailing click, so a guard cleared only by
// consuming one stayed raised and ate the user's next real click on that node.
const guard = createDragClickGuard();
guard.beginGesture();
guard.noteDrag();
// ...drop, and no click follows.
guard.beginGesture();
expect(guard.swallowsClick()).toBe(false);
});
it("guards each drag, not just the first", () => {
const guard = createDragClickGuard();
for (const _ of [1, 2]) {
guard.beginGesture();
guard.noteDrag();
expect(guard.swallowsClick()).toBe(true);
}
});
});
@@ -0,0 +1,230 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source";
import {
NODE_WIDTH,
reorderMany,
} from "@portal/components/pipelines/graph/pipelineLayout";
/**
* Drag-to-reorder for the pipeline chain.
*
* The chain is a sequence, so a step's meaningful move is "somewhere else in the order" - which
* makes the *wires* the drop targets, not the nodes. Each wire already knows the slot it opens
* (see layoutChain), so a drop is one call to reorderMany with that slot; there is no midpoint
* arithmetic or above/below bookkeeping, and no free coordinates to store.
*/
const DRAG_TYPE = "pipeline-step";
interface StepDragData extends Record<string, unknown> {
type: typeof DRAG_TYPE;
/** Every step this drag carries, in chain order - one, or the whole selection. */
moving: number[];
}
function isStepDrag(data: Record<string, unknown>): data is StepDragData {
return data.type === DRAG_TYPE && Array.isArray(data.moving);
}
export interface UseStepDraggableOptions {
/**
* The steps this node's drag should carry: the current selection when this node is part of it,
* otherwise just itself. Resolved by the graph, which is what knows the selection.
*/
moving: number[];
/** Told when this step's drag starts and ends, so the graph can light up the wires. */
onDragChange: (dragging: boolean) => void;
}
export interface UseStepDraggableResult {
ref: React.RefObject<HTMLDivElement | null>;
/**
* Wraps the node's click so the click that can trail a drag does not also select. Native drag
* usually swallows it, but the page editor carries the same guard - cheap insurance.
*/
guardClick: <E>(action: (event: E) => void) => (event: E) => void;
}
/**
* Tells a click that trails a drag apart from a genuine one.
*
* A gesture begins on pointerdown and may turn into a drag; only a click belonging to a gesture
* that dragged is swallowed. The clearing has to happen when the *next* gesture begins rather than
* when a click is swallowed - native HTML5 drag usually leaves no trailing click at all, so a flag
* cleared only by consuming one stays raised and eats the user's next real click on that node.
*/
export function createDragClickGuard() {
let dragged = false;
return {
/** A new press has started; nothing has dragged yet. */
beginGesture: () => {
dragged = false;
},
/** This gesture became a drag. */
noteDrag: () => {
dragged = true;
},
/** True if a click arriving now is the tail of a drag rather than a plain press. */
swallowsClick: () => dragged,
};
}
export type DragClickGuard = ReturnType<typeof createDragClickGuard>;
/**
* Stack a copy of every dragged card into the preview container, so a multi-step drag shows what is
* actually moving rather than only the card that was grabbed. Exported for testing; the cards are
* found in the DOM by their chain position.
*/
export function fillDragPreview(
container: HTMLElement,
moving: readonly number[],
): void {
container.className = "portal-graph__drag-preview";
container.style.width = `${NODE_WIDTH}px`;
for (const index of moving) {
const card = document.querySelector(`[data-step-index="${index}"]`);
if (!card) continue;
const copy = card.cloneNode(true) as HTMLElement;
// The originals dim once the drag starts; the copies are the drag, so they stay solid.
copy.classList.remove("is-dragging");
container.appendChild(copy);
}
}
/** Makes one step node draggable, tagged with the chain position it started from. */
export function useStepDraggable({
moving,
onDragChange,
}: UseStepDraggableOptions): UseStepDraggableResult {
const ref = useRef<HTMLDivElement | null>(null);
const guardRef = useRef<DragClickGuard | null>(null);
guardRef.current ??= createDragClickGuard();
const guard = guardRef.current;
// Read through refs so a reorder (which renumbers every later step) never re-registers the
// adapter mid-gesture.
const movingRef = useRef(moving);
movingRef.current = moving;
const onDragChangeRef = useRef(onDragChange);
onDragChangeRef.current = onDragChange;
useEffect(() => {
const element = ref.current;
if (!element) return;
// Any fresh input on the node starts a new gesture and clears the guard, so the only click it
// ever swallows is one trailing that same gesture's drag. Keyboard counts: activating the card
// with Enter or Space produces a click with no pointerdown before it.
const startGesture = () => guard.beginGesture();
element.addEventListener("pointerdown", startGesture);
element.addEventListener("keydown", startGesture);
const stopDraggable = draggable({
element,
getInitialData: (): StepDragData => ({
type: DRAG_TYPE,
moving: movingRef.current,
}),
onGenerateDragPreview: ({ location, nativeSetDragImage }) => {
const moving = movingRef.current;
// One step drags as itself; the browser's own preview of the grabbed card is right. A set
// needs to show what is actually moving, so the preview stacks a copy of every card.
if (moving.length < 2) return;
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: preserveOffsetOnSource({
element,
input: location.current.input,
}),
render: ({ container }) => fillDragPreview(container, moving),
});
},
onDragStart: () => {
guard.noteDrag();
onDragChangeRef.current(true);
},
onDrop: () => onDragChangeRef.current(false),
});
return () => {
element.removeEventListener("pointerdown", startGesture);
element.removeEventListener("keydown", startGesture);
stopDraggable();
};
}, [guard]);
const guardClick = useCallback(
<E>(action: (event: E) => void) =>
(event: E) => {
if (guard.swallowsClick()) return;
action(event);
},
[guard],
);
return { ref, guardClick };
}
export interface UseEdgeDropOptions {
/** The slot this wire opens; null for the wires either side of the empty-chain placeholder. */
insertIndex: number | null;
stepCount: number;
/**
* Given the chain's new order as original step indices, and the original indices of the steps the
* drag actually carried - so the caller can keep the dragged steps selected rather than guessing
* from the prior selection.
*/
onReorder: (order: number[], moved: readonly number[]) => void;
}
export interface UseEdgeDropResult {
ref: React.RefObject<HTMLDivElement | null>;
/** A step is hovering this wire and would land here. */
over: boolean;
}
/** Makes one wire a drop target that moves the dropped step into the slot the wire opens. */
export function useEdgeDrop({
insertIndex,
stepCount,
onReorder,
}: UseEdgeDropOptions): UseEdgeDropResult {
const ref = useRef<HTMLDivElement | null>(null);
const [over, setOver] = useState(false);
const insertIndexRef = useRef(insertIndex);
insertIndexRef.current = insertIndex;
const stepCountRef = useRef(stepCount);
stepCountRef.current = stepCount;
const onReorderRef = useRef(onReorder);
onReorderRef.current = onReorder;
useEffect(() => {
const element = ref.current;
if (!element) return;
return dropTargetForElements({
element,
// A wire with no slot is not a target at all, so a step dragged over it shows no landing spot.
canDrop: ({ source }) =>
insertIndexRef.current !== null && isStepDrag(source.data),
onDragEnter: () => setOver(true),
onDragLeave: () => setOver(false),
onDrop: ({ source }) => {
setOver(false);
const slot = insertIndexRef.current;
if (slot === null || !isStepDrag(source.data)) return;
const order = reorderMany(
stepCountRef.current,
source.data.moving,
slot,
);
if (order !== null) onReorderRef.current(order, source.data.moving);
},
});
}, []);
return { ref, over };
}
@@ -69,6 +69,29 @@ function seedPipelines(): StoredPolicy[] {
output: { type: "inline", options: {} },
outputIds: ["src-contracts"],
},
{
// A chain long enough to overflow the builder's graph column, which is where the graph has to
// start scrolling instead of pushing the inspector off the page.
id: "plc-long",
name: "Full document pipeline",
owner: "ops@acme.com",
enabled: true,
inputs: [{ sourceId: "src-claims", trigger: null }],
steps: [
{ operation: "/api/v1/misc/repair", parameters: {} },
{ operation: "/api/v1/misc/ocr-pdf", parameters: {} },
{ operation: "/api/v1/general/rotate-pdf", parameters: {} },
{ operation: "/api/v1/general/crop", parameters: {} },
{ operation: "/api/v1/general/remove-pages", parameters: {} },
{ operation: "/api/v1/misc/add-page-numbers", parameters: {} },
{ operation: "/api/v1/security/add-watermark", parameters: {} },
{ operation: "/api/v1/security/sanitize-pdf", parameters: {} },
{ operation: "/api/v1/misc/flatten", parameters: {} },
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
],
output: { type: "inline", options: {} },
outputIds: ["src-archive"],
},
{
id: "plc-onboarding",
name: "Onboarding OCR (paused)",
@@ -1,3 +1,13 @@
/**
* The builder claims the shell's view rather than lengthening it, so a long chain scrolls *inside*
* the graph while the header and the inspector stay put. A chain grows 112px per step, so it passes
* a typical viewport at around five steps - and if the page scrolled instead, clicking a node near
* the bottom would put the inspector (and Save) off-screen, which is the one interaction this whole
* layout exists to serve.
*
* The shell already makes .portal-shell__view the scroll container, so height here resolves against
* a definite box; capping the columns is what stops that view scrolling at all.
*/
.portal-builder {
display: flex;
flex-direction: column;
@@ -5,6 +15,14 @@
padding: 1.5rem;
max-width: 84rem;
margin: 0 auto;
height: 100%;
min-height: 0;
}
/* The header and any banners keep their own size; only the grid absorbs (or gives up) space. Left
to the flex default they would all shrink together and squash on a short viewport. */
.portal-builder > *:not(.portal-builder__grid) {
flex: none;
}
.portal-builder__loading {
@@ -13,254 +31,38 @@
padding: 4rem 0;
}
/* Header */
.portal-builder__head {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 1rem;
border-bottom: 1px solid var(--c-border-subtle);
}
.portal-builder__back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: none;
background: none;
padding: 0;
font-size: 0.8125rem;
color: var(--c-text-subtle);
cursor: pointer;
white-space: nowrap;
}
.portal-builder__back:hover {
color: var(--c-text);
}
.portal-builder__head-main {
flex: 1;
min-width: 12rem;
}
.portal-builder__head-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Two-pane layout */
.portal-builder__grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1.25rem;
/* Takes whatever the header leaves, and pins the row to exactly that. `minmax(0, 1fr)` rather
than the implicit `auto` row is what makes the columns' `max-height: 100%` mean anything: an
auto row is sized BY its tallest item, so a long settings form would size the row to itself and
then resolve its own 100% against it - capping nothing, and clipping the form with no scrollbar.
`align-items: start` still lets a short column hug its content inside the bounded row. */
grid-template-rows: minmax(0, 1fr);
flex: 1 1 auto;
min-height: 0;
align-items: start;
}
/* Stacked, the inspector sits below the graph, so there is nothing to hold in view - and capping
here would nest a scroll region inside a scrolling page, which is worse than a long page. Let the
builder grow and hand scrolling back to the shell. */
@media (max-width: 60rem) {
.portal-builder {
height: auto;
}
.portal-builder__grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
flex: none;
}
}
.portal-builder__flow {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.portal-builder__section-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-text-subtle);
font-weight: 600;
}
.portal-builder__empty {
font-size: 0.8125rem;
color: var(--c-text-subtle);
margin: 0;
padding: 0.5rem 0;
}
/* Step cards */
.portal-builder__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-builder__step {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
padding: 0.5rem 0.625rem;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-builder__step--active {
border-color: var(--c-primary);
background: var(--c-primary-tint);
}
.portal-builder__step-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.625rem;
border: none;
background: none;
padding: 0.25rem;
text-align: left;
cursor: pointer;
color: inherit;
}
.portal-builder__step-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
border-radius: 50%;
font-size: 0.6875rem;
font-weight: 600;
background: var(--c-primary-tint);
color: var(--c-primary);
}
.portal-builder__step--active .portal-builder__step-index {
background: var(--c-primary);
color: #fff;
}
.portal-builder__step-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-builder__step-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--c-text);
}
.portal-builder__step-note {
font-size: 0.6875rem;
color: var(--c-text-subtle);
}
/* A step that cannot run on what the one before it produces. */
.portal-builder__step-note--danger {
color: var(--c-danger);
}
/* A picker entry that cannot run on what the chain currently produces. */
.portal-pipelines__picker-note {
margin-left: auto;
padding-left: 0.5rem;
font-size: 0.6875rem;
color: var(--c-text-subtle);
}
.portal-builder__step-actions {
display: flex;
gap: 0.25rem;
}
.portal-builder__step-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border-radius: var(--radius-md);
border: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-subtle);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-builder__step-actions button:hover:not(:disabled) {
background: var(--c-hover);
color: var(--c-text);
}
.portal-builder__step-actions button:disabled {
opacity: 0.4;
cursor: default;
}
.portal-builder__add-step {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
width: 100%;
padding: 0.625rem;
border: 1px dashed var(--c-border);
border-radius: var(--radius-lg);
background: none;
color: var(--c-text-subtle);
font-size: 0.8125rem;
cursor: pointer;
transition:
border-color var(--motion-fast),
color var(--motion-fast);
}
.portal-builder__add-step:hover {
border-color: var(--c-primary);
color: var(--c-primary);
}
/* Pipeline settings (above the operation list) */
.portal-builder__settings {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
padding: 1.125rem;
}
.portal-builder__settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1.25rem;
}
.portal-builder__settings-col {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Input and destination each span the full settings width so their row has room. */
.portal-builder__inputs-col {
grid-column: 1 / -1;
}
/* The input row (source + trigger + optional schedule) and the destination row. */
/* The input's source dropdown and its edit affordance, in the inspector. */
.portal-builder__input-row {
display: flex;
flex-wrap: wrap;
@@ -273,58 +75,23 @@
min-width: 10rem;
}
/* The connect-source button trails to the end of the row. */
.portal-builder__input-row > button:last-child {
margin-left: auto;
}
/* Inspector: heading sits outside the card so it aligns with the operations heading. */
.portal-builder__inspector-col {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
/* Once the grid stacks (60rem), a sticky inspector would ride over content */
@media (max-width: 60rem) {
.portal-builder__inspector-col {
position: static;
}
}
.portal-builder__inspector {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.125rem;
}
/* Tool picker */
.portal-pipelines__picker {
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
background: var(--c-surface);
overflow: hidden;
/* The picker fills the modal hosting it rather than sitting in a card of its own: same surface,
same radius, so a bordered box in here would frame nothing. Its rows carry the structure, and
they run to the panel's edges - which is what lets the search divider span the full width. */
.portal-pipelines__picker-modal .sui-modal__body {
padding: 0;
}
/* Holds the shared Input, which brings its own border/focus ring; the row just insets it and rules
it off from the list below. */
.portal-pipelines__picker-search {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
padding: 0.75rem 0.75rem 0.625rem;
border-bottom: 1px solid var(--c-border-subtle);
}
.portal-pipelines__picker-search input {
flex: 1;
border: none;
background: none;
padding: 0;
font-size: 0.875rem;
color: var(--c-text);
outline: none;
.portal-pipelines__picker-search .sui-input {
width: 100%;
}
.portal-pipelines__picker-list {
@@ -334,7 +101,7 @@
}
.portal-pipelines__picker-group-label {
padding: 0.5rem 0.75rem 0.25rem;
padding: 0.5rem 1.125rem 0.25rem;
font-size: 0.6875rem;
color: var(--c-text-subtle);
}
@@ -346,7 +113,7 @@
width: 100%;
border: none;
background: none;
padding: 0.4375rem 0.75rem;
padding: 0.4375rem 1.125rem;
text-align: left;
cursor: pointer;
color: var(--c-text);
@@ -368,63 +135,52 @@
display: block;
}
/* Name over its optional note - a two-line item, so the note reads as a sub-line rather than
running straight on from the name. */
.portal-pipelines__picker-text {
display: flex;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.portal-pipelines__picker-name {
font-size: 0.8125rem;
}
/* Why this tool cannot follow the step before it. Advisory: the item is still pickable, just dimmed
and captioned so the reason is clear without shouting. */
.portal-pipelines__picker-note {
font-size: 0.6875rem;
color: var(--c-text-muted);
white-space: normal;
line-height: 1.3;
}
/* Muted via a token, not opacity: opacity on text drops the contrast below the floor. The name
still reads as de-emphasised, and the icon (not text) can take the opacity. */
.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-name {
color: var(--c-text-muted);
}
.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-icon {
opacity: 0.55;
}
.portal-pipelines__picker-empty {
padding: 1rem 0.75rem;
padding: 1rem 1.125rem;
font-size: 0.8125rem;
color: var(--c-text-subtle);
margin: 0;
}
/* The back link, step row, add-step affordance, tool-picker item and step
actions are the shared Button/ActionIcon carrying bespoke styling. Re-assert
their original look over the design-system button base (which otherwise
imposes a fixed height, its own padding/border and accent text colour). */
.portal-builder__back.sui-btn {
height: auto;
min-height: 0;
padding: 0;
font-weight: 400;
font-size: 0.8125rem;
color: var(--c-text-subtle);
}
.portal-builder__back.sui-btn:hover {
color: var(--c-text);
}
.portal-builder__step-main.sui-btn {
flex: 1;
height: auto;
min-height: 0;
padding: 0.25rem;
font-weight: 400;
color: inherit;
}
.portal-builder__add-step.sui-btn {
height: auto;
min-height: 0;
padding: 0.625rem;
border: 1px dashed var(--c-border);
background: none;
font-weight: 400;
font-size: 0.8125rem;
color: var(--c-text-subtle);
}
.portal-builder__add-step.sui-btn:hover {
border-color: var(--c-primary);
color: var(--c-primary);
}
/* The tool-picker item is the shared Button carrying bespoke styling, so re-assert its look over
the design-system base (which otherwise imposes a fixed height, its own padding and an accent
text colour). */
.portal-pipelines__picker-item.sui-btn {
height: auto;
min-height: 0;
padding: 0.4375rem 0.75rem;
padding: 0.4375rem 1.125rem;
font-weight: 400;
color: var(--c-text);
}
@@ -433,9 +189,39 @@
background: var(--c-hover);
}
.portal-builder__step-actions .sui-ai {
width: 1.5rem;
height: 1.5rem;
min-width: 1.5rem;
min-height: 1.5rem;
/* A quiet way into the chosen source's own settings, beside its dropdown. */
.portal-builder__input-row .portal-builder__source-edit {
color: var(--c-text-subtle);
}
.portal-builder__input-row .portal-builder__source-edit:hover:not(:disabled) {
color: var(--c-accent-fg, var(--c-primary));
}
/* The input's schedule row, its number field, and a builder-owned muted line. These lived in
Pipelines.css and only rendered because the router bundles both views together; a code-split (or
any Storybook story of the builder alone) left them unstyled. Kept here so the builder is
self-contained. */
.portal-builder__schedule {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-builder__schedule-count {
width: 5rem;
}
.portal-builder__muted {
font-size: 0.8125rem;
color: var(--c-text-subtle);
margin: 0;
}
/* The space-between button row shared by the builder's modal footers. */
.portal-builder__composer-footer {
display: flex;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
@@ -22,10 +22,21 @@ function withRoute(path: string) {
const meta: Meta<typeof PipelineBuilder> = {
title: "Portal/Views/PipelineBuilder",
component: PipelineBuilder,
parameters: { layout: "padded" },
// The builder reads the tool registry (for step labels + settings UIs), so
// it needs this provider to render at all.
parameters: { layout: "fullscreen" },
decorators: [
// The builder sizes itself against the shell's view - a fixed-height, non-scrolling box - which
// is what lets it cap its columns instead of lengthening the page. Given an auto-height parent
// its `height: 100%` resolves to nothing and the cap silently stops applying, so the story has
// to honour that contract or it reviews a layout the app never renders.
// Matches .portal-shell__view: a definite height with `auto` overflow, so the capped desktop
// layout has something to size against and the stacked layout can still scroll.
(Story) => (
<div style={{ height: "100dvh", overflowY: "auto" }}>
<Story />
</div>
),
// The builder reads the tool registry (for step labels + settings UIs), so
// it needs this provider to render at all.
(Story) => (
<ToolRegistryProvider>
<Story />
@@ -45,3 +56,12 @@ export const Default: Story = {
export const Edit: Story = {
decorators: [withRoute("/processor/pipelines/plc-redaction")],
};
/**
* A chain taller than the page. The graph column scrolls on its own so the header and the inspector
* stay where they are - if the page scrolled instead, selecting a step near the end of the chain
* would carry its settings off-screen.
*/
export const LongChain: Story = {
decorators: [withRoute("/processor/pipelines/plc-long")],
};
@@ -7,6 +7,8 @@ import {
} from "@testing-library/react";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import type { Policy, TriggerOutcome } from "@portal/api/pipelines";
import type { SourceView } from "@portal/api/sources";
import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext";
@@ -61,22 +63,63 @@ vi.mock("@portal/api/integrations", () => ({
createIntegration: (...args: unknown[]) => createIntegration(...args),
}));
// The destination picker just selects saved sources; stub it to a button that
// picks a fixed source, keeping this suite focused on the builder.
// The destination picker just selects saved sources; stub its three affordances
// (pick, create, edit) to buttons, keeping this suite focused on the builder.
vi.mock("@portal/components/pipelines/DestinationPicker", () => ({
DestinationPicker: ({
value,
onChange,
onCreateNew,
onEdit,
}: {
value: string[];
onChange: (ids: string[]) => void;
onCreateNew: () => void;
onEdit: (sourceId: string) => void;
}) => (
<button type="button" onClick={() => onChange(["src-1"])}>
{value.length > 0 ? `output:${value.join(",")}` : "pick output"}
</button>
<>
<button type="button" onClick={() => onChange(["src-1"])}>
{value.length > 0 ? `output:${value.join(",")}` : "pick output"}
</button>
<button type="button" onClick={onCreateNew}>
new destination
</button>
<button type="button" onClick={() => onEdit(value[0] ?? "")}>
edit destination
</button>
</>
),
}));
// The source modal has its own suite; stub it to the two things the builder
// depends on - the record it was opened on, and the sources-cache invalidation
// that follows a save (which is how a new source reaches the pickers).
vi.mock("@portal/components/sources/SourceModal", () => ({
SourceModal: ({
open,
sourceId,
}: {
open: boolean;
sourceId?: string | null;
}) => {
const queryClient = useQueryClient();
if (!open) return null;
return (
<div>
<span>source-modal:{sourceId || "new"}</span>
<button
type="button"
onClick={() =>
void queryClient.invalidateQueries({ queryKey: qk.sources() })
}
>
source saved
</button>
</div>
);
},
}));
// One editable tool, Compress, so the picker and step settings have something to render.
vi.mock("@app/contexts/ToolRegistryContext", () => {
const compress = {
@@ -129,9 +172,41 @@ vi.mock("@app/contexts/ToolRegistryContext", () => {
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
},
} as unknown as ToolRegistryEntry;
// A tool that will not run on its defaults: its validateParams is the same predicate its own Run
// button uses, so a step for it is "unconfigured" until a language is chosen.
const ocr = {
name: "OCR",
icon: null,
component: null,
description: "",
categoryId: "recommendedTools",
subcategoryId: "general",
automationSettings: (props: {
onParameterChange: (key: string, value: unknown) => void;
}) => (
<button
type="button"
onClick={() => props.onParameterChange("languages", ["eng"])}
>
pick language
</button>
),
operationConfig: {
operationType: "ocr",
toolType: 0,
endpoint: "/api/v1/misc/ocr-pdf",
defaultParameters: { languages: [] },
validateParams: (params: { languages?: string[] }) =>
(params.languages ?? []).length > 0,
buildFormData: () => new FormData(),
toApiParams: (params: Record<string, unknown>) => ({ ...params }),
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
},
} as unknown as ToolRegistryEntry;
const allTools = {
compress,
extractImages,
ocr,
} as unknown as ToolRegistryCatalog["allTools"];
const catalog: ToolRegistryCatalog = {
regularTools: allTools,
@@ -215,8 +290,44 @@ describe("PipelineBuilder", () => {
createIntegration.mockReset();
});
// Choose the given source in the (pre-seeded) input row's dropdown.
// The settings of a node are reached by selecting it in the graph, so every helper below opens
// its node first. Nodes are found by position rather than by label, because a node's title is
// its current value - it changes as the pipeline is filled in.
/** The graph's selectable nodes, in chain order: input, each step, output. */
function graphNodes(): HTMLElement[] {
return screen
.getAllByRole("button")
.filter((b) => b.hasAttribute("aria-pressed"));
}
/**
* The control that opens an end of the chain, once the graph has rendered. A new pipeline has not
* placed its ends yet, so that control is the "add" placeholder and clicking it both puts the node
* on the chain and selects it; a loaded pipeline already has the node, so it is a plain select.
*/
function endOpener(end: "input" | "output"): Promise<HTMLElement> {
return waitFor(() => {
const placeholder = screen.queryByText(
`portal.pipelines.graph.add.${end}`,
);
if (placeholder) return placeholder;
const nodes = graphNodes();
if (nodes.length === 0) throw new Error("the graph has not rendered yet");
return end === "input" ? nodes[0] : nodes[nodes.length - 1];
});
}
async function openInput() {
fireEvent.click(await endOpener("input"));
}
async function openOutput() {
fireEvent.click(await endOpener("output"));
}
async function pickInputSource(sourceName: string) {
await openInput();
fireEvent.click(
await screen.findByRole("textbox", {
name: "portal.pipelines.builder.inputSource",
@@ -225,24 +336,147 @@ describe("PipelineBuilder", () => {
fireEvent.click(await screen.findByText(sourceName));
}
it("always shows exactly one input row, with no add or remove controls", async () => {
/**
* Add a tool. An empty chain offers the placeholder; once it has steps, the wires carry the
* inserts instead.
*/
async function addTool(toolName: string) {
const placeholder = screen.queryByText(
"portal.pipelines.graph.addFirstTool",
);
if (placeholder) {
fireEvent.click(placeholder);
} else {
// The LAST wire, so repeated calls append. Taking the first would insert each new tool ahead
// of the ones already there, silently reversing the order a caller asked for.
const inserts = screen.getAllByLabelText(
"portal.pipelines.graph.insertHere",
);
fireEvent.click(inserts[inserts.length - 1]);
}
fireEvent.click(await screen.findByText(toolName));
}
async function pickDestination() {
await openOutput();
fireEvent.click(await screen.findByText("pick output"));
}
/** Open the header's overflow tray. */
async function openTray() {
fireEvent.click(
await screen.findByLabelText("portal.pipelines.builder.moreActions"),
);
}
it("greets a new pipeline with places to fill, not problems to fix", async () => {
renderBuilder("/processor/pipelines/new");
// The input row is a fixed part of the form: its source dropdown is present from the
// start, and there is nothing to add or remove.
// Both ends offer to be added rather than complaining about being empty: the user has not been
// asked for a source or a destination yet, so there is nothing yet to warn them about.
expect(
await screen.findAllByRole("textbox", {
await screen.findByText("portal.pipelines.graph.add.input"),
).toBeInTheDocument();
expect(
screen.getByText("portal.pipelines.graph.add.output"),
).toBeInTheDocument();
expect(
screen.queryByText("portal.pipelines.builder.needsSource"),
).not.toBeInTheDocument();
expect(
screen.queryByText("portal.pipelines.builder.needsDestination"),
).not.toBeInTheDocument();
// Nothing is on the chain, so there is nothing to remove either.
expect(
screen.queryByLabelText(/portal.pipelines.graph.removeNode/),
).not.toBeInTheDocument();
});
it("warns on a step whose tool cannot run on its defaults", async () => {
renderBuilder("/processor/pipelines/new");
await addTool("OCR");
// The tool declares its own mandatory parameters, so the node says so without the builder
// knowing anything about OCR.
expect(
await screen.findByText("portal.pipelines.builder.needsConfiguring"),
).toBeInTheDocument();
expect(
screen.getByText("portal.pipelines.composer.create").closest("button"),
).toBeDisabled();
});
it("clears the warning once the step is configured", async () => {
renderBuilder("/processor/pipelines/new");
await addTool("OCR");
await screen.findByText("portal.pipelines.builder.needsConfiguring");
// Adding a step selects it, so its settings are already open.
fireEvent.click(screen.getByText("pick language"));
await waitFor(() =>
expect(
screen.queryByText("portal.pipelines.builder.needsConfiguring"),
).not.toBeInTheDocument(),
);
});
it("leaves a tool that runs happily on its defaults unwarned", async () => {
renderBuilder("/processor/pipelines/new");
await addTool("Compress");
expect(
await screen.findByText("portal.pipelines.graph.add.input"),
).toBeInTheDocument();
expect(
screen.queryByText("portal.pipelines.builder.needsConfiguring"),
).not.toBeInTheDocument();
});
it("only asks for a source once the user has asked for the node", async () => {
renderBuilder("/processor/pipelines/new");
await openInput();
// Placing the node is what turns it into an outstanding choice.
expect(
await screen.findByText("portal.pipelines.builder.needsSource"),
).toBeInTheDocument();
// Still nothing chosen, so the pipeline cannot be saved.
expect(
screen.getByText("portal.pipelines.composer.create").closest("button"),
).toBeDisabled();
});
it("puts an end back to a placeholder when it is removed", async () => {
renderBuilder("/processor/pipelines/new");
await openInput();
await screen.findByText("portal.pipelines.builder.needsSource");
fireEvent.click(screen.getByLabelText("portal.pipelines.graph.removeNode"));
expect(
await screen.findByText("portal.pipelines.graph.add.input"),
).toBeInTheDocument();
expect(
screen.queryByText("portal.pipelines.builder.needsSource"),
).not.toBeInTheDocument();
});
it("edits a node's settings only once it is selected", async () => {
renderBuilder("/processor/pipelines/new");
await screen.findByText("portal.pipelines.graph.add.input");
// Nothing selected: the inspector says so rather than showing a form.
expect(
screen.getByText("portal.pipelines.inspector.noSelectionTitle"),
).toBeInTheDocument();
await openInput();
expect(
screen.getByRole("textbox", {
name: "portal.pipelines.builder.inputSource",
}),
).toHaveLength(1);
expect(
screen.queryByText("portal.pipelines.builder.addInput"),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", {
name: "portal.pipelines.builder.removeInput",
}),
).not.toBeInTheDocument();
).toBeInTheDocument();
});
it("builds a new pipeline: name it, add a tool, an input, a destination, and save", async () => {
@@ -257,12 +491,11 @@ describe("PipelineBuilder", () => {
},
);
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(await screen.findByText("Compress"));
await addTool("Compress");
// A pipeline must have at least one input source and one output destination.
await pickInputSource("Claims intake");
fireEvent.click(screen.getByText("pick output"));
await pickDestination();
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
@@ -291,13 +524,11 @@ describe("PipelineBuilder", () => {
{ target: { value: "Broken chain" } },
);
await pickInputSource("Claims intake");
fireEvent.click(screen.getByText("pick output"));
await pickDestination();
// Extract images emits images; compress only takes a PDF, so it can never run.
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(await screen.findByText("Extract images"));
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(await screen.findByText("Compress"));
await addTool("Extract images");
await addTool("Compress");
expect(
await screen.findByText("portal.pipelines.builder.stepsIncompatible"),
@@ -307,6 +538,21 @@ describe("PipelineBuilder", () => {
).toBeDisabled();
});
it("says why on the wire arriving at the step that cannot run", async () => {
renderBuilder("/processor/pipelines/new");
await pickInputSource("Claims intake");
await pickDestination();
await addTool("Extract images");
await addTool("Compress");
// The banner names which steps are at fault; the wire explains what is wrong where it happens.
const note = await screen.findByText(
/portal\.pipelines\.builder\.diagnostic\./,
);
expect(note.closest(".portal-graph-edge")).toHaveClass("is-blocking");
});
it("allows a chain whose steps line up", async () => {
renderBuilder("/processor/pipelines/new");
@@ -317,10 +563,9 @@ describe("PipelineBuilder", () => {
{ target: { value: "Fine chain" } },
);
await pickInputSource("Claims intake");
fireEvent.click(screen.getByText("pick output"));
await pickDestination();
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(await screen.findByText("Compress"));
await addTool("Compress");
expect(
screen.queryByText("portal.pipelines.builder.stepsIncompatible"),
@@ -352,7 +597,7 @@ describe("PipelineBuilder", () => {
expect(saveButton()).toBeDisabled();
// Both chosen: allowed, and both are sent.
fireEvent.click(screen.getByText("pick output"));
await pickDestination();
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
expect(savePipeline).toHaveBeenCalledWith(
@@ -363,6 +608,130 @@ describe("PipelineBuilder", () => {
);
});
it("creates and edits sources in place through the modal", async () => {
renderBuilder("/processor/pipelines/new");
await pickInputSource("Claims intake");
// Connect source opens the modal in create mode, without leaving the
// builder (and its unsaved edits) for the Sources page.
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
expect(screen.getByText("source-modal:new")).toBeInTheDocument();
expect(screen.queryByText("pipelines list")).not.toBeInTheDocument();
// The pencil beside the input opens the same modal on the chosen source.
fireEvent.click(
screen.getByLabelText("portal.pipelines.composer.editSource"),
);
expect(screen.getByText("source-modal:src-in")).toBeInTheDocument();
});
it("cannot edit an input source before one is chosen", async () => {
renderBuilder("/processor/pipelines/new");
await openInput();
expect(
screen.getByLabelText("portal.pipelines.composer.editSource"),
).toBeDisabled();
await pickInputSource("Claims intake");
expect(
screen.getByLabelText("portal.pipelines.composer.editSource"),
).not.toBeDisabled();
});
it("makes a source created from the input row the pipeline's input", async () => {
fetchSources
.mockResolvedValueOnce({ kpis: [], sources: [SOURCE] })
.mockResolvedValue({
kpis: [],
sources: [SOURCE, { ...SOURCE, id: "src-new", name: "Scanner drop" }],
});
renderBuilder("/processor/pipelines/new");
await openInput();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
fireEvent.click(screen.getByText("source saved"));
// The new source is the one the pipeline was missing, so it becomes the input.
await waitFor(() =>
expect(
screen.getByRole("textbox", {
name: "portal.pipelines.builder.inputSource",
}),
).toHaveValue("Scanner drop"),
);
});
it("makes a destination created from the picker the pipeline's output", async () => {
fetchSources
.mockResolvedValueOnce({ kpis: [], sources: [SOURCE] })
.mockResolvedValue({
kpis: [],
sources: [
SOURCE,
{ ...SOURCE, id: "src-new", name: "Archive bucket", type: "s3" },
],
});
renderBuilder("/processor/pipelines/new");
await openInput();
openOutput();
await screen.findByText("pick output");
fireEvent.click(screen.getByText("new destination"));
fireEvent.click(screen.getByText("source saved"));
// Created from the destination picker, so it lands in the output rather
// than the input.
await waitFor(() =>
expect(screen.getByText("output:src-new")).toBeInTheDocument(),
);
// The input was left alone: its node still shows the prompt.
expect(
screen.getByText("portal.pipelines.builder.chooseSource"),
).toBeInTheDocument();
});
it("leaves a new source that cannot be written to out of the destination", async () => {
// A webhook can be read from but not written to, so it must not be picked
// as a destination the dropdown has no option for.
fetchSources
.mockResolvedValueOnce({ kpis: [], sources: [SOURCE] })
.mockResolvedValue({
kpis: [],
sources: [
SOURCE,
{ ...SOURCE, id: "src-hook", name: "Partner hook", type: "webhook" },
],
});
renderBuilder("/processor/pipelines/new");
await openInput();
openOutput();
await screen.findByText("pick output");
fireEvent.click(screen.getByText("new destination"));
fireEvent.click(screen.getByText("source saved"));
// It was not made the destination...
await waitFor(() => expect(fetchSources).toHaveBeenCalledTimes(2));
expect(screen.getByText("pick output")).toBeInTheDocument();
// ...but it did arrive, and is offered as an input, where a webhook makes sense.
await openInput();
fireEvent.click(
screen.getByRole("textbox", {
name: "portal.pipelines.builder.inputSource",
}),
);
expect(await screen.findByText("Partner hook")).toBeInTheDocument();
});
it("edits the chosen destination through the same modal", async () => {
renderBuilder("/processor/pipelines/new");
await pickDestination();
fireEvent.click(screen.getByText("edit destination"));
expect(screen.getByText("source-modal:src-1")).toBeInTheDocument();
});
it("runs an existing pipeline and reports success", async () => {
renderBuilder("/processor/pipelines/plc-1");
@@ -401,9 +770,8 @@ describe("PipelineBuilder", () => {
it("clears processed history from the header and confirms", async () => {
renderBuilder("/processor/pipelines/plc-1");
fireEvent.click(
await screen.findByText("portal.pipelines.detail.clearHistory"),
);
await openTray();
fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory"));
await waitFor(() =>
expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"),
@@ -424,8 +792,7 @@ describe("PipelineBuilder", () => {
target: { value: "Watermarked" },
},
);
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(await screen.findByText("Compress"));
await addTool("Compress");
// The tool's settings upload a file, which a stored pipeline can't persist yet.
fireEvent.click(await screen.findByText("upload logo"));
@@ -450,10 +817,7 @@ describe("PipelineBuilder", () => {
target: { value: "Notify only" },
},
);
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(
await screen.findByText("portal.policies.operations.discordNotify.label"),
);
await addTool("portal.policies.operations.discordNotify.label");
// Operation chosen, account not: still not saveable.
expect(
@@ -516,10 +880,7 @@ describe("PipelineBuilder", () => {
},
);
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(
await screen.findByText("portal.policies.operations.discordNotify.label"),
);
await addTool("portal.policies.operations.discordNotify.label");
fireEvent.click(
await screen.findByPlaceholderText(
@@ -530,7 +891,7 @@ describe("PipelineBuilder", () => {
// Saving needs the input's source and a destination.
await pickInputSource("Claims intake");
fireEvent.click(screen.getByText("pick output"));
await pickDestination();
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
File diff suppressed because it is too large Load Diff
@@ -98,13 +98,6 @@
font-weight: 600;
}
.portal-pipelines__composer-footer {
display: flex;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
.portal-pipelines__source-list {
display: flex;
flex-direction: column;
@@ -116,13 +109,3 @@
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-md);
}
.portal-pipelines__schedule {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-pipelines__schedule-count {
width: 5rem;
}