Combine Policies and Pipelines pages (#7681)

# Description of Changes
Combine the Policies and Pipelines pages into one, so we have the new
concept of Policies as Pipelines that always run which the user cannot
disable. What used to be Policies are now referred to as Templates, and
they allow you to create a new Pipeline more easily with the simple UI.

There's followup work to be done here to improve the template UIs
because they've not been touched in a long time, but I've considered
that beyond the scope of this merge. The only real changes I've made to
them in this PR is that they have a toggle for whether they're policies,
they now have a "Customise" button to kick you into the full Pipeline
editor, and I've removed the source selection. Previously, they
supported selecting as many sources as you liked, but that feature never
worked and is incompatible with the backend as it stands now, which only
allows for one source. Because of that, I've made it so that they can
only run in editor unless you open them in the custom pipeline editor,
where you can switch out which source it will use.

There's also another bit of followup to rename and remove all the
previous Policies code. Now that they've been combined into one, we
don't need a lot of the Policies code anymore, but also there's about
300 files in the frontend referencing policies in text/comments which
need to be updated to say pipelines. This is way more work than is
reasonable to do in this PR so I'll just do it in a new PR.

## Limitations
This PR is about the merging of the old Policies and Pipelines and I'm
considering enforcing the new definition of a Policy where it's only
modifiable by admins beyond the scope of this PR.

<img width="756" height="395" alt="image"
src="https://github.com/user-attachments/assets/d31be5ce-f1c9-46b3-8e8d-866e63f89a81"
/>

<img width="1507" height="793" alt="image"
src="https://github.com/user-attachments/assets/9ba8875f-8be5-4881-91cf-40e0bc1076dc"
/>

<img width="1508" height="787" alt="image"
src="https://github.com/user-attachments/assets/3e1da77b-a0c0-4262-aad3-16650098db81"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
This commit is contained in:
James Brunton
2026-09-02 16:08:15 +00:00
committed by GitHub
co-authored by EthanHealy01
parent 1b2a3118a6
commit aca0e40c37
66 changed files with 2250 additions and 996 deletions
@@ -398,6 +398,8 @@ public class PolicyController {
policy.name(),
owner,
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -21,6 +21,8 @@ public record Policy(
String name,
String owner,
boolean enabled,
boolean required,
String icon,
List<PipelineInput> inputs,
List<PipelineStep> steps,
OutputSpec output,
@@ -29,6 +31,7 @@ public record Policy(
EditorConfig editor) {
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;
@@ -36,7 +39,11 @@ public record Policy(
editor = editor == null ? EditorConfig.disabled() : editor;
}
/** Without editor participation: a swept or on-demand policy. */
/**
* Without the {@code required} flag, {@code icon}, or editor participation: defaults to not
* org-required, no icon, and a swept/on-demand policy. Kept for the many callers and tests
* written before those fields; the frontend and stores that care use the full constructor.
*/
public Policy(
String id,
String name,
@@ -47,7 +54,26 @@ public record Policy(
OutputSpec output,
List<String> outputIds,
Long teamId) {
this(id, name, owner, enabled, inputs, steps, output, outputIds, teamId, null);
this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId, null);
}
/**
* Without the {@code required} flag or {@code icon} but with explicit editor participation: the
* seeded Classification policy runs on the editor, so it must set {@link EditorConfig} even
* though it predates the org-required and icon fields.
*/
public Policy(
String id,
String name,
String owner,
boolean enabled,
List<PipelineInput> inputs,
List<PipelineStep> steps,
OutputSpec output,
List<String> outputIds,
Long teamId,
EditorConfig editor) {
this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId, editor);
}
/**
@@ -108,19 +134,32 @@ 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, inputs, steps, resolved, outputIds, teamId, editor);
id, name, owner, enabled, required, icon, inputs, steps, resolved, outputIds,
teamId, editor);
}
/** 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, inputs, steps, output, outputIds, teamId, editor);
id, name, newOwner, enabled, required, icon, inputs, steps, output, outputIds,
teamId, editor);
}
/** A copy referencing the given saved output destinations. */
public Policy withOutputIds(List<String> newOutputIds) {
return new Policy(
id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId, editor);
id,
name,
owner,
enabled,
required,
icon,
inputs,
steps,
output,
newOutputIds,
teamId,
editor);
}
/**
@@ -20,28 +20,23 @@ import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Builds the Pipelines overview: one row per policy the caller's team built on the Pipelines page,
* with its sources resolved to live display names, its steps, and a trigger/output summary.
* Frontend/catalogue policies (marked by a {@code categoryId} in their output options) belong to
* the user-facing Policies page and are excluded; a folder-watch trigger is not a signal.
* Builds the unified Pipelines overview: one row per policy the caller's team owns, with its
* sources resolved to live display names, its steps, and a trigger/output summary. This lists EVERY
* policy - both pipelines built in the full builder and the friendly "suggested" policies - since
* the two surfaces were merged (a policy is a pipeline the org requires). No catalogue filter any
* more.
*/
@Service
@RequiredArgsConstructor
public class PolicyOverviewService {
// Output-options key marking a frontend/catalogue policy (set by the Policies page and seeder).
private static final String CATEGORY_OPTION = "categoryId";
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final PolicyAccessGuard policyAccessGuard;
private final SourceAccessGuard sourceAccessGuard;
public PoliciesOverviewResponse overview() {
List<Policy> policies =
policyAccessGuard.visibleFrom(policyStore).stream()
.filter(PolicyOverviewService::isPipeline)
.toList();
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore).stream().toList();
Map<String, String> sourceNames = sourceNames();
List<PolicyView> views =
@@ -55,18 +50,6 @@ public class PolicyOverviewService {
return new PoliciesOverviewResponse(buildKpis(policies), views);
}
private static boolean isPipeline(Policy policy) {
return !isCataloguePolicy(policy);
}
/** A frontend/catalogue policy, marked by a {@code categoryId} in its output options. */
private static boolean isCataloguePolicy(Policy policy) {
OutputSpec output = policy.output();
return output != null
&& output.options().get(CATEGORY_OPTION) instanceof String category
&& !category.isBlank();
}
/** Display names for every source the caller's team can see, keyed by source id. */
private Map<String, String> sourceNames() {
Map<String, String> names = new HashMap<>();
@@ -88,6 +71,8 @@ public class PolicyOverviewService {
policy.id(),
policy.name(),
policy.enabled(),
policy.required(),
iconKey(policy),
policy.enabled() ? "active" : "paused",
triggerSummary(policy),
sources,
@@ -111,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").
@@ -3,15 +3,18 @@ package stirling.software.proprietary.policy.overview;
import java.util.List;
/**
* One row in the Pipelines overview: a stored policy shown for the admin portal, with its
* referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines"
* surface lists every backend policy (the user-facing Policies page builds only a friendly subset
* of these).
* One row in the unified Pipelines overview: a stored policy shown for the admin portal, with its
* referenced sources resolved to names and its pipeline summarised. This surface lists every
* backend policy - both the pipelines built in the full builder and the friendly "suggested"
* policies - so a {@code required} policy (one the org mandates) reads the same as any other
* pipeline here.
*/
public record PolicyView(
String id,
String name,
boolean enabled,
boolean required,
String icon,
String status,
String trigger,
List<SourceRef> sources,
@@ -34,6 +34,8 @@ public class InProcessPolicyStore implements PolicyStore {
policy.name(),
policy.owner(),
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -17,6 +17,7 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyBinding;
import stirling.software.proprietary.policy.source.EditorSource;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
@@ -47,6 +48,8 @@ public class JpaPolicyStore implements PolicyStore {
policy.name(),
policy.owner(),
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -155,7 +158,14 @@ public class JpaPolicyStore implements PolicyStore {
JsonNode node =
liftEditorConfig(
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"
@@ -29,11 +29,11 @@ import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link PolicyOverviewService}: every Pipelines-page policy appears once with its
* sources resolved to names, its steps and trigger/output summarised, and the KPI strip counting
* active vs paused. Frontend/catalogue policies (owned by the Policies page) are excluded, while a
* pipeline that uses a folder-watch trigger stays. Login is disabled so the team guards pass
* everything through.
* Tests for {@link PolicyOverviewService}: every policy the caller's team owns appears once with
* its sources resolved to names, its steps and trigger/output summarised, and the KPI strip
* counting active vs paused. Since Policies were merged into Pipelines, the suggested ("catalogue")
* policies are listed alongside hand-built pipelines - nothing is filtered. Login is disabled so
* the team guards pass everything through.
*/
class PolicyOverviewServiceTest {
@@ -99,9 +99,9 @@ class PolicyOverviewServiceTest {
}
@Test
void excludesCataloguePoliciesButKeepsFolderWatchPipelines() {
void listsEveryPolicyIncludingSuggestedOnes() {
Source inbox = source("Inbox", "/inbox");
// A hand-built pipeline: shows.
// A hand-built pipeline.
policyStore.save(
new Policy(
null,
@@ -111,7 +111,7 @@ class PolicyOverviewServiceTest {
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
// A folder-watch pipeline is still a pipeline: shows.
// A folder-watch pipeline.
policyStore.save(
new Policy(
null,
@@ -123,7 +123,7 @@ class PolicyOverviewServiceTest {
inbox.id(), new TriggerConfig("folder-watch", Map.of()))),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
// A frontend/catalogue policy (categoryId in output options): hidden.
// A suggested ("catalogue") policy (categoryId in output options): now listed too.
policyStore.save(
new Policy(
null,
@@ -137,10 +137,68 @@ class PolicyOverviewServiceTest {
PoliciesOverviewResponse response = service.overview();
assertEquals(
List.of("Compress pipeline", "Inbox watcher"),
List.of("Classification Policy", "Compress pipeline", "Inbox watcher"),
response.pipelines().stream().map(PolicyView::name).toList());
// KPIs count both visible pipelines, not the hidden catalogue policy.
assertEquals(List.of(2L, 2L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
// KPIs count all three.
assertEquals(List.of(3L, 3L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void requiredFlagSurfacesInTheView() {
policyStore.save(
new Policy(
null,
"Mandatory redaction",
"owner",
true,
true,
"",
List.of(),
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
OutputSpec.inline(),
List.of(),
null,
EditorConfig.disabled()));
PolicyView view = find(service.overview(), "Mandatory redaction");
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,
EditorConfig.disabled()));
// 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,
EditorConfig.disabled()));
assertEquals("shield", find(service.overview(), "Custom with icon").icon());
assertEquals("security", find(service.overview(), "Template derived").icon());
}
@Test
@@ -8458,11 +8458,14 @@ platform = "PDF Platform"
processor = "PDF Processor"
[portal.pipelines]
subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources."
subtitle = "Automate your document workflows. Start from a template for a simple, guided setup, or build a custom pipeline from scratch. Enforce any pipeline as a policy to run it on every document."
title = "Pipelines"
[portal.pipelines.actions]
newPipeline = "New pipeline"
newCustomPipeline = "New custom pipeline"
[portal.pipelines.all]
title = "All pipelines"
[portal.pipelines.builder]
activate = "Activate"
@@ -8521,6 +8524,9 @@ output-uncertain = "May not run: output depends on setup"
source-mismatch = "Input is {{produced}}, needs {{accepts}}"
undeclared-operation = "Can't check what this step accepts"
[portal.pipelines.builder.icon]
label = "Change icon"
[portal.pipelines.composer]
addTool = "Add a tool"
create = "Create pipeline"
@@ -8570,6 +8576,11 @@ connectSource = "Connect a source"
description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
title = "No pipelines yet"
[portal.pipelines.enforce]
desc = "Runs automatically; members can't turn it off"
info = "What enforcing as a policy means"
label = "Enforce as policy"
[portal.pipelines.graph]
addFirstTool = "Add a tool"
dragHint = "Drop on a line to move it"
@@ -8629,6 +8640,11 @@ sources = "Sources"
status = "Status"
steps = "Steps"
trigger = "Trigger"
type = "Type"
[portal.pipelines.templates]
setUp = "Set up"
title = "Templates"
[portal.pipelines.trigger]
editor-export = "Every export"
@@ -8637,10 +8653,12 @@ folder-watch = "Folder watch"
manual = "Manual"
schedule = "Scheduled"
[portal.pipelines.type]
pipeline = "Pipeline"
policy = "Policy"
[portal.policies]
defaultName = "{{category}} Policy"
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
title = "Policies"
defaultName = "{{category}} Pipeline"
[portal.policies.card]
comingSoon = "Upgrade to Enterprise"
@@ -8766,7 +8784,7 @@ summary = "Detects and redacts PII, strips active content (JavaScript), and wate
2 = "Watermark"
[portal.policies.detail]
enforces = "Enforces"
enforces = "Steps"
onEveryExport = "On every export"
onEveryUpload = "On every upload"
outputAsNewFile = "as a new file"
@@ -8786,13 +8804,13 @@ resume = "Resume"
runNow = "Run now"
[portal.policies.detail.clearHistory]
body = "This policy will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone."
body = "This pipeline will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone."
cancel = "Cancel"
confirm = "Clear history"
title = "Clear processed history?"
[portal.policies.detail.emptyActivity]
description = "Documents will appear here once this policy runs."
description = "Documents will appear here once this pipeline runs."
title = "No activity yet"
[portal.policies.endpoints]
@@ -8804,11 +8822,6 @@ flatten = "Flatten"
ocrPdf = "OCR"
sanitizePdf = "Remove JavaScript"
[portal.policies.offline]
description = "Your policies are saved and will appear once the connection is restored."
retry = "Retry"
title = "Backend unavailable"
[portal.policies.operations]
change = "Change what this step does"
noResults = "No step matches that. Try a product name, or \"scan\", \"notify\", \"attach\"."
@@ -9007,7 +9020,7 @@ label = "Trigger a Zap or Make scenario"
[portal.policies.stats]
activeFor = "Active"
dataProcessed = "Data processed"
docsEnforced = "Docs enforced"
docsEnforced = "Docs processed"
[portal.policies.status]
active = "Active"
@@ -9037,10 +9050,9 @@ policy = "Policy"
status = "Status"
[portal.policies.wizard.actions]
back = "Back"
cancel = "Cancel"
continue = "Continue"
enablePolicy = "Enable policy"
customise = "Customise"
enablePolicy = "Create pipeline"
saveChanges = "Save changes"
[portal.policies.wizard.capability.classify]
@@ -9081,47 +9093,14 @@ labelsHeading = "Classification labels"
[portal.policies.wizard.errors]
noTools = "Enable at least one tool in the workflow first."
saveFailed = "Couldn't save the policy. Please try again."
[portal.policies.wizard.output]
heading = "Output & run"
[portal.policies.wizard.output.filenameRule]
autoNumber = "Auto-number"
label = "Filename rule"
placeholder = "Text to add (optional)"
prefix = "Prefix"
suffix = "Suffix"
[portal.policies.wizard.output.outputAs]
label = "Output as"
newFile = "New file"
newVersion = "New version"
[portal.policies.wizard.output.runOn]
export = "Export"
helper = "When the policy fires: on upload, or before export."
label = "Run on"
upload = "Upload"
[portal.policies.wizard.settings]
heading = "Settings"
[portal.policies.wizard.sources]
heading = "Sources"
loading = "Loading sources…"
[portal.policies.wizard.tabs]
ariaLabel = "Setup steps"
settings = "Settings"
workflow = "Actions"
saveFailed = "Couldn't save the pipeline. Please try again."
[portal.policies.wizard.title]
edit = "Edit {{category}} policy"
setUp = "Set up {{category}} policy"
edit = "Edit {{category}} pipeline"
setUp = "Set up {{category}} pipeline"
[portal.policies.wizard.workflow]
description = "Choose what this policy does to every document it processes."
description = "Choose what this pipeline does to every document it processes."
[portal.policySummary.action]
setUp = "Set up"
+8 -14
View File
@@ -9,7 +9,7 @@ html[data-app-theme="light"] {
--c-bg-raised: var(--p-white);
--c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-surface-sunken: var(--p-c-f0f0f0);
--c-input-bg: var(--p-white);
--c-modal-surface: var(--p-white);
--c-hover: var(--p-gray-50);
@@ -144,7 +144,7 @@ html[data-app-theme="midnight"] {
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-650);
--c-surface-sunken: var(--p-zinc-850);
--c-surface-sunken: var(--p-c-100f0e);
--c-input-bg: var(--c-surface);
--c-modal-surface: var(--c-surface);
--c-hover: var(--p-gray-800);
@@ -204,11 +204,8 @@ html[data-app-theme="custom"] {
--c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-snow));
--c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface-sunken: color-mix(
in srgb,
var(--c-primary) 8%,
var(--p-gray-100)
);
/* Neutral recess: no accent mix, so the sunken well stays a plain grey (not cold, not cream). */
--c-surface-sunken: var(--p-c-f0f0f0);
--c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white));
--c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50));
--c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100));
@@ -357,11 +354,8 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
var(--c-primary) 9%,
var(--p-zinc-775)
);
--c-surface-sunken: color-mix(
in srgb,
var(--c-primary) 8%,
var(--p-zinc-900)
);
/* Neutral recess (see light): no accent mix, so the sunken well stays a plain near-black. */
--c-surface-sunken: var(--p-c-100f0e);
--c-input-bg: var(--c-surface);
--c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750));
--c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700));
@@ -412,7 +406,7 @@ html[data-app-theme="custom"][data-accent="default"] {
--c-bg-raised: var(--p-white);
--c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-surface-sunken: var(--p-c-f0f0f0);
--c-input-bg: var(--p-white);
--c-hover: var(--p-gray-50);
--c-active: var(--p-gray-100);
@@ -425,7 +419,7 @@ html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-775);
--c-surface-sunken: var(--p-zinc-900);
--c-surface-sunken: var(--p-c-100f0e);
--c-input-bg: var(--c-surface);
--c-hover: var(--p-zinc-750);
--c-active: var(--p-zinc-700);
@@ -39,6 +39,7 @@
--p-zinc-100: #f4f4f5;
--p-c-141416: #141416;
--p-c-1a1a1d: #1a1a1d;
--p-c-100f0e: #100f0e;
--p-c-28282d: #28282d;
--p-c-343439: #343439;
--p-blue-400: #60a5fa;
+48
View File
@@ -0,0 +1,48 @@
.sui-card-rail {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
/* A real horizontal scroller: keep the bounce, but don't chain to the browser's back gesture. */
overscroll-behavior-x: contain;
/* Room for the scrollbar so it doesn't sit under the last row of item content. */
padding-bottom: 0.25rem;
}
/* Gap scale mirrors Stack / Inline. */
.sui-card-rail--gap-0 {
gap: var(--space-0);
}
.sui-card-rail--gap-0_5 {
gap: var(--space-0_5);
}
.sui-card-rail--gap-1 {
gap: var(--space-1);
}
.sui-card-rail--gap-1_5 {
gap: var(--space-1_5);
}
.sui-card-rail--gap-2 {
gap: var(--space-2);
}
.sui-card-rail--gap-3 {
gap: var(--space-3);
}
.sui-card-rail--gap-4 {
gap: var(--space-4);
}
.sui-card-rail--gap-5 {
gap: var(--space-5);
}
.sui-card-rail--gap-6 {
gap: var(--space-6);
}
.sui-card-rail--gap-8 {
gap: var(--space-8);
}
/* Uniform item sizing: fixed width so items don't stretch/shrink, optional fixed height so a row of
cards is equal-height. Both default to natural sizing when the caller sets no dimension. */
.sui-card-rail > * {
flex: 0 0 var(--sui-card-rail-item-w, auto);
height: var(--sui-card-rail-item-h, auto);
}
@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import CategoryOutlinedIcon from "@mui/icons-material/CategoryOutlined";
import GavelOutlinedIcon from "@mui/icons-material/GavelOutlined";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import { CardRail } from "@app/ui/CardRail";
import { OptionCard } from "@app/ui/OptionCard";
const items = [
{
icon: <ShieldOutlinedIcon />,
title: "Security",
desc: "Redact, sanitize, and watermark every document.",
},
{
icon: <CategoryOutlinedIcon />,
title: "Classification",
desc: "Tag each document against your team's labels.",
},
{
icon: <GavelOutlinedIcon />,
title: "Compliance",
desc: "Enforce frameworks and keep an audit trail.",
},
{
icon: <LayersOutlinedIcon />,
title: "Ingestion",
desc: "OCR and flatten documents as they arrive.",
},
{
icon: <AltRouteOutlinedIcon />,
title: "Routing",
desc: "Send finished documents where they belong.",
},
{
icon: <ScheduleOutlinedIcon />,
title: "Retention",
desc: "Archive and expire on your schedule.",
},
];
const meta: Meta<typeof CardRail> = {
title: "Primitives/CardRail",
component: CardRail,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof CardRail>;
/** A row of equal-size cards that scrolls sideways when they overflow the container. */
export const Default: Story = {
render: () => (
<CardRail itemWidth="16rem" itemHeight="11rem">
{items.map((it) => (
<OptionCard
key={it.title}
icon={it.icon}
title={it.title}
description={it.desc}
cta="Set up"
onSelect={() => {}}
/>
))}
</CardRail>
),
};
+56
View File
@@ -0,0 +1,56 @@
import type {
CSSProperties,
ElementType,
HTMLAttributes,
ReactNode,
} from "react";
import type { StackGap } from "@app/ui/Stack";
import "@app/ui/CardRail.css";
export interface CardRailProps extends HTMLAttributes<HTMLElement> {
/** Token-aligned gap between items (maps to `--space-*`). */
gap?: StackGap;
/** Fixed width for every item (any CSS length); omit to let items size themselves. */
itemWidth?: string;
/** Fixed height for every item; omit for natural height. Equal heights line item footers up. */
itemHeight?: string;
as?: ElementType;
children?: ReactNode;
}
/**
* A horizontal row of equal-sized items that scrolls sideways rather than wrapping - the "rail" of
* cards motif (template galleries, tier pickers, at-a-glance strips). The scrolling sibling to
* {@link Stack} (vertical) and {@link Inline} (horizontal, wraps): it keeps items on one line,
* contains the overscroll so it doesn't trigger the browser back-gesture, and sizes every child
* uniformly so their footers align.
*/
export function CardRail({
gap = "3",
itemWidth,
itemHeight,
as,
className,
style,
children,
...rest
}: CardRailProps) {
const Tag: ElementType = as ?? "div";
const vars = {
...(itemWidth ? { "--sui-card-rail-item-w": itemWidth } : {}),
...(itemHeight ? { "--sui-card-rail-item-h": itemHeight } : {}),
...style,
} as CSSProperties;
const classes = [
"sui-card-rail",
`sui-card-rail--gap-${gap}`,
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<Tag className={classes} style={vars} {...rest}>
{children}
</Tag>
);
}
+1 -23
View File
@@ -19,29 +19,7 @@
color: var(--color-section-label);
}
/* An (i) affordance beside the label: the explanation lives in its tooltip
rather than as permanent subtext under the control. */
.sui-field__info {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: var(--c-text-subtle);
cursor: pointer;
line-height: 0;
}
.sui-field__info:hover {
color: var(--c-text);
}
.sui-field__info:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 999px;
}
/* The label's (i) affordance is the shared InfoTooltip primitive (@app/ui/InfoTooltip). */
.sui-field__required {
/* The base red is a fill colour; as text on the form background it only
+2 -35
View File
@@ -5,7 +5,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { Tooltip } from "@mantine/core";
import { InfoTooltip } from "@app/ui/InfoTooltip";
import "@app/ui/FormField.css";
export interface FormFieldProps {
@@ -77,40 +77,7 @@ export function FormField({
)}
</label>
)}
{info && (
<Tooltip
label={info}
multiline
w={260}
withArrow
position="top"
events={{ hover: true, focus: true, touch: true }}
>
<button
type="button"
className="sui-field__info"
aria-label={
typeof info === "string" ? info : "More information"
}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</Tooltip>
)}
{info && <InfoTooltip label={info} />}
</div>
)}
<div className="sui-field__control">{child}</div>
@@ -0,0 +1,31 @@
.sui-icon-picker__grid {
display: grid;
grid-template-columns: repeat(5, 2.25rem);
gap: 0.25rem;
padding: 0.375rem;
}
.sui-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;
}
.sui-icon-picker__option:hover {
background: var(--c-hover);
color: var(--c-text);
}
.sui-icon-picker__option--selected {
border-color: var(--c-primary);
background: var(--c-primary-subtle);
/* Accent tuned to clear the contrast floor on the tinted chip (light + dark). */
color: var(--c-accent-text);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
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 FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import { IconPicker, type IconPickerOption } from "@app/ui/IconPicker";
const sx = { fontSize: "1.25rem" } as const;
const OPTIONS: IconPickerOption[] = [
{ key: "shield", label: "Shield", node: <ShieldOutlinedIcon sx={sx} /> },
{ key: "lock", label: "Lock", node: <LockOutlinedIcon sx={sx} /> },
{ key: "label", label: "Label", node: <LabelOutlinedIcon sx={sx} /> },
{ key: "layers", label: "Layers", node: <LayersOutlinedIcon sx={sx} /> },
{ key: "folder", label: "Folder", node: <FolderOutlinedIcon sx={sx} /> },
{ key: "bolt", label: "Bolt", node: <BoltOutlinedIcon sx={sx} /> },
{
key: "schedule",
label: "Schedule",
node: <ScheduleOutlinedIcon sx={sx} />,
},
{
key: "sparkle",
label: "Sparkle",
node: <AutoAwesomeOutlinedIcon sx={sx} />,
},
];
const meta: Meta<typeof IconPicker> = {
title: "Primitives/IconPicker",
component: IconPicker,
tags: ["autodocs"],
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof IconPicker>;
/** Click the glyph to open the grid and choose a new icon from the supplied set. */
export const Default: Story = {
render: () => {
const [value, setValue] = useState("shield");
return (
<IconPicker
value={value}
onChange={setValue}
options={OPTIONS}
ariaLabel="Icon"
/>
);
},
};
@@ -0,0 +1,78 @@
import { useState, type ReactNode } from "react";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Dropdown } from "@app/ui/Dropdown";
import "@app/ui/IconPicker.css";
export interface IconPickerOption {
/** Stable identifier stored as the picked value. */
key: string;
/** The glyph to show, sized by the caller. */
node: ReactNode;
/** Accessible name for this option (falls back to the key). */
label?: string;
}
export interface IconPickerProps {
/** The picked option's key. */
value: string;
onChange: (key: string) => void;
/** The icons to choose from, in display order. The caller supplies the set. */
options: IconPickerOption[];
/** Accessible name for the trigger (e.g. "Icon"). */
ariaLabel: string;
size?: "sm" | "md" | "lg";
}
/**
* Pick an icon from a caller-supplied set. The chosen glyph is the trigger; the menu is a grid. The
* icon set is injected (via {@link options}) rather than baked in, so any surface - a pipeline, a
* watched folder, an automation - passes its own vocabulary and shares the one control.
*/
export function IconPicker({
value,
onChange,
options,
ariaLabel,
size = "sm",
}: IconPickerProps) {
// Controlled so a grid button (not a Dropdown.Item) can close the menu on pick.
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.key === value) ?? options[0];
function pick(key: string) {
onChange(key);
setOpen(false);
}
return (
<Dropdown.Root open={open} onOpenChange={setOpen} align="start">
<Dropdown.Trigger>
<ActionIcon variant="secondary" size={size} aria-label={ariaLabel}>
{selected?.node}
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu>
<div className="sui-icon-picker__grid">
{options.map((option) => {
const isSelected = option.key === value;
return (
<button
key={option.key}
type="button"
className={
"sui-icon-picker__option" +
(isSelected ? " sui-icon-picker__option--selected" : "")
}
aria-label={option.label ?? option.key}
aria-pressed={isSelected}
onClick={() => pick(option.key)}
>
{option.node}
</button>
);
})}
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -0,0 +1,19 @@
.sui-info {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: var(--c-text-subtle);
cursor: pointer;
line-height: 0;
}
.sui-info:hover {
color: var(--c-text);
}
.sui-info:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 999px;
}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoTooltip } from "@app/ui/InfoTooltip";
const meta: Meta<typeof InfoTooltip> = {
title: "Primitives/InfoTooltip",
component: InfoTooltip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: {
label: "The folder (key prefix) within the bucket to watch.",
position: "top",
},
};
export default meta;
type Story = StoryObj<typeof InfoTooltip>;
/** Hover or focus the (i) to reveal the explanation. */
export const Default: Story = {};
/** Inline beside a label, the way FormField renders it. */
export const BesideLabel: Story = {
render: (args) => (
<span
style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}
>
<span style={{ fontSize: "0.8125rem", fontWeight: 600 }}>Folder</span>
<InfoTooltip {...args} />
</span>
),
};
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { Tooltip, type FloatingPosition } from "@mantine/core";
import "@app/ui/InfoTooltip.css";
export interface InfoTooltipProps {
/** The explanation shown in the tooltip on hover/focus. */
label: ReactNode;
/** Accessible name for the button. Defaults to the label when it's a string. */
ariaLabel?: string;
/** Which side the tooltip opens on. Default "top". */
position?: FloatingPosition;
}
/**
* The app's standard inline info affordance: a small, muted (i) that reveals supplementary text in a
* hover/focus tooltip, without taking permanent space. Used behind form labels ({@link FormField})
* and anywhere a control needs a hint - one implementation so every (i) reads and behaves the same.
*/
export function InfoTooltip({
label,
ariaLabel,
position = "top",
}: InfoTooltipProps) {
return (
<Tooltip
label={label}
multiline
w={260}
withArrow
position={position}
events={{ hover: true, focus: true, touch: true }}
>
<button
type="button"
className="sui-info"
aria-label={
ariaLabel ?? (typeof label === "string" ? label : "More information")
}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</Tooltip>
);
}
@@ -0,0 +1,84 @@
.sui-option-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
/* Fill whatever height the parent row establishes, so footers align across a row of cards. */
height: 100%;
}
/* Icon and title share a row. */
.sui-option-card__head {
display: flex;
align-items: center;
gap: 0.625rem;
}
.sui-option-card__icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: var(--radius-md);
background: var(--c-primary-subtle);
color: var(--c-accent-fg, var(--c-primary));
}
.sui-option-card__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 640;
color: var(--c-text);
}
.sui-option-card__desc {
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--c-text-subtle);
/* Clamp long descriptions with a trailing ellipsis. No flex-grow: stretching the box defeats
-webkit-line-clamp and hard-clips mid-word instead. */
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--sui-option-card-lines, 3);
}
/* Pin whatever ends the card (the CTA or a note) to the foot, so footers line up across cards
without flex-growing the clamped blurb above. */
.sui-option-card > :last-child {
margin-top: auto;
}
.sui-option-card__foot {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 620;
/* Accent text tuned to clear the contrast floor on the card surface (light + dark), unlike raw
--c-primary which is too light for small text. */
color: var(--c-accent-text);
}
/* Disabled cards recede onto the sunken surface: every label goes muted so the card reads clearly
greyed against the usable ones, while the icon keeps a faint raised chip in its own colour so it
stays distinct from the greyed text rather than flattening into it. */
.sui-option-card--disabled {
background: var(--c-surface-sunken);
cursor: default;
}
.sui-option-card--disabled .sui-option-card__icon {
background: var(--c-surface);
color: var(--c-text-subtle);
}
.sui-option-card--disabled .sui-option-card__title,
.sui-option-card--disabled .sui-option-card__desc,
.sui-option-card--disabled .sui-option-card__foot {
color: var(--c-text-muted);
font-weight: 600;
}
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import CategoryOutlinedIcon from "@mui/icons-material/CategoryOutlined";
import GavelOutlinedIcon from "@mui/icons-material/GavelOutlined";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { OptionCard } from "@app/ui/OptionCard";
const setUp = (
<>
Set up
<ArrowForwardRoundedIcon style={{ fontSize: "1rem" }} />
</>
);
const comingSoon = (
<>
<LockOutlinedIcon style={{ fontSize: "0.95rem" }} />
Coming soon
</>
);
const meta: Meta<typeof OptionCard> = {
title: "Primitives/OptionCard",
component: OptionCard,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
icon: <ShieldOutlinedIcon />,
title: "Security",
description:
"Redact sensitive information, strip active content, and watermark every document.",
cta: setUp,
disabled: false,
onSelect: () => {},
},
argTypes: {
icon: { control: false },
cta: { control: false },
note: { control: false },
onSelect: { control: false },
descriptionLines: { control: { type: "number", min: 1, max: 6 } },
},
decorators: [
(S) => (
<div style={{ width: "16rem", height: "12rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof OptionCard>;
/** Toggle `disabled`, edit the title/description, change the clamp in controls. */
export const Playground: Story = {};
/** Inert: no click or hover, muted, with a note in place of the CTA. */
export const Disabled: Story = {
args: { disabled: true, note: comingSoon },
};
/** The gallery use case: a row of selectable options with one disabled. */
export const Gallery: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div style={{ display: "flex", gap: "0.75rem", height: "12rem" }}>
<div style={{ flex: "0 0 16rem" }}>
<OptionCard
icon={<ShieldOutlinedIcon />}
title="Security"
description="Redact sensitive information, strip active content, and watermark every document."
cta={setUp}
onSelect={() => {}}
/>
</div>
<div style={{ flex: "0 0 16rem" }}>
<OptionCard
icon={<CategoryOutlinedIcon />}
title="Classification"
description="Identify each document's type against your team's labels and tag it automatically."
cta={setUp}
onSelect={() => {}}
/>
</div>
<div style={{ flex: "0 0 16rem" }}>
<OptionCard
icon={<GavelOutlinedIcon />}
title="Compliance"
description="Enforce your regulatory frameworks and keep an audit trail of every change."
disabled
note={comingSoon}
/>
</div>
</div>
),
};
@@ -0,0 +1,84 @@
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { Card } from "@app/ui/Card";
import "@app/ui/OptionCard.css";
export interface OptionCardProps {
/** Leading glyph, shown in a tinted chip. */
icon: ReactNode;
title: ReactNode;
/** Short blurb under the title; clamped to {@link descriptionLines} lines. */
description?: ReactNode;
/**
* Footer shown when the card is selectable - typically a call to action like "Set up ->". Pinned
* to the bottom edge so footers line up across a row of cards.
*/
cta?: ReactNode;
/**
* When true the card is inert (no click, no hover) and recedes to a muted, sunken treatment.
* {@link note} replaces the CTA to say why (e.g. a "coming soon" or lock chip).
*/
disabled?: boolean;
note?: ReactNode;
/** Lines the description clamps to before ellipsis. Default 3. */
descriptionLines?: number;
/** Fires when a selectable card is clicked or activated by keyboard. Ignored when disabled. */
onSelect?: () => void;
className?: string;
}
/**
* A choice presented as a titled card: a tinted icon chip, a title, a clamped blurb, and a footer (a
* CTA when selectable, a muted note when not). A primitive for the recurring "pick one of these"
* motif (template galleries, feature pickers) so its layout, disabled treatment and select a11y are
* shared rather than re-styled per feature.
*/
export function OptionCard({
icon,
title,
description,
cta,
disabled = false,
note,
descriptionLines = 3,
onSelect,
className,
}: OptionCardProps) {
const interactive = !disabled && !!onSelect;
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect?.();
}
}
return (
<Card
interactive={interactive}
className={[
"sui-option-card",
disabled ? "sui-option-card--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={{ "--sui-option-card-lines": descriptionLines } as CSSProperties}
onClick={interactive ? onSelect : undefined}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onKeyDown={interactive ? onKeyDown : undefined}
aria-disabled={disabled || undefined}
>
<div className="sui-option-card__head">
<span className="sui-option-card__icon" aria-hidden>
{icon}
</span>
<h3 className="sui-option-card__title">{title}</h3>
</div>
{description && <p className="sui-option-card__desc">{description}</p>}
{(disabled ? note : cta) && (
<span className="sui-option-card__foot">{disabled ? note : cta}</span>
)}
</Card>
);
}
+4
View File
@@ -10,6 +10,10 @@ export * from "@app/ui/ToggleSwitch";
export * from "@app/ui/ProgressBar";
export * from "@app/ui/MetricCard";
export * from "@app/ui/NodeCard";
export * from "@app/ui/OptionCard";
export * from "@app/ui/CardRail";
export * from "@app/ui/IconPicker";
export * from "@app/ui/InfoTooltip";
export * from "@app/ui/NavItem";
export * from "@app/ui/NavSurface";
export * from "@app/ui/Surface";
@@ -10,7 +10,6 @@ describe("sidebarGroups (SaaS)", () => {
expect(GROUP_PROCESSOR.map((e) => e.id)).toEqual([
"home",
"sources",
"policies",
"pipelines",
"documents",
]);
+15 -3
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { Home } from "@portal/views/Home";
import { Users } from "@portal/views/Users";
import { Documents } from "@portal/views/Documents";
@@ -7,7 +7,6 @@ import { Pipelines } from "@portal/views/Pipelines";
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
import { Sources } from "@portal/views/Sources";
import { Integrations } from "@portal/views/Integrations";
import { Policies } from "@portal/views/Policies";
import { EditorAdmin } from "@portal/views/EditorAdmin";
import { Infrastructure } from "@portal/views/Infrastructure";
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
@@ -27,6 +26,17 @@ const DeveloperDocs = lazy(() =>
// so they resolve to the portal, not the editor root.
const rel = (viewPath: string) => viewPath.replace(/^\//, "");
/** Redirect the retired Policies path to the unified Pipelines page, carrying any query string. */
function PoliciesRedirect() {
const { search } = useLocation();
return (
<Navigate
to={{ pathname: toPortalPath(VIEW_PATHS.pipelines), search }}
replace
/>
);
}
export function ViewRouter() {
return (
<Routes>
@@ -65,7 +75,9 @@ export function ViewRouter() {
element={<Navigate to={toPortalPath(VIEW_PATHS.sources)} replace />}
/>
<Route path={rel(VIEW_PATHS.integrations)} element={<Integrations />} />
<Route path={rel(VIEW_PATHS.policies)} element={<Policies />} />
{/* Policies merged into Pipelines (a policy is a pipeline the org requires). Keep the old
path working, preserving its query (e.g. onboarding's ?setup=<category>). */}
<Route path={rel(VIEW_PATHS.policies)} element={<PoliciesRedirect />} />
<Route path={rel(VIEW_PATHS.documents)} element={<Documents />} />
<Route path={rel(VIEW_PATHS.editor)} element={<EditorAdmin />} />
<Route
@@ -57,6 +57,14 @@ export interface Policy {
name: string;
owner?: string | null;
enabled: boolean;
/**
* Org-mandated ("this is a policy your organisation requires"). First-class, independent of the
* trigger: a required pipeline can't be paused, disabled, or deleted by an ordinary member, and
* 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[];
/**
@@ -89,6 +97,10 @@ export interface PipelineView {
id: string;
name: string;
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;
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { parseSimplePolicy } from "@portal/api/policies";
import { policyStep, policyStepToWire } from "@app/policies/operations";
import type { Policy } from "@portal/api/pipelines";
/** A template-representable classification policy, `required` as given. */
function classificationPolicy(required: boolean): Policy {
return {
id: "plc-1",
name: "Classify",
enabled: true,
required,
inputs: [],
steps: [policyStepToWire(policyStep("classify"))],
output: { type: "inline", options: { categoryId: "classification" } },
outputIds: [],
};
}
describe("parseSimplePolicy", () => {
it("carries required through so the wizard reopens org-mandated", () => {
const entry = parseSimplePolicy(classificationPolicy(true));
expect(entry?.policy?.state.required).toBe(true);
});
it("keeps a non-required policy non-required", () => {
const entry = parseSimplePolicy(classificationPolicy(false));
expect(entry?.policy?.state.required).toBe(false);
});
it("reads runOn from editor, not the stale options bag", () => {
// The builder writes the current runOn to `editor` and leaves the legacy options-bag copy
// behind, so the two disagree here on purpose; `editor` must win.
const policy: Policy = {
...classificationPolicy(false),
output: {
type: "inline",
options: { categoryId: "classification", runOn: "upload" },
},
editor: { allowed: true, runOn: "export" },
};
const entry = parseSimplePolicy(policy);
expect(entry?.policy?.state.runOn).toBe("export");
expect(entry?.policy?.state.runsOnEditor).toBe(true);
});
});
+96 -37
View File
@@ -12,13 +12,19 @@
import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { resolveRunOn } from "@app/policies/runOn";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import {
policyStep,
policyStepFromWire,
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type { Policy } from "@portal/api/pipelines";
import type {
PolicyDecodedState,
PolicyRunView,
WireOutputOptions,
WirePipelineStep,
WirePolicy,
} from "@app/policies/types";
@@ -75,6 +81,12 @@ export interface PolicyConfigDef {
export interface PolicyState {
configured: boolean;
status: PolicyStatus;
/** Org-mandated policy (see the pipeline `Policy.required`). */
required: boolean;
name?: string;
icon?: string;
/** Options the wizard doesn't model, preserved so a wizard save round-trips them (see codec). */
extraOptions?: Record<string, unknown>;
sources: string[];
/** Whether the editor runs this policy per file; stored, not derived from `sources`. */
runsOnEditor?: boolean;
@@ -92,6 +104,9 @@ export interface PolicyState {
}
export interface PolicySetupResult {
required: boolean;
/** Stored options the wizard doesn't model, carried through so a save preserves them (see codec). */
extraOptions?: Record<string, unknown>;
fieldValues: Record<string, boolean | string | string[]>;
sources: string[];
runsOnEditor: boolean;
@@ -435,6 +450,8 @@ function decoratePolicy(
const state: PolicyState = {
configured: true,
status,
required: decoded.required,
extraOptions: decoded.extraOptions,
sources: decoded.sources,
runsOnEditor: decoded.runsOnEditor,
scopeTypes: decoded.scopeTypes,
@@ -521,6 +538,81 @@ export function assemblePolicies(
return { summary, catalogue };
}
/**
* Whether `inner` appears in `outer` in order (no reordering), each used once. The wizard renders
* a category's capabilities in a fixed order, so a policy whose enabled tools are a subsequence of
* the template's canonical chain round-trips; any other order cannot be shown simply.
*/
function isOrderedSubset<T>(inner: T[], outer: T[]): boolean {
let cursor = 0;
for (const item of inner) {
const at = outer.indexOf(item, cursor);
if (at === -1) return false;
cursor = at + 1;
}
return true;
}
/**
* The CatalogueEntry that seeds the simple wizard for a policy, or null if the wizard can't express
* it losslessly - the single authority for routing an edit to the wizard vs the full builder. Null on
* anything the wizard can't show: no template origin, a server input/destination, an unknown or extra
* tool, or a reordered chain.
*/
export function parseSimplePolicy(
policy: Policy,
runs: PolicyRunView[] = [],
): CatalogueEntry | null {
const rawCategory = policy.output?.options?.categoryId;
const categoryId = typeof rawCategory === "string" ? rawCategory : "";
if (!categoryId) return null;
const category = POLICY_CATEGORIES.find((c) => c.id === categoryId);
const config = POLICY_CONFIG[categoryId];
if (!category || !config) return null;
// The wizard only runs on the editor (sources + runOn live in the options bag, not as server
// inputs/destinations). A policy carrying either cannot be shown simply.
if ((policy.inputs?.length ?? 0) > 0) return null;
if ((policy.outputIds?.length ?? 0) > 0) return null;
// Every step must be one of this template's capabilities, and they must stay in canonical order.
const canonical = config.defaultOperations.map((op) => op.toolId);
const toolIds: PolicyToolId[] = [];
for (const step of policy.steps) {
const parsed = policyStepFromWire(step as WirePipelineStep);
if (!parsed || !canonical.includes(parsed.toolId)) return null;
toolIds.push(parsed.toolId);
}
if (!isOrderedSubset(toolIds, canonical)) return null;
const wire: WirePolicy = {
id: policy.id ?? "",
name: policy.name,
enabled: policy.enabled,
required: policy.required,
trigger: null,
steps: policy.steps as WirePipelineStep[],
// The options bag is untyped on the pipeline record; the codec reads it defensively.
output: {
type: "inline",
options: (policy.output?.options ?? {}) as Partial<WireOutputOptions>,
},
editor: policy.editor,
};
const decorated = decoratePolicy(fromWirePolicy(wire), runs, false);
if (!decorated) return null;
// The wire codec models neither the icon nor the (custom) name; carry them from the raw record so
// the Customise hand-off preserves them instead of resetting to the category default.
return {
category,
config,
policy: {
...decorated,
state: { ...decorated.state, name: policy.name, icon: policy.icon },
},
};
}
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
export async function fetchPolicy(id: string): Promise<WirePolicy> {
return apiClient.local.json<WirePolicy>(
@@ -564,9 +656,6 @@ export async function clearProcessedHistory(id: string): Promise<void> {
// ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ────────
const DEFAULT_RETRIES = 3;
const DEFAULT_RETRY_DELAY = 5;
// Catalogue policy bodies carry categoryId at the top level so the pipelines
// mock handler can discriminate them from raw pipeline saves on the shared
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
@@ -597,6 +686,8 @@ export function buildWireFromSetup(
id: entry.policy?.state.backendId ?? "",
name: policyDisplayName(entry, t),
enabled,
required: result.required,
extraOptions: result.extraOptions,
categoryId: entry.category.id,
sources: result.sources,
runsOnEditor: result.runsOnEditor,
@@ -614,38 +705,6 @@ export function buildWireFromSetup(
};
}
/** Build a wire policy from an existing decorated policy (e.g. for pause/resume). */
export function buildWireFromState(
entry: CatalogueEntry,
policy: DecoratedPolicy,
enabled: boolean,
t: TFunction,
): CatalogueWireBody {
const s = policy.state;
return {
categoryId: entry.category.id,
...toWirePolicy({
id: s.backendId ?? "",
name: policyDisplayName(entry, t),
enabled,
categoryId: entry.category.id,
sources: s.sources,
// Carry the stored value through: pause/resume must not re-derive it.
runsOnEditor: s.runsOnEditor === true,
scopeTypes: s.scopeTypes,
reviewerEmail: s.reviewerEmail,
fieldValues: s.fieldValues,
runOn: resolveRunOn(s.runOn, entry.category.id),
outputMode: s.outputMode ?? "new_version",
outputName: s.outputName ?? "",
outputNamePosition: s.outputNamePosition ?? "suffix",
maxRetries: s.maxRetries ?? DEFAULT_RETRIES,
retryDelayMinutes: s.retryDelayMinutes ?? DEFAULT_RETRY_DELAY,
steps: policy.steps,
}),
};
}
/**
* POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The
* real endpoint is multipart; the portal sends no files, relying on whatever
@@ -42,10 +42,10 @@ export function ProcessorFlow({ dataOverride }: ProcessorFlowProps = {}) {
const [lens, setLens] = useState<Lens>("flow");
const isLoading = loading && data === null;
/** Deep-link to the Policies page and auto-open that policy's setup wizard. */
/** Deep-link to the Pipelines page and auto-open that suggested policy's setup wizard. */
const openPolicySetup = (key: string) =>
navigate(
`${toPortalPath(VIEW_PATHS.policies)}?setup=${encodeURIComponent(key)}`,
`${toPortalPath(VIEW_PATHS.pipelines)}?setup=${encodeURIComponent(key)}`,
);
/** Deep-link to Infrastructure with the audit-log tab open. */
@@ -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) {
@@ -0,0 +1,5 @@
.portal-enforce {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
import { InfoTooltip, ToggleSwitch } from "@app/ui";
import "@portal/components/pipelines/EnforceAsPolicyControl.css";
export interface EnforceAsPolicyControlProps {
/** Org-mandated policy (see Policy.required). */
required: boolean;
onRequiredChange: (required: boolean) => void;
}
/**
* The "Enforce as policy" switch plus the app's standard inline (i) info affordance explaining what
* it means. Shared by the builder header and the simple wizard so the control reads and behaves
* identically wherever a pipeline can be made org-mandated.
*/
export function EnforceAsPolicyControl({
required,
onRequiredChange,
}: EnforceAsPolicyControlProps) {
const { t } = useTranslation();
return (
<span className="portal-enforce">
<ToggleSwitch
size="sm"
checked={required}
onChange={onRequiredChange}
label={t("portal.pipelines.enforce.label")}
/>
<InfoTooltip
label={t("portal.pipelines.enforce.desc")}
ariaLabel={t("portal.pipelines.enforce.info")}
position="bottom"
/>
</span>
);
}
@@ -18,6 +18,8 @@ const noop = () => {};
*/
function Playground({ initialName }: { initialName: string }) {
const [name, setName] = useState(initialName);
const [icon, setIcon] = useState("route");
const [required, setRequired] = useState(false);
const blockers =
name.trim() === ""
? [
@@ -30,6 +32,10 @@ function Playground({ initialName }: { initialName: string }) {
<PipelineCreateHeader
name={name}
onNameChange={setName}
icon={icon}
onIconChange={setIcon}
required={required}
onRequiredChange={setRequired}
canSave={blockers.length === 0}
blockers={blockers}
saving={false}
@@ -27,6 +27,10 @@ function renderHeader(overrides: Partial<PipelineCreateHeaderProps> = {}) {
render(
<PipelineCreateHeader
name="Claims redaction"
icon=""
onIconChange={vi.fn()}
required={false}
onRequiredChange={vi.fn()}
canSave
blockers={[]}
saving={false}
@@ -1,12 +1,20 @@
import { useTranslation } from "react-i18next";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { ActionIcon, Button, Input } from "@app/ui";
import { ActionIcon, Button, IconPicker, Input } from "@app/ui";
import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
import { PIPELINE_ICON_OPTIONS } from "@portal/components/pipelines/pipelineIcon";
import { EnforceAsPolicyControl } from "@portal/components/pipelines/EnforceAsPolicyControl";
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;
/** "Enforce as policy" toggle, shown in the actions row. */
required: boolean;
onRequiredChange: (required: boolean) => void;
canSave: boolean;
/** Everything still owed before the pipeline can be created, shown on the disabled create button. */
@@ -28,6 +36,10 @@ export interface PipelineCreateHeaderProps {
export function PipelineCreateHeader({
name,
onNameChange,
icon,
onIconChange,
required,
onRequiredChange,
canSave,
blockers,
saving,
@@ -49,6 +61,13 @@ export function PipelineCreateHeader({
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
</ActionIcon>
<IconPicker
value={icon}
onChange={onIconChange}
options={PIPELINE_ICON_OPTIONS}
ariaLabel={t("portal.pipelines.builder.icon.label")}
/>
<Input
className="portal-pipeline-create-header__name"
value={name}
@@ -58,6 +77,11 @@ export function PipelineCreateHeader({
/>
<div className="portal-pipeline-create-header__actions">
<EnforceAsPolicyControl
required={required}
onRequiredChange={onRequiredChange}
/>
{/* The pair share one tooltip target because a disabled button swallows its own hover - the
wrapper is what the pointer lands on. */}
<PipelineBlockerTooltip
@@ -26,10 +26,16 @@ function Playground({
}) {
const [name, setName] = useState(initialName);
const [enabled, setEnabled] = useState(initialEnabled);
const [icon, setIcon] = useState("route");
const [required, setRequired] = useState(false);
return (
<PipelineEditHeader
name={name}
onNameChange={setName}
icon={icon}
onIconChange={setIcon}
required={required}
onRequiredChange={setRequired}
enabled={enabled}
onTogglePause={() => setEnabled((e) => !e)}
togglingEnabled={false}
@@ -30,6 +30,10 @@ function renderHeader(overrides: Partial<PipelineEditHeaderProps> = {}) {
render(
<PipelineEditHeader
name="Claims redaction"
icon=""
onIconChange={vi.fn()}
required={false}
onRequiredChange={vi.fn()}
enabled
togglingEnabled={false}
canSave
@@ -8,13 +8,21 @@ import PowerSettingsNewRoundedIcon from "@mui/icons-material/PowerSettingsNewRou
import ReplayRoundedIcon from "@mui/icons-material/ReplayRounded";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded";
import { ActionIcon, Button, Dropdown, Input } from "@app/ui";
import { ActionIcon, Button, Dropdown, IconPicker, Input } from "@app/ui";
import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
import { PIPELINE_ICON_OPTIONS } from "@portal/components/pipelines/pipelineIcon";
import { EnforceAsPolicyControl } from "@portal/components/pipelines/EnforceAsPolicyControl";
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;
/** "Enforce as policy" toggle, shown in the actions row. */
required: boolean;
onRequiredChange: (required: boolean) => void;
/** The pipeline's live state. Toggling it takes effect immediately, not on save. */
enabled: boolean;
@@ -49,6 +57,10 @@ export interface PipelineEditHeaderProps {
export function PipelineEditHeader({
name,
onNameChange,
icon,
onIconChange,
required,
onRequiredChange,
enabled,
onTogglePause,
togglingEnabled,
@@ -116,6 +128,13 @@ export function PipelineEditHeader({
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
</ActionIcon>
<IconPicker
value={icon}
onChange={onIconChange}
options={PIPELINE_ICON_OPTIONS}
ariaLabel={t("portal.pipelines.builder.icon.label")}
/>
{renaming ? (
<Input
ref={inputRef}
@@ -145,6 +164,11 @@ export function PipelineEditHeader({
</div>
<div className="portal-pipeline-edit-header__actions">
<EnforceAsPolicyControl
required={required}
onRequiredChange={onRequiredChange}
/>
{/* Pause and Save both write the whole policy, so they are mutually exclusive: neither can
start while the other is committing, or the two writes race and the loser's version wins. */}
<Button
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
} from "@portal/components/policies/storyFixtures";
import { PipelineTemplateCard } from "@portal/components/pipelines/PipelineTemplateCard";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const compliance = POLICY_CATEGORIES.find((c) => c.id === "compliance")!;
const meta: Meta<typeof PipelineTemplateCard> = {
title: "Portal/Pipelines/PipelineTemplateCard",
component: PipelineTemplateCard,
parameters: { layout: "padded" },
args: { onOpen: () => {} },
};
export default meta;
type Story = StoryObj<typeof PipelineTemplateCard>;
/** An available template — opens the simple guided setup. */
export const Default: Story = {
args: {
entry: { category: security, config: POLICY_CONFIG.security, policy: null },
},
};
/** Setup unavailable (e.g. the AI engine is off) — shown but inert. */
export const Locked: Story = {
args: {
entry: { category: security, config: POLICY_CONFIG.security, policy: null },
locked: true,
lockedLabel: "Requires AI engine",
},
};
/** Coming-soon category — locked and inert. */
export const ComingSoon: Story = {
args: {
entry: {
category: compliance,
config: POLICY_CONFIG.compliance,
policy: null,
},
},
};
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { OptionCard } from "@app/ui";
import type { CatalogueEntry } from "@portal/api/policies";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
interface PipelineTemplateCardProps {
entry: CatalogueEntry;
/** Open the simple setup wizard seeded from this template. */
onOpen: (entry: CatalogueEntry) => void;
/** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */
locked?: boolean;
/** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */
lockedLabel?: string;
}
/**
* A template in the Pipelines gallery: a ready-made starting point that opens the simple, guided
* setup. A thin adapter over the {@link OptionCard} primitive - it maps the catalogue category to
* the card's icon/title/blurb and picks the CTA vs the disabled note (coming soon / AI-locked).
*/
export function PipelineTemplateCard({
entry,
onOpen,
locked = false,
lockedLabel,
}: PipelineTemplateCardProps) {
const { t } = useTranslation();
const { category } = entry;
const comingSoon = category.comingSoon === true;
const disabled = comingSoon || locked;
return (
<OptionCard
icon={policyCategoryIcon(category.id)}
title={t(category.label)}
description={t(category.desc)}
disabled={disabled}
onSelect={() => onOpen(entry)}
cta={
<>
{t("portal.pipelines.templates.setUp")}
<ArrowForwardRoundedIcon style={{ fontSize: "1rem" }} />
</>
}
note={
<>
<LockOutlinedIcon style={{ fontSize: "0.95rem" }} />
{comingSoon
? t("portal.policies.card.comingSoon")
: (lockedLabel ?? t("portal.policies.card.requiresAiEngine"))}
</>
}
/>
);
}
@@ -7,6 +7,8 @@ const PIPELINES: PipelineView[] = [
id: "pipe-intake",
name: "Claims intake",
enabled: true,
required: false,
icon: "shield",
status: "active",
trigger: "folder-watch",
sources: [{ id: "src-claims", name: "Claims intake" }],
@@ -18,6 +20,8 @@ const PIPELINES: PipelineView[] = [
id: "pipe-archive",
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,9 +28,18 @@ 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.text({
key: "type",
header: t("portal.pipelines.table.type", "Type"),
sortable: true,
get: (p) =>
p.required
? t("portal.pipelines.type.policy")
: t("portal.pipelines.type.pipeline"),
}),
column.text({
key: "trigger",
header: t("portal.pipelines.table.trigger", "Trigger"),
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { canonicalPipelineIconKey } from "@portal/components/pipelines/pipelineIcon";
describe("canonicalPipelineIconKey", () => {
it("maps a category id to the picker's canonical key", () => {
// The builder seeds a template hand-off's icon from its category id; the picker only offers the
// canonical keys, so this must resolve or the shield shows as the default glyph.
expect(canonicalPipelineIconKey("security")).toBe("shield");
expect(canonicalPipelineIconKey("classification")).toBe("label");
});
it("leaves a canonical key (or empty) unchanged", () => {
expect(canonicalPipelineIconKey("shield")).toBe("shield");
expect(canonicalPipelineIconKey("")).toBe("");
});
});
@@ -0,0 +1,126 @@
// 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 { IconPickerOption } from "@app/ui";
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,
};
// A category id doubles as an icon value (see the ICONS aliases), but the picker only offers the
// canonical keys below, so a category-id value must be mapped to its canonical key or IconPicker
// can't match it and falls back to the default glyph. Keep in sync with the ICONS aliases.
const CATEGORY_ICON_KEY: Record<string, string> = {
ingestion: "layers",
security: "shield",
classification: "label",
compliance: "check",
routing: "route",
retention: "schedule",
};
/** An icon value (a pickable key, or a category-id alias) mapped to the canonical key the picker
* offers, so a template-derived pipeline shows its glyph as the selected option. */
export function canonicalPipelineIconKey(key: string): string {
return CATEGORY_ICON_KEY[key] ?? key;
}
/** 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} />;
}
/** The pipeline's icon vocabulary as options for the shared SUI `IconPicker`. */
export const PIPELINE_ICON_OPTIONS: IconPickerOption[] = PIPELINE_ICON_KEYS.map(
(key) => ({ key, label: key, node: pipelineIcon(key, "1.25rem") }),
);
@@ -32,17 +32,11 @@ vi.mock("react-i18next", () => ({
initReactI18next: { type: "3rdParty", init: vi.fn() },
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const fetchIntegrations = vi.fn();
vi.mock("@portal/api/integrations", () => ({
fetchIntegrations: () => fetchIntegrations(),
}));
const CONTINUE = "portal.policies.wizard.actions.continue";
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
@@ -60,6 +54,7 @@ function editEntry(steps: PipelineStep[]): CatalogueEntry {
state: {
configured: true,
status: "active",
required: true,
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
@@ -80,15 +75,13 @@ function editEntry(steps: PipelineStep[]): CatalogueEntry {
return { category: security, config: securityConfig, policy };
}
/** Advance the wizard from the workflow tab to the settings tab and submit. */
/** Submit the single-page wizard. */
async function submitWizard(saveLabel: string) {
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
}
describe("PolicySetupWizard", () => {
beforeEach(() => {
fetchSources.mockResolvedValue({ sources: [] });
fetchIntegrations.mockResolvedValue([]);
});
@@ -102,7 +95,12 @@ describe("PolicySetupWizard", () => {
]);
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
<PolicySetupWizard
entry={entry}
onClose={vi.fn()}
onSubmit={onSubmit}
onCustomise={vi.fn()}
/>,
);
await submitWizard(SAVE_CHANGES);
@@ -117,6 +115,32 @@ describe("PolicySetupWizard", () => {
]);
});
it("preserves stored sources and unmodelled options on save", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry = editEntry([
{ operation: "/api/v1/security/auto-redact", parameters: {} },
]);
// A customised policy carries a stored source the wizard has no UI for and an editor-authored
// blob the codec doesn't model. A wizard save must round-trip both, not silently drop them.
entry.policy!.state.sources = ["src-contracts"];
entry.policy!.state.extraOptions = { automation: { name: "x" } };
render(
<PolicySetupWizard
entry={entry}
onClose={vi.fn()}
onSubmit={onSubmit}
onCustomise={vi.fn()}
/>,
);
await submitWizard(SAVE_CHANGES);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
expect(result.sources).toEqual(["src-contracts"]);
expect(result.extraOptions).toEqual({ automation: { name: "x" } });
});
it("hides the Purview step when no Purview tenant is connected", async () => {
fetchIntegrations.mockResolvedValue([]);
const entry: CatalogueEntry = {
@@ -126,7 +150,12 @@ describe("PolicySetupWizard", () => {
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={vi.fn()} />,
<PolicySetupWizard
entry={entry}
onClose={vi.fn()}
onSubmit={vi.fn()}
onCustomise={vi.fn()}
/>,
);
// Sanitize is in the same chain and always shows, so once it renders the chain has loaded.
@@ -146,7 +175,12 @@ describe("PolicySetupWizard", () => {
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={vi.fn()} />,
<PolicySetupWizard
entry={entry}
onClose={vi.fn()}
onSubmit={vi.fn()}
onCustomise={vi.fn()}
/>,
);
await waitFor(() =>
@@ -163,7 +197,12 @@ describe("PolicySetupWizard", () => {
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
<PolicySetupWizard
entry={entry}
onClose={vi.fn()}
onSubmit={onSubmit}
onCustomise={vi.fn()}
/>,
);
await submitWizard(ENABLE);
@@ -178,4 +217,27 @@ describe("PolicySetupWizard", () => {
const redact = result.steps[0].parameters as { listOfText?: string };
expect(redact.listOfText).toBeTruthy();
});
it("defaults a new security policy to enforcing on export", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry: CatalogueEntry = {
category: security,
config: securityConfig,
policy: null,
};
render(
<PolicySetupWizard
entry={entry}
onClose={vi.fn()}
onSubmit={onSubmit}
onCustomise={vi.fn()}
/>,
);
await submitWizard(ENABLE);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
expect(result.runOn).toBe("export");
});
});
@@ -1,22 +1,9 @@
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import CheckIcon from "@mui/icons-material/Check";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import StorageOutlinedIcon from "@mui/icons-material/StorageOutlined";
import {
Banner,
Button,
Card,
FormField,
Input,
Modal,
Select,
Tabs,
ToggleSwitch,
} from "@app/ui";
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
import { Banner, Button, Card, Modal, ToggleSwitch } from "@app/ui";
import { SettingsRow } from "@app/ui/SettingsRow";
import { EnforceAsPolicyControl } from "@portal/components/pipelines/EnforceAsPolicyControl";
import {
humanizeEndpoint,
type CatalogueEntry,
@@ -32,11 +19,9 @@ import {
type PolicyToolStep,
} from "@app/policies/operations";
import { resolveRunOn } from "@app/policies/runOn";
import { useSources } from "@portal/queries/sources";
import { fetchIntegrations } from "@portal/api/integrations";
import { errorMessage } from "@portal/api/http";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
@@ -44,21 +29,6 @@ import { PolicyPurviewConfig } from "@portal/components/policies/PolicyPurviewCo
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
import "@portal/views/Policies.css";
/** Outline icon for a source tile, keyed by the backend source `type`. */
function sourceIcon(type: string): ReactNode {
const sx = { fontSize: "1.1rem" } as const;
switch (type) {
case "editor":
return <EditOutlinedIcon sx={sx} />;
case "folder":
return <FolderOutlinedIcon sx={sx} />;
case "s3":
return <CloudOutlinedIcon sx={sx} />;
default:
return <StorageOutlinedIcon sx={sx} />;
}
}
interface PolicySetupWizardProps {
/** The category being configured, or null when closed. */
entry: CatalogueEntry | null;
@@ -68,10 +38,14 @@ interface PolicySetupWizardProps {
* async; if it rejects the wizard re-enables submit and surfaces the failure.
*/
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
/**
* Fires when the user asks to Customise: hands the current (unsaved) settings to the full pipeline
* builder, which takes over editing. The builder can express anything the simple wizard can't, so
* this is a one-way step unless the pipeline stays simple-representable.
*/
onCustomise: (entry: CatalogueEntry, result: PolicySetupResult) => void;
}
type Step = "workflow" | "settings";
/** A policy step plus whether it runs. */
type ToolState = PolicyToolStep & { enabled: boolean };
@@ -227,6 +201,7 @@ export function PolicySetupWizard({
entry,
onClose,
onSubmit,
onCustomise,
}: PolicySetupWizardProps) {
// Re-key the wizard on the opened category so all state resets cleanly when a
// different category is opened (avoids stale field values bleeding across).
@@ -236,6 +211,7 @@ export function PolicySetupWizard({
entry={entry}
onClose={onClose}
onSubmit={onSubmit}
onCustomise={onCustomise}
/>
) : null;
}
@@ -244,10 +220,12 @@ function PolicySetupWizardBody({
entry,
onClose,
onSubmit,
onCustomise,
}: {
entry: CatalogueEntry;
onClose: () => void;
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
onCustomise: (entry: CatalogueEntry, result: PolicySetupResult) => void;
}) {
const { t } = useTranslation();
@@ -255,7 +233,6 @@ function PolicySetupWizardBody({
const isEdit = policy != null;
const isClassification = category.id === "classification";
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() => {
const seeded = seedTools(entry);
// Classification's single tool has no toggle in the workflow step, so keep it
@@ -265,60 +242,26 @@ function PolicySetupWizardBody({
? seeded.map((t) => ({ ...t, enabled: true }))
: seeded;
});
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
// Real sources only; editor participation is its own flag, not an entry here.
const [sources, setSources] = useState<string[]>(() =>
(policy?.state.sources ?? []).filter((s) => s !== "editor"),
);
// Whether the policy runs in the editor. Defaults on for a new policy (the common case);
// on edit it comes straight from the stored flag, never re-derived from the sources list.
const [runsOnEditor, setRunsOnEditor] = useState<boolean>(
policy?.state.runsOnEditor ?? true,
);
const sourcesAsync = useSources();
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
// The editor is always an available source. The backend now returns it as a
// virtual source too, so take that when present (avoids a duplicate tile) and
// otherwise fall back to a synthetic one; keep it first, selected by default.
const editorSource = backendSources.find((s) => s.id === "editor") ?? {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources.filter((s) => s.id !== "editor")];
}, [sourcesAsync.data, t]);
// Document-type scoping has no UI; preserve any saved scope on edit and
// default new policies to all document types.
// No UI for any of these: each carries the stored value through on edit, and a sensible default for
// a new policy - runOn per category (security enforces on export), the rest run-once/new-version.
const [fieldValues] = useState(() => resolveFieldValues(entry));
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
// TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]).
// Store username (which is the email in Spring Security) as reviewerEmail.
// See UserSelector.tsx in the editor for the grouping/display pattern.
const [reviewerEmail] = useState(policy?.state.reviewerEmail ?? "");
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
const [outputMode] = useState<"new_file" | "new_version">(
policy?.state.outputMode ?? "new_version",
);
const [outputName, setOutputName] = useState(policy?.state.outputName ?? "");
const [outputNamePosition, setOutputNamePosition] = useState<
"prefix" | "suffix" | "auto-number"
>(policy?.state.outputNamePosition ?? "suffix");
const [runOn, setRunOn] = useState<"upload" | "export">(() =>
const [outputName] = useState(policy?.state.outputName ?? "");
const [outputNamePosition] = useState<"prefix" | "suffix" | "auto-number">(
policy?.state.outputNamePosition ?? "suffix",
);
const [runOn] = useState<"upload" | "export">(() =>
resolveRunOn(policy?.state.runOn, category.id),
);
// Policies run once; retry config has no UI. Preserve any saved values on
// edit and default new policies to no retries (run once).
const [maxRetries] = useState(policy?.state.maxRetries ?? 0);
const [retryDelayMinutes] = useState(policy?.state.retryDelayMinutes ?? 0);
// A suggested policy is something the org requires by nature, so new ones default to required;
// editing preserves whatever was saved.
const [required, setRequired] = useState(policy?.state.required ?? true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -366,45 +309,48 @@ function PolicySetupWizardBody({
);
}
function toggleSource(id: string) {
// The editor is not a real source: its tile toggles the runsOnEditor flag instead of
// adding "editor" to the sources list.
if (id === "editor") {
setRunsOnEditor((on) => !on);
return;
}
setSources((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
/** The wizard's current state as a submit result: shared by Save and Customise. */
function collectResult(): PolicySetupResult {
const steps: PipelineStep[] = enabledTools.map((tl) =>
policyStepToWire(tl),
);
return {
required,
// Preserve any stored options this wizard has no UI for (a customised policy's sources, an
// editor-authored automation blob) rather than wiping them on save; the builder is where
// those are actually edited.
extraOptions: policy?.state.extraOptions,
runsOnEditor: true,
fieldValues,
sources: policy?.state.sources ?? [],
scopeTypes,
reviewerEmail,
outputMode,
outputName: outputName.trim(),
outputNamePosition,
runOn,
maxRetries,
retryDelayMinutes,
steps,
};
}
// Hand the current settings to the full builder. No "needs at least one tool" guard here: the
// builder has its own, and the point of customising is to keep shaping the chain.
function customise() {
onCustomise(entry, collectResult());
}
async function submit() {
if (submitting) return;
if (enabledTools.length === 0) {
setError(t("portal.policies.wizard.errors.noTools"));
setStep("workflow");
return;
}
setError(null);
setSubmitting(true);
const steps: PipelineStep[] = enabledTools.map((tl) =>
policyStepToWire(tl),
);
try {
await onSubmit(entry, {
fieldValues,
sources,
runsOnEditor,
scopeTypes,
reviewerEmail,
outputMode,
outputName: outputName.trim(),
outputNamePosition,
runOn,
maxRetries,
retryDelayMinutes,
steps,
});
await onSubmit(entry, collectResult());
} catch (e) {
setSubmitting(false);
// Surface the backend's actual reason (e.g. a step missing its account) rather than a
@@ -438,45 +384,27 @@ function PolicySetupWizardBody({
<Button variant="tertiary" size="sm" onClick={onClose}>
{t("portal.policies.wizard.actions.cancel")}
</Button>
{step === "workflow" ? (
<Button
size="sm"
style={{ marginLeft: "auto" }}
onClick={() => setStep("settings")}
>
{t("portal.policies.wizard.actions.continue")}
</Button>
) : (
<>
<Button
variant="secondary"
size="sm"
style={{ marginLeft: "auto" }}
onClick={() => setStep("workflow")}
>
{t("portal.policies.wizard.actions.back")}
</Button>
<Button size="sm" onClick={submit} loading={submitting}>
{isEdit
? t("portal.policies.wizard.actions.saveChanges")
: t("portal.policies.wizard.actions.enablePolicy")}
</Button>
</>
)}
<Button
variant="tertiary"
size="sm"
onClick={customise}
leftSection={<TuneRoundedIcon style={{ fontSize: "1.05rem" }} />}
>
{t("portal.policies.wizard.actions.customise")}
</Button>
<Button
size="sm"
style={{ marginLeft: "auto" }}
onClick={submit}
loading={submitting}
>
{isEdit
? t("portal.policies.wizard.actions.saveChanges")
: t("portal.policies.wizard.actions.enablePolicy")}
</Button>
</div>
}
>
<Tabs
variant="underline"
ariaLabel={t("portal.policies.wizard.tabs.ariaLabel")}
activeKey={step}
onChange={(k) => setStep(k)}
items={[
{ key: "workflow", label: t("portal.policies.wizard.tabs.workflow") },
{ key: "settings", label: t("portal.policies.wizard.tabs.settings") },
]}
/>
{error && (
<Banner
tone="danger"
@@ -485,7 +413,7 @@ function PolicySetupWizardBody({
/>
)}
{step === "workflow" && isClassification && (
{isClassification && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{t(
@@ -503,7 +431,7 @@ function PolicySetupWizardBody({
</div>
)}
{step === "workflow" && !isClassification && (
{!isClassification && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{t(
@@ -577,196 +505,12 @@ function PolicySetupWizardBody({
</div>
)}
{step === "settings" && (
<div className="portal-policies__wizard-section">
{config.fields.length > 0 && (
<>
<h3 className="portal-policies__wizard-heading">
{t("portal.policies.wizard.settings.heading")}
</h3>
<div className="portal-policies__fields">
{config.fields.map((field) => (
<PolicyFieldRow
key={field.key}
field={field}
value={fieldValues[field.key]}
onChange={(v) =>
setFieldValues((prev) => ({ ...prev, [field.key]: v }))
}
/>
))}
</div>
</>
)}
<h3 className="portal-policies__wizard-heading">
{t("portal.policies.wizard.sources.heading")}
</h3>
{sourcesAsync.loading && !sourcesAsync.data ? (
<p className="portal-policies__sources-loading">
{t("portal.policies.wizard.sources.loading")}
</p>
) : (
// The backend always returns the editor as a virtual source, so the
// loaded list is never empty - no "no sources" state exists.
<div className="portal-policies__sources">
{availableSources.map((src) => {
const on =
src.id === "editor" ? runsOnEditor : sources.includes(src.id);
return (
<Button
key={src.id}
variant={on ? "secondary" : "quiet"}
justify="between"
fullWidth
className={
"portal-policies__source" +
(on ? " portal-policies__source--on" : "")
}
// The check keeps its slot when unselected (hidden) so the
// icon + name stay put whether or not the tile is selected.
rightSection={
<CheckIcon
sx={{
fontSize: "1.1rem",
visibility: on ? "visible" : "hidden",
}}
/>
}
onClick={() => toggleSource(src.id)}
aria-pressed={on}
>
<span className="portal-policies__source-label">
{sourceIcon(src.type)}
{src.name}
</span>
</Button>
);
})}
</div>
)}
<h3 className="portal-policies__wizard-heading">
{t("portal.policies.wizard.output.heading")}
</h3>
<div className="portal-policies__fields">
{runsOnEditor && (
<>
<FormField
label={t("portal.policies.wizard.output.runOn.label")}
helperText={t("portal.policies.wizard.output.runOn.helper")}
>
<Select
inputSize="sm"
value={runOn}
onChange={(value) =>
setRunOn(resolveRunOn(value, category.id))
}
options={[
{
value: "upload",
label: t("portal.policies.wizard.output.runOn.upload"),
},
{
value: "export",
label: t("portal.policies.wizard.output.runOn.export"),
},
]}
/>
</FormField>
<FormField
label={t("portal.policies.wizard.output.outputAs.label")}
>
<Select
inputSize="sm"
value={outputMode}
onChange={(value) => {
const mode = (value ?? "new_file") as
| "new_file"
| "new_version";
setOutputMode(mode);
// Auto-number only applies to separate new files.
if (
mode === "new_version" &&
outputNamePosition === "auto-number"
) {
setOutputNamePosition("suffix");
}
}}
options={[
{
value: "new_version",
label: t(
"portal.policies.wizard.output.outputAs.newVersion",
),
},
{
value: "new_file",
label: t(
"portal.policies.wizard.output.outputAs.newFile",
),
},
]}
/>
</FormField>
<FormField
label={t("portal.policies.wizard.output.filenameRule.label")}
>
<div className="portal-policies__name-row">
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(value) =>
setOutputNamePosition(
(value ?? "suffix") as
| "prefix"
| "suffix"
| "auto-number",
)
}
options={[
{
value: "prefix",
label: t(
"portal.policies.wizard.output.filenameRule.prefix",
),
},
{
value: "suffix",
label: t(
"portal.policies.wizard.output.filenameRule.suffix",
),
},
...(outputMode === "new_file"
? [
{
value: "auto-number",
label: t(
"portal.policies.wizard.output.filenameRule.autoNumber",
),
},
]
: []),
]}
/>
{outputNamePosition !== "auto-number" && (
<Input
inputSize="sm"
value={outputName}
placeholder={t(
"portal.policies.wizard.output.filenameRule.placeholder",
)}
onChange={(e) => setOutputName(e.target.value)}
/>
)}
</div>
</FormField>
</>
)}
{/* TODO: reviewer user-picker goes here */}
</div>
</div>
)}
<div className="portal-policies__wizard-enforce">
<EnforceAsPolicyControl
required={required}
onRequiredChange={setRequired}
/>
</div>
</Modal>
);
}
@@ -29,6 +29,7 @@ export function decorateForStory(categoryId: string): DecoratedPolicy {
const state: PolicyState = {
configured: true,
status: decoded.enabled ? "active" : "paused",
required: decoded.required,
sources: decoded.sources,
scopeTypes: decoded.scopeTypes,
reviewerEmail: decoded.reviewerEmail,
@@ -5,7 +5,6 @@ import {
UsersIcon,
SourcesIcon,
IntegrationsIcon,
PoliciesIcon,
PipelinesIcon,
DocumentsIcon,
InfrastructureIcon,
@@ -31,11 +30,11 @@ export interface NavGroup {
// Sidebar nav groups. This is a flavor seam: the SaaS build shadows this file to
// drop sections not yet shipped there (see src/portal-saas/components/sidebarGroups).
// The processor's own workflow: home plus the pipeline it feeds.
// The processor's own workflow: home plus the pipeline it feeds. Policies were folded into
// Pipelines (a policy is a pipeline the org requires), so there's no separate Policies tab.
export const GROUP_PROCESSOR: NavEntry[] = [
{ id: "home", icon: <HomeIcon /> },
{ id: "sources", icon: <SourcesIcon /> },
{ id: "policies", icon: <PoliciesIcon /> },
{ id: "pipelines", icon: <PipelinesIcon /> },
{ id: "documents", icon: <DocumentsIcon /> },
];
@@ -212,6 +212,7 @@ function makePolicyEntry(overrides?: Partial<CatalogueEntry>): CatalogueEntry {
state: {
configured: true,
status: "active",
required: false,
sources: [],
scopeTypes: [],
reviewerEmail: "",
@@ -239,6 +240,8 @@ function makePipelineView(
id,
name,
enabled: true,
required: false,
icon: "",
status: "active",
trigger,
sources: [],
@@ -1,11 +1,11 @@
import { http, HttpResponse, delay } from "msw";
import type {
PipelineKpi,
PipelineStatus,
PipelineView,
PipelinesOverviewResponse,
Policy,
} from "@portal/api/pipelines";
import { getCataloguePolicies } from "@portal/mocks/handlers/policies";
/**
* Stateful mock for the Pipelines surface so the portal works fully offline with
@@ -131,50 +131,51 @@ function nextAssetId(): string {
return `ast_${Date.now().toString(36)}_${assetCounter}`;
}
function deriveStatus(policy: StoredPolicy): PipelineStatus {
return policy.enabled ? "active" : "paused";
}
// A pipeline-store record has inputs/outputIds; a catalogue record (WirePolicy) does not, so both
// are read defensively here.
type OverviewPolicy = Partial<Policy> & {
id: string;
name: string;
enabled: boolean;
};
// Distinct trigger types across a policy's inputs, or "manual" when none is triggered.
function triggerSummary(policy: StoredPolicy): string {
const types = [
function toView(policy: OverviewPolicy): PipelineView {
const inputs = policy.inputs ?? [];
const outputIds = policy.outputIds ?? [];
const triggers = [
...new Set(
policy.inputs
inputs
.map((input) => input.trigger?.type)
.filter((type): type is string => type != null),
),
];
return types.length === 0 ? "manual" : types.join(", ");
}
function toView(policy: StoredPolicy): PipelineView {
// 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,
status: deriveStatus(policy),
trigger: triggerSummary(policy),
sources: policy.inputs.map((input) => ({
required: policy.required ?? false,
icon,
status: policy.enabled ? "active" : "paused",
trigger: triggers.length === 0 ? "manual" : triggers.join(", "),
sources: inputs.map((input) => ({
id: input.sourceId,
name: SOURCE_NAMES[input.sourceId] ?? input.sourceId,
})),
steps: policy.steps.map((s) => s.operation),
steps: policy.steps?.map((s) => s.operation) ?? [],
output:
policy.outputIds && policy.outputIds.length > 0
? policy.outputIds.map((id) => SOURCE_NAMES[id] ?? id).join(", ")
outputIds.length > 0
? outputIds.map((id) => SOURCE_NAMES[id] ?? id).join(", ")
: (policy.output?.type ?? "inline"),
owner: policy.owner ?? "you@acme.com",
};
}
// Mirrors the backend PolicyOverviewService: hide frontend/catalogue policies (a categoryId in
// output options). A folder-watch trigger is still a normal pipeline and stays.
function isPipeline(policy: StoredPolicy): boolean {
const categoryId = policy.output?.options?.categoryId;
return !(typeof categoryId === "string" && categoryId.length > 0);
}
function buildKpis(policies: StoredPolicy[]): PipelineKpi[] {
function buildKpis(policies: OverviewPolicy[]): PipelineKpi[] {
const total = policies.length;
const active = policies.filter((p) => p.enabled).length;
return [
@@ -184,12 +185,18 @@ function buildKpis(policies: StoredPolicy[]): PipelineKpi[] {
];
}
// The unified overview lists EVERY policy (pipelines + catalogue), mirroring the real backend now
// that the catalogue filter is gone. The two mock stores are joined here, deduped by id.
function buildOverview(): PipelinesOverviewResponse {
const visible = store.filter(isPipeline);
const pipelines = visible
const byId = new Map<string, OverviewPolicy>();
for (const p of store) byId.set(p.id, p);
for (const p of getCataloguePolicies())
if (!byId.has(p.id)) byId.set(p.id, p as OverviewPolicy);
const all = [...byId.values()];
const pipelines = all
.map(toView)
.sort((a, b) => a.name.localeCompare(b.name));
return { kpis: buildKpis(visible), pipelines };
return { kpis: buildKpis(all), pipelines };
}
export const pipelinesHandlers = [
@@ -28,6 +28,15 @@ export function resetPoliciesStore(
runs = seedRuns ? [...seedRuns] : seedPolicyRuns();
}
/**
* The catalogue (suggested-policy) records, for the unified Pipelines overview to merge in - the
* real backend keeps a single store, so its overview already sees these; the mock's two stores must
* be joined here to match.
*/
export function getCataloguePolicies(): WirePolicy[] {
return store;
}
let idCounter = 0;
function nextId(categoryId: string): string {
idCounter += 1;
@@ -39,6 +39,7 @@ export function seedPolicies(): WirePolicy[] {
name: "Security Policy",
owner: "security@acme.com",
enabled: true,
required: true,
trigger: null,
steps: SECURITY_STEPS,
output: {
@@ -314,7 +314,7 @@ export function buildProcessorEntityGroups(
t,
(categoryId) =>
navigate(
`${toPortalPath(VIEW_PATHS.policies)}?category=${encodeURIComponent(categoryId)}`,
`${toPortalPath(VIEW_PATHS.pipelines)}?setup=${encodeURIComponent(categoryId)}`,
),
ENTITY_GROUP_LIMIT,
)
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
@@ -76,6 +76,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
import { canonicalPipelineIconKey } from "@portal/components/pipelines/pipelineIcon";
import { PipelineCreateHeader } from "@portal/components/pipelines/PipelineCreateHeader";
import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader";
import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar";
@@ -198,6 +199,12 @@ export function PipelineBuilder() {
]);
const { id } = useParams();
const isEdit = Boolean(id);
const location = useLocation();
// A Customise hand-off from the simple policy wizard: the in-progress settings as a full pipeline
// record, seeded here instead of fetched. When editing an existing policy the id-based fetch still
// runs in the background so run/pause/delete act on the last-saved version.
const handoff = location.state as { draft?: Policy } | null;
const seedDraft = handoff?.draft ?? null;
const { allTools } = useToolRegistry();
const executableTools = useMemo(
() => getExecutableTools(allTools),
@@ -267,6 +274,19 @@ export function PipelineBuilder() {
const [testRun, setTestRun] = useState<PolicyRunView | null>(null);
const [testing, setTesting] = useState(false);
const [outputIds, setOutputIds] = useState<string[]>([]);
// 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...). Seeded on load and written back untouched, so a customised policy
// never loses its simple-only settings even though the builder has no UI for them.
const [outputOptions, setOutputOptions] = useState<Record<string, unknown>>(
{},
);
const [outputType, setOutputType] = useState("inline");
/**
* Whether the user has asked for each end of the chain yet, distinguishing "not offered" from
* "offered and still owed a choice" - the two states an empty sourceId cannot tell apart. Only a
@@ -335,11 +355,13 @@ export function PipelineBuilder() {
};
}, []);
// Seed the form once: immediately for a new pipeline, or after the policy loads for an edit.
// Seed the form once: immediately for a new pipeline or a Customise hand-off, or after the policy
// loads for an edit. A hand-off draft wins over the fetched record (it carries the unsaved wizard
// edits), so an edit reached via Customise need not wait for the fetch.
useEffect(() => {
if (seeded) return;
if (isEdit && !policyState.data) return;
const policy = policyState.data ?? undefined;
if (isEdit && !seedDraft && !policyState.data) return;
const policy = seedDraft ?? policyState.data ?? undefined;
// An editor pipeline is recognised by the editor source id, which arrives with the sources
// fetch. If the policy loads first, seeding now would latch a blank input and re-save the
// pipeline off the editor (editor.allowed:false), so wait for that fetch to settle.
@@ -348,6 +370,19 @@ 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. Normalise either to a canonical pickable key - the picker matches its own
// vocabulary, not the category-id aliases, so an unnormalised categoryId shows as the default.
const seedCategoryId = policy?.output?.options?.categoryId;
setIcon(
canonicalPipelineIconKey(
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
// without inputs), the stored input for an edit. A legacy multi-input policy shows only its
// first input; saving persists just that one (the backend rejects more anyway).
@@ -378,6 +413,7 @@ export function PipelineBuilder() {
setSeeded(true);
}, [
isEdit,
seedDraft,
policyState.data,
allTools,
seeded,
@@ -675,8 +711,12 @@ export function PipelineBuilder() {
);
const snapshot = JSON.stringify({
name: name.trim(),
icon,
required,
input,
steps: stepSnapshot,
outputType,
outputOptions,
outputIds: [...outputIds].sort(),
});
const baseline = useRef<string | null>(null);
@@ -702,6 +742,8 @@ export function PipelineBuilder() {
const blockers: string[] = [];
if (name.trim() === "")
blockers.push(t("portal.pipelines.builder.blocker.name"));
// An editor pipeline has the editor as its chosen source and needs no destination, so sourceChosen
// is already true and outputValid already passes for it - these checks simply never fire.
if (!sourceChosen)
blockers.push(t("portal.pipelines.builder.blocker.source"));
else if (!scheduleValid)
@@ -803,18 +845,20 @@ export function PipelineBuilder() {
setError(null);
try {
const policy: Policy = {
id: policyState.data?.id ?? undefined,
id: policyState.data?.id ?? seedDraft?.id ?? undefined,
name: name.trim(),
enabled: enabledOverride ?? enabled,
required,
icon,
// The editor is virtual - there is no stored Source to pull from, and nothing server-side
// sweeps it - so it is never a wire input; its participation is recorded on `editor` below.
inputs: isEditorInput
? []
: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }],
steps: await serializeStepsForSave(),
// Destinations are the referenced saved sources; the inline output is preserved as-is
// or defaults to inline.
output: policyState.data?.output ?? { type: "inline", options: {} },
// The output carries the policy metadata bag (categoryId, scope, naming...), edited in the
// dev section and preserved verbatim otherwise, so a customised policy never loses it.
output: { type: outputType, options: outputOptions },
editor: { allowed: isEditorInput, runOn },
// An editor pipeline delivers back into the workspace. A stored destination would send the
// run to a folder or bucket instead, leaving the editor's copy untouched.
@@ -1271,6 +1315,10 @@ export function PipelineBuilder() {
<PipelineEditHeader
name={name}
onNameChange={setName}
icon={icon}
onIconChange={setIcon}
required={required}
onRequiredChange={setRequired}
enabled={enabled}
onTogglePause={handleTogglePause}
togglingEnabled={togglingEnabled}
@@ -1289,6 +1337,10 @@ export function PipelineBuilder() {
<PipelineCreateHeader
name={name}
onNameChange={setName}
icon={icon}
onIconChange={setIcon}
required={required}
onRequiredChange={setRequired}
canSave={canSave}
blockers={blockers}
saving={submitting}
@@ -31,6 +31,29 @@
max-width: 46rem;
}
/* Sections: the template gallery and the full pipelines list. */
.portal-pipelines__templates,
.portal-pipelines__all {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.portal-pipelines__section-title {
margin: 0;
font-size: 1rem;
font-weight: 640;
color: var(--c-text);
}
.portal-pipelines__section-sub {
margin: -0.375rem 0 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--c-text-subtle);
max-width: 46rem;
}
/* Table cells */
.portal-pipelines__name-cell {
display: flex;
@@ -22,16 +22,22 @@ vi.mock("@portal/hooks/useConnectGate", () => ({
}),
}));
// Deterministic i18n: keys returned verbatim. initReactI18next/Trans are exported too because the
// unified page pulls in modules (the policy wizard/catalogue) that reference them at import time.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: () => {} },
Trans: (props: { children?: unknown }) => props.children,
}));
const fetchPipelines = vi.fn();
const fetchPipeline = vi.fn();
vi.mock("@portal/api/pipelines", () => ({
fetchPipelines: () => fetchPipelines(),
fetchPipeline: (id: string) => fetchPipeline(id),
}));
import { Pipelines } from "@portal/views/Pipelines";
@@ -93,7 +99,9 @@ describe("Pipelines when the account is not connected", () => {
fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] });
renderAt("/processor/pipelines");
await screen.findByText("portal.pipelines.empty.title");
fireEvent.click(screen.getByText("portal.pipelines.actions.newPipeline"));
fireEvent.click(
screen.getByText("portal.pipelines.actions.newCustomPipeline"),
);
expect(connect).toHaveBeenCalled();
expect(screen.queryByText("builder")).toBeNull();
});
@@ -3,12 +3,26 @@ import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { PipelinesOverviewResponse } from "@portal/api/pipelines";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { PipelinesOverviewResponse, Policy } from "@portal/api/pipelines";
import { Pipelines } from "@portal/views/Pipelines";
/** The builder route: shows the draft handed in navigation state, so the Customise hand-off can be
* asserted without rendering the real builder. */
function DraftProbe() {
const draft = (useLocation().state as { draft?: Policy } | null)?.draft;
return (
<div>
pipeline page
<span data-testid="draft-icon">{draft?.icon ?? ""}</span>
<span data-testid="draft-name">{draft?.name ?? ""}</span>
</div>
);
}
const render = (
ui: Parameters<typeof baseRender>[0],
options?: Parameters<typeof baseRender>[1],
@@ -24,17 +38,30 @@ vi.mock("@portal/hooks/useConnectGate", () => ({
}),
}));
// Deterministic i18n: keys returned verbatim.
// Deterministic i18n: keys returned verbatim. initReactI18next/Trans are exported too because the
// unified page pulls in modules (the policy wizard/catalogue) that reference them at import time.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: () => {} },
Trans: (props: { children?: unknown }) => props.children,
}));
const fetchPipelines = vi.fn();
const fetchPipeline = vi.fn();
const savePipeline = vi.fn();
vi.mock("@portal/api/pipelines", () => ({
fetchPipelines: () => fetchPipelines(),
fetchPipeline: (id: string) => fetchPipeline(id),
savePipeline: (policy: unknown) => savePipeline(policy),
}));
// The template gallery is out of scope here: keep the catalogue empty so the test focuses on the
// pipelines list.
vi.mock("@portal/queries/policies", () => ({
usePoliciesOverview: () => ({ data: null, loading: false, error: null }),
}));
const RESPONSE: PipelinesOverviewResponse = {
@@ -48,6 +75,8 @@ const RESPONSE: PipelinesOverviewResponse = {
id: "plc-redaction",
name: "Redaction sweep",
enabled: true,
required: false,
icon: "security",
status: "active",
trigger: "schedule",
sources: [{ id: "src-claims", name: "Claims intake" }],
@@ -67,10 +96,7 @@ function renderView(initial = "/processor/pipelines") {
path="/processor/pipelines/new"
element={<div>builder new</div>}
/>
<Route
path="/processor/pipelines/:id"
element={<div>pipeline page</div>}
/>
<Route path="/processor/pipelines/:id" element={<DraftProbe />} />
</Routes>
</MemoryRouter>,
);
@@ -80,21 +106,97 @@ describe("Pipelines view", () => {
beforeEach(() => {
fetchPipelines.mockReset();
fetchPipelines.mockResolvedValue(RESPONSE);
fetchPipeline.mockReset();
// A plain pipeline (no template origin) - parseSimplePolicy returns null, so the row opens the
// full builder page.
fetchPipeline.mockResolvedValue({
id: "plc-redaction",
name: "Redaction sweep",
enabled: true,
inputs: [],
steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }],
output: { type: "inline", options: {} },
outputIds: [],
});
savePipeline.mockReset();
savePipeline.mockResolvedValue(undefined);
});
it("opens the builder when creating a pipeline", async () => {
renderView();
await screen.findByText("Redaction sweep");
fireEvent.click(screen.getByText("portal.pipelines.actions.newPipeline"));
fireEvent.click(
screen.getByText("portal.pipelines.actions.newCustomPipeline"),
);
expect(await screen.findByText("builder new")).toBeInTheDocument();
});
it("opens a pipeline's own page when its row is clicked", async () => {
it("opens the full builder when a plain pipeline row is clicked", async () => {
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
expect(await screen.findByText("pipeline page")).toBeInTheDocument();
});
it("pausing re-saves the stored record verbatim, only flipping enabled", async () => {
// Template-representable, so the row opens the simple detail panel (not the builder). It carries
// first-class fields the decoded view drops - a custom name and an icon - which pausing must not
// rewrite.
const policy = {
id: "plc-redaction",
name: "My custom redaction",
enabled: true,
required: false,
icon: "shield",
inputs: [],
steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }],
output: { type: "inline", options: { categoryId: "security" } },
outputIds: [],
editor: { allowed: true, runOn: "upload" },
};
fetchPipeline.mockResolvedValue(policy);
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(
await screen.findByText("portal.policies.detail.actions.pause"),
);
await waitFor(() => expect(savePipeline).toHaveBeenCalled());
// The whole record round-trips with only `enabled` flipped: name and icon survive.
expect(savePipeline).toHaveBeenCalledWith({ ...policy, enabled: false });
});
it("keeps the custom icon and name when customising from the wizard", async () => {
const policy = {
id: "plc-redaction",
name: "My custom redaction",
enabled: true,
required: false,
icon: "shield",
inputs: [],
steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }],
output: { type: "inline", options: { categoryId: "security" } },
outputIds: [],
editor: { allowed: true, runOn: "export" },
};
fetchPipeline.mockResolvedValue(policy);
renderView();
fireEvent.click(await screen.findByText("Redaction sweep")); // open detail panel
fireEvent.click(
await screen.findByText("portal.policies.detail.actions.editSettings"),
); // open wizard
fireEvent.click(
await screen.findByText("portal.policies.wizard.actions.customise"),
); // hand off to the builder
// The draft carried into the builder keeps the stored icon and name, not the category default.
expect(await screen.findByTestId("draft-icon")).toHaveTextContent("shield");
expect(screen.getByTestId("draft-name")).toHaveTextContent(
"My custom redaction",
);
});
it("shows the KPI stat boxes when pipelines exist", async () => {
renderView();
await screen.findByText("Redaction sweep");
+291 -52
View File
@@ -1,46 +1,241 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { Banner, Button, CardRail, EmptyState, Skeleton } from "@app/ui";
import { errorMessage } from "@portal/api/http";
import { useSectionFlags } from "@portal/hooks/useAsync";
import { usePipelines } from "@portal/queries/pipelines";
import { type PipelineView } from "@portal/api/pipelines";
import { usePoliciesOverview } from "@portal/queries/policies";
import {
fetchPipeline,
savePipeline,
type PipelineView,
type Policy,
} from "@portal/api/pipelines";
import {
buildWireFromSetup,
clearProcessedHistory,
deletePolicy,
parseSimplePolicy,
savePolicy,
type CatalogueEntry,
type PolicySetupResult,
} from "@portal/api/policies";
import { qk } from "@portal/queries/keys";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { PipelinesIcon } from "@portal/components/icons";
import { KpiStrip } from "@portal/components/pipelines/KpiStrip";
import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable";
import { PipelineTemplateCard } from "@portal/components/pipelines/PipelineTemplateCard";
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled";
import { useConnectGate } from "@portal/hooks/useConnectGate";
import "@portal/views/Pipelines.css";
/**
* The unified Pipelines + Policies surface: a gallery of suggested-policy templates not yet set up,
* above the full list of every pipeline/policy. A policy still fitting its template edits in the
* simple wizard, otherwise in the full builder (see {@link parseSimplePolicy}).
*/
export function Pipelines() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
// Building and editing a pipeline both need a linked account, so both ask for one first (#7581).
const { guard } = useConnectGate();
const state = usePipelines();
const { data, loading } = state;
const { isLoading } = useSectionFlags(state);
const pipelines = data?.pipelines ?? [];
// Empty once the fetch settles with no pipelines (or fails → no data); gates
// the empty panel below.
const showEmpty = !isLoading && pipelines.length === 0;
// The KPI strip is pure stat boxes: show it only once real pipelines exist, so
// the loading and empty states don't flash a row of placeholder cards.
const listState = usePipelines();
const { data: overview, loading: overviewLoading } = listState;
const { isLoading: listLoading } = useSectionFlags(listState);
const catalogueState = usePoliciesOverview();
const { data: catalogueData } = catalogueState;
const { enabled: aiEngineEnabled, loading: aiEngineLoading } =
useAiEngineEnabled();
const [detail, setDetail] = useState<CatalogueEntry | null>(null);
const [wizard, setWizard] = useState<CatalogueEntry | null>(null);
const [busy, setBusy] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const listPath = toPortalPath(VIEW_PATHS.pipelines);
const pipelines = overview?.pipelines ?? [];
const hasPipelines = pipelines.length > 0;
const showEmpty = !listLoading && pipelines.length === 0;
// Building and editing a pipeline both need a linked account, so both ask for one first.
const openCreate = guard(() =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`),
const isLocked = useCallback(
(entry: CatalogueEntry): boolean =>
entry.category.requiresAiEngine === true &&
!aiEngineEnabled &&
!aiEngineLoading,
[aiEngineEnabled, aiEngineLoading],
);
// Guarded in its own right so the ask happens here rather than after a pointless hop to Sources.
// The gallery is the on-ramp: only suggested policies NOT yet set up (once configured a policy
// lives in the list below). Templates the user can set up now sort first; coming-soon / AI-locked
// ones stay, shown disabled, at the end (a stable sort keeps each group in its original order).
const galleryEntries = useMemo(() => {
const entries = (catalogueData?.catalogue ?? []).filter(
(e) => e.policy === null,
);
const usable = (e: CatalogueEntry) =>
!e.category.comingSoon && !isLocked(e);
return [...entries].sort((a, b) => Number(usable(b)) - Number(usable(a)));
}, [catalogueData, isLocked]);
// Pipelines and policies share a backend, and Home/onboarding read the same caches, so refresh all
// three: the overview list, the catalogue list, and runs.
const refetch = useCallback(() => {
queryClient.invalidateQueries({ queryKey: qk.pipelines() });
queryClient.invalidateQueries({ queryKey: qk.policiesList() });
queryClient.invalidateQueries({ queryKey: qk.policyRuns() });
}, [queryClient]);
const openCreate = guard(() => navigate(`${listPath}/new`));
const connectSource = guard(() =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`),
);
// A row opens that pipeline's own page (view / edit / run / delete live there).
const openPipeline = guard((pipeline: PipelineView) =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`),
// Open a suggested template in the simple wizard (a fresh policy). AI-gated templates stay closed
// until the engine is confirmed on, so a click during the app-config load can't open a disabled one.
const openTemplate = useCallback(
(entry: CatalogueEntry) => {
if (entry.category.comingSoon) return;
if (entry.category.requiresAiEngine && !aiEngineEnabled) return;
setWizard(entry);
},
[aiEngineEnabled],
);
// A list row routes by representability: a policy that still fits its template opens the simple
// detail panel (edit/pause/delete there); anything else opens the full builder. The full record is
// fetched on click so parseSimplePolicy - the single authority - decides on real data.
const openListRow = guard(async (view: PipelineView) => {
setPageError(null);
try {
const policy = await fetchPipeline(view.id);
const entry = parseSimplePolicy(policy);
if (entry) setDetail(entry);
else navigate(`${listPath}/${view.id}`);
} catch (e) {
setPageError(errorMessage(e));
}
});
// ?setup=<categoryId> deep link (onboarding): open the wizard for that suggested policy, then
// strip the param so back/reload doesn't re-open it.
useEffect(() => {
const setupId = searchParams.get("setup");
if (!setupId || !catalogueData) return;
const entry = catalogueData.catalogue.find(
(e) => e.category.id === setupId,
);
if (entry && !entry.category.comingSoon) {
if (entry.policy) setDetail(entry);
else setWizard(entry);
}
const next = new URLSearchParams(searchParams);
next.delete("setup");
setSearchParams(next, { replace: true });
}, [searchParams, catalogueData, setSearchParams]);
/** The current settings as a full pipeline record, for save or hand-off. */
function draftFromResult(entry: CatalogueEntry, result: PolicySetupResult) {
const wire = buildWireFromSetup(entry, result, t);
const stored = entry.policy?.state;
const draft: Policy = {
id: stored?.backendId,
name: stored?.name ?? wire.name,
icon: stored?.icon,
enabled: wire.enabled,
required: wire.required,
inputs: [],
steps: wire.steps,
output: { type: wire.output.type, options: wire.output.options },
outputIds: [],
// A wizard policy only ever runs on the editor, so hand its editor participation to the
// builder rather than letting it default to disabled.
editor: wire.editor,
};
return draft;
}
async function handleSubmit(
entry: CatalogueEntry,
result: PolicySetupResult,
) {
setPageError(null);
try {
await savePolicy(buildWireFromSetup(entry, result, t));
setWizard(null);
setDetail(null);
refetch();
} catch (e) {
setPageError(errorMessage(e));
}
}
// Customise: hand the in-progress policy to the full builder. A saved policy keeps editing its own
// route; a new one goes to /new. The draft (unsaved wizard edits as a pipeline) rides in history
// state so the builder seeds from it rather than fetching.
function handleCustomise(entry: CatalogueEntry, result: PolicySetupResult) {
const draft = draftFromResult(entry, result);
const target = draft.id ? `${listPath}/${draft.id}` : `${listPath}/new`;
setWizard(null);
navigate(target, { state: { draft } });
}
async function runLifecycle(action: () => Promise<unknown>) {
if (busy) return;
setPageError(null);
setBusy(true);
try {
await action();
setDetail(null);
refetch();
} catch (e) {
setPageError(errorMessage(e));
} finally {
setBusy(false);
}
}
function handleTogglePause() {
const id = detail?.policy?.state.backendId;
const paused = detail?.policy?.state.status === "paused";
if (!id) return;
void runLifecycle(async () => {
// Re-save the stored record with only `enabled` flipped. Rebuilding it from the decoded view
// (as the wizard save does) drops first-class fields that view doesn't carry - the icon, a
// custom name, owner - so a pause would silently rewrite them.
const current = await fetchPipeline(id);
await savePipeline({ ...current, enabled: paused });
});
}
function handleDelete() {
const id = detail?.policy?.state.backendId;
if (id) void runLifecycle(() => deletePolicy(id));
}
function handleClearHistory() {
const id = detail?.policy?.state.backendId;
if (id) void runLifecycle(() => clearProcessedHistory(id));
}
function handleEdit() {
if (detail) {
setWizard(detail);
setDetail(null);
}
}
return (
<div className="portal-pipelines">
<header className="portal-pipelines__head">
@@ -57,46 +252,90 @@ export function Pipelines() {
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.actions.newPipeline")}
{t("portal.pipelines.actions.newCustomPipeline")}
</Button>
</header>
{hasPipelines && <KpiStrip data={data} loading={loading} />}
{pageError && <Banner tone="danger" description={pageError} />}
{isLoading && (
<div className="portal-pipelines__table-skeleton" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
<section className="portal-pipelines__all">
<h2 className="portal-pipelines__section-title">
{t("portal.pipelines.all.title")}
</h2>
{hasPipelines && <KpiStrip data={overview} loading={overviewLoading} />}
{listLoading && (
<div className="portal-pipelines__table-skeleton" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{showEmpty && (
<EmptyState
icon={<PipelinesIcon size={28} />}
title={t("portal.pipelines.empty.title")}
description={t("portal.pipelines.empty.description")}
actions={
<>
<Button
onClick={openCreate}
leftSection={
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.empty.action")}
</Button>
<Button variant="secondary" onClick={connectSource}>
{t("portal.pipelines.empty.connectSource")}
</Button>
</>
}
/>
)}
{!listLoading && hasPipelines && (
<PipelinesTable pipelines={pipelines} onRowClick={openListRow} />
)}
</section>
{galleryEntries.length > 0 && (
<section className="portal-pipelines__templates">
<h2 className="portal-pipelines__section-title">
{t("portal.pipelines.templates.title")}
</h2>
<CardRail itemWidth="16rem" itemHeight="10.75rem">
{galleryEntries.map((entry) => (
<PipelineTemplateCard
key={entry.category.id}
entry={entry}
onOpen={openTemplate}
locked={isLocked(entry)}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
))}
</CardRail>
</section>
)}
{showEmpty && (
<EmptyState
icon={<PipelinesIcon size={28} />}
title={t("portal.pipelines.empty.title")}
description={t("portal.pipelines.empty.description")}
actions={
<>
<Button
onClick={openCreate}
leftSection={
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.empty.action")}
</Button>
<Button variant="secondary" onClick={connectSource}>
{t("portal.pipelines.empty.connectSource")}
</Button>
</>
}
/>
)}
<PolicyDetailPanel
policy={detail?.policy ?? null}
busy={busy}
onClose={() => setDetail(null)}
onEdit={handleEdit}
onTogglePause={handleTogglePause}
onDelete={handleDelete}
onClearHistory={handleClearHistory}
/>
{!isLoading && pipelines.length > 0 && (
<PipelinesTable pipelines={pipelines} onRowClick={openPipeline} />
)}
<PolicySetupWizard
entry={wizard}
onClose={() => setWizard(null)}
onSubmit={handleSubmit}
onCustomise={handleCustomise}
/>
</div>
);
}
+7 -20
View File
@@ -234,6 +234,13 @@
font-weight: 600;
}
/* "Enforce as policy" — the last choice in the simple wizard, set off from the settings above. */
.portal-policies__wizard-enforce {
margin-top: 0.75rem;
padding-top: 0.875rem;
border-top: 1px solid var(--c-border-subtle);
}
.portal-policies__fields {
display: flex;
flex-direction: column;
@@ -274,26 +281,6 @@
margin-top: 0.75rem;
}
/* Sources picker */
/* Selectable source tiles: a vertical stack of full-width shared Buttons
(icon · name · check). Layout is the Button's own leftSection/label/
rightSection — only the selected border/tint is added here. */
.portal-policies__sources {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-policies__source--on {
border-color: var(--c-primary);
}
.portal-policies__source-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.portal-policies__link {
border: none;
background: none;
@@ -1,30 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { Policies } from "@portal/views/Policies";
const meta: Meta<typeof Policies> = {
title: "Portal/Views/Policies",
component: Policies,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Policies>;
/** Seeded mock data: the summary strip plus the configured catalogue. */
export const Default: Story = {};
/**
* A fresh workspace with no policies configured. The summary stat boxes stay
* hidden; the catalogue cards remain, since each one is the CTA to configure
* that policy category.
*/
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", () => HttpResponse.json([])),
http.get("/api/v1/policies/runs", () => HttpResponse.json([])),
],
},
},
};
@@ -1,264 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Banner, Button, Skeleton } from "@app/ui";
import { errorMessage } from "@portal/api/http";
import { useSectionFlags } from "@portal/hooks/useAsync";
import {
buildWireFromSetup,
buildWireFromState,
clearProcessedHistory,
deletePolicy,
savePolicy,
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type PolicySetupResult,
} from "@portal/api/policies";
import { usePoliciesOverview } from "@portal/queries/policies";
import { qk } from "@portal/queries/keys";
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogueTable";
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled";
import { useConnectGate } from "@portal/hooks/useConnectGate";
import "@portal/views/Policies.css";
export function Policies() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { gated, connect } = useConnectGate();
const state = usePoliciesOverview();
const { data, loading, error: fetchError } = state;
const { isLoading } = useSectionFlags(state);
const [detail, setDetail] = useState<CatalogueEntry | null>(null);
const [wizard, setWizard] = useState<CatalogueEntry | null>(null);
const [busy, setBusy] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
// Held in a ref so the effects below do not re-run on its identity. They write back to the URL,
// so a callback that changes each render would loop: strip the param, re-render, run again.
const connectRef = useRef(connect);
connectRef.current = connect;
// Deep link from the Home processor flow. It sets the wizard directly rather than going through
// openEntry, so the gate belongs here too: guarding openEntry alone would leave ?setup= as a way
// past it.
useEffect(() => {
const setupId = searchParams.get("setup");
if (!setupId || !data) return;
const entry = data.catalogue.find((e) => e.category.id === setupId);
if (entry && !entry.category.comingSoon) {
if (gated) connectRef.current();
else if (entry.policy) setDetail(entry);
else setWizard(entry);
}
const next = new URLSearchParams(searchParams);
next.delete("setup");
setSearchParams(next, { replace: true });
}, [searchParams, data, setSearchParams, gated]);
const { enabled: aiEngineEnabled, loading: aiEngineLoading } =
useAiEngineEnabled();
const isLocked = useCallback(
(entry: CatalogueEntry): boolean =>
entry.category.requiresAiEngine === true &&
!aiEngineEnabled &&
!aiEngineLoading &&
!entry.policy,
[aiEngineEnabled, aiEngineLoading],
);
const catalogue = data?.catalogue ?? [];
// Invalidate the shared policies caches; because ProcessorFlow and onboarding
// read the SAME entries, this also live-refreshes Home.
const refetch = useCallback(() => {
queryClient.invalidateQueries({ queryKey: qk.policiesList() });
queryClient.invalidateQueries({ queryKey: qk.policyRuns() });
}, [queryClient]);
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
// but the summary strip is pure stat boxes: hide it until at least one policy
// is configured so a fresh workspace doesn't show a row of zeros.
const hasPolicies = !!data && data.summary.active + data.summary.paused > 0;
const displayCatalogue: CatalogueEntry[] = useMemo(
() =>
catalogue.length > 0
? catalogue
: POLICY_CATEGORIES.map((cat) => ({
category: cat,
config: POLICY_CONFIG[cat.id] ?? {
summary: "",
rules: [],
scopeLabel: "",
fields: [],
defaultOperations: [],
},
policy: null,
})),
[catalogue],
);
const openEntry = useCallback(
(entry: CatalogueEntry) => {
// Ask rather than open an editor whose save would fail; viewing the catalogue stays open.
// Via the ref so this keeps its identity: the deep-link effect depends on it and writes the
// URL back, which would otherwise loop.
if (gated) {
connectRef.current();
return;
}
// Block setup of an AI-required policy until the engine is confirmed on (so a
// click during the app-config load can't open a wizard for a disabled
// feature); a configured policy stays openable so it can be paused/deleted.
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
return;
if (entry.policy) setDetail(entry);
else setWizard(entry);
},
[aiEngineEnabled, gated],
);
// Open a category passed as ?category=<id> (deep link from the super
// search), then strip the param so back/reload doesn't re-open it. Waits for
// the AI-engine flag too: openEntry refuses AI-gated categories until the
// flag is confirmed, and stripping the param before that decision would
// drop the deep link silently.
useEffect(() => {
const categoryId = searchParams.get("category");
if (categoryId === null || loading || aiEngineLoading) return;
const entry = displayCatalogue.find((e) => e.category.id === categoryId);
if (entry) openEntry(entry);
const next = new URLSearchParams(searchParams);
next.delete("category");
setSearchParams(next, { replace: true });
}, [
searchParams,
setSearchParams,
loading,
aiEngineLoading,
displayCatalogue,
openEntry,
]);
async function handleSubmit(
entry: CatalogueEntry,
result: PolicySetupResult,
) {
setPageError(null);
try {
await savePolicy(buildWireFromSetup(entry, result, t));
setWizard(null);
setDetail(null);
refetch();
} catch (e) {
setPageError(errorMessage(e));
}
}
async function runLifecycle(action: () => Promise<unknown>) {
if (busy) return;
setPageError(null);
setBusy(true);
try {
await action();
setDetail(null);
refetch();
} catch (e) {
setPageError(errorMessage(e));
} finally {
setBusy(false);
}
}
function handleTogglePause() {
const entry = detail;
const policy = entry?.policy;
if (!entry || !policy?.state.backendId) return;
const enabled = policy.state.status === "paused";
void runLifecycle(() =>
savePolicy(buildWireFromState(entry, policy, enabled, t)),
);
}
function handleDelete() {
const id = detail?.policy?.state.backendId;
if (id) void runLifecycle(() => deletePolicy(id));
}
function handleClearHistory() {
const id = detail?.policy?.state.backendId;
if (id) void runLifecycle(() => clearProcessedHistory(id));
}
function handleEdit() {
if (detail) {
setWizard(detail);
setDetail(null);
}
}
return (
<div className="portal-policies">
<header className="portal-policies__head">
<h1 className="portal-policies__title">{t("portal.policies.title")}</h1>
<p className="portal-policies__sub">{t("portal.policies.subtitle")}</p>
</header>
{pageError && <Banner tone="danger" description={pageError} />}
{hasPolicies && <CatalogueSummary data={data} loading={loading} />}
{isLoading && (
<div className="portal-policies__grid" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="3.5rem" />
))}
</div>
)}
{!isLoading && fetchError && (
<Banner
tone="warning"
title={t("portal.policies.offline.title")}
description={t("portal.policies.offline.description")}
action={
<Button variant="secondary" size="sm" onClick={refetch}>
{t("portal.policies.offline.retry")}
</Button>
}
/>
)}
{!isLoading && !fetchError && (
<PolicyCatalogueTable
entries={displayCatalogue}
onOpen={openEntry}
isLocked={isLocked}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
)}
<PolicyDetailPanel
policy={detail?.policy ?? null}
busy={busy}
onClose={() => setDetail(null)}
onEdit={handleEdit}
onTogglePause={handleTogglePause}
onDelete={handleDelete}
onClearHistory={handleClearHistory}
/>
<PolicySetupWizard
entry={wizard}
onClose={() => setWizard(null)}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -6,6 +6,7 @@ const FULL_STATE: PolicyDecodedState = {
id: "pol_123",
name: "Security Policy",
enabled: true,
required: true,
categoryId: "security",
sources: ["editor", "gdrive"],
runsOnEditor: true,
@@ -35,6 +36,14 @@ describe("toWirePolicy", () => {
expect(toWirePolicy(FULL_STATE).output.type).toBe("inline");
});
it("keeps required first-class (not in the options bag)", () => {
const wire = toWirePolicy(FULL_STATE);
expect(wire.required).toBe(true);
expect(
(wire.output.options as Record<string, unknown>).required,
).toBeUndefined();
});
it("packs metadata into output.options", () => {
const wire = toWirePolicy(FULL_STATE);
const opts = wire.output.options;
@@ -76,6 +85,7 @@ describe("fromWirePolicy → round-trip", () => {
const wire = toWirePolicy(FULL_STATE);
const decoded = fromWirePolicy(wire);
expect(decoded.id).toBe(FULL_STATE.id);
expect(decoded.required).toBe(FULL_STATE.required);
expect(decoded.categoryId).toBe(FULL_STATE.categoryId);
expect(decoded.sources).toEqual(FULL_STATE.sources);
expect(decoded.scopeTypes).toEqual(FULL_STATE.scopeTypes);
@@ -182,4 +192,17 @@ describe("fromWirePolicy → round-trip", () => {
delete (wire.output.options as Record<string, unknown>).fieldValues;
expect(fromWirePolicy(wire).fieldValues).toEqual({});
});
it("preserves options keys the codec does not model", () => {
const wire = toWirePolicy(FULL_STATE);
// An editor-authored blob the portal codec has no field for.
(wire.output.options as Record<string, unknown>).automation = { name: "x" };
const decoded = fromWirePolicy(wire);
expect(decoded.extraOptions).toEqual({ automation: { name: "x" } });
// Round-trips back onto the bag rather than being dropped on the next save.
expect(
(toWirePolicy(decoded).output.options as Record<string, unknown>)
.automation,
).toEqual({ name: "x" });
});
});
@@ -19,6 +19,22 @@ const DEFAULTS = {
retryDelayMinutes: 5,
} as const;
// The options-bag keys this codec models. Anything else in a stored bag is unknown to the frontend
// and preserved via PolicyDecodedState.extraOptions. Keep in sync with WireOutputOptions.
const MODELLED_OPTION_KEYS: ReadonlySet<string> = new Set([
"runOn",
"mode",
"name",
"position",
"maxRetries",
"retryDelayMinutes",
"categoryId",
"sources",
"scopeTypes",
"reviewerEmail",
"fieldValues",
]);
export function toWirePolicy(state: PolicyDecodedState): WirePolicy {
const options: WireOutputOptions = {
runOn: state.runOn,
@@ -38,9 +54,12 @@ export function toWirePolicy(state: PolicyDecodedState): WirePolicy {
name: state.name,
owner: "",
enabled: state.enabled,
required: state.required,
trigger: null,
steps: state.steps,
output: { type: "inline", options },
// Unmodelled keys go under the typed ones, which always win, so preserving them can't corrupt a
// known field.
output: { type: "inline", options: { ...state.extraOptions, ...options } },
// Omitting this makes the backend stamp EditorConfig.disabled(), so a pause or a
// wizard save would quietly take the policy off the editor.
editor: { allowed: state.runsOnEditor, runOn: state.runOn },
@@ -64,6 +83,7 @@ export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState {
id: policy.id,
name: policy.name,
enabled: policy.enabled,
required: policy.required ?? false,
categoryId,
sources: Array.isArray(raw.sources) ? raw.sources : [],
runsOnEditor: policy.editor?.allowed === true,
@@ -82,6 +102,11 @@ export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState {
outputNamePosition: position,
maxRetries: num(raw.maxRetries, DEFAULTS.maxRetries),
retryDelayMinutes: num(raw.retryDelayMinutes, DEFAULTS.retryDelayMinutes),
extraOptions: Object.fromEntries(
Object.entries(raw as Record<string, unknown>).filter(
([key]) => !MODELLED_OPTION_KEYS.has(key),
),
),
steps: Array.isArray(policy.steps) ? policy.steps : [],
};
}
@@ -51,6 +51,8 @@ export interface WirePolicy {
name: string;
owner?: string;
enabled: boolean;
/** Org-mandated policy; first-class on the record (see the pipeline `Policy.required`). */
required?: boolean;
trigger: null;
steps: WirePipelineStep[];
output: WireOutputSpec;
@@ -89,6 +91,8 @@ export interface PolicyDecodedState {
id: string;
name: string;
enabled: boolean;
/** Org-mandated policy; first-class on the record, not part of the options bag. */
required: boolean;
categoryId: string;
sources: string[];
/**
@@ -106,6 +110,11 @@ export interface PolicyDecodedState {
outputNamePosition: "prefix" | "suffix" | "auto-number";
maxRetries: number;
retryDelayMinutes: number;
/**
* `output.options` keys the codec does not model (e.g. the editor's automation blob), kept verbatim
* so re-encoding preserves them instead of silently dropping a key the frontend can't read.
*/
extraOptions?: Record<string, unknown>;
steps: WirePipelineStep[];
}