Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion src/pages/QueryPage/QueryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {QueryPageProps} from '../../types/queryPage';
import {QueriesSidebar} from '../../widgets/QueriesSidebar';
import {QueryTabs} from '../../modules/QueryTabs';
import {QueryComparison} from '../../modules/QueryComparison';
import {DeleteQueryDialog} from './internal/DeleteQueryDialog';
import {useHistoryActions} from './internal/useHistoryActions';
import {QueryWorkspace} from './internal/QueryWorkspace';
import {QueryPagePanel} from './internal/QueryPagePanel';
Expand Down Expand Up @@ -48,7 +49,10 @@ export function QueryPage<
defaultSidebarCollapsed = false,
onSidebarCollapsedChange,
}: QueryPageProps<THistory, TSaved, TNavigation, TCluster, TTutorial, TCustom, TRow, TMetaItem>) {
const sidebarTabs = useHistoryActions(sidebarProps.tabs, historyActions);
const {tabs: sidebarTabs, deleteConfirmation} = useHistoryActions(
sidebarProps.tabs,
historyActions,
);
const sidebarId = useId();
const panelPrefix = useId();
const [rootRef, rootSize] = useElementSize();
Expand All @@ -74,6 +78,7 @@ export function QueryPage<

return (
<div ref={rootRef} className={block(null, className)}>
<DeleteQueryDialog {...deleteConfirmation} />
<div
className={block('layout')}
style={{
Expand Down
4 changes: 4 additions & 0 deletions src/pages/QueryPage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,7 @@ keeps the first row and replaces the second. Cancel clears the selection.
`QueryPageQueryTab`, `QueryPageComparisonTab`, and `QueryPageHistoryActions` are
exported from the root and page entrypoint. Existing comparison entries must
replace `editorProps` and `executionPanelProps` with `comparisonProps`.

The default history Delete action opens a confirmation dialog with the query name and an
irrecoverable-data warning. `onDelete` is called only after confirmation; canceling or
closing the dialog leaves the query unchanged. Custom `getRowActions` retain their own behavior.
5 changes: 4 additions & 1 deletion src/pages/QueryPage/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
"action_resize-results": "Resize results",
"action_rename": "Rename",
"action_select-for-compare": "Select for Compare",
"action_delete": "Delete"
"action_delete": "Delete",
"title_delete-query": "Delete query",
"confirm_delete-query": "Do you really want to delete query {{name}}? The data cannot be recovered",
"action_cancel": "Cancel"
}
5 changes: 4 additions & 1 deletion src/pages/QueryPage/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
"action_resize-results": "Изменить высоту результатов",
"action_rename": "Переименовать",
"action_select-for-compare": "Выбрать для сравнения",
"action_delete": "Удалить"
"action_delete": "Удалить",
"title_delete-query": "Удалить запрос",
"confirm_delete-query": "Вы действительно хотите удалить запрос {{name}}? Данные невозможно будет восстановить",
"action_cancel": "Отмена"
}
5 changes: 5 additions & 0 deletions src/pages/QueryPage/internal/DeleteQueryDialog.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.qp-delete-query-dialog {
width: 480px;
max-width: 100%;
overflow-wrap: anywhere;
}
40 changes: 40 additions & 0 deletions src/pages/QueryPage/internal/DeleteQueryDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, {useId} from 'react';
import {Dialog, Text} from '@gravity-ui/uikit';
import type {QueryHistoryRow} from '../../../types/history';
import i18n from '../i18n';
import './DeleteQueryDialog.scss';

export function DeleteQueryDialog({
item,
onConfirm,
onCancel,
}: {
item?: QueryHistoryRow;
onConfirm: () => void;
onCancel: () => void;
}) {
const titleId = useId();

return (
<Dialog
open={Boolean(item)}
onClose={onCancel}
aria-labelledby={titleId}
className="qp-delete-query-dialog"
>
<Dialog.Header id={titleId} caption={i18n('title_delete-query')} />
<Dialog.Body>
<Text variant="body-2" color="complementary">
{i18n('confirm_delete-query', {name: item?.title || (item?.id ?? '')})}
</Text>
</Dialog.Body>
<Dialog.Footer
preset="danger"
textButtonCancel={i18n('action_cancel')}
textButtonApply={i18n('action_delete')}
onClickButtonCancel={onCancel}
onClickButtonApply={onConfirm}
/>
</Dialog>
);
}
31 changes: 28 additions & 3 deletions src/pages/QueryPage/internal/useHistoryActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,19 @@ export function useHistoryActions<
>(
tabs: QueriesSidebarProps<THistory, TSaved, TNavigation, TCluster, TTutorial>['tabs'],
actions?: QueryPageHistoryActions<THistory>,
): QueriesSidebarProps<THistory, TSaved, TNavigation, TCluster, TTutorial>['tabs'] {
) {
const [states, setStates] = useState<Record<string, HistoryState<THistory>>>({});

const [pendingDelete, setPendingDelete] = useState<{item: THistory; tabId: string}>();
const canDelete = Boolean(
pendingDelete &&
actions?.onDelete &&
tabs.some((tab) => tab.type === 'history' && tab.id === pendingDelete.tabId),
);
useEffect(() => {
if (!canDelete) setPendingDelete(undefined);
}, [canDelete]);

useEffect(() => {
const historyIds = new Set(
tabs.filter((tab) => tab.type === 'history').map((tab) => tab.id),
Expand All @@ -47,7 +57,7 @@ export function useHistoryActions<
}));
};

return tabs.map((tab) => {
const historyTabs: typeof tabs = tabs.map((tab) => {
if (tab.type !== 'history') return tab;
const {props} = tab;
const state = states[tab.id];
Expand Down Expand Up @@ -100,7 +110,9 @@ export function useHistoryActions<
text: i18n('action_delete'),
theme: 'danger' as const,
disabled: !actions?.onDelete,
onClick: (item: THistory) => actions?.onDelete?.(item, tab.id),
onClick: (item: THistory) => {
if (actions?.onDelete) setPendingDelete({item, tabId: tab.id});
},
},
]),
editing: externalEditing
Expand Down Expand Up @@ -143,4 +155,17 @@ export function useHistoryActions<
},
};
});

