diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte
index 67e5701317..2544e89fe7 100644
--- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte
+++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte
@@ -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?.()
diff --git a/frontend/src/lib/components/common/tabs/Tabs.svelte b/frontend/src/lib/components/common/tabs/Tabs.svelte
index 2457a2d356..642340b01b 100644
--- a/frontend/src/lib/components/common/tabs/Tabs.svelte
+++ b/frontend/src/lib/components/common/tabs/Tabs.svelte
@@ -1,5 +1,5 @@
diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte
index 78df98574a..77215d332b 100644
--- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte
+++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte
@@ -29,7 +29,8 @@
defaultModel = $bindable(),
customPrompts = $bindable(),
maxTokensPerModel = $bindable(),
- usingOpenaiClientCredentialsOauth = $bindable()
+ usingOpenaiClientCredentialsOauth = $bindable(),
+ onSave
}: {
aiProviders: Exclude
codeCompletionModel: string | undefined
@@ -37,6 +38,7 @@
customPrompts: Record
maxTokensPerModel: Record
usingOpenaiClientCredentialsOauth: boolean
+ onSave?: () => void
} = $props()
let fetchedAiModels = $state(false)
@@ -122,6 +124,7 @@
setCopilotInfo({})
}
sendUserToast(`Copilot settings updated`)
+ onSave?.()
}
async function onAiProviderChange(provider: AIProvider) {
diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte
index 5886b3e4db..1a001b7036 100644
--- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte
+++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte
@@ -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)
diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte
index f26fd6c4f1..337e84d8d1 100644
--- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte
+++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte
@@ -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?.()
}
diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
index 47fd6e33b7..2061483810 100644
--- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
@@ -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 = $state({})
let maxTokensPerModel: Record = $state({})
+ // Track initial AI config for unsaved changes detection
+ let initialAiProviders: Exclude = $state({})
+ let initialCodeCompletionModel: string | undefined = $state(undefined)
+ let initialDefaultModel: string | undefined = $state(undefined)
+ let initialCustomPrompts: Record = $state({})
+ let initialMaxTokensPerModel: Record = $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()
+ }
+ }
@@ -393,10 +516,13 @@
@@ -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'}
-
-
+ {
+ initialS3ResourceSettings = clone(s3ResourceSettings)
+ }}
+ />
+ {
+ ducklakeSavedSettings = clone(ducklakeSettings)
+ }}
+ />
{:else if tab == 'git_sync'}
{#if $workspaceStore}
@@ -923,5 +1068,11 @@
{/if}
+
+