mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
Revert "feat: gate drafts on real user input so a moved-on schema is not a draft"
This reverts commit 6cd86cf727.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import type { Schema } from '$lib/common'
|
||||
import { ResourceService, WorkspaceService, type Resource, type ResourceType } from '$lib/gen'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
|
||||
@@ -14,7 +14,6 @@
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { onUserInput } from '$lib/userDraftEditGate'
|
||||
|
||||
interface Props {
|
||||
canSave?: boolean
|
||||
@@ -105,46 +104,6 @@
|
||||
workspaceSpecs.push({ ws, defaultValue })
|
||||
}
|
||||
|
||||
// A workspace stays gated until the user puts something into its form: the
|
||||
// resource type's schema is what fills in properties the stored value never
|
||||
// had, and that is not an edit. While gated the autosave is suspended and
|
||||
// the deployed baseline absorbs whatever the form settles on, so opening a
|
||||
// resource whose type gained a property leaves no draft behind. See
|
||||
// `onUserInput`. A workspace opened ON a saved draft keeps its baseline —
|
||||
// the divergence there is the user's own, from an earlier session.
|
||||
let userEdited: Record<string, boolean> = $state({})
|
||||
let openedOnDraft: Record<string, boolean> = $state({})
|
||||
const suspendedWorkspaces = new Set<string>()
|
||||
|
||||
function setGated(ws: string, gated: boolean): void {
|
||||
if (!initialPath) return
|
||||
if (gated === suspendedWorkspaces.has(ws)) return
|
||||
if (gated) {
|
||||
UserDraft.stopSync('resource', initialPath, { workspace: ws })
|
||||
suspendedWorkspaces.add(ws)
|
||||
} else {
|
||||
UserDraft.restartSync('resource', initialPath, { workspace: ws })
|
||||
suspendedWorkspaces.delete(ws)
|
||||
}
|
||||
}
|
||||
|
||||
onUserInput(() => {
|
||||
if (selected) userEdited[selected] = true
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const wss = Object.keys(states)
|
||||
const edited = { ...userEdited }
|
||||
untrack(() => {
|
||||
for (const ws of wss) setGated(ws, !edited[ws])
|
||||
})
|
||||
})
|
||||
|
||||
// `stopSync` must be paired or the key stays unsynced for the session.
|
||||
onDestroy(() => {
|
||||
for (const ws of [...suspendedWorkspaces]) setGated(ws, false)
|
||||
})
|
||||
|
||||
let isValid = $state(true)
|
||||
let jsonError = $state('')
|
||||
let viewJsonSchema = $state(false)
|
||||
@@ -283,11 +242,6 @@
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
openedOnDraft[ws] = !!savedDraftState
|
||||
// Gate BEFORE the handle is acquired: `stopSync` queues on a
|
||||
// not-yet-live entry, and the form can settle before the effect
|
||||
// above gets a chance to run.
|
||||
setGated(ws, true)
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
@@ -302,23 +256,6 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Absorb the form's settling writes into the deployed baseline while the
|
||||
// selected workspace is gated, so they show up neither as the "unsaved
|
||||
// changes" banner nor, once `discardIf` reads the baseline, as a draft.
|
||||
// Only the selected workspace has a form rendered against it.
|
||||
$effect(() => {
|
||||
const ws = selected
|
||||
if (!ws || !initialPath) return
|
||||
if (userEdited[ws] || openedOnDraft[ws]) return
|
||||
// `$state.snapshot` deep-reads, so nested `args` mutations re-run this.
|
||||
const settled = states[ws]?.draft
|
||||
? ($state.snapshot(states[ws].draft) as ResourceState)
|
||||
: undefined
|
||||
untrack(() => {
|
||||
if (settled && !draftValuesEqual(settled, initialStates[ws])) initialStates[ws] = settled
|
||||
})
|
||||
})
|
||||
|
||||
// Keep current.path bound to the outer `path` prop for consumers
|
||||
$effect(() => {
|
||||
if (current) path = current.path
|
||||
@@ -352,13 +289,6 @@
|
||||
}
|
||||
export function discardLocalDraft(): void {
|
||||
if (!selected) return
|
||||
// Back to the deployed value with nothing of the user's left in it, so
|
||||
// the gate closes again — otherwise the form settles on the schema's
|
||||
// values a second time and the discarded draft comes straight back.
|
||||
// `discard` POSTs the delete itself, so suspending first is safe.
|
||||
openedOnDraft[selected] = false
|
||||
userEdited[selected] = false
|
||||
setGated(selected, true)
|
||||
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
})
|
||||
|
||||
@@ -2,21 +2,9 @@ import { untrack } from 'svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { UserDraft, normalizeDraftForCompare, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { onUserInput } from '$lib/userDraftEditGate'
|
||||
|
||||
type Cfg = Record<string, any>
|
||||
|
||||
/**
|
||||
* Detach a config from whatever holds it. The draft cell is deeply reactive,
|
||||
* so handing its object straight to `applyCfg` would make the form's own
|
||||
* `$state` (a schedule's `args`, say) the very object the cell holds — every
|
||||
* later keystroke would then mutate the draft in place behind the autosave's
|
||||
* back.
|
||||
*/
|
||||
function snapshotCfg<V>(cfg: V): V {
|
||||
return structuredClone($state.snapshot(cfg)) as V
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `a` differs from `b` after `normalizeDraftForCompare` (JSON
|
||||
* round-trip to drop `undefined`-valued keys, plus ignored deploy-directive
|
||||
@@ -96,10 +84,8 @@ export interface TriggerDraftSync {
|
||||
* …)` (another tab, a programmatic write) propagate into the open editor.
|
||||
*
|
||||
* - **apply-effect**: reflects external `handle.draft` changes into the form.
|
||||
* - **absorb-effect**: folds the form's own settling into the baseline until
|
||||
* the user's first input, so a schema that moved on is not a draft.
|
||||
* - **persist-effect**: writes form edits back through the handle, dropping
|
||||
* the draft when the form is back at the baseline.
|
||||
* the draft when the form is back at the deployed baseline.
|
||||
*
|
||||
* Both effect bodies are `untrack`ed and gated by `cfgDiffers`
|
||||
* idempotence so they can't feed back into each other. Must be called once
|
||||
@@ -113,60 +99,14 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
})
|
||||
const handle = $derived(handles[0])
|
||||
|
||||
// The form settles on values nobody entered: the arguments `SchemaForm`
|
||||
// renders come from the runnable's schema, so one that gained a property
|
||||
// fills it in — an empty string, the first option of a required enum — the
|
||||
// moment the drawer opens. Until the user actually puts something in, the
|
||||
// baseline absorbs whatever the form settles on and nothing persists, so an
|
||||
// untouched trigger is never reported as having unsaved changes. A drawer
|
||||
// opened ON a restored draft absorbs nothing: that divergence is the user's
|
||||
// own, from an earlier session. See `onUserInput`.
|
||||
let settledBaseline: Cfg | undefined = $state(undefined)
|
||||
let userEdited = $state(false)
|
||||
let openedOnDraft = $state(false)
|
||||
// These editors are mounted by the list page, not by the drawer, so input
|
||||
// arriving while the drawer is still loading is the click that opened it
|
||||
// (or anything else on the page behind) — never an edit to this form.
|
||||
onUserInput(() => {
|
||||
if (!opts.drawerLoading()) userEdited = true
|
||||
})
|
||||
|
||||
/** The deployed config, plus whatever the form settled on by itself. */
|
||||
const baseline = $derived(settledBaseline ?? opts.deployed())
|
||||
|
||||
$effect(() => {
|
||||
// A reload re-opens the gate's window: the drawer is being pointed at a
|
||||
// different trigger, or the same one re-read from the backend. The click
|
||||
// that opened it landed before this, hence the reset of `userEdited`.
|
||||
if (!opts.drawerLoading()) return
|
||||
untrack(() => {
|
||||
settledBaseline = undefined
|
||||
userEdited = false
|
||||
openedOnDraft = false
|
||||
})
|
||||
})
|
||||
|
||||
// absorb-effect: pre-edit form drift joins the baseline.
|
||||
$effect(() => {
|
||||
if (opts.drawerLoading() || userEdited || openedOnDraft) return
|
||||
const cfg = opts.getCfg()
|
||||
const deployed = opts.deployed()
|
||||
if (cfg == null || deployed == null) return
|
||||
// Snapshot before untracking: `getCfg` hands back the form's `$state`
|
||||
// objects by reference, so only a deep read subscribes to the nested
|
||||
// writes the form makes as it settles.
|
||||
const settled = snapshotCfg(cfg)
|
||||
untrack(() => {
|
||||
if (cfgDiffers(settled, settledBaseline ?? deployed)) settledBaseline = settled
|
||||
})
|
||||
})
|
||||
|
||||
// Live "is there a local draft?" — the form diverges from the deployed
|
||||
// baseline. Gated on `!drawerLoading` (the baseline isn't settled yet
|
||||
// mid-load) and on a non-null baseline (a brand-new trigger has none, so
|
||||
// "unsaved changes" / discard-to-deployed is meaningless there).
|
||||
const hasDraft = $derived(
|
||||
!opts.drawerLoading() && baseline != null && cfgDiffers(opts.getCfg() as Cfg, baseline as Cfg)
|
||||
!opts.drawerLoading() &&
|
||||
opts.deployed() != null &&
|
||||
cfgDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg)
|
||||
)
|
||||
|
||||
// Reactive "banner is possible" — depends on `drawerLoading()` so it
|
||||
@@ -191,7 +131,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
if (opts.drawerLoading() || d == null) return
|
||||
untrack(() => {
|
||||
if (cfgDiffers(d, opts.getCfg() as Cfg)) {
|
||||
void opts.applyCfg(snapshotCfg(d))
|
||||
void opts.applyCfg(d)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -208,32 +148,30 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
discardTimer = undefined
|
||||
if (opts.drawerLoading()) return
|
||||
const cfg = opts.getCfg()
|
||||
const deployed = opts.deployed()
|
||||
const h = handle
|
||||
if (!h || cfg == null) return
|
||||
if (!cfgDiffers(cfg, baseline) && cfgDiffers(h.draft, baseline)) {
|
||||
discard(opts.path(), baseline, true)
|
||||
if (!cfgDiffers(cfg, deployed) && cfgDiffers(h.draft, deployed)) {
|
||||
discard(opts.path(), deployed, true)
|
||||
}
|
||||
}, 600)
|
||||
}
|
||||
|
||||
// persist-effect: form edits → handle; drop the draft when back at the
|
||||
// baseline.
|
||||
// deployed baseline.
|
||||
$effect(() => {
|
||||
if (opts.drawerLoading() || !opts.path()) return
|
||||
// Nothing persists before the user's first input — the form's own
|
||||
// settling is not an edit, and gating here rather than relying on the
|
||||
// absorb-effect having run first keeps the two effects order-independent.
|
||||
if (!userEdited && !openedOnDraft) return
|
||||
const cfg = opts.getCfg()
|
||||
if (cfg == null) return
|
||||
untrack(() => {
|
||||
const h = handle
|
||||
if (!h) return
|
||||
if (cfgDiffers(cfg, baseline)) {
|
||||
const deployed = opts.deployed()
|
||||
if (cfgDiffers(cfg, deployed)) {
|
||||
if (cfgDiffers(cfg, h.draft)) h.draft = cfg
|
||||
} else if (cfgDiffers(h.draft, baseline)) {
|
||||
// Only when a draft actually exists to drop: `h.draft` equals the
|
||||
// baseline right after a discard or the post-load seed.
|
||||
} else if (cfgDiffers(h.draft, deployed)) {
|
||||
// Only when a draft actually exists to drop: `h.draft` equals
|
||||
// `deployed` right after a discard or the post-load seed.
|
||||
scheduleAutoDiscard()
|
||||
}
|
||||
})
|
||||
@@ -264,7 +202,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
return hasBaseline
|
||||
},
|
||||
get deployed() {
|
||||
return baseline
|
||||
return opts.deployed()
|
||||
},
|
||||
get current() {
|
||||
return opts.getCfg()
|
||||
@@ -273,14 +211,8 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
const d = handle?.draft
|
||||
if (cfgDiffers(d, opts.getCfg() as Cfg)) {
|
||||
// Overlay the local autosave on the just-loaded backend config.
|
||||
await opts.applyCfg(snapshotCfg(d))
|
||||
await opts.applyCfg(d)
|
||||
}
|
||||
// The form is not rendered while the drawer loads, so anything that
|
||||
// diverges from the deployed config right now is a draft restored onto
|
||||
// it — by the overlay above, or by the editor from the backend before
|
||||
// calling this — never the form settling. Absorbing that into the
|
||||
// baseline would hide the user's own work behind a clean drawer.
|
||||
openedOnDraft = cfgDiffers(opts.getCfg() as Cfg, opts.deployed())
|
||||
// Adopt the post-load form state as the cell's baseline without
|
||||
// POSTing, consuming the entry's one-shot first-write seed guard.
|
||||
// Trigger drawers never write the cell programmatically on open, so
|
||||
@@ -289,20 +221,14 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
const p = opts.path()
|
||||
const cfg = opts.getCfg()
|
||||
if (ws && p && cfg != null) {
|
||||
UserDraft.seed(opts.itemKind, p, snapshotCfg(cfg) as Cfg, {
|
||||
UserDraft.seed(opts.itemKind, p, structuredClone($state.snapshot(cfg)) as Cfg, {
|
||||
workspace: ws
|
||||
})
|
||||
}
|
||||
},
|
||||
async resetToDeployed(path: string) {
|
||||
const deployedCfg = snapshotCfg(opts.deployed()) as Cfg
|
||||
const deployedCfg = structuredClone($state.snapshot(opts.deployed())) as Cfg
|
||||
discard(path, deployedCfg)
|
||||
// Nothing of the user's is left in the form, so the gate closes again
|
||||
// — otherwise the form settles on the schema's values a second time
|
||||
// and the discarded draft comes straight back.
|
||||
settledBaseline = undefined
|
||||
userEdited = false
|
||||
openedOnDraft = false
|
||||
await opts.applyCfg(deployedCfg)
|
||||
},
|
||||
discard
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
/**
|
||||
* A draft is supposed to record what the USER changed, but an editor built
|
||||
* from a schema writes into the value on its own: the form materializes a
|
||||
* property the stored item never carried (an empty string, `false`, the first
|
||||
* option of a required enum, a schema `default`) and deletes one a `showExpr`
|
||||
* hides. So merely opening an item whose schema has moved on makes it diverge
|
||||
* from the deployed value with nobody having touched it — a draft nobody asked
|
||||
* for, cluttering the workspace.
|
||||
*
|
||||
* An editor guards against that by gating its draft on this: nothing the form
|
||||
* settles on counts until the user has actually put something in. Callers
|
||||
* decide what a gate covers (the resource editor keys it by workspace, since
|
||||
* switching workspaces re-renders the form against a fresh value) and what
|
||||
* gating means for them — suspending the autosave, absorbing the settled value
|
||||
* into the deployed baseline, or both.
|
||||
*
|
||||
* `pointerdown` and `keydown` are the two events that precede every human
|
||||
* edit, and capture phase puts this ahead of the handler that writes the
|
||||
* value, so a gate opened here is already open by the time the edit lands.
|
||||
* Listening on the document rather than the editor's own subtree is
|
||||
* deliberate: pickers and modals render in portals outside it, and missing a
|
||||
* real edit would silently drop the user's work, while opening the gate too
|
||||
* eagerly only costs the phantom draft that existed before.
|
||||
*
|
||||
* Registers for the lifetime of the calling component — call it during init.
|
||||
*/
|
||||
export function onUserInput(handle: () => void): void {
|
||||
if (typeof document === 'undefined') return
|
||||
const onEvent = (e: Event) => {
|
||||
if (e.isTrusted) handle()
|
||||
}
|
||||
document.addEventListener('pointerdown', onEvent, true)
|
||||
document.addEventListener('keydown', onEvent, true)
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('pointerdown', onEvent, true)
|
||||
document.removeEventListener('keydown', onEvent, true)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user