diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index ec12ce359..00a77cbfb 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -8,6 +8,14 @@ on: env: BASE_REF: ${{ github.event.pull_request.base.ref }} + # Turbo's own GitHub Actions event auto-detection for `--affected` doesn't + # reliably resolve the base ref in this repo (falls back to treating every + # package as affected, silently running Format/Lint/etc. against the whole + # monorepo instead of just what the PR touched). Giving it a concrete SHA + # sidesteps the ambiguity entirely -- verified locally that `--affected` + # correctly narrows to just the changed packages (+ their dependents) once + # this is set, versus all 9 packages without it. + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index b358a952f..ac64718d8 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -151,6 +151,7 @@ const preview: Preview = { 'File Upload', 'Input', 'Input Group', + 'Lockable Value Field', 'Label', 'Multi Select', 'Radio Group', diff --git a/packages/apollo-core/biome.json b/packages/apollo-core/biome.json index 8c70b32d1..31639e2e4 100644 --- a/packages/apollo-core/biome.json +++ b/packages/apollo-core/biome.json @@ -1,4 +1,5 @@ { + "root": false, "$schema": "https://biomejs.dev/schemas/2.3.6/schema.json", "vcs": { "enabled": true, diff --git a/packages/apollo-react/biome.json b/packages/apollo-react/biome.json index 456926e3c..728c4ce3b 100644 --- a/packages/apollo-react/biome.json +++ b/packages/apollo-react/biome.json @@ -1,4 +1,5 @@ { + "root": false, "$schema": "https://biomejs.dev/schemas/2.3.6/schema.json", "vcs": { "enabled": true, diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx index b31028287..49507a8d2 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx @@ -1,22 +1,61 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + type DragOverEvent, + DragOverlay, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import type { EditorProps } from '@monaco-editor/react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import type { FormSchema } from '@uipath/apollo-wind'; +import type { FormSchema, LockableFieldType, LockableValueFieldMode } from '@uipath/apollo-wind'; import { Badge, Button, + Card, + CardContent, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + FIELD_TYPE_META, + HoverCard, + HoverCardContent, + HoverCardTrigger, + Input, + Label, + LockableValueField, MetadataForm, + Popover, + PopoverContent, + PopoverTrigger, ScrollableTabsList, Switch, Tabs, TabsContent, TabsList, TabsTrigger, + Textarea, + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from '@uipath/apollo-wind'; import { apolloCoreDarkHCMonaco, @@ -28,20 +67,31 @@ import { } from '@uipath/apollo-wind/editor-themes'; import { ChevronDown, + ChevronsDownUp, + ChevronsUpDown, CircleAlert, CircleCheck, + CircleDot, + CircleOff, Code2, + Copy, + FileBracesCorner, GitFork, Globe, GripVertical, + Pencil, Play, Plus, + Search, Sparkles, Type, + Upload, + UserRoundCheck, X, } from 'lucide-react'; import type { CSSProperties, ReactNode } from 'react'; -import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { NodePropertyPanel } from './NodePropertyPanel'; // @monaco-editor/react uses a CJS build without an `exports` field, which @@ -614,6 +664,34 @@ const COMPACT_EDITOR_OPTIONS = { automaticLayout: true, } as const; +const JSON_VIEWER_OPTIONS = { + readOnly: true, + fontSize: 12, + lineHeight: 18, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'off', + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace', + padding: { top: 8, bottom: 8 }, + lineNumbers: 'off' as const, + lineDecorationsWidth: 0, + glyphMargin: false, + folding: true, + renderLineHighlight: 'none' as const, + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + overviewRulerLanes: 0, + scrollbar: { + vertical: 'auto' as const, + horizontal: 'auto' as const, + alwaysConsumeMouseWheel: false, + }, + automaticLayout: true, +} as const; + +const JSON_EDITOR_OPTIONS = { ...JSON_VIEWER_OPTIONS, readOnly: false } as const; + const INLINE_EDITOR_OPTIONS = { fontSize: 13, lineHeight: 20, @@ -1646,6 +1724,1744 @@ export const InputEditor: Story = { render: () => , }; +// ============================================================================ +// Output Panel helpers +// ============================================================================ + +type OutputNode = { + key: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'; + value?: string | number | boolean | null; + children?: OutputNode[]; + path: string; +}; + +function TypeBadge({ type }: { type: OutputNode['type'] }) { + const labels: Record = { + string: 'T', + number: '#', + boolean: '?', + object: '{}', + array: '[]', + null: '∅', + }; + const label = labels[type]; + const cls = 'border-border bg-surface-overlay text-foreground-muted'; + return ( + + {label} + + ); +} + +function outputValueColorClass(type: OutputNode['type'], value: unknown): string { + if (type === 'string') return 'text-success'; + if (type === 'number') return 'text-info'; + if (type === 'boolean') return value ? 'text-success' : 'text-error'; + if (type === 'null') return 'text-foreground-subtle'; + return 'text-foreground'; +} + +function formatOutputValue( + type: OutputNode['type'], + value: string | number | boolean | null | undefined +): string { + if (type === 'null' || value === null || value === undefined) return 'null'; + if (type === 'string') return `"${value}"`; + return String(value); +} + +function nodeMatchesQuery(node: OutputNode, q: string): boolean { + if (!q) return true; + if (node.key.toLowerCase().includes(q)) return true; + if ( + node.value !== undefined && + node.value !== null && + String(node.value).toLowerCase().includes(q) + ) + return true; + return node.children?.some((c) => nodeMatchesQuery(c, q)) ?? false; +} + +function collectContainerPaths(nodes: OutputNode[]): string[] { + const paths: string[] = []; + for (const n of nodes) { + if (n.children) { + paths.push(n.path); + paths.push(...collectContainerPaths(n.children)); + } + } + return paths; +} + +type FlatRow = { node: OutputNode; depth: number }; + +function flattenOutputTree( + nodes: OutputNode[], + collapsed: Record, + query: string, + depth = 0 +): FlatRow[] { + const rows: FlatRow[] = []; + for (const n of nodes) { + if (query && !nodeMatchesQuery(n, query)) continue; + rows.push({ node: n, depth }); + if (n.children && !collapsed[n.path]) { + rows.push(...flattenOutputTree(n.children, collapsed, query, depth + 1)); + } + } + return rows; +} + +const PANEL_NODE_ID = 'httpRequest1'; +const PANEL_NODE_LABEL = 'HTTP Request'; + +// ============================================================================ + +const REFERENCED_OUTPUTS = [ + { name: 'responseBody', type: 'object' }, + { name: 'statusCode', type: 'number' }, + { name: 'headers', type: 'object' }, + { name: 'errorMessage', type: 'string' }, + { name: 'duration', type: 'number' }, + { name: 'requestId', type: 'string' }, + { name: 'token', type: 'string' }, +]; + +const REFERENCED_INPUTS = [ + { name: 'responseBody', type: 'object' }, + { name: 'statusCode', type: 'number' }, + { name: 'headers', type: 'object' }, + { name: 'condition', type: 'string' }, + { name: 'result', type: 'boolean' }, + { name: 'prompt', type: 'string' }, + { name: 'response', type: 'string' }, + { name: 'assignee', type: 'string' }, + { name: 'message', type: 'string' }, +]; + +const HTTP_REQUEST_CHILDREN: OutputNode[] = [ + { key: 'statusCode', type: 'number', value: 200, path: `${PANEL_NODE_ID}.statusCode` }, + { + key: 'responseBody', + type: 'object', + path: `${PANEL_NODE_ID}.responseBody`, + children: [ + { key: 'id', type: 'string', value: 'inv-001', path: `${PANEL_NODE_ID}.responseBody.id` }, + { + key: 'amount', + type: 'number', + value: 1500, + path: `${PANEL_NODE_ID}.responseBody.amount`, + }, + { + key: 'currency', + type: 'string', + value: 'USD', + path: `${PANEL_NODE_ID}.responseBody.currency`, + }, + { + key: 'status', + type: 'string', + value: 'paid', + path: `${PANEL_NODE_ID}.responseBody.status`, + }, + ], + }, + { + key: 'headers', + type: 'object', + path: `${PANEL_NODE_ID}.headers`, + children: [ + { + key: 'content-type', + type: 'string', + value: 'application/json', + path: `${PANEL_NODE_ID}.headers.content-type`, + }, + { + key: 'x-request-id', + type: 'string', + value: 'abc-123', + path: `${PANEL_NODE_ID}.headers.x-request-id`, + }, + ], + }, + { key: 'errorMessage', type: 'string', value: null, path: `${PANEL_NODE_ID}.errorMessage` }, + { key: 'duration', type: 'number', value: 342, path: `${PANEL_NODE_ID}.duration` }, + { key: 'requestId', type: 'string', value: 'req-abc-123', path: `${PANEL_NODE_ID}.requestId` }, + { + key: 'token', + type: 'string', + value: 'eyJhbGciOiJSUzI1NiJ9', + path: `${PANEL_NODE_ID}.token`, + }, + { key: 'retryCount', type: 'number', value: 0, path: `${PANEL_NODE_ID}.retryCount` }, + { key: 'cached', type: 'boolean', value: false, path: `${PANEL_NODE_ID}.cached` }, +]; + +const INPUT_TREE_DATA: OutputNode[] = [ + { key: PANEL_NODE_ID, type: 'object', path: PANEL_NODE_ID, children: HTTP_REQUEST_CHILDREN }, + { + key: 'decision1', + type: 'object', + path: 'decision1', + children: [ + { + key: 'condition', + type: 'string', + value: 'invoice.amount > 1000', + path: 'decision1.condition', + }, + { key: 'result', type: 'boolean', value: true, path: 'decision1.result' }, + { key: 'branch', type: 'string', value: 'approve', path: 'decision1.branch' }, + ], + }, + { + key: 'agent1', + type: 'object', + path: 'agent1', + children: [ + { + key: 'prompt', + type: 'string', + value: 'Summarize the invoice details', + path: 'agent1.prompt', + }, + { key: 'model', type: 'string', value: 'gpt-4o-mini', path: 'agent1.model' }, + { + key: 'response', + type: 'string', + value: 'Invoice inv-001 for $1,500 USD is paid.', + path: 'agent1.response', + }, + { key: 'tokens', type: 'number', value: 342, path: 'agent1.tokens' }, + ], + }, + { + key: 'approval1', + type: 'object', + path: 'approval1', + children: [ + { key: 'assignee', type: 'string', value: 'finance-team', path: 'approval1.assignee' }, + { + key: 'message', + type: 'string', + value: 'Please review this invoice', + path: 'approval1.message', + }, + { key: 'dueDate', type: 'string', value: '2025-01-15', path: 'approval1.dueDate' }, + ], + }, +]; + +const HTTP_REQUEST_JSON = JSON.stringify( + { + statusCode: 200, + responseBody: { + id: 'inv-001', + amount: 1500.0, + currency: 'USD', + status: 'paid', + }, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + }, + errorMessage: null, + duration: 342, + requestId: 'req-abc-123', + token: 'eyJhbGciOiJSUzI1NiJ9', + retryCount: 0, + cached: false, + }, + null, + 2 +); + +const OUTPUT_TREE_DATA: OutputNode[] = [ + { key: PANEL_NODE_ID, type: 'object', path: PANEL_NODE_ID, children: HTTP_REQUEST_CHILDREN }, +]; + +const INPUT_JSON = HTTP_REQUEST_JSON; +const OUTPUT_JSON = HTTP_REQUEST_JSON; + +// ============================================================================ +// Concept 2 — Expression Reference Panel +// Flat list of all leaf output paths as copyable expression references. +// ============================================================================ + +function Concept2PanelStory({ + mode, + context = 'flow', +}: { + mode: 'input' | 'output'; + context?: 'studio' | 'flow'; +}) { + const monacoTheme = useMonacoTheme(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'default' | 'referenced' | 'all'>('default'); + const [collapsed, setCollapsed] = useState>(() => + Object.fromEntries( + [...collectContainerPaths(OUTPUT_TREE_DATA), ...collectContainerPaths(INPUT_TREE_DATA)] + .filter((p) => p !== PANEL_NODE_ID) + .map((p) => [p, true]) + ) + ); + const [copiedPath, setCopiedPath] = useState(null); + const [searchOpen, setSearchOpen] = useState(false); + const [editingPath, setEditingPath] = useState(null); + const [editedValues, setEditedValues] = useState< + Record + >({}); + const [nodeMode, setNodeMode] = useState<'live' | 'static' | 'simulated' | 'disabled'>('live'); + + const NODE_MODES = [ + { + value: 'live', + label: 'Live', + description: 'Use the real response from this node', + icon: CircleDot, + }, + { + value: 'static', + label: 'Static mock', + description: 'Always return a value you define', + icon: FileBracesCorner, + }, + { + value: 'simulated', + label: 'Simulated', + description: 'Generate a response dynamically using an LLM', + icon: Sparkles, + }, + { + value: 'disabled', + label: 'Skip node', + description: "Don't execute this node", + icon: CircleOff, + }, + ] as const; + const currentNodeMode = NODE_MODES.find((m) => m.value === nodeMode) ?? NODE_MODES[0]; + const CurrentModeIcon = currentNodeMode.icon; + + const isOutput = mode === 'output'; + const currentTreeData = isOutput ? OUTPUT_TREE_DATA : INPUT_TREE_DATA; + const currentReferenced = isOutput ? REFERENCED_OUTPUTS : REFERENCED_INPUTS; + const currentJson = isOutput ? OUTPUT_JSON : INPUT_JSON; + const referencedKeys = new Set(currentReferenced.map((r) => r.name)); + + const activeTreeData = + filter !== 'referenced' + ? currentTreeData + : currentTreeData + .map((root) => ({ + ...root, + children: root.children?.filter((n) => referencedKeys.has(n.key)), + })) + .filter((root) => (root.children?.length ?? 0) > 0); + + const rows = flattenOutputTree(activeTreeData, collapsed, search.toLowerCase()); + + const toggleCollapsed = (path: string) => + setCollapsed((prev) => ({ ...prev, [path]: !prev[path] })); + + const allContainerPaths = collectContainerPaths(activeTreeData); + const allCollapsed = allContainerPaths.length > 0 && allContainerPaths.every((p) => collapsed[p]); + const toggleAll = () => { + if (allCollapsed) { + setCollapsed({}); + } else { + setCollapsed(Object.fromEntries(allContainerPaths.map((p) => [p, true]))); + } + }; + + const escapeRef = useRef(false); + + const saveEdit = (node: OutputNode, raw: string) => { + const val = + node.type === 'boolean' ? raw === 'true' : node.type === 'number' ? Number(raw) || 0 : raw; + setEditedValues((prev) => ({ ...prev, [node.path]: val })); + setEditingPath(null); + }; + + const copyExpr = (path: string) => { + navigator.clipboard + ?.writeText(`{{${path}}}`) + ?.then(() => { + setCopiedPath(path); + setTimeout(() => setCopiedPath(null), 1500); + }) + ?.catch(() => {}); + }; + + return ( + + {}} + className="h-[640px]" + > +
+ {/* Node identity bar — hidden in Studio context */} + {context === 'flow' && ( +
+
+ + {PANEL_NODE_LABEL} + {PANEL_NODE_ID} +
+ {isOutput && ( + + + + + + {NODE_MODES.map((m) => { + const Icon = m.icon; + return ( + setNodeMode(m.value)} + className={cn( + 'flex items-start gap-2', + nodeMode === m.value && 'text-foreground' + )} + > + +
+ {m.label} + + {m.description} + +
+
+ ); + })} +
+
+ )} +
+ )} + + + {/* Tab strip — status badge moves here in Studio context */} +
+ + + Schema + + + JSON + + + {context === 'studio' && isOutput && ( + <> +
+ + + + + + {NODE_MODES.map((m) => { + const Icon = m.icon; + return ( + setNodeMode(m.value)} + className={cn( + 'flex items-start gap-2', + nodeMode === m.value && 'text-foreground' + )} + > + +
+ {m.label} + + {m.description} + +
+
+ ); + })} +
+
+ + )} +
+ + {/* Schema tab */} + + {/* Header: filter dropdown on left, search + collapse on right */} +
+ + + + + + setFilter('referenced')} + className={cn( + 'flex items-center justify-between', + filter === 'referenced' && 'text-foreground' + )} + > + Referenced in this node + + {currentReferenced.length} + + + setFilter('all')} + className={cn('text-[11px]', filter === 'all' && 'text-foreground')} + > + All + + + +
+ {searchOpen ? ( +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearch(''); + setSearchOpen(false); + } + }} + aria-label={isOutput ? 'Search outputs' : 'Search inputs'} + placeholder={isOutput ? 'Search outputs...' : 'Search inputs...'} + className="w-36 pl-6 pr-6 text-foreground placeholder:text-foreground-subtle focus-visible:ring-0" + /> + +
+ ) : ( + + )} + {allContainerPaths.length > 0 && ( + + )} +
+ + {/* Tree list */} +
+
+ {rows.map(({ node, depth }) => + node.children !== undefined ? ( +
+ + + + {node.key} + + + {node.type === 'array' + ? `${node.children.length} ${node.children.length === 1 ? 'item' : 'items'}` + : `${node.children.length} ${node.children.length === 1 ? 'key' : 'keys'}`} + +
+ ) : ( +
+
+ + + {node.key} + + = + {editingPath === node.path ? ( + { + if (!escapeRef.current) saveEdit(node, e.target.value); + escapeRef.current = false; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') saveEdit(node, e.currentTarget.value); + if (e.key === 'Escape') { + escapeRef.current = true; + setEditingPath(null); + } + }} + className={cn( + 'min-w-0 flex-1 rounded bg-transparent px-1 font-mono text-xs outline-none ring-1 ring-brand', + outputValueColorClass( + node.type, + editedValues[node.path] ?? node.value + ) + )} + /> + ) : ( + <> + +
+ + + )} +
+ ) + )} + {rows.length === 0 && ( +

+ No references match your search. +

+ )} +
+
+ + + {/* JSON tab */} + +
+ +
+
+ +
+ + + ); +} + +// ============================================================================ +// In Studio / In Flow layout +// ============================================================================ + +function InputOutputStory() { + const [context, setContext] = useState<'studio' | 'flow'>('flow'); + return ( +
+
+ {(['flow', 'studio'] as const).map((c, i) => ( + + {i > 0 &&
} + + + ))} +
+
+
+ +
+
+ +
+
+
+ ); +} + +// ============================================================================ +// Prototype — LockableValueField +// ============================================================================ + +interface LockableCase { + id: number; + title: string; + required: boolean; + value: string; + locked: boolean; + mode: LockableValueFieldMode; + fieldType: LockableFieldType; +} + +/** Guards against malformed JSON (e.g. from hand-editing the schema view) reaching setCases -- a + * missing/wrong-typed id would break Sortable, and an unknown fieldType would break rendering. */ +function isValidLockableCase(item: unknown): item is LockableCase { + if (typeof item !== 'object' || item === null) return false; + const c = item as Record; + return ( + typeof c.id === 'number' && + typeof c.title === 'string' && + typeof c.required === 'boolean' && + typeof c.value === 'string' && + typeof c.locked === 'boolean' && + (c.mode === 'fixed' || c.mode === 'expression') && + typeof c.fieldType === 'string' && + Object.hasOwn(FIELD_TYPE_META, c.fieldType) + ); +} + +const DEFAULT_LOCKABLE_CASES: LockableCase[] = [ + { + id: 1, + title: 'Invoice Number', + required: true, + value: '', + locked: true, + mode: 'fixed', + fieldType: 'string', + }, + { + id: 2, + title: 'Submission Date', + required: true, + value: '', + locked: true, + mode: 'fixed', + fieldType: 'date', + }, + { + id: 3, + title: 'Approved Amount', + required: true, + value: '', + locked: true, + mode: 'fixed', + fieldType: 'integer', + }, +]; + +function LockableCaseRow({ + id, + caseTitle, + onTitleChange, + required, + onRequiredChange, + onDelete, + value, + onValueChange, + locked, + onLockedChange, + mode, + onModeChange, + fieldType, + onFieldTypeChange, + compact, + controlsVisibility, + insertBefore, + insertAfter, +}: { + id: number; + caseTitle: string; + onTitleChange: (title: string) => void; + required: boolean; + onRequiredChange: (required: boolean) => void; + onDelete: () => void; + value: string; + onValueChange: (value: string) => void; + locked: boolean; + onLockedChange: (locked: boolean) => void; + mode: LockableValueFieldMode; + onModeChange: (mode: LockableValueFieldMode) => void; + fieldType: LockableFieldType; + onFieldTypeChange: (fieldType: LockableFieldType) => void; + compact?: boolean; + controlsVisibility?: 'visible' | 'hover'; + /** Shows the insertion line above this row (the dragged item would land here). */ + insertBefore?: boolean; + /** Shows the insertion line below this row (the dragged item would land here). */ + insertAfter?: boolean; +}) { + const [editingTitle, setEditingTitle] = useState(false); + const titleRef = useRef(null); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + return ( +
+ {/* Rendered as a child of this row's own transformed wrapper (not an + outer sibling) so it moves in lockstep with the row during the + sortable reflow animation instead of drifting out of sync. */} + {insertBefore && ( +
+ )} +
+ + + {editingTitle ? ( + onTitleChange(e.target.value)} + onBlur={() => setEditingTitle(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Escape') setEditingTitle(false); + }} + className="min-w-0 flex-1 rounded bg-surface-overlay px-1 py-0.5 text-xs font-medium text-foreground outline-none ring-1 ring-brand" + autoFocus + /> + ) : ( + + )} +
+ } + headerActions={ + + } + value={value} + onValueChange={onValueChange} + locked={locked} + onLockedChange={onLockedChange} + mode={mode} + onModeChange={onModeChange} + fieldType={fieldType} + onFieldTypeChange={onFieldTypeChange} + required={required} + onRequiredChange={onRequiredChange} + compact={compact} + controlsVisibility={controlsVisibility} + /> +
+ {insertAfter && ( +
+ )} +
+ ); +} + +interface FormButtonItem { + id: number; + label: string; + variant: 'default' | 'outline'; +} + +const DEFAULT_FORM_BUTTONS: FormButtonItem[] = [ + { id: 1, label: 'Approve', variant: 'default' }, + { id: 2, label: 'Cancel', variant: 'outline' }, +]; + +function FormButtonChip({ + label, + onLabelChange, + variant, + onVariantChange, + onDelete, +}: { + label: string; + onLabelChange: (label: string) => void; + variant: 'default' | 'outline'; + onVariantChange: (variant: 'default' | 'outline') => void; + onDelete: () => void; +}) { + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + + return ( +
+ {editing ? ( + onLabelChange(e.target.value)} + onBlur={() => setEditing(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Escape') setEditing(false); + }} + autoFocus + size={Math.max(label.length, 4)} + className="bg-transparent px-3 outline-none" + /> + ) : ( + + )} + + + + + + + + + Edit button + + + +
+ Type + { + if (value) onVariantChange(value as 'default' | 'outline'); + }} + className="w-full" + > + + Primary + + + Secondary + + +
+
+ + + +
+ ); +} + +function FieldDragOverlay({ caseItem }: { caseItem: LockableCase }) { + const meta = FIELD_TYPE_META[caseItem.fieldType]; + return ( +
+ + + + {caseItem.title} + {caseItem.required && *} + +
+ ); +} + +function LockableValueFieldShowcase({ + controlsVisibility, + onControlsVisibilityChange, +}: { + controlsVisibility: 'visible' | 'hover'; + onControlsVisibilityChange: (visibility: 'visible' | 'hover') => void; +}) { + const fullViewId = useId(); + const compactViewId = useId(); + const [showcaseValue, setShowcaseValue] = useState(''); + const [showcaseLocked, setShowcaseLocked] = useState(true); + const [showcaseMode, setShowcaseMode] = useState('fixed'); + const [showcaseFieldType, setShowcaseFieldType] = useState('string'); + const [showcaseRequired, setShowcaseRequired] = useState(true); + + const handleShowcaseFieldTypeChange = (type: LockableFieldType) => { + setShowcaseFieldType(type); + setShowcaseValue(''); + if (!FIELD_TYPE_META[type].supportsExpression) { + setShowcaseMode('fixed'); + } + }; + + return ( +
+
+ Demo controls +

+ Toggle Show/Hide to preview how field controls behave in the panel on the left. Uses + component →{' '} + + Lockable Value Field + +

+
+
+ + Controls + + v && onControlsVisibilityChange(v as 'visible' | 'hover')} + > + + Show + + + Hide + + +
+
+ + Full view + + + Label + {showcaseRequired && *} + + } + headerActions={ + + } + value={showcaseValue} + onValueChange={setShowcaseValue} + locked={showcaseLocked} + onLockedChange={setShowcaseLocked} + mode={showcaseMode} + onModeChange={setShowcaseMode} + fieldType={showcaseFieldType} + onFieldTypeChange={handleShowcaseFieldTypeChange} + required={showcaseRequired} + onRequiredChange={setShowcaseRequired} + controlsVisibility={controlsVisibility} + /> +
+
+ + Compact view (narrow container) + +
+ + Label + {showcaseRequired && *} + + } + headerActions={ + + } + value={showcaseValue} + onValueChange={setShowcaseValue} + locked={showcaseLocked} + onLockedChange={setShowcaseLocked} + mode={showcaseMode} + onModeChange={setShowcaseMode} + fieldType={showcaseFieldType} + onFieldTypeChange={handleShowcaseFieldTypeChange} + required={showcaseRequired} + onRequiredChange={setShowcaseRequired} + controlsVisibility={controlsVisibility} + /> +
+
+
+ ); +} + +function QuickFormStory() { + const monacoTheme = useMonacoTheme(); + const [cases, setCases] = useState(DEFAULT_LOCKABLE_CASES); + const nextIdRef = useRef(4); + const [formView, setFormView] = useState<'edit' | 'json'>('edit'); + const [formTitle, setFormTitle] = useState('Quick Approve'); + const [formDescription, setFormDescription] = useState('Add a description'); + const [editingFormTitle, setEditingFormTitle] = useState(false); + const [editingFormDescription, setEditingFormDescription] = useState(false); + const formTitleRef = useRef(null); + const formDescriptionRef = useRef(null); + const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(DEFAULT_LOCKABLE_CASES, null, 2)); + const [jsonError, setJsonError] = useState(null); + const [jsonCopied, setJsonCopied] = useState(false); + const [showcaseControlsVisibility, setShowcaseControlsVisibility] = useState<'visible' | 'hover'>( + 'visible' + ); + const [buttons, setButtons] = useState(DEFAULT_FORM_BUTTONS); + const nextButtonIdRef = useRef(3); + const fileInputRef = useRef(null); + + useEffect(() => { + if (formView !== 'json') { + setJsonDraft(JSON.stringify(cases, null, 2)); + setJsonError(null); + } + }, [cases, formView]); + + const handleJsonChange = (value: string) => { + setJsonDraft(value); + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + setJsonError('Expected a JSON array of fields.'); + return; + } + if (!parsed.every(isValidLockableCase)) { + setJsonError('Each field needs id, title, required, value, locked, mode, and fieldType.'); + return; + } + setCases(parsed); + setJsonError(null); + } catch { + setJsonError('Invalid JSON.'); + } + }; + + const handleCopyJson = () => { + navigator.clipboard + ?.writeText(jsonDraft) + ?.then(() => { + setJsonCopied(true); + setTimeout(() => setJsonCopied(false), 1500); + }) + ?.catch(() => {}); + }; + + const addCaseWithType = (fieldType: LockableFieldType) => { + const id = nextIdRef.current++; + setCases((prev) => [ + ...prev, + { + id, + title: `Field ${id}`, + required: true, + value: '', + locked: true, + mode: 'fixed', + fieldType, + }, + ]); + }; + const deleteCase = (id: number) => setCases((prev) => prev.filter((c) => c.id !== id)); + const updateCase = (id: number, patch: Partial) => + setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c))); + const addButton = () => { + const id = nextButtonIdRef.current++; + setButtons((prev) => [...prev, { id, label: 'Button', variant: 'outline' }]); + }; + const deleteButton = (id: number) => setButtons((prev) => prev.filter((b) => b.id !== id)); + const updateButton = (id: number, patch: Partial) => + setButtons((prev) => prev.map((b) => (b.id === id ? { ...b, ...patch } : b))); + const updateCaseFieldType = (id: number, fieldType: LockableFieldType) => + setCases((prev) => + prev.map((c) => + c.id === id + ? { + ...c, + fieldType, + value: '', + mode: FIELD_TYPE_META[fieldType].supportsExpression ? c.mode : 'fixed', + } + : c + ) + ); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 3 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const [activeDragId, setActiveDragId] = useState(null); + const [overDragId, setOverDragId] = useState(null); + + const handleDragStart = (event: DragStartEvent) => { + setActiveDragId(event.active.id as number); + }; + + const handleDragOver = (event: DragOverEvent) => { + setOverDragId((event.over?.id as number) ?? null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + setCases((prev) => { + const oldIndex = prev.findIndex((c) => c.id === active.id); + const newIndex = prev.findIndex((c) => c.id === over.id); + return arrayMove(prev, oldIndex, newIndex); + }); + } + setActiveDragId(null); + setOverDragId(null); + }; + + const handleDragCancel = () => { + setActiveDragId(null); + setOverDragId(null); + }; + + const activeCase = cases.find((c) => c.id === activeDragId); + + return ( +
+ + } + nodeLabel="Quick Approve" + nodeCategory="Quick approve/reject decision for the extracted invoice." + action={} + onClose={() => {}} + contentInset="0.875rem" + className="h-[760px]" + > + +
+ + + Parameters + + + Branching + + + Error handling + + +
+ + {/* Quick form */} +
+
+ Quick form +
+ + + + + + + + + Generate with AI + + + + + + Describe the form you want + +