feat: dynamic select in flow (#6315)

* update

* add dyn select for flows

* nits

* fix typo

* done

* update .sqlx

* better

* nits

* fix

* nits

* nits

* fix

* improvements

* update

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
dieriba
2025-08-06 07:20:03 +00:00
committed by GitHub
co-authored by Ruben Fiszel
parent 59f39d860a
commit 2a20329c4b
20 changed files with 461 additions and 160 deletions
+4 -7
View File
@@ -7,7 +7,8 @@
setInputCat as computeInputCat,
debounce,
emptyString,
getSchemaFromProperties
getSchemaFromProperties,
type DynamicSelect
} from '$lib/utils'
import { DollarSign, Plus, X, Check, Loader2, ExternalLink } from 'lucide-svelte'
import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte'
@@ -35,7 +36,6 @@
import SchemaForm from './SchemaForm.svelte'
import { deepEqual } from 'fast-equals'
import DynSelect from './DynSelect.svelte'
import type { Script } from '$lib/gen'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte'
import type { ComponentCustomCSS } from './apps/types'
import MultiSelect from './select/MultiSelect.svelte'
@@ -94,10 +94,7 @@
orderEditable?: boolean
shouldDispatchChanges?: boolean
noDefaultOnSelectFirst?: boolean
helperScript?:
| { type: 'inline'; path?: string; lang: Script['language']; code: string }
| { type: 'hash'; hash: string }
| undefined
helperScript?: DynamicSelect.HelperScript
otherArgs?: Record<string, any>
lightHeader?: boolean
diffStatus?: SchemaDiff | undefined
@@ -866,7 +863,7 @@
{:else if inputCat == 'dynselect'}
<DynSelect
name={label}
args={otherArgs}
{otherArgs}
{helperScript}
bind:value
entrypoint={format?.substring('dynselect_'.length) ?? ''}
+43 -26
View File
@@ -15,33 +15,31 @@
</script>
<script lang="ts">
import type { Script } from '$lib/gen'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { deepEqual } from 'fast-equals'
import JobLoader, { type Callbacks } from './JobLoader.svelte'
import Select from './select/Select.svelte'
import Tooltip from './Tooltip.svelte'
import { Loader2 } from 'lucide-svelte'
import { type DynamicSelect } from '$lib/utils'
import { deepEqual } from 'fast-equals'
import { untrack } from 'svelte'
import { readFieldsRecursively } from '$lib/utils'
interface Props {
value?: any
helperScript?:
| { type: 'inline'; path?: string; lang: Script['language']; code: string }
| { type: 'hash'; hash: string }
helperScript?: DynamicSelect.HelperScript
entrypoint: string
args?: Record<string, any>
otherArgs?: Record<string, any>
name: string
}
let { value = $bindable(), helperScript, entrypoint, args: _args, name }: Props = $props()
let { value = $bindable(), helperScript, entrypoint, otherArgs: otherArgs }: Props = $props()
let args = $state(structuredClone($state.snapshot(_args)))
$effect(() => {
readFieldsRecursively(_args, { excludeField: [name] })
untrack(() => !deepEqual(args, _args) && (args = $state.snapshot(_args)))
})
let resultJobLoader: JobLoader | undefined = $state()
let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false })
let items = $derived(_items.value)
let filterText: string = $state('')
let open: boolean = $state(false)
async function getItemsFromOptions() {
return new Promise<{ label: string; value: any }[]>((resolve, reject) => {
@@ -61,15 +59,14 @@
return
}
if (result.length == 0) resolve([])
if (result.every((x) => typeof x == 'string')) {
result = result.map((x) => ({ label: x, value: x }))
} else if (result.find((x) => validSelectObject(x) != undefined)) {
reject(validSelectObject(result.find((x) => validSelectObject(x) != undefined)))
} else {
if (filterText != undefined && filterText != '')
result = result.filter((x) => x['label'].includes(filterText))
resolve(result)
return
}
resolve(result)
},
cancel: () => reject(),
doneError({ id, error }) {
@@ -81,7 +78,7 @@
helperScript?.path ?? 'NO_PATH',
helperScript.code,
helperScript.lang,
{ ...args, filterText, _ENTRYPOINT_OVERRIDE: entrypoint },
{ ...otherArgs, filterText, _ENTRYPOINT_OVERRIDE: entrypoint },
undefined,
undefined,
undefined,
@@ -89,22 +86,42 @@
)
: resultJobLoader?.runScriptByHash(
helperScript?.hash ?? 'NO_HASH',
{ ...args, filterText, _ENTRYPOINT_OVERRIDE: entrypoint },
{ ...otherArgs, filterText, _ENTRYPOINT_OVERRIDE: entrypoint },
cb
)
})
}
let _items = usePromise(getItemsFromOptions)
let items = $derived(_items.value)
let neverLoaded = $state(true)
$effect(() => {
;[args, name, entrypoint, helperScript, filterText]
untrack(() => _items.refresh())
if (_items.value && value) {
if (!_items.value.find((x) => x.value == value)) {
value = undefined
}
}
})
let resultJobLoader: JobLoader | undefined = $state()
let filterText: string = $state('')
let open: boolean = $state(false)
let lastArgs = $state.snapshot(otherArgs)
let timeout: NodeJS.Timeout | undefined = $state()
let nargs = $state($state.snapshot(otherArgs))
$effect(() => {
otherArgs
untrack(() => clearTimeout(timeout))
timeout = setTimeout(() => {
nargs = $state.snapshot(otherArgs)
}, 1000)
})
$effect(() => {
;[filterText, entrypoint, helperScript]
if (resultJobLoader && (open || neverLoaded || !deepEqual(lastArgs, nargs))) {
neverLoaded = false
lastArgs = $state.snapshot(otherArgs)
_items.refresh()
}
})
</script>
{#if helperScript}
@@ -3,7 +3,7 @@
const bubble = createBubbler()
import type { Schema } from '$lib/common'
import { VariableService } from '$lib/gen'
import { VariableService, type ScriptLang } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import ItemPicker from './ItemPicker.svelte'
@@ -20,13 +20,15 @@
import Label from './Label.svelte'
import { sendUserToast } from '$lib/toast'
import Toggle from './Toggle.svelte'
import { emptyString } from '$lib/utils'
import { DynamicSelect, emptyString } from '$lib/utils'
import Popover from './meltComponents/Popover.svelte'
import SchemaFormDnd from './schema/SchemaFormDND.svelte'
import { deepEqual } from 'fast-equals'
import { tweened } from 'svelte/motion'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte'
import type { EditableSchemaFormUi } from '$lib/components/custom_ui'
import Section from '$lib/components/Section.svelte'
import Editor from './Editor.svelte'
// export let openEditTab: () => void = () => {}
const dispatch = createEventDispatcher()
@@ -64,6 +66,9 @@
customUi?: EditableSchemaFormUi | undefined
pannelExtraButtonWidth?: number
class?: string
dynSelectCode?: string | undefined
dynSelectLang?: ScriptLang | undefined
showDynSelectOpt?: boolean
openEditTab?: import('svelte').Snippet
addProperty?: import('svelte').Snippet
runButton?: import('svelte').Snippet
@@ -96,6 +101,9 @@
customUi = undefined,
pannelExtraButtonWidth = 0,
class: clazz = '',
dynSelectCode = $bindable(),
dynSelectLang = $bindable(),
showDynSelectOpt = false,
openEditTab,
addProperty,
runButton,
@@ -106,6 +114,24 @@
if (args == undefined) {
args = {}
}
if (dynSelectLang === undefined) {
dynSelectLang = schema?.['x-windmill-dyn-select-lang'] || 'bun'
}
if (dynSelectCode === undefined) {
dynSelectCode = schema?.['x-windmill-dyn-select-code'] || ''
}
})
$effect(() => {
if (schema && dynSelectCode !== undefined && dynSelectLang !== undefined) {
if (dynSelectCode && dynSelectCode.trim()) {
schema['x-windmill-dyn-select-code'] = dynSelectCode.trim()
schema['x-windmill-dyn-select-lang'] = dynSelectLang
} else {
delete schema['x-windmill-dyn-select-code']
delete schema['x-windmill-dyn-select-lang']
}
}
})
export function setDefaults() {
@@ -183,9 +209,11 @@
? property.type
: property.format === 'resource-s3_object'
? 'S3'
: property.oneOf && property.oneOf.length >= 2
? 'oneOf'
: 'object'
: property.format?.startsWith('dynselect-')
? 'dynselect'
: property.oneOf && property.oneOf.length >= 2
? 'oneOf'
: 'object'
}
export function openField(key: string) {
@@ -248,7 +276,7 @@
let schemaString: string = $state(JSON.stringify(schema, null, '\t'))
let error: string | undefined = $state(undefined)
let editor: SimpleEditor | undefined = $state(undefined)
let dynamicSelectEditor: Editor | undefined = $state(undefined)
export function updateJson() {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
@@ -297,6 +325,45 @@
$effect(() => {
!!editTab ? openEditTabFn() : closeEditTab()
})
let dynSelectFunctions = $derived(
Object.entries(schema?.properties ?? {})
.filter(([_, property]) => {
const props = property as any
return (
props.type === 'object' &&
(props.format?.startsWith('dynselect-') || props.format?.startsWith('dynselect_'))
)
})
.map(([fieldName, _]) => fieldName.replace(/\s+/g, '_'))
)
let typeOptions = [
['String', 'string'],
['Number', 'number'],
['Integer', 'integer'],
['Object', 'object'],
['OneOf', 'oneOf'],
['Array', 'array'],
['Boolean', 'boolean'],
['S3 Object', 'S3']
]
if (showDynSelectOpt) {
typeOptions.push(['DynSelect', 'dynselect'])
}
function initDynSelectFn(lang: ScriptLang) {
const generateFn = DynamicSelect.getGenerateTemplateFn(lang)
return Object.entries(schema?.properties ?? {})
.map(([functionName]) => generateFn(functionName))
.join('')
}
function updateDynSelectCode(functionName: string, lang: ScriptLang = 'bun') {
const generateFn = DynamicSelect.getGenerateTemplateFn(lang)
const code = generateFn(functionName)
dynSelectCode = dynSelectCode ? dynSelectCode.concat(code) : code
}
</script>
<div class="w-full h-full">
@@ -355,6 +422,11 @@
}
tick().then(() => dispatch('change', schema))
}}
helperScript={{
type: 'inline',
code: dynSelectCode!,
lang: dynSelectLang!
}}
prettifyHeader={isAppInput}
disabled={!!previewSchema}
{diff}
@@ -369,6 +441,57 @@
/>
{@render runButton?.()}
<div class="h-full">
{#if dynSelectFunctions.length > 0}
<Section
label="Dynamic select functions"
collapsable={true}
collapsed={false}
class="text-sm"
>
<div class="flex flex-col gap-2 h-full">
{#if dynSelectFunctions.length > 0}
<div class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-md">
<div class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
Expected Functions for Dynamic Select Fields:
</div>
<ul class="text-xs text-blue-700 dark:text-blue-300 space-y-1">
{#each dynSelectFunctions as functionName}
<li class="font-mono bg-blue-100 dark:bg-blue-800/30 px-2 py-1 rounded">
{functionName}()
</li>
{/each}
</ul>
</div>
{/if}
<ToggleButtonGroup
bind:selected={dynSelectLang}
on:selected={({ detail }) => {
dynSelectCode = initDynSelectFn(detail)
}}
>
{#snippet children({ item })}
<ToggleButton value="bun" label="Typescript (Bun)" {item} />
<ToggleButton value="python3" label="Python" {item} />
{/snippet}
</ToggleButtonGroup>
{#key dynSelectLang}
<div class="border w-full h-full">
<Editor
bind:this={dynamicSelectEditor}
class="flex flex-1 grow h-80 w-full"
scriptLang={dynSelectLang}
useWebsockets={false}
automaticLayout
bind:code={dynSelectCode}
/>
</div>
{/key}
</div>
</Section>
{/if}
</div>
</div>
</div>
</Pane>
@@ -530,6 +653,7 @@
(v) => {
const isS3 = v == 'S3'
const isOneOf = v == 'oneOf'
const isDynSelect = v == 'dynselect'
const emptyProperty = {
contentEncoding: undefined,
@@ -555,6 +679,15 @@
type: 'object',
format: 'resource-s3_object'
}
} else if (isDynSelect) {
const functionName = argName.replace(/\s+/g, '_')
schema.properties[argName] = {
...emptyProperty,
type: 'object',
format: 'dynselect-' + functionName
}
updateDynSelectCode(argName, dynSelectLang)
dynamicSelectEditor?.setCode(dynSelectCode || '')
} else if (isOneOf) {
schema.properties[argName] = {
...emptyProperty,
@@ -604,7 +737,7 @@
}}
>
{#snippet children({ item })}
{#each [['String', 'string'], ['Number', 'number'], ['Integer', 'integer'], ['Object', 'object'], ['OneOf', 'oneOf'], ['Array', 'array'], ['Boolean', 'boolean'], ['S3 Object', 'S3']] as x}
{#each typeOptions as x}
<ToggleButton value={x[1]} label={x[0]} {item} />
{/each}
{/snippet}
@@ -1,7 +1,7 @@
<script lang="ts">
import { stopPropagation } from 'svelte/legacy'
import { type Job, JobService, type RestartedFrom, type OpenFlow } from '$lib/gen'
import { type Job, JobService, type RestartedFrom, type OpenFlow, type ScriptLang } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Badge, Button } from './common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -504,6 +504,14 @@
savedArgs = previewArgs.val
}}
bind:isValid
helperScript={flowStore.val.schema?.['x-windmill-dyn-select-code'] &&
flowStore.val.schema?.['x-windmill-dyn-select-lang']
? {
type: 'inline',
code: flowStore.val.schema['x-windmill-dyn-select-code'] as string,
lang: flowStore.val.schema['x-windmill-dyn-select-lang'] as ScriptLang
}
: undefined}
/>
</div>
{/key}
@@ -571,7 +579,7 @@
bind:rightColumnSelect
bind:isOwner
{render}
customUi={customUi}
{customUi}
/>
{:else}
<div class="italic text-tertiary h-full grow"> Flow status will be displayed here </div>
+9 -1
View File
@@ -21,6 +21,7 @@
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import { untrack } from 'svelte'
import { type ScriptLang } from '$lib/gen'
let reloadArgs = $state(0)
let jsonEditor: JsonInputs | undefined = $state(undefined)
@@ -261,7 +262,14 @@
type: 'hash',
hash: runnable.hash
}
: undefined}
: runnable.schema?.['x-windmill-dyn-select-code'] &&
runnable.schema?.['x-windmill-dyn-select-lang']
? {
type: 'inline',
code: runnable.schema['x-windmill-dyn-select-code'] as string,
lang: runnable.schema['x-windmill-dyn-select-lang'] as ScriptLang
}
: undefined}
prettifyHeader
{noVariablePicker}
{autofocus}
@@ -3,9 +3,9 @@
const bubble = createBubbler()
import type { Schema } from '$lib/common'
import { VariableService, type Script } from '$lib/gen'
import { VariableService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue, computeShow } from '$lib/utils'
import { allTrue, computeShow, type DynamicSelect } from '$lib/utils'
import { Button } from './common'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
@@ -44,10 +44,7 @@
onlyMaskPassword?: boolean
dndConfig?: DndOptions | undefined
items?: { id: string; value: string }[] | undefined
helperScript?:
| { type: 'inline'; path?: string; lang: Script['language']; code: string }
| { type: 'hash'; hash: string }
| undefined
helperScript?: DynamicSelect.HelperScript
lightHeader?: boolean
diff?: Record<string, SchemaDiff>
nestedParent?: { label: string; nestedParent: any | undefined } | undefined
@@ -321,7 +318,7 @@
title={formerProperty?.title}
placeholder={formerProperty?.placeholder}
orderEditable={dndConfig != undefined}
otherArgs={args}
otherArgs={{ ...args, [argName]: undefined }}
{helperScript}
{lightHeader}
hideNested={typeof diff[argName].diff === 'object'}
@@ -394,7 +391,7 @@
title={schema.properties[argName].title}
placeholder={schema.properties[argName].placeholder}
orderEditable={dndConfig != undefined}
otherArgs={args}
otherArgs={{ ...args, [argName]: undefined }}
{helperScript}
{lightHeader}
diffStatus={diff[argName] ?? undefined}
@@ -42,6 +42,7 @@
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import { isObjectTooBig } from '$lib/utils'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { ScriptLang } from '$lib/gen'
interface Props {
noEditor: boolean
@@ -50,7 +51,6 @@
}
let { noEditor, disabled, onTestFlow }: Props = $props()
const {
flowStore,
previewArgs,
@@ -76,6 +76,8 @@
let editableSchemaForm: EditableSchemaForm | undefined = $state(undefined)
let savedPreviewArgs: Record<string, any> | undefined = $state(undefined)
let isValid = $state(true)
let dynSelectCode: string | undefined = $state(undefined)
let dynSelectLang: ScriptLang | undefined = $state(undefined)
function updateEditPanelSize(size: number | undefined) {
if (!$flowInputEditorState) return
@@ -366,6 +368,7 @@
on:delete={(e) => {
addPropertyV2?.handleDeleteArgument([e.detail])
}}
showDynSelectOpt
displayWebhookWarning
editTab={$flowInputEditorState?.selectedTab}
{previewSchema}
@@ -406,6 +409,8 @@
resetArgs()
}}
bind:isValid
bind:dynSelectCode
bind:dynSelectLang
>
{#snippet openEditTab()}
<div class={twMerge('flex flex-row divide-x', ButtonType.ColorVariants.blue.divider)}>
@@ -124,6 +124,7 @@
let initialObjectSelected = $state(
Object.keys(properties ?? {}).length == 0 ? 'resource' : 'custom-object'
)
let isDynSelect = $derived(format?.startsWith('dynselect-') ?? false)
</script>
<div class="flex flex-col gap-2">
@@ -284,7 +285,7 @@
}}
/>
{/if}
{:else if type === 'object' && format !== 'resource-s3_object'}
{:else if type === 'object' && format !== 'resource-s3_object' && !isDynSelect}
<Tabs
bind:selected={initialObjectSelected}
on:selected={(e) => {
@@ -325,7 +326,7 @@
</Tabs>
{/if}
{#if !(type === 'object' && oneOf && oneOf.length >= 2) && !(type == 'object' && initialObjectSelected == 'custom-object')}
{#if !(type === 'object' && oneOf && oneOf.length >= 2) && !(type == 'object' && initialObjectSelected == 'custom-object') && !isDynSelect}
<Label label="Default">
<ArgInput
noDefaultOnSelectFirst
@@ -265,8 +265,6 @@
/>
</div>
{/if}
{:else if type == 'object' && format?.startsWith('dynselect-')}
<div class="text-tertiary text-xs">No settings available for Dynamic Select</div>
{:else if type == 'object' && !format?.startsWith('resource-') && !isFlowInput && !isAppInput}
<div class="border">
<EditableSchemaForm
@@ -6,6 +6,7 @@
import type { Schema } from '$lib/common'
import { deepEqual } from 'fast-equals'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte'
import type { DynamicSelect } from '$lib/utils'
interface Props {
dndType?: string | undefined
schema: Schema
@@ -22,6 +23,7 @@
nestedClasses?: string
isValid?: boolean
noVariablePicker?: boolean
helperScript?: DynamicSelect.HelperScript
}
let {
@@ -36,6 +38,7 @@
nestedParent = undefined,
disableDnd = false,
shouldDispatchChanges = false,
helperScript = undefined,
diff = {},
nestedClasses = '',
isValid = $bindable(true),
@@ -114,6 +117,7 @@
{onlyMaskPassword}
{disablePortal}
{disabled}
{helperScript}
bind:schema
dndConfig={disableDnd
? undefined
@@ -60,7 +60,7 @@
onCreateItem?: (value: string) => void
} = $props()
let disabled = $derived(_disabled || loading)
let disabled = $derived(_disabled || (loading && !value))
let inputEl: HTMLInputElement | undefined = $state()
@@ -121,12 +121,14 @@
{disabled}
type="text"
bind:value={() => filterText, (v) => (filterText = v)}
placeholder={loading ? 'Loading...' : (valueEntry?.label ?? getLabel({ value }) ?? placeholder)}
placeholder={loading && !value
? 'Loading...'
: (valueEntry?.label ?? getLabel({ value }) ?? placeholder)}
style={containerStyle}
class={twMerge(
'!bg-surface text-ellipsis',
open ? '' : 'cursor-pointer',
value && !loading ? '!placeholder-primary' : '',
!loading ? '!placeholder-primary' : '',
(clearable || RightIcon) && !disabled && value ? '!pr-8' : '',
inputClass ?? ''
)}
+53 -9
View File
@@ -172,9 +172,9 @@ export function displayDate(
}
const dateChoices: Intl.DateTimeFormatOptions = displayDate
? {
day: 'numeric',
month: 'numeric'
}
day: 'numeric',
month: 'numeric'
}
: {}
return date.toLocaleString(undefined, {
...timeChoices,
@@ -593,6 +593,47 @@ export type InputCat =
| 'oneOf'
| 'dynselect'
export namespace DynamicSelect {
export type HelperScript =
| { type: 'inline'; path?: string; lang: Script['language']; code: string }
| { type: 'hash'; hash: string }
export const generatePythonFnTemplate = (functionName: string): string => {
return `
def ${functionName}():
return [
{ "label": "Foo", "value": "foo" },
{ "label": "Bar", "value": "bar" }
]
`
}
export const generateJsFnTemplate = (functionName: string): string => {
return `
// you can use filterText to filter the results from the backend
// you can refer to other args directly as parameters (e.g. foobar: string)
export function ${functionName}(filterText: string) {
return [
{ label: 'Foo', value: 'foo' },
{ label: 'Bar', value: 'bar' }
];
}
`
}
export const generateDefaultTemplateFn = (functionName: string, lang: ScriptLang): string => {
return lang === 'bun'
? generateJsFnTemplate(functionName)
: generatePythonFnTemplate(functionName)
}
export const getGenerateTemplateFn = (lang: ScriptLang) => {
return lang === 'bun' ? generateJsFnTemplate : generatePythonFnTemplate
}
}
export function setInputCat(
type: string | undefined,
format: string | undefined,
@@ -608,7 +649,10 @@ export function setInputCat(
return 'list'
} else if (type == 'object' && format?.startsWith('resource')) {
return 'resource-object'
} else if (type == 'object' && format?.startsWith('dynselect-')) {
} else if (
type == 'object' &&
(format?.startsWith('dynselect-') || format?.startsWith('dynselect_'))
) {
return 'dynselect'
} else if (!type || type == 'object' || type == 'array') {
return 'object'
@@ -981,7 +1025,7 @@ export async function tryEvery({
try {
await tryCode()
break
} catch (err) {}
} catch (err) { }
i++
}
if (i >= times) {
@@ -1248,7 +1292,7 @@ export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | un
if (meltItem) {
return meltItem(node)
}
return { destroy: () => {} }
return { destroy: () => { } }
}
export type Item = {
@@ -1453,9 +1497,9 @@ export type S3Uri = `s3://${string}/${string}`
export type S3Object =
| S3Uri
| {
s3: string
storage?: string
}
s3: string
storage?: string
}
export function parseS3Object(s3Object: S3Object): { s3: string; storage?: string } {
if (typeof s3Object === 'object') return s3Object