Change to have template cards

This commit is contained in:
James Brunton
2026-08-27 11:38:50 +01:00
parent 865785e7af
commit a3857a77bc
10 changed files with 239 additions and 49 deletions
@@ -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."
@@ -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));
}
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
} from "@portal/components/policies/storyFixtures";
import { PipelineTemplateCard } from "@portal/components/pipelines/PipelineTemplateCard";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const compliance = POLICY_CATEGORIES.find((c) => c.id === "compliance")!;
const meta: Meta<typeof PipelineTemplateCard> = {
title: "Portal/Pipelines/PipelineTemplateCard",
component: PipelineTemplateCard,
parameters: { layout: "padded" },
args: { onOpen: () => {} },
};
export default meta;
type Story = StoryObj<typeof PipelineTemplateCard>;
/** An available template — opens the simple guided setup. */
export const Default: Story = {
args: {
entry: { category: security, config: POLICY_CONFIG.security, policy: null },
},
};
/** Setup unavailable (e.g. the AI engine is off) — shown but inert. */
export const Locked: Story = {
args: {
entry: { category: security, config: POLICY_CONFIG.security, policy: null },
locked: true,
lockedLabel: "Requires AI engine",
},
};
/** Coming-soon category — locked and inert. */
export const ComingSoon: Story = {
args: {
entry: {
category: compliance,
config: POLICY_CONFIG.compliance,
policy: null,
},
},
};
@@ -0,0 +1,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 (
<Card
className={
"portal-pipelines__template-card" +
(openable ? "" : " portal-pipelines__template-card--locked")
}
interactive={openable}
onClick={openable ? () => 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
}
>
<span className="portal-pipelines__template-icon" aria-hidden>
{policyCategoryIcon(category.id)}
</span>
<h3 className="portal-pipelines__template-title">{t(category.label)}</h3>
<p className="portal-pipelines__template-blurb">{t(category.desc)}</p>
{comingSoon ? (
<Chip accent="neutral" size="sm">
{t("portal.policies.card.comingSoon")}
</Chip>
) : locked ? (
<Chip accent="neutral" size="sm">
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
</Chip>
) : (
<span className="portal-pipelines__template-cta">
{t("portal.pipelines.templates.setUp")}
<ArrowForwardRoundedIcon style={{ fontSize: "1rem" }} />
</span>
)}
</Card>
);
}
@@ -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({
@@ -10,6 +10,7 @@ import {
Banner,
Button,
Card,
Checkbox,
FormField,
Input,
Modal,
@@ -600,24 +601,6 @@ function PolicySetupWizardBody({
{step === "settings" && (
<div className="portal-policies__wizard-section">
<h3 className="portal-policies__wizard-heading">
{t("portal.policies.wizard.enforcement.heading")}
</h3>
<Card padding="none">
<SettingsRow
label={t("portal.policies.wizard.enforcement.requiredLabel")}
description={t("portal.policies.wizard.enforcement.requiredDesc")}
control={
<ToggleSwitch
size="sm"
checked={required}
onChange={setRequired}
label=""
/>
}
/>
</Card>
{config.fields.length > 0 && (
<>
<h3 className="portal-policies__wizard-heading">
@@ -803,6 +786,15 @@ function PolicySetupWizardBody({
)}
{/* TODO: reviewer user-picker goes here */}
</div>
<div className="portal-policies__wizard-enforce">
<Checkbox
checked={required}
onChange={(e) => setRequired(e.target.checked)}
label={t("portal.policies.wizard.enforcement.requiredLabel")}
description={t("portal.policies.wizard.enforcement.requiredDesc")}
/>
</div>
</div>
)}
</Modal>
@@ -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;
@@ -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();
});
+16 -11
View File
@@ -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={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.actions.newPipeline")}
{t("portal.pipelines.actions.newCustomPipeline")}
</Button>
</header>
{pageError && <Banner tone="danger" description={pageError} />}
{galleryEntries.length > 0 && (
<section className="portal-pipelines__suggested">
<section className="portal-pipelines__templates">
<h2 className="portal-pipelines__section-title">
{t("portal.pipelines.suggested.title")}
{t("portal.pipelines.templates.title")}
</h2>
<p className="portal-pipelines__section-sub">
{t("portal.pipelines.suggested.subtitle")}
{t("portal.pipelines.templates.subtitle")}
</p>
<PolicyCatalogueTable
entries={galleryEntries}
onOpen={openTemplate}
isLocked={isLocked}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
<div className="portal-pipelines__templates-grid">
{galleryEntries.map((entry) => (
<PipelineTemplateCard
key={entry.category.id}
entry={entry}
onOpen={openTemplate}
locked={isLocked(entry)}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
))}
</div>
</section>
)}
@@ -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;