mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* feat(frontend): replace other-user draft "View JSON" with "View Diff" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): replace other-user draft "Fork" with in-place "Load" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(frontend): detect first overlay edit by value divergence, not a timer Replaces the 700ms arming timer (which leaked across sessions and silently swallowed sub-window edits) with a deterministic check: a blocked save opens the overwrite prompt only once the cell value diverges from the loaded value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): overlay leak on revisit, diff z-index, home-popover edit affordances - Clear a stale "editing another user's draft" overlay when its editor is reloaded without a fresh Load, so returning to the item edits our own draft. - Open View Diff above the others-drafts modal (close it first) instead of rendering the drawer behind it. - Add an Edit button to our own row in the home draft popover; use a pencil icon (not a download) for Load. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: admin "Migrate" action for legacy drafts (delete / assign to self) Adds an admin-gated `POST /drafts/migrate_legacy/{kind}/{path}` endpoint to resolve pre-migration workspace-level drafts (email NULL): delete the row, or move its value onto the admin's own row. Surfaces a "Migrate" button on legacy rows in the home-page draft popover and the in-editor others-drafts modal (workspace admins / superadmins only), opening a modal with Delete and Assign to self. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): close home draft popover before opening View Diff / Migrate The hover popover sits above the diff drawer and migrate modal (z-index), so it covered them. Close it first so they render on top. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): remount the flow builder on "Reset to draft" from an overlay FlowBuilder captures the flow at mount, so reloading the value alone left the foreign graph on screen — reset appeared to do nothing. Force a remount (renderEditor=false → loadFlow) like navigation does. Scripts (imperative setCode) and apps (redraw++) already remount, so only flows needed this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): refresh the home row after migrating a legacy draft invalidateAll() didn't refetch the home list (it loads items client-side), so the legacy badge entry lingered after delete / assign-to-self. Bubble an onMigrated callback up to the row's `change` event, reusing the same reload chain (Item → ItemsList loadScripts/Flows/Apps) as delete/archive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit * nit * fix(frontend): match app overlay baseline to the migrated value AppEditor migrateApp()s the app on mount, so the draft cell settles to the migrated value. The overlay used the raw loaded value as the divergence baseline, so a post-mount mirror write could trip "Overwrite your current draft?" before any edit. Migrate the baseline too (like the deployed-baseline and raw_app bundle do) so it matches the settled cell. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): address review on legacy-draft migrate + overlay - Legacy "Assign to self" now confirms before replacing an existing own draft (MigrateLegacyDraftModal gains an `ownDraftExists` step, threaded from the home badge and the in-editor others-drafts modal). - Gate overlay mode on a per-response `hasOwnDraft` instead of the sticky `loadedFromDraft`, so navigating to a no-own-draft item in the same editor route can't wrongly enter overlay. Fixed in all 4 editor routes. - Raw-app "View Diff" now projects the deployed app into the flat draft-bundle shape (via a shared `extractDataConfig`) instead of diffing `.value` against the bundle, so the drawer shows a real diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
6.7 KiB
Svelte
220 lines
6.7 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* On-demand modal (from the AutosaveIndicator or DraftBadge popover) listing
|
|
* other users' drafts at this path. The owner list rides the overlay/list
|
|
* payload; individual drafts are fetched lazily for View Diff / Load.
|
|
* Parent-controlled via `isOpen` — never auto-opens.
|
|
*/
|
|
import { DraftService, type UserDraftItemKind } from '$lib/gen'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { Users, Pencil, GitCompareArrows, Wrench } from 'lucide-svelte'
|
|
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
|
import Button from '$lib/components/common/button/Button.svelte'
|
|
import Tooltip from '$lib/components/Tooltip.svelte'
|
|
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
|
import MigrateLegacyDraftModal from './MigrateLegacyDraftModal.svelte'
|
|
import { fetchDeployedValueForDiff } from '$lib/components/otherUserDraftDiff'
|
|
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
|
import { displayDate } from '$lib/utils'
|
|
import { userStore } from '$lib/stores'
|
|
|
|
export type OtherDraftUser = { username?: string | null; draft_saved_at?: string }
|
|
|
|
type Props = {
|
|
workspace: string
|
|
itemKind: UserDraftItemKind
|
|
path: string
|
|
/** Owners from the overlay response (`username`, or `null` for the
|
|
* legacy row). The authed user is filtered out server-side. */
|
|
otherDraftsUsers: OtherDraftUser[]
|
|
/** No deployed row exists (never deployed): hides View Diff, since there's
|
|
* no deployed baseline to diff against. */
|
|
draftOnly?: boolean
|
|
/** We have our own draft at this path — "Migrate → Assign to self" of the
|
|
* legacy row would overwrite it, so it confirms first. */
|
|
hasOwnDraft?: boolean
|
|
/** Reload the editor in place so it picks up the staged "Load" — we're
|
|
* already on this item's edit route. */
|
|
onReload?: () => void | Promise<void>
|
|
/** Controlled visibility — bind from the parent. */
|
|
isOpen: boolean
|
|
}
|
|
|
|
let {
|
|
workspace,
|
|
itemKind,
|
|
path,
|
|
otherDraftsUsers,
|
|
draftOnly = false,
|
|
hasOwnDraft = false,
|
|
onReload,
|
|
isOpen = $bindable()
|
|
}: Props = $props()
|
|
let busyFor = $state<string | null>(null)
|
|
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
|
let migrateOpen = $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 ownerLabel(owner: OtherDraftUser): string {
|
|
return owner.username ?? 'Legacy draft'
|
|
}
|
|
|
|
function ownerKey(owner: OtherDraftUser): string {
|
|
return owner.username ?? '__legacy__'
|
|
}
|
|
|
|
async function fetchDraft(owner: OtherDraftUser): Promise<unknown> {
|
|
return (
|
|
await DraftService.getDraftForUser({
|
|
workspace,
|
|
kind: itemKind,
|
|
path,
|
|
username: owner.username ?? undefined
|
|
})
|
|
).value
|
|
}
|
|
|
|
async function viewDiff(owner: OtherDraftUser) {
|
|
busyFor = ownerKey(owner)
|
|
try {
|
|
const [draftValue, deployed] = await Promise.all([
|
|
fetchDraft(owner),
|
|
fetchDeployedValueForDiff(workspace, itemKind, path)
|
|
])
|
|
// Close this modal first — the DiffDrawer (z-index ~1100) renders below
|
|
// Modal2 (z-1110), so leaving it open would hide the drawer behind it.
|
|
isOpen = false
|
|
diffDrawer?.openDrawer()
|
|
diffDrawer?.setDiff({
|
|
mode: 'simple',
|
|
title: `${ownerLabel(owner)}'s draft vs deployed`,
|
|
original: deployed,
|
|
current: draftValue as any
|
|
})
|
|
} catch (e) {
|
|
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
|
} finally {
|
|
busyFor = null
|
|
}
|
|
}
|
|
|
|
async function load(owner: OtherDraftUser) {
|
|
busyFor = ownerKey(owner)
|
|
try {
|
|
const value = await fetchDraft(owner)
|
|
isOpen = false
|
|
// Already on this item's edit route — stage + reload in place. If we
|
|
// have our own draft, the loader enters overlay mode (no save until
|
|
// the user confirms overwriting it).
|
|
OtherUserDraftLoad.stage(workspace, itemKind, value, path, ownerLabel(owner), {
|
|
navigate: false
|
|
})
|
|
await onReload?.()
|
|
} catch (e) {
|
|
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
|
} finally {
|
|
busyFor = null
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Modal2
|
|
bind:isOpen
|
|
title="Other users are currently working on {path}"
|
|
fixedWidth="sm"
|
|
fixedHeight="sm"
|
|
>
|
|
<div class="flex flex-col w-full gap-4">
|
|
<div class="flex gap-3 items-start">
|
|
<Users size={20} class="text-blue-500 shrink-0 mt-0.5" />
|
|
<p class="text-sm text-secondary">
|
|
Their drafts are independent of yours. For advanced collaboration, consider using <a
|
|
target="_blank"
|
|
href="https://www.windmill.dev/docs/advanced/workspace_forks">workspace forks (EE)</a
|
|
>
|
|
</p>
|
|
</div>
|
|
|
|
<ul class="divide-y border-t border-b flex-1 overflow-y-auto">
|
|
{#each otherDraftsUsers as owner (ownerKey(owner))}
|
|
<li class="flex items-center gap-3 py-2">
|
|
<div class="flex-1 min-w-0 flex flex-col">
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="text-sm font-medium text-primary truncate"
|
|
class:italic={!owner.username}
|
|
>
|
|
{ownerLabel(owner)}
|
|
</span>
|
|
{#if !owner.username}
|
|
<Tooltip>
|
|
Pre-migration workspace-scoped draft (no owner). Saved before drafts became
|
|
per-user — kept around so you can recover the content, but no current user owns
|
|
it.
|
|
</Tooltip>
|
|
{/if}
|
|
</div>
|
|
{#if owner.draft_saved_at}
|
|
<span class="text-2xs text-hint truncate">
|
|
Last updated: {displayDate(owner.draft_saved_at)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{#if !draftOnly}
|
|
<Button
|
|
variant="default"
|
|
size="xs"
|
|
startIcon={{ icon: GitCompareArrows }}
|
|
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
|
|
loading={busyFor === ownerKey(owner)}
|
|
on:click={() => viewDiff(owner)}
|
|
>
|
|
View Diff
|
|
</Button>
|
|
{/if}
|
|
<Button
|
|
variant="default"
|
|
size="xs"
|
|
startIcon={{ icon: Pencil }}
|
|
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
|
|
loading={busyFor === ownerKey(owner)}
|
|
on:click={() => load(owner)}
|
|
>
|
|
Load
|
|
</Button>
|
|
{#if !owner.username && canMigrateLegacy}
|
|
<Button
|
|
variant="default"
|
|
size="xs"
|
|
startIcon={{ icon: Wrench }}
|
|
on:click={() => (migrateOpen = true)}
|
|
>
|
|
Migrate
|
|
</Button>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
<div class="flex justify-end">
|
|
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>Close</Button>
|
|
</div>
|
|
</div>
|
|
</Modal2>
|
|
|
|
<DiffDrawer bind:this={diffDrawer} isFlow={itemKind === 'flow'} />
|
|
|
|
<MigrateLegacyDraftModal
|
|
bind:isOpen={migrateOpen}
|
|
{workspace}
|
|
{itemKind}
|
|
{path}
|
|
ownDraftExists={hasOwnDraft}
|
|
onMigrated={async () => {
|
|
isOpen = false
|
|
await onReload?.()
|
|
}}
|
|
/>
|