feat(frontend): wire flow editor to UserDraft

flows/add and flows/edit drive the flow value through a StateStore
adapter backed by UserDraft.use, so every edit auto-persists at
userdraft/w/{ws}/flow/{path} without touching FlowBuilder's internal
.val convention. On returning visits the local autosave wins and a
toast offers a diff against the latest backend draft/deployed version;
on a fresh visit the backend value is written into the handle. Deploy,
save-as-draft rename, restore-draft and restore-deployed each call
UserDraft.remove on the route path so the local autosave doesn't
outlive the action.

Adds UserDraft.has() for "is there already a local draft?" detection
in the load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-11 23:25:12 +02:00
parent da692257da
commit 46cfe43e60
3 changed files with 117 additions and 22 deletions
+20
View File
@@ -138,6 +138,26 @@ export const UserDraft = {
}
},
/**
* Whether a draft currently exists for (workspace, itemKind, path).
* For non-empty paths this checks localStorage; for empty paths it
* checks the in-memory entry. Useful for distinguishing "first visit"
* from "returning visit with unsaved local changes".
*/
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return entry.state.val !== undefined
if (isLocalOnly(path)) return false
try {
const raw = localStorage.getItem(localStorageKey(ws, itemKind, path))
return raw != null && raw !== 'undefined'
} catch {
return false
}
},
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
@@ -13,6 +13,7 @@
import { tick } from 'svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
import { UserDraft } from '$lib/userDraft.svelte'
let nodraft = page.url.searchParams.get('nodraft')
@@ -57,8 +58,8 @@
// initialArgs may also be set from decoded state below (e.g. fork preview)
let flowBuilder: FlowBuilder | undefined = $state(undefined)
const flowStore: StateStore<Flow> = $state({
val: {
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
@@ -68,23 +69,26 @@
extra_perms: {},
schema: emptySchema()
}
})
}
// New flow: empty path → in-memory only (no localStorage).
const flowHandle = UserDraft.use<Flow>('flow', '', { defaultValue: emptyFlow() })
const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
}
}
const flowStateStore = $state({ val: {} })
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
async function loadFlow() {
loading = true
let flow: Flow = {
path: '',
summary: '',
value: { modules: [] },
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
let flow: Flow = emptyFlow()
let state = forkState
const initialStateQuery = page.url.hash != '' ? page.url.hash.slice(1) : undefined
@@ -126,7 +130,6 @@
initialPath = `u/${$userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')}/${
oldPath[oldPath.length - 1]
}_fork`
flow = flow
goto('?', { replaceState: true })
selectedId = 'settings-metadata'
} else if (hubId) {
@@ -142,7 +145,6 @@
flow.value.preprocessor_module.value.content
)
}
flow = flow
goto('?', { replaceState: true })
selectedId = 'constants'
}
@@ -172,9 +174,11 @@
<FlowBuilder
onSaveInitial={(e) => {
UserDraft.remove('flow', '')
goto(`/flows/edit/${e.path}?selected=${e.id}`)
}}
onDeploy={(e) => {
UserDraft.remove('flow', '')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {
@@ -3,7 +3,13 @@
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { initialArgsStore, workspaceStore } from '$lib/stores'
import { cleanValueProperties, decodeState, emptySchema, type StateStore } from '$lib/utils'
import {
cleanValueProperties,
decodeState,
emptySchema,
orderedJsonStringify,
type StateStore
} from '$lib/utils'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
import { goto } from '$lib/navigation'
import { afterNavigate, replaceState } from '$app/navigation'
@@ -16,6 +22,7 @@
import { untrack } from 'svelte'
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
let version: undefined | number = $state(undefined)
let nodraft = page.url.searchParams.get('nodraft')
@@ -44,8 +51,11 @@
}
})
export const flowStore: StateStore<Flow> = $state({
val: {
const flowDraftPath = page.params.path ?? ''
const flowHandle = UserDraft.use<Flow>('flow', flowDraftPath)
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
@@ -55,7 +65,16 @@
extra_perms: {},
schema: emptySchema()
}
})
}
export const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
}
}
const flowStateStore = $state({ val: {} })
let loading = $state(false)
@@ -104,13 +123,62 @@
draft_triggers?: Trigger[]
}
}
const backendFlow =
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
const localDraft = flowHandle.draft
if (localDraft != undefined) {
// Returning visit: local autosave is the source of truth. If it
// matches the backend's view exactly, drop the toast — otherwise
// let the user decide between keeping local or discarding.
const localClean = cleanValueProperties(localDraft)
const backendClean = cleanValueProperties(backendFlow)
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
flow = backendFlow
flowHandle.draft = backendFlow
} else {
flow = localDraft
sendUserToast('Flow loaded from local autosave', false, [
{
label: 'Discard local autosave',
callback: () => {
flowHandle.draft = backendFlow
loadFlow()
}
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: backendClean,
current: localClean,
title: `${flowWithDraft.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: {
text: 'Discard autosave',
onClick: () => {
flowHandle.draft = backendFlow
loadFlow()
}
}
})
}
}
])
}
} else {
flow = backendFlow
flowHandle.draft = backendFlow
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
flow = flowWithDraft.draft
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
if (!flowWithDraft.draft_only) {
if (!flowWithDraft.draft_only && localDraft == undefined) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
const reloadAction = async () => {
@@ -119,6 +187,7 @@
kind: 'flow',
path: flow.path
})
UserDraft.remove('flow', flowDraftPath)
nobackenddraft = true
loadFlow()
}
@@ -143,7 +212,6 @@
])
}
} else {
flow = flowWithDraft
flowBuilder?.setDraftTriggers(undefined)
}
@@ -167,6 +235,7 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('flow', flowDraftPath)
goto(`/flows/edit/${savedFlow.draft.path}`)
loadFlow()
}
@@ -184,6 +253,7 @@
path: savedFlow.path
})
}
UserDraft.remove('flow', flowDraftPath)
goto(`/flows/edit/${savedFlow.path}`)
loadFlow()
}
@@ -200,6 +270,7 @@
{:else}
<FlowBuilder
onDeploy={(e) => {
UserDraft.remove('flow', flowDraftPath)
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {