From d6061eb0aa2fa55b6247f523a013ff16ce92d11a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 8 Jul 2026 16:23:04 +0100 Subject: [PATCH] Support tool selection in Pipelines page in Portal (#6905) # Description of Changes Redesigned the Portal Pipelines page so pipelines are created and edited on their own dedicated builder page, replacing the previous modal composer and inline detail card. ## Screenshots ### Pipelines list The redesigned list with summary KPIs; each row opens that pipeline's own page. ![Pipelines list](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/01-pipelines-list.png) ### Pipeline builder The dedicated create page: pipeline settings (sources, trigger, output) above, operations and per-tool settings below. ![Pipeline builder](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/02-builder-new.png) ### Tool picker Type-to-filter, category-grouped picker for adding an operation to the pipeline. ![Tool picker](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/03-tool-picker.png) ### Editing a pipeline An existing pipeline in the builder: reorderable steps, per-tool settings, and run/delete actions. ![Editing a pipeline](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/04-builder-edit.png) --- .../public/locales/en-US/translation.toml | 38 +- .../hooks/tools/redact/useRedactOperation.ts | 2 + .../hooks/tools/shared/toolApiMapping.test.ts | 9 + .../core/hooks/tools/shared/toolApiMapping.ts | 11 + .../hooks/tools/shared/toolAutomation.test.ts | 215 +++++ .../core/hooks/tools/shared/toolAutomation.ts | 260 ++++++ .../hooks/tools/shared/toolOperationTypes.ts | 71 +- .../hooks/tools/split/useSplitOperation.ts | 2 + frontend/editor/src/portal/ViewRouter.tsx | 9 + .../src/portal/components/PortalChrome.tsx | 10 +- .../components/pipelines/PipelineComposer.tsx | 477 ---------- .../pipelines/PipelineDetailCard.tsx | 215 ----- .../pipelines/PipelineStepSettings.tsx | 59 ++ .../components/pipelines/PipelinesTable.tsx | 26 +- .../components/pipelines/ToolPicker.tsx | 99 ++ .../src/portal/views/PipelineBuilder.css | 394 ++++++++ .../src/portal/views/PipelineBuilder.test.tsx | 222 +++++ .../src/portal/views/PipelineBuilder.tsx | 865 ++++++++++++++++++ .../editor/src/portal/views/Pipelines.css | 196 +--- .../src/portal/views/Pipelines.test.tsx | 220 +---- .../editor/src/portal/views/Pipelines.tsx | 164 +--- frontend/editor/src/portal/views/Sources.tsx | 15 +- 22 files changed, 2297 insertions(+), 1282 deletions(-) create mode 100644 frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts create mode 100644 frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts delete mode 100644 frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx delete mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/ToolPicker.tsx create mode 100644 frontend/editor/src/portal/views/PipelineBuilder.css create mode 100644 frontend/editor/src/portal/views/PipelineBuilder.test.tsx create mode 100644 frontend/editor/src/portal/views/PipelineBuilder.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d4d41a9257..5a27ded6ee 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7237,18 +7237,39 @@ title = "Pipelines" [portal.pipelines.actions] newPipeline = "New pipeline" +[portal.pipelines.builder] +addStep = "Add tool" +back = "Back to pipelines" +discard = "Discard changes" +enabled = "Enabled" +keepEditing = "Keep editing" +needsUpload = "Needs an uploaded file" +noToolMatches = "No tools match your search." +pipelineSettings = "Pipeline settings" +searchTools = "Search tools" +selectToolBody = "Add a tool to build your pipeline." +selectToolTitle = "No tools yet" +toolSettings = "Tool settings" +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" + [portal.pipelines.composer] +addTool = "Add tool" cancel = "Cancel" -chainEmpty = "Add operations from the palette below." +chainEmpty = "Add a tool to start building your pipeline." create = "Create pipeline" directory = "Output folder" directoryHelp = "Absolute path on the server. Must be within the configured allowed folders." -editTitle = "Edit pipeline" +editingUnsupported = "Displaying these tool params for editing is not supported yet." moveDown = "Move down" moveUp = "Move up" name = "Name" namePlaceholder = "e.g. Redaction sweep" noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly." +noToolSettings = "This tool has no configurable settings." operations_one = "Operation ({{count}})" operations_other = "Operations ({{count}})" output = "Output" @@ -7257,8 +7278,6 @@ save = "Save changes" scheduleEvery = "Run every" sources = "Sources" sourcesLoading = "Loading sources..." -subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes." -title = "New pipeline" trigger = "Trigger" triggerManual = "Manual only" @@ -7274,18 +7293,8 @@ confirm = "Delete" title = "Delete pipeline?" [portal.pipelines.detail] -closeAriaLabel = "Close detail" delete = "Delete pipeline" -edit = "Edit" -noSources = "No sources. Files are supplied directly to each run." -noSteps = "No operations configured." -output = "Output" -pause = "Pause" -resume = "Resume" run = "Run now" -sources = "Sources" -steps = "Operations" -subtitle = "{{trigger}} · {{status}}" [portal.pipelines.empty] action = "Create a pipeline" @@ -7307,6 +7316,7 @@ completed_other = "All {{count}} runs completed." empty = "Nothing to run: the sources had no documents to process." failed = "Run failed: {{error}}" running = "Run started; still in progress." +timeout = "Run is taking longer than expected; it may still finish in the background." [portal.pipelines.status] active = "Active" diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts index d93e3953c9..82d45e9e55 100644 --- a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts +++ b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts @@ -71,6 +71,8 @@ export const redactOperationConfig = defineSingleFileTool({ operationType: "redact", endpoint: (parameters: RedactParameters) => parameters.mode === "automatic" ? AUTO_ENDPOINT : null, + // Routing set: `mode` is frontend-only, so a stored step matches by this rather than by replay. + endpoints: [AUTO_ENDPOINT], defaultParameters, }); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts index 6ed02a49ae..ba8c0bc2be 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts @@ -1,9 +1,18 @@ import { describe, expect, test } from "vitest"; import { + isToolEndpoint, objectToFormData, type ToolApiParams, } from "@app/hooks/tools/shared/toolApiMapping"; +describe("isToolEndpoint", () => { + test("accepts a generated endpoint and rejects an unknown path", () => { + expect(isToolEndpoint("/api/v1/misc/compress-pdf")).toBe(true); + expect(isToolEndpoint("/api/v1/misc/not-a-real-tool")).toBe(false); + expect(isToolEndpoint("")).toBe(false); + }); +}); + describe("objectToFormData", () => { test("serializes primitive fields to string form values", () => { const request: ToolApiParams["/api/v1/misc/compress-pdf"] = { diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts index 760df9ce5e..9c6f5d9e4b 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts @@ -1,4 +1,5 @@ import { + TOOL_ENDPOINTS, type ToolApiParams, type ToolApiRequest, type ToolEndpoint, @@ -6,6 +7,16 @@ import { export type { ToolApiParams, ToolApiRequest, ToolEndpoint }; +const TOOL_ENDPOINT_SET: ReadonlySet = new Set(TOOL_ENDPOINTS); + +/** + * Runtime check that a string is one of the generated tool endpoints, so callers can narrow an + * arbitrary endpoint path to {@link ToolEndpoint} against the real supported set rather than casting. + */ +export function isToolEndpoint(value: string): value is ToolEndpoint { + return TOOL_ENDPOINT_SET.has(value); +} + /** * Mapping for tools that take only a file and have no request parameters (their * generated model is `Record`). Both directions are empty; the diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts new file mode 100644 index 0000000000..03b85798d8 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "vitest"; +import { + ToolCategoryId, + SubcategoryId, + type ToolRegistry, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; +import { type ToolId } from "@app/types/toolId"; +import { + asRegistryConfig, + ToolType, +} from "@app/hooks/tools/shared/toolOperationTypes"; +import { + deserializeToolStep, + getExecutableTools, + serializeToolStep, + stepRequiresUpload, + type WorkingToolStep, +} from "@app/hooks/tools/shared/toolAutomation"; +import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; +import { defaultParameters as compressDefaults } from "@app/hooks/tools/compress/useCompressParameters"; +import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; +import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation"; + +function entry(over: Partial): ToolRegistryEntry { + return { + icon: null, + name: "", + component: null, + description: "", + categoryId: ToolCategoryId.RECOMMENDED_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: null, + ...over, + }; +} + +const NoopSettings = () => null; + +// A migrated, param-less config (mappers present, no settings UI) -> "noSettings". +const repairConfig = asRegistryConfig({ + toolType: ToolType.singleFile, + operationType: "repair", + endpoint: "/api/v1/misc/repair", + defaultParameters: {}, + buildFormData: () => new FormData(), + toApiParams: () => ({}), + fromApiParams: () => ({}), +}); + +// A config with no mappers (not migrated) -> "unsupported". +const changeMetadataConfig = asRegistryConfig({ + toolType: ToolType.singleFile, + operationType: "changeMetadata", + endpoint: "/api/v1/misc/update-metadata", + defaultParameters: {}, + buildFormData: () => new FormData(), +}); + +const registry: Partial = { + compress: entry({ + name: "Compress", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(compressOperationConfig), + }), + repair: entry({ name: "Repair", operationConfig: repairConfig }), + changeMetadata: entry({ + name: "Change metadata", + automationSettings: NoopSettings, + operationConfig: changeMetadataConfig, + }), + // Excluded: automation explicitly off. + sign: entry({ + name: "Sign", + supportsAutomate: false, + operationConfig: repairConfig, + }), + // Excluded: no operationConfig at all. + extractPages: entry({ name: "Extract pages" }), +}; + +// Separate registry so these don't change the getExecutableTools expectations above. +const dynamicRegistry: Partial = { + split: entry({ + name: "Split", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(splitOperationConfig), + }), + redact: entry({ + name: "Redact", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(redactOperationConfig), + }), +}; + +describe("getExecutableTools", () => { + test("lists automatable tools with a resolvable endpoint, classified by support", () => { + const tools = getExecutableTools(registry); + expect(tools.map((t) => t.toolId)).toEqual([ + "changeMetadata", + "compress", + "repair", + ]); + expect(Object.fromEntries(tools.map((t) => [t.toolId, t.support]))).toEqual( + { + compress: "editable", + repair: "noSettings", + changeMetadata: "unsupported", + }, + ); + }); +}); + +describe("serialize/deserialize round-trip", () => { + test("compress maps UI params to the backend body and back", () => { + const step: WorkingToolStep = { + toolId: "compress" as ToolId, + operation: "/api/v1/misc/compress-pdf", + params: { + ...compressDefaults, + compressionLevel: 7, + compressionMethod: "filesize", + fileSizeValue: "2", + fileSizeUnit: "MB", + }, + support: "editable", + }; + + const api = serializeToolStep(step, registry); + expect(api.operation).toBe("/api/v1/misc/compress-pdf"); + expect(api.parameters).toMatchObject({ + optimizeLevel: 7, + expectedOutputSize: "2MB", + }); + + const back = deserializeToolStep(api, registry); + expect(back.toolId).toBe("compress"); + expect(back.params).toMatchObject({ + compressionLevel: 7, + compressionMethod: "filesize", + fileSizeValue: "2", + fileSizeUnit: "MB", + }); + }); + + test("an unknown endpoint is preserved as an unmapped step", () => { + const step = deserializeToolStep( + { operation: "/api/v1/unknown/thing", parameters: { keep: true } }, + registry, + ); + expect(step.toolId).toBeNull(); + expect(step.support).toBe("unknown"); + expect(serializeToolStep(step, registry)).toEqual({ + operation: "/api/v1/unknown/thing", + parameters: { keep: true }, + }); + }); + + test("a dynamic-endpoint tool (split by chapters) round-trips as an editable step", () => { + const step: WorkingToolStep = { + toolId: "split" as ToolId, + operation: "/api/v1/general/split-pdf-by-chapters", + params: { method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "2" }, + support: "editable", + }; + + const api = serializeToolStep(step, dynamicRegistry); + expect(api.operation).toBe("/api/v1/general/split-pdf-by-chapters"); + expect(api.parameters).toMatchObject({ bookmarkLevel: 2 }); + + // No `method` in the stored body, so this only matches via the declared endpoint set. + const back = deserializeToolStep(api, dynamicRegistry); + expect(back.toolId).toBe("split"); + expect(back.support).toBe("editable"); + expect(back.operation).toBe("/api/v1/general/split-pdf-by-chapters"); + expect(back.params).toMatchObject({ method: SPLIT_METHODS.BY_CHAPTERS }); + }); + + test("a dynamic-endpoint tool whose routing field is dropped (redact) stays editable", () => { + const step: WorkingToolStep = { + toolId: "redact" as ToolId, + operation: "/api/v1/security/auto-redact", + params: { mode: "automatic", wordsToRedact: ["secret"] }, + support: "editable", + }; + + const api = serializeToolStep(step, dynamicRegistry); + expect(api.operation).toBe("/api/v1/security/auto-redact"); + expect(api.parameters).not.toHaveProperty("mode"); + + // No `mode` in the body, so it only matches via the declared set (replay would yield null). + const back = deserializeToolStep(api, dynamicRegistry); + expect(back.toolId).toBe("redact"); + expect(back.support).toBe("editable"); + expect(back.operation).toBe("/api/v1/security/auto-redact"); + expect(back.params).toMatchObject({ mode: "automatic" }); + }); +}); + +describe("stepRequiresUpload", () => { + const step = (params: Record): WorkingToolStep => ({ + toolId: "compress" as ToolId, + operation: "/api/v1/misc/compress-pdf", + params, + support: "editable", + }); + + test("detects a File (or list of Files) among the parameters", () => { + const image = new File(["x"], "logo.png", { type: "image/png" }); + expect(stepRequiresUpload(step({ level: 5 }))).toBe(false); + expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true); + expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts new file mode 100644 index 0000000000..d567a1dad1 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -0,0 +1,260 @@ +/** + * Registry-level automation logic: which tools can be run as a backend operation step, how much + * of their parameters can be edited in a UI, and conversion between a tool's frontend parameter + * shape and the backend step contract (endpoint + backend parameters). + * + * This is core behaviour shared by every surface that composes or replays tool operations against + * the backend engine (portal pipelines today; backend-executed automations and AI plans later), + * so the "is this tool usable, and how" decision lives with the tools rather than in any one + * feature. It builds on each tool's `operationConfig` mappers (`toApiParams` / `fromApiParams`), + * keeping the frontend<->backend parameter mapping single-sourced in the tools. + */ + +import { type ReactNode } from "react"; +import { + getToolSupportsAutomate, + type SubcategoryId, + type ToolRegistry, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; +import { type ToolId } from "@app/types/toolId"; +import { + isToolEndpoint, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; +import { + type ErasedToolParams, + type RegistryToolOperationConfig, +} from "@app/hooks/tools/shared/toolOperationTypes"; + +/** + * How much of a tool's parameters a UI can edit when composing a backend step: + * - `editable`: has both mappers and a settings UI -> render the settings UI. + * - `noSettings`: migrated to the mapper seam but has no parameters to configure. + * - `unsupported`: not migrated to the mapper seam -> parameters can't be mapped for editing yet; + * the step runs with the backend's defaults. + */ +export type ToolStepSupport = "editable" | "noSettings" | "unsupported"; + +/** A tool that can be added to a backend operation chain, with its editing support. */ +export interface ExecutableTool { + toolId: ToolId; + name: string; + icon: ReactNode; + /** Subcategory the tool belongs to, so a picker can group tools without re-reading the registry. */ + subcategoryId: SubcategoryId; + /** Endpoint resolved from default parameters, for display/inclusion. The stored step's endpoint is re-resolved from the configured parameters at serialization time. */ + endpoint: ToolEndpoint; + support: ToolStepSupport; +} + +/** + * The backend step contract: an endpoint path plus its backend-shaped parameters. `operation` is a + * plain string, not a {@link ToolEndpoint}, because this is the raw backend boundary (it mirrors + * the stored policy step) and a pipeline may reference endpoints the frontend does not model. + */ +export interface ToolApiStep { + operation: string; + parameters: Record; +} + +/** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */ +export interface KnownToolStep { + toolId: ToolId; + operation: ToolEndpoint; + params: ErasedToolParams; + support: ToolStepSupport; +} + +/** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */ +export interface UnknownToolStep { + toolId: null; + operation: string; + params: ErasedToolParams; + support: "unknown"; +} + +/** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */ +export type WorkingToolStep = KnownToolStep | UnknownToolStep; + +/** + * Resolve a tool's endpoint from parameters. Static endpoints ignore params; dynamic ones may + * return undefined if the params don't determine one. The result is validated against the generated + * endpoint set (via {@link isToolEndpoint}) rather than cast, so a config endpoint that is not a + * known {@link ToolEndpoint} (e.g. a custom tool's arbitrary string) resolves to undefined. + */ +function resolveEndpoint( + config: RegistryToolOperationConfig | undefined, + params: ErasedToolParams, +): ToolEndpoint | undefined { + const endpoint = config?.endpoint; + if (typeof endpoint === "string") { + return isToolEndpoint(endpoint) ? endpoint : undefined; + } + if (typeof endpoint === "function") { + const resolved = safeCall(endpoint, params); + return typeof resolved === "string" && isToolEndpoint(resolved) + ? resolved + : undefined; + } + return undefined; +} + +/** Invoke a dynamic-endpoint function defensively; a throw means the params don't determine one. */ +function safeCall( + fn: (params: ErasedToolParams) => string | null | undefined, + params: ErasedToolParams, +): string | null | undefined { + try { + return fn(params); + } catch { + return undefined; + } +} + +/** Classify how much of a tool's parameters a UI can edit when composing a step. */ +export function classifyToolStepSupport( + entry: ToolRegistryEntry, +): ToolStepSupport { + const config = entry.operationConfig; + const hasMappers = Boolean(config?.toApiParams && config?.fromApiParams); + if (!hasMappers) return "unsupported"; + return entry.automationSettings ? "editable" : "noSettings"; +} + +function isFileValue(value: unknown): boolean { + if (typeof File === "undefined") return false; + if (value instanceof File) return true; + return Array.isArray(value) && value.some((item) => item instanceof File); +} + +/** + * True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be + * saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later + * (e.g. scheduled) run would have nothing to send for that named file field. + */ +export function stepRequiresUpload(step: WorkingToolStep): boolean { + return Object.values(step.params).some(isFileValue); +} + +/** + * The tools that can be run as a backend operation step, sorted by name. Includes only automatable + * tools whose endpoint resolves from defaults (so they can become a backend step); this drops + * tools with no operationConfig and tools whose endpoint needs runtime input (e.g. convert). + */ +export function getExecutableTools( + registry: Partial, +): ExecutableTool[] { + const tools: ExecutableTool[] = []; + for (const [id, entry] of Object.entries(registry)) { + if (!entry || !getToolSupportsAutomate(entry)) continue; + const config = entry.operationConfig; + if (!config) continue; + const endpoint = resolveEndpoint(config, config.defaultParameters ?? {}); + if (!endpoint) continue; + tools.push({ + toolId: id as ToolId, + name: entry.name, + icon: entry.icon, + subcategoryId: entry.subcategoryId, + endpoint, + support: classifyToolStepSupport(entry), + }); + } + return tools.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** A fresh working step for a tool just added to a chain, seeded with its default parameters. */ +export function newWorkingToolStep( + tool: ExecutableTool, + registry: Partial, +): KnownToolStep { + const config = registry[tool.toolId]?.operationConfig; + return { + toolId: tool.toolId, + operation: tool.endpoint, + params: { ...(config?.defaultParameters ?? {}) }, + support: tool.support, + }; +} + +/** Serialize a working step into the backend step contract (endpoint + backend parameters). */ +export function serializeToolStep( + step: WorkingToolStep, + registry: Partial, +): ToolApiStep { + const config = + step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined; + if (!config) { + // Unmapped step (unknown endpoint on edit): round-trip it unchanged. + return { operation: step.operation, parameters: step.params }; + } + const merged = { ...(config.defaultParameters ?? {}), ...step.params }; + const operation = resolveEndpoint(config, merged) ?? step.operation; + const parameters = config.toApiParams + ? (config.toApiParams(merged) as Record) + : {}; + return { operation, parameters }; +} + +/** + * Find the registry tool for a stored step's endpoint: exact match for static endpoints, else + * membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a + * frontend-only routing field). Replay is only the fallback when no set is declared. + */ +function findToolByEndpoint( + step: ToolApiStep, + registry: Partial, +): [ToolId, ToolRegistryEntry] | undefined { + let dynamic: [ToolId, ToolRegistryEntry] | undefined; + for (const [id, entry] of Object.entries(registry)) { + const endpoint = entry?.operationConfig?.endpoint; + if (typeof endpoint === "string") { + if (endpoint === step.operation) return [id as ToolId, entry]; + } else if (typeof endpoint === "function" && !dynamic) { + const declared = entry?.operationConfig?.endpoints; + const matched = declared + ? declared.some((e) => e === step.operation) + : safeCall(endpoint, step.parameters) === step.operation; + if (matched) dynamic = [id as ToolId, entry]; + } + } + return dynamic; +} + +/** A stored step kept verbatim because its endpoint maps to no known tool. */ +function unmappedStep(step: ToolApiStep): UnknownToolStep { + return { + toolId: null, + operation: step.operation, + params: { ...step.parameters }, + support: "unknown", + }; +} + +/** + * Rehydrate a stored backend step into a working step for editing: map the endpoint back to a tool + * and its backend parameters back to the frontend shape via `fromApiParams`. Steps whose endpoint + * maps to no known tool are kept as an unmapped, non-editable working step (parameters preserved). + */ +export function deserializeToolStep( + step: ToolApiStep, + registry: Partial, +): WorkingToolStep { + const match = findToolByEndpoint(step, registry); + 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 ?? {}) }; + // Validate against the generated endpoint set instead of casting the matched string. + const operation = + resolveEndpoint(config, params) ?? + (isToolEndpoint(step.operation) ? step.operation : undefined); + if (operation === undefined) return unmappedStep(step); + return { toolId, operation, params, support: classifyToolStepSupport(entry) }; +} diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts index 8a895a78da..321443f463 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -101,9 +101,9 @@ interface BaseToolOperationConfig { consumesAllInputs?: boolean; } -export interface SingleFileToolOperationConfig< +interface SingleFileToolBody< TParams, - TEndpoint extends ToolEndpoint = ToolEndpoint, + TEndpoint extends ToolEndpoint, > extends BaseToolOperationConfig { /** This tool processes one file at a time. */ toolType: ToolType.singleFile; @@ -111,18 +111,19 @@ export interface SingleFileToolOperationConfig< /** Builds FormData for API request. */ buildFormData: (params: TParams, file: File) => FormData; - /** - * API endpoint for the operation, or a function for dynamic routing. `null` - * when the operation has no backend endpoint (see {@link ToolOperationEndpoint}). - */ - endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null); - customProcessor?: undefined; } -export interface MultiFileToolOperationConfig< +/** Single-file tool config; see {@link EndpointBinding} for the endpoint/endpoints rule. */ +export type SingleFileToolOperationConfig< TParams, TEndpoint extends ToolEndpoint = ToolEndpoint, +> = SingleFileToolBody & + EndpointBinding; + +interface MultiFileToolBody< + TParams, + TEndpoint extends ToolEndpoint, > extends BaseToolOperationConfig { /** This tool processes multiple files at once. */ toolType: ToolType.multiFile; @@ -133,15 +134,15 @@ export interface MultiFileToolOperationConfig< /** Builds FormData for API request. */ buildFormData: (params: TParams, files: File[]) => FormData; - /** - * API endpoint for the operation, or a function for dynamic routing. `null` - * when the operation has no backend endpoint (see {@link ToolOperationEndpoint}). - */ - endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null); - customProcessor?: undefined; } +/** Multi-file counterpart of {@link SingleFileToolOperationConfig}. */ +export type MultiFileToolOperationConfig< + TParams, + TEndpoint extends ToolEndpoint = ToolEndpoint, +> = MultiFileToolBody & EndpointBinding; + export interface CustomToolOperationConfig< TParams, > extends BaseToolOperationConfig { @@ -157,6 +158,9 @@ export interface CustomToolOperationConfig< */ endpoint?: string | ((params: TParams) => string | undefined); + /** `never` so `endpoints` stays readable across the union; custom tools declare no set. */ + endpoints?: never; + /** * Custom processing logic that completely bypasses standard file processing. * This tool handles all API calls, response processing, and file creation. @@ -180,6 +184,23 @@ export type ToolOperationConfig< | MultiFileToolOperationConfig | CustomToolOperationConfig; +/** + * A static `endpoint` declares no set; a dynamic (function) `endpoint` must declare its full + * `endpoints` set, which is how findToolByEndpoint maps a stored step back to its tool when the + * endpoint-selecting parameter is frontend-only. + */ +type EndpointBinding = + | { endpoint: TEndpoint | null; endpoints?: never } + | { + endpoint: (params: TParams) => TEndpoint | null; + endpoints: readonly TEndpoint[]; + }; + +/** Union-distributing Omit, so stripping a key from a discriminated config keeps its branches. */ +type DistributiveOmit = T extends unknown + ? Omit + : never; + /** * Define a single-file tool's operation config. Infers the endpoint literal from * `endpoint` and binds toApiParams/fromApiParams to that endpoint's request @@ -189,9 +210,15 @@ export function defineSingleFileTool< TParams, const TEndpoint extends ToolEndpoint, >( - config: Omit, "toolType">, + config: DistributiveOmit< + SingleFileToolOperationConfig, + "toolType" + >, ): SingleFileToolOperationConfig { - return { ...config, toolType: ToolType.singleFile }; + return { + ...config, + toolType: ToolType.singleFile, + } as SingleFileToolOperationConfig; } /** Multi-file counterpart of {@link defineSingleFileTool}. */ @@ -199,9 +226,15 @@ export function defineMultiFileTool< TParams, const TEndpoint extends ToolEndpoint, >( - config: Omit, "toolType">, + config: DistributiveOmit< + MultiFileToolOperationConfig, + "toolType" + >, ): MultiFileToolOperationConfig { - return { ...config, toolType: ToolType.multiFile }; + return { + ...config, + toolType: ToolType.multiFile, + } as MultiFileToolOperationConfig; } /** diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts index fad5b1f04e..af14ca2045 100644 --- a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts +++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts @@ -177,6 +177,8 @@ export const splitOperationConfig = defineSingleFileTool({ fromApiParams: splitFromApiParams, operationType: "split", endpoint: getSplitEndpoint, + // Full routing set: a stored step maps back to Split though its `method` selector is frontend-only. + endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINTS))), defaultParameters, }); diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 62502e6c67..c93d7b28bd 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -3,6 +3,7 @@ import { Home } from "@portal/views/Home"; import { Users } from "@portal/views/Users"; import { Documents } from "@portal/views/Documents"; import { Pipelines } from "@portal/views/Pipelines"; +import { PipelineBuilder } from "@portal/views/PipelineBuilder"; import { Sources } from "@portal/views/Sources"; import { AgentBuilder } from "@portal/views/AgentBuilder"; import { Policies } from "@portal/views/Policies"; @@ -26,6 +27,14 @@ export function ViewRouter() { } /> } /> } /> + } + /> + } + /> } /> - - - + {/* The pipeline builder reads the tool registry to list and configure operations. */} + + + + + diff --git a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx deleted file mode 100644 index fe0546a606..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - ActionIcon, - Banner, - Button, - Checkbox, - Chip, - FormField, - Input, - Modal, - RadioGroup, - Select, -} from "@app/ui"; -import { errorMessage } from "@portal/api/http"; -import { - fetchTriggers, - savePipeline, - type OutputSpec, - type PipelineStep, - type Policy, - type TriggerConfig, - type TriggerInfo, -} from "@portal/api/pipelines"; -import { fetchSources, type SourceView } from "@portal/api/sources"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - PIPELINE_OPERATIONS, - humanizeOperation, -} from "@portal/components/pipelines/pipelineOperations"; -import "@portal/views/Pipelines.css"; - -type OutputMode = "inline" | "folder"; -type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS"; - -const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"]; -/** Empty trigger type = manual-only (no automatic trigger). */ -const MANUAL = ""; - -interface PipelineComposerProps { - open: boolean; - onClose: () => void; - /** Called after a pipeline is created or updated so the page can refetch. */ - onSaved: () => void; - /** When set, the composer edits this existing policy instead of creating one. */ - pipeline?: Policy; -} - -/** - * A policy's trigger parsed into the composer's fields: which trigger type (empty - * = manual), and the schedule interval when it's a schedule trigger. - */ -function parseTrigger(trigger: TriggerConfig | null): { - triggerType: string; - count: string; - unit: ScheduleUnit; -} { - if (!trigger) return { triggerType: MANUAL, count: "1", unit: "HOURS" }; - if (trigger.type === "schedule") { - const schedule = trigger.options?.schedule as - | { type?: string; count?: number; unit?: ScheduleUnit } - | undefined; - if (schedule?.type === "every") { - return { - triggerType: "schedule", - count: String(schedule.count ?? 1), - unit: schedule.unit ?? "HOURS", - }; - } - // Non-interval schedules (daily/weekly/monthly) aren't editable here; show - // the schedule choice with defaults the user can re-set. - return { triggerType: "schedule", count: "1", unit: "HOURS" }; - } - return { triggerType: trigger.type, count: "1", unit: "HOURS" }; -} - -/** Output sink fields parsed from a policy's output for editing. */ -function parseOutput(output: OutputSpec | undefined): { - mode: OutputMode; - directory: string; -} { - if (output?.type === "folder") { - return { - mode: "folder", - directory: String(output.options?.directory ?? ""), - }; - } - return { mode: "inline", directory: "" }; -} - -/** - * Compose a pipeline (a backend policy): name it, pick the sources it pulls from - * and how it's triggered, chain operations, and choose where output goes. On submit - * a blank id creates and a set id updates, matching the backend's POST contract. - * Per-operation parameter editing is out of scope here; operations are chained with - * their defaults. - */ -export function PipelineComposer({ - open, - onClose, - onSaved, - pipeline, -}: PipelineComposerProps) { - const { t } = useTranslation(); - const isEdit = pipeline !== undefined; - - const sourcesState = useAsync( - async () => (open ? (await fetchSources()).sources : []), - [open], - ); - const availableSources = sourcesState.data ?? []; - - // The triggers the backend supports, with their source-type compatibility, so - // the UI offers them (and pairs them with sources) without hard-coding the set. - const triggersState = useAsync( - async () => (open ? await fetchTriggers() : []), - [open], - ); - const triggers = useMemo( - () => triggersState.data ?? [], - [triggersState.data], - ); - - const [name, setName] = useState(""); - const [sourceIds, setSourceIds] = useState([]); - const [steps, setSteps] = useState([]); - const [triggerType, setTriggerType] = useState(MANUAL); - const [scheduleCount, setScheduleCount] = useState("1"); - const [scheduleUnit, setScheduleUnit] = useState("HOURS"); - const [outputMode, setOutputMode] = useState("inline"); - const [outputDirectory, setOutputDirectory] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - // Re-seed the form whenever the composer opens (or its target changes) so editing - // prefills the current config and a reopened create starts clean. - useEffect(() => { - if (!open) return; - const trigger = parseTrigger(pipeline?.trigger ?? null); - const output = parseOutput(pipeline?.output); - setName(pipeline?.name ?? ""); - setSourceIds(pipeline?.sourceIds ?? []); - setSteps(pipeline?.steps ?? []); - setTriggerType(trigger.triggerType); - setScheduleCount(trigger.count); - setScheduleUnit(trigger.unit); - setOutputMode(output.mode); - setOutputDirectory(output.directory); - setSubmitting(false); - setError(null); - }, [open, pipeline]); - - // Types of the currently-selected sources, for trigger compatibility. - const selectedSourceTypes = useMemo( - () => - new Set( - availableSources - .filter((s) => sourceIds.includes(s.id)) - .map((s) => s.type), - ), - [availableSources, sourceIds], - ); - - const triggerAvailable = useMemo( - () => (trigger: TriggerInfo) => - !trigger.requiresSource || - trigger.supportedSourceTypes.some((type) => - selectedSourceTypes.has(type), - ), - [selectedSourceTypes], - ); - - // A source-requiring trigger stops being valid the moment its compatible source - // is deselected; fall back to manual so we never submit an impossible trigger. - useEffect(() => { - if (triggerType === MANUAL) return; - const selected = triggers.find((trigger) => trigger.type === triggerType); - if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL); - }, [triggerType, triggers, triggerAvailable]); - - function toggleSource(id: string, checked: boolean) { - setSourceIds((ids) => - checked ? [...ids, id] : ids.filter((existing) => existing !== id), - ); - } - - function addStep(operation: string, parameters: Record) { - setSteps((current) => [ - ...current, - { operation, parameters: { ...parameters } }, - ]); - } - - function removeStep(index: number) { - setSteps((current) => current.filter((_, i) => i !== index)); - } - - function moveStep(index: number, delta: number) { - setSteps((current) => { - const next = [...current]; - const target = index + delta; - if (target < 0 || target >= next.length) return current; - [next[index], next[target]] = [next[target], next[index]]; - return next; - }); - } - - const scheduleCountValid = - triggerType !== "schedule" || Number(scheduleCount) > 0; - const outputValid = outputMode !== "folder" || outputDirectory.trim() !== ""; - const canSave = - name.trim() !== "" && scheduleCountValid && outputValid && !submitting; - - const triggerOptions = [ - { value: MANUAL, label: t("portal.pipelines.composer.triggerManual") }, - ...triggers.map((trigger) => ({ - value: trigger.type, - label: t(`portal.pipelines.trigger.${trigger.type}`, { - defaultValue: trigger.type, - }), - disabled: !triggerAvailable(trigger), - })), - ]; - - function buildTrigger(): TriggerConfig | null { - if (triggerType === MANUAL) return null; - if (triggerType === "schedule") { - return { - type: "schedule", - options: { - schedule: { - type: "every", - count: Number(scheduleCount), - unit: scheduleUnit, - }, - }, - }; - } - return { type: triggerType, options: {} }; - } - - async function submit() { - if (!canSave) return; - setSubmitting(true); - setError(null); - const output: OutputSpec = - outputMode === "folder" - ? { type: "folder", options: { directory: outputDirectory.trim() } } - : { type: "inline", options: {} }; - const policy: Policy = { - id: pipeline?.id, - name: name.trim(), - enabled: pipeline?.enabled ?? true, - trigger: buildTrigger(), - sourceIds, - steps, - output, - }; - try { - await savePipeline(policy); - onSaved(); - onClose(); - } catch (e) { - setError(errorMessage(e)); - } finally { - setSubmitting(false); - } - } - - return ( - - - - - } - > -
- - setName(e.target.value)} - /> - - - {/* Sources */} -
- - {t("portal.pipelines.composer.sources")} - - {sourcesState.loading ? ( -

- {t("portal.pipelines.composer.sourcesLoading")} -

- ) : availableSources.length === 0 ? ( -

- {t("portal.pipelines.composer.noSources")} -

- ) : ( -
- {availableSources.map((source) => ( - toggleSource(source.id, e.target.checked)} - label={source.name} - /> - ))} -
- )} - - - {t("portal.pipelines.composer.trigger")} - - - name="pipeline-trigger" - value={triggerType} - onChange={setTriggerType} - direction="horizontal" - options={triggerOptions} - /> - {triggerType === "schedule" && ( -
- - {t("portal.pipelines.composer.scheduleEvery")} - - setScheduleCount(e.target.value)} - className="portal-pipelines__schedule-count" - /> - setOutputDirectory(e.target.value)} - /> - - )} -
- - {error && } -
- - ); -} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx deleted file mode 100644 index ea72ddfc53..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { ActionIcon, Banner, Button, Chip } from "@app/ui"; -import { errorMessage } from "@portal/api/http"; -import { - fetchRun, - triggerPipeline, - type PipelineView, - type PolicyRunView, -} from "@portal/api/pipelines"; -import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; -import "@portal/views/Pipelines.css"; - -const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED"]); -const POLL_INTERVAL_MS = 1500; -const POLL_ATTEMPTS = 60; - -type RunResult = { tone: "success" | "danger" | "info"; text: string }; - -interface PipelineDetailCardProps { - pipeline: PipelineView; - onClose: () => void; - onEdit: (pipeline: PipelineView) => void; - onTogglePause: (pipeline: PipelineView) => void; - onDelete: (pipeline: PipelineView) => void; - /** Disables the actions while a page-level mutation is in flight. */ - busy?: boolean; -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** Expanded detail for the selected pipeline row, with run/edit/pause/delete actions. */ -export function PipelineDetailCard({ - pipeline, - onClose, - onEdit, - onTogglePause, - onDelete, - busy = false, -}: PipelineDetailCardProps) { - const { t } = useTranslation(); - const paused = pipeline.status === "paused"; - - const [running, setRunning] = useState(false); - const [runResult, setRunResult] = useState(null); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - - // Poll a run until it reaches a terminal state (or we give up), so a failure - // during execution surfaces with its error message rather than silently. - async function awaitRun(runId: string): Promise { - for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { - if (!mounted.current) return null; - const view = await fetchRun(runId); - if (TERMINAL_STATUSES.has(view.status)) return view; - await sleep(POLL_INTERVAL_MS); - } - return null; - } - - async function handleRun() { - if (running || busy) return; - setRunning(true); - setRunResult(null); - try { - const runIds = await triggerPipeline(pipeline.id); - if (runIds.length === 0) { - if (mounted.current) - setRunResult({ tone: "info", text: t("portal.pipelines.run.empty") }); - return; - } - const finals = await Promise.all(runIds.map((id) => awaitRun(id))); - if (!mounted.current) return; - const failed = finals.find((r) => r?.status === "FAILED"); - if (failed) { - setRunResult({ - tone: "danger", - text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }), - }); - } else if (finals.every((r) => r?.status === "COMPLETED")) { - setRunResult({ - tone: "success", - text: t("portal.pipelines.run.completed", { count: finals.length }), - }); - } else { - // Still running when we stopped polling, or cancelled. - setRunResult({ tone: "info", text: t("portal.pipelines.run.running") }); - } - } catch (e) { - if (mounted.current) - setRunResult({ tone: "danger", text: errorMessage(e) }); - } finally { - if (mounted.current) setRunning(false); - } - } - - return ( -
-
- - ⛓ - -
-

