From ec89b656b681adca81c0a178a62d5da178ee0269 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 30 Apr 2023 15:29:46 +0200 Subject: [PATCH] feat: introduce backend persisted draft systems for scripts --- .../20230429082953_add_drafts.down.sql | 3 + .../20230429082953_add_drafts.up.sql | 18 ++ backend/sqlx-data.json | 202 +++++++++++++----- backend/windmill-api/openapi.yaml | 140 +++++++++--- backend/windmill-api/src/apps.rs | 9 + backend/windmill-api/src/drafts.rs | 94 ++++++++ backend/windmill-api/src/flows.rs | 11 +- backend/windmill-api/src/lib.rs | 2 + backend/windmill-api/src/scripts.rs | 102 +++++++-- backend/windmill-api/src/variables.rs | 7 - backend/windmill-api/src/workspaces.rs | 2 + backend/windmill-common/src/scripts.rs | 15 +- frontend/src/lib/components/DraftBadge.svelte | 21 ++ frontend/src/lib/components/EditorBar.svelte | 8 +- .../src/lib/components/FlowBuilder.svelte | 4 +- .../src/lib/components/ModulePreview.svelte | 2 +- .../src/lib/components/ScriptBuilder.svelte | 154 +++++++++---- .../src/lib/components/ScriptEditor.svelte | 7 +- frontend/src/lib/components/Toast.svelte | 32 +-- .../UnsavedConfirmationModal.svelte | 5 +- .../src/lib/components/common/kbd/Kbd.svelte | 6 +- .../lib/components/common/table/Row.svelte | 8 +- .../components/common/table/ScriptRow.svelte | 85 +++++--- .../flows/content/ScriptEditorDrawer.svelte | 4 +- .../src/lib/components/home/ItemsList.svelte | 5 +- frontend/src/lib/script_helpers.ts | 9 +- frontend/src/lib/utils.ts | 2 +- .../(root)/(logged)/scripts/add/+page.svelte | 8 +- .../scripts/edit/[...hash]/+page.svelte | 48 ----- .../edit/{[...hash] => [...path]}/+page.js | 2 +- .../scripts/edit/[...path]/+page.svelte | 100 +++++++++ .../scripts/get/[...hash]/+page.svelte | 90 ++++---- .../scripts/run/[...hash]/+page.svelte | 2 +- 33 files changed, 867 insertions(+), 340 deletions(-) create mode 100644 backend/migrations/20230429082953_add_drafts.down.sql create mode 100644 backend/migrations/20230429082953_add_drafts.up.sql create mode 100644 backend/windmill-api/src/drafts.rs create mode 100644 frontend/src/lib/components/DraftBadge.svelte delete mode 100644 frontend/src/routes/(root)/(logged)/scripts/edit/[...hash]/+page.svelte rename frontend/src/routes/(root)/(logged)/scripts/edit/{[...hash] => [...path]}/+page.js (50%) create mode 100644 frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte diff --git a/backend/migrations/20230429082953_add_drafts.down.sql b/backend/migrations/20230429082953_add_drafts.down.sql new file mode 100644 index 0000000000..0c73d93393 --- /dev/null +++ b/backend/migrations/20230429082953_add_drafts.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +DROP TABLE draft; +DROP TYPE DRAFT_TYPE; diff --git a/backend/migrations/20230429082953_add_drafts.up.sql b/backend/migrations/20230429082953_add_drafts.up.sql new file mode 100644 index 0000000000..c8257a762b --- /dev/null +++ b/backend/migrations/20230429082953_add_drafts.up.sql @@ -0,0 +1,18 @@ +-- Add up migration script here +CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app'); + +CREATE TABLE draft ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id), + path VARCHAR(255) NOT NULL, + typ DRAFT_TYPE NOT NULL, + value JSONB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, path, typ) +); + +GRANT ALL ON draft TO windmill_admin; +GRANT ALL ON draft TO windmill_user; + +ALTER TABLE script ADD COLUMN draft_only BOOLEAN; +ALTER TABLE flow ADD COLUMN draft_only BOOLEAN; +ALTER TABLE app ADD COLUMN draft_only BOOLEAN; diff --git a/backend/sqlx-data.json b/backend/sqlx-data.json index 164c969ac0..742b6f7e4f 100644 --- a/backend/sqlx-data.json +++ b/backend/sqlx-data.json @@ -692,6 +692,32 @@ }, "query": "SELECT * FROM workspace_settings WHERE workspace_id = $1" }, + "17c2a1abcff52cfb39ae55cfafb6b5945bb87eebfdb2e030e7de3db938d0c6c9": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Jsonb", + { + "Custom": { + "kind": { + "Enum": [ + "script", + "flow", + "app" + ] + }, + "name": "draft_type" + } + } + ] + } + }, + "query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, path, typ) DO UPDATE SET value = $3" + }, "187a27d18f78f068319a0ec684f71a76c49dd09c5c74c2c777e35233c4f5a281": { "describe": { "columns": [ @@ -1459,6 +1485,19 @@ }, "query": "DELETE FROM pip_resolution_cache WHERE expiration <= now() RETURNING hash" }, + "39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'" + }, "39bae7dff750565183ac3893b51a8846d7db2f130a6795b54ef94acc232dec8b": { "describe": { "columns": [ @@ -1504,6 +1543,19 @@ }, "query": "DELETE FROM folder WHERE workspace_id = $1" }, + "3d04f6c1a77c9a0bcc999913d6df253b58b82c09fe23a6ab99b5af78d4604732": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'" + }, "3d363466d79075df3f74f946eff43ca89faefca3bcdf2c533425ca3868b0369a": { "describe": { "columns": [ @@ -2169,6 +2221,19 @@ }, "query": "INSERT INTO token\n (token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5)" }, + "567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "DELETE FROM script WHERE hash = $1 AND workspace_id = $2" + }, "56839d3aec6c0177d14589aedda8d5c431d841b6d5d0d99ce3836bb42d4d83d9": { "describe": { "columns": [ @@ -2356,57 +2421,6 @@ }, "query": "SELECT language as \"language: ScriptLang\" FROM script WHERE hash = $1 AND workspace_id = $2" }, - "63ab3bad7ab712f8050648851d8c69a1e8071060c2db57a7d2aa42e0361e2c18": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Int8", - "Varchar", - "Int8Array", - "Text", - "Text", - "Text", - "Varchar", - "Text", - "Bool", - "Jsonb", - "Text", - { - "Custom": { - "kind": { - "Enum": [ - "python3", - "deno", - "go", - "bash" - ] - }, - "name": "script_lang" - } - }, - { - "Custom": { - "kind": { - "Enum": [ - "script", - "trigger", - "failure", - "command", - "approval" - ] - }, - "name": "script_kind" - } - }, - "Varchar" - ] - } - }, - "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15)" - }, "64920b845c0ce81fb99497c03b249bb6cb06581079b5fc5bea5ddd8e7a895b79": { "describe": { "columns": [ @@ -3220,6 +3234,19 @@ }, "query": "SELECT workspace_id, name, display_name, owners, extra_perms FROM folder WHERE workspace_id = $1 ORDER BY name desc LIMIT $2 OFFSET $3" }, + "855427180b549115923e57cdde6fa2780069b049948bae4140e866c570977a90": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'" + }, "866b26e56fd376368c759db6eee13f92373e308f584903121b4546ad51aef86e": { "describe": { "columns": [ @@ -3677,6 +3704,58 @@ }, "query": "SELECT email, login_type::TEXT, super_admin, verified, name, company FROM password WHERE email = $1" }, + "90c5f1eea5d2cceb157ce0667a625f0fa4506961e2ad181765022a20b11d314d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Text", + "Bool", + "Jsonb", + "Text", + { + "Custom": { + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash" + ] + }, + "name": "script_lang" + } + }, + { + "Custom": { + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval" + ] + }, + "name": "script_kind" + } + }, + "Varchar", + "Bool" + ] + } + }, + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16)" + }, "911b1e1f2a5ba6d5159916e5598020e680c45043b0736ad0153ee261a151dd90": { "describe": { "columns": [ @@ -3959,6 +4038,27 @@ }, "query": "UPDATE schedule SET enabled = $1, email = $2 WHERE path = $3 AND workspace_id = $4 RETURNING *" }, + "97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f": { + "describe": { + "columns": [ + { + "name": "draft_only", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + true + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2" + }, "97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c": { "describe": { "columns": [ diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0962ec0449..400c41a2ed 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2125,6 +2125,41 @@ 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}/scripts/create: post: summary: create script @@ -2139,40 +2174,8 @@ paths: content: application/json: schema: - type: object - properties: - path: - type: string - parent_hash: - type: string - summary: - type: string - description: - type: string - content: - type: string - schema: - type: object - is_template: - type: boolean - lock: - type: array - items: - type: string - language: - type: string - enum: [python3, deno, go, bash] - kind: - type: string - enum: [script, failure, trigger, command, approval] - tag: - type: string - required: - - path - - summary - - description - - content - - language + $ref: "#/components/schemas/NewScript" + responses: "201": description: script created @@ -2366,6 +2369,34 @@ paths: schema: $ref: "#/components/schemas/Script" + /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: + allOf: + - $ref: "#/components/schemas/NewScript" + - type: object + properties: + draft: + $ref: "#/components/schemas/NewScript" + hash: + type: string + required: + - hash + + + /w/{workspace}/scripts/raw/p/{path}: get: summary: raw script by path @@ -5026,6 +5057,10 @@ components: type: boolean tag: type: string + has_draft: + type: boolean + draft_only: + type: boolean required: - hash - path @@ -5042,6 +5077,43 @@ components: - kind - starred + NewScript: + type: object + properties: + path: + type: string + parent_hash: + type: string + summary: + type: string + description: + type: string + content: + type: string + schema: + type: object + is_template: + type: boolean + lock: + type: array + items: + type: string + language: + type: string + enum: [python3, deno, go, bash] + kind: + type: string + enum: [script, failure, trigger, command, approval] + tag: + type: string + draft_only: + type: boolean + required: + - path + - summary + - description + - content + - language ScriptArgs: type: object additionalProperties: {} diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index ceaf575fdf..e5a4c157a3 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -517,6 +517,15 @@ async fn update_app( .execute(&mut tx) .await?; } + + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + path, + &w_id + ) + .execute(&mut tx) + .await?; + audit_log( &mut tx, &authed.username, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs new file mode 100644 index 0000000000..ee2f1f7b49 --- /dev/null +++ b/backend/windmill-api/src/drafts.rs @@ -0,0 +1,94 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2042 + * 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::{UserDB, DB}, + users::{maybe_refresh_folders, require_owner_of_path, Authed}, +}; + +use axum::{ + extract::{Extension, Path}, + routing::post, + Json, Router, +}; +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; +use windmill_common::error::Result; + +pub fn workspaced_service() -> Router { + Router::new().route("/create", post(create_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: serde_json::Value, + pub typ: DraftType, +} + +async fn create_draft( + authed: Authed, + Extension(db): Extension, + Extension(user_db): Extension, + Path(w_id): Path, + Json(draft): Json, +) -> Result<(StatusCode, String)> { + let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await; + + let mut tx = user_db.begin(&authed).await?; + + require_owner_of_path(&authed, &draft.path)?; + + sqlx::query!( + "INSERT INTO draft + (workspace_id, path, value, typ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, path, typ) DO UPDATE SET value = $3", + &w_id, + draft.path, + draft.value, + draft.typ: DraftType, + ) + .execute(&mut tx) + .await?; + + tx.commit().await?; + + Ok((StatusCode::CREATED, format!("draft {} created", draft.path))) +} + +// async fn get_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 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)) +// } diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index 34a2ac7440..b48f13206a 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -354,14 +354,21 @@ async fn update_flow( } for schedule in schedulables.into_iter() { - // TODO: Why is this in the loop in the first place? Seems like it's just doing nothing after the first iteration? Should this use schedule.path? - clear_schedule(tx.transaction_mut(), flow_path, true).await?; + clear_schedule(tx.transaction_mut(), &schedule.path, true).await?; if schedule.enabled { tx = push_scheduled_job(tx, schedule).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, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 60a329e79a..5714b55e11 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -33,6 +33,7 @@ mod apps; mod audit; mod capture; mod db; +mod drafts; mod favorite; mod flows; mod folders; @@ -134,6 +135,7 @@ pub async fn run_server( .nest("/resources", resources::workspaced_service()) .nest("/schedules", schedule::workspaced_service()) .nest("/scripts", scripts::workspaced_service()) + .nest("/drafts", drafts::workspaced_service()) .nest( "/users", users::workspaced_service().layer(Extension(argon2.clone())), diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index c4b17b3f5f..8a461a9a6c 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -35,7 +35,7 @@ use windmill_common::{ jobs::JobPayload, schedule::Schedule, scripts::{ - to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Script, ScriptHash, + to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Schema, Script, ScriptHash, ScriptKind, ScriptLang, }, users::username_to_permissioned_as, @@ -55,6 +55,21 @@ lazy_static::lazy_static! { const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20; +#[derive(Serialize, sqlx::FromRow)] +pub struct ScriptWDraft { + pub hash: ScriptHash, + pub path: String, + pub summary: String, + pub description: String, + pub content: String, + pub language: ScriptLang, + pub kind: ScriptKind, + pub tag: Option, + pub draft: Option, + pub schema: Option, + pub draft_only: Option, +} + pub fn global_service() -> Router { Router::new() .route( @@ -81,6 +96,7 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_scripts)) .route("/create", post(create_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("/raw/p/*path", get(raw_script_by_path)) .route("/exists/p/*path", get(exists_script_by_path)) @@ -104,23 +120,18 @@ async fn list_scripts( let mut sqlb = SqlBuilder::select_from("script as o") .fields(&[ - "o.workspace_id", "hash", "o.path", - "array_remove(array[parent_hashes[1]], NULL) as parent_hashes", "summary", - "description", - "created_by", - "created_at", + "COALESCE(draft.created_at, o.created_at) as created_at", "archived", - "deleted", - "is_template", "extra_perms", - "CASE WHEN lock_error_logs IS NOT NULL THEN 'error' ELSE null END as lock_error_logs", + "CASE WHEN lock_error_logs IS NOT NULL THEN true ELSE false END as has_deploy_errors", "language", - "kind", "favorite.path IS NOT NULL as starred", - "tag" + "tag", + "draft.path IS NOT NULL as has_draft", + "draft_only" ]) .left() .join("favorite") @@ -128,6 +139,11 @@ 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)) @@ -243,10 +259,20 @@ async fn create_script( } let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { (None, None) => Ok(None), - (None, Some(s)) => Err(Error::BadRequest(format!( + (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( "Path conflict for {} with non-archived hash {}", &ns.path, &s.hash ))), + (None, Some(s)) => { + sqlx::query!( + "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", + s.hash.0, + &w_id + ) + .execute(&mut tx) + .await?; + Ok(None) + } (Some(p_hash), o) => { if sqlx::query_scalar!( "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2", @@ -338,8 +364,8 @@ async fn create_script( //::text::json is to ensure we use serde_json with preserve order sqlx::query!( "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \ - content, created_by, schema, is_template, extra_perms, lock, language, kind, tag) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15)", + content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16)", &w_id, &hash.0, ns.path, @@ -355,6 +381,15 @@ async fn create_script( ns.language: ScriptLang, ns.kind.unwrap_or(ScriptKind::Script): ScriptKind, ns.tag, + ns.draft_only + ) + .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?; @@ -495,6 +530,31 @@ async fn get_script_by_path( Ok(Json(script)) } +async fn get_script_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 script_o = sqlx::query_as::<_, ScriptWDraft>( + "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, draft.value as draft 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 \ + AND script.created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND \ + workspace_id = $2)", + ) + .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)) +} + async fn list_paths( authed: Authed, Extension(user_db): Extension, @@ -759,7 +819,19 @@ async fn delete_script_by_path( let mut tx = user_db.begin(&authed).await?; let path = path.to_path(); - require_admin(authed.is_admin, &authed.username)?; + let draft_only = sqlx::query_scalar!( + "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", + path, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + + if !draft_only { + require_admin(authed.is_admin, &authed.username)?; + } + let script = sqlx::query_scalar!( "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", path, diff --git a/backend/windmill-api/src/variables.rs b/backend/windmill-api/src/variables.rs index 823c8bcb9c..5f91695a8b 100644 --- a/backend/windmill-api/src/variables.rs +++ b/backend/windmill-api/src/variables.rs @@ -12,13 +12,6 @@ use crate::{ users::{maybe_refresh_folders, require_owner_of_path, Authed}, webhook_util::{WebhookMessage, WebhookShared}, }; -/* - * Author: Ruben Fiszel - * Copyright: Windmill Labs, Inc 2022 - * 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 axum::{ extract::{Extension, Path, Query}, diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 41d3810fa0..cbbb674bf6 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -1165,6 +1165,8 @@ where "edited_at", "edited_by", "archived", + "has_draft", + "draft_only", ] { if obj.contains_key(key) { obj.remove(key); diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 3e4f18bb1d..a26c814b22 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -137,28 +137,26 @@ pub struct Script { pub language: ScriptLang, pub kind: ScriptKind, pub tag: Option, + pub draft_only: Option, } #[derive(Serialize)] #[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))] pub struct ListableScript { - pub workspace_id: String, pub hash: ScriptHash, pub path: String, - pub parent_hashes: Option, pub summary: String, - pub description: String, - pub created_by: String, pub created_at: chrono::DateTime, pub archived: bool, - pub deleted: bool, - pub is_template: bool, pub extra_perms: serde_json::Value, - pub lock_error_logs: Option, pub language: ScriptLang, - pub kind: ScriptKind, pub starred: bool, pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_draft: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_only: Option, + pub has_deploy_errors: bool, } #[derive(Serialize, Deserialize, Debug)] @@ -189,6 +187,7 @@ pub struct NewScript { pub language: ScriptLang, pub kind: Option, pub tag: Option, + pub draft_only: Option, } #[derive(Deserialize)] diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte new file mode 100644 index 0000000000..e9dd92583d --- /dev/null +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -0,0 +1,21 @@ + + +{#if has_draft} + {#if draft_only} + + Never deployed and is only a draft + Draft only + + {:else} + + Is deployed and has a draft + +Draft + + {/if} +{/if} diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index bfb74c47d6..32ef4fe6fd 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -4,10 +4,9 @@ + + + {#if !$userStore?.operator} (metadataOpen = false)}> @@ -369,15 +426,25 @@ > Customise + @@ -428,6 +495,9 @@ { + saveDraft() + }} bind:editor bind:this={scriptEditor} bind:schema={script.schema} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 788bca75ac..ec7ec25b0a 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -13,7 +13,7 @@ import { faGithub } from '@fortawesome/free-brands-svg-icons' import EditorBar, { EDITOR_BAR_WIDTH_THRESHOLD } from './EditorBar.svelte' import TestJobLoader from './TestJobLoader.svelte' - import { onMount } from 'svelte' + import { createEventDispatcher, onMount } from 'svelte' import { Button, Kbd } from './common' import SplitPanesWrapper from './splitPanes/SplitPanesWrapper.svelte' import WindmillIcon from './icons/WindmillIcon.svelte' @@ -102,6 +102,8 @@ loadPastTests() }) + const dispatch = createEventDispatcher() + function asKind(str: string | undefined) { return str as 'script' | 'approval' | 'trigger' | undefined } @@ -169,6 +171,7 @@ console.error('Could not save last_save to local storage', e) } lastSave = code + dispatch('format') }} class="flex flex-1 h-full !overflow-visible" lang={scriptLangToEditorLang(lang)} @@ -205,7 +208,7 @@ {#if testIsLoading} Running {:else} - Test Ctrl+Enter + Test CtrlEnter {/if} {/if} diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte index 6eae1fd24e..7e11cc85eb 100644 --- a/frontend/src/lib/components/Toast.svelte +++ b/frontend/src/lib/components/Toast.svelte @@ -25,8 +25,8 @@
-
-
+
+
{#if error} @@ -34,21 +34,8 @@ {/if}
-
+

{message}

-
- {#each actions as action, index (index)} - - {/each} -
+
+ {#each actions as action, index (index)} + + {/each} +
diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte index 5694865f18..afca0a8b82 100644 --- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte @@ -48,9 +48,6 @@ }} >
- - Are you sure you want to discard change you have made? (A draft has been temporarily and - locally saved) - + Are you sure you want to discard the changes you have made?
diff --git a/frontend/src/lib/components/common/kbd/Kbd.svelte b/frontend/src/lib/components/common/kbd/Kbd.svelte index a4e23e2365..843c655d29 100644 --- a/frontend/src/lib/components/common/kbd/Kbd.svelte +++ b/frontend/src/lib/components/common/kbd/Kbd.svelte @@ -1,9 +1,13 @@ diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 88abfb11d7..d2eff71ca5 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -22,13 +22,13 @@
-
+
{#if marked} {@html marked} {:else} @@ -40,7 +40,7 @@ first-of-type:rounded-t-md last-of-type:rounded-b-md {color}"
{#if $$slots.badges} - + {:else} +
{/if}
diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index ac70709e36..d4625aae55 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -8,7 +8,7 @@ import { ScriptService, type Script } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' - import { capitalize, isOwner, sendUserToast } from '$lib/utils' + import { isOwner, sendUserToast } from '$lib/utils' import { faArchive, faCalendarAlt, @@ -26,6 +26,7 @@ import Button from '../button/Button.svelte' import LanguageBadge from './LanguageBadge.svelte' import Row from './Row.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' export let script: Script & { canWrite: boolean } export let marked: string | undefined @@ -42,9 +43,9 @@ extra_perms, canWrite, lock_error_logs, - kind, - workspace_id, - archived + archived, + has_draft, + draft_only } = script const dispatch = createEventDispatcher() @@ -88,17 +89,16 @@ {path} {summary} {starred} - workspaceId={workspace_id ?? $workspaceStore ?? ''} + workspaceId={$workspaceStore ?? ''} on:change + canFavorite={!draft_only} > + {#if lock_error_logs} Deployment failed {/if} - {#if kind !== 'script'} - {capitalize(kind)} - {/if} {#if archived} archived @@ -117,7 +117,7 @@ size="xs" variant="border" startIcon={{ icon: faEdit }} - href="/scripts/edit/{hash}" + href="/scripts/edit/{path}" > Edit @@ -137,30 +137,51 @@ {/if} {/if} - - + {#if !draft_only} + + + {/if} { let owner = isOwner(path, $userStore, $workspaceStore) + if (draft_only) { + return [ + { + displayName: 'Delete', + icon: faTrashAlt, + action: (event) => { + if (event?.shiftKey) { + deleteScript(path) + } else { + deleteConfirmedCallback = () => { + deleteScript(path) + } + } + }, + type: dlt, + disabled: !canWrite + } + ] + } return [ { displayName: 'View script', @@ -171,13 +192,7 @@ { displayName: 'Edit', icon: faEdit, - href: `/scripts/edit/${hash}`, - disabled: !canWrite || archived - }, - { - displayName: 'Edit code', - icon: faEdit, - href: `/scripts/edit/${hash}`, + href: `/scripts/edit/${path}`, disabled: !canWrite || archived }, { diff --git a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte index 5529066868..4a1efa0be1 100644 --- a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte @@ -1,7 +1,7 @@ - -{#if script} - -{/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...hash]/+page.js b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.js similarity index 50% rename from frontend/src/routes/(root)/(logged)/scripts/edit/[...hash]/+page.js rename to frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.js index bfa320173e..4417ff1c25 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...hash]/+page.js +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.js @@ -1,5 +1,5 @@ export function load({ params }) { return { - stuff: { title: `Edit Script ${params.hash}` } + stuff: { title: `Edit Script ${params.path}` } } } diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte new file mode 100644 index 0000000000..908db8168c --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -0,0 +1,100 @@ + + +{#if script} + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index cd63dfdcb5..f580f9b586 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -225,8 +225,8 @@ {#if !$userStore?.operator} + {/if} {#if SCRIPT_VIEW_SHOW_SCHEDULE} - + {/if} -
+
+ +
{/if} {/each} {#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL} -
- -
+
+ +
{/if} {#if viewWebhookCommand}
diff --git a/frontend/src/routes/(root)/(logged)/scripts/run/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/run/[...hash]/+page.svelte index 56d4110056..6e4dfe1f5d 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/run/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/run/[...hash]/+page.svelte @@ -146,7 +146,7 @@ startIcon={{ icon: faPen }} disabled={script == undefined} variant="border" - href="/scripts/edit/{script?.hash}">EditEdit
{/if}