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

169 lines
4.9 KiB
Svelte

<script lang="ts">
import Path from './Path.svelte'
import LabelsInput from './LabelsInput.svelte'
import Toggle from './Toggle.svelte'
import Alert from './common/alert/Alert.svelte'
import { Button } from './common'
import Tooltip from './Tooltip.svelte'
import Label from './Label.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Loader2 } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { userStore, workspaceStore } from '$lib/stores'
import { isOwner } from '$lib/utils'
interface Variable {
value: string
is_secret: boolean
description: string
}
interface Props {
path: string
initialPath: string
pathError: string
variable: Variable
labels: string[] | undefined
wsSpecific: boolean
deployTo: string | undefined
can_write: boolean
edit: boolean
onLoadSecret?: () => void
}
let {
path = $bindable(),
initialPath,
pathError = $bindable(),
variable = $bindable(),
labels = $bindable(),
wsSpecific = $bindable(),
deployTo,
can_write,
edit,
onLoadSecret
}: Props = $props()
const MAX_VARIABLE_LENGTH = 10000
let editorKind: 'plain' | 'json' | 'yaml' = $state('plain')
let editor: any = $state(undefined)
export function setCode(value: string) {
editor?.setCode(value)
}
</script>
<div class="flex flex-col gap-1">
<label for="path" class="text-xs font-semibold text-emphasis">Path</label>
<Path
disabled={initialPath != '' && !isOwner(initialPath, $userStore, $workspaceStore)}
bind:error={pathError}
bind:path
{initialPath}
namePlaceholder="variable"
kind="variable"
/>
<LabelsInput bind:labels />
</div>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Secret</span>
<Toggle
on:change={() => edit && onLoadSecret?.()}
bind:checked={variable.is_secret}
disabled={edit && $userStore?.operator}
/>
{#if variable.is_secret}
<Alert type="info" title="Audit log for each access">
Every secret is encrypted at rest and in transit with a key specific to this workspace. In
addition, any read of a secret variable generates an audit log whose operation name is:
variables.decrypt_secret
</Alert>
{/if}
</label>
{#if deployTo}
<Label
label="Workspace specific"
tooltip="Prevents this variable from being deployed to prod/staging. May have been enabled automatically because a workspace-specific resource references this variable via $var:. Disabling this toggle does not retroactively un-mark the resource that referenced it."
>
<Toggle bind:checked={wsSpecific} />
</Label>
{/if}
<div class="flex flex-col gap-1">
<label for="variable-value" class="flex flex-row justify-left items-center">
<span class="text-xs font-semibold text-emphasis">Variable value&nbsp;</span>
<span class="text-xs text-secondary font-normal">
({variable.value.length}/{MAX_VARIABLE_LENGTH} characters)
</span>
{#if edit && variable.is_secret}
<div class="ml-3"></div>
{#if $userStore?.operator}
<div class="p-2 border">Operators cannot load secret value</div>
{:else}
<Button size="xs" variant="default" on:click={() => onLoadSecret?.()}>
Load secret value<Tooltip>Will generate an audit log</Tooltip>
</Button>
{/if}
{/if}
</label>
<div>
<div class="flex flex-col gap-2">
<ToggleButtonGroup bind:selected={editorKind}>
{#snippet children({ item })}
<ToggleButton value="plain" label="Plain" {item} />
<ToggleButton value="json" label="Json" {item} />
<ToggleButton value="yaml" label="YAML" {item} />
{/snippet}
</ToggleButtonGroup>
{#if editorKind == 'plain'}
<textarea
disabled={!can_write}
rows="4"
use:autosize
bind:value={variable.value}
placeholder="Update variable value"
id="variable-value"
></textarea>
{:else if editorKind == 'json'}
<div class="border rounded mb-4 w-full">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={editor}
autoHeight
lang="json"
bind:code={variable.value}
fixedOverflowWidgets={false}
class="bg-surface-tertiary"
/>
{/await}
</div>
{:else if editorKind == 'yaml'}
<div class="border rounded mb-4 w-full">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={editor}
autoHeight
lang="yaml"
bind:code={variable.value}
fixedOverflowWidgets={false}
class="bg-surface-tertiary"
/>
{/await}
</div>
{/if}
</div>
</div>
</div>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Description</span>
<textarea rows="4" use:autosize bind:value={variable.description} placeholder="Used for X"
></textarea>
</label>