From a1966427e893dc8a58c8f2862ded752884843813 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 1 May 2023 09:31:15 +0200 Subject: [PATCH] feat: introduce draft for flows --- backend/sqlx-data.json | 74 ++++++++-------- backend/windmill-api/openapi.yaml | 35 +++++++- backend/windmill-api/src/flows.rs | 62 ++++++++++++- backend/windmill-api/src/scripts.rs | 26 ++++-- backend/windmill-common/src/flows.rs | 5 ++ backend/windmill-common/src/scripts.rs | 1 + .../src/lib/components/FlowBuilder.svelte | 87 +++++++++++++------ .../lib/components/FlowPreviewContent.svelte | 2 +- .../lib/components/InputTransformForm.svelte | 5 +- .../src/lib/components/ModulePreview.svelte | 22 ++--- .../lib/components/ModulePreviewForm.svelte | 2 +- frontend/src/lib/components/Path.svelte | 4 +- .../src/lib/components/ScriptBuilder.svelte | 10 ++- .../src/lib/components/ScriptEditor.svelte | 3 +- .../src/lib/components/TemplateEditor.svelte | 10 ++- .../components/common/table/FlowRow.svelte | 66 +++++++++----- .../components/common/table/ScriptRow.svelte | 2 +- .../lib/components/flows/FlowEditor.svelte | 2 +- .../flows/content/FlowSettings.svelte | 2 +- .../flows/header/FlowImportExportMenu.svelte | 2 +- .../flows/header/FlowPreviewButtons.svelte | 22 ++--- .../propertyPicker/PropPicker.svelte | 2 +- .../flows/edit/[...path]/+page.svelte | 34 ++++++-- .../scripts/edit/[...path]/+page.svelte | 32 +++---- 24 files changed, 354 insertions(+), 158 deletions(-) diff --git a/backend/sqlx-data.json b/backend/sqlx-data.json index 742b6f7e4f..760633e497 100644 --- a/backend/sqlx-data.json +++ b/backend/sqlx-data.json @@ -2660,6 +2660,24 @@ }, "query": "SELECT policy from app WHERE path = $1 AND workspace_id = $2" }, + "6e8182e167b09a0f9800d5e5988d4dd027c49ad6e14d2665dfdf93c46cb54434": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Text", + "Jsonb", + "Varchar", + "Text" + ] + } + }, + "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, dependency_job, draft_only) VALUES ($1, $2, $3, $4, $5, $6, now(), $7::text::json, NULL, true)" + }, "701f215eb14ba67a79afea15d7effc0dd394ba6c4a72c95d4560c5a377015d4e": { "describe": { "columns": [], @@ -2693,25 +2711,6 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)" }, - "72098030cab635723a9cecf8b3b1448e69a8afd68342850ef6376352d2897723": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Text", - "Jsonb", - "Varchar", - "Text", - "Text", - "Text" - ] - } - }, - "query": "UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, edited_at = now(), schema = $6::text::json, dependency_job = NULL WHERE path = $7 AND workspace_id = $8" - }, "7226d470c04e58fcab92f3edac21956d2a10e5ed6b594747b22682f05a60a341": { "describe": { "columns": [ @@ -3596,24 +3595,6 @@ }, "query": "SELECT count(path) FROM flow WHERE path LIKE 'f/' || $1 || '%' AND archived IS false AND workspace_id = $2" }, - "8da6ff304d199401ee8ee9a3de1d51477c04f9abc1b6255d84c5b1fc58267c59": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - "Text", - "Jsonb", - "Varchar", - "Text" - ] - } - }, - "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, dependency_job) VALUES ($1, $2, $3, $4, $5, $6, now(), $7::text::json, NULL)" - }, "8ede6fb740b145b3a8320adb789500870c7a8ec807a7b156b9ff7a15791b78f8": { "describe": { "columns": [], @@ -4217,6 +4198,25 @@ }, "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions)\n VALUES ($1, $2, $3, $4, '{}') RETURNING id" }, + "9e103fb8405089e361d0528d34e166b3098cc0f7abecbef3b498cca86e74a285": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Jsonb", + "Varchar", + "Text", + "Text", + "Text" + ] + } + }, + "query": "UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, edited_at = now(), schema = $6::text::json, dependency_job = NULL, draft_only = NULL WHERE path = $7 AND workspace_id = $8" + }, "a17260a1f1ee02e786690994d98c84ddf81e2eeb883f895c9cfc47e144d422cb": { "describe": { "columns": [ diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 400c41a2ed..78fa9aa91a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2712,7 +2712,14 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Flow" + allOf: + - $ref: "#/components/schemas/Flow" + - type: object + properties: + has_draft: + type: boolean + draft_only: + type: boolean /w/{workspace}/flows/get/{path}: get: @@ -2731,6 +2738,30 @@ paths: schema: $ref: "#/components/schemas/Flow" + /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" + + + /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -6125,6 +6156,8 @@ components: type: boolean starred: type: boolean + draft_only: + type: boolean required: - path - edited_by diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index b5029d5e6e..063c1a5bd4 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -19,7 +19,7 @@ use axum::{ Json, Router, }; use hyper::StatusCode; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sql_builder::prelude::*; use sql_builder::SqlBuilder; use sqlx::{Postgres, Transaction}; @@ -29,6 +29,7 @@ use windmill_common::{ flows::{Flow, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, + scripts::Schema, utils::{ http_get_from_hub, list_elems_from_hub, not_found_if_none, paginate, Pagination, StripPath, }, @@ -43,6 +44,7 @@ pub fn workspaced_service() -> Router { .route("/archive/*path", post(archive_flow_by_path)) .route("/delete/*path", delete(delete_flow_by_path)) .route("/get/*path", get(get_flow_by_path)) + .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)) } @@ -73,6 +75,8 @@ async fn list_flows( "archived", "extra_perms", "favorite.path IS NOT NULL as starred", + "draft.path IS NOT NULL as has_draft", + "draft_only" ]) .left() .join("favorite") @@ -80,6 +84,11 @@ async fn list_flows( "favorite.favorite_kind = 'flow' 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 = 'flow'" + ) .order_desc("favorite.path IS NOT NULL") .order_by("edited_at", lq.order_desc.unwrap_or(true)) .and_where("o.workspace_id = ?".bind(&w_id)) @@ -194,7 +203,7 @@ async fn create_flow( sqlx::query!( "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, \ - schema, dependency_job) VALUES ($1, $2, $3, $4, $5, $6, now(), $7::text::json, NULL)", + schema, dependency_job, draft_only) VALUES ($1, $2, $3, $4, $5, $6, now(), $7::text::json, NULL, true)", w_id, nf.path, nf.summary, @@ -206,6 +215,14 @@ async fn create_flow( .execute(&mut tx) .await?; + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + nf.path, + &w_id + ) + .execute(&mut tx) + .await?; + audit_log( &mut tx, &authed.username, @@ -310,7 +327,7 @@ async fn update_flow( let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; sqlx::query!( "UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, \ - edited_at = now(), schema = $6::text::json, dependency_job = NULL WHERE path = $7 AND workspace_id = $8", + edited_at = now(), schema = $6::text::json, dependency_job = NULL, draft_only = NULL WHERE path = $7 AND workspace_id = $8", nf.path, nf.summary, nf.description, @@ -364,7 +381,7 @@ async fn update_flow( sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - nf.path, + flow_path, &w_id ) .execute(&mut tx) @@ -454,6 +471,43 @@ 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, + pub value: serde_json::Value, + pub extra_perms: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_only: Option, +} + +async fn get_flow_by_path_w_draft( + authed: Authed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + let mut tx = user_db.begin(&authed).await?; + + let flow_o = sqlx::query_as::<_, FlowWDraft>( + "SELECT flow.path, flow.summary, flow,description, flow.schema, flow.value, flow.extra_perms, flow.draft_only, draft.value as draft FROM flow LEFT JOIN draft ON + flow.path = draft.path AND flow.workspace_id = draft.workspace_id AND draft.typ = 'flow' + 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, Path((w_id, path)): Path<(String, StripPath)>, diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index 8a461a9a6c..5a9a3afad1 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -65,8 +65,10 @@ pub struct ScriptWDraft { pub language: ScriptLang, pub kind: ScriptKind, pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub draft: Option, pub schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, } @@ -386,15 +388,15 @@ async fn create_script( .execute(&mut tx) .await?; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - ns.path, - &w_id - ) - .execute(&mut tx) - .await?; - if let Some(p_path) = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + p_path, + &w_id + ) + .execute(&mut tx) + .await?; + let schedulables = sqlx::query_as!( Schedule, "UPDATE schedule SET script_path = $1 WHERE script_path = $2 AND workspace_id = $3 AND is_flow IS false RETURNING *", @@ -412,6 +414,14 @@ async fn create_script( tx = push_scheduled_job(tx, schedule).await?; } } + } else { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + ns.path, + &w_id + ) + .execute(&mut tx) + .await?; } if p_hashes.is_some() && !p_hashes.unwrap().is_empty() { diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 4f3e085edf..4066e46d74 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -33,6 +33,8 @@ pub struct Flow { pub archived: bool, pub schema: Option, pub extra_perms: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_only: Option, } #[derive(Serialize)] @@ -47,6 +49,9 @@ 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, } #[derive(Deserialize)] diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index a26c814b22..b3a29f85e4 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -137,6 +137,7 @@ pub struct Script { pub language: ScriptLang, pub kind: ScriptKind, pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, } diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 1c7fabf530..e3eb6b6270 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -1,6 +1,6 @@ -
+
{#if keys.length > 0} {#each keys as argName, i (argName)} {#if Object.keys(schema.properties ?? {}).includes(argName)} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 708e72f9ee..c1392c6324 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -312,8 +312,8 @@
- {:else} + {:else if !draft_only}
diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index 152dba8695..bf0ec9285e 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -33,16 +33,6 @@ $selectedId?.includes('branch') - {#if !upToDisabled} + {:else} - ← Edit or link an input + ← Edit or connect an input {/if}
{/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 38a07582a7..c952826761 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -36,29 +36,45 @@ let selectedId: string = 'settings-metadata' + let nobackenddraft = false async function loadFlow(): Promise { loading = true let flow: Flow if (stateLoadedFromUrl != undefined && stateLoadedFromUrl?.flow?.path == $page.params.path) { - sendUserToast('Flow restored from draft', false, [ + sendUserToast('Flow restored from ephemeral autosave', false, [ { - label: 'Restore last saved version instead', + label: 'Discard autosave and reload', callback: () => { - FlowService.getFlowByPath({ - workspace: $workspaceStore!, - path: $page.params.path - }).then((flow) => { - $flowStore = flow - }) + stateLoadedFromUrl = undefined + goto(`/flows/edit/${flow!.path}`) + loadFlow() } } ]) flow = stateLoadedFromUrl.flow } else { - flow = await FlowService.getFlowByPath({ + const flowWithDraft = await FlowService.getFlowByPathWithDraft({ workspace: $workspaceStore!, path: $page.params.path }) + if (flowWithDraft.draft != undefined && !nobackenddraft) { + flow = flowWithDraft.draft + if (!flowWithDraft.draft_only) { + sendUserToast('flow loaded from latest saved draft', false, [ + { + label: 'Ignore draft and load from latest deployed version', + callback: () => { + stateLoadedFromUrl = undefined + nobackenddraft = true + goto(`/flows/edit/${flow!.path}`) + loadFlow() + } + } + ]) + } + } else { + flow = flowWithDraft + } } await initFlow(flow, flowStore, flowStateStore) diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 908db8168c..c6f841474e 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -28,9 +28,9 @@ async function loadScript(): Promise { if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == $page.params.path) { script = scriptLoadedFromUrl - sendUserToast('Script loaded from latest state stored in the URL', false, [ + sendUserToast('Script loaded from latest autosave stored in the URL', false, [ { - label: 'Discard state and reload', + label: 'Discard autosave and reload', callback: () => { scriptLoadedFromUrl = undefined goto(`/scripts/edit/${script!.path}`) @@ -52,23 +52,23 @@ }) if (scriptWithDraft.draft != undefined) { script = scriptWithDraft.draft + if (!scriptWithDraft.draft_only) { + sendUserToast('Script loaded from latest saved draft', false, [ + { + label: 'Ignore draft and load from latest deployed version', + callback: () => { + scriptLoadedFromUrl = undefined + hash = scriptWithDraft.hash + console.log(hash) + goto(`/scripts/edit/${script!.path}`) + loadScript() + } + } + ]) + } } else { script = scriptWithDraft } - if (!scriptWithDraft.draft_only) { - sendUserToast('Script loaded from latest saved draft', false, [ - { - label: 'Ignore draft and load from latest deployed version', - callback: () => { - scriptLoadedFromUrl = undefined - hash = scriptWithDraft.hash - console.log(hash) - goto(`/scripts/edit/${script!.path}`) - loadScript() - } - } - ]) - } topHash = scriptWithDraft.hash } }