From 155fe6da35ec1975ba8ed307125a84f8fce78f22 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:50:19 +0200 Subject: [PATCH] feat(settings): add unsaved changes warning for workspace settings (#6813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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] * 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] --- .../UnsavedConfirmationModal.svelte | 37 ++-- .../lib/components/common/tabs/Tabs.svelte | 44 ++--- .../workspaceSettings/AISettings.svelte | 5 +- .../workspaceSettings/DucklakeSettings.svelte | 8 +- .../workspaceSettings/StorageSettings.svelte | 6 +- .../(logged)/workspace_settings/+page.svelte | 177 ++++++++++++++++-- 6 files changed, 229 insertions(+), 48 deletions(-) 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}
+ +