Skip to content

Commit a0dbe05

Browse files
authored
Merge pull request #403
feat(19255): Add safeguards for deleting nodes or edges on the Designer * feat(19255): add read-only status * refactor(19255): remove default delete support * refactor(19255): add custom listener for deletion * feat(19255): add checks for node/edge deletion * refactor(19255): add delete hot keys * feat(19255): add handling for deletion of elements on the Designer * feat(19255): update translations * chore(19255): update dependencies * test(19255): add tests * fix(19255): fix mock * test(19255): fix translations and test * chore(19255): add quiet to cypress percy testing * chore(19255): add cypress real event * test(19255): fix test * test(19255): fix test * chore(19255): a bit of cleaning
1 parent 4ec9d20 commit a0dbe05

File tree

16 files changed

+649
-852
lines changed

16 files changed

+649
-852
lines changed

.github/workflows/frontend-visual.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,14 @@ jobs:
4444
- name: 🧪 Run Cypress E2E
4545
uses: cypress-io/github-action@v6
4646
with:
47+
quiet: true
4748
working-directory: ./hivemq-edge/src/frontend/
4849
start: pnpm dev
4950

5051
- name: 🧪 Run Cypress Component
5152
uses: cypress-io/github-action@v6
5253
with:
54+
quiet: true
5355
working-directory: ./hivemq-edge/src/frontend/
5456
component: true
5557
start: pnpm dev

hivemq-edge/src/frontend/cypress/support/component.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import './workaround-cypress-10-0-2-process-issue.ts'
1919
import 'cypress-axe'
2020
import 'cypress-each'
2121
import '@percy/cypress'
22+
import 'cypress-real-events'
2223
import './commands'
2324

2425
import { mount, MountOptions, MountReturn } from 'cypress/react18'

hivemq-edge/src/frontend/cypress/support/e2e.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import 'cypress-axe'
1717
import 'cypress-each'
1818
import '@percy/cypress'
19+
import 'cypress-real-events'
1920

2021
import './commands'
2122

hivemq-edge/src/frontend/package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@
2727
"preview": "vite preview"
2828
},
2929
"dependencies": {
30-
"@chakra-ui/anatomy": "^2.1.1",
31-
"@chakra-ui/icons": "^2.0.19",
32-
"@chakra-ui/radio": "^2.0.22",
33-
"@chakra-ui/react": "^2.7.1",
34-
"@chakra-ui/skip-nav": "^2.0.15",
30+
"@chakra-ui/anatomy": "^2.2.2",
31+
"@chakra-ui/icons": "^2.1.1",
32+
"@chakra-ui/radio": "^2.1.2",
33+
"@chakra-ui/react": "^2.8.2",
34+
"@chakra-ui/skip-nav": "^2.1.0",
3535
"@chakra-ui/utils": "^2.0.14",
36-
"@emotion/react": "^11.11.1",
36+
"@emotion/react": "^11.11.4",
3737
"@emotion/styled": "^11.11.0",
38-
"@fontsource/roboto": "^5.0.3",
38+
"@fontsource/roboto": "^5.0.13",
3939
"@monaco-editor/react": "^4.5.1",
4040
"@nivo/bar": "^0.84.0",
4141
"@nivo/core": "^0.84.0",
@@ -62,6 +62,7 @@
6262
"axios": "^1.6.8",
6363
"chakra-react-select": "^4.7.6",
6464
"csstype": "^3.1.2",
65+
"cypress-real-events": "^1.12.0",
6566
"d3-array": "^3.2.4",
6667
"d3-hierarchy": "^3.1.2",
6768
"elkjs": "^0.9.1",

hivemq-edge/src/frontend/pnpm-lock.yaml

Lines changed: 342 additions & 841 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hivemq-edge/src/frontend/src/components/Modal/ConfirmationDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ const ConfirmationDialog: FC<ConfirmationDialogProps> = ({ isOpen, onClose, head
3535
<AlertDialogBody>{message}</AlertDialogBody>
3636

3737
<AlertDialogFooter>
38-
<Button ref={cancelRef as LegacyRef<HTMLButtonElement>} onClick={onClose}>
38+
<Button ref={cancelRef as LegacyRef<HTMLButtonElement>} onClick={onClose} data-testid="confirmation-cancel">
3939
{t('action.cancel')}
4040
</Button>
4141
<Button
42+
data-testid="confirmation-submit"
4243
onClick={() => {
4344
onClose()
4445
onSubmit?.()

hivemq-edge/src/frontend/src/extensions/datahub/__test-utils__/MockStoreWrapper.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export const MockStoreWrapper: FC<MockStoreWrapperProps> = ({ config, children }
3838
type: 'add',
3939
}))
4040
)
41-
}, [config, onAddEdges, onAddNodes])
41+
// eslint-disable-next-line react-hooks/exhaustive-deps
42+
}, [])
4243

