mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
improve instance settings drawer UX (#8002)
* fix(frontend): prevent false dirty state in instance settings on load Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): handle undefined python version in select binding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(frontend): extract SaveButton component and improve drawer header UX Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(frontend): replace inline diff with diff drawer and simplify save flow Save now saves immediately instead of requiring a two-step confirm flow. Diff view opens in a separate drawer with split/unified toggle instead of replacing the form content inline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): preserve dirty state when toggling YAML mode in instance settings syncFormToYaml() was setting yamlCodeInitial to the current modified YAML, causing hasUnsavedChanges to become false when entering YAML mode with pending form changes. Build yamlCodeInitial from initialValues instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): clear dirty state after saving in YAML mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * reduce save button timeout * feat(frontend): add review changes button to unsaved changes confirmation modal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(frontend): address code review issues from PR #8002 Remove unnecessary IIFE wrappers in handleSave/handleSaveAndCloseDiff, fix stale on:close reference on diff drawer, clip SaveButton overlay with overflow-hidden, make DiffEditor respond reactively to inlineDiff prop instead of using {#key} destroy/recreate, and revert normalizeValue object check to original simpler behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): remove tab-switch confirmation modal in full settings mode In full mode, the save button saves all settings across all categories, so switching tabs cannot lose unsaved changes. Remove the per-category dirty check, confirmation modal, and unused ConfirmationModal import. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): prevent SMTP toggles from creating false dirty state Use getter/setter bind:checked so Toggle reads undefined as false without writing it back to the store. This prevents visiting the SMTP tab from mutating smtp_settings and triggering a false unsaved diff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): prevent OTEL toggles from creating false dirty state Same fix as SMTP toggles: use getter/setter bind:checked so Toggle reads undefined as false without writing it back to the store. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(frontend): use recursive normalizeValue for dirty state instead of per-component fixes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(frontend): replace save button with always-visible review changes button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(frontend): address PR review comments on DiffEditor and SaveButton Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -169,12 +169,6 @@
|
||||
open = false
|
||||
}
|
||||
|
||||
function onWidthChange(editorWidth: number) {
|
||||
diffEditor?.updateOptions({
|
||||
renderSideBySide: inlineDiff ? false : editorWidth >= SIDE_BY_SIDE_MIN_WIDTH
|
||||
})
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && diffDivEl) {
|
||||
loadDiffEditor()
|
||||
@@ -182,7 +176,11 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
onWidthChange(editorWidth)
|
||||
if (diffEditor) {
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: inlineDiff ? false : editorWidth >= SIDE_BY_SIDE_MIN_WIDTH
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -208,7 +208,9 @@
|
||||
ee_only={setting.ee_only}
|
||||
settingKey={setting.key}
|
||||
>
|
||||
<ToggleButtonGroup bind:selected={$values[setting.key]}>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={() => $values[setting.key] ?? 'default', (v) => ($values[setting.key] = v)}
|
||||
>
|
||||
{#snippet children({ item: toggleButton })}
|
||||
{#each setting.select_items ?? [] as item}
|
||||
<ToggleButton
|
||||
@@ -548,7 +550,7 @@
|
||||
<Toggle
|
||||
disabled
|
||||
id="metrics_enabled"
|
||||
bind:checked={$values[setting.key].logs_enabled}
|
||||
bind:checked={$values[setting.key].metrics_enabled}
|
||||
options={{ right: 'Metrics (coming soon)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
onNavigateToTab?: (category: string) => void
|
||||
quickSetup?: boolean
|
||||
yamlMode?: boolean
|
||||
diffMode?: boolean
|
||||
hasUnsavedChanges?: boolean
|
||||
}
|
||||
|
||||
@@ -42,7 +41,6 @@
|
||||
onNavigateToTab,
|
||||
quickSetup = false,
|
||||
yamlMode = $bindable(false),
|
||||
diffMode = $bindable(false),
|
||||
hasUnsavedChanges = $bindable(false)
|
||||
}: Props = $props()
|
||||
|
||||
@@ -86,7 +84,8 @@
|
||||
function applyFormDefaults(vals: Record<string, any>): void {
|
||||
for (const [key, defaultVal] of Object.entries(formDefaults)) {
|
||||
if (vals[key] == undefined) {
|
||||
vals[key] = typeof defaultVal === 'object' ? JSON.parse(JSON.stringify(defaultVal)) : defaultVal
|
||||
vals[key] =
|
||||
typeof defaultVal === 'object' ? JSON.parse(JSON.stringify(defaultVal)) : defaultVal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +110,19 @@
|
||||
}
|
||||
applyFormDefaults(nvalues)
|
||||
|
||||
// Apply select/select_python defaults so initialValues matches what InstanceSetting's $effect does
|
||||
for (const category of settingsKeys) {
|
||||
for (const s of settings[category]) {
|
||||
if (
|
||||
(s.fieldType === 'select' || s.fieldType === 'select_python') &&
|
||||
nvalues[s.key] == undefined &&
|
||||
s.defaultValue
|
||||
) {
|
||||
nvalues[s.key] = s.defaultValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot initialValues before applying the base_url fallback so that
|
||||
// the dirty-check detects the unsaved default and enables the Save button.
|
||||
initialValues = JSON.parse(JSON.stringify(nvalues))
|
||||
@@ -201,6 +213,10 @@
|
||||
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
|
||||
baseUrlIsFallback = false
|
||||
|
||||
if (yamlMode) {
|
||||
yamlCodeInitial = yamlCode
|
||||
}
|
||||
|
||||
if (licenseKeySet) {
|
||||
setLicense()
|
||||
}
|
||||
@@ -333,11 +349,20 @@
|
||||
if (value === false) return undefined
|
||||
if (typeof value === 'string' && value.trim() === '') return undefined
|
||||
if (Array.isArray(value) && value.length === 0) return undefined
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0) return undefined
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
// Recursively normalize: if all values in the object normalize to undefined,
|
||||
// the object itself is effectively empty (e.g. {smtp_tls_implicit: false} ≡ {})
|
||||
const hasNonEmpty = Object.values(value).some((v) => normalizeValue(v) !== undefined)
|
||||
if (!hasNonEmpty) return undefined
|
||||
}
|
||||
|
||||
// Key-specific defaults: these values are equivalent to "not set"
|
||||
if (key === 'secret_backend') {
|
||||
if (typeof value === 'object' && value?.type === 'Database' && Object.keys(value).length === 1) {
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value?.type === 'Database' &&
|
||||
Object.keys(value).length === 1
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -658,7 +683,12 @@
|
||||
normalize: true,
|
||||
mask: !showSensitive
|
||||
})
|
||||
yamlCodeInitial = yamlCode
|
||||
yamlCodeInitial = buildSettingsYaml(
|
||||
initialValues,
|
||||
initialOauths,
|
||||
initialRequirePreexistingUserForOauth,
|
||||
{ normalize: true, mask: !showSensitive }
|
||||
)
|
||||
yamlEditor?.setCode(yamlCode)
|
||||
yamlError = ''
|
||||
}
|
||||
@@ -797,40 +827,25 @@
|
||||
</script>
|
||||
|
||||
<div class="pb-12">
|
||||
{#if diffMode}
|
||||
<div class="w-full h-[calc(100vh-8rem)]">
|
||||
{#await import('$lib/components/DiffEditor.svelte')}
|
||||
<Loader2 class="animate-spin m-4" />
|
||||
{:then Module}
|
||||
{@const diff = buildFullDiff()}
|
||||
<Module.default
|
||||
open={true}
|
||||
className="!h-full"
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={diff.original}
|
||||
defaultModified={diff.modified}
|
||||
readOnly
|
||||
inlineDiff={true}
|
||||
{#if yamlMode}
|
||||
<div class="flex flex-row justify-between">
|
||||
<p class="text-2xs text-tertiary">
|
||||
Use this YAML to manage instance settings as code.
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/advanced/instance_settings#kubernetes-operator"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Learn more <ExternalLink size={12} class="inline-block" /></a
|
||||
>
|
||||
</p>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<div class="flex items-center justify-end gap-4 mb-2">
|
||||
<Toggle
|
||||
checked={showSensitive}
|
||||
on:change={(e) => handleShowSensitiveToggle(e.detail)}
|
||||
options={{ right: 'Show sensitive values' }}
|
||||
size="xs"
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
{:else if yamlMode}
|
||||
<p class="text-2xs text-tertiary mb-2">
|
||||
Use this YAML to manage instance settings as code.
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/advanced/instance_settings#kubernetes-operator"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Learn more <ExternalLink size={12} class="inline-block" /></a>
|
||||
</p>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<div class="flex items-center justify-end gap-4 mb-2">
|
||||
<Toggle
|
||||
checked={showSensitive}
|
||||
on:change={(e) => handleShowSensitiveToggle(e.detail)}
|
||||
options={{ right: 'Show sensitive values' }}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded w-full h-[calc(100vh-12rem)]">
|
||||
{#await import('$lib/components/SimpleEditor.svelte')}
|
||||
@@ -887,11 +902,7 @@
|
||||
link="https://www.windmill.dev/docs/advanced/imports"
|
||||
/>
|
||||
{#if !$enterpriseLicense}
|
||||
<Alert
|
||||
type="info"
|
||||
title="Private registries configuration is an EE feature"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Alert type="info" title="Private registries configuration is an EE feature" class="mb-2" />
|
||||
{/if}
|
||||
{:else if category == 'Alerts'}
|
||||
<SettingsPageHeader
|
||||
@@ -1026,7 +1037,9 @@
|
||||
{values}
|
||||
{version}
|
||||
{oauths}
|
||||
warning={setting.key === 'base_url' && baseUrlIsFallback ? 'Auto-detected from browser — not yet saved' : undefined}
|
||||
warning={setting.key === 'base_url' && baseUrlIsFallback
|
||||
? 'Auto-detected from browser — not yet saved'
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
{#if quickSetup && category === 'Core' && setting.key === 'base_url'}
|
||||
@@ -1076,4 +1089,5 @@
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { Button } from './common'
|
||||
import { type ButtonType } from './common/button/model'
|
||||
import { Save, CheckCircle2, AlertCircle, Loader2 } from 'lucide-svelte'
|
||||
import { fly } from 'svelte/transition'
|
||||
|
||||
let {
|
||||
onSave,
|
||||
disabled = false,
|
||||
label = 'Save settings',
|
||||
size = undefined,
|
||||
unifiedSize = undefined,
|
||||
variant = 'accent'
|
||||
}: {
|
||||
onSave: () => void | Promise<void>
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
size?: ButtonType.Size | undefined
|
||||
unifiedSize?: ButtonType.UnifiedSize | undefined
|
||||
variant?: ButtonType.Variant
|
||||
} = $props()
|
||||
|
||||
let isSaving = $state(false)
|
||||
let saveStatus: 'success' | 'error' | null = $state(null)
|
||||
let statusTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearStatusTimeout() {
|
||||
if (statusTimeout !== null) {
|
||||
clearTimeout(statusTimeout)
|
||||
statusTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (isSaving) return
|
||||
|
||||
isSaving = true
|
||||
saveStatus = null
|
||||
clearStatusTimeout()
|
||||
|
||||
Promise.resolve(onSave())
|
||||
.then(() => {
|
||||
saveStatus = 'success'
|
||||
statusTimeout = setTimeout(() => {
|
||||
saveStatus = null
|
||||
statusTimeout = null
|
||||
}, 1500)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Save failed:', error)
|
||||
saveStatus = 'error'
|
||||
statusTimeout = setTimeout(() => {
|
||||
saveStatus = null
|
||||
statusTimeout = null
|
||||
}, 3000)
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false
|
||||
})
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
clearStatusTimeout()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="relative overflow-hidden rounded-md">
|
||||
<Button
|
||||
{variant}
|
||||
{size}
|
||||
{unifiedSize}
|
||||
startIcon={{
|
||||
icon: isSaving ? Loader2 : Save,
|
||||
classes: isSaving ? 'animate-spin' : ''
|
||||
}}
|
||||
disabled={disabled || isSaving}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isSaving ? 'Saving...' : label}
|
||||
</Button>
|
||||
|
||||
{#if saveStatus === 'success'}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-green-200 dark:bg-green-800 rounded-md"
|
||||
transition:fly={{ y: -10, duration: 300 }}
|
||||
>
|
||||
<CheckCircle2 class="w-5 h-5 text-green-700 dark:text-green-300" />
|
||||
</div>
|
||||
{:else if saveStatus === 'error'}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-red-500 dark:bg-red-700 rounded-md"
|
||||
transition:fly={{ y: -10, duration: 300 }}
|
||||
>
|
||||
<AlertCircle class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -5,7 +5,9 @@
|
||||
import MeltTooltip from './meltComponents/Tooltip.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { X, FileDiff, Save, Loader2 } from 'lucide-svelte'
|
||||
import SaveButton from './SaveButton.svelte'
|
||||
import { X, FileDiff, Loader2 } from 'lucide-svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { SettingsService } from '$lib/gen'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
@@ -16,14 +18,14 @@
|
||||
let { disableChatOffset = false }: Props = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let diffDrawer: Drawer | undefined = $state()
|
||||
let innerComponent: SuperadminSettingsInner | undefined = $state()
|
||||
let uptodateVersion: string | undefined = $state(undefined)
|
||||
let yamlMode = $state(false)
|
||||
let diffMode = $state(false)
|
||||
let hasUnsavedChanges = $state(false)
|
||||
let pendingSave = $state(false)
|
||||
let isSaving = $state(false)
|
||||
let showCloseConfirmModal = $state(false)
|
||||
let diffData: { original: string; modified: string } = $state({ original: '', modified: '' })
|
||||
let inlineDiff = $state(false)
|
||||
|
||||
async function loadUptodate() {
|
||||
try {
|
||||
@@ -65,39 +67,24 @@
|
||||
bypassCloseCheck = false
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!pendingSave) {
|
||||
if (!innerComponent?.syncBeforeDiff()) return
|
||||
diffMode = true
|
||||
pendingSave = true
|
||||
return
|
||||
}
|
||||
isSaving = true
|
||||
try {
|
||||
await innerComponent?.saveSettings()
|
||||
diffMode = false
|
||||
pendingSave = false
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!innerComponent?.syncBeforeDiff()) throw new Error('YAML sync failed')
|
||||
await innerComponent?.saveSettings()
|
||||
}
|
||||
|
||||
async function handleSaveAndCloseDiff(): Promise<void> {
|
||||
await handleSave()
|
||||
diffDrawer?.closeDrawer()
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
innerComponent?.discardAll()
|
||||
diffMode = false
|
||||
pendingSave = false
|
||||
}
|
||||
|
||||
function handleShowDiff() {
|
||||
if (!diffMode) {
|
||||
if (!innerComponent?.syncBeforeDiff()) return
|
||||
}
|
||||
diffMode = !diffMode
|
||||
if (!diffMode) {
|
||||
pendingSave = false
|
||||
}
|
||||
function handleReviewChanges() {
|
||||
if (!innerComponent?.syncBeforeDiff()) return
|
||||
diffData = innerComponent?.buildFullDiff() ?? { original: '', modified: '' }
|
||||
diffDrawer?.openDrawer()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -124,41 +111,32 @@
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if hasUnsavedChanges}
|
||||
<div transition:fade={{ duration: 150 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: X }}
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: X }}
|
||||
onClick={handleDiscard}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant={diffMode ? 'accent' : 'default'}
|
||||
variant="accent"
|
||||
size="xs"
|
||||
startIcon={{ icon: FileDiff }}
|
||||
onClick={handleShowDiff}
|
||||
onClick={handleReviewChanges}
|
||||
disabled={!hasUnsavedChanges}
|
||||
>
|
||||
{diffMode ? 'Hide diff' : 'Show diff'}
|
||||
Review changes
|
||||
</Button>
|
||||
<Toggle
|
||||
bind:checked={yamlMode}
|
||||
options={{ right: 'YAML' }}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
startIcon={{
|
||||
icon: isSaving ? Loader2 : Save,
|
||||
classes: isSaving ? 'animate-spin' : ''
|
||||
}}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : pendingSave ? 'Confirm & Save' : 'Save settings'}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
<SuperadminSettingsInner
|
||||
@@ -166,12 +144,40 @@
|
||||
closeDrawer={handleClose}
|
||||
showHeaderInfo={false}
|
||||
bind:yamlMode
|
||||
bind:diffMode
|
||||
bind:hasUnsavedChanges
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={diffDrawer} size="1200px">
|
||||
<DrawerContent title="Review changes" on:close={() => diffDrawer?.closeDrawer()}>
|
||||
{#snippet actions()}
|
||||
<Toggle
|
||||
bind:checked={inlineDiff}
|
||||
options={{ right: 'Unified' }}
|
||||
size="xs"
|
||||
/>
|
||||
<SaveButton onSave={handleSaveAndCloseDiff} disabled={!hasUnsavedChanges} size="xs" />
|
||||
{/snippet}
|
||||
<!-- DiffEditor reacts to inlineDiff changes via $effect — no {#key} needed -->
|
||||
<div class="h-full">
|
||||
{#await import('$lib/components/DiffEditor.svelte')}
|
||||
<Loader2 class="animate-spin m-4" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
open={true}
|
||||
className="!h-full"
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={diffData.original}
|
||||
defaultModified={diffData.modified}
|
||||
readOnly
|
||||
{inlineDiff}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#if showCloseConfirmModal}
|
||||
<ConfirmationModal
|
||||
open={showCloseConfirmModal}
|
||||
@@ -183,8 +189,6 @@
|
||||
on:confirmed={() => {
|
||||
innerComponent?.discardAll()
|
||||
showCloseConfirmModal = false
|
||||
diffMode = false
|
||||
pendingSave = false
|
||||
closeDrawer()
|
||||
}}
|
||||
>
|
||||
@@ -192,14 +196,14 @@
|
||||
<span>You have unsaved changes. Are you sure you want to discard them and close?</span>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
size="sm"
|
||||
startIcon={{ icon: FileDiff }}
|
||||
onClick={() => {
|
||||
showCloseConfirmModal = false
|
||||
diffMode = true
|
||||
handleReviewChanges()
|
||||
}}
|
||||
>
|
||||
Show diff
|
||||
Review changes
|
||||
</Button>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
closeDrawer,
|
||||
showHeaderInfo = true,
|
||||
yamlMode = $bindable(false),
|
||||
diffMode = $bindable(false),
|
||||
hasUnsavedChanges = $bindable(false)
|
||||
} = $props()
|
||||
|
||||
@@ -142,6 +141,9 @@
|
||||
return instanceSettings?.syncBeforeDiff() ?? true
|
||||
}
|
||||
|
||||
export function buildFullDiff(): { original: string; modified: string } {
|
||||
return instanceSettings?.buildFullDiff() ?? { original: '', modified: '' }
|
||||
}
|
||||
// --- Settings search ---
|
||||
const searchableItems = buildSearchableSettingItems()
|
||||
|
||||
@@ -203,7 +205,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="{showHeaderInfo ? 'pt-4' : ''} flex grow min-h-0">
|
||||
{#if !yamlMode && !diffMode}
|
||||
{#if !yamlMode}
|
||||
<!-- Sidebar Navigation -->
|
||||
<div class="w-52 shrink-0 h-full overflow-auto p-4 bg-surface flex flex-col">
|
||||
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
|
||||
@@ -231,7 +233,7 @@
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<div class="h-full overflow-auto bg-surface">
|
||||
<div class="h-fit px-8 py-4">
|
||||
{#if tab === 'users' && !yamlMode && !diffMode}
|
||||
{#if tab === 'users' && !yamlMode}
|
||||
<div class="h-full">
|
||||
{#if !automateUsernameCreation && !isCloudHosted()}
|
||||
<div class="mb-4">
|
||||
@@ -507,7 +509,6 @@
|
||||
bind:this={instanceSettings}
|
||||
hideTabs
|
||||
bind:yamlMode
|
||||
bind:diffMode
|
||||
bind:hasUnsavedChanges
|
||||
tab={instanceSettingsCategory}
|
||||
{authSubTab}
|
||||
@@ -543,4 +544,3 @@
|
||||
<span>Are you sure you want to remove <b>{deleteUserEmail}</b>?</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ export const settings: Record<string, Setting[]> = {
|
||||
],
|
||||
Jobs: [
|
||||
{
|
||||
label: 'Job Isolation',
|
||||
label: 'Job isolation',
|
||||
key: 'job_isolation',
|
||||
fieldType: 'select',
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '../common'
|
||||
import { Save, X, CheckCircle2, AlertCircle, Loader2 } from 'lucide-svelte'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import SaveButton from '../SaveButton.svelte'
|
||||
import { X } from 'lucide-svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
let {
|
||||
@@ -21,52 +22,6 @@
|
||||
inline?: boolean
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
let saveStatus: 'success' | 'error' | null = $state(null)
|
||||
let isSaving = $state(false)
|
||||
let statusTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearStatusTimeout() {
|
||||
if (statusTimeout !== null) {
|
||||
clearTimeout(statusTimeout)
|
||||
statusTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (isSaving) return
|
||||
|
||||
isSaving = true
|
||||
saveStatus = null
|
||||
clearStatusTimeout() // Clear any existing timeout
|
||||
|
||||
try {
|
||||
await onSave()
|
||||
saveStatus = 'success'
|
||||
// Auto-hide success status after 3 seconds
|
||||
statusTimeout = setTimeout(() => {
|
||||
saveStatus = null
|
||||
statusTimeout = null
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error)
|
||||
saveStatus = 'error'
|
||||
// Auto-hide error status after 5 seconds (longer for errors)
|
||||
statusTimeout = setTimeout(() => {
|
||||
saveStatus = null
|
||||
statusTimeout = null
|
||||
}, 5000)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup timeout on component unmount
|
||||
$effect(() => {
|
||||
return () => {
|
||||
clearStatusTimeout()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -81,44 +36,18 @@
|
||||
size="sm"
|
||||
startIcon={{ icon: X }}
|
||||
onClick={onDiscard}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
startIcon={{
|
||||
icon: isSaving ? Loader2 : Save,
|
||||
classes: isSaving ? 'animate-spin' : ''
|
||||
}}
|
||||
disabled={!hasUnsavedChanges || disabled || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : saveLabel}
|
||||
</Button>
|
||||
|
||||
<!-- Success icon overlay -->
|
||||
{#if saveStatus === 'success'}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-green-200 dark:bg-green-800 rounded-md"
|
||||
transition:fly={{ y: -10, duration: 300 }}
|
||||
>
|
||||
<CheckCircle2 class="w-5 h-5 text-green-700 dark:text-green-300" />
|
||||
</div>
|
||||
{:else if saveStatus === 'error'}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-red-500 dark:bg-red-700 rounded-md"
|
||||
transition:fly={{ y: -10, duration: 300 }}
|
||||
>
|
||||
<AlertCircle class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<SaveButton
|
||||
{onSave}
|
||||
disabled={!hasUnsavedChanges || disabled}
|
||||
label={saveLabel}
|
||||
unifiedSize="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import InstanceSettings from '$lib/components/InstanceSettings.svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import {
|
||||
setupNavigationGroups,
|
||||
tabToCategoryMap,
|
||||
@@ -25,6 +24,7 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
|
||||
import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
|
||||
const settingsSteps = [
|
||||
{ id: 'Core', label: 'Core' },
|
||||
@@ -148,19 +148,9 @@
|
||||
let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso')
|
||||
let yamlMode = $state(false)
|
||||
|
||||
// --- Unsaved changes detection (full mode) ---
|
||||
let pendingTab: string | undefined = $state(undefined)
|
||||
let showUnsavedChangesModal = $state(false)
|
||||
|
||||
function handleNavigate(newTab: string) {
|
||||
if (newTab === fullTab) return
|
||||
const currentCategory = tabToCategoryMap[fullTab]
|
||||
if (currentCategory && instanceSettings?.isDirty(currentCategory)) {
|
||||
pendingTab = newTab
|
||||
showUnsavedChangesModal = true
|
||||
} else {
|
||||
fullTab = newTab
|
||||
}
|
||||
fullTab = newTab
|
||||
}
|
||||
|
||||
// --- Settings search (full mode) ---
|
||||
@@ -558,33 +548,6 @@
|
||||
</div>
|
||||
</CenteredModal>
|
||||
|
||||
{#if showUnsavedChangesModal}
|
||||
<ConfirmationModal
|
||||
open={showUnsavedChangesModal}
|
||||
title="Unsaved changes detected"
|
||||
confirmationText="Discard changes"
|
||||
on:canceled={() => {
|
||||
showUnsavedChangesModal = false
|
||||
pendingTab = undefined
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
if (pendingTab !== undefined) {
|
||||
const currentCategory = tabToCategoryMap[fullTab]
|
||||
if (currentCategory) {
|
||||
instanceSettings?.discardCategory(currentCategory)
|
||||
}
|
||||
fullTab = pendingTab
|
||||
}
|
||||
showUnsavedChangesModal = false
|
||||
pendingTab = undefined
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>You have unsaved changes. Are you sure you want to discard them?</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
|
||||
{#if showLicenseKeyWarning}
|
||||
<ConfirmationModal
|
||||
open={showLicenseKeyWarning}
|
||||
@@ -603,7 +566,8 @@
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>
|
||||
You are running the Enterprise Edition image but have not entered a license key. A valid license key is required to use EE features. Are you sure you want to continue without one?
|
||||
You are running the Enterprise Edition image but have not entered a license key. A valid
|
||||
license key is required to use EE features. Are you sure you want to continue without one?
|
||||
</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
Reference in New Issue
Block a user