mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix(frontend): keep autosaving a reopened draft-only item
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
This commit is contained in:
co-authored by
Claude Opus 5
parent
7512783fde
commit
eb6e12f0d2
@@ -236,7 +236,12 @@
|
||||
!!openedWith[selected] &&
|
||||
!draftValuesEqual({ ...current, path: '' }, { ...openedWith[selected], path: '' }),
|
||||
value: () => (current ? ($state.snapshot(current) as ResourceState) : undefined),
|
||||
pathIsFree: (p) => (selected ? resourcePathIsFree(selected, p) : Promise.resolve(false))
|
||||
pathIsFree: (p) => (selected ? resourcePathIsFree(selected, p) : Promise.resolve(false)),
|
||||
onAbandonKey: (ws, p) => {
|
||||
// The handle is pinned to the path this editor opened; once the draft
|
||||
// has moved off it, its next write would recreate the row it left.
|
||||
if (p === initialPath) UserDraft.stopSync('resource', p, { workspace: ws })
|
||||
}
|
||||
})
|
||||
|
||||
// New-resource bootstrap: seed empty state per workspace (edit mode
|
||||
@@ -308,13 +313,10 @@
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
openedWith[ws] = structuredClone(s)
|
||||
existedInitially[ws] = !noDeployed
|
||||
if (noDeployed) {
|
||||
// The helper owns this draft's key from here (see `draftOnly`);
|
||||
// leaving the handle syncing too would write the same content back
|
||||
// under the path this editor opened, stranding the row on a rename.
|
||||
UserDraft.stopSync('resource', initialPath, { workspace: ws })
|
||||
newDraftSync.adopt(ws, initialPath, structuredClone(s))
|
||||
}
|
||||
// The helper owns this draft's key from here (see `draftOnly`): the
|
||||
// handle keeps autosaving it while the path is unchanged, and hands
|
||||
// the key over the moment a rename moves it.
|
||||
if (noDeployed) newDraftSync.adopt(ws, initialPath, structuredClone(s))
|
||||
perWsUser[ws] = user
|
||||
// Keep resource_type in sync for the base workspace (controls the schema)
|
||||
if (ws === effectiveWorkspace) {
|
||||
|
||||
@@ -148,7 +148,12 @@
|
||||
!!openedWith[selected] &&
|
||||
!draftValuesEqual({ ...current, path: '' }, { ...openedWith[selected], path: '' }),
|
||||
value: () => (current ? ($state.snapshot(current) as VariableState) : undefined),
|
||||
pathIsFree: (p) => (selected ? variablePathIsFree(selected, p) : Promise.resolve(false))
|
||||
pathIsFree: (p) => (selected ? variablePathIsFree(selected, p) : Promise.resolve(false)),
|
||||
onAbandonKey: (ws, p) => {
|
||||
// The handle is pinned to the path this editor opened; once the draft
|
||||
// has moved off it, its next write would recreate the row it left.
|
||||
if (p === editPath) UserDraft.stopSync('variable', p, { workspace: ws })
|
||||
}
|
||||
})
|
||||
|
||||
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
|
||||
@@ -240,13 +245,10 @@
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
openedWith[ws] = structuredClone(s)
|
||||
existedInitially[ws] = !noDeployed
|
||||
if (noDeployed) {
|
||||
// The helper owns this draft's key from here (see `draftOnly`);
|
||||
// leaving the handle syncing too would write the same content back
|
||||
// under the path this editor opened, stranding the row on a rename.
|
||||
UserDraft.stopSync('variable', p, { workspace: ws })
|
||||
newDraftSync.adopt(ws, p, structuredClone(s))
|
||||
}
|
||||
// The helper owns this draft's key from here (see `draftOnly`): the
|
||||
// handle keeps autosaving it while the path is unchanged, and hands
|
||||
// the key over the moment a rename moves it.
|
||||
if (noDeployed) newDraftSync.adopt(ws, p, structuredClone(s))
|
||||
extraPerms[ws] = v.extra_perms ?? {}
|
||||
perWsUser[ws] = user
|
||||
})
|
||||
|
||||
@@ -220,6 +220,41 @@ describe('useNewItemDraftSync', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
/** An editor's own autosave handle stays pinned to the path it opened, so
|
||||
* once the draft moves the helper has to hand that key back for suspension —
|
||||
* otherwise the handle's next write recreates the row just deleted. */
|
||||
it('reports the key it abandons so a pinned handle can be suspended', async () => {
|
||||
const form = $state({ path: 'u/me/pinned', n: 1 })
|
||||
const abandoned: string[] = []
|
||||
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
|
||||
const cleanup = $effect.root(() => {
|
||||
sync = useNewItemDraftSync({
|
||||
itemKind: 'resource',
|
||||
enabled: () => true,
|
||||
workspace: () => 'w',
|
||||
path: () => form.path,
|
||||
pathError: () => '',
|
||||
contentTouched: () => false,
|
||||
value: () => ({ n: form.n }),
|
||||
onAbandonKey: (_ws, p) => abandoned.push(p)
|
||||
})
|
||||
sync.adopt('w', 'u/me/pinned', { n: 1 })
|
||||
})
|
||||
flushSync()
|
||||
// Untouched: the key is still in use, nothing handed back.
|
||||
vi.advanceTimersByTime(2000)
|
||||
flushSync()
|
||||
expect(abandoned).toEqual([])
|
||||
|
||||
form.path = 'u/me/elsewhere'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
await sync!.flush()
|
||||
expect(abandoned).toEqual(['u/me/pinned'])
|
||||
cleanup()
|
||||
})
|
||||
|
||||
/** An adopted draft exists whether or not the user edits it, so the
|
||||
* touched gate that keeps an untouched NEW item from leaving a row must not
|
||||
* apply — deleting here would wipe the item the editor is showing. An
|
||||
|
||||
@@ -23,9 +23,13 @@ export interface NewItemDraftSyncOptions<V> {
|
||||
/** Reactive deep read of the value to persist (`$state.snapshot` of the
|
||||
* form state); `undefined` while there is nothing to persist. */
|
||||
value: () => V | undefined
|
||||
/** Whether nothing is deployed at `path` yet. Consulted only when a close
|
||||
* forces a commit early, where `Path`'s own check may not have run. */
|
||||
/** Whether nothing is deployed at `path` yet. Consulted before every commit:
|
||||
* `Path`'s own check is debounced and may not have answered. */
|
||||
pathIsFree?: (path: string) => Promise<boolean>
|
||||
/** Called for a key this helper has stopped writing to, after its row is
|
||||
* deleted. An editor whose own autosave handle is pinned to that key MUST
|
||||
* suspend it here, or the handle's next write would recreate the row. */
|
||||
onAbandonKey?: (workspace: string, path: string) => void
|
||||
}
|
||||
|
||||
export interface NewItemDraftSync<V> {
|
||||
@@ -105,6 +109,7 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
// and `remove` blanks that live cell — the form would lose its state
|
||||
// mid-rename. The fallback leaves the cell holding what the form holds.
|
||||
UserDraft.discard(opts.itemKind, written.path, value, { workspace: written.workspace })
|
||||
opts.onAbandonKey?.(written.workspace, written.path)
|
||||
markUnsettled(written.workspace, written.path)
|
||||
written = undefined
|
||||
writtenValue = undefined
|
||||
@@ -217,6 +222,7 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
if (w) {
|
||||
// See `write`: an adopted key is a live handle key, so keep its cell.
|
||||
UserDraft.discard(opts.itemKind, w.path, opts.value(), { workspace: w.workspace })
|
||||
opts.onAbandonKey?.(w.workspace, w.path)
|
||||
markUnsettled(w.workspace, w.path)
|
||||
}
|
||||
await settle()
|
||||
|
||||
Reference in New Issue
Block a user