diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index fcc51d918e..bb038f843b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -390,6 +390,7 @@ public class PolicyController { owner, policy.enabled(), policy.required(), + policy.icon(), policy.inputs(), policy.steps(), policy.output(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index ba3cab596c..bc49e1fb4f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -21,6 +21,7 @@ public record Policy( String owner, boolean enabled, boolean required, + String icon, List inputs, List steps, OutputSpec output, @@ -28,6 +29,7 @@ public record Policy( Long teamId) { public Policy { + icon = icon == null ? "" : icon; inputs = inputs == null ? List.of() : List.copyOf(inputs); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; @@ -35,9 +37,9 @@ public record Policy( } /** - * Without the {@code required} flag: defaults to not org-required. Kept for the many callers - * and tests written before {@code required} existed; the frontend and stores that care about it - * use the full constructor. + * Without the {@code required} flag or an {@code icon}: defaults to not org-required and no + * icon. Kept for the many callers and tests written before those existed; the frontend and + * stores that care use the full constructor. */ public Policy( String id, @@ -49,7 +51,7 @@ public record Policy( OutputSpec output, List outputIds, Long teamId) { - this(id, name, owner, enabled, false, inputs, steps, output, outputIds, teamId); + this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId); } /** @@ -102,19 +104,31 @@ public record Policy( /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ public Policy withOutput(OutputSpec resolved) { return new Policy( - id, name, owner, enabled, required, inputs, steps, resolved, outputIds, teamId); + id, name, owner, enabled, required, icon, inputs, steps, resolved, outputIds, + teamId); } /** A copy under a different owner (e.g. moving a seed off a placeholder name). */ public Policy withOwner(String newOwner) { return new Policy( - id, name, newOwner, enabled, required, inputs, steps, output, outputIds, teamId); + id, name, newOwner, enabled, required, icon, inputs, steps, output, outputIds, + teamId); } /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { return new Policy( - id, name, owner, enabled, required, inputs, steps, output, newOutputIds, teamId); + id, + name, + owner, + enabled, + required, + icon, + inputs, + steps, + output, + newOutputIds, + teamId); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index 2ebcefee2b..27f26d8e5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -72,6 +72,7 @@ public class PolicyOverviewService { policy.name(), policy.enabled(), policy.required(), + iconKey(policy), policy.enabled() ? "active" : "paused", triggerSummary(policy), sources, @@ -95,6 +96,25 @@ public class PolicyOverviewService { return outputSummary(policy.output()); } + /** + * The list-row icon key. The policy's first-class {@code icon} wins; otherwise a + * template-derived policy falls back to its {@code categoryId} (the template-identity marker + * the frontend maps to the category glyph). Empty when neither is set, so the frontend shows + * its default. + */ + private static String iconKey(Policy policy) { + if (!policy.icon().isBlank()) { + return policy.icon(); + } + OutputSpec output = policy.output(); + if (output != null + && output.options().get("categoryId") instanceof String category + && !category.isBlank()) { + return category; + } + return ""; + } + /** * Summarise a policy's triggers for the overview row: "manual" when no input is triggered, * otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule"). diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java index 6732e1d3c4..51b604f8fb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java @@ -14,6 +14,7 @@ public record PolicyView( String name, boolean enabled, boolean required, + String icon, String status, String trigger, List sources, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 4605352328..12b5d1d5d6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -35,6 +35,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.owner(), policy.enabled(), policy.required(), + policy.icon(), policy.inputs(), policy.steps(), policy.output(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 017fa02cc1..8e8cb4c2a5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; +import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.node.ArrayNode; @@ -45,6 +46,7 @@ public class JpaPolicyStore implements PolicyStore { policy.owner(), policy.enabled(), policy.required(), + policy.icon(), policy.inputs(), policy.steps(), policy.output(), @@ -150,7 +152,14 @@ public class JpaPolicyStore implements PolicyStore { private Optional toPolicy(PolicyEntity entity) { try { JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())); - return Optional.of(objectMapper.treeToValue(node, Policy.class)); + // A blob written by an older version won't carry fields added since (e.g. required, + // icon). Default absent primitives rather than rejecting the whole policy, so upgrades + // don't drop existing pipelines. + return Optional.of( + objectMapper + .readerFor(Policy.class) + .without(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .readValue(node)); } catch (Exception e) { log.error( "Skipping unreadable policy id={} name={}: stored JSON could not be parsed" diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 93ead594e7..85878abe67 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -151,6 +151,7 @@ class PolicyOverviewServiceTest { "owner", true, true, + "", List.of(), List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())), OutputSpec.inline(), @@ -161,6 +162,41 @@ class PolicyOverviewServiceTest { assertTrue(view.required()); } + @Test + void iconIsExplicitOtherwiseFallsBackToCategory() { + // The policy's first-class icon wins. + policyStore.save( + new Policy( + null, + "Custom with icon", + "owner", + true, + false, + "shield", + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + List.of(), + null)); + // No explicit icon: a template-derived policy falls back to its categoryId marker. + policyStore.save( + new Policy( + null, + "Template derived", + "owner", + true, + false, + "", + List.of(), + List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())), + new OutputSpec("inline", Map.of("categoryId", "security")), + List.of(), + null)); + + assertEquals("shield", find(service.overview(), "Custom with icon").icon()); + assertEquals("security", find(service.overview(), "Template derived").icon()); + } + @Test void anUnresolvedSourceFallsBackToItsId() { policyStore.save( diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 0a5c9a249f..2fe2b3b801 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7906,6 +7906,9 @@ output = "Back to the document" export = "On export" upload = "On upload" +[portal.pipelines.builder.icon] +label = "Change icon" + [portal.pipelines.builder.metadata] helper = "Temporary raw view of the policy's metadata (run/output settings, sources, scope). Edited here until it gets a proper UI; leave it as-is if unsure." invalid = "Not valid JSON. The last valid version is kept until this is fixed." diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index 9acabae805..868f675679 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -63,6 +63,8 @@ export interface Policy { * enforces on their documents when its trigger targets the editor. Admin-only to set. */ required?: boolean; + /** Row icon key (see pipelineIcon); chosen in the builder. Empty falls back to the category glyph. */ + icon?: string; inputs: PipelineInput[]; steps: PipelineStep[]; /** @@ -95,6 +97,8 @@ export interface PipelineView { enabled: boolean; /** Org-mandated policy (see {@link Policy.required}); surfaced as a "Required" badge in the list. */ required: boolean; + /** Icon key for the list row (see pipelineIcon). Empty when none set; may be a category id. */ + icon: string; status: PipelineStatus; /** Trigger summary: "manual" or the trigger type (e.g. "schedule"). */ trigger: string; diff --git a/frontend/editor/src/portal/components/icons.tsx b/frontend/editor/src/portal/components/icons.tsx index 5692e253d9..86a4eb81d1 100644 --- a/frontend/editor/src/portal/components/icons.tsx +++ b/frontend/editor/src/portal/components/icons.tsx @@ -59,16 +59,20 @@ export function SourcesIcon(props: IconProps) { ); } +/** + * A pipeline as a route: two waypoints joined by a winding path. Shared so the sidebar nav and the + * pipelines table's default row icon render the exact same glyph (see pipelineIcon). + */ +export const PIPELINE_ROUTE_GLYPH = ( + <> + + + + +); + export function PipelinesIcon(props: IconProps) { - return ( - - - - - - - - ); + return {PIPELINE_ROUTE_GLYPH}; } export function DocumentsIcon(props: IconProps) { diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx index b9717c0848..70503b8a8b 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx @@ -18,6 +18,7 @@ const noop = () => {}; */ function Playground({ initialName }: { initialName: string }) { const [name, setName] = useState(initialName); + const [icon, setIcon] = useState("route"); const blockers = name.trim() === "" ? [ @@ -30,6 +31,8 @@ function Playground({ initialName }: { initialName: string }) { void; + /** Row icon key (see pipelineIcon); chosen from the picker beside the name. */ + icon: string; + onIconChange: (key: string) => void; canSave: boolean; /** Everything still owed before the pipeline can be created, shown on the disabled create button. */ @@ -28,6 +32,8 @@ export interface PipelineCreateHeaderProps { export function PipelineCreateHeader({ name, onNameChange, + icon, + onIconChange, canSave, blockers, saving, @@ -49,6 +55,8 @@ export function PipelineCreateHeader({ + + setEnabled((e) => !e)} togglingEnabled={false} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx index 7ee2fbd173..99969710b3 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx @@ -10,11 +10,15 @@ import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; import { ActionIcon, Button, Dropdown, Input } from "@app/ui"; import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip"; +import { PipelineIconPicker } from "@portal/components/pipelines/PipelineIconPicker"; import "@portal/components/pipelines/PipelineEditHeader.css"; export interface PipelineEditHeaderProps { name: string; onNameChange: (name: string) => void; + /** Row icon key (see pipelineIcon); chosen from the picker beside the name. */ + icon: string; + onIconChange: (key: string) => void; /** The pipeline's live state. Toggling it takes effect immediately, not on save. */ enabled: boolean; @@ -49,6 +53,8 @@ export interface PipelineEditHeaderProps { export function PipelineEditHeader({ name, onNameChange, + icon, + onIconChange, enabled, onTogglePause, togglingEnabled, @@ -116,6 +122,8 @@ export function PipelineEditHeader({ + + {renaming ? ( = { + title: "Portal/Pipelines/PipelineIconPicker", + component: PipelineIconPicker, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +/** Live picker: click the glyph to open the grid and choose a new icon. */ +export const Default: Story = { + render: () => { + const [icon, setIcon] = useState("route"); + return ; + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.tsx b/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.tsx new file mode 100644 index 0000000000..4a636ec55c --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Dropdown } from "@app/ui"; +import { + PIPELINE_ICON_KEYS, + pipelineIcon, +} from "@portal/components/pipelines/pipelineIcon"; +import "@portal/components/pipelines/PipelineIconPicker.css"; + +interface PipelineIconPickerProps { + /** Current icon key (may be a category id or empty; resolved by pipelineIcon). */ + value: string; + onChange: (key: string) => void; +} + +/** Picks the pipeline's icon from a small set. The chosen glyph is the trigger; the menu is a grid. */ +export function PipelineIconPicker({ + value, + onChange, +}: PipelineIconPickerProps) { + const { t } = useTranslation(); + // Controlled so a grid button (not a Dropdown.Item) can close the menu on pick. + const [open, setOpen] = useState(false); + + function pick(key: string) { + onChange(key); + setOpen(false); + } + + return ( + + + + {pipelineIcon(value, "1.125rem")} + + + +
+ {PIPELINE_ICON_KEYS.map((key) => { + const selected = key === value; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx index f32521cf95..b01059eb3f 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx @@ -8,6 +8,7 @@ const PIPELINES: PipelineView[] = [ name: "Claims intake", enabled: true, required: false, + icon: "shield", status: "active", trigger: "folder-watch", sources: [{ id: "src-claims", name: "Claims intake" }], @@ -20,6 +21,7 @@ const PIPELINES: PipelineView[] = [ name: "Archive reprocess", enabled: false, required: true, + icon: "compress", status: "paused", trigger: "manual", sources: [], diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx index 9d66457467..209d85ab20 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx @@ -1,12 +1,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded"; import { column, DataTable, type DataTableColumn, type StatusTone, } from "@app/ui"; +import { pipelineIcon } from "@portal/components/pipelines/pipelineIcon"; import type { PipelineStatus, PipelineView } from "@portal/api/pipelines"; const STATUS_TONE: Record = { @@ -28,7 +28,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) { key: "name", header: t("portal.pipelines.table.name"), sortable: true, - icon: () => , + icon: (p) => pipelineIcon(p.icon, "1.25rem"), primary: (p) => p.name, }), column.badge({ diff --git a/frontend/editor/src/portal/components/pipelines/pipelineIcon.tsx b/frontend/editor/src/portal/components/pipelines/pipelineIcon.tsx new file mode 100644 index 0000000000..bc6a3c71a0 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/pipelineIcon.tsx @@ -0,0 +1,102 @@ +// A pipeline's icon, keyed by a small named vocabulary the icon picker offers. Distinct from +// policyCategoryIcon (which is keyed by category id): a custom pipeline has no category, so it needs +// a general set to choose from. Category ids are also accepted as keys, so a template-derived +// pipeline that only stores its categoryId still resolves to the matching glyph. + +import type { ReactNode } from "react"; +import type { SxProps, Theme } from "@mui/material"; +import { PIPELINE_ROUTE_GLYPH } from "@portal/components/icons"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined"; +import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; +import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined"; +import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined"; +import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; +import BrandingWatermarkOutlinedIcon from "@mui/icons-material/BrandingWatermarkOutlined"; +import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; +import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; +import DocumentScannerOutlinedIcon from "@mui/icons-material/DocumentScannerOutlined"; +import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; +import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined"; + +type MuiIcon = React.ComponentType<{ sx?: SxProps; className?: string }>; + +// "route" is the default, drawn bespoke (see pipelineIcon) to match the sidebar glyph, so it is not +// in this MUI map. Every other key resolves to an outline Material glyph. +const ICONS: Record = { + // Pickable vocabulary. + shield: ShieldOutlinedIcon, + lock: LockOutlinedIcon, + label: LabelOutlinedIcon, + layers: LayersOutlinedIcon, + check: CheckCircleOutlinedIcon, + route: AltRouteOutlinedIcon, + schedule: ScheduleOutlinedIcon, + watermark: BrandingWatermarkOutlinedIcon, + doc: DescriptionOutlinedIcon, + folder: FolderOutlinedIcon, + scan: DocumentScannerOutlinedIcon, + bolt: BoltOutlinedIcon, + sparkle: AutoAwesomeOutlinedIcon, + // Category-id aliases (same glyphs as policyCategoryIcon), so a template-derived pipeline that + // stores only its categoryId still resolves without an explicit pick. + ingestion: LayersOutlinedIcon, + security: ShieldOutlinedIcon, + classification: LabelOutlinedIcon, + compliance: CheckCircleOutlinedIcon, + routing: AltRouteOutlinedIcon, + retention: ScheduleOutlinedIcon, +}; + +/** The glyph for a pipeline with no icon set (and the picker's default): the bespoke route mark. */ +export const DEFAULT_PIPELINE_ICON = "route"; + +/** Icon keys the picker offers, in display order. */ +export const PIPELINE_ICON_KEYS: readonly string[] = [ + "route", + "shield", + "lock", + "label", + "layers", + "check", + "schedule", + "watermark", + "doc", + "folder", + "scan", + "bolt", + "sparkle", +]; + +// Defaults to inheriting the surrounding font-size so a wrapping box controls size. +export function pipelineIcon( + key?: string, + fontSize: string = "inherit", + className?: string, +): ReactNode { + const resolved = key && (key === "route" || ICONS[key]) ? key : "route"; + if (resolved === "route") { + // The default/route glyph is bespoke (matches the sidebar), em-sized like the Material icons. + return ( + + {PIPELINE_ROUTE_GLYPH} + + ); + } + const Icon = ICONS[resolved]; + const sx: SxProps = { fontSize }; + return ; +} diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts index 093b4eedf2..f9696f0622 100644 --- a/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts @@ -241,6 +241,7 @@ function makePipelineView( name, enabled: true, required: false, + icon: "", status: "active", trigger, sources: [], diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index 457325e22f..d7e0a52497 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -149,11 +149,17 @@ function toView(policy: OverviewPolicy): PipelineView { .filter((type): type is string => type != null), ), ]; + // Mirror the backend: the first-class icon wins, else the template's categoryId marker, else none. + const options = policy.output?.options ?? {}; + const icon = + policy.icon || + (typeof options.categoryId === "string" ? options.categoryId : ""); return { id: policy.id, name: policy.name, enabled: policy.enabled, required: policy.required ?? false, + icon, status: policy.enabled ? "active" : "paused", trigger: triggers.length === 0 ? "manual" : triggers.join(", "), sources: inputs.map((input) => ({ diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index 2d5f2b03ce..d3eca98e8c 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -284,6 +284,9 @@ export function PipelineBuilder() { // Org-mandated policy (see Policy.required). Admin sets it; members can't pause/delete a required // pipeline, and it enforces on their documents when it runs on the editor. const [required, setRequired] = useState(false); + // First-class row icon (see Policy.icon), chosen from the picker in the header. Empty falls back to + // the template category glyph in the list; a custom pipeline defaults to none until picked. + const [icon, setIcon] = useState(""); // The policy metadata bag carried on output.options (runOn, sources, output naming, scope, // reviewer, fieldValues...). Preserved verbatim through an edit so a customised policy never loses // its simple-only settings; edited in the output inspector's dev section until it gets real UI. @@ -369,6 +372,13 @@ export function PipelineBuilder() { setName(policy?.name ?? ""); setEnabled(policy?.enabled ?? true); setRequired(policy?.required ?? false); + // Seed the icon from the first-class field; a template hand-off has none yet, so fall back to + // its category id (a valid icon key) so the picker shows the category glyph. + const seedCategoryId = policy?.output?.options?.categoryId; + setIcon( + policy?.icon ?? + (typeof seedCategoryId === "string" ? seedCategoryId : ""), + ); setOutputOptions(policy?.output?.options ?? {}); setOutputType(policy?.output?.type ?? "inline"); // The one input row is always present: blank for a new pipeline (or a legacy policy saved @@ -717,6 +727,7 @@ export function PipelineBuilder() { name: name.trim(), enabled, required, + icon, inputs: editorEnforced || !sourceChosen ? [] @@ -856,6 +867,7 @@ export function PipelineBuilder() { name: name.trim(), enabled: enabledOverride ?? enabled, required, + icon, // An editor-enforced policy pulls from no source and writes to no destination; a // source-driven pipeline carries its one input (canSave guarantees the source) and // destinations. The wire shape stays a list. @@ -1365,6 +1377,8 @@ export function PipelineBuilder() {