4344
return <>{children}</>
4445
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/// <reference types="cypress" />
2+
3+
import { Edge, Node } from 'reactflow'
4+
import { Table, TableContainer, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react'
5+
import { MockStoreWrapper } from '@datahub/__test-utils__/MockStoreWrapper.tsx'
6+
import useDataHubDraftStore from '@datahub/hooks/useDataHubDraftStore.ts'
7+
8+
import DeleteListener from '@datahub/components/controls/DeleteListener.tsx'
9+
10+
const getWrapperWith = (initNodes: Node[], initEdges?: Edge[]) => {
11+
const Wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => {
12+
const { nodes, edges } = useDataHubDraftStore()
13+
return (
14+
<MockStoreWrapper
15+
config={{
16+
initialState: {
17+
nodes: initNodes,
18+
edges: initEdges,
19+
},
20+
}}
21+
>
22+
{children}
23+
<TableContainer>
24+
<Table>
25+
<Thead>
26+
<Tr>
27+
<Th></Th>
28+
<Th>count</Th>
29+
<Th>selected</Th>
30+
</Tr>
31+
</Thead>
32+
<Tbody>
33+
<Tr>
34+
<Th>nodes</Th>
35+
<Td>{nodes.length}</Td>
36+
<Td>{nodes.filter((e) => e.selected).length}</Td>
37+
</Tr>
38+
<Tr>
39+
<Th>edges</Th>
40+
<Td>{edges.length}</Td>
41+
<Td>{edges.filter((e) => e.selected).length}</Td>
42+
</Tr>
43+
</Tbody>
44+
</Table>
45+
</TableContainer>
46+
</MockStoreWrapper>
47+
)
48+
}
49+
50+
return Wrapper
51+
}
52+
53+
describe('DeleteListener', () => {
54+
beforeEach(() => {
55+
cy.viewport(800, 400)
56+
})
57+
58+
it('should render a confirmation modal when deleting', () => {
59+
cy.mountWithProviders(<DeleteListener />, {
60+
wrapper: getWrapperWith(
61+
[
62+
{
63+
id: '1',
64+
position: { x: 0, y: 0 },
65+
data: undefined,
66+
selected: true,
67+
},
68+
],
69+
[]
70+
),
71+
})
72+
73+
cy.get("[role='alertdialog']").should('not.exist')
74+
cy.get('body').type('{backspace}')
75+
cy.get("[role='alertdialog']")
76+
.should('be.visible')
77+
.should('contain.text', 'Are you sure you want to delete 1 node? The operation cannot be reversed.')
78+
cy.getByTestId('confirmation-submit').click()
79+
cy.get("[role='alertdialog']").should('not.exist')
80+
})
81+
82+
it('should render a confirmation modal when deleting multiple elements', () => {
83+
cy.mountWithProviders(<DeleteListener />, {
84+
wrapper: getWrapperWith(
85+
[
86+
{
87+
id: '1',
88+
position: { x: 0, y: 0 },
89+
data: undefined,
90+
selected: true,
91+
},
92+
{
93+
id: '2',
94+
position: { x: 0, y: 0 },
95+
data: undefined,
96+
selected: false,
97+
},
98+
{
99+
id: '3',
100+
position: { x: 0, y: 0 },
101+
data: undefined,
102+
selected: true,
103+
},
104+
],
105+
[{ id: 'e1', source: '1', target: '3', selected: true }]
106+
),
107+
})
108+
109+
cy.get('td').then((w) => {
110+
cy.wrap(w[0]).should('contain.text', 3)
111+
cy.wrap(w[1]).should('contain.text', 2)
112+
cy.wrap(w[2]).should('contain.text', 1)
113+
cy.wrap(w[3]).should('contain.text', 1)
114+
})
115+
116+
cy.get("[role='alertdialog']").should('not.exist')
117+
cy.get('body').type('{backspace}')
118+
cy.get("[role='alertdialog']").as('confirm')
119+
120+
cy.get('@confirm')
121+
.should('be.visible')
122+
.should(
123+
'contain.text',
124+
'Are you sure you want to delete 3 nodes and connections? The operation cannot be reversed.'
125+
)
126+
cy.getByTestId('confirmation-submit').click()
127+
cy.get("[role='alertdialog']").should('not.exist')
128+
cy.get('td').then((w) => {
129+
cy.wrap(w[0]).should('contain.text', 1)
130+
cy.wrap(w[1]).should('contain.text', 0)
131+
cy.wrap(w[2]).should('contain.text', 0)
132+
cy.wrap(w[3]).should('contain.text', 0)
133+
})
134+
})
135+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { FC, useMemo } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { useHotkeys } from 'react-hotkeys-hook'
4+
import { NodeRemoveChange, EdgeRemoveChange } from 'reactflow'
5+
import { ListItem, Text, UnorderedList, useDisclosure, useToast, VStack } from '@chakra-ui/react'
6+
7+
import ConfirmationDialog from '@/components/Modal/ConfirmationDialog.tsx'
8+
9+
import useDataHubDraftStore from '@datahub/hooks/useDataHubDraftStore.ts'
10+
import { DesignerStatus } from '@datahub/types.ts'
11+
import { DATAHUB_HOTKEY } from '@datahub/utils/datahub.utils.ts'
12+
import { canDeleteEdge, canDeleteNode } from '@datahub/utils/node.utils.ts'
13+
import { DATAHUB_TOAST_ID, dataHubToastOption } from '@datahub/utils/toast.utils.ts'
14+
15+
const DeleteListener: FC = () => {
16+
const { t } = useTranslation('datahub')
17+
const { nodes, edges, status, onNodesChange, onEdgesChange, setStatus } = useDataHubDraftStore()
18+
const { isOpen, onOpen, onClose } = useDisclosure()
19+
const toast = useToast()
20+
21+
const selectedElements = useMemo(() => {
22+
const selectedNodes = nodes.filter((node) => node.selected)
23+
const selectedEdges = edges.filter((edge) => edge.selected)
24+
return { selectedNodes, selectedEdges }
25+
}, [edges, nodes])
26+
27+
const SelectedElementsCount = useMemo(() => {
28+
const { selectedNodes, selectedEdges } = selectedElements
29+
return selectedNodes.length + selectedEdges.length
30+
}, [selectedElements])
31+
32+
const deleteContext = useMemo(() => {
33+
const { selectedNodes, selectedEdges } = selectedElements
34+
if (selectedNodes.length === 0) return 'EDGE_ONLY'
35+
if (selectedEdges.length === 0) return 'MODE_ONLY'
36+
return 'BOTH'
37+
}, [selectedElements])
38+
39+
useHotkeys([DATAHUB_HOTKEY.BACKSPACE, DATAHUB_HOTKEY.DELETE], () => {
40+
const { selectedNodes, selectedEdges } = selectedElements
41+
const canDeleteNodes = selectedNodes.map((node) => canDeleteNode(node, status))
42+
const canDeleteEdges = selectedEdges.map((edge) => canDeleteEdge(edge, nodes))
43+
44+
const allElements = [...canDeleteNodes, ...canDeleteEdges]
45+
const canDeleteElements = allElements.every((element) => Boolean(element.delete))
46+
if (canDeleteElements) {
47+
return onOpen()
48+
}
49+
50+
const allErrors = allElements.reduce<string[]>((acc, cur) => {
51+
if (cur.error && !acc.includes(cur.error)) {
52+
acc.push(cur.error)
53+
}
54+
return acc
55+
}, [])
56+
if (!toast.isActive(DATAHUB_TOAST_ID))
57+
toast({
58+
...dataHubToastOption,
59+
id: DATAHUB_TOAST_ID,
60+
title: t('workspace.deletion.modal.header', { count: SelectedElementsCount }),
61+
status: 'error',
62+
description: (
63+
<VStack alignItems="flex-start">
64+
<Text>{t('workspace.deletion.guards.message', { count: SelectedElementsCount })}</Text>
65+
<UnorderedList>
66+
{allErrors.map((error, index) => (
67+
<ListItem key={`toto-${index}`}>{error}</ListItem>
68+
))}
69+
</UnorderedList>
70+
</VStack>
71+
),
72+
})
73+
})
74+
75+
const handleConfirmOnSubmit = () => {
76+
const { selectedNodes, selectedEdges } = selectedElements
77+
onEdgesChange(selectedEdges.map<EdgeRemoveChange>((edge) => ({ id: edge.id, type: 'remove' })))
78+
onNodesChange(selectedNodes.map<NodeRemoveChange>((node) => ({ id: node.id, type: 'remove' })))
79+
setStatus(DesignerStatus.MODIFIED)
80+
}
81+
82+
return (
83+
<ConfirmationDialog
84+
isOpen={isOpen}
85+
onClose={onClose}
86+
onSubmit={handleConfirmOnSubmit}
87+
message={t('workspace.deletion.modal.message', {
88+
context: deleteContext,
89+
count: SelectedElementsCount,
90+
})}
91+
header={t('workspace.deletion.modal.header', { count: SelectedElementsCount })}
92+
/>
93+
)
94+
}
95+
96+
export default DeleteListener

hivemq-edge/src/frontend/src/extensions/datahub/components/pages/PolicyEditor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import ToolboxSelectionListener from '@datahub/components/controls/ToolboxSelect
2424
import { CopyPasteListener } from '@datahub/components/controls/CopyPasteListener.tsx'
2525
import CopyPasteStatus from '@datahub/components/controls/CopyPasteStatus.tsx'
2626
import ConnectionLine from '@datahub/components/nodes/ConnectionLine.tsx'
27+
import DeleteListener from '@datahub/components/controls/DeleteListener.tsx'
2728

2829
export type OnConnectStartParams = {
2930
nodeId: string | null
@@ -161,11 +162,13 @@ const PolicyEditor: FC = () => {
161162
onDragOver={onDragOver}
162163
onDrop={onDrop}
163164
isValidConnection={checkValidity}
165+
deleteKeyCode={[]}
164166

165167
// onError={(id: string, message: string) => console.log('XXXXXX e', id, message)}
166168
>
167169
<Box role="toolbar" aria-label={t('workspace.aria-label')} aria-controls="edge-workspace-canvas">
168170
<ToolboxSelectionListener />
171+
<DeleteListener />
169172
<CopyPasteListener render={(copiedNodes) => <CopyPasteStatus nbCopied={copiedNodes.length} />} />
170173
<DesignerToolbox />
171174
<CanvasControls />

0 commit comments

Comments
 (0)