Tidying up

This commit is contained in:
James Brunton
2026-08-27 13:37:05 +01:00
parent f2debcd930
commit 99fcd5fd8a
15 changed files with 169 additions and 197 deletions
@@ -7849,7 +7849,6 @@ title = "All pipelines"
[portal.pipelines.builder]
activate = "Activate"
back = "Back to pipelines"
backToSimple = "Back to the simple view"
cannotFollow = "Can't take {{produced}}"
chooseAccount = "Choose an account"
chooseDestination = "Choose a destination"
@@ -7914,10 +7913,6 @@ helper = "Temporary raw view of the policy's metadata (run/output settings, sour
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 skip, pause, or delete it, and it runs on their documents automatically."
label = "Enforce as policy"
[portal.pipelines.composer]
addTool = "Add a tool"
create = "Create pipeline"
@@ -7967,6 +7962,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"
@@ -8477,10 +8477,6 @@ label = "Apply a watermark"
description = "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team."
labelsHeading = "Classification labels"
[portal.policies.wizard.enforcement]
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."
saveFailed = "Couldn't save the pipeline. Please try again."
+1 -23
View File
@@ -19,29 +19,7 @@
color: var(--color-section-label);
}
/* An (i) affordance beside the label: the explanation lives in its tooltip
rather than as permanent subtext under the control. */
.sui-field__info {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: var(--c-text-subtle);
cursor: pointer;
line-height: 0;
}
.sui-field__info:hover {
color: var(--c-text);
}
.sui-field__info:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 999px;
}
/* The label's (i) affordance is the shared InfoTooltip primitive (@app/ui/InfoTooltip). */
.sui-field__required {
/* The base red is a fill colour; as text on the form background it only
+2 -35
View File
@@ -5,7 +5,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { Tooltip } from "@mantine/core";
import { InfoTooltip } from "@app/ui/InfoTooltip";
import "@app/ui/FormField.css";
export interface FormFieldProps {
@@ -77,40 +77,7 @@ export function FormField({
)}
</label>
)}
{info && (
<Tooltip
label={info}
multiline
w={260}
withArrow
position="top"
events={{ hover: true, focus: true, touch: true }}
>
<button
type="button"
className="sui-field__info"
aria-label={
typeof info === "string" ? info : "More information"
}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</Tooltip>
)}
{info && <InfoTooltip label={info} />}
</div>
)}
<div className="sui-field__control">{child}</div>
@@ -0,0 +1,19 @@
.sui-info {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: var(--c-text-subtle);
cursor: pointer;
line-height: 0;
}
.sui-info:hover {
color: var(--c-text);
}
.sui-info:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 999px;
}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoTooltip } from "@app/ui/InfoTooltip";
const meta: Meta<typeof InfoTooltip> = {
title: "Primitives/InfoTooltip",
component: InfoTooltip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: {
label: "The folder (key prefix) within the bucket to watch.",
position: "top",
},
};
export default meta;
type Story = StoryObj<typeof InfoTooltip>;
/** Hover or focus the (i) to reveal the explanation. */
export const Default: Story = {};
/** Inline beside a label, the way FormField renders it. */
export const BesideLabel: Story = {
render: (args) => (
<span
style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}
>
<span style={{ fontSize: "0.8125rem", fontWeight: 600 }}>Folder</span>
<InfoTooltip {...args} />
</span>
),
};
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { Tooltip, type FloatingPosition } from "@mantine/core";
import "@app/ui/InfoTooltip.css";
export interface InfoTooltipProps {
/** The explanation shown in the tooltip on hover/focus. */
label: ReactNode;
/** Accessible name for the button. Defaults to the label when it's a string. */
ariaLabel?: string;
/** Which side the tooltip opens on. Default "top". */
position?: FloatingPosition;
}
/**
* The app's standard inline info affordance: a small, muted (i) that reveals supplementary text in a
* hover/focus tooltip, without taking permanent space. Used behind form labels ({@link FormField})
* and anywhere a control needs a hint - one implementation so every (i) reads and behaves the same.
*/
export function InfoTooltip({
label,
ariaLabel,
position = "top",
}: InfoTooltipProps) {
return (
<Tooltip
label={label}
multiline
w={260}
withArrow
position={position}
events={{ hover: true, focus: true, touch: true }}
>
<button
type="button"
className="sui-info"
aria-label={
ariaLabel ?? (typeof label === "string" ? label : "More information")
}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</Tooltip>
);
}
+1
View File
@@ -13,6 +13,7 @@ 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";
@@ -0,0 +1,5 @@
.portal-enforce {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
import { InfoTooltip, ToggleSwitch } from "@app/ui";
import "@portal/components/pipelines/EnforceAsPolicyControl.css";
export interface EnforceAsPolicyControlProps {
/** Org-mandated policy (see Policy.required). */
required: boolean;
onRequiredChange: (required: boolean) => void;
}
/**
* The "Enforce as policy" switch plus the app's standard inline (i) info affordance explaining what
* it means. Shared by the builder header and the simple wizard so the control reads and behaves
* identically wherever a pipeline can be made org-mandated.
*/
export function EnforceAsPolicyControl({
required,
onRequiredChange,
}: EnforceAsPolicyControlProps) {
const { t } = useTranslation();
return (
<span className="portal-enforce">
<ToggleSwitch
size="sm"
checked={required}
onChange={onRequiredChange}
label={t("portal.pipelines.enforce.label")}
/>
<InfoTooltip
label={t("portal.pipelines.enforce.desc")}
ariaLabel={t("portal.pipelines.enforce.info")}
position="bottom"
/>
</span>
);
}
@@ -3,7 +3,7 @@ import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { ActionIcon, Button, IconPicker, Input } from "@app/ui";
import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
import { PIPELINE_ICON_OPTIONS } from "@portal/components/pipelines/pipelineIcon";
import { PipelinePolicyControls } from "@portal/components/pipelines/PipelinePolicyControls";
import { EnforceAsPolicyControl } from "@portal/components/pipelines/EnforceAsPolicyControl";
import "@portal/components/pipelines/PipelineCreateHeader.css";
export interface PipelineCreateHeaderProps {
@@ -12,10 +12,9 @@ export interface PipelineCreateHeaderProps {
/** Row icon key (see pipelineIcon); chosen from the picker beside the name. */
icon: string;
onIconChange: (key: string) => void;
/** Org-mandated policy toggle + return-to-simple, shown in the actions row. */
/** "Enforce as policy" toggle, shown in the actions row. */
required: boolean;
onRequiredChange: (required: boolean) => void;
onBackToSimple?: () => void;
canSave: boolean;
/** Everything still owed before the pipeline can be created, shown on the disabled create button. */
@@ -41,7 +40,6 @@ export function PipelineCreateHeader({
onIconChange,
required,
onRequiredChange,
onBackToSimple,
canSave,
blockers,
saving,
@@ -79,10 +77,9 @@ export function PipelineCreateHeader({
/>
<div className="portal-pipeline-create-header__actions">
<PipelinePolicyControls
<EnforceAsPolicyControl
required={required}
onRequiredChange={onRequiredChange}
onBackToSimple={onBackToSimple}
/>
{/* The pair share one tooltip target because a disabled button swallows its own hover - the
@@ -11,7 +11,7 @@ import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded";
import { ActionIcon, Button, Dropdown, IconPicker, Input } from "@app/ui";
import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
import { PIPELINE_ICON_OPTIONS } from "@portal/components/pipelines/pipelineIcon";
import { PipelinePolicyControls } from "@portal/components/pipelines/PipelinePolicyControls";
import { EnforceAsPolicyControl } from "@portal/components/pipelines/EnforceAsPolicyControl";
import "@portal/components/pipelines/PipelineEditHeader.css";
export interface PipelineEditHeaderProps {
@@ -20,10 +20,9 @@ export interface PipelineEditHeaderProps {
/** Row icon key (see pipelineIcon); chosen from the picker beside the name. */
icon: string;
onIconChange: (key: string) => void;
/** Org-mandated policy toggle + return-to-simple, shown in the actions row. */
/** "Enforce as policy" toggle, shown in the actions row. */
required: boolean;
onRequiredChange: (required: boolean) => void;
onBackToSimple?: () => void;
/** The pipeline's live state. Toggling it takes effect immediately, not on save. */
enabled: boolean;
@@ -62,7 +61,6 @@ export function PipelineEditHeader({
onIconChange,
required,
onRequiredChange,
onBackToSimple,
enabled,
onTogglePause,
togglingEnabled,
@@ -166,10 +164,9 @@ export function PipelineEditHeader({
</div>
<div className="portal-pipeline-edit-header__actions">
<PipelinePolicyControls
<EnforceAsPolicyControl
required={required}
onRequiredChange={onRequiredChange}
onBackToSimple={onBackToSimple}
/>
{/* Pause and Save both write the whole policy, so they are mutually exclusive: neither can
@@ -1,59 +0,0 @@
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import UndoRoundedIcon from "@mui/icons-material/UndoRounded";
import { Button, ToggleSwitch } from "@app/ui";
export interface PipelinePolicyControlsProps {
/** Org-mandated policy (see Policy.required). */
required: boolean;
onRequiredChange: (required: boolean) => void;
/**
* Return to the simple wizard. Present only while the pipeline still fits its template (so nothing
* would be lost); omitted otherwise.
*/
onBackToSimple?: () => void;
}
/**
* The pipeline-level policy controls that sit in the builder header alongside the primary actions:
* the "Enforce as policy" switch (its rule lives in a tooltip, not inline), and - when the chain is
* still template-shaped - a way back to the simple view.
*/
export function PipelinePolicyControls({
required,
onRequiredChange,
onBackToSimple,
}: PipelinePolicyControlsProps) {
const { t } = useTranslation();
return (
<>
{onBackToSimple && (
<Button
variant="tertiary"
size="sm"
onClick={onBackToSimple}
leftSection={<UndoRoundedIcon style={{ fontSize: "1.05rem" }} />}
>
{t("portal.pipelines.builder.backToSimple")}
</Button>
)}
<Tooltip
label={t("portal.pipelines.builder.required.desc")}
position="bottom-end"
withinPortal
multiline
w={260}
>
{/* Span wrapper so the tooltip has a ref-able hover target around the switch. */}
<span>
<ToggleSwitch
size="sm"
checked={required}
onChange={onRequiredChange}
label={t("portal.pipelines.builder.required.label")}
/>
</span>
</Tooltip>
</>
);
}
@@ -10,7 +10,6 @@ import {
Banner,
Button,
Card,
Checkbox,
FormField,
Input,
Modal,
@@ -19,6 +18,7 @@ import {
ToggleSwitch,
} from "@app/ui";
import { SettingsRow } from "@app/ui/SettingsRow";
import { EnforceAsPolicyControl } from "@portal/components/pipelines/EnforceAsPolicyControl";
import {
humanizeEndpoint,
type CatalogueEntry,
@@ -788,11 +788,9 @@ function PolicySetupWizardBody({
</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")}
<EnforceAsPolicyControl
required={required}
onRequiredChange={setRequired}
/>
</div>
</div>
@@ -65,7 +65,7 @@ import {
uploadPipelineAsset,
type PolicyAsset,
} from "@portal/api/pipelineAssets";
import { clearProcessedHistory, parseSimplePolicy } from "@portal/api/policies";
import { clearProcessedHistory } from "@portal/api/policies";
import { DestinationPicker } from "@portal/components/pipelines/DestinationPicker";
import { PolicyMetadataDevEditor } from "@portal/components/pipelines/PolicyMetadataDevEditor";
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
@@ -709,10 +709,6 @@ export function PipelineBuilder() {
: [];
const editorEnforced = enforcedSources.includes("editor");
const enforcedRunOn = outputOptions.runOn === "export" ? "export" : "upload";
const originCategoryId =
typeof outputOptions.categoryId === "string"
? outputOptions.categoryId
: "";
// Each validity condition is defined exactly once here, then consumed both by the graph (which
// flags each end) and by the blocker list below.
@@ -722,29 +718,6 @@ export function PipelineBuilder() {
const inputValid = sourceChosen && scheduleValid;
const outputValid = outputIds.length === 1;
// The working pipeline as a wire record, for the representability check. Steps serialize
// synchronously here (no asset upload) - parseSimplePolicy only reads their operation + params.
const currentSimplePolicy: Policy = {
id: policyState.data?.id ?? seedDraft?.id,
name: name.trim(),
enabled,
required,
icon,
inputs:
editorEnforced || !sourceChosen
? []
: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }],
steps: steps.map((step) => serializeToolStep(step, allTools)),
output: { type: outputType, options: outputOptions },
outputIds: editorEnforced ? [] : outputIds,
};
// "Back to simple" is offered only while the pipeline can still be shown by the wizard - governed
// by representability, not by whether the builder was opened. It disappears the moment an edit
// takes the chain out of its template's shape, and reappears if that edit is undone.
const simpleEntry = originCategoryId
? parseSimplePolicy(currentSimplePolicy)
: null;
// The single source of truth for "can this be committed": every reason it can't be, in the order
// they appear down the form, so a disabled Create / Save button can say exactly what is still owed.
const blockers: string[] = [];
@@ -782,16 +755,6 @@ export function PipelineBuilder() {
navigate(listPath);
}
/**
* Hand the pipeline back to the simple wizard, carrying the current (unsaved) state. Only reachable
* while {@link simpleEntry} is non-null, i.e. the chain still fits its template - so nothing is
* lost. The list route reopens the wizard from this record.
*/
function backToSimple() {
if (!simpleEntry) return;
navigate(listPath, { state: { reopenSimple: currentSimplePolicy } });
}
// Leave the builder, but prompt first if there are unsaved edits (see the unsaved-changes modal).
function attemptLeave(destination: string) {
if (dirty) setPendingNav(destination);
@@ -1383,7 +1346,6 @@ export function PipelineBuilder() {
onIconChange={setIcon}
required={required}
onRequiredChange={setRequired}
onBackToSimple={simpleEntry ? backToSimple : undefined}
enabled={enabled}
onTogglePause={handleTogglePause}
togglingEnabled={togglingEnabled}
@@ -1406,7 +1368,6 @@ export function PipelineBuilder() {
onIconChange={setIcon}
required={required}
onRequiredChange={setRequired}
onBackToSimple={simpleEntry ? backToSimple : undefined}
canSave={canSave}
blockers={blockers}
saving={submitting}
+1 -13
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Banner, Button, CardRail, EmptyState, Skeleton } from "@app/ui";
@@ -44,7 +44,6 @@ import "@portal/views/Pipelines.css";
export function Pipelines() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
@@ -130,17 +129,6 @@ export function Pipelines() {
[navigate, listPath],
);
// Reopen the simple wizard when the builder hands a pipeline back (still template-shaped). Cleared
// from history state after so a back/reload doesn't reopen it.
useEffect(() => {
const reopen = (location.state as { reopenSimple?: Policy } | null)
?.reopenSimple;
if (!reopen) return;
const entry = parseSimplePolicy(reopen);
if (entry) setWizard(entry);
navigate(location.pathname, { replace: true, state: null });
}, [location.state, location.pathname, navigate]);
// ?setup=<categoryId> deep link (onboarding): open the wizard for that suggested policy, then
// strip the param so back/reload doesn't re-open it.
useEffect(() => {