mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user