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 09:20:03 +02:00
committed by GitHub
parent 59f39d860a
commit 2a20329c4b
20 changed files with 461 additions and 160 deletions
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10, $11::text::json, $12, now()\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Text",
"Bool",
"Varchar",
"Bool",
"Bool",
"Text",
"Jsonb",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "081dc94a7d0fdaade77cfb593a025d8c48d7eab3dbb30ca0b43fb1ef45d8d8bd"
}
@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE flow SET path = $1, summary = $2, description = $3,dependency_job = NULL, lock_error_logs = '', draft_only = NULL, tag = $4, dedicated_worker = $5, visible_to_runner_only = $6, on_behalf_of_email = $7, value = $8, schema = $9::text::json, edited_by = $10, edited_at = now()\n WHERE path = $11 AND workspace_id = $12",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Varchar",
"Bool",
"Bool",
"Text",
"Jsonb",
"Text",
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "2b9607ed838c8c62eb0f2856420389f7be648f52edbb875ff52c96219ed3ba84"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n flow \n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now()\n WHERE \n path = $11 AND workspace_id = $12",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Varchar",
"Bool",
"Bool",
"Text",
"Jsonb",
"Text",
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772"
}
@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow (workspace_id, path, summary, description, dependency_job, lock_error_logs, draft_only, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, value, schema, edited_by, edited_at) \n VALUES ($1, $2, $3, $4, NULL, '', $5, $6, $7, $8, $9, $10, $11::text::json, $12, now())",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Text",
"Bool",
"Varchar",
"Bool",
"Bool",
"Text",
"Jsonb",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "e4f1ee1568ce3c186b569421c6c8a3039f73d04fc53c67c70e67371f06416ef3"
}
+1
View File
@@ -16732,6 +16732,7 @@ components:
additionalProperties:
type: boolean
FlowMetadata:
type: object
properties:
+125 -39
View File
@@ -409,11 +409,18 @@ async fn create_flow(
check_schedule_conflict(&mut tx, &w_id, &nf.path).await?;
let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok());
sqlx::query!(
"INSERT INTO flow (workspace_id, path, summary, description, \
dependency_job, lock_error_logs, draft_only, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, value, schema, edited_by, edited_at)
VALUES ($1, $2, $3, $4, NULL, '', $5, $6, $7, $8, $9, $10, $11::text::json, $12, now())",
r#"INSERT INTO flow (
workspace_id, path, summary, description,
dependency_job, lock_error_logs, draft_only, tag,
dedicated_worker, visible_to_runner_only, on_behalf_of_email,
value, schema, edited_by, edited_at
) VALUES (
$1, $2, $3, $4,
NULL, '', $5, $6,
$7, $8, $9,
$10, $11::text::json, $12, now()
)"#,
w_id,
nf.path,
nf.summary,
@@ -744,11 +751,27 @@ async fn update_flow(
let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok());
sqlx::query!(
"UPDATE flow SET path = $1, summary = $2, description = $3,\
dependency_job = NULL, lock_error_logs = '', draft_only = NULL, tag = $4, dedicated_worker = $5, visible_to_runner_only = $6, on_behalf_of_email = $7, \
value = $8, schema = $9::text::json, edited_by = $10, edited_at = now()
WHERE path = $11 AND workspace_id = $12",
if is_new_path { flow_path } else { &nf.path }, // if new path, do not rename directly (to avoid flow_version foreign key constraint)
"
UPDATE
flow
SET
path = $1,
summary = $2,
description = $3,
dependency_job = NULL,
lock_error_logs = '',
draft_only = NULL,
tag = $4,
dedicated_worker = $5,
visible_to_runner_only = $6,
on_behalf_of_email = $7,
value = $8,
schema = $9::text::json,
edited_by = $10,
edited_at = now()
WHERE
path = $11 AND workspace_id = $12",
if is_new_path { flow_path } else { &nf.path },
nf.summary,
nf.description.unwrap_or_else(String::new),
nf.tag,
@@ -761,12 +784,15 @@ async fn update_flow(
},
nf.value,
schema_str,
&authed.username,
authed.username,
flow_path,
w_id,
)
.execute(&mut *tx)
.await.map_err(|e| error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}")))?;
.await
.map_err(|e| {
error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}"))
})?;
if is_new_path {
// if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint
@@ -1047,36 +1073,80 @@ async fn get_flow_by_path(
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let flow_o = if query.with_starred_info.unwrap_or(false) {
sqlx::query_as::<_, FlowWithStarred>(
"SELECT flow.workspace_id, flow.path, flow.lock_error_logs, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by, favorite.path IS NOT NULL as starred
FROM flow
LEFT JOIN favorite
r#"
SELECT
flow.workspace_id,
flow.path,
flow.lock_error_logs,
flow.summary,
flow.description,
flow.archived,
flow.extra_perms,
flow.draft_only,
flow.dedicated_worker,
flow.tag,
flow.ws_error_handler_muted,
flow.timeout,
flow.visible_to_runner_only,
flow.on_behalf_of_email,
flow_version.schema,
flow_version.value,
flow_version.created_at AS edited_at,
flow_version.created_by AS edited_by,
favorite.path IS NOT NULL AS starred
FROM flow
LEFT JOIN favorite
ON favorite.favorite_kind = 'flow'
AND favorite.workspace_id = flow.workspace_id
AND favorite.path = flow.path
AND favorite.usr = $3
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2"
AND favorite.workspace_id = flow.workspace_id
AND favorite.path = flow.path
AND favorite.usr = $3
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2
"#,
)
.bind(path)
.bind(w_id)
.bind(&authed.username)
.fetch_optional(&mut *tx)
.await?
.bind(path)
.bind(w_id)
.bind(&authed.username)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as::<_, FlowWithStarred>(
"SELECT flow.workspace_id, flow.path, flow.lock_error_logs, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by, NULL as starred
FROM flow
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2"
r#"
SELECT
flow.workspace_id,
flow.path,
flow.lock_error_logs,
flow.summary,
flow.description,
flow.archived,
flow.extra_perms,
flow.draft_only,
flow.dedicated_worker,
flow.tag,
flow.ws_error_handler_muted,
flow.timeout,
flow.visible_to_runner_only,
flow.on_behalf_of_email,
flow_version.schema,
flow_version.value,
flow_version.created_at AS edited_at,
flow_version.created_by AS edited_by,
NULL AS starred
FROM flow
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2
"#,
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?
};
tx.commit().await?;
let flow = not_found_if_none(flow_o, "Flow", path)?;
@@ -1115,20 +1185,36 @@ async fn get_flow_by_path_w_draft(
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let flow_o = sqlx::query_as::<_, FlowWDraft>(
"SELECT flow.path, flow.summary, flow.description, flow_version.schema, flow_version.value, flow.extra_perms, flow.draft_only, flow.ws_error_handler_muted, flow.dedicated_worker, draft.value as draft, flow.tag, flow.visible_to_runner_only, flow.on_behalf_of_email
FROM flow
"SELECT
flow.path,
flow.summary,
flow.description,
flow_version.schema,
flow_version.value,
flow.extra_perms,
flow.draft_only,
flow.ws_error_handler_muted,
flow.dedicated_worker,
draft.value AS draft,
flow.tag,
flow.visible_to_runner_only,
flow.on_behalf_of_email
FROM flow
LEFT JOIN draft
ON flow.path = draft.path AND draft.workspace_id = $2 AND draft.typ = 'flow'
LEFT JOIN flow_version
ON flow.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'flow'
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2",
WHERE flow.path = $1
AND flow.workspace_id = $2",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let flow = not_found_if_none(flow_o, "Flow", path)?;
+2 -2
View File
@@ -27,7 +27,7 @@ use crate::{
DB,
};
#[derive(Serialize, Deserialize, sqlx::FromRow)]
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Flow {
pub workspace_id: String,
pub path: String,
@@ -91,7 +91,7 @@ pub struct ListableFlow {
pub deployment_msg: Option<String>,
}
#[derive(Deserialize, sqlx::FromRow)]
#[derive(Debug, Deserialize, sqlx::FromRow)]
pub struct NewFlow {
pub path: String,
pub summary: String,
+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