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 @@
{ + 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()}`) }} > -
Users
+
Users
-
Slack / Teams
+
Slack / Teams
{/if} {#if isCloudHosted()} @@ -440,7 +566,7 @@ aiId="workspace-settings-premium" aiDescription="Premium plans workspace settings" > -
Premium Plans
+
Premium Plans
{/if} {#if WORKSPACE_SHOW_WEBHOOK_CLI_SYNC} @@ -475,7 +601,7 @@ aiId="workspace-settings-windmill-lfs" aiDescription="Object Storage (S3) workspace settings" > -
Object Storage (S3)
+
Object Storage (S3)
-
Default App
+
Default App
-
Encryption
+
Encryption
-
General
+
General
@@ -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}
+ +