mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
feat: bulk discard selected drafts on the compare & deploy page (#10372)
* feat(frontend): bulk discard selected drafts on the Compare & Deploy page Add a destructive "Discard N drafts" button next to "Deploy N drafts" in draft mode. A single confirmation modal lists draft-only items (permanent deletions) by path before discarding sequentially with per-row status. Split the selection gate into isDiscardable (own draft, not deployed this session, not a data-pipeline bundle) and isDeployable (+ can_write): the server lets you discard your own draft on a path you can no longer write to, so each footer button counts its own eligible selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): address review findings on bulk draft discard - invalidate the workspace-drafts resource once per batch instead of once per discarded row (discardDraft gains an invalidate opt-out) - write-gate legacy (ownerless) drafts in isDiscardable + tooltips, mirroring the server's discard check - explain diverging footer counts with a hint when selected drafts are discardable but not deployable - update selection-contract comments left over from the deploy-only gate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): cover shared-draft outcome in bulk discard modal + comment reflow A draft-only item someone else also drafted is neither reverted nor deleted — only the current user's draft is removed. Say so in the modal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c12e7c3431
commit
ac970efa16
@@ -22,7 +22,11 @@
|
||||
draftBaseIsStale
|
||||
} from '$lib/utils_draft_deploy'
|
||||
import { checkDeployPermission, type DeployPermission } from '$lib/utils_workspace_deploy'
|
||||
import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import {
|
||||
type DraftItem,
|
||||
invalidateWorkspaceDrafts,
|
||||
useWorkspaceDrafts
|
||||
} from '$lib/workspaceDrafts.svelte'
|
||||
import type { Kind as LayoutKind } from '$lib/utils_deployable'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
@@ -44,8 +48,8 @@
|
||||
draftCount?: number
|
||||
/** When set (reached via a session's Review button), preselect only the
|
||||
* rows this chat modified — `${UserDraftItemKind}:${path}` keys, matching
|
||||
* Row.key. Undefined → preselect all deployable rows (the default). All rows
|
||||
* are still shown either way. */
|
||||
* Row.key. Undefined → preselect all actionable rows (the default). All
|
||||
* rows are still shown either way. */
|
||||
chatMask?: Set<string>
|
||||
/** False while the (async) chatMask is still loading. The select-all default
|
||||
* waits for this so it doesn't race the mask and select everything. Defaults
|
||||
@@ -180,35 +184,45 @@
|
||||
isFork && hideUnchanged ? items.filter((i) => i.unchanged_from_parent !== true) : items
|
||||
)
|
||||
|
||||
// A row is actionable when it isn't already deployed this session, the user has
|
||||
// write permission, AND it's their own draft (you can't deploy someone else's
|
||||
// draft — those show view-only in the "all drafts" view). The server enforces
|
||||
// the same; this keeps the UI honest. A data-pipeline bundle is never deployable
|
||||
// from this page — its scripts deploy individually inside the pipeline view — so
|
||||
// it's excluded from every selection path.
|
||||
function isSelectable(item: Row): boolean {
|
||||
// Selection gate: a row is actionable when it isn't already deployed this
|
||||
// session AND it's the user's own draft (someone else's shows view-only in the
|
||||
// "all drafts" view). A data-pipeline bundle is excluded — its scripts deploy
|
||||
// individually inside the pipeline view. Every selectable row can at least be
|
||||
// discarded: discarding your own email-scoped draft never needs write
|
||||
// permission on the path — only legacy (ownerless) drafts stay write-gated,
|
||||
// mirroring the server's discard check.
|
||||
function isDiscardable(item: Row): boolean {
|
||||
return (
|
||||
deploymentStatus[item.key]?.status !== 'deployed' &&
|
||||
item.can_write &&
|
||||
item.mine &&
|
||||
item.draftKind !== 'data_pipeline'
|
||||
item.draftKind !== 'data_pipeline' &&
|
||||
(!item.legacy_draft || item.can_write)
|
||||
)
|
||||
}
|
||||
|
||||
// Why a row can't be deployed (drives the disabled-checkbox tooltip).
|
||||
// `undefined` ⇒ actionable.
|
||||
// Deploying additionally requires write permission on the path, so the Deploy
|
||||
// count can be lower than the selection when a drafted path lost writability.
|
||||
function isDeployable(item: Row): boolean {
|
||||
return isDiscardable(item) && item.can_write
|
||||
}
|
||||
|
||||
// Why a row can't be selected (drives the disabled-checkbox tooltip).
|
||||
// `undefined` ⇒ selectable.
|
||||
function blockedReason(item: Row): string | undefined {
|
||||
if (!item.mine) return 'This draft belongs to another user'
|
||||
if (!item.can_write) return "You don't have write permission on this path"
|
||||
if (item.legacy_draft && !item.can_write)
|
||||
return 'Discarding a legacy draft requires write permission on the path'
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Why a row can't be discarded (drives the Discard button's title).
|
||||
// Discarding only removes the caller's own draft row, which they always own,
|
||||
// so — unlike deploy — it never requires write permission on the path. The
|
||||
// only block is someone else's draft (view-only in the "all drafts" view).
|
||||
// Discarding only removes the caller's own draft row, so it doesn't require
|
||||
// write permission on the path — except for legacy (ownerless) drafts, which
|
||||
// the server write-gates like a deploy.
|
||||
function discardBlockedReason(item: Row): string | undefined {
|
||||
if (!item.mine) return 'This draft belongs to another user'
|
||||
if (item.legacy_draft && !item.can_write)
|
||||
return 'Discarding a legacy draft requires write permission on the path'
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -290,8 +304,9 @@
|
||||
if (ws === currentWorkspaceId) deployPerm = p
|
||||
})
|
||||
})
|
||||
// Select all on the first non-empty load (deploy-all is the common intent);
|
||||
// only once, so a refetch after a deploy doesn't re-select the leftovers.
|
||||
// Select all on the first non-empty load (acting on everything is the common
|
||||
// intent); only once, so a refetch after a deploy doesn't re-select the
|
||||
// leftovers.
|
||||
let hasAutoSelected = $state(false)
|
||||
|
||||
const deploymentStatus: Record<
|
||||
@@ -314,9 +329,9 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!hasAutoSelected && chatMaskReady && visibleItems.length > 0) {
|
||||
// Default intent is deploy-all; when reached from a session's Review
|
||||
// Default intent is act-on-all; when reached from a session's Review
|
||||
// (chatMask set), preselect only that chat's items instead.
|
||||
const selectable = visibleItems.filter(isSelectable)
|
||||
const selectable = visibleItems.filter(isDiscardable)
|
||||
selectedItems = (
|
||||
chatMask
|
||||
? selectable.filter((i) =>
|
||||
@@ -332,17 +347,24 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Selected items still in the visible list and deployable. Derived (not a
|
||||
// pruning effect) so the "Deploy N drafts" button stays reactive to the
|
||||
// Workspace Drafts resource: deploy/discard drop items, and stale keys left in
|
||||
// selectedItems are simply ignored here (and by deploySelected).
|
||||
let selectedCount = $derived(
|
||||
visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)).length
|
||||
// Selected items still in the visible list, per action. Derived (not a
|
||||
// pruning effect) so the footer buttons stay reactive to the Workspace
|
||||
// Drafts resource: deploy/discard drop items, and stale keys left in
|
||||
// selectedItems are simply ignored here (and by the action handlers).
|
||||
let deployableCount = $derived(
|
||||
visibleItems.filter((i) => selectedItems.includes(i.key) && isDeployable(i)).length
|
||||
)
|
||||
let discardableCount = $derived(
|
||||
visibleItems.filter((i) => selectedItems.includes(i.key) && isDiscardable(i)).length
|
||||
)
|
||||
// Selected rows the user can discard but not deploy (own draft on a path
|
||||
// without write permission) — surfaced under the footer so the diverging
|
||||
// button counts are explained.
|
||||
let undeployableSelectedCount = $derived(discardableCount - deployableCount)
|
||||
|
||||
let allSelected = $derived(
|
||||
visibleItems.filter(isSelectable).length > 0 &&
|
||||
visibleItems.filter(isSelectable).every((i) => selectedItems.includes(i.key))
|
||||
visibleItems.filter(isDiscardable).length > 0 &&
|
||||
visibleItems.filter(isDiscardable).every((i) => selectedItems.includes(i.key))
|
||||
)
|
||||
|
||||
function toggleItem(item: { key: string }) {
|
||||
@@ -354,7 +376,7 @@
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedItems = visibleItems.filter(isSelectable).map((i) => i.key)
|
||||
selectedItems = visibleItems.filter(isDiscardable).map((i) => i.key)
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
@@ -394,8 +416,8 @@
|
||||
deploying = true
|
||||
// Snapshot the items to deploy: deployDraft invalidates the Workspace Drafts
|
||||
// resource, so `items` can change mid-loop — iterate a stable copy. Guard on
|
||||
// isSelectable so a non-writable row can never be deployed via a stale key.
|
||||
const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i))
|
||||
// isDeployable so a non-writable row can never be deployed via a stale key.
|
||||
const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isDeployable(i))
|
||||
let deployedAny = false
|
||||
for (const item of toDeploy) {
|
||||
deploymentStatus[item.key] = { status: 'loading' }
|
||||
@@ -470,6 +492,64 @@
|
||||
if (item) void doDiscard(item)
|
||||
}
|
||||
|
||||
// --- Bulk discard ---
|
||||
// One click can drop many drafts at once, and some of them (draft_only with
|
||||
// no other drafter) are permanent deletions — always confirm, listing the
|
||||
// permanent ones explicitly.
|
||||
let bulkDiscardItems = $state<Row[] | undefined>(undefined)
|
||||
let discarding = $state(false)
|
||||
const bulkPermanent = $derived((bulkDiscardItems ?? []).filter(isDestructiveDiscard))
|
||||
// Third outcome the modal must cover: a draft-only item someone else also
|
||||
// drafted isn't deleted — only this user's draft goes; the item survives via
|
||||
// the other drafts.
|
||||
const bulkSharedCount = $derived(
|
||||
(bulkDiscardItems ?? []).filter((i) => i.draft_only && !isDestructiveDiscard(i)).length
|
||||
)
|
||||
|
||||
function onDiscardSelectedClick() {
|
||||
const toDiscard = visibleItems.filter((i) => selectedItems.includes(i.key) && isDiscardable(i))
|
||||
if (toDiscard.length > 0) bulkDiscardItems = toDiscard
|
||||
}
|
||||
|
||||
async function discardSelected(toDiscard: Row[]) {
|
||||
discarding = true
|
||||
let changed = false
|
||||
for (const item of toDiscard) {
|
||||
deploymentStatus[item.key] = { status: 'loading' }
|
||||
// invalidate: false — one refetch after the whole batch (below), not
|
||||
// one per row.
|
||||
const res = await discardDraft(
|
||||
item.draftKind,
|
||||
item.path,
|
||||
currentWorkspaceId,
|
||||
item.draft_only,
|
||||
item.legacy_draft,
|
||||
false
|
||||
)
|
||||
if (res.success) {
|
||||
changed = true
|
||||
delete deploymentStatus[item.key]
|
||||
} else {
|
||||
deploymentStatus[item.key] = { status: 'failed', error: res.error }
|
||||
sendUserToast(`Failed to discard ${item.path}: ${res.error}`, true)
|
||||
}
|
||||
}
|
||||
discarding = false
|
||||
selectedItems = []
|
||||
if (changed) {
|
||||
// Refetch the Draft list once for the batch, then refresh the fork
|
||||
// comparison.
|
||||
invalidateWorkspaceDrafts(currentWorkspaceId)
|
||||
onChanged?.()
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBulkDiscard() {
|
||||
const toDiscard = bulkDiscardItems
|
||||
bulkDiscardItems = undefined
|
||||
if (toDiscard) void discardSelected(toDiscard)
|
||||
}
|
||||
|
||||
// Editor URL for a draft item, scoped to the current workspace. Raw apps live
|
||||
// under a different editor route, so map their kind accordingly. Kinds whose
|
||||
// editor is a drawer on a list page (variables, resources, schedules,
|
||||
@@ -559,7 +639,7 @@
|
||||
{selectedItems}
|
||||
{deploymentStatus}
|
||||
{allSelected}
|
||||
selectablePredicate={(item) => isSelectable(item as unknown as Row)}
|
||||
selectablePredicate={(item) => isDiscardable(item as unknown as Row)}
|
||||
selectBlockedReason={(item) => blockedReason(item as unknown as Row)}
|
||||
onToggleItem={toggleItem}
|
||||
onSelectAll={selectAll}
|
||||
@@ -752,17 +832,34 @@
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={selectedCount === 0 || deploying || !deployPerm.ok}
|
||||
title={!deployPerm.ok ? deployPerm.reason : undefined}
|
||||
loading={deploying}
|
||||
onClick={deploySelected}
|
||||
>
|
||||
Deploy {selectedCount} draft{selectedCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
destructive
|
||||
disabled={discardableCount === 0 || deploying || discarding}
|
||||
loading={discarding}
|
||||
startIcon={{ icon: Undo2 }}
|
||||
onClick={onDiscardSelectedClick}
|
||||
>
|
||||
Discard {discardableCount} draft{discardableCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={deployableCount === 0 || deploying || discarding || !deployPerm.ok}
|
||||
title={!deployPerm.ok ? deployPerm.reason : undefined}
|
||||
loading={deploying}
|
||||
onClick={deploySelected}
|
||||
>
|
||||
Deploy {deployableCount} draft{deployableCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
{#if !deployPerm.ok}
|
||||
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
|
||||
{:else if undeployableSelectedCount > 0}
|
||||
<span class="text-xs text-secondary">
|
||||
{undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1 ? 's' : ''}
|
||||
can't be deployed (no write permission on the path) but can still be discarded
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -772,6 +869,39 @@
|
||||
<DiffDrawer bind:this={diffDrawer} {isFlow} />
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
open={bulkDiscardItems !== undefined}
|
||||
title="Discard selected drafts"
|
||||
confirmationText="Discard"
|
||||
onConfirmed={confirmBulkDiscard}
|
||||
onCanceled={() => (bulkDiscardItems = undefined)}
|
||||
>
|
||||
<p>
|
||||
This will discard {bulkDiscardItems?.length} draft{(bulkDiscardItems?.length ?? 0) !== 1
|
||||
? 's'
|
||||
: ''}. Items with a deployed version revert to it.
|
||||
</p>
|
||||
{#if bulkSharedCount > 0}
|
||||
<p class="mt-2">
|
||||
{bulkSharedCount} draft-only {bulkSharedCount === 1 ? 'item is' : 'items are'} also drafted by
|
||||
other users: only your draft is removed and the {bulkSharedCount === 1 ? 'item' : 'items'} will
|
||||
remain through theirs.
|
||||
</p>
|
||||
{/if}
|
||||
{#if bulkPermanent.length > 0}
|
||||
<p class="mt-2">
|
||||
{bulkPermanent.length}
|
||||
{bulkPermanent.length === 1 ? 'item exists' : 'items exist'} only as a draft and will be
|
||||
<span class="font-semibold">permanently deleted</span>:
|
||||
</p>
|
||||
<ul class="list-disc list-inside font-mono text-xs mt-1">
|
||||
{#each bulkPermanent as item (item.key)}
|
||||
<li>{item.draft_path ?? item.path}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
|
||||
<!-- Only the destructive discard (deleting the last draft of a never-deployed
|
||||
item) opens this modal; non-destructive discards run without confirmation. -->
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -712,7 +712,11 @@ export async function discardDraft(
|
||||
path: string,
|
||||
workspace: string,
|
||||
_draftOnly = false,
|
||||
legacy = false
|
||||
legacy = false,
|
||||
// Batch callers pass false and invalidate once after the last discard —
|
||||
// per-item invalidation would refetch the whole draft list N times, with
|
||||
// overlapping responses able to land out of order.
|
||||
invalidate = true
|
||||
): Promise<DeployResult> {
|
||||
try {
|
||||
if (legacy) {
|
||||
@@ -723,7 +727,7 @@ export async function discardDraft(
|
||||
requestBody: { value: null, legacy: true }
|
||||
})
|
||||
setLocalDraftHint(workspace, kind, path, false)
|
||||
invalidateWorkspaceDrafts(workspace)
|
||||
if (invalidate) invalidateWorkspaceDrafts(workspace)
|
||||
return { success: true }
|
||||
}
|
||||
// postSave clears the syncer-owned `*` hint on the delete. `immediate`
|
||||
@@ -731,7 +735,7 @@ export async function discardDraft(
|
||||
// enqueue time and the invalidate below refetches before the delete,
|
||||
// re-listing the just-discarded draft.
|
||||
await UserDraftDbSyncer.save({ workspace, itemKind: kind, path, value: null, immediate: true })
|
||||
invalidateWorkspaceDrafts(workspace)
|
||||
if (invalidate) invalidateWorkspaceDrafts(workspace)
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e?.body ?? e?.message ?? String(e) }
|
||||
|
||||
Reference in New Issue
Block a user