From ecd4be602f7527877f2d4942aa0c298c0173c667 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Wed, 24 Jun 2026 12:41:22 +0200 Subject: [PATCH 1/9] dbeaver/pro#4425 feat: add confirmation dialog for unsaved changes in administration --- .../src/CommonDialog/confirmUnsavedChanges.ts | 57 +++++++++++++++++ webapp/packages/core-blocks/src/index.ts | 1 + .../core-localization/src/locales/en.ts | 1 + .../core-localization/src/locales/fr.ts | 1 + .../core-localization/src/locales/it.ts | 2 + .../core-localization/src/locales/ru.ts | 1 + .../core-localization/src/locales/vi.ts | 1 + .../core-localization/src/locales/zh.ts | 1 + .../packages/core-ui/src/Form/IFormState.ts | 1 + .../AppScreen/UnsavedChangesService.ts | 63 +++++++++++++++++++ .../Screens/AppScreen/useUnsavedChanges.ts | 54 ++++++++++++++++ webapp/packages/core-ui/src/index.ts | 2 + webapp/packages/core-ui/src/module.ts | 2 + ...onfigurationWizardPagesBootstrapService.ts | 28 +++------ .../Users/Teams/TeamsTable/CreateTeam.tsx | 12 ++++ .../Users/Teams/TeamsTable/TeamEdit.tsx | 25 ++++---- .../Users/UsersTable/CreateUser.tsx | 4 +- .../Users/UsersTable/UserEdit.tsx | 25 ++++---- .../src/SettingsAdministration.tsx | 16 ++++- 19 files changed, 252 insertions(+), 45 deletions(-) create mode 100644 webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts create mode 100644 webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts create mode 100644 webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts diff --git a/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts b/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts new file mode 100644 index 00000000000..78fbda460b1 --- /dev/null +++ b/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts @@ -0,0 +1,57 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { type CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import type { TLocalizationToken } from '@cloudbeaver/core-localization'; + +import { ConfirmationDialog } from './ConfirmationDialog.js'; + +export interface IUnsavedChangesProvider { + readonly isChanged: boolean; + readonly isSaving?: boolean; + save(): Promise; + reset(): void; + title?: string; + subTitle?: string; + message?: TLocalizationToken; +} + +/** + * Shows a unified "Save / Don't save / Cancel" dialog when the provider has unsaved changes. + * + * @returns `true` when navigation may proceed (saved successfully or discarded), `false` to block it. + */ +export async function confirmUnsavedChanges(commonDialogService: CommonDialogService, provider: IUnsavedChangesProvider): Promise { + if (!provider.isChanged) { + return true; + } + + if (provider.isSaving) { + return false; + } + + const { status, result } = await commonDialogService.open(ConfirmationDialog, { + title: provider.title ?? 'ui_save_reminder', + subTitle: provider.subTitle, + message: provider.message ?? 'ui_changes_might_be_lost', + confirmActionText: 'ui_processing_save', + extraActionText: 'ui_processing_dont_save', + cancelActionText: 'ui_processing_cancel', + showExtraAction: true, + }); + + if (status === DialogueStateResult.Resolved) { + return provider.save(); + } + + if (result?.isExtraAction) { + provider.reset(); + return true; + } + + return false; +} diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index 7e3e932b9bb..4e6b2a87523 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -17,6 +17,7 @@ export * from './CommonDialog/CommonDialog/CommonDialogBody.js'; export * from './CommonDialog/CommonDialog/CommonDialogFooter.js'; export * from './CommonDialog/CommonDialog/CommonDialogHeader.js'; export * from './CommonDialog/CommonDialog/CommonDialogWrapper.js'; +export * from './CommonDialog/confirmUnsavedChanges.js'; export * from './CommonDialog/ConfirmationDialog.js'; export { default as ConfirmationDialogStyles } from './CommonDialog/ConfirmationDialog.module.css'; export * from './CommonDialog/ConfirmationDialogDelete.js'; diff --git a/webapp/packages/core-localization/src/locales/en.ts b/webapp/packages/core-localization/src/locales/en.ts index 693fb99d523..ba9bf8f274b 100644 --- a/webapp/packages/core-localization/src/locales/en.ts +++ b/webapp/packages/core-localization/src/locales/en.ts @@ -20,6 +20,7 @@ export default [ ['ui_processing_ok', 'Ok'], ['ui_processing_create', 'Create'], ['ui_processing_save', 'Save'], + ['ui_processing_dont_save', "Don't save"], ['ui_processing_saving', 'Saving...'], ['ui_processing_do_you_want_to_proceed', 'Do you want to proceed?'], ['ui_processing_saved', 'Saved'], diff --git a/webapp/packages/core-localization/src/locales/fr.ts b/webapp/packages/core-localization/src/locales/fr.ts index 833d52ed603..e77f01a44b2 100644 --- a/webapp/packages/core-localization/src/locales/fr.ts +++ b/webapp/packages/core-localization/src/locales/fr.ts @@ -20,6 +20,7 @@ export default [ ['ui_processing_ok', 'Ok'], ['ui_processing_create', 'Créer'], ['ui_processing_save', 'Sauvegarder'], + ['ui_processing_dont_save', 'Ne pas sauvegarder'], ['ui_processing_saved', 'Sauvegardé'], ['ui_processing_stop', 'Arrêter'], ['ui_processing_skip', 'Passer'], diff --git a/webapp/packages/core-localization/src/locales/it.ts b/webapp/packages/core-localization/src/locales/it.ts index a0cc800593d..45d40ad3419 100644 --- a/webapp/packages/core-localization/src/locales/it.ts +++ b/webapp/packages/core-localization/src/locales/it.ts @@ -19,6 +19,7 @@ export default [ ['ui_processing_ok', 'Ok'], ['ui_processing_create', 'Crea'], ['ui_processing_save', 'Salva'], + ['ui_processing_dont_save', 'Non salvare'], ['ui_processing_saving', 'Saving...'], ['ui_processing_do_you_want_to_proceed', 'Do you want to proceed?'], ['ui_processing_saved', 'Saved'], @@ -95,6 +96,7 @@ export default [ ['ui_no_items_placeholder', 'Non ci sono ancora elementi.'], ['ui_search_no_result_placeholder', 'Nessun risultato trovato.'], ['ui_save_reminder', 'Ci sono modifiche non salvate.'], + ['ui_changes_might_be_lost', 'Le tue modifiche potrebbero andare perse'], ['ui_yes', 'Sì'], ['ui_no', 'No'], ['ui_select_all', 'Select all'], diff --git a/webapp/packages/core-localization/src/locales/ru.ts b/webapp/packages/core-localization/src/locales/ru.ts index 4d7eb39041a..2f1d5d1b2ea 100644 --- a/webapp/packages/core-localization/src/locales/ru.ts +++ b/webapp/packages/core-localization/src/locales/ru.ts @@ -18,6 +18,7 @@ export default [ ['ui_processing_ok', 'Принять'], ['ui_processing_create', 'Создать'], ['ui_processing_save', 'Сохранить'], + ['ui_processing_dont_save', 'Не сохранять'], ['ui_processing_saving', 'Сохранение...'], ['ui_processing_do_you_want_to_proceed', 'Хотите продолжить?'], ['ui_processing_saved', 'Сохранено'], diff --git a/webapp/packages/core-localization/src/locales/vi.ts b/webapp/packages/core-localization/src/locales/vi.ts index 438b20f77ad..9a2e0c710cd 100644 --- a/webapp/packages/core-localization/src/locales/vi.ts +++ b/webapp/packages/core-localization/src/locales/vi.ts @@ -20,6 +20,7 @@ export default [ ['ui_processing_ok', 'Đồng ý'], ['ui_processing_create', 'Tạo'], ['ui_processing_save', 'Lưu'], + ['ui_processing_dont_save', 'Không lưu'], ['ui_processing_saving', 'Đang lưu...'], ['ui_processing_do_you_want_to_proceed', 'Bạn có muốn tiếp tục không?'], ['ui_processing_saved', 'Đã lưu'], diff --git a/webapp/packages/core-localization/src/locales/zh.ts b/webapp/packages/core-localization/src/locales/zh.ts index cb70c11eb54..a2d0a8765a7 100644 --- a/webapp/packages/core-localization/src/locales/zh.ts +++ b/webapp/packages/core-localization/src/locales/zh.ts @@ -19,6 +19,7 @@ export default [ ['ui_processing_ok', '好'], ['ui_processing_create', '创建'], ['ui_processing_save', '保存'], + ['ui_processing_dont_save', '不保存'], ['ui_processing_saving', '保存中...'], ['ui_processing_do_you_want_to_proceed', '是否继续?'], ['ui_processing_saved', '已保存'], diff --git a/webapp/packages/core-ui/src/Form/IFormState.ts b/webapp/packages/core-ui/src/Form/IFormState.ts index 2090a0c24b4..205f1afbbdd 100644 --- a/webapp/packages/core-ui/src/Form/IFormState.ts +++ b/webapp/packages/core-ui/src/Form/IFormState.ts @@ -45,6 +45,7 @@ export interface IFormState { isError: boolean; isCancelled: boolean; isChanged: boolean; + isSaving: boolean; isReadOnly: boolean; save(providedContext?: IExecutionContext>): Promise; diff --git a/webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts b/webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts new file mode 100644 index 00000000000..27dfa75bf8b --- /dev/null +++ b/webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts @@ -0,0 +1,63 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { confirmUnsavedChanges, type IUnsavedChangesProvider } from '@cloudbeaver/core-blocks'; +import { injectable } from '@cloudbeaver/core-di'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; +import { ExecutorInterrupter, type IExecutionContextProvider } from '@cloudbeaver/core-executor'; +import { RouterService, type RouterTransitionData } from '@cloudbeaver/core-routing'; + +@injectable(() => [RouterService, CommonDialogService]) +export class UnsavedChangesService { + private readonly providers: Set; + private confirming: boolean; + + constructor( + routerService: RouterService, + private readonly commonDialogService: CommonDialogService, + ) { + this.providers = new Set(); + this.confirming = false; + routerService.transitionTask.addHandler(this.handleTransition.bind(this)); + } + + register(provider: IUnsavedChangesProvider): void { + this.providers.add(provider); + } + + unregister(provider: IUnsavedChangesProvider): void { + this.providers.delete(provider); + } + + hasUnsavedChanges(): boolean { + return Array.from(this.providers).some(provider => provider.isChanged); + } + + private async handleTransition(data: RouterTransitionData, contexts: IExecutionContextProvider): Promise { + if (this.confirming) { + return; + } + + const changed = Array.from(this.providers).filter(provider => provider.isChanged); + + if (changed.length === 0) { + return; + } + + this.confirming = true; + try { + for (const provider of changed) { + if (!(await confirmUnsavedChanges(this.commonDialogService, provider))) { + ExecutorInterrupter.interrupt(contexts); + return; + } + } + } finally { + this.confirming = false; + } + } +} diff --git a/webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts b/webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts new file mode 100644 index 00000000000..4fc9accd617 --- /dev/null +++ b/webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts @@ -0,0 +1,54 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { useEffect } from 'react'; + +import { type IUnsavedChangesProvider, useObjectRef } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; + +import { UnsavedChangesService } from './UnsavedChangesService.js'; + +/** + * Registers the page form with the global unsaved-changes guard for as long as the component is mounted. + * Leaving the page (route transition) prompts a Save / Don't save / Cancel dialog when the provider is changed. + */ +export function useUnsavedChanges(provider: IUnsavedChangesProvider): void { + const service = useService(UnsavedChangesService); + + const wrapper = useObjectRef( + () => ({ + provider, + get isChanged() { + return this.provider.isChanged; + }, + get isSaving() { + return this.provider.isSaving; + }, + get title() { + return this.provider.title; + }, + get subTitle() { + return this.provider.subTitle; + }, + get message() { + return this.provider.message; + }, + save() { + return this.provider.save(); + }, + reset() { + this.provider.reset(); + }, + }), + { provider }, + ); + + useEffect(() => { + service.register(wrapper); + return () => service.unregister(wrapper); + }, [service, wrapper]); +} diff --git a/webapp/packages/core-ui/src/index.ts b/webapp/packages/core-ui/src/index.ts index 53bd6bf2208..3d488f94ee3 100644 --- a/webapp/packages/core-ui/src/index.ts +++ b/webapp/packages/core-ui/src/index.ts @@ -48,6 +48,8 @@ export * from './InlineEditor/InlineEditorLoader.js'; export * from './Screens/AppScreen/NavigationService.js'; export * from './Screens/AppScreen/OptionsPanelService.js'; +export * from './Screens/AppScreen/UnsavedChangesService.js'; +export * from './Screens/AppScreen/useUnsavedChanges.js'; export * from './Tabs/ITab.js'; export * from './Tabs/TabContext.js'; diff --git a/webapp/packages/core-ui/src/module.ts b/webapp/packages/core-ui/src/module.ts index b100870282f..0053d281a2e 100644 --- a/webapp/packages/core-ui/src/module.ts +++ b/webapp/packages/core-ui/src/module.ts @@ -12,6 +12,7 @@ import { SideBarPanelService } from './SideBarPanel/SideBarPanelService.js'; import { LeftBarPanelService } from './SideBarPanel/LeftBarPanelService.js'; import { NavigationService } from './Screens/AppScreen/NavigationService.js'; import { OptionsPanelService } from './Screens/AppScreen/OptionsPanelService.js'; +import { UnsavedChangesService } from './Screens/AppScreen/UnsavedChangesService.js'; import { LocaleService } from './LocaleService.js'; import { ClipboardBootstrap } from './Clipboard/ClipboardBootstrap.js'; import { ClipboardService } from './Clipboard/ClipboardService.js'; @@ -30,6 +31,7 @@ export default ModuleRegistry.add({ .addSingleton(LeftBarPanelService) .addSingleton(NavigationService) .addSingleton(OptionsPanelService) + .addSingleton(UnsavedChangesService) .addSingleton(ClipboardBootstrap); }, }); diff --git a/webapp/packages/plugin-administration/src/ConfigurationWizard/ConfigurationWizardPagesBootstrapService.ts b/webapp/packages/plugin-administration/src/ConfigurationWizard/ConfigurationWizardPagesBootstrapService.ts index 5744382563b..2df0f246edd 100644 --- a/webapp/packages/plugin-administration/src/ConfigurationWizard/ConfigurationWizardPagesBootstrapService.ts +++ b/webapp/packages/plugin-administration/src/ConfigurationWizard/ConfigurationWizardPagesBootstrapService.ts @@ -6,9 +6,9 @@ * you may not use this file except in compliance with the License. */ import { AdministrationItemService, AdministrationItemType, ConfigurationWizardService } from '@cloudbeaver/core-administration'; -import { ConfirmationDialog, importLazyComponent } from '@cloudbeaver/core-blocks'; +import { confirmUnsavedChanges, importLazyComponent } from '@cloudbeaver/core-blocks'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { SessionDataResource } from '@cloudbeaver/core-root'; import { formValidationContext } from '@cloudbeaver/core-ui'; import { getFirstException } from '@cloudbeaver/core-utils'; @@ -112,25 +112,15 @@ export class ConfigurationWizardPagesBootstrapService extends Bootstrap { this.serverConfigurationFormStateManager.destroy(); }, - canDeActivate: async configurationWizard => { + canDeActivate: configurationWizard => { const state = this.serverConfigurationFormStateManager.formState; - if (state?.isSaving) { - return false; - } - - if (!configurationWizard && state?.isChanged) { - const { status } = await this.commonDialogService.open(ConfirmationDialog, { - title: 'ui_save_reminder', - message: 'ui_are_you_sure', - }); - - if (status === DialogueStateResult.Rejected) { - return false; - } - } - - return true; + return confirmUnsavedChanges(this.commonDialogService, { + isChanged: !configurationWizard && !!state?.isChanged, + isSaving: state?.isSaving, + save: () => this.serverConfigurationFormStateManager.save(), + reset: () => state?.reset(), + }); }, getContentComponent: () => ServerConfigurationPage, getDrawerComponent: () => ServerConfigurationDrawerItem, diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx index f1c65299b61..5f0fc765ab9 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx @@ -9,6 +9,7 @@ import { observer } from 'mobx-react-lite'; import { Container, Group, GroupTitle, Loader, s, Translate, useS, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; +import { useUnsavedChanges } from '@cloudbeaver/core-ui'; import { TeamForm } from '../TeamsForm/TeamForm.js'; import style from './CreateTeam.module.css'; @@ -19,6 +20,17 @@ export const CreateTeam: React.FC = observer(function CreateTeam() { const styles = useS(style); const service = useService(CreateTeamService); + useUnsavedChanges({ + get isChanged() { + return service.data?.isChanged ?? false; + }, + get isSaving() { + return service.data?.isSaving; + }, + save: () => service.data?.save() ?? Promise.resolve(true), + reset: () => service.data?.reset(), + }); + if (!service.data) { return null; } diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx index 7919b4473bf..8cf4bb3bcbc 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx @@ -8,9 +8,9 @@ import { observer } from 'mobx-react-lite'; import { TeamsResource } from '@cloudbeaver/core-authentication'; -import { ColoredContainer, ConfirmationDialog, GroupBack, GroupTitle, Text, useExecutor, useResource, useTranslate } from '@cloudbeaver/core-blocks'; +import { ColoredContainer, confirmUnsavedChanges, GroupBack, GroupTitle, Text, useExecutor, useResource, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; -import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { ExecutorInterrupter } from '@cloudbeaver/core-executor'; import { FormMode } from '@cloudbeaver/core-ui'; @@ -35,16 +35,19 @@ export const TeamEdit = observer(function TeamEdit({ item }) { executor: teamsTableOptionsPanelService.onClose, handlers: [ async function closeHandler(event, contexts) { - if (formState.isChanged && event === 'before') { - const { status } = await commonDialogService.open(ConfirmationDialog, { - title: 'ui_save_reminder', - message: 'ui_are_you_sure', - confirmActionText: 'ui_yes', - }); + if (event !== 'before') { + return; + } + + const confirmed = await confirmUnsavedChanges(commonDialogService, { + isChanged: formState.isChanged, + isSaving: formState.isSaving, + save: () => formState.save(), + reset: () => formState.reset(), + }); - if (status === DialogueStateResult.Rejected) { - ExecutorInterrupter.interrupt(contexts); - } + if (!confirmed) { + ExecutorInterrupter.interrupt(contexts); } }, ], diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx index c0b721553ee..613adc4ce55 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx @@ -8,7 +8,7 @@ import { observer } from 'mobx-react-lite'; import { Container, Group, GroupTitle, Loader, s, Translate, useS } from '@cloudbeaver/core-blocks'; -import type { IFormState } from '@cloudbeaver/core-ui'; +import { type IFormState, useUnsavedChanges } from '@cloudbeaver/core-ui'; import { AdministrationUserForm } from '../UserForm/AdministrationUserForm.js'; import type { IUserFormState } from '../UserForm/AdministrationUserFormService.js'; @@ -22,6 +22,8 @@ interface Props { export const CreateUser = observer(function CreateUser({ state, onCancel }) { const styles = useS(style); + useUnsavedChanges(state); + return ( diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx index e42f815543f..b839e269728 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx @@ -9,7 +9,7 @@ import { observer } from 'mobx-react-lite'; import { ColoredContainer, - ConfirmationDialog, + confirmUnsavedChanges, GroupBack, GroupTitle, Loader, @@ -19,7 +19,7 @@ import { useTranslate, } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; -import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { ExecutorInterrupter } from '@cloudbeaver/core-executor'; import { FormMode } from '@cloudbeaver/core-ui'; @@ -37,16 +37,19 @@ export const UserEdit = observer>(function UserEdit executor: usersTableOptionsPanelService.onClose, handlers: [ async function closeHandler(event, contexts) { - if (state.isChanged && event === 'before') { - const { status } = await commonDialogService.open(ConfirmationDialog, { - title: 'ui_save_reminder', - message: 'ui_are_you_sure', - confirmActionText: 'ui_yes', - }); + if (event !== 'before') { + return; + } + + const confirmed = await confirmUnsavedChanges(commonDialogService, { + isChanged: state.isChanged, + isSaving: state.isSaving, + save: () => state.save(), + reset: () => state.reset(), + }); - if (status === DialogueStateResult.Rejected) { - ExecutorInterrupter.interrupt(contexts); - } + if (!confirmed) { + ExecutorInterrupter.interrupt(contexts); } }, ], diff --git a/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx b/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx index dfd808f9876..65af97eacfe 100644 --- a/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx +++ b/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx @@ -22,7 +22,7 @@ import { import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { Settings } from '@cloudbeaver/plugin-settings-panel'; -import { TabList, TabsState, type ITabData } from '@cloudbeaver/core-ui'; +import { TabList, TabsState, type ITabData, useUnsavedChanges } from '@cloudbeaver/core-ui'; import { SettingsAdministrationService } from './SettingsAdministrationService.js'; import { useState } from 'react'; import { SettingsResolverService } from '@cloudbeaver/core-settings'; @@ -38,15 +38,17 @@ export const SettingsAdministration = observer(f const accessor = tabInfo?.options?.accessor; const changed = settingsSource?.isEdited() || false; - async function handleSave() { + async function handleSave(): Promise { if (!changed) { - return; + return true; } try { await settingsSource?.save(); notificationService.logSuccess({ title: translate('plugin_settings_administration_settings_save_success') }); + return true; } catch (error: any) { notificationService.logException(error, 'plugin_settings_administration_settings_save_fail'); + return false; } } @@ -60,6 +62,14 @@ export const SettingsAdministration = observer(f settingsSource?.clear(); } + useUnsavedChanges({ + get isChanged() { + return settingsSource?.isEdited() ?? false; + }, + save: handleSave, + reset: handleReset, + }); + function handleRestoreDefaults() { settingsSource?.restoreDefaults?.(); } From e4ae2c73c52b64728ff6ca305204de7f64748090 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Tue, 30 Jun 2026 11:59:12 +0200 Subject: [PATCH 2/9] dbeaver/pro#4425 refactor: update confirmUnsavedChanges interface --- .../core-blocks/src/CommonDialog/confirmUnsavedChanges.ts | 5 +++-- .../src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts b/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts index 78fbda460b1..2ede4b6a399 100644 --- a/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts +++ b/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts @@ -13,7 +13,8 @@ import { ConfirmationDialog } from './ConfirmationDialog.js'; export interface IUnsavedChangesProvider { readonly isChanged: boolean; readonly isSaving?: boolean; - save(): Promise; + /** Returns whether the save succeeded. `undefined` means there was nothing to save and navigation may proceed. */ + save(): Promise | undefined; reset(): void; title?: string; subTitle?: string; @@ -45,7 +46,7 @@ export async function confirmUnsavedChanges(commonDialogService: CommonDialogSer }); if (status === DialogueStateResult.Resolved) { - return provider.save(); + return (await provider.save()) ?? true; } if (result?.isExtraAction) { diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx index 5f0fc765ab9..b8506742c9b 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx @@ -27,7 +27,7 @@ export const CreateTeam: React.FC = observer(function CreateTeam() { get isSaving() { return service.data?.isSaving; }, - save: () => service.data?.save() ?? Promise.resolve(true), + save: () => service.data?.save(), reset: () => service.data?.reset(), }); From c96aff539fb2bf498adf851e319a68d879214b34 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Tue, 30 Jun 2026 12:00:54 +0200 Subject: [PATCH 3/9] dbeaver/pro#4425 refactor: simplify confirmUnsavedChanges usage --- .../Users/Teams/TeamsTable/CreateTeam.tsx | 2 +- .../Users/Teams/TeamsTable/TeamEdit.tsx | 20 +++++++++++-------- .../Users/UsersTable/UserEdit.tsx | 9 ++------- .../src/SettingsAdministration.tsx | 2 +- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx index b8506742c9b..02810ff8faf 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx index 8cf4bb3bcbc..7a0b89416e2 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/TeamEdit.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -8,7 +8,16 @@ import { observer } from 'mobx-react-lite'; import { TeamsResource } from '@cloudbeaver/core-authentication'; -import { ColoredContainer, confirmUnsavedChanges, GroupBack, GroupTitle, Text, useExecutor, useResource, useTranslate } from '@cloudbeaver/core-blocks'; +import { + ColoredContainer, + confirmUnsavedChanges, + GroupBack, + GroupTitle, + Text, + useExecutor, + useResource, + useTranslate, +} from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { ExecutorInterrupter } from '@cloudbeaver/core-executor'; @@ -39,12 +48,7 @@ export const TeamEdit = observer(function TeamEdit({ item }) { return; } - const confirmed = await confirmUnsavedChanges(commonDialogService, { - isChanged: formState.isChanged, - isSaving: formState.isSaving, - save: () => formState.save(), - reset: () => formState.reset(), - }); + const confirmed = await confirmUnsavedChanges(commonDialogService, formState); if (!confirmed) { ExecutorInterrupter.interrupt(contexts); diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx index b839e269728..dec753a3d85 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserEdit.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -41,12 +41,7 @@ export const UserEdit = observer>(function UserEdit return; } - const confirmed = await confirmUnsavedChanges(commonDialogService, { - isChanged: state.isChanged, - isSaving: state.isSaving, - save: () => state.save(), - reset: () => state.reset(), - }); + const confirmed = await confirmUnsavedChanges(commonDialogService, state); if (!confirmed) { ExecutorInterrupter.interrupt(contexts); diff --git a/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx b/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx index 65af97eacfe..e87629e0a92 100644 --- a/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx +++ b/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. From 2ebe8c4219a762c09165add5819627582b7c804b Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Tue, 30 Jun 2026 15:09:58 +0200 Subject: [PATCH 4/9] dbeaver/pro#4425 feat: add controlled selection feature to TabsState component --- .../packages/core-ui/src/Tabs/TabsState.tsx | 24 ++++++++++++++++--- .../src/Administration/Administration.tsx | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/webapp/packages/core-ui/src/Tabs/TabsState.tsx b/webapp/packages/core-ui/src/Tabs/TabsState.tsx index 985217619db..52457626ed1 100644 --- a/webapp/packages/core-ui/src/Tabs/TabsState.tsx +++ b/webapp/packages/core-ui/src/Tabs/TabsState.tsx @@ -9,7 +9,7 @@ import { TabProvider, useStoreState, useTabStore } from '@dbeaver/ui-kit'; import { action, observable } from 'mobx'; import { observer } from 'mobx-react-lite'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { useAutoLoad, useExecutor, useObjectRef, useObservableRef } from '@cloudbeaver/core-blocks'; import { useDataContext } from '@cloudbeaver/core-data-context'; @@ -30,6 +30,12 @@ export type TabsStateProps> = ExtractContainerProps & orientation?: 'horizontal' | 'vertical'; /** Provide a tab Id to control tabs state */ currentTabId?: string | null; + /** + * When true, the selected tab is driven only by `currentTabId`. + * Use it when tab switching is gated by an async, + * cancellable action (e.g. a route guard) to sync with UI. + */ + controlledSelection?: boolean; container?: ITabsContainer; localState?: MetadataMap; lazy?: boolean; @@ -48,6 +54,7 @@ export const TabsState = observer(function TabsState>({ selectedId, orientation, currentTabId, + controlledSelection, container, localState, children, @@ -109,6 +116,7 @@ export const TabsState = observer(function TabsState>({ selected, store, tabList, + controlledSelection, }, ); @@ -119,6 +127,13 @@ export const TabsState = observer(function TabsState>({ } }, [currentTabId]); + useLayoutEffect(() => { + if (controlledSelection && isNotNullDefined(currentTabId) && selected !== currentTabId) { + dynamic.store.setSelectedId(currentTabId); + dynamic.selectedId = currentTabId; + } + }, [controlledSelection, currentTabId, selected]); + useEffect(() => { if (displayed.length > 0 && autoSelect) { const selectedId = dynamic.store.getState().selectedId; @@ -139,6 +154,9 @@ export const TabsState = observer(function TabsState>({ ExecutorInterrupter.interrupt(contexts); return; } + if (dynamic.controlledSelection) { + return; + } dynamic.selectedId = data.tabId; if (dynamic.store.getState().selectedId !== data.tabId) { dynamic.store.setSelectedId(data.tabId); @@ -159,7 +177,7 @@ export const TabsState = observer(function TabsState>({ const currentSelectedId = selected; useEffect(() => { - if (!isNotNullDefined(currentSelectedId) || dynamic.selectedId === currentSelectedId) { + if (controlledSelection || !isNotNullDefined(currentSelectedId) || dynamic.selectedId === currentSelectedId) { return; } @@ -167,7 +185,7 @@ export const TabsState = observer(function TabsState>({ tabId: currentSelectedId, props, }); - }, [currentSelectedId]); + }, [currentSelectedId, controlledSelection]); const value = useObservableRef>( () => ({ diff --git a/webapp/packages/plugin-administration/src/Administration/Administration.tsx b/webapp/packages/plugin-administration/src/Administration/Administration.tsx index 5faccd55bba..2c0f2d02542 100644 --- a/webapp/packages/plugin-administration/src/Administration/Administration.tsx +++ b/webapp/packages/plugin-administration/src/Administration/Administration.tsx @@ -104,7 +104,7 @@ export const Administration = observer>(function return ( - + {visibleItems.map(item => ( From f4b3fbb9c3290e183bd0e0b217bafbfc63a198d7 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Tue, 30 Jun 2026 15:26:01 +0200 Subject: [PATCH 5/9] dbeaver/pro#4425 feat: add async confirmation action to ConfirmationDialog and update unsaved changes handling --- .../src/CommonDialog/ConfirmationDialog.tsx | 24 +++++++++++++++++-- .../src/CommonDialog/confirmUnsavedChanges.ts | 3 ++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx b/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx index ef9655973d4..5423c0104e0 100644 --- a/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx +++ b/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx @@ -37,6 +37,11 @@ export interface ConfirmationDialogPayload { confirmActionText?: TLocalizationToken; cancelActionText?: TLocalizationToken; extraActionText?: TLocalizationToken; + /** + * Async action run when the confirm button is pressed. While it runs the confirm button shows a loader and the + * other actions are disabled. The dialog resolves only if it returns `true`; otherwise it is rejected (no confirm). + */ + onConfirm?: () => Promise; } export interface ConfirmationDialogResult { @@ -67,10 +72,25 @@ export const ConfirmationDialog: DialogComponent )} - diff --git a/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts b/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts index 2ede4b6a399..c16524bdb43 100644 --- a/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts +++ b/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts @@ -43,10 +43,11 @@ export async function confirmUnsavedChanges(commonDialogService: CommonDialogSer extraActionText: 'ui_processing_dont_save', cancelActionText: 'ui_processing_cancel', showExtraAction: true, + onConfirm: async () => (await provider.save()) ?? true, }); if (status === DialogueStateResult.Resolved) { - return (await provider.save()) ?? true; + return true; } if (result?.isExtraAction) { From 7d5d99c4c19c2ca2fe51fa89b699f3737ada4f60 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Tue, 30 Jun 2026 16:06:04 +0200 Subject: [PATCH 6/9] dbeaver/pro#4425 style: fix lint --- .../{confirmUnsavedChanges.ts => confirmUnsavedChanges.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename webapp/packages/core-blocks/src/CommonDialog/{confirmUnsavedChanges.ts => confirmUnsavedChanges.tsx} (100%) diff --git a/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts b/webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.tsx similarity index 100% rename from webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts rename to webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.tsx From 673f0c2edf97787eda69d3f91560c8c0f2a947ab Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 3 Jul 2026 10:53:21 +0200 Subject: [PATCH 7/9] dbeaver/pro#4425 fix: sync currentTabId in render remove extra code --- .../AppScreen/UnsavedChangesService.ts | 63 ------------------- .../Screens/AppScreen/useUnsavedChanges.ts | 54 ---------------- .../packages/core-ui/src/Tabs/TabsState.tsx | 28 +++------ webapp/packages/core-ui/src/index.ts | 2 - webapp/packages/core-ui/src/module.ts | 4 +- .../src/Administration/Administration.tsx | 2 +- .../Users/Teams/TeamsTable/CreateTeam.tsx | 12 ---- .../Users/UsersTable/CreateUser.tsx | 4 +- .../src/SettingsAdministration.tsx | 16 +---- 9 files changed, 13 insertions(+), 172 deletions(-) delete mode 100644 webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts delete mode 100644 webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts diff --git a/webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts b/webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts deleted file mode 100644 index 27dfa75bf8b..00000000000 --- a/webapp/packages/core-ui/src/Screens/AppScreen/UnsavedChangesService.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2026 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { confirmUnsavedChanges, type IUnsavedChangesProvider } from '@cloudbeaver/core-blocks'; -import { injectable } from '@cloudbeaver/core-di'; -import { CommonDialogService } from '@cloudbeaver/core-dialogs'; -import { ExecutorInterrupter, type IExecutionContextProvider } from '@cloudbeaver/core-executor'; -import { RouterService, type RouterTransitionData } from '@cloudbeaver/core-routing'; - -@injectable(() => [RouterService, CommonDialogService]) -export class UnsavedChangesService { - private readonly providers: Set; - private confirming: boolean; - - constructor( - routerService: RouterService, - private readonly commonDialogService: CommonDialogService, - ) { - this.providers = new Set(); - this.confirming = false; - routerService.transitionTask.addHandler(this.handleTransition.bind(this)); - } - - register(provider: IUnsavedChangesProvider): void { - this.providers.add(provider); - } - - unregister(provider: IUnsavedChangesProvider): void { - this.providers.delete(provider); - } - - hasUnsavedChanges(): boolean { - return Array.from(this.providers).some(provider => provider.isChanged); - } - - private async handleTransition(data: RouterTransitionData, contexts: IExecutionContextProvider): Promise { - if (this.confirming) { - return; - } - - const changed = Array.from(this.providers).filter(provider => provider.isChanged); - - if (changed.length === 0) { - return; - } - - this.confirming = true; - try { - for (const provider of changed) { - if (!(await confirmUnsavedChanges(this.commonDialogService, provider))) { - ExecutorInterrupter.interrupt(contexts); - return; - } - } - } finally { - this.confirming = false; - } - } -} diff --git a/webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts b/webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts deleted file mode 100644 index 4fc9accd617..00000000000 --- a/webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2026 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { useEffect } from 'react'; - -import { type IUnsavedChangesProvider, useObjectRef } from '@cloudbeaver/core-blocks'; -import { useService } from '@cloudbeaver/core-di'; - -import { UnsavedChangesService } from './UnsavedChangesService.js'; - -/** - * Registers the page form with the global unsaved-changes guard for as long as the component is mounted. - * Leaving the page (route transition) prompts a Save / Don't save / Cancel dialog when the provider is changed. - */ -export function useUnsavedChanges(provider: IUnsavedChangesProvider): void { - const service = useService(UnsavedChangesService); - - const wrapper = useObjectRef( - () => ({ - provider, - get isChanged() { - return this.provider.isChanged; - }, - get isSaving() { - return this.provider.isSaving; - }, - get title() { - return this.provider.title; - }, - get subTitle() { - return this.provider.subTitle; - }, - get message() { - return this.provider.message; - }, - save() { - return this.provider.save(); - }, - reset() { - this.provider.reset(); - }, - }), - { provider }, - ); - - useEffect(() => { - service.register(wrapper); - return () => service.unregister(wrapper); - }, [service, wrapper]); -} diff --git a/webapp/packages/core-ui/src/Tabs/TabsState.tsx b/webapp/packages/core-ui/src/Tabs/TabsState.tsx index 52457626ed1..00dc7523736 100644 --- a/webapp/packages/core-ui/src/Tabs/TabsState.tsx +++ b/webapp/packages/core-ui/src/Tabs/TabsState.tsx @@ -28,14 +28,8 @@ export type TabsStateProps> = ExtractContainerProps & /** Default selected tab id */ selectedId?: string; orientation?: 'horizontal' | 'vertical'; - /** Provide a tab Id to control tabs state */ + /** Provide a tab Id to fully control the selected tab: the selection always mirrors `currentTabId`. */ currentTabId?: string | null; - /** - * When true, the selected tab is driven only by `currentTabId`. - * Use it when tab switching is gated by an async, - * cancellable action (e.g. a route guard) to sync with UI. - */ - controlledSelection?: boolean; container?: ITabsContainer; localState?: MetadataMap; lazy?: boolean; @@ -54,7 +48,6 @@ export const TabsState = observer(function TabsState>({ selectedId, orientation, currentTabId, - controlledSelection, container, localState, children, @@ -116,23 +109,16 @@ export const TabsState = observer(function TabsState>({ selected, store, tabList, - controlledSelection, + currentTabId, }, ); - useEffect(() => { - if (isNotNullDefined(currentTabId)) { - dynamic.store.setSelectedId(currentTabId); - dynamic.selectedId = currentTabId; - } - }, [currentTabId]); - useLayoutEffect(() => { - if (controlledSelection && isNotNullDefined(currentTabId) && selected !== currentTabId) { + if (isNotNullDefined(currentTabId) && selected !== currentTabId) { dynamic.store.setSelectedId(currentTabId); dynamic.selectedId = currentTabId; } - }, [controlledSelection, currentTabId, selected]); + }, [currentTabId, selected]); useEffect(() => { if (displayed.length > 0 && autoSelect) { @@ -154,7 +140,7 @@ export const TabsState = observer(function TabsState>({ ExecutorInterrupter.interrupt(contexts); return; } - if (dynamic.controlledSelection) { + if (isNotNullDefined(dynamic.currentTabId)) { return; } dynamic.selectedId = data.tabId; @@ -177,7 +163,7 @@ export const TabsState = observer(function TabsState>({ const currentSelectedId = selected; useEffect(() => { - if (controlledSelection || !isNotNullDefined(currentSelectedId) || dynamic.selectedId === currentSelectedId) { + if (isNotNullDefined(currentTabId) || !isNotNullDefined(currentSelectedId) || dynamic.selectedId === currentSelectedId) { return; } @@ -185,7 +171,7 @@ export const TabsState = observer(function TabsState>({ tabId: currentSelectedId, props, }); - }, [currentSelectedId, controlledSelection]); + }, [currentSelectedId, currentTabId]); const value = useObservableRef>( () => ({ diff --git a/webapp/packages/core-ui/src/index.ts b/webapp/packages/core-ui/src/index.ts index 3d488f94ee3..53bd6bf2208 100644 --- a/webapp/packages/core-ui/src/index.ts +++ b/webapp/packages/core-ui/src/index.ts @@ -48,8 +48,6 @@ export * from './InlineEditor/InlineEditorLoader.js'; export * from './Screens/AppScreen/NavigationService.js'; export * from './Screens/AppScreen/OptionsPanelService.js'; -export * from './Screens/AppScreen/UnsavedChangesService.js'; -export * from './Screens/AppScreen/useUnsavedChanges.js'; export * from './Tabs/ITab.js'; export * from './Tabs/TabContext.js'; diff --git a/webapp/packages/core-ui/src/module.ts b/webapp/packages/core-ui/src/module.ts index 0053d281a2e..40bfb2c6d85 100644 --- a/webapp/packages/core-ui/src/module.ts +++ b/webapp/packages/core-ui/src/module.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -12,7 +12,6 @@ import { SideBarPanelService } from './SideBarPanel/SideBarPanelService.js'; import { LeftBarPanelService } from './SideBarPanel/LeftBarPanelService.js'; import { NavigationService } from './Screens/AppScreen/NavigationService.js'; import { OptionsPanelService } from './Screens/AppScreen/OptionsPanelService.js'; -import { UnsavedChangesService } from './Screens/AppScreen/UnsavedChangesService.js'; import { LocaleService } from './LocaleService.js'; import { ClipboardBootstrap } from './Clipboard/ClipboardBootstrap.js'; import { ClipboardService } from './Clipboard/ClipboardService.js'; @@ -31,7 +30,6 @@ export default ModuleRegistry.add({ .addSingleton(LeftBarPanelService) .addSingleton(NavigationService) .addSingleton(OptionsPanelService) - .addSingleton(UnsavedChangesService) .addSingleton(ClipboardBootstrap); }, }); diff --git a/webapp/packages/plugin-administration/src/Administration/Administration.tsx b/webapp/packages/plugin-administration/src/Administration/Administration.tsx index 2c0f2d02542..5faccd55bba 100644 --- a/webapp/packages/plugin-administration/src/Administration/Administration.tsx +++ b/webapp/packages/plugin-administration/src/Administration/Administration.tsx @@ -104,7 +104,7 @@ export const Administration = observer>(function return ( - + {visibleItems.map(item => ( diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx index 02810ff8faf..650a3f2ee14 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsTable/CreateTeam.tsx @@ -9,7 +9,6 @@ import { observer } from 'mobx-react-lite'; import { Container, Group, GroupTitle, Loader, s, Translate, useS, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; -import { useUnsavedChanges } from '@cloudbeaver/core-ui'; import { TeamForm } from '../TeamsForm/TeamForm.js'; import style from './CreateTeam.module.css'; @@ -20,17 +19,6 @@ export const CreateTeam: React.FC = observer(function CreateTeam() { const styles = useS(style); const service = useService(CreateTeamService); - useUnsavedChanges({ - get isChanged() { - return service.data?.isChanged ?? false; - }, - get isSaving() { - return service.data?.isSaving; - }, - save: () => service.data?.save(), - reset: () => service.data?.reset(), - }); - if (!service.data) { return null; } diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx index 613adc4ce55..c0b721553ee 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/CreateUser.tsx @@ -8,7 +8,7 @@ import { observer } from 'mobx-react-lite'; import { Container, Group, GroupTitle, Loader, s, Translate, useS } from '@cloudbeaver/core-blocks'; -import { type IFormState, useUnsavedChanges } from '@cloudbeaver/core-ui'; +import type { IFormState } from '@cloudbeaver/core-ui'; import { AdministrationUserForm } from '../UserForm/AdministrationUserForm.js'; import type { IUserFormState } from '../UserForm/AdministrationUserFormService.js'; @@ -22,8 +22,6 @@ interface Props { export const CreateUser = observer(function CreateUser({ state, onCancel }) { const styles = useS(style); - useUnsavedChanges(state); - return ( diff --git a/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx b/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx index e87629e0a92..da5e247ed87 100644 --- a/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx +++ b/webapp/packages/plugin-settings-administration/src/SettingsAdministration.tsx @@ -22,7 +22,7 @@ import { import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { Settings } from '@cloudbeaver/plugin-settings-panel'; -import { TabList, TabsState, type ITabData, useUnsavedChanges } from '@cloudbeaver/core-ui'; +import { TabList, TabsState, type ITabData } from '@cloudbeaver/core-ui'; import { SettingsAdministrationService } from './SettingsAdministrationService.js'; import { useState } from 'react'; import { SettingsResolverService } from '@cloudbeaver/core-settings'; @@ -38,17 +38,15 @@ export const SettingsAdministration = observer(f const accessor = tabInfo?.options?.accessor; const changed = settingsSource?.isEdited() || false; - async function handleSave(): Promise { + async function handleSave() { if (!changed) { - return true; + return; } try { await settingsSource?.save(); notificationService.logSuccess({ title: translate('plugin_settings_administration_settings_save_success') }); - return true; } catch (error: any) { notificationService.logException(error, 'plugin_settings_administration_settings_save_fail'); - return false; } } @@ -62,14 +60,6 @@ export const SettingsAdministration = observer(f settingsSource?.clear(); } - useUnsavedChanges({ - get isChanged() { - return settingsSource?.isEdited() ?? false; - }, - save: handleSave, - reset: handleReset, - }); - function handleRestoreDefaults() { settingsSource?.restoreDefaults?.(); } From 6155100327fc67d9bfa637cad96c65acdf3e95b8 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 3 Jul 2026 11:43:21 +0200 Subject: [PATCH 8/9] dbeaver/pro#4425 feat: add confirmation for unsaved changes in administration --- .../src/CommonDialog/ConfirmationDialog.tsx | 3 +- .../Users/UsersAdministrationService.ts | 30 +++++++++-- .../package.json | 1 + .../SettingsAdministrationPluginBootstrap.ts | 52 +++++++++++++++++-- .../tsconfig.json | 3 ++ webapp/yarn.lock | 1 + 6 files changed, 81 insertions(+), 9 deletions(-) diff --git a/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx b/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx index 5423c0104e0..0c538b2a14f 100644 --- a/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx +++ b/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx @@ -38,8 +38,7 @@ export interface ConfirmationDialogPayload { cancelActionText?: TLocalizationToken; extraActionText?: TLocalizationToken; /** - * Async action run when the confirm button is pressed. While it runs the confirm button shows a loader and the - * other actions are disabled. The dialog resolves only if it returns `true`; otherwise it is rejected (no confirm). + * Async action run when the confirm button is pressed. While it runs the confirm button shows a loader and is disabled. The dialog resolves only if it returns `true`; otherwise it is rejected (no confirm). */ onConfirm?: () => Promise; } diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersAdministrationService.ts b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersAdministrationService.ts index dce23314acd..07339ce4aee 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersAdministrationService.ts +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersAdministrationService.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -9,8 +9,9 @@ import React from 'react'; import { AdministrationItemService, type IAdministrationItem } from '@cloudbeaver/core-administration'; import { type AdminUser, TeamsResource, UsersResource } from '@cloudbeaver/core-authentication'; -import { PlaceholderContainer } from '@cloudbeaver/core-blocks'; +import { confirmUnsavedChanges, PlaceholderContainer } from '@cloudbeaver/core-blocks'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { TabsContainer } from '@cloudbeaver/core-ui'; import { CreateTeamService } from './Teams/TeamsTable/CreateTeamService.js'; @@ -36,7 +37,7 @@ export interface IUserDetailsInfoProps { user: AdminUser; } -@injectable(() => [AdministrationItemService, CreateUserService, TeamsResource, CreateTeamService, UsersResource]) +@injectable(() => [AdministrationItemService, CreateUserService, TeamsResource, CreateTeamService, UsersResource, CommonDialogService]) export class UsersAdministrationService extends Bootstrap { readonly tabsContainer: TabsContainer; readonly userDetailsInfoPlaceholder: PlaceholderContainer; @@ -49,6 +50,7 @@ export class UsersAdministrationService extends Bootstrap { private readonly teamsResource: TeamsResource, private readonly createTeamService: CreateTeamService, private readonly usersResource: UsersResource, + private readonly commonDialogService: CommonDialogService, ) { super(); this.userDetailsInfoPlaceholder = new PlaceholderContainer(); @@ -66,11 +68,13 @@ export class UsersAdministrationService extends Bootstrap { }, { name: EUsersAdministrationSub.Users, + canDeActivate: this.confirmUserCreate.bind(this), onDeActivate: this.cancelUserCreate.bind(this), }, { name: EUsersAdministrationSub.Teams, onActivate: this.loadTeams.bind(this), + canDeActivate: this.confirmTeamCreate.bind(this), onDeActivate: this.cancelTeamCreate.bind(this), }, ], @@ -81,6 +85,26 @@ export class UsersAdministrationService extends Bootstrap { this.userDetailsInfoPlaceholder.add(UserCredentialsList, 0); } + private confirmUserCreate() { + const state = this.createUserService.state; + + if (!state) { + return true; + } + + return confirmUnsavedChanges(this.commonDialogService, state); + } + + private confirmTeamCreate() { + const data = this.createTeamService.data; + + if (!data) { + return true; + } + + return confirmUnsavedChanges(this.commonDialogService, data); + } + private cancelUserCreate(param: string | null, configurationWizard: boolean, outside: boolean) { if (param === 'create') { this.createUserService.close(); diff --git a/webapp/packages/plugin-settings-administration/package.json b/webapp/packages/plugin-settings-administration/package.json index 3c15980130f..c651b6fda0c 100644 --- a/webapp/packages/plugin-settings-administration/package.json +++ b/webapp/packages/plugin-settings-administration/package.json @@ -25,6 +25,7 @@ "@cloudbeaver/core-administration": "workspace:*", "@cloudbeaver/core-blocks": "workspace:*", "@cloudbeaver/core-di": "workspace:*", + "@cloudbeaver/core-dialogs": "workspace:*", "@cloudbeaver/core-events": "workspace:*", "@cloudbeaver/core-localization": "workspace:*", "@cloudbeaver/core-settings": "workspace:*", diff --git a/webapp/packages/plugin-settings-administration/src/SettingsAdministrationPluginBootstrap.ts b/webapp/packages/plugin-settings-administration/src/SettingsAdministrationPluginBootstrap.ts index 949879a6666..2042c51cffc 100644 --- a/webapp/packages/plugin-settings-administration/src/SettingsAdministrationPluginBootstrap.ts +++ b/webapp/packages/plugin-settings-administration/src/SettingsAdministrationPluginBootstrap.ts @@ -1,20 +1,30 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ import { AdministrationItemService, AdministrationItemType } from '@cloudbeaver/core-administration'; -import { importLazyComponent } from '@cloudbeaver/core-blocks'; +import { confirmUnsavedChanges, importLazyComponent } from '@cloudbeaver/core-blocks'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; +import { NotificationService } from '@cloudbeaver/core-events'; +import type { IEditableSettingsSource } from '@cloudbeaver/core-settings'; + +import { SettingsAdministrationService } from './SettingsAdministrationService.js'; const SettingsAdministration = importLazyComponent(() => import('./SettingsAdministration.js').then(module => module.SettingsAdministration)); const SettingsDrawerItem = importLazyComponent(() => import('./SettingsDrawerItem.js').then(module => module.SettingsDrawerItem)); -@injectable(() => [AdministrationItemService]) +@injectable(() => [AdministrationItemService, SettingsAdministrationService, CommonDialogService, NotificationService]) export class SettingsAdministrationPluginBootstrap extends Bootstrap { - constructor(private readonly administrationItemService: AdministrationItemService) { + constructor( + private readonly administrationItemService: AdministrationItemService, + private readonly settingsAdministrationService: SettingsAdministrationService, + private readonly commonDialogService: CommonDialogService, + private readonly notificationService: NotificationService, + ) { super(); } @@ -23,8 +33,42 @@ export class SettingsAdministrationPluginBootstrap extends Bootstrap { name: 'settings', type: AdministrationItemType.Administration, order: 3, + canDeActivate: this.confirmUnsavedSettings.bind(this), getContentComponent: () => SettingsAdministration, getDrawerComponent: () => SettingsDrawerItem, }); } + + private confirmUnsavedSettings() { + const edited = this.settingsAdministrationService.tabsContainer.tabInfoList + .map(info => info.options?.source) + .filter((source): source is IEditableSettingsSource => source?.isEdited() === true); + + if (edited.length === 0) { + return true; + } + + return confirmUnsavedChanges(this.commonDialogService, { + get isChanged() { + return edited.some(source => source.isEdited()); + }, + save: async () => { + try { + for (const source of edited) { + await source.save(); + } + this.notificationService.logSuccess({ title: 'plugin_settings_administration_settings_save_success' }); + return true; + } catch (exception: any) { + this.notificationService.logException(exception, 'plugin_settings_administration_settings_save_fail'); + return false; + } + }, + reset: () => { + for (const source of edited) { + source.clear(); + } + }, + }); + } } diff --git a/webapp/packages/plugin-settings-administration/tsconfig.json b/webapp/packages/plugin-settings-administration/tsconfig.json index 302f30fbbd5..14193ffd1a4 100644 --- a/webapp/packages/plugin-settings-administration/tsconfig.json +++ b/webapp/packages/plugin-settings-administration/tsconfig.json @@ -22,6 +22,9 @@ { "path": "../core-di" }, + { + "path": "../core-dialogs" + }, { "path": "../core-events" }, diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 0aeb995cff4..f4decb88be0 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4192,6 +4192,7 @@ __metadata: "@cloudbeaver/core-blocks": "workspace:*" "@cloudbeaver/core-cli": "workspace:*" "@cloudbeaver/core-di": "workspace:*" + "@cloudbeaver/core-dialogs": "workspace:*" "@cloudbeaver/core-events": "workspace:*" "@cloudbeaver/core-localization": "workspace:*" "@cloudbeaver/core-settings": "workspace:*" From e8f52accd0761174daab2c1f6938958d52a31a4d Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Tue, 7 Jul 2026 11:04:36 +0200 Subject: [PATCH 9/9] dbeaver/pro#4425 style: improve comments for onConfirm action in ConfirmationDialog --- .../core-blocks/src/CommonDialog/ConfirmationDialog.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx b/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx index 0c538b2a14f..9bc65482819 100644 --- a/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx +++ b/webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx @@ -38,7 +38,9 @@ export interface ConfirmationDialogPayload { cancelActionText?: TLocalizationToken; extraActionText?: TLocalizationToken; /** - * Async action run when the confirm button is pressed. While it runs the confirm button shows a loader and is disabled. The dialog resolves only if it returns `true`; otherwise it is rejected (no confirm). + * Async action run when the confirm button is pressed. + * While it runs the confirm button shows a loader and is disabled. + * The dialog resolves only if it returns `true`. */ onConfirm?: () => Promise; }