Files
windmill/frontend/src/lib/components/LocalDraftBanner.svelte
T
Ruben FiszelandClaude Opus 4.8 9c758df5eb reserve more fixed height for unsaved-changes banner to avoid content shift (#9873)
* fix(frontend): reserve fixed height for unsaved-changes banner to avoid content shift

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

* fix(frontend): remove border around reserved banner slot and shrink it

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

* fix(frontend): tighten top padding under the unsaved-changes banner

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

* fix(frontend): give unsaved-changes banner buttons minimal vertical breathing room

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

* refactor(frontend): drop redundant Metadata section title in trigger and script editors

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

* fix(frontend): tuck schedule editor labels under summary to match convention

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

* revert(frontend): keep Metadata section title in ScriptBuilder for a separate PR

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

* fix(frontend): drop leftover header-content margin on headless Section

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

* fix(frontend): drop top padding above resource editor first field

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

* style(frontend): match variable editor bottom padding to resource

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

* fix(frontend): add Path label in new resource form to match edit

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

* fix(frontend): reserve half the banner height to halve the idle gap

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

* fix(frontend): reserve a third of the banner height when idle

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

* fix(frontend): don't reserve banner slot or tighten top for new entities

Gate the reserved-height slot and the tight content top padding on the banner's baseline (bannerReserved) instead of merely on the banner snippet being present, so new-entity drawers keep normal top spacing and add no empty slot.

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

* docs(frontend): trim banner comments to the 4-line invariant limit

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

* docs(frontend): describe partial-reserve banner behavior accurately

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 11:58:31 +02:00

153 lines
5.1 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 {
cleanValueProperties,
orderedYamlStringify,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
import { AlertCircle, Diff } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { fade } 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
/**
* Whether the banner can appear, so it partially reserves its slot to keep
* the shift small. Pass when `getDeployed()` is non-reactive; else defaults
* to `getDeployed() != null`.
*/
reserveSpace?: boolean
}
let {
show,
getDeployed,
getCurrent,
onDiscard,
disabled = false,
title = 'Deployed <> Local changes',
reserveSpace
}: Props = $props()
/** Same cleaning + YAML serialization the DiffDrawer applies before
* comparing. Without it the banner would fire on differences the
* drawer treats as no-op (toggle defaults, `false ↔ undefined`,
* key ordering noise) — exactly the case where the user clicks
* "Show diff" and sees the "No changes detected" empty state. */
function diffKey(value: unknown): string {
try {
return orderedYamlStringify(cleanValueProperties(replaceFalseWithUndefined(value as Value)))
} catch {
return ''
}
}
// Whether the banner can appear (a deployed baseline exists). Gates the
// partially-reserved slot below (keeps the toggle shift small); new entities
// have none, so no slot and no gap.
let hasBaseline = $derived(reserveSpace ?? getDeployed() != null)
// Suppress the banner when:
// • There's no deployed baseline (brand-new entity — "Show diff"
// would early-return and "Discard" is semantically backwards),
// OR
// • Deployed and current are equal under the DiffDrawer's own
// comparison. The callers' `show` is a coarser "form differs
// from baseline" check that can stale-fire after a save lands
// or when `false`/`undefined` toggle noise flips a field.
let visible = $derived.by(() => {
if (!show || !hasBaseline) return false
return diffKey(getDeployed()) !== diffKey(getCurrent())
})
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 hasBaseline}
<!-- Reserve ~a third of the banner height when idle (h-2.5), grow to the full
h-8 when it shows: small idle gap, ~22px animated shift on appear. h-8 =
button height + a couple px so the buttons don't touch the edges. -->
<div class={twMerge('shrink-0 transition-[height] duration-150', visible ? 'h-8' : 'h-2.5')}>
{#if visible}
<div
transition:fade|local={{ duration: 120 }}
class={twMerge(
'flex flex-row items-center justify-between gap-2 px-4 h-full',
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="default"
startIcon={{ icon: Diff }}
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}
</div>
{/if}