mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
refactor(frontend): let the draft helper own draft-only items too
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
33063fbf25
commit
7512783fde
@@ -205,10 +205,6 @@
|
||||
)
|
||||
|
||||
let pathError = $state('')
|
||||
// Set before `save` awaits anything: the drawer closes without awaiting the
|
||||
// write, and the close-time draft move would otherwise leave a draft on the
|
||||
// resource being created.
|
||||
let saving = $state(false)
|
||||
|
||||
async function resourcePathIsFree(ws: string, p: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -219,19 +215,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A new resource's handle is keyed on the empty `initialPath`, so it is
|
||||
// detached and never POSTs; the form is mirrored under the typed path
|
||||
// instead. Inert in edit mode.
|
||||
// Nothing deployed at this path: the item IS its draft. Its list row is keyed
|
||||
// by the path inside that draft, so the key has to follow the form's path —
|
||||
// which is what `useNewItemDraftSync` does and the `useMany` handle can't
|
||||
// (it is pinned to the path this editor opened).
|
||||
const draftOnly = $derived(!!selected && existedInitially[selected] === false)
|
||||
// What the form was opened with: the empty seed for a new resource, the
|
||||
// draft itself for a draft-only one. Divergence from it is the user's edit.
|
||||
let openedWith: Record<string, ResourceState> = $state({})
|
||||
|
||||
const newDraftSync = useNewItemDraftSync<ResourceState>({
|
||||
itemKind: 'resource',
|
||||
enabled: () => !initialPath,
|
||||
enabled: () => draftOnly,
|
||||
workspace: () => selected,
|
||||
path: () => current?.path ?? '',
|
||||
pathError: () => pathError,
|
||||
contentTouched: () =>
|
||||
!!current &&
|
||||
!!selected &&
|
||||
!draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' }),
|
||||
!!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))
|
||||
})
|
||||
@@ -253,6 +256,7 @@
|
||||
}
|
||||
ensureHandle(selected, s)
|
||||
initialStates[selected] = structuredClone(s)
|
||||
openedWith[selected] = structuredClone(s)
|
||||
existedInitially[selected] = false
|
||||
})
|
||||
})
|
||||
@@ -302,7 +306,15 @@
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
ensureHandle(ws, s)
|
||||
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))
|
||||
}
|
||||
perWsUser[ws] = user
|
||||
// Keep resource_type in sync for the base workspace (controls the schema)
|
||||
if (ws === effectiveWorkspace) {
|
||||
@@ -343,36 +355,10 @@
|
||||
export function localDraftCurrent(): ResourceState | undefined {
|
||||
return current
|
||||
}
|
||||
/** A draft-only item's list row is keyed by the path INSIDE the draft, while
|
||||
* the autosave handle stays keyed on the path the editor opened. Renaming
|
||||
* one would leave the row pointing at a key that holds no draft — it would
|
||||
* 404 on reopen and its delete would miss — so move the draft to the path
|
||||
* the form now carries. Reads its state synchronously: the caller runs this
|
||||
* as the drawer closes, and awaiting first would race the editor's teardown. */
|
||||
async function moveRenamedDraftOnly(): Promise<void> {
|
||||
const ws = selected
|
||||
if (!ws || !initialPath || existedInitially[ws] !== false || saving) return
|
||||
const s = states[ws]?.draft
|
||||
if (!s || !s.path || s.path === initialPath || pathError !== '') return
|
||||
const value = $state.snapshot(s) as ResourceState
|
||||
const target = s.path
|
||||
// The path may have been typed too recently for `Path`'s debounced check
|
||||
// to have run: moving onto an occupied path would hand this draft to the
|
||||
// item living there.
|
||||
if (!(await resourcePathIsFree(ws, target))) return
|
||||
UserDraft.save('resource', target, value, { workspace: ws })
|
||||
UserDraft.remove('resource', initialPath, { workspace: ws })
|
||||
await Promise.all([
|
||||
UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: target }),
|
||||
UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: initialPath })
|
||||
])
|
||||
}
|
||||
|
||||
/** Settle every pending draft write. The drawer awaits this before its
|
||||
* `onClose`, whose list refetch would otherwise outrun the debounced POST. */
|
||||
export async function flushDraft(): Promise<void> {
|
||||
// Both started before the first await so they read live editor state.
|
||||
await Promise.all([newDraftSync.flush(), moveRenamedDraftOnly()])
|
||||
await newDraftSync.flush()
|
||||
}
|
||||
|
||||
/** Returns true when the item was draft-only: discarding deleted it
|
||||
@@ -385,10 +371,11 @@
|
||||
})
|
||||
return false
|
||||
}
|
||||
// Draft-only: no baseline to fall back to. Blank the cell so the form
|
||||
// unmounts — mounted on the empty state it re-fills the path from
|
||||
// `initialPath`, and that autosave would displace the delete. Flushed so
|
||||
// the list refetch on drawer close no longer finds the row.
|
||||
// Draft-only: the item is the draft, so discarding deletes it. `finish`
|
||||
// drops it wherever the helper keyed it and settles that before the
|
||||
// caller's list refetch; `remove` then blanks the cell so the form
|
||||
// unmounts, and clears the key this editor opened if the two differ.
|
||||
await newDraftSync.finish()
|
||||
UserDraft.remove('resource', initialPath ?? '', { workspace: selected })
|
||||
await UserDraftDbSyncer.flush({
|
||||
workspace: selected,
|
||||
@@ -426,14 +413,12 @@
|
||||
|
||||
export async function save(): Promise<void> {
|
||||
const dirty = dirtyWorkspaces
|
||||
// Synchronous, before the first await: the drawer closes right after
|
||||
// calling this, and the close-time draft move must see it.
|
||||
saving = true
|
||||
try {
|
||||
for (const ws of dirty) {
|
||||
const s = states[ws].draft!
|
||||
const ini = initialStates[ws]
|
||||
if (existedInitially[ws]) {
|
||||
const wasDeployed = existedInitially[ws]
|
||||
if (wasDeployed) {
|
||||
await ResourceService.updateResource({
|
||||
workspace: ws,
|
||||
path: ini.path,
|
||||
@@ -463,19 +448,19 @@
|
||||
})
|
||||
}
|
||||
initialStates[ws] = $state.snapshot(s) as ResourceState
|
||||
openedWith[ws] = $state.snapshot(s) as ResourceState
|
||||
existedInitially[ws] = true
|
||||
if (initialPath) {
|
||||
// Both awaited: the caller refetches the list right after, and a
|
||||
// debounced delete would bring the just-deployed item back as a draft.
|
||||
if (wasDeployed) {
|
||||
// Reset the handle to the new deployed baseline via `discard`, not
|
||||
// `remove`. See VariableEditor for the full rationale.
|
||||
UserDraft.discard('resource', initialPath, s, { workspace: ws })
|
||||
// Flushed: the caller refetches the list right after, and the
|
||||
// discard's delete rides the same debounce — the just-deployed item
|
||||
// would still come back rendered as a draft.
|
||||
await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: initialPath })
|
||||
} else {
|
||||
// Awaited: the caller refetches the list right after, and a debounced
|
||||
// delete would leave the just-created item still flagged as a draft.
|
||||
// The helper held this draft (new or draft-only), wherever it keyed it.
|
||||
await newDraftSync.finish()
|
||||
if (initialPath) UserDraft.restartSync('resource', initialPath, { workspace: ws })
|
||||
}
|
||||
// Path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
|
||||
@@ -58,9 +58,9 @@
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
let selected: string | undefined = $state(undefined)
|
||||
let pathError = $state('')
|
||||
// Set before `save` awaits anything, so a close-time draft move can't leave
|
||||
// a draft on the variable being created.
|
||||
let saving = $state(false)
|
||||
// What the form was opened with: the empty seed for a new variable, the
|
||||
// draft itself for a draft-only one. Divergence from it is the user's edit.
|
||||
let openedWith: Record<string, VariableState> = $state({})
|
||||
|
||||
async function variablePathIsFree(ws: string, p: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -130,19 +130,23 @@
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
|
||||
// A new variable's handle is keyed on the empty `editPath`, so it is
|
||||
// detached and never POSTs; the form is mirrored under the typed path
|
||||
// instead. Inert in edit mode.
|
||||
// Nothing deployed at this path: the item IS its draft. Its list row is keyed
|
||||
// by the path inside that draft, so the key has to follow the form's path —
|
||||
// which is what `useNewItemDraftSync` does and the `useMany` handle can't
|
||||
// (it is pinned to the path this editor opened).
|
||||
const draftOnly = $derived(!!selected && existedInitially[selected] === false)
|
||||
|
||||
const newDraftSync = useNewItemDraftSync<VariableState>({
|
||||
itemKind: 'variable',
|
||||
enabled: () => !edit,
|
||||
enabled: () => draftOnly,
|
||||
workspace: () => selected,
|
||||
path: () => current?.path ?? '',
|
||||
pathError: () => pathError,
|
||||
contentTouched: () =>
|
||||
!!current &&
|
||||
!!selected &&
|
||||
!draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' }),
|
||||
!!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))
|
||||
})
|
||||
@@ -234,44 +238,25 @@
|
||||
const s: VariableState = savedDraftState ?? deployedState
|
||||
ensureHandle(ws, s)
|
||||
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))
|
||||
}
|
||||
extraPerms[ws] = v.extra_perms ?? {}
|
||||
perWsUser[ws] = user
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** A draft-only item's list row is keyed by the path INSIDE the draft, while
|
||||
* the autosave handle stays keyed on the path the editor opened. Renaming
|
||||
* one would leave the row pointing at a key that holds no draft — it would
|
||||
* 404 on reopen and its delete would miss — so move the draft to the path
|
||||
* the form now carries. Reads its state synchronously: the caller runs this
|
||||
* as the drawer closes, and awaiting first would race the form's teardown. */
|
||||
async function moveRenamedDraftOnly(): Promise<void> {
|
||||
const ws = selected
|
||||
if (!ws || !editPath || existedInitially[ws] !== false || saving) return
|
||||
const s = states[ws]?.draft
|
||||
if (!s || !s.path || s.path === editPath || pathError !== '') return
|
||||
const value = $state.snapshot(s) as VariableState
|
||||
const target = s.path
|
||||
const from = editPath
|
||||
// The path may have been typed too recently for `Path`'s debounced check
|
||||
// to have run: moving onto an occupied path would hand this draft to the
|
||||
// item living there.
|
||||
if (!(await variablePathIsFree(ws, target))) return
|
||||
UserDraft.save('variable', target, value, { workspace: ws })
|
||||
UserDraft.remove('variable', from, { workspace: ws })
|
||||
await Promise.all([
|
||||
UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: target }),
|
||||
UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: from })
|
||||
])
|
||||
}
|
||||
|
||||
/** Settle every pending draft write before the drawer's close event, whose
|
||||
* list refetch would otherwise outrun the debounced POST. */
|
||||
async function flushDraft(): Promise<void> {
|
||||
// Both started before the first await so they read live form state.
|
||||
await Promise.all([newDraftSync.flush(), moveRenamedDraftOnly()])
|
||||
await newDraftSync.flush()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -283,7 +268,7 @@
|
||||
extraPerms = {}
|
||||
perWsUser = {}
|
||||
pathError = ''
|
||||
saving = false
|
||||
openedWith = {}
|
||||
newDraftSync.reset()
|
||||
}
|
||||
|
||||
@@ -299,6 +284,7 @@
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
openedWith[ws] = structuredClone(s)
|
||||
existedInitially[ws] = false
|
||||
selected = ws
|
||||
drawer?.openDrawer()
|
||||
@@ -329,12 +315,11 @@
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const dirty = dirtyWorkspaces
|
||||
// Synchronous, before the first await, so the close-time draft move sees it.
|
||||
saving = true
|
||||
try {
|
||||
for (const ws of dirty) {
|
||||
const s = states[ws].draft!
|
||||
const ini = initialStates[ws]
|
||||
const wasDeployed = existedInitially[ws]
|
||||
if (existedInitially[ws]) {
|
||||
await VariableService.updateVariable({
|
||||
workspace: ws,
|
||||
@@ -370,17 +355,17 @@
|
||||
// `undefined` reads as dirty). The `value: null` POST also deletes
|
||||
// the server draft row so `is_draft` clears on refetch.
|
||||
initialStates[ws] = $state.snapshot(s) as VariableState
|
||||
openedWith[ws] = $state.snapshot(s) as VariableState
|
||||
existedInitially[ws] = true
|
||||
if (editPath) {
|
||||
UserDraft.discard('variable', editPath, s, { workspace: ws })
|
||||
// Flushed: the caller refetches the list right after, and the
|
||||
// discard's delete rides the same debounce — the just-deployed item
|
||||
// would still come back rendered as a draft.
|
||||
await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: editPath })
|
||||
// Both awaited: the caller refetches the list right after, and a
|
||||
// debounced delete would bring the just-deployed item back as a draft.
|
||||
if (wasDeployed) {
|
||||
UserDraft.discard('variable', editPath!, s, { workspace: ws })
|
||||
await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: editPath! })
|
||||
} else {
|
||||
// Awaited: the caller refetches the list right after, and a debounced
|
||||
// delete would leave the just-created item still flagged as a draft.
|
||||
// The helper held this draft (new or draft-only), wherever it keyed it.
|
||||
await newDraftSync.finish()
|
||||
if (editPath) UserDraft.restartSync('variable', editPath, { workspace: ws })
|
||||
}
|
||||
// Path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
@@ -425,10 +410,11 @@
|
||||
})
|
||||
return
|
||||
}
|
||||
// Draft-only: no baseline to fall back to. Blank the cell so the form
|
||||
// unmounts — mounted on the empty state it re-fills the path from
|
||||
// `initialPath`, and that autosave would displace the delete. Flushed
|
||||
// so the list refetch on drawer close no longer finds the row.
|
||||
// Draft-only: the item is the draft, so discarding deletes it. `finish`
|
||||
// drops it wherever the helper keyed it and settles that before the
|
||||
// caller's list refetch; `remove` then blanks the cell so the form
|
||||
// unmounts, and clears the key this editor opened if the two differ.
|
||||
await newDraftSync.finish()
|
||||
UserDraft.remove('variable', editPath ?? '', { workspace: selected })
|
||||
await UserDraftDbSyncer.flush({
|
||||
workspace: selected,
|
||||
|
||||
@@ -3,11 +3,13 @@ import { flushSync } from 'svelte'
|
||||
|
||||
const save = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const discard = vi.fn()
|
||||
const flush = vi.fn(async () => {})
|
||||
vi.mock('$lib/userDraft.svelte', () => ({
|
||||
UserDraft: {
|
||||
save: (...a: unknown[]) => save(...a),
|
||||
remove: (...a: unknown[]) => remove(...a)
|
||||
remove: (...a: unknown[]) => remove(...a),
|
||||
discard: (...a: unknown[]) => discard(...a)
|
||||
}
|
||||
}))
|
||||
vi.mock('$lib/userDraftDbSyncer.svelte', () => ({
|
||||
@@ -70,14 +72,14 @@ describe('useNewItemDraftSync', () => {
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(remove).toHaveBeenCalledWith('resource', 'u/me/auto_name', { workspace: 'w' })
|
||||
expect(discard.mock.calls[0].slice(0, 2)).toEqual(['resource', 'u/me/auto_name'])
|
||||
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
|
||||
|
||||
form.pathError = 'path already used'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(remove).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { workspace: 'w' })
|
||||
expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['resource', 'u/me/renamed'])
|
||||
expect(draftPath).toBe('')
|
||||
|
||||
form.pathError = ''
|
||||
@@ -87,7 +89,7 @@ describe('useNewItemDraftSync', () => {
|
||||
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
|
||||
|
||||
cleanup()
|
||||
expect(remove).toHaveBeenCalledTimes(2)
|
||||
expect(discard).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
/** The commit is delayed so the key can't land on a half-typed path, and the
|
||||
@@ -188,6 +190,70 @@ describe('useNewItemDraftSync', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
/** A draft-only item arrives with a draft already stored under the path the
|
||||
* editor opened. Renaming it has to MOVE that row, so the helper must be
|
||||
* told which key it inherited or it would leave a second one behind. */
|
||||
it('moves an adopted key on rename instead of leaving it behind', async () => {
|
||||
const form = $state({ path: 'u/me/adopted', n: 1 })
|
||||
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 })
|
||||
})
|
||||
sync.adopt('w', 'u/me/adopted', { n: 1 })
|
||||
})
|
||||
flushSync()
|
||||
|
||||
form.path = 'u/me/moved'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
await sync!.flush()
|
||||
expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['resource', 'u/me/adopted'])
|
||||
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/moved', { n: 1 }, { workspace: 'w' })
|
||||
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
|
||||
* invalid path likewise leaves it where it is rather than dropping it. */
|
||||
it('keeps an adopted draft through an untouched open and an invalid path', async () => {
|
||||
const form = $state({ path: 'u/me/kept', pathError: '', n: 1 })
|
||||
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
|
||||
const cleanup = $effect.root(() => {
|
||||
sync = useNewItemDraftSync({
|
||||
itemKind: 'resource',
|
||||
enabled: () => true,
|
||||
workspace: () => 'w',
|
||||
path: () => form.path,
|
||||
pathError: () => form.pathError,
|
||||
contentTouched: () => false,
|
||||
value: () => ({ n: form.n })
|
||||
})
|
||||
sync.adopt('w', 'u/me/kept', { n: 1 })
|
||||
})
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(2000)
|
||||
flushSync()
|
||||
expect(discard).not.toHaveBeenCalled()
|
||||
|
||||
form.pathError = 'path already used'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(2000)
|
||||
flushSync()
|
||||
await sync!.flush()
|
||||
expect(discard).not.toHaveBeenCalled()
|
||||
expect(sync!.draftPath).toBe('u/me/kept')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
/** `Path` auto-fills a unique name on mount and flips its own `dirty` on any
|
||||
* keyup, tabbing included. Only a departure from that name counts, or an
|
||||
* untouched drawer would leave a phantom row behind. */
|
||||
@@ -245,7 +311,7 @@ describe('useNewItemDraftSync', () => {
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
|
||||
await sync!.finish()
|
||||
expect(remove).toHaveBeenCalledWith('variable', 'u/me/item', { workspace: 'w' })
|
||||
expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['variable', 'u/me/item'])
|
||||
|
||||
form.n = 2
|
||||
flushSync()
|
||||
@@ -259,7 +325,7 @@ describe('useNewItemDraftSync', () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenLastCalledWith('variable', 'u/me/item', { n: 3 }, { workspace: 'w' })
|
||||
expect(remove).toHaveBeenCalledTimes(1)
|
||||
expect(discard).toHaveBeenCalledTimes(1)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
@@ -28,9 +28,13 @@ export interface NewItemDraftSyncOptions<V> {
|
||||
pathIsFree?: (path: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface NewItemDraftSync {
|
||||
export interface NewItemDraftSync<V> {
|
||||
/** Storage path of the persisted draft, `''` when none. */
|
||||
readonly draftPath: string
|
||||
/** Take ownership of a draft that already exists at `path` — a draft-only
|
||||
* item the editor loaded. Without this the helper believes it has written
|
||||
* nothing, and a rename would add a second row instead of moving this one. */
|
||||
adopt(workspace: string, path: string, value: V): void
|
||||
/** Commit anything still pending and settle it server-side. Callers MUST
|
||||
* await this before a list refetch (both the commit delay and the syncer's
|
||||
* own debounce outlive a closing drawer, so a refetch would miss the row). */
|
||||
@@ -51,7 +55,7 @@ export interface NewItemDraftSync {
|
||||
* the list pages' draft-only rows and the get-by-path draft overlay resolve —
|
||||
* and moves it (delete the old key, write the new) as the path changes.
|
||||
*/
|
||||
export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewItemDraftSync {
|
||||
export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewItemDraftSync<V> {
|
||||
let draftPath = $state('')
|
||||
let finished = $state(false)
|
||||
// The key last written, workspace included: a move or a delete has to target
|
||||
@@ -67,13 +71,21 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
// first edit must keep the draft, so a pending commit is never cancelled by
|
||||
// teardown — only superseded by a newer one, or consumed by `flush`.
|
||||
let pending:
|
||||
| { timer: ReturnType<typeof setTimeout>; key: string; value: V | undefined }
|
||||
| {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
workspace: string | undefined
|
||||
key: string
|
||||
value: V | undefined
|
||||
}
|
||||
| undefined
|
||||
let pendingWorkspace: string | undefined
|
||||
// `Path` auto-fills a unique name on mount, so a non-empty path is no
|
||||
// evidence the user did anything. Only a departure from the name it settled
|
||||
// on counts (`Path.dirty` can't: it flips on any keyup, tabbing included).
|
||||
let autoPath: string | undefined
|
||||
// An adopted draft already exists: it is kept regardless of whether the user
|
||||
// edits anything, and an invalid path leaves it where it is rather than
|
||||
// deleting it. Only a brand-new item's draft is gated on being touched.
|
||||
let adopted = false
|
||||
|
||||
function markUnsettled(workspace: string, path: string): void {
|
||||
if (!unsettled.some((k) => k.workspace === workspace && k.path === path)) {
|
||||
@@ -89,7 +101,10 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
|
||||
function write(workspace: string | undefined, path: string, value: V | undefined): void {
|
||||
if (written && (written.path !== path || written.workspace !== workspace)) {
|
||||
UserDraft.remove(opts.itemKind, written.path, { workspace: written.workspace })
|
||||
// `discard`, not `remove`: an adopted key is the editor's own handle key,
|
||||
// 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 })
|
||||
markUnsettled(written.workspace, written.path)
|
||||
written = undefined
|
||||
writtenValue = undefined
|
||||
@@ -109,18 +124,36 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
pending = undefined
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
/** Validate the pending key, then take it. The timer is cleared first and
|
||||
* the payload kept, so the validation below can't race a second commit of
|
||||
* the same transition; a newer transition scheduled meanwhile wins. */
|
||||
async function commitPending(): Promise<void> {
|
||||
const p = pending
|
||||
if (!p) return
|
||||
dropPending()
|
||||
clearTimeout(p.timer)
|
||||
if (p.key) {
|
||||
// `Path` debounces its own existence check and may not have answered
|
||||
// yet, so the key is verified here rather than trusted: keying a draft
|
||||
// on a path that already holds an item would take it as an edit of
|
||||
// that item, and saving from there would overwrite its value.
|
||||
const free =
|
||||
opts.pathError() === '' && (opts.pathIsFree ? await opts.pathIsFree(p.key) : true)
|
||||
if (pending !== p) return
|
||||
if (!free) {
|
||||
pending = undefined
|
||||
return
|
||||
}
|
||||
}
|
||||
pending = undefined
|
||||
draftPath = p.key
|
||||
write(pendingWorkspace, p.key, p.value)
|
||||
write(p.workspace, p.key, p.value)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!opts.enabled() || finished) return
|
||||
const p = opts.path()
|
||||
const key = p !== '' && opts.pathError() === '' && touched() ? p : ''
|
||||
const usable = p !== '' && opts.pathError() === ''
|
||||
const key = usable && (adopted || touched()) ? p : adopted ? untrack(() => draftPath) : ''
|
||||
const workspace = opts.workspace()
|
||||
const value = opts.value()
|
||||
if (key === untrack(() => draftPath)) {
|
||||
@@ -130,8 +163,12 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
}
|
||||
untrack(() => {
|
||||
dropPending()
|
||||
pendingWorkspace = workspace
|
||||
pending = { timer: setTimeout(commit, COMMIT_DELAY_MS), key, value }
|
||||
pending = {
|
||||
timer: setTimeout(() => void commitPending(), COMMIT_DELAY_MS),
|
||||
workspace,
|
||||
key,
|
||||
value
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,18 +196,15 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
get draftPath() {
|
||||
return draftPath
|
||||
},
|
||||
adopt(workspace: string, path: string, value: V) {
|
||||
written = { workspace, path }
|
||||
writtenValue = JSON.stringify(value)
|
||||
autoPath = path
|
||||
draftPath = path
|
||||
adopted = true
|
||||
},
|
||||
async flush() {
|
||||
const p = pending
|
||||
if (p && p.key) {
|
||||
// Forced by a close, so the commit delay that lets `Path`'s debounced
|
||||
// existence check land was cut short. Re-check before keying on it:
|
||||
// a path that already holds an item would take this draft as an edit
|
||||
// of that item, and saving from there would overwrite its value.
|
||||
const free =
|
||||
opts.pathError() === '' && (opts.pathIsFree ? await opts.pathIsFree(p.key) : true)
|
||||
if (!free && pending === p) dropPending()
|
||||
}
|
||||
commit()
|
||||
await commitPending()
|
||||
await settle()
|
||||
},
|
||||
async finish() {
|
||||
@@ -181,13 +215,15 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
|
||||
writtenValue = undefined
|
||||
draftPath = ''
|
||||
if (w) {
|
||||
UserDraft.remove(opts.itemKind, w.path, { workspace: w.workspace })
|
||||
// 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 })
|
||||
markUnsettled(w.workspace, w.path)
|
||||
}
|
||||
await settle()
|
||||
},
|
||||
reset() {
|
||||
finished = false
|
||||
adopted = false
|
||||
dropPending()
|
||||
written = undefined
|
||||
writtenValue = undefined
|
||||
|
||||
Reference in New Issue
Block a user