From 81a0773edfd7dbbd11f30d64c079db96fd4bb48e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 3 Sep 2026 23:46:55 +0200 Subject: [PATCH] feat(frontend): autosave drafts for new resources and variables Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG --- .../src/lib/components/AppConnectInner.svelte | 49 +++++++ .../src/lib/components/ResourceEditor.svelte | 102 +++++++++++--- .../components/ResourceEditorDrawer.svelte | 18 ++- .../src/lib/components/ResourceForm.svelte | 8 ++ .../src/lib/components/VariableEditor.svelte | 98 +++++++++++--- .../src/lib/components/VariableForm.svelte | 4 + .../useNewItemDraftSync.svelte.dom.test.ts | 128 ++++++++++++++++++ .../components/useNewItemDraftSync.svelte.ts | 96 +++++++++++++ .../(root)/(logged)/resources/+page.svelte | 27 +++- .../(root)/(logged)/variables/+page.svelte | 27 +++- frontend/vite.config.js | 6 +- 11 files changed, 513 insertions(+), 50 deletions(-) create mode 100644 frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts create mode 100644 frontend/src/lib/components/useNewItemDraftSync.svelte.ts diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index a9a0837d1f..226142eb9c 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -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 { + const out: Record = {} + 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 @@ 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({ + 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 { + 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} current!.path, setPath} + bind:pathError + bind:pathDirty bind:labels={current.labels} bind:description={current.description} bind:args={current.args} diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 296ab693a8..5f7317963a 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -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 } | 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?.() + }} > 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} diff --git a/frontend/src/lib/components/ResourceForm.svelte b/frontend/src/lib/components/ResourceForm.svelte index cddd83aae7..5ee39d7ebe 100644 --- a/frontend/src/lib/components/ResourceForm.svelte +++ b/frontend/src/lib/components/ResourceForm.svelte @@ -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 @@ -53,6 +57,8 @@ path = $bindable(), initialPath, hidePath = false, + pathError = $bindable(''), + pathDirty = $bindable(false), labels = $bindable(), description = $bindable(), args = $bindable(), @@ -160,6 +166,8 @@ = $state({}) let selected: string | undefined = $state(undefined) let pathError = $state('') + let pathDirty = $state(false) const handlesArray = UserDraft.useMany(() => 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({ + 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 { - 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 @@ } - clearPageDrawerAnchor(VARIABLES_PATH)}> + { + clearPageDrawerAnchor(VARIABLES_PATH) + // A new variable left unsaved persists as a draft-only row, which a + // list only sees on refetch. + dispatch('close') + }} +> (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} diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index 662f949957..e03f524b3b 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -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 @@ ({ + 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 | 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() + }) +}) diff --git a/frontend/src/lib/components/useNewItemDraftSync.svelte.ts b/frontend/src/lib/components/useNewItemDraftSync.svelte.ts new file mode 100644 index 0000000000..1c87be33f5 --- /dev/null +++ b/frontend/src/lib/components/useNewItemDraftSync.svelte.ts @@ -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 { + 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(opts: NewItemDraftSyncOptions): 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 = '' + } + } +} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 4932355654..46c3be91e2 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -1,5 +1,6 @@