mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 16:00:38 +00:00
Db draft removal
This commit is contained in:
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
@@ -48,7 +48,6 @@ use windmill_common::{
|
||||
flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow},
|
||||
jobs::JobPayload,
|
||||
schedule::Schedule,
|
||||
scripts::Schema,
|
||||
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
|
||||
};
|
||||
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
@@ -67,7 +66,6 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list_tokens/{*path}", get(list_tokens))
|
||||
.route("/get/{*path}", get(get_flow_by_path))
|
||||
.route("/deployment_status/p/{*path}", get(get_deployment_status))
|
||||
.route("/get/draft/{*path}", get(get_flow_by_path_w_draft))
|
||||
.route("/exists/{*path}", get(exists_flow_by_path))
|
||||
.route("/list_paths", get(list_paths))
|
||||
.route("/history/p/{*path}", get(get_flow_history))
|
||||
@@ -148,7 +146,6 @@ async fn list_flows(
|
||||
"archived",
|
||||
"extra_perms",
|
||||
"favorite.path IS NOT NULL as starred",
|
||||
"draft.path IS NOT NULL as has_draft",
|
||||
"draft_only",
|
||||
"ws_error_handler_muted",
|
||||
"o.labels"
|
||||
@@ -160,11 +157,6 @@ async fn list_flows(
|
||||
.bind(&authed.username),
|
||||
)
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'"
|
||||
)
|
||||
.left()
|
||||
.join("flow_version fv")
|
||||
.on(
|
||||
"fv.id = o.versions[array_upper(o.versions, 1)]"
|
||||
@@ -703,18 +695,6 @@ async fn check_schedule_conflict<'c>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
|
||||
return windmill_api_auth::require_is_writer(
|
||||
authed,
|
||||
path,
|
||||
w_id,
|
||||
db,
|
||||
"SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2",
|
||||
"flow",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FlowVersion {
|
||||
pub id: i64,
|
||||
@@ -1470,81 +1450,6 @@ async fn get_flow_by_path(
|
||||
Ok(Json(flow))
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct FlowWDraft {
|
||||
pub path: String,
|
||||
pub summary: String,
|
||||
pub description: String,
|
||||
pub schema: Option<Schema>,
|
||||
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
|
||||
pub extra_perms: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
|
||||
/// Timestamp at which the most recent DB draft was created.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dedicated_worker: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visible_to_runner_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_behalf_of_email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
async fn get_flow_by_path_w_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<FlowWDraft> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let flow_o = sqlx::query_as::<_, FlowWDraft>(
|
||||
"SELECT
|
||||
flow.path,
|
||||
flow.summary,
|
||||
flow.description,
|
||||
flow_version.schema,
|
||||
flow_version.value,
|
||||
flow.extra_perms,
|
||||
flow.draft_only,
|
||||
flow.ws_error_handler_muted,
|
||||
flow.dedicated_worker,
|
||||
draft.value AS draft,
|
||||
draft.created_at AS draft_created_at,
|
||||
flow.tag,
|
||||
flow.visible_to_runner_only,
|
||||
flow.on_behalf_of_email,
|
||||
flow.labels
|
||||
FROM flow
|
||||
LEFT JOIN draft
|
||||
ON flow.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
AND draft.typ = 'flow'
|
||||
LEFT JOIN flow_version
|
||||
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
WHERE flow.path = $1
|
||||
AND flow.workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let flow = not_found_if_none(flow_o, "Flow", path)?;
|
||||
Ok(Json(flow))
|
||||
}
|
||||
|
||||
async fn exists_flow_by_path(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
|
||||
@@ -82,12 +82,6 @@ async fn test_app_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let resp = authed_get(port, "get/p", "u/test-user/nonexistent").await;
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
// --- get draft ---
|
||||
let resp = authed_get(port, "get/draft", "u/test-user/test_app").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["path"], "u/test-user/test_app");
|
||||
|
||||
// --- get lite ---
|
||||
let resp = authed_get(port, "get/lite", "u/test-user/test_app").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_draft_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/drafts");
|
||||
|
||||
// create a script first so the draft has a valid path
|
||||
let resp = authed(client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
)))
|
||||
.json(&json!({
|
||||
"path": "u/test-user/draft_script",
|
||||
"summary": "Script for draft test",
|
||||
"description": "",
|
||||
"content": "export async function main() { return 1; }",
|
||||
"language": "deno",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?);
|
||||
|
||||
// --- create draft ---
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&json!({
|
||||
"path": "u/test-user/draft_script",
|
||||
"typ": "script",
|
||||
"value": {
|
||||
"content": "export async function main() { return 2; }",
|
||||
"language": "deno"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "create draft: {}", resp.text().await?);
|
||||
|
||||
// verify draft exists via script get/draft endpoint
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert!(body["draft"].is_object(), "expected draft to be present");
|
||||
|
||||
// --- update draft (create with same path overwrites) ---
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&json!({
|
||||
"path": "u/test-user/draft_script",
|
||||
"typ": "script",
|
||||
"value": {
|
||||
"content": "export async function main() { return 3; }",
|
||||
"language": "deno"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
|
||||
// --- delete draft ---
|
||||
let resp = authed(client().delete(format!(
|
||||
"{base}/delete/script/u/test-user/draft_script"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// verify draft is gone
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert!(body["draft"].is_null(), "expected draft to be deleted");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -82,12 +82,6 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let resp = authed_get(port, "get", "u/test-user/nonexistent").await;
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
// --- get draft ---
|
||||
let resp = authed_get(port, "get/draft", "u/test-user/test_flow").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["path"], "u/test-user/test_flow");
|
||||
|
||||
// --- list ---
|
||||
let resp = authed(client().get(format!("{base}/list")))
|
||||
.send()
|
||||
@@ -259,12 +253,10 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// ===== Hub endpoints (require external network, expect 500 or 200) =====
|
||||
|
||||
// --- hub/list ---
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/flows/hub/list"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/list")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == 200 || resp.status() == 500,
|
||||
"hub/list: unexpected status {}",
|
||||
@@ -272,12 +264,10 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// --- hub/get ---
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/flows/hub/get/1"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/get/1")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == 200 || resp.status() == 500,
|
||||
"hub/get: unexpected status {}",
|
||||
|
||||
@@ -98,12 +98,6 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["path"], "u/test-user/test_script");
|
||||
|
||||
// --- get draft ---
|
||||
let resp = authed_get(port, "get/draft", "u/test-user/test_script").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["path"], "u/test-user/test_script");
|
||||
|
||||
// --- raw by path (requires language extension) ---
|
||||
let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
@@ -32,7 +32,6 @@ use itertools::Itertools;
|
||||
use quick_cache::sync::Cache;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::value::RawValue;
|
||||
use sql_builder::prelude::*;
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
@@ -44,7 +43,7 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
use windmill_common::{
|
||||
assets::{
|
||||
clear_static_asset_usage, clear_static_asset_usage_by_script_hash,
|
||||
insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType,
|
||||
insert_static_asset_usage, AssetUsageKind,
|
||||
},
|
||||
error::{self, to_anyhow},
|
||||
min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2},
|
||||
@@ -81,122 +80,6 @@ use windmill_queue::{
|
||||
|
||||
const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20;
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct ScriptWDraft<SR> {
|
||||
pub hash: ScriptHash,
|
||||
pub path: String,
|
||||
pub summary: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub language: ScriptLang,
|
||||
pub kind: ScriptKind,
|
||||
pub tag: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
/// Timestamp at which the most recent DB draft was created.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub schema: Option<Schema>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub envs: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_ttl: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_ignore_s3_path: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dedicated_worker: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restart_unless_cancelled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_use: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_secs: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visible_to_runner_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub has_preprocessor: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_behalf_of_email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[sqlx(json(nullable))]
|
||||
pub assets: Option<Vec<AssetWithAltAccessType>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[sqlx(json(nullable))]
|
||||
pub modules: Option<HashMap<String, ScriptModule>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
#[sqlx(flatten)]
|
||||
pub runnable_settings: SR,
|
||||
}
|
||||
|
||||
impl ScriptWDraft<ScriptRunnableSettingsHandle> {
|
||||
pub async fn prefetch_cached<'a>(
|
||||
self,
|
||||
db: &DB,
|
||||
) -> error::Result<ScriptWDraft<ScriptRunnableSettingsInline>> {
|
||||
let (debouncing_settings, concurrency_settings) =
|
||||
windmill_common::runnable_settings::prefetch_cached_from_handle(
|
||||
self.runnable_settings.runnable_settings_handle,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ScriptWDraft {
|
||||
runnable_settings: ScriptRunnableSettingsInline {
|
||||
concurrency_settings: concurrency_settings.maybe_fallback(
|
||||
self.runnable_settings.concurrency_key,
|
||||
self.runnable_settings.concurrent_limit,
|
||||
self.runnable_settings.concurrency_time_window_s,
|
||||
),
|
||||
debouncing_settings: debouncing_settings.maybe_fallback(
|
||||
self.runnable_settings.debounce_key,
|
||||
self.runnable_settings.debounce_delay_s,
|
||||
),
|
||||
},
|
||||
hash: self.hash,
|
||||
path: self.path,
|
||||
summary: self.summary,
|
||||
description: self.description,
|
||||
content: self.content,
|
||||
language: self.language,
|
||||
kind: self.kind,
|
||||
tag: self.tag,
|
||||
draft: self.draft,
|
||||
draft_created_at: self.draft_created_at,
|
||||
schema: self.schema,
|
||||
draft_only: self.draft_only,
|
||||
envs: self.envs,
|
||||
cache_ttl: self.cache_ttl,
|
||||
cache_ignore_s3_path: self.cache_ignore_s3_path,
|
||||
dedicated_worker: self.dedicated_worker,
|
||||
ws_error_handler_muted: self.ws_error_handler_muted,
|
||||
priority: self.priority,
|
||||
restart_unless_cancelled: self.restart_unless_cancelled,
|
||||
delete_after_use: self.delete_after_use,
|
||||
delete_after_secs: self.delete_after_secs,
|
||||
timeout: self.timeout,
|
||||
visible_to_runner_only: self.visible_to_runner_only,
|
||||
auto_kind: self.auto_kind,
|
||||
has_preprocessor: self.has_preprocessor,
|
||||
on_behalf_of_email: self.on_behalf_of_email,
|
||||
assets: self.assets,
|
||||
modules: self.modules,
|
||||
labels: self.labels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/top", get(get_top_hub_scripts))
|
||||
@@ -221,7 +104,6 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/create", post(create_script))
|
||||
.route("/create_snapshot", post(create_snapshot_script))
|
||||
.route("/archive/p/{*path}", post(archive_script_by_path))
|
||||
.route("/get/draft/{*path}", get(get_script_by_path_w_draft))
|
||||
.route("/get/p/{*path}", get(get_script_by_path))
|
||||
.route("/list_tokens/{*path}", get(list_tokens))
|
||||
.route("/raw/p/{*path}", get(raw_script_by_path))
|
||||
@@ -302,7 +184,7 @@ async fn list_scripts(
|
||||
"hash",
|
||||
"o.path",
|
||||
"summary",
|
||||
"COALESCE(draft.created_at, o.created_at) as created_at",
|
||||
"o.created_at as created_at",
|
||||
"archived",
|
||||
"extra_perms",
|
||||
if !lq.without_description.unwrap_or(false) {
|
||||
@@ -314,7 +196,6 @@ async fn list_scripts(
|
||||
"language",
|
||||
"favorite.path IS NOT NULL as starred",
|
||||
"tag",
|
||||
"draft.path IS NOT NULL as has_draft",
|
||||
"draft_only",
|
||||
"ws_error_handler_muted",
|
||||
"auto_kind",
|
||||
@@ -328,11 +209,6 @@ async fn list_scripts(
|
||||
"favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?"
|
||||
.bind(&authed.username),
|
||||
)
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'"
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
||||
.and_where("o.workspace_id = ?".bind(&w_id))
|
||||
@@ -1814,32 +1690,6 @@ async fn list_tokens(
|
||||
list_tokens_internal(&db, &w_id, &path, false).await
|
||||
}
|
||||
|
||||
async fn get_script_by_path_w_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<ScriptWDraft<ScriptRunnableSettingsInline>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("scripts:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let script_o = sqlx::query_as::<_, ScriptWDraft<ScriptRunnableSettingsHandle>>(
|
||||
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
|
||||
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script'
|
||||
WHERE script.path = $1 AND script.workspace_id = $2
|
||||
ORDER BY script.created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let script = not_found_if_none(script_o, "Script", path)?;
|
||||
Ok(Json(script.prefetch_cached(&db).await?))
|
||||
}
|
||||
|
||||
async fn get_script_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -2404,18 +2254,6 @@ async fn get_deployment_status(
|
||||
Ok(Json(deployment_status))
|
||||
}
|
||||
|
||||
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
|
||||
return windmill_api_auth::require_is_writer(
|
||||
authed,
|
||||
path,
|
||||
w_id,
|
||||
db,
|
||||
"SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
|
||||
"script",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn archive_script_by_path(
|
||||
authed: ApiAuthed,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
|
||||
@@ -7802,65 +7802,6 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/drafts/create:
|
||||
post:
|
||||
summary: create draft
|
||||
operationId: createDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
type: string
|
||||
enum: ["flow", "script", "app"]
|
||||
value: {}
|
||||
required:
|
||||
- path
|
||||
- typ
|
||||
- enum
|
||||
responses:
|
||||
"201":
|
||||
description: draft created
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/drafts/delete/{kind}/{path}:
|
||||
delete:
|
||||
summary: delete draft
|
||||
operationId: deleteDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- script
|
||||
- flow
|
||||
- app
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: draft deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/scripts/create:
|
||||
post:
|
||||
summary: create script
|
||||
@@ -8282,23 +8223,6 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/TruncatedToken"
|
||||
|
||||
/w/{workspace}/scripts/get/draft/{path}:
|
||||
get:
|
||||
summary: get script by path with draft
|
||||
operationId: getScriptByPathWithDraft
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: script details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NewScriptWithDraft"
|
||||
|
||||
/w/{workspace}/scripts/history/p/{path}:
|
||||
get:
|
||||
summary: get history of a script by path
|
||||
@@ -9447,8 +9371,6 @@ paths:
|
||||
- $ref: "#/components/schemas/Flow"
|
||||
- type: object
|
||||
properties:
|
||||
has_draft:
|
||||
type: boolean
|
||||
draft_only:
|
||||
type: boolean
|
||||
|
||||
@@ -9674,32 +9596,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/get/draft/{path}:
|
||||
get:
|
||||
summary: get flow by path with draft
|
||||
operationId: getFlowByPathWithDraft
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: flow details with draft
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Flow"
|
||||
- type: object
|
||||
properties:
|
||||
draft:
|
||||
$ref: "#/components/schemas/Flow"
|
||||
draft_created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
|
||||
|
||||
/w/{workspace}/flows/exists/{path}:
|
||||
get:
|
||||
summary: exists flow by path
|
||||
@@ -10414,23 +10310,6 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersion"
|
||||
|
||||
/w/{workspace}/apps/get/draft/{path}:
|
||||
get:
|
||||
summary: get app by path with draft
|
||||
operationId: getAppByPathWithDraft
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: app details with draft
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersionWDraft"
|
||||
|
||||
/w/{workspace}/apps/history/p/{path}:
|
||||
get:
|
||||
summary: get app history by path
|
||||
@@ -21692,8 +21571,6 @@ components:
|
||||
type: boolean
|
||||
tag:
|
||||
type: string
|
||||
has_draft:
|
||||
type: boolean
|
||||
draft_only:
|
||||
type: boolean
|
||||
envs:
|
||||
@@ -21890,22 +21767,6 @@ components:
|
||||
- content
|
||||
- language
|
||||
|
||||
NewScriptWithDraft:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/NewScript"
|
||||
- type: object
|
||||
properties:
|
||||
draft:
|
||||
$ref: "#/components/schemas/NewScript"
|
||||
draft_created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
|
||||
hash:
|
||||
type: string
|
||||
required:
|
||||
- hash
|
||||
|
||||
ScriptHistory:
|
||||
type: object
|
||||
properties:
|
||||
@@ -26976,19 +26837,6 @@ components:
|
||||
- raw_app
|
||||
|
||||
|
||||
AppWithLastVersionWDraft:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AppWithLastVersion"
|
||||
- type: object
|
||||
properties:
|
||||
draft_only:
|
||||
type: boolean
|
||||
draft: {}
|
||||
draft_created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
|
||||
|
||||
AppHistory:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -88,7 +88,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
|
||||
.route("/list_search", get(list_search_apps))
|
||||
.route("/get/p/{*path}", get(get_app))
|
||||
.route("/get/lite/{*path}", get(get_app_lite))
|
||||
.route("/get/draft/{*path}", get(get_app_w_draft))
|
||||
.route("/secret_of/{*path}", get(get_secret_id))
|
||||
.route(
|
||||
"/secret_of_latest_version/{*path}",
|
||||
@@ -153,7 +152,6 @@ pub struct ListableApp {
|
||||
pub execution_mode: String,
|
||||
pub starred: bool,
|
||||
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub has_draft: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[sqlx(default)]
|
||||
@@ -208,20 +206,6 @@ pub struct AppWithLastVersionAndStarred {
|
||||
pub starred: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, FromRow)]
|
||||
pub struct AppWithLastVersionAndDraft {
|
||||
#[sqlx(flatten)]
|
||||
#[serde(flatten)]
|
||||
pub app: AppWithLastVersion,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
/// Timestamp at which the most recent DB draft was created.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AppHistory {
|
||||
pub app_id: i64,
|
||||
@@ -371,7 +355,6 @@ async fn list_apps(
|
||||
"app_version.created_at as edited_at",
|
||||
"app.extra_perms",
|
||||
"favorite.path IS NOT NULL as starred",
|
||||
"draft.path IS NOT NULL as has_draft",
|
||||
"draft_only",
|
||||
"app_version.raw_app",
|
||||
"app.labels",
|
||||
@@ -387,11 +370,6 @@ async fn list_apps(
|
||||
.on(
|
||||
"app_version.id = versions[array_upper(versions, 1)]"
|
||||
)
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'"
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("app_version.created_at", true)
|
||||
.and_where("app.workspace_id = ?".bind(&w_id))
|
||||
@@ -634,55 +612,6 @@ async fn get_app_lite(
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
async fn get_app_w_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<AppWithLastVersionAndDraft> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
|
||||
r#"
|
||||
SELECT
|
||||
app.id,
|
||||
app.path,
|
||||
app.summary,
|
||||
app.versions,
|
||||
app.policy,
|
||||
app.custom_path,
|
||||
app.extra_perms,
|
||||
app_version.value,
|
||||
app_version.created_at,
|
||||
app_version.created_by,
|
||||
app.draft_only,
|
||||
draft.value AS "draft",
|
||||
draft.created_at AS "draft_created_at",
|
||||
app_version.raw_app,
|
||||
app.labels
|
||||
FROM app
|
||||
INNER JOIN app_version
|
||||
ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
LEFT JOIN draft
|
||||
ON app.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
AND draft.typ = 'app'
|
||||
WHERE app.path = $1
|
||||
AND app.workspace_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(path.to_owned())
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let app = not_found_if_none(app_o, "App", path)?;
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
async fn get_app_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -3190,18 +3119,6 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
|
||||
Ok((permissioned_as, email))
|
||||
}
|
||||
|
||||
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
|
||||
return crate::users::require_is_writer(
|
||||
authed,
|
||||
path,
|
||||
w_id,
|
||||
db,
|
||||
"SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2",
|
||||
"app",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn exists_app(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2024
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
users::{maybe_refresh_folders, require_owner_of_path},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{delete, post},
|
||||
Json, Router,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_common::{db::UserDB, error::Result, utils::StripPath};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/create", post(create_draft))
|
||||
.route("/delete/{kind}/{*path}", delete(delete_draft))
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||
#[sqlx(type_name = "DRAFT_TYPE", rename_all = "lowercase")]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
pub enum DraftType {
|
||||
Script,
|
||||
Flow,
|
||||
App,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Draft {
|
||||
pub path: String,
|
||||
pub value: sqlx::types::Json<Box<serde_json::value::Value>>,
|
||||
pub typ: DraftType,
|
||||
}
|
||||
|
||||
pub async fn require_writer_of_path(
|
||||
authed: &ApiAuthed,
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
db: DB,
|
||||
kind: &DraftType,
|
||||
) -> Result<()> {
|
||||
if authed.is_admin {
|
||||
return Ok(());
|
||||
} else if require_owner_of_path(authed, path).is_ok() {
|
||||
return Ok(());
|
||||
} else {
|
||||
match kind {
|
||||
DraftType::Script => crate::scripts::require_is_writer(authed, path, w_id, db).await,
|
||||
DraftType::Flow => crate::flows::require_is_writer(authed, path, w_id, db).await,
|
||||
DraftType::App => crate::apps::require_is_writer(authed, path, w_id, db).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(draft): Json<Draft>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_writer_of_path(&authed, &draft.path, &w_id, db, &draft.typ).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft
|
||||
(workspace_id, path, value, typ)
|
||||
VALUES ($1, $2, $3::text::json, $4)
|
||||
ON CONFLICT (workspace_id, path, typ)
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()",
|
||||
&w_id,
|
||||
draft.path,
|
||||
//to preserve key orders
|
||||
serde_json::to_string(&draft.value).unwrap(),
|
||||
draft.typ as DraftType,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((StatusCode::CREATED, format!("draft {} created", draft.path)))
|
||||
}
|
||||
|
||||
async fn delete_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3",
|
||||
path.to_path(),
|
||||
kind as DraftType,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("deleted draft"))
|
||||
}
|
||||
|
||||
// async fn get_draft(
|
||||
// authed: ApiAuthed,
|
||||
// Extension(user_db): Extension<UserDB>,
|
||||
// Path((w_id, path)): Path<(String, StripPath)>,
|
||||
// ) -> JsonResult<Draft> {
|
||||
// let path = path.to_path();
|
||||
// let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// let script_o = sqlx::query_as!(
|
||||
// Draft,
|
||||
// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#,
|
||||
// path,
|
||||
// w_id
|
||||
// )
|
||||
// .fetch_optional(&mut *tx)
|
||||
// .await?;
|
||||
// tx.commit().await?;
|
||||
|
||||
// let draft = not_found_if_none(script_o, "draft", path)?;
|
||||
// Ok(Json(draft))
|
||||
// }
|
||||
@@ -78,7 +78,6 @@ mod concurrency_groups;
|
||||
mod db;
|
||||
mod db_health;
|
||||
|
||||
mod drafts;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod ee;
|
||||
pub mod ee_oss;
|
||||
@@ -554,7 +553,6 @@ pub async fn run_server(
|
||||
concurrency_groups::workspaced_service(),
|
||||
)
|
||||
.nest("/embeddings", embeddings::workspaced_service())
|
||||
.nest("/drafts", drafts::workspaced_service())
|
||||
.nest("/favorites", favorite::workspaced_service())
|
||||
.nest("/flows", flows::workspaced_service())
|
||||
.nest(
|
||||
|
||||
@@ -282,7 +282,6 @@ where
|
||||
"edited_by",
|
||||
"permissioned_as",
|
||||
"archived",
|
||||
"has_draft",
|
||||
"error",
|
||||
"last_server_ping",
|
||||
"server_id",
|
||||
|
||||
@@ -79,7 +79,6 @@ pub struct ListableFlow {
|
||||
pub archived: bool,
|
||||
pub extra_perms: serde_json::Value,
|
||||
pub starred: bool,
|
||||
pub has_draft: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -446,8 +446,6 @@ pub struct ListableScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub has_draft: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
pub has_deploy_errors: bool,
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
|
||||
Generated
+7
@@ -17,6 +17,7 @@
|
||||
"jszip": "3.8.0",
|
||||
"minimatch": "^10.0.0",
|
||||
"open": "^10.0.0",
|
||||
"pg-gateway": "0.3.0-beta.4",
|
||||
"svelte": "^5.45.2",
|
||||
"tar-stream": "^3.1.7",
|
||||
"windmill-parser-wasm-csharp": "1.510.1",
|
||||
@@ -1236,6 +1237,12 @@
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/pg-gateway": {
|
||||
"version": "0.3.0-beta.4",
|
||||
"resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.4.tgz",
|
||||
"integrity": "sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
| {
|
||||
mode: 'normal'
|
||||
deployed: Value
|
||||
draft: Value | undefined
|
||||
draft?: Value | undefined
|
||||
current: Value
|
||||
defaultDiffType?: 'deployed' | 'draft'
|
||||
button?: { text: string; onClick: () => void }
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Popover from './Popover.svelte'
|
||||
import { Badge } from './common'
|
||||
|
||||
interface Props {
|
||||
has_draft?: boolean
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
let { has_draft = false, draft_only = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if has_draft}
|
||||
{#if draft_only}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Never deployed and is only a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">Draft only</Badge>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Is deployed and has a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">+Draft</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -2,7 +2,6 @@
|
||||
import {
|
||||
FlowService,
|
||||
type Flow,
|
||||
DraftService,
|
||||
type PathScript,
|
||||
type OpenFlow,
|
||||
type InputTransform,
|
||||
@@ -13,7 +12,6 @@
|
||||
import { initHistory, redo, undo } from '$lib/history.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
generateRandomString,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
@@ -29,7 +27,7 @@
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
|
||||
|
||||
import { createRawSnippet, onMount, setContext, untrack } from 'svelte'
|
||||
import { createRawSnippet, setContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Button } from './common'
|
||||
@@ -46,7 +44,6 @@
|
||||
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
|
||||
import { cleanFlow } from './flows/utils.svelte'
|
||||
import {
|
||||
Save,
|
||||
DiffIcon,
|
||||
HistoryIcon,
|
||||
FileJson,
|
||||
@@ -77,12 +74,8 @@
|
||||
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
|
||||
import DeployButton from './DeployButton.svelte'
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
|
||||
import {
|
||||
deployTriggers,
|
||||
filterDraftTriggers,
|
||||
handleSelectTriggerFromKind
|
||||
} from './triggers/utils'
|
||||
import type { Trigger } from './triggers/utils'
|
||||
import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
|
||||
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
||||
import { Triggers } from './triggers/triggers.svelte'
|
||||
import { StepsInputArgs } from './flows/stepsInputArgs.svelte'
|
||||
@@ -118,20 +111,15 @@
|
||||
disabledFlowInputs = false,
|
||||
savedPrimarySchedule = undefined,
|
||||
version = undefined,
|
||||
setSavedraftCb = undefined,
|
||||
draftTriggersFromUrl = undefined,
|
||||
selectedTriggerIndexFromUrl = undefined,
|
||||
children,
|
||||
loadedFromHistoryFromUrl,
|
||||
noInitial = false,
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onSaveInitial,
|
||||
onSaveDraft,
|
||||
onDeploy,
|
||||
onDeployError,
|
||||
onDetails,
|
||||
onSaveDraftError,
|
||||
onSaveDraftOnlyAtNewPath,
|
||||
onHistoryRestore,
|
||||
onNavigate
|
||||
}: FlowBuilderProps = $props()
|
||||
@@ -261,128 +249,9 @@
|
||||
}
|
||||
|
||||
let loadingSave = $state(false)
|
||||
let loadingDraft = $state(false)
|
||||
|
||||
export async function saveDraft(forceSave = false): Promise<void> {
|
||||
withAIChangesWarning(async () => {
|
||||
await saveDraftInternal(forceSave)
|
||||
})
|
||||
}
|
||||
|
||||
async function saveDraftInternal(forceSave = false): Promise<void> {
|
||||
if (!newFlow && !savedFlow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (savedFlow) {
|
||||
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
|
||||
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
const current = cleanValueProperties(
|
||||
$state.snapshot({
|
||||
...flowStore.val,
|
||||
path: $pathStore,
|
||||
draft_triggers: currentDraftTriggers
|
||||
})
|
||||
)
|
||||
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
||||
sendUserToast('No changes detected, ignoring', false, [
|
||||
{
|
||||
label: 'Save anyway',
|
||||
callback: () => {
|
||||
saveDraftInternal(true)
|
||||
}
|
||||
}
|
||||
])
|
||||
return
|
||||
}
|
||||
}
|
||||
loadingDraft = true
|
||||
try {
|
||||
const flow = cleanFlow(flowStore.val)
|
||||
if (newFlow || savedFlow?.draft_only) {
|
||||
if (savedFlow?.draft_only) {
|
||||
await FlowService.deleteFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
keepCaptures: true
|
||||
})
|
||||
}
|
||||
if (!initialPath || $pathStore != initialPath) {
|
||||
await CaptureService.moveCapturesAndConfigs({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath || fakeInitialPath,
|
||||
requestBody: {
|
||||
new_path: $pathStore
|
||||
},
|
||||
runnableKind: 'flow'
|
||||
})
|
||||
}
|
||||
await FlowService.createFlow({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: $pathStore,
|
||||
summary: flow.summary ?? '',
|
||||
description: flow.description ?? '',
|
||||
value: flow.value,
|
||||
schema: flow.schema,
|
||||
tag: flow.tag,
|
||||
draft_only: true,
|
||||
ws_error_handler_muted: flow.ws_error_handler_muted,
|
||||
visible_to_runner_only: flow.visible_to_runner_only,
|
||||
on_behalf_of_email: flow.on_behalf_of_email,
|
||||
labels: (flow as any).labels
|
||||
}
|
||||
})
|
||||
}
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
|
||||
typ: 'flow',
|
||||
value: {
|
||||
...flow,
|
||||
path: $pathStore,
|
||||
draft_triggers: triggersState.getDraftTriggersSnapshot()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
savedFlow = {
|
||||
...(newFlow || savedFlow?.draft_only
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowStore.val)),
|
||||
path: $pathStore,
|
||||
draft_only: true
|
||||
}
|
||||
: savedFlow),
|
||||
draft: {
|
||||
...structuredClone($state.snapshot(flowStore.val)),
|
||||
path: $pathStore,
|
||||
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
}
|
||||
} as FlowWithDraftAndDraftTriggers
|
||||
|
||||
let savedAtNewPath = false
|
||||
if (newFlow) {
|
||||
onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' })
|
||||
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
|
||||
savedAtNewPath = true
|
||||
initialPath = $pathStore
|
||||
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' })
|
||||
// this is so we can use the flow builder outside of sveltekit
|
||||
}
|
||||
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
|
||||
sendUserToast('Saved as draft')
|
||||
} catch (error) {
|
||||
sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true)
|
||||
onSaveDraftError?.({ error })
|
||||
}
|
||||
loadingDraft = false
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setSavedraftCb?.(() => saveDraft())
|
||||
})
|
||||
// No-op: persistence happens via the page-level UserDraft autosave.
|
||||
export function saveDraft(): void {}
|
||||
|
||||
export function computeUnlockedSteps(flow: Flow) {
|
||||
return Object.fromEntries(
|
||||
@@ -677,7 +546,7 @@
|
||||
[
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? [])
|
||||
...(untrack(() => draftTriggersFromUrl) ?? [])
|
||||
],
|
||||
untrack(() => selectedTriggerIndexFromUrl)
|
||||
)
|
||||
@@ -706,10 +575,6 @@
|
||||
$primaryScheduleStore,
|
||||
$userStore
|
||||
)
|
||||
|
||||
if (savedFlow && savedFlow.draft) {
|
||||
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
|
||||
}
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
@@ -834,7 +699,6 @@
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedFlow,
|
||||
draft: savedFlow?.draft,
|
||||
current: {
|
||||
...currentFlow,
|
||||
path: $pathStore,
|
||||
@@ -879,25 +743,7 @@
|
||||
const mod = isMac() ? '⌘' : 'Ctrl+'
|
||||
|
||||
function getMoreItems(): Item[] {
|
||||
// When the top bar is compact, fold the inline Diff + Save draft buttons
|
||||
// in here so they stay reachable. Save draft keeps its keyboard shortcut.
|
||||
const compactExtras: Item[] = compactTopbar
|
||||
? [
|
||||
...(customUi?.topBar?.draft !== false
|
||||
? [
|
||||
{
|
||||
displayName: 'Save draft',
|
||||
icon: Save,
|
||||
action: () => saveDraft(),
|
||||
shortcut: `${mod}S`,
|
||||
disabled: (!newFlow && !savedFlow) || loading
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
: []
|
||||
return [
|
||||
...compactExtras,
|
||||
...baseMenuItems,
|
||||
{
|
||||
displayName: 'Undo',
|
||||
@@ -905,7 +751,7 @@
|
||||
action: () => handleUndo(),
|
||||
disabled: $history.index === 0,
|
||||
shortcut: `${mod}Z`,
|
||||
separatorTop: compactExtras.length > 0 || baseMenuItems.length > 0
|
||||
separatorTop: baseMenuItems.length > 0
|
||||
},
|
||||
{
|
||||
displayName: 'Redo',
|
||||
@@ -1018,17 +864,7 @@
|
||||
]
|
||||
}
|
||||
|
||||
function handleDeployTrigger(trigger: Trigger) {
|
||||
const { id, path, type } = trigger
|
||||
//Update the saved flow to remove the draft trigger that is deployed
|
||||
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
|
||||
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
|
||||
(t) => t.id !== id || t.path !== path || t.type !== type
|
||||
)
|
||||
savedFlow.draft.draft_triggers =
|
||||
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
|
||||
}
|
||||
}
|
||||
function handleDeployTrigger(_trigger: Trigger) {}
|
||||
|
||||
let forceTestTab: Record<string, boolean> = $state({})
|
||||
let highlightArg: Record<string, string | undefined> = $state({})
|
||||
@@ -1218,19 +1054,6 @@
|
||||
{#if !compactTopbar}
|
||||
{@render previewButtons()}
|
||||
{/if}
|
||||
{#if customUi?.topBar?.draft !== false && !compactTopbar}
|
||||
<Button
|
||||
loading={loadingDraft}
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={() => saveDraft()}
|
||||
disabled={(!newFlow && !savedFlow) || loading}
|
||||
shortCut={{ key: 'S' }}
|
||||
>
|
||||
Draft
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<DeployButton
|
||||
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
|
||||
const bubble = createBubbler()
|
||||
import {
|
||||
DraftService,
|
||||
ScriptService,
|
||||
type NewScriptWithDraft,
|
||||
type Script,
|
||||
type NewScript,
|
||||
type TriggersCount,
|
||||
PostgresTriggerService,
|
||||
CaptureService,
|
||||
@@ -31,7 +30,6 @@
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
generateRandomString,
|
||||
@@ -58,14 +56,13 @@
|
||||
EllipsisVertical,
|
||||
Plus,
|
||||
Rocket,
|
||||
Save,
|
||||
Settings,
|
||||
Shuffle,
|
||||
Tag,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import { isMac, type Item } from '$lib/utils'
|
||||
import { type Item } from '$lib/utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import Awareness from './Awareness.svelte'
|
||||
@@ -91,13 +88,7 @@
|
||||
import CaptureTable from './triggers/CaptureTable.svelte'
|
||||
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
|
||||
import DeployButton from './DeployButton.svelte'
|
||||
import {
|
||||
type NewScriptWithDraftAndDraftTriggers,
|
||||
type Trigger,
|
||||
deployTriggers,
|
||||
filterDraftTriggers,
|
||||
handleSelectTriggerFromKind
|
||||
} from './triggers/utils'
|
||||
import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
|
||||
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
||||
import { Triggers } from './triggers/triggers.svelte'
|
||||
import type { ScriptBuilderProps } from './script_builder'
|
||||
@@ -129,10 +120,7 @@
|
||||
children,
|
||||
onDeploy,
|
||||
onDeployError,
|
||||
onSaveInitial,
|
||||
onSeeDetails,
|
||||
onSaveDraftError,
|
||||
onSaveDraft,
|
||||
onNavigate,
|
||||
disableAi
|
||||
}: ScriptBuilderProps = $props()
|
||||
@@ -163,18 +151,10 @@
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
let topbarWidth = $state(0)
|
||||
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
|
||||
const mod = isMac() ? '⌘' : 'Ctrl+'
|
||||
|
||||
function getCompactMenuItems(): Item[] {
|
||||
const hasTags = ($workerTags?.length ?? 0) > 0
|
||||
return [
|
||||
{
|
||||
displayName: 'Save draft',
|
||||
icon: Save,
|
||||
action: () => saveDraft(),
|
||||
shortcut: `${mod}S`,
|
||||
disabled: initialPath != '' && !savedScript
|
||||
},
|
||||
...(customUi?.topBar?.tagEdit != false && hasTags
|
||||
? [
|
||||
{
|
||||
@@ -289,13 +269,6 @@
|
||||
$primaryScheduleStore,
|
||||
$userStore
|
||||
)
|
||||
|
||||
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
|
||||
savedScript = filterDraftTriggers(
|
||||
savedScript,
|
||||
triggersState
|
||||
) as NewScriptWithDraftAndDraftTriggers
|
||||
}
|
||||
}
|
||||
|
||||
// Add triggers context store
|
||||
@@ -366,7 +339,6 @@
|
||||
|
||||
let pathError = $state('')
|
||||
let loadingSave = $state(false)
|
||||
let loadingDraft = $state(false)
|
||||
|
||||
if (script.content == '') {
|
||||
if (template === 'wac_python') {
|
||||
@@ -620,7 +592,7 @@
|
||||
}
|
||||
|
||||
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
|
||||
savedScript = structuredClone($state.snapshot(newScript)) as NewScriptWithDraft
|
||||
savedScript = structuredClone($state.snapshot(newScript))
|
||||
setDraftTriggers([])
|
||||
|
||||
if (!disableHistoryChange) {
|
||||
@@ -644,158 +616,12 @@
|
||||
loadingSave = false
|
||||
}
|
||||
|
||||
async function saveDraft(forceSave = false): Promise<void> {
|
||||
scriptEditor?.flushModuleState()
|
||||
if (initialPath != '' && !savedScript) {
|
||||
return
|
||||
}
|
||||
|
||||
if (savedScript) {
|
||||
const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript)
|
||||
const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers })
|
||||
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
||||
sendUserToast('No changes detected, ignoring', false, [
|
||||
{
|
||||
label: 'Save anyway',
|
||||
callback: () => {
|
||||
saveDraft(true)
|
||||
}
|
||||
}
|
||||
])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadingDraft = true
|
||||
try {
|
||||
script.schema = script.schema ?? emptySchema()
|
||||
try {
|
||||
const result = await inferArgs(
|
||||
script.language,
|
||||
script.content,
|
||||
script.schema as any,
|
||||
script.kind === 'preprocessor' ? 'preprocessor' : undefined
|
||||
)
|
||||
if (script.kind === 'preprocessor') {
|
||||
script.auto_kind = undefined
|
||||
script.has_preprocessor = undefined
|
||||
} else {
|
||||
script.auto_kind = result?.auto_kind || undefined
|
||||
script.has_preprocessor = result?.has_preprocessor || undefined
|
||||
}
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
|
||||
}
|
||||
let newHash = ''
|
||||
if (initialPath == '' || savedScript?.draft_only) {
|
||||
if (savedScript?.draft_only) {
|
||||
await ScriptService.deleteScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
keepCaptures: true
|
||||
})
|
||||
script.parent_hash = undefined
|
||||
}
|
||||
if (!initialPath || script.path != initialPath) {
|
||||
await CaptureService.moveCapturesAndConfigs({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath || fakeInitialPath,
|
||||
requestBody: {
|
||||
new_path: script.path
|
||||
},
|
||||
runnableKind: 'script'
|
||||
})
|
||||
}
|
||||
newHash = await ScriptService.createScript({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: script.path,
|
||||
summary: script.summary,
|
||||
description: script.description ?? '',
|
||||
content: script.content,
|
||||
schema: script.schema,
|
||||
is_template: script.is_template,
|
||||
language: script.language,
|
||||
kind: script.kind,
|
||||
tag: script.tag,
|
||||
draft_only: true,
|
||||
envs: script.envs,
|
||||
concurrent_limit: script.concurrent_limit,
|
||||
concurrency_time_window_s: script.concurrency_time_window_s,
|
||||
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
|
||||
debounce_delay_s: script.debounce_delay_s,
|
||||
debounce_args_to_accumulate:
|
||||
script.debounce_args_to_accumulate && script.debounce_args_to_accumulate.length > 0
|
||||
? script.debounce_args_to_accumulate
|
||||
: undefined,
|
||||
max_total_debouncing_time: script.max_total_debouncing_time,
|
||||
max_total_debounces_amount: script.max_total_debounces_amount,
|
||||
cache_ttl: script.cache_ttl,
|
||||
cache_ignore_s3_path: script.cache_ignore_s3_path,
|
||||
ws_error_handler_muted: script.ws_error_handler_muted,
|
||||
priority: script.priority,
|
||||
restart_unless_cancelled: script.restart_unless_cancelled,
|
||||
timeout: script.timeout,
|
||||
concurrency_key: emptyString(script.concurrency_key)
|
||||
? undefined
|
||||
: script.concurrency_key,
|
||||
visible_to_runner_only: script.visible_to_runner_only,
|
||||
auto_kind: script.auto_kind,
|
||||
has_preprocessor: script.has_preprocessor,
|
||||
on_behalf_of_email: script.on_behalf_of_email,
|
||||
assets: script.assets,
|
||||
modules: script.modules,
|
||||
labels: script.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
const draftTriggers = triggersState.getDraftTriggersSnapshot()
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
|
||||
typ: 'script',
|
||||
value: {
|
||||
...script,
|
||||
draft_triggers: draftTriggers
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const clonedScript = structuredClone($state.snapshot(script))
|
||||
savedScript = {
|
||||
...(initialPath == '' || savedScript?.draft_only
|
||||
? { ...clonedScript, draft_only: true }
|
||||
: savedScript),
|
||||
draft: {
|
||||
...clonedScript,
|
||||
draft_triggers: draftTriggers
|
||||
}
|
||||
} as NewScriptWithDraftAndDraftTriggers
|
||||
|
||||
let savedAtNewPath = false
|
||||
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
|
||||
savedAtNewPath = true
|
||||
initialPath = script.path
|
||||
onSaveInitial?.({ path: script.path, hash: newHash })
|
||||
}
|
||||
onSaveDraft?.({ path: script.path, savedAtNewPath, script })
|
||||
|
||||
sendUserToast('Saved as draft')
|
||||
} catch (error) {
|
||||
sendUserToast(
|
||||
`Error while saving the script as a draft: ${error.body || error.message}`,
|
||||
true
|
||||
)
|
||||
onSaveDraftError?.({ path: script.path, error })
|
||||
}
|
||||
loadingDraft = false
|
||||
}
|
||||
// No-op: persistence happens via the page-level UserDraft autosave.
|
||||
function saveDraft(): void {}
|
||||
|
||||
function computeDropdownItems(
|
||||
initialPath: string,
|
||||
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
|
||||
savedScript: Script | NewScript | undefined,
|
||||
diffDrawer: DiffDrawerI | undefined
|
||||
) {
|
||||
let dropdownItems: { label: string; onClick: () => void }[] =
|
||||
@@ -972,17 +798,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeployTrigger(trigger: Trigger) {
|
||||
const { id, path, type } = trigger
|
||||
//Update the saved script to remove the draft trigger that is deployed
|
||||
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
|
||||
const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter(
|
||||
(t) => t.id !== id || t.path !== path || t.type !== type
|
||||
)
|
||||
savedScript.draft.draft_triggers =
|
||||
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
|
||||
}
|
||||
}
|
||||
function handleDeployTrigger(_trigger: Trigger) {}
|
||||
|
||||
function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) {
|
||||
if (lang == 'docker') {
|
||||
@@ -2035,17 +1851,6 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{@render settingsButton()}
|
||||
<Button
|
||||
loading={loadingDraft}
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={() => saveDraft()}
|
||||
disabled={initialPath != '' && !savedScript}
|
||||
shortCut={{ key: 'S' }}
|
||||
>
|
||||
<span> Draft </span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<DeployButton
|
||||
@@ -2083,8 +1888,8 @@
|
||||
autoKind={script.auto_kind}
|
||||
{template}
|
||||
tag={script.tag}
|
||||
lastSavedCode={savedScript?.draft?.content}
|
||||
lastDeployedCode={savedScript?.draft_only ? undefined : savedScript?.content}
|
||||
lastSavedCode={savedScript?.content}
|
||||
lastDeployedCode={savedScript?.content}
|
||||
bind:args
|
||||
bind:hasPreprocessor
|
||||
bind:captureTable
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
import { redo, undo } from '$lib/history.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { enterpriseLicense, tutorialsToDo, userStore, workspaceStore } from '$lib/stores'
|
||||
@@ -39,12 +39,7 @@
|
||||
Globe
|
||||
} from 'lucide-svelte'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
orderedJsonStringify,
|
||||
type Value,
|
||||
replaceFalseWithUndefined
|
||||
} from '../../../utils'
|
||||
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils'
|
||||
import type { App, AppEditorContext, AppViewerContext } from '../types'
|
||||
import { toStatic } from '../utils'
|
||||
import AppExportButton from './AppExportButton.svelte'
|
||||
@@ -74,7 +69,6 @@
|
||||
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
|
||||
import type { DiffDrawerI } from '$lib/components/diff_drawer'
|
||||
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
|
||||
import AppEditorHeaderDeployInitialDraft from './AppEditorHeaderDeployInitialDraft.svelte'
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
import { updatePolicy } from './appPolicy'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
@@ -88,11 +82,9 @@
|
||||
savedApp?:
|
||||
| {
|
||||
value: App
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -138,7 +130,7 @@
|
||||
* updated by user input from then on — we deliberately do NOT sync from
|
||||
* `newPath` afterwards so the user's in-flight rename isn't clobbered by
|
||||
* a parent reload that re-supplies the saved path. The fallback chain at
|
||||
* read sites (`newEditedPath || savedApp?.draft?.path || savedApp?.path`)
|
||||
* read sites (`newEditedPath || savedApp?.path`)
|
||||
* handles the case where `newEditedPath` is briefly empty before the
|
||||
* synthesized initialization runs — falls through to the saved path so
|
||||
* rename detection still works. */
|
||||
@@ -172,8 +164,7 @@
|
||||
|
||||
const loading = $state({
|
||||
publish: false,
|
||||
save: false,
|
||||
saveDraft: false
|
||||
save: false
|
||||
})
|
||||
|
||||
let selectedJobId: string | undefined = $state(undefined)
|
||||
@@ -181,7 +172,6 @@
|
||||
let pathError: string = $state('')
|
||||
let appExport: AppExportButton | undefined = $state()
|
||||
|
||||
let draftDrawerOpen = $state(false)
|
||||
let saveDrawerOpen = $state(false)
|
||||
let inputsDrawerOpen = $state(untrack(() => fromHub))
|
||||
let historyBrowserDrawerOpen = $state(false)
|
||||
@@ -198,10 +188,6 @@
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
|
||||
function closeDraftDrawer() {
|
||||
draftDrawerOpen = false
|
||||
}
|
||||
|
||||
async function createApp(path: string) {
|
||||
policy = await updatePolicy($app, policy)
|
||||
try {
|
||||
@@ -257,7 +243,7 @@
|
||||
replaceFalseWithUndefined({
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
})
|
||||
@@ -358,156 +344,6 @@
|
||||
return
|
||||
}
|
||||
|
||||
async function saveInitialDraft() {
|
||||
policy = await updatePolicy($app, policy)
|
||||
try {
|
||||
await AppService.createApp({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
value: $app,
|
||||
path: newEditedPath,
|
||||
summary: $summary,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
})
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: newEditedPath,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: $app,
|
||||
path: newEditedPath,
|
||||
summary: $summary,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
}
|
||||
})
|
||||
savedApp = {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
draft_only: true,
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
},
|
||||
custom_path: customPath
|
||||
}
|
||||
|
||||
draftDrawerOpen = false
|
||||
// The initial draft was promoted to a real path on the backend —
|
||||
// drop the autosave keyed on the prior (possibly empty) path so
|
||||
// a future "+ App" click opens on a clean slate.
|
||||
UserDraft.remove('app', $appPath)
|
||||
onSavedNewAppPath?.(newEditedPath)
|
||||
} catch (e) {
|
||||
sendUserToast('Error saving initial draft', e)
|
||||
}
|
||||
draftDrawerOpen = false
|
||||
}
|
||||
|
||||
async function saveDraft(forceSave = false) {
|
||||
if (newApp) {
|
||||
// initial draft
|
||||
draftDrawerOpen = true
|
||||
return
|
||||
}
|
||||
if (!savedApp) {
|
||||
return
|
||||
}
|
||||
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
|
||||
const current = cleanValueProperties({
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
policy
|
||||
})
|
||||
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
||||
sendUserToast('No changes detected, ignoring', false, [
|
||||
{
|
||||
label: 'Save anyway',
|
||||
callback: () => {
|
||||
saveDraft(true)
|
||||
}
|
||||
}
|
||||
])
|
||||
return
|
||||
}
|
||||
loading.saveDraft = true
|
||||
try {
|
||||
policy = await updatePolicy($app, policy)
|
||||
let path = $appPath
|
||||
if (savedApp.draft_only) {
|
||||
await AppService.deleteApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: path
|
||||
})
|
||||
await AppService.createApp({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
value: $app!,
|
||||
summary: $summary,
|
||||
policy,
|
||||
path: newEditedPath || path,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
})
|
||||
}
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: $app!,
|
||||
summary: $summary,
|
||||
policy,
|
||||
path: newEditedPath || path
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
savedApp = {
|
||||
...(savedApp?.draft_only
|
||||
? {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
: savedApp),
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
path: newEditedPath || path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
}
|
||||
|
||||
sendUserToast('Draft saved')
|
||||
UserDraft.remove('app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
onSavedNewAppPath?.(newEditedPath || path)
|
||||
}
|
||||
} catch (e) {
|
||||
loading.saveDraft = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
let onLatest = $state(true)
|
||||
async function compareVersions() {
|
||||
if (version === undefined) {
|
||||
@@ -553,7 +389,6 @@
|
||||
break
|
||||
case 's':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
saveDraft()
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
@@ -597,13 +432,6 @@
|
||||
let moreItems = $derived([
|
||||
...(compactTopbar
|
||||
? [
|
||||
{
|
||||
displayName: 'Save draft',
|
||||
icon: Save,
|
||||
action: () => saveDraft(),
|
||||
shortcut: `${mod}S`,
|
||||
disabled: !newApp && !savedApp
|
||||
},
|
||||
{
|
||||
displayName: `Debug runs (${$jobs?.length > 99 ? '99+' : ($jobs?.length ?? 0)})`,
|
||||
icon: Bug,
|
||||
@@ -680,7 +508,7 @@
|
||||
action: () => {
|
||||
appReportingDrawerOpen = true
|
||||
},
|
||||
disabled: !savedApp || savedApp.draft_only
|
||||
disabled: !savedApp
|
||||
},
|
||||
{
|
||||
displayName: 'Diff',
|
||||
@@ -697,11 +525,10 @@
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
draft: savedApp.draft,
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
@@ -825,7 +652,7 @@
|
||||
modifiedValue: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
path: newEditedPath || savedApp?.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
@@ -841,39 +668,12 @@
|
||||
currentValue={{
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
path: newEditedPath || savedApp?.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if $appPath == ''}
|
||||
<Drawer bind:open={draftDrawerOpen} size="800px">
|
||||
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
|
||||
{#snippet actions()}
|
||||
<div>
|
||||
<Button
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={pathError != ''}
|
||||
on:click={() => saveInitialDraft()}
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
>
|
||||
Save initial draft
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<AppEditorHeaderDeployInitialDraft
|
||||
bind:summary={$summary}
|
||||
bind:appPath={$appPath}
|
||||
bind:pathError
|
||||
bind:newEditedPath
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
|
||||
<AppJobsDrawer
|
||||
bind:open={$jobsDrawerOpen}
|
||||
jobs={$jobs}
|
||||
@@ -896,7 +696,7 @@
|
||||
<div class="flex flex-row gap-4">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!savedApp || savedApp.draft_only}
|
||||
disabled={!savedApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
return
|
||||
@@ -909,11 +709,10 @@
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
draft: savedApp.draft,
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
},
|
||||
@@ -1201,19 +1000,6 @@
|
||||
</div>
|
||||
<AppExportButton bind:this={appExport} />
|
||||
<PreviewToggle loading={loading.save} />
|
||||
{#if !compactTopbar}
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={loading.save}
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={() => saveDraft()}
|
||||
unifiedSize="md"
|
||||
disabled={!newApp && !savedApp}
|
||||
shortCut={{ key: 'S' }}
|
||||
>
|
||||
Draft
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={loading.save}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Alert } from '$lib/components/common'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
|
||||
let {
|
||||
summary = $bindable(),
|
||||
appPath = $bindable(),
|
||||
pathError = $bindable(),
|
||||
newEditedPath = $bindable()
|
||||
} = $props()
|
||||
|
||||
let path: Path | undefined = $state(undefined)
|
||||
let dirtyPath = $state(false)
|
||||
</script>
|
||||
|
||||
<Alert bgClass="mb-4" title="Require path" type="info">
|
||||
Choose a path to save the initial draft of the app.
|
||||
</Alert>
|
||||
<h3>Summary</h3>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="App summary"
|
||||
class="text-sm w-full font-semibold"
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
bind:value={summary}
|
||||
onkeyup={() => {
|
||||
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-2"></div>
|
||||
<Path
|
||||
autofocus={false}
|
||||
bind:this={path}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
bind:dirty={dirtyPath}
|
||||
initialPath=""
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
/>
|
||||
<div class="py-4"></div>
|
||||
@@ -1,21 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
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 JsonEditor from '../../JsonEditor.svelte'
|
||||
import { AppService, DraftService } from '$lib/gen'
|
||||
import { AppService } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Globe, Loader2, Save } from 'lucide-svelte'
|
||||
import { Globe, Loader2 } from 'lucide-svelte'
|
||||
|
||||
let jsonViewerDrawer: Drawer | undefined = $state()
|
||||
|
||||
let code: string = $state('')
|
||||
let path: string = ''
|
||||
let useDraft: boolean = $state(false)
|
||||
let loading = $state(true)
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -25,17 +24,12 @@
|
||||
loading = true
|
||||
jsonViewerDrawer?.toggleDrawer()
|
||||
path = path_l
|
||||
const fapp = await AppService.getAppByPathWithDraft({
|
||||
const fapp = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path
|
||||
})
|
||||
useDraft = fapp?.draft != undefined
|
||||
app = { ...fapp }
|
||||
if (fapp.draft) {
|
||||
delete app['draft']
|
||||
}
|
||||
const capp = fapp?.draft ? fapp.draft : fapp.value
|
||||
code = JSON.stringify(capp, null, 4)
|
||||
code = JSON.stringify(fapp.value, null, 4)
|
||||
loading = false
|
||||
}
|
||||
|
||||
@@ -49,29 +43,10 @@
|
||||
UserDraft.remove('app', path)
|
||||
sendUserToast('App deployed')
|
||||
}
|
||||
|
||||
export async function saveDraft() {
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: path,
|
||||
typ: 'app',
|
||||
value: JSON.parse(code)
|
||||
}
|
||||
})
|
||||
dispatch('change')
|
||||
UserDraft.remove('app', path)
|
||||
sendUserToast('Draft saved')
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={jsonViewerDrawer} size="800px">
|
||||
<DrawerContent title="App JSON" on:close={() => jsonViewerDrawer?.toggleDrawer()}>
|
||||
{#if useDraft}
|
||||
<div class="mb-1">
|
||||
<Badge small color="indigo" baseClass="border border-indigo-200">+Draft</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:else}
|
||||
@@ -80,9 +55,6 @@
|
||||
|
||||
{#snippet actions()}
|
||||
{#if !$userStore?.operator}
|
||||
<Button on:click={saveDraft} startIcon={{ icon: Save }} variant="accent" size="xs">
|
||||
Save as draft
|
||||
</Button>
|
||||
<Button on:click={saveApp} startIcon={{ icon: Globe }} variant="accent" size="xs">
|
||||
Deploy
|
||||
</Button>
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { AppService, DraftService, type ListableApp } from '$lib/gen'
|
||||
import { AppService, type ListableApp } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
import Row from './Row.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
import {
|
||||
ExternalLink,
|
||||
@@ -28,7 +27,7 @@
|
||||
import { goto as gotoUrl } from '$app/navigation'
|
||||
import { page } from '$app/state'
|
||||
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { DELETE, copyToClipboard } from '$lib/utils'
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
@@ -37,7 +36,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
|
||||
app: ListableApp & { draft_only?: boolean; canWrite: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -98,7 +97,6 @@
|
||||
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
||||
{/if}
|
||||
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
|
||||
<DraftBadge has_draft={app.has_draft} draft_only={app.draft_only} />
|
||||
{#if app.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each app.labels.slice(0, 3) as label}
|
||||
@@ -155,7 +153,7 @@
|
||||
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
|
||||
items={async () => {
|
||||
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
|
||||
let { draft_only, canWrite, summary, execution_mode, path } = app
|
||||
|
||||
const canEdit = canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
@@ -271,25 +269,6 @@
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(has_draft
|
||||
? [
|
||||
{
|
||||
displayName: 'Delete Draft',
|
||||
icon: Trash,
|
||||
action: async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path,
|
||||
kind: 'app'
|
||||
})
|
||||
dispatch('change')
|
||||
},
|
||||
type: DELETE,
|
||||
disabled: !canWrite,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
|
||||
@@ -6,15 +6,14 @@
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { FlowService, type Flow, DraftService } from '$lib/gen'
|
||||
import { FlowService, type Flow } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
import Row from './Row.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { DELETE, copyToClipboard, isOwner } from '$lib/utils'
|
||||
import { copyToClipboard, isOwner } from '$lib/utils'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
|
||||
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
@@ -39,7 +38,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
|
||||
flow: Flow & { draft_only?: boolean; canWrite: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -121,7 +120,6 @@
|
||||
<Badge color="red" baseClass="border">archived</Badge>
|
||||
{/if}
|
||||
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
|
||||
<DraftBadge has_draft={flow.has_draft} draft_only={flow.draft_only} />
|
||||
{#if flow.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each flow.labels.slice(0, 3) as label}
|
||||
@@ -180,7 +178,7 @@
|
||||
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
|
||||
items={async () => {
|
||||
let { draft_only, path, archived, has_draft } = flow
|
||||
let { draft_only, path, archived } = flow
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
const canEdit = flow.canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
@@ -293,25 +291,6 @@
|
||||
disabled: !owner || !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
...(has_draft
|
||||
? [
|
||||
{
|
||||
displayName: 'Delete Draft',
|
||||
icon: Trash,
|
||||
action: async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path,
|
||||
kind: 'flow'
|
||||
})
|
||||
dispatch('change')
|
||||
},
|
||||
type: DELETE,
|
||||
disabled: !owner,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
|
||||
@@ -7,16 +7,15 @@
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
|
||||
import { ScriptService, type Script, DraftService } from '$lib/gen'
|
||||
import { ScriptService, type Script } from '$lib/gen'
|
||||
import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
import Row from './Row.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { capitalize, copyToClipboard, DELETE, isOwner } from '$lib/utils'
|
||||
import { capitalize, copyToClipboard, isOwner } from '$lib/utils'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
|
||||
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
@@ -168,7 +167,6 @@
|
||||
>
|
||||
{/if}
|
||||
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
|
||||
<DraftBadge has_draft={script.has_draft} draft_only={script.draft_only} />
|
||||
{#if script.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each script.labels.slice(0, 3) as label}
|
||||
@@ -404,25 +402,6 @@
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
|
||||
...(script.has_draft
|
||||
? [
|
||||
{
|
||||
displayName: 'Delete Draft',
|
||||
icon: Trash,
|
||||
action: async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: script.path,
|
||||
kind: 'script'
|
||||
})
|
||||
dispatch('change')
|
||||
},
|
||||
type: DELETE,
|
||||
disabled: !owner,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...($userStore?.is_admin || $userStore?.is_super_admin
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -45,8 +45,8 @@ vi.mock('$lib/gen', async () => {
|
||||
ScriptService: wrapService(actual.ScriptService, {
|
||||
existsScriptByPath: vi.fn(async () => false),
|
||||
createScript: vi.fn(async () => 'created'),
|
||||
getScriptByPathWithDraft: vi.fn(async () => {
|
||||
throw new Error('getScriptByPathWithDraft mock not configured')
|
||||
getScriptByPath: vi.fn(async () => {
|
||||
throw new Error('getScriptByPath mock not configured')
|
||||
}),
|
||||
listScripts: vi.fn(async () => [])
|
||||
}),
|
||||
@@ -57,9 +57,6 @@ vi.mock('$lib/gen', async () => {
|
||||
getFlowByPath: vi.fn(async () => {
|
||||
throw new Error('getFlowByPath mock not configured')
|
||||
}),
|
||||
getFlowByPathWithDraft: vi.fn(async () => {
|
||||
throw new Error('getFlowByPathWithDraft mock not configured')
|
||||
}),
|
||||
getFlowLatestVersion: vi.fn(async () => ({ id: 1 })),
|
||||
listFlows: vi.fn(async () => [])
|
||||
}),
|
||||
@@ -77,8 +74,8 @@ vi.mock('$lib/gen', async () => {
|
||||
}),
|
||||
AppService: wrapService(actual.AppService, {
|
||||
existsApp: vi.fn(async () => false),
|
||||
getAppByPathWithDraft: vi.fn(async () => {
|
||||
throw new Error('getAppByPathWithDraft mock not configured')
|
||||
getAppByPath: vi.fn(async () => {
|
||||
throw new Error('getAppByPath mock not configured')
|
||||
}),
|
||||
listApps: vi.fn(async () => [])
|
||||
}),
|
||||
@@ -537,23 +534,14 @@ describe('global AI tools', () => {
|
||||
|
||||
it('preserves existing script metadata and seeds freshness on first script write', async () => {
|
||||
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
|
||||
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/existing',
|
||||
hash: 'deployed-hash',
|
||||
draft_created_at: '2026-05-22T10:00:00Z',
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
content: 'old deployed content',
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
draft: {
|
||||
path: 'f/scripts/existing',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
content: 'old draft content',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
}
|
||||
kind: 'script'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
@@ -569,20 +557,19 @@ describe('global AI tools', () => {
|
||||
path: 'f/scripts/existing',
|
||||
parent_hash: 'deployed-hash',
|
||||
summary: 'new summary',
|
||||
description: 'db draft description',
|
||||
description: 'deployed description',
|
||||
content: 'new content',
|
||||
language: 'bun'
|
||||
})
|
||||
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 'deployed-hash',
|
||||
remoteDraftRev: '2026-05-22T10:00:00Z'
|
||||
remoteRev: 'deployed-hash'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
|
||||
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
|
||||
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any)
|
||||
vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({
|
||||
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
|
||||
path: 'f/flows/existing',
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
@@ -591,19 +578,7 @@ describe('global AI tools', () => {
|
||||
edited_by: 'admin',
|
||||
edited_at: '2026-05-22T09:00:00Z',
|
||||
archived: false,
|
||||
extra_perms: {},
|
||||
draft_created_at: '2026-05-22T10:00:00Z',
|
||||
draft: {
|
||||
path: 'f/flows/existing',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
value: { modules: [] },
|
||||
schema: { properties: { draft: { type: 'string' } } },
|
||||
edited_by: 'admin',
|
||||
edited_at: '2026-05-22T09:30:00Z',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
}
|
||||
extra_perms: {}
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_flow', {
|
||||
@@ -615,12 +590,11 @@ describe('global AI tools', () => {
|
||||
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
|
||||
path: 'f/flows/existing',
|
||||
summary: 'new summary',
|
||||
description: 'db draft description',
|
||||
description: 'deployed description',
|
||||
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
|
||||
})
|
||||
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 42,
|
||||
remoteDraftRev: '2026-05-22T10:00:00Z'
|
||||
remoteRev: 42
|
||||
})
|
||||
})
|
||||
|
||||
@@ -733,32 +707,22 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
it('seeds raw app draft metadata on first app write', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [3, 4],
|
||||
draft_created_at: '2026-05-22T10:30:00Z',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export async function main() {}' }
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
|
||||
},
|
||||
policy: { execution_mode: 'publisher' },
|
||||
custom_path: 'report',
|
||||
draft: {
|
||||
summary: 'saved app draft',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'draft content' },
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export async function main() {}' }
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
|
||||
},
|
||||
policy: { execution_mode: 'anonymous' }
|
||||
}
|
||||
custom_path: 'report'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_app_file', {
|
||||
@@ -769,9 +733,9 @@ describe('global AI tools', () => {
|
||||
|
||||
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
|
||||
expect(draft).toMatchObject({
|
||||
summary: 'saved app draft',
|
||||
summary: 'deployed app',
|
||||
files: {
|
||||
'/src/App.tsx': 'draft content',
|
||||
'/src/App.tsx': 'deployed content',
|
||||
'/src/New.tsx': 'export default function New() { return null }'
|
||||
},
|
||||
runnables: {
|
||||
@@ -781,12 +745,11 @@ describe('global AI tools', () => {
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
|
||||
policy: { execution_mode: 'anonymous' },
|
||||
policy: { execution_mode: 'publisher' },
|
||||
custom_path: 'report'
|
||||
})
|
||||
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 4,
|
||||
remoteDraftRev: '2026-05-22T10:30:00Z'
|
||||
remoteRev: 4
|
||||
})
|
||||
})
|
||||
|
||||
@@ -841,39 +804,31 @@ describe('global AI tools', () => {
|
||||
expect(item.value.backend[0]).not.toHaveProperty('content')
|
||||
})
|
||||
|
||||
it('summarizes backend raw app drafts from the same source as file reads', async () => {
|
||||
const appWithDraft = {
|
||||
it('summarizes backend raw apps from the same source as file reads', async () => {
|
||||
const deployedApp = {
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: ['deployed'] }
|
||||
},
|
||||
draft: {
|
||||
summary: 'saved app draft',
|
||||
value: {
|
||||
files: {
|
||||
'/src/App.tsx': 'draft content',
|
||||
'/src/DraftOnly.tsx': 'draft-only content'
|
||||
},
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: {
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "draft" }'
|
||||
}
|
||||
files: {
|
||||
'/src/App.tsx': 'deployed content',
|
||||
'/src/Helper.tsx': 'helper content'
|
||||
},
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: {
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "deployed" }'
|
||||
}
|
||||
},
|
||||
data: { tables: ['draft'] }
|
||||
}
|
||||
}
|
||||
},
|
||||
data: { tables: ['deployed'] }
|
||||
}
|
||||
}
|
||||
vi.mocked(AppService.getAppByPathWithDraft)
|
||||
.mockResolvedValueOnce(appWithDraft as any)
|
||||
.mockResolvedValueOnce(appWithDraft as any)
|
||||
vi.mocked(AppService.getAppByPath)
|
||||
.mockResolvedValueOnce(deployedApp as any)
|
||||
.mockResolvedValueOnce(deployedApp as any)
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'app',
|
||||
@@ -881,15 +836,14 @@ describe('global AI tools', () => {
|
||||
})
|
||||
const item = JSON.parse(raw)
|
||||
|
||||
expect(raw).not.toContain('draft-only content')
|
||||
expect(item).toMatchObject({
|
||||
type: 'app',
|
||||
path: 'f/apps/report',
|
||||
summary: 'saved app draft',
|
||||
summary: 'deployed app',
|
||||
value: {
|
||||
frontend: [
|
||||
{ path: '/src/App.tsx', size: 'draft content'.length },
|
||||
{ path: '/src/DraftOnly.tsx', size: 'draft-only content'.length }
|
||||
{ path: '/src/App.tsx', size: 'deployed content'.length },
|
||||
{ path: '/src/Helper.tsx', size: 'helper content'.length }
|
||||
],
|
||||
backend: [
|
||||
expect.objectContaining({
|
||||
@@ -897,10 +851,10 @@ describe('global AI tools', () => {
|
||||
name: 'main',
|
||||
type: 'inline',
|
||||
language: 'bun',
|
||||
contentSize: 'export async function main() { return "draft" }'.length
|
||||
contentSize: 'export async function main() { return "deployed" }'.length
|
||||
})
|
||||
],
|
||||
data: { tables: ['draft'] }
|
||||
data: { tables: ['deployed'] }
|
||||
},
|
||||
isDraft: false
|
||||
})
|
||||
@@ -908,14 +862,14 @@ describe('global AI tools', () => {
|
||||
await expect(
|
||||
callGlobalTool('read_app_file', {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/DraftOnly.tsx'
|
||||
file_path: '/src/Helper.tsx'
|
||||
})
|
||||
).resolves.toBe('draft-only content')
|
||||
).resolves.toBe('helper content')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads raw app files without creating a local draft', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
@@ -923,14 +877,6 @@ describe('global AI tools', () => {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
},
|
||||
draft: {
|
||||
summary: 'saved app draft',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'draft content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
}
|
||||
} as any)
|
||||
|
||||
@@ -939,12 +885,12 @@ describe('global AI tools', () => {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/App.tsx'
|
||||
})
|
||||
).resolves.toBe('draft content')
|
||||
).resolves.toBe('deployed content')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
@@ -968,7 +914,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
@@ -989,7 +935,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
it('does not persist a raw app draft when delete_app_runnable validation fails', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
|
||||
@@ -576,7 +576,12 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'app' && item.value && typeof item.value === 'object' && 'files' in item.value) {
|
||||
if (
|
||||
item.type === 'app' &&
|
||||
item.value &&
|
||||
typeof item.value === 'object' &&
|
||||
'files' in item.value
|
||||
) {
|
||||
return {
|
||||
type: 'app',
|
||||
path: item.path,
|
||||
@@ -892,10 +897,9 @@ function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
}
|
||||
}
|
||||
|
||||
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
|
||||
function appDraftMeta(app: { versions?: number[] }): UserDraftMeta {
|
||||
return {
|
||||
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
|
||||
remoteDraftRev: app.draft_created_at
|
||||
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,8 +909,8 @@ async function loadAppValueForRead(path: string, workspace: string): Promise<App
|
||||
return draft.value as AppDraftValue
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
return appSourceToDraftValue(app.draft ?? app, app)
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
return appSourceToDraftValue(app, app)
|
||||
}
|
||||
|
||||
async function loadAppDraftValue(path: string, workspace: string): Promise<LoadedAppDraftValue> {
|
||||
@@ -915,8 +919,8 @@ async function loadAppDraftValue(path: string, workspace: string): Promise<Loade
|
||||
return { value: draft.value as AppDraftValue }
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
const value = appSourceToDraftValue(app.draft ?? app, app)
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
const value = appSourceToDraftValue(app, app)
|
||||
return { value, meta: appDraftMeta(app) }
|
||||
}
|
||||
|
||||
@@ -1065,8 +1069,8 @@ async function readWorkspaceItem(
|
||||
)
|
||||
case 'app': {
|
||||
// Returns lightweight metadata only — file/runnable contents come via read_app_file.
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
const value = appSourceToDraftValue(app.draft ?? app)
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
const value = appSourceToDraftValue(app)
|
||||
const metadata = summarizeAppValue(value)
|
||||
return {
|
||||
type: 'app',
|
||||
@@ -1903,11 +1907,11 @@ async function writeScriptDraft(
|
||||
}
|
||||
UserDraft.save('script', storagePath, draft, { workspace })
|
||||
} else if (backendExists) {
|
||||
const existing = await ScriptService.getScriptByPathWithDraft({
|
||||
const existing = await ScriptService.getScriptByPath({
|
||||
workspace,
|
||||
path: args.path
|
||||
})
|
||||
const base = (existing.draft ?? existing) as NewScript
|
||||
const base = existing as unknown as NewScript
|
||||
const draft: NewScript = {
|
||||
...structuredClone(base),
|
||||
parent_hash: existing.hash,
|
||||
@@ -1920,7 +1924,7 @@ async function writeScriptDraft(
|
||||
'script',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at },
|
||||
{ remoteRev: existing.hash },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
@@ -1974,22 +1978,21 @@ async function writeFlowDraft(
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
} else if (backendExists) {
|
||||
const [existing, latestVersion] = await Promise.all([
|
||||
FlowService.getFlowByPathWithDraft({ workspace, path: args.path }),
|
||||
FlowService.getFlowByPath({ workspace, path: args.path }),
|
||||
FlowService.getFlowLatestVersion({ workspace, path: args.path })
|
||||
])
|
||||
const base = (existing.draft ?? existing) as Flow
|
||||
const draft: Flow = {
|
||||
...structuredClone(base),
|
||||
...structuredClone(existing),
|
||||
path: args.path,
|
||||
summary: args.summary ?? base.summary,
|
||||
summary: args.summary ?? existing.summary,
|
||||
value,
|
||||
schema: draftValue.schema ?? base.schema
|
||||
schema: draftValue.schema ?? existing.schema
|
||||
}
|
||||
UserDraft.setDraftAndMeta(
|
||||
'flow',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at },
|
||||
{ remoteRev: latestVersion.id },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { Value } from "$lib/utils"
|
||||
import type { Value } from '$lib/utils'
|
||||
|
||||
export type DiffDrawerDiff =
|
||||
| {
|
||||
mode: 'normal'
|
||||
deployed: Value
|
||||
draft: Value | undefined
|
||||
current: Value
|
||||
defaultDiffType?: 'deployed' | 'draft'
|
||||
button?: { text: string; onClick: () => void }
|
||||
}
|
||||
| {
|
||||
mode: 'simple'
|
||||
original: Value
|
||||
current: Value
|
||||
title: string
|
||||
button?: { text: string; onClick: () => void }
|
||||
}
|
||||
export type DiffDrawerDiff =
|
||||
| {
|
||||
mode: 'normal'
|
||||
deployed: Value
|
||||
draft?: Value | undefined
|
||||
current: Value
|
||||
defaultDiffType?: 'deployed' | 'draft'
|
||||
button?: { text: string; onClick: () => void }
|
||||
}
|
||||
| {
|
||||
mode: 'simple'
|
||||
original: Value
|
||||
current: Value
|
||||
title: string
|
||||
button?: { text: string; onClick: () => void }
|
||||
}
|
||||
|
||||
export interface DiffDrawerI {
|
||||
openDrawer: () => void
|
||||
closeDrawer: () => void
|
||||
setDiff: (diff: DiffDrawerDiff) => void
|
||||
}
|
||||
openDrawer: () => void
|
||||
closeDrawer: () => void
|
||||
setDiff: (diff: DiffDrawerDiff) => void
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenFlow } from '$lib/gen'
|
||||
import type { Flow, OpenFlow } from '$lib/gen'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import type { FlowState } from './flows/flowState'
|
||||
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
|
||||
import type { Trigger } from './triggers/utils'
|
||||
import type { DiffDrawerI } from './diff_drawer'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
|
||||
import type { ScheduleTrigger } from './triggers'
|
||||
@@ -17,14 +17,13 @@ export type FlowBuilderProps = {
|
||||
loading?: boolean
|
||||
flowStore: StateStore<OpenFlow>
|
||||
flowStateStore: StateStore<FlowState>
|
||||
savedFlow?: FlowWithDraftAndDraftTriggers | undefined
|
||||
savedFlow?: Flow | undefined
|
||||
diffDrawer?: DiffDrawerI | undefined
|
||||
customUi?: FlowBuilderWhitelabelCustomUi
|
||||
disableAi?: boolean
|
||||
disabledFlowInputs?: boolean
|
||||
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
|
||||
version?: number | undefined
|
||||
setSavedraftCb?: ((cb: () => void) => void) | undefined
|
||||
draftTriggersFromUrl?: Trigger[] | undefined
|
||||
selectedTriggerIndexFromUrl?: number | undefined
|
||||
children?: import('svelte').Snippet
|
||||
@@ -34,18 +33,6 @@ export type FlowBuilderProps = {
|
||||
}
|
||||
noInitial?: boolean
|
||||
liveEditorDraftStoragePath?: string
|
||||
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
|
||||
onSaveDraft?: ({
|
||||
path,
|
||||
savedAtNewPath,
|
||||
newFlow
|
||||
}: {
|
||||
path: string
|
||||
savedAtNewPath: boolean
|
||||
newFlow: boolean
|
||||
}) => void
|
||||
onSaveDraftError?: ({ error }: { error: any }) => void
|
||||
onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void
|
||||
onDeploy?: ({ path }: { path: string }) => void
|
||||
onDeployError?: ({ error }: { error: any }) => void
|
||||
onDetails?: ({ path }: { path: string }) => void
|
||||
|
||||
@@ -24,23 +24,14 @@
|
||||
flowEditorDrawer?.openDrawer?.()
|
||||
|
||||
try {
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
const backendFlow = await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path
|
||||
})
|
||||
|
||||
savedFlow = {
|
||||
...structuredClone(flowWithDraft),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone(flowWithDraft.draft),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path
|
||||
}
|
||||
: undefined
|
||||
} as Flow & { draft?: Flow }
|
||||
savedFlow = structuredClone(backendFlow) as Flow
|
||||
|
||||
// Use the draft if available, otherwise the deployed flow
|
||||
flow = flowWithDraft.draft ?? flowWithDraft
|
||||
flow = backendFlow
|
||||
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
loading = false
|
||||
@@ -53,11 +44,7 @@
|
||||
let callback: (() => void) | undefined = undefined
|
||||
let flowPath: string = $state('')
|
||||
let flow: Flow | undefined = $state(undefined)
|
||||
let savedFlow:
|
||||
| (Flow & {
|
||||
draft?: Flow | undefined
|
||||
})
|
||||
| undefined = $state(undefined)
|
||||
let savedFlow: Flow | undefined = $state(undefined)
|
||||
let loading = $state(true)
|
||||
|
||||
const flowStore: StateStore<Flow> = $state({
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
type?: U
|
||||
time?: number
|
||||
starred?: boolean
|
||||
has_draft?: boolean
|
||||
hash?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
type?: U
|
||||
time?: number
|
||||
starred?: boolean
|
||||
has_draft?: boolean
|
||||
}
|
||||
|
||||
type TableScript = TableItem<Script, 'script'>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { rawAppToHubUrl } from '$lib/hub'
|
||||
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
@@ -26,12 +26,7 @@
|
||||
WandSparkles
|
||||
} from 'lucide-svelte'
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
orderedJsonStringify,
|
||||
type Value,
|
||||
replaceFalseWithUndefined
|
||||
} from '../../utils'
|
||||
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
|
||||
// import { allItems, toStatic } from '../apps/editor/settingsPanel/utils'
|
||||
@@ -51,7 +46,6 @@
|
||||
import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte'
|
||||
import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte'
|
||||
import type { Runnable } from './RawAppInlineScriptRunnable.svelte'
|
||||
import { updateRawAppPolicy } from './rawAppPolicy'
|
||||
@@ -96,11 +90,9 @@
|
||||
savedApp?:
|
||||
| {
|
||||
value: any
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -186,14 +178,12 @@
|
||||
|
||||
const loading = $state({
|
||||
publish: false,
|
||||
save: false,
|
||||
saveDraft: false
|
||||
save: false
|
||||
})
|
||||
|
||||
let pathError: string = $state('')
|
||||
let appExport = $state() as AppExportButton | undefined
|
||||
|
||||
let draftDrawerOpen = $state(false)
|
||||
let saveDrawerOpen = $state(false)
|
||||
let historyBrowserDrawerOpen = $state(false)
|
||||
let publishToHubDrawerOpen = $state(false)
|
||||
@@ -239,10 +229,6 @@
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
|
||||
function closeDraftDrawer() {
|
||||
draftDrawerOpen = false
|
||||
}
|
||||
|
||||
async function computeTriggerables() {
|
||||
policy = await updateRawAppPolicy(runnables, policy)
|
||||
}
|
||||
@@ -313,7 +299,7 @@
|
||||
replaceFalseWithUndefined({
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
})
|
||||
@@ -422,175 +408,6 @@
|
||||
return
|
||||
}
|
||||
|
||||
async function saveInitialDraft() {
|
||||
if (!app) {
|
||||
sendUserToast(`App hasn't been loaded yet`, true)
|
||||
return
|
||||
}
|
||||
await computeTriggerables()
|
||||
try {
|
||||
let { css, js } = await getBundle()
|
||||
await AppService.createAppRaw({
|
||||
workspace: $workspaceStore!,
|
||||
formData: {
|
||||
app: {
|
||||
value: app,
|
||||
path: newEditedPath,
|
||||
summary: summary,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
},
|
||||
js,
|
||||
css
|
||||
}
|
||||
})
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: newEditedPath,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: app,
|
||||
path: newEditedPath,
|
||||
summary: summary,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
}
|
||||
})
|
||||
savedApp = {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
draft_only: true,
|
||||
draft: {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
},
|
||||
custom_path: customPath
|
||||
}
|
||||
|
||||
draftDrawerOpen = false
|
||||
// The initial draft was promoted to a real path on the backend —
|
||||
// drop the autosave keyed on the prior (possibly empty) path so
|
||||
// a future "+ App" click opens on a clean slate.
|
||||
UserDraft.remove('raw_app', appPath)
|
||||
dispatch('savedNewAppPath', newEditedPath)
|
||||
} catch (e) {
|
||||
sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true)
|
||||
}
|
||||
draftDrawerOpen = false
|
||||
}
|
||||
|
||||
async function saveDraft(forceSave = false) {
|
||||
if (!app) {
|
||||
sendUserToast(`App hasn't been loaded yet`, true)
|
||||
return
|
||||
}
|
||||
if (newApp) {
|
||||
// initial draft
|
||||
draftDrawerOpen = true
|
||||
return
|
||||
}
|
||||
if (!savedApp) {
|
||||
return
|
||||
}
|
||||
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
|
||||
const current = cleanValueProperties({
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
policy
|
||||
})
|
||||
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
||||
sendUserToast('No changes detected, ignoring', false, [
|
||||
{
|
||||
label: 'Save anyway',
|
||||
callback: () => {
|
||||
saveDraft(true)
|
||||
}
|
||||
}
|
||||
])
|
||||
return
|
||||
}
|
||||
loading.saveDraft = true
|
||||
try {
|
||||
await computeTriggerables()
|
||||
let path = appPath
|
||||
if (savedApp.draft_only) {
|
||||
await AppService.deleteApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: path
|
||||
})
|
||||
let { css, js } = await getBundle()
|
||||
|
||||
await AppService.createAppRaw({
|
||||
workspace: $workspaceStore!,
|
||||
formData: {
|
||||
app: {
|
||||
value: app!,
|
||||
summary: summary,
|
||||
policy,
|
||||
path: newEditedPath || path,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
},
|
||||
js,
|
||||
css
|
||||
}
|
||||
})
|
||||
}
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: app!,
|
||||
summary: summary,
|
||||
policy,
|
||||
path: newEditedPath || path
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
savedApp = {
|
||||
...(savedApp?.draft_only
|
||||
? {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
: savedApp),
|
||||
draft: {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
path: newEditedPath || path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
}
|
||||
|
||||
sendUserToast('Draft saved')
|
||||
UserDraft.remove('raw_app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
dispatch('savedNewAppPath', newEditedPath || path)
|
||||
}
|
||||
} catch (e) {
|
||||
loading.saveDraft = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
let onLatest = $state(true)
|
||||
async function compareVersions() {
|
||||
if (version === undefined) {
|
||||
@@ -613,13 +430,6 @@
|
||||
let moreItems = $derived([
|
||||
...(compactTopbar
|
||||
? [
|
||||
{
|
||||
displayName: 'Save draft',
|
||||
icon: Save,
|
||||
action: () => saveDraft(),
|
||||
shortcut: `${mod}S`,
|
||||
disabled: !newApp && !savedApp
|
||||
},
|
||||
{
|
||||
displayName: `Jobs (${jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})`,
|
||||
icon: Bug,
|
||||
@@ -685,11 +495,10 @@
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
draft: savedApp.draft,
|
||||
current: {
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
@@ -712,7 +521,7 @@
|
||||
modifiedValue: {
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
path: newEditedPath || savedApp?.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
@@ -736,44 +545,19 @@
|
||||
currentValue={{
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
path: newEditedPath || savedApp?.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if appPath == ''}
|
||||
<Drawer bind:open={draftDrawerOpen} size="800px">
|
||||
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
|
||||
{#snippet actions()}
|
||||
<div>
|
||||
<Button
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={pathError != '' || app == undefined}
|
||||
on:click={() => saveInitialDraft()}
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
>
|
||||
Save initial draft
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
<AppEditorHeaderDeployInitialDraft
|
||||
bind:summary
|
||||
bind:appPath
|
||||
bind:pathError
|
||||
bind:newEditedPath
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
<Drawer bind:open={saveDrawerOpen} size="800px">
|
||||
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
|
||||
{#snippet actions()}
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!savedApp || savedApp.draft_only}
|
||||
disabled={!savedApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
return
|
||||
@@ -786,11 +570,10 @@
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
draft: savedApp.draft,
|
||||
current: {
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
},
|
||||
@@ -978,19 +761,6 @@
|
||||
AI
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !compactTopbar}
|
||||
<Button
|
||||
loading={loading.save}
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={() => saveDraft()}
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
disabled={!newApp && !savedApp}
|
||||
shortCut={{ key: 'S' }}
|
||||
>
|
||||
Draft
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
loading={loading.save}
|
||||
startIcon={{ icon: Save }}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NewScript } from '$lib/gen'
|
||||
import type { NewScript, Script } from '$lib/gen'
|
||||
import type { AssetWithAltAccessType } from './assets/lib'
|
||||
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
|
||||
import type { DiffDrawerI } from './diff_drawer'
|
||||
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
|
||||
import type { ScheduleTrigger } from './triggers'
|
||||
import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
|
||||
import type { Trigger } from './triggers/utils'
|
||||
import type { WorkspaceItem } from './workspacePicker'
|
||||
|
||||
export interface ScriptBuilderProps {
|
||||
@@ -29,7 +29,7 @@ export interface ScriptBuilderProps {
|
||||
showMeta?: boolean
|
||||
neverShowMeta?: boolean
|
||||
diffDrawer?: DiffDrawerI | undefined
|
||||
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
|
||||
savedScript?: Script | NewScript | undefined
|
||||
searchParams?: URLSearchParams
|
||||
disableHistoryChange?: boolean
|
||||
customUi?: ScriptBuilderWhitelabelCustomUi
|
||||
@@ -38,11 +38,7 @@ export interface ScriptBuilderProps {
|
||||
children?: import('svelte').Snippet
|
||||
onDeploy?: (e: { path: string; hash: string }) => void
|
||||
onDeployError?: (e: { path: string; error: any }) => void
|
||||
onSaveInitial?: (e: { path: string; hash: string }) => void
|
||||
onHistoryRestore?: () => void
|
||||
onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void
|
||||
onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void
|
||||
onSeeDetails?: (e: { path: string }) => void
|
||||
onSaveDraftError?: (e: { path: string; error: any }) => void
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
}
|
||||
|
||||
@@ -472,7 +472,6 @@
|
||||
type?: U
|
||||
time?: number
|
||||
starred?: boolean
|
||||
has_draft?: boolean
|
||||
}
|
||||
|
||||
// interface SelectableSearchMenuItem {
|
||||
|
||||
@@ -650,71 +650,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
|
||||
})
|
||||
}
|
||||
|
||||
export type FlowWithDraftAndDraftTriggers = Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
|
||||
export type NewScriptWithDraftAndDraftTriggers = NewScript & {
|
||||
draft?: NewScript & { draft_triggers?: Trigger[] }
|
||||
hash: string
|
||||
}
|
||||
|
||||
// Get rid of deployed triggers from the saved flow in the case there is a match with a deployed trigger
|
||||
export function filterDraftTriggers(
|
||||
savedValue: FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers,
|
||||
triggersState: Triggers
|
||||
): FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers {
|
||||
const deployedTriggers = triggersState.triggers.filter((t) => !t.draftConfig && !t.isDraft)
|
||||
|
||||
// Early return if no deployed triggers or no draft triggers to filter
|
||||
if (deployedTriggers.length === 0 || !savedValue?.draft?.draft_triggers?.length) {
|
||||
return savedValue
|
||||
}
|
||||
|
||||
const deployedTriggerKeys = new Set(deployedTriggers.map((t) => `${t.path}:${t.type}`))
|
||||
|
||||
const originalSavedDraftTriggers = savedValue.draft.draft_triggers
|
||||
const keptTriggers: Trigger[] = []
|
||||
const removedTriggers: Trigger[] = []
|
||||
|
||||
// Single pass to separate kept vs removed triggers
|
||||
for (const savedTrigger of originalSavedDraftTriggers) {
|
||||
const triggerKey = `${savedTrigger.draftConfig?.path}:${savedTrigger.type}`
|
||||
if (deployedTriggerKeys.has(triggerKey)) {
|
||||
removedTriggers.push(savedTrigger)
|
||||
} else {
|
||||
keptTriggers.push(savedTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
// Early return if nothing was filtered
|
||||
if (removedTriggers.length === 0) {
|
||||
return savedValue
|
||||
}
|
||||
|
||||
// Update saved value
|
||||
const newSavedValue = {
|
||||
...savedValue,
|
||||
draft: {
|
||||
...savedValue.draft,
|
||||
draft_triggers: keptTriggers.length > 0 ? keptTriggers : undefined
|
||||
}
|
||||
} as typeof savedValue
|
||||
|
||||
const removedTriggerKeys = new Set(removedTriggers.map((t) => `${t.draftConfig?.path}:${t.type}`))
|
||||
|
||||
// Remove filtered triggers from triggersState
|
||||
triggersState.setTriggers(
|
||||
triggersState.triggers.filter((trigger) => {
|
||||
const triggerKey = `${trigger.draftConfig?.path}:${trigger.type}`
|
||||
return !removedTriggerKeys.has(triggerKey)
|
||||
})
|
||||
)
|
||||
|
||||
return newSavedValue
|
||||
}
|
||||
export type FlowWithDraftAndDraftTriggers = Flow
|
||||
export type NewScriptWithDraftAndDraftTriggers = NewScript & { hash?: string }
|
||||
|
||||
export function getHandlerType(scriptPath: string): ErrorHandler {
|
||||
const handlerMap = {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<script lang="ts">
|
||||
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
|
||||
import {
|
||||
AppService,
|
||||
type AppWithLastVersion,
|
||||
type AppWithLastVersionWDraft,
|
||||
DraftService
|
||||
} from '$lib/gen'
|
||||
import { AppService, type AppWithLastVersion } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { cleanValueProperties, orderedJsonStringify, type Value } from '$lib/utils'
|
||||
import { cleanValueProperties, orderedJsonStringify } from '$lib/utils'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -21,17 +16,13 @@
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
|
||||
let app = $state(
|
||||
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
|
||||
)
|
||||
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
|
||||
let savedApp:
|
||||
| {
|
||||
value: App
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
@@ -43,7 +34,7 @@
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta }
|
||||
| { baseline: AppWithLastVersion & { value: any }; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
// Backend revs at the most recent `loadApp` — handed to AppEditor as
|
||||
@@ -97,60 +88,32 @@
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
const tok = ++loadAppToken
|
||||
const app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
const backendApp = await AppService.getAppByPath({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (tok !== loadAppToken) return
|
||||
const app_w_draft_: AppWithLastVersionWDraft = structuredClone(stateSnapshot(app_w_draft))
|
||||
const backendApp_ = structuredClone(stateSnapshot(backendApp))
|
||||
savedApp = {
|
||||
summary: app_w_draft_.summary,
|
||||
value: app_w_draft_.value as App,
|
||||
path: app_w_draft_.path,
|
||||
policy: app_w_draft_.policy,
|
||||
draft_only: app_w_draft_.draft_only,
|
||||
draft:
|
||||
app_w_draft_.draft?.['summary'] !== undefined // backward compatibility for old drafts missing metadata
|
||||
? app_w_draft_.draft
|
||||
: app_w_draft_.draft
|
||||
? {
|
||||
summary: app_w_draft_.summary,
|
||||
value: app_w_draft_.draft,
|
||||
path: app_w_draft_.path,
|
||||
policy: app_w_draft_.policy,
|
||||
custom_path: app_w_draft_.custom_path
|
||||
}
|
||||
: undefined,
|
||||
custom_path: app_w_draft_.custom_path
|
||||
summary: backendApp_.summary,
|
||||
value: backendApp_.value as App,
|
||||
path: backendApp_.path,
|
||||
policy: backendApp_.policy,
|
||||
custom_path: backendApp_.custom_path
|
||||
}
|
||||
|
||||
// Resolve the app value: backend draft > deployed, then overlay any
|
||||
// local autosave from UserDraft if present.
|
||||
const backendApp = app_w_draft.draft
|
||||
? app_w_draft.summary !== undefined
|
||||
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
: app_w_draft
|
||||
|
||||
const localDraftValue = UserDraft.get<App>('app', path)
|
||||
const previousMeta = UserDraft.getMeta('app', path)
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
remoteRev: backendApp.versions
|
||||
? backendApp.versions[backendApp.versions.length - 1]
|
||||
: undefined
|
||||
}
|
||||
currentRevs = newRevs
|
||||
if (
|
||||
localDraftValue != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendApp.value))
|
||||
orderedJsonStringify(cleanValueProperties(backendApp.value as any))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
@@ -162,9 +125,7 @@
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
UserDraft.saveMeta('app', path, newRevs)
|
||||
}
|
||||
const appPath = backendApp.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = newRevs
|
||||
@@ -172,15 +133,8 @@
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${appPath}`)
|
||||
goto(`/apps/edit/${backendApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
@@ -193,35 +147,6 @@
|
||||
if (localDraftValue != undefined) UserDraft.remove('app', path)
|
||||
app = backendApp
|
||||
}
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) {
|
||||
const reloadAction = () => {
|
||||
app = app_w_draft
|
||||
redraw++
|
||||
}
|
||||
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties(app ?? {})
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -239,31 +164,12 @@
|
||||
}
|
||||
})
|
||||
|
||||
async function restoreDraft() {
|
||||
if (!savedApp || !savedApp.draft) {
|
||||
sendUserToast('Could not restore to draft', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
|
||||
async function restoreDeployed() {
|
||||
if (!savedApp) {
|
||||
sendUserToast('Could not restore to deployed', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
if (savedApp.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: savedApp.path
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
@@ -287,7 +193,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
import { run } from 'svelte/legacy'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
import { AppService, DraftService } from '$lib/gen'
|
||||
import { AppService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
type Value
|
||||
} from '$lib/utils'
|
||||
import { cleanValueProperties, orderedJsonStringify, readFieldsRecursively } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
@@ -51,11 +46,9 @@
|
||||
files: Record<string, { code: string }>
|
||||
runnables: Record<string, HiddenRunnable>
|
||||
}
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
@@ -181,30 +174,27 @@
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
const tok = ++loadAppToken
|
||||
const app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
const backendApp = await AppService.getAppByPath({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (tok !== loadAppToken) return
|
||||
const app_w_draft_ = structuredClone(stateSnapshot(app_w_draft))
|
||||
const backendApp_ = structuredClone(stateSnapshot(backendApp))
|
||||
savedApp = {
|
||||
summary: app_w_draft_.summary,
|
||||
value: app_w_draft_.value as any,
|
||||
path: app_w_draft_.path,
|
||||
policy: app_w_draft_.policy,
|
||||
draft_only: app_w_draft_.draft_only,
|
||||
draft: app_w_draft_.draft,
|
||||
custom_path: app_w_draft_.custom_path
|
||||
summary: backendApp_.summary,
|
||||
value: backendApp_.value as any,
|
||||
path: backendApp_.path,
|
||||
policy: backendApp_.policy,
|
||||
custom_path: backendApp_.custom_path
|
||||
}
|
||||
|
||||
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
|
||||
const backendSource: any = backendApp
|
||||
const localDraft = draftHandle.draft
|
||||
const previousMeta = draftHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
remoteRev: backendApp.versions
|
||||
? backendApp.versions[backendApp.versions.length - 1]
|
||||
: undefined
|
||||
}
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: backendSource.value?.files ?? {},
|
||||
@@ -215,8 +205,8 @@
|
||||
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
|
||||
: { ...DEFAULT_DATA }),
|
||||
summary: backendSource.summary ?? '',
|
||||
policy: backendSource.policy ?? app_w_draft.policy,
|
||||
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
|
||||
policy: backendSource.policy ?? backendApp.policy,
|
||||
custom_path: backendSource.custom_path ?? backendApp.custom_path
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -234,9 +224,7 @@
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
draftHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const appPath = app_w_draft.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
@@ -244,13 +232,6 @@
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadApp doesn't re-read
|
||||
@@ -264,42 +245,13 @@
|
||||
runnables = localDraft.runnables
|
||||
data = localDraft.data
|
||||
summary = localDraft.summary
|
||||
policy = localDraft.policy ?? app_w_draft.policy
|
||||
newPath = app_w_draft.path
|
||||
policy = localDraft.policy ?? backendApp.policy
|
||||
newPath = backendApp.path
|
||||
files = localDraft.files
|
||||
} else {
|
||||
if (localDraft != undefined) UserDraft.remove('raw_app', path)
|
||||
extractRawApp(backendSource)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only) {
|
||||
const reloadAction = () => {
|
||||
extractRawApp(app_w_draft)
|
||||
redraw++
|
||||
}
|
||||
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties({ files, runnables })
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,36 +268,12 @@
|
||||
}
|
||||
})
|
||||
|
||||
async function restoreDraft() {
|
||||
if (!savedApp || !savedApp.draft) {
|
||||
sendUserToast('Could not restore to draft', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('raw_app', path)
|
||||
// Drop the in-memory handle state so loadApp sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
|
||||
async function restoreDeployed() {
|
||||
if (!savedApp) {
|
||||
sendUserToast('Could not restore to deployed', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
if (savedApp.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: savedApp.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
@@ -370,7 +298,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
|
||||
@@ -184,11 +184,6 @@
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<FlowBuilder
|
||||
onSaveInitial={(e) => {
|
||||
UserDraft.remove('flow', '')
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'flow')
|
||||
goto(`/flows/edit/${e.path}?selected=${e.id}`)
|
||||
}}
|
||||
onDeploy={(e) => {
|
||||
UserDraft.remove('flow', '')
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'flow')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { FlowService, type Flow, DraftService } from '$lib/gen'
|
||||
import { FlowService, type Flow } from '$lib/gen'
|
||||
|
||||
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
@@ -42,11 +42,7 @@
|
||||
$initialArgsStore = undefined
|
||||
}
|
||||
|
||||
let savedFlow:
|
||||
| (Flow & {
|
||||
draft?: Flow | undefined
|
||||
})
|
||||
| undefined = $state(undefined)
|
||||
let savedFlow: Flow | undefined = $state(undefined)
|
||||
|
||||
const flowDraftPath = page.params.path ?? ''
|
||||
|
||||
@@ -90,8 +86,6 @@
|
||||
|
||||
let selectedId: string = $state('settings-metadata')
|
||||
|
||||
let nobackenddraft = false
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
@@ -149,32 +143,17 @@
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
const backendFlow = await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = {
|
||||
...structuredClone($state.snapshot(flowWithDraft)),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowWithDraft.draft)),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
|
||||
}
|
||||
: undefined
|
||||
} as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
savedFlow = structuredClone($state.snapshot(backendFlow)) as Flow
|
||||
|
||||
const backendFlow =
|
||||
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
|
||||
const localDraft = flowHandle.draft
|
||||
const previousMeta = flowHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: v,
|
||||
remoteDraftRev: flowWithDraft.draft_created_at
|
||||
remoteRev: v
|
||||
}
|
||||
|
||||
if (localDraft != undefined) {
|
||||
@@ -197,28 +176,18 @@
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
flowHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const flowPath = backendFlow.path
|
||||
const hasBackendDraft = flowWithDraft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
loadFlow()
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flowPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadFlow doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
})
|
||||
@@ -229,55 +198,7 @@
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
}
|
||||
|
||||
if (flowWithDraft.draft != undefined && !nobackenddraft) {
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
|
||||
|
||||
if (!flowWithDraft.draft_only && localDraft == undefined) {
|
||||
const deployed = cleanValueProperties(flowWithDraft)
|
||||
const draft = cleanValueProperties(flow)
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flow.path
|
||||
})
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// flowHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadFlow would treat it
|
||||
// as a local autosave and the staleness check would fire
|
||||
// a spurious "newer version was deployed" modal because
|
||||
// remoteDraftRev moved from "defined" to "undefined".
|
||||
// Drop the in-memory state first.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
sendUserToast('flow loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
flowBuilder?.setDraftTriggers(undefined)
|
||||
}
|
||||
flowBuilder?.setDraftTriggers(undefined)
|
||||
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
if (tok !== loadFlowToken) return
|
||||
@@ -297,35 +218,12 @@
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
async function restoreDraft() {
|
||||
if (!savedFlow || !savedFlow.draft) {
|
||||
sendUserToast('Could not restore to draft', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// Drop the in-memory handle state so loadFlow sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/flows/edit/${savedFlow.draft.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
async function restoreDeployed() {
|
||||
if (!savedFlow) {
|
||||
sendUserToast('Could not restore to deployed', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
if (savedFlow.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: savedFlow.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/flows/edit/${savedFlow.path}`)
|
||||
@@ -335,7 +233,7 @@
|
||||
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} isFlow />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
@@ -357,9 +255,6 @@
|
||||
onDetails={(e) => {
|
||||
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
onSaveDraftOnlyAtNewPath={(e) => {
|
||||
goto(`/flows/edit/${e.path}?selected=${e.selectedId}`)
|
||||
}}
|
||||
onHistoryRestore={() => {
|
||||
loadFlow()
|
||||
}}
|
||||
|
||||
@@ -280,10 +280,6 @@
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'script')
|
||||
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
onSaveInitial={(e) => {
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'script')
|
||||
goto(`/scripts/edit/${e.path}`)
|
||||
}}
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
searchParams={page.url.searchParams}
|
||||
bind:script={scriptHandle.draft}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ScriptService, type NewScript, type NewScriptWithDraft, DraftService } from '$lib/gen'
|
||||
import { ScriptService, type NewScript, type Script } from '$lib/gen'
|
||||
|
||||
import { initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
|
||||
|
||||
let savedScript: NewScriptWithDraft | undefined = $state(undefined)
|
||||
let savedScript: Script | NewScript | undefined = $state(undefined)
|
||||
let fullyLoaded = $state(false)
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
@@ -169,24 +169,20 @@
|
||||
hash
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
|
||||
savedScript = structuredClone($state.snapshot(scriptByHash))
|
||||
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
|
||||
} else {
|
||||
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
const backendScript = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptWithDraft))
|
||||
savedScript = structuredClone($state.snapshot(backendScript))
|
||||
|
||||
const localDraft = scriptHandle.draft
|
||||
const previousMeta = scriptHandle.meta
|
||||
const backendDraft = scriptWithDraft.draft
|
||||
? ({ ...scriptWithDraft.draft } as EditableScript)
|
||||
: undefined
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: scriptWithDraft.hash,
|
||||
remoteDraftRev: scriptWithDraft.draft_created_at
|
||||
remoteRev: backendScript.hash
|
||||
}
|
||||
|
||||
// Compute the fully-baked initial value once so the assignment
|
||||
@@ -194,10 +190,9 @@
|
||||
// `parent_hash = ...` would count as a second write under
|
||||
// useLocalStorageValue's saveInitialValue=false contract and get
|
||||
// persisted before the user has touched anything.
|
||||
const baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript
|
||||
const bakedBaseline: EditableScript = {
|
||||
...baseline,
|
||||
parent_hash: topHash ?? scriptWithDraft.hash
|
||||
...(backendScript as EditableScript),
|
||||
parent_hash: topHash ?? backendScript.hash
|
||||
}
|
||||
|
||||
if (urlScriptSeed) {
|
||||
@@ -226,8 +221,7 @@
|
||||
urlScriptSeed = undefined
|
||||
// === END TEMP URL-HASH SYNC branch ===
|
||||
} else if (localDraft != undefined) {
|
||||
const reference = backendDraft ?? scriptWithDraft
|
||||
const referenceClean = cleanValueProperties(reference)
|
||||
const referenceClean = cleanValueProperties(backendScript)
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
|
||||
// Local matches the saved version — silently drop it and use the saved one.
|
||||
@@ -249,21 +243,13 @@
|
||||
scriptHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const scriptPath = bakedBaseline.path
|
||||
const hasBackendDraft = !!backendDraft
|
||||
notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, {
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
applyBaseline(bakedBaseline)
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: scriptPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The entry's
|
||||
// in-memory state is kept alive by this route's handle, so
|
||||
@@ -276,55 +262,6 @@
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (backendDraft) {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
if (bakedBaseline['primary_schedule']) {
|
||||
savedPrimarySchedule = bakedBaseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
|
||||
|
||||
if (!scriptWithDraft.draft_only) {
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: bakedBaseline.path
|
||||
})
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// scriptHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadScript would treat
|
||||
// it as a local autosave and the staleness check
|
||||
// would fire a spurious "newer version was deployed"
|
||||
// modal because remoteDraftRev moved from "defined"
|
||||
// to "undefined". Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${bakedBaseline.path}`)
|
||||
loadScript()
|
||||
}
|
||||
const deployed = cleanValueProperties(scriptWithDraft)
|
||||
const draft = cleanValueProperties(bakedBaseline)
|
||||
sendUserToast('Script loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
}
|
||||
@@ -374,35 +311,12 @@
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
async function restoreDraft() {
|
||||
if (!savedScript || !savedScript.draft) {
|
||||
sendUserToast('Could not restore to draft', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('script', draftPath)
|
||||
// Drop the in-memory handle state so loadScript sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare the
|
||||
// stale in-memory meta against the freshly fetched backend and fire
|
||||
// a spurious modal.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${savedScript.draft.path}`)
|
||||
loadScript()
|
||||
}
|
||||
|
||||
async function restoreDeployed() {
|
||||
if (!savedScript) {
|
||||
sendUserToast('Could not restore to deployed', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
if (savedScript.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: savedScript.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${savedScript.path}`)
|
||||
@@ -410,7 +324,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
@@ -440,10 +354,6 @@
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'script')
|
||||
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
onSaveInitial={(e) => {
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'script')
|
||||
goto(`/scripts/edit/${e.path}`)
|
||||
}}
|
||||
onSeeDetails={(e) => {
|
||||
goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user