mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 00:01:37 +00:00
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:
@@ -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()),
|
||||
}])),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}])),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user