diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 702696c85..b2cdcf695 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -31,7 +31,7 @@ "@storybook/icons": "^2.0.1", "@storybook/react": "^10.4.1", "@storybook/react-vite": "^10.4.1", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "4.3.0", "@types/react": "^19.2.6", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.2.0", 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 + + +
+ +