Add configurable icons for pipelines

This commit is contained in:
James Brunton
2026-08-27 11:38:50 +01:00
parent 39108f4425
commit 4b3247b455
24 changed files with 376 additions and 19 deletions
@@ -390,6 +390,7 @@ public class PolicyController {
owner,
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -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);
}
/**
@@ -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").
@@ -14,6 +14,7 @@ public record PolicyView(
String name,
boolean enabled,
boolean required,
String icon,
String status,
String trigger,
List<SourceRef> sources,
@@ -35,6 +35,7 @@ public class InProcessPolicyStore implements PolicyStore {
policy.owner(),
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -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"
@@ -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" }],