mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Add configurable icons for pipelines
This commit is contained in:
+1
@@ -390,6 +390,7 @@ public class PolicyController {
|
||||
owner,
|
||||
policy.enabled(),
|
||||
policy.required(),
|
||||
policy.icon(),
|
||||
policy.inputs(),
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
|
||||
+21
-7
@@ -21,6 +21,7 @@ public record Policy(
|
||||
String owner,
|
||||
boolean enabled,
|
||||
boolean required,
|
||||
String icon,
|
||||
List<PipelineInput> inputs,
|
||||
List<PipelineStep> 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<String> 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<String> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+20
@@ -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").
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ public record PolicyView(
|
||||
String name,
|
||||
boolean enabled,
|
||||
boolean required,
|
||||
String icon,
|
||||
String status,
|
||||
String trigger,
|
||||
List<SourceRef> sources,
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ public class InProcessPolicyStore implements PolicyStore {
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.required(),
|
||||
policy.icon(),
|
||||
policy.inputs(),
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
|
||||
+10
-1
@@ -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<Policy> 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"
|
||||
|
||||
+36
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<circle cx="5" cy="19" r="2" />
|
||||
<circle cx="19" cy="5" r="2" />
|
||||
<path d="M11 19h5.5a3.5 3.5 0 0 0 0 -7h-8a3.5 3.5 0 0 1 0 -7h4.5" />
|
||||
</>
|
||||
);
|
||||
|
||||
export function PipelinesIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="15" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="15" width="6" height="6" rx="1" />
|
||||
<path d="M6 9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9" />
|
||||
<path d="M12 14v1" />
|
||||
</Svg>
|
||||
);
|
||||
return <Svg {...props}>{PIPELINE_ROUTE_GLYPH}</Svg>;
|
||||
}
|
||||
|
||||
export function DocumentsIcon(props: IconProps) {
|
||||
|
||||
@@ -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 }) {
|
||||
<PipelineCreateHeader
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
icon={icon}
|
||||
onIconChange={setIcon}
|
||||
canSave={blockers.length === 0}
|
||||
blockers={blockers}
|
||||
saving={false}
|
||||
|
||||
@@ -2,11 +2,15 @@ import { useTranslation } from "react-i18next";
|
||||
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
import { ActionIcon, Button, Input } from "@app/ui";
|
||||
import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
|
||||
import { PipelineIconPicker } from "@portal/components/pipelines/PipelineIconPicker";
|
||||
import "@portal/components/pipelines/PipelineCreateHeader.css";
|
||||
|
||||
export interface PipelineCreateHeaderProps {
|
||||
name: string;
|
||||
onNameChange: (name: 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({
|
||||
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
<PipelineIconPicker value={icon} onChange={onIconChange} />
|
||||
|
||||
<Input
|
||||
className="portal-pipeline-create-header__name"
|
||||
value={name}
|
||||
|
||||
@@ -26,10 +26,13 @@ function Playground({
|
||||
}) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [icon, setIcon] = useState("route");
|
||||
return (
|
||||
<PipelineEditHeader
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
icon={icon}
|
||||
onIconChange={setIcon}
|
||||
enabled={enabled}
|
||||
onTogglePause={() => setEnabled((e) => !e)}
|
||||
togglingEnabled={false}
|
||||
|
||||
@@ -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({
|
||||
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
<PipelineIconPicker value={icon} onChange={onIconChange} />
|
||||
|
||||
{renaming ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.portal-icon-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 2.25rem);
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-icon-picker__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--c-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-icon-picker__option:hover {
|
||||
background: var(--c-hover);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.portal-icon-picker__option--selected {
|
||||
border-color: var(--c-primary);
|
||||
background: var(--c-primary-subtle);
|
||||
color: var(--c-accent-fg, var(--c-primary));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PipelineIconPicker } from "@portal/components/pipelines/PipelineIconPicker";
|
||||
|
||||
const meta: Meta<typeof PipelineIconPicker> = {
|
||||
title: "Portal/Pipelines/PipelineIconPicker",
|
||||
component: PipelineIconPicker,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PipelineIconPicker>;
|
||||
|
||||
/** 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 <PipelineIconPicker value={icon} onChange={setIcon} />;
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<Dropdown.Root open={open} onOpenChange={setOpen} align="start">
|
||||
<Dropdown.Trigger>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label={t("portal.pipelines.builder.icon.label")}
|
||||
>
|
||||
{pipelineIcon(value, "1.125rem")}
|
||||
</ActionIcon>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu>
|
||||
<div className="portal-icon-picker__grid">
|
||||
{PIPELINE_ICON_KEYS.map((key) => {
|
||||
const selected = key === value;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={
|
||||
"portal-icon-picker__option" +
|
||||
(selected ? " portal-icon-picker__option--selected" : "")
|
||||
}
|
||||
aria-label={key}
|
||||
aria-pressed={selected}
|
||||
onClick={() => pick(key)}
|
||||
>
|
||||
{pipelineIcon(key, "1.25rem")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<PipelineStatus, StatusTone> = {
|
||||
@@ -28,7 +28,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
|
||||
key: "name",
|
||||
header: t("portal.pipelines.table.name"),
|
||||
sortable: true,
|
||||
icon: () => <AccountTreeRounded />,
|
||||
icon: (p) => pipelineIcon(p.icon, "1.25rem"),
|
||||
primary: (p) => p.name,
|
||||
}),
|
||||
column.badge({
|
||||
|
||||
@@ -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<Theme>; 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<string, MuiIcon> = {
|
||||
// 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 (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ fontSize }}
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
{PIPELINE_ROUTE_GLYPH}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const Icon = ICONS[resolved];
|
||||
const sx: SxProps<Theme> = { fontSize };
|
||||
return <Icon sx={sx} className={className} />;
|
||||
}
|
||||
@@ -241,6 +241,7 @@ function makePipelineView(
|
||||
name,
|
||||
enabled: true,
|
||||
required: false,
|
||||
icon: "",
|
||||
status: "active",
|
||||
trigger,
|
||||
sources: [],
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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() {
|
||||
<PipelineEditHeader
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
icon={icon}
|
||||
onIconChange={setIcon}
|
||||
enabled={enabled}
|
||||
onTogglePause={handleTogglePause}
|
||||
togglingEnabled={togglingEnabled}
|
||||
@@ -1383,6 +1397,8 @@ export function PipelineBuilder() {
|
||||
<PipelineCreateHeader
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
icon={icon}
|
||||
onIconChange={setIcon}
|
||||
canSave={canSave}
|
||||
blockers={blockers}
|
||||
saving={submitting}
|
||||
|
||||
@@ -50,6 +50,7 @@ const RESPONSE: PipelinesOverviewResponse = {
|
||||
name: "Redaction sweep",
|
||||
enabled: true,
|
||||
required: false,
|
||||
icon: "security",
|
||||
status: "active",
|
||||
trigger: "schedule",
|
||||
sources: [{ id: "src-claims", name: "Claims intake" }],
|
||||
|
||||
Reference in New Issue
Block a user