From d0693e1b7dcb6d0d311c0251311b9afd54293820 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 23 Jul 2026 12:30:56 -0700 Subject: [PATCH 01/14] feat(apollo-react): add Form HITL quick-form panel prototype Bring the Form HITL / Form HITL Review NodePropertyPanel stories over from the hitl-prototype branch as the starting point for packaging this work up for review: a quick-form builder panel (drag-reorder fields, per-field lock/read-only, Fixed value vs. JS expression, AI generate, JSON schema view) built on the new LockableValueField. This lands the prototype's current state as-is; the next two commits remove the now-redundant Review page and switch consumption over to LockableValueField from @uipath/apollo-wind. Co-Authored-By: Claude Sonnet 5 --- .../NodePropertyPanel/LockableValueField.tsx | 680 ++++++++++ .../NodePropertyPanel.stories.tsx | 1159 ++++++++++++++++- 2 files changed, 1832 insertions(+), 7 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/NodePropertyPanel/LockableValueField.tsx diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/LockableValueField.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/LockableValueField.tsx new file mode 100644 index 000000000..0fc03e338 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/LockableValueField.tsx @@ -0,0 +1,680 @@ +import { + Button, + Calendar, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FileUpload, + Input, + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + Label, + MultiSelect, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Textarea, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@uipath/apollo-wind'; +import { + ALargeSmall, + Asterisk, + Braces, + Calendar as CalendarIcon, + ChevronDown, + Code2, + Hash, + List, + ListChecks, + type LucideIcon, + Paperclip, + Sparkles, + ToggleLeft, + Type, +} from 'lucide-react'; +import type { ReactNode, SVGProps } from 'react'; +import { useId } from 'react'; + +/** + * Prototype-only: raw Material Symbols Outlined glyphs (FILL 0, wght 400, + * GRAD 0, opsz 24) for a side-by-side comparison against lucide's Lock / + * LockOpen. Not styled to match lucide's stroke conventions on purpose -- + * this is here to judge the Material look as-is, unmodified. + */ +function MaterialLock({ + size = 24, + className, + ...props +}: { size?: number | string; className?: string } & SVGProps) { + return ( + + + + ); +} + +function MaterialLockOpenRight({ + size = 24, + className, + ...props +}: { size?: number | string; className?: string } & SVGProps) { + return ( + + + + ); +} + +export type LockableValueFieldMode = 'fixed' | 'expression'; + +export type LockableFieldType = + | 'string' + | 'integer' + | 'date' + | 'boolean' + | 'single-select' + | 'multi-select' + | 'file'; + +interface FieldTypeMeta { + label: string; + icon: LucideIcon; + supportsExpression: boolean; + fixedLabel: string; + fixedDescription: string; +} + +export const FIELD_TYPE_META: Record = { + string: { + label: 'String', + icon: ALargeSmall, + supportsExpression: true, + fixedLabel: 'Fixed value', + fixedDescription: 'Use a literal string value', + }, + integer: { + label: 'Integer', + icon: Hash, + supportsExpression: true, + fixedLabel: 'Fixed value', + fixedDescription: 'Use a literal number value', + }, + date: { + label: 'Date', + icon: CalendarIcon, + supportsExpression: true, + fixedLabel: 'Fixed date', + fixedDescription: 'Use a literal date value', + }, + boolean: { + label: 'Boolean', + icon: ToggleLeft, + supportsExpression: true, + fixedLabel: 'Fixed value', + fixedDescription: 'Use a literal true or false value', + }, + 'single-select': { + label: 'Single select', + icon: List, + supportsExpression: false, + fixedLabel: 'Fixed value', + fixedDescription: 'Choose one option', + }, + 'multi-select': { + label: 'Multiselect', + icon: ListChecks, + supportsExpression: false, + fixedLabel: 'Fixed value', + fixedDescription: 'Choose one or more options', + }, + file: { + label: 'File', + icon: Paperclip, + supportsExpression: false, + fixedLabel: 'Fixed value', + fixedDescription: 'Upload a file', + }, +}; + +export const FIELD_TYPE_ORDER: LockableFieldType[] = [ + 'string', + 'integer', + 'date', + 'boolean', + 'single-select', + 'multi-select', + 'file', +]; + +export const DEMO_SELECT_OPTIONS = [ + { label: 'Option 1', value: 'option-1' }, + { label: 'Option 2', value: 'option-2' }, + { label: 'Option 3', value: 'option-3' }, +]; + +export function parseListValue(value: string): string[] { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export interface LockableValueFieldProps { + /** Current field value. Encoding depends on fieldType (e.g. multi-select is a JSON array string). */ + value?: string; + /** Called when the user edits the value (only fires while unlocked). */ + onValueChange?: (value: string) => void; + /** Whether the field is read-only. Defaults to true. */ + locked?: boolean; + /** Called when the user toggles the lock. */ + onLockedChange?: (locked: boolean) => void; + /** Fixed value vs. JS expression. Defaults to 'fixed'. Ignored for types that don't support expressions. */ + mode?: LockableValueFieldMode; + /** Called when the user switches modes. */ + onModeChange?: (mode: LockableValueFieldMode) => void; + /** The field's data type. Defaults to 'string'. Determines which control renders the value. */ + fieldType?: LockableFieldType; + /** Called when the user switches the field type. */ + onFieldTypeChange?: (fieldType: LockableFieldType) => void; + /** Shows a required-field asterisk next to the default label. Ignored when `label` is provided. */ + required?: boolean; + /** Called when the user toggles required/optional. Renders the Required switch when provided. */ + onRequiredChange?: (required: boolean) => void; + /** Overrides the default mode-based label (e.g. a field name instead of "String value"). */ + label?: ReactNode; + /** Extra content rendered after the built-in AI assist / Insert variable buttons (e.g. a delete button). */ + headerActions?: ReactNode; + /** Forces the header row into its narrow-container icon-only layout, regardless of actual width. For demos/comparisons. */ + compact?: boolean; + /** Whether the field-type, AI-assist, and insert-variable controls are always shown or only on hover. Defaults to 'visible'. */ + controlsVisibility?: 'visible' | 'hover'; + /** Whether the AI-assist and Insert-variable actions render at all. Set to false for read-only reviewer contexts where field configuration isn't editable. Defaults to true. */ + showFieldActions?: boolean; + id?: string; + className?: string; +} + +const FIELD_LABEL: Record = { + fixed: 'String value', + expression: 'Write a string expression', +}; + +const FIELD_PLACEHOLDER: Record = { + fixed: 'String value', + expression: 'Write a string expression', +}; + +/** + * LockableValueField — a field that can be locked to read-only, typed as one + * of several data types, and (for scalar types) switched between a literal + * value and a JS expression. + * + * Prototype: the expression mode is styled as code (monospace) but does not + * yet carry real syntax highlighting or evaluation. See ExpressionField for + * the direction a full code-editing surface would take. Select/multiselect + * options are demo placeholders; file uploads aren't persisted anywhere. + */ +export function LockableValueField({ + value = '', + onValueChange, + locked = true, + onLockedChange, + mode = 'fixed', + onModeChange, + fieldType = 'string', + onFieldTypeChange, + required, + onRequiredChange, + label, + headerActions, + compact, + controlsVisibility = 'visible', + showFieldActions = true, + id, + className, +}: LockableValueFieldProps) { + const promptId = useId(); + const typeMeta = FIELD_TYPE_META[fieldType]; + const effectiveMode = typeMeta.supportsExpression ? mode : 'fixed'; + const collapsedTextClass = cn('@max-[259px]:hidden', compact && '!hidden'); + const collapsedPaddingClass = cn('@max-[259px]:px-1.5', compact && '!px-1.5'); + + // Locked fields are read-only, not disabled — the raw control (switch, date + // picker, select) has nothing left to do once editing is blocked, so it's + // replaced with plain, selectable text showing the same value. + const lockedDisplayValue = + fieldType === 'boolean' + ? value === 'true' + ? 'True' + : 'False' + : fieldType === 'date' + ? value + ? new Date(value).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : '' + : fieldType === 'single-select' + ? (DEMO_SELECT_OPTIONS.find((option) => option.value === value)?.label ?? '') + : fieldType === 'multi-select' + ? parseListValue(value) + .map((v) => DEMO_SELECT_OPTIONS.find((option) => option.value === v)?.label ?? v) + .join(', ') + : value; + + return ( +
+
+ {label ?? ( + + )} + +
+
+ {onFieldTypeChange && ( + + + + + + + + Type + + + {FIELD_TYPE_ORDER.map((type) => { + const meta = FIELD_TYPE_META[type]; + const isActive = type === fieldType; + return ( + onFieldTypeChange(type)}> + + + {meta.label} + + + ); + })} + + + )} + {onRequiredChange && ( + <> +
+ + + {/* Wrapped in a span: TooltipTrigger's asChild merge otherwise + overwrites the Switch's own data-state (checked/unchecked) + with the tooltip's open/closed state, breaking its color classes. */} + + + + + Required + +
+
+ + + + + + + + Required + + +
+ Required + +
+
+
+
+ + )} + {showFieldActions && ( + <> + + + + + + + + Generate with AI + + +
+ +