Files
windmill/frontend/src/lib/userDraftEditGate.ts
T
c3f7f8a458 fix: stop an untouched item's form from saving a draft nobody wrote (#10964)
* feat: gate drafts on real user input so a moved-on schema is not a draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* Revert "feat: gate drafts on real user input so a moved-on schema is not a draft"

This reverts commit 6cd86cf727.

* fix: stop counting empty schema-added fields and server metadata as drafts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* feat: sweep away existing drafts that carry no changes, once per workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* Reapply "feat: gate drafts on real user input so a moved-on schema is not a draft"

This reverts commit b7b18e345e.

* Revert "fix: stop counting empty schema-added fields and server metadata as drafts"

This reverts commit 9787270ad8.

* docs: describe the sweep by the gate that now prevents new phantom drafts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: make the draft sweep a compare-and-delete so it cannot eat live edits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: close the gate's load-time window and stop sealing a failed sweep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: open the gate on the edit itself, and stop the sweep at ownerless drafts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: count a click as an edit, and keep an unjudged row from sealing the sweep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: release the sweep's sync baseline when its delete is refused

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: drop the refused delete before re-baselining, and bound the sweep's retries

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* refactor: send the sweep's delete straight to the API, not through the syncer

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: never absorb a change the resource type's schema could not have made

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: push an edit the gate only notices after the write has landed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: stop the gating effect re-suspending a resource opened on a draft

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* chore: update ee-repo-ref to d33ea730c550cdbc7d050aeb6d40dcef3d134e07

This commit updates the EE repository reference after PR #782 was merged in windmill-ee-private.

Previous ee-repo-ref: 313c572c9dcbcaafd8a1594df4054f9dd26f395c

New ee-repo-ref: d33ea730c550cdbc7d050aeb6d40dcef3d134e07

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-07 19:31:59 +02:00

67 lines
3.3 KiB
TypeScript

import { onDestroy } from 'svelte'
/**
* What kind of event opened the gate.
*
* - `value`: the user changed something — the event *is* the edit.
* - `precursor`: a gesture that usually precedes an edit. Needed because a
* custom component (a picker, a toggle built out of divs) writes its value
* through Svelte state and fires no native value event at all, so waiting for
* one would drop those edits. The cost is that a bare click counts too.
*/
export type UserInputKind = 'value' | 'precursor'
/** Events that ARE an edit. `drop` and `paste` matter on their own: text
* dragged in from another application produces no pointer or key event in this
* document at all. */
const VALUE_EVENTS = ['input', 'change', 'drop', 'paste'] as const
/** `click` is here for the controls that mutate state from a click handler and
* fire no native value event — ArgInput's "Add item", say. A mouse always sends
* `pointerdown` first, but an assistive technology can activate one with a
* trusted `click` alone, and that is a real edit with nothing else to catch it. */
const PRECURSOR_EVENTS = ['pointerdown', 'keydown', 'click'] as const
/**
* 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.
*
* 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: (kind: UserInputKind) => void): void {
if (typeof document === 'undefined') return
const listeners: Array<[string, (e: Event) => void]> = []
const register = (type: string, kind: UserInputKind) => {
const onEvent = (e: Event) => {
// A programmatic `dispatchEvent` is untrusted, which is what keeps the
// form's own settling from opening the gate it is gated by.
if (e.isTrusted) handle(kind)
}
document.addEventListener(type, onEvent, true)
listeners.push([type, onEvent])
}
for (const type of VALUE_EVENTS) register(type, 'value')
for (const type of PRECURSOR_EVENTS) register(type, 'precursor')
onDestroy(() => {
for (const [type, onEvent] of listeners) document.removeEventListener(type, onEvent, true)
})
}