mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 08:03:38 +00:00
* fix: show draft badge and disable toggle for draft-only triggers * fix: align draft badge visibility with local draft hint * fix: refine draft trigger rows (badge by label, off toggle, hover hints) * fix: match draft pill size to standard badge size * fix: show not-allowed cursor on disabled toggles * fix: show the draft-only badge in place of the trigger toggle Draft-only triggers have nothing deployed to enable, so the row renders the "Draft only" badge in the toggle's slot instead of a disabled toggle. Deployed triggers that also have a draft keep their toggle and show the "Draft" badge next to the label. Applies to schedules and every trigger list page, including amqp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: place the draft badge left of the trigger toggle Both badges now sit in the toggle's row: "Draft only" next to a disabled, off toggle, and "Draft" next to the live toggle of a deployed trigger that also has a draft. The label keeps only its `*` marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: show the draft badge hint as a tooltip when it has no actions Without owner rows the popover renders a focus-ringed 256px card for a single sentence. Route that case through Tooltip and keep the popover for the owner list, whose View Diff / Load / Migrate buttons need click targets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: drop the "edited by" label when there is no author Draft-only rows are synthesized from the draft table and carry no author, so the label rendered with nothing after it. Show it only when a name exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: render the draft badge on push-mode GCP and Azure triggers Those rows have no mode toggle, and the badge slot was nested inside the toggle's conditional, so they showed no badge at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: let the trigger control slot grow, and hint the suspended group too The suspended three-state control is ~246px and overflowed the fixed 8rem slot into the row's status and badges; the slot now treats 8rem as a minimum so unsuspended rows still line up. Moving `title` onto the wrapper also gives the suspended group the draft explanation the toggle already had. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: drop the date's "the"/"at" prefix on authorless rows Without an author the prefix dangled ("the 7/15, 03:42 PM"); those rows now show a bare timestamp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address PR review — draft-only row state, and DraftBadge visibility - Derive each trigger row's status indicator from one effective mode, so a synthesized draft-only row (mode 'enabled', no server_id) no longer claims the trigger is starting up next to an off, disabled toggle. - Fold draft_only into DraftBadge's own visibility rule so call sites pass their state as-is instead of hard-coding is_draft={true} behind a guard that duplicated the rule. - Apply the same call to variables and resources, which still carried the original is_draft={false} form and so rendered no badge at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: keep every mode control off draft-only rows The row's overflow menu still offered "Suspend job execution" for draft-only triggers, calling the mode API for a trigger with no deployment, and the schedules enabled/disabled filter still read the raw `enabled` flag, so draft-only schedules listed under Enabled while rendering an off toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf: mount the draft diff drawer only where it can be opened DraftBadge mounted a DiffDrawer on every instance, so unpaginated lists like resources and variables carried a hidden drawer per row (measured: 4334 vs 3854 DOM nodes over 120 draft-less rows). The drawer is only reachable from the popover's View Diff, which already requires `actionsEnabled`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
383 lines
13 KiB
Svelte
383 lines
13 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* Home-page draft badge with per-user initial circles. Hover popover lists
|
|
* each draft owner; when full context (workspace + itemKind + path) is
|
|
* passed, OTHER users' rows get inline "View Diff" / "Load" (loading
|
|
* yourself is meaningless, so own rows don't). draft_only → "Draft only"
|
|
* (no deployed row), else "Draft". Renders nothing when there's no draft.
|
|
*/
|
|
import Popover from './meltComponents/Popover.svelte'
|
|
import Tooltip from './meltComponents/Tooltip.svelte'
|
|
import { Badge } from './common'
|
|
import Button from './common/button/Button.svelte'
|
|
import DiffDrawer from './DiffDrawer.svelte'
|
|
import MigrateLegacyDraftModal from './common/confirmationModal/MigrateLegacyDraftModal.svelte'
|
|
import { GitCompareArrows, Pencil, Wrench } from 'lucide-svelte'
|
|
import { DraftService, type UserDraftItemKind } from '$lib/gen'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { goto } from '$lib/navigation'
|
|
import { OtherUserDraftLoad, editRouteFor } from '$lib/components/otherUserDraftLoad.svelte'
|
|
import { fetchDeployedValueForDiff } from '$lib/components/otherUserDraftDiff'
|
|
import { userStore } from '$lib/stores'
|
|
|
|
type DraftUser = { username?: string | null }
|
|
|
|
interface Props {
|
|
is_draft?: boolean
|
|
draft_only?: boolean
|
|
draft_users?: DraftUser[]
|
|
/** Authed user's username — pins their circle first and annotates `(you)`. */
|
|
currentUsername?: string | null
|
|
/** Context for the View Diff / Load actions. Missing → text-only popover. */
|
|
workspace?: string
|
|
itemKind?: UserDraftItemKind
|
|
path?: string
|
|
/** Called after an admin migrates (deletes / assigns) the legacy draft, so
|
|
* the parent row can refetch and drop the now-resolved legacy entry. */
|
|
onMigrated?: () => void
|
|
/** Offer "Load" alongside "View Diff" on other users' rows. The deploy /
|
|
* review page sets this false: loading into a fresh editor is moot there. */
|
|
allowFork?: boolean
|
|
/** Compact variant: render only the avatar circles (no "Draft" pill text),
|
|
* slightly smaller — for tight spots like the diff-tree sidebar. The hover
|
|
* popover is unchanged. */
|
|
iconOnly?: boolean
|
|
}
|
|
|
|
let {
|
|
is_draft = false,
|
|
draft_only = false,
|
|
draft_users = [],
|
|
currentUsername = undefined,
|
|
workspace = undefined,
|
|
itemKind = undefined,
|
|
path = undefined,
|
|
onMigrated = undefined,
|
|
allowFork = true,
|
|
iconOnly = false
|
|
}: Props = $props()
|
|
|
|
// Authed user lands first; everyone else keeps the backend's ordering.
|
|
const orderedUsers = $derived.by(() => {
|
|
if (!currentUsername) return draft_users
|
|
const selfIdx = draft_users.findIndex((u) => u.username === currentUsername)
|
|
if (selfIdx < 0) return draft_users
|
|
const self = draft_users[selfIdx]
|
|
const rest = draft_users.filter((_, i) => i !== selfIdx)
|
|
return [self, ...rest]
|
|
})
|
|
|
|
/** Two-letter initials; `john.doe` → `JD`, `alice` → `AL`, 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, same circle across rows.
|
|
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 + "+N" overflow when 4+. Sliced from
|
|
// `orderedUsers` so the authed user (first) is never hidden in the overflow.
|
|
const MAX_CIRCLES = 3
|
|
const visibleUsers = $derived(
|
|
orderedUsers.length <= MAX_CIRCLES ? orderedUsers : orderedUsers.slice(0, MAX_CIRCLES - 1)
|
|
)
|
|
const overflowCount = $derived(
|
|
orderedUsers.length > MAX_CIRCLES ? orderedUsers.length - (MAX_CIRCLES - 1) : 0
|
|
)
|
|
|
|
// Show whenever any draft exists: the authed user has one (`is_draft`), some
|
|
// other user does (`draft_users`), or the row is itself a draft with nothing
|
|
// deployed (`draft_only`). Callers pass their state as-is; deciding
|
|
// visibility here keeps every call site from re-deriving this rule.
|
|
const showBadge = $derived(is_draft || draft_only || draft_users.length > 0)
|
|
|
|
// Inline actions need full context to fetch and fork drafts.
|
|
const actionsEnabled = $derived(!!workspace && !!itemKind && !!path && draft_users.length > 0)
|
|
|
|
const kindLabel = $derived(
|
|
itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script'
|
|
)
|
|
|
|
// "Only you can see this {kind}" — true when the authed user owns the
|
|
// sole draft on a never-deployed item.
|
|
const onlyOwnDraft = $derived(
|
|
draft_only &&
|
|
draft_users.length === 1 &&
|
|
!!currentUsername &&
|
|
draft_users[0]?.username === currentUsername
|
|
)
|
|
|
|
let busyFor = $state<string | null>(null)
|
|
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
|
let migrateOpen = $state(false)
|
|
// The hover popover sits above the diff drawer / migrate modal (z-index), so
|
|
// close it before opening either or it would cover them.
|
|
let popoverOpen = $state(false)
|
|
|
|
// Legacy (no-owner) drafts can only be resolved by workspace admins / superadmins.
|
|
const canMigrateLegacy = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin)
|
|
|
|
function ownerKey(owner: DraftUser): string {
|
|
return owner.username ?? '__legacy__'
|
|
}
|
|
|
|
async function fetchDraft(owner: DraftUser): Promise<unknown> {
|
|
if (!workspace || !itemKind || !path) {
|
|
throw new Error('Missing context for draft fetch')
|
|
}
|
|
return (
|
|
await DraftService.getDraftForUser({
|
|
workspace,
|
|
kind: itemKind,
|
|
path,
|
|
username: owner.username ?? undefined
|
|
})
|
|
).value
|
|
}
|
|
|
|
async function viewDiff(owner: DraftUser) {
|
|
if (!workspace || !itemKind || !path) return
|
|
busyFor = ownerKey(owner)
|
|
try {
|
|
const [draftValue, deployed] = await Promise.all([
|
|
fetchDraft(owner),
|
|
fetchDeployedValueForDiff(workspace, itemKind, path)
|
|
])
|
|
// Close the popover so it doesn't render over the drawer.
|
|
popoverOpen = false
|
|
diffDrawer?.openDrawer()
|
|
diffDrawer?.setDiff({
|
|
mode: 'simple',
|
|
title: `${fullLabel(owner)}'s draft vs deployed`,
|
|
original: deployed,
|
|
current: draftValue as any
|
|
})
|
|
} catch (e: any) {
|
|
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
|
} finally {
|
|
busyFor = null
|
|
}
|
|
}
|
|
|
|
async function load(owner: DraftUser) {
|
|
if (!workspace || !itemKind || !path) return
|
|
busyFor = ownerKey(owner)
|
|
try {
|
|
const value = await fetchDraft(owner)
|
|
// Stage their value and open this item's editor. If we already have a
|
|
// draft here, the editor enters overlay mode (no save until the user
|
|
// confirms overwriting it).
|
|
OtherUserDraftLoad.stage(workspace, itemKind, value, path, fullLabel(owner), {
|
|
navigate: true
|
|
})
|
|
} catch (e: any) {
|
|
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
|
} finally {
|
|
busyFor = null
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#snippet circleStack()}
|
|
<!-- `-space-x-1` overlaps the circles; the indigo ring tint makes the overlap read intentional. -->
|
|
<span class="flex -space-x-1">
|
|
{#each visibleUsers as u, i (i)}
|
|
<span
|
|
class="inline-flex h-3 w-3 items-center justify-center rounded-full text-[8px] font-semibold text-white ring-1 ring-indigo-100 dark:ring-indigo-700/40 {colorFor(
|
|
u
|
|
)}"
|
|
title={u.username === currentUsername ? `${fullLabel(u)} (you)` : fullLabel(u)}
|
|
>
|
|
{initials(u)}
|
|
</span>
|
|
{/each}
|
|
{#if overflowCount > 0}
|
|
<span
|
|
class="inline-flex h-3 w-3 items-center justify-center rounded-full bg-gray-500 text-[8px] font-semibold text-white ring-1 ring-indigo-100 dark:ring-indigo-700/40"
|
|
title="{overflowCount} more"
|
|
>
|
|
+{overflowCount}
|
|
</span>
|
|
{/if}
|
|
</span>
|
|
{/snippet}
|
|
|
|
{#snippet badge()}
|
|
{#if iconOnly}
|
|
<!-- Compact "has a draft" marker for tight spots like the diff-tree
|
|
sidebar: a small indigo pencil + the author avatar(s). -->
|
|
<span class="inline-flex items-center gap-0.5" title={draft_only ? 'Draft only' : 'Draft'}>
|
|
<Pencil class="h-3 w-3 shrink-0 text-indigo-500 dark:text-indigo-400" />
|
|
{#if orderedUsers.length > 0}
|
|
{@render circleStack()}
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<Badge small color="indigo" class="gap-0.5">
|
|
{#if orderedUsers.length > 0}
|
|
{@render circleStack()}
|
|
{/if}
|
|
{draft_only ? 'Draft only' : 'Draft'}
|
|
</Badge>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#if showBadge}
|
|
{#if draft_users.length === 0}
|
|
<!-- Nothing to click without owner rows, so a tooltip is the right shape:
|
|
a popover here renders a focus-ringed card for a single sentence. -->
|
|
<Tooltip class="inline-flex items-center">
|
|
{@render badge()}
|
|
{#snippet text()}
|
|
{draft_only ? 'Never deployed and is only a draft' : 'Is deployed and has a draft'}
|
|
{/snippet}
|
|
</Tooltip>
|
|
{:else}
|
|
<!-- inline-flex/items-center so the trigger button hugs the badge and lines up
|
|
with sibling badges (a plain button is taller, dropping the pill ~2px). -->
|
|
<Popover
|
|
openOnHover={true}
|
|
debounceDelay={50}
|
|
enableFlyTransition
|
|
class="inline-flex items-center"
|
|
bind:isOpen={popoverOpen}
|
|
>
|
|
{#snippet trigger()}
|
|
{@render badge()}
|
|
{/snippet}
|
|
{#snippet content()}
|
|
<div class="flex flex-col gap-2 min-w-[16rem] text-xs p-4 pb-1">
|
|
<p class="text-primary">
|
|
{draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'}
|
|
|
|
{#if onlyOwnDraft}
|
|
<br />
|
|
<span class="text-tertiary italic">Only you can see this {kindLabel}.</span>
|
|
{/if}
|
|
</p>
|
|
<ul class="flex flex-col divide-y border-t">
|
|
{#each orderedUsers as u, i (i)}
|
|
{@const isSelf = !!currentUsername && u.username === currentUsername}
|
|
<li class="flex items-center gap-2 py-1.5">
|
|
<span
|
|
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold text-white {colorFor(
|
|
u
|
|
)}"
|
|
>
|
|
{initials(u)}
|
|
</span>
|
|
<span class="flex-1 truncate text-primary inline-flex items-center gap-1">
|
|
{fullLabel(u)}{isSelf ? ' (you)' : ''}
|
|
{#if !u.username}
|
|
<Tooltip small>
|
|
{#snippet text()}
|
|
A legacy draft predates the per-user drafts migration: it isn't tied to any
|
|
user (workspace-level, email NULL), so everyone with access to this path
|
|
sees it.
|
|
{/snippet}
|
|
</Tooltip>
|
|
{/if}
|
|
</span>
|
|
{#if actionsEnabled && isSelf}
|
|
<!-- Own draft: jump straight into the editor. -->
|
|
{#if !$userStore?.operator && itemKind && path}
|
|
<Button
|
|
variant="subtle"
|
|
size="xs3"
|
|
startIcon={{ icon: Pencil }}
|
|
on:click={() => goto(editRouteFor(itemKind, path))}
|
|
>
|
|
Edit
|
|
</Button>
|
|
{/if}
|
|
{:else if actionsEnabled && !isSelf}
|
|
{#if !draft_only}
|
|
<Button
|
|
variant="subtle"
|
|
size="xs3"
|
|
startIcon={{ icon: GitCompareArrows }}
|
|
disabled={busyFor !== null && busyFor !== ownerKey(u)}
|
|
loading={busyFor === ownerKey(u)}
|
|
on:click={() => viewDiff(u)}
|
|
>
|
|
View Diff
|
|
</Button>
|
|
{/if}
|
|
<!-- Operators can't edit items, so Load is hidden (View Diff stays, it's
|
|
read-only). `allowFork=false` (deploy/review page) hides it too. -->
|
|
{#if allowFork && !$userStore?.operator}
|
|
<Button
|
|
variant="subtle"
|
|
size="xs3"
|
|
startIcon={{ icon: Pencil }}
|
|
disabled={busyFor !== null && busyFor !== ownerKey(u)}
|
|
loading={busyFor === ownerKey(u)}
|
|
on:click={() => load(u)}
|
|
>
|
|
Load
|
|
</Button>
|
|
{/if}
|
|
{#if !u.username && canMigrateLegacy}
|
|
<Button
|
|
variant="subtle"
|
|
size="xs3"
|
|
startIcon={{ icon: Wrench }}
|
|
on:click={() => {
|
|
popoverOpen = false
|
|
migrateOpen = true
|
|
}}
|
|
>
|
|
Migrate
|
|
</Button>
|
|
{/if}
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Only reachable from the popover's "View Diff", which requires `actionsEnabled`.
|
|
Mounting it unconditionally would put a hidden drawer (portal + effects) on
|
|
every row of unpaginated lists like resources and variables. -->
|
|
{#if actionsEnabled}
|
|
<DiffDrawer bind:this={diffDrawer} isFlow={itemKind === 'flow'} />
|
|
{/if}
|
|
|
|
{#if workspace && itemKind && path}
|
|
<MigrateLegacyDraftModal
|
|
bind:isOpen={migrateOpen}
|
|
{workspace}
|
|
{itemKind}
|
|
{path}
|
|
ownDraftExists={!!currentUsername && draft_users.some((u) => u.username === currentUsername)}
|
|
onMigrated={() => onMigrated?.()}
|
|
/>
|
|
{/if}
|