From a3857a77bcaf273c19d585c332482806faad773b Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 26 Aug 2026 11:49:46 +0100 Subject: [PATCH] Change to have template cards --- .../public/locales/en-US/translation.toml | 24 +++--- .../pipelines/PipelineTemplateCard.css | 54 +++++++++++++ .../PipelineTemplateCard.stories.tsx | 45 +++++++++++ .../pipelines/PipelineTemplateCard.tsx | 79 +++++++++++++++++++ .../components/pipelines/PipelinesTable.tsx | 5 +- .../components/policies/PolicySetupWizard.tsx | 28 +++---- .../editor/src/portal/views/Pipelines.css | 11 ++- .../src/portal/views/Pipelines.test.tsx | 8 +- .../editor/src/portal/views/Pipelines.tsx | 27 ++++--- frontend/editor/src/portal/views/Policies.css | 7 ++ 10 files changed, 239 insertions(+), 49 deletions(-) create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index f349252fbf..a64a73fa9d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7837,11 +7837,11 @@ platform = "PDF Platform" processor = "PDF Processor" [portal.pipelines] -subtitle = "Pipelines and the policies your organisation requires. Start from a suggested policy, or build any pipeline and enforce it. Click a row to edit, run, or pause it." +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" @@ -7912,8 +7912,8 @@ invalid = "Not valid JSON. The last valid version is kept until this is fixed." label = "Policy metadata (advanced)" [portal.pipelines.builder.required] -desc = "Members can't pause or delete a required policy, and it enforces on their documents." -label = "Required by your organisation" +desc = "Members can't skip, pause, or delete it, and it runs on their documents automatically." +label = "Enforce as policy" [portal.pipelines.composer] addTool = "Add a tool" @@ -8017,10 +8017,6 @@ timeout = "Run is taking longer than expected; it may still finish in the backgr active = "Active" paused = "Paused" -[portal.pipelines.suggested] -subtitle = "Ready-made policies with a simple setup. Add one, then customise it into a full pipeline if you need to." -title = "Suggested policies" - [portal.pipelines.table] name = "Pipeline" sources = "Sources" @@ -8029,6 +8025,11 @@ steps = "Steps" trigger = "Trigger" type = "Type" +[portal.pipelines.templates] +setUp = "Set up" +subtitle = "Ready-made starting points with a simple, guided setup. Pick one, then customise it into a full pipeline if you need more control." +title = "Templates" + [portal.pipelines.trigger] folder-watch = "Folder watch" manual = "Manual" @@ -8036,7 +8037,7 @@ schedule = "Scheduled" [portal.pipelines.type] pipeline = "Pipeline" -required = "Required" +policy = "Policy" [portal.policies] defaultName = "{{category}} Policy" @@ -8475,9 +8476,8 @@ description = "Every uploaded document is classified against the built-in labels labelsHeading = "Classification labels" [portal.policies.wizard.enforcement] -heading = "Enforcement" -requiredDesc = "Members can't pause or delete a required policy, and it runs on their documents automatically." -requiredLabel = "Required by your organisation" +requiredDesc = "Run this on every document automatically. Members can't skip, pause, or delete it." +requiredLabel = "Enforce as policy" [portal.policies.wizard.errors] noTools = "Enable at least one tool in the workflow first." diff --git a/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.css b/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.css new file mode 100644 index 0000000000..167c409c7d --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.css @@ -0,0 +1,54 @@ +.portal-pipelines__template-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + height: 100%; +} + +.portal-pipelines__template-card--locked { + /* Recede via surface + ink rather than opacity, which would fade text below the contrast floor. */ + background: var(--c-surface-sunken); + color: var(--c-text-muted); +} + +.portal-pipelines__template-icon { + display: inline-flex; + 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)); +} + +.portal-pipelines__template-card--locked .portal-pipelines__template-icon { + background: var(--c-surface-raised); + color: var(--c-text-muted); +} + +.portal-pipelines__template-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 640; + color: var(--c-text); +} + +.portal-pipelines__template-blurb { + /* Push the CTA to the card foot so cards in a row line their footers up. */ + flex: 1; + margin: 0; + font-size: 0.8125rem; + line-height: 1.45; + color: var(--c-text-subtle); +} + +.portal-pipelines__template-cta { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.8125rem; + font-weight: 620; + color: var(--c-accent-fg, var(--c-primary)); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.stories.tsx new file mode 100644 index 0000000000..fff50a5223 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + POLICY_CATEGORIES, + POLICY_CONFIG, +} from "@portal/components/policies/storyFixtures"; +import { PipelineTemplateCard } from "@portal/components/pipelines/PipelineTemplateCard"; + +const security = POLICY_CATEGORIES.find((c) => c.id === "security")!; +const compliance = POLICY_CATEGORIES.find((c) => c.id === "compliance")!; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineTemplateCard", + component: PipelineTemplateCard, + parameters: { layout: "padded" }, + args: { onOpen: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** An available template — opens the simple guided setup. */ +export const Default: Story = { + args: { + entry: { category: security, config: POLICY_CONFIG.security, policy: null }, + }, +}; + +/** Setup unavailable (e.g. the AI engine is off) — shown but inert. */ +export const Locked: Story = { + args: { + entry: { category: security, config: POLICY_CONFIG.security, policy: null }, + locked: true, + lockedLabel: "Requires AI engine", + }, +}; + +/** Coming-soon category — locked and inert. */ +export const ComingSoon: Story = { + args: { + entry: { + category: compliance, + config: POLICY_CONFIG.compliance, + policy: null, + }, + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.tsx b/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.tsx new file mode 100644 index 0000000000..a3f7a1c1ab --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineTemplateCard.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "react-i18next"; +import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded"; +import { Card, Chip } from "@app/ui"; +import type { CatalogueEntry } from "@portal/api/policies"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; +import "@portal/components/pipelines/PipelineTemplateCard.css"; + +interface PipelineTemplateCardProps { + entry: CatalogueEntry; + /** Open the simple setup wizard seeded from this template. */ + onOpen: (entry: CatalogueEntry) => void; + /** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */ + locked?: boolean; + /** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */ + lockedLabel?: string; +} + +/** + * A template in the Pipelines gallery: a ready-made starting point that opens the simple, guided + * setup. Vertical card (icon, title, blurb, a "Set up" affordance) so the gallery reads as a set of + * choices, distinct from the pipelines table below. Locked/coming-soon templates show a chip and + * don't open. + */ +export function PipelineTemplateCard({ + entry, + onOpen, + locked = false, + lockedLabel, +}: PipelineTemplateCardProps) { + const { t } = useTranslation(); + const { category } = entry; + const comingSoon = category.comingSoon === true; + const openable = !comingSoon && !locked; + + return ( + onOpen(entry) : undefined} + role={openable ? "button" : undefined} + tabIndex={openable ? 0 : undefined} + onKeyDown={ + openable + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(entry); + } + } + : undefined + } + > + + {policyCategoryIcon(category.id)} + + +

{t(category.label)}

+

{t(category.desc)}

+ + {comingSoon ? ( + + {t("portal.policies.card.comingSoon")} + + ) : locked ? ( + + {lockedLabel ?? t("portal.policies.card.requiresAiEngine")} + + ) : ( + + {t("portal.pipelines.templates.setUp")} + + + )} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx index af411cf8b5..9d66457467 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx @@ -35,11 +35,10 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) { key: "type", header: t("portal.pipelines.table.type", "Type"), sortable: true, - // A required pipeline IS a policy (the org mandates it); an ordinary one is just an - // automation. + // A pipeline enforced as a policy shows as "Policy"; an ordinary one as "Pipeline". get: (p) => p.required - ? { tone: "purple", label: t("portal.pipelines.type.required") } + ? { tone: "purple", label: t("portal.pipelines.type.policy") } : { tone: "neutral", label: t("portal.pipelines.type.pipeline") }, }), column.text({ diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index f97bfe391c..f85816a88a 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -10,6 +10,7 @@ import { Banner, Button, Card, + Checkbox, FormField, Input, Modal, @@ -600,24 +601,6 @@ function PolicySetupWizardBody({ {step === "settings" && (
-

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

- - - } - /> - - {config.fields.length > 0 && ( <>

@@ -803,6 +786,15 @@ function PolicySetupWizardBody({ )} {/* TODO: reviewer user-picker goes here */}

+ +
+ setRequired(e.target.checked)} + label={t("portal.policies.wizard.enforcement.requiredLabel")} + description={t("portal.policies.wizard.enforcement.requiredDesc")} + /> +
)} diff --git a/frontend/editor/src/portal/views/Pipelines.css b/frontend/editor/src/portal/views/Pipelines.css index 46c28e0cb0..89c8bb269a 100644 --- a/frontend/editor/src/portal/views/Pipelines.css +++ b/frontend/editor/src/portal/views/Pipelines.css @@ -31,14 +31,21 @@ max-width: 46rem; } -/* Sections: the suggested-policy gallery and the full list. */ -.portal-pipelines__suggested, +/* Sections: the template gallery and the full pipelines list. */ +.portal-pipelines__templates, .portal-pipelines__all { display: flex; flex-direction: column; gap: 0.75rem; } +/* Template gallery: a responsive row of cards that wraps to fit. */ +.portal-pipelines__templates-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: 0.75rem; +} + .portal-pipelines__section-title { margin: 0; font-size: 1rem; diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 5395c0f2bb..a2cdc7bdce 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -32,8 +32,8 @@ vi.mock("@portal/api/pipelines", () => ({ fetchPipeline: (id: string) => fetchPipeline(id), })); -// The suggested-policy gallery is out of scope here: keep the catalogue empty so the test focuses on -// the pipelines list. +// 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 }), })); @@ -99,7 +99,9 @@ describe("Pipelines view", () => { 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(); }); diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index a39fe8773c..76d049ee0f 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -28,7 +28,7 @@ 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 { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogueTable"; +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"; @@ -252,26 +252,31 @@ export function Pipelines() { onClick={openCreate} leftSection={} > - {t("portal.pipelines.actions.newPipeline")} + {t("portal.pipelines.actions.newCustomPipeline")} {pageError && } {galleryEntries.length > 0 && ( -
+

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

- {t("portal.pipelines.suggested.subtitle")} + {t("portal.pipelines.templates.subtitle")}

- +
+ {galleryEntries.map((entry) => ( + + ))} +
)} diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index fedcbbf835..d30244c821 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;