Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import { useTranslate } from '../localization/useTranslate.js';
import { useState } from 'react';

export interface ConfirmationDialogPayload {

Check failure on line 25 in webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Interface name `ConfirmationDialogPayload` must match the RegExp: /^I[A-Z]/u
icon?: string;
size?: CommonDialogWrapperSize;
title: string;
Expand All @@ -37,9 +37,14 @@
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).
*/
Comment on lines +40 to +44
onConfirm?: () => Promise<boolean>;
Comment thread
sergeyteleshev marked this conversation as resolved.
}

export interface ConfirmationDialogResult {

Check failure on line 47 in webapp/packages/core-blocks/src/CommonDialog/ConfirmationDialog.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Interface name `ConfirmationDialogResult` must match the RegExp: /^I[A-Z]/u
isExtraAction?: boolean;
skipConfirmations: boolean;
}
Expand Down Expand Up @@ -67,10 +72,25 @@
confirmActionText,
cancelActionText,
extraActionText,
onConfirm,
} = payload;
const [skipConfirmations, setSkipConfirmations] = useState(false);

function resolve() {
async function resolve() {
if (onConfirm) {
let success = false;
try {
success = await onConfirm();
} catch {
success = false;
}

if (!success) {
rejectDialog({ skipConfirmations });
return;
}
}

resolveDialog({
skipConfirmations,
});
Expand Down Expand Up @@ -116,7 +136,7 @@
<Translate token={extraActionText || 'ui_no'} />
</Button>
)}
<Button type="button" className="tw:shrink-0" onClick={resolve}>
<Button type="button" className="tw:shrink-0" loader onClick={resolve}>
<Translate token={confirmActionText || 'ui_processing_ok'} />
</Button>
</CommonDialogFooter>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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';

Check failure on line 11 in webapp/packages/core-blocks/src/CommonDialog/confirmUnsavedChanges.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()

export interface IUnsavedChangesProvider {
readonly isChanged: boolean;
readonly isSaving?: boolean;
/** Returns whether the save succeeded. `undefined` means there was nothing to save and navigation may proceed. */
save(): Promise<boolean> | undefined;
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<boolean> {
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,
onConfirm: async () => (await provider.save()) ?? true,
});

if (status === DialogueStateResult.Resolved) {
return true;
}

if (result?.isExtraAction) {
provider.reset();
return true;
}

return false;
}
1 change: 1 addition & 0 deletions webapp/packages/core-blocks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@

export * from './AuthenticationProviderLoader.js';
export * from './useAuthenticationAction.js';
export * from './CommonDialog/CommonDialog/CommonDialogBody.js';

Check failure on line 16 in webapp/packages/core-blocks/src/index.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()
export * from './CommonDialog/CommonDialog/CommonDialogFooter.js';

Check failure on line 17 in webapp/packages/core-blocks/src/index.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()
export * from './CommonDialog/CommonDialog/CommonDialogHeader.js';

Check failure on line 18 in webapp/packages/core-blocks/src/index.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()
export * from './CommonDialog/CommonDialog/CommonDialogWrapper.js';

Check failure on line 19 in webapp/packages/core-blocks/src/index.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()
export * from './CommonDialog/confirmUnsavedChanges.js';
export * from './CommonDialog/ConfirmationDialog.js';

Check failure on line 21 in webapp/packages/core-blocks/src/index.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()
export { default as ConfirmationDialogStyles } from './CommonDialog/ConfirmationDialog.module.css';
export * from './CommonDialog/ConfirmationDialogDelete.js';

Check failure on line 23 in webapp/packages/core-blocks/src/index.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()
export * from './CommonDialog/RenameDialog.js';

Check failure on line 24 in webapp/packages/core-blocks/src/index.ts

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Don't import/export .tsx files from .ts files directly, use React.lazy()
export * from './CommonDialog/DialogsPortal.js';
export * from './ExportImageDialog/ExportImageDialogLazy.js';
export * from './ExportImageDialog/ExportImageFormats.js';
Expand Down
1 change: 1 addition & 0 deletions webapp/packages/core-localization/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions webapp/packages/core-localization/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
2 changes: 2 additions & 0 deletions webapp/packages/core-localization/src/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions webapp/packages/core-localization/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', 'Сохранено'],
Expand Down
1 change: 1 addition & 0 deletions webapp/packages/core-localization/src/locales/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions webapp/packages/core-localization/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '已保存'],
Expand Down
1 change: 1 addition & 0 deletions webapp/packages/core-ui/src/Form/IFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface IFormState<TState> {
isError: boolean;
isCancelled: boolean;
isChanged: boolean;
isSaving: boolean;
isReadOnly: boolean;

save(providedContext?: IExecutionContext<IFormState<TState>>): Promise<boolean>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IUnsavedChangesProvider>;
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<RouterTransitionData>): Promise<void> {
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;
}
}
}
54 changes: 54 additions & 0 deletions webapp/packages/core-ui/src/Screens/AppScreen/useUnsavedChanges.ts
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not introduce new hooks and services that are responsible for showing notification about unsaved changes.

