Skip to content

Commit

Permalink
Merge pull request #556
Browse files Browse the repository at this point in the history
refactor(25464): Improver visual identity of the workspace

* refactor(25464): export visual mapping for adapter status

* refactor(25464): export zoom selectors

* refactor(25464): add glue to adapter and bridge nodes

* refactor(25464): add style to the minimap

* refactor(25464): change icon

* refactor(25464): refactor nodes to introduce skeleton rendering

* refactor(25464): hide toolbars when nodes are being dragged

* refactor(25464): add devices to the group when adapters are added

* test(25464): fix tests

* fix(25464): change theme to match data hub reactflow

* refactor(25464): refactor the toolbar button group for reuse

* test(25464): add tests

* fix(25464): fix edge markers

* test(25464): fix tests

* fix(25464): fix review issues
  • Loading branch information
vanch3d committed Sep 11, 2024
1 parent 217b2e6 commit 17cfaed
Show file tree
Hide file tree
Showing 20 changed files with 426 additions and 167 deletions.
Original file line number Diff line number Diff line change
@@ -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<ConnectionStatusBadgeProps> = ({ status }) => {
const ConnectionStatusBadge: FC<ConnectionStatusBadgeProps> = ({ status, skeleton = false }) => {
const { t } = useTranslation()

const mapping =
Expand All @@ -27,6 +20,16 @@ const ConnectionStatusBadge: FC<ConnectionStatusBadgeProps> = ({ status }) => {
: status?.connection || Status.connection.UNKNOWN
]

if (skeleton)
return (
<SkeletonCircle
size="8"
startColor={`${mapping.color}.300`}
endColor={`${mapping.color}.500`}
aria-label={mapping.text}
/>
)

return (
<Badge variant="subtle" colorScheme={mapping.color} borderRadius={15} data-testid="connection-status">
{t('hivemq.connection.status', { context: mapping.text })}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ToolbarButtonGroup>
<IconButton icon={<Icon as={LuAlbum} />} aria-label="first button" onClick={cy.stub().as('button1')} />
<IconButton icon={<Icon as={LuBaby} />} aria-label="second button" onClick={cy.stub().as('button2')} />
</ToolbarButtonGroup>,
{
wrapper: ({ children }) => <ReactFlowProvider>{children}</ReactFlowProvider>,
}
)

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(
<ToolbarButtonGroup orientation="horizontal">
<IconButton icon={<Icon as={LuAlbum} />} aria-label="first button" onClick={cy.stub().as('button1')} />
<IconButton icon={<Icon as={LuBaby} />} aria-label="second button" onClick={cy.stub().as('button2')} />
</ToolbarButtonGroup>,
{
wrapper: ({ children }) => <ReactFlowProvider>{children}</ReactFlowProvider>,
}
)

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(
<ToolbarButtonGroup>
<IconButton icon={<Icon as={LuAlbum} />} aria-label="first button" />
<IconButton icon={<Icon as={LuBaby} />} aria-label="second button" />
</ToolbarButtonGroup>,
{
wrapper: ({ children }) => <ReactFlowProvider>{children}</ReactFlowProvider>,
}
)
cy.checkAccessibility()
cy.percySnapshot('The login page on loading')
})
})
Original file line number Diff line number Diff line change
@@ -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<ButtonGroupProps> = ({ children, ...rest }) => {
const zoomFactor = useStore((s) => s.transform[2])

const getToolbarSize = useMemo<string>(() => {
if (zoomFactor >= 1.5) return 'lg'
if (zoomFactor >= 1) return 'md'
if (zoomFactor >= 0.75) return 'sm'
return 'xs'
}, [zoomFactor])

return (
<ButtonGroup
size={getToolbarSize}
variant="solid"
colorScheme="gray"
orientation="vertical"
isAttached
sx={{
boxShadow: '0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19)',
backgroundColor: 'rgba(0, 0, 0, 0.19)',
}}
{...rest}
>
{children}
</ButtonGroup>
)
}

export default ToolbarButtonGroup
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,17 +14,9 @@ interface NodeToolbarProps extends ButtonGroupProps {

const NodeDatahubToolbar: FC<NodeToolbarProps> = (props) => {
const { t } = useTranslation('datahub')
const zoomFactor = useStore((s) => s.transform[2])

const getToolbarSize = useMemo<string>(() => {
if (zoomFactor >= 1.5) return 'lg'
if (zoomFactor >= 1) return 'md'
if (zoomFactor >= 0.75) return 'sm'
return 'xs'
}, [zoomFactor])

return (
<ButtonGroup size={getToolbarSize} variant="solid" colorScheme="gray" isAttached {...props}>
<ToolbarButtonGroup orientation="horizontal" isAttached {...props}>
<IconButton
icon={<LuFileEdit />}
data-testid="node-toolbar-edit"
Expand All @@ -44,7 +36,7 @@ const NodeDatahubToolbar: FC<NodeToolbarProps> = (props) => {
colorScheme="red"
onClick={props.onDelete}
/>
</ButtonGroup>
</ToolbarButtonGroup>
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand Down Expand Up @@ -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 (
<ReactFlow
id="edge-workspace-canvas"
Expand All @@ -64,16 +96,39 @@ const ReactFlowWrapper = () => {
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDrag={onReactFlowNodeDrag}
fitView
snapToGrid={true}
nodesConnectable={false}
deleteKeyCode={null}
proOptions={proOptions}
>
<Box role="toolbar" aria-label={t('workspace.controls.aria-label')} aria-controls="edge-workspace-canvas">
<SelectionListener />
<StatusListener />
<Background />
<CanvasControls />
<MiniMap
style={{ backgroundColor: 'var(--chakra-colors-chakra-body-bg)', margin: 0 }}
zoomable
pannable
nodeClassName={(node) => node.type || ''}
nodeComponent={(miniMapNode) => {
if (miniMapNode.className === NodeTypes.EDGE_NODE)
return <circle cx={miniMapNode.x} cy={miniMapNode.y} r="50" fill="#ffc000" />
if (miniMapNode.className === NodeTypes.DEVICE_NODE || miniMapNode.className === NodeTypes.HOST_NODE)
return null
return (
<rect
x={miniMapNode.x}
y={miniMapNode.y}
width={miniMapNode.width}
height={miniMapNode.height}
fill="#e2e2e2"
/>
)
}}
/>
</Box>
<SuspenseOutlet />
</ReactFlow>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,24 +9,20 @@ 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<ControlProps> = ({ onInteractiveChange }) => {
const { t } = useTranslation()
const { optionDrawer } = useEdgeFlowContext()
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)
Expand Down
Loading

0 comments on commit 17cfaed

Please sign in to comment.