diff --git a/.github/workflows/trigger.md b/.github/workflows/trigger.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hivemq-edge-frontend/src/locales/en/translation.json b/hivemq-edge-frontend/src/locales/en/translation.json index df2645d385..91be73fd91 100755 --- a/hivemq-edge-frontend/src/locales/en/translation.json +++ b/hivemq-edge-frontend/src/locales/en/translation.json @@ -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": { diff --git a/hivemq-edge-frontend/src/modules/Pulse/components/assets/ManagedAssetSelect.spec.cy.tsx b/hivemq-edge-frontend/src/modules/Pulse/components/assets/ManagedAssetSelect.spec.cy.tsx index 25bd9f9177..40640eb381 100644 --- a/hivemq-edge-frontend/src/modules/Pulse/components/assets/ManagedAssetSelect.spec.cy.tsx +++ b/hivemq-edge-frontend/src/modules/Pulse/components/assets/ManagedAssetSelect.spec.cy.tsx @@ -56,7 +56,7 @@ describe('ManagedAssetSelect', () => { cy.getByTestId('combiner-asset-selected-value').should('not.exist') }) - it.only('should render mapped assets properly', () => { + it('should render mapped assets properly', () => { cy.mountWithProviders( label': { + flex: 1, + marginBottom: 0, + marginInlineEnd: 0, + }, + '> label + *': { + flex: 2, + }, + }, +}) + export const formControlTheme = defineMultiStyleConfig({ - variants: { hivemq }, + variants: { hivemq, horizontal }, }) diff --git a/hivemq-edge-frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx b/hivemq-edge-frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx index b18d7ec25f..3559639d36 100644 --- a/hivemq-edge-frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx +++ b/hivemq-edge-frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx @@ -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, @@ -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() @@ -115,6 +116,7 @@ const ReactFlowWrapper = () => { aria-label={t('workspace.canvas.aria-label')} > + diff --git a/hivemq-edge-frontend/src/modules/Workspace/components/controls/CanvasToolbar.spec.cy.tsx b/hivemq-edge-frontend/src/modules/Workspace/components/controls/CanvasToolbar.spec.cy.tsx new file mode 100644 index 0000000000..e9e2d499c3 --- /dev/null +++ b/hivemq-edge-frontend/src/modules/Workspace/components/controls/CanvasToolbar.spec.cy.tsx @@ -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(, { + wrapper: ({ children }: { children: JSX.Element }) => {children}, + }) + + cy.getByTestId('canvas-toolbar').should('be.visible') + cy.getByTestId('workspace-search').should('be.visible') + }) +}) diff --git a/hivemq-edge-frontend/src/modules/Workspace/components/controls/CanvasToolbar.tsx b/hivemq-edge-frontend/src/modules/Workspace/components/controls/CanvasToolbar.tsx new file mode 100644 index 0000000000..2e12a2fef0 --- /dev/null +++ b/hivemq-edge-frontend/src/modules/Workspace/components/controls/CanvasToolbar.tsx @@ -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 ( + + + + + + ) +} + +export default CanvasToolbar diff --git a/hivemq-edge-frontend/src/modules/Workspace/components/controls/SelectionListener.tsx b/hivemq-edge-frontend/src/modules/Workspace/components/controls/SelectionListener.tsx index 34498434d0..a8a900c777 100644 --- a/hivemq-edge-frontend/src/modules/Workspace/components/controls/SelectionListener.tsx +++ b/hivemq-edge-frontend/src/modules/Workspace/components/controls/SelectionListener.tsx @@ -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() @@ -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 }) } diff --git a/hivemq-edge-frontend/src/modules/Workspace/components/filters/SearchEntities.spec.cy.tsx b/hivemq-edge-frontend/src/modules/Workspace/components/filters/SearchEntities.spec.cy.tsx new file mode 100644 index 0000000000..22c4528b92 --- /dev/null +++ b/hivemq-edge-frontend/src/modules/Workspace/components/filters/SearchEntities.spec.cy.tsx @@ -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 ( + +
{nodes.length}
+
{selected.length}
+ + } + > + {children} +
+ ) + } + + return Wrapper +} + +describe('SearchEntities', () => { + beforeEach(() => { + cy.viewport(600, 400) + }) + + it('should render properly', () => { + cy.mountWithProviders(mockReactFlow()) + + 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('should search', () => { + const onchange = cy.stub().as('onchange') + const onNavigate = cy.stub().as('onNavigate') + cy.mountWithProviders(, { + 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()) + + cy.checkAccessibility() + }) +}) diff --git a/hivemq-edge-frontend/src/modules/Workspace/components/filters/SearchEntities.tsx b/hivemq-edge-frontend/src/modules/Workspace/components/filters/SearchEntities.tsx new file mode 100644 index 0000000000..2ef490fb58 --- /dev/null +++ b/hivemq-edge-frontend/src/modules/Workspace/components/filters/SearchEntities.tsx @@ -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 = ({ onChange, onNavigate }) => { + const { t } = useTranslation() + const [search, setSearch] = useState('') + const [current, setCurrent] = useState(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 ( + + + + + + + handleChange(e.target.value)} + /> + {search && ( + + } + onClick={() => { + handleClear(true) + }} + /> + + )} + + + } + aria-label={t('workspace.searchToolbox.search.previous')} + onClick={() => handleNavigate('prev')} + /> + + {t('workspace.searchToolbox.search.matches', { + index: hasSearchStarted ? current + 1 : 0, + count: hasSearchStarted ? selectedNodes.length : 0, + })} + + + } + aria-label={t('workspace.searchToolbox.search.next')} + onClick={() => handleNavigate('next')} + /> + + + ) +} + +export default SearchEntities diff --git a/hivemq-edge-frontend/src/modules/Workspace/utils/react-flow.utils.ts b/hivemq-edge-frontend/src/modules/Workspace/utils/react-flow.utils.ts index d3347a02c6..eb366ac8a8 100644 --- a/hivemq-edge-frontend/src/modules/Workspace/utils/react-flow.utils.ts +++ b/hivemq-edge-frontend/src/modules/Workspace/utils/react-flow.utils.ts @@ -1,10 +1,15 @@ /* istanbul ignore file -- @preserve */ -import type { ReactFlowState } from '@xyflow/react' +import type { ReactFlowState, FitViewOptions } from '@xyflow/react' export const CONFIG_ZOOM_SKELETON = 0.5 export const CONFIG_ZOOM_MIN = 0.2 export const CONFIG_ZOOM_MAX = 1.5 +export const CONFIG_FITVIEW_OPTION: FitViewOptions = { + padding: 0.25, + duration: 750, +} + export const selectorIsSkeletonZoom = (state: ReactFlowState) => state.transform[2] <= CONFIG_ZOOM_SKELETON export const selectorSetZoomMinMax = (state: ReactFlowState) => ({