diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json index fa08401d09..fb461fe6d2 100644 --- a/frontend/.storybook/a11y-baseline.dark.json +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -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"], diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 91db120c19..df46eb9dc4 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -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": [ diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 0fffc04dbc..4f70f2b813 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 77958629b0..28e8e1baa6 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -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. diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts index d68154c9b1..4e843c8da7 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts @@ -48,7 +48,7 @@ export const addPasswordFromApiParams = ( ): Partial => ({ password: apiParams.password ?? defaultParameters.password, ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword, - keyLength: apiParams.keyLength, + keyLength: apiParams.keyLength ?? defaultParameters.keyLength, permissions: { preventAssembly: apiParams.preventAssembly ?? permissionsDefaults.preventAssembly, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 467e3ea5f7..e8d34ef9ad 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -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 } }, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index aab9e1ab10..b32f783b06 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -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) ?? diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts index 56687b2217..4c6e714ec0 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -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); }); }); diff --git a/frontend/editor/src/core/ui/CodeBlock.tsx b/frontend/editor/src/core/ui/CodeBlock.tsx index cff078057d..2d66bd2353 100644 --- a/frontend/editor/src/core/ui/CodeBlock.tsx +++ b/frontend/editor/src/core/ui/CodeBlock.tsx @@ -70,7 +70,7 @@ export function CodeBlock({ )} -
+      
         {code}
       
