Skip to content
Draft
Empty file added .github/workflows/trigger.md
Empty file.
11 changes: 11 additions & 0 deletions hivemq-edge-frontend/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,17 @@
"metadata": {
"sample": "Load samples"
}
},
"searchToolbox": {
"search": {
"label": "Search for",
"placeholder": "Search for ...",
"helper": "Search for an adapter, bridge or device",
"clear": "Clear the search",
"previous": "Previous entity",
"next": "Next entity",
"matches": "{{ index }} of {{ count }}"
}
}
},
"modals": {
Expand Down
24 changes: 23 additions & 1 deletion hivemq-edge-frontend/src/modules/Theme/components/FormControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ const hivemq = definePartsStyle({
},
})

const horizontal = definePartsStyle({
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
// marginY: 3,
// ':last-child': {
// // marginBottom: 0,
// },
gap: 3,
'> label': {
flex: 1,
marginBottom: 0,
marginInlineEnd: 0,
// minWidth: 100,
},
'> label + *': {
flex: 2,
},
},
})

export const formControlTheme = defineMultiStyleConfig({
variants: { hivemq },
variants: { hivemq, horizontal },
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import StatusListener from '@/modules/Workspace/components/controls/StatusListen
import CanvasControls from '@/modules/Workspace/components/controls/CanvasControls.tsx'
import SelectionListener from '@/modules/Workspace/components/controls/SelectionListener.tsx'
import MonitoringEdge from '@/modules/Workspace/components/edges/MonitoringEdge.tsx'
import CanvasToolbar from '@/modules/Workspace/components/controls/CanvasToolbar.tsx'
import {
NodeAdapter,
NodeBridge,
Expand All @@ -28,9 +29,9 @@ import {
NodeCombiner,
NodePulse,
} from '@/modules/Workspace/components/nodes'
import { DynamicEdge } from '@/modules/Workspace/components/edges/DynamicEdge'
import { getGluedPosition, gluedNodeDefinition } from '@/modules/Workspace/utils/nodes-utils.ts'
import { proOptions } from '@/components/react-flow/react-flow.utils.ts'
import { DynamicEdge } from './edges/DynamicEdge'

const ReactFlowWrapper = () => {
const { t } = useTranslation()
Expand Down Expand Up @@ -115,6 +116,7 @@ const ReactFlowWrapper = () => {
aria-label={t('workspace.canvas.aria-label')}
>
<Box role="toolbar" aria-label={t('workspace.controls.aria-label')} aria-controls="edge-workspace-canvas">
<CanvasToolbar />
<SelectionListener />
<StatusListener />
<Background />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ReactFlowProvider } from '@xyflow/react'
import CanvasToolbar from '@/modules/Workspace/components/controls/CanvasToolbar.tsx'

describe('CanvasToolbar', () => {
beforeEach(() => {
cy.viewport(800, 250)
})

it('should renders properly', () => {
cy.mountWithProviders(<CanvasToolbar />, {
wrapper: ({ children }: { children: JSX.Element }) => <ReactFlowProvider>{children}</ReactFlowProvider>,
})

cy.getByTestId('canvas-toolbar').should('be.visible')
cy.getByTestId('workspace-search').should('be.visible')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { FC } from 'react'
import { HStack } from '@chakra-ui/react'

import Panel from '@/components/react-flow/Panel.tsx'
import SearchEntities from '@/modules/Workspace/components/filters/SearchEntities.tsx'

const CanvasToolbar: FC = () => {
return (
<Panel position="top-left">
<HStack m={2} data-testid="canvas-toolbar">
<SearchEntities />
</HStack>
</Panel>
)
}

export default CanvasToolbar
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts'
import type { DeviceMetadata } from '@/modules/Workspace/types.ts'
import { NodeTypes } from '@/modules/Workspace/types.ts'
import { WorkspaceNavigationCommand } from '@/modules/Workspace/types.ts'
import { addSelectedNodesState } from '@/modules/Workspace/utils/react-flow.utils.ts'
import { addSelectedNodesState, CONFIG_FITVIEW_OPTION } from '@/modules/Workspace/utils/react-flow.utils.ts'

const SelectionListener = () => {
const { state, pathname } = useLocation()
Expand All @@ -22,7 +22,7 @@ const SelectionListener = () => {

const focusOnNodes = (nodesIds: string[]) => {
addSelectedNodes(nodesIds)
fitView({ nodes: nodesIds.map((e) => ({ id: e })), duration: 750 })
fitView({ nodes: nodesIds.map((e) => ({ id: e })), ...CONFIG_FITVIEW_OPTION })
navigate(pathname, { state: null, replace: true })
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Node } from '@xyflow/react'
import { MOCK_NODE_ADAPTER } from '@/__test-utils__/react-flow/nodes.ts'
import { mockReactFlow } from '@/__test-utils__/react-flow/providers.tsx'
import { ReactFlowTesting } from '@/__test-utils__/react-flow/ReactFlowTesting.tsx'
import SearchEntities from '@/modules/Workspace/components/filters/SearchEntities.tsx'
import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts'
import { useMemo } from 'react'

const getWrapperWith = (initialNodes?: Node[]) => {
const Wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => {
const { nodes } = useWorkspaceStore()
const selected = useMemo(() => {
return nodes.filter((node) => node.selected)
}, [nodes])

return (
<ReactFlowTesting
config={{
initialState: {
nodes: initialNodes,
},
}}
showDashboard={true}
dashboard={
<>
<div data-testid="data-length">{nodes.length}</div>
<div data-testid="data-selected">{selected.length}</div>
</>
}
>
{children}
</ReactFlowTesting>
)
}

return Wrapper
}

describe('SearchEntities', () => {
beforeEach(() => {
cy.viewport(600, 400)
})

it('should render properly', () => {
cy.mountWithProviders(mockReactFlow(<SearchEntities />))

cy.get('[role="group"] > label').should('not.be.visible').should('have.text', 'Search for')
cy.getByTestId('workspace-search').should('have.attr', 'placeholder', 'Search for ...')
cy.getByTestId('workspace-search-clear').should('not.exist')
cy.get('[role="group"] > [role="group"] ').within(() => {
cy.getByTestId('workspace-search-prev').should('have.attr', 'aria-label', 'Previous entity').should('be.disabled')
cy.getByTestId('workspace-search-next').should('have.attr', 'aria-label', 'Next entity').should('be.disabled')
cy.getByTestId('workspace-search-counter').should('have.text', '0 of 0')
})
})

it.only('should search', () => {
const onchange = cy.stub().as('onchange')
const onNavigate = cy.stub().as('onNavigate')
cy.mountWithProviders(<SearchEntities onChange={onchange} onNavigate={onNavigate} />, {
wrapper: getWrapperWith([
{ ...MOCK_NODE_ADAPTER, position: { x: 0, y: 0 } },
{ ...MOCK_NODE_ADAPTER, id: 'adapter2', position: { x: 0, y: 0 } },
]),
})

cy.get('@onchange').should('not.have.been.called')
cy.get('@onNavigate').should('not.have.been.called')
cy.getByTestId('workspace-search').type('test')
cy.getByTestId('workspace-search').should('have.value', 'test')
cy.getByTestId('workspace-search-clear').should('be.visible').should('have.attr', 'aria-label', 'Clear the search')
cy.getByTestId('workspace-search-clear').click()
cy.getByTestId('workspace-search').should('have.attr', 'placeholder', 'Search for ...')
cy.getByTestId('workspace-search').should('have.value', '')
cy.get('@onchange').should('have.been.calledWith', [])

cy.getByTestId('workspace-search').type('adapt')
cy.getByTestId('workspace-search-prev').should('not.be.disabled')
cy.getByTestId('workspace-search-next').should('not.be.disabled')

cy.get('@onchange').should('have.been.calledWith', ['idAdapter', 'adapter2'])

cy.getByTestId('workspace-search-next').click()
cy.get('@onNavigate').should('have.been.calledWith', 'adapter2')
cy.getByTestId('workspace-search-next').click()
cy.get('@onNavigate').should('have.been.calledWith', 'idAdapter')
cy.getByTestId('workspace-search-prev').click()
cy.get('@onNavigate').should('have.been.calledWith', 'adapter2')
})

it('should be accessible', () => {
cy.injectAxe()
cy.mountWithProviders(mockReactFlow(<SearchEntities />))

cy.checkAccessibility()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeSelectionChange } from '@xyflow/react'
import { useReactFlow } from '@xyflow/react'
import {
ButtonGroup,
FormControl,
FormLabel,
Icon,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Text,
} from '@chakra-ui/react'
import { SearchIcon } from '@chakra-ui/icons'
import { MdArrowBack, MdArrowForward, MdClear } from 'react-icons/md'

import IconButton from '@/components/Chakra/IconButton.tsx'
import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts'
import { CONFIG_FITVIEW_OPTION } from '@/modules/Workspace/utils/react-flow.utils.ts'

interface SearchEntitiesProps {
onChange?: (values: string[]) => void
onNavigate?: (current: string) => void
}

const SearchEntities: FC<SearchEntitiesProps> = ({ onChange, onNavigate }) => {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [current, setCurrent] = useState<number | null>(null)
const { fitView } = useReactFlow()
const { nodes, onNodesChange } = useWorkspaceStore()

const selectedNodes = useMemo(() => {
return nodes.filter((e) => e.selected === true).map((e) => e.id)
}, [nodes])

const handleNavigate = (direction: 'next' | 'prev') => {
if (current === null) return
let newIndex = direction === 'next' ? current + 1 : current - 1
if (newIndex < 0) newIndex = selectedNodes.length - 1
if (newIndex >= selectedNodes.length) newIndex = 0
setCurrent(newIndex)
fitView({ nodes: [{ id: selectedNodes[newIndex] }], ...CONFIG_FITVIEW_OPTION })
onNavigate?.(selectedNodes[newIndex])
}

const handleClear = (clearValue = false) => {
onNodesChange(
nodes.map((node) => {
const select: NodeSelectionChange = {
id: node.id,
type: 'select',
selected: false,
}
return select
})
)
fitView({ ...CONFIG_FITVIEW_OPTION })
setCurrent(null)
if (clearValue) setSearch('')
onChange?.([])
}

const handleChange = (value: string) => {
if (!value) {
handleClear(true)
return
}
const foundNodes = nodes.filter((node) => {
return new RegExp(value, 'i').test(node.id) && node.hidden !== true
})
const ids = foundNodes.map((node) => node.id)
setSearch(value)

if (foundNodes.length === 0) {
handleClear()
return
}

// change the selection status of selected node
onNodesChange(
nodes.map((node) => {
const select: NodeSelectionChange = {
id: node.id,
type: 'select',
selected: ids.includes(node.id),
}
return select
})
)

setCurrent(0)
fitView({ nodes: foundNodes, ...CONFIG_FITVIEW_OPTION })
onChange?.(ids)
}

const hasSearchStarted = current !== null && search !== ''

return (
<FormControl variant="horizontal">
<FormLabel fontSize="sm" htmlFor="workspace-search" whiteSpace="nowrap" hidden>
{t('workspace.searchToolbox.search.label')}
</FormLabel>
<InputGroup size="sm">
<InputLeftElement>
<Icon as={SearchIcon} boxSize="3" />
</InputLeftElement>
<Input
data-testid="workspace-search"
placeholder={t('workspace.searchToolbox.search.placeholder')}
size="sm"
id="workspace-search"
value={search}
onChange={(e) => handleChange(e.target.value)}
/>
{search && (
<InputRightElement>
<IconButton
size="sm"
variant="ghost"
data-testid="workspace-search-clear"
aria-label={t('workspace.searchToolbox.search.clear')}
icon={<MdClear />}
onClick={() => {
handleClear(true)
}}
/>
</InputRightElement>
)}
</InputGroup>
<ButtonGroup size="sm" isAttached isDisabled={!hasSearchStarted}>
<IconButton
data-testid="workspace-search-prev"
icon={<MdArrowBack />}
aria-label={t('workspace.searchToolbox.search.previous')}
onClick={() => handleNavigate('prev')}
/>
<Text alignContent="center" marginX={2} data-testid="workspace-search-counter" userSelect="none">
{t('workspace.searchToolbox.search.matches', {
index: hasSearchStarted ? current + 1 : 0,
count: hasSearchStarted ? selectedNodes.length : 0,
})}
</Text>

<IconButton
data-testid="workspace-search-next"
icon={<MdArrowForward />}
aria-label={t('workspace.searchToolbox.search.next')}
onClick={() => handleNavigate('next')}
/>
</ButtonGroup>
</FormControl>
)
}

export default SearchEntities
Loading
Loading