feat(settings): add unsaved changes warning for workspace settings (#6813)

* feat(settings): add unsaved changes warning on windmill ai tab

Add dialog to warn users when leaving the Windmill AI settings tab with
unsaved changes, allowing them to save or cancel their changes.

Changes:
- Track initial AI config state in workspace settings
- Compare current vs initial state to detect unsaved changes
- Integrate UnsavedConfirmationModal with beforeNavigate guard
- Update initial state after successful save via onSave callback

Implements request from issue #6812

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* also confirm on tab changes

* fix

* fix

* fix

* clean tabs usage

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
centdix
2025-10-14 16:50:19 +00:00
committed by GitHub
co-authored by windmill-internal-app[bot] claude[bot]
parent efec3fb568
commit 155fe6da35
6 changed files with 229 additions and 48 deletions
@@ -14,25 +14,37 @@
import type { GetInitialAndModifiedValues } from './unsavedTypes'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
export let getInitialAndModifiedValues: GetInitialAndModifiedValues = undefined
export let diffDrawer: DiffDrawer | undefined = undefined
export let additionalExitAction: () => void = () => {}
let savedValue: Value | undefined = undefined
let modifiedValue: Value | undefined = undefined
interface Props {
getInitialAndModifiedValues?: GetInitialAndModifiedValues
diffDrawer?: DiffDrawer | undefined
additionalExitAction?: () => void
triggerOnSearchParamsChange?: boolean
onDiscardChanges?: () => void
}
let bypassBeforeNavigate = false
let open = false
let goingTo: URL | undefined = undefined
let {
getInitialAndModifiedValues = undefined,
diffDrawer = undefined,
additionalExitAction = () => {},
triggerOnSearchParamsChange = false,
onDiscardChanges = undefined
}: Props = $props()
let savedValue: Value | undefined = $state(undefined)
let modifiedValue: Value | undefined = $state(undefined)
let bypassBeforeNavigate = $state(false)
let open = $state(false)
let goingTo: URL | undefined = $state(undefined)
beforeNavigate(async (newNavigationState) => {
if (
!bypassBeforeNavigate &&
getInitialAndModifiedValues &&
newNavigationState.to &&
newNavigationState.to.url != $page.url &&
newNavigationState.to.url.pathname !== newNavigationState.from?.url.pathname
((newNavigationState.to.url != $page.url &&
newNavigationState.to.url.pathname !== newNavigationState.from?.url.pathname) ||
(triggerOnSearchParamsChange && newNavigationState.to.url.search != $page.url.search))
) {
// console.log('going to', newNavigationState.to.url)
goingTo = newNavigationState.to.url
const state = getInitialAndModifiedValues?.()
@@ -86,6 +98,9 @@
open = false
}}
on:confirmed={() => {
open = false
// Discard changes before navigating
onDiscardChanges?.()
if (goingTo) {
bypassBeforeNavigate = true
additionalExitAction?.()
@@ -1,5 +1,5 @@
<script lang="ts">
import { setContext, untrack } from 'svelte'
import { setContext } from 'svelte'
import { writable } from 'svelte/store'
import { createEventDispatcher } from 'svelte'
import { twMerge } from 'tailwind-merge'
@@ -17,6 +17,13 @@
values?: string[] | undefined
children?: import('svelte').Snippet<[any]>
content?: import('svelte').Snippet
/**
* If true, the tab component will only update the internal store when a tab is clicked,
* but will NOT immediately update the bindable 'selected' prop. This allows the parent
* component to control when the tab actually changes (e.g., after navigation completes).
* Use this when you want to prevent navigation before checking for unsaved changes.
*/
deferSelectedUpdate?: boolean
}
let {
@@ -28,23 +35,30 @@
hashNavigation = false,
values = undefined,
children,
content
content,
deferSelectedUpdate = false
}: Props = $props()
// Single source of truth for tab state
const selectedStore = writable(selected)
function update(value: string) {
if (!deferSelectedUpdate) {
selected = value
}
dispatch('selected', value)
}
setContext<TabsContext>('Tabs', {
selected: selectedStore,
update: (value: string) => {
selectedStore.set(value)
selected = value
},
update,
hashNavigation
})
function updateSelected() {
// Sync external prop changes to store (single direction: prop → store)
$effect(() => {
selectedStore.set(selected)
}
})
let hashValues = $derived(values ? values.map((x) => '#' + x) : undefined)
@@ -53,22 +67,10 @@
const hash = window.location.hash
if (hash && hashValues?.includes(hash)) {
const id = hash.replace('#', '')
selectedStore.set(id)
selected = id
update(id)
}
}
}
$effect(() => {
selected && untrack(() => updateSelected())
})
let lastSelected: string | undefined = $state(selected)
$effect(() => {
if ($selectedStore !== untrack(() => lastSelected)) {
lastSelected = $selectedStore
$selectedStore && dispatch('selected', $selectedStore)
}
})
</script>
<svelte:window onhashchange={hashChange} />
@@ -29,7 +29,8 @@
defaultModel = $bindable(),
customPrompts = $bindable(),
maxTokensPerModel = $bindable(),
usingOpenaiClientCredentialsOauth = $bindable()
usingOpenaiClientCredentialsOauth = $bindable(),
onSave
}: {
aiProviders: Exclude<AIConfig['providers'], undefined>
codeCompletionModel: string | undefined
@@ -37,6 +38,7 @@
customPrompts: Record<string, string>
maxTokensPerModel: Record<string, number>
usingOpenaiClientCredentialsOauth: boolean
onSave?: () => void
} = $props()
let fetchedAiModels = $state(false)
@@ -122,6 +124,7 @@
setCopilotInfo({})
}
sendUserToast(`Copilot settings updated`)
onSave?.()
}
async function onAiProviderChange(provider: AIProvider) {
@@ -87,8 +87,13 @@
type Props = {
ducklakeSettings: DucklakeSettingsType
ducklakeSavedSettings: DucklakeSettingsType
onSave?: () => void
}
let { ducklakeSettings = $bindable(), ducklakeSavedSettings = $bindable() }: Props = $props()
let {
ducklakeSettings = $bindable(),
ducklakeSavedSettings = $bindable(),
onSave: onSaveProp = undefined
}: Props = $props()
let isInstanceCatalogEnabled = $derived($superadmin && !isCloudHosted())
@@ -151,6 +156,7 @@
})
ducklakeSavedSettings = clone(ducklakeSettings)
sendUserToast('Ducklake settings saved successfully')
onSaveProp?.()
} catch (e) {
sendUserToast(e, true)
console.error('Error saving ducklake settings', e)
@@ -26,7 +26,10 @@
import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
let { s3ResourceSettings = $bindable() }: { s3ResourceSettings: S3ResourceSettings } = $props()
let {
s3ResourceSettings = $bindable(),
onSave = undefined
}: { s3ResourceSettings: S3ResourceSettings; onSave?: () => void } = $props()
let s3FileViewer: S3FilePicker | undefined = $state()
@@ -40,6 +43,7 @@
})
console.log('Large file storage settings changed', large_file_storage)
sendUserToast(`Large file storage settings changed`)
onSave?.()
}
</script>
@@ -56,6 +56,7 @@
type DucklakeSettingsType
} from '$lib/components/workspaceSettings/DucklakeSettings.svelte'
import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
let slackInitialPath: string = $state('')
let slackScriptPath: string = $state('')
@@ -83,12 +84,25 @@
let customPrompts: Record<string, string> = $state({})
let maxTokensPerModel: Record<string, number> = $state({})
// Track initial AI config for unsaved changes detection
let initialAiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let initialCodeCompletionModel: string | undefined = $state(undefined)
let initialDefaultModel: string | undefined = $state(undefined)
let initialCustomPrompts: Record<string, string> = $state({})
let initialMaxTokensPerModel: Record<string, number> = $state({})
let s3ResourceSettings: S3ResourceSettings = $state({
resourceType: 's3',
resourcePath: undefined,
publicResource: undefined,
secondaryStorage: undefined
})
let initialS3ResourceSettings: S3ResourceSettings = $state({
resourceType: 's3',
resourcePath: undefined,
publicResource: undefined,
secondaryStorage: undefined
})
let ducklakeSettings: DucklakeSettingsType = $state({
ducklakes: []
@@ -109,7 +123,12 @@
| 'general'
| 'webhook'
| 'deploy_to'
| 'error_handler') ?? 'users'
| 'error_handler'
| 'ai'
| 'windmill_lfs'
| 'git_sync'
| 'default_app'
| 'encryption') ?? 'users'
)
let usingOpenaiClientCredentialsOauth = $state(false)
@@ -253,6 +272,13 @@
customPrompts[mode] = ''
}
}
// Store initial AI config state for unsaved changes detection
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
errorHandlerItemKind = settings.error_handler
? (settings.error_handler.split('/')[0] as 'flow' | 'script')
: 'script'
@@ -272,6 +298,7 @@
settings.large_file_storage,
!!$enterpriseLicense
)
initialS3ResourceSettings = clone(s3ResourceSettings)
ducklakeSettings = convertDucklakeSettingsFromBackend(settings.ducklake)
ducklakeSavedSettings = clone(ducklakeSettings)
@@ -373,6 +400,102 @@
untrack(() => tab)
)
})
// Function to check if there are unsaved changes in AI settings
function getAiSettingsInitialAndModifiedValues() {
// Only check for unsaved changes when on the AI tab
if (tab !== 'ai') {
return {
savedValue: undefined,
modifiedValue: undefined
}
}
const savedValue = {
aiProviders: initialAiProviders,
defaultModel: initialDefaultModel,
codeCompletionModel: initialCodeCompletionModel,
customPrompts: initialCustomPrompts,
maxTokensPerModel: initialMaxTokensPerModel
}
const modifiedValue = {
aiProviders: aiProviders,
defaultModel: defaultModel,
codeCompletionModel: codeCompletionModel,
customPrompts: customPrompts,
maxTokensPerModel: maxTokensPerModel
}
return { savedValue, modifiedValue }
}
// Function to discard unsaved AI settings changes
function discardAiSettingsChanges() {
aiProviders = clone(initialAiProviders)
defaultModel = initialDefaultModel
codeCompletionModel = initialCodeCompletionModel
customPrompts = clone(initialCustomPrompts)
maxTokensPerModel = clone(initialMaxTokensPerModel)
}
// Function to check if there are unsaved changes in storage settings
function getStorageSettingsInitialAndModifiedValues() {
// Only check for unsaved changes when on the windmill_lfs tab
if (tab !== 'windmill_lfs') {
return {
savedValue: undefined,
modifiedValue: undefined
}
}
const savedValue = {
s3ResourceSettings: initialS3ResourceSettings,
ducklakeSettings: ducklakeSavedSettings
}
const modifiedValue = {
s3ResourceSettings: s3ResourceSettings,
ducklakeSettings: ducklakeSettings
}
return { savedValue, modifiedValue }
}
// Function to discard unsaved storage settings changes
function discardStorageSettingsChanges() {
s3ResourceSettings = clone(initialS3ResourceSettings)
ducklakeSettings = clone(ducklakeSavedSettings)
}
// Combined function to check for unsaved changes across all tabs
function getAllUnsavedChanges() {
// Check AI settings
const aiChanges = getAiSettingsInitialAndModifiedValues()
if (aiChanges.savedValue && aiChanges.modifiedValue) {
return aiChanges
}
// Check storage settings
const storageChanges = getStorageSettingsInitialAndModifiedValues()
if (storageChanges.savedValue && storageChanges.modifiedValue) {
return storageChanges
}
return {
savedValue: {},
modifiedValue: {}
}
}
// Combined function to discard changes based on current tab
function discardAllChanges() {
if (tab === 'ai') {
discardAiSettingsChanges()
} else if (tab === 'windmill_lfs') {
discardStorageSettingsChanges()
}
}
</script>
<CenteredPage>
@@ -393,10 +516,13 @@
<div class="overflow-x-auto scrollbar-hidden">
<Tabs
bind:selected={tab}
on:selected={() => {
deferSelectedUpdate={true}
on:selected={(e) => {
// setQueryWithoutLoad($page.url, [{ key: 'tab', value: tab }], 0)
$page.url.searchParams.set('tab', tab)
goto(`?${$page.url.searchParams.toString()}`)
const params = new URLSearchParams($page.url.searchParams)
const newTab = e.detail
params.set('tab', newTab)
goto(`?${params.toString()}`)
}}
>
<Tab
@@ -405,7 +531,7 @@
aiId="workspace-settings-users"
aiDescription="Users workspace settings"
>
<div class="flex gap-2 items-center my-1"> Users</div>
<div class="flex gap-2 items-center my-1">Users</div>
</Tab>
<Tab
size="xs"
@@ -430,7 +556,7 @@
aiId="workspace-settings-slack"
aiDescription="Slack / Teams workspace settings"
>
<div class="flex gap-2 items-center my-1"> Slack / Teams</div>
<div class="flex gap-2 items-center my-1">Slack / Teams</div>
</Tab>
{/if}
{#if isCloudHosted()}
@@ -440,7 +566,7 @@
aiId="workspace-settings-premium"
aiDescription="Premium plans workspace settings"
>
<div class="flex gap-2 items-center my-1"> Premium Plans </div>
<div class="flex gap-2 items-center my-1">Premium Plans</div>
</Tab>
{/if}
{#if WORKSPACE_SHOW_WEBHOOK_CLI_SYNC}
@@ -475,7 +601,7 @@
aiId="workspace-settings-windmill-lfs"
aiDescription="Object Storage (S3) workspace settings"
>
<div class="flex gap-2 items-center my-1"> Object Storage (S3)</div>
<div class="flex gap-2 items-center my-1">Object Storage (S3)</div>
</Tab>
<Tab
size="xs"
@@ -483,7 +609,7 @@
aiId="workspace-settings-default-app"
aiDescription="Default app workspace settings"
>
<div class="flex gap-2 items-center my-1"> Default App </div>
<div class="flex gap-2 items-center my-1">Default App</div>
</Tab>
<Tab
size="xs"
@@ -491,7 +617,7 @@
aiId="workspace-settings-encryption"
aiDescription="Encryption workspace settings"
>
<div class="flex gap-2 items-center my-1"> Encryption </div>
<div class="flex gap-2 items-center my-1">Encryption</div>
</Tab>
<Tab
size="xs"
@@ -499,7 +625,7 @@
aiId="workspace-settings-general"
aiDescription="General workspace settings"
>
<div class="flex gap-2 items-center my-1"> General </div>
<div class="flex gap-2 items-center my-1">General</div>
</Tab>
</Tabs>
</div>
@@ -817,10 +943,29 @@
bind:customPrompts
bind:maxTokensPerModel
bind:usingOpenaiClientCredentialsOauth
onSave={() => {
// Update initial state after successful save
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
}}
/>
{:else if tab == 'windmill_lfs'}
<StorageSettings bind:s3ResourceSettings />
<DucklakeSettings bind:ducklakeSettings bind:ducklakeSavedSettings />
<StorageSettings
bind:s3ResourceSettings
onSave={() => {
initialS3ResourceSettings = clone(s3ResourceSettings)
}}
/>
<DucklakeSettings
bind:ducklakeSettings
bind:ducklakeSavedSettings
onSave={() => {
ducklakeSavedSettings = clone(ducklakeSettings)
}}
/>
{:else if tab == 'git_sync'}
{#if $workspaceStore}
<GitSyncSection />
@@ -923,5 +1068,11 @@
{/if}
</CenteredPage>
<UnsavedConfirmationModal
getInitialAndModifiedValues={getAllUnsavedChanges}
onDiscardChanges={discardAllChanges}
triggerOnSearchParamsChange={true}
/>
<style>
</style>