mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
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:
committed by
GitHub
co-authored by
windmill-internal-app[bot]
claude[bot]
parent
efec3fb568
commit
155fe6da35
+26
-11
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user