For cases such as driver creation, Identity Providers, Secret Management and and other places where we use options panel, use the same approach we use for options panel in connection and other dialogs. Add handler to the closeTask and check everything there. As general rule, use only handlers for resolving unsaved changes issues, prevent closing by interrupting context.

All other cases should be resolved by using canDeActivate handler. Seems like we have regression in terms of unsaved changes notification when we are leaving server configuration page, you can find and fix this problem first

const service = useService(UnsavedChangesService);

const wrapper = useObjectRef<IUnsavedChangesProvider & { provider: IUnsavedChangesProvider }>(
() => ({
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]);
}
24 changes: 21 additions & 3 deletions webapp/packages/core-ui/src/Tabs/TabsState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +30,12 @@
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<T, any>;
localState?: MetadataMap<string, any>;
lazy?: boolean;
Expand All @@ -48,6 +54,7 @@
selectedId,
orientation,
currentTabId,
controlledSelection,
container,
localState,
children,
Expand All @@ -64,7 +71,7 @@
...rest
}: TabsStateProps<T>): React.ReactElement | null {
const context = useDataContext();
const props = useMemo(() => rest as any as T, [...Object.values(rest)]);

Check warning on line 74 in webapp/packages/core-ui/src/Tabs/TabsState.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

React Hook useMemo has a spread element in its dependency array. This means we can't statically verify whether you've passed the correct dependencies

Check warning on line 74 in webapp/packages/core-ui/src/Tabs/TabsState.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

React Hook useMemo has a missing dependency: 'rest'. Either include it or remove the dependency array

let displayed: string[] = [];

Expand Down Expand Up @@ -109,6 +116,7 @@
selected,
store,
tabList,
controlledSelection,
},
);

Expand All @@ -117,8 +125,15 @@
dynamic.store.setSelectedId(currentTabId);
dynamic.selectedId = currentTabId;
}
}, [currentTabId]);

Check warning on line 128 in webapp/packages/core-ui/src/Tabs/TabsState.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

React Hook useEffect has a missing dependency: 'dynamic'. Either include it or remove the dependency array

useLayoutEffect(() => {
Comment thread
sergeyteleshev marked this conversation as resolved.
if (controlledSelection && isNotNullDefined(currentTabId) && selected !== currentTabId) {
dynamic.store.setSelectedId(currentTabId);
dynamic.selectedId = currentTabId;
}
}, [controlledSelection, currentTabId, selected]);

Check warning on line 135 in webapp/packages/core-ui/src/Tabs/TabsState.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

React Hook useLayoutEffect has a missing dependency: 'dynamic'. Either include it or remove the dependency array
Comment thread
SychevAndrey marked this conversation as resolved.
Outdated

useEffect(() => {
if (displayed.length > 0 && autoSelect) {
const selectedId = dynamic.store.getState().selectedId;
Expand All @@ -128,7 +143,7 @@
dynamic.store.setSelectedId(displayed[0]);
}
}
}, [displayed, autoSelect]);

Check warning on line 146 in webapp/packages/core-ui/src/Tabs/TabsState.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

React Hook useEffect has a missing dependency: 'dynamic.store'. Either include it or remove the dependency array

useExecutor({
executor: openExecutor,
Expand All @@ -139,6 +154,9 @@
ExecutorInterrupter.interrupt(contexts);
return;
}
if (dynamic.controlledSelection) {
return;
}
dynamic.selectedId = data.tabId;
if (dynamic.store.getState().selectedId !== data.tabId) {
dynamic.store.setSelectedId(data.tabId);
Expand All @@ -159,15 +177,15 @@
const currentSelectedId = selected;

useEffect(() => {
if (!isNotNullDefined(currentSelectedId) || dynamic.selectedId === currentSelectedId) {
if (controlledSelection || !isNotNullDefined(currentSelectedId) || dynamic.selectedId === currentSelectedId) {
return;
}

openExecutor.execute({
tabId: currentSelectedId,
props,
});
}, [currentSelectedId]);
}, [currentSelectedId, controlledSelection]);

Check warning on line 188 in webapp/packages/core-ui/src/Tabs/TabsState.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

React Hook useEffect has missing dependencies: 'dynamic.selectedId', 'openExecutor', and 'props'. Either include them or remove the dependency array

const value = useObservableRef<ITabsContext<T>>(
() => ({
Expand Down
2 changes: 2 additions & 0 deletions webapp/packages/core-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading