Files
windmill/frontend/src/lib/components/ResourceEditorDrawer.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

112 lines
2.9 KiB
Svelte

<script lang="ts">
import { Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { Loader2, Save } from 'lucide-svelte'
import WsSpecificVersions from './WsSpecificVersions.svelte'
import { workspaceStore } from '$lib/stores'
import LocalDraftBanner from './LocalDraftBanner.svelte'
let {
workspace = undefined,
disableChatOffset = false
}: { workspace?: string; disableChatOffset?: boolean } = $props()
let drawer: Drawer | undefined = $state()
let canSave = $state(true)
let resource_type: string | undefined = $state(undefined)
let defaultValues: Record<string, any> | undefined = $state(undefined)
let resourceEditor:
| {
save: () => void
localDraftDeployed: () => unknown
localDraftCurrent: () => unknown
discardLocalDraft: () => void
}
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
let canWriteSelected = $state(true)
let path: string | undefined = $state(undefined)
let selected: string | undefined = $state(undefined)
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
export async function initEdit(p: string): Promise<void> {
resource_type = undefined
path = p
selected = effectiveWorkspace
drawer?.openDrawer?.()
}
export async function initNew(
resourceType: string,
nDefaultValues?: Record<string, any>
): Promise<void> {
path = undefined
resource_type = resourceType
defaultValues = nDefaultValues
selected = effectiveWorkspace
drawer?.openDrawer?.()
}
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
</script>
<Drawer bind:this={drawer} size="50rem" {disableChatOffset}>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
on:close={drawer?.closeDrawer}
>
{#await import('./ResourceEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
{path}
{resource_type}
{defaultValues}
{workspace}
on:refresh
bind:this={resourceEditor}
bind:canSave
bind:selected
onDraftStateChange={(v) => (hasLocalDraft = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
{/await}
{#snippet banner()}
<LocalDraftBanner
show={hasLocalDraft}
getDeployed={() => resourceEditor?.localDraftDeployed()}
getCurrent={() => resourceEditor?.localDraftCurrent()}
onDiscard={() => resourceEditor?.discardLocalDraft()}
disabled={!canWriteSelected}
/>
{/snippet}
{#snippet actions()}
{#if mode == 'edit' && path && effectiveWorkspace}
<WsSpecificVersions
kind="resource"
workspaceId={effectiveWorkspace}
initialPath={path}
bind:selected
/>
{/if}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
on:click={() => {
resourceEditor?.save()
drawer?.closeDrawer()
}}
disabled={!canSave}
>
Save
</Button>
{/snippet}
</DrawerContent>
</Drawer>