mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
Merge branch 'main' into debouncing-tests
This commit is contained in:
@@ -110,7 +110,6 @@
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"rust-analyzer-lsp@claude-plugins-official": true,
|
||||
"typescript-lsp@claude-plugins-official": true,
|
||||
"code-review@claude-plugins-official": true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ name: Windmill
|
||||
|
||||
startupEnvs:
|
||||
CARGO_FEATURES: "quickjs"
|
||||
WM_CLONE_DB: false
|
||||
USE_RUST_PLUGIN: false
|
||||
|
||||
services:
|
||||
- name: BE
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed"
|
||||
}
|
||||
+24
@@ -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
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
-25
@@ -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"
|
||||
}
|
||||
Generated
+1
@@ -16383,6 +16383,7 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"hyper 1.8.1",
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Tests for WM_END_USER_EMAIL environment variable.
|
||||
//!
|
||||
//! These tests verify that WM_END_USER_EMAIL is populated with the authenticated
|
||||
//! user's email when executing app components.
|
||||
//!
|
||||
//! TODO: Add tests for scripts and flows once public execution endpoints are identified.
|
||||
//! Currently only apps support non-workspace-member execution via OptAuthed + token lookup.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::worker::Connection;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const SAME_WS_TOKEN: &str = "SECRET_TOKEN";
|
||||
const OTHER_WS_TOKEN: &str = "OTHER_WS_TOKEN";
|
||||
const NO_WS_TOKEN: &str = "NO_WS_TOKEN";
|
||||
|
||||
const SAME_WS_EMAIL: &str = "test@windmill.dev";
|
||||
const OTHER_WS_EMAIL: &str = "other-ws@windmill.dev";
|
||||
const NO_WS_EMAIL: &str = "no-ws@windmill.dev";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
// TODO: Script tests - need to identify public execution endpoints for non-workspace-members
|
||||
// async fn run_script(port: u16, token: &str) -> anyhow::Result<String> {
|
||||
// let url = format!(
|
||||
// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/p/f/test/get_end_user_email",
|
||||
// port
|
||||
// );
|
||||
// let resp = authed(client().post(&url), token)
|
||||
// .json(&json!({}))
|
||||
// .send()
|
||||
// .await?;
|
||||
// if !resp.status().is_success() {
|
||||
// anyhow::bail!("script run failed: {} - {}", resp.status(), resp.text().await?);
|
||||
// }
|
||||
// Ok(resp.json::<serde_json::Value>().await?
|
||||
// .as_str().unwrap_or("").to_string())
|
||||
// }
|
||||
|
||||
// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members
|
||||
// async fn run_flow(port: u16, token: &str) -> anyhow::Result<String> {
|
||||
// let url = format!(
|
||||
// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/f/f/test/get_end_user_email_flow",
|
||||
// port
|
||||
// );
|
||||
// let resp = authed(client().post(&url), token)
|
||||
// .json(&json!({}))
|
||||
// .send()
|
||||
// .await?;
|
||||
// if !resp.status().is_success() {
|
||||
// anyhow::bail!("flow run failed: {} - {}", resp.status(), resp.text().await?);
|
||||
// }
|
||||
// Ok(resp.json::<serde_json::Value>().await?
|
||||
// .as_str().unwrap_or("").to_string())
|
||||
// }
|
||||
|
||||
/// Create an app with inline script via API
|
||||
async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps/create",
|
||||
port
|
||||
);
|
||||
let resp = authed(client().post(&url), SAME_WS_TOKEN)
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"summary": "Test app for WM_END_USER_EMAIL",
|
||||
"value": {
|
||||
"type": "app",
|
||||
"grid": [],
|
||||
"subgrids": {},
|
||||
"hiddenInlineScripts": [{
|
||||
"name": "get_email",
|
||||
"language": "deno",
|
||||
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }",
|
||||
"path": "f/test/email_app/get_email"
|
||||
}]
|
||||
},
|
||||
"policy": {
|
||||
"execution_mode": "anonymous",
|
||||
"on_behalf_of": null,
|
||||
"on_behalf_of_email": null,
|
||||
"triggerables_v2": {
|
||||
"get_email": {
|
||||
"static_inputs": {},
|
||||
"one_of_inputs": {}
|
||||
},
|
||||
// SHA256 hash of raw_code content for anonymous execution
|
||||
"rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": {
|
||||
"static_inputs": {},
|
||||
"one_of_inputs": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("create app failed: {} - {}", resp.status(), resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a raw app with inline script via API (uses regular app endpoint with rawapp type)
|
||||
async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps/create",
|
||||
port
|
||||
);
|
||||
let resp = authed(client().post(&url), SAME_WS_TOKEN)
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"summary": "Test raw app for WM_END_USER_EMAIL",
|
||||
"value": {
|
||||
"type": "rawapp",
|
||||
"css": "",
|
||||
"inlineScripts": [{
|
||||
"name": "get_email",
|
||||
"language": "deno",
|
||||
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }"
|
||||
}]
|
||||
},
|
||||
"policy": {
|
||||
"execution_mode": "anonymous",
|
||||
"on_behalf_of": null,
|
||||
"on_behalf_of_email": null,
|
||||
"triggerables_v2": {
|
||||
"get_email": {
|
||||
"static_inputs": {},
|
||||
"one_of_inputs": {}
|
||||
},
|
||||
// SHA256 hash of raw_code content for anonymous execution
|
||||
"rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": {
|
||||
"static_inputs": {},
|
||||
"one_of_inputs": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("create raw app failed: {} - {}", resp.status(), resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result<String> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}",
|
||||
port, app_path
|
||||
);
|
||||
let mut payload = json!({
|
||||
"args": {},
|
||||
"component": "get_email",
|
||||
"raw_code": {
|
||||
"language": "deno",
|
||||
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }",
|
||||
"path": format!("{}/get_email", app_path)
|
||||
}
|
||||
});
|
||||
if force_viewer {
|
||||
payload["force_viewer_static_fields"] = json!({});
|
||||
}
|
||||
let resp = authed(client().post(&url), token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("app inline script run failed: {} - {}", resp.status(), resp.text().await?);
|
||||
}
|
||||
let job_id = resp.text().await?;
|
||||
wait_for_job_result(port, token, &job_id).await
|
||||
}
|
||||
|
||||
async fn run_raw_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result<String> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}",
|
||||
port, app_path
|
||||
);
|
||||
let mut payload = json!({
|
||||
"args": {},
|
||||
"component": "get_email",
|
||||
"raw_code": {
|
||||
"language": "deno",
|
||||
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }"
|
||||
}
|
||||
});
|
||||
if force_viewer {
|
||||
payload["force_viewer_static_fields"] = json!({});
|
||||
}
|
||||
let resp = authed(client().post(&url), token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("raw app inline script run failed: {} - {}", resp.status(), resp.text().await?);
|
||||
}
|
||||
let job_id = resp.text().await?;
|
||||
wait_for_job_result(port, token, &job_id).await
|
||||
}
|
||||
|
||||
async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Result<String> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/jobs_u/completed/get_result/{}",
|
||||
port, job_id
|
||||
);
|
||||
for _ in 0..100 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let resp = authed(client().get(&url), token).send().await?;
|
||||
if resp.status().is_success() {
|
||||
return Ok(resp.json::<serde_json::Value>().await?
|
||||
.as_str().unwrap_or("").to_string());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("timeout waiting for job result")
|
||||
}
|
||||
|
||||
// TODO: Script tests - need to identify public execution endpoints for non-workspace-members
|
||||
// #[cfg(feature = "deno_core")]
|
||||
// #[sqlx::test(fixtures("base", "end_user_email"))]
|
||||
// async fn test_script_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// initialize_tracing().await;
|
||||
// set_jwt_secret().await;
|
||||
// let server = ApiServer::start(db.clone()).await?;
|
||||
// let port = server.addr.port();
|
||||
//
|
||||
// in_test_worker(Connection::Sql(db.clone()), async move {
|
||||
// let result = run_script(port, SAME_WS_TOKEN).await?;
|
||||
// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
|
||||
// Ok::<(), anyhow::Error>(())
|
||||
// }, port).await?;
|
||||
//
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members
|
||||
// #[cfg(feature = "deno_core")]
|
||||
// #[sqlx::test(fixtures("base", "end_user_email"))]
|
||||
// async fn test_flow_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// initialize_tracing().await;
|
||||
// set_jwt_secret().await;
|
||||
// let server = ApiServer::start(db.clone()).await?;
|
||||
// let port = server.addr.port();
|
||||
//
|
||||
// in_test_worker(Connection::Sql(db.clone()), async move {
|
||||
// let result = run_flow(port, SAME_WS_TOKEN).await?;
|
||||
// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
|
||||
// Ok::<(), anyhow::Error>(())
|
||||
// }, port).await?;
|
||||
//
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base", "end_user_email"))]
|
||||
async fn test_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let app_path = "f/test/email_app";
|
||||
|
||||
in_test_worker(Connection::Sql(db.clone()), async move {
|
||||
// Create the app with inline script first
|
||||
create_app_with_inline_script(port, app_path).await?;
|
||||
|
||||
// Same workspace user (force_viewer mode works for workspace members)
|
||||
let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
|
||||
assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
|
||||
|
||||
// Other workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email");
|
||||
|
||||
// No workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}, port).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base", "end_user_email"))]
|
||||
async fn test_raw_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let app_path = "f/test/email_raw_app";
|
||||
|
||||
in_test_worker(Connection::Sql(db.clone()), async move {
|
||||
// Create the raw app with inline script first
|
||||
create_raw_app_with_inline_script(port, app_path).await?;
|
||||
|
||||
// Same workspace user (force_viewer mode works for workspace members)
|
||||
let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
|
||||
assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
|
||||
|
||||
// Other workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email");
|
||||
|
||||
// No workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}, port).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
-- Fixture for WM_END_USER_EMAIL tests
|
||||
-- Sets up 3 users with different workspace memberships:
|
||||
-- 1. test@windmill.dev - in test-workspace (from base.sql)
|
||||
-- 2. other-ws@windmill.dev - in other-workspace only
|
||||
-- 3. no-ws@windmill.dev - not in any workspace
|
||||
|
||||
-- Second workspace for cross-workspace user
|
||||
INSERT INTO workspace (id, name, owner)
|
||||
VALUES ('other-workspace', 'other-workspace', 'other-ws-user');
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key)
|
||||
VALUES ('other-workspace', 'cloud', 'other-key');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id)
|
||||
VALUES ('other-workspace');
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
|
||||
VALUES ('other-workspace', 'all', 'All users', '{}');
|
||||
|
||||
-- User in other-workspace only (not in test-workspace)
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('other-ws@windmill.dev', 'hash', 'password', false, true, 'Other WS User');
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role)
|
||||
VALUES ('other-workspace', 'other-ws@windmill.dev', 'other-ws-user', true, 'Admin');
|
||||
|
||||
INSERT INTO token(token, email, label, super_admin)
|
||||
VALUES ('OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false);
|
||||
|
||||
-- User not in any workspace
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('no-ws@windmill.dev', 'hash', 'password', false, true, 'No WS User');
|
||||
|
||||
INSERT INTO token(token, email, label, super_admin)
|
||||
VALUES ('NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false);
|
||||
|
||||
-- Script that returns WM_END_USER_EMAIL (public via extra_perms)
|
||||
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms)
|
||||
VALUES (
|
||||
'test-workspace', 'test-user',
|
||||
'export function main() { return Deno.env.get("WM_END_USER_EMAIL") || ""; }',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email', 900001, 'deno', '', 'script',
|
||||
'{"g/all": true}'
|
||||
);
|
||||
|
||||
-- Flow that returns WM_END_USER_EMAIL (public via extra_perms)
|
||||
INSERT INTO flow (workspace_id, summary, description, path, versions, schema, value, edited_by, extra_perms)
|
||||
VALUES (
|
||||
'test-workspace', 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email_flow', '{900002}',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}',
|
||||
'test-user',
|
||||
'{"g/all": true}'
|
||||
);
|
||||
|
||||
INSERT INTO flow_version (id, workspace_id, path, schema, value, created_by)
|
||||
VALUES (
|
||||
900002, 'test-workspace', 'f/test/get_end_user_email_flow',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}',
|
||||
'test-user'
|
||||
);
|
||||
@@ -35,7 +35,45 @@ use windmill_common::{
|
||||
lazy_static::lazy_static! {
|
||||
// Global auth cache accessible from main.rs for direct invalidation
|
||||
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
|
||||
// Cache for token -> email lookups (for non-workspace-member authenticated users)
|
||||
static ref TOKEN_EMAIL_CACHE: Cache<String, Option<String>> = Cache::new(500);
|
||||
}
|
||||
|
||||
/// Get email from a valid token, with caching.
|
||||
/// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member.
|
||||
async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
|
||||
if let Some(cached) = TOKEN_EMAIL_CACHE.get(token) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let email = sqlx::query_scalar!(
|
||||
"SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)",
|
||||
token
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten(); // email column is nullable, so we get Option<Option<String>>
|
||||
|
||||
TOKEN_EMAIL_CACHE.insert(token.to_string(), email.clone());
|
||||
email
|
||||
}
|
||||
|
||||
/// Get end user email from authenticated user or token.
|
||||
/// Returns email if user is authenticated (workspace member) or has valid instance token.
|
||||
pub async fn get_end_user_email(
|
||||
db: &DB,
|
||||
opt_authed: Option<&ApiAuthed>,
|
||||
token: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if let Some(authed) = opt_authed {
|
||||
return Some(authed.email.clone());
|
||||
}
|
||||
if let Some(token) = token {
|
||||
return get_email_from_token(db, token).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
// Global function to invalidate a specific token from cache
|
||||
pub fn invalidate_token_from_cache(token: &str) {
|
||||
|
||||
@@ -29,8 +29,8 @@ use scopes::ScopeDefinition;
|
||||
|
||||
// Re-export key auth types and functions
|
||||
pub use auth::{
|
||||
invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened, Tokened,
|
||||
TruncatedTokenWithEmail, AUTH_CACHE,
|
||||
get_end_user_email, invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened,
|
||||
Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
|
||||
};
|
||||
|
||||
// ------------ ApiAuthed & OptJobAuthed types ------------
|
||||
|
||||
@@ -43,8 +43,8 @@ use windmill_common::{
|
||||
get_database_url,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
|
||||
ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
|
||||
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
},
|
||||
instance_config::{self, ApplyMode, InstanceConfig},
|
||||
server::Smtp,
|
||||
@@ -519,6 +519,7 @@ pub async fn get_global_setting(
|
||||
&& key != DEFAULT_TAGS_WORKSPACES_SETTING
|
||||
&& key != HUB_BASE_URL_SETTING
|
||||
&& key != HUB_ACCESSIBLE_URL_SETTING
|
||||
&& key != DISABLE_HUB_SETTING
|
||||
&& key != EMAIL_DOMAIN_SETTING
|
||||
&& key != APP_WORKSPACED_ROUTE_SETTING
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ windmill-dep-map.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
hex.workspace = true
|
||||
magic-crypt.workspace = true
|
||||
http.workspace = true
|
||||
hyper.workspace = true
|
||||
lazy_static.workspace = true
|
||||
|
||||
@@ -31,7 +31,9 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE};
|
||||
use windmill_common::variables::{
|
||||
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
|
||||
};
|
||||
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::GitRepositorySettings;
|
||||
@@ -2418,20 +2420,28 @@ async fn set_encryption_key(
|
||||
));
|
||||
}
|
||||
|
||||
// Build the previous cipher before the transaction (reads from cache/pool)
|
||||
let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_key SET key = $1 WHERE workspace_id = $2",
|
||||
request.new_key.clone(),
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
|
||||
|
||||
if !request.skip_reencrypt.unwrap_or(false) {
|
||||
let new_encryption_key = build_crypt(&db, w_id.as_str()).await?;
|
||||
// Build the new cipher directly from the key string, since the transaction
|
||||
// hasn't committed yet and build_crypt() would read the old key from the pool.
|
||||
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
|
||||
format!("{}{}", request.new_key, salt)
|
||||
} else {
|
||||
request.new_key.clone()
|
||||
};
|
||||
let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256);
|
||||
|
||||
let mut truncated_new_key = request.new_key.clone();
|
||||
truncated_new_key.truncate(8);
|
||||
@@ -2445,7 +2455,7 @@ async fn set_encryption_key(
|
||||
"SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for variable in all_variables {
|
||||
@@ -2466,11 +2476,16 @@ async fn set_encryption_key(
|
||||
w_id,
|
||||
variable.path
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Invalidate the cache only after the transaction has committed
|
||||
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
|
||||
|
||||
// Trigger git sync for encryption key changes
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{collections::HashMap, sync::Arc};
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
use crate::{
|
||||
auth::OptTokened,
|
||||
auth::{get_end_user_email, OptTokened},
|
||||
db::{ApiAuthed, DB},
|
||||
jobs::RunJobQuery,
|
||||
users::{require_owner_of_path, OptAuthed},
|
||||
@@ -993,9 +993,18 @@ macro_rules! process_app_multipart {
|
||||
let mut uploaded_js = false;
|
||||
|
||||
let mut multipart = $multipart;
|
||||
while let Some(field) = multipart.next_field().await.unwrap() {
|
||||
let name = field.name().unwrap().to_string();
|
||||
let data = field.bytes().await.unwrap();
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(format!("failed to read multipart field: {e}")))?
|
||||
{
|
||||
let name = field
|
||||
.name()
|
||||
.ok_or_else(|| Error::BadRequest("multipart field missing name".to_string()))?
|
||||
.to_string();
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
Error::BadRequest(format!("failed to read multipart stream: {e}"))
|
||||
})?;
|
||||
if name == "app" {
|
||||
let app = serde_json::from_slice(&data).map_err(to_anyhow)?;
|
||||
let (ntx, npath, nid) = $internal_fn(
|
||||
@@ -2149,7 +2158,8 @@ async fn execute_component(
|
||||
(email.as_str(), permissioned_as)
|
||||
};
|
||||
|
||||
let end_user_email = opt_authed.as_ref().map(|a| a.email.clone());
|
||||
let end_user_email =
|
||||
get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await;
|
||||
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub use windmill_api_auth::auth::{
|
||||
invalidate_token_from_cache, list_tokens_internal, transform_old_scope_to_new_scope, AuthCache,
|
||||
ExpiringAuthCache, OptTokened, Tokened, TruncatedTokenWithEmail,
|
||||
get_end_user_email, invalidate_token_from_cache, list_tokens_internal,
|
||||
transform_old_scope_to_new_scope, AuthCache, ExpiringAuthCache, OptTokened, Tokened,
|
||||
TruncatedTokenWithEmail,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,6 +44,7 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
|
||||
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
|
||||
pub const HUB_BASE_URL_SETTING: &str = "hub_base_url";
|
||||
pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url";
|
||||
pub const DISABLE_HUB_SETTING: &str = "disable_hub";
|
||||
pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels";
|
||||
pub const CRITICAL_ALERT_MUTE_UI_SETTING: &str = "critical_alert_mute_ui";
|
||||
pub const CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING: &str = "critical_alerts_on_db_oversize";
|
||||
|
||||
@@ -230,6 +230,8 @@ pub struct GlobalSettings {
|
||||
pub no_default_maven: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_tags_per_workspace: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_hub: Option<bool>,
|
||||
|
||||
// String settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
+1
@@ -2164,6 +2164,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"duckdb",
|
||||
"regex",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -6,6 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
chrono = "0.4.41"
|
||||
duckdb = { version = "1.4.4", features = ["bundled"] }
|
||||
regex = "1"
|
||||
rust_decimal = "1.37.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{CStr, CString, c_char, c_uint},
|
||||
ffi::{c_char, c_uint, CStr, CString},
|
||||
ptr::null_mut,
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use duckdb::{Row, core::LogicalTypeId, params_from_iter, types::TimeUnit};
|
||||
use rust_decimal::{Decimal, prelude::FromPrimitive};
|
||||
use serde::Deserialize;
|
||||
use duckdb::{core::LogicalTypeId, params_from_iter, types::TimeUnit, Row};
|
||||
use regex::Regex;
|
||||
use rust_decimal::{prelude::FromPrimitive, Decimal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
#[derive(Deserialize, Clone, Debug, PartialEq, Default)]
|
||||
@@ -96,6 +98,218 @@ pub extern "C" fn run_duckdb_ffi(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct PrepareQueryColumnInfo {
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
type_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct PrepareQueryResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
columns: Option<Vec<PrepareQueryColumnInfo>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn is_setup_statement(query: &str) -> bool {
|
||||
let trimmed = query.trim_start();
|
||||
let upper = trimmed.to_uppercase();
|
||||
upper.starts_with("ATTACH")
|
||||
|| upper.starts_with("USE")
|
||||
|| upper.starts_with("INSTALL")
|
||||
|| upper.starts_with("LOAD")
|
||||
|| upper.starts_with("SET")
|
||||
|| upper.starts_with("RESET")
|
||||
|| upper.starts_with("CREATE OR REPLACE SECRET")
|
||||
|| upper.starts_with("CREATE SECRET")
|
||||
}
|
||||
|
||||
/// Returns true if the query is expected to return a result set and can be wrapped with DESCRIBE.
|
||||
fn is_describable_query(query: &str) -> bool {
|
||||
let trimmed = query.trim_start();
|
||||
let upper = trimmed.to_uppercase();
|
||||
upper.starts_with("SELECT")
|
||||
|| upper.starts_with("WITH")
|
||||
|| upper.starts_with("VALUES")
|
||||
|| upper.starts_with("TABLE")
|
||||
|| upper.starts_with("FROM")
|
||||
}
|
||||
|
||||
static PARAM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\d+").expect("invalid regex"));
|
||||
|
||||
fn replace_params_with_null(query: &str) -> String {
|
||||
PARAM_RE.replace_all(query, "NULL").to_string()
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn prepare_duckdb_ffi(
|
||||
query_block_list: *const *const c_char,
|
||||
query_block_list_count: usize,
|
||||
token: *const c_char,
|
||||
base_internal_url: *const c_char,
|
||||
w_id: *const c_char,
|
||||
) -> *mut c_char {
|
||||
let r = match convert_prepare_args(
|
||||
query_block_list,
|
||||
query_block_list_count,
|
||||
token,
|
||||
base_internal_url,
|
||||
w_id,
|
||||
)
|
||||
.and_then(|(query_block_list, token, base_internal_url, w_id)| {
|
||||
prepare_duckdb_internal(query_block_list, token, base_internal_url, w_id)
|
||||
}) {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
let err = serde_json::to_string(&err)
|
||||
.unwrap_or_else(|_| "Unknown error in duckdb ffi lib".to_string());
|
||||
format!("ERROR {}", err)
|
||||
}
|
||||
};
|
||||
|
||||
CString::new(r).map(|s| s.into_raw()).unwrap_or_else(|e| {
|
||||
println!("Failed to allocate error string in duckdb ffi lib: {:?}", e);
|
||||
null_mut()
|
||||
})
|
||||
}
|
||||
|
||||
fn setup_duckdb_connection(
|
||||
conn: &duckdb::Connection,
|
||||
token: &str,
|
||||
base_internal_url: &str,
|
||||
w_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let (s3_access_key, s3_secret_key) = token.rsplit_once('.').unwrap_or(("", token));
|
||||
let (s3_endpoint_ssl, s3_endpoint) = base_internal_url
|
||||
.split_once("://")
|
||||
.unwrap_or(("http", &base_internal_url));
|
||||
let s3_endpoint_ssl = s3_endpoint_ssl == "https";
|
||||
|
||||
conn.execute_batch(&format!(
|
||||
"INSTALL httpfs; LOAD httpfs;
|
||||
INSTALL azure; LOAD azure;
|
||||
CREATE OR REPLACE SECRET s3_secret (
|
||||
TYPE s3,
|
||||
PROVIDER config,
|
||||
KEY_ID '{s3_access_key}',
|
||||
SECRET '{s3_secret_key}',
|
||||
ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy',
|
||||
URL_STYLE path,
|
||||
USE_SSL {s3_endpoint_ssl}
|
||||
);
|
||||
CREATE OR REPLACE SECRET gcs_secret (
|
||||
TYPE gcs,
|
||||
KEY_ID '{s3_access_key}',
|
||||
SECRET '{s3_secret_key}',
|
||||
ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy',
|
||||
USE_SSL {s3_endpoint_ssl}
|
||||
);
|
||||
",
|
||||
))
|
||||
.map_err(|e| format!("Error setting up S3 secret: {}", e.to_string()))
|
||||
}
|
||||
|
||||
fn convert_prepare_args<'a>(
|
||||
query_block_list: *const *const c_char,
|
||||
query_block_list_count: usize,
|
||||
token: *const c_char,
|
||||
base_internal_url: *const c_char,
|
||||
w_id: *const c_char,
|
||||
) -> Result<(Vec<&'a str>, &'a str, &'a str, &'a str), String> {
|
||||
let query_block_list = unsafe {
|
||||
std::slice::from_raw_parts(query_block_list, query_block_list_count)
|
||||
.iter()
|
||||
.map(|q| {
|
||||
CStr::from_ptr(*q).to_str().unwrap_or_else(|e| {
|
||||
println!(
|
||||
"Invalid query_block string pointer in duckdb ffi: {}",
|
||||
e.to_string()
|
||||
);
|
||||
"Invalid query_block string pointer in duckdb ffi"
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let token = unsafe { CStr::from_ptr(token) }
|
||||
.to_str()
|
||||
.map_err(|e| format!("Invalid token string: {}", e.to_string()))?;
|
||||
let base_internal_url = unsafe { CStr::from_ptr(base_internal_url) }
|
||||
.to_str()
|
||||
.map_err(|e| format!("Invalid base_internal_url string: {}", e.to_string()))?;
|
||||
let w_id = unsafe { CStr::from_ptr(w_id) }
|
||||
.to_str()
|
||||
.map_err(|e| format!("Invalid w_id string: {}", e.to_string()))?;
|
||||
Ok((query_block_list, token, base_internal_url, w_id))
|
||||
}
|
||||
|
||||
fn prepare_duckdb_internal(
|
||||
query_block_list: Vec<&str>,
|
||||
token: &str,
|
||||
base_internal_url: &str,
|
||||
w_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
|
||||
setup_duckdb_connection(&conn, token, base_internal_url, w_id)?;
|
||||
|
||||
let mut results: Vec<PrepareQueryResult> = vec![];
|
||||
|
||||
// IMPORTANT: Setup statements (ATTACH, USE, INSTALL, etc.) are executed but intentionally
|
||||
// do not produce a PrepareQueryResult entry. The frontend prepends these as connection setup
|
||||
// before the actual user queries, and mapPrepareResults expects results.length to equal the
|
||||
// number of user queries (not setup statements). If a new setup-like statement is added to
|
||||
// the connection flow (e.g. in setup_duckdb_connection or transform_attach_ducklake) without
|
||||
// also being caught by is_setup_statement, the result count will mismatch and the frontend
|
||||
// will throw.
|
||||
for query_block in &query_block_list {
|
||||
if is_setup_statement(query_block) {
|
||||
conn.execute_batch(query_block)
|
||||
.map_err(|e| format!("Error executing setup statement: {}", e.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let modified_query = replace_params_with_null(query_block);
|
||||
// Validate the query parses correctly by preparing it
|
||||
if let Err(e) = conn.prepare(&modified_query) {
|
||||
results.push(PrepareQueryResult { columns: None, error: Some(e.to_string()) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// DESCRIBE only works on queries that return result sets (SELECT, WITH, VALUES, TABLE,
|
||||
// FROM). For non-returning statements (INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, etc.)
|
||||
// we skip DESCRIBE and assume no columns.
|
||||
if !is_describable_query(&modified_query) {
|
||||
results.push(PrepareQueryResult { columns: Some(vec![]), error: None });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Note: We have to use a DESCRIBE statement and cannot simply use the
|
||||
// methods returned by .prepare() because they panic if the statement was
|
||||
// not executed at least once (which we specifically do not want to do).
|
||||
let describe_query = format!("DESCRIBE {}", modified_query);
|
||||
match conn.prepare(&describe_query).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(PrepareQueryColumnInfo {
|
||||
name: row.get::<_, String>(0)?,
|
||||
type_name: row.get::<_, String>(1)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
}) {
|
||||
Ok(columns) => {
|
||||
results.push(PrepareQueryResult { columns: Some(columns), error: None });
|
||||
}
|
||||
Err(e) => {
|
||||
results.push(PrepareQueryResult { columns: None, error: Some(e.to_string()) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::to_string(&results).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn convert_args<'a>(
|
||||
query_block_list: *const *const c_char,
|
||||
query_block_list_count: usize,
|
||||
@@ -170,38 +384,7 @@ fn run_duckdb_internal<'a>(
|
||||
) -> Result<(String, Option<Vec<String>>), String> {
|
||||
let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
|
||||
let (s3_access_key, s3_secret_key) = token.split_at(token.rfind('.').unwrap_or(0));
|
||||
let s3_secret_key = &s3_secret_key[1..];
|
||||
let (s3_endpoint_ssl, s3_endpoint) = base_internal_url
|
||||
.split_once("://")
|
||||
.unwrap_or(("http", &base_internal_url));
|
||||
let s3_endpoint_ssl = match s3_endpoint_ssl {
|
||||
"https" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
conn.execute_batch(&format!(
|
||||
"INSTALL httpfs; LOAD httpfs;
|
||||
INSTALL azure; LOAD azure;
|
||||
CREATE OR REPLACE SECRET s3_secret (
|
||||
TYPE s3,
|
||||
PROVIDER config,
|
||||
KEY_ID '{s3_access_key}',
|
||||
SECRET '{s3_secret_key}',
|
||||
ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy',
|
||||
URL_STYLE path,
|
||||
USE_SSL {s3_endpoint_ssl}
|
||||
);
|
||||
CREATE OR REPLACE SECRET gcs_secret (
|
||||
TYPE gcs,
|
||||
KEY_ID '{s3_access_key}',
|
||||
SECRET '{s3_secret_key}',
|
||||
ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy',
|
||||
USE_SSL {s3_endpoint_ssl}
|
||||
);
|
||||
",
|
||||
))
|
||||
.map_err(|e| format!("Error setting up S3 secret: {}", e.to_string()))?;
|
||||
setup_duckdb_connection(&conn, token, base_internal_url, w_id)?;
|
||||
|
||||
let mut results: Vec<Vec<Box<RawValue>>> = vec![];
|
||||
let mut column_order = None;
|
||||
|
||||
@@ -161,6 +161,22 @@ pub async fn do_duckdb(
|
||||
let base_internal_url = client.base_internal_url.clone();
|
||||
let w_id = job.workspace_id.clone();
|
||||
|
||||
if annotations.prepare {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
prepare_duckdb_ffi_safe(
|
||||
query_block_list.iter().map(String::as_str),
|
||||
&token,
|
||||
&base_internal_url,
|
||||
&w_id,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::from(to_anyhow(e)))
|
||||
.and_then(|r| r)?;
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
run_duckdb_ffi_safe(
|
||||
query_block_list.iter().map(String::as_str),
|
||||
@@ -248,6 +264,18 @@ struct DuckDbFfiLib {
|
||||
collect_first_row_only: bool,
|
||||
) -> *mut c_char,
|
||||
>,
|
||||
prepare_duckdb_ffi: Option<
|
||||
Symbol<
|
||||
'static,
|
||||
unsafe extern "C" fn(
|
||||
query_block_list: *const *const c_char,
|
||||
query_block_list_count: usize,
|
||||
token: *const c_char,
|
||||
base_internal_url: *const c_char,
|
||||
w_id: *const c_char,
|
||||
) -> *mut c_char,
|
||||
>,
|
||||
>,
|
||||
free_cstr: Symbol<'static, unsafe extern "C" fn(string: *mut c_char) -> ()>,
|
||||
}
|
||||
|
||||
@@ -307,8 +335,11 @@ impl DuckDbFfiLib {
|
||||
}
|
||||
}
|
||||
|
||||
let prepare_duckdb_ffi = unsafe { lib.get(b"prepare_duckdb_ffi").ok() };
|
||||
|
||||
Ok(DuckDbFfiLib {
|
||||
run_duckdb_ffi: unsafe { lib.get(b"run_duckdb_ffi").map_err(to_anyhow)? },
|
||||
prepare_duckdb_ffi,
|
||||
free_cstr: unsafe { lib.get(b"free_cstr").map_err(to_anyhow)? },
|
||||
})
|
||||
}
|
||||
@@ -388,6 +419,56 @@ fn run_duckdb_ffi_safe<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_duckdb_ffi_safe<'a>(
|
||||
query_block_list: impl Iterator<Item = &'a str>,
|
||||
token: &str,
|
||||
base_internal_url: &str,
|
||||
w_id: &str,
|
||||
) -> Result<Box<RawValue>> {
|
||||
let query_block_list = query_block_list
|
||||
.map(|s| {
|
||||
CString::new(s).map_err(|e| {
|
||||
Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string()))
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let query_block_list = query_block_list
|
||||
.iter()
|
||||
.map(|s| s.as_ptr())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let token = CString::new(token).map_err(to_anyhow)?;
|
||||
let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?;
|
||||
let w_id = CString::new(w_id).map_err(to_anyhow)?;
|
||||
|
||||
let lib = DuckDbFfiLib::get_singleton()?;
|
||||
let prepare_fn = lib.prepare_duckdb_ffi.as_ref().ok_or_else(|| {
|
||||
Error::InternalErr(
|
||||
"prepare_duckdb_ffi not available in duckdb ffi library. Please update to the latest windmill_duckdb_ffi_lib.".to_string(),
|
||||
)
|
||||
})?;
|
||||
let free_cstr = &lib.free_cstr;
|
||||
|
||||
let result_str = unsafe {
|
||||
let ptr = prepare_fn(
|
||||
query_block_list.as_ptr(),
|
||||
query_block_list.len(),
|
||||
token.as_ptr(),
|
||||
base_internal_url.as_ptr(),
|
||||
w_id.as_ptr(),
|
||||
);
|
||||
let str = CStr::from_ptr(ptr).to_string_lossy().to_string();
|
||||
free_cstr(ptr);
|
||||
str
|
||||
};
|
||||
|
||||
if result_str.starts_with("ERROR") {
|
||||
Err(Error::ExecutionErr(result_str[6..].to_string()))
|
||||
} else {
|
||||
Ok(serde_json::value::RawValue::from_string(result_str).map_err(to_anyhow)?)
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedAttachDbResource<'a> {
|
||||
resource_path: &'a str,
|
||||
name: &'a str,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -6,27 +6,20 @@
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import Select from './select/Select.svelte'
|
||||
import { ArrowLeft, Expand, LoaderCircle, Minimize, RefreshCcw } from 'lucide-svelte'
|
||||
import type { DbInput } from './dbTypes'
|
||||
import DBManagerContent from './DBManagerContent.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { untrack } from 'svelte'
|
||||
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
|
||||
|
||||
interface Props {
|
||||
uriState: DbManagerUriState
|
||||
/** Z-index offset for the drawer, useful when opening from within modals */
|
||||
offset?: number
|
||||
}
|
||||
|
||||
let { offset = 0 }: Props = $props()
|
||||
let { uriState, offset = 0 }: Props = $props()
|
||||
|
||||
let input: DbInput | undefined = $state()
|
||||
let open = $derived(!!input)
|
||||
|
||||
// For datatable inputs, track the selected datatable separately
|
||||
let selectedDatatable = $state<string | undefined>(undefined)
|
||||
|
||||
// Check if input is a datatable type
|
||||
const isDatatableInput = $derived(
|
||||
input?.type === 'database' && input.resourcePath.startsWith('datatable://')
|
||||
)
|
||||
let open = $derived(uriState.open)
|
||||
|
||||
// Load available datatables when drawer opens with datatable input
|
||||
const datatables = resource<string[]>([], async () => {
|
||||
@@ -39,16 +32,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Computed input that updates when selectedDatatable changes
|
||||
const effectiveInput: DbInput | undefined = $derived.by(() => {
|
||||
if (!input) return undefined
|
||||
if (!isDatatableInput || !selectedDatatable) return input
|
||||
return {
|
||||
...input,
|
||||
resourcePath: `datatable://${selectedDatatable}`
|
||||
}
|
||||
})
|
||||
|
||||
const datatableItems = $derived(
|
||||
datatables.current.map((dt) => ({
|
||||
value: dt,
|
||||
@@ -56,32 +39,26 @@
|
||||
}))
|
||||
)
|
||||
|
||||
export function openDrawer(nInput: DbInput) {
|
||||
input = nInput
|
||||
if (isDatatableInput) {
|
||||
datatables.refetch()
|
||||
// Refetch datatables when switching to a datatable input
|
||||
$effect(() => {
|
||||
if (uriState.isDatatableInput) {
|
||||
untrack(() => datatables.refetch())
|
||||
}
|
||||
// If it's a datatable input, extract the datatable name for the selector
|
||||
if (nInput.type === 'database' && nInput.resourcePath.startsWith('datatable://')) {
|
||||
selectedDatatable = nInput.resourcePath.replace('datatable://', '')
|
||||
datatables.refetch()
|
||||
} else {
|
||||
selectedDatatable = undefined
|
||||
}
|
||||
}
|
||||
export function closeDrawer() {
|
||||
input = undefined
|
||||
selectedDatatable = undefined
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
uriState.closeDrawer()
|
||||
dbManagerContent?.clearReplResult()
|
||||
if (window.location.hash.startsWith('#dbmanager:'))
|
||||
history.replaceState('', document.title, window.location.href.replace(/#dbmanager:.*$/, ''))
|
||||
}
|
||||
|
||||
let windowWidth = $state(window.innerWidth)
|
||||
let expand = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (!open) expand = false
|
||||
if (!open) {
|
||||
expand = false
|
||||
uriState.closeDrawer()
|
||||
}
|
||||
})
|
||||
|
||||
let dbManagerContent: DBManagerContent | undefined = $state()
|
||||
@@ -96,7 +73,7 @@
|
||||
size={expand ? `${windowWidth}px` : '1200px'}
|
||||
preventEscape
|
||||
{offset}
|
||||
on:close={closeDrawer}
|
||||
on:close={handleClose}
|
||||
>
|
||||
<DrawerContent
|
||||
title={hasReplResult ? 'Query Result' : 'Database Manager'}
|
||||
@@ -104,18 +81,24 @@
|
||||
if (hasReplResult) {
|
||||
dbManagerContent?.clearReplResult()
|
||||
} else {
|
||||
closeDrawer()
|
||||
handleClose()
|
||||
}
|
||||
}}
|
||||
CloseIcon={hasReplResult ? ArrowLeft : undefined}
|
||||
noPadding
|
||||
id="db-manager-drawer"
|
||||
>
|
||||
{#if effectiveInput && $workspaceStore}
|
||||
{#key selectedDatatable}
|
||||
<DBManagerContent bind:this={dbManagerContent} input={effectiveInput} bind:hasReplResult>
|
||||
{#if uriState.effectiveInput && $workspaceStore}
|
||||
{#key uriState.selectedDatatable}
|
||||
<DBManagerContent
|
||||
bind:this={dbManagerContent}
|
||||
input={uriState.effectiveInput}
|
||||
bind:hasReplResult
|
||||
bind:selectedSchemaKey={uriState.selectedSchema}
|
||||
bind:selectedTableKey={uriState.selectedTable}
|
||||
>
|
||||
{#snippet dbSelector()}
|
||||
{#if isDatatableInput}
|
||||
{#if uriState.isDatatableInput}
|
||||
{#if datatables.loading}
|
||||
<div class="flex items-center gap-2 text-tertiary ml-2">
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
@@ -125,7 +108,7 @@
|
||||
<Select
|
||||
transformInputSelectedText={(s) => `Datatable: ${s}`}
|
||||
items={datatableItems}
|
||||
bind:value={selectedDatatable}
|
||||
bind:value={uriState.selectedDatatable}
|
||||
placeholder="Select data table"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
import Select from './select/Select.svelte'
|
||||
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
|
||||
@@ -31,7 +31,7 @@
|
||||
let datatables = usePromise(() =>
|
||||
WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
|
||||
)
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
@@ -49,7 +49,6 @@
|
||||
<ExploreAssetButton
|
||||
class="mt-1 w-fit"
|
||||
asset={{ kind: 'datatable', path: value }}
|
||||
{dbManagerDrawer}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
{item.displayName}
|
||||
</p>
|
||||
{@render item.extra?.()}
|
||||
{#if item.shortcut}
|
||||
<span class="ml-auto pl-4 text-2xs text-secondary shrink-0">{item.shortcut}</span>
|
||||
{/if}
|
||||
{#if item.tooltip}
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
import Select from './select/Select.svelte'
|
||||
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
|
||||
@@ -31,7 +31,7 @@
|
||||
let ducklakes = usePromise(() =>
|
||||
WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
|
||||
)
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
@@ -49,7 +49,6 @@
|
||||
<ExploreAssetButton
|
||||
class="mt-1 w-fit"
|
||||
asset={{ kind: 'ducklake', path: value }}
|
||||
{dbManagerDrawer}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
import { isDbType } from '$lib/components/dbTypes'
|
||||
import { formatAsset, type Asset } from '$lib/components/assets/lib'
|
||||
import { Button, ButtonType } from '$lib/components/common'
|
||||
import DbManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
|
||||
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { globalDbManagerDrawer, userStore } from '$lib/stores'
|
||||
import { isS3Uri } from '$lib/utils'
|
||||
import { Database, File } from 'lucide-svelte'
|
||||
import DucklakeIcon from './icons/DucklakeIcon.svelte'
|
||||
@@ -27,7 +26,6 @@
|
||||
asset,
|
||||
_resourceMetadata,
|
||||
s3FilePicker,
|
||||
dbManagerDrawer,
|
||||
onClick,
|
||||
class: className = '',
|
||||
noText = false,
|
||||
@@ -38,7 +36,6 @@
|
||||
asset: Asset
|
||||
_resourceMetadata?: { resource_type?: string }
|
||||
s3FilePicker?: S3FilePicker
|
||||
dbManagerDrawer?: DbManagerDrawer
|
||||
onClick?: () => void
|
||||
class?: string
|
||||
noText?: boolean
|
||||
@@ -46,6 +43,8 @@
|
||||
btnClasses?: string
|
||||
disabled?: boolean
|
||||
} = $props()
|
||||
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
const assetUri = $derived(formatAsset(asset))
|
||||
</script>
|
||||
|
||||
|
||||
@@ -862,14 +862,6 @@
|
||||
|
||||
const mod = isMac() ? '⌘' : 'Ctrl+'
|
||||
|
||||
const undoShortcutSnippet = createRawSnippet(() => ({
|
||||
render: () => `<span class="ml-auto text-2xs text-tertiary">${mod}Z</span>`
|
||||
}))
|
||||
|
||||
const redoShortcutSnippet = createRawSnippet(() => ({
|
||||
render: () => `<span class="ml-auto text-2xs text-tertiary">${mod}⇧Z</span>`
|
||||
}))
|
||||
|
||||
function getMoreItems(): Item[] {
|
||||
return [
|
||||
...baseMenuItems,
|
||||
@@ -878,7 +870,7 @@
|
||||
icon: Undo,
|
||||
action: () => handleUndo(),
|
||||
disabled: $history.index === 0,
|
||||
extra: undoShortcutSnippet,
|
||||
shortcut: `${mod}Z`,
|
||||
separatorTop: baseMenuItems.length > 0
|
||||
},
|
||||
{
|
||||
@@ -886,7 +878,7 @@
|
||||
icon: Redo,
|
||||
action: () => handleRedo(),
|
||||
disabled: $history.index === $history.history.length - 1,
|
||||
extra: redoShortcutSnippet
|
||||
shortcut: `${mod}⇧Z`
|
||||
},
|
||||
{
|
||||
displayName: 'Tutorials',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ResourceService, WorkspaceService } from '$lib/gen'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import AppConnect from './AppConnectDrawer.svelte'
|
||||
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
|
||||
@@ -174,7 +174,6 @@
|
||||
|
||||
let appConnect: AppConnect | undefined = $state()
|
||||
let resourceEditor: ResourceEditorDrawer | undefined = $state()
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
let hovering = $state(false)
|
||||
let isDatatableSelected = $derived(value?.startsWith('datatable://') ?? false)
|
||||
</script>
|
||||
@@ -317,7 +316,6 @@
|
||||
class="mt-1"
|
||||
_resourceMetadata={{ resource_type: resourceType }}
|
||||
asset={{ kind: 'resource', path: value }}
|
||||
{dbManagerDrawer}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,9 @@
|
||||
(v) => {
|
||||
v.maxTs ? (filters.val.max_ts = new Date(v.maxTs)) : delete filters.val.max_ts
|
||||
v.minTs ? (filters.val.min_ts = new Date(v.minTs)) : delete filters.val.min_ts
|
||||
v.timeframe ? (filters.val.timeframe = v.timeframe) : delete filters.val.timeframe
|
||||
v.timeframe && v.timeframe !== 'Latest runs'
|
||||
? (filters.val.timeframe = v.timeframe)
|
||||
: delete filters.val.timeframe
|
||||
}
|
||||
)
|
||||
let timeframe = $derived(_timeframe.val)
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
type Props = {
|
||||
s3FilePicker?: any | undefined
|
||||
dbManagerDrawer?: any | undefined
|
||||
resourceEditorDrawer?: ResourceEditorDrawer | undefined
|
||||
resourceDataCache: Record<string, string | undefined>
|
||||
asset: Asset
|
||||
@@ -18,7 +17,6 @@
|
||||
}
|
||||
let {
|
||||
s3FilePicker,
|
||||
dbManagerDrawer,
|
||||
resourceEditorDrawer,
|
||||
resourceDataCache,
|
||||
asset,
|
||||
@@ -71,7 +69,6 @@
|
||||
<ExploreAssetButton
|
||||
{asset}
|
||||
{s3FilePicker}
|
||||
{dbManagerDrawer}
|
||||
onClick={() => onClick?.()}
|
||||
noText
|
||||
_resourceMetadata={{ resource_type: resourceDataCacheValue }}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
} from './lib'
|
||||
import { untrack } from 'svelte'
|
||||
import { ResourceService, WorkspaceService } from '$lib/gen'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import Tooltip from '../meltComponents/Tooltip.svelte'
|
||||
import Tooltip2 from '../Tooltip.svelte'
|
||||
import ResourceEditorDrawer from '../ResourceEditorDrawer.svelte'
|
||||
@@ -48,7 +48,6 @@
|
||||
let blueBgDiv: HTMLDivElement | undefined = $state()
|
||||
|
||||
let s3FilePicker: S3FilePicker | undefined = $state()
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state()
|
||||
let isOpen = $state(false)
|
||||
let resourceDataCache: Record<string, string | undefined> = $state({})
|
||||
@@ -209,7 +208,6 @@
|
||||
onClick={() => (isOpen = false)}
|
||||
{asset}
|
||||
{resourceDataCache}
|
||||
{dbManagerDrawer}
|
||||
{resourceEditorDrawer}
|
||||
{s3FilePicker}
|
||||
{ducklakeNotFound}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ResourceService, type Job } from '$lib/gen'
|
||||
import { inferAssets } from '$lib/infer'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { usePromise } from '$lib/svelte5Utils.svelte'
|
||||
import { pruneNullishArray, uniqueBy } from '$lib/utils'
|
||||
import ResourceEditorDrawer from '../ResourceEditorDrawer.svelte'
|
||||
@@ -64,7 +64,6 @@
|
||||
})
|
||||
|
||||
let s3FilePicker: S3FilePicker | undefined = $state()
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state()
|
||||
</script>
|
||||
|
||||
@@ -86,7 +85,6 @@
|
||||
<AssetButtons
|
||||
{asset}
|
||||
{resourceDataCache}
|
||||
{dbManagerDrawer}
|
||||
{resourceEditorDrawer}
|
||||
{s3FilePicker}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
getContextMenuContainerClass,
|
||||
CONTEXT_MENU_ITEM_BASE_CLASS,
|
||||
CONTEXT_MENU_ITEM_HOVER_MELT_CLASS,
|
||||
CONTEXT_MENU_ITEM_DELETE_CLASS,
|
||||
CONTEXT_MENU_ITEM_DISABLED_CLASS,
|
||||
CONTEXT_MENU_DIVIDER_CLASS,
|
||||
CONTEXT_MENU_ANIMATION_CLASSES
|
||||
@@ -20,6 +21,8 @@
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
divider?: boolean
|
||||
type?: 'action' | 'delete'
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -111,18 +114,23 @@
|
||||
CONTEXT_MENU_ITEM_BASE_CLASS,
|
||||
menuItem.disabled
|
||||
? CONTEXT_MENU_ITEM_DISABLED_CLASS
|
||||
: CONTEXT_MENU_ITEM_HOVER_MELT_CLASS
|
||||
: menuItem.type === 'delete'
|
||||
? CONTEXT_MENU_ITEM_DELETE_CLASS
|
||||
: CONTEXT_MENU_ITEM_HOVER_MELT_CLASS
|
||||
)}
|
||||
use:melt={$item}
|
||||
onclick={() => handleItemClick(menuItem)}
|
||||
>
|
||||
{#if menuItem.icon}
|
||||
<menuItem.icon size={14} class="mr-2" />
|
||||
<menuItem.icon size={14} class="mr-2 shrink-0" />
|
||||
{/if}
|
||||
{#if menu}
|
||||
{@render menu({ item: menuItem })}
|
||||
{:else}
|
||||
<span>{menuItem.label}</span>
|
||||
<span class="grow">{menuItem.label}</span>
|
||||
{/if}
|
||||
{#if menuItem.shortcut}
|
||||
<span class="ml-auto pl-4 text-2xs text-secondary shrink-0">{menuItem.shortcut}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -27,6 +27,12 @@ export const CONTEXT_MENU_ITEM_HOVER_CLASS = 'hover:bg-surface-hover'
|
||||
*/
|
||||
export const CONTEXT_MENU_ITEM_HOVER_MELT_CLASS = 'data-[highlighted]:bg-surface-hover'
|
||||
|
||||
/**
|
||||
* Delete action styles for context menu items
|
||||
*/
|
||||
export const CONTEXT_MENU_ITEM_DELETE_CLASS =
|
||||
'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300'
|
||||
|
||||
/**
|
||||
* Disabled state styles for context menu items
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { z } from 'zod'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import type { DbInput, DbType } from './dbTypes'
|
||||
import { isDbType } from './dbTypes'
|
||||
|
||||
/**
|
||||
* Single URL param `dbm` encodes the full DB manager state:
|
||||
* firstSegment~path~schema.table
|
||||
*
|
||||
* firstSegment:
|
||||
* datatable – database with datatable:// resource (resourceType always postgresql)
|
||||
* ducklake – ducklake connection
|
||||
* postgresql, mysql, … – regular database (the segment IS the resource type)
|
||||
*
|
||||
* schema.table (third segment, optional):
|
||||
* schema.table – both
|
||||
* .table – table only
|
||||
* schema. – schema only
|
||||
* (omitted) – neither
|
||||
*
|
||||
* Default schemas (omitted from URL, restored on parse):
|
||||
* datatable → public
|
||||
* ducklake → main
|
||||
*
|
||||
* Examples:
|
||||
* datatable~main~.customers (schema "public" implied)
|
||||
* ducklake~main~.orders (schema "main" implied)
|
||||
* postgresql~$res:u/user/my_pg~public.customers
|
||||
*/
|
||||
|
||||
const dbManagerSchema = z.object({
|
||||
dbm: z.string().nullable()
|
||||
})
|
||||
|
||||
interface ParsedDbm {
|
||||
type: 'database' | 'datatable' | 'ducklake'
|
||||
path: string
|
||||
resType?: string
|
||||
schema?: string
|
||||
table?: string
|
||||
}
|
||||
|
||||
function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
const parts = raw.split('~')
|
||||
if (parts.length < 2 || !parts[1]) return null
|
||||
|
||||
const firstSeg = parts[0]
|
||||
const path = parts[1]
|
||||
const schemaTable = parts[2] ?? ''
|
||||
|
||||
let type: ParsedDbm['type']
|
||||
let resType: string | undefined
|
||||
if (firstSeg === 'datatable') {
|
||||
type = 'datatable'
|
||||
} else if (firstSeg === 'ducklake') {
|
||||
type = 'ducklake'
|
||||
} else if (isDbType(firstSeg)) {
|
||||
type = 'database'
|
||||
resType = firstSeg
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
let schema: string | undefined
|
||||
let table: string | undefined
|
||||
if (schemaTable) {
|
||||
const dotIdx = schemaTable.indexOf('.')
|
||||
if (dotIdx === 0) {
|
||||
table = schemaTable.slice(1) || undefined
|
||||
} else if (dotIdx === schemaTable.length - 1) {
|
||||
schema = schemaTable.slice(0, -1) || undefined
|
||||
} else if (dotIdx > 0) {
|
||||
schema = schemaTable.slice(0, dotIdx)
|
||||
table = schemaTable.slice(dotIdx + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore default schema when omitted for datatable/ducklake
|
||||
if (!schema && table && type in defaultSchemas) {
|
||||
schema = defaultSchemas[type]
|
||||
}
|
||||
|
||||
return { type, path, resType, schema, table }
|
||||
}
|
||||
|
||||
const defaultSchemas: Record<string, string> = { datatable: 'public', ducklake: 'main' }
|
||||
|
||||
function buildDbm(p: ParsedDbm): string {
|
||||
const firstSeg = p.type === 'database' ? p.resType! : p.type
|
||||
const schema = p.schema === defaultSchemas[p.type] ? undefined : p.schema
|
||||
let schemaTable = ''
|
||||
if (schema && p.table) {
|
||||
schemaTable = `${schema}.${p.table}`
|
||||
} else if (p.table) {
|
||||
schemaTable = `.${p.table}`
|
||||
} else if (schema) {
|
||||
schemaTable = `${schema}.`
|
||||
}
|
||||
return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}`
|
||||
}
|
||||
|
||||
export interface DbManagerUriState {
|
||||
readonly input: DbInput | undefined
|
||||
readonly effectiveInput: DbInput | undefined
|
||||
readonly isDatatableInput: boolean
|
||||
selectedDatatable: string | undefined
|
||||
selectedSchema: string | undefined
|
||||
selectedTable: string | undefined
|
||||
readonly open: boolean
|
||||
openDrawer: (nInput: DbInput) => void
|
||||
closeDrawer: () => void
|
||||
}
|
||||
|
||||
export function useDbManagerUriState(): DbManagerUriState {
|
||||
const params = useSearchParams(dbManagerSchema)
|
||||
|
||||
const parsed = $derived(parseDbm(params.dbm))
|
||||
|
||||
let input: DbInput | undefined = $derived.by(() => {
|
||||
if (!parsed) return undefined
|
||||
if (parsed.type === 'ducklake') {
|
||||
return {
|
||||
type: 'ducklake' as const,
|
||||
ducklake: parsed.path,
|
||||
specificTable: parsed.table
|
||||
}
|
||||
}
|
||||
// datatable or database
|
||||
const resType = parsed.type === 'datatable' ? 'postgresql' : parsed.resType
|
||||
if (!isDbType(resType ?? undefined)) return undefined
|
||||
return {
|
||||
type: 'database' as const,
|
||||
resourceType: resType as DbType,
|
||||
resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path,
|
||||
specificSchema: parsed.schema,
|
||||
specificTable: parsed.table
|
||||
}
|
||||
})
|
||||
|
||||
const isDatatableInput = $derived(parsed?.type === 'datatable')
|
||||
|
||||
function updateField(updates: Partial<ParsedDbm>) {
|
||||
const p = parseDbm(params.dbm)
|
||||
if (!p) return
|
||||
Object.assign(p, updates)
|
||||
params.dbm = buildDbm(p)
|
||||
}
|
||||
|
||||
function openDrawer(nInput: DbInput) {
|
||||
if (nInput.type === 'database') {
|
||||
const isDatatable = nInput.resourcePath.startsWith('datatable://')
|
||||
params.dbm = buildDbm({
|
||||
type: isDatatable ? 'datatable' : 'database',
|
||||
path: isDatatable ? nInput.resourcePath.slice('datatable://'.length) : nInput.resourcePath,
|
||||
resType: isDatatable ? undefined : nInput.resourceType,
|
||||
schema: nInput.specificSchema,
|
||||
table: nInput.specificTable
|
||||
})
|
||||
} else {
|
||||
params.dbm = buildDbm({
|
||||
type: 'ducklake',
|
||||
path: nInput.ducklake,
|
||||
table: nInput.specificTable
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
params.dbm = null
|
||||
}
|
||||
|
||||
return {
|
||||
get input() {
|
||||
return input
|
||||
},
|
||||
get effectiveInput() {
|
||||
return input
|
||||
},
|
||||
get isDatatableInput() {
|
||||
return isDatatableInput
|
||||
},
|
||||
get selectedDatatable() {
|
||||
return parsed?.type === 'datatable' ? parsed.path : undefined
|
||||
},
|
||||
set selectedDatatable(v: string | undefined) {
|
||||
if (v) updateField({ path: v })
|
||||
},
|
||||
get selectedSchema() {
|
||||
return parsed?.schema
|
||||
},
|
||||
set selectedSchema(v: string | undefined) {
|
||||
updateField({ schema: v })
|
||||
},
|
||||
get selectedTable() {
|
||||
return parsed?.table
|
||||
},
|
||||
set selectedTable(v: string | undefined) {
|
||||
updateField({ table: v })
|
||||
},
|
||||
get open() {
|
||||
return !!input
|
||||
},
|
||||
openDrawer,
|
||||
closeDrawer
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
import FlowResult from './FlowResult.svelte'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import FlowSelectionPanel from './FlowSelectionPanel.svelte'
|
||||
import {
|
||||
resolveSelectedModuleIds,
|
||||
locateModules,
|
||||
areContiguousSiblings
|
||||
} from '../multiSelectUtils'
|
||||
|
||||
interface Props {
|
||||
noEditor?: boolean
|
||||
@@ -89,10 +94,29 @@
|
||||
$effect(() => {
|
||||
computeMissingInputWarnings(flowStore, flowStateStore.val, flowInputsStore)
|
||||
})
|
||||
|
||||
// Derived state for multi-select operations in the side panel
|
||||
let resolvedModuleIds = $derived(
|
||||
resolveSelectedModuleIds(selectionManager.selectedIds, flowStore.val.value.modules ?? [])
|
||||
)
|
||||
let canMoveSelected = $derived(
|
||||
resolvedModuleIds.length > 0 &&
|
||||
areContiguousSiblings(
|
||||
locateModules(resolvedModuleIds, flowStore.val.value.modules ?? [])
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if selectionManager && selectionManager.selectedIds.length > 1}
|
||||
<FlowSelectionPanel {selectionManager} {noEditor} />
|
||||
<FlowSelectionPanel
|
||||
{selectionManager}
|
||||
{noEditor}
|
||||
onDeleteSelected={() => flowModuleSchemaMap?.deleteMultiple(resolvedModuleIds)}
|
||||
onDuplicateSelected={() => flowModuleSchemaMap?.duplicateMultiple(resolvedModuleIds)}
|
||||
onMoveSelected={() => flowModuleSchemaMap?.moveMultiple(resolvedModuleIds)}
|
||||
{canMoveSelected}
|
||||
resolvedCount={resolvedModuleIds.length}
|
||||
/>
|
||||
{:else if selectedId?.startsWith('settings')}
|
||||
<FlowSettings {enableAi} {noEditor} />
|
||||
{:else if selectedId === 'Input'}
|
||||
|
||||
@@ -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
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -425,7 +425,6 @@
|
||||
on:blur={() => {
|
||||
iteratorFieldFocused = false
|
||||
}}
|
||||
autofocus
|
||||
lang="javascript"
|
||||
bind:code={mod.value.iterator.expr}
|
||||
class="h-full"
|
||||
|
||||
@@ -2,14 +2,29 @@
|
||||
import FlowCard from '../common/FlowCard.svelte'
|
||||
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
|
||||
import { StickyNote } from 'lucide-svelte'
|
||||
import { StickyNote, Move, Copy, Trash2 } from 'lucide-svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
selectionManager: SelectionManager
|
||||
noEditor: boolean
|
||||
onDeleteSelected?: () => void
|
||||
onDuplicateSelected?: () => void
|
||||
onMoveSelected?: () => void
|
||||
canMoveSelected?: boolean
|
||||
resolvedCount?: number
|
||||
}
|
||||
let { selectionManager, noEditor }: Props = $props()
|
||||
let {
|
||||
selectionManager,
|
||||
noEditor,
|
||||
onDeleteSelected,
|
||||
onDuplicateSelected,
|
||||
onMoveSelected,
|
||||
canMoveSelected = false,
|
||||
resolvedCount = 0
|
||||
}: Props = $props()
|
||||
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
|
||||
@@ -19,20 +34,44 @@
|
||||
noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds)
|
||||
}
|
||||
}
|
||||
|
||||
let menuItems: Item[] = $derived([
|
||||
{
|
||||
displayName: 'Move',
|
||||
icon: Move,
|
||||
action: () => onMoveSelected?.(),
|
||||
disabled: !canMoveSelected
|
||||
},
|
||||
{
|
||||
displayName: 'Duplicate',
|
||||
icon: Copy,
|
||||
action: () => onDuplicateSelected?.()
|
||||
},
|
||||
{
|
||||
displayName: `Delete (${resolvedCount})`,
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => onDeleteSelected?.()
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<FlowCard {noEditor} title="Multiple Selection">
|
||||
{#snippet action()}
|
||||
<Button
|
||||
onClick={addGroupNote}
|
||||
disabled={!noteEditorContext?.noteEditor || selectionManager.selectedIds.length === 0}
|
||||
startIcon={{ icon: StickyNote }}
|
||||
>
|
||||
Create group note
|
||||
</Button>
|
||||
<div class="flex gap-1 items-center">
|
||||
<Button
|
||||
onClick={addGroupNote}
|
||||
disabled={!noteEditorContext?.noteEditor || selectionManager.selectedIds.length === 0}
|
||||
startIcon={{ icon: StickyNote }}
|
||||
>
|
||||
Create group note
|
||||
</Button>
|
||||
{#if resolvedCount > 0}
|
||||
<DropdownV2 items={menuItems} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="px-4">
|
||||
<p class="text-xs text-secondary mb-4">{selectionManager.selectedIds.length} nodes selected</p>
|
||||
<div class="space-y-2 mb-4">
|
||||
{#each selectionManager.selectedIds as nodeId}
|
||||
<div class="text-sm px-2 py-1 bg-surface rounded border">
|
||||
|
||||
@@ -17,3 +17,15 @@ export function nextId(flowState: FlowState, fullFlow: OpenFlow): string {
|
||||
}, 0)
|
||||
return numberToChars(max)
|
||||
}
|
||||
|
||||
// Computes a copy id like "a2", "a3", etc. based on the original id
|
||||
export function copyId(originalId: string, flowState: FlowState, fullFlow: OpenFlow): string {
|
||||
const allIds = new Set(dfs(fullFlow.value.modules, (fm) => fm.id).concat(Object.keys(flowState)))
|
||||
for (let n = 2; n < 10000; n++) {
|
||||
const candidate = `${originalId}${n}`
|
||||
if (!allIds.has(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return `${originalId}10000`
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { preventDefault, stopPropagation } from 'svelte/legacy'
|
||||
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { classNames, type StateStore } from '$lib/utils'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { classNames, type Item, type StateStore } from '$lib/utils'
|
||||
import {
|
||||
Bed,
|
||||
Database,
|
||||
Gauge,
|
||||
Move,
|
||||
EllipsisVertical,
|
||||
PhoneIncoming,
|
||||
Repeat,
|
||||
Square,
|
||||
@@ -20,7 +21,7 @@
|
||||
Timer,
|
||||
Maximize2
|
||||
} from 'lucide-svelte'
|
||||
import { createEventDispatcher, getContext, onDestroy } from 'svelte'
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -48,6 +49,7 @@
|
||||
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
|
||||
import DiffActionBar from './DiffActionBar.svelte'
|
||||
import { getGraphContext } from '$lib/components/graph/graphContext'
|
||||
import MoveHandleButton from '$lib/components/graph/MoveHandleButton.svelte'
|
||||
|
||||
interface Props {
|
||||
selected?: boolean
|
||||
@@ -69,7 +71,6 @@
|
||||
id?: string | undefined
|
||||
label: string
|
||||
path?: string
|
||||
modType?: string | undefined
|
||||
nodeState?: FlowNodeState
|
||||
concurrency?: boolean
|
||||
// TODO: Implement for this one. See how concurrency is implemented.
|
||||
@@ -89,6 +90,7 @@
|
||||
isOwner?: boolean
|
||||
enableTestRun?: boolean
|
||||
maximizeSubflow?: () => void
|
||||
menuItems?: Item[]
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -106,7 +108,6 @@
|
||||
id = undefined,
|
||||
label,
|
||||
path = '',
|
||||
modType = undefined,
|
||||
nodeState,
|
||||
concurrency = false,
|
||||
debouncing = false,
|
||||
@@ -123,7 +124,8 @@
|
||||
onEditInput,
|
||||
flowJob,
|
||||
enableTestRun = false,
|
||||
maximizeSubflow = undefined
|
||||
maximizeSubflow = undefined,
|
||||
menuItems = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// AI action colors take priority over execution state
|
||||
@@ -138,6 +140,9 @@
|
||||
const diffManager = flowGraphContext?.diffManager
|
||||
const moveManager = flowGraphContext?.moveManager
|
||||
|
||||
// Hide per-node action buttons when multiple nodes are selected (multi-select mode)
|
||||
let isMultiSelected = $derived((flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1)
|
||||
|
||||
let pickableIds: Record<string, any> | undefined = $state(undefined)
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -161,6 +166,7 @@
|
||||
let outputPicker: OutputPicker | undefined = $state(undefined)
|
||||
let testJob: any | undefined = $state(undefined)
|
||||
let outputPickerBarOpen = $state(false)
|
||||
let dropdownOpen = $state(false)
|
||||
|
||||
let flowStateStore = $derived(flowEditorContext?.flowStateStore)
|
||||
|
||||
@@ -188,42 +194,6 @@
|
||||
|
||||
let isDragging = $derived(!!moveManager?.dragging)
|
||||
|
||||
// --- Drag handle logic ---
|
||||
let dragCleanup: (() => void) | undefined
|
||||
|
||||
function onMovePointerDown(e: Event) {
|
||||
const pe = e as PointerEvent
|
||||
const startX = pe.clientX
|
||||
const startY = pe.clientY
|
||||
let didDrag = false
|
||||
|
||||
function onMovePointer(me: PointerEvent) {
|
||||
const dx = me.clientX - startX
|
||||
const dy = me.clientY - startY
|
||||
if (!didDrag && Math.sqrt(dx * dx + dy * dy) > 5) {
|
||||
didDrag = true
|
||||
if (moveManager && id) {
|
||||
moveManager.startDrag(id, startX, startY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
document.removeEventListener('pointermove', onMovePointer)
|
||||
document.removeEventListener('pointerup', onUp)
|
||||
dragCleanup = undefined
|
||||
if (!didDrag) {
|
||||
dispatch('move')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', onMovePointer)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
dragCleanup = onUp
|
||||
}
|
||||
|
||||
onDestroy(() => dragCleanup?.())
|
||||
|
||||
const outputPickerVisible = $derived(
|
||||
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging
|
||||
)
|
||||
@@ -467,6 +437,7 @@
|
||||
{deletable}
|
||||
{bold}
|
||||
bind:editId
|
||||
disableEditId={isMultiSelected}
|
||||
{hover}
|
||||
{colorClasses}
|
||||
>
|
||||
@@ -475,7 +446,7 @@
|
||||
{/snippet}
|
||||
</FlowModuleSchemaItemViewer>
|
||||
|
||||
{#if outputPickerVisible}
|
||||
{#if outputPickerVisible && !isMultiSelected}
|
||||
<OutputPicker
|
||||
bind:this={outputPicker}
|
||||
{selected}
|
||||
@@ -518,50 +489,6 @@
|
||||
{@render buttonMaximizeSubflow?.()}
|
||||
{/if}
|
||||
|
||||
{#if id !== 'preprocessor'}
|
||||
<!-- The `style="will-change: transform;"` fixes a bug in Safari where the close and move
|
||||
and delete buttons would get clipped (unless an animation is running) -->
|
||||
<div
|
||||
class={twMerge('absolute -translate-y-[100%] top-2 right-4 h-7 p-1 min-w-7')}
|
||||
style="will-change: transform;"
|
||||
>
|
||||
<button
|
||||
class={twMerge(
|
||||
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary cursor-grab',
|
||||
hover || selected ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md',
|
||||
'group-hover:block',
|
||||
'touch-none'
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(onMovePointerDown))}
|
||||
title="Move"
|
||||
>
|
||||
<Move size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute -translate-y-[100%] top-2 -right-2 h-7 p-1 min-w-7"
|
||||
style="will-change: transform;"
|
||||
>
|
||||
<button
|
||||
class={twMerge(
|
||||
'trash center-center text-secondary shadow-sm bg-surface duration-0 hover:bg-red-400 hover:text-white p-1',
|
||||
selected || hover ? 'block' : '!hidden',
|
||||
'group-hover:block',
|
||||
'shadow-md rounded-md'
|
||||
)}
|
||||
title="Delete"
|
||||
onclick={stopPropagation(
|
||||
preventDefault((event) => dispatch('delete', { id, type: modType }))
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
|
||||
<Popover
|
||||
style="will-change: transform;"
|
||||
@@ -591,6 +518,52 @@
|
||||
<TriangleAlert size={12} strokeWidth={2} />
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
{#if !isMultiSelected && id !== 'preprocessor' && moveManager && id}
|
||||
<div
|
||||
class="absolute -translate-y-[100%] top-2 right-5 h-7 p-1 min-w-7"
|
||||
style="will-change: transform;"
|
||||
>
|
||||
<MoveHandleButton
|
||||
{moveManager}
|
||||
moduleId={id}
|
||||
singleNode
|
||||
visible={hover || selected || dropdownOpen}
|
||||
onClickMove={() => dispatch('move')}
|
||||
class="trash group-hover:block"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isMultiSelected && menuItems && menuItems.length > 0}
|
||||
<div
|
||||
class="absolute -translate-y-[100%] top-2 -right-2 h-7 p-1 min-w-7"
|
||||
style="will-change: transform;"
|
||||
>
|
||||
<DropdownV2
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
bind:open={dropdownOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
hover || selected || dropdownOpen ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md',
|
||||
'group-hover:block'
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
title="Actions"
|
||||
>
|
||||
<EllipsisVertical size={12} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if maximizeSubflow !== undefined}
|
||||
{@render buttonMaximizeSubflow?.()}
|
||||
{/if}
|
||||
@@ -603,7 +576,7 @@
|
||||
onmouseenter={() => (hover = true)}
|
||||
onmouseleave={() => (hover = false)}
|
||||
>
|
||||
{#if (hover || selected || testRunDropdownOpen) && outputPickerVisible}
|
||||
{#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible}
|
||||
<div transition:fade={{ duration: 100 }}>
|
||||
{#if !testIsLoading}
|
||||
<Button
|
||||
@@ -655,13 +628,13 @@
|
||||
</div>
|
||||
|
||||
{#snippet buttonMaximizeSubflow()}
|
||||
<div class="absolute -translate-y-[100%] top-2 right-10 h-7 p-1">
|
||||
<div class="absolute -translate-y-[100%] top-2 right-12 h-7 p-1">
|
||||
<button
|
||||
title="Expand subflow"
|
||||
class={twMerge(
|
||||
'center-center text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary p-1',
|
||||
'shadow-md rounded-md',
|
||||
hover || selected ? 'opacity-100' : 'opacity-50'
|
||||
!isMultiSelected && (hover || selected) ? 'opacity-100' : 'opacity-50'
|
||||
)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
deletable?: boolean
|
||||
bold?: boolean
|
||||
editId?: boolean
|
||||
disableEditId?: boolean
|
||||
hover?: boolean
|
||||
colorClasses?: FlowNodeColorClasses
|
||||
icon?: import('svelte').Snippet
|
||||
@@ -28,6 +29,7 @@
|
||||
deletable = false,
|
||||
bold = false,
|
||||
editId = $bindable(false),
|
||||
disableEditId = false,
|
||||
hover = false,
|
||||
colorClasses,
|
||||
icon,
|
||||
@@ -74,16 +76,18 @@
|
||||
)}
|
||||
baseClass={twMerge('!px-1')}
|
||||
title={id}
|
||||
clickable
|
||||
onclick={(e) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
editId = !editId
|
||||
onclick?.()
|
||||
}}
|
||||
clickable={!disableEditId}
|
||||
onclick={disableEditId
|
||||
? undefined
|
||||
: (e) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
editId = !editId
|
||||
onclick?.()
|
||||
}}
|
||||
>
|
||||
<span class="max-w-full text-2xs truncate flex items-center">
|
||||
{#if editId || (hover && deletable)}
|
||||
{#if !disableEditId && (editId || (hover && deletable))}
|
||||
<span transition:slide={{ axis: 'x', duration: 100 }}>
|
||||
<Pencil size={10} class="mr-1" />
|
||||
</span>
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
import { emptyFlowModuleState } from '../utils.svelte'
|
||||
|
||||
import { dfs } from '../dfs'
|
||||
import { nextId, copyId } from '../flowModuleNextId'
|
||||
import { push } from '$lib/history.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
|
||||
import { getDependentComponents } from '../flowExplorer'
|
||||
import { locateModules, groupByParent } from '../multiSelectUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
|
||||
@@ -345,6 +347,80 @@
|
||||
noteMode = !noteMode
|
||||
}
|
||||
|
||||
export function deleteMultiple(ids: string[]) {
|
||||
const deletingSet = new Set(ids)
|
||||
const allDeps: Record<string, string[]> = {}
|
||||
for (const id of ids) {
|
||||
const deps = getDependentComponents(id, flowStore.val)
|
||||
for (const [depId, exprs] of Object.entries(deps)) {
|
||||
if (!deletingSet.has(depId)) {
|
||||
allDeps[depId] = [...(allDeps[depId] ?? []), ...exprs]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
for (const id of ids) {
|
||||
removeAtId(flowStore.val.value.modules, id)
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
selectionManager.clearSelection()
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
if (Object.keys(allDeps).length > 0) {
|
||||
dependents = allDeps
|
||||
deleteCallback = cb
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
export function duplicateMultiple(ids: string[]) {
|
||||
const locations = locateModules(ids, flowStore.val.value.modules)
|
||||
const groups = groupByParent(locations)
|
||||
|
||||
push(history, flowStore.val)
|
||||
|
||||
const allCloneIds: string[] = []
|
||||
|
||||
for (const group of groups) {
|
||||
const sorted = [...group].sort((a, b) => a.index - b.index)
|
||||
const parentArr = sorted[0].parentArray
|
||||
const lastIndex = sorted[sorted.length - 1].index
|
||||
|
||||
const clones: FlowModule[] = []
|
||||
for (const loc of sorted) {
|
||||
const original = parentArr[loc.index]
|
||||
const clone: FlowModule = $state.snapshot(original)
|
||||
|
||||
clone.id = copyId(original.id, flowStateStore.val, flowStore.val)
|
||||
flowStateStore.val[clone.id] = emptyFlowModuleState()
|
||||
|
||||
dfs([clone], (mod) => {
|
||||
if (mod.id !== clone.id) {
|
||||
const newModId = nextId(flowStateStore.val, flowStore.val)
|
||||
mod.id = newModId
|
||||
flowStateStore.val[newModId] = emptyFlowModuleState()
|
||||
}
|
||||
})
|
||||
|
||||
clones.push(clone)
|
||||
allCloneIds.push(clone.id)
|
||||
}
|
||||
|
||||
parentArr.splice(lastIndex + 1, 0, ...clones)
|
||||
}
|
||||
|
||||
refreshStateStore(flowStore)
|
||||
selectionManager.selectByIds(allCloneIds)
|
||||
}
|
||||
|
||||
export function moveMultiple(ids: string[]) {
|
||||
moveManager.toggleMovingMultiple(ids)
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
|
||||
change: void
|
||||
@@ -533,18 +609,37 @@
|
||||
if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) {
|
||||
await tick()
|
||||
if (moveManager.movingModuleId) {
|
||||
// console.log('modules', modules, movingModules, movingModule)
|
||||
push(history, flowStore.val)
|
||||
let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id)
|
||||
|
||||
let [removedModule] = originalModules.splice(indexToRemove, 1)
|
||||
// When moving within the same array, removal shifts subsequent indices down by 1
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && indexToRemove < detail.index) {
|
||||
insertIndex -= 1
|
||||
if (!originalModules || !targetModules) {
|
||||
moveManager.clearMoving()
|
||||
return
|
||||
}
|
||||
if (moveManager.movingIds && moveManager.movingIds.length > 1) {
|
||||
// Multi-move: splice out all moving modules from their parent, insert at target
|
||||
const firstIndex = originalModules.findIndex(
|
||||
(m) => m.id === moveManager.movingIds?.[0]
|
||||
)
|
||||
const removedModules = originalModules.splice(
|
||||
firstIndex,
|
||||
moveManager.movingIds.length
|
||||
)
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && firstIndex < detail.index) {
|
||||
insertIndex -= moveManager.movingIds.length
|
||||
}
|
||||
targetModules.splice(insertIndex, 0, ...removedModules)
|
||||
selectionManager.selectByIds(removedModules.map((m) => m.id))
|
||||
} else {
|
||||
let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id)
|
||||
let [removedModule] = originalModules.splice(indexToRemove, 1)
|
||||
// When moving within the same array, removal shifts subsequent indices down by 1
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && indexToRemove < detail.index) {
|
||||
insertIndex -= 1
|
||||
}
|
||||
targetModules.splice(insertIndex, 0, removedModule)
|
||||
selectionManager.selectId(removedModule.id)
|
||||
}
|
||||
targetModules.splice(insertIndex, 0, removedModule)
|
||||
selectionManager.selectId(removedModule.id)
|
||||
moveManager.clearMoving()
|
||||
} else {
|
||||
if (detail.isPreprocessor) {
|
||||
@@ -678,6 +773,41 @@
|
||||
onMove={(id) => {
|
||||
moveManager.toggleMoving(id)
|
||||
}}
|
||||
onDuplicate={(id) => {
|
||||
let targetModules: FlowModule[] | undefined
|
||||
let targetIndex: number = -1
|
||||
|
||||
dfs(flowStore.val.value.modules, (mod, modules) => {
|
||||
const idx = modules.findIndex((m) => m.id === id)
|
||||
if (idx !== -1) {
|
||||
targetModules = modules
|
||||
targetIndex = idx
|
||||
}
|
||||
})
|
||||
|
||||
if (!targetModules || targetIndex === -1) return
|
||||
|
||||
push(history, flowStore.val)
|
||||
|
||||
const original = targetModules[targetIndex]
|
||||
const clone: FlowModule = $state.snapshot(original)
|
||||
|
||||
// Assign copy id to the clone, and fresh ids to nested modules
|
||||
clone.id = copyId(original.id, flowStateStore.val, flowStore.val)
|
||||
flowStateStore.val[clone.id] = emptyFlowModuleState()
|
||||
|
||||
dfs([clone], (mod) => {
|
||||
if (mod.id !== clone.id) {
|
||||
const newModId = nextId(flowStateStore.val, flowStore.val)
|
||||
mod.id = newModId
|
||||
flowStateStore.val[newModId] = emptyFlowModuleState()
|
||||
}
|
||||
})
|
||||
|
||||
targetModules.splice(targetIndex + 1, 0, clone)
|
||||
refreshStateStore(flowStore)
|
||||
selectionManager.selectId(clone.id)
|
||||
}}
|
||||
onUpdateMock={(detail) => {
|
||||
let module = findModuleById(detail.id)
|
||||
module.mock = $state.snapshot(detail.mock)
|
||||
@@ -696,6 +826,10 @@
|
||||
}
|
||||
}}
|
||||
multiSelectEnabled
|
||||
movingIds={moveManager.movingIds}
|
||||
onDeleteMultiple={deleteMultiple}
|
||||
onDuplicateMultiple={duplicateMultiple}
|
||||
onMoveMultiple={moveMultiple}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import FlowModuleSchemaItem from './FlowModuleSchemaItem.svelte'
|
||||
import FlowModuleIcon from '../FlowModuleIcon.svelte'
|
||||
import { prettyLanguage } from '$lib/common'
|
||||
import { msToSec } from '$lib/utils'
|
||||
import { msToSec, type Item } from '$lib/utils'
|
||||
import FlowJobsMenu from './FlowJobsMenu.svelte'
|
||||
import {
|
||||
isTriggerStep,
|
||||
@@ -47,6 +47,7 @@
|
||||
flowJob?: Job | undefined
|
||||
isOwner?: boolean
|
||||
maximizeSubflow?: () => void
|
||||
menuItems?: Item[]
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -67,7 +68,8 @@
|
||||
onEditInput,
|
||||
flowJob,
|
||||
isOwner = false,
|
||||
maximizeSubflow
|
||||
maximizeSubflow,
|
||||
menuItems = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const { selectionManager, moveManager } = getGraphContext()
|
||||
@@ -117,12 +119,11 @@
|
||||
: ''
|
||||
: ''
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
{#if mod}
|
||||
<div class="relative">
|
||||
{#if moveManager?.movingModuleId == mod.id}
|
||||
{#if moveManager?.movingModuleId == mod.id && !moveManager?.movingIds?.includes(mod.id)}
|
||||
<div class="absolute z-10 inset-0 flex items-center justify-center">
|
||||
<Button variant="accent" on:click={() => dispatch('move')} size="xs" destructive>
|
||||
Cancel move
|
||||
@@ -171,6 +172,7 @@
|
||||
deletable={insertable}
|
||||
{editMode}
|
||||
{moduleAction}
|
||||
{menuItems}
|
||||
label={`${
|
||||
mod.summary || (mod.value.type == 'forloopflow' ? 'For loop' : 'While loop')
|
||||
} ${mod.value.parallel ? '(parallel)' : ''} ${
|
||||
@@ -205,6 +207,7 @@
|
||||
deletable={insertable}
|
||||
{editMode}
|
||||
{moduleAction}
|
||||
{menuItems}
|
||||
on:changeId
|
||||
on:delete
|
||||
on:move
|
||||
@@ -224,6 +227,7 @@
|
||||
deletable={insertable}
|
||||
{editMode}
|
||||
{moduleAction}
|
||||
{menuItems}
|
||||
on:changeId
|
||||
on:delete
|
||||
on:move
|
||||
@@ -243,6 +247,7 @@
|
||||
{retries}
|
||||
{editMode}
|
||||
{moduleAction}
|
||||
{menuItems}
|
||||
on:changeId
|
||||
on:pointerdown={handlePointerDown}
|
||||
on:delete
|
||||
@@ -256,7 +261,6 @@
|
||||
deletable={insertable}
|
||||
id={mod.id}
|
||||
{...itemProps}
|
||||
modType={mod.value.type}
|
||||
{nodeState}
|
||||
label={mod.summary ||
|
||||
(mod.value.type === 'aiagent' ? 'AI Agent' : undefined) ||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import FlowGraphPreviewButton from './FlowGraphPreviewButton.svelte'
|
||||
import type { Job } from '$lib/gen'
|
||||
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
|
||||
import { getGraphContext } from '$lib/components/graph/graphContext'
|
||||
|
||||
interface Props {
|
||||
label?: string | undefined
|
||||
@@ -69,6 +70,12 @@
|
||||
flowHasChanged = false
|
||||
}: Props = $props()
|
||||
|
||||
const flowGraphContext = getGraphContext()
|
||||
|
||||
let isMultiSelected = $derived(
|
||||
(flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1
|
||||
)
|
||||
|
||||
const outputPickerVisible = $derived(
|
||||
(nodeKind || (inputJson && Object.keys(inputJson).length > 0)) && editMode
|
||||
)
|
||||
@@ -125,7 +132,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if outputPickerVisible}
|
||||
{#if outputPickerVisible && !isMultiSelected}
|
||||
<OutputPicker
|
||||
{selected}
|
||||
{hover}
|
||||
@@ -201,7 +208,7 @@
|
||||
hoverButton = false
|
||||
}}
|
||||
>
|
||||
{#if outputPickerVisible}
|
||||
{#if outputPickerVisible && !isMultiSelected}
|
||||
<div transition:fade={{ duration: 100 }}>
|
||||
<FlowGraphPreviewButton
|
||||
{isRunning}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { dfs } from './dfs'
|
||||
|
||||
/** Virtual/non-module node IDs that should be filtered out of multi-select operations */
|
||||
const VIRTUAL_ID_PREFIXES = ['Input', 'Result', 'Trigger', 'preprocessor', 'failure']
|
||||
|
||||
function isVirtualId(id: string): boolean {
|
||||
if (VIRTUAL_ID_PREFIXES.includes(id)) return true
|
||||
if (
|
||||
id.endsWith('-start') ||
|
||||
id.endsWith('-end') ||
|
||||
id.includes('-branch-') ||
|
||||
id.startsWith('subflow:')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Single DFS pass to build a map of moduleId → set of ancestor module IDs.
|
||||
* Used to deduplicate nested selections (keep container, drop children).
|
||||
*/
|
||||
function buildAncestorMap(modules: FlowModule[]): Map<string, Set<string>> {
|
||||
const ancestors = new Map<string, Set<string>>()
|
||||
|
||||
function walk(mods: FlowModule[], parentAncestors: Set<string>) {
|
||||
for (const mod of mods) {
|
||||
ancestors.set(mod.id, parentAncestors)
|
||||
const childAncestors = new Set([...parentAncestors, mod.id])
|
||||
|
||||
const val = mod.value
|
||||
if (val.type === 'forloopflow' || val.type === 'whileloopflow') {
|
||||
walk(val.modules, childAncestors)
|
||||
} else if (val.type === 'branchall') {
|
||||
for (const branch of val.branches) walk(branch.modules, childAncestors)
|
||||
} else if (val.type === 'branchone') {
|
||||
for (const branch of val.branches) walk(branch.modules, childAncestors)
|
||||
walk(val.default, childAncestors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(modules, new Set())
|
||||
return ancestors
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter raw selected node IDs down to the minimal set of top-level module IDs:
|
||||
* 1. Filter out virtual graph nodes (Input, Result, Trigger, -start, -end, -branch-*, subflow:*, preprocessor, failure)
|
||||
* 2. Verify each ID exists as a real module in the flow module tree
|
||||
* 3. Deduplicate nested: if a container (loop/branch) AND its children are both selected, keep only the container
|
||||
*/
|
||||
export function resolveSelectedModuleIds(rawIds: string[], modules: FlowModule[]): string[] {
|
||||
// Step 1: Filter out virtual IDs
|
||||
const candidateIds = rawIds.filter((id) => !isVirtualId(id))
|
||||
|
||||
// Step 2+3: Single DFS to build ancestor map (also verifies existence)
|
||||
const ancestorMap = buildAncestorMap(modules)
|
||||
const verifiedIds = candidateIds.filter((id) => ancestorMap.has(id))
|
||||
|
||||
// If any ancestor of this module is also selected, it's a nested child — drop it
|
||||
const selectedSet = new Set(verifiedIds)
|
||||
return verifiedIds.filter((id) => {
|
||||
const ancestors = ancestorMap.get(id)!
|
||||
for (const ancestor of ancestors) {
|
||||
if (selectedSet.has(ancestor)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export type ModuleLocation = {
|
||||
id: string
|
||||
parentArray: FlowModule[]
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* For each ID, find its parent array (reference) and index using DFS.
|
||||
*/
|
||||
export function locateModules(ids: string[], modules: FlowModule[]): ModuleLocation[] {
|
||||
const idSet = new Set(ids)
|
||||
const locations: ModuleLocation[] = []
|
||||
|
||||
dfs(modules, (mod, parentModules) => {
|
||||
if (idSet.has(mod.id)) {
|
||||
const index = parentModules.findIndex((m) => m.id === mod.id)
|
||||
if (index !== -1) {
|
||||
locations.push({ id: mod.id, parentArray: parentModules, index })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return locations
|
||||
}
|
||||
|
||||
/**
|
||||
* Group locations that share the same parent array, sorted by index within each group.
|
||||
*/
|
||||
export function groupByParent(locations: ModuleLocation[]): ModuleLocation[][] {
|
||||
const groups = new Map<FlowModule[], ModuleLocation[]>()
|
||||
for (const loc of locations) {
|
||||
const existing = groups.get(loc.parentArray)
|
||||
if (existing) {
|
||||
existing.push(loc)
|
||||
} else {
|
||||
groups.set(loc.parentArray, [loc])
|
||||
}
|
||||
}
|
||||
// Sort each group by index
|
||||
for (const group of groups.values()) {
|
||||
group.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* True if all locations share the same parent array and have consecutive indices.
|
||||
* Required for move to be valid.
|
||||
*/
|
||||
export function areContiguousSiblings(locations: ModuleLocation[]): boolean {
|
||||
if (locations.length === 0) return false
|
||||
if (locations.length === 1) return true
|
||||
|
||||
// All must share the same parent
|
||||
const parent = locations[0].parentArray
|
||||
if (!locations.every((loc) => loc.parentArray === parent)) return false
|
||||
|
||||
// Sort by index and check contiguity
|
||||
const sorted = [...locations].sort((a, b) => a.index - b.index)
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i].index !== sorted[i - 1].index + 1) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { loadHubApps } from '$lib/hub'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { disableHubStore } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
filter?: string
|
||||
@@ -30,11 +32,22 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let hubNotAvailable = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
hubApps = await loadHubApps()
|
||||
if ($disableHubStore) return
|
||||
const result = await loadHubApps()
|
||||
if (result === undefined) {
|
||||
hubNotAvailable = true
|
||||
} else {
|
||||
hubApps = result
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $disableHubStore}
|
||||
<!-- Hub disabled, show nothing -->
|
||||
{:else}
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={prefilteredItems}
|
||||
@@ -54,7 +67,11 @@
|
||||
</div>
|
||||
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
|
||||
|
||||
{#if hubApps}
|
||||
{#if hubNotAvailable}
|
||||
<Alert type="warning" title="Hub not available">
|
||||
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
|
||||
</Alert>
|
||||
{:else if hubApps}
|
||||
{#if filteredItems.length == 0}
|
||||
<NoItemFound />
|
||||
{:else}
|
||||
@@ -93,3 +110,4 @@
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { loadHubFlows } from '$lib/hub'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { disableHubStore } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
filter?: string
|
||||
@@ -30,11 +32,22 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let hubNotAvailable = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
hubFlows = await loadHubFlows()
|
||||
if ($disableHubStore) return
|
||||
const result = await loadHubFlows()
|
||||
if (result === undefined) {
|
||||
hubNotAvailable = true
|
||||
} else {
|
||||
hubFlows = result
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $disableHubStore}
|
||||
<!-- Hub disabled, show nothing -->
|
||||
{:else}
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={prefilteredItems}
|
||||
@@ -54,7 +67,11 @@
|
||||
</div>
|
||||
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
|
||||
|
||||
{#if hubFlows}
|
||||
{#if hubNotAvailable}
|
||||
<Alert type="warning" title="Hub not available">
|
||||
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
|
||||
</Alert>
|
||||
{:else if hubFlows}
|
||||
{#if filteredItems.length == 0}
|
||||
<NoItemFound />
|
||||
{:else}
|
||||
@@ -95,3 +112,4 @@
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { disableHubStore } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
kind?: HubScriptKind & string
|
||||
@@ -47,6 +48,7 @@
|
||||
)
|
||||
|
||||
async function getAllApps(filterKind: typeof kind) {
|
||||
if ($disableHubStore) return
|
||||
try {
|
||||
hubNotAvailable = false
|
||||
allApps = (
|
||||
@@ -67,6 +69,7 @@
|
||||
filterKind: typeof kind,
|
||||
appFilter: string | undefined
|
||||
) {
|
||||
if ($disableHubStore) return
|
||||
try {
|
||||
loading = true
|
||||
hubNotAvailable = false
|
||||
@@ -138,6 +141,9 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $disableHubStore}
|
||||
<!-- Hub disabled, show nothing -->
|
||||
{:else}
|
||||
<div class="w-full flex items-center gap-2">
|
||||
{@render children?.()}
|
||||
<div class="relative w-full">
|
||||
@@ -156,7 +162,9 @@
|
||||
</div>
|
||||
|
||||
{#if hubNotAvailable}
|
||||
<Alert type="error" title="Hub not available" />
|
||||
<Alert type="warning" title="Hub not available">
|
||||
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
|
||||
</Alert>
|
||||
{:else if (items.length > 0 && apps.length > 0) || !loading}
|
||||
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
|
||||
{#if items.length == 0}
|
||||
@@ -204,3 +212,4 @@
|
||||
<Skeleton layout={[0.5, [4]]} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
[])
|
||||
: undefined
|
||||
} catch (err) {
|
||||
sendUserToast('Failed to fetch hub scripts: ' + err, 'error')
|
||||
console.error('Failed to fetch hub scripts:', err)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
@@ -44,9 +44,10 @@
|
||||
import { Circle, ExternalLink } from 'lucide-svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { usePromise } from '$lib/svelte5Utils.svelte'
|
||||
import { hubBaseUrlStore, userStore } from '$lib/stores'
|
||||
import { disableHubStore, hubBaseUrlStore, userStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
|
||||
let hubNotAvailable = $state(false)
|
||||
|
||||
@@ -94,13 +95,14 @@
|
||||
})
|
||||
|
||||
async function getAllApps(filterKind: typeof kind) {
|
||||
if ($disableHubStore) return
|
||||
try {
|
||||
hubNotAvailable = false
|
||||
allApps = (await listHubIntegrationsCached({ kind: filterKind, refreshCount })).map(
|
||||
(x) => x.name
|
||||
)
|
||||
} catch (err) {
|
||||
sendUserToast('Failed to fetch hub integrations: ' + err, 'error')
|
||||
console.error('Failed to fetch hub integrations:', err)
|
||||
allApps = []
|
||||
hubNotAvailable = true
|
||||
}
|
||||
@@ -112,7 +114,9 @@
|
||||
)
|
||||
$effect(() => {
|
||||
;[filter, kind, appFilter, refreshCount]
|
||||
hubScriptsFilteredPromise.refresh()
|
||||
if (!$disableHubStore) {
|
||||
hubScriptsFilteredPromise.refresh()
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
loading = hubScriptsFilteredPromise.status === 'loading'
|
||||
@@ -175,9 +179,13 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
{#if hubNotAvailable}
|
||||
<div class="text-2xs text-red-400 font-normal text-center py-2 px-3 items-center">
|
||||
Hub not available
|
||||
{#if $disableHubStore}
|
||||
<!-- Hub disabled, show nothing -->
|
||||
{:else if hubNotAvailable}
|
||||
<div class="px-3 py-2 mt-2">
|
||||
<Alert type="warning" title="Hub not available" size="xs">
|
||||
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if loading}
|
||||
{#each Array(15).fill(0) as _}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -21,7 +21,15 @@
|
||||
|
||||
onMount(() => {
|
||||
moveManager.setScreenToFlowPosition(screenToFlowPosition)
|
||||
moveManager.setComputeDraggedNodeIds((moduleId) => getSubflowNodeIds(moduleId, nodes, edges))
|
||||
moveManager.setComputeDraggedNodeIds((moduleIds) => {
|
||||
const combined = new Set<string>()
|
||||
for (const id of moduleIds) {
|
||||
for (const nodeId of getSubflowNodeIds(id, nodes, edges)) {
|
||||
combined.add(nodeId)
|
||||
}
|
||||
}
|
||||
return combined
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -33,11 +41,17 @@
|
||||
|
||||
function onPointerUp(_e: PointerEvent) {
|
||||
const moduleId = moveManager.dragging?.moduleId
|
||||
const selectedIds = moveManager.dragging?.selectedIds
|
||||
const zone = moveManager.endDrag()
|
||||
if (zone && moduleId) {
|
||||
// Set movingModuleId directly (non-toggle) so the insert handler knows which module to relocate
|
||||
moveManager.setMoving(moduleId)
|
||||
// Then trigger the insert, which detects movingModuleId and performs the splice
|
||||
// Set moving state so the insert handler knows which module(s) to relocate
|
||||
if (selectedIds && selectedIds.length > 1) {
|
||||
moveManager.movingModuleId = selectedIds[0]
|
||||
moveManager.movingIds = selectedIds
|
||||
} else {
|
||||
moveManager.setMoving(moduleId)
|
||||
}
|
||||
// Then trigger the insert, which detects movingModuleId/movingIds and performs the splice
|
||||
eventHandlers.insert({
|
||||
sourceId: zone.sourceId,
|
||||
targetId: zone.targetId,
|
||||
|
||||
@@ -46,8 +46,19 @@
|
||||
return { x: n.position.x, y: n.position.y }
|
||||
}
|
||||
|
||||
function computeGhost(moduleId: string, allNodes: Node[], allEdges: Edge[]) {
|
||||
const { sfNodes, sfEdges } = getSubflowNodesAndEdges(moduleId, allNodes, allEdges)
|
||||
function computeGhost(moduleId: string, draggedNodeIds: Set<string>, allNodes: Node[], allEdges: Edge[]) {
|
||||
// Use pre-computed draggedNodeIds when available (covers multi-select),
|
||||
// otherwise fall back to single-module subflow computation.
|
||||
let sfNodes: Node[]
|
||||
let sfEdges: Edge[]
|
||||
if (draggedNodeIds.size > 0) {
|
||||
sfNodes = allNodes.filter((n) => draggedNodeIds.has(n.id))
|
||||
sfEdges = allEdges.filter((e) => draggedNodeIds.has(e.source) && draggedNodeIds.has(e.target))
|
||||
} else {
|
||||
const result = getSubflowNodesAndEdges(moduleId, allNodes, allEdges)
|
||||
sfNodes = result.sfNodes
|
||||
sfEdges = result.sfEdges
|
||||
}
|
||||
if (sfNodes.length === 0) return undefined
|
||||
|
||||
// Compute bounding box using absolute positions
|
||||
@@ -112,8 +123,7 @@
|
||||
let ghost = $derived.by(() => {
|
||||
const moduleId = moveManager.dragging?.moduleId
|
||||
if (!moduleId) return undefined
|
||||
// Compute ghost once at drag start — don't react to node/edge changes during drag
|
||||
return untrack(() => computeGhost(moduleId, nodes, edges))
|
||||
return untrack(() => computeGhost(moduleId, moveManager.draggedNodeIds, nodes, edges))
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@
|
||||
import { computeNoteNodes } from './noteUtils.svelte'
|
||||
import { Tooltip } from '../meltComponents'
|
||||
import { getNoteEditorContext } from './noteEditor.svelte'
|
||||
import {
|
||||
resolveSelectedModuleIds,
|
||||
locateModules,
|
||||
areContiguousSiblings
|
||||
} from '../flows/multiSelectUtils'
|
||||
|
||||
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
|
||||
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
|
||||
@@ -131,6 +136,10 @@
|
||||
notes?: FlowNote[]
|
||||
chatInputEnabled?: boolean
|
||||
multiSelectEnabled?: boolean
|
||||
onDeleteMultiple?: (ids: string[]) => void
|
||||
onDuplicateMultiple?: (ids: string[]) => void
|
||||
onMoveMultiple?: (ids: string[]) => void
|
||||
movingIds?: string[]
|
||||
onDelete?: (id: string) => void
|
||||
onInsert?: (detail: {
|
||||
sourceId?: string
|
||||
@@ -150,6 +159,7 @@
|
||||
onDeleteBranch?: (detail: { id: string; index: number }) => Promise<void>
|
||||
onChangeId?: (detail: { id: string; newId: string; deps: Record<string, string[]> }) => void
|
||||
onMove?: (id: string) => void
|
||||
onDuplicate?: (id: string) => void
|
||||
onUpdateMock?: (detail: { mock: FlowModule['mock']; id: string }) => void
|
||||
onTestUpTo?: ((id: string) => void) | undefined
|
||||
onSelectedIteration?: onSelectedIteration
|
||||
@@ -175,6 +185,7 @@
|
||||
onInsert = undefined,
|
||||
onDelete = undefined,
|
||||
onMove = undefined,
|
||||
onDuplicate = undefined,
|
||||
onDeleteBranch = undefined,
|
||||
onNewBranch = undefined,
|
||||
onSelect = undefined,
|
||||
@@ -231,7 +242,11 @@
|
||||
diffBeforeFlow = undefined,
|
||||
currentInputSchema = undefined,
|
||||
markRemovedAsShadowed = false,
|
||||
multiSelectEnabled = false
|
||||
multiSelectEnabled = false,
|
||||
onDeleteMultiple = undefined,
|
||||
onDuplicateMultiple = undefined,
|
||||
onMoveMultiple = undefined,
|
||||
movingIds = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// Initialize note manager with fine-grained reactivity
|
||||
@@ -428,6 +443,9 @@
|
||||
move: (detail) => {
|
||||
onMove?.(detail.id)
|
||||
},
|
||||
duplicate: (detail) => {
|
||||
onDuplicate?.(detail.id)
|
||||
},
|
||||
selectedIteration: (detail) => {
|
||||
onSelectedIteration?.(detail)
|
||||
},
|
||||
@@ -508,6 +526,15 @@
|
||||
|
||||
let canUseDiffDrawer = $derived(diffBeforeFlow || moduleActions || editMode)
|
||||
|
||||
// Derived state for multi-select operations
|
||||
let resolvedModuleIds = $derived(
|
||||
resolveSelectedModuleIds(selectionManager.selectedIds, effectiveModules ?? [])
|
||||
)
|
||||
let canMoveSelected = $derived(
|
||||
resolvedModuleIds.length > 0 &&
|
||||
areContiguousSiblings(locateModules(resolvedModuleIds, effectiveModules ?? []))
|
||||
)
|
||||
|
||||
// Initialize moduleTracker with effectiveModules
|
||||
let moduleTracker = $state(new ChangeTracker<FlowModule[]>([]))
|
||||
|
||||
@@ -566,6 +593,31 @@
|
||||
exitNoteMode?.()
|
||||
}
|
||||
}
|
||||
if ((event.key === 'Backspace' || event.key === 'Delete') && editMode) {
|
||||
const active = document.activeElement
|
||||
if (active && active !== document.body && !flowContainer?.contains(active)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
active instanceof HTMLInputElement ||
|
||||
active instanceof HTMLTextAreaElement ||
|
||||
active?.getAttribute('contenteditable') === 'true'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (noteManager.selectedNoteId && noteEditorContext) {
|
||||
noteEditorContext.noteEditor.deleteNote(noteManager.selectedNoteId)
|
||||
noteManager.clearNoteSelection()
|
||||
return
|
||||
}
|
||||
if (resolvedModuleIds.length > 1) {
|
||||
onDeleteMultiple?.(resolvedModuleIds)
|
||||
} else if (resolvedModuleIds.length === 1) {
|
||||
onDelete?.(resolvedModuleIds[0])
|
||||
} else if (selectedId) {
|
||||
onDelete?.(selectedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStores() {
|
||||
@@ -967,6 +1019,7 @@
|
||||
elevateNodesOnSelect={false}
|
||||
{proOptions}
|
||||
multiSelectionKey={'Shift'}
|
||||
deleteKey={null}
|
||||
nodesDraggable={false}
|
||||
--background-color={false}
|
||||
>
|
||||
@@ -978,8 +1031,17 @@
|
||||
|
||||
{#if multiSelectEnabled}
|
||||
<SelectionBoundingBox
|
||||
selectedNodes={selectionManager.selectedIds}
|
||||
selectedNodes={selectionManager.selectedIds.filter(id =>
|
||||
nodesWithOffset.some(n => n.id === id)
|
||||
)}
|
||||
allNodes={nodesWithOffset as (Node & { type: string })[]}
|
||||
onDeleteSelected={() => onDeleteMultiple?.(resolvedModuleIds)}
|
||||
onDuplicateSelected={() => onDuplicateMultiple?.(resolvedModuleIds)}
|
||||
onMoveSelected={() => onMoveMultiple?.(resolvedModuleIds)}
|
||||
onCancelMove={() => onMoveMultiple?.(movingIds ?? [])}
|
||||
{canMoveSelected}
|
||||
isMoving={movingIds != null && movingIds.length > 0}
|
||||
{resolvedModuleIds}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1094,4 +1156,8 @@
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.svelte-flow__selection-wrapper) {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { stopPropagation, preventDefault } from 'svelte/legacy'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { Move } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MoveManager } from './moveManager.svelte'
|
||||
|
||||
interface Props {
|
||||
moveManager: MoveManager
|
||||
moduleId: string
|
||||
selectedIds?: string[]
|
||||
singleNode?: boolean
|
||||
visible?: boolean
|
||||
onClickMove?: () => void
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
moveManager,
|
||||
moduleId,
|
||||
selectedIds,
|
||||
singleNode = false,
|
||||
visible = true,
|
||||
onClickMove,
|
||||
class: extraClass
|
||||
}: Props = $props()
|
||||
|
||||
let dragCleanup: (() => void) | undefined
|
||||
|
||||
function onPointerDown(e: Event) {
|
||||
const pe = e as PointerEvent
|
||||
const startX = pe.clientX
|
||||
const startY = pe.clientY
|
||||
let didDrag = false
|
||||
|
||||
function onMove(me: PointerEvent) {
|
||||
const dx = me.clientX - startX
|
||||
const dy = me.clientY - startY
|
||||
if (!didDrag && Math.sqrt(dx * dx + dy * dy) > 5) {
|
||||
didDrag = true
|
||||
moveManager.startDrag(moduleId, startX, startY, singleNode ? undefined : selectedIds)
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', onUp)
|
||||
dragCleanup = undefined
|
||||
if (!didDrag) {
|
||||
onClickMove?.()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
dragCleanup = onUp
|
||||
}
|
||||
|
||||
onDestroy(() => dragCleanup?.())
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={twMerge(
|
||||
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary cursor-grab',
|
||||
visible ? '' : '!hidden',
|
||||
'shadow-md rounded-md',
|
||||
'touch-none',
|
||||
extraClass
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(onPointerDown))}
|
||||
title="Move"
|
||||
>
|
||||
<Move size={12} />
|
||||
</button>
|
||||
@@ -1,23 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { ViewportPortal, type Node } from '@xyflow/svelte'
|
||||
import { calculateNodesBoundsWithOffset } from './util'
|
||||
import { StickyNote } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { StickyNote, Move, Copy, Trash2, EllipsisVertical } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import { getNoteEditorContext } from './noteEditor.svelte'
|
||||
import { getGraphContext } from './graphContext'
|
||||
import MoveHandleButton from './MoveHandleButton.svelte'
|
||||
import { tick } from 'svelte'
|
||||
import { isMac, type Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
selectedNodes: string[]
|
||||
allNodes: (Node & { type: string })[]
|
||||
onDeleteSelected?: () => void
|
||||
onDuplicateSelected?: () => void
|
||||
onMoveSelected?: () => void
|
||||
onCancelMove?: () => void
|
||||
canMoveSelected?: boolean
|
||||
isMoving?: boolean
|
||||
resolvedModuleIds?: string[]
|
||||
}
|
||||
|
||||
let { selectedNodes, allNodes }: Props = $props()
|
||||
let {
|
||||
selectedNodes,
|
||||
allNodes,
|
||||
onDeleteSelected,
|
||||
onDuplicateSelected,
|
||||
onMoveSelected,
|
||||
onCancelMove,
|
||||
canMoveSelected = false,
|
||||
isMoving = false,
|
||||
resolvedModuleIds = []
|
||||
}: Props = $props()
|
||||
|
||||
let resolvedCount = $derived(resolvedModuleIds.length)
|
||||
|
||||
// Get NoteEditor context for group note creation
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
// Get Graph context for clearFlowSelection function
|
||||
// Get Graph context for clearFlowSelection function and moveManager
|
||||
const graphContext = getGraphContext()
|
||||
const moveManager = graphContext?.moveManager
|
||||
|
||||
function handleAddGroupNote() {
|
||||
if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) {
|
||||
@@ -32,7 +55,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
let bounds = $derived(() => {
|
||||
let menuItems: Item[] = $derived([
|
||||
{
|
||||
displayName: 'Move',
|
||||
icon: Move,
|
||||
action: () => onMoveSelected?.(),
|
||||
disabled: !canMoveSelected
|
||||
},
|
||||
{
|
||||
displayName: 'Duplicate',
|
||||
icon: Copy,
|
||||
action: () => onDuplicateSelected?.()
|
||||
},
|
||||
{
|
||||
displayName: `Delete (${resolvedCount})`,
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
shortcut: isMac() ? '⌫' : 'Del',
|
||||
action: () => onDeleteSelected?.()
|
||||
},
|
||||
...(noteEditorContext?.noteEditor
|
||||
? [
|
||||
{
|
||||
displayName: 'Add note',
|
||||
icon: StickyNote,
|
||||
separatorTop: true,
|
||||
action: handleAddGroupNote
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
let bounds = $derived.by(() => {
|
||||
if (selectedNodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -53,8 +107,8 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if bounds() && selectedNodes.length > 1}
|
||||
{@const currentBounds = bounds()!}
|
||||
{#if bounds && selectedNodes.length > 1}
|
||||
{@const currentBounds = bounds!}
|
||||
<ViewportPortal target="front">
|
||||
<div
|
||||
class={'absolute cursor-pointer bg-surface-selected/40 rounded-md pointer-events-none'}
|
||||
@@ -63,20 +117,37 @@
|
||||
style:height="{currentBounds.height}px"
|
||||
style:z-index="10"
|
||||
>
|
||||
<!-- Add Group Note Button positioned in top-right corner -->
|
||||
{#if noteEditorContext?.noteEditor}
|
||||
<div class="absolute -top-4 -right-1 z-20" style="pointer-events: auto;">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
title="Create group note ({selectedNodes.length} nodes)"
|
||||
onclick={handleAddGroupNote}
|
||||
startIcon={{ icon: StickyNote }}
|
||||
<div
|
||||
class="absolute -top-4 -right-1 z-20 flex gap-2 items-center"
|
||||
style="pointer-events: auto;"
|
||||
>
|
||||
{#if isMoving}
|
||||
<Button variant="accent" onClick={() => onCancelMove?.()} size="xs" destructive
|
||||
>Cancel move</Button
|
||||
>
|
||||
Create group note ({selectedNodes.length} nodes)
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if resolvedCount > 0}
|
||||
{#if canMoveSelected && moveManager && resolvedModuleIds.length > 0}
|
||||
<div class="">
|
||||
<MoveHandleButton
|
||||
{moveManager}
|
||||
moduleId={resolvedModuleIds[0]}
|
||||
selectedIds={resolvedModuleIds}
|
||||
onClickMove={() => onMoveSelected?.()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<DropdownV2 items={menuItems} size="sm" placement="right-start">
|
||||
{#snippet buttonReplacement()}
|
||||
<button
|
||||
class="center-center p-1 rounded-md shadow-md bg-surface text-secondary hover:bg-surface-tertiary"
|
||||
title="Actions"
|
||||
>
|
||||
<EllipsisVertical size={12} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ViewportPortal>
|
||||
{/if}
|
||||
|
||||
@@ -56,6 +56,7 @@ export type GraphEventHandlers = {
|
||||
delete: (detail: { id: string }, label: string) => void
|
||||
newBranch: (id: string) => void
|
||||
move: (detail: { id: string }) => void
|
||||
duplicate: (detail: { id: string }) => void
|
||||
selectedIteration: onSelectedIteration
|
||||
changeId: (newId: string) => void
|
||||
simplifyFlow: (b: boolean) => void
|
||||
|
||||
@@ -22,6 +22,7 @@ export type DropZoneRegistration = {
|
||||
|
||||
type DragInfo = {
|
||||
moduleId: string
|
||||
selectedIds?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,22 +55,13 @@ export function getSubflowNodeIds(
|
||||
// Include child nodes (e.g. asset/AI tool nodes) of nodes added via edges.
|
||||
// Nodes found through disableMoveIds (like inner module "b") may have children
|
||||
// ("b-asset-in-...") that weren't caught by the initial prefix match on moduleId.
|
||||
// Only scan children of edge-added nodes that aren't already covered by the
|
||||
// moduleId prefix (those children were already captured in the first pass).
|
||||
const newFromEdges: string[] = []
|
||||
for (const id of nodeIds) {
|
||||
if (id !== moduleId && !id.startsWith(nodeIdPrefix)) {
|
||||
newFromEdges.push(id)
|
||||
}
|
||||
}
|
||||
if (newFromEdges.length > 0) {
|
||||
for (const n of allNodes) {
|
||||
if (!nodeIds.has(n.id)) {
|
||||
for (const id of newFromEdges) {
|
||||
if (n.id.startsWith(id + '-')) {
|
||||
nodeIds.add(n.id)
|
||||
break
|
||||
}
|
||||
const edgeMatchedIds = [...nodeIds]
|
||||
for (const n of allNodes) {
|
||||
if (!nodeIds.has(n.id)) {
|
||||
for (const id of edgeMatchedIds) {
|
||||
if (n.id.startsWith(id + '-')) {
|
||||
nodeIds.add(n.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,35 +80,57 @@ export class MoveManager {
|
||||
/** The module ID currently being moved via legacy click-to-move */
|
||||
movingModuleId = $state<string | undefined>(undefined)
|
||||
|
||||
/** Multiple module IDs being moved together (multi-select move) */
|
||||
movingIds = $state<string[] | undefined>(undefined)
|
||||
|
||||
toggleMoving(id: string) {
|
||||
if (this.movingModuleId === id) {
|
||||
this.movingModuleId = undefined
|
||||
this.#updateDraggedNodeIds(undefined)
|
||||
} else {
|
||||
this.movingModuleId = id
|
||||
this.#updateDraggedNodeIds(id)
|
||||
this.#updateDraggedNodeIds([id])
|
||||
}
|
||||
}
|
||||
|
||||
toggleMovingMultiple(ids: string[]) {
|
||||
if (
|
||||
this.movingIds &&
|
||||
this.movingIds.length === ids.length &&
|
||||
this.movingIds.every((id, i) => id === ids[i])
|
||||
) {
|
||||
this.movingModuleId = undefined
|
||||
this.movingIds = undefined
|
||||
this.#updateDraggedNodeIds(undefined)
|
||||
} else {
|
||||
this.movingModuleId = ids[0]
|
||||
this.movingIds = ids
|
||||
this.#updateDraggedNodeIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
setMoving(id: string) {
|
||||
this.movingModuleId = id
|
||||
this.#updateDraggedNodeIds(id)
|
||||
this.#updateDraggedNodeIds([id])
|
||||
}
|
||||
|
||||
clearMoving() {
|
||||
this.movingModuleId = undefined
|
||||
this.movingIds = undefined
|
||||
this.#updateDraggedNodeIds(undefined)
|
||||
}
|
||||
|
||||
#computeDraggedNodeIds: ((moduleId: string) => Set<string>) | undefined
|
||||
#computeDraggedNodeIds: ((moduleIds: string[]) => Set<string>) | undefined
|
||||
|
||||
setComputeDraggedNodeIds(fn: (moduleId: string) => Set<string>) {
|
||||
setComputeDraggedNodeIds(fn: (moduleIds: string[]) => Set<string>) {
|
||||
this.#computeDraggedNodeIds = fn
|
||||
}
|
||||
|
||||
#updateDraggedNodeIds(moduleId: string | undefined) {
|
||||
#updateDraggedNodeIds(moduleIds: string[] | undefined) {
|
||||
this.draggedNodeIds =
|
||||
moduleId && this.#computeDraggedNodeIds ? this.#computeDraggedNodeIds(moduleId) : new Set()
|
||||
moduleIds && moduleIds.length > 0 && this.#computeDraggedNodeIds
|
||||
? this.#computeDraggedNodeIds(moduleIds)
|
||||
: new Set()
|
||||
}
|
||||
|
||||
#screenToFlowPosition: ((pos: { x: number; y: number }) => { x: number; y: number }) | undefined
|
||||
@@ -134,14 +148,19 @@ export class MoveManager {
|
||||
this.#registeredDropZones.delete(edgeId)
|
||||
}
|
||||
|
||||
startDrag(moduleId: string, screenX: number, screenY: number) {
|
||||
startDrag(moduleId: string, screenX: number, screenY: number, selectedIds?: string[]) {
|
||||
// Clear any active click-to-move so only drag mode is active
|
||||
this.movingModuleId = undefined
|
||||
this.dragging = { moduleId }
|
||||
this.dragging = { moduleId, selectedIds }
|
||||
this.ghostScreenX = screenX
|
||||
this.ghostScreenY = screenY
|
||||
this.nearestDropZone = undefined
|
||||
this.#updateDraggedNodeIds(moduleId)
|
||||
// Compute dragged node IDs for the primary module plus any additional selected modules
|
||||
const allIds =
|
||||
selectedIds && selectedIds.length > 0
|
||||
? [moduleId, ...selectedIds.filter((id) => id !== moduleId)]
|
||||
: [moduleId]
|
||||
this.#updateDraggedNodeIds(allIds)
|
||||
}
|
||||
|
||||
updateDrag(screenX: number, screenY: number) {
|
||||
|
||||
@@ -133,6 +133,13 @@ export class NoteManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected note ID
|
||||
*/
|
||||
get selectedNoteId(): string | undefined {
|
||||
return this.#selectedNoteId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a note is currently selected
|
||||
*/
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
|
||||
{#if moveManager?.movingModuleId && data?.insertable}
|
||||
<div class="edgeButtonContainer nodrag nopan" style:transform="translate(-50%, -50%)">
|
||||
{#if !data.disableMoveIds?.includes(moveManager.movingModuleId)}
|
||||
{#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some((id) => data.disableMoveIds?.includes(id))}
|
||||
<button
|
||||
title="Paste module"
|
||||
onclick={() => {
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
import type { Edge, Node } from '@xyflow/svelte'
|
||||
|
||||
import { getNodeColorClasses, NODE } from '../../util'
|
||||
import { globalDbManagerDrawer, userStore } from '$lib/stores'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { slide } from 'svelte/transition'
|
||||
import AssetColumnBadges from '$lib/components/assets/AssetColumnBadges.svelte'
|
||||
@@ -313,7 +313,6 @@
|
||||
noText
|
||||
buttonVariant="accent"
|
||||
s3FilePicker={flowGraphAssetsCtx?.val.s3FilePicker}
|
||||
dbManagerDrawer={globalDbManagerDrawer.val}
|
||||
_resourceMetadata={cachedResourceMetadata}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import MapItem from '$lib/components/flows/map/MapItem.svelte'
|
||||
import { GitBranchPlus } from 'lucide-svelte'
|
||||
import { GitBranchPlus, Move, Copy, Trash2, StickyNote } from 'lucide-svelte'
|
||||
import NodeWrapper from './NodeWrapper.svelte'
|
||||
import type { ModuleN } from '../../graphBuilder.svelte'
|
||||
import { jobToGraphModuleState } from '$lib/components/modulesTest.svelte'
|
||||
import { getNoteEditorContext } from '../../noteEditor.svelte'
|
||||
import type { ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
|
||||
import { addGroupNoteContextMenuItem } from '../../noteUtils.svelte'
|
||||
import { isMac, type Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
data: ModuleN['data']
|
||||
@@ -43,12 +42,54 @@
|
||||
})
|
||||
|
||||
// Define context menu items
|
||||
const contextMenuItems: ContextMenuItem[] = $derived(
|
||||
data.editMode ? [addGroupNoteContextMenuItem(data.id, noteEditorContext)] : []
|
||||
let noteDisabled = $derived(
|
||||
!noteEditorContext?.noteEditor ||
|
||||
(noteEditorContext?.noteEditor?.isNodeOnlyMemberOfGroupNote(data.id) ?? false)
|
||||
)
|
||||
|
||||
let isPreprocessor = $derived(data.id === 'preprocessor')
|
||||
|
||||
const menuItems: Item[] = $derived(
|
||||
data.editMode
|
||||
? [
|
||||
...(isPreprocessor
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Move',
|
||||
icon: Move,
|
||||
action: () => data.eventHandlers.move({ id: data.id })
|
||||
},
|
||||
{
|
||||
displayName: 'Duplicate',
|
||||
icon: Copy,
|
||||
action: () => data.eventHandlers.duplicate({ id: data.id })
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete' as const,
|
||||
shortcut: isMac() ? '⌫' : 'Del',
|
||||
action: () => data.eventHandlers.delete({ id: data.id }, '')
|
||||
},
|
||||
{
|
||||
displayName: 'Add note',
|
||||
icon: StickyNote,
|
||||
separatorTop: true,
|
||||
disabled: noteDisabled,
|
||||
action: () => {
|
||||
if (noteEditorContext?.noteEditor && !noteDisabled) {
|
||||
noteEditorContext.noteEditor.createGroupNote([data.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} {contextMenuItems}>
|
||||
<NodeWrapper offset={data.offset} {menuItems}>
|
||||
{#snippet children({ darkMode })}
|
||||
<MapItem
|
||||
moduleId={data.id}
|
||||
@@ -56,6 +97,7 @@
|
||||
insertable={data.insertable}
|
||||
editMode={data.editMode}
|
||||
moduleAction={data.moduleAction}
|
||||
{menuItems}
|
||||
annotation={flowJobs &&
|
||||
(data.module.value.type === 'forloopflow' || data.module.value.type === 'whileloopflow')
|
||||
? 'Iteration: ' +
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ContextMenu, { type ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
enableSourceHandle?: boolean
|
||||
@@ -11,6 +12,7 @@
|
||||
offset?: number
|
||||
wrapperClass?: string
|
||||
contextMenuItems?: ContextMenuItem[]
|
||||
menuItems?: Item[]
|
||||
/** xyflow node ID — used to fade nodes that are part of a moving subflow */
|
||||
nodeId?: string
|
||||
children?: import('svelte').Snippet<[any]>
|
||||
@@ -22,10 +24,27 @@
|
||||
offset = 0,
|
||||
wrapperClass = '',
|
||||
contextMenuItems = undefined,
|
||||
menuItems = undefined,
|
||||
nodeId = undefined,
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
let resolvedContextMenuItems: ContextMenuItem[] | undefined = $derived(
|
||||
contextMenuItems ??
|
||||
menuItems?.flatMap((item) => [
|
||||
...(item.separatorTop ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] : []),
|
||||
{
|
||||
id: item.displayName,
|
||||
label: item.displayName,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
type: item.type,
|
||||
shortcut: item.shortcut,
|
||||
onClick: item.action as (() => void) | undefined
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const { moveManager } = getGraphContext()
|
||||
|
||||
let faded = $derived(
|
||||
@@ -37,8 +56,8 @@
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
{#if contextMenuItems && contextMenuItems.length > 0}
|
||||
<ContextMenu items={contextMenuItems}>
|
||||
{#if resolvedContextMenuItems && resolvedContextMenuItems.length > 0}
|
||||
<ContextMenu items={resolvedContextMenuItems}>
|
||||
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
|
||||
{@render children?.({ darkMode })}
|
||||
</div>
|
||||
|
||||
@@ -78,13 +78,24 @@ export class SelectionManager {
|
||||
}
|
||||
|
||||
// If the new selection is the same as the current selection, do nothing
|
||||
if (JSON.stringify(nodes) === JSON.stringify($state.snapshot(this.#selectedNodes))) {
|
||||
const newIds = nodes.map((n) => n.id).join(',')
|
||||
const currentIds = this.#selectedNodes.map((n) => n.id).join(',')
|
||||
if (newIds === currentIds) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#selectedNodes = nodes
|
||||
}
|
||||
|
||||
// Select multiple nodes by their IDs
|
||||
selectByIds(ids: string[]) {
|
||||
if (!ids || ids.length === 0) {
|
||||
this.clearSelection()
|
||||
return
|
||||
}
|
||||
this.#selectedNodes = ids.map((id) => ({ id }))
|
||||
}
|
||||
|
||||
// Clear all selections
|
||||
clearSelection() {
|
||||
this.#selectedNodes = [{ id: 'settings' }]
|
||||
|
||||
@@ -331,6 +331,16 @@ export const settings: Record<string, Setting[]> = {
|
||||
storage: 'setting',
|
||||
ee_only: '',
|
||||
hiddenIfEmpty: true
|
||||
},
|
||||
{
|
||||
label: 'Disable Hub',
|
||||
description:
|
||||
'Disable the Windmill Hub integration entirely. Enable this if your instance runs in a closed environment without internet access and you do not have a private hub setup.',
|
||||
key: 'disable_hub',
|
||||
fieldType: 'boolean',
|
||||
storage: 'setting',
|
||||
ee_only: '',
|
||||
requiresReloadOnChange: true
|
||||
}
|
||||
],
|
||||
SMTP: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
flow: `/flows/get/${path}`,
|
||||
app: `/apps/get/${path}`,
|
||||
raw_app: `/apps_raw/get/${path}`,
|
||||
asset: `#dbmanager:${path}`
|
||||
asset: '#'
|
||||
}[kind]
|
||||
}
|
||||
export function getFavoriteLabel(path: string, kind: FavoriteKind): string {
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
import CustomInstanceDbWizardModal from './CustomInstanceDbWizardModal.svelte'
|
||||
import { ArrowRight, TriangleAlert } from 'lucide-svelte'
|
||||
import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import DBManagerDrawer from '../DBManagerDrawer.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
value: string | undefined
|
||||
customInstanceDbs: ResourceReturn<ListCustomInstanceDbsResponse>
|
||||
confirmationModal: ConfirmationModalHandle
|
||||
dbManagerDrawer: DBManagerDrawer | undefined
|
||||
wizardBottomHint?: Snippet | undefined
|
||||
class?: string
|
||||
tag?: CustomInstanceDbTag
|
||||
@@ -24,7 +22,6 @@
|
||||
value = $bindable(),
|
||||
customInstanceDbs,
|
||||
confirmationModal,
|
||||
dbManagerDrawer,
|
||||
wizardBottomHint,
|
||||
class: className,
|
||||
tag
|
||||
@@ -90,7 +87,6 @@
|
||||
<CustomInstanceDbWizardModal
|
||||
{customInstanceDbs}
|
||||
{confirmationModal}
|
||||
{dbManagerDrawer}
|
||||
{tag}
|
||||
bottomHint={wizardBottomHint}
|
||||
bind:opened={
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
type Props = {
|
||||
customInstanceDbs: ResourceReturn<ListCustomInstanceDbsResponse>
|
||||
confirmationModal: ConfirmationModalHandle
|
||||
dbManagerDrawer: any | undefined
|
||||
bottomHint?: Snippet | undefined
|
||||
opened: { status: CustomInstanceDb | undefined; dbname: string } | undefined
|
||||
tag?: CustomInstanceDbTag
|
||||
@@ -33,7 +32,6 @@
|
||||
let {
|
||||
customInstanceDbs,
|
||||
confirmationModal,
|
||||
dbManagerDrawer,
|
||||
bottomHint,
|
||||
opened = $bindable(),
|
||||
tag
|
||||
@@ -76,7 +74,6 @@
|
||||
class="flex-1"
|
||||
asset={{ kind: 'resource', path: 'CUSTOM_INSTANCE_DB/' + dbname }}
|
||||
_resourceMetadata={{ resource_type: 'postgresql' }}
|
||||
{dbManagerDrawer}
|
||||
disabled={!$isCustomInstanceDbEnabled || !enableManageButton}
|
||||
onClick={() => (opened = undefined)}
|
||||
/>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
import { random_adj } from '../random_positive_adjetive'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { SettingService, WorkspaceService, type GetSettingsResponse } from '$lib/gen'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { resource } from 'runed'
|
||||
@@ -141,7 +141,6 @@
|
||||
}
|
||||
|
||||
let confirmationModal = createAsyncConfirmationModal()
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
let dirtyMap = $derived.by(() => {
|
||||
const map: Record<string, boolean> = {}
|
||||
for (let i = 0; i < tempSettings.dataTables.length; i++) {
|
||||
@@ -245,7 +244,6 @@
|
||||
<CustomInstanceDbSelect
|
||||
class="flex-1"
|
||||
{confirmationModal}
|
||||
{dbManagerDrawer}
|
||||
{customInstanceDbs}
|
||||
bind:value={dataTable.database.resource_path}
|
||||
tag="datatable"
|
||||
@@ -265,7 +263,6 @@
|
||||
<ExploreAssetButton
|
||||
class="h-9"
|
||||
asset={{ kind: 'datatable', path: dataTable.name }}
|
||||
{dbManagerDrawer}
|
||||
disabled
|
||||
/>
|
||||
</svelte:fragment>
|
||||
@@ -275,7 +272,6 @@
|
||||
<ExploreAssetButton
|
||||
class="h-9"
|
||||
asset={{ kind: 'datatable', path: dataTable.name }}
|
||||
{dbManagerDrawer}
|
||||
/>
|
||||
{/if}
|
||||
</Cell>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
import { SettingService, WorkspaceService } from '$lib/gen'
|
||||
import type { GetSettingsResponse } from '$lib/gen'
|
||||
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import ExploreAssetButton from '../ExploreAssetButton.svelte'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
@@ -187,7 +187,6 @@
|
||||
'Where the data is actually stored, in parquet format. You need to configure a workspace storage first'
|
||||
}
|
||||
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
let confirmationModal = createAsyncConfirmationModal()
|
||||
</script>
|
||||
|
||||
@@ -293,7 +292,6 @@
|
||||
bind:value={ducklake.catalog.resource_path}
|
||||
{customInstanceDbs}
|
||||
{confirmationModal}
|
||||
{dbManagerDrawer}
|
||||
tag="ducklake"
|
||||
>
|
||||
{#snippet wizardBottomHint()}
|
||||
@@ -372,7 +370,6 @@
|
||||
{:else}
|
||||
<ExploreAssetButton
|
||||
asset={{ kind: 'ducklake', path: ducklake.name }}
|
||||
{dbManagerDrawer}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,13 @@ import { ChangeOnDeepInequality, MapResource } from './svelte5Utils.svelte'
|
||||
import { sqlDataTypeToJsTypeHeuristic } from './components/apps/components/display/dbtable/utils'
|
||||
import { chunkBy, clone, getQueryStmtCountHeuristic } from './utils'
|
||||
|
||||
function extractErrorMessage(e: unknown): string {
|
||||
if (e != null && typeof e === 'object' && 'body' in e) {
|
||||
return (e as any).body?.error?.message ?? JSON.stringify(e)
|
||||
}
|
||||
return e instanceof Error ? e.message : JSON.stringify(e)
|
||||
}
|
||||
|
||||
function computeQueryKey(query: InferAssetsSqlQueryDetails, workspace?: string) {
|
||||
return `${query.source_kind}::${query.source_name}::${query.source_schema}::${workspace}::${query.query_string}`
|
||||
}
|
||||
@@ -21,66 +28,26 @@ export function usePreparedAssetSqlQueries(
|
||||
),
|
||||
async (toFetch) => {
|
||||
let queries = Object.entries(clone(toFetch))
|
||||
// We only support preparing datatable source kinds for now.
|
||||
queries = queries.filter(([_, q]) => q.source_kind === 'datatable')
|
||||
queries = queries.filter(
|
||||
([_, q]) => q.source_kind === 'datatable' || q.source_kind === 'ducklake'
|
||||
)
|
||||
// We only support preparing single-statement queries for now.
|
||||
queries = queries.filter(([_, q]) => getQueryStmtCountHeuristic(q.query_string) === 1)
|
||||
|
||||
if (!queries?.length) return {}
|
||||
try {
|
||||
// We chunk by source_name to minimize the number of requests.
|
||||
// For example if we have 10 queries on the same data table,
|
||||
// we can prepare them all with a single script.
|
||||
queries.sort((a, b) => a[1].source_name.localeCompare(b[1].source_name))
|
||||
let results = (
|
||||
await Promise.all(
|
||||
chunkBy(queries, ([key, q]) => q.source_name).map(async (chunk) => {
|
||||
console.log(
|
||||
'Preparing chunk of queries:',
|
||||
chunk.map(([_, q]) => q)
|
||||
)
|
||||
let queryContent = chunk
|
||||
.flatMap(([key, q]) => [
|
||||
q.source_schema ? `SET search_path TO ${q.source_schema};` : 'RESET search_path;',
|
||||
q.query_string + (q.query_string.trim().endsWith(';') ? '' : ';')
|
||||
])
|
||||
.join('\n')
|
||||
queryContent =
|
||||
'-- prepare\n--result_collection=all_statements_first_row\n' + queryContent
|
||||
let datatableQueries = queries.filter(([_, q]) => q.source_kind === 'datatable')
|
||||
let ducklakeQueries = queries.filter(([_, q]) => q.source_kind === 'ducklake')
|
||||
|
||||
let res = (await JobService.runScriptPreviewAndWaitResult({
|
||||
workspace: getWorkspace()!,
|
||||
requestBody: {
|
||||
language: 'postgresql',
|
||||
content: queryContent,
|
||||
args: { database: `datatable://${chunk[0][1]?.source_name}` }
|
||||
}
|
||||
})) as { error?: string; columns?: { name: string; type: string }[] }[]
|
||||
let allResults: [string, PreparedAssetsSqlQuery][] = []
|
||||
|
||||
console.log('Prepared query content:', res)
|
||||
|
||||
let res2: [string, PreparedAssetsSqlQuery][] = res.map((r, i) => [
|
||||
chunk[i][0],
|
||||
r.columns
|
||||
? {
|
||||
columns: Object.fromEntries(
|
||||
r.columns.map(({ name, type }) => [
|
||||
name,
|
||||
sqlDataTypeToJsTypeHeuristic(type)
|
||||
])
|
||||
)
|
||||
}
|
||||
: { error: r.error ?? "Couldn't prepare query " }
|
||||
])
|
||||
return res2
|
||||
})
|
||||
)
|
||||
).flat()
|
||||
|
||||
return Object.fromEntries(results)
|
||||
} catch (e) {
|
||||
throw e
|
||||
if (datatableQueries.length) {
|
||||
allResults.push(...(await prepareDatatableQueries(datatableQueries, getWorkspace)))
|
||||
}
|
||||
if (ducklakeQueries.length) {
|
||||
allResults.push(...(await prepareDucklakeQueries(ducklakeQueries, getWorkspace)))
|
||||
}
|
||||
|
||||
return Object.fromEntries(allResults)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -96,3 +63,104 @@ export function usePreparedAssetSqlQueries(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type QueryEntry = [string, InferAssetsSqlQueryDetails]
|
||||
|
||||
function mapPrepareResults(
|
||||
res: { error?: string; columns?: { name: string; type: string }[] }[],
|
||||
chunk: QueryEntry[]
|
||||
): [string, PreparedAssetsSqlQuery][] {
|
||||
if (res.length !== chunk.length) {
|
||||
throw new Error(`Prepare results count mismatch: got ${res.length}, expected ${chunk.length}`)
|
||||
}
|
||||
return res.map((r, i) => [
|
||||
chunk[i]?.[0],
|
||||
r.columns
|
||||
? {
|
||||
columns: Object.fromEntries(
|
||||
r.columns.map(({ name, type: t }) => [name, sqlDataTypeToJsTypeHeuristic(t)])
|
||||
)
|
||||
}
|
||||
: { error: r.error ?? "Couldn't prepare query " }
|
||||
])
|
||||
}
|
||||
|
||||
async function prepareDatatableQueries(
|
||||
queries: QueryEntry[],
|
||||
getWorkspace: () => string | undefined
|
||||
): Promise<[string, PreparedAssetsSqlQuery][]> {
|
||||
queries.sort((a, b) => a[1].source_name.localeCompare(b[1].source_name))
|
||||
let results = (
|
||||
await Promise.all(
|
||||
chunkBy(queries, ([_, q]) => q.source_name).map(async (chunk) => {
|
||||
let queryContent = chunk
|
||||
.flatMap(([_, q]) => [
|
||||
q.source_schema ? `SET search_path TO ${q.source_schema};` : 'RESET search_path;',
|
||||
q.query_string + (q.query_string.trim().endsWith(';') ? '' : ';')
|
||||
])
|
||||
.join('\n')
|
||||
queryContent = '-- prepare\n--result_collection=all_statements_first_row\n' + queryContent
|
||||
|
||||
try {
|
||||
let res = (await JobService.runScriptPreviewAndWaitResult({
|
||||
workspace: getWorkspace()!,
|
||||
requestBody: {
|
||||
language: 'postgresql',
|
||||
content: queryContent,
|
||||
args: { database: `datatable://${chunk[0][1]?.source_name}` }
|
||||
}
|
||||
})) as { error?: string; columns?: { name: string; type: string }[] }[]
|
||||
|
||||
return mapPrepareResults(res, chunk)
|
||||
} catch (e) {
|
||||
const error = extractErrorMessage(e)
|
||||
return chunk.map(([key]) => [key, { error }] as [string, PreparedAssetsSqlQuery])
|
||||
}
|
||||
})
|
||||
)
|
||||
).flat()
|
||||
return results
|
||||
}
|
||||
|
||||
async function prepareDucklakeQueries(
|
||||
queries: QueryEntry[],
|
||||
getWorkspace: () => string | undefined
|
||||
): Promise<[string, PreparedAssetsSqlQuery][]> {
|
||||
queries.sort((a, b) => a[1].source_name.localeCompare(b[1].source_name))
|
||||
let results = (
|
||||
await Promise.all(
|
||||
chunkBy(queries, ([_, q]) => `${q.source_name}::${q.source_schema ?? ''}`).map(
|
||||
async (chunk) => {
|
||||
let sourceName = chunk[0][1].source_name
|
||||
let sourceSchema = chunk[0][1].source_schema
|
||||
let attachSetup = `ATTACH 'ducklake://${sourceName}' AS dl;\n`
|
||||
attachSetup += sourceSchema ? `USE dl.${sourceSchema};\n` : `USE dl;\n`
|
||||
|
||||
let queryContent = chunk
|
||||
.map(([_, q]) => q.query_string + (q.query_string.trim().endsWith(';') ? '' : ';'))
|
||||
.join('\n')
|
||||
queryContent =
|
||||
'-- prepare\n--result_collection=all_statements_first_row\n' +
|
||||
attachSetup +
|
||||
queryContent
|
||||
|
||||
try {
|
||||
let res = (await JobService.runScriptPreviewAndWaitResult({
|
||||
workspace: getWorkspace()!,
|
||||
requestBody: {
|
||||
language: 'duckdb',
|
||||
content: queryContent,
|
||||
args: {}
|
||||
}
|
||||
})) as { error?: string; columns?: { name: string; type: string }[] }[]
|
||||
return mapPrepareResults(res, chunk)
|
||||
} catch (e) {
|
||||
const error = extractErrorMessage(e)
|
||||
return chunk.map(([key]) => [key, { error }] as [string, PreparedAssetsSqlQuery])
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ def main():
|
||||
# wmill.setState(newState)
|
||||
# 4. Return the new rows
|
||||
# return range from (state to newState)
|
||||
#
|
||||
# For more complex states, consider using Data Tables:
|
||||
# https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables
|
||||
return [1, 2, 3]`
|
||||
|
||||
const PYTHON_INIT_CODE = `import os
|
||||
@@ -554,6 +557,9 @@ export async function main() {
|
||||
// await wmill.setState(newState)
|
||||
// 4. Return the new rows
|
||||
// return range from (state to newState)
|
||||
//
|
||||
// For more complex states, consider using Data Tables:
|
||||
// https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables
|
||||
|
||||
return [1,2,3]
|
||||
|
||||
@@ -575,6 +581,9 @@ export async function main() {
|
||||
// await wmill.setState(newState)
|
||||
// 4. Return the new rows
|
||||
// return range from (state to newState)
|
||||
//
|
||||
// For more complex states, consider using Data Tables:
|
||||
// https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables
|
||||
|
||||
return [1,2,3]
|
||||
|
||||
@@ -599,6 +608,9 @@ func main() (interface{}, error) {
|
||||
// 3. Compare the two states and update the internal state
|
||||
wmill.SetState(4)
|
||||
// 4. Return the new rows
|
||||
//
|
||||
// For more complex states, consider using Data Tables:
|
||||
// https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables
|
||||
|
||||
return state, nil
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { getLocalSetting, type StateStore } from './utils'
|
||||
import { createState } from './svelte5Utils.svelte'
|
||||
import { DEFAULT_HUB_BASE_URL } from './hub'
|
||||
import type { DbManagerUriState } from './components/dbManagerDrawerModel.svelte'
|
||||
|
||||
export interface UserExt {
|
||||
email: string
|
||||
@@ -82,6 +83,7 @@ export const superadmin = writable<string | false | undefined>(undefined)
|
||||
export const devopsRole = writable<string | false | undefined>(undefined)
|
||||
export const lspTokenStore = writable<string | undefined>(undefined)
|
||||
export const hubBaseUrlStore = writable<string>(DEFAULT_HUB_BASE_URL)
|
||||
export const disableHubStore = writable<boolean>(false)
|
||||
export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
|
||||
[usersWorkspaceStore, superadmin],
|
||||
([store, superadmin]) => {
|
||||
@@ -126,9 +128,7 @@ export const codeCompletionSessionEnabled = writable<boolean>(
|
||||
|
||||
export const usedTriggerKinds = writable<string[]>([])
|
||||
|
||||
export let globalDbManagerDrawer: StateStore<any | undefined> = createState({
|
||||
val: undefined
|
||||
})
|
||||
export let globalDbManagerDrawer: StateStore<DbManagerUriState | undefined> = { val: undefined }
|
||||
|
||||
type SQLBaseSchema = {
|
||||
[schemaKey: string]: {
|
||||
|
||||
@@ -107,9 +107,10 @@ export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsRes
|
||||
} else {
|
||||
sp.set(key, serializeParam(v))
|
||||
}
|
||||
const hash = window.location.hash
|
||||
const newUrl = sp.toString()
|
||||
? `${window.location.pathname}?${sp}`
|
||||
: window.location.pathname
|
||||
? `${window.location.pathname}?${sp}${hash}`
|
||||
: `${window.location.pathname}${hash}`
|
||||
history.replaceState(history.state, '', newUrl)
|
||||
},
|
||||
enumerable: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ export { sendUserToast }
|
||||
import type { AnyMeltElement } from '@melt-ui/svelte'
|
||||
import type { TriggerKind } from './components/triggers'
|
||||
import { stateSnapshot } from './svelte5Utils.svelte'
|
||||
|
||||
export namespace OpenApi {
|
||||
export enum OpenApiVersion {
|
||||
V2,
|
||||
@@ -1495,6 +1496,7 @@ export type Item = {
|
||||
tooltip?: string
|
||||
separatorTop?: boolean
|
||||
submenuItems?: Item[]
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
export function isObjectTooBig(obj: any): boolean {
|
||||
|
||||
@@ -11,13 +11,7 @@
|
||||
UserService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
capitalize,
|
||||
classNames,
|
||||
getModifierKey,
|
||||
parseDbInputFromAssetSyntax,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils'
|
||||
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
|
||||
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
|
||||
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
|
||||
@@ -32,6 +26,7 @@
|
||||
type UserExt,
|
||||
defaultScripts,
|
||||
hubBaseUrlStore,
|
||||
disableHubStore,
|
||||
usedTriggerKinds,
|
||||
devopsRole,
|
||||
whitelabelNameStore,
|
||||
@@ -66,8 +61,8 @@
|
||||
import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte'
|
||||
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
|
||||
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
|
||||
import { watchOnce } from 'runed'
|
||||
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
|
||||
import { useDbManagerUriState } from '$lib/components/dbManagerDrawerModel.svelte'
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
@@ -163,6 +158,7 @@
|
||||
loadUsage()
|
||||
syncTutorialsTodos()
|
||||
loadHubBaseUrl()
|
||||
loadDisableHub()
|
||||
loadUsedTriggerKinds()
|
||||
}
|
||||
|
||||
@@ -182,6 +178,11 @@
|
||||
DEFAULT_HUB_BASE_URL
|
||||
}
|
||||
|
||||
async function loadDisableHub() {
|
||||
$disableHubStore =
|
||||
((await SettingService.getGlobal({ key: 'disable_hub' })) as boolean) ?? false
|
||||
}
|
||||
|
||||
async function loadFavorites() {
|
||||
const scripts = await ScriptService.listScripts({
|
||||
workspace: $workspaceStore ?? '',
|
||||
@@ -439,18 +440,8 @@
|
||||
untrack(() => loadProtectionRules(workspace))
|
||||
}
|
||||
})
|
||||
watchOnce(
|
||||
() => globalDbManagerDrawer.val,
|
||||
() => {
|
||||
if (!globalDbManagerDrawer.val) return
|
||||
const hash = window.location.hash
|
||||
if (hash.startsWith('#dbmanager:')) {
|
||||
const [_, path] = hash.split('#dbmanager:')
|
||||
const dbInput = parseDbInputFromAssetSyntax(path)
|
||||
if (dbInput) globalDbManagerDrawer.val?.openDrawer(dbInput)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
globalDbManagerDrawer.val = useDbManagerUriState()
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
@@ -786,6 +777,6 @@
|
||||
<CenteredModal title="Loading user..." loading={true}></CenteredModal>
|
||||
{/if}
|
||||
|
||||
{#if $workspaceStore}
|
||||
<DBManagerDrawer bind:this={globalDbManagerDrawer.val} />
|
||||
{#if $workspaceStore && globalDbManagerDrawer.val}
|
||||
<DBManagerDrawer uriState={globalDbManagerDrawer.val} />
|
||||
{/if}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
let assets = $derived(_assets.current?.flatMap((page) => page.assets))
|
||||
|
||||
let s3FilePicker: S3FilePicker | undefined = $state()
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any
|
||||
let assetsUsageDropdown: AssetsUsageDrawer | undefined = $state()
|
||||
|
||||
let allS3Storages = resource(
|
||||
@@ -192,7 +191,6 @@
|
||||
<ExploreAssetButton
|
||||
asset={{ kind: props.assetKind, path: item.value }}
|
||||
{s3FilePicker}
|
||||
{dbManagerDrawer}
|
||||
btnClasses="dark:bg-surface"
|
||||
/>
|
||||
</div>
|
||||
@@ -337,7 +335,6 @@
|
||||
<ExploreAssetButton
|
||||
{asset}
|
||||
{s3FilePicker}
|
||||
{dbManagerDrawer}
|
||||
_resourceMetadata={asset.metadata}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
enterpriseLicense,
|
||||
userStore,
|
||||
workspaceStore,
|
||||
userWorkspaces,
|
||||
globalDbManagerDrawer
|
||||
userWorkspaces
|
||||
} from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import {
|
||||
@@ -557,8 +556,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any
|
||||
|
||||
let showTable = $derived(
|
||||
tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'
|
||||
)
|
||||
@@ -1064,7 +1061,6 @@
|
||||
{#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator}
|
||||
<ExploreAssetButton
|
||||
asset={{ kind: 'resource', path }}
|
||||
{dbManagerDrawer}
|
||||
_resourceMetadata={{ resource_type }}
|
||||
class="w-24"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -61,7 +61,7 @@ if command -v psql &>/dev/null; then
|
||||
if psql "$db_conn/postgres" -tc "SELECT 1 FROM pg_database WHERE datname = '${db_name}'" 2>/dev/null | grep -q 1; then
|
||||
echo "Database $db_name already exists"
|
||||
else
|
||||
if [[ -n "${WM_CLONE_DB:-}" ]]; then
|
||||
if [[ "${WM_CLONE_DB:-}" == "1" || "${WM_CLONE_DB:-}" == "true" ]]; then
|
||||
# Terminate active connections so CREATE DATABASE ... TEMPLATE works
|
||||
psql "$db_conn/postgres" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'windmill' AND pid <> pg_backend_pid();" 2>/dev/null || true
|
||||
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name} TEMPLATE windmill" 2>/dev/null \
|
||||
@@ -178,13 +178,20 @@ if [ -n "$ee_repo" ]; then
|
||||
if [ -d "$ee_worktree_dir" ]; then
|
||||
ee_rel=$(python3 -c "import os; print(os.path.relpath('$ee_worktree_dir', '$(pwd)'))" 2>/dev/null || echo "$ee_worktree_dir")
|
||||
mkdir -p .claude
|
||||
rust_plugin=""
|
||||
if [[ "${USE_RUST_PLUGIN:-}" == "1" || "${USE_RUST_PLUGIN:-}" == "true" ]]; then
|
||||
rust_plugin=',
|
||||
"enabledPlugins": {
|
||||
"rust-analyzer-lsp@claude-plugins-official": true
|
||||
}'
|
||||
fi
|
||||
cat > .claude/settings.local.json <<EOFCLAUDE
|
||||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"$ee_rel"
|
||||
]
|
||||
}
|
||||
}${rust_plugin}
|
||||
}
|
||||
EOFCLAUDE
|
||||
echo "Created .claude/settings.local.json with EE path: $ee_rel"
|
||||
|
||||
Reference in New Issue
Block a user