Files
windmill/frontend/src/lib/components/LocalDraftBanner.svelte
T
GuilhemandClaude Opus 4.7 075faabf3b feat(frontend): surface local drafts in drawer editors with an unsaved-changes banner (#9335)
* feat(frontend): surface local drafts in drawer editors with an unsaved-changes banner

Drawer-based editors (the 11 trigger types, plus resource and variable)
restore unsaved edits from browser localStorage on open using the same
mechanism as flows/scripts, but only showed a transient "Reset to deployed"
toast with no way to review the diff.

Add a persistent "You have unsaved changes" banner below the drawer header
with Show diff / Discard actions, shown whenever the form diverges from the
deployed baseline. Replaces the toast for these editors; flows/scripts/apps
(full-page) keep their existing toast.

- new shared LocalDraftBanner.svelte (Alert-styled bar + DiffDrawer)
- DrawerContent: optional `banner` snippet rendered below the header
- useTriggerDraftSync: reactive `hasDraft`, `deployed`/`current` getters and
  `resetToDeployed`; drop the restore toast (banner supersedes it)
- wire the banner into all 11 trigger editors + variable; resource lifts its
  dirty state up to ResourceEditorDrawer via a callback + accessors
- fix ScheduleEditorInner.openNew not resetting initialConfig (reused editor
  instance kept a stale baseline, wrongly flagging a new schedule dirty)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): address PR review on local-draft drawer banners

- LocalDraftBanner: snapshot diff sides at click time so the diff drawer
  doesn't keep updating as the user types behind it.
- VariableEditor / ResourceEditor: scope the banner and its Discard action
  to the selected workspace; the cross-workspace dirty state stays surfaced
  by the existing otherDirty Alert. Forward can_write via a new
  onCanWriteChange callback so the resource banner hides Discard in
  read-only mode (matching the trigger editors).
- useTriggerDraftSync: drop the now-unused path arg from maybeRestore and
  update all 11 trigger editor call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): deep-clone fallback in UserDraft.discard to avoid baseline aliasing

When a caller passed a live $state proxy as `fallback` (Variable/Resource
editors handed `initialStates[selected]` to the banner's Discard), the
handle's draft cell ended up sharing the same proxy as the caller's
baseline. Subsequent form edits mutated both sides in lock-step and the
dirty check kept reporting equal, so the banner never reappeared and
the Update button stayed disabled until the drawer was reopened.

Cloning the fallback inside `discard` (via `snapshotDraftValue`) gives
the handle a fresh tree and decouples the two reactive graphs. Trigger
editors already cloned at their call site (resetToDeployed); this just
makes the API self-contained for all callers.

Also switch the variable form's "Audit log for each access" alert from
warning to info — it's informational, not a warning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): honor disabled prop in LocalDraftBanner's diff drawer

The banner's `disabled` prop hid the inline Discard button but the diff
drawer's "Discard changes" action was still wired unconditionally, so a
read-only user could bypass the hidden inline action via Show diff.
Gate the diff-drawer button on the same flag so both surfaces agree.

Flagged by cubic and Codex on PR #9335.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:46:30 +00:00

102 lines
3.0 KiB
Svelte

<script lang="ts">
import { Button } from '$lib/components/common'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { classes } from '$lib/components/common/alert/model'
import { type Value } from '$lib/utils'
import { AlertCircle } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
interface Props {
/** Whether there are unsaved local changes relative to the deployed baseline. */
show: boolean
/** Deployed/backend baseline config (the "original" side of the diff). */
getDeployed: () => unknown
/** Current form config — the local autosave (the "current" side of the diff). */
getCurrent: () => unknown
/** Drop the local changes and reset the form to the deployed baseline. */
onDiscard: () => void | Promise<void>
/** When true (e.g. no write access), hide the Discard action. */
disabled?: boolean
/** Diff drawer title. */
title?: string
}
let {
show,
getDeployed,
getCurrent,
onDiscard,
disabled = false,
title = 'Deployed <> Local changes'
}: Props = $props()
let diffDrawer: DiffDrawer | undefined = $state()
function showDiff() {
const deployed = getDeployed()
if (deployed == null) return
// Snapshot both sides at click time. They are typically Svelte `$state`
// proxies (resource/variable's `initialStates[ws]` and the draft handle's
// cell); without snapshot the diff drawer would re-read them reactively
// and update as the user keeps typing behind it.
const original = $state.snapshot(deployed) as Value
const current = $state.snapshot(getCurrent()) as Value
diffDrawer?.openDrawer()
// Mirror the inline Discard's `disabled` gate inside the diff drawer —
// otherwise a read-only user could still trigger onDiscard via the
// drawer button even though we hid the banner's inline action.
diffDrawer?.setDiff({
mode: 'simple',
original,
current,
title,
button: disabled
? undefined
: {
text: 'Discard changes',
onClick: async () => {
await onDiscard()
diffDrawer?.closeDrawer()
}
}
})
}
</script>
<DiffDrawer bind:this={diffDrawer} />
{#if show}
<div
transition:slide|local={{ duration: 120 }}
class={twMerge(
'flex flex-row items-center justify-between gap-2 px-4 py-1',
classes.warning.bgClass,
'!border-0 !rounded-none'
)}
>
<div class="flex flex-row items-center gap-2 min-w-0">
<AlertCircle class={classes.warning.iconClass} size={16} />
<span class={twMerge('text-xs font-semibold truncate', classes.warning.titleClass)}>
You have unsaved changes
</span>
</div>
<div class="flex flex-row items-center gap-2 shrink-0">
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={showDiff}>Show diff</Button
>
{#if !disabled}
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={onDiscard}>Discard</Button
>
{/if}
</div>
</div>
{/if}