diff --git a/hivemq-edge/src/frontend/src/components/ConnectionStatusBadge/ConnectionStatusBadge.tsx b/hivemq-edge/src/frontend/src/components/ConnectionStatusBadge/ConnectionStatusBadge.tsx index 7611f1534..cb4d0a427 100644 --- a/hivemq-edge/src/frontend/src/components/ConnectionStatusBadge/ConnectionStatusBadge.tsx +++ b/hivemq-edge/src/frontend/src/components/ConnectionStatusBadge/ConnectionStatusBadge.tsx @@ -1,23 +1,16 @@ import { FC } from 'react' import { useTranslation } from 'react-i18next' -import { Badge } from '@chakra-ui/react' +import { Badge, SkeletonCircle } from '@chakra-ui/react' import { Status } from '@/api/__generated__' - -const statusMapping = { - [Status.runtime.STOPPED]: { text: 'STOPPED', color: 'status.error' }, - [Status.connection.ERROR]: { text: 'ERROR', color: 'status.error' }, - [Status.connection.UNKNOWN]: { text: 'UNKNOWN', color: 'status.error' }, - [Status.connection.CONNECTED]: { text: 'CONNECTED', color: 'status.connected' }, - [Status.connection.DISCONNECTED]: { text: 'DISCONNECTED', color: 'status.disconnected' }, - [Status.connection.STATELESS]: { text: 'STATELESS', color: 'status.stateless' }, -} +import { statusMapping } from '@/modules/Workspace/utils/adapter.utils.ts' interface ConnectionStatusBadgeProps { status?: Status + skeleton?: boolean } -const ConnectionStatusBadge: FC = ({ status }) => { +const ConnectionStatusBadge: FC = ({ status, skeleton = false }) => { const { t } = useTranslation() const mapping = @@ -27,6 +20,16 @@ const ConnectionStatusBadge: FC = ({ status }) => { : status?.connection || Status.connection.UNKNOWN ] + if (skeleton) + return ( + + ) + return ( {t('hivemq.connection.status', { context: mapping.text })} diff --git a/hivemq-edge/src/frontend/src/components/react-flow/ToolbarButtonGroup.spec.cy.tsx b/hivemq-edge/src/frontend/src/components/react-flow/ToolbarButtonGroup.spec.cy.tsx new file mode 100644 index 000000000..d1c62e0ec --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/react-flow/ToolbarButtonGroup.spec.cy.tsx @@ -0,0 +1,59 @@ +import { ReactFlowProvider } from 'reactflow' +import { Icon } from '@chakra-ui/react' +import { LuAlbum, LuBaby } from 'react-icons/lu' + +import IconButton from '@/components/Chakra/IconButton.tsx' +import ToolbarButtonGroup from '@/components/react-flow/ToolbarButtonGroup.tsx' + +describe('ToolbarButtonGroup', () => { + beforeEach(() => { + cy.viewport(800, 250) + }) + + it('should renders properly', () => { + cy.mountWithProviders( + + } aria-label="first button" onClick={cy.stub().as('button1')} /> + } aria-label="second button" onClick={cy.stub().as('button2')} /> + , + { + wrapper: ({ children }) => {children}, + } + ) + + cy.get('[role="group"]').should('have.attr', 'data-orientation', 'vertical') + cy.getByAriaLabel('second button').should('be.visible') + cy.getByAriaLabel('first button').should('be.visible') + }) + + it('should renders props', () => { + cy.mountWithProviders( + + } aria-label="first button" onClick={cy.stub().as('button1')} /> + } aria-label="second button" onClick={cy.stub().as('button2')} /> + , + { + wrapper: ({ children }) => {children}, + } + ) + + cy.get('[role="group"]').should('have.attr', 'data-orientation', 'horizontal') + cy.getByAriaLabel('second button').should('be.visible') + cy.getByAriaLabel('first button').should('be.visible') + }) + + it('should be accessible', () => { + cy.injectAxe() + cy.mountWithProviders( + + } aria-label="first button" /> + } aria-label="second button" /> + , + { + wrapper: ({ children }) => {children}, + } + ) + cy.checkAccessibility() + cy.percySnapshot('The login page on loading') + }) +}) diff --git a/hivemq-edge/src/frontend/src/components/react-flow/ToolbarButtonGroup.tsx b/hivemq-edge/src/frontend/src/components/react-flow/ToolbarButtonGroup.tsx new file mode 100644 index 000000000..54f07cebf --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/react-flow/ToolbarButtonGroup.tsx @@ -0,0 +1,34 @@ +import { FC, useMemo } from 'react' +import { useStore } from 'reactflow' +import { ButtonGroup, ButtonGroupProps } from '@chakra-ui/react' + +// TODO[NVL] ChakraUI Theme doesn't support ButtonGroup +const ToolbarButtonGroup: FC = ({ children, ...rest }) => { + const zoomFactor = useStore((s) => s.transform[2]) + + const getToolbarSize = useMemo(() => { + if (zoomFactor >= 1.5) return 'lg' + if (zoomFactor >= 1) return 'md' + if (zoomFactor >= 0.75) return 'sm' + return 'xs' + }, [zoomFactor]) + + return ( + + {children} + + ) +} + +export default ToolbarButtonGroup diff --git a/hivemq-edge/src/frontend/src/extensions/datahub/components/nodes/NodeDatahubToolbar.tsx b/hivemq-edge/src/frontend/src/extensions/datahub/components/nodes/NodeDatahubToolbar.tsx index e66c4b4b6..e75f90572 100644 --- a/hivemq-edge/src/frontend/src/extensions/datahub/components/nodes/NodeDatahubToolbar.tsx +++ b/hivemq-edge/src/frontend/src/extensions/datahub/components/nodes/NodeDatahubToolbar.tsx @@ -1,10 +1,10 @@ -import { FC, useMemo } from 'react' +import { FC } from 'react' import { useTranslation } from 'react-i18next' -import { ButtonGroup, ButtonGroupProps } from '@chakra-ui/react' +import { ButtonGroupProps } from '@chakra-ui/react' import { LuCopy, LuDelete, LuFileEdit } from 'react-icons/lu' import IconButton from '@/components/Chakra/IconButton.tsx' -import { useStore } from 'reactflow' +import ToolbarButtonGroup from '@/components/react-flow/ToolbarButtonGroup.tsx' interface NodeToolbarProps extends ButtonGroupProps { onCopy?: (event: React.BaseSyntheticEvent) => void @@ -14,17 +14,9 @@ interface NodeToolbarProps extends ButtonGroupProps { const NodeDatahubToolbar: FC = (props) => { const { t } = useTranslation('datahub') - const zoomFactor = useStore((s) => s.transform[2]) - - const getToolbarSize = useMemo(() => { - if (zoomFactor >= 1.5) return 'lg' - if (zoomFactor >= 1) return 'md' - if (zoomFactor >= 0.75) return 'sm' - return 'xs' - }, [zoomFactor]) return ( - + } data-testid="node-toolbar-edit" @@ -44,7 +36,7 @@ const NodeDatahubToolbar: FC = (props) => { colorScheme="red" onClick={props.onDelete} /> - + ) } diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx index 37e002d6d..7d55ab284 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo } from 'react' +import { type MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import ReactFlow, { Background } from 'reactflow' +import ReactFlow, { Background, getIncomers, getOutgoers, MiniMap, Node, NodePositionChange } from 'reactflow' import { Box } from '@chakra-ui/react' import 'reactflow/dist/style.css' @@ -24,6 +24,8 @@ import { NodeDevice, NodeClient, } from '@/modules/Workspace/components/nodes' +import { gluedNodeDefinition } from '@/modules/Workspace/utils/nodes-utils.ts' +import { proOptions } from '@/modules/Workspace/utils/react-flow.utils.ts' const ReactFlowWrapper = () => { const { t } = useTranslation() @@ -55,6 +57,36 @@ const ReactFlowWrapper = () => { [] ) + /** + * Bug with the SHIFT+select + * @see https://github.com/xyflow/xyflow/issues/4441 + */ + const onReactFlowNodeDrag = useCallback( + (_event: ReactMouseEvent, _node: Node, draggedNodes: Node[]) => { + const gluedDraggedNodes = draggedNodes.filter((node) => + Object.keys(gluedNodeDefinition).includes(node.type as NodeTypes) + ) + for (const movedNode of gluedDraggedNodes) { + const [type, spacing, handle] = gluedNodeDefinition[movedNode.type as NodeTypes] + if (!type) continue + + const outgoers = + handle === 'target' ? getOutgoers(movedNode, nodes, edges) : getIncomers(movedNode, nodes, edges) + const gluedNode = outgoers.find((node) => node.type === type) + if (!gluedNode) continue + + const positionChange: NodePositionChange = { + id: gluedNode.id, + type: 'position', + position: { x: movedNode.position.x, y: movedNode.position.y + spacing }, + } + + onNodesChange([positionChange]) + } + }, + [edges, nodes, onNodesChange] + ) + return ( { edgeTypes={edgeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + onNodeDrag={onReactFlowNodeDrag} fitView snapToGrid={true} nodesConnectable={false} deleteKeyCode={null} + proOptions={proOptions} > + node.type || ''} + nodeComponent={(miniMapNode) => { + if (miniMapNode.className === NodeTypes.EDGE_NODE) + return + if (miniMapNode.className === NodeTypes.DEVICE_NODE || miniMapNode.className === NodeTypes.HOST_NODE) + return null + return ( + + ) + }} + /> diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/CanvasControls.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/CanvasControls.tsx index e12e78d07..9a7dc2f85 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/CanvasControls.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/CanvasControls.tsx @@ -1,6 +1,6 @@ import { FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { ControlProps, Panel, ReactFlowState, useReactFlow, useStore, useStoreApi } from 'reactflow' +import { ControlProps, Panel, useReactFlow, useStore, useStoreApi } from 'reactflow' import { ButtonGroup } from '@chakra-ui/react' import { shallow } from 'zustand/shallow' import { IoMdOptions } from 'react-icons/io' @@ -9,16 +9,12 @@ import { FaLock, FaLockOpen, FaMinus, FaPlus } from 'react-icons/fa6' import { useEdgeFlowContext } from '../../hooks/useEdgeFlowContext.ts' import IconButton from '@/components/Chakra/IconButton.tsx' -import { CONFIG_ZOOM_MAX, CONFIG_ZOOM_MIN } from '@/modules/Workspace/utils/react-flow.utils.ts' - -const selector = (s: ReactFlowState) => ({ - isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable, -}) - -const selectorSetZoomMinMax = (state: ReactFlowState) => ({ - setMinZoom: state.setMinZoom, - setMaxZoom: state.setMaxZoom, -}) +import { + CONFIG_ZOOM_MAX, + CONFIG_ZOOM_MIN, + selectorIsInteractive, + selectorSetZoomMinMax, +} from '@/modules/Workspace/utils/react-flow.utils.ts' const CanvasControls: FC = ({ onInteractiveChange }) => { const { t } = useTranslation() @@ -26,7 +22,7 @@ const CanvasControls: FC = ({ onInteractiveChange }) => { const store = useStoreApi() const { setMinZoom, setMaxZoom } = useStore(selectorSetZoomMinMax) const { zoomIn, zoomOut, fitView } = useReactFlow() - const { isInteractive } = useStore(selector, shallow) + const { isInteractive } = useStore(selectorIsInteractive, shallow) useEffect(() => { setMinZoom(CONFIG_ZOOM_MIN) diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/ContextualToolbar.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/ContextualToolbar.tsx index 1832cf2bb..67f6ad08c 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/ContextualToolbar.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/ContextualToolbar.tsx @@ -1,5 +1,14 @@ import { type FC, MouseEventHandler, useMemo } from 'react' -import { NodeToolbar, type NodeProps, type NodeToolbarProps, Position, Node, Edge, MarkerType } from 'reactflow' +import { + NodeToolbar, + type NodeProps, + type NodeToolbarProps, + Position, + Node, + Edge, + MarkerType, + getOutgoers, +} from 'reactflow' import { useTranslation } from 'react-i18next' import { useTheme } from '@chakra-ui/react' import { LuPanelRightOpen } from 'react-icons/lu' @@ -8,20 +17,21 @@ import { ImMakeGroup } from 'react-icons/im' import { Adapter, Status } from '@/api/__generated__' import { EdgeTypes, Group, IdStubs, NodeTypes } from '@/modules/Workspace/types.ts' import IconButton from '@/components/Chakra/IconButton.tsx' +import ToolbarButtonGroup from '@/components/react-flow/ToolbarButtonGroup.tsx' import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts' import { getGroupLayout } from '@/modules/Workspace/utils/group.utils.ts' import { getThemeForStatus } from '@/modules/Workspace/utils/status-utils.ts' -import WorkspaceButtonGroup from '@/modules/Workspace/components/parts/WorkspaceButtonGroup.tsx' +import { gluedNodeDefinition } from '@/modules/Workspace/utils/nodes-utils.ts' -type SelectedNodeProps = Pick & Pick +type SelectedNodeProps = Pick & Pick interface ContextualToolbarProps extends SelectedNodeProps { onOpenPanel?: MouseEventHandler | undefined children?: React.ReactNode } -const ContextualToolbar: FC = ({ id, onOpenPanel, children }) => { +const ContextualToolbar: FC = ({ id, onOpenPanel, children, dragging }) => { const { t } = useTranslation() - const { onInsertGroupNode, nodes } = useWorkspaceStore() + const { onInsertGroupNode, nodes, edges } = useWorkspaceStore() const theme = useTheme() const selectedNodes = nodes.filter((node) => node.selected) @@ -30,8 +40,19 @@ const ContextualToolbar: FC = ({ id, onOpenPanel, childr const adapters = selectedNodes.filter( (node) => node.type === NodeTypes.ADAPTER_NODE && !node.parentId && !node.parentNode ) - return adapters.length >= 2 ? adapters : undefined - }, [selectedNodes]) + + // Add devices to the group + const devices = adapters.reduce((acc, curr) => { + const [type] = gluedNodeDefinition[curr.type as NodeTypes] + if (!type) return acc + const outgoers = getOutgoers(curr, nodes, edges) + const gluedNode = outgoers.find((node) => node.type === type) + if (gluedNode) acc.push(gluedNode) + return acc + }, []) + + return adapters.length >= 2 ? [...adapters, ...devices] : undefined + }, [edges, nodes, selectedNodes]) const onCreateGroup = () => { if (!selectedGroupCandidates) return @@ -100,12 +121,12 @@ const ContextualToolbar: FC = ({ id, onOpenPanel, childr return ( <> - + = ({ id, onOpenPanel, childr aria-label={t('workspace.toolbar.command.overview')} onClick={onOpenPanel} /> - + {(children || isGroupable) && ( = ({ id, onOpenPanel, childr > {children} {isGroupable && ( - + } aria-label={t('workspace.toolbar.command.group')} onClick={onCreateGroup} /> - + )} )} diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.tsx index f3fd0789f..de564faa9 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.tsx @@ -1,13 +1,14 @@ import { FC, useMemo } from 'react' -import { Handle, NodeProps, Position } from 'reactflow' +import { Handle, NodeProps, Position, useStore } from 'reactflow' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' -import { Box, HStack, Icon, Image, Text, VStack } from '@chakra-ui/react' +import { Box, HStack, Icon, Image, SkeletonText, Text, VStack } from '@chakra-ui/react' import { type Adapter } from '@/api/__generated__' import { useGetAdapterTypes } from '@/api/hooks/useProtocolAdapters/useGetAdapterTypes.ts' import IconButton from '@/components/Chakra/IconButton.tsx' import { ConnectionStatusBadge } from '@/components/ConnectionStatusBadge' +import ToolbarButtonGroup from '@/components/react-flow/ToolbarButtonGroup.tsx' import { type TopicFilter } from '@/modules/Workspace/types.ts' import { useEdgeFlowContext } from '@/modules/Workspace/hooks/useEdgeFlowContext.ts' import { discoverAdapterTopics } from '@/modules/Workspace/utils/topics-utils.ts' @@ -17,9 +18,9 @@ import ContextualToolbar from '@/modules/Workspace/components/nodes/ContextualTo import NodeWrapper from '@/modules/Workspace/components/parts/NodeWrapper.tsx' import TopicsContainer from '@/modules/Workspace/components/parts/TopicsContainer.tsx' import { CONFIG_ADAPTER_WIDTH } from '@/modules/Workspace/utils/nodes-utils.ts' -import WorkspaceButtonGroup from '@/modules/Workspace/components/parts/WorkspaceButtonGroup.tsx' +import { selectorIsSkeletonZoom } from '@/modules/Workspace/utils/react-flow.utils.ts' -const NodeAdapter: FC> = ({ id, data: adapter, selected }) => { +const NodeAdapter: FC> = ({ id, data: adapter, selected, dragging }) => { const { t } = useTranslation() const { data: protocols } = useGetAdapterTypes() const adapterProtocol = protocols?.items?.find((e) => e.id === adapter.type) @@ -31,14 +32,15 @@ const NodeAdapter: FC> = ({ id, data: adapter, selected }) => }, [adapter.config, adapterProtocol]) const { onContextMenu } = useContextMenu(id, selected, `/workspace/node/adapter/${adapter.type}`) const navigate = useNavigate() + const showSkeleton = useStore(selectorIsSkeletonZoom) const HACK_BIDIRECTIONAL = isBidirectional(adapterProtocol) const adapterNavPath = `/workspace/node/adapter/${adapter.type}/${id}` return ( <> - - + + {HACK_BIDIRECTIONAL && ( } @@ -51,7 +53,7 @@ const NodeAdapter: FC> = ({ id, data: adapter, selected }) => aria-label={t('workspace.toolbar.command.subscriptions.inward')} onClick={() => navigate(`${adapterNavPath}/inward`)} /> - + > = ({ id, data: adapter, selected }) => w={CONFIG_ADAPTER_WIDTH} p={2} > - - - - - {adapter.id} - - - {options.showStatus && ( - - + {!showSkeleton && ( + + {HACK_BIDIRECTIONAL && } + + + + + {adapter.id} + + + {options.showStatus && ( + + + + )} + {options.showTopics && } + + )} + {showSkeleton && ( + + + - )} - {options.showTopics && } - + + + + + )} {HACK_BIDIRECTIONAL && } diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeBridge.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeBridge.tsx index 3dbaeff94..572cbd1ea 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeBridge.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeBridge.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' -import { Handle, NodeProps, Position } from 'reactflow' -import { Box, HStack, Image, Text, VStack } from '@chakra-ui/react' +import { Handle, NodeProps, Position, useStore } from 'reactflow' +import { Box, HStack, Image, SkeletonText, Text, VStack } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { Bridge } from '@/api/__generated__' @@ -13,33 +13,60 @@ import { getBridgeTopics } from '../../utils/topics-utils.ts' import { useEdgeFlowContext } from '../../hooks/useEdgeFlowContext.ts' import { useContextMenu } from '../../hooks/useContextMenu.ts' import ContextualToolbar from '@/modules/Workspace/components/nodes/ContextualToolbar.tsx' +import { CONFIG_ADAPTER_WIDTH } from '@/modules/Workspace/utils/nodes-utils.ts' +import { selectorIsSkeletonZoom } from '@/modules/Workspace/utils/react-flow.utils.ts' -const NodeBridge: FC> = ({ id, selected, data: bridge }) => { +const NodeBridge: FC> = ({ id, selected, data: bridge, dragging }) => { const { t } = useTranslation() const topics = getBridgeTopics(bridge) const { options } = useEdgeFlowContext() const { onContextMenu } = useContextMenu(id, selected, '/workspace/node/bridge') + const showSkeleton = useStore(selectorIsSkeletonZoom) return ( <> - - - - {options.showTopics && } - - - {t('workspace.node.bridge')} - - {bridge.id} - - - {options.showStatus && ( - - + + + {!showSkeleton && ( + + {options.showTopics && } + + {t('workspace.node.bridge')} + + {bridge.id} + + + {options.showStatus && ( + + + + )} + {options.showTopics && } + + )} + {showSkeleton && ( + + + - )} - {options.showTopics && } - + + + + + )} diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.tsx index 0fc93e736..eebc27f13 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.tsx @@ -1,40 +1,61 @@ import { FC } from 'react' -import { Handle, Position, NodeProps } from 'reactflow' +import { Handle, Position, NodeProps, useStore } from 'reactflow' import { HStack, Icon, Text, VStack } from '@chakra-ui/react' import { DeviceMetadata } from '@/modules/Workspace/types.ts' import NodeWrapper from '@/modules/Workspace/components/parts/NodeWrapper.tsx' -import { deviceCapabilityIcon, deviceCategoryIcon } from '@/modules/Workspace/utils/adapter.utils.ts' +import { + deviceCapabilityIcon, + deviceCategoryIcon, + ProtocolAdapterCategoryName, +} from '@/modules/Workspace/utils/adapter.utils.ts' import { useContextMenu } from '@/modules/Workspace/hooks/useContextMenu.ts' import ContextualToolbar from '@/modules/Workspace/components/nodes/ContextualToolbar.tsx' +import { CONFIG_ADAPTER_WIDTH } from '@/modules/Workspace/utils/nodes-utils.ts' +import { selectorIsSkeletonZoom } from '@/modules/Workspace/utils/react-flow.utils.ts' -const NodeDevice: FC> = ({ id, selected, data }) => { +const NodeDevice: FC> = ({ id, selected, data, dragging }) => { const { onContextMenu } = useContextMenu(id, selected, '/workspace/node') const { category, capabilities } = data + const showSkeleton = useStore(selectorIsSkeletonZoom) return ( <> - + - - {capabilities?.map((capability) => ( - - ))} - - - - {data.protocol} - + {!showSkeleton && ( + <> + + {capabilities?.map((capability) => ( + + ))} + + + + {data.protocol} + + + )} + {showSkeleton && ( + + )} diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeEdge.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeEdge.tsx index a9d925feb..bcd19cfcf 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeEdge.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeEdge.tsx @@ -14,7 +14,7 @@ const NodeEdge: FC = (props) => { return ( <> - + > = ({ id, data, selected, ...props }) => { const { t } = useTranslation() @@ -65,8 +65,8 @@ const NodeGroup: FC> = ({ id, data, selected, ...props }) => { return ( <> - - + + } @@ -81,7 +81,7 @@ const NodeGroup: FC> = ({ id, data, selected, ...props }) => { aria-label={t('workspace.grouping.command.ungroup')} onClick={onConfirmUngroup} /> - + {selected && ( = ({ selected, data }) => { const { label } = data return ( <> - - {label} + + {label} diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/parts/WorkspaceButtonGroup.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/parts/WorkspaceButtonGroup.tsx deleted file mode 100644 index b92cf8d4f..000000000 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/parts/WorkspaceButtonGroup.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FC } from 'react' -import { ButtonGroup, ButtonGroupProps } from '@chakra-ui/react' - -// TODO[NVL] ChakraUI Theme doesn't support ButtonGroup -const WorkspaceButtonGroup: FC = ({ children, ...rest }) => { - return ( - - {children} - - ) -} - -export default WorkspaceButtonGroup diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.ts index e5d510b2c..f51006ed1 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.ts @@ -1,10 +1,10 @@ -import { type ProtocolAdapter } from '@/api/__generated__' +import { type ProtocolAdapter, Status } from '@/api/__generated__' import { type IconType } from 'react-icons' import { TbSettingsAutomation } from 'react-icons/tb' import { FaIndustry } from 'react-icons/fa6' import { GrConnectivity } from 'react-icons/gr' import { AiFillExperiment } from 'react-icons/ai' -import { MdOutlineFindInPage } from 'react-icons/md' +import { RiCompassDiscoverLine } from 'react-icons/ri' import { HmInput, HmOutput } from '@/components/react-icons/hm' /** @@ -15,6 +15,17 @@ export const isBidirectional = (adapter: ProtocolAdapter | undefined) => { return Boolean(adapter?.id?.includes('opc-ua-client')) } +/** + * @deprecated This is a mock, should be in the OpenAPI spec, https://hivemq.kanbanize.com/ctrl_board/57/cards/25259/details/ + * @see ProtocolAdapterCategory + */ +export enum ProtocolAdapterCategoryName { + BUILDING_AUTOMATION = 'BUILDING_AUTOMATION', + INDUSTRIAL = 'INDUSTRIAL', + CONNECTIVITY = 'CONNECTIVITY', + SIMULATION = 'SIMULATION', +} + /** * @deprecated This is a mock, mapping should be based on ProtocolAdapterCategory and image property * @see ProtocolAdapterCategory @@ -35,6 +46,15 @@ type CapabilityType = ArrayElement | 'WRITE' */ export const deviceCapabilityIcon: Record = { ['READ']: HmOutput, - ['DISCOVER']: MdOutlineFindInPage, + ['DISCOVER']: RiCompassDiscoverLine, ['WRITE']: HmInput, } + +export const statusMapping = { + [Status.runtime.STOPPED]: { text: 'STOPPED', color: 'status.error' }, + [Status.connection.ERROR]: { text: 'ERROR', color: 'status.error' }, + [Status.connection.UNKNOWN]: { text: 'UNKNOWN', color: 'status.error' }, + [Status.connection.CONNECTED]: { text: 'CONNECTED', color: 'status.connected' }, + [Status.connection.DISCONNECTED]: { text: 'DISCONNECTED', color: 'status.disconnected' }, + [Status.connection.STATELESS]: { text: 'STATELESS', color: 'status.stateless' }, +} diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.spec.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.spec.ts index 3e5a666c8..e4bc42eff 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.spec.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.spec.ts @@ -97,16 +97,16 @@ describe('createBridgeNode', () => { nodeBridge: expect.objectContaining({ id: 'bridge@bridge-id-01', position: { - x: 426.5, - y: 500, + x: 462.5, + y: 600, }, }), nodeHost: expect.objectContaining({ id: 'host@bridge-id-01', position: { - x: 426.5, - y: 750, + x: 462.5, + y: 850, }, }), hostConnector: expect.objectContaining({}), @@ -144,7 +144,7 @@ describe('createListenerNode', () => { nodeListener: expect.objectContaining({ id: 'listener@tcp-listener-1883', position: { - x: 47, + x: -25, y: 280, }, }), @@ -187,8 +187,8 @@ describe('createAdapterNode', () => { nodeAdapter: expect.objectContaining({ id: `adapter@${MOCK_ADAPTER_ID}`, position: { - x: 553, - y: 0, + x: 625, + y: -66.66666666666669, }, }), edgeConnector: expect.objectContaining({}), @@ -229,8 +229,8 @@ describe('createClientNode', () => { }, id: 'client@my-first-client', position: { - x: 426.5, - y: 500, + x: 462.5, + y: 600, }, sourcePosition: 'bottom', type: NodeTypes.CLIENT_NODE, @@ -257,8 +257,8 @@ describe('createClientNode', () => { nodeClient: expect.objectContaining({ id: 'client@my-first-client', position: { - x: 426.5, - y: 500, + x: 462.5, + y: 600, }, }), clientConnector: expect.objectContaining({}), @@ -287,8 +287,8 @@ describe('createClientNode', () => { }, id: 'client@my-first-client', position: { - x: 426.5, - y: 500, + x: 462.5, + y: 600, }, sourcePosition: 'bottom', type: NodeTypes.CLIENT_NODE, @@ -315,8 +315,8 @@ describe('createClientNode', () => { nodeClient: expect.objectContaining({ id: 'client@my-first-client', position: { - x: 426.5, - y: 500, + x: 462.5, + y: 600, }, }), clientConnector: expect.objectContaining({}), diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.ts index ab13e2f37..f5c90b006 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/nodes-utils.ts @@ -13,11 +13,18 @@ import { BrokerClient, BrokerClientConfiguration } from '@/api/types/api-broker- export const CONFIG_ADAPTER_WIDTH = 245 -const POS_SEPARATOR = 8 +const POS_SEPARATOR = 80 const POS_EDGE: XYPosition = { x: 300, y: 200 } -const POS_NODE_INC: XYPosition = { x: 245 + POS_SEPARATOR, y: 300 } +const POS_NODE_INC: XYPosition = { x: CONFIG_ADAPTER_WIDTH + POS_SEPARATOR, y: 400 } const MAX_ADAPTERS = 10 +export const gluedNodeDefinition: Record = { + [NodeTypes.BRIDGE_NODE]: [NodeTypes.HOST_NODE, 200, 'target'], + [NodeTypes.ADAPTER_NODE]: [NodeTypes.DEVICE_NODE, -125, 'target'], + [NodeTypes.HOST_NODE]: [NodeTypes.BRIDGE_NODE, -200, 'source'], + [NodeTypes.DEVICE_NODE]: [NodeTypes.ADAPTER_NODE, 125, 'source'], +} + export const createEdgeNode = (label: string, positionStorage?: Record) => { const nodeEdge: Node = { id: IdStubs.EDGE_NODE, @@ -68,7 +75,7 @@ export const createBridgeNode = ( }, animated: isConnected && !!remote.length, style: { - strokeWidth: isConnected ? 1.5 : 0.5, + strokeWidth: 1.5, stroke: getThemeForStatus(theme, bridge.status), }, } @@ -100,7 +107,7 @@ export const createBridgeNode = ( }, animated: isConnected && !!local.length, style: { - strokeWidth: isConnected ? 1.5 : 0.5, + strokeWidth: 1.5, stroke: getThemeForStatus(theme, bridge.status), }, } @@ -187,7 +194,7 @@ export const createAdapterNode = ( }, animated: isConnected && !!topics.length, style: { - strokeWidth: isConnected ? 1.5 : 0.5, + strokeWidth: 1.5, stroke: getThemeForStatus(theme, adapter.status), }, } @@ -204,8 +211,8 @@ export const createAdapterNode = ( targetPosition: Position.Top, data: type, position: positionStorage?.[idBAdapterDevice] ?? { - x: nodeAdapter.position.x + 48, - y: nodeAdapter.position.y - 250, + x: nodeAdapter.position.x, + y: nodeAdapter.position.y + gluedNodeDefinition[NodeTypes.ADAPTER_NODE][1], }, } @@ -223,7 +230,7 @@ export const createAdapterNode = ( }, animated: isConnected && !!topics.length, style: { - strokeWidth: isConnected ? 1.5 : 0.5, + strokeWidth: 1.5, stroke: getThemeForStatus(theme, adapter.status), }, } @@ -267,7 +274,7 @@ export const createClientNode = ( }, animated: isConnected, style: { - strokeWidth: isConnected ? 1.5 : 0.5, + strokeWidth: 1.5, stroke: theme.colors.status.connected[500], }, } diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/react-flow.utils.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/react-flow.utils.ts index a52a40f2d..1b237b7aa 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/react-flow.utils.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/react-flow.utils.ts @@ -1,2 +1,19 @@ -export const CONFIG_ZOOM_MIN = 0.25 +import { ProOptions, ReactFlowState } from 'reactflow' + +export const CONFIG_ZOOM_SKELETON = 0.5 +export const CONFIG_ZOOM_MIN = 0.2 export const CONFIG_ZOOM_MAX = 1.5 + +// TODO[NVL] We should get a PRO license! +export const proOptions: ProOptions = { hideAttribution: true } + +export const selectorIsSkeletonZoom = (state: ReactFlowState) => state.transform[2] <= CONFIG_ZOOM_SKELETON + +export const selectorSetZoomMinMax = (state: ReactFlowState) => ({ + setMinZoom: state.setMinZoom, + setMaxZoom: state.setMaxZoom, +}) + +export const selectorIsInteractive = (s: ReactFlowState) => ({ + isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable, +}) diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.spec.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.spec.ts index 214e2fff4..a0ad4845c 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.spec.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.spec.ts @@ -173,7 +173,7 @@ describe('getEdgeStatus', () => { isConnected: true, } edge.style = { - strokeWidth: 0.5, + strokeWidth: 1.5, stroke: color, } edge.animated = true diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.ts index 7f9ca9cdf..728c16835 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/status-utils.ts @@ -64,7 +64,7 @@ export type EdgeStyle = Pick export const getEdgeStatus = (isConnected: boolean, hasTopics: boolean, themeForStatus: string): EdgeStyle => { const edge: EdgeStyle = {} edge.style = { - strokeWidth: isConnected ? 1.5 : 0.5, + strokeWidth: 1.5, stroke: themeForStatus, } edge.animated = isConnected && hasTopics