mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
feat(frontend): add defaultValue + empty-path handling to UserDraft
UserDraft.use() accepts an opts.defaultValue used when no localStorage entry exists yet. It is not persisted on first read — only an actual mutation writes through. Empty paths (new items) bypass localStorage entirely. The entry still lives in the in-memory Map so multiple components on the same /add page share state, but save/get/remove/use never read or write localStorage with an empty path. Once the item is saved and the route navigates to its new URL, a fresh use() on the non-empty path takes over. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6b7c1ea87a
commit
b56a189713
@@ -33,6 +33,15 @@ export type UserDraftOptions = {
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export type UserDraftUseOptions<V> = UserDraftOptions & {
|
||||
/**
|
||||
* Initial value used when localStorage holds no draft for this
|
||||
* (workspace, itemKind, path). It is *not* eagerly persisted — the first
|
||||
* actual mutation is what writes to localStorage.
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
type DraftState<V> = { val: V | undefined }
|
||||
|
||||
type DraftEntry = {
|
||||
@@ -52,6 +61,28 @@ 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 createInMemoryState<T>(defaultValue: T | undefined): DraftState<T> {
|
||||
let s = $state<T | undefined>(defaultValue)
|
||||
return {
|
||||
get val(): T | undefined {
|
||||
return s
|
||||
},
|
||||
set val(newVal: T | undefined) {
|
||||
s = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
@@ -72,9 +103,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; for empty paths the in-memory state stays in-memory.
|
||||
entry.state.val = value
|
||||
return
|
||||
}
|
||||
if (isLocalOnly(path)) return
|
||||
try {
|
||||
localStorage.setItem(localStorageKey(ws, itemKind, path), JSON.stringify(value))
|
||||
} catch (e) {
|
||||
@@ -93,6 +127,7 @@ export const UserDraft = {
|
||||
if (entry) {
|
||||
return entry.state.val as V | undefined
|
||||
}
|
||||
if (isLocalOnly(path)) return undefined
|
||||
try {
|
||||
const raw = localStorage.getItem(localStorageKey(ws, itemKind, path))
|
||||
if (raw == null || raw === 'undefined') return undefined
|
||||
@@ -112,6 +147,7 @@ export const UserDraft = {
|
||||
entry.state.val = undefined
|
||||
return
|
||||
}
|
||||
if (isLocalOnly(path)) return
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
} catch (e) {
|
||||
@@ -122,15 +158,18 @@ export const UserDraft = {
|
||||
use<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftOptions
|
||||
opts?: UserDraftUseOptions<V>
|
||||
): UserDraftHandle<V> {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const lk = localStorageKey(ws, itemKind, path)
|
||||
const defaultValue = opts?.defaultValue
|
||||
|
||||
let entry = entries.get(mk)
|
||||
if (!entry) {
|
||||
entry = { count: 1, state: useLocalStorageValue<unknown>(lk, undefined) }
|
||||
const state: DraftState<unknown> = isLocalOnly(path)
|
||||
? createInMemoryState<unknown>(defaultValue)
|
||||
: useLocalStorageValue<unknown>(localStorageKey(ws, itemKind, path), defaultValue)
|
||||
entry = { count: 1, state }
|
||||
entries.set(mk, entry)
|
||||
} else {
|
||||
entry.count++
|
||||
|
||||
@@ -170,6 +170,98 @@ describe('UserDraft.use() — observer sync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.use() — defaultValue', () => {
|
||||
it('returns defaultValue when localStorage has no entry', () => {
|
||||
const handle = UserDraft.use<{ value: string }>('flow', 'u/me/withdefault', {
|
||||
defaultValue: { value: 'fallback' }
|
||||
})
|
||||
|
||||
expect(handle.draft).toEqual({ value: 'fallback' })
|
||||
})
|
||||
|
||||
it('does not persist the defaultValue on first read', () => {
|
||||
UserDraft.use<{ value: string }>('flow', 'u/me/lazyDefault', {
|
||||
defaultValue: { value: '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',
|
||||
JSON.stringify({ value: 'persisted' })
|
||||
)
|
||||
|
||||
const handle = UserDraft.use<{ value: string }>('flow', 'u/me/overridden', {
|
||||
defaultValue: { value: 'fallback' }
|
||||
})
|
||||
|
||||
expect(handle.draft).toEqual({ value: 'persisted' })
|
||||
})
|
||||
|
||||
it('a write through the setter persists even though defaultValue was set', () => {
|
||||
const handle = UserDraft.use<{ value: string }>('flow', 'u/me/writeDefault', {
|
||||
defaultValue: { value: 'fallback' }
|
||||
})
|
||||
|
||||
handle.draft = { value: 'modified' }
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(
|
||||
JSON.stringify({ value: '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<{ value: number }>('flow', '', {
|
||||
defaultValue: { value: 0 }
|
||||
})
|
||||
|
||||
handle.draft = { value: 99 }
|
||||
|
||||
expect(handle.draft).toEqual({ value: 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<{ value: number }>('flow', '')
|
||||
const b = UserDraft.use<{ value: number }>('flow', '')
|
||||
|
||||
a.draft = { value: 1 }
|
||||
expect(b.draft).toEqual({ value: 1 })
|
||||
|
||||
b.draft = { value: 2 }
|
||||
expect(a.draft).toEqual({ value: 2 })
|
||||
})
|
||||
|
||||
it('save() with empty path is a no-op against localStorage but updates live handles', () => {
|
||||
const handle = UserDraft.use<{ value: number }>('flow', '')
|
||||
|
||||
UserDraft.save('flow', '', { value: 5 })
|
||||
expect(handle.draft).toEqual({ value: 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<{ value: number }>('flow', '')
|
||||
handle.draft = { value: 11 }
|
||||
expect(UserDraft.get('flow', '')).toEqual({ value: 11 })
|
||||
})
|
||||
|
||||
it('remove() with empty path is a no-op against localStorage but clears live handles', () => {
|
||||
const handle = UserDraft.use<{ value: number }>('flow', '')
|
||||
handle.draft = { value: 1 }
|
||||
|
||||
UserDraft.remove('flow', '')
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.use() — reference counting & cleanup', () => {
|
||||
it('destroys the entry when the last handle is released', () => {
|
||||
// First handle acquires the entry.
|
||||
|
||||
Reference in New Issue
Block a user