Skip to content

Commit 735b26b

Browse files
tplevkoCopilot
andcommitted
feat(datamapper): Allow to delete usage of a datampping by hitting Delete key
Co-authored-by: Copilot <copilot@github.com>
1 parent 9914e63 commit 735b26b

3 files changed

Lines changed: 305 additions & 0 deletions

File tree

packages/ui/src/components/View/SourceTargetView.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Button, Split, SplitItem } from '@patternfly/react-core';
44
import { SearchMinusIcon, SearchPlusIcon } from '@patternfly/react-icons';
55
import { CSSProperties, FunctionComponent, useCallback, useMemo, useRef, useState } from 'react';
66

7+
import { useDataMapper } from '../../hooks/useDataMapper';
8+
import { useDataMapperDeleteHotkey } from '../../hooks/useDataMapperDeleteHotkey.hook';
79
import { useMappingLinks } from '../../hooks/useMappingLinks';
810
import { MappingLinksContainer } from './MappingLinkContainer';
911
import { SourcePanel } from './SourcePanel';
@@ -17,9 +19,13 @@ export const SourceTargetView: FunctionComponent<SourceTargetViewProps> = ({
1719
uiScaleFactor: initialScaleFactor = 1,
1820
}) => {
1921
const { mappingLinkCanvasRef } = useMappingLinks();
22+
const { refreshMappingTree } = useDataMapper();
2023
const containerRef = useRef<HTMLDivElement>(null);
2124
const [scaleFactor, setScaleFactor] = useState(initialScaleFactor);
2225

26+
// Enable Delete key support for removing selected mappings
27+
useDataMapperDeleteHotkey(refreshMappingTree);
28+
2329
const handleZoomIn = useCallback(() => {
2430
setScaleFactor((prev) => Math.min(prev + 0.1, 1.2)); // Max 1.2x zoom
2531
}, []);
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { renderHook } from '@testing-library/react';
2+
import hotkeys from 'hotkeys-js';
3+
4+
import { DocumentTree } from '../models/datamapper/document-tree';
5+
import { MappingActionKind } from '../models/datamapper/mapping-action';
6+
import { TargetDocumentNodeData } from '../models/datamapper/visualization';
7+
import { MappingActionService } from '../services/visualization/mapping-action.service';
8+
import { TreeUIService } from '../services/visualization/tree-ui.service';
9+
import { DocumentTreeState, useDocumentTreeStore } from '../store/document-tree.store';
10+
import { useDataMapper } from './useDataMapper';
11+
import { useDataMapperDeleteHotkey } from './useDataMapperDeleteHotkey.hook';
12+
13+
// Mock dependencies
14+
jest.mock('hotkeys-js');
15+
jest.mock('./useDataMapper');
16+
jest.mock('../services/visualization/mapping-action.service');
17+
jest.mock('../services/visualization/tree-ui.service');
18+
19+
describe('useDataMapperDeleteHotkey', () => {
20+
let mockOnUpdate: jest.Mock;
21+
let mockClearSelection: jest.Mock;
22+
let mockHotkeys: jest.MockedFunction<typeof hotkeys>;
23+
let mockHotkeysUnbind: jest.Mock;
24+
let mockUseDataMapper: jest.MockedFunction<typeof useDataMapper>;
25+
let mockGetAllowedActions: jest.Mock;
26+
let mockDeleteMappingItem: jest.Mock;
27+
let mockCreateTree: jest.Mock;
28+
let mockFindNodeByPath: jest.Mock;
29+
let mockTargetBodyDocument: { id: string };
30+
let mockMappingTree: { mappings: unknown[] };
31+
let mockTreeNode: { nodeData: TargetDocumentNodeData };
32+
let mockTargetDocumentNodeData: TargetDocumentNodeData;
33+
34+
beforeEach(() => {
35+
// Reset all mocks
36+
jest.clearAllMocks();
37+
38+
// Setup basic mocks
39+
mockOnUpdate = jest.fn();
40+
mockClearSelection = jest.fn();
41+
mockHotkeysUnbind = jest.fn();
42+
mockGetAllowedActions = jest.fn();
43+
mockDeleteMappingItem = jest.fn();
44+
mockCreateTree = jest.fn();
45+
mockFindNodeByPath = jest.fn();
46+
47+
// Mock hotkeys
48+
mockHotkeys = hotkeys as jest.MockedFunction<typeof hotkeys>;
49+
mockHotkeys.unbind = mockHotkeysUnbind;
50+
51+
// Mock useDataMapper
52+
mockTargetBodyDocument = { id: 'target-doc' };
53+
mockMappingTree = { mappings: [] };
54+
mockUseDataMapper = useDataMapper as jest.MockedFunction<typeof useDataMapper>;
55+
mockUseDataMapper.mockReturnValue({
56+
targetBodyDocument: mockTargetBodyDocument,
57+
mappingTree: mockMappingTree,
58+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
59+
} as any);
60+
61+
// Mock MappingActionService
62+
(MappingActionService.getAllowedActions as jest.Mock) = mockGetAllowedActions;
63+
(MappingActionService.deleteMappingItem as jest.Mock) = mockDeleteMappingItem;
64+
65+
// Mock TreeUIService
66+
(TreeUIService.createTree as jest.Mock) = mockCreateTree;
67+
68+
// Setup mock node data
69+
mockTargetDocumentNodeData = { id: 'test-node' } as TargetDocumentNodeData;
70+
mockTreeNode = {
71+
nodeData: mockTargetDocumentNodeData,
72+
};
73+
74+
mockFindNodeByPath = jest.fn();
75+
mockCreateTree.mockReturnValue({
76+
findNodeByPath: mockFindNodeByPath,
77+
} as unknown as DocumentTree);
78+
79+
// Setup default store state
80+
useDocumentTreeStore.setState({
81+
selectedNodePath: null,
82+
selectedNodeIsSource: false,
83+
clearSelection: mockClearSelection,
84+
} as Partial<DocumentTreeState>);
85+
});
86+
87+
afterEach(() => {
88+
jest.restoreAllMocks();
89+
});
90+
91+
describe('successful deletion', () => {
92+
beforeEach(() => {
93+
useDocumentTreeStore.setState({
94+
selectedNodePath: 'test-path',
95+
selectedNodeIsSource: false,
96+
clearSelection: mockClearSelection,
97+
} as Partial<DocumentTreeState>);
98+
99+
mockFindNodeByPath.mockReturnValue(mockTreeNode);
100+
mockGetAllowedActions.mockReturnValue([MappingActionKind.Delete]);
101+
});
102+
103+
it('should delete mapping when valid target node is selected and Delete is allowed', () => {
104+
renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
105+
106+
const hotkeyCallback = mockHotkeys.mock.calls[0][1] as (event: KeyboardEvent) => void;
107+
const mockEvent = { preventDefault: jest.fn() } as unknown as KeyboardEvent;
108+
109+
hotkeyCallback(mockEvent);
110+
111+
expect(mockDeleteMappingItem).toHaveBeenCalledWith(mockTargetDocumentNodeData);
112+
});
113+
114+
it('should clear selection after deletion', () => {
115+
renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
116+
117+
const hotkeyCallback = mockHotkeys.mock.calls[0][1] as (event: KeyboardEvent) => void;
118+
const mockEvent = { preventDefault: jest.fn() } as unknown as KeyboardEvent;
119+
120+
hotkeyCallback(mockEvent);
121+
122+
expect(mockClearSelection).toHaveBeenCalled();
123+
});
124+
125+
it('should call onUpdate callback after deletion', () => {
126+
renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
127+
128+
const hotkeyCallback = mockHotkeys.mock.calls[0][1] as (event: KeyboardEvent) => void;
129+
const mockEvent = { preventDefault: jest.fn() } as unknown as KeyboardEvent;
130+
131+
hotkeyCallback(mockEvent);
132+
133+
expect(mockOnUpdate).toHaveBeenCalled();
134+
});
135+
});
136+
137+
describe('deletion blocked scenarios', () => {
138+
it('should not delete when no node is selected', () => {
139+
useDocumentTreeStore.setState({
140+
selectedNodePath: null,
141+
selectedNodeIsSource: false,
142+
clearSelection: mockClearSelection,
143+
} as Partial<DocumentTreeState>);
144+
145+
renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
146+
147+
const hotkeyCallback = mockHotkeys.mock.calls[0][1] as (event: KeyboardEvent) => void;
148+
const mockEvent = { preventDefault: jest.fn() } as unknown as KeyboardEvent;
149+
150+
hotkeyCallback(mockEvent);
151+
152+
expect(mockDeleteMappingItem).not.toHaveBeenCalled();
153+
expect(mockClearSelection).not.toHaveBeenCalled();
154+
expect(mockOnUpdate).not.toHaveBeenCalled();
155+
});
156+
157+
it('should not delete when Delete action is not allowed', () => {
158+
useDocumentTreeStore.setState({
159+
selectedNodePath: 'test-path',
160+
selectedNodeIsSource: false,
161+
clearSelection: mockClearSelection,
162+
} as Partial<DocumentTreeState>);
163+
164+
mockFindNodeByPath.mockReturnValue(mockTreeNode);
165+
mockGetAllowedActions.mockReturnValue([MappingActionKind.If, MappingActionKind.Choose]);
166+
167+
renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
168+
169+
const hotkeyCallback = mockHotkeys.mock.calls[0][1] as (event: KeyboardEvent) => void;
170+
const mockEvent = { preventDefault: jest.fn() } as unknown as KeyboardEvent;
171+
172+
hotkeyCallback(mockEvent);
173+
174+
expect(mockDeleteMappingItem).not.toHaveBeenCalled();
175+
expect(mockClearSelection).not.toHaveBeenCalled();
176+
expect(mockOnUpdate).not.toHaveBeenCalled();
177+
});
178+
179+
describe('tree creation and memoization', () => {
180+
it('should create TargetDocumentNodeData with correct parameters', () => {
181+
renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
182+
183+
// The hook creates a TargetDocumentNodeData internally
184+
// We can verify TreeUIService.createTree was called
185+
expect(mockCreateTree).toHaveBeenCalled();
186+
});
187+
188+
it('should recreate tree when targetBodyDocument changes', () => {
189+
const { rerender } = renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
190+
191+
const callCountBefore = mockCreateTree.mock.calls.length;
192+
193+
// Change targetBodyDocument
194+
mockUseDataMapper.mockReturnValue({
195+
targetBodyDocument: { id: 'new-target-doc' },
196+
mappingTree: mockMappingTree,
197+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
198+
} as any);
199+
200+
rerender();
201+
202+
expect(mockCreateTree.mock.calls.length).toBeGreaterThan(callCountBefore);
203+
});
204+
205+
it('should recreate tree when mappingTree changes', () => {
206+
const { rerender } = renderHook(() => useDataMapperDeleteHotkey(mockOnUpdate));
207+
208+
const callCountBefore = mockCreateTree.mock.calls.length;
209+
210+
// Change mappingTree
211+
mockUseDataMapper.mockReturnValue({
212+
targetBodyDocument: mockTargetBodyDocument,
213+
mappingTree: { mappings: [{ id: 'new-mapping' }] },
214+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
215+
} as any);
216+
217+
rerender();
218+
219+
expect(mockCreateTree.mock.calls.length).toBeGreaterThan(callCountBefore);
220+
});
221+
});
222+
});
223+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import hotkeys from 'hotkeys-js';
2+
import { useCallback, useEffect, useMemo } from 'react';
3+
4+
import { DocumentTree } from '../models/datamapper/document-tree';
5+
import { MappingActionKind } from '../models/datamapper/mapping-action';
6+
import { TargetDocumentNodeData } from '../models/datamapper/visualization';
7+
import { MappingActionService } from '../services/visualization/mapping-action.service';
8+
import { TreeUIService } from '../services/visualization/tree-ui.service';
9+
import { useDocumentTreeStore } from '../store/document-tree.store';
10+
import { useDataMapper } from './useDataMapper';
11+
12+
/**
13+
* Hook that enables Delete/Backspace hotkey support for removing selected mappings
14+
* in the DataMapper. When a target node with a mapping is selected and the user
15+
* presses Delete or Backspace, the mapping is removed and the selection is cleared.
16+
*
17+
* @param onUpdate - Callback to trigger a re-render after deletion
18+
*/
19+
export function useDataMapperDeleteHotkey(onUpdate: () => void) {
20+
const selectedNodePath = useDocumentTreeStore((state) => state.selectedNodePath);
21+
const selectedNodeIsSource = useDocumentTreeStore((state) => state.selectedNodeIsSource);
22+
const clearSelection = useDocumentTreeStore((state) => state.clearSelection);
23+
const { targetBodyDocument, mappingTree } = useDataMapper();
24+
25+
// Create the target document tree
26+
const targetBodyNodeData = useMemo(
27+
() => new TargetDocumentNodeData(targetBodyDocument, mappingTree),
28+
[targetBodyDocument, mappingTree],
29+
);
30+
31+
const targetBodyTree = useMemo<DocumentTree | undefined>(() => {
32+
return TreeUIService.createTree(targetBodyNodeData);
33+
}, [targetBodyNodeData]);
34+
35+
const handleKeyDown = useCallback(() => {
36+
// Only handle deletions on target nodes (not source nodes)
37+
if (!selectedNodePath || selectedNodeIsSource || !targetBodyTree) return;
38+
39+
// Find the selected tree node by path
40+
const treeNode = targetBodyTree.findNodeByPath(selectedNodePath);
41+
if (!treeNode) return;
42+
43+
// Get the node data from the tree node
44+
const selectedNode = treeNode.nodeData as TargetDocumentNodeData;
45+
if (!selectedNode) return;
46+
47+
// Check if deletion is allowed for this node
48+
const allowedActions = new Set(MappingActionService.getAllowedActions(selectedNode));
49+
if (!allowedActions.has(MappingActionKind.Delete)) return;
50+
51+
// Delete the mapping
52+
MappingActionService.deleteMappingItem(selectedNode);
53+
54+
// Clear selection and trigger update
55+
clearSelection();
56+
onUpdate();
57+
}, [selectedNodePath, selectedNodeIsSource, targetBodyTree, clearSelection, onUpdate]);
58+
59+
useEffect(() => {
60+
hotkeys.filter = (event) => {
61+
const target = event.target as HTMLElement;
62+
const tagName = target.tagName;
63+
// Allow hotkey unless user is typing in an input field
64+
return !(tagName === 'INPUT' || tagName === 'TEXTAREA' || target.isContentEditable);
65+
};
66+
67+
hotkeys('Delete, backspace', (event) => {
68+
event.preventDefault();
69+
handleKeyDown();
70+
});
71+
72+
return () => {
73+
hotkeys.unbind('Delete, backspace');
74+
};
75+
}, [handleKeyDown]);
76+
}

0 commit comments

Comments
 (0)