mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 00:16:53 +00:00
* 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>
169 lines
4.9 KiB
Svelte
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 </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>
|