mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 16:03:29 +00:00
feat(frontend): staleness modal for the script editor's local autosave
Replace the script editor's toast-based "Discard / Show diff" pattern
with a dedicated modal that surfaces *why* the local autosave is out
of date: a new DB draft on the server, or a new deployed version.
Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev
priority) and a `setMeta({ force: true })` mode so the "Keep current
draft" acknowledgement persists even when it happens to be the
entry's first state mutation — under `saveInitialValue: false` an
ack-only setMeta would otherwise be skipped and the modal would
re-fire on next mount.
The modal lives at LocalDraftStaleModal.svelte; the script editor
wires it as a template for the remaining editors. Other editors
(flows, apps, raw_apps, resources, variables, triggers) still use
the previous toast pattern and will be migrated in follow-up
commits.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { CornerDownLeft, RefreshCcw } from 'lucide-svelte'
|
||||
|
||||
type Cause = 'draft' | 'version'
|
||||
|
||||
let {
|
||||
open = false,
|
||||
cause = 'version',
|
||||
onLoadLatest,
|
||||
onKeepDraft
|
||||
}: {
|
||||
open?: boolean
|
||||
/** What changed on the remote since the local draft was created. */
|
||||
cause?: Cause
|
||||
onLoadLatest: () => void | Promise<void>
|
||||
onKeepDraft: () => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!open) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
onLoadLatest()
|
||||
break
|
||||
case 'Escape':
|
||||
onKeepDraft()
|
||||
break
|
||||
}
|
||||
}
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
|
||||
const title = $derived(
|
||||
cause === 'draft'
|
||||
? 'A newer draft was saved on the server'
|
||||
: 'A newer version was deployed on the server'
|
||||
)
|
||||
const body = $derived(
|
||||
cause === 'draft'
|
||||
? "Someone else (or another tab) pushed a draft for this item while you were editing locally. Your local autosave is now older than what's on the server."
|
||||
: "A new version was deployed for this item while you were editing locally. Your local autosave is now older than what's deployed."
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydowncapture={onKeyDown} />
|
||||
|
||||
{#if open}
|
||||
<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',
|
||||
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
|
||||
)}
|
||||
></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={classNames(
|
||||
'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',
|
||||
open
|
||||
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
||||
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
|
||||
)}
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
|
||||
>
|
||||
<RefreshCcw class="text-blue-700 dark:text-blue-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">{title}</h3>
|
||||
<p class="mt-2 text-sm text-secondary">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
<Button
|
||||
on:click={() => onLoadLatest()}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
<span class="min-w-20">Load latest version</span>
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => onKeepDraft()}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Keep current draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -113,6 +113,55 @@ function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the rev metadata recorded against the local draft to the current
|
||||
* backend revs. Returns the staleness cause, or `null` when the local draft
|
||||
* is still based on the latest backend state we know about.
|
||||
*
|
||||
* - Entries with no recorded meta (legacy entries written before this field
|
||||
* existed) report `null` — we can't tell if they're stale, and we'd rather
|
||||
* trust the local autosave than spam the user with false positives.
|
||||
* - DB-draft staleness wins over deployed-version staleness: a remote DB
|
||||
* draft is the more recent state to reconcile against.
|
||||
* - If a DB draft existed when the local autosave was created but now no
|
||||
* longer exists on the remote (someone discarded it), we report `version`
|
||||
* because the deployed version is now the canonical "latest saved".
|
||||
*/
|
||||
export type UserDraftStalenessCause = 'draft' | 'version'
|
||||
|
||||
export function checkStaleness(
|
||||
meta: UserDraftMeta,
|
||||
currentRev: string | number | undefined,
|
||||
currentDraftRev?: string | number | undefined
|
||||
): UserDraftStalenessCause | null {
|
||||
if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null
|
||||
if (meta.remoteDraftRev !== currentDraftRev) {
|
||||
return currentDraftRev !== undefined ? 'draft' : 'version'
|
||||
}
|
||||
if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously write to localStorage, bypassing the entry's setter. Used by
|
||||
* `setMeta({ force: true })` so a "Keep current draft" modal acknowledgement
|
||||
* persists even when it happens to be the first state mutation of the entry
|
||||
* (which `useLocalStorageValue`'s `saveInitialValue: false` contract would
|
||||
* otherwise skip).
|
||||
*/
|
||||
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
|
||||
try {
|
||||
const next = wrap(value, meta)
|
||||
if (next === undefined) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(next))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('UserDraft: localStorage write failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
@@ -172,8 +221,15 @@ export type UserDraftHandle<V> = {
|
||||
* Used after the "Keep current draft" modal action to ack the new remote
|
||||
* rev so the staleness modal doesn't fire again until the remote moves
|
||||
* further.
|
||||
*
|
||||
* Pass `{ force: true }` to also persist the new meta to localStorage
|
||||
* synchronously, bypassing `saveInitialValue: false`'s skip of the first
|
||||
* state mutation. Without `force`, an ack that happens to be the first
|
||||
* write on the entry would only update in-memory state and be lost on
|
||||
* next mount — re-triggering the staleness modal even after the user
|
||||
* just acknowledged it.
|
||||
*/
|
||||
setMeta(meta: UserDraftMeta): void
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
|
||||
}
|
||||
|
||||
export const UserDraft = {
|
||||
@@ -314,10 +370,13 @@ export const UserDraft = {
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void {
|
||||
sharedEntry.state.val = wrap(value, meta)
|
||||
},
|
||||
setMeta(meta: UserDraftMeta): void {
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void {
|
||||
const current = sharedEntry.state.val as StoredDraft<V> | undefined
|
||||
if (current === undefined) return
|
||||
sharedEntry.state.val = wrap(current.value, meta)
|
||||
if (opts?.force && !isLocalOnly(path)) {
|
||||
persistDirect(localStorageKey(ws, itemKind, path), current.value, meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +364,68 @@ describe('UserDraft — rev metadata for staleness checks', () => {
|
||||
expect(handle.draft).toBe('no-rev')
|
||||
expect(handle.meta).toEqual({})
|
||||
})
|
||||
|
||||
it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => {
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/forceack',
|
||||
JSON.stringify({ value: 'edited', remoteRev: 'v1' })
|
||||
)
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/forceack')
|
||||
|
||||
// Without force, this is the entry's first state mutation and gets
|
||||
// swallowed by saveInitialValue=false — localStorage would still
|
||||
// hold the old remoteRev.
|
||||
handle.setMeta({ remoteRev: 'v2' }, { force: true })
|
||||
|
||||
expect(handle.meta).toEqual({ remoteRev: 'v2' })
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/forceack')).toBe(
|
||||
JSON.stringify({ value: 'edited', remoteRev: 'v2' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkStaleness', () => {
|
||||
let checkStaleness: (
|
||||
meta: { remoteRev?: string | number; remoteDraftRev?: string | number },
|
||||
currentRev: string | number | undefined,
|
||||
currentDraftRev?: string | number | undefined
|
||||
) => 'draft' | 'version' | null
|
||||
|
||||
beforeEach(async () => {
|
||||
// Re-import to dodge ESM caching surprises across test files.
|
||||
;({ checkStaleness } = await import('./userDraft.svelte'))
|
||||
})
|
||||
|
||||
it('returns null for legacy entries with no recorded rev', () => {
|
||||
expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when meta matches current revs exactly', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull()
|
||||
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns "draft" when a newer DB draft was pushed on the remote', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft')
|
||||
})
|
||||
|
||||
it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft')
|
||||
})
|
||||
|
||||
it('returns "version" when the deployed rev moved and draft revs match', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version')
|
||||
})
|
||||
|
||||
it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe(
|
||||
'version'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers "draft" over "version" when both have changed', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.use() — reference counting & cleanup', () => {
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { get } from 'svelte/store'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
|
||||
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
|
||||
|
||||
@@ -41,6 +42,43 @@
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote (deployed or DB
|
||||
// draft) has moved on since the user's autosave was created.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: EditableScript; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function applyBaseline(baseline: EditableScript): void {
|
||||
initialPath = baseline.path
|
||||
scriptBuilder?.setDraftTriggers(baseline.draft_triggers)
|
||||
scriptBuilder?.setCode(baseline.content)
|
||||
if (baseline['primary_schedule']) {
|
||||
savedPrimarySchedule = baseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
}
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(baseline, revs)
|
||||
applyBaseline(baseline)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
scriptHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
/** Increments per `loadScript` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
@@ -65,9 +103,14 @@
|
||||
savedScript = structuredClone($state.snapshot(scriptWithDraft))
|
||||
|
||||
const localDraft = scriptHandle.draft
|
||||
const previousMeta = scriptHandle.meta
|
||||
const backendDraft = scriptWithDraft.draft
|
||||
? ({ ...scriptWithDraft.draft } as EditableScript)
|
||||
: undefined
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: scriptWithDraft.hash,
|
||||
remoteDraftRev: scriptWithDraft.draft_created_at
|
||||
}
|
||||
|
||||
// Compute the fully-baked initial value once so the assignment
|
||||
// below is a single write — otherwise post-load mutations like
|
||||
@@ -81,64 +124,52 @@
|
||||
}
|
||||
|
||||
if (localDraft != undefined) {
|
||||
// Local autosave wins; offer a diff against the latest saved
|
||||
// version (backend draft if any, otherwise deployed) so the
|
||||
// user can discard it.
|
||||
const reference = backendDraft ?? scriptWithDraft
|
||||
const referenceClean = cleanValueProperties(reference)
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
|
||||
// Local matches the saved version — silently drop it and use the saved one.
|
||||
scriptHandle.draft = bakedBaseline
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
} else {
|
||||
sendUserToast('Script loaded from local autosave', false, [
|
||||
{
|
||||
label: 'Discard local autosave',
|
||||
callback: () => {
|
||||
scriptHandle.draft = bakedBaseline
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: referenceClean,
|
||||
current: localClean,
|
||||
title: `${backendDraft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
|
||||
button: {
|
||||
text: 'Discard autosave',
|
||||
onClick: () => {
|
||||
scriptHandle.draft = bakedBaseline
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
// Remote moved on since the local autosave was written —
|
||||
// surface the choice via modal. The local draft stays on
|
||||
// screen until the user picks.
|
||||
pendingBaseline = { baseline: bakedBaseline, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else if (
|
||||
previousMeta.remoteRev === undefined &&
|
||||
previousMeta.remoteDraftRev === undefined
|
||||
) {
|
||||
// Legacy entry (no meta recorded) — backfill so future
|
||||
// loads can detect staleness even if the user doesn't edit.
|
||||
scriptHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
}
|
||||
} else if (backendDraft) {
|
||||
scriptHandle.draft = bakedBaseline
|
||||
if (scriptHandle.draft?.['primary_schedule']) {
|
||||
savedPrimarySchedule = scriptHandle.draft['primary_schedule']
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
if (bakedBaseline['primary_schedule']) {
|
||||
savedPrimarySchedule = bakedBaseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
|
||||
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
|
||||
|
||||
if (!scriptWithDraft.draft_only) {
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: scriptHandle.draft!.path
|
||||
path: bakedBaseline.path
|
||||
})
|
||||
UserDraft.remove('script', draftPath)
|
||||
goto(`/scripts/edit/${scriptHandle.draft!.path}`)
|
||||
goto(`/scripts/edit/${bakedBaseline.path}`)
|
||||
loadScript()
|
||||
}
|
||||
const deployed = cleanValueProperties(scriptWithDraft)
|
||||
const draft = cleanValueProperties(scriptHandle.draft)
|
||||
const draft = cleanValueProperties(bakedBaseline)
|
||||
sendUserToast('Script loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Discard draft reset to deployed version',
|
||||
@@ -160,7 +191,7 @@
|
||||
])
|
||||
}
|
||||
} else {
|
||||
scriptHandle.draft = bakedBaseline
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +245,12 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if scriptHandle.draft}
|
||||
<ScriptBuilder
|
||||
bind:this={scriptBuilder}
|
||||
|
||||
Reference in New Issue
Block a user