diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 3dac4e284e..3851ffa786 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -398,6 +398,8 @@ public class PolicyController { policy.name(), owner, policy.enabled(), + policy.required(), + policy.icon(), policy.inputs(), policy.steps(), policy.output(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 63b26f380c..72d06a4149 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -21,6 +21,8 @@ public record Policy( String name, String owner, boolean enabled, + boolean required, + String icon, List inputs, List 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 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 inputs, + List steps, + OutputSpec output, + List 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 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); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index 0d7845a209..0ea57c4248 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -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 policies = - policyAccessGuard.visibleFrom(policyStore).stream() - .filter(PolicyOverviewService::isPipeline) - .toList(); + List policies = policyAccessGuard.visibleFrom(policyStore).stream().toList(); Map sourceNames = sourceNames(); List 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 sourceNames() { Map 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"). diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java index 509379a66a..51b604f8fb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java @@ -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 sources, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 08bc253863..17f872756d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -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(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index f335a4d754..32831d8f0f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -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" diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 4d0830f9c5..08f38a1950 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -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 diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 402142ea17..a8abf6ce3b 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 1a74158022..0f6f5461a2 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -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); diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css index 5ca38559b2..ee8f0225e8 100644 --- a/frontend/editor/src/core/theme/primitives.css +++ b/frontend/editor/src/core/theme/primitives.css @@ -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; diff --git a/frontend/editor/src/core/ui/CardRail.css b/frontend/editor/src/core/ui/CardRail.css new file mode 100644 index 0000000000..a4ac526780 --- /dev/null +++ b/frontend/editor/src/core/ui/CardRail.css @@ -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); +} diff --git a/frontend/editor/src/core/ui/CardRail.stories.tsx b/frontend/editor/src/core/ui/CardRail.stories.tsx new file mode 100644 index 0000000000..79ce5597a7 --- /dev/null +++ b/frontend/editor/src/core/ui/CardRail.stories.tsx @@ -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: , + title: "Security", + desc: "Redact, sanitize, and watermark every document.", + }, + { + icon: , + title: "Classification", + desc: "Tag each document against your team's labels.", + }, + { + icon: , + title: "Compliance", + desc: "Enforce frameworks and keep an audit trail.", + }, + { + icon: , + title: "Ingestion", + desc: "OCR and flatten documents as they arrive.", + }, + { + icon: , + title: "Routing", + desc: "Send finished documents where they belong.", + }, + { + icon: , + title: "Retention", + desc: "Archive and expire on your schedule.", + }, +]; + +const meta: Meta = { + title: "Primitives/CardRail", + component: CardRail, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** A row of equal-size cards that scrolls sideways when they overflow the container. */ +export const Default: Story = { + render: () => ( + + {items.map((it) => ( + {}} + /> + ))} + + ), +}; diff --git a/frontend/editor/src/core/ui/CardRail.tsx b/frontend/editor/src/core/ui/CardRail.tsx new file mode 100644 index 0000000000..6d989866e1 --- /dev/null +++ b/frontend/editor/src/core/ui/CardRail.tsx @@ -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 { + /** 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 ( + + {children} + + ); +} diff --git a/frontend/editor/src/core/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css index f7aaf61131..8caac1d30d 100644 --- a/frontend/editor/src/core/ui/FormField.css +++ b/frontend/editor/src/core/ui/FormField.css @@ -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 diff --git a/frontend/editor/src/core/ui/FormField.tsx b/frontend/editor/src/core/ui/FormField.tsx index 473ca82472..ae6682ac89 100644 --- a/frontend/editor/src/core/ui/FormField.tsx +++ b/frontend/editor/src/core/ui/FormField.tsx @@ -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({ )} )} - {info && ( - - - - )} + {info && } )}
{child}
diff --git a/frontend/editor/src/core/ui/IconPicker.css b/frontend/editor/src/core/ui/IconPicker.css new file mode 100644 index 0000000000..2c4c5cb9ef --- /dev/null +++ b/frontend/editor/src/core/ui/IconPicker.css @@ -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); +} diff --git a/frontend/editor/src/core/ui/IconPicker.stories.tsx b/frontend/editor/src/core/ui/IconPicker.stories.tsx new file mode 100644 index 0000000000..8fe0534867 --- /dev/null +++ b/frontend/editor/src/core/ui/IconPicker.stories.tsx @@ -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: }, + { key: "lock", label: "Lock", node: }, + { key: "label", label: "Label", node: }, + { key: "layers", label: "Layers", node: }, + { key: "folder", label: "Folder", node: }, + { key: "bolt", label: "Bolt", node: }, + { + key: "schedule", + label: "Schedule", + node: , + }, + { + key: "sparkle", + label: "Sparkle", + node: , + }, +]; + +const meta: Meta = { + title: "Primitives/IconPicker", + component: IconPicker, + tags: ["autodocs"], + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +/** 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 ( + + ); + }, +}; diff --git a/frontend/editor/src/core/ui/IconPicker.tsx b/frontend/editor/src/core/ui/IconPicker.tsx new file mode 100644 index 0000000000..e3c7a98128 --- /dev/null +++ b/frontend/editor/src/core/ui/IconPicker.tsx @@ -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 ( + + + + {selected?.node} + + + +
+ {options.map((option) => { + const isSelected = option.key === value; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/editor/src/core/ui/InfoTooltip.css b/frontend/editor/src/core/ui/InfoTooltip.css new file mode 100644 index 0000000000..7f2b4015d8 --- /dev/null +++ b/frontend/editor/src/core/ui/InfoTooltip.css @@ -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; +} diff --git a/frontend/editor/src/core/ui/InfoTooltip.stories.tsx b/frontend/editor/src/core/ui/InfoTooltip.stories.tsx new file mode 100644 index 0000000000..7bf6b4844b --- /dev/null +++ b/frontend/editor/src/core/ui/InfoTooltip.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { InfoTooltip } from "@app/ui/InfoTooltip"; + +const meta: Meta = { + 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; + +/** 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) => ( + + Folder + + + ), +}; diff --git a/frontend/editor/src/core/ui/InfoTooltip.tsx b/frontend/editor/src/core/ui/InfoTooltip.tsx new file mode 100644 index 0000000000..9c3572565b --- /dev/null +++ b/frontend/editor/src/core/ui/InfoTooltip.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/editor/src/core/ui/OptionCard.css b/frontend/editor/src/core/ui/OptionCard.css new file mode 100644 index 0000000000..990986b431 --- /dev/null +++ b/frontend/editor/src/core/ui/OptionCard.css @@ -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; +} diff --git a/frontend/editor/src/core/ui/OptionCard.stories.tsx b/frontend/editor/src/core/ui/OptionCard.stories.tsx new file mode 100644 index 0000000000..f7b6ce93eb --- /dev/null +++ b/frontend/editor/src/core/ui/OptionCard.stories.tsx @@ -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 + + +); + +const comingSoon = ( + <> + + Coming soon + +); + +const meta: Meta = { + title: "Primitives/OptionCard", + component: OptionCard, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { + icon: , + 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) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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) => ( +
+ +
+ ), + ], + render: () => ( +
+
+ } + title="Security" + description="Redact sensitive information, strip active content, and watermark every document." + cta={setUp} + onSelect={() => {}} + /> +
+
+ } + title="Classification" + description="Identify each document's type against your team's labels and tag it automatically." + cta={setUp} + onSelect={() => {}} + /> +
+
+ } + title="Compliance" + description="Enforce your regulatory frameworks and keep an audit trail of every change." + disabled + note={comingSoon} + /> +
+
+ ), +}; diff --git a/frontend/editor/src/core/ui/OptionCard.tsx b/frontend/editor/src/core/ui/OptionCard.tsx new file mode 100644 index 0000000000..336489f7d7 --- /dev/null +++ b/frontend/editor/src/core/ui/OptionCard.tsx @@ -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) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect?.(); + } + } + + return ( + +
+ + {icon} + +

{title}

+
+ {description &&

{description}

} + {(disabled ? note : cta) && ( + {disabled ? note : cta} + )} +
+ ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index c621319f0e..386cc00f31 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -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"; diff --git a/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts index 547f11351b..a5571510bb 100644 --- a/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts +++ b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts @@ -10,7 +10,6 @@ describe("sidebarGroups (SaaS)", () => { expect(GROUP_PROCESSOR.map((e) => e.id)).toEqual([ "home", "sources", - "policies", "pipelines", "documents", ]); diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index c7b87f7388..cd54ead740 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -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 ( + + ); +} + export function ViewRouter() { return ( @@ -65,7 +75,9 @@ export function ViewRouter() { element={} /> } /> - } /> + {/* 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=). */} + } /> } /> } /> { + 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); + }); +}); diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index 6963ccc1b1..90bfffea8c 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -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; 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; fieldValues: Record; 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(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, + }, + 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 { return apiClient.local.json( @@ -564,9 +656,6 @@ export async function clearProcessedHistory(id: string): Promise { // ── 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 diff --git a/frontend/editor/src/portal/components/ProcessorFlow.tsx b/frontend/editor/src/portal/components/ProcessorFlow.tsx index b4aa34a8f2..21f32d5d50 100644 --- a/frontend/editor/src/portal/components/ProcessorFlow.tsx +++ b/frontend/editor/src/portal/components/ProcessorFlow.tsx @@ -42,10 +42,10 @@ export function ProcessorFlow({ dataOverride }: ProcessorFlowProps = {}) { const [lens, setLens] = useState("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. */ diff --git a/frontend/editor/src/portal/components/icons.tsx b/frontend/editor/src/portal/components/icons.tsx index 5692e253d9..86a4eb81d1 100644 --- a/frontend/editor/src/portal/components/icons.tsx +++ b/frontend/editor/src/portal/components/icons.tsx @@ -59,16 +59,20 @@ export function SourcesIcon(props: IconProps) { ); } +/** + * A pipeline as a route: two waypoints joined by a winding path. Shared so the sidebar nav and the + * pipelines table's default row icon render the exact same glyph (see pipelineIcon). + */ +export const PIPELINE_ROUTE_GLYPH = ( + <> + + + + +); + export function PipelinesIcon(props: IconProps) { - return ( - - - - - - - - ); + return {PIPELINE_ROUTE_GLYPH}; } export function DocumentsIcon(props: IconProps) { diff --git a/frontend/editor/src/portal/components/pipelines/EnforceAsPolicyControl.css b/frontend/editor/src/portal/components/pipelines/EnforceAsPolicyControl.css new file mode 100644 index 0000000000..e9e80f912d --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/EnforceAsPolicyControl.css @@ -0,0 +1,5 @@ +.portal-enforce { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} diff --git a/frontend/editor/src/portal/components/pipelines/EnforceAsPolicyControl.tsx b/frontend/editor/src/portal/components/pipelines/EnforceAsPolicyControl.tsx new file mode 100644 index 0000000000..0da0257ae5 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/EnforceAsPolicyControl.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx index b9717c0848..1226473bfc 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx @@ -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 }) { = {}) { render( 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({ + +
+ + {/* The pair share one tooltip target because a disabled button swallows its own hover - the wrapper is what the pointer lands on. */} setEnabled((e) => !e)} togglingEnabled={false} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx index e216dbb292..c491da17de 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx @@ -30,6 +30,10 @@ function renderHeader(overrides: Partial = {}) { render( 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({ + + {renaming ? (
+ + {/* 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. */} - {step === "workflow" ? ( - - ) : ( - <> - - - - )} + +
} > - setStep(k)} - items={[ - { key: "workflow", label: t("portal.policies.wizard.tabs.workflow") }, - { key: "settings", label: t("portal.policies.wizard.tabs.settings") }, - ]} - /> - {error && ( )} - {step === "workflow" && isClassification && ( + {isClassification && (

{t( @@ -503,7 +431,7 @@ function PolicySetupWizardBody({

)} - {step === "workflow" && !isClassification && ( + {!isClassification && (

{t( @@ -577,196 +505,12 @@ function PolicySetupWizardBody({

)} - {step === "settings" && ( -
- {config.fields.length > 0 && ( - <> -

- {t("portal.policies.wizard.settings.heading")} -

-
- {config.fields.map((field) => ( - - setFieldValues((prev) => ({ ...prev, [field.key]: v })) - } - /> - ))} -
- - )} - -

- {t("portal.policies.wizard.sources.heading")} -

- {sourcesAsync.loading && !sourcesAsync.data ? ( -

- {t("portal.policies.wizard.sources.loading")} -

- ) : ( - // The backend always returns the editor as a virtual source, so the - // loaded list is never empty - no "no sources" state exists. -
- {availableSources.map((src) => { - const on = - src.id === "editor" ? runsOnEditor : sources.includes(src.id); - return ( - - ); - })} -
- )} - -

- {t("portal.policies.wizard.output.heading")} -

-
- {runsOnEditor && ( - <> - - { - 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", - ), - }, - ]} - /> - - -
- setOutputName(e.target.value)} - /> - )} -
-
- - )} - {/* TODO: reviewer user-picker goes here */} -
-
- )} +
+ +
); } diff --git a/frontend/editor/src/portal/components/policies/storyFixtures.ts b/frontend/editor/src/portal/components/policies/storyFixtures.ts index 0e92ade641..4573f06c9b 100644 --- a/frontend/editor/src/portal/components/policies/storyFixtures.ts +++ b/frontend/editor/src/portal/components/policies/storyFixtures.ts @@ -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, diff --git a/frontend/editor/src/portal/components/sidebarGroups.tsx b/frontend/editor/src/portal/components/sidebarGroups.tsx index a292e11244..b7796a66bf 100644 --- a/frontend/editor/src/portal/components/sidebarGroups.tsx +++ b/frontend/editor/src/portal/components/sidebarGroups.tsx @@ -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: }, { id: "sources", icon: }, - { id: "policies", icon: }, { id: "pipelines", icon: }, { id: "documents", icon: }, ]; diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts index 4be356bab0..f9696f0622 100644 --- a/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts @@ -212,6 +212,7 @@ function makePolicyEntry(overrides?: Partial): 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: [], diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index c5505f38ff..d7e0a52497 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -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 & { + 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(); + 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 = [ diff --git a/frontend/editor/src/portal/mocks/handlers/policies.ts b/frontend/editor/src/portal/mocks/handlers/policies.ts index ce6c041ae9..93ee64a07d 100644 --- a/frontend/editor/src/portal/mocks/handlers/policies.ts +++ b/frontend/editor/src/portal/mocks/handlers/policies.ts @@ -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; diff --git a/frontend/editor/src/portal/mocks/policies.ts b/frontend/editor/src/portal/mocks/policies.ts index fdd6908b66..755c9070af 100644 --- a/frontend/editor/src/portal/mocks/policies.ts +++ b/frontend/editor/src/portal/mocks/policies.ts @@ -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: { diff --git a/frontend/editor/src/portal/search/entitySearch.tsx b/frontend/editor/src/portal/search/entitySearch.tsx index 1d0a7cc489..c33b920e7b 100644 --- a/frontend/editor/src/portal/search/entitySearch.tsx +++ b/frontend/editor/src/portal/search/entitySearch.tsx @@ -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, ) diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index 4e46190693..f617eb5ce9 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -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(null); const [testing, setTesting] = useState(false); const [outputIds, setOutputIds] = useState([]); + // 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>( + {}, + ); + 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(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() { ({ }), })); +// 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(); }); diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 250722706e..2e04fd6b84 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -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 ( +
+ pipeline page + {draft?.icon ?? ""} + {draft?.name ?? ""} +
+ ); +} + const render = ( ui: Parameters[0], options?: Parameters[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={
builder new
} /> - pipeline page
} - /> + } />
, ); @@ -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"); diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index 6311e3c438..07611520ec 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -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(null); + const [wizard, setWizard] = useState(null); + const [busy, setBusy] = useState(false); + const [pageError, setPageError] = useState(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= 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) { + 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 (
@@ -57,46 +252,90 @@ export function Pipelines() { onClick={openCreate} leftSection={} > - {t("portal.pipelines.actions.newPipeline")} + {t("portal.pipelines.actions.newCustomPipeline")}
- {hasPipelines && } + {pageError && } - {isLoading && ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
+
+

+ {t("portal.pipelines.all.title")} +

+ + {hasPipelines && } + + {listLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {showEmpty && ( + } + title={t("portal.pipelines.empty.title")} + description={t("portal.pipelines.empty.description")} + actions={ + <> + + + + } + /> + )} + + {!listLoading && hasPipelines && ( + + )} +
+ + {galleryEntries.length > 0 && ( +
+

+ {t("portal.pipelines.templates.title")} +

+ + {galleryEntries.map((entry) => ( + + ))} + +
)} - {showEmpty && ( - } - title={t("portal.pipelines.empty.title")} - description={t("portal.pipelines.empty.description")} - actions={ - <> - - - - } - /> - )} + setDetail(null)} + onEdit={handleEdit} + onTogglePause={handleTogglePause} + onDelete={handleDelete} + onClearHistory={handleClearHistory} + /> - {!isLoading && pipelines.length > 0 && ( - - )} + setWizard(null)} + onSubmit={handleSubmit} + onCustomise={handleCustomise} + />
); } diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index fedcbbf835..b62976fbe9 100644 --- a/frontend/editor/src/portal/views/Policies.css +++ b/frontend/editor/src/portal/views/Policies.css @@ -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; diff --git a/frontend/editor/src/portal/views/Policies.stories.tsx b/frontend/editor/src/portal/views/Policies.stories.tsx deleted file mode 100644 index ac1a92b825..0000000000 --- a/frontend/editor/src/portal/views/Policies.stories.tsx +++ /dev/null @@ -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 = { - title: "Portal/Views/Policies", - component: Policies, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** 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([])), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx deleted file mode 100644 index b8cb42112a..0000000000 --- a/frontend/editor/src/portal/views/Policies.tsx +++ /dev/null @@ -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(null); - const [wizard, setWizard] = useState(null); - const [busy, setBusy] = useState(false); - const [pageError, setPageError] = useState(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= (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) { - 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 ( -
-
-

{t("portal.policies.title")}

-

{t("portal.policies.subtitle")}

-
- - {pageError && } - - {hasPolicies && } - - {isLoading && ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- )} - - {!isLoading && fetchError && ( - - {t("portal.policies.offline.retry")} - - } - /> - )} - - {!isLoading && !fetchError && ( - - )} - - setDetail(null)} - onEdit={handleEdit} - onTogglePause={handleTogglePause} - onDelete={handleDelete} - onClearHistory={handleClearHistory} - /> - - setWizard(null)} - onSubmit={handleSubmit} - /> -
- ); -} diff --git a/frontend/editor/src/proprietary/policies/codec.test.ts b/frontend/editor/src/proprietary/policies/codec.test.ts index 18536347da..70d704cc3e 100644 --- a/frontend/editor/src/proprietary/policies/codec.test.ts +++ b/frontend/editor/src/proprietary/policies/codec.test.ts @@ -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).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).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).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) + .automation, + ).toEqual({ name: "x" }); + }); }); diff --git a/frontend/editor/src/proprietary/policies/codec.ts b/frontend/editor/src/proprietary/policies/codec.ts index 3a8ec04b29..44117a2591 100644 --- a/frontend/editor/src/proprietary/policies/codec.ts +++ b/frontend/editor/src/proprietary/policies/codec.ts @@ -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 = 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).filter( + ([key]) => !MODELLED_OPTION_KEYS.has(key), + ), + ), steps: Array.isArray(policy.steps) ? policy.steps : [], }; } diff --git a/frontend/editor/src/proprietary/policies/types.ts b/frontend/editor/src/proprietary/policies/types.ts index d13d4024ef..6dad09855b 100644 --- a/frontend/editor/src/proprietary/policies/types.ts +++ b/frontend/editor/src/proprietary/policies/types.ts @@ -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; steps: WirePipelineStep[]; }