From dd4ce2cc912c782d113ffcf4875887e3313ddfee Mon Sep 17 00:00:00 2001 From: Nicolas Van Labeke Date: Thu, 15 Aug 2024 16:14:09 +0100 Subject: [PATCH] Merge pull request #526 feat(25160): Add devices and brokers to the workspace * feat(25160): add mock for bi-directional * feat(25160): add type for DEVICE node * feat(25160): add custom node for DEVICE * refactor(25160): remove the show host option * refactor(25160): add device-related handle to adapter * feat(25160): add device node and link to adapter to the workspace * test(25160): add tests * test(25160): fix tests * test(25160): add tests * test(25160): fix tests and rename the file * refactor(25160): updated type --- .../src/__test-utils__/react-flow/nodes.ts | 11 ++++- .../useProtocolAdapters/__handlers__/index.ts | 1 + .../frontend/src/locales/en/translation.json | 1 - ...ty.spec.tsx => useGetAdapterInfo.spec.tsx} | 2 +- .../Workspace/components/ReactFlowWrapper.tsx | 2 + .../components/nodes/NodeAdapter.tsx | 3 ++ .../components/nodes/NodeDevice.spec.cy.tsx | 30 ++++++++++++++ .../Workspace/components/nodes/NodeDevice.tsx | 38 ++++++++++++++++++ .../Workspace/components/nodes/index.ts | 3 +- .../Workspace/hooks/FlowContext.spec.tsx | 2 +- .../modules/Workspace/hooks/FlowContext.tsx | 1 - .../hooks/useEdgeFlowContext.spec.tsx | 1 - .../hooks/useGetFlowElements.spec.tsx | 10 ++--- .../Workspace/hooks/useGetFlowElements.ts | 14 ++++--- .../frontend/src/modules/Workspace/types.ts | 3 +- .../Workspace/utils/adapter.utils.spec.ts | 10 +++++ .../modules/Workspace/utils/adapter.utils.ts | 30 ++++++++++++++ .../modules/Workspace/utils/nodes-utils.ts | 40 ++++++++++++++++++- 18 files changed, 181 insertions(+), 21 deletions(-) rename hivemq-edge/src/frontend/src/modules/ProtocolAdapters/hooks/{useGetCapability.spec.tsx => useGetAdapterInfo.spec.tsx} (96%) create mode 100644 hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.spec.cy.tsx create mode 100644 hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.tsx create mode 100644 hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.spec.ts diff --git a/hivemq-edge/src/frontend/src/__test-utils__/react-flow/nodes.ts b/hivemq-edge/src/frontend/src/__test-utils__/react-flow/nodes.ts index 8cc353cec..b6c05b2e8 100644 --- a/hivemq-edge/src/frontend/src/__test-utils__/react-flow/nodes.ts +++ b/hivemq-edge/src/frontend/src/__test-utils__/react-flow/nodes.ts @@ -1,7 +1,7 @@ import { NodeProps, Position } from 'reactflow' -import { mockAdapter } from '@/api/hooks/useProtocolAdapters/__handlers__' +import { mockAdapter, mockProtocolAdapter } from '@/api/hooks/useProtocolAdapters/__handlers__' import { mockBridge } from '@/api/hooks/useGetBridges/__handlers__' -import { Listener } from '@/api/__generated__' +import { Listener, ProtocolAdapter } from '@/api/__generated__' import { mockMqttListener } from '@/api/hooks/useGateway/__handlers__' import { Group, NodeTypes } from '@/modules/Workspace/types.ts' @@ -53,3 +53,10 @@ export const MOCK_NODE_GROUP: NodeProps = { data: { childrenNodeIds: ['idAdapter', 'idBridge'], title: 'The group title', isOpen: true }, ...MOCK_DEFAULT_NODE, } + +export const MOCK_NODE_DEVICE: NodeProps = { + id: 'idDevice', + type: NodeTypes.DEVICE_NODE, + data: mockProtocolAdapter, + ...MOCK_DEFAULT_NODE, +} diff --git a/hivemq-edge/src/frontend/src/api/hooks/useProtocolAdapters/__handlers__/index.ts b/hivemq-edge/src/frontend/src/api/hooks/useProtocolAdapters/__handlers__/index.ts index 0521f57e9..fa94d09f1 100644 --- a/hivemq-edge/src/frontend/src/api/hooks/useProtocolAdapters/__handlers__/index.ts +++ b/hivemq-edge/src/frontend/src/api/hooks/useProtocolAdapters/__handlers__/index.ts @@ -112,6 +112,7 @@ export const mockProtocolAdapter: ProtocolAdapter = { configSchema: mockJSONSchema, uiSchema: mockUISchema, installed: true, + capabilities: ['READ', 'DISCOVER'], category: { description: 'Industrial, typically field bus protocols.', displayName: 'Industrial', diff --git a/hivemq-edge/src/frontend/src/locales/en/translation.json b/hivemq-edge/src/frontend/src/locales/en/translation.json index 9b96d6832..fbd7cd2b4 100755 --- a/hivemq-edge/src/frontend/src/locales/en/translation.json +++ b/hivemq-edge/src/frontend/src/locales/en/translation.json @@ -660,7 +660,6 @@ "showTopics": "Topics", "showStatus": "Status indicators", "showMonitoringOnEdge": "Observability from the links", - "showHosts": "Hosts", "showGateway": "Gateways" }, "layout": { diff --git a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/hooks/useGetCapability.spec.tsx b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/hooks/useGetAdapterInfo.spec.tsx similarity index 96% rename from hivemq-edge/src/frontend/src/modules/ProtocolAdapters/hooks/useGetCapability.spec.tsx rename to hivemq-edge/src/frontend/src/modules/ProtocolAdapters/hooks/useGetAdapterInfo.spec.tsx index b81c535b6..19e4a0217 100644 --- a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/hooks/useGetCapability.spec.tsx +++ b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/hooks/useGetAdapterInfo.spec.tsx @@ -22,7 +22,7 @@ describe('useGetAdapterInfo', () => { await waitFor(() => { expect(result.current.isLoading).toBeFalsy() }) - expect(result.current.isDiscoverable).toBeFalsy() + expect(result.current.isDiscoverable).toBeTruthy() expect(result.current.adapter).toStrictEqual( expect.objectContaining({ id: 'my-adapter', 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 1207a3df0..4d312885e 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/ReactFlowWrapper.tsx @@ -22,6 +22,7 @@ import { NodeGroup, NodeListener, NodeHost, + NodeDevice, } from '@/modules/Workspace/components/nodes' const ReactFlowWrapper = () => { @@ -35,6 +36,7 @@ const ReactFlowWrapper = () => { [NodeTypes.BRIDGE_NODE]: NodeBridge, [NodeTypes.LISTENER_NODE]: NodeListener, [NodeTypes.HOST_NODE]: NodeHost, + [NodeTypes.DEVICE_NODE]: NodeDevice, }), [] ) 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 5297be4eb..ed51f61b0 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 @@ -13,6 +13,7 @@ import { CONFIG_ADAPTER_WIDTH } from '../../utils/nodes-utils.ts' import { useEdgeFlowContext } from '../../hooks/useEdgeFlowContext.ts' import { useContextMenu } from '../../hooks/useContextMenu.ts' import { TopicFilter } from '../../types.ts' +import { isBidirectional } from '@/modules/Workspace/utils/adapter.utils.ts' const NodeAdapter: FC> = ({ id, data: adapter, selected }) => { const { data: protocols } = useGetAdapterTypes() @@ -25,6 +26,7 @@ const NodeAdapter: FC> = ({ id, data: adapter, selected }) => }, [adapter.config, adapterProtocol]) const { onContextMenu } = useContextMenu(id, selected, `/edge-flow/node/adapter/${adapter.type}`) + const HACK_BIDIRECTIONAL = isBidirectional(adapterProtocol) return ( <> > = ({ id, data: adapter, selected }) => + {HACK_BIDIRECTIONAL && } ) } diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.spec.cy.tsx new file mode 100644 index 000000000..4fa5e513c --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.spec.cy.tsx @@ -0,0 +1,30 @@ +import { MOCK_NODE_DEVICE } from '@/__test-utils__/react-flow/nodes.ts' +import { mockReactFlow } from '@/__test-utils__/react-flow/providers.tsx' + +import { NodeDevice } from '@/modules/Workspace/components/nodes/index.ts' + +describe('NodeDevice', () => { + beforeEach(() => { + cy.viewport(400, 400) + }) + + it.only('should render properly', () => { + cy.mountWithProviders(mockReactFlow()) + + cy.getByTestId('device-description') + .should('have.text', 'Simulation') + .find('svg') + .should('have.attr', 'data-type', 'INDUSTRIAL') + + cy.getByTestId('device-capabilities').find('svg').as('capabilities').should('have.length', 2) + cy.get('@capabilities').eq(0).should('have.attr', 'data-type', 'READ') + cy.get('@capabilities').eq(1).should('have.attr', 'data-type', 'DISCOVER') + }) + + it('should be accessible', () => { + cy.injectAxe() + cy.mountWithProviders(mockReactFlow()) + cy.checkAccessibility() + cy.percySnapshot('Component: NodeDevice') + }) +}) 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 new file mode 100644 index 000000000..020232a0d --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeDevice.tsx @@ -0,0 +1,38 @@ +import { FC } from 'react' +import { Handle, Position, NodeProps } from 'reactflow' +import { HStack, Icon, Text, VStack } from '@chakra-ui/react' + +import { ProtocolAdapter } from '@/api/__generated__' +import NodeWrapper from '@/modules/Workspace/components/parts/NodeWrapper.tsx' +import { deviceCapabilityIcon, deviceCategoryIcon } from '@/modules/Workspace/utils/adapter.utils.ts' + +const NodeDevice: FC> = ({ selected, data }) => { + const { category, capabilities } = data + return ( + <> + + + + {capabilities?.map((capability) => ( + + ))} + + + + {data.protocol} + + + + + + ) +} + +export default NodeDevice diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/index.ts b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/index.ts index c33164dc5..481c08846 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/index.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/index.ts @@ -4,5 +4,6 @@ import NodeBridge from './NodeBridge.tsx' import NodeEdge from './NodeEdge.tsx' import NodeListener from './NodeListener.tsx' import NodeHost from './NodeHost.tsx' +import NodeDevice from './NodeDevice.tsx' -export { NodeGroup, NodeAdapter, NodeBridge, NodeEdge, NodeListener, NodeHost } +export { NodeGroup, NodeAdapter, NodeBridge, NodeEdge, NodeListener, NodeHost, NodeDevice } diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.spec.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.spec.tsx index 82a0d4471..0368b2e3d 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.spec.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.spec.tsx @@ -4,7 +4,7 @@ import { render, act } from '@testing-library/react' import { EdgeFlowContext, EdgeFlowProvider } from './FlowContext.tsx' import { EdgeFlowOptions } from '@/modules/Workspace/types.ts' -const optionKeys = ['showTopics', 'showStatus', 'showMetrics', 'showGateway', 'showHosts'] +const optionKeys = ['showTopics', 'showStatus', 'showMetrics', 'showGateway'] const ProviderTestingMock = () => { const context = useContext(EdgeFlowContext) diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.tsx index 739717027..b79ab5b4e 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/FlowContext.tsx @@ -15,7 +15,6 @@ const defaultEdgeFlowContext: EdgeFlowOptions = { showTopics: true, showStatus: true, showGateway: false, - showHosts: false, } const defaultEdgeFlowGrouping: EdgeFlowGrouping = { diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useEdgeFlowContext.spec.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useEdgeFlowContext.spec.tsx index dc532d161..6808f8f3d 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useEdgeFlowContext.spec.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useEdgeFlowContext.spec.tsx @@ -24,7 +24,6 @@ describe('useEdgeFlowContext', () => { const { result } = renderHook(() => useEdgeFlowContext(), { wrapper }) expect(result.current.options).toEqual({ showGateway: false, - showHosts: false, showTopics: true, showStatus: true, }) diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.spec.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.spec.tsx index f273e131e..ef9aa6dc8 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.spec.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.spec.tsx @@ -48,12 +48,10 @@ describe('useGetFlowElements', () => { }) it.each<[Partial, number, number]>([ - [{}, 3, 2], - [{ showGateway: true }, 4, 3], - [{ showGateway: false }, 3, 2], - [{ showHosts: true }, 4, 3], - [{ showHosts: false }, 3, 2], - [{ showGateway: true, showHosts: true }, 5, 4], + [{}, 4, 3], + [{ showGateway: true }, 5, 4], + [{ showGateway: false }, 4, 3], + [{ showGateway: true }, 5, 4], ])('should consider %s for %s nodes and %s edges', async (defaults, countNode, countEdge) => { const wrapper: React.JSXElementConstructor<{ children: React.ReactElement }> = ({ children }) => ( diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.ts b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.ts index 20eb437ac..60bb16bdb 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/hooks/useGetFlowElements.ts @@ -53,16 +53,15 @@ const useGetFlowElements = () => { ) nodes.push(nodeBridge) edges.push(edgeConnector) - if (options.showHosts) { - nodes.push(nodeHost) - edges.push(hostConnector) - } + + nodes.push(nodeHost) + edges.push(hostConnector) }) adapters.forEach((adapter, incAdapterNb) => { const type = adapterTypes?.items?.find((e) => e.id === adapter.type) - const { nodeAdapter, edgeConnector } = createAdapterNode( + const { nodeAdapter, edgeConnector, nodeDevice, deviceConnector } = createAdapterNode( type as ProtocolAdapter, adapter, incAdapterNb, @@ -71,6 +70,11 @@ const useGetFlowElements = () => { ) nodes.push(nodeAdapter) edges.push(edgeConnector) + + if (nodeDevice && deviceConnector) { + nodes.push(nodeDevice) + edges.push(deviceConnector) + } }) setNodes([nodeEdge, ...applyLayout(nodes, groups)]) diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/types.ts b/hivemq-edge/src/frontend/src/modules/Workspace/types.ts index 61b53fade..ab546e827 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/types.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/types.ts @@ -3,7 +3,6 @@ import { Edge, Node, OnEdgesChange, OnNodesChange, NodeAddChange, EdgeAddChange, export interface EdgeFlowOptions { showTopics: boolean showStatus: boolean - showHosts: boolean showGateway: boolean } @@ -24,6 +23,7 @@ export enum NodeTypes { LISTENER_NODE = 'LISTENER_NODE', CLUSTER_NODE = 'CLUSTER_NODE', HOST_NODE = 'HOST_NODE', + DEVICE_NODE = 'DEVICE_NODE', } export enum EdgeTypes { @@ -35,6 +35,7 @@ export enum IdStubs { BRIDGE_NODE = 'bridge', ADAPTER_NODE = 'adapter', HOST_NODE = 'host', + DEVICE_NODE = 'device', GROUP_NODE = 'group', LISTENER_NODE = 'listener', CONNECTOR = 'connect', diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.spec.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.spec.ts new file mode 100644 index 000000000..49b5dbe6a --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/adapter.utils.spec.ts @@ -0,0 +1,10 @@ +import { expect } from 'vitest' +import { isBidirectional } from '@/modules/Workspace/utils/adapter.utils.ts' +import { mockProtocolAdapter } from '@/api/hooks/useProtocolAdapters/__handlers__' + +describe('isBidirectional', () => { + it('should return the layout characteristics of a group', async () => { + expect(isBidirectional(mockProtocolAdapter)).toStrictEqual(false) + expect(isBidirectional({ ...mockProtocolAdapter, id: 'opc-ua-client' })).toStrictEqual(true) + }) +}) 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 2c99a62a8..850f133dd 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,4 +1,10 @@ import { ProtocolAdapter } from '@/api/__generated__' +import { 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 { MdInput, MdOutlineFindInPage, MdOutput } from 'react-icons/md' /** * @deprecated This is a mock, replacing the missing WRITE capability from the adapters @@ -7,3 +13,27 @@ import { ProtocolAdapter } from '@/api/__generated__' export const isBidirectional = (adapter: ProtocolAdapter | undefined) => { return Boolean(adapter?.id?.includes('opc-ua-client')) } + +/** + * @deprecated This is a mock, mapping should be based on ProtocolAdapterCategory and image property + * @see ProtocolAdapterCategory + */ +export const deviceCategoryIcon: Record = { + BUILDING_AUTOMATION: TbSettingsAutomation, + INDUSTRIAL: FaIndustry, + CONNECTIVITY: GrConnectivity, + SIMULATION: AiFillExperiment, +} + +type ArrayElement> = ArrayType[number] +type CapabilitiesArray = NonNullable +type CapabilityType = ArrayElement | 'WRITE' + +/** + * @deprecated This is a mock, replacing the missing WRITE capability from the adapters + */ +export const deviceCapabilityIcon: Record = { + ['READ']: MdOutput, + ['DISCOVER']: MdOutlineFindInPage, + ['WRITE']: MdInput, +} 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 0d7ecaffe..23c5667c0 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 @@ -8,6 +8,7 @@ import { Adapter, Bridge, Status, Listener, ProtocolAdapter } from '@/api/__gene import { EdgeTypes, IdStubs, NodeTypes } from '../types.ts' import { getBridgeTopics, discoverAdapterTopics } from '../utils/topics-utils.ts' import { getThemeForStatus } from '@/modules/Workspace/utils/status-utils.ts' +import { isBidirectional } from '@/modules/Workspace/utils/adapter.utils.ts' export const CONFIG_ADAPTER_WIDTH = 245 @@ -190,7 +191,44 @@ export const createAdapterNode = ( }, } - return { nodeAdapter, edgeConnector } + let nodeDevice: Node | undefined = undefined + let deviceConnector: Edge | undefined = undefined + + const HACK_BIDIRECTIONAL = isBidirectional(type) + if (HACK_BIDIRECTIONAL) { + const idBAdapterDevice = `${IdStubs.DEVICE_NODE}@${idAdapter}` + nodeDevice = { + id: idBAdapterDevice, + type: NodeTypes.DEVICE_NODE, + targetPosition: Position.Top, + data: type, + position: positionStorage?.[idBAdapterDevice] ?? { + x: nodeAdapter.position.x + 48, + y: nodeAdapter.position.y - 250, + }, + } + + deviceConnector = { + id: `${IdStubs.CONNECTOR}-${IdStubs.DEVICE_NODE}@${idAdapter}`, + target: idBAdapterDevice, + sourceHandle: 'Top', + source: idAdapter, + focusable: false, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 20, + height: 20, + color: getThemeForStatus(theme, adapter.status), + }, + animated: isConnected && !!topics.length, + style: { + strokeWidth: isConnected ? 1.5 : 0.5, + stroke: getThemeForStatus(theme, adapter.status), + }, + } + } + + return { nodeAdapter, edgeConnector, nodeDevice, deviceConnector } } export const getDefaultMetricsFor = (node: Node): string[] => {