diff --git a/backend/.sqlx/query-6b9348e60cc1ce158314a93fc7aa55a9f8fa854b29edcea83710a9170124edf0.json b/backend/.sqlx/query-6b9348e60cc1ce158314a93fc7aa55a9f8fa854b29edcea83710a9170124edf0.json new file mode 100644 index 0000000000..43d3b6d7f6 --- /dev/null +++ b/backend/.sqlx/query-6b9348e60cc1ce158314a93fc7aa55a9f8fa854b29edcea83710a9170124edf0.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "6b9348e60cc1ce158314a93fc7aa55a9f8fa854b29edcea83710a9170124edf0" +} diff --git a/backend/migrations/20260616090412_add_data_pipeline_draft_kind.down.sql b/backend/migrations/20260616090412_add_data_pipeline_draft_kind.down.sql new file mode 100644 index 0000000000..fcb5ef5bbf --- /dev/null +++ b/backend/migrations/20260616090412_add_data_pipeline_draft_kind.down.sql @@ -0,0 +1,2 @@ +-- Postgres cannot drop a single enum value; leaving 'data_pipeline' in +-- DRAFT_KIND is harmless on rollback. diff --git a/backend/migrations/20260616090412_add_data_pipeline_draft_kind.up.sql b/backend/migrations/20260616090412_add_data_pipeline_draft_kind.up.sql new file mode 100644 index 0000000000..38ae68ddcb --- /dev/null +++ b/backend/migrations/20260616090412_add_data_pipeline_draft_kind.up.sql @@ -0,0 +1,6 @@ +-- A `data_pipeline` draft bundles every unsaved pipeline script of a folder +-- into a single row keyed at the folder path (typ has no deployed backing +-- table — see UserDraftItemKind::deployed_table). Lets the asset-graph view +-- store its in-flight drafts in the per-user DB draft sync instead of +-- browser-local storage. +ALTER TYPE DRAFT_KIND ADD VALUE IF NOT EXISTS 'data_pipeline'; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f621b3be80..8cc9efd2ef 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7965,6 +7965,35 @@ paths: "404": description: no draft for that owner at that path + /w/{workspace}/drafts/get_own/{kind}/{path}: + get: + summary: fetch the current user's own draft content at a path (any kind) + operationId: getOwnDraft + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + $ref: "#/components/schemas/UserDraftItemKind" + - $ref: "#/components/parameters/ScriptPath" + responses: + "200": + description: the user's draft content, or null when none exists + content: + application/json: + schema: + nullable: true + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: [value, created_at] + /w/{workspace}/drafts/update/{kind}/{path}: post: summary: upsert (or clear) the current user's draft at a path @@ -21463,6 +21492,7 @@ components: - trigger_nextcloud - trigger_google - trigger_github + - data_pipeline # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- OpenFlow: diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index c6146fb47a..2d324af43b 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -25,6 +25,7 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_drafts)) .route("/get/{kind}/{*path}", get(get_draft_for_user)) + .route("/get_own/{kind}/{*path}", get(get_own_draft)) .route("/update/{kind}/{*path}", post(update_draft)) } @@ -393,6 +394,37 @@ async fn get_draft_for_user( }) } +/// Fetch the AUTHED user's OWN draft at a path, for any kind — including +/// private kinds (`shares_drafts_across_users() == false`). Backs editors with +/// no deployed-item GET to overlay a draft onto: the `data_pipeline` bundle is +/// keyed at a folder path with no runnable to hang `get_draft` on, so it loads +/// its in-flight state from here. Returns `null` (200) when the user has no +/// draft there, so a fresh pipeline isn't a 404. Secret-variable values come +/// back `$encrypted:`-prefixed, same as `get_draft_for_user` — variable editors +/// use their own overlay GET, not this route. +async fn get_own_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, +) -> Result>> { + let path = path.to_path(); + require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; + let row = sqlx::query_as!( + DraftForUser, + r#"SELECT value as "value!: sqlx::types::Json>", created_at + FROM draft + WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email = $4"#, + &w_id, + path, + kind as UserDraftItemKind, + &authed.email, + ) + .fetch_optional(&db) + .await?; + Ok(Json(row)) +} + /// The deployed table RLS resolves item-level `extra_perms` against. /// Delegates to `UserDraftItemKind::deployed_table()` (the shared single /// source); `None` kinds fall through to the path-only access check. diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index e008ccc658..6ae4a2ec8d 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -53,6 +53,10 @@ pub enum UserDraftItemKind { TriggerNextcloud, TriggerGoogle, TriggerGithub, + /// All unsaved scripts of one data pipeline, bundled into a single draft + /// keyed at the pipeline's folder path. Not a runnable: it has no deployed + /// backing table and is private to its owner. + DataPipeline, } impl UserDraftItemKind { @@ -84,12 +88,13 @@ impl UserDraftItemKind { UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud", UserDraftItemKind::TriggerGoogle => "trigger_google", UserDraftItemKind::TriggerGithub => "trigger_github", + UserDraftItemKind::DataPipeline => "data_pipeline", } } /// Every variant, for code that must enumerate kinds (e.g. generating /// the `draft_only` existence SQL). - pub const ALL: [UserDraftItemKind; 24] = [ + pub const ALL: [UserDraftItemKind; 25] = [ UserDraftItemKind::Script, UserDraftItemKind::Flow, UserDraftItemKind::App, @@ -114,6 +119,7 @@ impl UserDraftItemKind { UserDraftItemKind::TriggerNextcloud, UserDraftItemKind::TriggerGoogle, UserDraftItemKind::TriggerGithub, + UserDraftItemKind::DataPipeline, ]; /// The deployed table backing this kind, keyed by `(workspace_id, path)`. @@ -144,6 +150,9 @@ impl UserDraftItemKind { TriggerEmail | TriggerDefaultEmail => Some("email_trigger"), TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle | TriggerGithub => None, + // Keyed at a folder path, not a runnable; access falls back to the + // path-only (folder write) check. + DataPipeline => None, } } diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 7269e54a3b..4afbd843b3 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -172,6 +172,13 @@ }) }) + // A data-pipeline bundle isn't deployable from this page — deploy happens + // per-script inside the pipeline view. Exclude it from every selection path + // so the bulk "Deploy N drafts" never tries to deploy a bundle. + function isRowDeployable(i: { key: string; draftKind: Row['draftKind'] }): boolean { + return deploymentStatus[i.key]?.status !== 'deployed' && i.draftKind !== 'data_pipeline' + } + let selectedItems = $state([]) let deploying = $state(false) // Select all on the first non-empty load (deploy-all is the common intent); @@ -198,9 +205,7 @@ $effect(() => { if (!hasAutoSelected && items.length > 0) { - selectedItems = items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .map((i) => i.key) + selectedItems = items.filter(isRowDeployable).map((i) => i.key) hasAutoSelected = true } }) @@ -210,16 +215,12 @@ // Drafts resource: deploy/discard drop items, and stale keys left in // selectedItems are simply ignored here (and by deploySelected). let selectedCount = $derived( - items.filter( - (i) => selectedItems.includes(i.key) && deploymentStatus[i.key]?.status !== 'deployed' - ).length + items.filter((i) => selectedItems.includes(i.key) && isRowDeployable(i)).length ) let allSelected = $derived( items.length > 0 && - items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .every((i) => selectedItems.includes(i.key)) + items.filter(isRowDeployable).every((i) => selectedItems.includes(i.key)) ) function toggleItem(item: { key: string }) { @@ -231,9 +232,7 @@ } function selectAll() { - selectedItems = items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .map((i) => i.key) + selectedItems = items.filter(isRowDeployable).map((i) => i.key) } function deselectAll() { @@ -342,7 +341,19 @@ trigger_azure: '/azure_triggers', trigger_email: '/email_triggers' } + // A data-pipeline bundle is keyed at `f//data_pipeline`; its editor + // is the pipeline view of that folder. + function pipelineFolderFromPath(path: string): string | undefined { + const segs = path.split('/') + return segs[0] === 'f' && segs.length >= 2 ? segs[1] : undefined + } function draftEditUrl(d: Row): string | undefined { + if (d.draftKind === 'data_pipeline') { + const folder = pipelineFolderFromPath(d.path) + return folder + ? `/pipeline/${encodeURIComponent(folder)}?workspace=${encodeURIComponent(currentWorkspaceId)}` + : undefined + } const listPage = LIST_PAGE_FOR_KIND[d.draftKind] if (listPage) { return `${listPage}?workspace=${encodeURIComponent(currentWorkspaceId)}#${d.path}` @@ -365,6 +376,12 @@ // auto-generated `draft_{uuid}` path so it isn't shown in bold (the row still // shows the storage path in its secondary line). function displayPath(d: Row): string { + // The pipeline bundle's storage path (`f//data_pipeline`) is an + // implementation detail — show the folder it belongs to. + if (d.draftKind === 'data_pipeline') { + const folder = pipelineFolderFromPath(d.path) + return folder ? `f/${folder}` : d.path + } const path = d.draft_path ?? d.path if (AUTO_GEN_DRAFT_RE.test(path)) return '' const segs = path.split('/') @@ -379,6 +396,7 @@ // draft and a script draft at the same path are indistinguishable. function kindLabel(kind: Row['draftKind']): string { if (kind === 'raw_app') return 'app' + if (kind === 'data_pipeline') return 'pipeline' if (kind === 'trigger_schedule') return 'schedule' if (kind.startsWith('trigger_')) return `${kind.slice('trigger_'.length)} trigger` return kind @@ -392,7 +410,7 @@ {selectedItems} {deploymentStatus} {allSelected} - selectablePredicate={(item) => deploymentStatus[item.key]?.status !== 'deployed'} + selectablePredicate={(item) => isRowDeployable(item as unknown as Row)} onToggleItem={toggleItem} onSelectAll={selectAll} onDeselectAll={deselectAll} @@ -465,14 +483,25 @@ {/if} {#if deploymentStatus[draftItem.key]?.status !== 'deployed'} - + {#if draftItem.draftKind === 'data_pipeline'} + + {@const openUrl = draftEditUrl(draftItem)} + {#if openUrl} + + {/if} + {:else} + + {/if}