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 a9736354c6..079f11f846 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,11 +1,11 @@ import { NodeProps, Position } from 'reactflow' -import { Listener, ProtocolAdapter } from '@/api/__generated__' +import { Listener } from '@/api/__generated__' import { BrokerClientConfiguration } from '@/api/types/api-broker-client.ts' import { mockAdapter, mockProtocolAdapter } from '@/api/hooks/useProtocolAdapters/__handlers__' import { mockBridge } from '@/api/hooks/useGetBridges/__handlers__' import { mockClientSubscription } from '@/api/hooks/useClientSubscriptions/__handlers__' import { mockMqttListener } from '@/api/hooks/useGateway/__handlers__' -import { Group, NodeTypes } from '@/modules/Workspace/types.ts' +import { DeviceMetadata, Group, NodeTypes } from '@/modules/Workspace/types.ts' export const MOCK_DEFAULT_NODE = { selected: false, @@ -56,7 +56,7 @@ export const MOCK_NODE_GROUP: NodeProps = { ...MOCK_DEFAULT_NODE, } -export const MOCK_NODE_DEVICE: NodeProps = { +export const MOCK_NODE_DEVICE: NodeProps = { id: 'idDevice', type: NodeTypes.DEVICE_NODE, data: mockProtocolAdapter, diff --git a/hivemq-edge/src/frontend/src/components/Chakra/DrawerExpandButton.spec.cy.tsx b/hivemq-edge/src/frontend/src/components/Chakra/DrawerExpandButton.spec.cy.tsx new file mode 100644 index 0000000000..d95d93121e --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/Chakra/DrawerExpandButton.spec.cy.tsx @@ -0,0 +1,25 @@ +import DrawerExpandButton from '@/components/Chakra/DrawerExpandButton.tsx' + +describe('DrawerExpandButton', () => { + beforeEach(() => { + cy.viewport(400, 150) + }) + + it('should render expanded properly', () => { + cy.mountWithProviders() + cy.get('button').should('have.attr', 'data-expanded', 'true') + + cy.get('@toggle').should('not.have.been.called') + cy.get('button').click() + cy.get('@toggle').should('have.been.called') + }) + + it('should render shrunk properly', () => { + cy.mountWithProviders() + cy.get('button').should('have.attr', 'data-expanded', 'false') + + cy.get('@toggle').should('not.have.been.called') + cy.get('button').click() + cy.get('@toggle').should('have.been.called') + }) +}) diff --git a/hivemq-edge/src/frontend/src/components/Chakra/DrawerExpandButton.tsx b/hivemq-edge/src/frontend/src/components/Chakra/DrawerExpandButton.tsx new file mode 100644 index 0000000000..15a56b5f95 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/Chakra/DrawerExpandButton.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react' +import { IconButton, IconButtonProps } from '@chakra-ui/react' +import { LuExpand, LuShrink } from 'react-icons/lu' +import { useTranslation } from 'react-i18next' + +interface DrawerExpandButtonProps extends Omit { + isExpanded: boolean + toggle: () => void +} + +const DrawerExpandButton: FC = ({ isExpanded, toggle, ...props }) => { + const { t } = useTranslation('components') + return ( + : } + style={{ + position: 'absolute', + top: 'var(--chakra-space-2)', + right: 0, + width: '32px', + height: '32px', + transform: 'translate(-48px, 0)', + minWidth: 'inherit', + }} + aria-label={isExpanded ? t('DrawerExpandButton.aria-label.shrink') : t('DrawerExpandButton.aria-label.expand')} + /> + ) +} + +export default DrawerExpandButton diff --git a/hivemq-edge/src/frontend/src/components/MQTT/TopicCreatableSelect.tsx b/hivemq-edge/src/frontend/src/components/MQTT/TopicCreatableSelect.tsx index b6fbd8272b..486b9e5e09 100644 --- a/hivemq-edge/src/frontend/src/components/MQTT/TopicCreatableSelect.tsx +++ b/hivemq-edge/src/frontend/src/components/MQTT/TopicCreatableSelect.tsx @@ -9,6 +9,7 @@ import { CreatableProps, SelectInstance, chakraComponents, + Select, } from 'chakra-react-select' import { useTranslation } from 'react-i18next' @@ -56,6 +57,7 @@ interface TopicCreatableSelectProps extends Partial, 'options'>> { id: string options: string[] + isCreatable?: boolean } const AbstractTopicCreatableSelect = ({ @@ -63,6 +65,7 @@ const AbstractTopicCreatableSelect = ({ options, isLoading, isMulti, + isCreatable = true, ...rest }: TopicCreatableSelectProps) => { const topicOptions = Array.from(new Set([...options])) @@ -74,8 +77,10 @@ const AbstractTopicCreatableSelect = ({ trim: false, } + const SelectComponent = isCreatable ? CreatableSelect : Select + return ( - > + > aria-label={t('topicCreate.label')} placeholder={t('topicCreate.placeholder')} noOptionsMessage={() => t('topicCreate.options.noOptionsMessage')} @@ -115,6 +120,7 @@ interface MultiTopicsCreatableSelectProps extends Omit, 'value' | 'onChange' | 'options'> { value: string[] onChange: (value: string[] | undefined) => void + isCreatable?: boolean } export const MultiTopicsCreatableSelect = ({ value, onChange, ...props }: MultiTopicsCreatableSelectProps) => { @@ -125,7 +131,7 @@ export const MultiTopicsCreatableSelect = ({ value, onChange, ...props }: MultiT options={data} isLoading={!isSuccess} isMulti={true} - value={value.map((e) => ({ label: e, value: e, iconColor: 'brand.200' }))} + value={value?.map((e) => ({ label: e, value: e, iconColor: 'brand.200' }))} onChange={(m) => onChange(m.map((e) => e.value))} /> ) diff --git a/hivemq-edge/src/frontend/src/components/react-icons/hm/HmInput.tsx b/hivemq-edge/src/frontend/src/components/react-icons/hm/HmInput.tsx new file mode 100644 index 0000000000..b12b544377 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/react-icons/hm/HmInput.tsx @@ -0,0 +1,29 @@ +import { GenIcon, IconBaseProps } from 'react-icons' + +export const HmInput = (props: IconBaseProps) => + GenIcon({ + tag: 'svg', + attr: { viewBox: '0 0 22 18' }, + child: [ + { + tag: 'g', + attr: { transform: 'matrix(1, 0, 0, 1, -290.7229919433594, -240.23300170898438)' }, + child: [ + { + tag: 'path', + attr: { + d: 'M 310.729 240.233 L 292.729 240.233 C 291.629 240.233 290.729 241.133 290.729 242.233 L 290.723 246.308 L 292.729 246.223 L 292.729 242.213 L 310.729 242.213 L 310.729 256.243 L 292.729 256.243 L 292.729 252.223 L 290.729 252.223 L 290.729 256.233 C 290.729 257.333 291.629 258.213 292.729 258.213 L 310.729 258.213 C 311.829 258.213 312.729 257.333 312.729 256.233 L 312.729 242.233 C 312.729 241.128 311.834 240.233 310.729 240.233 Z', + }, + child: [], + }, + { + tag: 'path', + attr: { + d: 'M 299.328 254.314 L 304.328 249.314 L 299.328 244.314 L 297.918 245.724 L 300.498 248.314 L 291.328 248.314 L 291.328 250.314 L 300.498 250.314 L 297.918 252.904 L 299.328 254.314 Z', + }, + child: [], + }, + ], + }, + ], + })(props) diff --git a/hivemq-edge/src/frontend/src/components/react-icons/hm/HmOutput.tsx b/hivemq-edge/src/frontend/src/components/react-icons/hm/HmOutput.tsx new file mode 100644 index 0000000000..ff9ee0f22f --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/react-icons/hm/HmOutput.tsx @@ -0,0 +1,29 @@ +import { GenIcon, IconBaseProps } from 'react-icons' + +export const HmOutput = (props: IconBaseProps) => + GenIcon({ + tag: 'svg', + attr: { viewBox: '0 0 22 18' }, + child: [ + { + tag: 'g', + attr: { transform: 'matrix(1, 0, 0, 1, -290.7560119628906, -260.54998779296875)' }, + child: [ + { + tag: 'path', + attr: { + d: 'M 310.144 276.55 L 293.179 276.55 L 293.179 262.55 L 310.144 262.55 L 310.144 264.55 L 312.567 264.55 L 312.567 262.55 C 312.567 261.445 311.483 260.55 310.144 260.55 L 293.179 260.55 C 291.846 260.55 290.756 261.45 290.756 262.55 L 290.756 276.55 C 290.756 277.65 291.846 278.55 293.179 278.55 L 310.144 278.55 C 311.483 278.55 312.567 277.654 312.567 276.55 L 312.567 274.55 L 310.144 274.55 L 310.144 276.55 Z', + }, + child: [], + }, + { + tag: 'path', + attr: { + d: 'M 307.452 274.633 L 312.452 269.633 L 307.452 264.633 L 306.042 266.043 L 308.622 268.633 L 299.452 268.633 L 299.452 270.633 L 308.622 270.633 L 306.042 273.223 L 307.452 274.633 Z', + }, + child: [], + }, + ], + }, + ], + })(props) diff --git a/hivemq-edge/src/frontend/src/components/react-icons/hm/index.ts b/hivemq-edge/src/frontend/src/components/react-icons/hm/index.ts new file mode 100644 index 0000000000..39e29c2569 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/react-icons/hm/index.ts @@ -0,0 +1,4 @@ +import { HmInput } from '@/components/react-icons/hm/HmInput.tsx' +import { HmOutput } from '@/components/react-icons/hm/HmOutput.tsx' + +export { HmInput, HmOutput } diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Fields/InternalNotice.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Fields/InternalNotice.tsx new file mode 100644 index 0000000000..b4ace67e47 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Fields/InternalNotice.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react' +import { FieldProps, getUiOptions, labelValue } from '@rjsf/utils' +import { RJSFSchema } from '@rjsf/utils/src/types.ts' +import { getChakra } from '@rjsf/chakra-ui/lib/utils' +import { Alert, AlertDescription, AlertIcon, AlertStatus, FormControl, FormLabel } from '@chakra-ui/react' +import { AdapterContext } from '@/modules/ProtocolAdapters/types.ts' + +export const InternalNotice: FC> = (props) => { + const chakraProps = getChakra({ uiSchema: props.uiSchema }) + const { message, status } = getUiOptions(props.uiSchema) + + return ( + + {labelValue( + + {props.label} + , + props.hideLabel || !props.label + )} + + + + {message && {message as string}} + + + ) +} diff --git a/hivemq-edge/src/frontend/src/components/rjsf/__internals/TopicInputTemplate.tsx b/hivemq-edge/src/frontend/src/components/rjsf/__internals/TopicInputTemplate.tsx index 318dec8f5c..a9ea8a071c 100644 --- a/hivemq-edge/src/frontend/src/components/rjsf/__internals/TopicInputTemplate.tsx +++ b/hivemq-edge/src/frontend/src/components/rjsf/__internals/TopicInputTemplate.tsx @@ -1,8 +1,8 @@ import { FC } from 'react' -import { BaseInputTemplateProps } from '@rjsf/utils' +import { BaseInputTemplateProps, getUiOptions } from '@rjsf/utils' import { FormControl, FormLabel } from '@chakra-ui/react' -import { SingleTopicCreatableSelect } from '@/components/MQTT/TopicCreatableSelect.tsx' +import { MultiTopicsCreatableSelect, SingleTopicCreatableSelect } from '@/components/MQTT/TopicCreatableSelect.tsx' import { useGetEdgeTopics } from '@/hooks/useGetEdgeTopics/useGetEdgeTopics.ts' import config from '@/config' @@ -13,6 +13,7 @@ export const TopicInputTemplate: FC = (props) => { branchOnly: config.features.TOPIC_EDITOR_SHOW_BRANCHES, publishOnly: true, }) + const { create, multiple } = getUiOptions(props.uiSchema) return ( = (props) => { isInvalid={rawErrors && rawErrors.length > 0} > {label} - + {!multiple && ( + + )} + {multiple && ( + + )} ) } diff --git a/hivemq-edge/src/frontend/src/locales/en/components.json b/hivemq-edge/src/frontend/src/locales/en/components.json index f63ab1e7f5..228af47188 100755 --- a/hivemq-edge/src/frontend/src/locales/en/components.json +++ b/hivemq-edge/src/frontend/src/locales/en/components.json @@ -71,6 +71,12 @@ "optIn": "Accept all" } }, + "DrawerExpandButton": { + "aria-label": { + "expand": "Expand", + "shrink": "Shrink" + } + }, "rjsf": { "CompactArrayField": { "action": { diff --git a/hivemq-edge/src/frontend/src/locales/en/translation.json b/hivemq-edge/src/frontend/src/locales/en/translation.json index 416fb11621..0601198b3f 100755 --- a/hivemq-edge/src/frontend/src/locales/en/translation.json +++ b/hivemq-edge/src/frontend/src/locales/en/translation.json @@ -750,6 +750,7 @@ "header_BRIDGE_NODE": "Bridge Overview", "header_CLUSTER_NODE": "Group Overview", "header_EDGE_NODE": "Edge Overview", + "header_DEVICE_NODE": "Device Overview", "modify_ADAPTER_NODE": "Modify the adapter", "modify_BRIDGE_NODE": "Modify the bridge", "eventLog": { @@ -912,5 +913,10 @@ "title": "A new version of $t(branding.appName) is available", "description": "Visit the GitHub repository to learn more about the latest version of $t(branding.appName)" } + }, + "warnings": { + "deprecated": { + "subscriptions": "The subscriptions are now available through the workspace" + } } } diff --git a/hivemq-edge/src/frontend/src/modules/App/routes.tsx b/hivemq-edge/src/frontend/src/modules/App/routes.tsx index 4966a38787..7618a2cbe3 100644 --- a/hivemq-edge/src/frontend/src/modules/App/routes.tsx +++ b/hivemq-edge/src/frontend/src/modules/App/routes.tsx @@ -17,6 +17,7 @@ const UnifiedNamespacePage = lazy(() => import('@/modules/UnifiedNamespace/Unifi const EdgeFlowPage = lazy(() => import('@/modules/Workspace/EdgeFlowPage.tsx')) const NodePanelController = lazy(() => import('@/modules/Workspace/components/controls/NodePanelController.tsx')) const EvenLogPage = lazy(() => import('@/modules/EventLog/EvenLogPage.tsx')) +const AdapterSubscriptionManager = lazy(() => import('@/modules/Subscriptions/AdapterSubscriptionManager.tsx')) import { dataHubRoutes } from '@/extensions/datahub/routes.tsx' @@ -73,6 +74,14 @@ export const routes = createBrowserRouter( path: ':nodeType/:device?/:adapter?/:nodeId', element: , }, + { + path: ':nodeType/:device?/:adapter?/:nodeId/inward', + element: , + }, + { + path: ':nodeType/:device?/:adapter?/:nodeId/outward', + element: , + }, ], }, { diff --git a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/adapters/AdapterActionMenu.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/adapters/AdapterActionMenu.spec.cy.tsx index f3d89da233..f12cc56925 100644 --- a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/adapters/AdapterActionMenu.spec.cy.tsx +++ b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/adapters/AdapterActionMenu.spec.cy.tsx @@ -93,7 +93,7 @@ describe('AdapterActionMenu', () => { cy.getByTestId('adapter-action-export').should('not.be.visible') }) - it.only('should be accessible', () => { + it('should be accessible', () => { cy.injectAxe() cy.mountWithProviders() cy.getByAriaLabel('Actions').click() diff --git a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx index 855ae25316..2ffd1a6965 100644 --- a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx +++ b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx @@ -39,6 +39,7 @@ import { getRequiredUiSchema, } from '@/modules/ProtocolAdapters/utils/uiSchema.utils.ts' import { AdapterContext } from '@/modules/ProtocolAdapters/types.ts' +import { getMainRootFromPath, getTopicPaths } from '@/modules/Workspace/utils/topics-utils.ts' interface AdapterInstanceDrawerProps { adapterType?: string @@ -69,12 +70,19 @@ const AdapterInstanceDrawer: FC = ({ const { schema, uiSchema, name, logo, isDiscoverable } = useMemo(() => { const adapter: ProtocolAdapter | undefined = data?.items?.find((e) => e.id === adapterType) const { configSchema, uiSchema, capabilities } = adapter || {} + + // TODO[NVL] This is still a hack; backend needs to provide identification of subscription properties + const paths = getTopicPaths(configSchema || {}) + const subIndex = getMainRootFromPath(paths) + const hideSubscriptionsKey = + import.meta.env.VITE_FLAG_ADAPTER_SCHEMA_HIDE_SUBSCRIPTION === 'true' ? subIndex : undefined + return { isDiscoverable: Boolean(capabilities?.includes('DISCOVER')), schema: configSchema, name: adapter?.name, logo: adapter?.logoUrl, - uiSchema: getRequiredUiSchema(uiSchema, isNewAdapter), + uiSchema: getRequiredUiSchema(uiSchema, isNewAdapter, hideSubscriptionsKey), } }, [data?.items, isNewAdapter, adapterType]) diff --git a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts index b1852cfbd3..c5268aa390 100644 --- a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts +++ b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts @@ -1,9 +1,18 @@ import { RegistryFieldsType, RegistryWidgetsType, UiSchema } from '@rjsf/utils' +import { AlertStatus } from '@chakra-ui/react' + import CompactArrayField from '@/components/rjsf/Fields/CompactArrayField.tsx' +import { InternalNotice } from '@/components/rjsf/Fields/InternalNotice.tsx' + +import i18n from '@/config/i18n.config.ts' -export const getRequiredUiSchema = (uiSchema: UiSchema | undefined, isNewAdapter: boolean): UiSchema => { +export const getRequiredUiSchema = ( + uiSchema: UiSchema | undefined, + isNewAdapter: boolean, + hideSubscriptions?: string +): UiSchema => { const { ['ui:submitButtonOptions']: submitButtonOptions, id, ...rest } = uiSchema || {} - return { + const newSchema: UiSchema = { 'ui:submitButtonOptions': { // required to relocate the submit button outside the form ...submitButtonOptions, @@ -16,6 +25,19 @@ export const getRequiredUiSchema = (uiSchema: UiSchema | undefined, isNewAdapter }, ...rest, } + + if (hideSubscriptions) { + const status: AlertStatus = 'info' + newSchema[hideSubscriptions] = { + 'ui:field': 'text:warning', + 'ui:options': { + status, + message: i18n.t('warnings.deprecated.subscriptions'), + }, + } + } + + return newSchema } export const adapterJSFWidgets: RegistryWidgetsType = { @@ -23,4 +45,7 @@ export const adapterJSFWidgets: RegistryWidgetsType = { 'discovery:tagBrowser': 'text', } -export const adapterJSFFields: RegistryFieldsType = { compactTable: CompactArrayField } +export const adapterJSFFields: RegistryFieldsType = { + compactTable: CompactArrayField, + 'text:warning': InternalNotice, +} diff --git a/hivemq-edge/src/frontend/src/modules/Subscriptions/AdapterSubscriptionManager.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/Subscriptions/AdapterSubscriptionManager.spec.cy.tsx new file mode 100644 index 0000000000..b87167ba08 --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Subscriptions/AdapterSubscriptionManager.spec.cy.tsx @@ -0,0 +1,100 @@ +/// + +import AdapterSubscriptionManager from '@/modules/Subscriptions/AdapterSubscriptionManager.tsx' +import { Route, Routes } from 'react-router-dom' +import { Node } from 'reactflow' + +import { ReactFlowTesting } from '@/__test-utils__/react-flow/ReactFlowTesting.tsx' +import { MOCK_NODE_ADAPTER } from '@/__test-utils__/react-flow/nodes.ts' +import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts' +import { mockAdapter, mockProtocolAdapter } from '@/api/hooks/useProtocolAdapters/__handlers__' + +const getWrapperWith = (initialNodes?: Node[]) => { + const Wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => { + const { nodes } = useWorkspaceStore() + return ( + {nodes.length}} + > + + + + + ) + } + + return Wrapper +} + +describe('AdapterSubscriptionManager', () => { + beforeEach(() => { + cy.viewport(800, 800) + cy.intercept('/api/v1/management/protocol-adapters/types', { items: [mockProtocolAdapter] }).as('getProtocol') + cy.intercept('/api/v1/management/protocol-adapters/adapters', { items: [mockAdapter] }).as('getAdapter') + cy.intercept('/api/v1/management/bridges', { items: [] }) + }) + + it('should render the drawer', () => { + cy.mountWithProviders(, { + routerProps: { initialEntries: [`/node/wrong-adapter`] }, + wrapper: getWrapperWith(), + }) + + cy.get('[role="dialog"]').should('be.visible') + + cy.get('header').should('contain.text', 'Manage inward subscriptions') + cy.get('[role="dialog"]').find('button').as('dialog-buttons').should('have.length', 2) + cy.get('@dialog-buttons').eq(0).should('have.attr', 'aria-label', 'Close') + cy.get('@dialog-buttons').eq(1).should('have.attr', 'aria-label', 'Expand') + + cy.get('@dialog-buttons').eq(1).click() + cy.get('@dialog-buttons').eq(1).should('have.attr', 'aria-label', 'Shrink') + cy.get('@dialog-buttons').eq(0).click() + cy.get('[role="dialog"]').should('not.exist') + }) + + it('should render error properly', () => { + cy.mountWithProviders(, { + routerProps: { initialEntries: [`/node/wrong-adapter`] }, + wrapper: getWrapperWith(), + }) + + cy.get('[role="dialog"]').should('be.visible') + + cy.get('[role="alert"]').should('be.visible') + cy.get('[role="alert"] span').should('have.attr', 'data-status', 'error') + cy.get('[role="alert"] div div') + .should('have.attr', 'data-status', 'error') + .should('contain.text', 'We cannot load your adapters for the time being. Please try again later') + }) + + it('should render inward properly', () => { + cy.mountWithProviders(, { + routerProps: { initialEntries: [`/node/idAdapter`] }, + wrapper: getWrapperWith([{ ...MOCK_NODE_ADAPTER, position: { x: 0, y: 0 } }]), + }) + + cy.getByTestId('data-length').should('contain.text', '1') + cy.get('header').should('contain.text', 'Manage inward subscriptions') + }) + + it('should render outward properly', () => { + cy.mountWithProviders(, { + routerProps: { initialEntries: [`/node/idAdapter`] }, + wrapper: getWrapperWith([{ ...MOCK_NODE_ADAPTER, position: { x: 0, y: 0 } }]), + }) + cy.get('header').should('contain.text', 'Manage outward subscriptions') + + cy.get('[role="alert"]').should('be.visible') + cy.get('[role="alert"] span').should('have.attr', 'data-status', 'error') + cy.get('[role="alert"] div div') + .should('have.attr', 'data-status', 'error') + .should('contains.text', 'There are no valid schema defining the extracted subscriptions') + }) +}) diff --git a/hivemq-edge/src/frontend/src/modules/Subscriptions/AdapterSubscriptionManager.tsx b/hivemq-edge/src/frontend/src/modules/Subscriptions/AdapterSubscriptionManager.tsx new file mode 100644 index 0000000000..98fa03c728 --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Subscriptions/AdapterSubscriptionManager.tsx @@ -0,0 +1,69 @@ +import { type FC, useEffect, useMemo } from 'react' +import { Node } from 'reactflow' +import { useNavigate, useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerHeader, + DrawerOverlay, + Text, + useBoolean, + useDisclosure, +} from '@chakra-ui/react' + +import type { Adapter } from '@/api/__generated__' +import DrawerExpandButton from '@/components/Chakra/DrawerExpandButton.tsx' +import SubscriptionForm from '@/modules/Subscriptions/components/SubscriptionForm.tsx' +import { NodeTypes } from '@/modules/Workspace/types.ts' +import useWorkspaceStore from '@/modules/Workspace/hooks/useWorkspaceStore.ts' +import ErrorMessage from '@/components/ErrorMessage.tsx' + +interface AdapterSubscriptionManagerProps { + type: 'inward' | 'outward' +} + +const AdapterSubscriptionManager: FC = ({ type }) => { + const { t } = useTranslation() + const { isOpen, onOpen, onClose } = useDisclosure() + const navigate = useNavigate() + const { nodeId } = useParams() + const [isExpanded, setExpanded] = useBoolean(false) + const { nodes } = useWorkspaceStore() + + const selectedNode = useMemo(() => { + return nodes.find((node) => node.id === nodeId && node.type === NodeTypes.ADAPTER_NODE) as Node | undefined + }, [nodeId, nodes]) + + const handleClose = () => { + onClose() + navigate('/workspace') + } + + useEffect(() => { + onOpen() + }, [onOpen]) + + const adapterId = selectedNode?.data.id + + return ( + + + + + + + Manage {type} subscriptions + + + {!adapterId && } + {adapterId && } + + + + ) +} + +export default AdapterSubscriptionManager diff --git a/hivemq-edge/src/frontend/src/modules/Subscriptions/components/SubscriptionForm.tsx b/hivemq-edge/src/frontend/src/modules/Subscriptions/components/SubscriptionForm.tsx new file mode 100644 index 0000000000..3946ed6aaf --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Subscriptions/components/SubscriptionForm.tsx @@ -0,0 +1,63 @@ +import { type FC, useCallback } from 'react' +import Form from '@rjsf/chakra-ui' +import { type IChangeEvent } from '@rjsf/core' + +import ErrorMessage from '@/components/ErrorMessage.tsx' +import { ObjectFieldTemplate } from '@/components/rjsf/ObjectFieldTemplate.tsx' +import { FieldTemplate } from '@/components/rjsf/FieldTemplate.tsx' +import { BaseInputTemplate } from '@/components/rjsf/BaseInputTemplate.tsx' +import { ArrayFieldTemplate } from '@/components/rjsf/ArrayFieldTemplate.tsx' +import { ArrayFieldItemTemplate } from '@/components/rjsf/ArrayFieldItemTemplate.tsx' +import { customFormatsValidator } from '@/modules/ProtocolAdapters/utils/validation-utils.ts' +import { adapterJSFFields, adapterJSFWidgets } from '@/modules/ProtocolAdapters/utils/uiSchema.utils.ts' +import { useSubscriptionManager } from '@/modules/Subscriptions/hooks/useSubscriptionManager.tsx' +import { useTranslation } from 'react-i18next' + +interface SubscriptionFormProps { + id: string + type: 'inward' | 'outward' +} + +// TODO[NVL] Should replicate the config from the adapter form; share component? +const SubscriptionForm: FC = ({ id, type }) => { + const { t } = useTranslation() + const { inwardManager, outwardManager } = useSubscriptionManager(id) + + const subscriptionManager = type === 'inward' ? inwardManager : outwardManager + + const onFormSubmit = useCallback( + (data: IChangeEvent) => { + const subscriptions = data.formData?.subscriptions + subscriptionManager?.onSubmit?.(subscriptions) + console.log('XXXXXXX', subscriptions) + }, + [subscriptionManager] + ) + + if (!subscriptionManager) return + + return ( +
console.log('XXXXXXX', errors)} + formData={subscriptionManager.formData} + widgets={adapterJSFWidgets} + fields={adapterJSFFields} + templates={{ + ObjectFieldTemplate, + FieldTemplate, + BaseInputTemplate, + ArrayFieldTemplate, + ArrayFieldItemTemplate, + }} + /> + ) +} + +export default SubscriptionForm diff --git a/hivemq-edge/src/frontend/src/modules/Subscriptions/hooks/useSubscriptionManager.spec.tsx b/hivemq-edge/src/frontend/src/modules/Subscriptions/hooks/useSubscriptionManager.spec.tsx new file mode 100644 index 0000000000..dd1b4edaac --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Subscriptions/hooks/useSubscriptionManager.spec.tsx @@ -0,0 +1,186 @@ +/// + +import { beforeEach, expect } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' + +import { useSubscriptionManager } from '@/modules/Subscriptions/hooks/useSubscriptionManager.tsx' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AuthProvider } from '@/modules/Auth/AuthProvider.tsx' +import { MemoryRouter } from 'react-router-dom' +import { ReactFlowProvider } from 'reactflow' +import { Adapter, AdaptersList, Bridge, BridgeList, ProtocolAdapter, ProtocolAdaptersList } from '@/api/__generated__' +import { http, HttpResponse } from 'msw' +import { server } from '@/__test-utils__/msw/mockServer.ts' +import { mockAdapter, mockProtocolAdapter } from '@/api/hooks/useProtocolAdapters/__handlers__' +import { MOCK_ADAPTER_ID } from '@/__test-utils__/mocks.ts' + +const wrapper: React.JSXElementConstructor<{ children: React.ReactElement }> = ({ children }) => ( + + + + {children} + + + +) + +const customHandlers = ( + types: Array | undefined, + adapters?: Array | undefined, + bridges?: Array | undefined +) => [ + http.get('**/protocol-adapters/types', () => { + return types + ? HttpResponse.json({ items: types }, { status: 200 }) + : new HttpResponse(null, { status: 500 }) + }), + + http.get('**/protocol-adapters/adapters', () => { + return adapters + ? HttpResponse.json({ items: adapters }, { status: 200 }) + : new HttpResponse(null, { + status: 500, + }) + }), + http.get('**/management/bridges', () => { + return bridges + ? HttpResponse.json({ items: bridges }, { status: 200 }) + : new HttpResponse(null, { + status: 500, + }) + }), +] + +const TEST_INWARD_EXPECTED = { + inwardManager: expect.objectContaining({ + formData: expect.objectContaining({ + subscriptions: [ + { + destination: 'root/topic/ref/1', + qos: 0, + }, + { + destination: 'root/topic/ref/2', + qos: 0, + }, + ], + }), + schema: expect.objectContaining({ + required: ['subscriptions'], + type: 'object', + properties: { + subscriptions: expect.objectContaining({ + description: 'List of subscriptions for the simulation', + }), + }, + }), + uiSchema: expect.objectContaining({ + subscriptions: { + items: { + 'ui:collapsable': { + titleKey: 'destination', + }, + 'ui:order': ['destination', 'qos', '*'], + }, + }, + }), + }), + isLoading: false, +} + +describe('useSubscriptionManager', () => { + beforeEach(() => { + server.use(...customHandlers([mockProtocolAdapter], [mockAdapter], [])) + }) + + it('should return no subscription for wrong adapter', async () => { + const { result } = renderHook(() => useSubscriptionManager('wrong-adapter'), { wrapper }) + expect(result.current.isLoading).toBeTruthy() + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + }) + expect(result.current).toStrictEqual({ + inwardManager: undefined, + isLoading: false, + outwardManager: undefined, + }) + }) + + it('should return inward subscription', async () => { + const { result } = renderHook(() => useSubscriptionManager(MOCK_ADAPTER_ID), { wrapper }) + expect(result.current.isLoading).toBeTruthy() + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + }) + expect(result.current).toStrictEqual({ ...TEST_INWARD_EXPECTED, outwardManager: undefined }) + }) + + it('should return outward subscription', async () => { + server.use( + ...customHandlers( + [{ ...mockProtocolAdapter, id: 'opc-ua-client' }], + [{ ...mockAdapter, type: 'opc-ua-client' }], + [] + ) + ) + const { result } = renderHook(() => useSubscriptionManager(MOCK_ADAPTER_ID), { wrapper }) + expect(result.current.isLoading).toBeTruthy() + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + }) + expect(result.current).toStrictEqual({ + ...TEST_INWARD_EXPECTED, + outwardManager: expect.objectContaining({ + formData: { + subscriptions: [], + }, + schema: expect.objectContaining({ + properties: { + subscriptions: expect.objectContaining({ + items: expect.objectContaining({ + required: ['node', 'mqtt-topic'], + properties: expect.objectContaining({ + mapping: expect.objectContaining({ + description: + 'The list of data model transformations required to produce a valid destination node from the selected MQTT topics', + }), + 'mqtt-topic': expect.objectContaining({ + description: 'The MQTT topics used to identify the source of the mapping', + }), + node: expect.objectContaining({}), + }), + }), + }), + }, + required: ['subscriptions'], + type: 'object', + }), + uiSchema: { + subscriptions: { + items: { + 'mqtt-topic': { + items: { + 'ui:options': { + create: false, + multiple: false, + }, + }, + }, + }, + }, + }, + }), + }) + }) +}) diff --git a/hivemq-edge/src/frontend/src/modules/Subscriptions/hooks/useSubscriptionManager.tsx b/hivemq-edge/src/frontend/src/modules/Subscriptions/hooks/useSubscriptionManager.tsx new file mode 100644 index 0000000000..c39b97d543 --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Subscriptions/hooks/useSubscriptionManager.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import { type JSONSchema7 } from 'json-schema' +import { type RJSFSchema, type UiSchema } from '@rjsf/utils' + +import { useGetAdapterTypes } from '@/api/hooks/useProtocolAdapters/useGetAdapterTypes.ts' +import { useListProtocolAdapters } from '@/api/hooks/useProtocolAdapters/useListProtocolAdapters.ts' +import { type SubscriptionManagerType } from '@/modules/Subscriptions/types.ts' +import { MOCK_OUTWARD_SUBSCRIPTION_OPCUA } from '@/modules/Subscriptions/utils/subscription.utils.ts' +import { getMainRootFromPath, getTopicPaths } from '@/modules/Workspace/utils/topics-utils.ts' + +export const useSubscriptionManager = (adapterId: string) => { + const { data: allProtocols, isLoading: isProtocolLoading } = useGetAdapterTypes() + const { data: allAdapters, isLoading: isAdapterLoading } = useListProtocolAdapters() + + const adapterInfo = useMemo(() => { + const selectedAdapter = allAdapters?.find((adapter) => adapter.id === adapterId) + if (!selectedAdapter) return undefined + + const selectedProtocol = allProtocols?.items?.find((protocol) => protocol.id === selectedAdapter.type) + if (!selectedProtocol) return undefined + + return { selectedAdapter, selectedProtocol } + }, [allAdapters, allProtocols?.items, adapterId]) + + const inwardManager = useMemo(() => { + if (!adapterInfo) return undefined + const { selectedProtocol, selectedAdapter } = adapterInfo + + const { properties } = selectedProtocol?.configSchema as JSONSchema7 + if (!properties) return undefined + + // TODO[NVL] This is still a hack; backend needs to provide identification of subscription properties + const paths = getTopicPaths(selectedProtocol?.configSchema || {}) + const subIndex = getMainRootFromPath(paths) + if (!subIndex) return undefined + + const formData = selectedAdapter.config?.[subIndex] + if (!formData) return undefined + + const subs = properties?.[subIndex] + if (!subs) return undefined + + const schema: RJSFSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + subscriptions: subs, + }, + required: ['subscriptions'], + } + const { ['ui:tabs']: tabs, ...rest } = selectedProtocol.uiSchema as UiSchema + return { schema, formData: { subscriptions: formData }, uiSchema: rest } + }, [adapterInfo]) + + const outwardManager = useMemo(() => { + if (!adapterInfo) return undefined + const { selectedProtocol } = adapterInfo + + if (!['opc-ua-client'].includes(selectedProtocol.id || '')) return undefined + + return { + schema: MOCK_OUTWARD_SUBSCRIPTION_OPCUA.schema || {}, + formData: { subscriptions: [] }, + uiSchema: MOCK_OUTWARD_SUBSCRIPTION_OPCUA.uiSchema || {}, + } + }, [adapterInfo]) + + const isLoading = isAdapterLoading || isProtocolLoading + + return { isLoading, inwardManager, outwardManager } +} diff --git a/hivemq-edge/src/frontend/src/modules/Subscriptions/types.ts b/hivemq-edge/src/frontend/src/modules/Subscriptions/types.ts new file mode 100644 index 0000000000..f5943cc861 --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Subscriptions/types.ts @@ -0,0 +1,36 @@ +import { type RJSFSchema, type UiSchema } from '@rjsf/utils' + +export interface SubscriptionManagerType { + schema: RJSFSchema + // TODO[NVL] Needs to align the types for the subscriptions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formData: { subscriptions: Record } + uiSchema: UiSchema + onSubmit?: (data: unknown) => void +} + +/** + * @deprecated This is a mock, will need to be replaced by OpenAPI specs when available + */ +export interface OutwardSubscription { + node: string + mqttTopic: string[] + mapping: Mapping[] +} + +/** + * @deprecated This is a mock, will need to be replaced by OpenAPI specs when available + */ +export interface Mapping { + source: string[] + destination: string + transformation: Transformation +} + +/** + * @deprecated This is a mock, will need to be replaced by OpenAPI specs when available + */ +export interface Transformation { + function: 'toString' | 'toInt' | 'join' + params: string +} diff --git a/hivemq-edge/src/frontend/src/modules/Subscriptions/utils/subscription.utils.ts b/hivemq-edge/src/frontend/src/modules/Subscriptions/utils/subscription.utils.ts new file mode 100644 index 0000000000..c788ac869f --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Subscriptions/utils/subscription.utils.ts @@ -0,0 +1,89 @@ +import { type RJSFSchema, UiSchema } from '@rjsf/utils' + +interface MockSubscription { + schema?: RJSFSchema + uiSchema?: UiSchema +} + +/** + * @deprecated This is a mock, will need to be replaced by OpenAPI specs when available + */ +export const MOCK_OUTWARD_SUBSCRIPTION_OPCUA: MockSubscription = { + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + subscriptions: { + type: 'array', + items: { + type: 'object', + required: ['node', 'mqtt-topic'], + properties: { + node: { + type: 'string', + title: 'Destination Node ID', + description: 'identifier of the node on the OPC-UA server. Example: "ns=3;s=85/0:Temperature"', + }, + 'mqtt-topic': { + type: 'array', + title: 'Source Topics', + description: 'The MQTT topics used to identify the source of the mapping', + items: { + type: 'string', + uniqueItems: true, + format: 'mqtt-topic', + }, + }, + mapping: { + type: 'array', + title: 'Mapping instructions', + description: + 'The list of data model transformations required to produce a valid destination node from the selected MQTT topics', + maxItems: 20, + items: { + type: 'object', + properties: { + source: { + type: 'array', + description: 'The path of the property to use from the source data model', + items: { + type: 'string', + }, + }, + destination: { + type: 'string', + description: 'The path of the property to populate in the destination data model', + }, + transformation: { + type: 'object', + description: 'The transformation to apply to the source data points', + properties: { + function: { + enum: ['toString', 'toInt', 'join'], + }, + params: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + required: ['subscriptions'], + }, + uiSchema: { + subscriptions: { + items: { + 'mqtt-topic': { + items: { + 'ui:options': { create: false, multiple: false }, + }, + }, + }, + }, + }, +} diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/NodePanelController.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/NodePanelController.tsx index d7b3a9419b..363bb09d00 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/NodePanelController.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/controls/NodePanelController.tsx @@ -6,8 +6,9 @@ import { useDisclosure } from '@chakra-ui/react' import { Adapter, Bridge } from '@/api/__generated__' import { SuspenseFallback } from '@/components/SuspenseOutlet.tsx' import { AdapterNavigateState, ProtocolAdapterTabIndex } from '@/modules/ProtocolAdapters/types.ts' -import { EdgeTypes, Group, NodeTypes } from '@/modules/Workspace/types.ts' +import { DeviceMetadata, EdgeTypes, Group, NodeTypes } from '@/modules/Workspace/types.ts' +const DevicePropertyDrawer = lazy(() => import('../drawers/DevicePropertyDrawer.tsx')) const NodePropertyDrawer = lazy(() => import('../drawers/NodePropertyDrawer.tsx')) const LinkPropertyDrawer = lazy(() => import('../drawers/LinkPropertyDrawer.tsx')) const GroupPropertyDrawer = lazy(() => import('../drawers/GroupPropertyDrawer.tsx')) @@ -27,6 +28,9 @@ const NodePanelController: FC = () => { ) as Node | undefined const selectedEdge = nodes.find((e) => e.id === nodeId && e.type === NodeTypes.EDGE_NODE) + const selectedDevice = nodes.find((e) => e.id === nodeId && e.type === NodeTypes.DEVICE_NODE) as + | Node + | undefined const selectedLinkSource = nodes.find((e) => { const link = edges.find((e) => e.id === nodeId && e.type === EdgeTypes.REPORT_EDGE) @@ -110,6 +114,15 @@ const NodePanelController: FC = () => { onEditEntity={handleEditEntity} /> )} + {selectedDevice && ( + + )} {selectedGroup && ( + +import { MOCK_NODE_DEVICE } from '@/__test-utils__/react-flow/nodes.ts' +import DevicePropertyDrawer from '@/modules/Workspace/components/drawers/DevicePropertyDrawer.tsx' + +describe('DevicePropertyDrawer', () => { + beforeEach(() => { + cy.viewport(800, 800) + cy.intercept('/api/v1/management/protocol-adapters/types', { statusCode: 404 }) + }) + + it('should render properly', () => { + const onClose = cy.stub().as('onClose') + const onEditEntity = cy.stub().as('onEditEntity') + cy.mountWithProviders( + + ) + + cy.get('@onClose').should('not.have.been.called') + cy.getByAriaLabel('Close').click() + cy.get('@onClose').should('have.been.calledOnce') + + cy.get('header').should('contain.text', 'Device Overview') + }) +}) diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/drawers/DevicePropertyDrawer.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/drawers/DevicePropertyDrawer.tsx new file mode 100644 index 0000000000..2e442b6d67 --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/drawers/DevicePropertyDrawer.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { Node } from 'reactflow' +import { + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + Text, +} from '@chakra-ui/react' + +import { DeviceMetadata } from '@/modules/Workspace/types.ts' + +interface DevicePropertyDrawerProps { + nodeId: string + selectedNode: Node + isOpen: boolean + onClose: () => void + onEditEntity: () => void +} + +const DevicePropertyDrawer: FC = ({ isOpen, selectedNode, onClose }) => { + const { t } = useTranslation() + + return ( + + + + + + {t('workspace.property.header', { context: selectedNode.type })} + + + + + + ) +} + +export default DevicePropertyDrawer diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.spec.cy.tsx index 45b9bb2544..0ca7696d5e 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.spec.cy.tsx +++ b/hivemq-edge/src/frontend/src/modules/Workspace/components/nodes/NodeAdapter.spec.cy.tsx @@ -14,7 +14,7 @@ import NodeAdapter from './NodeAdapter.tsx' describe('NodeAdapter', () => { beforeEach(() => { - cy.viewport(400, 400) + cy.viewport(600, 400) cy.intercept('/api/v1/management/protocol-adapters/types', { items: [ mockProtocolAdapter, @@ -109,10 +109,36 @@ describe('NodeAdapter', () => { it('should render the toolbar properly', () => { cy.mountWithProviders( ) + + cy.getByTestId('test-navigate-pathname').should('have.text', '/') + cy.get('[role="toolbar"] button').eq(0).click() + cy.getByTestId('test-navigate-pathname').should( + 'have.text', + `/workspace/node/adapter/opc-ua-client/${MOCK_NODE_ADAPTER.id}` + ) + + cy.get('[role="toolbar"] button').eq(1).click() + cy.getByTestId('test-navigate-pathname').should( + 'have.text', + `/workspace/node/adapter/opc-ua-client/${MOCK_NODE_ADAPTER.id}/outward` + ) + + cy.get('[role="toolbar"] button').eq(2).click() + cy.getByTestId('test-navigate-pathname').should( + 'have.text', + `/workspace/node/adapter/opc-ua-client/${MOCK_NODE_ADAPTER.id}/inward` + ) }) it('should be accessible', () => { 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 717f30bb2b..f3fd0789ff 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 { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' import { Box, HStack, Icon, Image, Text, VStack } from '@chakra-ui/react' -import { type TopicFilter } from '@/modules/Workspace/types.ts' 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 { type TopicFilter } from '@/modules/Workspace/types.ts' import { useEdgeFlowContext } from '@/modules/Workspace/hooks/useEdgeFlowContext.ts' import { discoverAdapterTopics } from '@/modules/Workspace/utils/topics-utils.ts' import { useContextMenu } from '@/modules/Workspace/hooks/useContextMenu.ts' @@ -29,8 +30,11 @@ const NodeAdapter: FC> = ({ id, data: adapter, selected }) => return discoverAdapterTopics(adapterProtocol, adapter.config).map((e) => ({ topic: e })) }, [adapter.config, adapterProtocol]) const { onContextMenu } = useContextMenu(id, selected, `/workspace/node/adapter/${adapter.type}`) + const navigate = useNavigate() const HACK_BIDIRECTIONAL = isBidirectional(adapterProtocol) + const adapterNavPath = `/workspace/node/adapter/${adapter.type}/${id}` + return ( <> @@ -39,13 +43,13 @@ const NodeAdapter: FC> = ({ id, data: adapter, selected }) => } aria-label={t('workspace.toolbar.command.subscriptions.outward')} - // onClick={onCreateGroup} + onClick={() => navigate(`${adapterNavPath}/outward`)} /> )} } aria-label={t('workspace.toolbar.command.subscriptions.inward')} - // onClick={onCreateGroup} + onClick={() => navigate(`${adapterNavPath}/inward`)} /> 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 index 4fa5e513ca..331656454e 100644 --- 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 @@ -1,14 +1,16 @@ import { MOCK_NODE_DEVICE } from '@/__test-utils__/react-flow/nodes.ts' import { mockReactFlow } from '@/__test-utils__/react-flow/providers.tsx' +import { CustomNodeTesting } from '@/__test-utils__/react-flow/CustomNodeTesting.tsx' import { NodeDevice } from '@/modules/Workspace/components/nodes/index.ts' +import { NodeTypes } from '@/modules/Workspace/types.ts' describe('NodeDevice', () => { beforeEach(() => { cy.viewport(400, 400) }) - it.only('should render properly', () => { + it('should render properly', () => { cy.mountWithProviders(mockReactFlow()) cy.getByTestId('device-description') @@ -21,6 +23,22 @@ describe('NodeDevice', () => { cy.get('@capabilities').eq(1).should('have.attr', 'data-type', 'DISCOVER') }) + it('should render the selected adapter properly', () => { + cy.mountWithProviders( + + ) + cy.getByTestId('device-description').should('contain', 'Simulation') + cy.get('[role="toolbar"] button').should('have.length', 1) + cy.get('[role="toolbar"] button').eq(0).should('have.attr', 'aria-label', 'Open the overview panel') + + cy.getByTestId('test-navigate-pathname').should('have.text', '/') + cy.get('[role="toolbar"] button').eq(0).click() + cy.getByTestId('test-navigate-pathname').should('have.text', `/workspace/node/${MOCK_NODE_DEVICE.id}`) + }) + it('should be accessible', () => { cy.injectAxe() cy.mountWithProviders(mockReactFlow()) 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 020232a0de..0fc93e736d 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 @@ -2,14 +2,19 @@ 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 { 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 { useContextMenu } from '@/modules/Workspace/hooks/useContextMenu.ts' +import ContextualToolbar from '@/modules/Workspace/components/nodes/ContextualToolbar.tsx' -const NodeDevice: FC> = ({ selected, data }) => { +const NodeDevice: FC> = ({ id, selected, data }) => { + const { onContextMenu } = useContextMenu(id, selected, '/workspace/node') const { category, capabilities } = data + return ( <> + > = ({ selected, data }) => { textAlign="center" p={3} borderTopRadius={30} + onDoubleClick={onContextMenu} + onContextMenu={onContextMenu} > diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/types.ts b/hivemq-edge/src/frontend/src/modules/Workspace/types.ts index b9b4ddce6b..5f82fc202b 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/types.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/types.ts @@ -1,4 +1,5 @@ import { Edge, Node, OnEdgesChange, OnNodesChange, NodeAddChange, EdgeAddChange, Rect } from 'reactflow' +import { ProtocolAdapter } from '@/api/__generated__' export interface EdgeFlowOptions { showTopics: boolean @@ -76,3 +77,8 @@ export interface TopicTreeMetadata { label: string count: number } + +/** + * @deprecated This is a mock, will need to be replaced by OpenAPI specs when available + */ +export type DeviceMetadata = ProtocolAdapter 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 850f133dd7..e5d510b2cf 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,11 @@ -import { ProtocolAdapter } from '@/api/__generated__' -import { IconType } from 'react-icons' +import { type ProtocolAdapter } 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 { MdInput, MdOutlineFindInPage, MdOutput } from 'react-icons/md' +import { MdOutlineFindInPage } from 'react-icons/md' +import { HmInput, HmOutput } from '@/components/react-icons/hm' /** * @deprecated This is a mock, replacing the missing WRITE capability from the adapters @@ -33,7 +34,7 @@ type CapabilityType = ArrayElement | 'WRITE' * @deprecated This is a mock, replacing the missing WRITE capability from the adapters */ export const deviceCapabilityIcon: Record = { - ['READ']: MdOutput, + ['READ']: HmOutput, ['DISCOVER']: MdOutlineFindInPage, - ['WRITE']: MdInput, + ['WRITE']: HmInput, } diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.spec.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.spec.ts index e0906823f5..02dfb98d1c 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.spec.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.spec.ts @@ -14,6 +14,7 @@ import { discoverAdapterTopics, flattenObject, getBridgeTopics, + getMainRootFromPath, getPropertiesFromPath, getTopicPaths, mergeAllTopics, @@ -181,6 +182,17 @@ describe('mergeAllTopics', () => { }) }) +describe('getMainRootFromPath', () => { + it('should work', () => { + expect(getMainRootFromPath([])).toStrictEqual(undefined) + expect(getMainRootFromPath(['root'])).toStrictEqual('root') + expect(getMainRootFromPath(['root.'])).toStrictEqual('root') + expect(getMainRootFromPath(['root-'])).toStrictEqual('root-') + expect(getMainRootFromPath(['root.*.first.one'])).toStrictEqual('root') + expect(getMainRootFromPath(['root.*.first.one', 'root2.*.next.one'])).toStrictEqual('root') + }) +}) + describe('getPropertiesFromPath', () => { it('should work', () => { expect(getPropertiesFromPath('test', undefined)).toStrictEqual(undefined) diff --git a/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.ts b/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.ts index 659b341b17..dc754060e7 100644 --- a/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.ts +++ b/hivemq-edge/src/frontend/src/modules/Workspace/utils/topics-utils.ts @@ -39,6 +39,15 @@ export const flattenObject = (input: RJSFSchema, root = '') => { return result } +export const getMainRootFromPath = (paths: string[]): string | undefined => { + const firstPath = paths.shift() + if (!firstPath) return undefined + + const root = firstPath.split('.').shift() + if (!root) return undefined + return root +} + export const getTopicPaths = (configSchema: RJSFSchema) => { const flattenSchema = flattenObject(configSchema) return (