Skip to content

Commit 0de3222

Browse files
committed
Merge pull request #1213
* feat(37055): add the search toolbox * feat(37055): add a toolbar for the canvas * feat(37055): add variant for the form control * refactor(37055): add options for the react-flow fitview * fix(37055): fix bug * fix(37055): fix translations * chore(37055): a bit of cleaning * test(37055): add tests * refactor(37055): remove the internal state and add callback for chang… * test(37055): add tests * chore(37055): trigger ci * fix(37055): fix bug with state/count * fix(37055): fix translations * test(37055): fix tests * fix(37055): a bit of cleaning
1 parent c7e0c8c commit 0de3222

File tree

9 files changed

+330
-5
lines changed

9 files changed

+330
-5
lines changed

hivemq-edge-frontend/src/locales/en/translation.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,17 @@
10301030
"metadata": {
10311031
"sample": "Load samples"
10321032
}
1033+
},
1034+
"searchToolbox": {
1035+
"search": {
1036+
"label": "Search for",
1037+
"placeholder": "Search for ...",
1038+
"helper": "Search for an adapter, bridge or device",
1039+
"clear": "Clear the search",
1040+
"previous": "Previous entity",
1041+
"next": "Next entity",
1042+
"matches": "{{ index }} of {{ count }}"
1043+
}
10331044
}
10341045
},
10351046
"modals": {

hivemq-edge-frontend/src/modules/Theme/components/FormControl.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ const hivemq = definePartsStyle({
1919
},
2020
})
2121

22+
const horizontal = definePartsStyle({
23+
container: {
24+
display: 'flex',
25+
alignItems: 'center',
26+
justifyContent: 'space-between',
27+
gap: 3,
28+
'> label': {
29+
flex: 1,
30+
marginBottom: 0,
31+
marginInlineEnd: 0,
32+
},
33+
'> label + *': {
34+
flex: 2,
35+
},
36+
},
37+
})
38+
2239
export const formControlTheme = defineMultiStyleConfig({
23-
variants: { hivemq },
40+
variants: { hivemq, horizontal },
2441
})

