Merge branch 'main' into glm/fix-flow-layout
This commit is contained in:
24
backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json
generated
Normal file
24
backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env' -> $3\n ELSE\n root_job.raw_flow -> 'flow_env' -> $3\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n (flow_version.value -> 'flow_env' -> $3) #> $4\n ELSE\n (root_job.raw_flow -> 'flow_env' -> $3) #> $4\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154"
|
||||
}
|
||||
@@ -8857,9 +8857,8 @@ paths:
|
||||
type: boolean
|
||||
flow_env:
|
||||
type: object
|
||||
description: Environment variables available to all steps
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
|
||||
additionalProperties: {}
|
||||
priority:
|
||||
type: number
|
||||
description: Execution priority (higher numbers run first)
|
||||
@@ -14644,9 +14643,8 @@ paths:
|
||||
type: boolean
|
||||
flow_env:
|
||||
type: object
|
||||
description: Environment variables available to all steps
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
|
||||
additionalProperties: {}
|
||||
priority:
|
||||
type: number
|
||||
description: Execution priority (higher numbers run first)
|
||||
|
||||
@@ -448,14 +448,15 @@ async fn get_flow_env_by_flow_job_id(
|
||||
Path((w_id, flow_job_id, var_name)): Path<(String, Uuid, String)>,
|
||||
Query(JsonPath { json_path, .. }): Query<JsonPath>,
|
||||
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
|
||||
let flow_env = sqlx::query_scalar!(
|
||||
// Fetch raw value (without json_path) to check for $var:/$res: references
|
||||
let raw_value = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
CASE
|
||||
WHEN flow_version.id IS NOT NULL THEN
|
||||
(flow_version.value -> 'flow_env' -> $3) #> $4
|
||||
flow_version.value -> 'flow_env' -> $3
|
||||
ELSE
|
||||
(root_job.raw_flow -> 'flow_env' -> $3) #> $4
|
||||
root_job.raw_flow -> 'flow_env' -> $3
|
||||
END AS "flow_env: sqlx::types::Json<Box<RawValue>>"
|
||||
FROM
|
||||
v2_job current_job
|
||||
@@ -472,16 +473,86 @@ async fn get_flow_env_by_flow_job_id(
|
||||
flow_job_id,
|
||||
w_id,
|
||||
var_name,
|
||||
json_path
|
||||
.as_ref()
|
||||
.map(|x| x.split(".").collect::<Vec<_>>())
|
||||
.unwrap_or_default() as Vec<&str>,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| r.map(|x| x.0))
|
||||
.flatten()
|
||||
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
|
||||
.and_then(|r| r.map(|x| x.0));
|
||||
|
||||
// Resolve $var:/$res: references if present
|
||||
let resolved = if let Some(raw) = raw_value {
|
||||
let raw_str = raw.get();
|
||||
let db_authed = windmill_common::db::DbWithOptAuthed::<ApiAuthed>::from_authed(
|
||||
&authed,
|
||||
db.clone(),
|
||||
None,
|
||||
);
|
||||
if let Some(path) = raw_str
|
||||
.strip_prefix("\"$var:")
|
||||
.and_then(|s| s.strip_suffix("\""))
|
||||
{
|
||||
match windmill_store::variables::get_value_internal(&db_authed, &w_id, path, false)
|
||||
.await
|
||||
{
|
||||
Ok(val) => to_raw_value(&serde_json::Value::String(val)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to resolve flow_env variable $var:{path}: {e}");
|
||||
raw
|
||||
}
|
||||
}
|
||||
} else if let Some(path) = raw_str
|
||||
.strip_prefix("\"$res:")
|
||||
.and_then(|s| s.strip_suffix("\""))
|
||||
{
|
||||
match windmill_store::resources::get_resource_value_interpolated_internal(
|
||||
&db_authed,
|
||||
&w_id,
|
||||
path,
|
||||
Some(flow_job_id),
|
||||
Some(&tokened.token),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(val)) => to_raw_value(&val),
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"Failed to resolve flow_env resource $res:{path}: resource not found"
|
||||
);
|
||||
raw
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to resolve flow_env resource $res:{path}: {e}");
|
||||
raw
|
||||
}
|
||||
}
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
} else {
|
||||
to_raw_value(&serde_json::Value::Null)
|
||||
};
|
||||
|
||||
// Apply json_path navigation on the (possibly resolved) value
|
||||
let flow_env = if let Some(ref jp) = json_path {
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_str(resolved.get()).unwrap_or(serde_json::Value::Null);
|
||||
for part in jp.split('.') {
|
||||
value = match value {
|
||||
serde_json::Value::Object(ref mut map) => {
|
||||
map.remove(part).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
serde_json::Value::Array(ref arr) => part
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|i| arr.get(i).cloned())
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
_ => serde_json::Value::Null,
|
||||
};
|
||||
}
|
||||
to_raw_value(&value)
|
||||
} else {
|
||||
resolved
|
||||
};
|
||||
|
||||
log_job_view(
|
||||
&db,
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::common::{cached_result_path, get_root_job_id, save_in_cache};
|
||||
use crate::common::{cached_result_path, get_root_job_id, save_in_cache, transform_json};
|
||||
use crate::js_eval::{eval_timeout, IdContext};
|
||||
use crate::worker_utils::get_tag_and_concurrency;
|
||||
use crate::{
|
||||
@@ -53,7 +53,7 @@ use windmill_common::runnable_settings::{
|
||||
use windmill_common::scripts::{ScriptHash, ScriptRunnableSettingsInline};
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::utils::WarnAfterExt;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::worker::{to_raw_value, Connection};
|
||||
use windmill_common::{
|
||||
add_time, get_latest_flow_version_info_for_path, get_script_info_for_hash, FlowVersionInfo,
|
||||
ScriptHashInfo, DB,
|
||||
@@ -2245,6 +2245,35 @@ pub async fn handle_flow(
|
||||
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
||||
) -> anyhow::Result<()> {
|
||||
let flow = flow_data.value();
|
||||
|
||||
// Resolve $var: and $res: references in flow_env.
|
||||
// We resolve into a separate variable to avoid cloning the entire FlowValue
|
||||
// (which includes modules, failure_module, etc.) just to replace flow_env.
|
||||
let resolved_env;
|
||||
let flow_env = if let Some(ref env) = flow.flow_env {
|
||||
match transform_json(
|
||||
client,
|
||||
&flow_job.workspace_id,
|
||||
env,
|
||||
&flow_job,
|
||||
&Connection::Sql(db.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved)) => {
|
||||
resolved_env = resolved;
|
||||
Some(&resolved_env)
|
||||
}
|
||||
Ok(None) => flow.flow_env.as_ref(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to resolve flow_env references: {e}");
|
||||
flow.flow_env.as_ref()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let status = flow_job
|
||||
.parse_flow_status()
|
||||
.with_context(|| "Unable to parse flow status")?;
|
||||
@@ -2348,6 +2377,7 @@ pub async fn handle_flow(
|
||||
flow_job,
|
||||
status,
|
||||
flow,
|
||||
flow_env,
|
||||
db,
|
||||
client,
|
||||
last_result.clone(),
|
||||
@@ -2448,6 +2478,7 @@ async fn push_next_flow_job(
|
||||
flow_job: Arc<MiniPulledJob>,
|
||||
mut status: FlowStatus,
|
||||
flow: &FlowValue,
|
||||
flow_env: Option<&HashMap<String, Box<RawValue>>>,
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
client: &AuthedClient,
|
||||
last_job_result: Option<Arc<Box<RawValue>>>,
|
||||
@@ -2580,7 +2611,7 @@ async fn push_next_flow_job(
|
||||
let skip = compute_bool_from_expr(
|
||||
&skip_expr,
|
||||
arc_flow_job_args.clone(),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
Arc::new(to_raw_value(&json!("{}"))),
|
||||
None,
|
||||
None,
|
||||
@@ -2705,7 +2736,7 @@ async fn push_next_flow_job(
|
||||
expr.to_string(),
|
||||
context,
|
||||
Some(arc_flow_job_args.clone()),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
None,
|
||||
None,
|
||||
None
|
||||
@@ -2966,7 +2997,7 @@ async fn push_next_flow_job(
|
||||
&input_transform,
|
||||
arc_last_job_result.clone(),
|
||||
Some(arc_flow_job_args.clone()),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
Some(client),
|
||||
None,
|
||||
)
|
||||
@@ -3004,7 +3035,7 @@ async fn push_next_flow_job(
|
||||
&status.retry,
|
||||
arc_last_job_result.clone(),
|
||||
arc_flow_job_args.clone(),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
Some(client),
|
||||
)
|
||||
.await?
|
||||
@@ -3092,7 +3123,7 @@ async fn push_next_flow_job(
|
||||
compute_bool_from_expr(
|
||||
&skip_if.expr,
|
||||
arc_flow_job_args.clone(),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
arc_last_job_result.clone(),
|
||||
None,
|
||||
Some(&idcontext),
|
||||
@@ -3182,7 +3213,7 @@ async fn push_next_flow_job(
|
||||
};
|
||||
transform_input(
|
||||
arc_flow_job_args.clone(),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
arc_last_job_result.clone(),
|
||||
input_transforms,
|
||||
resumes.clone(),
|
||||
@@ -3209,7 +3240,7 @@ async fn push_next_flow_job(
|
||||
let next_flow_transform = compute_next_flow_transform(
|
||||
arc_flow_job_args.clone(),
|
||||
arc_last_job_result.clone(),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
&flow_job,
|
||||
&flow,
|
||||
transform_context,
|
||||
@@ -3373,7 +3404,7 @@ async fn push_next_flow_job(
|
||||
let ctx = get_transform_context(&flow_job, "", &status);
|
||||
let ti = transform_input(
|
||||
Marc::new(args),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
arc_last_job_result.clone(),
|
||||
input_transforms,
|
||||
resumes.clone(),
|
||||
@@ -3428,7 +3459,7 @@ async fn push_next_flow_job(
|
||||
let ctx = get_transform_context(&flow_job, &previous_id, &status);
|
||||
let ti = transform_input(
|
||||
Marc::new(hm),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
arc_last_job_result.clone(),
|
||||
input_transforms,
|
||||
resumes.clone(),
|
||||
@@ -3546,7 +3577,7 @@ async fn push_next_flow_job(
|
||||
timeout_transform,
|
||||
arc_last_job_result.clone(),
|
||||
Some(arc_flow_job_args.clone()),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
Some(client),
|
||||
Some(&ctx),
|
||||
)
|
||||
@@ -3625,7 +3656,7 @@ async fn push_next_flow_job(
|
||||
parallelism_transform,
|
||||
arc_last_job_result.clone(),
|
||||
Some(arc_flow_job_args.clone()),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
Some(client),
|
||||
Some(&ctx),
|
||||
)
|
||||
@@ -4461,7 +4492,7 @@ async fn compute_next_flow_transform(
|
||||
let pred = compute_bool_from_expr(
|
||||
&b.expr,
|
||||
arc_flow_job_args.clone(),
|
||||
flow.flow_env.as_ref(),
|
||||
flow_env,
|
||||
arc_last_job_result.clone(),
|
||||
None,
|
||||
Some(&idcontext),
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import { LayoutDashboard, Loader2, Plus, Code2 } from 'lucide-svelte'
|
||||
import { importStore } from '../apps/store'
|
||||
@@ -14,12 +16,21 @@
|
||||
let pendingRaw: string = $state('')
|
||||
|
||||
let importType: 'yaml' | 'json' = $state('yaml')
|
||||
let appKind: 'lowcode' | 'fullcode' = $state('lowcode')
|
||||
|
||||
let appTypeModalOpen = $state(false)
|
||||
|
||||
async function importRaw() {
|
||||
$importStore = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw)
|
||||
await goto('/apps/add?nodraft=true')
|
||||
const parsed = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw)
|
||||
if (appKind === 'fullcode') {
|
||||
// Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation),
|
||||
// so the in-memory importStore would be lost. Use sessionStorage instead.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(parsed))
|
||||
await goto('/apps_raw/add?nodraft=true')
|
||||
} else {
|
||||
$importStore = parsed
|
||||
await goto('/apps/add?nodraft=true')
|
||||
}
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
@@ -51,17 +62,19 @@
|
||||
variant="accent"
|
||||
dropdownItems={[
|
||||
{
|
||||
label: 'Import low-code app from YAML',
|
||||
label: 'Import low-code app',
|
||||
onClick: () => {
|
||||
drawer?.toggleDrawer?.()
|
||||
appKind = 'lowcode'
|
||||
importType = 'yaml'
|
||||
drawer?.toggleDrawer?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Import low-code app from JSON',
|
||||
label: 'Import full-code app',
|
||||
onClick: () => {
|
||||
appKind = 'fullcode'
|
||||
importType = 'yaml'
|
||||
drawer?.toggleDrawer?.()
|
||||
importType = 'json'
|
||||
}
|
||||
}
|
||||
]}
|
||||
@@ -118,22 +131,32 @@
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Raw JSON -->
|
||||
<!-- Import Drawer -->
|
||||
<Drawer bind:this={drawer} size="800px">
|
||||
<DrawerContent
|
||||
title={'Import low-code app from ' + (importType === 'yaml' ? 'YAML' : 'JSON')}
|
||||
title={appKind === 'fullcode' ? 'Import full-code app' : 'Import low-code app'}
|
||||
on:close={() => drawer?.toggleDrawer?.()}
|
||||
>
|
||||
{#await import('$lib/components/SimpleEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:code={pendingRaw}
|
||||
lang={importType}
|
||||
class="h-full"
|
||||
fixedOverflowWidgets={false}
|
||||
/>
|
||||
{/await}
|
||||
<Tabs bind:selected={importType}>
|
||||
<Tab value="yaml" label="YAML" />
|
||||
<Tab value="json" label="JSON" />
|
||||
{#snippet content()}
|
||||
<div class="relative pt-2 h-full">
|
||||
{#key importType}
|
||||
{#await import('$lib/components/SimpleEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:code={pendingRaw}
|
||||
lang={importType}
|
||||
class="h-full"
|
||||
fixedOverflowWidgets={false}
|
||||
/>
|
||||
{/await}
|
||||
{/key}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
{#snippet actions()}
|
||||
<Button size="sm" on:click={importRaw}>Import</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
import { writable } from 'svelte/store'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Plus, Trash2 } from 'lucide-svelte'
|
||||
import { DollarSign, Plus, Trash2 } from 'lucide-svelte'
|
||||
import FlowCard from '../common/FlowCard.svelte'
|
||||
import JsonEditor from '$lib/components/JsonEditor.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import ItemPicker from '$lib/components/ItemPicker.svelte'
|
||||
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
|
||||
import { VariableService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
noEditor: boolean
|
||||
}
|
||||
|
||||
type EnvVarType = 'string' | 'json'
|
||||
type EnvVarType = 'string' | 'json' | 'resource'
|
||||
|
||||
interface EnvVarEntry {
|
||||
id: string
|
||||
@@ -37,6 +41,9 @@
|
||||
|
||||
function determineValueType(value: any): EnvVarType {
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('$res:')) {
|
||||
return 'resource'
|
||||
}
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return value.trim().startsWith('{') ||
|
||||
@@ -53,30 +60,51 @@
|
||||
|
||||
let flowEnvTypes = $state<Record<string, EnvVarType>>({})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'String', value: 'string' as EnvVarType },
|
||||
{ label: 'JSON', value: 'json' as EnvVarType }
|
||||
const typeOptions: { label: string; value: EnvVarType }[] = [
|
||||
{ label: 'String', value: 'string' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: 'Resource', value: 'resource' }
|
||||
]
|
||||
|
||||
// Track resource paths separately for bind:value with ResourcePicker
|
||||
let resourcePaths = $state<Record<string, string | undefined>>({})
|
||||
|
||||
// Initialize resourcePaths from existing flow_env values
|
||||
for (const [key, value] of Object.entries(flowStore.val.value.flow_env || {})) {
|
||||
if (typeof value === 'string' && value.startsWith('$res:')) {
|
||||
resourcePaths[key] = value.substring('$res:'.length)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize types for new keys and sync resourcePaths → flow_env
|
||||
$effect(() => {
|
||||
for (const [key, value] of flowEnvVarsMap.entries()) {
|
||||
if (!flowEnvTypes[key]) {
|
||||
flowEnvTypes[key] = determineValueType(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
for (const [key, type] of Object.entries(flowEnvTypes)) {
|
||||
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
|
||||
const currentType = determineValueType(flowStore.val.value.flow_env[key])
|
||||
if (currentType !== type) {
|
||||
updateEnvType(key, type)
|
||||
for (const [key, path] of Object.entries(resourcePaths)) {
|
||||
if (flowStore.val.value.flow_env && flowEnvTypes[key] === 'resource') {
|
||||
const newVal = '$res:' + (path || '')
|
||||
if (flowStore.val.value.flow_env[key] !== newVal) {
|
||||
flowStore.val.value.flow_env[key] = newVal
|
||||
flowStore.val = flowStore.val
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Convert values when user changes the type dropdown
|
||||
let prevTypes: Record<string, EnvVarType> = {}
|
||||
$effect(() => {
|
||||
for (const [key, type] of Object.entries(flowEnvTypes)) {
|
||||
if (prevTypes[key] && prevTypes[key] !== type) {
|
||||
updateEnvType(key, type)
|
||||
}
|
||||
prevTypes[key] = type
|
||||
}
|
||||
})
|
||||
|
||||
let flowEnvEntries = $derived(
|
||||
Array.from(flowEnvVarsMap.entries()).map(([key, value]): EnvVarEntry => {
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
@@ -113,6 +141,7 @@
|
||||
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
|
||||
delete flowStore.val.value.flow_env[key]
|
||||
delete flowEnvTypes[key]
|
||||
delete resourcePaths[key]
|
||||
flowStore.val = flowStore.val
|
||||
}
|
||||
}
|
||||
@@ -150,32 +179,55 @@
|
||||
flowStore.val.value.flow_env = newEnvVars
|
||||
delete flowEnvTypes[oldKey]
|
||||
flowEnvTypes[newKey] = type
|
||||
|
||||
// Move resource path if applicable
|
||||
if (type === 'resource' && oldKey in resourcePaths) {
|
||||
resourcePaths[newKey] = resourcePaths[oldKey]
|
||||
delete resourcePaths[oldKey]
|
||||
}
|
||||
|
||||
flowStore.val = flowStore.val
|
||||
}
|
||||
}
|
||||
|
||||
function updateEnvType(key: string, newType: EnvVarType) {
|
||||
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
|
||||
const currentValue = flowStore.val.value.flow_env[key]
|
||||
const stringValue =
|
||||
typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2)
|
||||
|
||||
flowEnvTypes[key] = newType
|
||||
|
||||
if (newType === 'json') {
|
||||
try {
|
||||
const parsed = JSON.parse(stringValue)
|
||||
flowStore.val.value.flow_env[key] = parsed
|
||||
} catch {
|
||||
flowStore.val.value.flow_env[key] = stringValue
|
||||
if (newType === 'resource') {
|
||||
flowStore.val.value.flow_env[key] = '$res:'
|
||||
resourcePaths[key] = ''
|
||||
} else if (newType === 'json') {
|
||||
delete resourcePaths[key]
|
||||
const currentValue = flowStore.val.value.flow_env[key]
|
||||
if (typeof currentValue === 'string') {
|
||||
try {
|
||||
flowStore.val.value.flow_env[key] = JSON.parse(currentValue)
|
||||
} catch {
|
||||
// keep as string if not valid JSON
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flowStore.val.value.flow_env[key] = stringValue
|
||||
delete resourcePaths[key]
|
||||
const currentValue = flowStore.val.value.flow_env[key]
|
||||
if (typeof currentValue !== 'string') {
|
||||
flowStore.val.value.flow_env[key] = JSON.stringify(currentValue, null, 2)
|
||||
}
|
||||
}
|
||||
flowStore.val = flowStore.val
|
||||
}
|
||||
}
|
||||
|
||||
function setVarPath(key: string, path: string) {
|
||||
if (flowStore.val.value.flow_env) {
|
||||
flowStore.val.value.flow_env[key] = '$var:' + path
|
||||
flowStore.val = flowStore.val
|
||||
}
|
||||
}
|
||||
|
||||
let variablePicker: ItemPicker | undefined = $state(undefined)
|
||||
let pickForKey: string | undefined = $state(undefined)
|
||||
|
||||
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
|
||||
inputMatches: writable(undefined),
|
||||
connectProp: () => {},
|
||||
@@ -192,8 +244,8 @@
|
||||
Flow envs can be referenced in any flow step input using the syntax{' '}
|
||||
<code>flow_env.VARIABLE_NAME</code> or <code>flow_env["VARIABLE_NAME"]</code>. These
|
||||
variables are available in the property picker and can be used in JavaScript expressions and
|
||||
input bindings. You can choose between String or JSON types for each variable - JSON types
|
||||
allow complex data structures.
|
||||
input bindings. String values can link to workspace variables using the <DollarSign size={12}
|
||||
class="inline" /> button. Resource type references workspace resources resolved at runtime.
|
||||
</Alert>
|
||||
|
||||
{#if flowEnvEntries.length === 0}
|
||||
@@ -246,10 +298,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label class="text-sm font-medium">Value</label>
|
||||
{#if entry.type === 'json'}
|
||||
<Label label="Value">
|
||||
{#if entry.type === 'resource'}
|
||||
<ResourcePicker
|
||||
bind:value={resourcePaths[entry.key]}
|
||||
disabled={noEditor}
|
||||
/>
|
||||
{:else if entry.type === 'json'}
|
||||
<div class="w-full">
|
||||
<JsonEditor
|
||||
bind:code={entry.displayValue}
|
||||
@@ -261,16 +316,43 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
value={entry.displayValue}
|
||||
oninput={(e) => updateEnvValue(entry.key, e.currentTarget.value, 'string')}
|
||||
disabled={noEditor}
|
||||
class="input w-full"
|
||||
placeholder="Variable value"
|
||||
/>
|
||||
<div class="relative group w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={entry.displayValue}
|
||||
oninput={(e) =>
|
||||
updateEnvValue(entry.key, e.currentTarget.value, 'string')}
|
||||
disabled={noEditor}
|
||||
class="input w-full"
|
||||
placeholder="Variable value"
|
||||
/>
|
||||
{#if !noEditor}
|
||||
<Button
|
||||
iconOnly
|
||||
startIcon={{ icon: DollarSign }}
|
||||
unifiedSize="sm"
|
||||
onClick={() => {
|
||||
pickForKey = entry.key
|
||||
variablePicker?.openDrawer?.()
|
||||
}}
|
||||
wrapperClasses="opacity-0 group-hover:opacity-100 transition-opacity absolute right-2 top-1/2 -translate-y-1/2 bg-surface-input"
|
||||
variant="subtle"
|
||||
title="Insert a Variable"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if typeof entry.value === 'string' && entry.value.startsWith('$var:') && entry.value.length > 5}
|
||||
<div class="text-2xs text-tertiary">
|
||||
Linked to variable <a
|
||||
href="/variables#{entry.value.slice(5)}"
|
||||
target="_blank"
|
||||
class="text-accent underline font-normal"
|
||||
>{entry.value.slice(5)}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -285,3 +367,20 @@
|
||||
</div>
|
||||
</FlowCard>
|
||||
</div>
|
||||
|
||||
<ItemPicker
|
||||
bind:this={variablePicker}
|
||||
pickCallback={(path, _) => {
|
||||
if (pickForKey) {
|
||||
setVarPath(pickForKey, path)
|
||||
pickForKey = undefined
|
||||
}
|
||||
}}
|
||||
itemName="Variable"
|
||||
extraField="path"
|
||||
loadItems={async () =>
|
||||
(await VariableService.listVariable({ workspace: $workspaceStore ?? '' })).map((x) => ({
|
||||
name: x.path,
|
||||
...x
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
|
||||
import type { PropPickerContext } from '$lib/components/prop_picker'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
|
||||
|
||||
interface Props {
|
||||
pickableProperties: PickableProperties | undefined
|
||||
@@ -67,9 +67,8 @@
|
||||
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
|
||||
flowPropPickerConfig.set(undefined)
|
||||
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let flow_env = $derived(pickableProperties?.flow_env || flowStore.val.value.flow_env)
|
||||
|
||||
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
|
||||
propPickerConfig,
|
||||
inputMatches,
|
||||
@@ -156,7 +155,6 @@
|
||||
{extraResults}
|
||||
{displayContext}
|
||||
{error}
|
||||
{flow_env}
|
||||
previousId={pickableProperties?.previousId}
|
||||
{pickableProperties}
|
||||
allowCopy={!notSelectable && !$propPickerConfig}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
error?: boolean
|
||||
allowCopy?: boolean
|
||||
previousId?: string | undefined
|
||||
flow_env?: Record<string, any> | undefined
|
||||
result?: any | undefined
|
||||
extraResults?: any
|
||||
}
|
||||
@@ -30,7 +29,6 @@
|
||||
error = false,
|
||||
allowCopy = false,
|
||||
previousId = undefined,
|
||||
flow_env = undefined,
|
||||
result = undefined,
|
||||
extraResults = undefined
|
||||
}: Props = $props()
|
||||
@@ -39,7 +37,6 @@
|
||||
let resources: Record<string, any> = $state({})
|
||||
let displayVariable = $state(false)
|
||||
let displayResources = $state(false)
|
||||
let displayFlowEnv = $state(false)
|
||||
|
||||
let allResultsCollapsed = $state(true)
|
||||
let collapsableInitialState:
|
||||
@@ -47,7 +44,6 @@
|
||||
allResultsCollapsed: boolean
|
||||
displayVariable: boolean
|
||||
displayResources: boolean
|
||||
displayFlowEnv: boolean
|
||||
}
|
||||
| undefined
|
||||
|
||||
@@ -139,7 +135,9 @@
|
||||
resultByIdFiltered = {}
|
||||
}
|
||||
if (!$inputMatches?.some((match) => match.word === 'flow_env')) {
|
||||
flowEnvFiltered = {}
|
||||
if (search === EMPTY_STRING) {
|
||||
flowEnvFiltered = pickableProperties.flow_env
|
||||
}
|
||||
}
|
||||
if ($inputMatches?.length == 1) {
|
||||
filteringFlowInputsOrResult = $inputMatches[0].value
|
||||
@@ -185,8 +183,7 @@
|
||||
collapsableInitialState = {
|
||||
allResultsCollapsed,
|
||||
displayVariable,
|
||||
displayResources,
|
||||
displayFlowEnv
|
||||
displayResources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,10 +197,6 @@
|
||||
displayResources = true
|
||||
return
|
||||
}
|
||||
if ($inputMatches[0].word === 'flow_env') {
|
||||
displayFlowEnv = true
|
||||
return
|
||||
}
|
||||
if ($inputMatches[0].word === 'results') {
|
||||
allResultsCollapsed = false
|
||||
return
|
||||
@@ -214,8 +207,7 @@
|
||||
if (!collapsableInitialState) {
|
||||
return
|
||||
}
|
||||
;({ allResultsCollapsed, displayVariable, displayResources, displayFlowEnv } =
|
||||
collapsableInitialState)
|
||||
;({ allResultsCollapsed, displayVariable, displayResources } = collapsableInitialState)
|
||||
collapsableInitialState = undefined
|
||||
}
|
||||
|
||||
@@ -279,6 +271,18 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flowEnvFiltered && Object.keys(flowEnvFiltered ?? {}).length > 0}
|
||||
<span class={categoryTitleClasses}>Flow Env Variables</span>
|
||||
<div class={categoryContentClasses}>
|
||||
<ObjectViewer
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
json={flowEnvFiltered}
|
||||
prefix="flow_env"
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<span class={categoryTitleClasses}>Error</span>
|
||||
<div class={categoryContentClasses}>
|
||||
@@ -445,45 +449,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow_env && Object.keys(flow_env).length > 0 && $inputMatches?.some((match) => match.word === 'flow_env')}
|
||||
<div class="overflow-y-auto pb-2">
|
||||
<span class="font-normal text-xs text-secondary">Flow Env Variables:</span>
|
||||
|
||||
{#if displayFlowEnv}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
displayFlowEnv = false
|
||||
}}
|
||||
wrapperClasses="inline-flex whitespace-nowrap w-fit"
|
||||
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
|
||||
>
|
||||
<ObjectViewer
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
rawKey={false}
|
||||
json={flowEnvFiltered}
|
||||
prefix="flow_env"
|
||||
on:select
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
displayFlowEnv = true
|
||||
}}
|
||||
wrapperClasses="inline-flex whitespace-nowrap w-fit"
|
||||
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
|
||||
>
|
||||
{'{...}'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- </div> -->
|
||||
</Scrollable>
|
||||
|
||||
@@ -41,10 +41,18 @@
|
||||
const templateId = $page.url.searchParams.get('template_id')
|
||||
const hubId = $page.url.searchParams.get('hub')
|
||||
|
||||
const importRaw = $importStore
|
||||
// Check in-memory store first, then sessionStorage (used when full page reload occurs)
|
||||
let importRaw = $importStore
|
||||
if ($importStore) {
|
||||
$importStore = undefined
|
||||
}
|
||||
if (!importRaw) {
|
||||
const sessionData = sessionStorage.getItem('rawAppImport')
|
||||
if (sessionData) {
|
||||
sessionStorage.removeItem('rawAppImport')
|
||||
importRaw = JSON.parse(sessionData)
|
||||
}
|
||||
}
|
||||
|
||||
const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp')
|
||||
|
||||
@@ -189,7 +197,7 @@
|
||||
files: svelte5Template
|
||||
}
|
||||
]
|
||||
let templatePicker = $state(nodraft != null)
|
||||
let templatePicker = $state(nodraft != null && !importRaw)
|
||||
let reloadCounter = $state(0)
|
||||
|
||||
// Modal state
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
EyeOff,
|
||||
Circle
|
||||
} from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
type ListableVariableW = ListableVariable & { canWrite: boolean }
|
||||
|
||||
@@ -202,6 +203,14 @@
|
||||
loadContextualVariables()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
variableEditor?.editVariable(path)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
|
||||
@@ -96,9 +96,8 @@ components:
|
||||
type: boolean
|
||||
flow_env:
|
||||
type: object
|
||||
description: Environment variables available to all steps
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
|
||||
additionalProperties: {}
|
||||
priority:
|
||||
type: number
|
||||
description: Execution priority (higher numbers run first)
|
||||
|
||||
Reference in New Issue
Block a user