From ac970efa1698e42ad1e7b3cea5f06f18a567269d Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 28 Jul 2026 14:35:44 +0200 Subject: [PATCH] feat: bulk discard selected drafts on the compare & deploy page (#10372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .../src/lib/components/CompareDrafts.svelte | 216 ++++++++++++++---- frontend/src/lib/utils_draft_deploy.ts | 10 +- 2 files changed, 180 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 32be6caf35..65fe205046 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -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 /** 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(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()}
- +
+ + +
{#if !deployPerm.ok} {deployPerm.reason} + {:else if undeployableSelectedCount > 0} + + {undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1 ? 's' : ''} + can't be deployed (no write permission on the path) but can still be discarded + {/if}
{/snippet} @@ -772,6 +869,39 @@ + (bulkDiscardItems = undefined)} +> +

+ This will discard {bulkDiscardItems?.length} draft{(bulkDiscardItems?.length ?? 0) !== 1 + ? 's' + : ''}. Items with a deployed version revert to it. +

+ {#if bulkSharedCount > 0} +

+ {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. +

+ {/if} + {#if bulkPermanent.length > 0} +

+ {bulkPermanent.length} + {bulkPermanent.length === 1 ? 'item exists' : 'items exist'} only as a draft and will be + permanently deleted: +

+
    + {#each bulkPermanent as item (item.key)} +
  • {item.draft_path ?? item.path}
  • + {/each} +
+ {/if} +
+ { 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) }