diff --git a/frontend/editor/src/core/ui/NodeCard.css b/frontend/editor/src/core/ui/NodeCard.css new file mode 100644 index 0000000000..7982e2581a --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.css @@ -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); +} diff --git a/frontend/editor/src/core/ui/NodeCard.stories.tsx b/frontend/editor/src/core/ui/NodeCard.stories.tsx new file mode 100644 index 0000000000..acfe28f71d --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.stories.tsx @@ -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: , + title: "Compress", + detail: "level 7", + onSelect: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** 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: ( + + + + ), + }, +}; diff --git a/frontend/editor/src/core/ui/NodeCard.tsx b/frontend/editor/src/core/ui/NodeCard.tsx new file mode 100644 index 0000000000..451cc8823b --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.tsx @@ -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, + "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; +} + +/** + * 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 ( +
+ + {trailing} +
+ ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index 45212d8038..b3e2ca6ac0 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -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"; diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index 5c8afcd2cb..2ec00e9818 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -212,6 +212,20 @@ async function localForm( return unwrap(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(path: string, body: FormData): Promise { + const res = await fetch(`${localBaseUrl()}${path}`, { + method: "POST", + headers: { Accept: "application/json", ...(await localAuthHeader()) }, + body, + }); + if (res.status === 401) { + onLocalUnauthorized(); + } + return unwrap(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. */ diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index d6088d06ff..50bdc173c4 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -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 { ); } +/** 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 { + 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 { return apiClient.local.json( diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 152c45f7c5..1657b56bdf 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -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; } diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx index de1187c3da..864668b764 100644 --- a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -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 ( -
-
- onChange(id ? [id] : [])} + options={sources.map((source) => ({ + value: source.id, + label: source.name, + }))} + /> +
+ onEdit(chosen)} + > + + +
+ + )} - + ); } diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css new file mode 100644 index 0000000000..7f1984b4b1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css @@ -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; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx new file mode 100644 index 0000000000..4800689d7d --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx @@ -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 = { + title: "Portal/Pipelines/PipelineDefinitionModal", + component: PipelineDefinitionModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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 ( + <> + + setOpen(false)} + json={JSON_BODY} + /> + + ); +} + +/** The definition as it opens from the header: JSON first, cURL a tab away. */ +export const Default: Story = { render: () => }; + +/** The trigger it opens from, so the closed state can be exercised too. */ +export const FromTrigger: Story = { render: () => }; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx new file mode 100644 index 0000000000..32e55e3826 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx @@ -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[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( + , + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("opens on the JSON tab", () => { + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText(/"name": "Claims"/)).toBeInTheDocument(); + }); + + it("shows the definition alone - no tab strip to choose between", () => { + render(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx new file mode 100644 index 0000000000..adcd19f5f5 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css new file mode 100644 index 0000000000..f559e42795 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css @@ -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; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx new file mode 100644 index 0000000000..41c73a5ade --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx @@ -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 = { + title: "Portal/Pipelines/PipelineHeader", + component: PipelineHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** The name and the enabled switch are live, so the section can be seen in both states. */ +function Playground({ + initialName, + isEdit, + initialEnabled = true, + runResult = null, + ...rest +}: { + initialName: string; + isEdit: boolean; + initialEnabled?: boolean; + runResult?: RunResultSummary | null; + saving?: boolean; + testing?: boolean; + running?: boolean; + canSave?: boolean; + stepCount?: number; +}) { + const [name, setName] = useState(initialName); + const [enabled, setEnabled] = useState(initialEnabled); + return ( + + ); +} + +/** An existing pipeline: everything is available. */ +export const Editing: Story = { + render: () => , +}; + +/** + * A pipeline that has never been saved. It can still be tested against a file, but there is + * nothing yet to run on a schedule, clear history for, or delete. + */ +export const New: Story = { + render: () => , +}; + +/** Paused: the pipeline exists but its trigger will not fire. */ +export const Paused: Story = { + render: () => ( + + ), +}; + +/** Mid test-run: the picker shows its own progress while the graph shows the steps. */ +export const Testing: Story = { + render: () => , +}; + +/** After a test run: the outcome and its files sit beside the button that started them. */ +export const WithRunResult: Story = { + render: () => ( + + ), +}; + +/** A failed run: the summary is here, the failing step's own message is on its node. */ +export const WithFailedRun: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx new file mode 100644 index 0000000000..2c5552be07 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx @@ -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[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onEnabledChange: vi.fn(), + onSave: vi.fn(), + onCancel: vi.fn(), + onBack: vi.fn(), + onTest: vi.fn(), + onRun: vi.fn(), + onClearHistory: vi.fn(), + onDelete: vi.fn(), + onViewDefinition: vi.fn(), + onDownloadOutput: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineHeader", () => { + it("edits the pipeline's name and enabled state", () => { + const handlers = renderHeader(); + fireEvent.change( + screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), + { target: { value: "Renamed" } }, + ); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + + fireEvent.click(screen.getByRole("checkbox")); + expect(handlers.onEnabledChange).toHaveBeenCalledWith(false); + }); + + it("offers run, clear history and delete only once the pipeline exists", () => { + renderHeader({ isEdit: false }); + expect( + screen.queryByText("portal.pipelines.detail.run"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.delete"), + ).not.toBeInTheDocument(); + // A test run needs no saved record, so it stays: it is how you check the steps as you build. + expect( + screen.getByText("portal.pipelines.builder.testRun"), + ).toBeInTheDocument(); + }); + + it("labels the save action for what it will do", () => { + renderHeader({ isEdit: false }); + expect( + screen.getByText("portal.pipelines.composer.create"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.composer.save"), + ).not.toBeInTheDocument(); + }); + + it("blocks saving until the pipeline is valid", () => { + renderHeader({ canSave: false }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("hands the chosen file to the test run", () => { + const handlers = renderHeader(); + const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); + const input = + document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + expect(handlers.onTest).toHaveBeenCalledWith(file); + }); + + it("will not offer a test run on a chain with no steps", () => { + renderHeader({ stepCount: 0 }); + expect( + screen.getByText("portal.pipelines.builder.testRun").closest("button"), + ).toBeDisabled(); + }); + + it("shows why a test run failed, not only that it did", () => { + renderHeader({ + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }); + expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); + }); + + it("runs and deletes from the row, clears history from the tray", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.detail.run")); + expect(handlers.onRun).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); + expect(handlers.onDelete).toHaveBeenCalled(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + expect(handlers.onClearHistory).toHaveBeenCalled(); + }); + + it("leaves the page through cancel and back", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + expect(handlers.onCancel).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.builder.back")); + expect(handlers.onBack).toHaveBeenCalled(); + }); + + it("keeps the occasional actions out of the row, behind a tray", () => { + renderHeader(); + // Running and testing earn a button each; reading the definition and wiping history do not. + expect( + screen.queryByText("portal.pipelines.builder.viewDefinition"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.clearHistory"), + ).not.toBeInTheDocument(); + expect( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ).toBeInTheDocument(); + }); + + it("opens the definition from the tray", () => { + const handlers = renderHeader(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click( + screen.getByText("portal.pipelines.builder.viewDefinition"), + ); + expect(handlers.onViewDefinition).toHaveBeenCalled(); + }); + + it("shows no run strip until a test has been run", () => { + renderHeader(); + expect( + screen.queryByText(/portal.pipelines.inspector.status/), + ).not.toBeInTheDocument(); + }); + + it("reports a finished run and downloads the file clicked", () => { + const handlers = renderHeader({ + runResult: { + status: "completed", + completedSteps: 2, + stepCount: 2, + outputs: [ + { fileId: "f1", fileName: "claim.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }); + fireEvent.click(screen.getByText("claim.pdf")); + expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ + fileId: "f1", + fileName: "claim.pdf", + }); + // A file the backend did not name still has to be reachable. + expect(screen.getByText("f2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx new file mode 100644 index 0000000000..25bfbd044f --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx @@ -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 ( +
+
+ +
+ + +
+
+ +
+ onNameChange(e.target.value)} + /> + {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch + would imply it applies the moment it is flipped. No description - a second line beside + the single-line name field leaves the row ragged. */} + onEnabledChange(e.target.checked)} + label={t("portal.pipelines.builder.enabled")} + /> +
+ +
+ file && onTest(file)} + leftSection={} + > + {t("portal.pipelines.builder.testRun")} + + + {isEdit && ( + + )} + + {/* Occasional things - reading the definition, wiping the processed history - kept behind a + tray so they do not compete with running and testing, which is what this row is for. */} + + + + + + + + } + > + {t("portal.pipelines.builder.viewDefinition")} + + {isEdit && ( + + } + > + {t("portal.pipelines.detail.clearHistory")} + + )} + + + + {isEdit && ( + + )} +
+ + {runResult && ( + + )} +
+ ); +} + +interface RunResultStripProps { + result: RunResultSummary; + onDownload: (output: RunOutputFile) => void; +} + +/** What the last test run did, beside the button that started it. */ +function RunResultStrip({ result, onDownload }: RunResultStripProps) { + const { t } = useTranslation(); + const outputs = result.outputs ?? []; + + return ( +
+
+ {result.status === "running" && } + {result.status === "completed" && ( + + )} + {result.status === "failed" && ( + + )} + + {t(`portal.pipelines.inspector.status.${result.status}`, { + done: result.completedSteps, + count: result.stepCount, + })} + +
+ + {/* The reason it failed, where the failure is announced - not only on the node, which the user + has to know to click. */} + {result.status === "failed" && result.error && ( + + {result.error} + + )} + + {outputs.map((output) => ( + + ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.css b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css new file mode 100644 index 0000000000..0fc50597e8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css @@ -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); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx new file mode 100644 index 0000000000..ff4c8f3d21 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx @@ -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 = { + title: "Portal/Pipelines/PipelineInspector", + component: PipelineInspector, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Stands in for a node's real editor, which the builder supplies. */ +function StubSettings() { + return ( + <> + + + + + } onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") onClose(); @@ -104,36 +104,48 @@ export function ToolPicker({
{group.label}
- {group.tools.map((tool) => ( - - ))} + + ); + })} )) )} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css new file mode 100644 index 0000000000..a821b9910e --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css @@ -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; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx new file mode 100644 index 0000000000..7f7dcca5d4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx @@ -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 ? ( + onInsert(edge.insertIndex as number)} + > + + + ) : null; + + return ( +
+ + {warning ? ( + + + + + {warning.text} + + + {insertButton} + + ) : ( + insertButton + )} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css new file mode 100644 index 0000000000..9bea07fd4b --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css @@ -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; + } +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx new file mode 100644 index 0000000000..c19a7c15f2 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx @@ -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; + +const KIND_ICON: Record = { + input: , + step: , + output: , +}; + +const KIND_ACCENT: Record = { + 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; +} + +/** + * 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 && ( + + ); + const remove = onRemove && ( + + + + ); + + return ( + + + {warning} + + ) : ( + 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 ( + + + + ); + } + return ( + + {runState === "running" && ( + + )} + {runState === "done" && ( + + )} + {runState === "failed" && ( + + )} + + {t(`portal.pipelines.graph.run.${runState}`)} + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css new file mode 100644 index 0000000000..dba96072c4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css @@ -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; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx new file mode 100644 index 0000000000..0ecee1bfc7 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx @@ -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 ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css new file mode 100644 index 0000000000..c9aff67012 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css @@ -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; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx new file mode 100644 index 0000000000..c16291bd9c --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx @@ -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 = { + title: "Portal/Pipelines/PipelineGraph", + component: PipelineGraph, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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(initialSteps); + const [selected, setSelected] = useState(null); + const [added, setAdded] = useState(0); + const [inputEnd, setInputEnd] = useState( + unplacedEnds ? null : INPUT, + ); + const [outputEnd, setOutputEnd] = useState( + 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 ( + + ); +} + +/** A typical chain. Drag a step onto any wire to move it there. */ +export const Default: Story = { + render: () => ( + + ), +}; + +/** A pipeline with its ends settled but no steps yet: the placeholder holds the first step's place. */ +export const Empty: Story = { + render: () => , +}; + +/** + * 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: () => , +}; + +/** + * 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: () => ( + + ), +}; + +/** Mid test-run: finished steps carry a tick, the current one pulses. */ +export const Running: Story = { + render: () => ( + + ), +}; + +/** A failed run, and a step that cannot be saved: the warning replaces the detail line. */ +export const Problems: Story = { + render: () => ( + + ), +}; + +/** + * 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: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx new file mode 100644 index 0000000000..c94bd512f3 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx @@ -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[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) => + vars?.name ? `${key}:${String(vars.name)}` : key, + }), +})); + +function renderGraph(overrides: Partial = {}) { + const handlers = { + onSelect: vi.fn(), + onAddEnd: vi.fn(), + onRemoveEnd: vi.fn(), + onInsertStep: vi.fn(), + onRemoveSteps: vi.fn(), + onReorderSteps: vi.fn(), + }; + render( + , + ); + 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({ steps: [0] }); + return ( + { + 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(); + 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(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx new file mode 100644 index 0000000000..7afb3a9f41 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx @@ -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(null); + const { nodes, edges, width, height } = layoutChain({ + stepCount: steps.length, + }); + + const graphRef = useRef(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(null); + useLayoutEffect(() => { + const position = focusStepAfterRender.current; + if (position === null) return; + focusStepAfterRender.current = null; + graphRef.current + ?.querySelector( + `[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) { + 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) { + 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 ( +
+
+ {edges.map((edge) => ( + + ))} + + {draggingIndex !== null && edges.length > 0 && ( +

+ {t("portal.pipelines.graph.dragHint")} +

+ )} + + {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 ( +
+ onInsertStep(0)} + /> +
+ ); + } + if (node.kind === "input" || node.kind === "output") { + const kind = node.kind; + const content = kind === "input" ? input : output; + return ( +
+ {content === null ? ( + onAddEnd(kind)} + /> + ) : ( + onSelect(kind)} + onRemove={() => onRemoveEnd(kind)} + /> + )} +
+ ); + } + const index = node.stepIndex ?? 0; + return ( +
+ selectStep(index, event)} + onRemove={() => onRemoveSteps([index])} + onDragChange={(dragging) => + setDraggingIndex(dragging ? index : null) + } + onOpenRunState={ + steps[index].runState === "failed" && onOpenStepError + ? () => onOpenStepError(index) + : undefined + } + /> +
+ ); + })} +
+
+ ); +} + +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 ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts new file mode 100644 index 0000000000..d03e6b3c42 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts @@ -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(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts new file mode 100644 index 0000000000..10fadfb6f8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts @@ -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:"`. 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; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts new file mode 100644 index 0000000000..1816f1a0ca --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts @@ -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) => + `
${label}
`, + ) + .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); + } + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts new file mode 100644 index 0000000000..f80949d077 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts @@ -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 { + type: typeof DRAG_TYPE; + /** Every step this drag carries, in chain order - one, or the whole selection. */ + moving: number[]; +} + +function isStepDrag(data: Record): 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; + /** + * 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: (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; + +/** + * 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(null); + const guardRef = useRef(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( + (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; + /** 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(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 }; +} diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index f4c085ade7..0158d1e168 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -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)", diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index 128e8d4a1b..464d3fad55 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -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%; } diff --git a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx index 95d2987e52..c204721c38 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx @@ -22,10 +22,21 @@ function withRoute(path: string) { const meta: Meta = { 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) => ( +
+ +
+ ), + // The builder reads the tool registry (for step labels + settings UIs), so + // it needs this provider to render at all. (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")], +}; diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index e06025889e..24d215a99f 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -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; }) => ( - + <> + + + + ), })); +// 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 ( +
+ source-modal:{sourceId || "new"} + +
+ ); + }, +})); + // 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) => ({ ...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; + }) => ( + + ), + 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) => ({ ...params }), + fromApiParams: (params: Record) => ({ ...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 { + 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")); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index d788fc4f35..ecc249d88e 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -1,19 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import KeyboardArrowUpRoundedIcon from "@mui/icons-material/KeyboardArrowUpRounded"; -import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; import { ActionIcon, Banner, Button, - Checkbox, - EmptyState, + FormField, Input, Modal, Select, @@ -47,11 +43,14 @@ import { deletePipeline, fetchPipeline, fetchRun, + fetchRunOutput, fetchTriggers, + runPipelineTest, savePipeline, triggerPipeline, type Policy, type PolicyRunView, + type RunOutputFile, type TriggerConfig, type TriggerInfo, type TriggerOutcome, @@ -61,14 +60,27 @@ import { DestinationPicker } from "@portal/components/pipelines/DestinationPicke import { availableOutputModes } from "@portal/components/pipelines/outputModes"; import { type SourceView } from "@portal/api/sources"; import { useSources } from "@portal/queries/sources"; +import { SourceModal } from "@portal/components/sources/SourceModal"; import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; import { useAsync } from "@portal/hooks/useAsync"; import { useQueryClient } from "@tanstack/react-query"; import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; +import { PipelineHeader } from "@portal/components/pipelines/PipelineHeader"; +import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; +import { + PipelineGraph, + selectedSteps, + type ChainEnd, + type ChainWarning, + type GraphSelection, + type GraphStepContent, +} from "@portal/components/pipelines/graph/PipelineGraph"; import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; +import { BrandMark } from "@portal/components/BrandMarks"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { integrationStepConfigured, @@ -158,6 +170,11 @@ function buildTriggerFor(input: WorkingInput): TriggerConfig | null { return { type: input.triggerType, options: {} }; } +/** Whether a source can be written to, i.e. offered as a pipeline destination. */ +function isWritableSource(source: SourceView): boolean { + return (availableOutputModes() as string[]).includes(source.type); +} + /** * Full-page pipeline builder (route: /pipelines/new and /pipelines/:id). Pipeline-level settings * (sources, trigger, output) sit above the operation list; the operation list and the selected @@ -206,10 +223,7 @@ export function PipelineBuilder() { // A destination is a source used as a write target: only writable types (folder/S3, filtered per // deployment) can be picked, and the virtual editor is already excluded from availableSources. const writableSources = useMemo( - () => - availableSources.filter((source) => - (availableOutputModes() as string[]).includes(source.type), - ), + () => availableSources.filter(isWritableSource), [availableSources], ); const triggers = useMemo( @@ -223,9 +237,23 @@ export function PipelineBuilder() { // wire shape stays a list (see save()). const [input, setInput] = useState(blankInput); const [steps, setSteps] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(null); - const [pickerOpen, setPickerOpen] = useState(false); + /** Which node the inspector is editing: an end of the chain, a step, or nothing. */ + const [selected, setSelected] = useState(null); + /** Slot the tool picker will insert into, or null when it is closed. */ + const [pickerAt, setPickerAt] = useState(null); + const [definitionOpen, setDefinitionOpen] = useState(false); + /** The last test run in this session: one file through the steps as they stand. */ + const [testRun, setTestRun] = useState(null); + const [testing, setTesting] = useState(false); const [outputIds, setOutputIds] = useState([]); + /** + * Whether the user has asked for each end of the chain yet, distinguishing "not offered" from + * "offered and still owed a choice" - the two states an empty sourceId cannot tell apart. Only a + * brand new pipeline starts with either false; anything loaded arrives with both ends set, and + * choosing one places it, so these are just the "clicked add, chosen nothing" window. + */ + const [inputAsked, setInputAsked] = useState(false); + const [outputAsked, setOutputAsked] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [seeded, setSeeded] = useState(false); @@ -236,6 +264,39 @@ export function PipelineBuilder() { const [deleting, setDeleting] = useState(false); const [pendingNav, setPendingNav] = useState(null); + // Create or edit a source in place, instead of leaving the builder (and its + // unsaved edits) for the Sources page. + const [sourceModal, setSourceModal] = useState<{ + open: boolean; + sourceId: string | null; + }>({ open: false, sourceId: null }); + // A source created from here is the one the pipeline was missing, so select it + // on arrival - as the input or the destination, whichever asked for it. + const autoSelectRef = useRef<"input" | "output" | null>(null); + const knownSourceIdsRef = useRef>(new Set()); + useEffect(() => { + const target = autoSelectRef.current; + const known = knownSourceIdsRef.current; + knownSourceIdsRef.current = new Set(availableSources.map((s) => s.id)); + if (!target) return; + const fresh = availableSources.find((s) => !known.has(s.id)); + if (!fresh) return; + // One arrival answers the request, whatever type it turned out to be. + autoSelectRef.current = null; + if (target === "input") { + changeInputSource(fresh.id); + } else if (isWritableSource(fresh)) { + // A source of an unwritable type is left alone rather than becoming a + // destination the picker has no option for. + setOutputIds([fresh.id]); + } + }, [availableSources]); + + function createSourceFor(target: "input" | "output") { + autoSelectRef.current = target; + setSourceModal({ open: true, sourceId: null }); + } + const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -273,14 +334,6 @@ export function PipelineBuilder() { setSeeded(true); }, [isEdit, policyState.data, allTools, seeded]); - // Keep one tool's settings open: auto-select the first step whenever a pipeline has steps but - // nothing is selected (initial load, or after the selected step is removed). - useEffect(() => { - if (seeded && selectedIndex === null && steps.length > 0) { - setSelectedIndex(0); - } - }, [seeded, selectedIndex, steps.length]); - const sourceType = (sourceId: string) => availableSources.find((s) => s.id === sourceId)?.type; @@ -339,38 +392,65 @@ export function PipelineBuilder() { }); } - function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + /** Put an end on the chain and open it, so the click that asks for it also offers the choice. */ + function addEnd(end: ChainEnd) { + if (end === "input") setInputAsked(true); + else setOutputAsked(true); + setSelected(end); + } + + /** Take an end back off, discarding whatever it held so its row reads as unfilled again. */ + function removeEnd(end: ChainEnd) { + if (end === "input") { + setInputAsked(false); + setInput(blankInput()); + } else { + setOutputAsked(false); + setOutputIds([]); + } + setSelected((current) => (current === end ? null : current)); + } + + /** Drop a new step into the slot the picker was opened on, and select it to be configured. */ + function insertStep(step: WorkingToolStep) { + const at = pickerAt ?? steps.length; setSteps((current) => { - const next = [...current, newIntegrationStep(op)]; - setSelectedIndex(next.length - 1); + const next = [...current]; + next.splice(at, 0, step); return next; }); - setPickerOpen(false); + setSelected({ steps: [at] }); + setPickerAt(null); + } + + function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + insertStep(newIntegrationStep(op)); } function addStep(tool: ExecutableTool) { - setSteps((current) => { - const next = [...current, newWorkingToolStep(tool, allTools)]; - setSelectedIndex(next.length - 1); - return next; - }); - setPickerOpen(false); + insertStep(newWorkingToolStep(tool, allTools)); } - function removeStep(index: number) { - setSelectedIndex(null); - setSteps((current) => current.filter((_, i) => i !== index)); + function removeSteps(indices: number[]) { + const gone = new Set(indices); + setSelected(null); + setSteps((current) => current.filter((_, i) => !gone.has(i))); } - function moveStep(index: number, delta: number) { - setSteps((current) => { - const target = index + delta; - if (target < 0 || target >= current.length) return current; - const next = [...current]; - [next[index], next[target]] = [next[target], next[index]]; - return next; - }); - setSelectedIndex((cur) => (cur === index ? index + delta : cur)); + /** + * Apply a reordered chain, given as the original step indices in their new positions. The steps + * the drag carried stay selected where they land, so a set can be dragged again without re-picking + * it - and dragging an unselected step selects it, rather than leaving the inspector on whatever + * was selected before. + */ + function reorderSteps(order: number[], moved: readonly number[]) { + const moving = new Set(moved); + 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); } function updateStepParams(index: number, params: ErasedToolParams) { @@ -396,6 +476,21 @@ export function PipelineBuilder() { return entry?.name ?? humanizeOperation(step.operation); } + /** + * A step's glyph, matching how the tool picker draws it: an integration step carries its vendor's + * mark, a tool step its own icon. Without this every node falls back to the generic slider glyph, + * so a chain reads as a stack of identical cards. + */ + function stepIcon(step: WorkingToolStep): ReactNode { + const op = stepOperation(step); + if (op) + return ( + + ); + if (isIntegrationStep(step)) return ; + return step.toolId ? allTools[step.toolId]?.icon : undefined; + } + // Steps whose params carry an uploaded file can't be saved: the bytes aren't persisted with the // policy, so a later run would send null for that field (see stepRequiresUpload). const uploadStepLabels = steps.filter(stepRequiresUpload).map(stepLabel); @@ -431,16 +526,22 @@ export function PipelineBuilder() { .map((d) => stepLabel(steps[d.stepIndex])); const hasIncompatibleSteps = hasBlockingDiagnostics(chainDiagnostics); - // What a newly added step would be handed, so the picker can flag tools that cannot take it. - const chainOutput = useMemo( + /** + * What a step added at the open slot would be handed, so the picker can flag tools that cannot + * take it. Scoped to the steps *before* that slot rather than the whole chain: the graph inserts + * anywhere, so what precedes the new step is not necessarily the chain's final output. + */ + const precedingOutput = useMemo( () => - chainOutputFormat( - steps.map((step) => ({ - operation: step.operation, - parameters: step.params, - })), - ), - [steps], + pickerAt === null + ? undefined + : chainOutputFormat( + steps.slice(0, pickerAt).map((step) => ({ + operation: step.operation, + parameters: step.params, + })), + ), + [steps, pickerAt], ); function diagnosticNote(diagnostic: ToolDiagnostic): string { @@ -451,26 +552,21 @@ export function PipelineBuilder() { }); } - /** The most severe diagnostic for a step, rendered as its note. */ - function renderStepDiagnostic(index: number) { + /** + * The step's most severe diagnostic, for the wire arriving at it - which is where a note about + * what the step is being handed belongs, rather than on the step itself. + */ + function stepInputWarning(index: number): ChainWarning | undefined { const forStep = diagnosticsForStep(chainDiagnostics, index); const diagnostic = forStep.find((d) => d.severity === "ERROR") ?? forStep.find((d) => d.severity === "WARN") ?? forStep[0]; - if (!diagnostic) return null; - return ( - - {diagnosticNote(diagnostic)} - - ); + if (!diagnostic) return undefined; + return { + text: diagnosticNote(diagnostic), + blocking: diagnostic.severity === "ERROR", + }; } // Track unsaved edits: snapshot the form and compare against the state captured just after @@ -505,7 +601,6 @@ export function PipelineBuilder() { !submitting; const listPath = toPortalPath(VIEW_PATHS.pipelines); - const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}/new`; function close() { navigate(listPath); @@ -517,12 +612,6 @@ export function PipelineBuilder() { else navigate(destination); } - // Jump to the source builder, for when the source you want to read from or write to doesn't - // exist yet. Inputs and the output destination are both saved sources, so both create one here. - function goToSources() { - attemptLeave(sourcesPath); - } - async function save(destination: string) { if (!canSave) return; setSubmitting(true); @@ -550,16 +639,69 @@ export function PipelineBuilder() { } // Poll a run until it reaches a terminal state (or we give up), so a failure surfaces. - async function awaitRun(runId: string): Promise { + async function awaitRun( + runId: string, + onProgress?: (view: PolicyRunView) => void, + ): Promise { for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { if (!mounted.current) return null; const view = await fetchRun(runId); + onProgress?.(view); if (TERMINAL_STATUSES.has(view.status)) return view; await sleep(POLL_INTERVAL_MS); } return null; } + /** + * Run the steps as they stand against one uploaded file. Output is forced inline so nothing + * reaches the pipeline's real destination, and the pipeline need not be saved first - this is + * how the chain gets checked while it is still being built. + */ + async function handleTest(file: File) { + if (testing) return; + setTesting(true); + setTestRun(null); + setRunResult(null); + try { + const { runId } = await runPipelineTest( + { + name: name.trim() || t("portal.pipelines.builder.testRun"), + steps: steps.map((step) => serializeToolStep(step, allTools)), + output: { type: "inline", options: {} }, + }, + file, + ); + const final = await awaitRun(runId, (view) => { + if (mounted.current) setTestRun(view); + }); + if (mounted.current && final) setTestRun(final); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } finally { + if (mounted.current) setTesting(false); + } + } + + /** Save one of a test run's outputs to disk. */ + async function downloadOutput(output: RunOutputFile) { + try { + const blob = await fetchRunOutput(output.fileId); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = output.fileName ?? output.fileId; + link.click(); + // Revoke on the next tick: some browsers have not yet begun reading the + // blob when click() returns, and revoking now would cancel the download. + setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } + } + /** Explain an empty trigger: parked files outrank blander reasons. */ function emptySweepResult(outcome: TriggerOutcome): RunResult { if (outcome.parked > 0) { @@ -669,95 +811,257 @@ export function PipelineBuilder() { ); } + const chosenSteps = selectedSteps(selected); + // One step selected means its settings; several means there is no single thing to configure. const selectedStep = - selectedIndex !== null ? (steps[selectedIndex] ?? null) : null; + chosenSteps.length === 1 ? (steps[chosenSteps[0]] ?? null) : null; - return ( -
-
- -
- setName(e.target.value)} - /> -
-
- setEnabled(e.target.checked)} - label={t("portal.pipelines.builder.enabled")} - /> - {isEdit && ( + const chosenSource = availableSources.find((s) => s.id === input.sourceId); + const chosenDestination = writableSources.find((s) => s.id === outputIds[0]); + + /** How this input fires, in a few words, for the input node's summary line. */ + function triggerSummary(): string { + if (input.triggerType === MANUAL) + return t("portal.pipelines.composer.triggerManual"); + if (input.triggerType === "schedule") + // One counted phrase per unit, so it reads "Runs every hour" / "Runs every 3 hours" rather + // than the ungrammatical, untranslatable "Run every 1 hours". + return t( + `portal.pipelines.composer.runsEvery.${input.scheduleUnit.toLowerCase()}`, + { count: Number(input.scheduleCount) || 1 }, + ); + return t(`portal.pipelines.trigger.${input.triggerType}`, { + defaultValue: input.triggerType, + }); + } + + /** Why a step cannot be saved yet, if anything. */ + function stepWarning(step: WorkingToolStep): string | undefined { + if (isIntegrationStep(step)) { + if (!stepOperation(step)) + return t("portal.pipelines.builder.chooseOperation"); + if (!integrationStepConfigured(step)) + return t("portal.pipelines.builder.chooseAccount"); + return undefined; + } + if (stepRequiresUpload(step)) + return t("portal.pipelines.builder.needsUpload"); + if (stepNeedsConfiguring(step, allTools)) + return t("portal.pipelines.builder.needsConfiguring"); + return undefined; + } + + /** A step's one-line summary: what it will do beyond its name. */ + function stepDetail(step: WorkingToolStep): string | undefined { + if (step.support === "unsupported") + return t("portal.pipelines.builder.usesDefaults"); + if (step.support === "unknown") + return t("portal.pipelines.builder.unknownStep"); + return undefined; + } + + // A run reports one step cursor, so progress reads off it: everything before the cursor is done, + // the cursor itself is whatever the run currently is. + function stepRunState(index: number): GraphStepContent["runState"] { + if (!testRun) return undefined; + if (index < testRun.currentStep) return "done"; + if (index > testRun.currentStep) return undefined; + if (testRun.status === "FAILED") return "failed"; + if (testRun.status === "COMPLETED") return "done"; + return "running"; + } + + const graphSteps: GraphStepContent[] = steps.map((step, i) => ({ + label: stepLabel(step), + detail: stepDetail(step), + icon: stepIcon(step), + warning: stepWarning(step), + inputWarning: stepInputWarning(i), + runState: stepRunState(i), + })); + + const definitionJson = JSON.stringify( + { + name: name.trim(), + enabled, + inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], + steps: steps.map((step) => serializeToolStep(step, allTools)), + outputIds, + }, + null, + 2, + ); + + const testSummary = + testRun === null + ? null + : { + status: + testRun.status === "FAILED" + ? ("failed" as const) + : testRun.status === "COMPLETED" + ? ("completed" as const) + : ("running" as const), + completedSteps: testRun.currentStep, + stepCount: testRun.stepCount, + error: testRun.error, + outputs: testRun.outputs ?? [], + }; + + /** The editor for whatever node is selected. Undefined when nothing is. */ + function inspectorBody() { + if (selected === "input") { + // Nothing to pick from yet: a dropdown of nothing helps no one, so offer only the way to make + // the first source. The trigger has no meaning without a source either, so it waits too. + const hasSources = availableSources.length > 0; + return ( + <> + {hasSources && ( <> - - - + +
+
+ + updateInput({ + triggerType: + value && value !== MANUAL_OPTION ? value : MANUAL, + }) + } + options={triggerOptionsFor(input.sourceId)} + /> + + + {input.triggerType === "schedule" && ( +
+ + {t("portal.pipelines.composer.scheduleEvery")} + + + updateInput({ scheduleCount: e.target.value }) + } + className="portal-builder__schedule-count" + /> + changeInputSource(value ?? "")} - options={sourceOptions} - /> -
-
- - updateInput({ scheduleCount: e.target.value }) - } - className="portal-pipelines__schedule-count" - /> -