hivemq-edge-frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import StatusListener from '@/modules/Workspace/components/controls/StatusListen
1717
import CanvasControls from '@/modules/Workspace/components/controls/CanvasControls.tsx'
1818
import SelectionListener from '@/modules/Workspace/components/controls/SelectionListener.tsx'
1919
import MonitoringEdge from '@/modules/Workspace/components/edges/MonitoringEdge.tsx'
20+
import CanvasToolbar from '@/modules/Workspace/components/controls/CanvasToolbar.tsx'
2021
import {
2122
NodeAdapter,
2223
NodeBridge,
@@ -28,9 +29,9 @@ import {
2829
NodeCombiner,
2930
NodePulse,
3031
} from '@/modules/Workspace/components/nodes'
32+
import { DynamicEdge } from '@/modules/Workspace/components/edges/DynamicEdge'
3133
import { getGluedPosition, gluedNodeDefinition } from '@/modules/Workspace/utils/nodes-utils.ts'
3234
import { proOptions } from '@/components/react-flow/react-flow.utils.ts'
33-
import { DynamicEdge } from './edges/DynamicEdge'
3435

3536
const ReactFlowWrapper = () => {
3637
const { t } = useTranslation()
@@ -115,6 +116,7 @@ const ReactFlowWrapper = () => {
115116
aria-label={t('workspace.canvas.aria-label')}
116117
>
117118
<Box role="toolbar" aria-label={t('workspace.controls.aria-label')} aria-controls="edge-workspace-canvas">
119+
<CanvasToolbar />
118120
<SelectionListener />
119121
<StatusListener />
120122
<Background />
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ReactFlowProvider } from '@xyflow/react'
2+
import CanvasToolbar from '@/modules/Workspace/components/controls/CanvasToolbar.tsx'
3+
4+
describe('CanvasToolbar', () => {
5+
beforeEach(() => {
6+
cy.viewport(800, 250)
7+
})
8+
9+
it('should renders properly', () => {
10+
cy.mountWithProviders(<CanvasToolbar />, {
11+
wrapper: ({ children }: { children: JSX.Element }) => <ReactFlowProvider>{children}</ReactFlowProvider>,
12+
})
13+
14+
cy.getByTestId('canvas-toolbar').should('be.visible')
15+
cy.getByTestId('workspace-search').should('be.visible')
16+
})
17+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { FC } from 'react'
2+
import { HStack } from '@chakra-ui/react'
3+
4+
import Panel from '@/components/react-flow/Panel.tsx'
5+
import SearchEntities from '@/modules/Workspace/components/filters/SearchEntities.tsx'
6+
7+
const CanvasToolbar: FC = () => {
8+
return (
9+
<Panel position="top-left">
10+
<HStack m={2} data-testid="canvas-toolbar">
11+
<SearchEntities />
12+
</HStack>
13+
</Panel>
14+
)
15+
}
16+
17+
export default CanvasToolbar

hivemq-edge-frontend/src/modules/Workspace/components/controls/SelectionListener.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts'
77
import type { DeviceMetadata } from '@/modules/Workspace/types.ts'
88
import { NodeTypes } from '@/modules/Workspace/types.ts'
99
import { WorkspaceNavigationCommand } from '@/modules/Workspace/types.ts'
10-
import { addSelectedNodesState } from '@/modules/Workspace/utils/react-flow.utils.ts'
10+
import { addSelectedNodesState, CONFIG_FITVIEW_OPTION } from '@/modules/Workspace/utils/react-flow.utils.ts'
1111

1212
const SelectionListener = () => {
1313
const { state, pathname } = useLocation()
@@ -22,7 +22,7 @@ const SelectionListener = () => {
2222

2323
const focusOnNodes = (nodesIds: string[]) => {
2424
addSelectedNodes(nodesIds)
25-
fitView({ nodes: nodesIds.map((e) => ({ id: e })), duration: 750 })
25+
fitView({ nodes: nodesIds.map((e) => ({ id: e })), ...CONFIG_FITVIEW_OPTION })
2626
navigate(pathname, { state: null, replace: true })
2727
}
2828

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Node } from '@xyflow/react'
2+
import { MOCK_NODE_ADAPTER } from '@/__test-utils__/react-flow/nodes.ts'
3+
import { mockReactFlow } from '@/__test-utils__/react-flow/providers.tsx'
4+
import { ReactFlowTesting } from '@/__test-utils__/react-flow/ReactFlowTesting.tsx'
5+
import SearchEntities from '@/modules/Workspace/components/filters/SearchEntities.tsx'
6+
import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts'
7+
import { useMemo } from 'react'
8+
9+
const getWrapperWith = (initialNodes?: Node[]) => {
10+
const Wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => {
11+
const { nodes } = useWorkspaceStore()
12+
const selected = useMemo(() => {
13+
return nodes.filter((node) => node.selected)
14+
}, [nodes])
15+
16+
return (
17+
<ReactFlowTesting
18+
config={{
19+
initialState: {
20+
nodes: initialNodes,
21+
},
22+
}}
23+
showDashboard={true}
24+
dashboard={
25+
<>
26+
<div data-testid="data-length">{nodes.length}</div>
27+
<div data-testid="data-selected">{selected.length}</div>
28+
</>
29+
}
30+
>
31+
{children}
32+
</ReactFlowTesting>
33+
)
34+
}
35+
36+
return Wrapper
37+
}
38+
39+
describe('SearchEntities', () => {
40+
beforeEach(() => {
41+
cy.viewport(600, 400)
42+
})
43+
44+
it('should render properly', () => {
45+
cy.mountWithProviders(mockReactFlow(<SearchEntities />))
46+
47+
cy.get('[role="group"] > label').should('not.be.visible').should('have.text', 'Search for')
48+
cy.getByTestId('workspace-search').should('have.attr', 'placeholder', 'Search for ...')
49+
cy.getByTestId('workspace-search-clear').should('not.exist')
50+
cy.get('[role="group"] > [role="group"] ').within(() => {
51+
cy.getByTestId('workspace-search-prev').should('have.attr', 'aria-label', 'Previous entity').should('be.disabled')
52+
cy.getByTestId('workspace-search-next').should('have.attr', 'aria-label', 'Next entity').should('be.disabled')
53+
cy.getByTestId('workspace-search-counter').should('have.text', '0 of 0')
54+
})
55+
})
56+
57+
it('should search', () => {
58+
const onchange = cy.stub().as('onchange')
59+
const onNavigate = cy.stub().as('onNavigate')
60+
cy.mountWithProviders(<SearchEntities onChange={onchange} onNavigate={onNavigate} />, {
61+
wrapper: getWrapperWith([
62+
{ ...MOCK_NODE_ADAPTER, position: { x: 0, y: 0 } },
63+
{ ...MOCK_NODE_ADAPTER, id: 'adapter2', position: { x: 0, y: 0 } },
64+
]),
65+
})
66+
67+
cy.get('@onchange').should('not.have.been.called')
68+
cy.get('@onNavigate').should('not.have.been.called')
69+
cy.getByTestId('workspace-search').type('test')
70+
cy.getByTestId('workspace-search').should('have.value', 'test')
71+
cy.getByTestId('workspace-search-clear').should('be.visible').should('have.attr', 'aria-label', 'Clear the search')
72+
cy.getByTestId('workspace-search-clear').click()
73+
cy.getByTestId('workspace-search').should('have.attr', 'placeholder', 'Search for ...')
74+
cy.getByTestId('workspace-search').should('have.value', '')
75+
cy.get('@onchange').should('have.been.calledWith', [])
76+
77+
cy.getByTestId('workspace-search').type('adapt')
78+
cy.getByTestId('workspace-search-prev').should('not.be.disabled')
79+
cy.getByTestId('workspace-search-next').should('not.be.disabled')
80+
81+
cy.get('@onchange').should('have.been.calledWith', ['idAdapter', 'adapter2'])
82+
83+
cy.getByTestId('workspace-search-next').click()
84+
cy.get('@onNavigate').should('have.been.calledWith', 'adapter2')
85+
cy.getByTestId('workspace-search-next').click()
86+
cy.get('@onNavigate').should('have.been.calledWith', 'idAdapter')
87+
cy.getByTestId('workspace-search-prev').click()
88+
cy.get('@onNavigate').should('have.been.calledWith', 'adapter2')
89+
})
90+
91+
it('should be accessible', () => {
92+
cy.injectAxe()
93+
cy.mountWithProviders(mockReactFlow(<SearchEntities />))
94+
95+
cy.checkAccessibility()
96+
})
97+
})
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { FC } from 'react'
2+
import { useMemo, useState } from 'react'
3+
import { useTranslation } from 'react-i18next'
4+
import type { NodeSelectionChange } from '@xyflow/react'
5+
import { useReactFlow } from '@xyflow/react'
6+
import {
7+
ButtonGroup,
8+
FormControl,
9+
FormLabel,
10+
Icon,
11+
Input,
12+
InputGroup,
13+
InputLeftElement,
14+
InputRightElement,
15+
Text,
16+
} from '@chakra-ui/react'
17+
import { SearchIcon } from '@chakra-ui/icons'
18+
import { MdArrowBack, MdArrowForward, MdClear } from 'react-icons/md'
19+
20+
import IconButton from '@/components/Chakra/IconButton.tsx'
21+
import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts'
22+
import { CONFIG_FITVIEW_OPTION } from '@/modules/Workspace/utils/react-flow.utils.ts'
23+
24+
interface SearchEntitiesProps {
25+
onChange?: (values: string[]) => void
26+
onNavigate?: (current: string) => void
27+
}
28+
29+
const SearchEntities: FC<SearchEntitiesProps> = ({ onChange, onNavigate }) => {
30+
const { t } = useTranslation()
31+
const [search, setSearch] = useState('')
32+
const [current, setCurrent] = useState<number | null>(null)
33+
const { fitView } = useReactFlow()
34+
const { nodes, onNodesChange } = useWorkspaceStore()
35+
36+
const selectedNodes = useMemo(() => {
37+
return nodes.filter((e) => e.selected === true).map((e) => e.id)
38+
}, [nodes])
39+
40+
const handleNavigate = (direction: 'next' | 'prev') => {
41+
if (current === null) return
42+
let newIndex = direction === 'next' ? current + 1 : current - 1
43+
if (newIndex < 0) newIndex = selectedNodes.length - 1
44+
if (newIndex >= selectedNodes.length) newIndex = 0
45+
setCurrent(newIndex)
46+
fitView({ nodes: [{ id: selectedNodes[newIndex] }], ...CONFIG_FITVIEW_OPTION })
47+
onNavigate?.(selectedNodes[newIndex])
48+
}
49+
50+
const handleClear = (clearValue = false) => {
51+
onNodesChange(
52+
nodes.map((node) => {
53+
const select: NodeSelectionChange = {
54+
id: node.id,
55+
type: 'select',
56+
selected: false,
57+
}
58+
return select
59+
})
60+
)
61+
fitView({ ...CONFIG_FITVIEW_OPTION })
62+
setCurrent(null)
63+
if (clearValue) setSearch('')
64+
onChange?.([])
65+
}
66+
67+
const handleChange = (value: string) => {
68+
if (!value) {
69+
handleClear(true)
70+
return
71+
}
72+
const foundNodes = nodes.filter((node) => {
73+
return new RegExp(value, 'i').test(node.id) && node.hidden !== true
74+
})
75+
const ids = foundNodes.map((node) => node.id)
76+
setSearch(value)
77+
78+
if (foundNodes.length === 0) {
79+
handleClear()
80+
return
81+
}
82+
83+
// change the selection status of selected node
84+
onNodesChange(
85+
nodes.map((node) => {
86+
const select: NodeSelectionChange = {
87+
id: node.id,
88+
type: 'select',
89+
selected: ids.includes(node.id),
90+
}
91+
return select
92+
})
93+
)
94+
95+
setCurrent(0)
96+
fitView({ nodes: foundNodes, ...CONFIG_FITVIEW_OPTION })
97+
onChange?.(ids)
98+
}
99+
100+
const hasSearchStarted = current !== null && search !== ''
101+
102+
return (
103+
<FormControl variant="horizontal">
104+
<FormLabel fontSize="sm" htmlFor="workspace-search" whiteSpace="nowrap" hidden>
105+
{t('workspace.searchToolbox.search.label')}
106+
</FormLabel>
107+
<InputGroup size="sm">
108+
<InputLeftElement>
109+
<Icon as={SearchIcon} boxSize="3" />
110+
</InputLeftElement>
111+
<Input
112+
data-testid="workspace-search"
113+
placeholder={t('workspace.searchToolbox.search.placeholder')}
114+
size="sm"
115+
id="workspace-search"
116+
value={search}
117+
onChange={(e) => handleChange(e.target.value)}
118+
/>
119+
{search && (
120+
<InputRightElement>
121+
<IconButton
122+
size="sm"
123+
variant="ghost"
124+
data-testid="workspace-search-clear"
125+
aria-label={t('workspace.searchToolbox.search.clear')}
126+
icon={<MdClear />}
127+
onClick={() => {
128+
handleClear(true)
129+
}}
130+
/>
131+
</InputRightElement>
132+
)}
133+
</InputGroup>
134+
<ButtonGroup size="sm" isAttached isDisabled={!hasSearchStarted}>
135+
<IconButton
136+
data-testid="workspace-search-prev"
137+
icon={<MdArrowBack />}
138+
aria-label={t('workspace.searchToolbox.search.previous')}
139+
onClick={() => handleNavigate('prev')}
140+
/>
141+
<Text alignContent="center" marginX={2} data-testid="workspace-search-counter" userSelect="none">
142+
{t('workspace.searchToolbox.search.matches', {
143+
index: hasSearchStarted ? current + 1 : 0,
144+
count: hasSearchStarted ? selectedNodes.length : 0,
145+
})}
146+
</Text>
147+
148+
<IconButton
149+
data-testid="workspace-search-next"
150+
icon={<MdArrowForward />}
151+
aria-label={t('workspace.searchToolbox.search.next')}
152+
onClick={() => handleNavigate('next')}
153+
/>
154+
</ButtonGroup>
155+
</FormControl>
156+
)
157+
}
158+
159+
export default SearchEntities

hivemq-edge-frontend/src/modules/Workspace/utils/react-flow.utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
/* istanbul ignore file -- @preserve */
2-
import type { ReactFlowState } from '@xyflow/react'
2+
import type { ReactFlowState, FitViewOptions } from '@xyflow/react'
33

44
export const CONFIG_ZOOM_SKELETON = 0.5
55
export const CONFIG_ZOOM_MIN = 0.2
66
export const CONFIG_ZOOM_MAX = 1.5
77

8+
export const CONFIG_FITVIEW_OPTION: FitViewOptions = {
9+
padding: 0.25,
10+
duration: 750,
11+
}
12+
813
export const selectorIsSkeletonZoom = (state: ReactFlowState) => state.transform[2] <= CONFIG_ZOOM_SKELETON
914

1015
export const selectorSetZoomMinMax = (state: ReactFlowState) => ({

0 commit comments

Comments
 (0)