feat(drafts): home-page Draft badge — show user-initial circles, drop the '+'

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<String> }` 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<sqlx::types::Json<Vec<DraftUserRef>>>`
  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.
This commit is contained in:
Diego Imbert
2026-06-09 14:41:56 +02:00
parent 0be6bc1426
commit 35c9dbda9a
14 changed files with 262 additions and 46 deletions
+14 -1
View File
@@ -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()),
}])),
});
}
}
+19 -1
View File
@@ -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<Vec<DraftUserRef>>`.
// 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()),
}])),
});
}
}
+41
View File
@@ -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
+21 -1
View File
@@ -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<String>,
/// 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<sqlx::types::Json<Vec<windmill_types::user_drafts::DraftUserRef>>>,
}
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()),
}])),
});
}
}
@@ -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<DraftUserRef>` 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};
+7
View File
@@ -102,6 +102,13 @@ pub struct ListableFlow {
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
/// 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<sqlx::types::Json<Vec<crate::user_drafts::DraftUserRef>>>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
+2
View File
@@ -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).
+7 -16
View File
@@ -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),
+9
View File
@@ -476,6 +476,15 @@ pub struct ListableScript {
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
/// 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<sqlx::types::Json<Vec<crate::user_drafts::DraftUserRef>>>,
}
fn is_false(x: &bool) -> bool {
+18
View File
@@ -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<String>,
}
+103 -24
View File
@@ -1,38 +1,117 @@
<script lang="ts">
// Renders a small status pill on home-page rows when the authed user
// has a per-user draft on the entity. `is_draft` is the per-user
// signal that replaced main's workspace-wide `has_draft` (the field
// rename mirrors the get-by-path overlay's `is_draft` flag).
//
// draft_only=true → "Draft only" (entity has never been deployed)
// draft_only=false → "+Draft" (deployed and user has a draft on top)
//
// Nothing renders when `is_draft` is false.
/**
* Home-page badge that surfaces "this entity has a draft" plus tiny
* user-initial circles for every workspace user with a per-user draft
* at this path. Up to 3 circles render inline; with 4+ users we show
* the first 2 + a `+N` overflow circle so the badge stays compact.
*
* Variants:
* draft_only=true → "Draft only" (no deployed row exists)
* draft_only=false → "Draft" (deployed and at least one user
* has a draft on top)
*
* Nothing renders when neither `is_draft` is true nor `draft_users`
* is non-empty — the list endpoint omits `draft_users` for paths
* with no drafts, so a falsy/empty array is the no-draft signal.
*/
import Popover from './Popover.svelte'
import { Badge } from './common'
type DraftUser = { username?: string | null }
interface Props {
is_draft?: boolean
draft_only?: boolean
draft_users?: DraftUser[]
}
let { is_draft = false, draft_only = false }: Props = $props()
let { is_draft = false, draft_only = false, draft_users = [] }: Props = $props()
/** Two-letter uppercase initials from a username — `john.doe`/`john_doe` →
* `JD`, `alice` → `AL`, the legacy NULL-email row (no username) → `?`. */
function initials(u: DraftUser): string {
const name = u.username
if (!name) return '?'
const parts = name.split(/[._\-\s]+/).filter(Boolean)
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
}
function fullLabel(u: DraftUser): string {
return u.username ?? 'Legacy workspace draft'
}
// Deterministic color per username so the same user gets the same circle
// across rows. Tailwind palette of 6 — small enough to read at a glance.
const PALETTE = [
'bg-blue-500',
'bg-emerald-500',
'bg-amber-500',
'bg-rose-500',
'bg-violet-500',
'bg-cyan-500'
]
function colorFor(u: DraftUser): string {
const name = u.username ?? ''
let hash = 0
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0
return PALETTE[hash % PALETTE.length]
}
// First 3 circles when ≤3 users; first 2 + a "+N" overflow when 4+.
const MAX_CIRCLES = 3
const visibleUsers = $derived(
draft_users.length <= MAX_CIRCLES ? draft_users : draft_users.slice(0, MAX_CIRCLES - 1)
)
const overflowCount = $derived(
draft_users.length > MAX_CIRCLES ? draft_users.length - (MAX_CIRCLES - 1) : 0
)
const showBadge = $derived(is_draft || draft_users.length > 0)
</script>
{#if is_draft}
{#if draft_only}
<Popover notClickable>
{#snippet text()}
{#if showBadge}
<Popover notClickable>
{#snippet text()}
{#if draft_users.length > 0}
{draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'}
<div class="mt-1 flex flex-col gap-0.5">
{#each draft_users as u}
<span>{fullLabel(u)}</span>
{/each}
</div>
{:else if draft_only}
Never deployed and is only a draft
{/snippet}
<Badge small color="indigo">Draft only</Badge>
</Popover>
{:else}
<Popover notClickable>
{#snippet text()}
{:else}
Is deployed and has a draft
{/snippet}
<Badge small color="indigo">+Draft</Badge>
</Popover>
{/if}
{/if}
{/snippet}
<div class="flex items-center gap-1">
{#if draft_users.length > 0}
<div class="flex -space-x-1">
{#each visibleUsers as u}
<span
class="inline-flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-semibold text-white ring-1 ring-white {colorFor(
u
)}"
title={fullLabel(u)}
>
{initials(u)}
</span>
{/each}
{#if overflowCount > 0}
<span
class="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-500 text-[8px] font-semibold text-white ring-1 ring-white"
title="{overflowCount} more"
>
+{overflowCount}
</span>
{/if}
</div>
{/if}
<Badge small color="indigo">{draft_only ? 'Draft only' : 'Draft'}</Badge>
</div>
</Popover>
{/if}
@@ -119,7 +119,7 @@
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
<DraftBadge is_draft={app.is_draft} draft_only={app.draft_only} />
<DraftBadge is_draft={app.is_draft} draft_only={app.draft_only} draft_users={app.draft_users} />
{#if app.labels?.length}
<div class="flex items-center gap-0.5">
{#each app.labels.slice(0, 3) as label}
@@ -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 @@
<Badge color="red" baseClass="border">archived</Badge>
{/if}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
<DraftBadge is_draft={flow.is_draft} draft_only={flow.draft_only} />
<DraftBadge
is_draft={flow.is_draft}
draft_only={flow.draft_only}
draft_users={flow.draft_users}
/>
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
@@ -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}
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
<DraftBadge is_draft={script.is_draft} draft_only={script.draft_only} />
<DraftBadge
is_draft={script.is_draft}
draft_only={script.draft_only}
draft_users={script.draft_users}
/>
{#if script.labels?.length}
<div class="flex items-center gap-0.5">
{#each script.labels.slice(0, 3) as label}