mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
feat(frontend): staleness modal for flow, app, and raw-app editors
Migrates the flow, app, and raw_app editor routes to the same `LocalDraftStaleModal` flow already used by scripts: compare the recorded meta against the current `version` / `versions[last]` and `draft_created_at`; on mismatch, surface the choice in a modal. Adds `UserDraft.saveMeta` for routes that don't hold a live handle (the app editor reads via `UserDraft.get` and the handle lives in the child `AppEditor` component). It writes meta directly to localStorage and tolerates the no-entry case.
This commit is contained in:
@@ -276,6 +276,34 @@ export const UserDraft = {
|
||||
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the rev metadata for an entry without touching the value, and
|
||||
* persist immediately. Used by editor routes that don't hold a live
|
||||
* handle (apps, raw apps) — they read the local draft via `UserDraft.get`
|
||||
* and the handle is created later inside the child editor.
|
||||
*
|
||||
* No-op when the entry has no draft to attach meta to.
|
||||
*/
|
||||
saveMeta(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
meta: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
const current = entry.state.val as StoredDraft<unknown> | undefined
|
||||
if (current === undefined) return
|
||||
entry.state.val = wrap(current.value, meta)
|
||||
}
|
||||
if (isLocalOnly(path)) return
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
if (existing === undefined) return
|
||||
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the rev metadata for the entry. Returns an empty object if there
|
||||
* is no entry. Useful for staleness checks before reading the draft.
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
|
||||
let app = $state(
|
||||
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
|
||||
@@ -36,6 +37,35 @@
|
||||
let redraw = $state(0)
|
||||
let path = page.params.path ?? ''
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
UserDraft.remove('app', path)
|
||||
UserDraft.saveMeta('app', path, pendingBaseline.revs)
|
||||
app = pendingBaseline.baseline
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
UserDraft.saveMeta('app', path, pendingBaseline.revs)
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
|
||||
afterNavigate(() => {
|
||||
@@ -94,37 +124,30 @@
|
||||
: app_w_draft
|
||||
|
||||
const localDraftValue = UserDraft.get<App>('app', path)
|
||||
const previousMeta = UserDraft.getMeta('app', path)
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
if (
|
||||
localDraftValue != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendApp.value))
|
||||
) {
|
||||
const reloadAction = async () => {
|
||||
UserDraft.remove('app', path)
|
||||
await loadApp()
|
||||
redraw++
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendApp, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else if (
|
||||
previousMeta.remoteRev === undefined &&
|
||||
previousMeta.remoteDraftRev === undefined
|
||||
) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
UserDraft.saveMeta('app', path, newRevs)
|
||||
}
|
||||
const deployed = cleanValueProperties(savedApp?.draft || savedApp)
|
||||
const local = { ...deployed, value: localDraftValue }
|
||||
sendUserToast('App restored from local autosave', false, [
|
||||
{
|
||||
label: 'Discard local autosave and reload',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: local,
|
||||
title: `${savedApp?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
|
||||
button: { text: 'Discard autosave', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
app = { ...backendApp, value: localDraftValue }
|
||||
} else {
|
||||
// Local is missing or matches backend — wipe any stale entry so it
|
||||
@@ -227,6 +250,12 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#key redraw}
|
||||
{#if app}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
|
||||
type RawAppDraft = {
|
||||
files: Record<string, string>
|
||||
@@ -55,6 +56,36 @@
|
||||
|
||||
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: RawAppDraft; backendSource: any; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, backendSource, revs } = pendingBaseline
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(baseline, revs)
|
||||
extractRawApp(backendSource)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
draftHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// Persist the bundle whenever any of the four pieces of state changes.
|
||||
$effect(() => {
|
||||
if (!files) return
|
||||
@@ -123,7 +154,14 @@
|
||||
}
|
||||
|
||||
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
|
||||
const localDraft = UserDraft.get<RawAppDraft>('raw_app', path)
|
||||
const localDraft = draftHandle.draft
|
||||
const previousMeta = draftHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: backendSource.value?.files ?? {},
|
||||
runnables: backendSource.value?.runnables ?? {},
|
||||
@@ -140,32 +178,18 @@
|
||||
orderedJsonStringify(cleanValueProperties(localDraft)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendBundle))
|
||||
) {
|
||||
const reloadAction = async () => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
await loadApp()
|
||||
redraw++
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendBundle, backendSource, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else if (
|
||||
previousMeta.remoteRev === undefined &&
|
||||
previousMeta.remoteDraftRev === undefined
|
||||
) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
draftHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const local = { ...deployed, value: localDraft }
|
||||
sendUserToast('App restored from local autosave', false, [
|
||||
{
|
||||
label: 'Discard local autosave and reload',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: local,
|
||||
title: `${app_w_draft.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
|
||||
button: { text: 'Discard autosave', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
runnables = localDraft.runnables
|
||||
data = localDraft.data
|
||||
summary = localDraft.summary
|
||||
@@ -175,6 +199,7 @@
|
||||
} else {
|
||||
if (localDraft != undefined) UserDraft.remove('raw_app', path)
|
||||
extractRawApp(backendSource)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only) {
|
||||
const reloadAction = () => {
|
||||
@@ -269,6 +294,12 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#if files}
|
||||
{#key redraw}
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { untrack } from 'svelte'
|
||||
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
|
||||
let version: undefined | number = $state(undefined)
|
||||
|
||||
@@ -89,6 +90,33 @@
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: Flow; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(baseline, revs)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
flowHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
|
||||
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
|
||||
let loadedFromHistoryFromUrl:
|
||||
@@ -139,50 +167,38 @@
|
||||
const backendFlow =
|
||||
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
|
||||
const localDraft = flowHandle.draft
|
||||
const previousMeta = flowHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: v,
|
||||
remoteDraftRev: flowWithDraft.draft_created_at
|
||||
}
|
||||
|
||||
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)) {
|
||||
// Local matches backend exactly — silently drop the autosave.
|
||||
flow = backendFlow
|
||||
flowHandle.draft = backendFlow
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendFlow, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else if (
|
||||
previousMeta.remoteRev === undefined &&
|
||||
previousMeta.remoteDraftRev === undefined
|
||||
) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
flowHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flow = backendFlow
|
||||
flowHandle.draft = backendFlow
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
}
|
||||
|
||||
if (flowWithDraft.draft != undefined && !nobackenddraft) {
|
||||
@@ -278,6 +294,12 @@
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if notFound}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
|
||||
|
||||
Reference in New Issue
Block a user