Files
windmill/frontend/src/lib/components/WorkspaceDeployLayout.svelte
T
GuilhemandClaude Opus 4.8 e09cd5862c feat: per-user draft review & deploy page (gating, badges, rename, raw-app deploy fixes) (#9625)
* feat: per-user draft gating, badges and rename display on deploy page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): don't strike the path when a draft adds a summary to a summary-less item

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): don't strike draft-only items' auto-generated path against the pretty path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy raw-app drafts from top-level files so the bundle isn't dropped

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): share raw-app source→draft-value projection across chat and deploy page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy renamed/new flow, app and raw-app drafts at draft_path, not the temp storage path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): add a design-system Checkbox and use it for deploy-page row/select-all checkboxes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: "Show all drafts" toggle on the deploy-drafts page

Replace the deploy-drafts page's legacy-hiding "Only my drafts" toggle with
a "Show all drafts" toggle that switches the listing scope between the
current user's own drafts (+ legacy no-owner rows) and every user's drafts
in the workspace.

Backend (`drafts.rs`, `openapi.yaml`):
- `/drafts/list` gains an `all_users` query param that drops the owner
  filter, and a per-row `mine` flag (own draft or legacy no-owner row).
  `DISTINCT ON` now prefers the user's own row, then the legacy row, then
  another user's, so `mine`/`legacy_draft` describe the kept row.

Frontend (`CompareDrafts.svelte`, `workspaceDrafts.svelte.ts`):
- "Show all drafts" toggle (default off). The all-users superset is fetched
  lazily via the shared resource only while the toggle is on, so the page's
  fork draft-count (own drafts) is unaffected.
- Other users' drafts are view-only: disabled checkbox + Discard with a
  "belongs to another user" tooltip; Show diff stays enabled. Selection,
  select-all and the deploy count only ever include the user's own drafts.
  The multi-user warning triangle shows on owned rows only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): gate all_users draft listing by read permission

Addresses the PR review on the per-user deploy-drafts page:

- `/drafts/list?all_users=true` previously had only `WHERE workspace_id = $1`
  with no read-permission check, so any non-operator could enumerate every
  draft's path, summary and authors — including items they can't read. Now
  rows the caller doesn't own (`mine = false`) are gated through
  `require_can_read_path` (the same gate `/drafts/get` uses) and dropped when
  unreadable; both its `NotFound` and `NotAuthorized` denials are treated as
  "not visible".
- Skip the per-row `require_can_write_path` probe on those non-owned rows
  (they're never selectable — `isSelectable` requires `mine`): set
  `can_write = false` directly, removing a redundant N RLS write-probes when
  `all_users` is on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): only confirm destructive draft discards on the deploy page

