mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 00:00:33 +00:00
feat: wire UserDraft.save through DbSyncer + conflict modal
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Surfaces UserDraft sync conflicts one at a time. Mounted once near the
|
||||
* top of the tree (root layout) so any UserDraft.save call can enqueue
|
||||
* a conflict via `UserDraftConflictStore.enqueue` and have it shown to
|
||||
* the user without coordinating with a route component.
|
||||
*/
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { AlertTriangle, CornerDownLeft } from 'lucide-svelte'
|
||||
import { UserDraftConflictStore } from '$lib/userDraftConflictStore.svelte'
|
||||
import { UserDraftDbSyncer, syncDrafts } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
const conflict = $derived(UserDraftConflictStore.current)
|
||||
const open = $derived(conflict !== undefined)
|
||||
|
||||
const lastSyncDate = $derived(UserDraftDbSyncer.getLastSync())
|
||||
|
||||
let busy = $state(false)
|
||||
|
||||
async function overwriteServer() {
|
||||
if (!conflict) return
|
||||
busy = true
|
||||
try {
|
||||
await syncDrafts({
|
||||
workspace: conflict.workspace,
|
||||
user: conflict.user,
|
||||
drafts: [
|
||||
{
|
||||
itemKind: conflict.itemKind,
|
||||
path: conflict.rejected.path,
|
||||
value: conflict.rejected.incoming_value
|
||||
}
|
||||
],
|
||||
force: true
|
||||
})
|
||||
UserDraftConflictStore.dismiss()
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not overwrite server draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadServer() {
|
||||
if (!conflict) return
|
||||
// `_skipSync` so the write doesn't immediately bounce back to the
|
||||
// server (which already has this value).
|
||||
UserDraft.save(conflict.itemKind, conflict.rejected.path, conflict.rejected.server_value, {
|
||||
workspace: conflict.workspace,
|
||||
_skipSync: true
|
||||
})
|
||||
UserDraftConflictStore.dismiss()
|
||||
}
|
||||
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open && conflict}
|
||||
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
|
||||
'ease-out duration-300 opacity-100'
|
||||
)}
|
||||
></div>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class="relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 ease-out duration-300 opacity-100 translate-y-0 sm:scale-100"
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50"
|
||||
>
|
||||
<AlertTriangle class="text-amber-600 dark:text-amber-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">
|
||||
Your draft for <code>{conflict.rejected.path}</code> is out of date
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-secondary">
|
||||
Another session saved a newer draft for this {conflict.itemKind} since this tab last
|
||||
synced.
|
||||
</p>
|
||||
<dl
|
||||
class="mt-3 text-xs text-tertiary grid grid-cols-[max-content_1fr] gap-x-2 gap-y-1"
|
||||
>
|
||||
<dt>Last sync from this tab:</dt>
|
||||
<dd>
|
||||
{lastSyncDate ? new Date(lastSyncDate).toLocaleString() : 'never'}
|
||||
</dd>
|
||||
<dt>Server draft saved at:</dt>
|
||||
<dd>{new Date(conflict.rejected.server_created_at).toLocaleString()}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 mt-4 flex-wrap">
|
||||
<Button
|
||||
disabled={busy}
|
||||
on:click={loadServer}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Load server draft
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
on:click={overwriteServer}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
Overwrite server draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { workspaceStore } from './stores'
|
||||
import { userStore, workspaceStore } from './stores'
|
||||
import { useLocalStorageValue } from './svelte5Utils.svelte'
|
||||
import { UserDraftDbSyncer, isSyncableKind } from './userDraftDbSyncer.svelte'
|
||||
import { UserDraftConflictStore } from './userDraftConflictStore.svelte'
|
||||
|
||||
export const USER_DRAFT_ITEM_KINDS = [
|
||||
'script',
|
||||
@@ -348,8 +350,49 @@ export function localDraftDiffers<V>(
|
||||
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a draft to localStorage and (when the kind has a backend `draft`
|
||||
* row) push it through the DbSyncer. Internal `_skipSync` opts pass-through
|
||||
* lets the syncer write missed drafts back to LS without re-syncing them.
|
||||
*/
|
||||
function pushToSyncer(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: unknown,
|
||||
workspace: string
|
||||
): void {
|
||||
if (!isSyncableKind(itemKind)) return
|
||||
const user = get(userStore)?.username
|
||||
if (!user) return
|
||||
UserDraftDbSyncer.pushDrafts({
|
||||
workspace,
|
||||
user,
|
||||
drafts: [{ itemKind, path, value }],
|
||||
onMissedDrafts: (drafts) => {
|
||||
for (const d of drafts) {
|
||||
UserDraft.save(d.typ as UserDraftItemKind, d.path, d.value, {
|
||||
workspace,
|
||||
_skipSync: true
|
||||
})
|
||||
}
|
||||
},
|
||||
onDraftsRejected: (rejected) => {
|
||||
UserDraftConflictStore.enqueue(
|
||||
rejected.map((r) => ({
|
||||
workspace,
|
||||
user,
|
||||
itemKind: r.typ,
|
||||
rejected: r
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type SaveOptions = UserDraftOptions & { _skipSync?: boolean }
|
||||
|
||||
export const UserDraft = {
|
||||
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
|
||||
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: SaveOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
@@ -361,18 +404,21 @@ export const UserDraft = {
|
||||
const meta = extractMeta(current)
|
||||
entry.state.setWithoutPersist(wrap(value, meta))
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
return
|
||||
} else {
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
// signal survives a write while the editor is closed.
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
try {
|
||||
localStorage.setItem(
|
||||
localStorageKey(ws, itemKind, path),
|
||||
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('UserDraft.save: localStorage write failed', e)
|
||||
}
|
||||
}
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
// signal survives a write while the editor is closed.
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
try {
|
||||
localStorage.setItem(
|
||||
localStorageKey(ws, itemKind, path),
|
||||
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('UserDraft.save: localStorage write failed', e)
|
||||
if (!opts?._skipSync) {
|
||||
pushToSyncer(itemKind, path, value, ws)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -729,6 +775,22 @@ function acquireEntry(
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
// Mirror live-handle writes (e.g. `handle.draft = X` from an editor's
|
||||
// $effect) to the DbSyncer. Skip the first run — the initial value
|
||||
// came from localStorage or the default, not from a user mutation, so
|
||||
// echoing it back would create a redundant request on every editor
|
||||
// mount.
|
||||
let firstRun = true
|
||||
$effect(() => {
|
||||
const stored = stateRef!.val
|
||||
if (firstRun) {
|
||||
firstRun = false
|
||||
return
|
||||
}
|
||||
const value = stored === undefined ? undefined : (stored.value as unknown)
|
||||
if (value === undefined) return
|
||||
pushToSyncer(itemKind, path, value, workspace)
|
||||
})
|
||||
})
|
||||
if (stateRef) {
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Module-level queue of UserDraft sync conflicts (drafts rejected by the
|
||||
* server because a newer version was saved since the client's last sync).
|
||||
*
|
||||
* Components anywhere in the app can call `enqueueConflicts()` to surface a
|
||||
* conflict; the single `UserDraftConflictModal` mounted in the root layout
|
||||
* consumes them one at a time so the user can decide per-conflict.
|
||||
*/
|
||||
import type { RejectedDraft, SyncableItemKind } from './userDraftDbSyncer.svelte'
|
||||
|
||||
export type ConflictEntry = {
|
||||
workspace: string
|
||||
user: string
|
||||
itemKind: SyncableItemKind
|
||||
rejected: RejectedDraft
|
||||
}
|
||||
|
||||
let pending = $state<ConflictEntry[]>([])
|
||||
|
||||
export const UserDraftConflictStore = {
|
||||
get current(): ConflictEntry | undefined {
|
||||
return pending[0]
|
||||
},
|
||||
get hasAny(): boolean {
|
||||
return pending.length > 0
|
||||
},
|
||||
enqueue(entries: ConflictEntry[]): void {
|
||||
if (entries.length === 0) return
|
||||
pending.push(...entries)
|
||||
},
|
||||
dismiss(): void {
|
||||
pending.shift()
|
||||
},
|
||||
clear(): void {
|
||||
pending.length = 0
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
|
||||
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
|
||||
import ForkConflictModal from '$lib/components/ForkConflictModal.svelte'
|
||||
import UserDraftConflictModal from '$lib/components/common/confirmationModal/UserDraftConflictModal.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
isPremiumStore,
|
||||
@@ -99,6 +101,11 @@
|
||||
goto('/user/login')
|
||||
}
|
||||
|
||||
// Bind UserDraftDbSyncer's lastSync cell to this layout's Svelte scope.
|
||||
// Without this it still works via raw localStorage reads, but the value
|
||||
// isn't reactive across tabs.
|
||||
UserDraftDbSyncer.mount()
|
||||
|
||||
function onQueryChangeUserSettings() {
|
||||
if (userSettings && page.url.hash.startsWith(USER_SETTINGS_HASH)) {
|
||||
const mcpMode = page.url.hash.includes('-mcp')
|
||||
@@ -856,6 +863,8 @@
|
||||
|
||||
<ForkConflictModal />
|
||||
|
||||
<UserDraftConflictModal />
|
||||
|
||||
<Modal2
|
||||
title="Forking {$workspaceStore}"
|
||||
target="#content"
|
||||
|
||||
Reference in New Issue
Block a user