return {
tabs: historyTabs,
deleteConfirmation: {
item: canDelete ? pendingDelete?.item : undefined,
onCancel: () => setPendingDelete(undefined),
onConfirm: () => {
if (!canDelete || !pendingDelete) return;
setPendingDelete(undefined);
actions?.onDelete?.(pendingDelete.item, pendingDelete.tabId);
},
},
};
}
62 changes: 60 additions & 2 deletions tests/components/QueryPageHistoryActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('QueryPage history actions', () => {
let container: HTMLDivElement;
let root: Root;
let result: QueriesSidebarTab[];
let deleteConfirmation: ReturnType<typeof useHistoryActions>['deleteConfirmation'];

function Harness({
tabs,
Expand All @@ -33,7 +34,9 @@ describe('QueryPage history actions', () => {
tabs: QueriesSidebarTab[];
actions?: QueryPageHistoryActions;
}) {
result = useHistoryActions(tabs, actions);
const historyActions = useHistoryActions(tabs, actions);
result = historyActions.tabs;
deleteConfirmation = historyActions.deleteConfirmation;
return null;
}

Expand Down Expand Up @@ -72,8 +75,10 @@ describe('QueryPage history actions', () => {
expect(menu[2].theme).toBe('danger');
action(0);
action(1);
action(2);
expect(props().editing?.rowId).toBeUndefined();
expect(props().comparison?.enabled).toBe(false);
expect(deleteConfirmation.item).toBeUndefined();
});

it('renames ID zero, forwards the history context and clears editing after submit or cancel', () => {
Expand All @@ -92,8 +97,59 @@ describe('QueryPage history actions', () => {
act(() => props().editing?.onCancel?.(rows[1]));
expect(onCancel).toHaveBeenCalledExactlyOnceWith(rows[1]);
expect(props().editing?.rowId).toBeUndefined();
});

it('requests confirmation before deleting and allows cancellation', () => {
const onDelete = vi.fn();
render([history()], {onDelete});
action(2, rows[1]);
expect(onDelete).toHaveBeenCalledExactlyOnceWith(rows[1], 'history');
expect(deleteConfirmation.item).toBe(rows[1]);
expect(onDelete).not.toHaveBeenCalled();
act(() => deleteConfirmation.onCancel());
expect(deleteConfirmation.item).toBeUndefined();
act(() => deleteConfirmation.onConfirm());
expect(onDelete).not.toHaveBeenCalled();
});

it.each([rows[0], rows[1]])(
'confirms deletion for row ID $id in the originating tab',
(row) => {
const onDelete = vi.fn();
render([history(), history('other')], {onDelete});
action(2, row, 'other');
expect(deleteConfirmation.item).toBe(row);
expect(onDelete).not.toHaveBeenCalled();
act(() => deleteConfirmation.onConfirm());
expect(onDelete).toHaveBeenCalledExactlyOnceWith(row, 'other');
expect(deleteConfirmation.item).toBeUndefined();
act(() => deleteConfirmation.onConfirm());
expect(onDelete).toHaveBeenCalledTimes(1);
},
);

it('clears pending deletion when its history tab is removed', () => {
const onDelete = vi.fn();
const first = history();
const second = history('other');
render([first, second], {onDelete});
action(2);
render([second], {onDelete});
expect(deleteConfirmation.item).toBeUndefined();
render([first, second], {onDelete});
act(() => deleteConfirmation.onConfirm());
expect(onDelete).not.toHaveBeenCalled();
});

it('clears pending deletion when the delete callback becomes unavailable', () => {
const onDelete = vi.fn();
const tab = history();
render([tab], {onDelete});
action(2);
render([tab]);
expect(deleteConfirmation.item).toBeUndefined();
render([tab], {onDelete});
act(() => deleteConfirmation.onConfirm());
expect(onDelete).not.toHaveBeenCalled();
});

it('uses existing editing persistence when no page rename callback is supplied', () => {
Expand Down Expand Up @@ -173,6 +229,8 @@ describe('QueryPage history actions', () => {
expect(props().editing).toBe(editing);
expect(props().comparison).toBe(comparison);
expect(props().search).toBe(tab.props.search);
action(0);
expect(deleteConfirmation.item).toBeUndefined();
});

it('isolates history tab state and clears removed tabs while retaining other tab objects', () => {
Expand Down
Loading