mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-20 00:02:28 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b95662f49 | ||
|
|
eb6e12f0d2 | ||
|
|
7512783fde | ||
|
|
33063fbf25 | ||
|
|
ed1ccc05b9 | ||
|
|
81a0773edf |
@@ -73,9 +73,14 @@
|
||||
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
on:close={() => {
|
||||
on:close={async () => {
|
||||
// Flushed before the step reset (which retires the form the draft mirrors)
|
||||
// and before `close`, whose list refetch would otherwise outrun the draft's
|
||||
// own commit delay and the syncer's debounce, and miss the new row.
|
||||
const flushed = appConnectInner?.flushDraft()
|
||||
step = 1
|
||||
handedOff = false
|
||||
await flushed
|
||||
dispatch('close')
|
||||
}}
|
||||
size="700px"
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
|
||||
import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte'
|
||||
import Path from './Path.svelte'
|
||||
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
|
||||
import { Button, RadioCard, Skeleton } from './common'
|
||||
import ApiConnectForm from './ApiConnectForm.svelte'
|
||||
import SearchItems from './SearchItems.svelte'
|
||||
@@ -329,6 +330,54 @@
|
||||
|
||||
let pathError = $state('')
|
||||
|
||||
async function resourcePathIsFree(p: string): Promise<boolean> {
|
||||
try {
|
||||
return !(await ResourceService.existsResource({ workspace: effectiveWorkspace, path: p }))
|
||||
} catch {
|
||||
// Fail closed: an unanswered check is not evidence the path is free.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Fields saved as linked secret variables never enter the draft: a resource
|
||||
// draft is stored as-is, without the encryption those variables get.
|
||||
function draftArgs(): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries($state.snapshot(args) ?? {})) {
|
||||
out[k] = linkedSecrets.includes(k) ? '' : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
// The manual step is a brand-new resource: mirror the form to a draft keyed
|
||||
// by the typed path, so a closed drawer can be picked up from the resources
|
||||
// list (draft-only row → editor). Filling an existing path is not a draft.
|
||||
const newDraftSync = useNewItemDraftSync({
|
||||
itemKind: 'resource',
|
||||
enabled: () => step == 2 && manual && !fillPath,
|
||||
workspace: () => effectiveWorkspace,
|
||||
path: () => path,
|
||||
pathError: () => pathError,
|
||||
contentTouched: () =>
|
||||
description !== '' ||
|
||||
(labels?.length ?? 0) > 0 ||
|
||||
wsSpecific ||
|
||||
Object.entries(draftArgs()).some(
|
||||
([k, v]) =>
|
||||
v !== '' &&
|
||||
v !== undefined &&
|
||||
v !== (resourceTypeInfo?.schema as any)?.properties?.[k]?.default
|
||||
),
|
||||
value: () => ({
|
||||
path,
|
||||
description,
|
||||
args: draftArgs(),
|
||||
labels,
|
||||
wsSpecific,
|
||||
resource_type: resourceType
|
||||
}),
|
||||
pathIsFree: resourcePathIsFree
|
||||
})
|
||||
|
||||
export async function open(rt?: string) {
|
||||
if (!rt) {
|
||||
loadResourceTypes()
|
||||
@@ -952,6 +1001,9 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
// Awaited: `refresh` refetches the list, and a debounced delete would
|
||||
// leave the just-created resource still flagged as a draft.
|
||||
await newDraftSync.finish()
|
||||
dispatch('refresh', path)
|
||||
dispatch('close')
|
||||
sendUserToast(
|
||||
@@ -963,7 +1015,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle the pending draft write before the drawer's close event, whose
|
||||
* list refetch would otherwise outrun the debounced POST. */
|
||||
export async function flushDraft(): Promise<void> {
|
||||
await newDraftSync.flush()
|
||||
}
|
||||
|
||||
export async function back() {
|
||||
if (step == 2 && manual) {
|
||||
// Back abandons this form; the draft it mirrored goes with it. Not
|
||||
// awaited: the step change is the user's feedback and must not wait on
|
||||
// a POST (`finish` captures what it deletes before returning).
|
||||
void newDraftSync.finish()
|
||||
newDraftSync.reset()
|
||||
}
|
||||
if (step == 4) {
|
||||
step -= 2
|
||||
} else if (step > 1) {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
|
||||
|
||||
interface Props {
|
||||
canSave?: boolean
|
||||
@@ -47,12 +49,16 @@
|
||||
onCanWriteChange
|
||||
}: Props = $props()
|
||||
|
||||
// Persisted as the draft value: the list pages' draft-only rows and the
|
||||
// global AI chat read this exact shape, `resource_type` included, since a
|
||||
// draft with no deployed row has nowhere else to carry its type.
|
||||
type ResourceState = {
|
||||
path: string
|
||||
description: string
|
||||
args: Record<string, any>
|
||||
labels: string[] | undefined
|
||||
wsSpecific: boolean
|
||||
resource_type?: string
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -198,6 +204,50 @@
|
||||
})
|
||||
)
|
||||
|
||||
let pathError = $state('')
|
||||
|
||||
async function resourcePathIsFree(ws: string, p: string): Promise<boolean> {
|
||||
try {
|
||||
return !(await ResourceService.existsResource({ workspace: ws, path: p }))
|
||||
} catch {
|
||||
// Fail closed: an unanswered check is not evidence the path is free.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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: () => draftOnly,
|
||||
workspace: () => selected,
|
||||
path: () => current?.path ?? '',
|
||||
pathError: () => pathError,
|
||||
contentTouched: () =>
|
||||
!!current &&
|
||||
!!selected &&
|
||||
!!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)),
|
||||
keyed: (v, p) => ({ ...v, path: p }),
|
||||
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 })
|
||||
},
|
||||
onResumeKey: (ws, p) => {
|
||||
if (p === initialPath) UserDraft.restartSync('resource', p, { workspace: ws })
|
||||
}
|
||||
})
|
||||
|
||||
// New-resource bootstrap: seed empty state per workspace (edit mode
|
||||
// is seeded by the lazy-fetch effect below).
|
||||
$effect(() => {
|
||||
@@ -210,10 +260,12 @@
|
||||
description: '',
|
||||
args: (defaultValues && Object.keys(defaultValues).length > 0 ? defaultValues : {}) as any,
|
||||
labels: undefined,
|
||||
wsSpecific: false
|
||||
wsSpecific: false,
|
||||
resource_type
|
||||
}
|
||||
ensureHandle(selected, s)
|
||||
initialStates[selected] = structuredClone(s)
|
||||
openedWith[selected] = structuredClone(s)
|
||||
existedInitially[selected] = false
|
||||
})
|
||||
})
|
||||
@@ -231,22 +283,44 @@
|
||||
// `.draft` already holds the editor's `ResourceState` shape.
|
||||
const savedDraftState = (r as any).draft as ResourceState | undefined
|
||||
fetchedResources[ws] = r
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
// CREATE, not update (update 404s).
|
||||
const noDeployed = !!(r as any).no_deployed
|
||||
// Deployed baseline as the dirty-check reference, so the banner
|
||||
// compares draft-vs-deployed and fires immediately when a draft exists.
|
||||
const deployedState: ResourceState = {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
args: (r.value ?? {}) as any,
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
// A draft-only item has no deployed side: everything in it is unsaved.
|
||||
const deployedState: ResourceState = noDeployed
|
||||
? {
|
||||
path: '',
|
||||
description: '',
|
||||
args: {},
|
||||
labels: undefined,
|
||||
wsSpecific: false,
|
||||
resource_type: r.resource_type
|
||||
}
|
||||
: {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
args: (r.value ?? {}) as any,
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false,
|
||||
resource_type: r.resource_type
|
||||
}
|
||||
// A draft saved without `resource_type` compares against a baseline
|
||||
// that has it; fill it in so the field alone can't read as a change.
|
||||
if (savedDraftState && savedDraftState.resource_type === undefined) {
|
||||
savedDraftState.resource_type = r.resource_type
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
// CREATE, not update (update 404s).
|
||||
existedInitially[ws] = !(r as any).no_deployed
|
||||
openedWith[ws] = structuredClone(s)
|
||||
existedInitially[ws] = !noDeployed
|
||||
// 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) {
|
||||
@@ -268,7 +342,7 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
canSave = anyDirty && dirtyValid && dirtyCanWrite
|
||||
canSave = anyDirty && dirtyValid && dirtyCanWrite && pathError === ''
|
||||
})
|
||||
|
||||
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
|
||||
@@ -287,11 +361,34 @@
|
||||
export function localDraftCurrent(): ResourceState | undefined {
|
||||
return current
|
||||
}
|
||||
export function discardLocalDraft(): void {
|
||||
if (!selected) return
|
||||
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
/** 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> {
|
||||
await newDraftSync.flush()
|
||||
}
|
||||
|
||||
/** Returns true when the item was draft-only: discarding deleted it
|
||||
* outright, so there is nothing left for the editor to show. */
|
||||
export async function discardLocalDraft(): Promise<boolean> {
|
||||
if (!selected) return false
|
||||
if (existedInitially[selected]) {
|
||||
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
})
|
||||
return false
|
||||
}
|
||||
// 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,
|
||||
itemKind: 'resource',
|
||||
path: initialPath ?? ''
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -326,7 +423,8 @@
|
||||
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,
|
||||
@@ -355,11 +453,21 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
// Reset the handle to the new deployed baseline via `discard`, not
|
||||
// `remove`. See VariableEditor for the full rationale.
|
||||
initialStates[ws] = $state.snapshot(s) as ResourceState
|
||||
openedWith[ws] = $state.snapshot(s) as ResourceState
|
||||
existedInitially[ws] = true
|
||||
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
|
||||
// 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 })
|
||||
await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: initialPath })
|
||||
} else {
|
||||
// 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.
|
||||
invalidateWorkspacePaths(ws)
|
||||
@@ -386,6 +494,7 @@
|
||||
{#key current}
|
||||
<ResourceForm
|
||||
bind:path={() => current!.path, setPath}
|
||||
bind:pathError
|
||||
bind:labels={current.labels}
|
||||
bind:description={current.description}
|
||||
bind:args={current.args}
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
workspace = undefined,
|
||||
disableChatOffset = false,
|
||||
onRestored = undefined,
|
||||
onSaved = undefined
|
||||
onSaved = undefined,
|
||||
onClose = undefined
|
||||
}: {
|
||||
workspace?: string
|
||||
disableChatOffset?: boolean
|
||||
@@ -31,6 +32,9 @@
|
||||
/** Fires after Save has written, for a caller showing state derived from the
|
||||
* resource — `onRestored` only covers restoring an old version. */
|
||||
onSaved?: () => void
|
||||
/** Fires whenever the drawer closes, saved or not: a new resource left
|
||||
* unsaved persists as a draft-only row, which a list only sees on refetch. */
|
||||
onClose?: () => void
|
||||
} = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
@@ -44,7 +48,8 @@
|
||||
save: () => void
|
||||
localDraftDeployed: () => unknown
|
||||
localDraftCurrent: () => unknown
|
||||
discardLocalDraft: () => void
|
||||
discardLocalDraft: () => Promise<boolean>
|
||||
flushDraft: () => Promise<void>
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let hasLocalDraft = $state(false)
|
||||
@@ -97,7 +102,13 @@
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
{disableChatOffset}
|
||||
on:close={() => clearPageDrawerAnchor(RESOURCES_PATH)}
|
||||
on:close={async () => {
|
||||
clearPageDrawerAnchor(RESOURCES_PATH)
|
||||
// Before `onClose`: its list refetch would otherwise outrun the draft's
|
||||
// own commit delay and the syncer's debounce, and miss the new row.
|
||||
await resourceEditor?.flushDraft()
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
|
||||
@@ -131,7 +142,10 @@
|
||||
reserveSpace={mode == 'edit'}
|
||||
getDeployed={() => resourceEditor?.localDraftDeployed()}
|
||||
getCurrent={() => resourceEditor?.localDraftCurrent()}
|
||||
onDiscard={() => resourceEditor?.discardLocalDraft()}
|
||||
onDiscard={async () => {
|
||||
// A draft-only resource is gone once discarded; nothing is left to edit.
|
||||
if (await resourceEditor?.discardLocalDraft()) drawer?.closeDrawer()
|
||||
}}
|
||||
disabled={!canWriteSelected}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
path: string
|
||||
initialPath: string
|
||||
hidePath?: boolean
|
||||
/** `Path`'s validation error (`''` when valid). */
|
||||
pathError: string
|
||||
labels: string[] | undefined
|
||||
description: string
|
||||
args: Record<string, any>
|
||||
@@ -53,6 +55,7 @@
|
||||
path = $bindable(),
|
||||
initialPath,
|
||||
hidePath = false,
|
||||
pathError = $bindable(),
|
||||
labels = $bindable(),
|
||||
description = $bindable(),
|
||||
args = $bindable(),
|
||||
@@ -160,6 +163,7 @@
|
||||
<Path
|
||||
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
|
||||
bind:path
|
||||
bind:error={pathError}
|
||||
{initialPath}
|
||||
namePlaceholder="resource"
|
||||
kind="resource"
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -56,6 +58,18 @@
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
let selected: string | undefined = $state(undefined)
|
||||
let pathError = $state('')
|
||||
// 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 {
|
||||
return !(await VariableService.existsVariable({ workspace: ws, path: p }))
|
||||
} catch {
|
||||
// Fail closed: an unanswered check is not evidence the path is free.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<VariableState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
@@ -116,6 +130,36 @@
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
|
||||
// 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: () => draftOnly,
|
||||
workspace: () => selected,
|
||||
path: () => current?.path ?? '',
|
||||
pathError: () => pathError,
|
||||
contentTouched: () =>
|
||||
!!current &&
|
||||
!!selected &&
|
||||
!!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)),
|
||||
keyed: (v, p) => ({ ...v, path: p }),
|
||||
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 })
|
||||
},
|
||||
onResumeKey: (ws, p) => {
|
||||
if (p === editPath) UserDraft.restartSync('variable', p, { workspace: ws })
|
||||
}
|
||||
})
|
||||
|
||||
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
|
||||
// on delete). The editor only CLEARS it — a workspace at the deployed
|
||||
// baseline has no draft, so drop any stale hint (this is how a draft
|
||||
@@ -176,31 +220,51 @@
|
||||
]).then(([v, user]) => {
|
||||
// `.draft` already holds the editor's `VariableState` shape.
|
||||
const savedDraftState = (v as any).draft as VariableState | undefined
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
// CREATE, not update (update 404s).
|
||||
const noDeployed = !!(v as any).no_deployed
|
||||
// Deployed baseline as the dirty-check reference, so the banner
|
||||
// compares draft-vs-deployed and fires immediately when a draft exists.
|
||||
const deployedState: VariableState = {
|
||||
path: v.path,
|
||||
variable: {
|
||||
value: v.value ?? '',
|
||||
is_secret: v.is_secret,
|
||||
description: v.description ?? ''
|
||||
},
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
// A draft-only item has no deployed side: everything in it is unsaved.
|
||||
const deployedState: VariableState = noDeployed
|
||||
? {
|
||||
path: '',
|
||||
variable: { value: '', is_secret: true, description: '' },
|
||||
labels: undefined,
|
||||
wsSpecific: false
|
||||
}
|
||||
: {
|
||||
path: v.path,
|
||||
variable: {
|
||||
value: v.value ?? '',
|
||||
is_secret: v.is_secret,
|
||||
description: v.description ?? ''
|
||||
},
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: VariableState = savedDraftState ?? deployedState
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
// CREATE, not update (update 404s).
|
||||
existedInitially[ws] = !(v as any).no_deployed
|
||||
openedWith[ws] = structuredClone(s)
|
||||
existedInitially[ws] = !noDeployed
|
||||
// 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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** 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> {
|
||||
await newDraftSync.flush()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
// Clearing workspaceSpecs triggers useMany's reconcile to release
|
||||
// every acquired entry. The $derived `states` then collapses to {}.
|
||||
@@ -210,6 +274,8 @@
|
||||
extraPerms = {}
|
||||
perWsUser = {}
|
||||
pathError = ''
|
||||
openedWith = {}
|
||||
newDraftSync.reset()
|
||||
}
|
||||
|
||||
export function initNew(): void {
|
||||
@@ -224,6 +290,7 @@
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
openedWith[ws] = structuredClone(s)
|
||||
existedInitially[ws] = false
|
||||
selected = ws
|
||||
drawer?.openDrawer()
|
||||
@@ -238,7 +305,8 @@
|
||||
}
|
||||
|
||||
async function loadSecret(): Promise<void> {
|
||||
if (!editPath || !selected) return
|
||||
// A draft-only variable has no deployed secret to load.
|
||||
if (!editPath || !selected || !existedInitially[selected]) return
|
||||
const getV = await VariableService.getVariable({
|
||||
workspace: selected,
|
||||
path: editPath,
|
||||
@@ -257,6 +325,7 @@
|
||||
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,
|
||||
@@ -292,8 +361,18 @@
|
||||
// `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
|
||||
UserDraft.discard('variable', editPath ?? '', s, { workspace: ws })
|
||||
// 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 {
|
||||
// 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.
|
||||
invalidateWorkspacePaths(ws)
|
||||
@@ -307,7 +386,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
on:close={async () => {
|
||||
clearPageDrawerAnchor(VARIABLES_PATH)
|
||||
// A new variable left unsaved persists as a draft-only row, which a list
|
||||
// only sees on refetch — settle the write before asking for that refetch.
|
||||
await flushDraft()
|
||||
dispatch('close')
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
bannerReserved={edit}
|
||||
@@ -319,11 +408,26 @@
|
||||
reserveSpace={edit}
|
||||
getDeployed={() => (selected ? initialStates[selected] : undefined)}
|
||||
getCurrent={() => current}
|
||||
onDiscard={() => {
|
||||
onDiscard={async () => {
|
||||
if (!selected) return
|
||||
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
if (existedInitially[selected]) {
|
||||
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
})
|
||||
return
|
||||
}
|
||||
// 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,
|
||||
itemKind: 'variable',
|
||||
path: editPath ?? ''
|
||||
})
|
||||
drawer?.closeDrawer()
|
||||
}}
|
||||
disabled={!can_write}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushSync } from 'svelte'
|
||||
|
||||
const save = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const discard = vi.fn()
|
||||
const forcePersist = vi.fn(async () => {})
|
||||
const flush = vi.fn(async () => {})
|
||||
vi.mock('$lib/userDraft.svelte', () => ({
|
||||
UserDraft: {
|
||||
save: (...a: unknown[]) => save(...a),
|
||||
remove: (...a: unknown[]) => remove(...a),
|
||||
discard: (...a: unknown[]) => discard(...a),
|
||||
forcePersist: (...a: unknown[]) => forcePersist(...a)
|
||||
}
|
||||
}))
|
||||
vi.mock('$lib/userDraftDbSyncer.svelte', () => ({
|
||||
UserDraftDbSyncer: { flush: (...a: unknown[]) => flush(...(a as [])) }
|
||||
}))
|
||||
|
||||
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
/** Drives the helper through a new-item drawer session and pins the writes it
|
||||
* must and must not make: nothing for an untouched form (the path field
|
||||
* auto-fills a name on mount), a move that deletes the key it left, a delete
|
||||
* once the path fails validation, and no delete at all on teardown — a draft
|
||||
* left behind by closing the drawer is the feature. */
|
||||
describe('useNewItemDraftSync', () => {
|
||||
it('writes only a touched form, follows the path, and leaves the draft on teardown', () => {
|
||||
const form = $state({ path: 'u/me/auto_name', pathError: '', touched: false, n: 1 })
|
||||
let draftPath = ''
|
||||
const cleanup = $effect.root(() => {
|
||||
const sync = useNewItemDraftSync({
|
||||
itemKind: 'resource',
|
||||
enabled: () => true,
|
||||
workspace: () => 'w',
|
||||
path: () => form.path,
|
||||
pathError: () => form.pathError,
|
||||
contentTouched: () => form.touched,
|
||||
value: () => ({ n: form.n })
|
||||
})
|
||||
$effect(() => {
|
||||
draftPath = sync.draftPath
|
||||
})
|
||||
})
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(2000)
|
||||
flushSync()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
|
||||
form.touched = true
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenCalledWith('resource', 'u/me/auto_name', { n: 1 }, { workspace: 'w' })
|
||||
expect(draftPath).toBe('u/me/auto_name')
|
||||
|
||||
form.n = 2
|
||||
flushSync()
|
||||
expect(save).toHaveBeenLastCalledWith(
|
||||
'resource',
|
||||
'u/me/auto_name',
|
||||
{ n: 2 },
|
||||
{ workspace: 'w' }
|
||||
)
|
||||
|
||||
form.path = 'u/me/renamed'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
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(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['resource', 'u/me/renamed'])
|
||||
expect(draftPath).toBe('')
|
||||
|
||||
form.pathError = ''
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
|
||||
|
||||
cleanup()
|
||||
expect(discard).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
/** The commit is delayed so the key can't land on a half-typed path, and the
|
||||
* editor is destroyed the moment its drawer closes. Closing right after the
|
||||
* first edit therefore tears down mid-delay, and the draft must survive it. */
|
||||
it('persists a commit still pending when the editor is torn down', async () => {
|
||||
const form = $state({ path: 'u/me/quick', touched: false, 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: () => form.touched,
|
||||
value: () => ({ n: form.n })
|
||||
})
|
||||
})
|
||||
flushSync()
|
||||
form.touched = true
|
||||
flushSync()
|
||||
|
||||
// Torn down half-way through the commit delay.
|
||||
vi.advanceTimersByTime(500)
|
||||
cleanup()
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(save).toHaveBeenCalledWith('resource', 'u/me/quick', { n: 1 }, { workspace: 'w' })
|
||||
|
||||
// The drawer's close handler awaits this, so the list refetch behind it
|
||||
// sees the row rather than racing the syncer's debounce.
|
||||
await sync!.flush()
|
||||
expect(flush).toHaveBeenCalledWith({
|
||||
workspace: 'w',
|
||||
itemKind: 'resource',
|
||||
path: 'u/me/quick'
|
||||
})
|
||||
})
|
||||
|
||||
/** A close cuts the commit delay short, so `Path`'s debounced existence
|
||||
* check may not have run. Keying a draft on an occupied path would hand it
|
||||
* to the item already there, and saving from that item would overwrite it. */
|
||||
it('refuses to commit a forced flush onto an occupied path', async () => {
|
||||
const form = $state({ path: 'u/me/taken', touched: false, n: 1 })
|
||||
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
|
||||
const cleanup = $effect.root(() => {
|
||||
sync = useNewItemDraftSync({
|
||||
itemKind: 'resource',
|
||||
enabled: () => true,
|
||||
workspace: () => 'w',
|
||||
path: () => form.path,
|
||||
// Still clear: the check that would set it has not run yet.
|
||||
pathError: () => '',
|
||||
contentTouched: () => form.touched,
|
||||
value: () => ({ n: form.n }),
|
||||
pathIsFree: async () => false
|
||||
})
|
||||
})
|
||||
flushSync()
|
||||
form.touched = true
|
||||
flushSync()
|
||||
|
||||
await sync!.flush()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
/** A move deletes the key it left on the same debounce as the write, so the
|
||||
* close-time flush has to settle both or the list refetch renders a ghost
|
||||
* row at the old path. */
|
||||
it('settles the key a move deleted, not just the one it wrote', async () => {
|
||||
const form = $state({ path: 'u/me/first', touched: true, 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: () => form.touched,
|
||||
value: () => ({ n: form.n })
|
||||
})
|
||||
})
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
|
||||
form.path = 'u/me/second'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
|
||||
await sync!.flush()
|
||||
const flushed = flush.mock.calls.map((c: any[]) => c[0].path)
|
||||
expect(flushed).toContain('u/me/first')
|
||||
expect(flushed).toContain('u/me/second')
|
||||
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()
|
||||
})
|
||||
|
||||
/** The list synthesizes a draft-only row from the path INSIDE the draft while
|
||||
* get and delete address the key, so a stored draft has to describe its own
|
||||
* key. A rename the draft can't follow yet must not smuggle the new path
|
||||
* into the row it is still living in. */
|
||||
it('stores a draft describing the key it lives under, not an unusable path', async () => {
|
||||
const form = $state({ path: 'u/me/home', 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: () => ({ path: form.path, n: form.n }),
|
||||
keyed: (v, p) => ({ ...v, path: p })
|
||||
})
|
||||
sync.adopt('w', 'u/me/home', { path: 'u/me/home', n: 1 })
|
||||
})
|
||||
flushSync()
|
||||
|
||||
// Renamed to a path the draft cannot move to, then edited.
|
||||
form.path = 'u/me/taken'
|
||||
form.pathError = 'path already used'
|
||||
form.n = 2
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(2000)
|
||||
flushSync()
|
||||
await sync!.flush()
|
||||
expect(save).toHaveBeenLastCalledWith(
|
||||
'resource',
|
||||
'u/me/home',
|
||||
{ path: 'u/me/home', n: 2 },
|
||||
{ workspace: 'w' }
|
||||
)
|
||||
})
|
||||
|
||||
/** Renaming away suspends the handle pinned to the original key; renaming
|
||||
* back has to resume it, or the draft is deleted at both keys and written
|
||||
* to neither. */
|
||||
it('resumes a key it returns to after abandoning it', async () => {
|
||||
const form = $state({ path: 'u/me/there', n: 1 })
|
||||
const events: 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) => events.push(`abandon:${p}`),
|
||||
onResumeKey: (_ws, p) => events.push(`resume:${p}`)
|
||||
})
|
||||
sync.adopt('w', 'u/me/there', { n: 1 })
|
||||
})
|
||||
flushSync()
|
||||
|
||||
form.path = 'u/me/away'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
|
||||
form.path = 'u/me/there'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
await sync!.flush()
|
||||
expect(events).toEqual(['abandon:u/me/there', 'abandon:u/me/away', 'resume:u/me/there'])
|
||||
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/there', { n: 1 }, { workspace: 'w' })
|
||||
// The resumed handle's own change detection can no-op this write away.
|
||||
expect(forcePersist).toHaveBeenCalledWith('resource', 'u/me/there', { workspace: 'w' })
|
||||
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
|
||||
* 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. */
|
||||
it('treats the auto-filled path as untouched but a typed one as an edit', () => {
|
||||
const form = $state({ path: '', n: 1 })
|
||||
const cleanup = $effect.root(() => {
|
||||
useNewItemDraftSync({
|
||||
itemKind: 'resource',
|
||||
enabled: () => true,
|
||||
workspace: () => 'w',
|
||||
path: () => form.path,
|
||||
pathError: () => '',
|
||||
contentTouched: () => false,
|
||||
value: () => ({ n: form.n })
|
||||
})
|
||||
})
|
||||
flushSync()
|
||||
// `Path` fills its generated name in after mount.
|
||||
form.path = 'u/me/lucky_resource'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(2000)
|
||||
flushSync()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
|
||||
form.path = 'u/me/typed_by_hand'
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
'resource',
|
||||
'u/me/typed_by_hand',
|
||||
{ n: 1 },
|
||||
{ workspace: 'w' }
|
||||
)
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('finish deletes the persisted key and stops mirroring until reset', async () => {
|
||||
const form = $state({ path: 'u/me/item', touched: true, n: 1 })
|
||||
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
|
||||
const cleanup = $effect.root(() => {
|
||||
sync = useNewItemDraftSync({
|
||||
itemKind: 'variable',
|
||||
enabled: () => true,
|
||||
workspace: () => 'w',
|
||||
path: () => form.path,
|
||||
pathError: () => '',
|
||||
contentTouched: () => form.touched,
|
||||
value: () => ({ n: form.n })
|
||||
})
|
||||
})
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
|
||||
await sync!.finish()
|
||||
expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['variable', 'u/me/item'])
|
||||
|
||||
form.n = 2
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
|
||||
sync!.reset()
|
||||
form.n = 3
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenLastCalledWith('variable', 'u/me/item', { n: 3 }, { workspace: 'w' })
|
||||
expect(discard).toHaveBeenCalledTimes(1)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
/** Longer than `Path`'s 500ms debounced existence check, so the key never
|
||||
* lands on a half-typed path and the check's verdict is in before a commit. */
|
||||
const COMMIT_DELAY_MS = 1000
|
||||
|
||||
export interface NewItemDraftSyncOptions<V> {
|
||||
itemKind: UserDraftItemKind
|
||||
/** Reactive: false leaves the helper inert (edit mode — the handle syncs). */
|
||||
enabled: () => boolean
|
||||
/** Reactive workspace the draft is stored in. */
|
||||
workspace: () => string | undefined
|
||||
/** Reactive path field (`''` while none). */
|
||||
path: () => string
|
||||
/** Reactive `Path` validation error (`''` when valid). */
|
||||
pathError: () => string
|
||||
/** Reactive: the user edited the form's content. The path is not part of
|
||||
* this — `Path` auto-fills a name on mount, and this helper tracks a
|
||||
* departure from that name itself. */
|
||||
contentTouched: () => boolean
|
||||
/** 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 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
|
||||
/** Called before writing to a key previously passed to `onAbandonKey`, so
|
||||
* the editor can resume the handle it suspended there. */
|
||||
onResumeKey?: (workspace: string, path: string) => void
|
||||
/** Return `value` with its own path set to `path`. A stored draft has to
|
||||
* describe the key it lives under: the list synthesizes a draft-only row
|
||||
* from the path INSIDE the draft, while get and delete address the key, so
|
||||
* letting the two diverge makes the row unreachable. Divergence is normal
|
||||
* while the form holds a path the draft cannot move to yet. */
|
||||
keyed?: (value: V, path: string) => V
|
||||
}
|
||||
|
||||
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). */
|
||||
flush(): Promise<void>
|
||||
/** Delete the persisted draft and stop mirroring, once the item is created. */
|
||||
finish(): Promise<void>
|
||||
/** Re-arm for the next drawer session (an editor instance that outlives
|
||||
* its drawer). Forgets the previous session's key without deleting it: a
|
||||
* draft left behind by closing the drawer is the point. */
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side autosave for a drawer editor's brand-new item. Those editors
|
||||
* key their `useMany` handle on the path they were opened with, which is
|
||||
* empty for a new item, so the handle is detached and never POSTs. This
|
||||
* mirrors the form into a draft keyed by the typed path instead — the key
|
||||
* 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<V> {
|
||||
let draftPath = $state('')
|
||||
let finished = $state(false)
|
||||
// The key last written, workspace included: a move or a delete has to target
|
||||
// the row actually left behind, not wherever the form points now.
|
||||
let written: { workspace: string; path: string } | undefined
|
||||
let writtenValue: string | undefined
|
||||
// Every key this session has touched and not yet settled — the deletes a
|
||||
// move leaves behind included, since those POST on the same debounce as the
|
||||
// write and would otherwise still be pending when the list refetches.
|
||||
let unsettled: { workspace: string; path: string }[] = []
|
||||
// The commit the timer will make, snapshotted at schedule time so it still
|
||||
// lands once the editor is gone: closing a drawer a keystroke after the
|
||||
// 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>
|
||||
workspace: string | undefined
|
||||
key: string
|
||||
value: V | undefined
|
||||
}
|
||||
| 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
|
||||
// Keys handed to `onAbandonKey`, so a return to one can resume it.
|
||||
const abandoned = new Set<string>()
|
||||
|
||||
function markUnsettled(workspace: string, path: string): void {
|
||||
if (!unsettled.some((k) => k.workspace === workspace && k.path === path)) {
|
||||
unsettled.push({ workspace, path })
|
||||
}
|
||||
}
|
||||
|
||||
function touched(): boolean {
|
||||
const p = opts.path()
|
||||
if (autoPath === undefined && p !== '') autoPath = p
|
||||
return opts.contentTouched() || (p !== '' && p !== autoPath)
|
||||
}
|
||||
|
||||
function write(workspace: string | undefined, path: string, value: V | undefined): void {
|
||||
if (written && (written.path !== path || written.workspace !== 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 })
|
||||
opts.onAbandonKey?.(written.workspace, written.path)
|
||||
abandoned.add(`${written.workspace}/${written.path}`)
|
||||
markUnsettled(written.workspace, written.path)
|
||||
written = undefined
|
||||
writtenValue = undefined
|
||||
}
|
||||
if (!workspace || !path || value === undefined) return
|
||||
// Stored describing its own key: while the form holds a path the draft
|
||||
// cannot move to yet, the row must still name where it actually lives.
|
||||
const stored = opts.keyed ? opts.keyed(value, path) : value
|
||||
const serialized = JSON.stringify(stored)
|
||||
if (written && serialized === writtenValue) return
|
||||
const resumed = abandoned.delete(`${workspace}/${path}`)
|
||||
if (resumed) opts.onResumeKey?.(workspace, path)
|
||||
UserDraft.save(opts.itemKind, path, stored, { workspace })
|
||||
if (resumed) {
|
||||
// A live handle at this key mirrors CHANGES, and its baseline advanced
|
||||
// while it was suspended — the value we just restored can equal it, so
|
||||
// nothing would be sent and the row we deleted on the way out would
|
||||
// never come back. Safe here: only a never-deployed draft is keyed this
|
||||
// way, so there is no baseline this could overwrite.
|
||||
void UserDraft.forcePersist(opts.itemKind, path, { workspace })
|
||||
}
|
||||
markUnsettled(workspace, path)
|
||||
written = { workspace, path }
|
||||
writtenValue = serialized
|
||||
}
|
||||
|
||||
function dropPending(): void {
|
||||
if (!pending) return
|
||||
clearTimeout(pending.timer)
|
||||
pending = undefined
|
||||
}
|
||||
|
||||
/** 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
|
||||
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(p.workspace, p.key, p.value)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!opts.enabled() || finished) return
|
||||
const p = opts.path()
|
||||
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)) {
|
||||
// Back on the committed key: a transition away from it is stale.
|
||||
untrack(dropPending)
|
||||
return
|
||||
}
|
||||
untrack(() => {
|
||||
dropPending()
|
||||
pending = {
|
||||
timer: setTimeout(() => void commitPending(), COMMIT_DELAY_MS),
|
||||
workspace,
|
||||
key,
|
||||
value
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!opts.enabled() || finished) return
|
||||
const workspace = opts.workspace()
|
||||
const key = draftPath
|
||||
const value = opts.value()
|
||||
untrack(() => {
|
||||
if (key) write(workspace, key, value)
|
||||
})
|
||||
})
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
const keys = unsettled
|
||||
unsettled = []
|
||||
await Promise.all(
|
||||
keys.map((k) =>
|
||||
UserDraftDbSyncer.flush({ workspace: k.workspace, itemKind: opts.itemKind, path: k.path })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
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() {
|
||||
await commitPending()
|
||||
await settle()
|
||||
},
|
||||
async finish() {
|
||||
finished = true
|
||||
dropPending()
|
||||
const w = written
|
||||
written = undefined
|
||||
writtenValue = undefined
|
||||
draftPath = ''
|
||||
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()
|
||||
},
|
||||
reset() {
|
||||
finished = false
|
||||
adopted = false
|
||||
abandoned.clear()
|
||||
dropPending()
|
||||
written = undefined
|
||||
writtenValue = undefined
|
||||
unsettled = []
|
||||
autoPath = undefined
|
||||
draftPath = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { page } from '$app/state'
|
||||
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
@@ -314,7 +316,25 @@
|
||||
loading.types = false
|
||||
}
|
||||
|
||||
async function deleteResource(path: string, account?: number): Promise<void> {
|
||||
async function deleteResource(
|
||||
path: string,
|
||||
account?: number,
|
||||
draftOnly?: boolean
|
||||
): Promise<void> {
|
||||
if (draftOnly) {
|
||||
// The row is the user's own draft and nothing else: the resource delete
|
||||
// would 404 on the missing deployed row. `UserDraft.remove` rather than a
|
||||
// bare POST so the in-memory cache is evicted too — the global AI chat
|
||||
// lists drafts from it and would keep offering this one.
|
||||
UserDraft.remove('resource', path, { workspace: $workspaceStore! })
|
||||
await UserDraftDbSyncer.flush({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'resource',
|
||||
path
|
||||
})
|
||||
reload()
|
||||
return
|
||||
}
|
||||
if (account) {
|
||||
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
|
||||
}
|
||||
@@ -1313,12 +1333,12 @@
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteResource(path, account)
|
||||
deleteResource(path, account, draft_only)
|
||||
} else {
|
||||
deleteIsLinked = is_linked ?? false
|
||||
deletePath = path
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteResource(path, account)
|
||||
deleteResource(path, account, draft_only)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1477,13 +1497,14 @@
|
||||
{/if}
|
||||
|
||||
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
|
||||
<AppConnect bind:this={appConnect} on:refresh={loadResources} />
|
||||
<AppConnect bind:this={appConnect} on:refresh={loadResources} on:close={loadResources} />
|
||||
<AgentEvalModal agentPath={evalsAgentPath} bind:open={evalsOpen} />
|
||||
|
||||
<ResourceEditorDrawer
|
||||
bind:this={resourceEditor}
|
||||
on:refresh={loadResources}
|
||||
onRestored={loadResources}
|
||||
onClose={loadResources}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import {
|
||||
Alert,
|
||||
@@ -217,7 +219,26 @@
|
||||
loading.contextual = false
|
||||
}
|
||||
|
||||
async function deleteVariable(path: string, account?: number): Promise<void> {
|
||||
async function deleteVariable(
|
||||
path: string,
|
||||
account?: number,
|
||||
draftOnly?: boolean
|
||||
): Promise<void> {
|
||||
if (draftOnly) {
|
||||
// The row is the user's own draft and nothing else: the variable delete
|
||||
// would 404 on the missing deployed row. `UserDraft.remove` rather than a
|
||||
// bare POST so the in-memory cache is evicted too — the global AI chat
|
||||
// lists drafts from it and would keep offering this one.
|
||||
UserDraft.remove('variable', path, { workspace: $workspaceStore! })
|
||||
await UserDraftDbSyncer.flush({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'variable',
|
||||
path
|
||||
})
|
||||
loadVariables()
|
||||
sendUserToast(`Draft ${path} was deleted`)
|
||||
return
|
||||
}
|
||||
if (account) {
|
||||
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
|
||||
}
|
||||
@@ -316,7 +337,7 @@
|
||||
</PageHeader>
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
|
||||
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
|
||||
<VariableEditor bind:this={variableEditor} on:create={loadVariables} on:close={loadVariables} />
|
||||
<ContextualVariableEditor
|
||||
bind:this={contextualVariableEditor}
|
||||
on:update={loadContextualVariables}
|
||||
@@ -563,11 +584,11 @@
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
if (event['shiftKey']) {
|
||||
deleteVariable(path, account)
|
||||
deleteVariable(path, account, draft_only)
|
||||
} else {
|
||||
deleteIsLinked = is_linked ?? false
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteVariable(path, account)
|
||||
deleteVariable(path, account, draft_only)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -240,10 +240,14 @@ const config = {
|
||||
// replay sanitization) whose contracts can only be asserted against a
|
||||
// real document.
|
||||
extends: './vite.config.js',
|
||||
// Node resolution picks svelte's server entry, where `$effect` is inert;
|
||||
// `*.svelte.dom.test.ts` rune modules need the browser one to run effects.
|
||||
resolve: { conditions: ['browser'] },
|
||||
test: {
|
||||
name: 'dom',
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.dom.{test,spec}.{js,ts}']
|
||||
include: ['src/**/*.dom.{test,spec}.{js,ts}'],
|
||||
server: { deps: { inline: ['svelte'] } }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user