Skip to content

Commit 2e700c3

Browse files
authored
Merge pull request #388
feat(21481): Create nodes from dropping an edge from a source * feat(21481): add copy/paste operation to the designer * chore(21481): update dependencies * feat(21481): add hot keys * feat(21481): add component for rendering hotkeys * feat(21481): add keyboard shortcut cheatsheet to the designer * feat(21481): add list of shortcuts for help * feat(21481): update translations * refactor(21481): linting * refactor(21481): fix translation * refactor(21481): add failsafe for loaded policies * test(21481): fix tests * test(21481): fix wrapper * feat(21481): add copy/paste status bar * test(21481): add tests * test(21481): add tests * test(21481): add tests * test(21481): fix wrapper * test(21481): add tests * refactor(21481): =change display of modifier key for the different OS
1 parent 8e4932c commit 2e700c3

18 files changed

+537
-15
lines changed

hivemq-edge/src/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"react": "^18.2.0",
7676
"react-dom": "^18.2.0",
7777
"react-hook-form": "^7.43.9",
78+
"react-hotkeys-hook": "^4.5.0",
7879
"react-i18next": "^12.3.0",
7980
"react-icons": "^5.0.1",
8081
"react-router-dom": "^6.11.2",

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

Lines changed: 17 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/// <reference types="cypress" />
2+
3+
import ShortcutRenderer from '@/components/Chakra/ShortcutRenderer.tsx'
4+
5+
describe('ShortcutRenderer', () => {
6+
beforeEach(() => {
7+
cy.viewport(400, 150)
8+
})
9+
10+
it('should render a term and its definition', () => {
11+
cy.mountWithProviders(<ShortcutRenderer hotkeys="CTRL+C" description="This is a description" />)
12+
13+
cy.get('[role="term"]').should('contain.text', 'CTRL + C')
14+
cy.get('kbd').should('have.length', 2)
15+
cy.get('kbd').eq(0).should('contain.text', 'CTRL')
16+
cy.get('kbd').eq(1).should('contain.text', 'C')
17+
cy.get('[role="definition"]').should('contain.text', 'This is a description')
18+
})
19+
20+
it('should render multiple shortcuts', () => {
21+
cy.mountWithProviders(<ShortcutRenderer hotkeys="CTRL+C,Meta+V,ESC" description="This is a description" />)
22+
23+
cy.get('[role="term"]').should('contain.text', 'CTRL + C , Command + V , ESC')
24+
cy.get('kbd').should('have.length', 5)
25+
})
26+
27+
it('should be accessible', () => {
28+
cy.injectAxe()
29+
cy.mountWithProviders(<ShortcutRenderer hotkeys="CTRL+C" description="This is a description" />)
30+
cy.checkAccessibility()
31+
cy.percySnapshot('Component: ShortcutRenderer')
32+
})
33+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { FC, Fragment } from 'react'
2+
import { chakra, Kbd, Text } from '@chakra-ui/react'
3+
4+
interface ShortcutRendererProps {
5+
hotkeys: string
6+
description?: string
7+
}
8+
9+
const ShortcutRenderer: FC<ShortcutRendererProps> = ({ hotkeys, description }) => {
10+
const listHotkeys = hotkeys.split(',')
11+
const shortcuts = listHotkeys.map((e) => e.split('+'))
12+
13+
const localiseKeyboard = (shortcut: string[]) => {
14+
const [modifier, ...rest] = shortcut
15+
16+
if (modifier === 'Meta') {
17+
const os = window.navigator.platform
18+
if (os.startsWith('Mac')) {
19+
return ['Command', ...rest]
20+
} else {
21+
return ['Ctrl', ...rest]
22+
}
23+
}
24+
return shortcut
25+
}
26+
27+
return (
28+
<>
29+
<span role="term" aria-label={hotkeys}>
30+
{shortcuts.map((shortcut, indexShortcut) => {
31+
const localisedShortcut = localiseKeyboard(shortcut)
32+
return (
33+
<Fragment key={`${shortcut}-${indexShortcut}`}>
34+
{indexShortcut !== 0 && ' , '}
35+
{localisedShortcut.map((element, indexElement) => (
36+
<chakra.span key={`$${shortcut}-${indexShortcut}-${indexElement}`} aria-hidden="true">
37+
{indexElement !== 0 && ' + '}
38+
<Kbd>{element}</Kbd>
39+
</chakra.span>
40+
))}
41+
</Fragment>
42+
)
43+
})}
44+
</span>
45+
{description && (
46+
<>
47+
<Text ml={4} as={chakra.span} role="definition">
48+
{' '}
49+
{description}
50+
</Text>
51+
</>
52+
)}
53+
</>
54+
)
55+
}
56+
57+
export default ShortcutRenderer

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ interface MockStoreWrapperProps {
1616
}
1717

1818
export const MockStoreWrapper: FC<MockStoreWrapperProps> = ({ config, children }) => {
19-
const { onAddNodes, onAddEdges } = useDataHubDraftStore()
19+
const { onAddNodes, onAddEdges, reset } = useDataHubDraftStore()
20+
21+
useEffect(() => {
22+
reset()
23+
}, [reset])
2024

2125
useEffect(() => {
2226
const { initialState } = config
@@ -46,7 +50,11 @@ interface MockChecksStoreWrapperProps {
4650

4751
export const MockChecksStoreWrapper: FC<MockChecksStoreWrapperProps> = ({ config, children }) => {
4852
const { setNode, setReport } = usePolicyChecksStore()
49-
const { onAddNodes } = useDataHubDraftStore()
53+
const { onAddNodes, reset } = useDataHubDraftStore()
54+
55+
useEffect(() => {
56+
reset()
57+
}, [reset])
5058

5159
useEffect(() => {
5260
const { node, report } = config

hivemq-edge/src/frontend/src/extensions/datahub/components/controls/CanvasControls.spec.cy.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ describe('CanvasControls', () => {
1414
<CanvasControls />
1515
</ReactFlowProvider>
1616
)
17-
cy.get("[role='group']").find('button').should('have.length', 4)
18-
cy.get("[role='group']").find('button').eq(0).should('have.attr', 'aria-label', 'Zoom in')
19-
cy.get("[role='group']").find('button').eq(1).should('have.attr', 'aria-label', 'Zoom out')
20-
cy.get("[role='group']").find('button').eq(2).should('have.attr', 'aria-label', 'Fit to the canvas')
21-
cy.get("[role='group']").find('button').eq(3).should('have.attr', 'aria-label', 'Lock the canvas')
17+
cy.get("[role='group']").find('button').as('toolbox')
18+
cy.get('@toolbox').should('have.length', 5)
19+
cy.get('@toolbox').eq(0).should('have.attr', 'aria-label', 'Zoom in')
20+
cy.get('@toolbox').eq(1).should('have.attr', 'aria-label', 'Zoom out')
21+
cy.get('@toolbox').eq(2).should('have.attr', 'aria-label', 'Fit to the canvas')
22+
cy.get('@toolbox').eq(3).should('have.attr', 'aria-label', 'Lock the canvas')
23+
cy.get('@toolbox').eq(4).should('have.attr', 'aria-label', 'Help using the Designer')
2224
})
2325
})

hivemq-edge/src/frontend/src/extensions/datahub/components/controls/CanvasControls.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { FC } from 'react'
22
import { ControlProps, Panel, ReactFlowState, useReactFlow, useStore, useStoreApi } from 'reactflow'
3-
3+
import { useTranslation } from 'react-i18next'
44
import { ButtonGroup } from '@chakra-ui/react'
55
import { FaLock, FaLockOpen, FaMinus, FaPlus } from 'react-icons/fa6'
66
import { LuBoxSelect } from 'react-icons/lu'
7-
import { useTranslation } from 'react-i18next'
87

98
import IconButton from '@/components/Chakra/IconButton.tsx'
9+
import DesignerCheatSheet from '@datahub/components/controls/DesignerCheatSheet.tsx'
1010

1111
import 'reactflow/dist/style.css'
1212

@@ -47,6 +47,7 @@ const CanvasControls: FC<ControlProps> = ({ onInteractiveChange }) => {
4747
onClick={onToggleInteractivity}
4848
aria-label={t('workspace.controls.toggleInteractivity') as string}
4949
/>
50+
<DesignerCheatSheet />
5051
</ButtonGroup>
5152
</Panel>
5253
)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/// <reference types="cypress" />
2+
3+
import { Edge, Node } from 'reactflow'
4+
import { Text } from '@chakra-ui/react'
5+
import { DataHubNodeType } from '@datahub/types.ts'
6+
import { MockStoreWrapper } from '@datahub/__test-utils__/MockStoreWrapper.tsx'
7+
import { getNodePayload } from '@datahub/utils/node.utils.ts'
8+
import useDataHubDraftStore from '@datahub/hooks/useDataHubDraftStore.ts'
9+
10+
import { CopyPasteListener } from '@datahub/components/controls/CopyPasteListener.tsx'
11+
12+
const getWrapperWith = (initNodes: Node[], initEdges?: Edge[]) => {
13+
const Wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => {
14+
const { nodes, edges } = useDataHubDraftStore()
15+
return (
16+
<MockStoreWrapper
17+
config={{
18+
initialState: {
19+
nodes: initNodes,
20+
edges: initEdges,
21+
},
22+
}}
23+
>
24+
{children}
25+
<Text data-testid="nodes">{nodes.length}</Text>
26+
<Text data-testid="edges">{edges.length}</Text>
27+
</MockStoreWrapper>
28+
)
29+
}
30+
31+
return Wrapper
32+
}
33+
34+
describe('CopyPasteListener', () => {
35+
beforeEach(() => {
36+
cy.viewport(800, 250)
37+
})
38+
39+
it('should not copy if no node selected', () => {
40+
cy.mountWithProviders(<CopyPasteListener render={(n) => <Text data-testid="copied">{n.length}</Text>} />, {
41+
wrapper: getWrapperWith(
42+
[
43+
{
44+
id: '3',
45+
type: DataHubNodeType.FUNCTION,
46+
position: { x: 0, y: 0 },
47+
data: getNodePayload(DataHubNodeType.FUNCTION),
48+
},
49+
],
50+
[]
51+
),
52+
})
53+
54+
cy.getByTestId('copied').should('have.text', 0)
55+
cy.getByTestId('nodes').should('have.text', 1)
56+
cy.get('body').type('{meta}C')
57+
cy.getByTestId('copied').should('have.text', 0)
58+
cy.get('body').type('{meta}V')
59+
cy.getByTestId('nodes').should('have.text', 1)
60+
cy.getByTestId('copied').should('have.text', 0)
61+
})
62+
63+
it('should copy single node', () => {
64+
cy.mountWithProviders(<CopyPasteListener render={(n) => <Text data-testid="copied">{n.length}</Text>} />, {
65+
wrapper: getWrapperWith(
66+
[
67+
{
68+
id: '3',
69+
type: DataHubNodeType.FUNCTION,
70+
position: { x: 0, y: 0 },
71+
data: getNodePayload(DataHubNodeType.FUNCTION),
72+
selected: true,
73+
},
74+
],
75+
[]
76+
),
77+
})
78+
79+
cy.getByTestId('copied').should('have.text', 0)
80+
cy.getByTestId('nodes').should('have.text', 1)
81+
cy.get('body').type('{meta}C')
82+
cy.getByTestId('copied').should('have.text', 1)
83+
cy.get('body').type('{meta}V')
84+
cy.getByTestId('nodes').should('have.text', 2)
85+
cy.get('body').type('{esc}')
86+
cy.getByTestId('copied').should('have.text', 0)
87+
})
88+
89+
it('should copy subgraph', () => {
90+
cy.mountWithProviders(<CopyPasteListener render={(n) => <Text data-testid="copied">{n.length}</Text>} />, {
91+
wrapper: getWrapperWith(
92+
[
93+
{
94+
id: '1',
95+
position: { x: 0, y: 0 },
96+
data: undefined,
97+
selected: true,
98+
},
99+
{
100+
id: '2',
101+
position: { x: 0, y: 0 },
102+
data: undefined,
103+
selected: false,
104+
},
105+
{
106+
id: '3',
107+
position: { x: 0, y: 0 },
108+
data: undefined,
109+
selected: true,
110+
},
111+
],
112+
[
113+
{ id: 'e1', source: '1', target: '3' },
114+
{ id: 'e2', source: '1', target: '2' },
115+
{ id: 'e3', source: '2', target: '3' },
116+
]
117+
),
118+
})
119+
120+
cy.getByTestId('copied').should('have.text', 0)
121+
cy.getByTestId('nodes').should('have.text', 3)
122+
cy.getByTestId('edges').should('have.text', 3)
123+
cy.get('body').type('{meta}C')
124+
cy.getByTestId('copied').should('have.text', 2)
125+
cy.get('body').type('{meta}V')
126+
cy.getByTestId('nodes').should('have.text', 5)
127+
cy.getByTestId('edges').should('have.text', 4)
128+
cy.get('body').type('{esc}')
129+
cy.getByTestId('copied').should('have.text', 0)
130+
})
131+
})

0 commit comments

Comments
 (0)