Discarding a draft is non-destructive in every case except removing the last
draft of a never-deployed item (`draft_only` with no other user's draft),
which permanently deletes it. Confirm only that case; reverting a draft over a
deployed item, or discarding your copy while another user still holds a draft,
now runs immediately (the ⚠️ already signals the multi-user case). Drops the
redundant "other users still have a draft" / "deployed version unaffected"
confirmation branches.

Harden the destructive check: it keyed off `otherDraftUsers()`, which subtracts
`currentUsername`; while `$userStore.username` is unhydrated, your own draft
looked like another user's, flipping a draft-only item to "non-destructive" and
deleting it with no confirmation. Now: deployed counterpart → never destructive;
`draft_only` with unknown `currentUsername` → treated as destructive (confirm).
The delete modal also shows the friendly `draft_path` instead of the raw
`draft_{uuid}` storage path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy low-code app drafts (value + summary persistence)

A visual (low-code) app draft is autosaved as the *bare* App value
(grid/theme/... plus a draft-only `draft_path`), not wrapped in
{ value, summary, policy } like script/flow drafts. The Review & Deploy page
read `requestBody.value = d.value` — undefined for that shape — so deploying any
low-code app draft (created or edited) sent no value and failed. Read the value
from the draft object itself, strip the draft-only `draft_path` from it, and use
that as the deploy path.

Also persist the app summary, which was dropped entirely: the autosave stores
the bare App value (the summary normally lives only in the `app` table column,
set on deploy), so a draft never carried it — reopening a draft or deploying it
lost the summary. Mirror the summary onto the autosaved App (like `draft_path`),
read it back when loading a draft, and on deploy send it as the summary column
while stripping it (and `draft_path`) from the deployed value so the value stays
clean.

Verified end-to-end: a new low-code app with a summary deploys at its pretty
path with the summary set, content intact, and no draft_path/summary leaked into
the deployed value; the draft is cleaned up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:49:36 +00:00

201 lines
5.6 KiB
Svelte

<script lang="ts">
import { Loader2 } from 'lucide-svelte'
import { Badge } from './common'
import Checkbox from './common/checkbox/Checkbox.svelte'
import Row from './common/table/Row.svelte'
import Tooltip from './Tooltip.svelte'
import type { Snippet } from 'svelte'
import type { Kind } from '$lib/utils_deployable'
interface DeployableItem {
key: string
path: string
kind: Kind
triggerKind?: string
[key: string]: unknown
}
interface Props {
items: DeployableItem[]
selectedItems: string[]
selectablePredicate?: (item: DeployableItem) => boolean
/** For a non-deployed item that isn't selectable, return a reason string to
* render a disabled checkbox + hover tooltip (instead of greying the row);
* return undefined to keep the default greyed-out, no-checkbox treatment. */
selectBlockedReason?: (item: DeployableItem) => string | undefined
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
allSelected?: boolean
emptyMessage?: string
children?: Snippet
// Snippets for customization
header?: Snippet
alerts?: Snippet
/** Rendered on the right of the "Select all" row (e.g. a filter toggle). */
selectAllActions?: Snippet
itemSummary?: Snippet<[DeployableItem]>
/** Overrides the secondary path line per item (e.g. to strike a renamed path). */
itemPath?: Snippet<[DeployableItem]>
itemActions?: Snippet<[DeployableItem]>
footer?: Snippet
// Callbacks
onToggleItem?: (item: DeployableItem) => void
onSelectAll?: () => void
onDeselectAll?: () => void
}
let {
items,
selectedItems,
selectablePredicate = () => true,
selectBlockedReason,
deploymentStatus,
allSelected = false,
emptyMessage = 'No items to deploy',
header,
alerts,
selectAllActions,
itemSummary,
itemPath,
itemActions,
footer,
onToggleItem,
onSelectAll,
onDeselectAll
}: Props = $props()
let selectableItems = $derived(items.filter(selectablePredicate))
let hasSelectableItems = $derived(selectableItems.length > 0)
// Plain row click and the checkbox both toggle this row in/out — multi-select
// is the default, no modifier needed.
function handleSelect(item: DeployableItem) {
onToggleItem?.(item)
}
</script>
<div class="flex flex-col h-full">
<!-- Header section -->
{#if header}
<div class="bg-surface">
{@render header()}
</div>
{/if}
<!-- Alerts section -->
{#if alerts}
{@render alerts()}
{/if}
<!-- Controls row: "Select all" (when there are items) + optional right-side
actions (e.g. a filter toggle). Renders when there are items OR actions are
provided, so a filter that empties the list doesn't take its own toggle with it. -->
{#if items.length > 0 || selectAllActions}
<div class="px-4 py-2 flex items-center justify-between">
{#if items.length > 0}
<label
class="flex items-center gap-2 text-secondary text-xs"
class:opacity-50={!hasSelectableItems}
class:cursor-pointer={hasSelectableItems}
>
<Checkbox
disabled={!hasSelectableItems}
checked={allSelected}
onChange={allSelected ? onDeselectAll : onSelectAll}
/> Select all
</label>
{:else}
<span></span>
{/if}
{#if selectAllActions}
{@render selectAllActions()}
{/if}
</div>
{/if}
{#if items.length > 0}
<!-- Items list -->
<div class="overflow-y-auto">
<div class="border rounded-md bg-surface-tertiary">
{#each items as item (item.key)}
{@const isSelectable = selectablePredicate(item)}
{@const isSelected = selectedItems.includes(item.key)}
{@const status = deploymentStatus[item.key]}
{@const isDeployed = status?.status === 'deployed'}
{@const blockedReason =
!isSelectable && !isDeployed ? selectBlockedReason?.(item) : undefined}
<Row
isSelectable={isSelectable && !isDeployed}
selectDisabledReason={blockedReason}
selectOnRowClick={true}
alignWithSelectable={true}
disabled={blockedReason ? false : !isSelectable}
selected={isSelected && !isDeployed}
onSelect={() => handleSelect(item)}
path={item.kind !== 'resource' &&
item.kind !== 'variable' &&
item.kind !== 'resource_type'
? item.path
: ''}
marked={undefined}
kind={item.kind}
triggerKind={item.triggerKind}
canFavorite={false}
workspaceId=""
>
{#snippet customSummary()}
{#if itemSummary}
{@render itemSummary(item)}
{:else}
{item.path}
{/if}
{/snippet}
{#snippet pathDisplay()}
{#if itemPath}
{@render itemPath(item)}
{:else}
{item.kind !== 'resource' &&
item.kind !== 'variable' &&
item.kind !== 'resource_type'
? item.path
: ''}
{/if}
{/snippet}
{#snippet actions()}
{#if itemActions}
{@render itemActions(item)}
{/if}
<!-- Deployment status always shown -->
{#if status}
{#if status.status === 'loading'}
<Loader2 class="animate-spin" />
{:else if status.status === 'deployed'}
<Badge color="green">Deployed</Badge>
{:else if status.status === 'failed'}
<div class="inline-flex gap-1">
<Badge color="red">Failed</Badge>
<Tooltip>{status.error}</Tooltip>
</div>
{/if}
{/if}
{/snippet}
</Row>
{/each}
</div>
</div>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-hint text-xs">{emptyMessage}</div>
</div>
{/if}
<!-- Footer -->
{#if footer}
<div class="pt-4">
{@render footer()}
</div>
{/if}
</div>