mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
feat(frontend): persist empty-path drafts across reloads
Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit)
because we worried about collisions between concurrent /add tabs. The user
asked for the trade-off to flip: a /flows/add or /scripts/add reload should
restore the user's work, while explicitly clicking "+ Flow / + Script / …"
should always open a clean editor.
- Drop `isLocalOnly` from UserDraft so empty-path entries persist under
`userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind
refcounting and saveInitialValue=false behavior already handle them
correctly — the change is just lifting the bypass.
- Each /add page now calls `UserDraft.remove(kind, '')` synchronously
when `?nodraft=true` is present in the URL, before the handle is
created.
- The two "+" entry points that lacked the `?nodraft=true` flag
(CreateActionsScript's plain `<a href>` and CreateActionsFlow's
YAML/JSON import paths) now include it, so every fresh-start path goes
through the wipe.
- Tests updated: the "empty path (in-memory only)" block becomes
"empty path (persists across reloads)" and asserts the new behavior.
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
async function importRaw() {
|
||||
$importFlowStore =
|
||||
importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '')
|
||||
await goto('/flows/add')
|
||||
await goto('/flows/add?nodraft=true')
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
const parsed =
|
||||
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '')
|
||||
$importScriptStore = parsed
|
||||
await goto(`${base}/scripts/add?import=true`)
|
||||
await goto(`${base}/scripts/add?import=true&nodraft=true`)
|
||||
wacDrawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
unifiedSize="lg"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
href="{base}/scripts/add"
|
||||
href="{base}/scripts/add?nodraft=true"
|
||||
endIcon={{ icon: Code2 }}
|
||||
>
|
||||
Script
|
||||
|
||||
@@ -91,16 +91,6 @@ function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
return ws
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when this (workspace, itemKind, path) should never touch
|
||||
* localStorage. An empty path means "new item, not yet on disk"; we keep the
|
||||
* draft in-memory so multiple components on the same /add page still share
|
||||
* state, but we don't persist it to avoid colliding new-item drafts.
|
||||
*/
|
||||
function isLocalOnly(path: string): boolean {
|
||||
return path === ''
|
||||
}
|
||||
|
||||
function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const out: StoredDraft<V> = { value }
|
||||
@@ -186,18 +176,6 @@ function readPersisted<V>(key: string): StoredDraft<V> | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function createInMemoryState<V>(defaultValue: StoredDraft<V> | undefined): DraftState<V> {
|
||||
let s = $state<StoredDraft<V> | undefined>(defaultValue)
|
||||
return {
|
||||
get val(): StoredDraft<V> | undefined {
|
||||
return s
|
||||
},
|
||||
set val(newVal: StoredDraft<V> | undefined) {
|
||||
s = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
@@ -253,14 +231,12 @@ export const UserDraft = {
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Update the shared reactive state so all observers are notified.
|
||||
// For non-empty paths the underlying useLocalStorageValue setter
|
||||
// persists the wrapped value; for empty paths it stays in-memory.
|
||||
// Preserve any existing rev metadata on the entry.
|
||||
// The underlying useLocalStorageValue setter persists the wrapped
|
||||
// value. Preserve any existing rev metadata on the entry.
|
||||
const current = entry.state.val as StoredDraft<unknown> | undefined
|
||||
entry.state.val = wrap(value, extractMeta(current))
|
||||
return
|
||||
}
|
||||
if (isLocalOnly(path)) return
|
||||
// External save without a live handle: preserve any persisted meta
|
||||
// so the staleness signal isn't lost just because the editor wasn't
|
||||
// open while we wrote.
|
||||
@@ -286,7 +262,6 @@ export const UserDraft = {
|
||||
if (entry) {
|
||||
return unwrap(entry.state.val as StoredDraft<V> | undefined)
|
||||
}
|
||||
if (isLocalOnly(path)) return undefined
|
||||
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
|
||||
},
|
||||
|
||||
@@ -312,7 +287,6 @@ export const UserDraft = {
|
||||
if (current === undefined) return
|
||||
entry.state.val = wrap(current.value, meta)
|
||||
}
|
||||
if (isLocalOnly(path)) return
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
if (existing === undefined) return
|
||||
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
|
||||
@@ -327,22 +301,20 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (isLocalOnly(path)) return {}
|
||||
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a draft currently exists for (workspace, itemKind, path).
|
||||
* For non-empty paths this checks localStorage; for empty paths it
|
||||
* checks the in-memory entry. Useful for distinguishing "first visit"
|
||||
* from "returning visit with unsaved local changes".
|
||||
* Falls back to the persisted localStorage entry when no live handle is
|
||||
* registered. Useful for distinguishing "first visit" from "returning
|
||||
* visit with unsaved local changes".
|
||||
*/
|
||||
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return entry.state.val !== undefined
|
||||
if (isLocalOnly(path)) return false
|
||||
return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined
|
||||
},
|
||||
|
||||
@@ -366,17 +338,15 @@ export const UserDraft = {
|
||||
|
||||
let entry = entries.get(mk)
|
||||
if (!entry) {
|
||||
const state: DraftState<unknown> = isLocalOnly(path)
|
||||
? createInMemoryState<unknown>(wrappedDefault)
|
||||
: useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(ws, itemKind, path),
|
||||
wrappedDefault,
|
||||
undefined,
|
||||
// The first value to flow into the handle (e.g. a backend load
|
||||
// in the editor route) is the baseline — only persist when the
|
||||
// user actually changes it afterwards.
|
||||
{ saveInitialValue: false }
|
||||
)
|
||||
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(ws, itemKind, path),
|
||||
wrappedDefault,
|
||||
undefined,
|
||||
// The first value to flow into the handle (e.g. a backend load
|
||||
// in the editor route) is the baseline — only persist when the
|
||||
// user actually changes it afterwards.
|
||||
{ saveInitialValue: false }
|
||||
)
|
||||
entry = { count: 1, state }
|
||||
entries.set(mk, entry)
|
||||
} else {
|
||||
@@ -423,7 +393,7 @@ export const UserDraft = {
|
||||
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)) {
|
||||
if (opts?.force) {
|
||||
persistDirect(localStorageKey(ws, itemKind, path), current.value, meta)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -227,18 +227,22 @@ describe('UserDraft.use() — defaultValue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft — empty path (new-item, in-memory only)', () => {
|
||||
it('use() with empty path uses defaultValue and does not persist', () => {
|
||||
describe('UserDraft — empty path (new-item drafts persist across reloads)', () => {
|
||||
it('use() with empty path persists subsequent edits to localStorage', () => {
|
||||
const handle = UserDraft.use<number>('flow', '', { defaultValue: 0 })
|
||||
|
||||
// First write under saveInitialValue=false counts as the baseline and
|
||||
// is skipped — only the user's subsequent edits persist.
|
||||
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()
|
||||
handle.draft = 100
|
||||
// The "+ Flow / + Script / …" buttons are expected to call
|
||||
// `UserDraft.remove(kind, '')` to wipe before navigating; an
|
||||
// unguarded /add reload therefore restores the previous session.
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(100))
|
||||
})
|
||||
|
||||
it('two handles with empty path share in-memory state per workspace', () => {
|
||||
it('two handles with empty path share state per workspace', () => {
|
||||
const a = UserDraft.use<number>('flow', '')
|
||||
const b = UserDraft.use<number>('flow', '')
|
||||
|
||||
@@ -249,30 +253,19 @@ describe('UserDraft — empty path (new-item, in-memory only)', () => {
|
||||
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', '')
|
||||
|
||||
it('save() with empty path writes to localStorage when no handle is live', () => {
|
||||
UserDraft.save('flow', '', 5)
|
||||
expect(handle.draft).toBe(5)
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(5))
|
||||
})
|
||||
|
||||
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
|
||||
it('get() with empty path falls back to localStorage when no handle is live', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(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
|
||||
|
||||
it('remove() with empty path clears localStorage', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
|
||||
import { emptyApp } from '$lib/components/apps/editor/appUtils'
|
||||
import { tick } from 'svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
|
||||
// "+ App" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave so the child AppEditor's handle
|
||||
// opens on a clean slate. A plain reload of /apps/add (no nodraft)
|
||||
// instead restores the previous session via AppEditor's `UserDraft.use`.
|
||||
if (nodraft) UserDraft.remove('app', '')
|
||||
let appEditor: AppEditor | undefined = $state(undefined)
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
const templateId = page.url.searchParams.get('template_id')
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
|
||||
// "+ Raw App" / "+ App > Full code" buttons navigate with ?nodraft=true to
|
||||
// signal "start fresh". Wipe the persisted empty-path autosave before the
|
||||
// handle is created so the editor opens on the default template. A plain
|
||||
// reload of /apps_raw/add (no nodraft) instead restores the previous session.
|
||||
if (nodraft) UserDraft.remove('raw_app', '')
|
||||
|
||||
// Check in-memory store first, then sessionStorage (used when full page reload occurs)
|
||||
let importRaw = $importStore
|
||||
if ($importStore) {
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
|
||||
// "+ Flow" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave before the handle is created so
|
||||
// the editor opens on an empty flow. A plain reload of /flows/add (no
|
||||
// nodraft param) instead restores whatever the user was last working on.
|
||||
if (nodraft) UserDraft.remove('flow', '')
|
||||
|
||||
afterNavigate(() => {
|
||||
if (nodraft) {
|
||||
let url = new URL(page.url.href)
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
const collabLang = page.url.searchParams.get('lang') as ScriptLang | null
|
||||
const wacParam = page.url.searchParams.get('wac')
|
||||
const importParam = page.url.searchParams.get('import')
|
||||
const nodraft = page.url.searchParams.get('nodraft')
|
||||
|
||||
// "+ Script" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave before the handle is created so
|
||||
// the editor opens on a blank script. A plain reload of /scripts/add (no
|
||||
// nodraft param) instead restores the previous session.
|
||||
if (nodraft) UserDraft.remove('script', '')
|
||||
|
||||
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
|
||||
if (get(initialArgsStore)) $initialArgsStore = undefined
|
||||
|
||||
Reference in New Issue
Block a user