diff --git a/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts b/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts index 596dc95db..2953955d3 100644 --- a/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts @@ -25,26 +25,30 @@ describe('Tests for sidebar expression configuration', () => { cy.uploadFixture('flows/camelRoute/basic.yaml'); cy.openDesignPage(); - cy.openStepConfigurationTab('setHeader'); + cy.openStepConfigurationTabByPath('custom-node__route.from.steps.0.setHeader'); cy.selectFormTab('All'); cy.selectExpression('JQ'); cy.interactWithConfigInputObject('expression', '.id'); cy.addExpressionResultType('java.lang.String'); cy.interactWithConfigInputObject('trim'); + // TODO: Closing the configuration panel because adding a new step keep the selection status, + // but closes the panel. This will be fixed in https://github.com/KaotoIO/kaoto/issues/1923 + cy.closeStepConfigurationTab(); + cy.selectAppendNode('setHeader'); cy.chooseFromCatalog('processor', 'setHeader'); cy.checkNodeExist('setHeader', 2); - cy.openStepConfigurationTab('setHeader', 1); + cy.openStepConfigurationTabByPath('custom-node__route.from.steps.1.setHeader'); cy.selectFormTab('All'); cy.selectExpression('JQ'); cy.interactWithConfigInputObject('expression', '.name'); cy.addExpressionResultType('java.lang.String'); cy.interactWithConfigInputObject('trim'); - cy.openStepConfigurationTab('setHeader', 0); + cy.openStepConfigurationTabByPath('custom-node__route.from.steps.0.setHeader'); // Check the configured fields didn't disappear from the first node cy.checkConfigCheckboxObject('trim', true); @@ -52,7 +56,7 @@ describe('Tests for sidebar expression configuration', () => { cy.checkConfigInputObject('expression', '.id'); // Check the configured fields didn't disappear from the second node - cy.openStepConfigurationTab('setHeader', 0); + cy.openStepConfigurationTabByPath('custom-node__route.from.steps.1.setHeader'); cy.checkConfigCheckboxObject('trim', true); cy.addExpressionResultType('java.lang.String'); cy.checkConfigInputObject('expression', '.name'); diff --git a/packages/ui-tests/cypress/support/cypress.d.ts b/packages/ui-tests/cypress/support/cypress.d.ts index c1a693900..162ab7659 100644 --- a/packages/ui-tests/cypress/support/cypress.d.ts +++ b/packages/ui-tests/cypress/support/cypress.d.ts @@ -46,6 +46,7 @@ declare global { // design openGroupConfigurationTab(step: string, stepIndex?: number): Chainable>; openStepConfigurationTab(step: string, stepIndex?: number): Chainable>; + openStepConfigurationTabByPath(path: string): Chainable>; toggleExpandGroup(groupName: string): Chainable>; fitToScreen(): Chainable>; closeStepConfigurationTab(): Chainable>; diff --git a/packages/ui-tests/cypress/support/next-commands/design.ts b/packages/ui-tests/cypress/support/next-commands/design.ts index d25de1f10..b2e3e1c55 100644 --- a/packages/ui-tests/cypress/support/next-commands/design.ts +++ b/packages/ui-tests/cypress/support/next-commands/design.ts @@ -7,6 +7,10 @@ Cypress.Commands.add('openStepConfigurationTab', (step: string, stepIndex?: numb cy.get(`g[data-nodelabel^="${step}"]`).eq(stepIndex).click({ force: true }); }); +Cypress.Commands.add('openStepConfigurationTabByPath', (path: string) => { + cy.get(`g[data-testid="${path}"]`).click({ force: true }); +}); + Cypress.Commands.add('openGroupConfigurationTab', (group: string, groupIndex?: number) => { groupIndex = groupIndex ?? 0; cy.get(`g[data-grouplabel^="${group}"]`).eq(groupIndex).click({ force: true }); diff --git a/packages/ui/src/components/Visualization/Canvas/Canvas.tsx b/packages/ui/src/components/Visualization/Canvas/Canvas.tsx index c044715df..8fd2ba68f 100644 --- a/packages/ui/src/components/Visualization/Canvas/Canvas.tsx +++ b/packages/ui/src/components/Visualization/Canvas/Canvas.tsx @@ -90,8 +90,16 @@ export const Canvas: FunctionComponent> = ({ enti }, }; - controller.fromModel(model, false); - setInitialized(true); + if (!initialized) { + controller.fromModel(model, false); + setInitialized(true); + return; + } + + requestAnimationFrame(() => { + controller.fromModel(model, true); + controller.getGraph().layout(); + }); }, [controller, entities, visibleFlows]); const handleSelection = useCallback((selectedIds: string[]) => { diff --git a/packages/ui/src/components/Visualization/Canvas/controller.service.ts b/packages/ui/src/components/Visualization/Canvas/controller.service.ts index 7b249f0f8..f054745bb 100644 --- a/packages/ui/src/components/Visualization/Canvas/controller.service.ts +++ b/packages/ui/src/components/Visualization/Canvas/controller.service.ts @@ -12,7 +12,7 @@ import { withPanZoom, } from '@patternfly/react-topology'; import { CustomGroupWithSelection, CustomNodeWithSelection, NoBendpointsEdge } from '../Custom'; -import { PlaceholderNode } from '../Custom/Node/PlaceholderNode'; +import { PlaceholderNodeWithDnD } from '../Custom/Node/PlaceholderNode'; import { LayoutType } from './canvas.models'; import { CustomEdge } from '../Custom/Edge/CustomEdge'; @@ -52,7 +52,7 @@ export class ControllerService { case 'group': return CustomGroupWithSelection; case 'node-placeholder': - return PlaceholderNode; + return PlaceholderNodeWithDnD; default: switch (kind) { case ModelKind.graph: diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.tsx index 9dfec7612..1ae2eedb1 100644 --- a/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.tsx +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.tsx @@ -7,35 +7,55 @@ import { withSelection, } from '@patternfly/react-topology'; import { FunctionComponent } from 'react'; -import { CanvasDefaults } from '../../Canvas/canvas.defaults'; import { CanvasNode } from '../../Canvas/canvas.models'; import { NodeContextMenuFn } from '../ContextMenu/NodeContextMenu'; -import { CustomGroupCollapsible } from './CustomGroupCollapsible'; +import { CustomNodeObserver } from '../Node/CustomNode'; +import { useCollapseStep } from '../hooks/collapse-step.hook'; +import { CustomGroupExpanded } from './CustomGroupExpanded'; type IDefaultGroup = Parameters[0]; interface ICustomGroup extends IDefaultGroup { element: GraphElement; } -const CustomGroup: FunctionComponent = observer(({ element, ...rest }) => { - const vizNode = element.getData()?.vizNode; - const label = vizNode?.getNodeLabel(); - +const CustomGroupInner: FunctionComponent = observer(({ element, onCollapseChange, ...rest }) => { if (!isNode(element)) { - throw new Error('CustomGroup must be used only on Node elements'); + throw new Error('CustomGroupInner must be used only on Node elements'); + } + + const { onCollapseNode, onExpandNode } = useCollapseStep(element); + + if (element.isCollapsed()) { + return ( + { + onExpandNode(); + onCollapseChange?.(element, true); + }} + /> + ); } return ( - { + onCollapseNode(); + onCollapseChange?.(element, false); + }} /> ); }); +const CustomGroup: FunctionComponent = ({ element, ...rest }: ICustomGroup) => { + if (!isNode(element)) { + throw new Error('CustomGroup must be used only on Node elements'); + } + + return ; +}; + export const CustomGroupWithSelection = withSelection()(withContextMenu(NodeContextMenuFn)(CustomGroup)); diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx deleted file mode 100644 index b5e78d6c6..000000000 --- a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { isNode, observer } from '@patternfly/react-topology'; -import { FunctionComponent } from 'react'; -import { useCollapseStep } from '../hooks/collapse-step.hook'; -import { CustomNodeWithSelection } from '../Node/CustomNode'; -import { CustomGroupExpanded } from './CustomGroupExpanded'; -import { CustomGroupProps } from './Group.models'; - -export const CustomGroupCollapsible: FunctionComponent = observer( - ({ className, element, selected, onCollapseChange, ...rest }) => { - if (!isNode(element)) { - throw new Error('CustomGroupCollapsible must be used only on Node elements'); - } - - const { onCollapseNode, onExpandNode } = useCollapseStep(element); - - if (element.isCollapsed()) { - return ( - { - onExpandNode(); - onCollapseChange?.(element, true); - }} - /> - ); - } - - return ( - { - onCollapseNode(); - onCollapseChange?.(element, false); - }} - /> - ); - }, -); diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx index 91f939028..b134b8290 100644 --- a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx @@ -11,7 +11,6 @@ import { observer, useAnchor, useHover, - useSelection, withDndDrop, } from '@patternfly/react-topology'; import { FunctionComponent, useContext, useRef } from 'react'; @@ -21,13 +20,13 @@ import { LayoutType } from '../../Canvas'; import { StepToolbar } from '../../Canvas/StepToolbar/StepToolbar'; import { CanvasDefaults } from '../../Canvas/canvas.defaults'; import { AddStepIcon } from '../Edge/AddStepIcon'; +import { customGroupExpandedDropTargetSpec } from '../customComponentUtils'; import { TargetAnchor } from '../target-anchor'; import './CustomGroupExpanded.scss'; import { CustomGroupProps } from './Group.models'; -import { customGroupExpandedDropTargetSpec } from '../customComponentUtils'; export const CustomGroupExpandedInner: FunctionComponent = observer( - ({ element, onContextMenu, onCollapseToggle, dndDropRef, droppable }) => { + ({ element, onContextMenu, onCollapseToggle, dndDropRef, droppable, selected, onSelect }) => { if (!isNode(element)) { throw new Error('CustomGroupExpanded must be used only on Node elements'); } @@ -37,7 +36,6 @@ export const CustomGroupExpandedInner: FunctionComponent = obs const label = vizNode?.getNodeLabel(settingsAdapter.getSettings().nodeLabel); const isDisabled = !!vizNode?.getComponentSchema()?.definition?.disabled; const tooltipContent = vizNode?.getTooltipContent(); - const [isSelected, onSelect] = useSelection(); const [isGHover, gHoverRef] = useHover(CanvasDefaults.HOVER_DELAY_IN, CanvasDefaults.HOVER_DELAY_OUT); const [isToolbarHover, toolbarHoverRef] = useHover( CanvasDefaults.HOVER_DELAY_IN, @@ -46,8 +44,8 @@ export const CustomGroupExpandedInner: FunctionComponent = obs const boxRef = useRef(null); const shouldShowToolbar = settingsAdapter.getSettings().nodeToolbarTrigger === NodeToolbarTrigger.onHover - ? isGHover || isToolbarHover || isSelected - : isSelected; + ? isGHover || isToolbarHover || selected + : selected; const shouldShowAddStep = shouldShowToolbar && vizNode?.getNodeInteraction().canHaveNextStep && vizNode.getNextNode() === undefined; const isHorizontal = element.getGraph().getLayout() === LayoutType.DagreHorizontal; @@ -80,7 +78,7 @@ export const CustomGroupExpandedInner: FunctionComponent = obs className="custom-group" data-testid={`custom-group__${vizNode.id}`} data-grouplabel={label} - data-selected={isSelected} + data-selected={selected} data-disabled={isDisabled} data-toolbar-open={shouldShowToolbar} onClick={onSelect} diff --git a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.test.tsx b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.test.tsx new file mode 100644 index 000000000..584577ae7 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.test.tsx @@ -0,0 +1,45 @@ +import { BaseEdge, BaseGraph, BaseNode, ElementContext, VisualizationProvider } from '@patternfly/react-topology'; +import { act, render } from '@testing-library/react'; +import { TestProvidersWrapper } from '../../../../stubs'; +import { ControllerService } from '../../Canvas/controller.service'; +import { CustomNodeObserver } from './CustomNode'; + +describe('CustomNode', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should throw an error if not used on Node elements', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const edgeElement = new BaseEdge(); + + expect(() => { + act(() => { + render(); + }); + }).toThrow('CustomNode must be used only on Node elements'); + }); + + it('should render without error', () => { + const parentElement = new BaseGraph(); + const element = new BaseNode(); + const controller = ControllerService.createController(); + parentElement.setController(controller); + element.setController(controller); + element.setParent(parentElement); + + const { Provider } = TestProvidersWrapper(); + + const wrapper = render( + + + + + + + , + ); + + expect(wrapper.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx index 161af8b6e..7ca9142f2 100644 --- a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx +++ b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx @@ -2,8 +2,8 @@ import { Icon } from '@patternfly/react-core'; import { ArrowDownIcon, ArrowRightIcon, BanIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; import { AnchorEnd, - DefaultNode, DEFAULT_LAYER, + DefaultNode, DragObjectWithType, DragSourceSpec, DragSpecOperationType, @@ -20,27 +20,25 @@ import { TOP_LAYER, useAnchor, useCombineRefs, - useHover, useDragNode, - useSelection, + useHover, withContextMenu, withDndDrop, withSelection, - useVisualizationController, } from '@patternfly/react-topology'; import clsx from 'clsx'; import { FunctionComponent, useContext, useRef } from 'react'; +import { useEntityContext } from '../../../../hooks/useEntityContext/useEntityContext'; import { AddStepMode, IVisualizationNode, NodeToolbarTrigger } from '../../../../models'; import { SettingsContext } from '../../../../providers'; import { CanvasDefaults } from '../../Canvas/canvas.defaults'; import { CanvasNode, LayoutType } from '../../Canvas/canvas.models'; import { StepToolbar } from '../../Canvas/StepToolbar/StepToolbar'; import { NodeContextMenuFn } from '../ContextMenu/NodeContextMenu'; +import { customNodeDropTargetSpec } from '../customComponentUtils'; import { AddStepIcon } from '../Edge/AddStepIcon'; import { TargetAnchor } from '../target-anchor'; import './CustomNode.scss'; -import { useEntityContext } from '../../../../hooks/useEntityContext/useEntityContext'; -import { customNodeDropTargetSpec } from '../customComponentUtils'; type DefaultNodeProps = Parameters[0]; @@ -50,22 +48,20 @@ interface CustomNodeProps extends DefaultNodeProps { onCollapseToggle?: () => void; } -const CustomNode: FunctionComponent = observer( - ({ element, onContextMenu, onCollapseToggle, dndDropRef, hover, droppable, canDrop }) => { +const CustomNodeInner: FunctionComponent = observer( + ({ element, onContextMenu, onCollapseToggle, dndDropRef, hover, droppable, canDrop, selected, onSelect }) => { if (!isNode(element)) { - throw new Error('CustomNode must be used only on Node elements'); + throw new Error('CustomNodeInner must be used only on Node elements'); } const vizNode: IVisualizationNode | undefined = element.getData()?.vizNode; const entitiesContext = useEntityContext(); - const controller = useVisualizationController(); const settingsAdapter = useContext(SettingsContext); const label = vizNode?.getNodeLabel(settingsAdapter.getSettings().nodeLabel); const isDisabled = !!vizNode?.getComponentSchema()?.definition?.disabled; const tooltipContent = vizNode?.getTooltipContent(); const validationText = vizNode?.getNodeValidationText(); const doesHaveWarnings = !isDisabled && !!validationText; - const [isSelected, onSelect] = useSelection(); const [isGHover, gHoverRef] = useHover(CanvasDefaults.HOVER_DELAY_IN, CanvasDefaults.HOVER_DELAY_OUT); const [isToolbarHover, toolbarHoverRef] = useHover( CanvasDefaults.HOVER_DELAY_IN, @@ -75,8 +71,8 @@ const CustomNode: FunctionComponent = observer( const boxRef = useRef(null); const shouldShowToolbar = settingsAdapter.getSettings().nodeToolbarTrigger === NodeToolbarTrigger.onHover - ? isGHover || isToolbarHover || isSelected - : isSelected; + ? isGHover || isToolbarHover || selected + : selected; const shouldShowAddStep = shouldShowToolbar && vizNode?.getNodeInteraction().canHaveNextStep && vizNode.getNextNode() === undefined; const isHorizontal = element.getGraph().getLayout() === LayoutType.DagreHorizontal; @@ -94,9 +90,13 @@ const CustomNode: FunctionComponent = observer( > = { item: { type: '#node#' }, begin: () => { - const graph = controller.getGraph(); // Hide all edges when dragging starts - graph.getEdges().forEach((edge) => edge.setVisible(false)); + element + .getGraph() + .getEdges() + .forEach((edge) => { + edge.setVisible(false); + }); }, canDrag: () => { if (settingsAdapter.getSettings().experimentalFeatures.enableDragAndDrop) { @@ -110,18 +110,23 @@ const CustomNode: FunctionComponent = observer( const draggedNodePath = element.getData().vizNode.data.path; dropResult.getData()?.vizNode?.moveNodeTo(draggedNodePath); // Set an empty model to clear the graph - controller.fromModel({ + element.getController().fromModel({ nodes: [], edges: [], }); - entitiesContext.updateEntitiesFromCamelResource(); + + requestAnimationFrame(() => { + entitiesContext.updateEntitiesFromCamelResource(); + }); } else { // Show all edges after dropping - controller + element .getGraph() .getEdges() - .forEach((edge) => edge.setVisible(true)); - controller.getGraph().layout(); + .forEach((edge) => { + edge.setVisible(true); + }); + element.getGraph().layout(); } }, }; @@ -148,7 +153,7 @@ const CustomNode: FunctionComponent = observer( className="custom-node" data-testid={`custom-node__${vizNode.id}`} data-nodelabel={label} - data-selected={isSelected} + data-selected={selected} data-disabled={isDisabled} data-toolbar-open={shouldShowToolbar} data-warning={doesHaveWarnings} @@ -244,6 +249,15 @@ const CustomNode: FunctionComponent = observer( }, ); -export const CustomNodeWithSelection = withDndDrop(customNodeDropTargetSpec)( - withSelection()(withContextMenu(NodeContextMenuFn)(CustomNode)), +const CustomNode: FunctionComponent = ({ element, ...rest }: CustomNodeProps) => { + if (!isNode(element)) { + throw new Error('CustomNode must be used only on Node elements'); + } + return ; +}; + +export const CustomNodeObserver = observer(CustomNode); + +export const CustomNodeWithSelection = withSelection()( + withDndDrop(customNodeDropTargetSpec)(withContextMenu(NodeContextMenuFn)(CustomNode)), ); diff --git a/packages/ui/src/components/Visualization/Custom/Node/PlaceholderNode.test.tsx b/packages/ui/src/components/Visualization/Custom/Node/PlaceholderNode.test.tsx new file mode 100644 index 000000000..440ba5463 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Node/PlaceholderNode.test.tsx @@ -0,0 +1,45 @@ +import { BaseEdge, BaseGraph, BaseNode, ElementContext, VisualizationProvider } from '@patternfly/react-topology'; +import { act, render } from '@testing-library/react'; +import { TestProvidersWrapper } from '../../../../stubs'; +import { ControllerService } from '../../Canvas/controller.service'; +import { PlaceholderNodeObserver } from './PlaceholderNode'; + +describe('PlaceholderNode', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should throw an error if not used on Node elements', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const edgeElement = new BaseEdge(); + + expect(() => { + act(() => { + render(); + }); + }).toThrow('PlaceholderNode must be used only on Node elements'); + }); + + it('should render without error', () => { + const parentElement = new BaseGraph(); + const element = new BaseNode(); + const controller = ControllerService.createController(); + parentElement.setController(controller); + element.setController(controller); + element.setParent(parentElement); + + const { Provider } = TestProvidersWrapper(); + + const wrapper = render( + + + + + + + , + ); + + expect(wrapper.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/components/Visualization/Custom/Node/PlaceholderNode.tsx b/packages/ui/src/components/Visualization/Custom/Node/PlaceholderNode.tsx index 38ff2e6e8..24e97a52e 100644 --- a/packages/ui/src/components/Visualization/Custom/Node/PlaceholderNode.tsx +++ b/packages/ui/src/components/Visualization/Custom/Node/PlaceholderNode.tsx @@ -30,7 +30,7 @@ interface PlaceholderNodeInnerProps extends DefaultNodeProps { const PlaceholderNodeInner: FunctionComponent = observer( ({ element, dndDropRef, hover, canDrop }) => { if (!isNode(element)) { - throw new Error('PlaceholderNode must be used only on Node elements'); + throw new Error('PlaceholderNodeInner must be used only on Node elements'); } const vizNode: IVisualizationNode | undefined = element.getData()?.vizNode; const settingsAdapter = useContext(SettingsContext); @@ -93,4 +93,16 @@ const PlaceholderNodeInner: FunctionComponent = obser }, ); -export const PlaceholderNode = withDndDrop(placeholderNodeDropTargetSpec)(PlaceholderNodeInner); +const PlaceholderNode: FunctionComponent = ({ + element, + ...rest +}: PlaceholderNodeInnerProps) => { + if (!isNode(element)) { + throw new Error('PlaceholderNode must be used only on Node elements'); + } + return ; +}; + +export const PlaceholderNodeObserver = observer(PlaceholderNode); + +export const PlaceholderNodeWithDnD = withDndDrop(placeholderNodeDropTargetSpec)(PlaceholderNode); diff --git a/packages/ui/src/components/Visualization/Custom/Node/__snapshots__/CustomNode.test.tsx.snap b/packages/ui/src/components/Visualization/Custom/Node/__snapshots__/CustomNode.test.tsx.snap new file mode 100644 index 000000000..a5eaf37ca --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Node/__snapshots__/CustomNode.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomNode should render without error 1`] = ``; diff --git a/packages/ui/src/components/Visualization/Custom/Node/__snapshots__/PlaceholderNode.test.tsx.snap b/packages/ui/src/components/Visualization/Custom/Node/__snapshots__/PlaceholderNode.test.tsx.snap new file mode 100644 index 000000000..bc1a29add --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Node/__snapshots__/PlaceholderNode.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PlaceholderNode should render without error 1`] = ``;