From df63428cf3d3d7fe563ae28564775de02d321466 Mon Sep 17 00:00:00 2001 From: andreizdrali-uipath Date: Wed, 16 Sep 2026 16:54:33 +0300 Subject: [PATCH] feat(apollo-react): guardrail list section [AL-575] Add `GuardrailList`, the applied-guardrails list both products render under a tool or an agent: rows with their lifecycle and status chips, BYO notices, per-row edit and remove, drag reordering, and the empty state. The host filters and the list renders. Items arrive already resolved against their definitions, every callback is an intent, and `renderItemActions`, `statusBanner` and `addSlot` are slots rather than product branches, so no telemetry, flag or product type crosses the boundary. `GuardrailListItem` is a structural mirror of what both products already hold. Reordering is dnd-kit with `verticalListSortingStrategy`, restricted to the vertical axis and the parent element (`@dnd-kit/modifiers`, the one new dependency). The row transform is `CSS.Translate`, never `CSS.Transform`: `useSortable` derives its layout transform from the row's before and after rects, so with variable row heights (a description line, a BYO notice) `CSS.Transform` emits a `scaleY` that visibly squashes or stretches the row's text mid-animation. The strategy never produces a scale of its own, so dropping it costs nothing. Verified against the installed `@dnd-kit/sortable` source, which the published docs do not cover. Rows highlight on hover unconditionally, like wind's `TableRow`: a row is a hover target whether or not clicking it opens the editor. `rowActivatesEdit` restores the legacy click-to-edit behaviour, and only then does the row body carry `cursor-pointer` and its own focus ring, since only then is it a control. The tint is `accent`, Apollo's hover surface (`--accent: var(--surface-hover)`), not `muted`, which is `surface-overlay`, the panel the list usually sits on and therefore invisible against it. The family-wide rule is in the README. Because the row paints that tint, the drag handle carries no negative margin: `p-1` gives the row 4px, which is exactly the reach of the handle's `ring-offset-2` focus ring, so pulling the handle out to align its glyph with the row's leading edge put its hover surface and its ring outside the tinted box. The glyph sits an icon button's worth of padding inside the edge instead. `byoChip` badges a bring-your-own row beside its name, opt-in like `previewChip` because it is a product decision rather than something inferable from `state.isByo`: Agents badges these rows (#6275, AL-590), Flow does not. Without it a host adopting the list lost the chip, since the row builds its chips internally from `getGuardrailListChips`, whose ids are a closed union, and the name area takes no slot. It renders before `previewChip`, because a BYO row is also a built-in validator and provenance reads before lifecycle, in the `success` tone the chip already documents as the colour both products give BYO. Agents' italic provider line from the same ticket is not included: that restyles the shared row rather than adding to it. Seventeen `guardrails.list.*` lingui ids. Twelve take their English from Flow's canvas catalog, so the wording is what the product already ships; the other five have no host equivalent and are newly written here (`edit-row`, `status-feature-disabled`, `status-disabled`, `status-unavailable`, `administration-governance`), so they are the ones needing a real loc pass rather than a lookup. English only, like every other string in this package: `chore(l10n): sync from Localization` owns the other thirteen catalogs. The i18n test uses the family's shared catalog scans (`__fixtures__/catalog-coverage`) rather than its own locale list and reader, and the stories carry no Japanese example: with the translations gone it would render English and claim otherwise. The "Preview" lifecycle chip takes the family's `info` tone, added to `GuardrailStatusChip` on #1139 for this and the palette: blue is what both products already give that chip (Flow's `InfoBadge tone="preview"`, Agents' `PreviewChip` on `semantic.colorInfoBackground`), where this row shipped it neutral grey. `GuardrailStatusBanner` and `MixedScopesBanner` become public so a host can compose the `statusBanner` slot, and each gains a story. `MixedScopesBanner` also gains the `mt-0` the title-less alert fix should have given it: wind's `AlertDescription` is unconditionally `mt-1`, which assumes an `AlertTitle` above it, so a title-less alert renders its text 4px below its icon. The same hunk is on apollo-ui#1147 and apollo-ui#1161 so the three merge without a conflict; the real fix is one line in wind's `alertVariants`. `__fixtures__/**` is excluded from the rslib build alongside `src/test/**`: fixtures are data for the suites, not API, and they reach for devDependencies. Co-Authored-By: Claude Opus 5 --- packages/apollo-react/package.json | 1 + packages/apollo-react/rslib.config.ts | 4 + .../canvas/components/Guardrails/README.md | 156 ++++++- .../Guardrails/__fixtures__/dnd-geometry.ts | 66 +++ .../__fixtures__/guardrail-list.fixtures.ts | 51 ++ .../components/guardrail-list-row.test.tsx | 436 ++++++++++++++++++ .../components/guardrail-list-row.tsx | 322 +++++++++++++ .../guardrail-status-banner.stories.tsx | 51 ++ .../components/guardrail-status-banner.tsx | 12 +- .../mixed-scopes-banner.stories.tsx | 51 ++ .../components/mixed-scopes-banner.tsx | 3 +- .../Guardrails/guardrail-list-utils.test.ts | 166 +++++++ .../Guardrails/guardrail-list-utils.ts | 92 ++++ .../Guardrails/guardrail-list.stories.tsx | 248 ++++++++++ .../Guardrails/guardrail-list.test.tsx | 310 +++++++++++++ .../components/Guardrails/guardrail-list.tsx | 340 ++++++++++++++ .../canvas/components/Guardrails/i18n.test.ts | 56 +++ .../src/canvas/components/Guardrails/i18n.ts | 198 +++++++- .../src/canvas/components/Guardrails/index.ts | 33 +- .../components/Guardrails/list-types.ts | 97 ++++ .../apollo-react/src/canvas/locales/en.json | 21 +- pnpm-lock.yaml | 3 + 22 files changed, 2691 insertions(+), 26 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/dnd-geometry.ts create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/guardrail-list.fixtures.ts create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.tsx create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.test.ts create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.ts create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.tsx create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts create mode 100644 packages/apollo-react/src/canvas/components/Guardrails/list-types.ts diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index 8b3f2ee6dc..99257b7c10 100644 --- a/packages/apollo-react/package.json +++ b/packages/apollo-react/package.json @@ -176,6 +176,7 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.14.0", diff --git a/packages/apollo-react/rslib.config.ts b/packages/apollo-react/rslib.config.ts index d1999eae4c..cfd4500446 100644 --- a/packages/apollo-react/rslib.config.ts +++ b/packages/apollo-react/rslib.config.ts @@ -55,6 +55,10 @@ export default defineConfig({ '!./src/**/*.test.{ts,tsx}', '!./src/**/*.stories.{ts,tsx}', '!./src/**/storybook-utils/**', + // Test-only, like `src/test/**`: fixtures are data for the suites, not API, and they + // reach for devDependencies (the dnd geometry helper needs `@testing-library/react`), + // so there is nothing to gain from publishing them. + '!./src/**/__fixtures__/**', '!./src/test/**', '!./src/icons/.cache', '!./src/**/*.md', diff --git a/packages/apollo-react/src/canvas/components/Guardrails/README.md b/packages/apollo-react/src/canvas/components/Guardrails/README.md index 460e396d16..2af85d25b5 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/README.md +++ b/packages/apollo-react/src/canvas/components/Guardrails/README.md @@ -5,9 +5,29 @@ later stage, Agents (`frontend-sw`). Lives in apollo-react next to canvas — MU entirely on `@uipath/apollo-wind` primitives and its `forms/` engine, strings on lingui — and is exported through the narrow `@uipath/apollo-react/canvas/guardrails` subpath (also re-exported from `./canvas`). Members: the definitions layer (wire types, parser, canonical -copy and `useGuardrailDefinitions`), `GuardrailBuilder` (the whole Add/Edit screen), -`GuardrailFormLayout` (the screen shell), and `GuardrailValidatorForm` (the validator -parameter section, also rendered inside the builder). +copy and `useGuardrailDefinitions`), `GuardrailList` (the applied-guardrails section), +`GuardrailBuilder` (the whole Add/Edit screen), `GuardrailFormLayout` (the screen shell), +and `GuardrailValidatorForm` (the validator parameter section, also rendered inside the +builder), plus the leaves the sections compose: `GuardrailStatusChip`, +`GuardrailStatusBanner` and `MixedScopesBanner`. + +## Hover and focus, family-wide + +Hover is never a prop. No wind primitive takes one, and neither does anything here: a component +derives it from the interaction it offers, so a host that wires up callbacks gets the right +affordances without styling anything. What differs between members is the element's role, and +that decides the treatment: + +- **The element is itself a control** (the palette item, the centralized row, the guardrail + list's activatable row body): gate the hover on being enabled, and pair it with `cursor-pointer` + and an explicit `focus-visible` ring, the way wind's `Button` and `DropdownMenuItem` do. +- **The element is a row that contains controls** (the guardrail list row, with its drag handle + and its actions): highlight unconditionally, the way wind's `TableRow` does, with no cursor + change. Focus belongs to the controls inside it. + +Use `accent` for the hover surface. Apollo maps `--accent` to `--surface-hover`, while `--muted` +is `--surface-overlay`, the raised panel these sections usually sit on: hovering with `muted` +paints a row the colour of its own background and barely reads. ## Definitions layer @@ -153,6 +173,136 @@ can diff their remaining local tables against it in CI while they migrate off th > `guardrails.definitions.*` id the source has dropped. The second scans all fourteen files, > so a rename cannot leave the sync's translations behind as dead entries. +## GuardrailList + +The guardrails applied to an agent or a tool: an ordered list of rows with add, edit, remove +and reorder affordances, the two bring-your-own configuration notices, and optional status +chips. A row highlights on hover, the way the legacy entries and the centralized section's rows +do, whether or not clicking it opens the editor: it is the target for the drag handle and the +row actions either way. + +```tsx +import { GuardrailList } from '@uipath/apollo-react/canvas/guardrails'; + + persist(spliceBack(reordered, move))} +/>; +``` + +### Contract + +- **The host filters, the list renders.** Feature flags, entitlements and scope filtering + never cross this boundary: both products decide what a user may see, then pass the + survivors as `guardrails` and `definitions`. The list renders every row it is given, even + one whose definition never arrived. +- **Callbacks are intents.** `onRemove` reports the request; the confirmation dialog, the + scoped-removal unwind and the write stay host-side, because the two products confirm with + different copy and unwind tool-scoped guardrails differently. Same for `onAdd` and + `onEdit`. No telemetry ships in the package: hosts wrap their own callbacks. +- **`onReorder` reports the visible array plus the move** (`{ from, to, id }`), so a host + rendering a filtered view (Flow's per-tool view over an agent-level list) can splice the + result back without this component knowing a fuller list exists. +- **`definitions` is read for three things only**: the provider line, the two BYO notices and + the status chip. Rows resolve by validator id, and a BYO row by its validator name alone, + which is the rule both products already ship (the name is unique per tenant, so an admin + rebinding a configuration to another connection still resolves). + `resolveGuardrailListItemState` is exported for hosts needing the same answer outside a row. + The type is the minimum the list reads, so `useGuardrailDefinitions`' output feeds it + unchanged, and so does a product's own definition type. +- **The BYO notices keep the "definitions have loaded" guard.** While the array is empty no + row claims its configuration is gone, which is what both products already do; without it + every BYO row flashes the notice for as long as the catalog takes to load. +- **Every addition is opt-in**, so adopting the list behind a flag renders what the host + renders today and the new UI arrives deliberately: + + | prop | default | why | + | --- | --- | --- | + | `statusChips` | `false` | Neither product chips definition status or administration today. The two BYO notices are *not* gated: both products already show those, and a guardrail that cannot run is not an opt-in detail. | + | `previewChip` | `false` | Product lifecycle, not a package concern. Both hosts pass it today and drop it at GA without a release here. | + | `byoChip` | `false` | Whether a bring-your-own row is badged as such. Agents shows it, Flow does not, so it cannot be inferred from `state.isByo`. Rendered before `previewChip`, since a BYO row is also a built-in validator: provenance first, then lifecycle. | + | `reorderDisabled` | `disabled` | Agents stops reordering in a read-only list and Flow does not, so one flag could not express both. Flow passes `false`. | + | `unstyled` | `false` | Agents renders inside its own section accordion, where the card border and padding are extra chrome. | + | `hideHeader` | `false` | Agents owns the section title and its add affordance. | + | `footer` | none | Agents' add affordance sits *below* the rows and swaps itself for an entitlement line, which the header-only `addSlot` cannot express. | + | `emptyState` | the default line | Agents renders nothing when empty: pass `null`. An explicit `null` is honoured, so the check is for an absent prop, not a falsy value. | + | `rowActivatesEdit` | `false` | Agents opens the editor by clicking the row body. The body becomes a `role="button"`, whose children ARIA treats as presentational, so the status and administration chips, the BYO notices and the description are named in its `aria-describedby`, in that reading order. The chips are state that changes what activating the row does. The lifecycle `Preview` chip, the provider line, the action badge and the scopes stay presentational: a host that needs those announced should render them outside the activatable body. | + | `renderItemActions` | inline buttons | Agents' actions are an overflow menu. The slot receives `defaultActions`, so it can add to them instead of replacing them. | + | `renderRowTooltip` | none | Agents hovers a combined description, provider and scopes tooltip over the row body. A tooltipped body that is not activatable gets `tabIndex={0}`, so the content opens on focus as well as hover. On an activatable row the body's own `aria-describedby` wins over the open tooltip's, because Radix `Slot` lets the child's non-handler props override the slot's; that is the intended precedence, since the tooltip only repeats row metadata the row already announces. | + | `formatScopes` / `formatAction` | raw values | Scope and action wording is product copy; return `null` to hide either line. | + | `getItemId` | `id ?? name` | Flow keys rows by `id`, Agents by `name`. | + | `getItemAdministration` | `'local'` | Governance-managed guardrails come from a different endpoint, so nothing on the record identifies them. | + + So Flow passes `previewChip` and `reorderDisabled={false}`; Agents passes `previewChip`, + `byoChip`, `unstyled`, `hideHeader`, `emptyState={null}`, `footer`, `rowActivatesEdit`, + `formatAction={() => null}`, `formatScopes`, `renderItemActions` and `renderRowTooltip`. + +- **Reorder is real dnd-kit, and keyboard operable.** Pointer and keyboard sensors, vertical + and parent-bound modifiers, and a drag handle that is a real button: Agents' handle today is + an `aria-hidden` icon carrying the listeners, so it cannot be reached from the keyboard. + That is a fix, not a regression. With reorder off, or with a single row, no drag machinery + is mounted at all: the sensors are hooks, so they live in a `SortableRows` component + alongside the `DndContext` rather than in `GuardrailList`, where they would run for every + non-reorderable list. +- `renderRowTooltip` brings its own `TooltipProvider`, because a row renders where none is + guaranteed. The body it anchors to is focusable either way (`role="button"` when the row + activates edit, `tabIndex={0}` when it does not), so the tooltip is reachable by keyboard + and not pointer-only. +- **Every row action names its row.** The Edit and Remove buttons are the same two icons on + every row, so a generic "Edit guardrail" leaves a screen-reader user tabbing the actions + column unable to tell which row they are on. Both take a `{{name}}` template (`editRow`, + `removeRow`), and the generic labels (`editItem`, `removeItem`) stay as the fallback for a + row with no name, where the template would announce "Edit ". +- **Error text is `text-error`, not `text-destructive`.** The two resolve differently in + several `tailwind.consumer.css` theme blocks and wind's `FormFieldError` settled on + `text-error`. The BYO notices keep `role="alert"` rather than the family banner's + `role="status"`: Flow announces them on mount today and the shared row keeps that parity. + +### Chips and notices + +`GuardrailStatusChip` is a `` carrying wind's exported `badgeVariants` plus the chip +family's pill geometry. Composed from the variants rather than the `Badge` component because +`Badge` renders a `
`, and the palette entry places these chips inside its `); + renderRow(PII_GUARDRAIL, { onEdit: vi.fn(), index: 2, renderItemActions }); + + expect(screen.getByRole('button', { name: 'More options' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Edit PII detection 1' }) + ).not.toBeInTheDocument(); + expect(renderItemActions).toHaveBeenCalledWith( + expect.objectContaining({ item: PII_GUARDRAIL, id: 'g1', index: 2, disabled: false }) + ); + }); + + it('lets a slot render the default actions alongside its own', () => { + renderRow(PII_GUARDRAIL, { + onEdit: vi.fn(), + renderItemActions: ({ defaultActions }) => ( + <> + {defaultActions} + + + ), + }); + + expect(screen.getByRole('button', { name: 'Edit PII detection 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'More options' })).toBeInTheDocument(); + }); + }); + + describe('rowActivatesEdit', () => { + it('does not make the body activatable by default', () => { + const { container } = renderRow(PII_GUARDRAIL, { onEdit: vi.fn() }); + + expect(rowBody(container)).not.toHaveAttribute('role', 'button'); + }); + + it('opens the editor on click and on Enter or Space', () => { + const onEdit = vi.fn(); + const { container } = renderRow(PII_GUARDRAIL, { onEdit, rowActivatesEdit: true }); + + const body = rowBody(container); + expect(body).toHaveAccessibleName('Edit PII detection 1'); + fireEvent.click(body); + fireEvent.keyDown(body, { key: 'Enter' }); + fireEvent.keyDown(body, { key: ' ' }); + + expect(onEdit).toHaveBeenCalledTimes(3); + }); + + it('stays inert while read-only', () => { + const { container } = renderRow(PII_GUARDRAIL, { + onEdit: vi.fn(), + rowActivatesEdit: true, + disabled: true, + }); + + expect(rowBody(container)).not.toHaveAttribute('role', 'button'); + }); + + it('describes the activatable body with its notices and description', () => { + const described = { ...BYO_GUARDRAIL, description: 'Blocks prompt injection attempts.' }; + const { container } = renderRow(described, { onEdit: vi.fn(), rowActivatesEdit: true }, [ + { validator: 'byo', status: 'Disabled', byoValidatorName: 'noma_prompt_injection' }, + ]); + + // ARIA treats a button's children as presentational, so without this the row is + // announced as its label alone and the reason it cannot run is silent. + const body = rowBody(container); + const ids = body.getAttribute('aria-describedby')?.split(' ') ?? []; + expect(ids).toHaveLength(2); + const texts = ids.map((id) => document.getElementById(id)?.textContent); + expect(texts[0]).toContain('has been disabled'); + expect(texts[1]).toBe('Blocks prompt injection attempts.'); + }); + + it('describes it with the status and administration chips, ahead of the notices', () => { + // The chips are state that changes what activating the row does, unlike the provider + // line and the scopes, which stay presentational. + const described = { ...BYO_GUARDRAIL, description: 'Blocks prompt injection attempts.' }; + const { container } = renderRow( + described, + { + onEdit: vi.fn(), + rowActivatesEdit: true, + statusChips: true, + previewChip: true, + administration: 'governance', + }, + [{ validator: 'byo', status: 'Disabled', byoValidatorName: 'noma_prompt_injection' }] + ); + + const ids = rowBody(container).getAttribute('aria-describedby')?.split(' ') ?? []; + const texts = ids.map((id) => document.getElementById(id)?.textContent); + expect(texts).toEqual([ + 'Disabled', + 'Governance managed', + expect.stringContaining('has been disabled'), + 'Blocks prompt injection attempts.', + ]); + }); + + it('leaves the presentational metadata out of the description', () => { + const { container } = renderRow(BYO_GUARDRAIL, { + onEdit: vi.fn(), + rowActivatesEdit: true, + previewChip: true, + }); + + // Provider, action and scopes are repeated metadata; naming them turns one announcement + // into a paragraph. The lifecycle `Preview` chip is the same kind of thing. + const ids = rowBody(container).getAttribute('aria-describedby')?.split(' ') ?? []; + const texts = ids.map((id) => document.getElementById(id)?.textContent); + expect(texts).not.toContain('Preview'); + expect(texts.join(' ')).not.toContain('Noma Security'); + }); + + it('names nothing when there is nothing to describe', () => { + const { container } = renderRow(CUSTOM_GUARDRAIL, { + onEdit: vi.fn(), + rowActivatesEdit: true, + }); + + // No chips, no BYO notice and no description on this fixture. + expect(rowBody(container)).not.toHaveAttribute('aria-describedby'); + }); + + it('keeps the row actions out of the activatable body, so no button nests in a button', async () => { + const { container } = renderRow(PII_GUARDRAIL, { + onEdit: vi.fn(), + onRemove: vi.fn(), + rowActivatesEdit: true, + }); + + expect(rowBody(container).querySelector('button')).toBeNull(); + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + describe('the action buttons name their row', () => { + it('interpolates the row name into both labels', () => { + // Every row ships the same two icons; without the name a screen-reader user tabbing the + // actions column cannot tell which row a button belongs to. + renderRow(BYO_GUARDRAIL, { onEdit: vi.fn(), onRemove: vi.fn() }); + + expect(screen.getByRole('button', { name: 'Edit Noma prompt shield' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove Noma prompt shield' })).toBeInTheDocument(); + }); + + it('falls back to the generic labels when the row has no name', () => { + renderRow({ ...PII_GUARDRAIL, name: ' ' }, { onEdit: vi.fn(), onRemove: vi.fn() }); + + expect(screen.getByRole('button', { name: 'Edit guardrail' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove guardrail' })).toBeInTheDocument(); + }); + }); + + it('renders the host tooltip over the row body', () => { + renderRow(PII_GUARDRAIL, { renderRowTooltip: (item) => `About ${item.name}` }); + + // Radix keeps the content unmounted until it opens; what matters here is that a row + // brings its own provider, so mounting a tooltip outside one does not throw. + expect(screen.getByText('PII detection 1')).toBeInTheDocument(); + }); + + it('makes a tooltipped body focusable, so the tooltip is not pointer-only', () => { + const { container } = renderRow(PII_GUARDRAIL, { + renderRowTooltip: (item) => `About ${item.name}`, + }); + + // Radix opens on focus as well as hover, so focusability is the whole fix (WCAG 1.4.13). + expect(container.querySelector('[data-slot="guardrail-list-row"] > div')).toHaveAttribute( + 'tabindex', + '0' + ); + }); + + it('leaves an untooltipped, non-activatable body out of the tab order', () => { + const { container } = renderRow(PII_GUARDRAIL); + + expect(container.querySelector('[data-slot="guardrail-list-row"] > div')).not.toHaveAttribute( + 'tabindex' + ); + }); + + it('capitalizes the raw action type but not host-localized output', () => { + const { unmount } = renderRow(PII_GUARDRAIL); + expect(screen.getByText('log')).toHaveClass('capitalize'); + unmount(); + + // A localized string is already cased for its locale; `capitalize` would mangle it. + renderRow(PII_GUARDRAIL, { formatAction: () => 'protokollieren' }); + expect(screen.getByText('protokollieren')).not.toHaveClass('capitalize'); + }); + + it('renders the drag handle the list passes in', () => { + renderRow(PII_GUARDRAIL, { handle: }); + + expect(screen.getByRole('button', { name: 'Reorder guardrail' })).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = renderRow(BYO_GUARDRAIL, { + onEdit: vi.fn(), + onRemove: vi.fn(), + statusChips: true, + previewChip: true, + administration: 'governance', + }); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.tsx new file mode 100644 index 0000000000..cd927e76f7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.tsx @@ -0,0 +1,322 @@ +import { + Badge, + Button, + cn, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@uipath/apollo-wind'; +import { Pencil, Trash2 } from 'lucide-react'; +import * as React from 'react'; +import { getGuardrailListChips } from '../guardrail-list-utils'; +import { formatGuardrailFormMessage, type GuardrailListLabels } from '../i18n'; +import type { + GuardrailListAdministration, + GuardrailListItem, + GuardrailListItemActionsContext, + GuardrailListItemState, + GuardrailRowTooltipRenderer, +} from '../list-types'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +export interface GuardrailListRowProps + extends Omit, 'children'> { + item: GuardrailListItem; + /** The row's resolved id, and its index in the rendered list. */ + id: string; + index: number; + /** What resolving the row against the definitions produced. */ + state: GuardrailListItemState; + labels: GuardrailListLabels; + /** Read-only: edit and remove are disabled. Reorder is the list's decision, not the row's. */ + disabled?: boolean; + /** Drag handle, already wired to the sortable. Absent when the list cannot be reordered. */ + handle?: React.ReactNode; + administration?: GuardrailListAdministration; + statusChips?: boolean; + previewChip?: boolean; + byoChip?: boolean; + /** Makes the row body a button that opens the editor (Agents' behaviour). */ + rowActivatesEdit?: boolean; + onEdit?: (item: GuardrailListItem) => void; + onRemove?: (item: GuardrailListItem) => void; + renderItemActions?: (ctx: GuardrailListItemActionsContext) => React.ReactNode; + /** Hover and focus content for the row body. See `GuardrailRowTooltipRenderer`. */ + renderRowTooltip?: GuardrailRowTooltipRenderer; + formatScopes?: (item: GuardrailListItem) => React.ReactNode; + formatAction?: (item: GuardrailListItem) => React.ReactNode; +} + +/** + * One guardrail row: drag handle, name with its chips, BYO notices, description, provider + * line, action badge and scopes, then the row actions. + * + * Presentational and reorder-free. The sortable wrapper in `guardrail-list.tsx` reaches it + * through `ref`, `style` and `handle`, so it also mounts without a `DndContext`. + */ +const GuardrailListRow = React.forwardRef( + ( + { + item, + id, + index, + state, + labels, + disabled = false, + handle, + administration, + statusChips = false, + previewChip = false, + byoChip = false, + rowActivatesEdit = false, + onEdit, + onRemove, + renderItemActions, + renderRowTooltip, + formatScopes, + formatAction, + className, + ...props + }, + ref + ) => { + const handleEdit = onEdit && !disabled ? () => onEdit(item) : undefined; + const handleRemove = onRemove && !disabled ? () => onRemove(item) : undefined; + + const chips = statusChips + ? getGuardrailListChips({ status: state.status, administration }, labels) + : []; + const isBuiltInValidator = item.$guardrailType === 'builtInValidator'; + + // Every row ships the same two icon buttons, so an unnamed one leaves a screen-reader user + // unable to tell which row they are on. The generic label stays for a row with no name. + const rowName = item.name.trim(); + const nameRow = (template: string, generic: string) => + rowName ? formatGuardrailFormMessage(template, { name: rowName }) : generic; + + const scopes = item.selector?.scopes ?? []; + // Presence of the prop decides, never the value: a host hides the line by returning null + // (Agents renders scopes in its own tooltip instead), which `??` would swallow. + const scopesContent = formatScopes ? formatScopes(item) : scopes.join(', ') || null; + const actionContent = formatAction + ? formatAction(item) + : (item.action?.$actionType ?? labels.actionUnknown); + + const defaultActions = ( + <> + {onEdit && ( + + )} + {onRemove && ( + + )} + + ); + + const actions = renderItemActions + ? renderItemActions({ + item, + id, + index, + disabled, + onEdit: handleEdit, + onRemove: handleRemove, + defaultActions, + }) + : defaultActions; + + const tooltip = renderRowTooltip?.(item); + const activatable = rowActivatesEdit && handleEdit !== undefined; + + // ARIA treats the children of a `role="button"` as presentational, so an activatable row + // is announced as its label alone. `aria-describedby` puts back only what changes what + // activating it does: the status and administration chips, the BYO notices and the + // description. `Preview`, the provider, the action and the scopes stay presentational, + // being repeated metadata; naming them all turns one announcement into a paragraph. The + // README's `rowActivatesEdit` row says so, for a host that needs them outside the body. + const bodyId = React.useId(); + const chipId = (chip: string) => `${bodyId}-chip-${chip}`; + const byoDisabledId = `${bodyId}-byo-disabled`; + const byoUnavailableId = `${bodyId}-byo-unavailable`; + const descriptionId = `${bodyId}-description`; + const describedBy = activatable + ? [ + ...chips.map((chip) => chipId(chip.id)), + state.byoDisabled ? byoDisabledId : undefined, + state.byoUnavailable ? byoUnavailableId : undefined, + item.description ? descriptionId : undefined, + ] + .filter(Boolean) + .join(' ') + : ''; + + // Only the body carries the role: the handle and the actions are siblings, so an + // activatable row never nests interactive controls inside a button. + const bodyProps: React.HTMLAttributes = activatable + ? { + role: 'button', + tabIndex: 0, + 'aria-label': nameRow(labels.editRow, labels.editItem), + ...(describedBy ? { 'aria-describedby': describedBy } : {}), + onClick: handleEdit, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleEdit?.(); + } + }, + } + : // Not activatable, but tooltipped: Radix opens on focus as well as hover, so the body + // has to be focusable or the tooltip content is pointer-only (WCAG 1.4.13). + tooltip + ? { tabIndex: 0 } + : {}; + + const body = ( +
+
+ {item.name} + {byoChip && state.isByo && ( + // Green, the colour both products already give this chip, and ahead of `preview` + // because a BYO row is also a built-in validator: provenance first, then lifecycle. + {labels.byo} + )} + {previewChip && isBuiltInValidator && ( + // Blue, the colour both products already give this chip. + {labels.preview} + )} + {chips.map((chip) => ( + + {chip.label} + + ))} +
+ {/* Ungated: a guardrail that cannot run is not an opt-in detail, and both products + already show these. `text-error` not `text-destructive`, which resolve differently + in several `tailwind.consumer.css` blocks; `role="alert"` for parity with Flow, + which announces them on mount. */} + {state.byoDisabled && ( + + )} + {state.byoUnavailable && ( + + )} + {item.description && ( +
+ {item.description} +
+ )} + {state.provider !== undefined && ( +
+ {labels.provider}: {state.provider} +
+ )} + {(actionContent || scopesContent) && ( +
+ {actionContent && ( + + {actionContent} + + )} + {scopesContent && ( + {scopesContent} + )} +
+ )} +
+ ); + + return ( +
+ {handle} + {tooltip ? ( + // Own provider: wind's `Tooltip` throws outside one, and a row renders where none is + // guaranteed. Nesting inside an existing provider only rescopes the delays. + + + {body} + {tooltip} + + + ) : ( + body + )} +
+ {actions} +
+
+ ); + } +); +GuardrailListRow.displayName = 'GuardrailListRow'; + +export { GuardrailListRow }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.stories.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.stories.tsx new file mode 100644 index 0000000000..d51ae5b916 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GuardrailStatusBanner } from './guardrail-status-banner'; + +const meta = { + title: 'Components/UiPath/Guardrail Status Banner', + component: GuardrailStatusBanner, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +Why a guardrail cannot run: the definition is disabled, the tenant is not entitled to it, or +the feature is off. Exported so a host can compose it into \`GuardrailList\`'s +\`statusBanner\` slot, which takes a node rather than a typed banner prop because the reasons +are product knowledge. + +Two tones, and they differ in more than colour. \`error\` keeps the underlying alert's +\`role="alert"\`, which interrupts a screen reader. \`warning\` is a persistent notice, so it +downgrades to \`role="status"\`, a polite live region. + `, + }, + }, + }, + tags: ['autodocs'], + args: { tone: 'error', message: 'This guardrail is unavailable for your tenant.' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ErrorTone: Story = {}; + +export const WarningTone: Story = { + args: { tone: 'warning', message: 'This guardrail is disabled and will not be evaluated.' }, +}; + +/** Both tones stacked, which is how a list renders more than one reason. */ +export const BothTones: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.tsx index 79604ef56e..6ef77982c0 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.tsx +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.tsx @@ -6,13 +6,19 @@ export interface GuardrailStatusBannerProps { message: string; } -/** Status banner shown above the guardrail form (definition disabled / unauthorized / feature off). */ +/** + * Status banner shown above the guardrail form (definition disabled / unauthorized / feature off). + * + * `mt-0` cancels the AlertDescription top offset, which assumes an AlertTitle above it. With no + * title it pushes the text 4px below the absolutely positioned icon. Drop it once apollo-wind + * handles the title-less case. + */ export function GuardrailStatusBanner({ tone, message }: GuardrailStatusBannerProps) { if (tone === 'error') { return ( - {message} + {message} ); } @@ -21,7 +27,7 @@ export function GuardrailStatusBanner({ tone, message }: GuardrailStatusBannerPr // region) instead of Alert's default role="alert". - {message} + {message} ); } diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.stories.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.stories.tsx new file mode 100644 index 0000000000..f9aab1c852 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { MixedScopesBanner } from './mixed-scopes-banner'; + +const labels = { + mixedScopesAlsoApplied: GUARDRAIL_BUILDER_EN_LABELS.mixedScopesAlsoApplied, + mixedScopesSaveAsNewHint: GUARDRAIL_BUILDER_EN_LABELS.mixedScopesSaveAsNewHint, +}; + +const meta = { + title: 'Components/UiPath/Mixed Scopes Banner', + component: MixedScopesBanner, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +Shown when the guardrail being edited also governs somewhere else: other scopes, other tools, +or both. Editing it there changes behaviour the user cannot see from here, which is why the +banner ends in a "Save as new" hint. + +The scopes and tool names arrive pre-localized. This package does not know a host's tool +names, and scope labels are the host's wording. + +Pass \`otherAppliedScopes: null\` and the banner renders nothing, so a caller can mount it +unconditionally. + `, + }, + }, + }, + tags: ['autodocs'], + args: { + otherAppliedScopes: { scopes: ['Agent'], tools: ['Search invoices', 'Send email'] }, + labels, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Scopes only, which is what an Agent-level guardrail opened from a tool looks like. */ +export const ScopesOnly: Story = { + args: { otherAppliedScopes: { scopes: ['Agent', 'Tools'], tools: [] }, labels }, +}; + +/** Nothing to warn about: the banner renders nothing rather than an empty box. */ +export const Hidden: Story = { + args: { otherAppliedScopes: null, labels }, +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.tsx index 148a1983dc..9ec2fdf311 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.tsx +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.tsx @@ -18,7 +18,8 @@ export function MixedScopesBanner({ otherAppliedScopes, labels }: MixedScopesBan return ( - + {/* `mt-0` like `GuardrailStatusBanner`: wind's `AlertDescription` assumes a title above it. */} +

{labels.mixedScopesAlsoApplied}

{/* Keys are prefixed by source: both lists render as siblings of one
    , and a tool may legitimately be named after a scope ("Agent", "Tools"), which would otherwise diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.test.ts new file mode 100644 index 0000000000..7bf6478e00 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { + BYO_DEFINITION, + BYO_GUARDRAIL, + CUSTOM_GUARDRAIL, + DEFINITIONS, + PII_DEFINITION, + PII_GUARDRAIL, +} from './__fixtures__/guardrail-list.fixtures'; +import { + getGuardrailListChips, + getGuardrailListItemId, + matchesGuardrailListDefinition, + resolveGuardrailListItemState, +} from './guardrail-list-utils'; +import { GUARDRAIL_LIST_EN_LABELS } from './i18n'; + +const labels = GUARDRAIL_LIST_EN_LABELS; + +describe('getGuardrailListItemId', () => { + it('prefers the product id', () => { + expect(getGuardrailListItemId(PII_GUARDRAIL)).toBe('g1'); + }); + + it('falls back to the name when there is no id', () => { + expect(getGuardrailListItemId({ name: 'PII detection 1' })).toBe('PII detection 1'); + }); +}); + +describe('matchesGuardrailListDefinition', () => { + it('matches a UiPath validator on the validator id', () => { + expect(matchesGuardrailListDefinition(PII_DEFINITION, PII_GUARDRAIL)).toBe(true); + }); + + it('matches a BYO guardrail on the validator name alone', () => { + // Unique per tenant, so an admin rebinding the configuration to another connection still + // resolves. The definition's `validator` deliberately does not have to agree. + expect( + matchesGuardrailListDefinition( + { ...BYO_DEFINITION, validator: 'something_else' }, + BYO_GUARDRAIL + ) + ).toBe(true); + }); + + it('never matches a UiPath validator against a BYO definition with the same validator id', () => { + expect( + matchesGuardrailListDefinition( + { ...BYO_DEFINITION, validator: 'pii_detection' }, + PII_GUARDRAIL + ) + ).toBe(false); + }); + + it('never matches a custom guardrail, which has no validator', () => { + expect(matchesGuardrailListDefinition(PII_DEFINITION, CUSTOM_GUARDRAIL)).toBe(false); + }); +}); + +describe('resolveGuardrailListItemState', () => { + it('resolves a UiPath validator to its definition and status', () => { + expect(resolveGuardrailListItemState(PII_GUARDRAIL, DEFINITIONS)).toEqual({ + definition: PII_DEFINITION, + status: 'Available', + isByo: false, + provider: undefined, + byoDisabled: false, + byoUnavailable: false, + }); + }); + + it('exposes the BYO connector as the provider', () => { + expect(resolveGuardrailListItemState(BYO_GUARDRAIL, DEFINITIONS)).toMatchObject({ + isByo: true, + provider: 'Noma Security', + byoDisabled: false, + byoUnavailable: false, + }); + }); + + it('reports a disabled BYO configuration', () => { + const state = resolveGuardrailListItemState(BYO_GUARDRAIL, [ + PII_DEFINITION, + { ...BYO_DEFINITION, status: 'Disabled' }, + ]); + + expect(state).toMatchObject({ status: 'Disabled', byoDisabled: true, byoUnavailable: false }); + }); + + it('reports a BYO configuration that no longer resolves', () => { + const state = resolveGuardrailListItemState(BYO_GUARDRAIL, [PII_DEFINITION]); + + expect(state).toMatchObject({ + definition: undefined, + status: 'Unavailable', + byoUnavailable: true, + }); + }); + + it('stays quiet about a BYO row while the definitions are still in flight', () => { + // Without the empty-array guard every BYO row would claim its configuration is gone for + // as long as the catalog takes to load. Both products already guard this. + const state = resolveGuardrailListItemState(BYO_GUARDRAIL, []); + + expect(state).toMatchObject({ status: undefined, byoUnavailable: false, byoDisabled: false }); + }); + + it('treats a missing definitions array as still in flight', () => { + expect(resolveGuardrailListItemState(BYO_GUARDRAIL)).toMatchObject({ byoUnavailable: false }); + }); + + it('resolves a custom guardrail to nothing without claiming it is broken', () => { + expect(resolveGuardrailListItemState(CUSTOM_GUARDRAIL, DEFINITIONS)).toEqual({ + definition: undefined, + status: undefined, + isByo: false, + provider: undefined, + byoDisabled: false, + byoUnavailable: false, + }); + }); + + it('does not confuse a disabled UiPath validator for a disabled BYO configuration', () => { + const state = resolveGuardrailListItemState(PII_GUARDRAIL, [ + { ...PII_DEFINITION, status: 'Disabled' }, + ]); + + expect(state).toMatchObject({ status: 'Disabled', byoDisabled: false }); + }); +}); + +describe('getGuardrailListChips', () => { + it('chips nothing for a locally administered, available row', () => { + expect(getGuardrailListChips({ status: 'Available', administration: 'local' }, labels)).toEqual( + [] + ); + }); + + it('chips nothing when nothing resolved', () => { + expect(getGuardrailListChips({}, labels)).toEqual([]); + }); + + it.each([ + ['FeatureDisabled', 'warning', labels.statusFeatureDisabled], + ['Unauthorised', 'warning', labels.statusUnauthorized], + ['Disabled', 'error', labels.statusDisabled], + ['Unavailable', 'error', labels.statusUnavailable], + ] as const)('chips %s as a %s', (status, tone, label) => { + expect(getGuardrailListChips({ status }, labels)).toEqual([{ id: 'status', tone, label }]); + }); + + it('chips governance administration', () => { + expect(getGuardrailListChips({ administration: 'governance' }, labels)).toEqual([ + { id: 'administration', tone: 'neutral', label: 'Governance managed' }, + ]); + }); + + it('chips the status before the administration', () => { + const chips = getGuardrailListChips( + { status: 'Disabled', administration: 'governance' }, + labels + ); + + expect(chips.map((chip) => chip.id)).toEqual(['status', 'administration']); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.ts b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.ts new file mode 100644 index 0000000000..424c649486 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list-utils.ts @@ -0,0 +1,92 @@ +import type { GuardrailListLabels } from './i18n'; +import type { + GuardrailListAdministration, + GuardrailListChip, + GuardrailListDefinition, + GuardrailListItem, + GuardrailListItemState, +} from './list-types'; + +/** + * A row's identity. Agents keys rows by `name`, unique per agent by construction, and Flow by + * `id`, so one default covers both; override with `getItemId`. + */ +export function getGuardrailListItemId(item: GuardrailListItem): string { + return item.id ?? item.name; +} + +/** + * Both products' own `matchesGuardrailDefinition`: a BYO guardrail matches on the validator + * name alone, so rebinding a configuration to another connection still resolves. + */ +export function matchesGuardrailListDefinition( + definition: GuardrailListDefinition, + item: GuardrailListItem +): boolean { + return item.byoValidatorName === undefined + ? definition.byoValidatorName === undefined && definition.validator === item.validatorType + : definition.byoValidatorName === item.byoValidatorName; +} + +/** + * Resolve one row against the host-filtered definitions. The BYO notices keep both products' + * `definitions.length > 0` guard, without which every BYO row claims its configuration is + * gone while the catalog is still in flight. + */ +export function resolveGuardrailListItemState( + item: GuardrailListItem, + definitions: readonly GuardrailListDefinition[] = [] +): GuardrailListItemState { + // Only built-in-validator guardrails carry this field in either product, so it is the test. + const isByo = item.byoValidatorName !== undefined; + const definition = definitions.find((candidate) => + matchesGuardrailListDefinition(candidate, item) + ); + const byoUnavailable = isByo && definitions.length > 0 && definition === undefined; + const byoDisabled = isByo && definition?.status === 'Disabled'; + + return { + definition, + status: byoUnavailable ? 'Unavailable' : definition?.status, + isByo, + provider: definition?.byoConnectorName, + byoDisabled, + byoUnavailable, + }; +} + +/** + * At most one status and one administration chip. A locally administered `Available` row + * produces none, which is the common case: both products chip nothing for it today. + */ +export function getGuardrailListChips( + input: { + status?: GuardrailListItemState['status']; + administration?: GuardrailListAdministration; + }, + labels: GuardrailListLabels +): GuardrailListChip[] { + const chips: GuardrailListChip[] = []; + + const statusChip = ((): Omit | undefined => { + switch (input.status) { + case 'FeatureDisabled': + return { tone: 'warning', label: labels.statusFeatureDisabled }; + case 'Unauthorised': + return { tone: 'warning', label: labels.statusUnauthorized }; + case 'Disabled': + return { tone: 'error', label: labels.statusDisabled }; + case 'Unavailable': + return { tone: 'error', label: labels.statusUnavailable }; + default: + return undefined; + } + })(); + if (statusChip) chips.push({ id: 'status', ...statusChip }); + + if (input.administration === 'governance') { + chips.push({ id: 'administration', tone: 'neutral', label: labels.administrationGovernance }); + } + + return chips; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.stories.tsx b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.stories.tsx new file mode 100644 index 0000000000..c995460fe1 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.stories.tsx @@ -0,0 +1,248 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + TooltipProvider, +} from '@uipath/apollo-wind'; +import { MoreVertical } from 'lucide-react'; +import { useState } from 'react'; +import type { GuardrailScope } from './builder-types'; +import { GuardrailStatusBanner } from './components/guardrail-status-banner'; +import { GuardrailList } from './guardrail-list'; +import type { GuardrailListDefinition, GuardrailListItem } from './list-types'; + +const meta = { + title: 'Components/UiPath/Guardrail List', + component: GuardrailList, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +The guardrails applied to an agent or a tool: an ordered, reorderable list of rows with add, +edit and remove affordances, the two bring-your-own configuration notices, and optional +status chips. + +The host filters and this renders. Rows and definitions arrive already filtered by feature +flags, entitlements and scope, and every callback is an intent: confirmation dialogs, +persistence and telemetry stay with the product. Every addition beyond what both products +already show is opt-in, so adopting it behind a flag renders what the host renders today. + `, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( + +
    + +
    +
    + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const AGENT_AND_TOOL: GuardrailScope[] = ['Agent', 'Tool']; + +const guardrails: GuardrailListItem[] = [ + { + id: 'g1', + name: 'PII detection 1', + description: 'Scans agent output for personal data.', + $guardrailType: 'builtInValidator', + selector: { scopes: AGENT_AND_TOOL, matchNames: ['Send email'] }, + action: { $actionType: 'log' }, + validatorType: 'pii_detection', + }, + { + id: 'g2', + name: 'Noma prompt shield', + description: 'Blocks prompt injection attempts on LLM calls.', + $guardrailType: 'builtInValidator', + selector: { scopes: ['Llm'] }, + action: { $actionType: 'block' }, + validatorType: 'byo', + byoValidatorName: 'noma_prompt_injection', + }, + { + id: 'g3', + name: 'Blocked words', + $guardrailType: 'custom', + selector: { scopes: ['Tool'], matchNames: ['Send email'] }, + action: { $actionType: 'filter' }, + }, +]; + +const definitions: GuardrailListDefinition[] = [ + { validator: 'pii_detection', status: 'Available' }, + { + validator: 'byo', + status: 'Available', + byoValidatorName: 'noma_prompt_injection', + byoConnectorName: 'Noma Security', + }, +]; + +const noop = () => {}; + +/** Flow's shape: the card, its own header with an Add button, and reorderable rows. */ +export const Default: Story = { + args: { + guardrails, + definitions, + previewChip: true, + onAdd: noop, + onEdit: noop, + onRemove: noop, + onReorder: noop, + }, +}; + +/** Read-only, but still reorderable: what Flow passes when the canvas is locked. */ +export const ReadOnlyButReorderable: Story = { + args: { ...Default.args, disabled: true, reorderDisabled: false }, +}; + +export const Empty: Story = { + args: { guardrails: [], definitions, onAdd: noop }, +}; + +/** A bring-your-own row whose configuration was disabled, and one that no longer resolves. */ +export const ByoNotices: Story = { + args: { + guardrails, + definitions: [ + { validator: 'pii_detection', status: 'Available' }, + { + validator: 'byo', + status: 'Disabled', + byoValidatorName: 'noma_prompt_injection', + byoConnectorName: 'Noma Security', + }, + ], + onEdit: noop, + onRemove: noop, + }, +}; + +/** Status and administration chips, both off by default. */ +export const StatusChips: Story = { + args: { + guardrails, + definitions: [ + { validator: 'pii_detection', status: 'Unauthorised' }, + { validator: 'byo', status: 'Available', byoValidatorName: 'noma_prompt_injection' }, + ], + statusChips: true, + previewChip: true, + getItemAdministration: (item) => (item.id === 'g1' ? 'governance' : 'local'), + onEdit: noop, + }, +}; + +/** A definitions load failure is a host-owned banner rendered above the rows. */ +export const WithStatusBanner: Story = { + args: { + ...Default.args, + statusBanner: ( + + ), + }, +}; + +/** + * Agents' shape: the section chrome and the add affordance belong to the host, rows open the + * editor on click, the actions are an overflow menu, and the description, provider and scopes + * move into a hover tooltip. + */ +export const EmbeddedInHostSection: Story = { + args: { + guardrails, + definitions, + unstyled: true, + hideHeader: true, + rowActivatesEdit: true, + previewChip: true, + emptyState: null, + onEdit: noop, + onRemove: noop, + onReorder: noop, + formatAction: () => null, + formatScopes: (item) => `Scopes: ${(item.selector?.scopes ?? []).join(', ')}`, + renderRowTooltip: (item) => ( +
    + {item.description &&

    {item.description}

    } +

    Scopes: {(item.selector?.scopes ?? []).join(', ')}

    +
    + ), + renderItemActions: ({ item, onEdit, onRemove }) => ( + + + + + + onEdit?.()}>Edit + onRemove?.()}>Remove + + + ), + footer: ( + + ), + }, + decorators: [ + (Story) => ( + +
    +

    Guardrails (host section)

    + +
    +
    + ), + ], +}; + +/** Reordering reports the visible array plus the move, and the host owns the write. */ +export const ControlledReorder: Story = { + args: { guardrails, definitions, onReorder: noop }, + render: (args) => { + function ReorderExample() { + const [rows, setRows] = useState(args.guardrails); + const [lastMove, setLastMove] = useState('none yet'); + return ( +
    + { + setRows(reordered); + setLastMove(`${move.id}: ${move.from} to ${move.to}`); + }} + /> +

    Last move: {lastMove}

    +
    + ); + } + return ; + }, +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.test.tsx new file mode 100644 index 0000000000..6bf79ea629 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.test.tsx @@ -0,0 +1,310 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { flushDndFrame, layoutRowsVertically } from './__fixtures__/dnd-geometry'; +import { + BYO_GUARDRAIL, + CUSTOM_GUARDRAIL, + DEFINITIONS, + GUARDRAILS, + PII_GUARDRAIL, +} from './__fixtures__/guardrail-list.fixtures'; +import { GuardrailStatusBanner } from './components/guardrail-status-banner'; +import { GuardrailList, type GuardrailListProps } from './guardrail-list'; + +function renderList(props: Partial = {}) { + return render(); +} + +const reorderHandle = (name: string) => + screen.getByRole('button', { name: `Reorder guardrail ${name}` }); + +describe('GuardrailList', () => { + it('renders every row it is given, in order', () => { + renderList(); + + const rows = screen.getAllByText(/PII detection 1|Noma prompt shield|Blocked words/); + expect(rows.map((row) => row.textContent)).toEqual([ + 'PII detection 1', + 'Noma prompt shield', + 'Blocked words', + ]); + }); + + it('renders a row whose definition never arrived', () => { + renderList({ definitions: [] }); + + expect(screen.getByText('Noma prompt shield')).toBeInTheDocument(); + }); + + describe('header', () => { + it('renders the title and the add affordance', () => { + const onAdd = vi.fn(); + renderList({ onAdd }); + + expect(screen.getByText('Guardrails')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('omits the add button when the host wired no intent', () => { + renderList(); + + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + }); + + it('disables the add button while read-only', () => { + renderList({ onAdd: vi.fn(), disabled: true }); + + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + }); + + it('lets a slot replace the add button', () => { + renderList({ onAdd: vi.fn(), addSlot: Requires entitlement }); + + expect(screen.getByText('Requires entitlement')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + }); + + it('can be hidden for hosts that own the section chrome', () => { + renderList({ hideHeader: true, onAdd: vi.fn() }); + + expect(screen.queryByText('Guardrails')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + }); + }); + + describe('chrome', () => { + it('renders a card by default and drops it when unstyled', () => { + const { container, unmount } = renderList(); + expect(container.querySelector('[data-slot="guardrail-list"]')).toHaveClass('border'); + unmount(); + + const { container: bare } = renderList({ unstyled: true }); + expect(bare.querySelector('[data-slot="guardrail-list"]')).not.toHaveClass('border'); + }); + + it('renders the status banner above the rows', () => { + renderList({ + statusBanner: , + }); + + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load validators'); + }); + + it('renders the footer below the rows', () => { + renderList({ footer: }); + + expect(screen.getByRole('button', { name: 'Add another guardrail' })).toBeInTheDocument(); + }); + }); + + describe('empty state', () => { + it('explains itself by default', () => { + renderList({ guardrails: [] }); + + expect(screen.getByText('No guardrails configured')).toBeInTheDocument(); + }); + + it('renders nothing when the host passes null', () => { + renderList({ guardrails: [], emptyState: null }); + + expect(screen.queryByText('No guardrails configured')).not.toBeInTheDocument(); + }); + + it('renders a host-supplied replacement', () => { + renderList({ guardrails: [], emptyState: Nothing here yet }); + + expect(screen.getByText('Nothing here yet')).toBeInTheDocument(); + expect(screen.queryByText('No guardrails configured')).not.toBeInTheDocument(); + }); + }); + + describe('rows', () => { + it('identifies rows by id, and by name when the host has no ids', () => { + const { container, unmount } = renderList(); + expect( + Array.from(container.querySelectorAll('[data-guardrail-id]')).map((row) => + row.getAttribute('data-guardrail-id') + ) + ).toEqual(['g1', 'g2', 'g3']); + unmount(); + + const { container: byName } = renderList({ + guardrails: [{ name: 'PII detection 1' }], + }); + expect(byName.querySelector('[data-guardrail-id]')).toHaveAttribute( + 'data-guardrail-id', + 'PII detection 1' + ); + }); + + it('lets the host override row identity', () => { + const { container } = renderList({ getItemId: (item) => `custom-${item.name}` }); + + expect(container.querySelector('[data-guardrail-id]')).toHaveAttribute( + 'data-guardrail-id', + 'custom-PII detection 1' + ); + }); + + it('tags governance-administered rows when the chips are on', () => { + renderList({ + statusChips: true, + getItemAdministration: (item) => (item.id === 'g2' ? 'governance' : 'local'), + }); + + expect(screen.getAllByText('Governance managed')).toHaveLength(1); + }); + + it('forwards the row intents', () => { + const onEdit = vi.fn(); + const onRemove = vi.fn(); + renderList({ onEdit, onRemove }); + + // Addressed by name rather than by index, which is what naming each row's actions buys. + fireEvent.click(screen.getByRole('button', { name: 'Edit PII detection 1' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove Noma prompt shield' })); + + expect(onEdit).toHaveBeenCalledWith(PII_GUARDRAIL); + expect(onRemove).toHaveBeenCalledWith(BYO_GUARDRAIL); + }); + }); + + describe('reorder', () => { + it('mounts no drag affordance without an onReorder intent', () => { + renderList(); + + expect(screen.queryByRole('button', { name: /^Reorder guardrail/ })).not.toBeInTheDocument(); + }); + + it('mounts no drag affordance for a single row', () => { + renderList({ guardrails: [PII_GUARDRAIL], onReorder: vi.fn() }); + + expect(screen.queryByRole('button', { name: /^Reorder guardrail/ })).not.toBeInTheDocument(); + }); + + it('mounts no DndContext at all while reorder is off', () => { + const { rerender } = renderList({ onReorder: vi.fn(), reorderDisabled: true }); + + // `DndContext` announces drags through a live region it renders itself, so its absence + // is the observable half of "no drag machinery mounts". The other half is structural: + // the sensors are hooks, and they live in `SortableRows` next to the context rather + // than in `GuardrailList`, so they do not run for a non-reorderable list either. + expect(document.querySelector('[id^="DndLiveRegion"]')).toBeNull(); + + rerender( + + ); + + expect(document.querySelector('[id^="DndLiveRegion"]')).not.toBeNull(); + }); + + it('follows `disabled` by default', () => { + renderList({ onReorder: vi.fn(), disabled: true }); + + expect(screen.queryByRole('button', { name: /^Reorder guardrail/ })).not.toBeInTheDocument(); + }); + + it('stays reorderable in a read-only list when the host asks', () => { + renderList({ onReorder: vi.fn(), disabled: true, reorderDisabled: false }); + + expect(reorderHandle('PII detection 1')).toBeInTheDocument(); + }); + + it('names each handle after its row, and keeps it keyboard reachable', () => { + renderList({ onReorder: vi.fn() }); + + const handle = reorderHandle('Noma prompt shield'); + expect(handle.tagName).toBe('BUTTON'); + expect(handle).not.toHaveAttribute('aria-hidden'); + }); + + it('keeps the handle inside the row it belongs to', () => { + renderList({ onReorder: vi.fn() }); + + // The row paints a hover tint with 4px of padding, which is exactly the reach of the + // handle's `ring-offset-2` focus ring, so a negative margin here puts the handle's hover + // surface and its ring outside the tinted box. Pinned as a class because happy-dom has + // no layout to measure. + expect(reorderHandle('PII detection 1').className).not.toMatch(/-m[lxs]?-/); + }); + + it('reports the reordered rows and the move after a keyboard drag', async () => { + const onReorder = vi.fn(); + const { container } = renderList({ onReorder }); + layoutRowsVertically(container); + + const handle = reorderHandle('PII detection 1'); + fireEvent.keyDown(handle, { key: ' ', code: 'Space' }); + await flushDndFrame(); + fireEvent.keyDown(handle, { key: 'ArrowDown', code: 'ArrowDown' }); + await flushDndFrame(); + fireEvent.keyDown(handle, { key: ' ', code: 'Space' }); + + expect(onReorder).toHaveBeenCalledWith([BYO_GUARDRAIL, PII_GUARDRAIL, CUSTOM_GUARDRAIL], { + from: 0, + to: 1, + id: 'g1', + }); + }); + + it('reports nothing when a drag is cancelled', async () => { + const onReorder = vi.fn(); + const { container } = renderList({ onReorder }); + layoutRowsVertically(container); + + const handle = reorderHandle('PII detection 1'); + fireEvent.keyDown(handle, { key: ' ', code: 'Space' }); + await flushDndFrame(); + fireEvent.keyDown(handle, { key: 'ArrowDown', code: 'ArrowDown' }); + await flushDndFrame(); + fireEvent.keyDown(handle, { key: 'Escape', code: 'Escape' }); + + expect(onReorder).not.toHaveBeenCalled(); + }); + + it('reports nothing when a row is dropped where it started', async () => { + const onReorder = vi.fn(); + const { container } = renderList({ onReorder }); + layoutRowsVertically(container); + + const handle = reorderHandle('PII detection 1'); + fireEvent.keyDown(handle, { key: ' ', code: 'Space' }); + await flushDndFrame(); + fireEvent.keyDown(handle, { key: ' ', code: 'Space' }); + + expect(onReorder).not.toHaveBeenCalled(); + }); + }); + + it('has no accessibility violations', async () => { + const { container } = renderList({ + onAdd: vi.fn(), + onEdit: vi.fn(), + onRemove: vi.fn(), + onReorder: vi.fn(), + statusChips: true, + previewChip: true, + byoChip: true, + }); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it('hands byoChip to the rows, so only the BYO one is badged', () => { + renderList({ byoChip: true }); + + // GUARDRAILS is one UiPath validator, one BYO, one custom: exactly one chip. + expect(screen.getAllByText('BYO')).toHaveLength(1); + expect( + screen.getByText('Noma prompt shield').closest('[data-slot="guardrail-list-row"]') + ).toHaveTextContent('BYO'); + }); + + it('has no accessibility violations while empty and read-only', async () => { + const { container } = renderList({ guardrails: [], disabled: true, onAdd: vi.fn() }); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.tsx b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.tsx new file mode 100644 index 0000000000..09dbe0667a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-list.tsx @@ -0,0 +1,340 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Button, cn } from '@uipath/apollo-wind'; +import { GripVertical, Plus, Shield } from 'lucide-react'; +import { type ReactNode, useCallback, useMemo } from 'react'; +import { GuardrailListRow, type GuardrailListRowProps } from './components/guardrail-list-row'; +import { getGuardrailListItemId, resolveGuardrailListItemState } from './guardrail-list-utils'; +import { + formatGuardrailFormMessage, + type GuardrailListLabels, + useGuardrailListLabels, +} from './i18n'; +import type { + GuardrailListAdministration, + GuardrailListDefinition, + GuardrailListItem, + GuardrailListItemActionsContext, + GuardrailReorderMove, + GuardrailRowTooltipRenderer, +} from './list-types'; + +const DND_MODIFIERS = [restrictToVerticalAxis, restrictToParentElement]; +// A pointer has to travel before a press becomes a drag, or clicking a row's body to edit it +// registers as a nudge instead. Both products already use 8px. +const POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 8 } }; + +export interface GuardrailListProps { + /** + * The rows to render, in order, already filtered by the host: feature flags, entitlements + * and scope filtering never cross this boundary. The list renders every item it is given. + */ + guardrails: GuardrailListItem[]; + /** + * Definitions the rows resolve against, already filtered by the host. Used only to derive + * the provider line, the two BYO notices and the status chip; rows with no matching + * definition still render. + */ + definitions?: GuardrailListDefinition[]; + /** Read-only: suppresses add, edit and remove. Reorder follows `reorderDisabled`. */ + disabled?: boolean; + /** + * Defaults to `disabled`, Agents' behaviour; Flow keeps its read-only list reorderable and + * passes `false`. With reorder off no drag machinery mounts at all. + */ + reorderDisabled?: boolean; + + /** Open the editor for a row. The row body activates it too when `rowActivatesEdit` is set. */ + onEdit?: (item: GuardrailListItem) => void; + /** + * An intent, not a mutation: the confirmation, the scoped-removal unwind and the write stay + * host-side, because the two products confirm and unwind differently. + */ + onRemove?: (item: GuardrailListItem) => void; + /** Add affordance intent (header button, or whatever `addSlot` / `footer` render). */ + onAdd?: () => void; + /** + * Receives the reordered *visible* array plus the move, so a host rendering a filtered view + * can splice the result back without this component knowing a fuller list exists. + */ + onReorder?: (guardrails: GuardrailListItem[], move: GuardrailReorderMove) => void; + + /** Row identity. Defaults to `id ?? name`; must be stable and unique. */ + getItemId?: (item: GuardrailListItem) => string; + /** Defaults to `'local'`, which chips nothing: the record itself does not say. */ + getItemAdministration?: (item: GuardrailListItem) => GuardrailListAdministration | undefined; + + /** Render the status and administration chips. Off: neither product shows them today. */ + statusChips?: boolean; + /** Render the "Preview" badge on built-in-validator rows. Product lifecycle, not a package concern. */ + previewChip?: boolean; + /** Render the "BYO" badge on rows backed by a BYO validator. Provenance, opt-in per host. */ + byoChip?: boolean; + /** Drop the card border and padding, for hosts that already own the section chrome. */ + unstyled?: boolean; + /** Hide the header (title and add affordance) when the host renders its own. */ + hideHeader?: boolean; + /** Make the row body a button that opens the editor. */ + rowActivatesEdit?: boolean; + /** Replaces the default empty line. Pass `null` to render nothing when the list is empty. */ + emptyState?: ReactNode; + /** Rendered under the rows, e.g. an add affordance that doubles as an entitlement notice. */ + footer?: ReactNode; + /** Replaces the header's add button, e.g. with an entitlement lock. */ + addSlot?: ReactNode; + /** Rendered above the rows, e.g. `GuardrailStatusBanner` for a definitions load failure. */ + statusBanner?: ReactNode; + + /** Hover and focus content for a row's body. See `GuardrailRowTooltipRenderer`. */ + renderRowTooltip?: GuardrailRowTooltipRenderer; + /** Replace a row's inline actions, e.g. with an overflow menu. */ + renderItemActions?: (ctx: GuardrailListItemActionsContext) => ReactNode; + /** Localize a row's scopes line. Return `null` to hide it. Default: raw scopes, comma-joined. */ + formatScopes?: (item: GuardrailListItem) => ReactNode; + /** Localize a row's action badge. Return `null` to hide it. Default: the raw action type. */ + formatAction?: (item: GuardrailListItem) => ReactNode; + + labels?: Partial; + className?: string; +} + +type SortableRowProps = Omit; + +/** + * The drag machinery, mounted only while the list is reorderable. The sensors live here rather + * than in `GuardrailList` because `useSensors` is a hook: in the parent it would run for every + * non-reorderable list, which is most of them. + */ +function SortableRows({ + ids, + onDragEnd, + children, +}: { + ids: string[]; + onDragEnd: (event: DragEndEvent) => void; + children: ReactNode; +}) { + const sensors = useSensors( + useSensor(PointerSensor, POINTER_SENSOR_OPTIONS), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + return ( + + + {children} + + + ); +} + +/** A row inside a `DndContext`, so `useSortable` never runs outside its provider. */ +function SortableGuardrailRow({ id, item, labels, ...rowProps }: SortableRowProps) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const handle = ( + + ); + + return ( + + ); +} + +/** + * The guardrails applied to an agent or a tool: an ordered, reorderable list with add, edit + * and remove affordances. + * + * The host filters, this renders. Every callback is an intent, and every addition beyond what + * both products already show is opt-in, so adopting it behind a flag changes nothing on screen. + */ +export function GuardrailList({ + guardrails, + definitions, + disabled = false, + reorderDisabled, + onEdit, + onRemove, + onAdd, + onReorder, + getItemId = getGuardrailListItemId, + getItemAdministration, + statusChips = false, + previewChip = false, + byoChip = false, + unstyled = false, + hideHeader = false, + rowActivatesEdit = false, + emptyState, + footer, + addSlot, + statusBanner, + renderRowTooltip, + renderItemActions, + formatScopes, + formatAction, + labels: labelOverrides, + className, +}: GuardrailListProps) { + const labels = useGuardrailListLabels(labelOverrides); + + const rows = useMemo( + () => guardrails.map((item) => ({ item, id: getItemId(item) })), + [guardrails, getItemId] + ); + const ids = useMemo(() => rows.map((row) => row.id), [rows]); + const isReorderable = + !(reorderDisabled ?? disabled) && onReorder !== undefined && guardrails.length > 1; + + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!onReorder || !over || active.id === over.id) return; + const from = ids.indexOf(String(active.id)); + const to = ids.indexOf(String(over.id)); + if (from === -1 || to === -1) return; + // `active.id` is the row id `getItemId` produced, so the move needs no index lookup. + onReorder(arrayMove(guardrails, from, to), { from, to, id: String(active.id) }); + }, + [guardrails, ids, onReorder] + ); + + const renderedRows = rows.map(({ item, id }, index) => { + const rowProps: Omit = { + item, + id, + index, + state: resolveGuardrailListItemState(item, definitions), + labels, + disabled, + administration: getItemAdministration?.(item), + statusChips, + previewChip, + byoChip, + rowActivatesEdit, + onEdit, + onRemove, + renderRowTooltip, + renderItemActions, + formatScopes, + formatAction, + }; + return isReorderable ? ( + + ) : ( + + ); + }); + + const content = + guardrails.length > 0 ? ( +
    {renderedRows}
    + ) : // An explicit `null` hides the empty line; only an absent prop takes the default. + emptyState === undefined ? ( +

    {labels.empty}

    + ) : ( + emptyState + ); + + return ( +
    + {!hideHeader && ( +
    +
    +
    + {addSlot !== undefined + ? addSlot + : onAdd && ( + + )} +
    + )} +
    + {statusBanner} + {isReorderable ? ( + + {content} + + ) : ( + content + )} + {footer} +
    +
    + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts new file mode 100644 index 0000000000..48ea9b1fce --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { findCatalogDrift, findCatalogOrphans } from './__fixtures__/catalog-coverage'; +import { + formatGuardrailFormMessage, + GUARDRAIL_LIST_EN_LABELS, + GUARDRAIL_LIST_EN_MESSAGES, + resolveGuardrailListLabels, +} from './i18n'; + +describe('resolveGuardrailListLabels', () => { + it('returns the English defaults when there is nothing to merge', () => { + expect(resolveGuardrailListLabels()).toEqual(GUARDRAIL_LIST_EN_LABELS); + }); + + it('layers the catalog over the defaults and the overrides over both', () => { + const labels = resolveGuardrailListLabels( + { title: 'Absicherungen', add: 'Hinzufügen' }, + { add: 'Neu' } + ); + + expect(labels.title).toBe('Absicherungen'); + expect(labels.add).toBe('Neu'); + expect(labels.empty).toBe(GUARDRAIL_LIST_EN_LABELS.empty); + }); + + it('never lets an absent string blank a default', () => { + const labels = resolveGuardrailListLabels({ title: undefined }, { add: undefined }); + + expect(labels.title).toBe('Guardrails'); + expect(labels.add).toBe('Add'); + }); +}); + +describe('GUARDRAIL_LIST_EN_LABELS', () => { + it('carries the template convention the row interpolates with', () => { + expect(GUARDRAIL_LIST_EN_LABELS.reorderItem).toBe('Reorder guardrail {{name}}'); + expect( + formatGuardrailFormMessage(GUARDRAIL_LIST_EN_LABELS.editRow, { name: 'PII detection 1' }) + ).toBe('Edit PII detection 1'); + expect( + formatGuardrailFormMessage(GUARDRAIL_LIST_EN_LABELS.removeRow, { name: 'PII detection 1' }) + ).toBe('Remove PII detection 1'); + }); +}); + +describe('the shared canvas catalog', () => { + // Shared with every component's i18n test; `__fixtures__/catalog-coverage` says why these + // two scans and not a translation-coverage one. + it('carries every list message with the same English', () => { + expect(findCatalogDrift(GUARDRAIL_LIST_EN_MESSAGES)).toEqual({ missing: [], drifted: [] }); + }); + + it('carries no list message the source no longer declares', () => { + expect(findCatalogOrphans(GUARDRAIL_LIST_EN_MESSAGES, 'guardrails.list.')).toEqual([]); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts b/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts index 08ce108ed5..041dcdb3dd 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts @@ -185,22 +185,34 @@ export const GUARDRAIL_BUILDER_EN_LABELS: GuardrailBuilderLabels = { actionAppRequiredError: 'Action app is required', }; -/** Merge English defaults, a loaded catalog, and per-string overrides (undefined skipped). */ -export function resolveGuardrailBuilderLabels( - catalog?: Partial, - overrides?: Partial -): GuardrailBuilderLabels { - const merged: GuardrailBuilderLabels = { ...GUARDRAIL_BUILDER_EN_LABELS }; +// One merge for every label set: English defaults, then the catalog, then the host's +// overrides, skipping `undefined` so a partial source never blanks a string. +function mergeLabels( + defaults: T, + catalog?: Partial, + overrides?: Partial +): T { + const merged: T = { ...defaults }; for (const source of [catalog, overrides]) { if (!source) continue; - for (const key of Object.keys(merged) as Array) { + for (const key of Object.keys(merged) as Array) { const value = source[key]; - if (value !== undefined) merged[key] = value; + // `Partial[keyof T]` is `T[keyof T] | undefined`; TS cannot follow the narrowing + // through a generic index, hence the assertion. + if (value !== undefined) merged[key] = value as T[keyof T]; } } return merged; } +/** Merge English defaults, a loaded catalog, and per-string overrides (undefined skipped). */ +export function resolveGuardrailBuilderLabels( + catalog?: Partial, + overrides?: Partial +): GuardrailBuilderLabels { + return mergeLabels(GUARDRAIL_BUILDER_EN_LABELS, catalog, overrides); +} + /** Interpolate `{{token}}` placeholders in a catalog message. Unknown tokens are left as-is. */ export function formatGuardrailFormMessage( template: string, @@ -216,15 +228,7 @@ export function resolveGuardrailFormLabels( catalog?: Partial, overrides?: Partial ): GuardrailValidatorFormLabels { - const merged: GuardrailValidatorFormLabels = { ...GUARDRAIL_FORM_EN_LABELS }; - for (const source of [catalog, overrides]) { - if (!source) continue; - for (const key of Object.keys(merged) as Array) { - const value = source[key]; - if (value !== undefined) merged[key] = value; - } - } - return merged; + return mergeLabels(GUARDRAIL_FORM_EN_LABELS, catalog, overrides); } // Reifies each ICU placeholder back into the `{{token}}` template convention: the @@ -476,3 +480,163 @@ export function useGuardrailBuilderLabels( [_, overrides] ); } + +/** + * Chrome strings of the guardrail list section (header, add affordance, row actions, status + * chips, BYO notices). Domain copy stays out: scope and action wording arrive through + * `formatScopes` / `formatAction`, and the guardrail's own name and description are data. + * + * Values may contain `{{placeholder}}` tokens; interpolate with `formatGuardrailFormMessage`. + */ +export interface GuardrailListLabels { + /** Section header title. */ + title: string; + /** Header add-button label. */ + add: string; + /** Line shown instead of the rows when the list is empty. */ + empty: string; + /** Aria-label template of a row's drag handle: `{{name}}`. */ + reorderItem: string; + /** Aria-label of a row's edit button, and the fallback for a row with no name. */ + editItem: string; + /** + * Aria-label template of a row's edit button, and of the row body when `rowActivatesEdit` + * is set: `{{name}}`. Naming the row is what tells a screen reader which of a dozen + * identical buttons it is on. + */ + editRow: string; + /** Aria-label of a row's remove button, and the fallback for a row with no name. */ + removeItem: string; + /** Aria-label template of a row's remove button: `{{name}}`. */ + removeRow: string; + /** Lifecycle badge on built-in-validator rows (rendered only with `previewChip`). */ + preview: string; + /** Provenance badge on BYO rows (rendered only with `byoChip`). */ + byo: string; + /** Prefix of the BYO connector line, rendered as `{provider}: {connector}`. */ + provider: string; + /** Action badge text for a row whose action is missing or unrecognized. */ + actionUnknown: string; + /** Row notice: the BYO configuration this guardrail points at was disabled. */ + byoDisabledNotice: string; + /** Row notice: the BYO configuration this guardrail points at is gone. */ + byoUnavailableNotice: string; + /** Status chip: the validator's feature flag is off tenant-wide. */ + statusFeatureDisabled: string; + /** Status chip: the tenant is not entitled to the validator. */ + statusUnauthorized: string; + /** Status chip: the (BYO) configuration is disabled. */ + statusDisabled: string; + /** Status chip: no definition resolves for this row any more. */ + statusUnavailable: string; + /** Administration chip on rows a governance policy owns. */ + administrationGovernance: string; +} + +/** The subset of `useSafeLingui`'s translator the list labels need. */ +type ListTranslate = (descriptor: { + id: string; + message: string; + values?: Record; +}) => string; + +// One builder holds every `_({ id, message })` call, so the English defaults, the flat record +// the catalog test diffs and the runtime lingui path cannot drift, and `lingui extract` still +// sees static calls. Same shape as `definitions-copy.ts`. +function buildGuardrailListLabels(_: ListTranslate): GuardrailListLabels { + return { + title: _({ id: 'guardrails.list.title', message: 'Guardrails' }), + add: _({ id: 'guardrails.list.add', message: 'Add' }), + empty: _({ id: 'guardrails.list.empty', message: 'No guardrails configured' }), + reorderItem: _({ + id: 'guardrails.list.reorder-item', + message: 'Reorder guardrail {name}', + values: TEMPLATE_TOKENS, + }), + editItem: _({ id: 'guardrails.list.edit-item', message: 'Edit guardrail' }), + editRow: _({ + id: 'guardrails.list.edit-row', + message: 'Edit {name}', + values: TEMPLATE_TOKENS, + }), + removeItem: _({ id: 'guardrails.list.remove-item', message: 'Remove guardrail' }), + removeRow: _({ + id: 'guardrails.list.remove-row', + message: 'Remove {name}', + values: TEMPLATE_TOKENS, + }), + preview: _({ id: 'guardrails.list.preview', message: 'Preview' }), + byo: _({ id: 'guardrails.list.byo', message: 'BYO' }), + provider: _({ id: 'guardrails.list.provider', message: 'Provider' }), + actionUnknown: _({ id: 'guardrails.list.action-unknown', message: 'Unknown' }), + byoDisabledNotice: _({ + id: 'guardrails.list.byo-disabled-notice', + message: + "This guardrail's configuration has been disabled and can no longer be used. Contact your administrator to re-enable the configuration or replace this guardrail before running the agent.", + }), + byoUnavailableNotice: _({ + id: 'guardrails.list.byo-unavailable-notice', + message: + "This guardrail's configuration is no longer available. Replace it before running the agent.", + }), + statusFeatureDisabled: _({ + id: 'guardrails.list.status-feature-disabled', + message: 'Feature disabled', + }), + statusUnauthorized: _({ + id: 'guardrails.list.status-unauthorized', + message: 'Unauthorized', + }), + statusDisabled: _({ id: 'guardrails.list.status-disabled', message: 'Disabled' }), + statusUnavailable: _({ id: 'guardrails.list.status-unavailable', message: 'Unavailable' }), + administrationGovernance: _({ + id: 'guardrails.list.administration-governance', + message: 'Governance managed', + }), + }; +} + +// Resolves a descriptor the way lingui does with `values: TEMPLATE_TOKENS`, so the English +// defaults carry the same `{{token}}` convention as a translated catalog entry. +const englishListTranslate: ListTranslate = ({ message, values }) => + values + ? message.replace(/\{(\w+)\}/g, (match, token: string) => values[token] ?? match) + : message; + +/** The English chrome strings, resolved without a lingui provider. */ +export const GUARDRAIL_LIST_EN_LABELS: GuardrailListLabels = + buildGuardrailListLabels(englishListTranslate); + +/** + * The same strings flattened to message id to the **ICU source message**, which is the form + * the catalogs store: the parity test compares these against `locales/en.json` verbatim. + */ +export const GUARDRAIL_LIST_EN_MESSAGES: Readonly> = Object.freeze( + (() => { + const messages: Record = {}; + buildGuardrailListLabels((descriptor) => { + messages[descriptor.id] = descriptor.message; + return descriptor.message; + }); + return messages; + })() +); + +/** Merge English defaults, a loaded catalog, and per-string overrides (undefined skipped). */ +export function resolveGuardrailListLabels( + catalog?: Partial, + overrides?: Partial +): GuardrailListLabels { + return mergeLabels(GUARDRAIL_LIST_EN_LABELS, catalog, overrides); +} + +/** Localized chrome strings of the list section; per-string `overrides` always win. */ +export function useGuardrailListLabels( + overrides?: Partial +): GuardrailListLabels { + const { _ } = useSafeLingui(); + return useMemo( + () => resolveGuardrailListLabels(buildGuardrailListLabels(_), overrides), + [_, overrides] + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/index.ts b/packages/apollo-react/src/canvas/components/Guardrails/index.ts index c4036f8044..a9ff6e71e4 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/index.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/index.ts @@ -33,8 +33,12 @@ export { } from './builder-utils'; export type { GuardrailChipProps } from './components/guardrail-chip'; export { GuardrailChip, guardrailChipVariants } from './components/guardrail-chip'; +export type { GuardrailStatusBannerProps } from './components/guardrail-status-banner'; +export { GuardrailStatusBanner } from './components/guardrail-status-banner'; export type { GuardrailStatusChipProps } from './components/guardrail-status-chip'; export { GuardrailStatusChip } from './components/guardrail-status-chip'; +export type { MixedScopesBannerProps } from './components/mixed-scopes-banner'; +export { MixedScopesBanner } from './components/mixed-scopes-banner'; export type { GuardrailCopyTable, GuardrailValidatorCopy } from './definitions-copy'; export { CURATED_GUARDRAIL_VALIDATORS, @@ -67,17 +71,44 @@ export type { GuardrailBuilderProps } from './guardrail-builder'; export { GuardrailBuilder } from './guardrail-builder'; export type { GuardrailFormLayoutProps } from './guardrail-form-layout'; export { GuardrailFormLayout } from './guardrail-form-layout'; +export type { GuardrailListProps } from './guardrail-list'; +export { GuardrailList } from './guardrail-list'; +export { + getGuardrailListChips, + getGuardrailListItemId, + matchesGuardrailListDefinition, + resolveGuardrailListItemState, +} from './guardrail-list-utils'; export { GuardrailValidatorForm } from './guardrail-validator-form'; -export type { GuardrailBuilderLabels, GuardrailValidatorFormLabels } from './i18n'; +export type { + GuardrailBuilderLabels, + GuardrailListLabels, + GuardrailValidatorFormLabels, +} from './i18n'; export { formatGuardrailFormMessage, GUARDRAIL_BUILDER_EN_LABELS, GUARDRAIL_FORM_EN_LABELS, + GUARDRAIL_LIST_EN_LABELS, + GUARDRAIL_LIST_EN_MESSAGES, resolveGuardrailBuilderLabels, resolveGuardrailFormLabels, + resolveGuardrailListLabels, useGuardrailBuilderLabels, useGuardrailFormLabels, + useGuardrailListLabels, } from './i18n'; +export type { + GuardrailListAdministration, + GuardrailListChip, + GuardrailListDefinition, + GuardrailListItem, + GuardrailListItemActionsContext, + GuardrailListItemState, + GuardrailListStatus, + GuardrailReorderMove, + GuardrailRowTooltipRenderer, +} from './list-types'; export type { GuardrailParameterDefinition, GuardrailParameterRenderContext, diff --git a/packages/apollo-react/src/canvas/components/Guardrails/list-types.ts b/packages/apollo-react/src/canvas/components/Guardrails/list-types.ts new file mode 100644 index 0000000000..514232a958 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/list-types.ts @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react'; +import type { GuardrailDefinitionStatus, GuardrailSelector } from './builder-types'; + +/** + * Structural mirrors of the shapes both products already persist, so a host maps nothing; + * mutual assignability is asserted host-side. Where the list reads one field of a larger + * shape it asks for that field alone, which is what keeps both products' unions and their + * custom-guardrail members assignable. + */ + +/** One row, covering both products' guardrail kinds. */ +export interface GuardrailListItem { + /** Stable id where the product has one (Flow); rows fall back to `name` (Agents). */ + id?: string; + name: string; + description?: string; + selector?: GuardrailSelector; + /** Only the discriminator is read, for the action badge. */ + action?: { $actionType: string }; + /** Widened to `string`: the list branches on `'builtInValidator'` and ignores the rest. */ + $guardrailType?: string; + validatorType?: string; + /** Present on bring-your-own rows; it is what resolves them to a definition. */ + byoValidatorName?: string; +} + +/** + * The definition fields a row resolves against. `GuardrailDefinition` and the definitions + * layer's enriched output both satisfy it, so a host passes the array it already has. + */ +export interface GuardrailListDefinition { + validator: string; + status: GuardrailDefinitionStatus; + byoValidatorName?: string; + /** Rendered as the row's provider line. */ + byoConnectorName?: string; +} + +/** + * Who administers a row: the agent's own configuration, or a tenant governance policy. Not + * "origin", which both products already use for BYO versus UiPath-managed, a different axis + * carried here by `byoValidatorName`. + */ +export type GuardrailListAdministration = 'local' | 'governance'; + +/** `'Unavailable'` is not a wire status: a BYO row whose definition stopped resolving. */ +export type GuardrailListStatus = GuardrailDefinitionStatus | 'Unavailable'; + +/** What resolving a row against the definitions told us about it. */ +export interface GuardrailListItemState { + definition?: GuardrailListDefinition; + /** `undefined` when nothing resolved, as for a custom guardrail. */ + status?: GuardrailListStatus; + isByo: boolean; + provider?: string; + /** The configuration exists but is disabled. Both render a blocking notice on the row. */ + byoDisabled: boolean; + byoUnavailable: boolean; +} + +/** One resolved chip; `id` is also the React key. */ +export interface GuardrailListChip { + id: 'status' | 'administration'; + tone: 'neutral' | 'warning' | 'error'; + label: string; +} + +/** Where a row moved to, alongside the reordered array. */ +export interface GuardrailReorderMove { + from: number; + to: number; + /** As `getItemId` resolved it. */ + id: string; +} + +/** + * Hover and keyboard-focus content for a row body. Return nothing to leave that row + * untooltipped, which is what lets a host tooltip some rows and not others. + */ +export type GuardrailRowTooltipRenderer = (item: GuardrailListItem) => ReactNode; + +/** Context handed to the `renderItemActions` slot (Agents' overflow menu). */ +export interface GuardrailListItemActionsContext { + item: GuardrailListItem; + id: string; + /** Index in the rendered (visible) list. */ + index: number; + disabled: boolean; + /** + * Bound to this row, and absent when the host passed no handler *or* the list is disabled, + * so a slot can render its own control without re-checking `disabled`. + */ + onEdit?: () => void; + onRemove?: () => void; + /** So a slot can add to the inline Edit / Remove buttons instead of replacing them. */ + defaultActions: ReactNode; +} diff --git a/packages/apollo-react/src/canvas/locales/en.json b/packages/apollo-react/src/canvas/locales/en.json index ef0092ac50..4785a64bbb 100644 --- a/packages/apollo-react/src/canvas/locales/en.json +++ b/packages/apollo-react/src/canvas/locales/en.json @@ -282,5 +282,24 @@ "guardrails.definitions.llm_as_judge.param.model.tooltip": "The model used to evaluate the policy against each payload.", "guardrails.definitions.llm_as_judge.param.positiveExamples.tooltip": "Optional payloads that should pass the policy. Used by the judge as calibration anchors.", "guardrails.definitions.llm_as_judge.param.negativeExamples.tooltip": "Optional payloads that should fail the policy. Used by the judge as calibration anchors.", - "guardrails.definitions.llm_as_judge.param.threshold.tooltip": "Strictness on a 0–6 scale. Lower values are stricter — the judge flags anything that hints at a violation. Higher values are more lenient — only clear, unambiguous violations are flagged." + "guardrails.definitions.llm_as_judge.param.threshold.tooltip": "Strictness on a 0–6 scale. Lower values are stricter — the judge flags anything that hints at a violation. Higher values are more lenient — only clear, unambiguous violations are flagged.", + "guardrails.list.title": "Guardrails", + "guardrails.list.add": "Add", + "guardrails.list.empty": "No guardrails configured", + "guardrails.list.reorder-item": "Reorder guardrail {name}", + "guardrails.list.edit-item": "Edit guardrail", + "guardrails.list.edit-row": "Edit {name}", + "guardrails.list.remove-item": "Remove guardrail", + "guardrails.list.remove-row": "Remove {name}", + "guardrails.list.preview": "Preview", + "guardrails.list.byo": "BYO", + "guardrails.list.provider": "Provider", + "guardrails.list.action-unknown": "Unknown", + "guardrails.list.byo-disabled-notice": "This guardrail's configuration has been disabled and can no longer be used. Contact your administrator to re-enable the configuration or replace this guardrail before running the agent.", + "guardrails.list.byo-unavailable-notice": "This guardrail's configuration is no longer available. Replace it before running the agent.", + "guardrails.list.status-feature-disabled": "Feature disabled", + "guardrails.list.status-unauthorized": "Unauthorized", + "guardrails.list.status-disabled": "Disabled", + "guardrails.list.status-unavailable": "Unavailable", + "guardrails.list.administration-governance": "Governance managed" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dc76d2500..9bff52e8aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -592,6 +592,9 @@ importers: '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@dnd-kit/sortable': specifier: ^10.0.0 version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)