mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard
A `value: null` POST is a discard, not a save, but it ran through the same runner the indicator watched — so resetting to deployed flashed "Saving..." → "Saved", reading as "your draft just landed" while we were actually wiping it. Track in-flight discards in a SvelteSet, expose a distinct `'discarding'` UserDraftSyncState, and the indicator stays quiet for it: no spinner, no label change, and the `discarding → none` transition deliberately skips the "Saved" flash. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,9 @@
|
||||
savedVisible = false
|
||||
} else if (s === 'none' && prev === 'saving') {
|
||||
// A save just landed: flash "Saved" for SAVED_LABEL_MS.
|
||||
// `discarding → none` deliberately falls through — wiping
|
||||
// a draft isn't a save and "Saved" would read as "your
|
||||
// draft just landed", the opposite of what happened.
|
||||
savedVisible = true
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
@@ -94,6 +97,15 @@
|
||||
timer = undefined
|
||||
}
|
||||
savedVisible = false
|
||||
} else if (s === 'discarding') {
|
||||
// Reset-to-deployed in flight — squash any stale "Saved"
|
||||
// flash so the UI doesn't briefly claim success while we
|
||||
// wipe the draft.
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
savedVisible = false
|
||||
}
|
||||
prev = s
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
import { DraftService, type UserDraftItemKind } from './gen'
|
||||
import { OpenAPI } from './gen/core/OpenAPI'
|
||||
import { createCoalescingKeyedRunner } from './coalescingRunner.svelte'
|
||||
@@ -96,19 +96,20 @@ export type UserDraftLastSyncQuery = {
|
||||
/**
|
||||
* Autosave lifecycle for a single draft, derived from the two-stage
|
||||
* pipeline:
|
||||
* - `saving`: a POST is currently in flight (coalescing runner busy).
|
||||
* - `pending`: a change is queued in the debouncer but not yet fired.
|
||||
* - `failed`: the last POST threw (network / 5xx / etc.) and no later
|
||||
* attempt has succeeded. Conflicts use the conflict modal
|
||||
* path and don't surface here.
|
||||
* - `none`: none of the above — the draft is in sync (or nothing
|
||||
* happened).
|
||||
* Priority on render: `saving` > `pending` > `failed` > `none`. An active
|
||||
* save attempt outranks the prior failure so the indicator shows the
|
||||
* retry as in-flight; once it settles, either success clears `failed` or
|
||||
* a fresh throw sets it again.
|
||||
* - `saving`: a regular save POST is in flight (coalescing runner busy).
|
||||
* - `discarding`: a delete POST (value: null) is in flight — semantically a
|
||||
* "reset to deployed", not a save. The indicator suppresses
|
||||
* its spinner / "Saving..." / "Saved" UI for this state so
|
||||
* the user isn't misled into thinking a draft is landing.
|
||||
* - `pending`: a change is queued in the debouncer but not yet fired.
|
||||
* - `failed`: the last POST threw (network / 5xx / etc.) and no later
|
||||
* attempt has succeeded. Conflicts use the conflict modal
|
||||
* path and don't surface here.
|
||||
* - `none`: none of the above — the draft is in sync (or nothing
|
||||
* happened).
|
||||
* Priority on render: `saving` > `discarding` > `pending` > `failed` > `none`.
|
||||
*/
|
||||
export type UserDraftSyncState = 'none' | 'pending' | 'saving' | 'failed'
|
||||
export type UserDraftSyncState = 'none' | 'pending' | 'saving' | 'discarding' | 'failed'
|
||||
|
||||
export type UserDraftStateHandle = {
|
||||
/** Reactive — read it inside a `$derived`/`$effect` and it re-runs as
|
||||
@@ -178,6 +179,16 @@ const conflicts = new SvelteMap<string, DraftConflictInfo>()
|
||||
*/
|
||||
const failures = new SvelteMap<string, string>()
|
||||
|
||||
/**
|
||||
* Reactive set of draft keys whose currently in-flight POST is a discard
|
||||
* (a `value: null` "reset to deployed" delete) rather than a regular
|
||||
* save. Populated by `postSave` for the lifetime of the request so
|
||||
* `getState` can return `'discarding'` instead of `'saving'`, and the
|
||||
* indicator can stay quiet — Saving... → Saved would otherwise read as
|
||||
* "your draft was just saved" while the user was actually wiping it.
|
||||
*/
|
||||
const discarding = new SvelteSet<string>()
|
||||
|
||||
/**
|
||||
* Best-effort error → readable string. The generated client wraps HTTP
|
||||
* failures as `ApiError` with `body` / `statusText`; raw fetch errors
|
||||
@@ -201,6 +212,8 @@ function formatSaveError(e: unknown): string {
|
||||
|
||||
async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
const key = draftKey(opts.workspace, opts.itemKind, opts.path)
|
||||
const isDiscard = opts.value === null
|
||||
if (isDiscard) discarding.add(key)
|
||||
const lastSync = getLastSyncEntry(key)?.lastSync
|
||||
try {
|
||||
const resp = await DraftService.saveDraft({
|
||||
@@ -253,6 +266,8 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
// means we don't pretend the user's pending edit landed when
|
||||
// it didn't.
|
||||
failures.set(key, formatSaveError(e))
|
||||
} finally {
|
||||
if (isDiscard) discarding.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +370,9 @@ export const UserDraftDbSyncer = {
|
||||
const key = draftKey(query.workspace, query.itemKind, query.path)
|
||||
return {
|
||||
get state(): UserDraftSyncState {
|
||||
if (runner.isRunning(key)) return 'saving'
|
||||
if (runner.isRunning(key)) {
|
||||
return discarding.has(key) ? 'discarding' : 'saving'
|
||||
}
|
||||
if (debouncer.isPending(key)) return 'pending'
|
||||
if (failures.has(key)) return 'failed'
|
||||
return 'none'
|
||||
|
||||
Reference in New Issue
Block a user