{pipeline.name}

- - {t("portal.pipelines.detail.subtitle", { - trigger: t(`portal.pipelines.trigger.${pipeline.trigger}`, { - defaultValue: pipeline.trigger, - }), - status: t(`portal.pipelines.status.${pipeline.status}`), - })} - -
- - × - -
- -
-
- - {t("portal.pipelines.detail.steps")} - - {pipeline.steps.length === 0 ? ( -

- {t("portal.pipelines.detail.noSteps")} -

- ) : ( -
- {pipeline.steps.map((step, i) => ( - - {`${i + 1}. ${humanizeOperation(step)}`} - - ))} -
- )} -
- -
- - {t("portal.pipelines.detail.sources")} - - {pipeline.sources.length === 0 ? ( -

- {t("portal.pipelines.detail.noSources")} -

- ) : ( -
- {pipeline.sources.map((source) => ( - - {source.name} - - ))} -
- )} -
- -
- - {t("portal.pipelines.detail.output")} - - - {t(`portal.pipelines.output.${pipeline.output}`, { - defaultValue: pipeline.output, - })} - -
-
- - {runResult && ( - - )} - -
- - - - -
-
- ); -} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx new file mode 100644 index 0000000000..fe2ee9dd2a --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx @@ -0,0 +1,59 @@ +import { Suspense } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner } from "@app/ui"; +import { type ToolRegistry } from "@app/data/toolsTaxonomy"; +import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; +import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; + +interface PipelineStepSettingsProps { + step: WorkingToolStep; + registry: Partial; + onChange: (params: ErasedToolParams) => void; +} + +/** + * Renders the parameter editor for one pipeline step, chosen by the tool's capability: + * the tool's own settings UI when editable, an explanatory note when it has no parameters, + * or a "not supported yet" fallback for tools not yet migrated to the mapper seam. + */ +export function PipelineStepSettings({ + step, + registry, + onChange, +}: PipelineStepSettingsProps) { + const { t } = useTranslation(); + + if (step.support === "noSettings") { + return ( + + ); + } + + const entry = step.toolId ? registry[step.toolId] : undefined; + const Settings = + step.support === "editable" ? entry?.automationSettings : null; + + if (!Settings) { + return ( + + ); + } + + return ( + + + onChange({ ...step.params, [key]: value }) + } + disabled={false} + /> + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx index d2fda82fdc..42550e28a4 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded"; import { Chip, StatusBadge, @@ -16,16 +17,11 @@ const STATUS_TONE: Record = { interface PipelinesTableProps { pipelines: PipelineView[]; - /** Id of the row whose detail panel is open, drives the caret state. */ - expandedId: string | null; + /** A row opens that pipeline's own page. */ onRowClick: (pipeline: PipelineView) => void; } -export function PipelinesTable({ - pipelines, - expandedId, - onRowClick, -}: PipelinesTableProps) { +export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) { const { t } = useTranslation(); const columns = useMemo[]>( () => [ @@ -90,24 +86,18 @@ export function PipelinesTable({ ), }, { - key: "expand", + key: "open", header: "", align: "right", width: "2.5rem", - render: (p) => ( - - ▸ + render: () => ( + + ), }, ], - [expandedId, t], + [t], ); return ( diff --git a/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx new file mode 100644 index 0000000000..e031854ec1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx @@ -0,0 +1,99 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { + getSubcategoryLabel, + SUBCATEGORY_ORDER, + type SubcategoryId, +} from "@app/data/toolsTaxonomy"; +import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation"; + +interface ToolPickerProps { + tools: ExecutableTool[]; + onPick: (tool: ExecutableTool) => void; + onClose: () => void; +} + +/** + * Type-to-filter, category-grouped tool picker for adding a step to a pipeline. Replaces the flat + * wall of tool pills so the list stays usable as the tool count grows. + */ +export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + + const groups = useMemo(() => { + const q = query.trim().toLowerCase(); + const matched = q + ? tools.filter((tool) => tool.name.toLowerCase().includes(q)) + : tools; + const byCategory = new Map(); + for (const tool of matched) { + const list = byCategory.get(tool.subcategoryId) ?? []; + list.push(tool); + byCategory.set(tool.subcategoryId, list); + } + return SUBCATEGORY_ORDER.filter((id) => byCategory.has(id)).map((id) => ({ + id, + label: getSubcategoryLabel(t, id), + tools: byCategory.get(id) ?? [], + })); + }, [tools, query, t]); + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") onClose(); + }} + /> +
+
+ {groups.length === 0 ? ( +

+ {t("portal.pipelines.builder.noToolMatches")} +

+ ) : ( + groups.map((group) => ( +
+
+ {group.label} +
+ {group.tools.map((tool) => ( + + ))} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css new file mode 100644 index 0000000000..cef65d6e75 --- /dev/null +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -0,0 +1,394 @@ +.portal-builder { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +.portal-builder__loading { + display: flex; + justify-content: center; + 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(--color-border-light); +} + +.portal-builder__back { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border: none; + background: none; + padding: 0; + font-size: 0.8125rem; + color: var(--color-text-4); + cursor: pointer; + white-space: nowrap; +} + +.portal-builder__back:hover { + color: var(--color-text-1); +} + +.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; + align-items: start; +} + +@media (max-width: 60rem) { + .portal-builder__grid { + grid-template-columns: 1fr; + } +} + +.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(--color-text-5); + font-weight: 600; +} + +.portal-builder__empty { + font-size: 0.8125rem; + color: var(--color-text-4); + 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(--color-surface); + border: 1px solid var(--color-border-light); + 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(--color-blue); + background: var(--color-blue-light); +} + +.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(--color-blue-light); + color: var(--color-blue); +} + +.portal-builder__step--active .portal-builder__step-index { + background: var(--color-blue); + 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(--color-text-1); +} + +.portal-builder__step-note { + font-size: 0.6875rem; + color: var(--color-text-4); +} + +.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(--color-border); + background: var(--color-surface); + color: var(--color-text-3); + cursor: pointer; + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.portal-builder__step-actions button:hover:not(:disabled) { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.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(--color-border); + border-radius: var(--radius-lg); + background: none; + color: var(--color-text-3); + font-size: 0.8125rem; + cursor: pointer; + transition: + border-color var(--motion-fast), + color var(--motion-fast); +} + +.portal-builder__add-step:hover { + border-color: var(--color-blue); + color: var(--color-blue); +} + +/* 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(--color-border-light); + 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; +} + +/* 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; +} + +.portal-builder__inspector { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 1.125rem; +} + +/* Tool picker */ +.portal-pipelines__picker { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + overflow: hidden; +} + +.portal-pipelines__picker-search { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--color-border-light); +} + +.portal-pipelines__picker-search input { + flex: 1; + border: none; + background: none; + padding: 0; + font-size: 0.875rem; + color: var(--color-text-1); + outline: none; +} + +.portal-pipelines__picker-list { + max-height: 20rem; + overflow-y: auto; + padding: 0.25rem 0; +} + +.portal-pipelines__picker-group-label { + padding: 0.5rem 0.75rem 0.25rem; + font-size: 0.6875rem; + color: var(--color-text-5); +} + +.portal-pipelines__picker-item { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + border: none; + background: none; + padding: 0.4375rem 0.75rem; + text-align: left; + cursor: pointer; + color: var(--color-text-1); + transition: background var(--motion-fast); +} + +.portal-pipelines__picker-item:hover { + background: var(--color-bg-hover); +} + +.portal-pipelines__picker-icon { + display: inline-flex; + align-items: center; + color: var(--color-text-3); + font-size: 1.125rem; +} + +.portal-pipelines__picker-name { + font-size: 0.8125rem; +} + +.portal-pipelines__picker-empty { + padding: 1rem 0.75rem; + font-size: 0.8125rem; + color: var(--color-text-4); + 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(--color-text-4); +} + +.portal-builder__back.sui-btn:hover { + color: var(--color-text-1); +} + +.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(--color-border); + background: none; + font-weight: 400; + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-builder__add-step.sui-btn:hover { + border-color: var(--color-blue); + color: var(--color-blue); +} + +.portal-pipelines__picker-item.sui-btn { + height: auto; + min-height: 0; + padding: 0.4375rem 0.75rem; + font-weight: 400; + color: var(--color-text-1); +} + +.portal-pipelines__picker-item.sui-btn:hover { + background: var(--color-bg-hover); +} + +.portal-builder__step-actions .sui-ai { + width: 1.5rem; + height: 1.5rem; + min-width: 1.5rem; + min-height: 1.5rem; +} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx new file mode 100644 index 0000000000..97ee9362d4 --- /dev/null +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { Policy } from "@portal/api/pipelines"; +import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext"; +import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; +import { PipelineBuilder } from "@portal/views/PipelineBuilder"; + +const render = ( + ui: Parameters[0], + options?: Parameters[1], +) => baseRender(ui, { wrapper: MantineProvider, ...options }); + +// Deterministic i18n: keys returned verbatim. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchPipeline = vi.fn(); +const fetchTriggers = vi.fn(); +const savePipeline = vi.fn(); +const deletePipeline = vi.fn(); +const triggerPipeline = vi.fn(); +const fetchRun = vi.fn(); +vi.mock("@portal/api/pipelines", () => ({ + fetchPipeline: (id: string) => fetchPipeline(id), + fetchTriggers: () => fetchTriggers(), + savePipeline: (policy: unknown) => savePipeline(policy), + deletePipeline: (id: string) => deletePipeline(id), + triggerPipeline: (id: string) => triggerPipeline(id), + fetchRun: (runId: string) => fetchRun(runId), +})); + +const fetchSources = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), +})); + +// One editable tool, Compress, so the picker and step settings have something to render. +vi.mock("@app/contexts/ToolRegistryContext", () => { + const compress = { + name: "Compress", + icon: null, + component: null, + description: "", + categoryId: "recommendedTools", + subcategoryId: "general", + automationSettings: (props: { + onParameterChange: (key: string, value: unknown) => void; + }) => ( + + ), + operationConfig: { + operationType: "compress", + toolType: 0, + endpoint: "/api/v1/misc/compress-pdf", + defaultParameters: {}, + buildFormData: () => new FormData(), + toApiParams: (params: Record) => ({ ...params }), + fromApiParams: (params: Record) => ({ ...params }), + }, + } as unknown as ToolRegistryEntry; + const allTools = { compress } as unknown as ToolRegistryCatalog["allTools"]; + const catalog: ToolRegistryCatalog = { + regularTools: allTools, + superTools: allTools, + linkTools: allTools, + allTools, + getToolById: () => null, + }; + return { useToolRegistry: () => catalog }; +}); + +const POLICY: Policy = { + id: "plc-1", + name: "Existing pipeline", + enabled: true, + trigger: null, + sourceIds: [], + steps: [], + output: { type: "inline", options: {} }, +}; + +function renderBuilder(initial: string) { + return render( + + + } /> + } /> + pipelines list
} /> + + , + ); +} + +describe("PipelineBuilder", () => { + beforeEach(() => { + fetchPipeline.mockReset(); + fetchTriggers.mockReset(); + savePipeline.mockReset(); + deletePipeline.mockReset(); + triggerPipeline.mockReset(); + fetchRun.mockReset(); + fetchSources.mockReset(); + fetchPipeline.mockResolvedValue(POLICY); + fetchTriggers.mockResolvedValue([]); + fetchSources.mockResolvedValue({ kpis: [], sources: [] }); + savePipeline.mockResolvedValue({}); + deletePipeline.mockResolvedValue(undefined); + triggerPipeline.mockResolvedValue(["run-1"]); + fetchRun.mockResolvedValue({ status: "COMPLETED" }); + }); + + it("builds a new pipeline: name it, add a tool, and save", async () => { + renderBuilder("/portal/pipelines/new"); + + // The name field is the only textbox before the picker opens. + fireEvent.change(await screen.findByRole("textbox"), { + target: { value: "Nightly compress" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /addTool/ })); + fireEvent.click(await screen.findByText("Compress")); + + fireEvent.click(screen.getByText("portal.pipelines.composer.create")); + + await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Nightly compress", + trigger: null, + steps: [ + expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }), + ], + }), + ); + expect(await screen.findByText("pipelines list")).toBeInTheDocument(); + }); + + it("runs an existing pipeline and reports success", async () => { + renderBuilder("/portal/pipelines/plc-1"); + + fireEvent.click(await screen.findByText("portal.pipelines.detail.run")); + + await waitFor(() => expect(triggerPipeline).toHaveBeenCalledWith("plc-1")); + expect( + await screen.findByText("portal.pipelines.run.completed"), + ).toBeInTheDocument(); + }); + + it("blocks saving a step that needs an uploaded file", async () => { + renderBuilder("/portal/pipelines/new"); + + fireEvent.change(await screen.findByRole("textbox"), { + target: { value: "Watermarked" }, + }); + fireEvent.click(screen.getByRole("button", { name: /addTool/ })); + fireEvent.click(await screen.findByText("Compress")); + // The tool's settings upload a file, which a stored pipeline can't persist yet. + fireEvent.click(await screen.findByText("upload logo")); + + expect( + await screen.findByText("portal.pipelines.builder.uploadUnsupported"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("deletes an existing pipeline after confirmation", async () => { + renderBuilder("/portal/pipelines/plc-1"); + + fireEvent.click(await screen.findByText("portal.pipelines.detail.delete")); + fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm")); + + await waitFor(() => expect(deletePipeline).toHaveBeenCalledWith("plc-1")); + expect(await screen.findByText("pipelines list")).toBeInTheDocument(); + }); + + it("prompts to save or discard when leaving with unsaved edits", async () => { + renderBuilder("/portal/pipelines/new"); + + fireEvent.change(await screen.findByRole("textbox"), { + target: { value: "Draft" }, + }); + fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + + expect( + await screen.findByText("portal.pipelines.builder.unsavedTitle"), + ).toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.pipelines.builder.discard")); + expect(await screen.findByText("pipelines list")).toBeInTheDocument(); + }); + + it("leaves immediately when there are no unsaved edits", async () => { + renderBuilder("/portal/pipelines/new"); + + await screen.findByRole("textbox"); + fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + + expect(await screen.findByText("pipelines list")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx new file mode 100644 index 0000000000..cbd4ba4b45 --- /dev/null +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -0,0 +1,865 @@ +import { useEffect, useMemo, useRef, useState } 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 AddRoundedIcon from "@mui/icons-material/AddRounded"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import { + ActionIcon, + Banner, + Button, + Checkbox, + EmptyState, + FormField, + Input, + Modal, + RadioGroup, + Select, + Spinner, +} from "@app/ui"; +import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; +import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; +import { + deserializeToolStep, + getExecutableTools, + newWorkingToolStep, + serializeToolStep, + stepRequiresUpload, + type ExecutableTool, + type WorkingToolStep, +} from "@app/hooks/tools/shared/toolAutomation"; +import { errorMessage } from "@portal/api/http"; +import { + deletePipeline, + fetchPipeline, + fetchRun, + fetchTriggers, + savePipeline, + triggerPipeline, + type OutputSpec, + type Policy, + type PolicyRunView, + type TriggerConfig, + type TriggerInfo, +} from "@portal/api/pipelines"; +import { fetchSources, type SourceView } from "@portal/api/sources"; +import { useAsync } from "@portal/hooks/useAsync"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; +import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; +import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; +import "@portal/views/PipelineBuilder.css"; + +type OutputMode = "inline" | "folder"; +type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS"; + +const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"]; +/** Empty trigger type = manual-only (no automatic trigger). */ +const MANUAL = ""; + +const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED"]); +const POLL_INTERVAL_MS = 1500; +const POLL_ATTEMPTS = 60; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +type RunResult = { + tone: "success" | "danger" | "info" | "warning"; + text: string; +}; + +function parseTrigger(trigger: TriggerConfig | null): { + triggerType: string; + count: string; + unit: ScheduleUnit; +} { + if (!trigger) return { triggerType: MANUAL, count: "1", unit: "HOURS" }; + if (trigger.type === "schedule") { + const schedule = trigger.options?.schedule as + | { type?: string; count?: number; unit?: ScheduleUnit } + | undefined; + if (schedule?.type === "every") { + return { + triggerType: "schedule", + count: String(schedule.count ?? 1), + unit: schedule.unit ?? "HOURS", + }; + } + return { triggerType: "schedule", count: "1", unit: "HOURS" }; + } + return { triggerType: trigger.type, count: "1", unit: "HOURS" }; +} + +function parseOutput(output: OutputSpec | undefined): { + mode: OutputMode; + directory: string; +} { + if (output?.type === "folder") { + return { + mode: "folder", + directory: String(output.options?.directory ?? ""), + }; + } + return { mode: "inline", directory: "" }; +} + +/** + * 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 + * tool's settings sit side by side below. For an existing pipeline the header also runs and + * deletes it. Replaces the former modal composer and the list's inline detail card. + */ +export function PipelineBuilder() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams(); + const isEdit = Boolean(id); + const { allTools } = useToolRegistry(); + const executableTools = useMemo( + () => getExecutableTools(allTools), + [allTools], + ); + + const policyState = useAsync( + async () => (id ? await fetchPipeline(id) : null), + [id], + ); + const sourcesState = useAsync( + async () => (await fetchSources()).sources, + [], + ); + const triggersState = useAsync( + async () => await fetchTriggers(), + [], + ); + const availableSources = sourcesState.data ?? []; + const triggers = useMemo( + () => triggersState.data ?? [], + [triggersState.data], + ); + + const [name, setName] = useState(""); + const [enabled, setEnabled] = useState(true); + const [sourceIds, setSourceIds] = useState([]); + const [steps, setSteps] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [triggerType, setTriggerType] = useState(MANUAL); + const [scheduleCount, setScheduleCount] = useState("1"); + const [scheduleUnit, setScheduleUnit] = useState("HOURS"); + const [outputMode, setOutputMode] = useState("inline"); + const [outputDirectory, setOutputDirectory] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [seeded, setSeeded] = useState(false); + const [running, setRunning] = useState(false); + const [runResult, setRunResult] = useState(null); + const [pendingDelete, setPendingDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [pendingNav, setPendingNav] = useState(null); + + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + // Seed the form once: immediately for a new pipeline, or after the policy loads for an edit. + useEffect(() => { + if (seeded) return; + if (isEdit && !policyState.data) return; + const policy = policyState.data ?? undefined; + const trigger = parseTrigger(policy?.trigger ?? null); + const output = parseOutput(policy?.output); + setName(policy?.name ?? ""); + setEnabled(policy?.enabled ?? true); + setSourceIds(policy?.sourceIds ?? []); + setSteps( + (policy?.steps ?? []).map((step) => deserializeToolStep(step, allTools)), + ); + setTriggerType(trigger.triggerType); + setScheduleCount(trigger.count); + setScheduleUnit(trigger.unit); + setOutputMode(output.mode); + setOutputDirectory(output.directory); + 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 selectedSourceTypes = useMemo( + () => + new Set( + availableSources + .filter((s) => sourceIds.includes(s.id)) + .map((s) => s.type), + ), + [availableSources, sourceIds], + ); + + const triggerAvailable = useMemo( + () => (trigger: TriggerInfo) => + !trigger.requiresSource || + trigger.supportedSourceTypes.some((type) => + selectedSourceTypes.has(type), + ), + [selectedSourceTypes], + ); + + useEffect(() => { + if (triggerType === MANUAL) return; + const selected = triggers.find((trigger) => trigger.type === triggerType); + if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL); + }, [triggerType, triggers, triggerAvailable]); + + function toggleSource(sourceId: string, checked: boolean) { + setSourceIds((ids) => + checked + ? [...ids, sourceId] + : ids.filter((existing) => existing !== sourceId), + ); + } + + function addStep(tool: ExecutableTool) { + setSteps((current) => { + const next = [...current, newWorkingToolStep(tool, allTools)]; + setSelectedIndex(next.length - 1); + return next; + }); + setPickerOpen(false); + } + + function removeStep(index: number) { + setSelectedIndex(null); + setSteps((current) => current.filter((_, i) => i !== index)); + } + + 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)); + } + + function updateStepParams(index: number, params: ErasedToolParams) { + setSteps((current) => + current.map((step, i) => + i === index && step.toolId !== null ? { ...step, params } : step, + ), + ); + } + + function stepLabel(step: WorkingToolStep): string { + const entry = step.toolId ? allTools[step.toolId] : undefined; + return entry?.name ?? humanizeOperation(step.operation); + } + + // 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); + const hasUploadSteps = uploadStepLabels.length > 0; + + // Track unsaved edits: snapshot the form and compare against the state captured just after + // seeding, so leaving the builder can prompt to save or discard. + const snapshot = JSON.stringify({ + name: name.trim(), + enabled, + sourceIds: [...sourceIds].sort(), + steps: steps.map((step) => serializeToolStep(step, allTools)), + uploads: steps.map(stepRequiresUpload), + triggerType, + scheduleCount, + scheduleUnit, + outputMode, + outputDirectory, + }); + const baseline = useRef(null); + useEffect(() => { + if (seeded && baseline.current === null) baseline.current = snapshot; + }, [seeded, snapshot]); + const dirty = baseline.current !== null && baseline.current !== snapshot; + + const scheduleCountValid = + triggerType !== "schedule" || Number(scheduleCount) > 0; + const outputValid = outputMode !== "folder" || outputDirectory.trim() !== ""; + const canSave = + name.trim() !== "" && + scheduleCountValid && + outputValid && + !hasUploadSteps && + !submitting; + + const triggerOptions = [ + { value: MANUAL, label: t("portal.pipelines.composer.triggerManual") }, + ...triggers.map((trigger) => ({ + value: trigger.type, + label: t(`portal.pipelines.trigger.${trigger.type}`, { + defaultValue: trigger.type, + }), + disabled: !triggerAvailable(trigger), + })), + ]; + + function buildTrigger(): TriggerConfig | null { + if (triggerType === MANUAL) return null; + if (triggerType === "schedule") { + return { + type: "schedule", + options: { + schedule: { + type: "every", + count: Number(scheduleCount), + unit: scheduleUnit, + }, + }, + }; + } + return { type: triggerType, options: {} }; + } + + const listPath = toPortalPath(VIEW_PATHS.pipelines); + const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}?new=1`; + + function close() { + navigate(listPath); + } + + // Leave the builder, but prompt first if there are unsaved edits (see the unsaved-changes modal). + function attemptLeave(destination: string) { + if (dirty) setPendingNav(destination); + else navigate(destination); + } + + // Jump to the Sources page with its create wizard open, for when the source you want to run + // this pipeline over doesn't exist yet. + function goToSources() { + attemptLeave(sourcesPath); + } + + async function save(destination: string) { + if (!canSave) return; + setSubmitting(true); + setError(null); + const output: OutputSpec = + outputMode === "folder" + ? { type: "folder", options: { directory: outputDirectory.trim() } } + : { type: "inline", options: {} }; + const policy: Policy = { + id: policyState.data?.id ?? undefined, + name: name.trim(), + enabled, + trigger: buildTrigger(), + sourceIds, + steps: steps.map((step) => serializeToolStep(step, allTools)), + output, + }; + try { + await savePipeline(policy); + navigate(destination); + } catch (e) { + setError(errorMessage(e)); + setSubmitting(false); + } + } + + // Poll a run until it reaches a terminal state (or we give up), so a failure surfaces. + async function awaitRun(runId: string): Promise { + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + if (!mounted.current) return null; + const view = await fetchRun(runId); + if (TERMINAL_STATUSES.has(view.status)) return view; + await sleep(POLL_INTERVAL_MS); + } + return null; + } + + async function handleRun() { + if (running || !id) return; + setRunning(true); + setRunResult(null); + try { + const runIds = await triggerPipeline(id); + if (runIds.length === 0) { + if (mounted.current) + setRunResult({ tone: "info", text: t("portal.pipelines.run.empty") }); + return; + } + const finals = await Promise.all(runIds.map((runId) => awaitRun(runId))); + if (!mounted.current) return; + const failed = finals.find((r) => r?.status === "FAILED"); + if (failed) { + setRunResult({ + tone: "danger", + text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }), + }); + } else if (finals.some((r) => r === null)) { + // Gave up polling before a terminal status; the run may still finish server-side. + setRunResult({ + tone: "warning", + text: t("portal.pipelines.run.timeout"), + }); + } else if (finals.every((r) => r?.status === "COMPLETED")) { + setRunResult({ + tone: "success", + text: t("portal.pipelines.run.completed", { count: finals.length }), + }); + } else { + setRunResult({ tone: "info", text: t("portal.pipelines.run.running") }); + } + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } finally { + if (mounted.current) setRunning(false); + } + } + + async function confirmDelete() { + if (!id || deleting) return; + setDeleting(true); + try { + await deletePipeline(id); + close(); + } catch (e) { + setError(errorMessage(e)); + setDeleting(false); + setPendingDelete(false); + } + } + + if (isEdit && !seeded) { + return ( +
+ +
+ ); + } + + const selectedStep = + selectedIndex !== null ? (steps[selectedIndex] ?? null) : null; + + return ( +
+
+ +
+ setName(e.target.value)} + /> +
+
+ setEnabled(e.target.checked)} + label={t("portal.pipelines.builder.enabled")} + /> + {isEdit && ( + <> + + + + )} + + +
+
+ + {error && } + {runResult && ( + + )} + {hasUploadSteps && ( + + )} + + {/* Pipeline-level settings, above the operation list. */} +
+
+ {t("portal.pipelines.builder.pipelineSettings")} +
+
+
+ + {t("portal.pipelines.composer.sources")} + + {sourcesState.loading ? ( +

+ {t("portal.pipelines.composer.sourcesLoading")} +

+ ) : availableSources.length === 0 ? ( +

+ {t("portal.pipelines.composer.noSources")} +

+ ) : ( +
+ {availableSources.map((source) => ( + toggleSource(source.id, e.target.checked)} + label={source.name} + /> + ))} +
+ )} + +
+ +
+ + {t("portal.pipelines.composer.trigger")} + + + name="pipeline-trigger" + value={triggerType} + onChange={setTriggerType} + options={triggerOptions} + /> + {triggerType === "schedule" && ( +
+ + {t("portal.pipelines.composer.scheduleEvery")} + + setScheduleCount(e.target.value)} + className="portal-pipelines__schedule-count" + /> + setOutputDirectory(e.target.value)} + /> + + )} +
+
+
+ +
+
+
+ {t("portal.pipelines.composer.operations", { count: steps.length })} +
+ + {steps.length === 0 && !pickerOpen && ( +

+ {t("portal.pipelines.composer.chainEmpty")} +

+ )} + +
    + {steps.map((step, i) => ( +
  1. +
    + +
    + moveStep(i, -1)} + > + + + moveStep(i, 1)} + > + + + removeStep(i)} + > + + +
    +
    +
  2. + ))} +
