diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index 97b5e46b81..6fad765c1b 100644 --- a/packages/apollo-react/package.json +++ b/packages/apollo-react/package.json @@ -123,6 +123,11 @@ "import": "./dist/canvas/constants.js", "require": "./dist/canvas/constants.cjs" }, + "./canvas/guardrails": { + "types": "./dist/canvas/components/Guardrails/index.d.ts", + "import": "./dist/canvas/components/Guardrails/index.js", + "require": "./dist/canvas/components/Guardrails/index.cjs" + }, "./canvas/xyflow/*.css": "./dist/canvas/xyflow/*.css", "./canvas/styles/*.css": "./dist/canvas/styles/*.css" }, @@ -211,6 +216,7 @@ "@uipath/apollo-wind": "workspace:*", "@xyflow/react": "12.8.2", "@xyflow/system": "0.0.66", + "class-variance-authority": "^0.7.1", "d3-hierarchy": "^3.1.2", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", @@ -267,6 +273,7 @@ "@types/d3-scale-chromatic": "^3.0.3", "@types/d3-selection": "^3.0.11", "@types/d3-zoom": "^3.0.8", + "@types/jest-axe": "^3.5.9", "@types/lodash": "^4.17.21", "@types/luxon": "^3.7.1", "@types/mdast": "^4.0.4", @@ -284,6 +291,7 @@ "esbuild": "^0.28.1", "glob": "^13.0.0", "happy-dom": "^20.0.0", + "jest-axe": "^10.0.0", "react": "19.2.3", "react-dom": "19.2.3", "typescript": "^5.9.3", diff --git a/packages/apollo-react/src/canvas/components/Guardrails/README.md b/packages/apollo-react/src/canvas/components/Guardrails/README.md new file mode 100644 index 0000000000..5548e02b47 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/README.md @@ -0,0 +1,241 @@ +# Guardrails components + +Shared UI for the UiPath Guardrails experience, consumed by Flow (flow-workbench) and, in a +later stage, Agents (`frontend-sw`). Lives in apollo-react next to canvas — MUI-free, built +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: `GuardrailBuilder` (the whole Add/Edit screen), +`GuardrailFormLayout` (the screen shell), and `GuardrailValidatorForm` (the validator +parameter section, also rendered inside the builder). + +## GuardrailBuilder + +The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note, +type display (edit mode), name, description, validator parameters, scope selector, action +(log / block / escalate; filter reserved for the custom-guardrail phase), evaluations +toggle, mixed-scopes banner, and the Save / Cancel / Save-as-new footer. + +```tsx +import { GuardrailBuilder } from '@uipath/apollo-react/canvas/guardrails'; + +; +``` + +Contract highlights: + +- **Owns form state.** Initialized from `definition`/`guardrail`/`defaultName` at mount — + remount with a new `key` to reset (all known hosts already remount per session). +- **Owns validation and gates its own Save.** Messages localize through lingui + (overridable per string via `labels`); a host that validates externally (e.g. zod) passes + `errors` — host messages display immediately, win per field, and gate Save. The pure + predicates (`getGuardrailActionErrorFields`, `getGuardrailSelectorErrorFields`, + `getRequiredEmptyParameterIds`) are exported. +- **Escalation is slot-driven.** `renderRecipientSearch` (user/group directory autosuggest) + and `renderAppPicker` (escalation app) are host capabilities; `escalateHelp` renders under + the escalation grid (e.g. a marketplace link — product URLs never ship in this package). + Without slots the form falls back to a plain input / an "unavailable" note. +- **Layout knobs for both hosts**: `inline`/`hideHeader`/`dialogMaxWidth`, `title` accepts a + ReactNode (chips, links), `evalsTogglePlacement: 'form' | 'footer'`. +- Requires an ancestor `TooltipProvider`. + +`GuardrailFormLayout` is exported standalone for hosts composing their own screen: three +modes (inline+hideHeader / inline with back-button header / modal Dialog), `secondaryAction`, +`saveDisabled`, and a `footerStart` region. + +## GuardrailValidatorForm + +Renders one editor per `GuardrailParameterDefinition`, covering the seven wire parameter +types: + +| type | editor | +| --- | --- | +| `number` | numeric input with `min`/`max`/`step` | +| `text` | multiline textarea (`maxLength`) | +| `boolean` | switch | +| `enum` | single select (a stored value missing from `options` is kept as a synthetic option) | +| `enum-list` | toggleable chips inline for ≤8 options, otherwise the wind `MultiSelect` | +| `text-list` | repeated textarea rows with Add/Remove (`maxItems`, `maxLength`) | +| `map-enum` | one numeric input per key selected in the sibling `keySource` enum-list | + +```tsx +import { + GuardrailValidatorForm, + getRequiredEmptyParameterIds, + seedGuardrailParameters, +} from '@uipath/apollo-react/canvas/guardrails'; + +const [parameters, setParameters] = useState(() => + seedGuardrailParameters(definition.parameters, existingGuardrail?.validatorParameters) +); + + — host-owned validation + onClearError={clearParamError} +/>; +``` + +Requires an ancestor `TooltipProvider` (for the per-parameter info tooltips). + +The controlled contract above is this family's, not `MetadataForm`'s: the form owns its own +state and exposes a plugin seam, so the translation lives in one named place, +`useMetadataFormBridge`. Nothing else in the family reaches into `context.form`. + +### Contract + +- **Fully controlled values; validation is shared.** The host owns values (`parameters` + + `onChange`). Validation runs on both sides, and the split is deliberate: + - *The form* declares `required`/`min`/`max` from the parameter definitions and its resolver + evaluates them, with messages from the label catalog so they translate. A field can + therefore show an error with no `errors` entry at all. **When** it reports is `validateLive`: + on by default, since a host mounting `GuardrailValidatorForm` standalone has no save of its + own and nothing else would validate. `GuardrailBuilder` passes its own post-save-attempt + flag instead, so inside the dialog parameters stay quiet until the first failed Save and + then go live — the same gate the name, scope and action fields have always used. + - *The host* owns anything the form cannot know — domain rules, and the save-time gate. + Compute required-field errors with `getRequiredEmptyParameterIds(definitions, parameters)` + and out-of-range values with `getOutOfRangeParameterIds(definitions, parameters)`, gate + Save on both, and map the returned ids to your own localized messages. The component + renders `errors[id]` under the matching editor and calls `onClearError(id)` before + `onChange` when that parameter is edited. + + **The host's verdict wins where they disagree** — a `text-list` of whitespace-only rows + passes the array's `.min(1)` but counts as empty for `getRequiredEmptyParameterIds`. That + precedence is pinned by a test rather than left to whichever ran last. Gate Save on the host + predicates: they are authoritative, and the resolver is there for live feedback while typing. + They are also what fills the dialog on a failed Save, since the resolver is still held back at + that instant — so keep computing them even though the resolver covers `required`/`min`/`max`. +- **Definitions arrive pre-resolved.** `label`, `tooltip` and `optionLabels` are display + strings the host already localized; domain copy (PII entity names, validator descriptions) + never ships in this package. +- **No product types cross the boundary.** `GuardrailValidatorParameter` structurally mirrors + the wire shape both products persist, so host unions assign cleanly in both directions. +- **Per-parameter override.** `renderParameter(ctx)` replaces the editor for any parameter + (return `undefined` to fall through). `ctx.onValueChange` upserts the parameter; + `ctx.onParametersChange` replaces the whole array for overrides that persist sidecar + parameters (e.g. a model picker storing connection metadata). + +### Save-time companions + +The editors never prune or drop values while typing; reconcile at save time: + +```ts +import { dropEmptyOptionalParameters, syncMapEnumParameters } from '@uipath/apollo-react/canvas/guardrails'; + +const cleaned = dropEmptyOptionalParameters( + syncMapEnumParameters(parameters, definition.parameters), + definition.parameters +); +``` + +- `syncMapEnumParameters` rebuilds every `map-enum` value so its keys exactly match the + current `keySource` selection (preserving user edits, then per-key defaults, then `min`). + It mirrors the map-enum editor's key resolution — keeping the two in one package is the + point: they must never drift. +- `dropEmptyOptionalParameters` removes optional parameters left `''`/`[]`, which runtimes + reject at publish time. +- `seedGuardrailParameters` builds the initial value array from definitions (editing passes + the stored values through verbatim), coercing `null` defaults to the union's value types. + +### Localization + +The component's own chrome strings (placeholders, Add, aria labels) localize through the +package's standard lingui setup: `useSafeLingui` with explicit `guardrails.*` ids and English +defaults, translations in the shared canvas catalog (`src/canvas/locales/*.json`, 13 locales +translated; `ru` falls back to English per key). Without a lingui provider the components +render the English defaults — mount `ApI18nProvider component="canvas"` (from +`@uipath/apollo-react/i18n`) for translations. `labels` overrides individual strings and wins +over the catalog. The resolver's own messages (`requiredError`, `minError`, `maxError`) are +part of that catalog: the schema declares those constraints, so the messages ship with the +component rather than arriving through `errors`. Domain messages still belong to hosts and +come in via `errors`. + +Localized template strings that cross into plain-string APIs (dialog titles, the text-list +remove label consumed by wind's `formatTemplate`) are ICU messages formatted with sentinel +values, so they come back carrying the `{{token}}` convention — see `TEMPLATE_TOKENS` in +`i18n.ts`. + +### Consuming from a shadow-DOM host (Agents stage 2) + +Radix overlays (the enum select, the enum-list popover, tooltips) portal to `document.body` +by default and escape shadow roots; wrap the form's subtree with `PortalContainerProvider` +and inject the compiled canvas stylesheet +(`@uipath/apollo-react/canvas/styles/tailwind.canvas.css?inline` — its Tailwind build scans +this directory) into the shadow root (see `AgentCanvasEditor` in `frontend-sw` for the +`?inline` injection precedent). `@uipath/apollo-wind` must resolve to a single copy alongside +apollo-react's own pin, or Radix contexts and CSS duplicate. + +## Built on the forms/ MetadataForm stack + +`GuardrailValidatorForm` is not a form renderer of its own: internally it is +`buildGuardrailFormSchema(definitions, labels)` + the package's `MetadataForm` +(`components/forms/`: `FormSchema` → `MetadataForm` → `field-renderer`), mounted with +`container="div"`. The public contract above is the adapter boundary — hosts never see the +schema. + +`MetadataForm` owns its own state; it has no controlled-host props. An earlier revision of +#1107 added some (`values`, `onValuesChange`, `errors`, `disableValidation`) and they were +removed in review, because they existed to route around features the schema contract already +declared. The translation from this family's controlled contract onto the primitive therefore +lives in one named place, `useMetadataFormBridge`, which is a `FormPlugin` that: + +- registers the guardrail-owned custom components from the first paint (`FormPlugin.components`); +- pushes host `parameters` in with `context.form.setValue`, structurally compared so an echo of + the form's own emission performs no write and focus/cursor survive; +- pushes host `errors` in as `type: 'external'`, cleared only when the prop drops them; +- reports user edits out through `onValueChange`, suppressed while the hook is itself writing. + +Validation is live rather than disabled: `buildGuardrailFormSchema` declares `required`/`min`/ +`max` with messages from the label catalog (so they translate), and the host's own predicates +(`getRequiredEmptyParameterIds`, `getOutOfRangeParameterIds`) run alongside, reaching the form +as external errors. Where the two disagree — a `text-list` of whitespace-only rows passes the +array's `.min(1)` but counts as empty for the host — the host verdict is what the user sees; +`guardrail-validator-form.test.tsx` pins that. + +How each parameter type maps: + +| parameter type | rendering | +| --- | --- | +| `number` | field type `number` | +| `text` | field type `textarea` (`minRows`, `maxLength`) | +| `boolean` | field type `switch` | +| `enum` | field type `select` (synthetic option appended for a stale stored value) | +| `enum-list` > 8 options | field type `multiselect` | +| `enum-list` ≤ 8 options | custom component `guardrail-enum-list-chips` (`GuardrailChip` toggles in a `FieldShell`) | +| `text-list` | field type `string-list` (added to forms/ for this convergence — generic) | +| `map-enum` | custom component `guardrail-map-enum` (reads the `keySource` sibling via the form context) | +| any id claimed by `renderParameter` | custom component `guardrail-render-parameter` (the bridge that mounts the host's node and exposes `onValueChange`/`onParametersChange`) | + +Why the three custom components stay guardrail-owned: the chip-toggle UX is a product +decision (small option sets read better as chips than a dropdown), `map-enum` derives its +rows from a sibling field's live selection, and `renderParameter` is a host seam — all three +are exactly what `type: 'custom'` + component registration exists for. + +Adapter invariants (guarded by the `controlled contract` tests in +`guardrail-validator-form.test.tsx`): + +- Emissions upsert only the edited parameter into the host's current array — untouched + defaults never leak in, and parameters without a matching definition (sidecars written via + `onParametersChange`, e.g. `byomConnectionId`) never enter the form and round-trip + untouched. +- A synchronous host echo of the emitted array is a no-op (per-field deep-equal guard): no + re-emission, focus and cursor survive. Hosts must echo synchronously from `onChange`. +- Values are coerced to the wire shape on emit (`coerceGuardrailParameterValue`): a cleared + number input persists `0`, never `NaN`; text/enum never persist `null`. + +**Rule for new work**: a new parameter editor extends `field-renderer` with a first-class +field type (when it's generic) or registers a custom component here (when it's +guardrail-shaped) — never a parallel renderer next to `MetadataForm`. diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts new file mode 100644 index 0000000000..c233c08547 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts @@ -0,0 +1,183 @@ +import type * as React from 'react'; +import type { GuardrailParameterDefinition, GuardrailValidatorParameter } from './types'; + +/** + * Structural mirrors of the guardrail wire shapes both consuming products persist. Hosts + * pass their own equivalent types without mapping; mutual assignability is asserted on the + * host side (see the Flow adapter's type-assertion file). + */ + +export type GuardrailScope = 'Agent' | 'Llm' | 'Tool'; + +export interface GuardrailSelector { + scopes: GuardrailScope[]; + matchNames?: string[]; +} + +export type GuardrailSeverityLevel = 'Info' | 'Warning' | 'Error'; + +/** Recipient type discriminators (numeric on the wire). */ +export const GuardrailRecipientType = { + User: 1, + Group: 2, + StaticEmail: 3, + AssetEmail: 4, + StaticGroupName: 5, + AssetGroupName: 6, +} as const; +export type GuardrailRecipientTypeValue = + (typeof GuardrailRecipientType)[keyof typeof GuardrailRecipientType]; + +/** + * Escalation recipient union. The editor offers User/Group/StaticEmail/StaticGroupName; + * the asset variants (4/6) exist so values using them round-trip through the form unedited. + */ +export type GuardrailEscalateRecipient = + | { type: 1; value: string; displayName: string } + | { type: 2; value: string; displayName: string } + | { type: 3; value: string } + | { type: 4; folderPath?: string; assetName: string } + | { type: 5; value: string } + | { type: 6; folderPath?: string; assetName: string }; + +export interface GuardrailEscalateApp { + id: string; + version: string; + name: string; + folderId?: string; + folderName?: string; + appProcessKey?: string; + runtime?: string; +} + +export type GuardrailAction = + | { $actionType: 'log'; severityLevel: GuardrailSeverityLevel } + | { $actionType: 'block'; reason: string } + // Field references are product-shaped; the OOTB builder never edits them (pass-through + // only, the filter option is custom-guardrail territory), so they stay opaque here. + | { $actionType: 'filter'; fields: unknown[] } + | { $actionType: 'escalate'; app: GuardrailEscalateApp; recipient: GuardrailEscalateRecipient }; + +export type GuardrailDefinitionStatus = + | 'Available' + | 'FeatureDisabled' + | 'Unauthorised' + | 'Disabled'; + +/** + * The display-ready definition of an OOTB guardrail validator. `displayName` and `usageNote` + * arrive pre-resolved (host-localized); `parameters` reuses the validator-form definition + * type. + */ +export interface GuardrailDefinition { + validator: string; + displayName: string; + allowedScopes: GuardrailScope[]; + parameters: GuardrailParameterDefinition[]; + status: GuardrailDefinitionStatus; + /** Pre-localized informational note rendered above the form. */ + usageNote?: React.ReactNode; + /** Present for bring-your-own guardrail definitions; stamped onto saved values. */ + byoValidatorName?: string; +} + +/** `validatorType` persisted for bring-your-own guardrail definitions. */ +export const GUARDRAIL_BYO_VALIDATOR_TYPE = 'byo'; + +/** The builder's in/out value — mirrors the persisted built-in-validator guardrail shape. */ +export interface GuardrailBuilderValue { + id: string; + $guardrailType: 'builtInValidator'; + name: string; + description?: string; + selector: GuardrailSelector; + action: GuardrailAction; + enabledForEvals: boolean; + validatorType: string; + validatorParameters: GuardrailValidatorParameter[]; + byoValidatorName?: string; +} + +/** + * Host-supplied validation errors, merged over the builder's internal validation (the host + * message wins per field). Any present error gates Save. + */ +export interface GuardrailBuilderErrors { + name?: string; + blockReason?: string; + filterFields?: string; + recipient?: string; + actionApp?: string; + scopes?: string; + toolNames?: string; + /** Per-parameter messages keyed by parameter id. */ + parameters?: Record; +} + +/** Context handed to the `renderRecipientSearch` slot (user/group directory autosuggest). */ +export interface GuardrailRecipientSearchContext { + kind: 'user' | 'group'; + /** Current display value (displayName, falling back to the raw value). */ + displayValue: string; + /** Localized placeholder for the current kind. */ + placeholder: string; + /** Whether the recipient currently fails validation (style the input accordingly). */ + invalid: boolean; + /** + * The validation message, when there is one. A slot that renders it **owns** it — the form + * renders no message of its own for a claimed field, matching `renderAppPicker`. Ignore it and + * the user sees only the invalid styling. + */ + error?: string; + onSelect: (selection: { value: string; displayName: string }) => void; + onClear: () => void; +} + +/** Context handed to the `renderAppPicker` slot (escalation action app). */ +export interface GuardrailAppPickerContext { + /** The selected app, or null when unset. */ + app: GuardrailEscalateApp | null; + onChange: (app: GuardrailEscalateApp | null) => void; + /** Localized field label. */ + label: string; + /** Validation message to surface, if any. */ + error?: string; +} + +/** + * Context handed to the `renderStaticRecipient` slot (StaticEmail/AssetEmail/StaticGroupName/ + * AssetGroupName recipients). + */ +export interface GuardrailStaticRecipientContext { + /** Which recipient family the type select currently shows. */ + kind: 'email' | 'groupName'; + /** The current recipient — the static or asset variant of the kind. */ + recipient: GuardrailEscalateRecipient; + /** Localized field label for the kind. */ + label: string; + /** Whether the recipient currently fails validation (style the control accordingly). */ + invalid: boolean; + /** Validation message to surface, if any. */ + error?: string; + /** + * Replace the recipient wholesale — lets hosts toggle between the static and asset + * variants of the same kind (StaticEmail 3 ↔ AssetEmail 4, StaticGroupName 5 ↔ + * AssetGroupName 6). + */ + onChange: (recipient: GuardrailEscalateRecipient) => void; +} + +export interface GuardrailBuilderSlots { + /** Replace the recipient autosuggest for User/Group recipients. Fallback: a plain input. */ + renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => React.ReactNode; + /** + * Replace the editor for static/asset recipients (types 3/4/5/6). Return `undefined` to + * fall through to the built-in plain input (which edits `value` for static recipients and + * `assetName` for asset ones). + */ + renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => React.ReactNode | undefined; + /** Render the escalation app picker. Fallback: a localized "picker unavailable" note. */ + renderAppPicker?: (ctx: GuardrailAppPickerContext) => React.ReactNode; + /** Rendered under the escalation grid (e.g. a marketplace help line). */ + escalateHelp?: React.ReactNode; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts new file mode 100644 index 0000000000..d6bf971a08 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import { + type GuardrailAction, + type GuardrailDefinition, + GuardrailRecipientType, +} from './builder-types'; +import { + createDefaultGuardrailAction, + generateGuardrailId, + getGuardrailActionErrorFields, + getGuardrailSelectorErrorFields, + initGuardrailBuilderFormData, +} from './builder-utils'; + +function makeDef(overrides?: Partial): GuardrailDefinition { + return { + validator: 'pii_detection', + displayName: 'PII Detection', + allowedScopes: ['Agent', 'Llm', 'Tool'], + parameters: [], + status: 'Available', + ...overrides, + }; +} + +describe('generateGuardrailId', () => { + it('produces the guardrail-- format', () => { + expect(generateGuardrailId()).toMatch(/^guardrail-[a-z0-9]+-[a-z0-9]{5}$/); + }); +}); + +describe('createDefaultGuardrailAction', () => { + it('creates the per-type default payloads', () => { + expect(createDefaultGuardrailAction('log')).toEqual({ + $actionType: 'log', + severityLevel: 'Info', + }); + expect(createDefaultGuardrailAction('block')).toEqual({ $actionType: 'block', reason: '' }); + expect(createDefaultGuardrailAction('filter')).toEqual({ $actionType: 'filter', fields: [] }); + expect(createDefaultGuardrailAction('escalate')).toEqual({ + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + }); + }); +}); + +describe('getGuardrailActionErrorFields', () => { + it('flags an empty block reason', () => { + expect(getGuardrailActionErrorFields({ $actionType: 'block', reason: ' ' })).toEqual([ + 'blockReason', + ]); + expect(getGuardrailActionErrorFields({ $actionType: 'block', reason: 'why' })).toEqual([]); + }); + + it('flags an empty filter field selection', () => { + expect(getGuardrailActionErrorFields({ $actionType: 'filter', fields: [] })).toEqual([ + 'filterFields', + ]); + expect(getGuardrailActionErrorFields({ $actionType: 'filter', fields: [{}] })).toEqual([]); + }); + + it('flags missing escalate recipient and app', () => { + const empty: GuardrailAction = { + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + }; + expect(getGuardrailActionErrorFields(empty)).toEqual(['recipient', 'actionApp']); + + const filled: GuardrailAction = { + $actionType: 'escalate', + app: { id: 'app-1', version: '1', name: 'Escalation app' }, + recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' }, + }; + expect(getGuardrailActionErrorFields(filled)).toEqual([]); + }); + + it('treats a filled asset recipient as valid (validated on assetName)', () => { + const assetRecipient: GuardrailAction = { + $actionType: 'escalate', + app: { id: 'app-1', version: '1', name: 'App' }, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'asset' }, + }; + expect(getGuardrailActionErrorFields(assetRecipient)).toEqual([]); + }); + + it('never flags a log action', () => { + expect(getGuardrailActionErrorFields({ $actionType: 'log', severityLevel: 'Info' })).toEqual( + [] + ); + }); +}); + +describe('getGuardrailSelectorErrorFields', () => { + it('requires at least one scope', () => { + expect(getGuardrailSelectorErrorFields({ scopes: [] })).toEqual(['scopes']); + }); + + it('requires tools when the Tool scope is selected', () => { + expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'] })).toEqual(['toolNames']); + expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'], matchNames: [] })).toEqual([ + 'toolNames', + ]); + expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'], matchNames: ['ToolA'] })).toEqual( + [] + ); + }); + + it('accepts non-Tool scopes without matchNames', () => { + expect(getGuardrailSelectorErrorFields({ scopes: ['Agent', 'Llm'] })).toEqual([]); + }); +}); + +describe('getGuardrailActionErrorFields — asset recipients', () => { + it('treats a filled assetName as a valid recipient and an empty one as invalid', () => { + const base = { + $actionType: 'escalate' as const, + app: { id: 'app1', version: '1', name: 'App' }, + }; + expect( + getGuardrailActionErrorFields({ + ...base, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'EmailAsset' }, + }) + ).toEqual([]); + expect( + getGuardrailActionErrorFields({ + ...base, + recipient: { type: GuardrailRecipientType.AssetGroupName, assetName: ' ' }, + }) + ).toEqual(['recipient']); + }); +}); + +describe('initGuardrailBuilderFormData', () => { + it('copies an existing guardrail verbatim', () => { + const existing = { + id: 'g1', + $guardrailType: 'builtInValidator' as const, + name: 'Mine', + description: 'desc', + selector: { scopes: ['Tool' as const], matchNames: ['OtherTool'] }, + action: { $actionType: 'log' as const, severityLevel: 'Info' as const }, + enabledForEvals: false, + validatorType: 'pii_detection', + validatorParameters: [], + }; + + const form = initGuardrailBuilderFormData(makeDef(), 'Tool', existing, 'MyTool'); + expect(form.id).toBe('g1'); + expect(form.selector.matchNames).toEqual(['OtherTool']); + expect(form.enabledForEvals).toBe(false); + }); + + it('sets matchNames when scope is Tool and toolName is provided', () => { + const form = initGuardrailBuilderFormData(makeDef(), 'Tool', undefined, 'MyTool'); + expect(form.selector.scopes).toEqual(['Tool']); + expect(form.selector.matchNames).toEqual(['MyTool']); + }); + + it('does not set matchNames when scope is Tool but no toolName', () => { + const form = initGuardrailBuilderFormData(makeDef(), 'Tool'); + expect(form.selector.scopes).toEqual(['Tool']); + expect(form.selector.matchNames).toBeUndefined(); + }); + + it('falls back to the first allowedScope when the opening scope is not allowed (non-Agent too)', () => { + // The palette filters by allowedScopes before opening, but a disallowed opening scope + // must never seed a selector the user can't fix (the scope selector renders only for Agent). + const form = initGuardrailBuilderFormData( + makeDef({ allowedScopes: ['Agent', 'Llm'] }), + 'Tool', + undefined, + 'MyTool' + ); + expect(form.selector.scopes).toEqual(['Agent']); + expect(form.selector.matchNames).toBeUndefined(); + }); + + it('falls back to first allowedScope when Agent is not allowed', () => { + const form = initGuardrailBuilderFormData( + makeDef({ allowedScopes: ['Llm', 'Tool'] }), + 'Agent', + undefined, + 'MyTool' + ); + expect(form.selector.scopes).toEqual(['Llm']); + expect(form.selector.matchNames).toBeUndefined(); + }); + + it('sets matchNames when Agent scope falls back to Tool', () => { + const form = initGuardrailBuilderFormData( + makeDef({ allowedScopes: ['Tool'] }), + 'Agent', + undefined, + 'MyTool' + ); + expect(form.selector.scopes).toEqual(['Tool']); + expect(form.selector.matchNames).toEqual(['MyTool']); + }); + + it('seeds defaults with null coercion for create mode', () => { + const definition = makeDef({ + parameters: [ + { id: 'prompt', type: 'text', label: 'Prompt', required: true, defaultValue: null }, + { + id: 'examples', + type: 'text-list', + label: 'Examples', + required: false, + defaultValue: null, + }, + ], + }); + const form = initGuardrailBuilderFormData(definition, 'Agent'); + + expect(form.validatorParameters).toEqual([ + { $parameterType: 'text', id: 'prompt', value: '' }, + { $parameterType: 'text-list', id: 'examples', value: [] }, + ]); + expect(form.action).toEqual({ $actionType: 'log', severityLevel: 'Info' }); + expect(form.enabledForEvals).toBe(true); + expect(form.name).toBe('PII Detection'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts new file mode 100644 index 0000000000..8cc1547a61 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts @@ -0,0 +1,146 @@ +import { + type GuardrailAction, + type GuardrailBuilderValue, + type GuardrailDefinition, + GuardrailRecipientType, + type GuardrailScope, + type GuardrailSelector, +} from './builder-types'; +import { normalizeGuardrailParameters, seedGuardrailParameters } from './utils'; + +export function generateGuardrailId(): string { + return `guardrail-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; +} + +/** Default action payload for a freshly selected action type. */ +export function createDefaultGuardrailAction( + type: GuardrailAction['$actionType'] +): GuardrailAction { + switch (type) { + case 'block': + return { $actionType: 'block', reason: '' }; + case 'filter': + return { $actionType: 'filter', fields: [] }; + case 'escalate': + return { + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + }; + default: + return { $actionType: 'log', severityLevel: 'Info' }; + } +} + +export type GuardrailActionErrorField = 'blockReason' | 'filterFields' | 'recipient' | 'actionApp'; + +/** Action fields whose current value fails required validation. Message text is caller-owned. */ +export function getGuardrailActionErrorFields( + action: GuardrailAction +): GuardrailActionErrorField[] { + const fields: GuardrailActionErrorField[] = []; + switch (action.$actionType) { + case 'block': + if (!action.reason.trim()) fields.push('blockReason'); + break; + case 'filter': + if (action.fields.length === 0) fields.push('filterFields'); + break; + case 'escalate': { + const recipient = action.recipient; + const filled = + 'value' in recipient + ? recipient.value.trim().length > 0 + : recipient.assetName.trim().length > 0; + if (!filled) fields.push('recipient'); + if (!action.app.id) fields.push('actionApp'); + break; + } + } + return fields; +} + +export type GuardrailSelectorErrorField = 'scopes' | 'toolNames'; + +/** + * Selector fields whose current value fails required validation. Unconditional — callers + * gate on whether the scope selector is shown at all. + */ +export function getGuardrailSelectorErrorFields( + selector: GuardrailSelector +): GuardrailSelectorErrorField[] { + const fields: GuardrailSelectorErrorField[] = []; + if (selector.scopes.length === 0) fields.push('scopes'); + if ( + selector.scopes.includes('Tool') && + (!selector.matchNames || selector.matchNames.length === 0) + ) { + fields.push('toolNames'); + } + return fields; +} + +export interface GuardrailBuilderFormData { + id: string; + name: string; + description: string; + selector: GuardrailSelector; + action: GuardrailAction; + enabledForEvals: boolean; + validatorParameters: GuardrailBuilderValue['validatorParameters']; +} + +/** + * Initial form state: an existing guardrail is copied verbatim (edit); otherwise values are + * seeded from the definition — parameters via `seedGuardrailParameters`, the scope coerced to + * the definition's first allowed scope when `Agent` is not allowed, a log/Info default + * action, and evaluations enabled. + * + * @internal Exported for testing only + */ +export function initGuardrailBuilderFormData( + definition: GuardrailDefinition, + scope: GuardrailScope, + existing?: GuardrailBuilderValue, + toolName?: string +): GuardrailBuilderFormData { + if (existing) { + return { + id: existing.id, + name: existing.name, + description: existing.description ?? '', + selector: existing.selector, + action: existing.action, + enabledForEvals: existing.enabledForEvals, + // Normalised, not copied verbatim: this is the only path that receives persisted wire + // data, and a malformed value (a `text-list` of `null`, say) otherwise reached the + // builder's `guardrailResult` memo and threw during render, so the builder never mounted. + // Doing it here rather than at the crash site also fixes `getRequiredEmptyParameterIds`, + // which counted a null list as filled and let it past the Save gate. + validatorParameters: normalizeGuardrailParameters( + existing.validatorParameters, + definition.parameters + ), + }; + } + + // Coerce for every opening scope, not just Agent: hosts filter definitions by allowedScopes + // before opening the builder, but a definition that doesn't allow the opening scope must not + // seed an invalid selector the user can't fix (the scope selector only renders for Agent). + const initialScope = definition.allowedScopes.includes(scope) + ? scope + : (definition.allowedScopes[0] ?? scope); + + return { + id: generateGuardrailId(), + name: definition.displayName, + description: '', + selector: { + scopes: [initialScope], + ...(initialScope === 'Tool' && toolName ? { matchNames: [toolName] } : {}), + }, + action: { $actionType: 'log', severityLevel: 'Info' }, + enabledForEvals: true, + validatorParameters: seedGuardrailParameters(definition.parameters), + }; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx new file mode 100644 index 0000000000..718339f629 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx @@ -0,0 +1,67 @@ +import type { CustomFieldComponentProps } from '@uipath/apollo-wind'; +import { FormField, FormFieldError } from '@uipath/apollo-wind'; +import { useId } from 'react'; +import type { GuardrailValidatorFormLabels } from '../i18n'; +import type { GuardrailParameterDefinition } from '../types'; +import { FieldShell } from './field-shell'; +import { GuardrailChip } from './guardrail-chip'; +import { ParameterLabel } from './parameter-label'; + +/** + * Inline chip-toggle editor for enum-list parameters with small option sets (the ≤8 case), + * registered as the `guardrail-enum-list-chips` custom component; larger sets map to the + * first-class `multiselect` field type instead. `paramDef` and `labels` arrive via + * `componentProps` from `buildGuardrailFormSchema`. + */ +export function EnumListChipsField(props: CustomFieldComponentProps) { + const { value, onChange, error, disabled } = props; + const paramDef = props.paramDef as GuardrailParameterDefinition; + const labels = props.labels as GuardrailValidatorFormLabels; + + const uid = useId(); + const selected = Array.isArray(value) ? (value as string[]) : []; + + /** Friendly label for an option value, falling back to the raw value when unmapped. */ + const labelFor = (option: string) => paramDef.optionLabels?.[option] ?? option; + + const handleToggle = (option: string, pressed: boolean) => { + onChange(pressed ? [...selected, option] : selected.filter((s) => s !== option)); + }; + + return ( + + {/* The chips are Toggle buttons, not labelable controls, so the label names nothing on its + own and needs an id for the group to point at. Same treatment as the scope selector: + without it a screen reader reads individually named buttons with no idea which + parameter they belong to, or that it is invalid. */} + + + {/* biome-ignore lint/a11y/useSemanticElements:
requires as its first + child, which would pull the styled label inside FieldShell and change the layout. + role=group + aria-labelledby is equivalent for assistive tech. + + No aria-required: not permitted on role=group, and it trips aria-allowed-attr. The + requirement is carried by the label's RequiredIndicator and by the error when unmet. */} +
+ {(paramDef.options ?? []).map((option) => ( + handleToggle(option, pressed)} + disabled={disabled} + > + {labelFor(option)} + + ))} +
+ + {error} + + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx new file mode 100644 index 0000000000..2fd2ffa316 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx @@ -0,0 +1,368 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { type GuardrailAction, GuardrailRecipientType } from '../builder-types'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { EscalateActionFields } from './escalate-action-fields'; + +const labels = GUARDRAIL_BUILDER_EN_LABELS; +type EscalateAction = Extract; + +function makeAction(overrides?: Partial): EscalateAction { + return { + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + ...overrides, + }; +} + +const baseProps = { + actionTypeSelect:
, + labels, +}; + +describe('EscalateActionFields', () => { + it('renders the injected action type cell inside its grid', () => { + render(); + expect(screen.getByTestId('action-type-cell')).toBeInTheDocument(); + }); + + describe('recipient type switching', () => { + it.each([ + ['Group', { type: GuardrailRecipientType.Group, value: '', displayName: '' }], + ['Email address', { type: GuardrailRecipientType.StaticEmail, value: '' }], + ['Group name', { type: GuardrailRecipientType.StaticGroupName, value: '' }], + ] as const)('switching to %s resets the recipient payload', async (optionLabel, expectedRecipient) => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(await screen.findByRole('option', { name: optionLabel })); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ recipient: expectedRecipient }) + ); + }); + }); + + it('uses the renderRecipientSearch slot for user recipients', () => { + const onChange = vi.fn(); + render( + ( + + )} + /> + ); + + const slot = screen.getByTestId('directory-search'); + expect(slot).toHaveAttribute('data-kind', 'user'); + expect(slot).toHaveAttribute('data-display', 'User One'); + expect(slot).toHaveAttribute('data-invalid', 'true'); + + fireEvent.click(slot); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: { type: GuardrailRecipientType.User, value: 'u2', displayName: 'User Two' }, + }) + ); + }); + + it('falls back to a plain input for user recipients without the slot', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('Search for a user...'); + fireEvent.change(input, { target: { value: 'jane@acme.com' } }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: { + type: GuardrailRecipientType.User, + value: 'jane@acme.com', + displayName: 'jane@acme.com', + }, + }) + ); + }); + + it('renders a plain input for static email recipients', () => { + const onChange = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByPlaceholderText('Enter email address'), { + target: { value: 'a@b.c' }, + }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' }, + }) + ); + }); + + it('renders the recipient error once, inside the recipient field', () => { + render( + + ); + + expect(screen.getAllByText('Recipient is required')).toHaveLength(1); + }); + + describe('recipient error ownership', () => { + // The sibling FormFieldError used to render outside the slot/fallback ternary, so a host + // rendering ctx.error itself showed the message twice. + it('renders the message once when a search slot renders it itself', () => { + render( +
{ctx.error}
} + /> + ); + + expect(screen.getAllByText('Recipient is required')).toHaveLength(1); + expect(screen.getByTestId('search-slot')).toHaveTextContent('Recipient is required'); + }); + + it('renders the message once when a static slot renders it itself', () => { + render( +
{ctx.error}
} + /> + ); + + expect(screen.getAllByText('Recipient is required')).toHaveLength(1); + expect(screen.getByTestId('static-slot')).toHaveTextContent('Recipient is required'); + }); + + // The searchable fallback set aria-invalid by hand next to a bare message, so nothing tied + // the two together. Input's `error` prop wires the association. + it('associates the message with the input in the searchable fallback', () => { + render( + + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + + const describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy as string)).toHaveTextContent( + 'Recipient is required' + ); + expect(input).toHaveAttribute('aria-errormessage', describedBy as string); + }); + }); + + it('uses the renderAppPicker slot with app context', () => { + const onChange = vi.fn(); + render( + ( + + )} + /> + ); + + const slot = screen.getByTestId('app-picker'); + expect(slot).toHaveAttribute('data-label', 'Action App'); + expect(slot).toHaveAttribute('data-error', 'Action app is required'); + expect(slot).toHaveAttribute('data-app', 'My app'); + + fireEvent.click(slot); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ app: { id: '', version: '', name: '' } }) + ); + }); + + it('falls back to the unavailable note without an app picker slot', () => { + render(); + expect(screen.getByText(/app picker unavailable/i)).toBeInTheDocument(); + }); + + it('renders escalateHelp below the grid', () => { + render( + See the marketplace.

} + /> + ); + + expect(screen.getByTestId('help-line')).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); + +describe('renderStaticRecipient slot', () => { + it('mounts the slot for static recipients and lets it replace the recipient wholesale', () => { + const onChange = vi.fn(); + const action = makeAction({ + recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' }, + }); + + render( + ( + + )} + /> + ); + + const slot = screen.getByTestId('static-slot'); + expect(slot).toHaveAttribute('data-kind', 'email'); + fireEvent.click(slot); + expect(onChange).toHaveBeenCalledWith({ + ...action, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'EmailAsset' }, + }); + }); + + it('falls through to the built-in input when the slot returns undefined', () => { + const onChange = vi.fn(); + const action = makeAction({ + recipient: { type: GuardrailRecipientType.StaticGroupName, value: '' }, + }); + + render( + undefined} + /> + ); + + const input = screen.getByPlaceholderText(labels.groupNamePlaceholder); + fireEvent.change(input, { target: { value: 'Ops' } }); + expect(onChange).toHaveBeenCalledWith({ + ...action, + recipient: { type: GuardrailRecipientType.StaticGroupName, value: 'Ops' }, + }); + }); + + it('is not invoked for searchable (user/group) recipients', () => { + const renderStaticRecipient = vi.fn(); + render( + {}} + renderStaticRecipient={renderStaticRecipient} + /> + ); + expect(renderStaticRecipient).not.toHaveBeenCalled(); + }); +}); + +describe('asset recipient variants', () => { + it('displays an asset recipient as its static sibling in the type select', () => { + render( + {}} + /> + ); + + expect( + screen.getByRole('combobox', { + name: `${labels.assignToLabel}: ${labels.recipientGroupNameLabel}`, + }) + ).toBeInTheDocument(); + }); + + it('edits assetName through the built-in fallback input', () => { + const onChange = vi.fn(); + const action = makeAction({ + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'Old' }, + }); + + render(); + + const input = screen.getByPlaceholderText(labels.emailPlaceholder); + expect(input).toHaveValue('Old'); + fireEvent.change(input, { target: { value: 'New' } }); + expect(onChange).toHaveBeenCalledWith({ + ...action, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'New' }, + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx new file mode 100644 index 0000000000..c9b11f58e8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx @@ -0,0 +1,278 @@ +import { + Alert, + AlertDescription, + FormField, + FormFieldError, + Input, + Label, + RequiredIndicator, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@uipath/apollo-wind'; +import { Info } from 'lucide-react'; +import { type ReactNode, useCallback, useId } from 'react'; +import { + type GuardrailAction, + type GuardrailAppPickerContext, + type GuardrailEscalateRecipient, + type GuardrailRecipientSearchContext, + GuardrailRecipientType, + type GuardrailStaticRecipientContext, +} from '../builder-types'; +import type { GuardrailBuilderLabels } from '../i18n'; + +type EscalateAction = Extract; + +export interface EscalateActionFieldsProps { + action: EscalateAction; + onChange: (action: EscalateAction) => void; + /** Leading grid cell (the Action type select); this component owns the full escalate layout. */ + actionTypeSelect: ReactNode; + errors?: { recipient?: string; actionApp?: string }; + labels: GuardrailBuilderLabels; + renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => ReactNode; + renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => ReactNode | undefined; + renderAppPicker?: (ctx: GuardrailAppPickerContext) => ReactNode; + /** Rendered under the escalation grid (e.g. a marketplace help line). */ + escalateHelp?: ReactNode; +} + +/** + * Escalation action fields: recipient type + recipient value + action-app picker. The + * recipient autosuggest (User/Group) and the app picker are host capabilities injected via + * render props; plain-input / unavailable-note fallbacks keep the form usable without them. + */ +export function EscalateActionFields({ + action, + onChange, + actionTypeSelect, + errors, + labels, + renderRecipientSearch, + renderStaticRecipient, + renderAppPicker, + escalateHelp, +}: EscalateActionFieldsProps) { + // Namespaced per instance — two builders can share a document (inline panels). + const uid = useId(); + + const recipientTypeLabels: Record = { + [GuardrailRecipientType.User]: labels.recipientUserLabel, + [GuardrailRecipientType.Group]: labels.recipientGroupLabel, + [GuardrailRecipientType.StaticEmail]: labels.recipientEmailLabel, + [GuardrailRecipientType.StaticGroupName]: labels.recipientGroupNameLabel, + }; + + const recipientType = action.recipient.type; + // The type select offers the four base entries; asset variants (a host-slot concern) + // display as their static siblings so the selection never blanks. + const displayedRecipientType = + recipientType === GuardrailRecipientType.AssetEmail + ? GuardrailRecipientType.StaticEmail + : recipientType === GuardrailRecipientType.AssetGroupName + ? GuardrailRecipientType.StaticGroupName + : recipientType; + + const handleRecipientTypeChange = useCallback( + (value: string) => { + const newType = Number(value) as GuardrailEscalateRecipient['type']; + let recipient: GuardrailEscalateRecipient; + + switch (newType) { + case GuardrailRecipientType.Group: + recipient = { type: GuardrailRecipientType.Group, value: '', displayName: '' }; + break; + case GuardrailRecipientType.StaticEmail: + recipient = { type: GuardrailRecipientType.StaticEmail, value: '' }; + break; + case GuardrailRecipientType.StaticGroupName: + recipient = { type: GuardrailRecipientType.StaticGroupName, value: '' }; + break; + default: + recipient = { type: GuardrailRecipientType.User, value: '', displayName: '' }; + } + + onChange({ ...action, recipient }); + }, + [action, onChange] + ); + + const handleRecipientSelect = useCallback( + (selection: { value: string; displayName: string }) => { + const r = action.recipient; + if (r.type === GuardrailRecipientType.User || r.type === GuardrailRecipientType.Group) { + onChange({ + ...action, + recipient: { ...r, value: selection.value, displayName: selection.displayName }, + }); + } + }, + [action, onChange] + ); + + const handleRecipientClear = useCallback(() => { + const r = action.recipient; + if (r.type === GuardrailRecipientType.User || r.type === GuardrailRecipientType.Group) { + onChange({ ...action, recipient: { ...r, value: '', displayName: '' } }); + } + }, [action, onChange]); + + const handleTextValueChange = useCallback( + (value: string) => { + const r = action.recipient; + if ( + r.type === GuardrailRecipientType.StaticEmail || + r.type === GuardrailRecipientType.StaticGroupName + ) { + onChange({ ...action, recipient: { ...r, value } }); + } else if ( + r.type === GuardrailRecipientType.AssetEmail || + r.type === GuardrailRecipientType.AssetGroupName + ) { + onChange({ ...action, recipient: { ...r, assetName: value } }); + } + }, + [action, onChange] + ); + + const recipientValue = + 'value' in action.recipient ? action.recipient.value : action.recipient.assetName; + const recipientDisplayValue = + ('displayName' in action.recipient ? action.recipient.displayName : '') || recipientValue; + const isSearchable = + recipientType === GuardrailRecipientType.User || recipientType === GuardrailRecipientType.Group; + const searchKind = recipientType === GuardrailRecipientType.User ? 'user' : 'group'; + const searchPlaceholder = + searchKind === 'user' ? labels.userSearchPlaceholder : labels.groupSearchPlaceholder; + + const appPickerCtx: GuardrailAppPickerContext = { + app: action.app.name ? action.app : null, + onChange: (app) => onChange({ ...action, app: app ?? { id: '', version: '', name: '' } }), + label: labels.actionAppLabel, + error: errors?.actionApp, + }; + + const fields = ( + <> + {/* Recipient type */} + + + + + + {/* Recipient value */} + + + {/* One rule across all three slots, matching `renderAppPicker`: a slot receives `error` + and owns rendering it, so the form renders no message of its own for a claimed field. + The sibling `FormFieldError` used to sit outside this ternary, so a host doing the + obvious `` got the message twice. Every fallback goes + through `Input`'s `error` prop, which renders the message *and* wires + aria-describedby / aria-errormessage / aria-invalid — the searchable fallback set + aria-invalid by hand and left the message associated with nothing. */} + {isSearchable ? ( + renderRecipientSearch ? ( + renderRecipientSearch({ + kind: searchKind, + displayValue: recipientDisplayValue, + placeholder: searchPlaceholder, + invalid: Boolean(errors?.recipient), + error: errors?.recipient, + onSelect: handleRecipientSelect, + onClear: handleRecipientClear, + }) + ) : ( + // Fallback without a host directory search: a plain input writing the value directly. + + handleRecipientSelect({ value: e.target.value, displayName: e.target.value }) + } + placeholder={searchPlaceholder} + error={errors?.recipient} + /> + ) + ) : ( + (() => { + const staticNode = renderStaticRecipient?.({ + kind: + displayedRecipientType === GuardrailRecipientType.StaticEmail + ? 'email' + : 'groupName', + recipient: action.recipient, + label: recipientTypeLabels[displayedRecipientType] ?? labels.recipientFallbackLabel, + invalid: Boolean(errors?.recipient), + error: errors?.recipient, + onChange: (recipient) => onChange({ ...action, recipient }), + }); + // The slot owns the message; see the note above. + if (staticNode !== undefined) return staticNode; + return ( + handleTextValueChange(e.target.value)} + placeholder={ + displayedRecipientType === GuardrailRecipientType.StaticEmail + ? labels.emailPlaceholder + : labels.groupNamePlaceholder + } + error={errors?.recipient} + /> + ); + })() + )} + + + {/* Action app picker (host capability) */} + + {renderAppPicker ? ( + renderAppPicker(appPickerCtx) + ) : ( + <> + + + + {labels.appPickerUnavailable} + + {/* The builder still gates Save on `actionApp` when no picker slot is supplied, + so without this the user is blocked with the reason rendered nowhere. */} + {errors?.actionApp} + + )} + + + ); + + return ( +
+
+ {actionTypeSelect} + {fields} +
+ {escalateHelp} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx new file mode 100644 index 0000000000..eb57307445 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { FieldShell } from './field-shell'; + +describe('FieldShell', () => { + it('renders the error border only when invalid', () => { + const { rerender } = render(); + expect(screen.getByTestId('shell')).not.toHaveClass('border-error'); + + rerender(); + expect(screen.getByTestId('shell')).toHaveClass('border-error'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx new file mode 100644 index 0000000000..69cc580316 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx @@ -0,0 +1,37 @@ +import { cn } from '@uipath/apollo-wind'; +import * as React from 'react'; + +export interface FieldShellProps extends React.ComponentPropsWithoutRef<'div'> { + /** Renders the error border (uses the `error` token, matching aria-invalid controls). */ + invalid?: boolean; +} + +/** + * Input-look container for non-input controls (chip groups) in the guardrail forms. + * + * Tracks apollo-wind's field chrome deliberately, including the `future:` layer: fields there go + * borderless on a raised surface with a larger radius, so a shell that kept a visible outline and + * a `background` fill read as flat and out-of-place next to its own neighbours in the future and + * dark themes. `InputGroup` is the same idea in apollo-wind — a container that looks like an input + * but wraps non-input children — and this mirrors its treatment, error state included. + */ +const FieldShell = React.forwardRef( + ({ invalid = false, className, children, ...props }, ref) => ( +
+ {children} +
+ ) +); +FieldShell.displayName = 'FieldShell'; + +export { FieldShell }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx new file mode 100644 index 0000000000..b2a8a31283 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { type GuardrailAction, GuardrailRecipientType } from '../builder-types'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { GuardrailActionSection } from './guardrail-action-section'; + +const labels = GUARDRAIL_BUILDER_EN_LABELS; +const logAction: GuardrailAction = { $actionType: 'log', severityLevel: 'Info' }; + +describe('GuardrailActionSection', () => { + it('renders the action type select with the current value', () => { + render(); + + expect(screen.getByText('Action type')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /action type/i })).toHaveTextContent('Log'); + }); + + it('resets the payload when the action type changes', async () => { + const onActionChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('combobox', { name: /action type/i })); + fireEvent.click(await screen.findByRole('option', { name: 'Block' })); + + expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'block', reason: '' }); + }); + + it('offers Filter only when showFilter is set', async () => { + const { unmount } = render( + + ); + fireEvent.click(screen.getByRole('combobox', { name: /action type/i })); + expect(await screen.findByRole('option', { name: 'Log' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Filter' })).not.toBeInTheDocument(); + unmount(); + + render( + + ); + fireEvent.click(screen.getByRole('combobox', { name: /action type/i })); + expect(await screen.findByRole('option', { name: 'Filter' })).toBeInTheDocument(); + }); + + it('renders the severity select for log actions and reports changes', async () => { + const onActionChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('combobox', { name: /severity level/i })); + fireEvent.click(await screen.findByRole('option', { name: 'Warning' })); + + expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'log', severityLevel: 'Warning' }); + }); + + it('renders the blocking reason input with its error for block actions', () => { + const onActionChange = vi.fn(); + render( + + ); + + const input = screen.getByLabelText(/blocking reason/i); + fireEvent.change(input, { target: { value: 'no PII' } }); + expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'block', reason: 'no PII' }); + expect(screen.getByText('Block reason is required')).toBeInTheDocument(); + }); + + it('renders filterContent and its error for filter actions', () => { + render( + } + errors={{ filterFields: 'Fields selection is required' }} + labels={labels} + /> + ); + + expect(screen.getByTestId('filter-slot')).toBeInTheDocument(); + expect(screen.getByText('Fields selection is required')).toBeInTheDocument(); + }); + + it('delegates escalate actions to the escalation fields', () => { + render( + + ); + + expect(screen.getByText('Assign to')).toBeInTheDocument(); + expect(screen.getByText('Action App')).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx new file mode 100644 index 0000000000..63677e5acd --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx @@ -0,0 +1,156 @@ +import { + FormField, + FormFieldError, + Input, + Label, + RequiredIndicator, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@uipath/apollo-wind'; +import { type ReactNode, useId } from 'react'; +import type { + GuardrailAction, + GuardrailAppPickerContext, + GuardrailRecipientSearchContext, + GuardrailStaticRecipientContext, +} from '../builder-types'; +import { createDefaultGuardrailAction } from '../builder-utils'; +import type { GuardrailBuilderLabels } from '../i18n'; +import { EscalateActionFields } from './escalate-action-fields'; + +export interface GuardrailActionSectionProps { + action: GuardrailAction; + onActionChange: (action: GuardrailAction) => void; + /** Whether to include 'filter' as an option (custom guardrails only) */ + showFilter?: boolean; + /** Content rendered as the second grid column when $actionType === 'filter' */ + filterContent?: ReactNode; + errors?: { blockReason?: string; filterFields?: string; recipient?: string; actionApp?: string }; + labels: GuardrailBuilderLabels; + renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => ReactNode; + renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => ReactNode | undefined; + renderAppPicker?: (ctx: GuardrailAppPickerContext) => ReactNode; + escalateHelp?: ReactNode; +} + +/** + * Action section of the guardrail builder: a 2-column grid of action-type select + the + * type-dependent secondary field. Switching the type resets the action payload. Escalate + * expands into the full escalation layout. + */ +export function GuardrailActionSection({ + action, + onActionChange, + showFilter = false, + filterContent, + errors, + labels, + renderRecipientSearch, + renderStaticRecipient, + renderAppPicker, + escalateHelp, +}: GuardrailActionSectionProps) { + // Namespaced per instance — two builders can share a document (inline panels). + const uid = useId(); + + const actionTypeSelect = ( + + + + + ); + + if (action.$actionType === 'escalate') { + return ( + + ); + } + + return ( +
+
+ {actionTypeSelect} + + {action.$actionType === 'log' && ( + + + + + )} + + {action.$actionType === 'block' && ( + + + + onActionChange({ ...action, reason: e.target.value } as GuardrailAction) + } + placeholder={labels.blockReasonPlaceholder} + error={errors?.blockReason} + /> + + )} + + {action.$actionType === 'filter' && ( + + {filterContent} + {errors?.filterFields} + + )} +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.test.tsx new file mode 100644 index 0000000000..da7948f865 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { GuardrailChip } from './guardrail-chip'; + +describe('GuardrailChip', () => { + it('renders a pressed toggle button', () => { + render(Email); + expect(screen.getByRole('button', { name: 'Email', pressed: true })).toBeInTheDocument(); + }); + + it('reports presses', () => { + const onPressedChange = vi.fn(); + render( + + Email + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Email', pressed: false })); + expect(onPressedChange).toHaveBeenCalledWith(true); + }); + + it('renders the addable appearance with a dashed border', () => { + render( + + ToolB + + ); + expect(screen.getByRole('button', { name: 'ToolB' })).toHaveClass('border-dashed'); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + <> + On + + Addable + + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx new file mode 100644 index 0000000000..1ae27b88a2 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx @@ -0,0 +1,45 @@ +import { cn, Toggle } from '@uipath/apollo-wind'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +// `cn` is twMerge(clsx(...)), and a `future:`-prefixed class sits in a different merge group from +// its unprefixed counterpart — so Toggle's own `future:text-muted-foreground` and +// `future:data-[state=on]:text-foreground` survive alongside anything set here without a prefix, +// and win under a `.future-*` root. Every colour the chip overrides therefore needs a `future:` +// twin, or the pressed chip renders a brand fill with plain foreground text. +const guardrailChipVariants = cva( + // Pill geometry over Toggle's base (which contributes the focus ring, disabled handling, + // and data-[state] hooks). h-6/px-2.5/text-xs matches the compact chip scale. + 'h-6 min-w-0 gap-1 rounded-full border px-2.5 text-xs font-medium [&_svg]:size-3', + { + variants: { + appearance: { + default: + 'bg-background text-foreground border-border hover:bg-muted hover:text-foreground data-[state=on]:bg-brand-subtle data-[state=on]:text-foreground-accent data-[state=on]:border-brand-lighter future:text-foreground future:hover:text-foreground future:data-[state=on]:text-foreground-accent', + // Dashed affordance for items that can be added but are not currently targeted. + addable: + 'border-dashed bg-background text-foreground-muted border-border hover:bg-muted hover:text-foreground future:text-foreground-muted future:hover:text-foreground', + }, + }, + defaultVariants: { appearance: 'default' }, + } +); + +export interface GuardrailChipProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +/** Toggleable pill used for scopes, entities, and tool targeting in the guardrail forms. */ +const GuardrailChip = React.forwardRef( + ({ appearance, className, ...props }, ref) => ( + + ) +); +GuardrailChip.displayName = 'GuardrailChip'; + +export { GuardrailChip, guardrailChipVariants }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.test.tsx new file mode 100644 index 0000000000..f0add32ac0 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.test.tsx @@ -0,0 +1,271 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import type { GuardrailSelector } from '../builder-types'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { GuardrailScopeSelector } from './guardrail-scope-selector'; + +describe('GuardrailScopeSelector', () => { + const defaultProps = { + selector: { scopes: [] } as GuardrailSelector, + onChange: vi.fn(), + availableToolNames: ['ToolA', 'ToolB'], + labels: GUARDRAIL_BUILDER_EN_LABELS, + }; + + // Asserted directly rather than via the axe check below: axe has no orphan-