From 6dcd63c18b3b02f5c692ce80882b6ddb34b09639 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sat, 29 Nov 2025 15:56:02 +0800 Subject: [PATCH] # Add Template String Support for Nested Object/Array Fields in Flow Editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Problem Currently in Flow mode, only top-level string fields support template strings (e.g., `Bearer ${results.b.api_key}`). When a parameter is an object or array with nested fields, users are forced to: - Switch the entire object/array to JavaScript Expression mode - Write the complete structure as JavaScript code - Lose the visual editing experience **Example:** ```typescript interface Header { key: string; value: string; } export async function main(headers: Header[]) { // ... } Previously, to use dynamic values in the value field, users had to write: ([ { key: "X-Engine", value: "direct" }, { key: "Authorization", value: `Bearer ${results.b.api_key}` } ]) This is error-prone and loses the benefits of visual form editing. โœ… Solution This PR implements per-field template string support for nested objects and arrays, similar to n8n's approach where every visible input field can use expressions. Key Features 1. Recursive Visual Editing: Each nested field in objects/arrays now has its own ${} template string toggle 2. Smart Conversion: When any nested field uses dynamic expressions, the parent automatically converts to JavaScript mode internally while maintaining visual editing in the UI 3. Bidirectional Parsing: When loading existing flows, JavaScript expressions are automatically parsed back to visual form when possible 4. Backward Compatible: Complex JavaScript logic that can't be visualized continues to use the JavaScript editor Implementation Details New Component: SchemaFormTransform.svelte - Specialized form component for Flow mode that recursively uses InputTransformForm for nested fields - Each field gets independent static/javascript toggle Enhanced: InputTransformForm.svelte - Detects objects/arrays with properties schema - Uses SchemaFormTransform for visual editing of nested fields - Automatically generates equivalent JavaScript expressions when fields contain dynamic values - Parses JavaScript expressions back to visual format on load Dual Storage Strategy: { type: 'javascript', // For backend execution expr: '({ ... })', // Generated JavaScript code value: { ... } // For UI visual editing } ๐Ÿงช Testing Scenarios Scenario 1: New Flow Creation 1. Create a script with nested object/array parameters 2. Add the script to a flow 3. Edit nested fields using template strings 4. Verify: UI stays in visual mode, no sudden jump to JS editor 5. Verify: Generated code is syntactically correct Scenario 2: Loading Existing Flow 1. Open a flow with JavaScript expressions for object/array parameters 2. Verify: Simple structures are parsed and displayed visually 3. Verify: Complex logic (with .map(), conditionals) stays in JS mode 4. Edit and save 5. Verify: Round-trip consistency (saved value equals original) Scenario 3: Mixed Static/Dynamic Values 1. Create an array with some static and some dynamic fields 2. Verify: Only dynamic fields trigger JavaScript mode generation 3. Verify: Static fields are properly JSON-encoded in generated code ๐Ÿ”„ Lifecycle Behavior | Stage | Behavior | |------------|--------------------------------------------------| | New Flow | Visual editing โ†’ Auto-generates JS expressions | | Load Flow | Auto-parses JS โ†’ Displays visually (if possible) | | Editing | Always stays in visual mode | | Saving | Generates correct JS equivalent to manual code | | Complex JS | Falls back to JS editor (expected behavior) | ๐Ÿ“Š Example Before (must use JavaScript Expression): // User forced to write entire structure as code ([ { key: "X-Engine", value: "direct" }, { key: "Authorization", value: `Bearer ${results.b.api_key}` } ]) After (visual editing): - UI shows visual form with "Add item" button - Each item has key and value text inputs - value field has ${} template string button - User types directly: Bearer ${results.b.api_key} - System auto-generates equivalent JS code ๐ŸŽจ User Experience Improvements - โœ… No more context switching between visual and code editors - โœ… Reduced syntax errors (no manual bracket matching) - โœ… Faster workflow (visual form is quicker than typing code) - โœ… Better discoverability (template string button is visible) - โœ… Maintains Windmill's static/javascript architecture under the hood ๐Ÿ” Files Changed - frontend/src/lib/components/SchemaFormTransform.svelte (new) - frontend/src/lib/components/InputTransformForm.svelte (enhanced) ๐Ÿ“ Notes - This change only affects Flow mode input transforms - Apps and standalone script execution are unaffected - Backward compatible with existing flows - No database migration required --- .../lib/components/InputTransformForm.svelte | 385 +++++++++++++++--- .../lib/components/SchemaFormTransform.svelte | 240 +++++++++++ 2 files changed, 569 insertions(+), 56 deletions(-) create mode 100644 frontend/src/lib/components/SchemaFormTransform.svelte diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 2dc5672d25d0c..93771508026b2 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -41,6 +41,7 @@ import S3ArrayHelperButton from './S3ArrayHelperButton.svelte' import { inputBorderClass } from './text_input/TextInput.svelte' import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte' + import SchemaFormTransform from './SchemaFormTransform.svelte' // We add 'ai' for ai agent tools. 'ai' means the field will be filled by the AI agent dynamically. type PropertyType = InputTransform['type'] | 'ai' @@ -160,6 +161,52 @@ }) } + // Try to parse JavaScript expression back to static object/array for visual editing + function tryParseJsToStatic(expr: string, schemaType: string): any | null { + if (!expr || typeof expr !== 'string') return null + + try { + // Remove wrapping parentheses if present + let code = expr.trim() + if (code.startsWith('(') && code.endsWith(')')) { + code = code.slice(1, -1).trim() + } + + // Try to parse as object literal or array + // This is a simple heuristic - check if it looks like a static structure + if (schemaType === 'object' && code.startsWith('{') && code.endsWith('}')) { + // Try to evaluate it safely + // For now, use a simple regex-based approach to extract static-looking values + // If the object contains only simple expressions, convert it + const hasComplexLogic = /\bif\b|\bfor\b|\bwhile\b|\bfunction\b|\breturn\b/.test(code) + if (!hasComplexLogic) { + // Attempt to parse it + try { + const parsed = new Function(`return ${code}`)() + return parsed + } catch { + return null + } + } + } else if (schemaType === 'array' && code.startsWith('[') && code.endsWith(']')) { + // Similar for arrays + const hasComplexLogic = /\bif\b|\bfor\b|\bwhile\b|\bfunction\b|\breturn\b/.test(code) + if (!hasComplexLogic) { + try { + const parsed = new Function(`return ${code}`)() + return parsed + } catch { + return null + } + } + } + } catch (e) { + return null + } + + return null + } + function getPropertyType(arg: InputTransform | any): PropertyType { // For agent tools, if static with undefined/empty value, treat as 'ai', meaning the field will be filled by the AI agent dynamically. if ( @@ -186,6 +233,16 @@ } } + // Try to parse javascript expressions for objects/arrays back to static for visual editing + if (type == 'javascript' && (inputCat === 'object' || inputCat === 'list')) { + const parsed = tryParseJsToStatic(arg.expr, inputCat === 'object' ? 'object' : 'array') + if (parsed !== null) { + type = 'static' + arg.value = parsed + // Keep the expr for reference but switch to static mode for UI + } + } + return type } @@ -763,62 +820,278 @@ {/if} {:else if (propertyType === undefined || propertyType == 'static') && schema?.properties?.[argName]} - { - focused = false - }} - shouldDispatchChanges - on:change={() => { - dispatch('change', { argName, arg }) - }} - label={argName} - bind:editor={monaco} - bind:description={schema.properties[argName].description} - bind:value={arg.value} - type={schema.properties[argName].type} - oneOf={schema.properties[argName].oneOf} - required={schema.required?.includes(argName)} - bind:pattern={schema.properties[argName].pattern} - bind:valid={inputCheck} - defaultValue={schema.properties[argName].default} - bind:enum_={schema.properties[argName].enum} - bind:format={schema.properties[argName].format} - contentEncoding={schema.properties[argName].contentEncoding} - bind:itemsType={schema.properties[argName].items} - properties={schema.properties[argName].properties} - nestedRequired={schema.properties[argName].required} - displayHeader={false} - extra={argExtra} - {variableEditor} - {itemPicker} - bind:pickForField - showSchemaExplorer - nullable={schema.properties[argName].nullable} - bind:title={schema.properties[argName].title} - bind:placeholder={schema.properties[argName].placeholder} - {helperScript} - {s3StorageConfigured} - otherArgs={Object.fromEntries( - Object.entries(otherArgs).map(([key, transform]) => [ - key, - transform?.type === 'static' ? transform.value : transform?.expr - ]) - )} - > - {#snippet innerBottomSnippet()} - {#if shouldShowS3ArrayHelper} - - switchToJsAndConnect((path) => appendPathToArrayExpr(arg.expr, path))} - /> - {/if} - {/snippet} - + {@const schemaProperty = schema.properties[argName]} + {@const isObjectWithProperties = + (schemaProperty.type === 'object' && schemaProperty.properties) || + (schemaProperty.type === 'array' && + schemaProperty.items?.type === 'object' && + schemaProperty.items?.properties)} + + {#if isObjectWithProperties} + + {#if schemaProperty.type === 'object' && schemaProperty.properties} + +
+ {#if arg.value && typeof arg.value === 'object' && !Array.isArray(arg.value)} + + {@const nestedArgs = Object.fromEntries( + Object.keys(schemaProperty.properties).map((key) => { + const val = arg.value?.[key] + // If already an InputTransform, keep it; otherwise wrap as static + if (val && typeof val === 'object' && ('type' in val || 'expr' in val)) { + return [key, val] + } + return [key, { type: 'static', value: val }] + }) + )} + nestedArgs, + (v) => { + // Check if any nested field uses javascript mode + let hasJavascriptField = false + const plainValues = {} + const jsExprParts = [] + + Object.keys(v ?? {}).forEach((key) => { + const transform = v[key] + if (transform?.type === 'javascript') { + hasJavascriptField = true + // Remove wrapping if it's already wrapped + let expr = transform.expr + if (expr.startsWith('(') && expr.endsWith(')')) { + expr = expr.slice(1, -1) + } + jsExprParts.push(`"${key}": ${expr}`) + } else { + const val = + transform?.type === 'static' ? transform.value : transform?.value + plainValues[key] = val + // For JS object construction + jsExprParts.push(`"${key}": ${JSON.stringify(val)}`) + } + }) + + if (hasJavascriptField) { + // Switch parent to javascript mode with full object expression + // BUT keep propertyType as 'static' so UI stays in visual mode + arg.type = 'javascript' + arg.expr = `({\n ${jsExprParts.join(',\n ')}\n})` + // Keep arg.value for visual editing + arg.value = plainValues + } else { + // All fields are static, keep as static object + arg.type = 'static' + arg.value = plainValues + arg.expr = undefined + } + dispatch('change', { argName, arg }) + } + } + {extraLib} + {previousModuleId} + {pickableProperties} + {enableAi} + {otherArgs} + {isAgentTool} + {s3StorageConfigured} + /> + {/if} +
+ {:else if schemaProperty.type === 'array' && schemaProperty.items?.type === 'object' && schemaProperty.items?.properties} + +
+ {#if Array.isArray(arg.value)} + {#each arg.value as item, i} +
+ + {@const itemArgs = Object.fromEntries( + Object.keys(schemaProperty.items.properties).map((key) => { + const val = item?.[key] + if ( + val && + typeof val === 'object' && + ('type' in val || 'expr' in val) + ) { + return [key, val] + } + return [key, { type: 'static', value: val }] + }) + )} + itemArgs, + (v) => { + // Check if any field in this array item uses javascript + let hasJavascriptField = false + const plainValues = {} + const jsExprParts = [] + + Object.keys(v ?? {}).forEach((key) => { + const transform = v[key] + if (transform?.type === 'javascript') { + hasJavascriptField = true + let expr = transform.expr + if (expr.startsWith('(') && expr.endsWith(')')) { + expr = expr.slice(1, -1) + } + jsExprParts.push(`"${key}": ${expr}`) + } else { + const val = + transform?.type === 'static' + ? transform.value + : transform?.value + plainValues[key] = val + jsExprParts.push(`"${key}": ${JSON.stringify(val)}`) + } + }) + + // Update the array item + arg.value[i] = plainValues + + // Check if ANY item in the array has javascript + let arrayHasJavascript = hasJavascriptField + if (!arrayHasJavascript && Array.isArray(arg.value)) { + // Check other items for javascript expressions stored as strings + arrayHasJavascript = arg.value.some((item, idx) => { + if (idx === i) return false + return Object.values(item ?? {}).some( + (val) => + typeof val === 'string' && + (val.includes('${') || + val.startsWith('`') || + val.includes('results.')) + ) + }) + } + + if (arrayHasJavascript) { + // Convert entire array to javascript mode + // BUT keep UI in visual mode by preserving arg.value + const arrayExprParts = arg.value.map((item, idx) => { + if (idx === i && hasJavascriptField) { + return `{\n ${jsExprParts.join(',\n ')}\n }` + } else { + const itemParts = Object.keys(item ?? {}).map((key) => { + return `"${key}": ${JSON.stringify(item[key])}` + }) + return `{\n ${itemParts.join(',\n ')}\n }` + } + }) + arg.type = 'javascript' + arg.expr = `([\n ${arrayExprParts.join(',\n ')}\n])` + // Keep arg.value for visual editing - don't set to undefined! + } + + dispatch('change', { argName, arg }) + } + } + {extraLib} + {previousModuleId} + {pickableProperties} + {enableAi} + {otherArgs} + {isAgentTool} + {s3StorageConfigured} + /> +
+ {/each} + {/if} + +
+ {/if} + {:else} + + { + focused = false + }} + shouldDispatchChanges + on:change={() => { + dispatch('change', { argName, arg }) + }} + label={argName} + bind:editor={monaco} + bind:description={schema.properties[argName].description} + bind:value={arg.value} + type={schema.properties[argName].type} + oneOf={schema.properties[argName].oneOf} + required={schema.required?.includes(argName)} + bind:pattern={schema.properties[argName].pattern} + bind:valid={inputCheck} + defaultValue={schema.properties[argName].default} + bind:enum_={schema.properties[argName].enum} + bind:format={schema.properties[argName].format} + contentEncoding={schema.properties[argName].contentEncoding} + bind:itemsType={schema.properties[argName].items} + properties={schema.properties[argName].properties} + nestedRequired={schema.properties[argName].required} + displayHeader={false} + extra={argExtra} + {variableEditor} + {itemPicker} + bind:pickForField + showSchemaExplorer + nullable={schema.properties[argName].nullable} + bind:title={schema.properties[argName].title} + bind:placeholder={schema.properties[argName].placeholder} + {helperScript} + {s3StorageConfigured} + otherArgs={Object.fromEntries( + Object.entries(otherArgs).map(([key, transform]) => [ + key, + transform?.type === 'static' ? transform.value : transform?.expr + ]) + )} + > + {#snippet innerBottomSnippet()} + {#if shouldShowS3ArrayHelper} + + switchToJsAndConnect((path) => appendPathToArrayExpr(arg.expr, path))} + /> + {/if} + {/snippet} + + {/if} {:else if arg.expr != undefined}
+ import { createBubbler } from 'svelte/legacy' + + const bubble = createBubbler() + import type { Schema } from '$lib/common' + import { allTrue, computeShow } from '$lib/utils' + import { createEventDispatcher, untrack } from 'svelte' + import { deepEqual } from 'fast-equals' + import { dragHandleZone, type Options as DndOptions } from '@windmill-labs/svelte-dnd-action' + import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte' + import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte' + import { twMerge } from 'tailwind-merge' + import type { InputTransform } from '$lib/gen' + import InputTransformForm from './InputTransformForm.svelte' + import type { PickableProperties } from './flows/previousResults' + + interface Props { + schema: Schema | any + hiddenArgs?: string[] + args?: Record + disabled?: boolean + isValid?: boolean + autofocus?: boolean + disablePortal?: boolean + dndConfig?: DndOptions | undefined + items?: { id: string; value: string }[] | undefined + diff?: Record + nestedParent?: { label: string; nestedParent: any | undefined } | undefined + shouldDispatchChanges?: boolean + nestedClasses?: string + largeGap?: boolean + className?: string + extraLib?: string + previousModuleId: string | undefined + pickableProperties?: PickableProperties | undefined + enableAi?: boolean + otherArgs?: Record + isAgentTool?: boolean + s3StorageConfigured?: boolean + } + + let { + schema = $bindable(), + hiddenArgs = [], + args = $bindable(undefined), + disabled = false, + isValid = $bindable(true), + autofocus = false, + disablePortal = false, + dndConfig = undefined, + items = undefined, + diff = {}, + nestedParent = undefined, + shouldDispatchChanges = false, + nestedClasses = '', + largeGap = false, + className = '', + extraLib = $bindable('missing extraLib'), + previousModuleId, + pickableProperties = undefined, + enableAi = false, + otherArgs = {}, + isAgentTool = false, + s3StorageConfigured = true + }: Props = $props() + + const dispatch = createEventDispatcher() + + let inputCheck: { [id: string]: boolean } = $state({}) + + let keys: string[] = $state([]) + + function removeExtraKey() { + const nargs = {} + Object.keys(args ?? {}).forEach((key) => { + if (keys.includes(key) && args) { + nargs[key] = args[key] + } + }) + args = nargs + } + + function hasExtraKeys() { + return Object.keys(args ?? {}).some((x) => !keys.includes(x)) + } + + function reorder() { + let lkeys = Object.keys(schema?.properties ?? {}) + if (!deepEqual(schema?.order, lkeys) || !deepEqual(keys, lkeys)) { + if (schema?.order && Array.isArray(schema.order)) { + const n = {} + ;(schema.order as string[]).forEach((x) => { + if (schema.properties && schema.properties[x] != undefined) { + n[x] = schema.properties[x] + } + }) + Object.keys(schema.properties ?? {}) + .filter((x) => !schema.order?.includes(x)) + .forEach((x) => { + n[x] = schema.properties[x] + }) + if ( + !deepEqual(schema.properties, n) || + !deepEqual(Object.keys(schema.properties), Object.keys(n)) + ) { + schema.properties = n + } + } + let nkeys = Object.keys(schema.properties ?? {}) + + if (!deepEqual(keys, nkeys)) { + keys = nkeys + dispatch('change') + } + } + + if (hasExtraKeys()) { + removeExtraKey() + } + } + + let hidden: Record = $state({}) + let fields = $derived(items ?? keys.map((x) => ({ id: x, value: x }))) + + function handleHiddenFields(schema: Schema | any, args: Record) { + for (const x of fields) { + if (schema?.properties?.[x.value]?.showExpr) { + // For InputTransform, we need to check the actual value + const argValue = + args[x.value]?.type === 'static' ? args[x.value]?.value : args[x.value]?.expr + const contextArgs = {} + Object.keys(args ?? {}).forEach((key) => { + const arg = args[key] + contextArgs[key] = arg?.type === 'static' ? arg?.value : arg?.expr + }) + + if (computeShow(x.value, schema.properties?.[x.value]?.showExpr, contextArgs)) { + hidden[x.value] = false + } else if (!hidden[x.value]) { + hidden[x.value] = true + // remove arg + delete args[x.value] + // make sure it's made valid + inputCheck[x.value] = true + } + } + } + } + + $effect.pre(() => { + if (args == undefined || typeof args !== 'object') { + args = {} + } + }) + + $effect.pre(() => { + schema?.order + Object.keys(schema?.properties ?? {}) + schema && (untrack(() => reorder()), (hidden = {})) + }) + + $effect.pre(() => { + ;[schema, args] + + if (args && typeof args == 'object') { + let oneShowExpr = false + for (const key of fields) { + if (schema?.properties?.[key.value]?.showExpr) { + oneShowExpr = true + } + } + if (!oneShowExpr) { + return + } + for (const key in args) { + args[key] + } + } + untrack(() => handleHiddenFields(schema, args ?? {})) + }) + + $effect.pre(() => { + isValid = allTrue(inputCheck ?? {}) + }) + + +
+ {#if keys.length > 0 && args} + {#each fields as item, i (item.id)} + {@const argName = item.value} + + {#if !hiddenArgs.includes(argName) && keys.includes(argName)} + +
{ + dispatch('click', argName) + }} + > + {#if args && typeof args == 'object' && schema?.properties[argName]} + {#if !hidden[argName]} + { + dispatch('change') + }} + {schema} + bind:arg={args[argName]} + {argName} + {extraLib} + bind:inputCheck={inputCheck[argName]} + {previousModuleId} + {pickableProperties} + {enableAi} + {otherArgs} + {isAgentTool} + {s3StorageConfigured} + /> + {/if} + {/if} +
+ {/if} +
+ {/each} + {:else} +
No inputs
+ {/if} +