+ + {pickerOpen ? ( + setPickerOpen(false)} + /> + ) : ( + + )} +
+ + +
+ + !deleting && setPendingDelete(false)} + width="sm" + title={t("portal.pipelines.delete.title")} + footer={ +
+ + +
+ } + > +

{t("portal.pipelines.delete.body", { name: name || "" })}

+
+ + !submitting && setPendingNav(null)} + width="sm" + title={t("portal.pipelines.builder.unsavedTitle")} + footer={ +
+ +
+ + +
+
+ } + > +

{t("portal.pipelines.builder.unsavedBody")}

+
+
+ ); +} diff --git a/frontend/editor/src/portal/views/Pipelines.css b/frontend/editor/src/portal/views/Pipelines.css index c61f6bcafe..7cd241074d 100644 --- a/frontend/editor/src/portal/views/Pipelines.css +++ b/frontend/editor/src/portal/views/Pipelines.css @@ -81,104 +81,6 @@ color: var(--color-blue); } -/* Expanded detail panel */ -.portal-pipelines__expanded { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - padding: 1.25rem; - animation: portal-pipelines-reveal var(--motion-fast) ease-out; -} - -@keyframes portal-pipelines-reveal { - from { - opacity: 0; - transform: translateY(-0.25rem); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.portal-pipelines__expanded-head { - display: flex; - align-items: center; - gap: 0.75rem; - padding-bottom: 0.875rem; - margin-bottom: 1rem; - border-bottom: 1px solid var(--color-border-light); -} - -.portal-pipelines__expanded-title { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-pipelines__expanded-sub { - font-size: 0.75rem; - color: var(--color-text-4); -} - -.portal-pipelines__expanded-close { - margin-left: auto; - width: 1.75rem; - height: 1.75rem; - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-4); - font-size: 1.125rem; - line-height: 1; - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.portal-pipelines__expanded-close:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); -} - -/* Detail body */ -.portal-pipelines__detail { - display: flex; - flex-direction: column; - gap: 1.125rem; -} - -.portal-pipelines__detail-section { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.portal-pipelines__detail-heading { - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--color-text-5); - font-weight: 600; -} - -.portal-pipelines__chips { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -.portal-pipelines__detail-actions { - display: flex; - gap: 0.5rem; - padding-top: 0.875rem; - margin-top: 1rem; - border-top: 1px solid var(--color-border-light); -} - /* Table skeleton */ .portal-pipelines__table-skeleton { display: flex; @@ -186,20 +88,13 @@ gap: 0.5rem; } -/* ──────────────────────────────────────────────────────────────────────── */ -/* Composer */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-pipelines__composer { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.portal-pipelines__composer-section { - display: flex; - flex-direction: column; - gap: 0.5rem; +/* Shared with the pipeline builder page. */ +.portal-pipelines__detail-heading { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); + font-weight: 600; } .portal-pipelines__composer-footer { @@ -221,83 +116,6 @@ border-radius: var(--radius-md); } -/* Operation chain */ -.portal-pipelines__chain { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.portal-pipelines__chain-row { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.4375rem 0.625rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-md); -} - -.portal-pipelines__chain-index { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - border-radius: 50%; - font-size: 0.6875rem; - font-weight: 600; - background: var(--color-blue-light); - color: var(--color-blue); -} - -.portal-pipelines__chain-op { - flex: 1; - font-size: 0.8125rem; - color: var(--color-text-1); -} - -.portal-pipelines__chain-actions { - display: flex; - gap: 0.25rem; -} - -.portal-pipelines__chain-actions button { - width: 1.5rem; - height: 1.5rem; - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-3); - font-size: 0.8125rem; - line-height: 1; - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.portal-pipelines__chain-actions button:hover:not(:disabled) { - background: var(--color-bg-hover); - color: var(--color-text-1); -} - -.portal-pipelines__chain-actions button:disabled { - opacity: 0.4; - cursor: default; -} - -.portal-pipelines__op-palette { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -/* Schedule row */ .portal-pipelines__schedule { display: flex; align-items: center; diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index ece7a2d87e..5bc20616bb 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -3,22 +3,18 @@ import { fireEvent, render as baseRender, screen, - waitFor, } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; -import { MemoryRouter } from "react-router-dom"; -import { HttpError } from "@portal/api/http"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { PipelinesOverviewResponse } from "@portal/api/pipelines"; +import { Pipelines } from "@portal/views/Pipelines"; const render = ( ui: Parameters[0], options?: Parameters[1], ) => baseRender(ui, { wrapper: MantineProvider, ...options }); -import type { PipelinesOverviewResponse, Policy } from "@portal/api/pipelines"; -import type { SourcesResponse } from "@portal/api/sources"; -import { Pipelines } from "@portal/views/Pipelines"; -// Deterministic i18n: keys returned verbatim, so assertions are stable without -// the async TOML backend. +// Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -27,25 +23,8 @@ vi.mock("react-i18next", () => ({ })); const fetchPipelines = vi.fn(); -const fetchPipeline = vi.fn(); -const savePipeline = vi.fn(); -const deletePipeline = vi.fn(); -const fetchTriggers = vi.fn(); -const triggerPipeline = vi.fn(); -const fetchRun = vi.fn(); vi.mock("@portal/api/pipelines", () => ({ fetchPipelines: () => fetchPipelines(), - fetchPipeline: (id: string) => fetchPipeline(id), - savePipeline: (policy: unknown) => savePipeline(policy), - deletePipeline: (id: string) => deletePipeline(id), - fetchTriggers: () => fetchTriggers(), - triggerPipeline: (id: string) => triggerPipeline(id), - fetchRun: (runId: string) => fetchRun(runId), -})); - -const fetchSources = vi.fn(); -vi.mock("@portal/api/sources", () => ({ - fetchSources: () => fetchSources(), })); const RESPONSE: PipelinesOverviewResponse = { @@ -66,55 +45,20 @@ const RESPONSE: PipelinesOverviewResponse = { output: "inline", owner: "security@acme.com", }, - { - id: "plc-archive", - name: "Archive compressor", - enabled: true, - status: "active", - trigger: "manual", - sources: [], - steps: ["/api/v1/misc/compress-pdf"], - output: "folder", - owner: "data@acme.com", - }, ], }; -const RAW_REDACTION: Policy = { - id: "plc-redaction", - name: "Redaction sweep", - enabled: true, - trigger: { - type: "schedule", - options: { schedule: { type: "every", count: 6, unit: "HOURS" } }, - }, - sourceIds: ["src-claims"], - steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }], - output: { type: "inline", options: {} }, -}; - -const SOURCES: SourcesResponse = { - kpis: [], - sources: [ - { - id: "src-claims", - name: "Claims intake", - type: "folder", - status: "active", - referenceCount: 1, - referencingPolicies: [], - config: [], - docsTotal: 0, - docs24h: 0, - docs30d: 0, - }, - ], -}; - -function renderView() { +function renderView(initial = "/portal/pipelines") { return render( - - + + + } /> + builder new} /> + pipeline page} + /> + , ); } @@ -122,139 +66,19 @@ function renderView() { describe("Pipelines view", () => { beforeEach(() => { fetchPipelines.mockReset(); - fetchPipeline.mockReset(); - savePipeline.mockReset(); - deletePipeline.mockReset(); - fetchSources.mockReset(); - fetchTriggers.mockReset(); - triggerPipeline.mockReset(); - fetchRun.mockReset(); - // The composer loads the trigger registry on open; default to none. - fetchTriggers.mockResolvedValue([]); + fetchPipelines.mockResolvedValue(RESPONSE); }); - it("surfaces the inline error message when a delete fails", async () => { - fetchPipelines.mockResolvedValue(RESPONSE); - deletePipeline.mockRejectedValue( - new HttpError(500, "Server Error", { - detail: "Could not delete pipeline", - }), - ); - + it("opens the builder when creating a pipeline", async () => { renderView(); - - fireEvent.click(await screen.findByText("Redaction sweep")); - fireEvent.click(await screen.findByText("portal.pipelines.detail.delete")); - fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm")); - - await waitFor(() => { - expect(deletePipeline).toHaveBeenCalledWith("plc-redaction"); - }); - expect( - await screen.findByText("Could not delete pipeline"), - ).toBeInTheDocument(); - }); - - it("pauses a pipeline by re-saving it with enabled flipped off", async () => { - fetchPipelines.mockResolvedValue(RESPONSE); - fetchPipeline.mockResolvedValue(RAW_REDACTION); - savePipeline.mockResolvedValue({}); - - renderView(); - - fireEvent.click(await screen.findByText("Redaction sweep")); - fireEvent.click(await screen.findByText("portal.pipelines.detail.pause")); - - await waitFor(() => { - expect(savePipeline).toHaveBeenCalledTimes(1); - }); - expect(fetchPipeline).toHaveBeenCalledWith("plc-redaction"); - expect(savePipeline).toHaveBeenCalledWith( - expect.objectContaining({ id: "plc-redaction", enabled: false }), - ); - }); - - it("runs a pipeline now and reports success inline", async () => { - fetchPipelines.mockResolvedValue(RESPONSE); - triggerPipeline.mockResolvedValue(["run-1"]); - fetchRun.mockResolvedValue({ - runId: "run-1", - policyId: "plc-redaction", - status: "COMPLETED", - currentStep: 1, - stepCount: 1, - error: null, - errorCode: null, - createdAt: 0, - }); - - renderView(); - - fireEvent.click(await screen.findByText("Redaction sweep")); - fireEvent.click(await screen.findByText("portal.pipelines.detail.run")); - - await waitFor(() => { - expect(triggerPipeline).toHaveBeenCalledWith("plc-redaction"); - }); - expect( - await screen.findByText("portal.pipelines.run.completed"), - ).toBeInTheDocument(); - }); - - it("surfaces an execution failure from a manual run", async () => { - fetchPipelines.mockResolvedValue(RESPONSE); - triggerPipeline.mockResolvedValue(["run-1"]); - fetchRun.mockResolvedValue({ - runId: "run-1", - policyId: "plc-redaction", - status: "FAILED", - currentStep: 1, - stepCount: 1, - error: "step 1 blew up", - errorCode: null, - createdAt: 0, - }); - - renderView(); - - fireEvent.click(await screen.findByText("Redaction sweep")); - fireEvent.click(await screen.findByText("portal.pipelines.detail.run")); - - expect( - await screen.findByText("portal.pipelines.run.failed"), - ).toBeInTheDocument(); - }); - - it("creates a pipeline with the chosen name and chained operation", async () => { - fetchPipelines.mockResolvedValue(RESPONSE); - fetchSources.mockResolvedValue(SOURCES); - savePipeline.mockResolvedValue({}); - - renderView(); - - // Wait for the table so the initial fetch has settled, then open the composer. await screen.findByText("Redaction sweep"); fireEvent.click(screen.getByText("portal.pipelines.actions.newPipeline")); + expect(await screen.findByText("builder new")).toBeInTheDocument(); + }); - fireEvent.change(await screen.findByRole("textbox"), { - target: { value: "Nightly compress" }, - }); - // Operation palette chip labels are derived from the endpoint path. - fireEvent.click(await screen.findByText("+ Compress")); - fireEvent.click(screen.getByText("portal.pipelines.composer.create")); - - await waitFor(() => { - expect(savePipeline).toHaveBeenCalledTimes(1); - }); - expect(savePipeline).toHaveBeenCalledWith( - expect.objectContaining({ - name: "Nightly compress", - trigger: null, - output: expect.objectContaining({ type: "inline" }), - steps: [ - expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }), - ], - }), - ); + it("opens a pipeline's own page when its row is clicked", async () => { + renderView(); + fireEvent.click(await screen.findByText("Redaction sweep")); + expect(await screen.findByText("pipeline page")).toBeInTheDocument(); }); }); diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index 8781778ced..0122de5bbc 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -1,107 +1,32 @@ -import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui"; +import { useNavigate } from "react-router-dom"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button, EmptyState, Skeleton } from "@app/ui"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { errorMessage } from "@portal/api/http"; import { - deletePipeline, - fetchPipeline, fetchPipelines, - savePipeline, type PipelinesOverviewResponse, type PipelineView, - type Policy, } from "@portal/api/pipelines"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; -import { PipelineDetailCard } from "@portal/components/pipelines/PipelineDetailCard"; -import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer"; import "@portal/views/Pipelines.css"; export function Pipelines() { const { t } = useTranslation(); - // Refetch after every mutation by bumping this counter, so the table reflects - // the backend (mirrors the Sources view). - const [version, setVersion] = useState(0); - const state = useAsync( - () => fetchPipelines(), - [version], - ); + const navigate = useNavigate(); + const state = useAsync(() => fetchPipelines(), []); const { data, loading } = state; const { isLoading, isEmpty } = useSectionFlags(state); - const refetch = useCallback(() => setVersion((v) => v + 1), []); - - const [expandedId, setExpandedId] = useState(null); - const [composerOpen, setComposerOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [mutating, setMutating] = useState(false); - const [pageError, setPageError] = useState(null); - const [pendingDelete, setPendingDelete] = useState(null); - const [deleting, setDeleting] = useState(false); - const [deleteError, setDeleteError] = useState(null); const pipelines = data?.pipelines ?? []; - const expanded = pipelines.find((p) => p.id === expandedId) ?? null; - function openCreate() { - setEditing(null); - setComposerOpen(true); - } - - // Editing needs the raw policy (steps, trigger, source ids), which the overview - // rows don't carry, so fetch it before opening the composer prefilled. - async function openEdit(pipeline: PipelineView) { - if (mutating) return; - setPageError(null); - setMutating(true); - try { - setEditing(await fetchPipeline(pipeline.id)); - setComposerOpen(true); - } catch (e) { - setPageError(errorMessage(e)); - } finally { - setMutating(false); - } - } - - // Pause/resume: re-save the policy with enabled flipped (the backend has no - // dedicated endpoint; every mutation routes through POST /policies). Fetch the - // raw record first so the full config round-trips intact. - async function togglePause(pipeline: PipelineView) { - if (mutating) return; - setPageError(null); - setMutating(true); - try { - const raw = await fetchPipeline(pipeline.id); - await savePipeline({ ...raw, enabled: !raw.enabled }); - refetch(); - } catch (e) { - setPageError(errorMessage(e)); - } finally { - setMutating(false); - } - } - - function requestDelete(pipeline: PipelineView) { - setDeleteError(null); - setPendingDelete(pipeline); - } - - async function confirmDelete() { - if (!pendingDelete || deleting) return; - setDeleting(true); - setDeleteError(null); - try { - await deletePipeline(pendingDelete.id); - setPendingDelete(null); - setExpandedId(null); - refetch(); - } catch (e) { - setDeleteError(errorMessage(e)); - } finally { - setDeleting(false); - } - } + const openCreate = () => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); + // A row opens that pipeline's own page (view / edit / run / delete live there). + const openPipeline = (pipeline: PipelineView) => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); return (
@@ -114,13 +39,14 @@ export function Pipelines() { {t("portal.pipelines.subtitle")}

- - {pageError && } - {isLoading && ( @@ -144,66 +70,8 @@ export function Pipelines() { )} {!isLoading && !isEmpty && pipelines.length > 0 && ( - - setExpandedId((cur) => (cur === p.id ? null : p.id)) - } - /> + )} - - {expanded && ( - setExpandedId(null)} - onEdit={openEdit} - onTogglePause={togglePause} - onDelete={requestDelete} - busy={mutating} - /> - )} - - setComposerOpen(false)} - onSaved={refetch} - /> - - !deleting && setPendingDelete(null)} - width="sm" - title={t("portal.pipelines.delete.title")} - footer={ -
- - -
- } - > -

- {t("portal.pipelines.delete.body", { - name: pendingDelete?.name ?? "", - })} -

- {deleteError && } -
); } diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index 91191805ab..a7dd47136a 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,4 +1,5 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; @@ -24,6 +25,7 @@ import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); const { setActiveView } = useView(); + const [searchParams, setSearchParams] = useSearchParams(); // Refetch after every mutation by bumping this counter, so the table reflects // the in-memory store the handlers maintain (mirrors the Policies view). const [version, setVersion] = useState(0); @@ -64,6 +66,17 @@ export function Sources() { setWizardOpen(true); } + // Arriving with ?new (e.g. from the pipeline builder's "connect a source" link) opens the + // create wizard straight away, then strips the flag so a refresh doesn't reopen it. + useEffect(() => { + if (searchParams.get("new") === null) return; + setEditingSource(null); + setWizardOpen(true); + const next = new URLSearchParams(searchParams); + next.delete("new"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + // Editing needs the raw source (config options), which the overview rows don't // carry, so fetch it before opening the wizard prefilled. async function openEdit(source: SourceView) {