mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
feat(frontend): autosave drafts for new resources and variables
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6a7a6d9144
commit
81a0773edf
@@ -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'
|
||||
@@ -328,6 +329,46 @@
|
||||
}
|
||||
|
||||
let pathError = $state('')
|
||||
let pathDirty = $state(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,
|
||||
touched: () =>
|
||||
pathDirty ||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
export async function open(rt?: string) {
|
||||
if (!rt) {
|
||||
@@ -952,6 +993,7 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
newDraftSync.finish()
|
||||
dispatch('refresh', path)
|
||||
dispatch('close')
|
||||
sendUserToast(
|
||||
@@ -964,6 +1006,12 @@
|
||||
}
|
||||
|
||||
export async function back() {
|
||||
if (step == 2 && manual) {
|
||||
// Back abandons this form; the draft it mirrored goes with it.
|
||||
newDraftSync.finish()
|
||||
newDraftSync.reset()
|
||||
pathDirty = false
|
||||
}
|
||||
if (step == 4) {
|
||||
step -= 2
|
||||
} else if (step > 1) {
|
||||
@@ -1295,6 +1343,7 @@
|
||||
<ResourcePathHint />
|
||||
<Path
|
||||
bind:error={pathError}
|
||||
bind:dirty={pathDirty}
|
||||
bind:path
|
||||
initialPath=""
|
||||
namePlaceholder={resourceType}
|
||||
|
||||
@@ -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,26 @@
|
||||
})
|
||||
)
|
||||
|
||||
let pathError = $state('')
|
||||
let pathDirty = $state(false)
|
||||
|
||||
// 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.
|
||||
const newDraftSync = useNewItemDraftSync<ResourceState>({
|
||||
itemKind: 'resource',
|
||||
enabled: () => !initialPath,
|
||||
workspace: () => selected,
|
||||
path: () => current?.path ?? '',
|
||||
pathError: () => pathError,
|
||||
touched: () =>
|
||||
pathDirty ||
|
||||
(!!current &&
|
||||
!!selected &&
|
||||
!draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' })),
|
||||
value: () => (current ? ($state.snapshot(current) as ResourceState) : undefined)
|
||||
})
|
||||
|
||||
// New-resource bootstrap: seed empty state per workspace (edit mode
|
||||
// is seeded by the lazy-fetch effect below).
|
||||
$effect(() => {
|
||||
@@ -210,7 +236,8 @@
|
||||
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)
|
||||
@@ -231,22 +258,39 @@
|
||||
// `.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
|
||||
existedInitially[ws] = !noDeployed
|
||||
perWsUser[ws] = user
|
||||
// Keep resource_type in sync for the base workspace (controls the schema)
|
||||
if (ws === effectiveWorkspace) {
|
||||
@@ -268,7 +312,7 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
canSave = anyDirty && dirtyValid && dirtyCanWrite
|
||||
canSave = anyDirty && dirtyValid && dirtyCanWrite && pathError === ''
|
||||
})
|
||||
|
||||
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
|
||||
@@ -287,11 +331,27 @@
|
||||
export function localDraftCurrent(): ResourceState | undefined {
|
||||
return current
|
||||
}
|
||||
export function discardLocalDraft(): void {
|
||||
if (!selected) return
|
||||
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
/** 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: 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.
|
||||
UserDraft.remove('resource', initialPath ?? '', { workspace: selected })
|
||||
await UserDraftDbSyncer.flush({
|
||||
workspace: selected,
|
||||
itemKind: 'resource',
|
||||
path: initialPath ?? ''
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -355,11 +415,15 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
// 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
|
||||
existedInitially[ws] = true
|
||||
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
|
||||
if (initialPath) {
|
||||
// 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 })
|
||||
} else {
|
||||
newDraftSync.finish()
|
||||
}
|
||||
// Path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths(ws)
|
||||
@@ -386,6 +450,8 @@
|
||||
{#key current}
|
||||
<ResourceForm
|
||||
bind:path={() => current!.path, setPath}
|
||||
bind:pathError
|
||||
bind:pathDirty
|
||||
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,7 @@
|
||||
save: () => void
|
||||
localDraftDeployed: () => unknown
|
||||
localDraftCurrent: () => unknown
|
||||
discardLocalDraft: () => void
|
||||
discardLocalDraft: () => Promise<boolean>
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let hasLocalDraft = $state(false)
|
||||
@@ -97,7 +101,10 @@
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
{disableChatOffset}
|
||||
on:close={() => clearPageDrawerAnchor(RESOURCES_PATH)}
|
||||
on:close={() => {
|
||||
clearPageDrawerAnchor(RESOURCES_PATH)
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
|
||||
@@ -131,7 +138,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,10 @@
|
||||
path: string
|
||||
initialPath: string
|
||||
hidePath?: boolean
|
||||
/** `Path`'s validation error (`''` when valid). */
|
||||
pathError?: string
|
||||
/** Whether the user edited the path (as opposed to `Path`'s auto-filled name). */
|
||||
pathDirty?: boolean
|
||||
labels: string[] | undefined
|
||||
description: string
|
||||
args: Record<string, any>
|
||||
@@ -53,6 +57,8 @@
|
||||
path = $bindable(),
|
||||
initialPath,
|
||||
hidePath = false,
|
||||
pathError = $bindable(''),
|
||||
pathDirty = $bindable(false),
|
||||
labels = $bindable(),
|
||||
description = $bindable(),
|
||||
args = $bindable(),
|
||||
@@ -160,6 +166,8 @@
|
||||
<Path
|
||||
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
|
||||
bind:path
|
||||
bind:error={pathError}
|
||||
bind:dirty={pathDirty}
|
||||
{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,7 @@
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
let selected: string | undefined = $state(undefined)
|
||||
let pathError = $state('')
|
||||
let pathDirty = $state(false)
|
||||
|
||||
const handlesArray = UserDraft.useMany<VariableState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
@@ -116,6 +119,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.
|
||||
const newDraftSync = useNewItemDraftSync<VariableState>({
|
||||
itemKind: 'variable',
|
||||
enabled: () => !edit,
|
||||
workspace: () => selected,
|
||||
path: () => current?.path ?? '',
|
||||
pathError: () => pathError,
|
||||
touched: () =>
|
||||
pathDirty ||
|
||||
(!!current &&
|
||||
!!selected &&
|
||||
!draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' })),
|
||||
value: () => (current ? ($state.snapshot(current) as VariableState) : undefined)
|
||||
})
|
||||
|
||||
// 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,25 +196,34 @@
|
||||
]).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
|
||||
existedInitially[ws] = !noDeployed
|
||||
extraPerms[ws] = v.extra_perms ?? {}
|
||||
perWsUser[ws] = user
|
||||
})
|
||||
@@ -210,6 +239,8 @@
|
||||
extraPerms = {}
|
||||
perWsUser = {}
|
||||
pathError = ''
|
||||
pathDirty = false
|
||||
newDraftSync.reset()
|
||||
}
|
||||
|
||||
export function initNew(): void {
|
||||
@@ -238,7 +269,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,
|
||||
@@ -293,7 +325,11 @@
|
||||
// the server draft row so `is_draft` clears on refetch.
|
||||
initialStates[ws] = $state.snapshot(s) as VariableState
|
||||
existedInitially[ws] = true
|
||||
UserDraft.discard('variable', editPath ?? '', s, { workspace: ws })
|
||||
if (editPath) {
|
||||
UserDraft.discard('variable', editPath, s, { workspace: ws })
|
||||
} else {
|
||||
newDraftSync.finish()
|
||||
}
|
||||
// Path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths(ws)
|
||||
@@ -307,7 +343,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
on:close={() => {
|
||||
clearPageDrawerAnchor(VARIABLES_PATH)
|
||||
// A new variable left unsaved persists as a draft-only row, which a
|
||||
// list only sees on refetch.
|
||||
dispatch('close')
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
bannerReserved={edit}
|
||||
@@ -319,11 +364,25 @@
|
||||
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: 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.
|
||||
UserDraft.remove('variable', editPath ?? '', { workspace: selected })
|
||||
await UserDraftDbSyncer.flush({
|
||||
workspace: selected,
|
||||
itemKind: 'variable',
|
||||
path: editPath ?? ''
|
||||
})
|
||||
drawer?.closeDrawer()
|
||||
}}
|
||||
disabled={!can_write}
|
||||
/>
|
||||
@@ -347,6 +406,7 @@
|
||||
bind:this={form}
|
||||
bind:path={current.path}
|
||||
bind:pathError
|
||||
bind:pathDirty
|
||||
bind:variable={current.variable}
|
||||
bind:labels={current.labels}
|
||||
bind:wsSpecific={current.wsSpecific}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
path: string
|
||||
initialPath: string
|
||||
pathError: string
|
||||
/** Whether the user edited the path (as opposed to `Path`'s auto-filled name). */
|
||||
pathDirty?: boolean
|
||||
variable: Variable
|
||||
labels: string[] | undefined
|
||||
wsSpecific: boolean
|
||||
@@ -40,6 +42,7 @@
|
||||
path = $bindable(),
|
||||
initialPath,
|
||||
pathError = $bindable(),
|
||||
pathDirty = $bindable(false),
|
||||
variable = $bindable(),
|
||||
labels = $bindable(),
|
||||
wsSpecific = $bindable(),
|
||||
@@ -73,6 +76,7 @@
|
||||
<Path
|
||||
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
|
||||
bind:error={pathError}
|
||||
bind:dirty={pathDirty}
|
||||
bind:path
|
||||
{initialPath}
|
||||
namePlaceholder="variable"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushSync } from 'svelte'
|
||||
|
||||
const save = vi.fn()
|
||||
const remove = vi.fn()
|
||||
vi.mock('$lib/userDraft.svelte', () => ({
|
||||
UserDraft: {
|
||||
save: (...a: unknown[]) => save(...a),
|
||||
remove: (...a: unknown[]) => remove(...a)
|
||||
}
|
||||
}))
|
||||
|
||||
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,
|
||||
touched: () => 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(remove).toHaveBeenCalledWith('resource', 'u/me/auto_name', { workspace: 'w' })
|
||||
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(draftPath).toBe('')
|
||||
|
||||
form.pathError = ''
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
|
||||
|
||||
cleanup()
|
||||
expect(remove).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('finish deletes the persisted key and stops mirroring until reset', () => {
|
||||
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: () => '',
|
||||
touched: () => form.touched,
|
||||
value: () => ({ n: form.n })
|
||||
})
|
||||
})
|
||||
flushSync()
|
||||
vi.advanceTimersByTime(1000)
|
||||
flushSync()
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
|
||||
sync!.finish()
|
||||
flushSync()
|
||||
expect(remove).toHaveBeenCalledWith('variable', 'u/me/item', { workspace: 'w' })
|
||||
|
||||
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(remove).toHaveBeenCalledTimes(1)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.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 name or the content. `Path` auto-fills a
|
||||
* name on mount, so opening and closing an untouched drawer must not leave
|
||||
* a draft behind. */
|
||||
touched: () => 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
|
||||
}
|
||||
|
||||
export interface NewItemDraftSync {
|
||||
/** Storage path of the persisted draft, `''` when none. */
|
||||
readonly draftPath: string
|
||||
/** Delete the persisted draft and stop mirroring, once the item is created. */
|
||||
finish(): 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 {
|
||||
let draftPath = $state('')
|
||||
let finished = $state(false)
|
||||
// Last key actually written: a moved or finished draft deletes exactly the
|
||||
// row it left behind, and component teardown deletes nothing.
|
||||
let writtenPath = ''
|
||||
|
||||
$effect(() => {
|
||||
if (!opts.enabled() || finished) return
|
||||
const p = opts.path()
|
||||
const target = p !== '' && opts.pathError() === '' && opts.touched() ? p : ''
|
||||
if (target === untrack(() => draftPath)) return
|
||||
const t = setTimeout(() => (draftPath = target), COMMIT_DELAY_MS)
|
||||
return () => clearTimeout(t)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!opts.enabled() || finished) return
|
||||
const ws = opts.workspace()
|
||||
const p = draftPath
|
||||
const v = opts.value()
|
||||
untrack(() => {
|
||||
if (!ws) return
|
||||
if (writtenPath && writtenPath !== p) {
|
||||
UserDraft.remove(opts.itemKind, writtenPath, { workspace: ws })
|
||||
writtenPath = ''
|
||||
}
|
||||
if (!p || v === undefined) return
|
||||
UserDraft.save(opts.itemKind, p, v, { workspace: ws })
|
||||
writtenPath = p
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
get draftPath() {
|
||||
return draftPath
|
||||
},
|
||||
finish() {
|
||||
finished = true
|
||||
const ws = untrack(() => opts.workspace())
|
||||
if (writtenPath && ws) UserDraft.remove(opts.itemKind, writtenPath, { workspace: ws })
|
||||
writtenPath = ''
|
||||
draftPath = ''
|
||||
},
|
||||
reset() {
|
||||
finished = false
|
||||
writtenPath = ''
|
||||
draftPath = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.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 +315,24 @@
|
||||
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.
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'resource',
|
||||
path,
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
reload()
|
||||
return
|
||||
}
|
||||
if (account) {
|
||||
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
|
||||
}
|
||||
@@ -1313,12 +1331,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 +1495,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,6 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import {
|
||||
Alert,
|
||||
@@ -217,7 +218,25 @@
|
||||
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.
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'variable',
|
||||
path,
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
loadVariables()
|
||||
sendUserToast(`Draft ${path} was deleted`)
|
||||
return
|
||||
}
|
||||
if (account) {
|
||||
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
|
||||
}
|
||||
@@ -316,7 +335,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 +582,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