mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 16:03:29 +00:00
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.
475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
|
|
// Capture onDestroy callbacks so we can simulate component teardown without
|
|
// a real component context.
|
|
const onDestroyCallbacks: Array<() => void> = []
|
|
|
|
vi.mock('svelte', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>
|
|
return {
|
|
...actual,
|
|
onDestroy: (fn: () => void) => {
|
|
onDestroyCallbacks.push(fn)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Imported AFTER vi.mock so the module sees the mocked onDestroy.
|
|
const { UserDraft, __resetUserDraftForTesting } = await import('./userDraft.svelte')
|
|
const { workspaceStore } = await import('./stores')
|
|
|
|
function flushDestroyCallbacks(): void {
|
|
const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length)
|
|
for (const cb of callbacks) cb()
|
|
}
|
|
|
|
// Helper: localStorage payloads are always wrapped as { value: <draft> } so
|
|
// future metadata fields can be added without breaking existing entries.
|
|
function wrapped<V>(value: V): string {
|
|
return JSON.stringify({ value })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
__resetUserDraftForTesting()
|
|
onDestroyCallbacks.length = 0
|
|
localStorage.clear()
|
|
workspaceStore.set('test_ws')
|
|
})
|
|
|
|
describe('UserDraft.save / get / remove (no observers)', () => {
|
|
it('save writes a wrapped { value } payload under the workspace-scoped key', () => {
|
|
UserDraft.save('flow', 'u/me/myflow', { hello: 'world' })
|
|
|
|
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/myflow')
|
|
expect(raw).toBe(wrapped({ hello: 'world' }))
|
|
})
|
|
|
|
it('get reads from a wrapped localStorage payload when no observer is registered', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/script/u/me/script1', wrapped('code'))
|
|
|
|
expect(UserDraft.get('script', 'u/me/script1')).toBe('code')
|
|
})
|
|
|
|
it('get returns undefined when nothing is stored', () => {
|
|
expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined()
|
|
})
|
|
|
|
it('get returns undefined when the stored payload is malformed', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json')
|
|
expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined()
|
|
})
|
|
|
|
it('get returns undefined when the stored payload is unwrapped (pre-migration entry)', () => {
|
|
// Drafts written before the wrapping was introduced look like the raw
|
|
// value rather than { value: ... }. They must be ignored rather than
|
|
// surface as undefined-shaped drafts.
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/raw', JSON.stringify({ hello: 'world' }))
|
|
expect(UserDraft.get('flow', 'u/me/raw')).toBeUndefined()
|
|
expect(UserDraft.has('flow', 'u/me/raw')).toBe(false)
|
|
})
|
|
|
|
it('remove clears the localStorage entry', () => {
|
|
UserDraft.save('app', 'u/me/app1', { grid: [] })
|
|
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull()
|
|
|
|
UserDraft.remove('app', 'u/me/app1')
|
|
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull()
|
|
})
|
|
|
|
it('uses the workspace from opts when provided', () => {
|
|
UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' })
|
|
|
|
expect(localStorage.getItem('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1))
|
|
// Default workspace key must remain empty.
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull()
|
|
})
|
|
|
|
it('supports trigger kinds as item kinds', () => {
|
|
UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] })
|
|
|
|
const raw = localStorage.getItem('userdraft/w/test_ws/trigger_kafka/u/me/topic1')
|
|
expect(raw).toBe(wrapped({ brokers: ['localhost:9092'] }))
|
|
})
|
|
|
|
it('throws when neither opts.workspace nor $workspaceStore is set', () => {
|
|
workspaceStore.set(undefined)
|
|
expect(() => UserDraft.save('flow', 'u/me/x', 1)).toThrow(/no workspace/)
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.use() — observer sync', () => {
|
|
it('loads the existing localStorage value on first use', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded'))
|
|
|
|
const handle = UserDraft.use<string>('flow', 'u/me/loaded')
|
|
expect(handle.draft).toBe('preloaded')
|
|
})
|
|
|
|
it('two handles on the same key share the same underlying state', () => {
|
|
const a = UserDraft.use<number>('flow', 'u/me/shared')
|
|
const b = UserDraft.use<number>('flow', 'u/me/shared')
|
|
|
|
a.draft = 42
|
|
expect(b.draft).toBe(42)
|
|
|
|
b.draft = 99
|
|
expect(a.draft).toBe(99)
|
|
})
|
|
|
|
it('save() propagates to live use() handles (in-memory)', () => {
|
|
const handle = UserDraft.use<number>('flow', 'u/me/observed')
|
|
expect(handle.draft).toBeUndefined()
|
|
|
|
// First write through a live entry is treated as the "initial value"
|
|
// (saveInitialValue=false) and is NOT persisted — observers still see it.
|
|
UserDraft.save('flow', 'u/me/observed', 7)
|
|
expect(handle.draft).toBe(7)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBeNull()
|
|
|
|
// Subsequent writes persist.
|
|
UserDraft.save('flow', 'u/me/observed', 9)
|
|
expect(handle.draft).toBe(9)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
|
|
})
|
|
|
|
it('remove() clears localStorage without touching the in-memory handle', () => {
|
|
// Seed localStorage so the live handle initialises from it.
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1))
|
|
const handle = UserDraft.use<number>('flow', 'u/me/removed')
|
|
expect(handle.draft).toBe(1)
|
|
|
|
UserDraft.remove('flow', 'u/me/removed')
|
|
// Live handle keeps its current value — remove() only wipes the
|
|
// persisted side. This is what lets callers run UserDraft.remove
|
|
// during navigation without flickering the editor UI.
|
|
expect(handle.draft).toBe(1)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull()
|
|
})
|
|
|
|
it('the second write through the handle setter persists to localStorage', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/setter')
|
|
|
|
// First write is the baseline — not persisted.
|
|
handle.draft = 'initial'
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBeNull()
|
|
|
|
// Second (and onwards) persists.
|
|
handle.draft = 'persisted'
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted'))
|
|
})
|
|
|
|
it('setting handle.draft = undefined after edits removes the localStorage entry', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/clear')
|
|
handle.draft = 'initial' // baseline, not persisted
|
|
handle.draft = 'edited' // persisted
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull()
|
|
|
|
handle.draft = undefined
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull()
|
|
expect(handle.draft).toBeUndefined()
|
|
})
|
|
|
|
it('two handles in different workspaces are isolated', () => {
|
|
const a = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_a' })
|
|
const b = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_b' })
|
|
|
|
a.draft = 1
|
|
b.draft = 2
|
|
|
|
expect(a.draft).toBe(1)
|
|
expect(b.draft).toBe(2)
|
|
})
|
|
|
|
it('save() falls back to localStorage when no handle is registered', () => {
|
|
UserDraft.save('flow', 'u/me/noobs', 'fallback')
|
|
// First use() afterwards loads the persisted value.
|
|
const handle = UserDraft.use<string>('flow', 'u/me/noobs')
|
|
expect(handle.draft).toBe('fallback')
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.use() — defaultValue', () => {
|
|
it('returns defaultValue when localStorage has no entry', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/withdefault', { defaultValue: 'fallback' })
|
|
|
|
expect(handle.draft).toBe('fallback')
|
|
})
|
|
|
|
it('does not persist the defaultValue on first read', () => {
|
|
UserDraft.use<string>('flow', 'u/me/lazyDefault', { defaultValue: 'fallback' })
|
|
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull()
|
|
})
|
|
|
|
it('localStorage value wins over defaultValue', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/overridden', wrapped('persisted'))
|
|
|
|
const handle = UserDraft.use<string>('flow', 'u/me/overridden', {
|
|
defaultValue: 'fallback'
|
|
})
|
|
|
|
expect(handle.draft).toBe('persisted')
|
|
})
|
|
|
|
it('second write through the setter persists even though defaultValue was set', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/writeDefault', {
|
|
defaultValue: 'fallback'
|
|
})
|
|
|
|
// First write is the initial-value baseline.
|
|
handle.draft = 'initial'
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBeNull()
|
|
|
|
handle.draft = 'modified'
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(
|
|
wrapped('modified')
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('UserDraft — empty path (new-item, in-memory only)', () => {
|
|
it('use() with empty path uses defaultValue and does not persist', () => {
|
|
const handle = UserDraft.use<number>('flow', '', { defaultValue: 0 })
|
|
|
|
handle.draft = 99
|
|
|
|
expect(handle.draft).toBe(99)
|
|
// No localStorage key with empty path should ever be written.
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
|
})
|
|
|
|
it('two handles with empty path share in-memory state per workspace', () => {
|
|
const a = UserDraft.use<number>('flow', '')
|
|
const b = UserDraft.use<number>('flow', '')
|
|
|
|
a.draft = 1
|
|
expect(b.draft).toBe(1)
|
|
|
|
b.draft = 2
|
|
expect(a.draft).toBe(2)
|
|
})
|
|
|
|
it('save() with empty path is a no-op against localStorage but updates live handles', () => {
|
|
const handle = UserDraft.use<number>('flow', '')
|
|
|
|
UserDraft.save('flow', '', 5)
|
|
expect(handle.draft).toBe(5)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
|
})
|
|
|
|
it('get() with empty path returns the in-memory value when a handle is live, else undefined', () => {
|
|
expect(UserDraft.get('flow', '')).toBeUndefined()
|
|
|
|
const handle = UserDraft.use<number>('flow', '')
|
|
handle.draft = 11
|
|
expect(UserDraft.get('flow', '')).toBe(11)
|
|
})
|
|
|
|
it('remove() with empty path is a no-op (no localStorage to clear, in-memory untouched)', () => {
|
|
const handle = UserDraft.use<number>('flow', '')
|
|
handle.draft = 1
|
|
|
|
UserDraft.remove('flow', '')
|
|
// Empty-path entries never touched localStorage and remove() no longer
|
|
// resets the in-memory state, so the handle keeps its value.
|
|
expect(handle.draft).toBe(1)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('UserDraft — rev metadata for staleness checks', () => {
|
|
it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/atomic')
|
|
|
|
// Single atomic write — under saveInitialValue=false this counts as the
|
|
// initial baseline and shouldn't hit localStorage yet.
|
|
handle.setDraftAndMeta('backendValue', {
|
|
remoteRev: 42,
|
|
remoteDraftRev: '2026-01-01T00:00:00Z'
|
|
})
|
|
expect(handle.draft).toBe('backendValue')
|
|
expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' })
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull()
|
|
|
|
// A subsequent user edit persists *with* the rev metadata.
|
|
handle.draft = 'userEdit'
|
|
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')
|
|
expect(raw).toBe(
|
|
JSON.stringify({
|
|
value: 'userEdit',
|
|
remoteRev: 42,
|
|
remoteDraftRev: '2026-01-01T00:00:00Z'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('setMeta updates only the rev fields, preserving the value', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/setmeta')
|
|
handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted
|
|
handle.draft = 'edited' // persisted with remoteRev: 1
|
|
|
|
handle.setMeta({ remoteRev: 2 })
|
|
expect(handle.draft).toBe('edited')
|
|
expect(handle.meta).toEqual({ remoteRev: 2 })
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setmeta')).toBe(
|
|
JSON.stringify({ value: 'edited', remoteRev: 2 })
|
|
)
|
|
})
|
|
|
|
it('handle.draft setter preserves rev metadata across user edits', () => {
|
|
const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve')
|
|
handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' })
|
|
handle.draft = { count: 1 } // first edit, persisted
|
|
handle.draft = { count: 2 } // another edit
|
|
|
|
expect(handle.meta).toEqual({ remoteRev: 'v1' })
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/preserve')).toBe(
|
|
JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' })
|
|
)
|
|
})
|
|
|
|
it('UserDraft.getMeta reads from localStorage when no live handle exists', () => {
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/getmeta',
|
|
JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' })
|
|
)
|
|
expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({
|
|
remoteRev: 7,
|
|
remoteDraftRev: '2026-01-02'
|
|
})
|
|
})
|
|
|
|
it('UserDraft.getMeta returns empty object when there is no entry', () => {
|
|
expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({})
|
|
})
|
|
|
|
it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => {
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/savepreserve',
|
|
JSON.stringify({ value: 'old', remoteRev: 5 })
|
|
)
|
|
UserDraft.save('flow', 'u/me/savepreserve', 'new')
|
|
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe(
|
|
JSON.stringify({ value: 'new', remoteRev: 5 })
|
|
)
|
|
})
|
|
|
|
it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => {
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/legacy',
|
|
JSON.stringify({ value: 'no-rev' })
|
|
)
|
|
const handle = UserDraft.use<string>('flow', 'u/me/legacy')
|
|
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', () => {
|
|
it('destroys the entry when the last handle is released', () => {
|
|
// First handle acquires the entry.
|
|
const a = UserDraft.use<number>('flow', 'u/me/ref')
|
|
a.draft = 1 // baseline write — not persisted
|
|
|
|
// Second handle increments the count.
|
|
const b = UserDraft.use<number>('flow', 'u/me/ref')
|
|
expect(b.draft).toBe(1)
|
|
|
|
// onDestroy for both handles got registered.
|
|
expect(onDestroyCallbacks.length).toBe(2)
|
|
|
|
// Releasing one handle keeps the entry alive — save() still updates handle a.
|
|
const firstCb = onDestroyCallbacks.shift()!
|
|
firstCb()
|
|
|
|
UserDraft.save('flow', 'u/me/ref', 2)
|
|
expect(a.draft).toBe(2)
|
|
// Now persisted (second write after the baseline).
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
|
|
|
|
// Releasing the second handle drops the entry; subsequent save()
|
|
// must go straight to localStorage rather than mutating in-memory
|
|
// state (which no longer exists).
|
|
const secondCb = onDestroyCallbacks.shift()!
|
|
secondCb()
|
|
|
|
UserDraft.save('flow', 'u/me/ref', 3)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3))
|
|
})
|
|
|
|
it('a fresh use() after cleanup re-reads the latest persisted value', () => {
|
|
const a = UserDraft.use<string>('flow', 'u/me/cycle')
|
|
a.draft = 'initial' // baseline — not persisted
|
|
a.draft = 'edited' // persisted
|
|
flushDestroyCallbacks()
|
|
|
|
// After all handles release, a brand-new use() must pick up the
|
|
// value persisted to localStorage from the previous round.
|
|
const b = UserDraft.use<string>('flow', 'u/me/cycle')
|
|
expect(b.draft).toBe('edited')
|
|
})
|
|
})
|