diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index 8b3f2ee6d..99257b7c1 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 d1999eae4..cfd450044 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 460e396d1..2af85d25b 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 000000000..cd927e76f --- /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 000000000..d51ae5b91 --- /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 79604ef56..6ef77982c 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 000000000..f9aab1c85 --- /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 148a1983d..9ec2fdf31 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 000000000..7bf6478e0 --- /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 000000000..424c64948 --- /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 000000000..c995460fe --- /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 000000000..6bf79ea62 --- /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 000000000..09dbe0667 --- /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 000000000..48ea9b1fc --- /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 08ce108ed..041dcdb3d 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 c4036f804..a9ff6e71e 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 000000000..514232a95 --- /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 ef0092ac5..4785a64bb 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 2dc76d250..9bff52e8a 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)