mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 16:15:51 +00:00
* 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>
201 lines
5.6 KiB
Svelte
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>
|