From 35c9dbda9a7d43d8f0ec94aeb09123cac1a108fa Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 9 Jun 2026 14:41:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(drafts):=20home-page=20Draft=20badge=20?= =?UTF-8?q?=E2=80=94=20show=20user-initial=20circles,=20drop=20the=20'+'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home-page Draft badge previously showed '+Draft' as a flat label. Add per-user awareness: up to 3 user-initial circles render to the left of the label, ordered alphabetically; with 4+ users we collapse to the first 2 + a '+N' overflow circle so rows stay compact. Backend: * New `DraftUserRef { username: Option }` in windmill-types::user_drafts, re-exported from windmill-common so the list endpoints in scripts/flows/apps crates share one import path (windmill-types/windmill-common can't be reordered without a cycle). * ListableScript / ListableFlow / ListableApp gain a `draft_users: Option>>` field. The list SQL adds a per-row subquery `SELECT json_agg(...) FROM draft d LEFT JOIN usr u ...` that aggregates the workspace users with a per-user draft at this path. NULL (no drafts) decodes to None; LEFT JOIN against `usr` lets orphaned drafts (user removed from workspace) still surface with username = None. * Synthesized draft-only rows set draft_users to a single-element vector with the authed user (those rows come from `email = $2`). OpenAPI: `draft_users` added to listScripts / listFlows / ListableApp response shapes as an array of `{ username }` with nullable username. Frontend DraftBadge: * Accepts `draft_users: { username?: string | null }[]`. Renders up to MAX_CIRCLES (3) initial circles; at 4+ users renders first 2 + a gray '+N' overflow circle. * Initials: 'john.doe'/'john_doe' → 'JD', 'alice' → 'AL', the legacy NULL-email row → '?'. * Color picked deterministically from a 6-entry palette so the same user gets the same circle color across rows. * Label is now just 'Draft' (dropped the '+'). 'Draft only' is unchanged. * Tooltip lists every user in full. ScriptRow / FlowRow / AppRow thread `draft_users` through their prop types and pass it to DraftBadge. --- backend/windmill-api-flows/src/flows.rs | 15 ++- backend/windmill-api-scripts/src/scripts.rs | 20 ++- backend/windmill-api/openapi.yaml | 41 ++++++ backend/windmill-api/src/apps.rs | 22 ++- backend/windmill-common/src/user_drafts.rs | 8 ++ backend/windmill-types/src/flows.rs | 7 + backend/windmill-types/src/lib.rs | 2 + backend/windmill-types/src/s3.rs | 23 +--- backend/windmill-types/src/scripts.rs | 9 ++ backend/windmill-types/src/user_drafts.rs | 18 +++ frontend/src/lib/components/DraftBadge.svelte | 127 ++++++++++++++---- .../lib/components/common/table/AppRow.svelte | 2 +- .../components/common/table/FlowRow.svelte | 7 +- .../components/common/table/ScriptRow.svelte | 7 +- 14 files changed, 262 insertions(+), 46 deletions(-) create mode 100644 backend/windmill-types/src/user_drafts.rs diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 3d35201fcc..f3628902a5 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -21,7 +21,9 @@ use windmill_api_auth::{ }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ - user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay}, + user_drafts::{ + fetch_draft_only, maybe_overlay_draft, DraftUserRef, UserDraftItemKind, WithDraftOverlay, + }, utils::HTTP_CLIENT, webhook::{WebhookMessage, WebhookShared}, DB, @@ -155,6 +157,13 @@ async fn list_flows( "ws_error_handler_muted", "o.labels", "draft.email IS NOT NULL as is_draft", + // All workspace users with a per-user draft at this path, + // aggregated as a JSON array. Same shape & decoding as the + // scripts list — see scripts.rs for the rationale. + "(SELECT json_agg(json_build_object('username', u.username) ORDER BY u.username NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users", ]) .left() .join("favorite") @@ -302,6 +311,10 @@ async fn list_flows( labels: None, is_draft: true, draft_path, + // Synthesized rows come from the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), }); } } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index a98f4c0420..fb770674ae 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -13,7 +13,9 @@ use windmill_api_auth::{ ApiAuthed, }; use windmill_common::{ - user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay}, + user_drafts::{ + fetch_draft_only, maybe_overlay_draft, DraftUserRef, UserDraftItemKind, WithDraftOverlay, + }, utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, @@ -208,6 +210,16 @@ async fn list_scripts( "kind", "o.labels", "draft.email IS NOT NULL as is_draft", + // All workspace users (and the legacy NULL-email row) with a + // per-user draft at this path. Aggregated as a JSON array so + // the row decodes via `sqlx::types::Json>`. + // NULL (no rows) decodes to `None`; never returns an empty + // array. LEFT JOIN to `usr` lets orphaned drafts (user removed + // from the workspace) still surface with `username = None`. + "(SELECT json_agg(json_build_object('username', u.username) ORDER BY u.username NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users", ]) .left() .join("favorite") @@ -427,6 +439,12 @@ async fn list_scripts( labels: None, is_draft: true, draft_path, + // Synthesized rows come from the authed user's own draft + // (the WHERE clause above filters by `email = $2`), so + // they're known to be the single-user case. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), }); } } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3adce68a37..843aca2143 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7857,6 +7857,20 @@ paths: instead of the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: @@ -9578,6 +9592,20 @@ paths: name instead of the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: @@ -27178,6 +27206,19 @@ components: one). Lets the home list render the meaningful name instead of the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and the legacy + NULL-email row if any) who have a per-user draft at this + path. Drives the home page's user-avatar circles inside the + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true required: - id - workspace_id diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 0a7b8e2a1c..75eb75cc75 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -58,7 +58,9 @@ use windmill_common::{ get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, JobPayload, RawCode, }, - user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay}, + user_drafts::{ + fetch_draft_only, maybe_overlay_draft, DraftUserRef, UserDraftItemKind, WithDraftOverlay, + }, users::username_to_permissioned_as, utils::{ http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, @@ -176,6 +178,13 @@ pub struct ListableApp { #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_path: Option, + /// Workspace users (including the authed user, and the legacy + /// NULL-email row if any) who currently have a per-user draft at + /// this path. Drives the home page's user-avatar circles inside + /// the Draft badge. `None` when no drafts exist. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, } fn is_false(b: &bool) -> bool { @@ -385,6 +394,13 @@ async fn list_apps( "app_version.raw_app", "app.labels", "draft.email IS NOT NULL as is_draft", + // All workspace users with a per-user draft at this path, + // aggregated as a JSON array. Same shape & decoding as the + // scripts/flows list — see scripts.rs for the rationale. + "(SELECT json_agg(json_build_object('username', u.username) ORDER BY u.username NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ = 'app') as draft_users", ]) .left() .join("favorite") @@ -520,6 +536,10 @@ async fn list_apps( labels: None, is_draft: true, draft_path, + // Synthesized rows come from the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), }); } } diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index 0230f4b404..9354e197bd 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -13,6 +13,14 @@ //! directly without taking a dependency on the top-level `windmill-api` //! crate. Keep this file tiny and free of HTTP/axum concerns. +// `DraftUserRef` lives in `windmill-types` so the list-endpoint row structs +// (`ListableScript`, `ListableFlow`) — which sit in `windmill-types` and +// can't reach `windmill-common` without a dependency cycle — can declare +// `Vec` aggregates. Re-exported here so the list/get +// handlers in `windmill-api-scripts` / `windmill-api-flows` / `windmill-api` +// keep a single import path for the per-user-draft surface. +pub use windmill_types::user_drafts::DraftUserRef; + use crate::db::DB; use crate::error::Result; use chrono::{DateTime, Utc}; diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index b13e91e623..f11f5ca50a 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -102,6 +102,13 @@ pub struct ListableFlow { #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_path: Option, + /// Workspace users (including the authed user, and the legacy + /// NULL-email row if any) who currently have a per-user draft at + /// this path. Drives the home page's user-avatar circles inside + /// the Draft badge. `None` when no drafts exist. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-types/src/lib.rs b/backend/windmill-types/src/lib.rs index 4d9b96c2af..9144d61f89 100644 --- a/backend/windmill-types/src/lib.rs +++ b/backend/windmill-types/src/lib.rs @@ -19,6 +19,8 @@ pub mod schedule; pub mod scripts; #[cfg(not(target_arch = "wasm32"))] pub mod triggers; +#[cfg(not(target_arch = "wasm32"))] +pub mod user_drafts; /// Duplicated from windmill-common::worker::to_raw_value. /// windmill-types cannot depend on windmill-common (it would be circular). diff --git a/backend/windmill-types/src/s3.rs b/backend/windmill-types/src/s3.rs index 0ab1794d7e..5a7634043c 100644 --- a/backend/windmill-types/src/s3.rs +++ b/backend/windmill-types/src/s3.rs @@ -364,13 +364,11 @@ mod tests { assert_eq!(deserialized, S3Permission::READ | S3Permission::WRITE); // Unknown permissions are silently ignored - let deserialized: S3Permission = - serde_json::from_str("\"read,unknown,delete\"").unwrap(); + let deserialized: S3Permission = serde_json::from_str("\"read,unknown,delete\"").unwrap(); assert_eq!(deserialized, S3Permission::READ | S3Permission::DELETE); // All four permissions - let all: S3Permission = - serde_json::from_str("\"read,write,delete,list\"").unwrap(); + let all: S3Permission = serde_json::from_str("\"read,write,delete,list\"").unwrap(); assert_eq!( all, S3Permission::READ | S3Permission::WRITE | S3Permission::DELETE | S3Permission::LIST @@ -409,20 +407,15 @@ mod tests { ); // Region set, endpoint empty → use region - let with_region = S3Resource { - region: "ap-southeast-1".to_string(), - ..resource.clone() - }; + let with_region = S3Resource { region: "ap-southeast-1".to_string(), ..resource.clone() }; assert_eq!( with_region.endpoint_with_region_fallback(Some("ignored".to_string())), "s3.ap-southeast-1.amazonaws.com" ); // Endpoint set → return as-is - let with_endpoint = S3Resource { - endpoint: "custom.s3.endpoint.com".to_string(), - ..resource.clone() - }; + let with_endpoint = + S3Resource { endpoint: "custom.s3.endpoint.com".to_string(), ..resource.clone() }; assert_eq!( with_endpoint.endpoint_with_region_fallback(Some("ignored".to_string())), "custom.s3.endpoint.com" @@ -431,10 +424,8 @@ mod tests { #[test] fn test_lfs_methods_filesystem() { - let rules = vec![S3PermissionRule { - pattern: "**/*.csv".to_string(), - allow: S3Permission::READ, - }]; + let rules = + vec![S3PermissionRule { pattern: "**/*.csv".to_string(), allow: S3Permission::READ }]; let lfs = LargeFileStorage::FilesystemStorage(FilesystemStorage { root_path: "/data/workspace".to_string(), public_resource: Some(true), diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 6d8d3e00f3..5920d1e3fa 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -476,6 +476,15 @@ pub struct ListableScript { #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_path: Option, + /// Workspace users (including the authed user, and the legacy + /// NULL-email row if any) who currently have a per-user draft at + /// this path. Sourced from a SQL aggregate over the `draft` table — + /// each entry is `{ username }` (`None` for the legacy row). Drives + /// the home page's user-avatar circles inside the Draft badge. + /// `None` when no drafts exist; never an empty array. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, } fn is_false(x: &bool) -> bool { diff --git a/backend/windmill-types/src/user_drafts.rs b/backend/windmill-types/src/user_drafts.rs new file mode 100644 index 0000000000..30740fc561 --- /dev/null +++ b/backend/windmill-types/src/user_drafts.rs @@ -0,0 +1,18 @@ +//! Shared types for the per-user draft surface. +//! +//! Mirrors the `OtherDraftUser` type in `windmill-common::user_drafts` — +//! kept here so `windmill-types` row structs (ListableScript / ListableFlow / +//! ListableApp) can expose a typed `draft_users` field without taking a +//! dependency on `windmill-common`. The two structs serialize identically, +//! so the frontend doesn't notice. + +use serde::{Deserialize, Serialize}; + +/// One workspace user (or the legacy NULL-email row) with a per-user draft +/// at a given path. Used by the home-page list endpoints to feed the +/// avatar-circles inside the Draft badge. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DraftUserRef { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index c84b319798..4620967ba6 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -1,38 +1,117 @@ -{#if is_draft} - {#if draft_only} - - {#snippet text()} +{#if showBadge} + + {#snippet text()} + {#if draft_users.length > 0} + {draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'} +
+ {#each draft_users as u} + • {fullLabel(u)} + {/each} +
+ {:else if draft_only} Never deployed and is only a draft - {/snippet} - Draft only -
- {:else} - - {#snippet text()} + {:else} Is deployed and has a draft - {/snippet} - +Draft - - {/if} + {/if} + {/snippet} +
+ {#if draft_users.length > 0} +
+ {#each visibleUsers as u} + + {initials(u)} + + {/each} + {#if overflowCount > 0} + + +{overflowCount} + + {/if} +
+ {/if} + {draft_only ? 'Draft only' : 'Draft'} +
+
{/if} diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 09ccab7f99..a96db35b6f 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -119,7 +119,7 @@ Raw {/if} - + {#if app.labels?.length}
{#each app.labels.slice(0, 3) as label} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 2ccd445b20..15ae5c6517 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -44,6 +44,7 @@ draft_only?: boolean is_draft?: boolean draft_path?: string + draft_users?: { username?: string | null }[] canWrite: boolean } marked: string | undefined @@ -140,7 +141,11 @@ archived {/if} - + {#if flow.labels?.length}
{#each flow.labels.slice(0, 3) as label} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 34dae77d03..298aebe678 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -57,6 +57,7 @@ use_codebase: boolean is_draft?: boolean draft_path?: string + draft_users?: { username?: string | null }[] } marked: string | undefined shareModal: ShareModal @@ -187,7 +188,11 @@ > {/if} - + {#if script.labels?.length}
{#each script.labels.slice(0, 3) as label}