diff --git a/src/pages/QueryPage/QueryPage.tsx b/src/pages/QueryPage/QueryPage.tsx index d154f8b..511e935 100644 --- a/src/pages/QueryPage/QueryPage.tsx +++ b/src/pages/QueryPage/QueryPage.tsx @@ -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'; @@ -48,7 +49,10 @@ export function QueryPage< defaultSidebarCollapsed = false, onSidebarCollapsedChange, }: QueryPageProps) { - const sidebarTabs = useHistoryActions(sidebarProps.tabs, historyActions); + const {tabs: sidebarTabs, deleteConfirmation} = useHistoryActions( + sidebarProps.tabs, + historyActions, + ); const sidebarId = useId(); const panelPrefix = useId(); const [rootRef, rootSize] = useElementSize(); @@ -74,6 +78,7 @@ export function QueryPage< return (
+
void; + onCancel: () => void; +}) { + const titleId = useId(); + + return ( + + + + + {i18n('confirm_delete-query', {name: item?.title || (item?.id ?? '')})} + + + + + ); +} diff --git a/src/pages/QueryPage/internal/useHistoryActions.ts b/src/pages/QueryPage/internal/useHistoryActions.ts index 42e3f30..b046082 100644 --- a/src/pages/QueryPage/internal/useHistoryActions.ts +++ b/src/pages/QueryPage/internal/useHistoryActions.ts @@ -21,9 +21,19 @@ export function useHistoryActions< >( tabs: QueriesSidebarProps['tabs'], actions?: QueryPageHistoryActions, -): QueriesSidebarProps['tabs'] { +) { const [states, setStates] = useState>>({}); + 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), @@ -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]; @@ -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 @@ -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); + }, + }, + }; } diff --git a/tests/components/QueryPageHistoryActions.test.tsx b/tests/components/QueryPageHistoryActions.test.tsx index 7bbc055..2ba0788 100644 --- a/tests/components/QueryPageHistoryActions.test.tsx +++ b/tests/components/QueryPageHistoryActions.test.tsx @@ -25,6 +25,7 @@ describe('QueryPage history actions', () => { let container: HTMLDivElement; let root: Root; let result: QueriesSidebarTab[]; + let deleteConfirmation: ReturnType['deleteConfirmation']; function Harness({ tabs, @@ -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; } @@ -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', () => { @@ -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', () => { @@ -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', () => {