Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/apollo-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/apollo-react/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
156 changes: 153 additions & 3 deletions packages/apollo-react/src/canvas/components/Guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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';

<GuardrailList
guardrails={visibleGuardrails} // already filtered by the host
definitions={definitions} // already filtered by the host
disabled={isReadOnly}
Comment thread
andreizdrali-uipath marked this conversation as resolved.
onAdd={openPalette}
onEdit={openBuilder}
onRemove={confirmRemoval}
onReorder={(reordered, move) => 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 `<span>` 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 `<div>`, and the palette entry places these chips inside its `<button>`,
where flow content is invalid. It is deliberately **not** `GuardrailChip`, which wraps a Radix
`Toggle`: these are read-only labels, and rendering them as toggles would put fake buttons in
the tab order and misreport them to screen readers.

`getGuardrailListChips` is the mapping, exported and pure. An `Available`, locally
administered row chips nothing, which is the common case and keeps the list quiet.
`FeatureDisabled` and `Unauthorised` chip as warnings; `Disabled` and the synthetic
`Unavailable` (a BYO row whose definition stopped resolving) chip as errors.

The administration axis is deliberately **not** called "origin": both products already use
that word for BYO versus UiPath-managed validators (`GuardrailOriginChip`,
`CentralizedGuardrailOriginBadge`), which is a different axis and travels on
`byoValidatorName`. Governance-managed guardrails still render in each product's separate
centralized section; this ships the chip and the seam, so either layout stays possible.

`GuardrailStatusBanner` and `MixedScopesBanner` are exported for hosts composing their own
chrome. The list itself takes one `statusBanner` slot above the rows (a definitions load
failure, say) rather than a typed banner prop: the mixed-scopes banner ends in a "Save as
new" hint, which is builder copy, and both products render it inside a builder rather than
over a list.

### Localization

Chrome strings resolve through `useGuardrailListLabels` (lingui, `guardrails.list.*` ids in
the shared canvas catalog); `labels` overrides any of them and wins over the catalog. Twelve
of the seventeen 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`.

**English only**, like the canonical copy above: the other thirteen catalogs get these ids
from `chore(l10n): sync from Localization`, and until it runs `useSafeLingui` renders the
English default, so nothing is missing on screen. The five newly written ids are the ones that
need a real loc pass rather than a lookup.

## GuardrailBuilder

The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Synthetic layout for the drag-and-drop suites. happy-dom runs no layout, so every element
* reports a zero rect, dnd-kit cannot tell two rows apart and a reorder silently does nothing.
* A vertical grid of rects is the whole of what a reorder needs, which is why this is a
* fixture rather than a mock of dnd-kit.
*/

import { act } from '@testing-library/react';

const ROW_HEIGHT = 48;
const ROW_WIDTH = 320;

function rectAt(top: number, height: number): DOMRect {
const rect = {
x: 0,
y: top,
top,
left: 0,
right: ROW_WIDTH,
bottom: top + height,
width: ROW_WIDTH,
height,
};
return { ...rect, toJSON: () => rect } as DOMRect;
}

function stubRect(element: Element, rect: DOMRect): void {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => rect,
});
}

/**
* Stack every rendered row into a column, and give their container a rect that spans it (the
* `restrictToParentElement` modifier clamps the drag translation to that rect, and a zero-sized
* parent clamps it to nothing).
*/
export function layoutRowsVertically(
container: HTMLElement,
selector = '[data-slot="guardrail-list-row"]'
): void {
const rows = Array.from(container.querySelectorAll<HTMLElement>(selector));
rows.forEach((row, index) => stubRect(row, rectAt(index * ROW_HEIGHT, ROW_HEIGHT)));

const parents = new Set(
rows.map((row) => row.parentElement).filter((el): el is HTMLElement => el !== null)
);
for (const parent of parents) {
stubRect(parent, rectAt(0, rows.length * ROW_HEIGHT));
}
}

/**
* Let dnd-kit finish a step of a drag.
*
* It measures droppables and applies coordinate changes inside `requestAnimationFrame`, which
* the canvas test setup implements as `setTimeout(cb, 0)`: a macrotask, so it does not flush
* with the `act()` around a `fireEvent`. Await this between the keystrokes of a drag, or the
* next one is computed against the geometry of the step before.
*/
export async function flushDndFrame(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { GuardrailListDefinition, GuardrailListItem } from '../list-types';

/**
* Rows and definitions shaped like what the two products pass: one UiPath validator, one
* bring-your-own validator resolved through a connector, and one custom guardrail with no
* definition at all. Between them they cover every branch a row can take.
*/

export const PII_GUARDRAIL: GuardrailListItem = {
id: 'g1',
name: 'PII detection 1',
description: 'Scans agent output for personal data.',
$guardrailType: 'builtInValidator',
selector: { scopes: ['Agent', 'Tool'], matchNames: ['Send email'] },
action: { $actionType: 'log' },
validatorType: 'pii_detection',
};

export const BYO_GUARDRAIL: GuardrailListItem = {
id: 'g2',
name: 'Noma prompt shield',
$guardrailType: 'builtInValidator',
selector: { scopes: ['Llm'] },
action: { $actionType: 'block' },
validatorType: 'byo',
byoValidatorName: 'noma_prompt_injection',
};

export const CUSTOM_GUARDRAIL: GuardrailListItem = {
id: 'g3',
name: 'Blocked words',
$guardrailType: 'custom',
selector: { scopes: ['Tool'], matchNames: ['Send email'] },
action: { $actionType: 'filter' },
};

export const GUARDRAILS: GuardrailListItem[] = [PII_GUARDRAIL, BYO_GUARDRAIL, CUSTOM_GUARDRAIL];

export const PII_DEFINITION: GuardrailListDefinition = {
validator: 'pii_detection',
status: 'Available',
};

export const BYO_DEFINITION: GuardrailListDefinition = {
validator: 'byo',
status: 'Available',
byoValidatorName: 'noma_prompt_injection',
byoConnectorName: 'Noma Security',
};

export const DEFINITIONS: GuardrailListDefinition[] = [PII_DEFINITION, BYO_DEFINITION];
Loading
Loading