diff --git a/packages/ui-tests/cypress/e2e/designer/rootContainerConfig/rootContainersConf.cy.ts b/packages/ui-tests/cypress/e2e/designer/rootContainerConfig/rootContainersConf.cy.ts index 3aab3bd47..8bae9dfa0 100644 --- a/packages/ui-tests/cypress/e2e/designer/rootContainerConfig/rootContainersConf.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/rootContainerConfig/rootContainersConf.cy.ts @@ -8,24 +8,14 @@ describe('Test for camel route root containers configuration', () => { cy.openDesignPage(); cy.toggleRouteVisibility(1); - cy.get('[data-id^="route-1234"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__action-icon') - .eq(0) - .click(); - - cy.get('[data-id^="route-4321"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__action-icon') - .eq(0) - .click(); + cy.toggleExpandGroup('route-1234'); + cy.toggleExpandGroup('route-4321'); + cy.checkNodeExist('timer', 0); cy.checkNodeExist('log', 0); - cy.get('[data-id^="route-4321"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__action-icon') - .eq(0) - .click(); + + cy.toggleExpandGroup('route-4321'); + cy.checkNodeExist('timer', 1); cy.checkNodeExist('log', 1); }); @@ -35,11 +25,10 @@ describe('Test for camel route root containers configuration', () => { cy.uploadFixture('flows/camelRoute/multiflow.yaml'); cy.openDesignPage(); cy.toggleRouteVisibility(1); - cy.get('[data-id^="route-1234"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__action-icon') - .click(); + + cy.toggleExpandGroup('route-1234'); cy.toggleRouteVisibility(1); + cy.checkNodeExist('timer', 0); cy.checkNodeExist('log', 0); }); @@ -48,10 +37,8 @@ describe('Test for camel route root containers configuration', () => { cy.uploadFixture('flows/camelRoute/basic.yaml'); cy.openDesignPage(); - cy.get('[data-id^="camel-route"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__label__background') - .click(); + cy.openStepConfigurationTab('camel-route'); + cy.interactWithConfigInputObject('description', 'test.description'); cy.interactWithConfigInputObject('group', 'test.group'); cy.interactWithConfigInputObject('inputType.description', 'test.inputType.description'); @@ -102,10 +89,7 @@ describe('Test for camel route root containers configuration', () => { cy.uploadFixture('flows/kamelet/basic.yaml'); cy.openDesignPage(); - cy.get('[data-id^="eip-action"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__label__background') - .click(); + cy.openStepConfigurationTab('eip-action'); cy.interactWithConfigInputObject('name', 'test.name'); cy.interactWithConfigInputObject('title', 'test.title'); @@ -144,7 +128,7 @@ describe('Test for camel route root containers configuration', () => { cy.uploadFixture('flows/pipe/basic.yaml'); cy.openDesignPage(); - cy.get('[data-id^="pipe"] .pf-topology__group__label text').click({ force: true }); + cy.openStepConfigurationTab('pipe'); cy.get(`input[name="name"]`).clear(); cy.get(`input[name="name"]`).type('testName'); @@ -187,23 +171,13 @@ describe('Test for camel route root containers configuration', () => { cy.openDesignPage(); cy.toggleRouteVisibility(1); - cy.get('[data-id^="route-4321"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__action-icon') - .eq(1) - .click(); - cy.get('[data-testid="context-menu-container-remove"]').click(); + cy.performNodeAction('route-4321', 'container-remove'); cy.contains('button', 'Cancel').click(); cy.checkNodeExist('timer', 2); cy.checkNodeExist('log', 2); - cy.get('[data-id^="route-4321"]') - .find('.pf-topology__group__label') - .find('.pf-topology__node__action-icon') - .eq(1) - .click(); - cy.get('[data-testid="context-menu-container-remove"]').click(); + cy.performNodeAction('route-4321', 'container-remove'); cy.contains('button', 'Confirm').click(); cy.checkNodeExist('timer', 1); diff --git a/packages/ui-tests/cypress/support/cypress.d.ts b/packages/ui-tests/cypress/support/cypress.d.ts index e145a9a12..5bfa3c26c 100644 --- a/packages/ui-tests/cypress/support/cypress.d.ts +++ b/packages/ui-tests/cypress/support/cypress.d.ts @@ -5,7 +5,15 @@ export {}; declare global { - type ActionType = 'append' | 'prepend' | 'replace' | 'insert' | 'insert-special' | 'delete' | 'disable'; + type ActionType = + | 'append' + | 'prepend' + | 'replace' + | 'insert' + | 'insert-special' + | 'delete' + | 'disable' + | 'container-remove'; namespace Cypress { interface Chainable { @@ -34,6 +42,7 @@ declare global { showAllRoutes(): Chainable>; // design openStepConfigurationTab(step: string, stepIndex?: number): Chainable>; + toggleExpandGroup(groupName: string, groupIndex?: number): Chainable>; fitToScreen(): Chainable>; closeStepConfigurationTab(): Chainable>; removeNodeByName(inputName: string, nodeIndex?: number): Chainable>; @@ -43,6 +52,7 @@ declare global { selectInsertSpecialNode(inputName: string, nodeIndex?: number): Chainable>; selectInsertNode(inputName: string, nodeIndex?: number): Chainable>; selectPrependNode(inputName: string, nodeIndex?: number): Chainable>; + selectRemoveGroup(groupName: string, nodeIndex?: number): Chainable>; performNodeAction(nodeName: string, action: ActionType, nodeIndex?: number): Chainable>; checkNodeExist(inputName: string, nodesCount: number): Chainable>; checkEdgeExists(sourceName: string, targetName: string): Chainable>; diff --git a/packages/ui-tests/cypress/support/next-commands/design.ts b/packages/ui-tests/cypress/support/next-commands/design.ts index f5e576c3b..8b946d46c 100644 --- a/packages/ui-tests/cypress/support/next-commands/design.ts +++ b/packages/ui-tests/cypress/support/next-commands/design.ts @@ -7,6 +7,11 @@ Cypress.Commands.add('openStepConfigurationTab', (step: string, stepIndex?: numb cy.get(`[data-nodelabel="${step}"]`).eq(stepIndex).click({ force: true }); }); +Cypress.Commands.add('toggleExpandGroup', (groupName: string, groupIndex?: number) => { + groupIndex = groupIndex ?? 0; + cy.get(`[data-testid="collapseButton-${groupName}"]`).eq(groupIndex).click({ force: true }); +}); + Cypress.Commands.add('closeStepConfigurationTab', () => { cy.get('[data-testid="close-side-bar"]').click(); cy.get('.pf-topology-resizable-side-bar').should('not.exist'); @@ -49,6 +54,10 @@ Cypress.Commands.add('selectPrependNode', (nodeName: string, nodeIndex?: number) cy.performNodeAction(nodeName, 'prepend', nodeIndex); }); +Cypress.Commands.add('selectRemoveGroup', (groupName: string, groupIndex?: number) => { + cy.performNodeAction(groupName, 'container-remove', groupIndex); +}); + Cypress.Commands.add('chooseFromCatalog', (_nodeType: string, name: string) => { cy.get(`input[placeholder="Filter by name, description or tag"]`).click(); cy.get(`input[placeholder="Filter by name, description or tag"]`).type(name); diff --git a/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap b/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap index 9ea86a136..30283f9ad 100644 --- a/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap +++ b/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap @@ -661,53 +661,6 @@ exports[`Canvas should render correctly 1`] = ` - - - - - - - - - - - - + - - - - - - route-8888 - - - - - - - - - - - - - - - - - - + route-8888 + +
+ +
+
+ +
+ + + @@ -1085,11 +1023,9 @@ exports[`Canvas should render correctly 1`] = ` data-kind="node" data-type="group" > - - - + - - - - - - - - - - - - + - - - - - - route-8888 - - - - - - - - - - - - - - - - - - + route-8888 + +
+ +
+
+ +
+ + +
@@ -1794,11 +1668,9 @@ exports[`Canvas should render correctly with more routes 1`] = ` data-kind="node" data-type="group" > - - - + - - - - - - - - - - - - + - - - - - - route-8888 - - - - - - - - - - - - - - - - - - + route-8888 + +
+ +
+
+ +
+ + +
@@ -2503,11 +2313,9 @@ exports[`Canvas should render the Catalog button if \`CatalogModalContext\` is p data-kind="node" data-type="group" > - - - + { mode: AddStepMode.PrependStep | AddStepMode.AppendStep; diff --git a/packages/ui/src/components/Visualization/Custom/ItemDeleteGroup.tsx b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDeleteGroup.tsx similarity index 80% rename from packages/ui/src/components/Visualization/Custom/ItemDeleteGroup.tsx rename to packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDeleteGroup.tsx index fb86add92..5092d7549 100644 --- a/packages/ui/src/components/Visualization/Custom/ItemDeleteGroup.tsx +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDeleteGroup.tsx @@ -1,10 +1,10 @@ import { TrashIcon } from '@patternfly/react-icons'; import { ContextMenuItem } from '@patternfly/react-topology'; import { FunctionComponent, PropsWithChildren, useCallback, useContext } from 'react'; -import { IDataTestID } from '../../../models'; -import { IVisualizationNode } from '../../../models/visualization/base-visual-entity'; -import { DeleteModalContext } from '../../../providers/delete-modal.provider'; -import { EntitiesContext } from '../../../providers/entities.provider'; +import { IDataTestID } from '../../../../models'; +import { IVisualizationNode } from '../../../../models/visualization/base-visual-entity'; +import { DeleteModalContext } from '../../../../providers/delete-modal.provider'; +import { EntitiesContext } from '../../../../providers/entities.provider'; interface ItemDeleteGroupProps extends PropsWithChildren { vizNode: IVisualizationNode; diff --git a/packages/ui/src/components/Visualization/Custom/ItemDeleteStep.tsx b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDeleteStep.tsx similarity index 80% rename from packages/ui/src/components/Visualization/Custom/ItemDeleteStep.tsx rename to packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDeleteStep.tsx index d307cc3d0..093a6ae9f 100644 --- a/packages/ui/src/components/Visualization/Custom/ItemDeleteStep.tsx +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDeleteStep.tsx @@ -1,10 +1,10 @@ import { TrashIcon } from '@patternfly/react-icons'; import { ContextMenuItem } from '@patternfly/react-topology'; import { FunctionComponent, PropsWithChildren, useCallback, useContext } from 'react'; -import { IDataTestID } from '../../../models'; -import { IVisualizationNode } from '../../../models/visualization/base-visual-entity'; -import { EntitiesContext } from '../../../providers/entities.provider'; -import { DeleteModalContext } from '../../../providers/delete-modal.provider'; +import { IDataTestID } from '../../../../models'; +import { IVisualizationNode } from '../../../../models/visualization/base-visual-entity'; +import { EntitiesContext } from '../../../../providers/entities.provider'; +import { DeleteModalContext } from '../../../../providers/delete-modal.provider'; interface ItemDeleteStepProps extends PropsWithChildren { vizNode: IVisualizationNode; diff --git a/packages/ui/src/components/Visualization/Custom/ItemDisableStep.tsx b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDisableStep.tsx similarity index 80% rename from packages/ui/src/components/Visualization/Custom/ItemDisableStep.tsx rename to packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDisableStep.tsx index 412269369..7459ccc9a 100644 --- a/packages/ui/src/components/Visualization/Custom/ItemDisableStep.tsx +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemDisableStep.tsx @@ -1,10 +1,10 @@ import { BanIcon, CheckIcon } from '@patternfly/react-icons'; import { ContextMenuItem } from '@patternfly/react-topology'; import { FunctionComponent, PropsWithChildren, useCallback, useContext } from 'react'; -import { IDataTestID } from '../../../models'; -import { IVisualizationNode } from '../../../models/visualization/base-visual-entity'; -import { EntitiesContext } from '../../../providers/entities.provider'; -import { setValue } from '../../../utils/set-value'; +import { IDataTestID } from '../../../../models'; +import { IVisualizationNode } from '../../../../models/visualization/base-visual-entity'; +import { EntitiesContext } from '../../../../providers/entities.provider'; +import { setValue } from '../../../../utils/set-value'; interface ItemDisableStepProps extends PropsWithChildren { vizNode: IVisualizationNode; diff --git a/packages/ui/src/components/Visualization/Custom/ItemInsertStep.tsx b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemInsertStep.tsx similarity index 83% rename from packages/ui/src/components/Visualization/Custom/ItemInsertStep.tsx rename to packages/ui/src/components/Visualization/Custom/ContextMenu/ItemInsertStep.tsx index 3fd4fbb0a..4e48630ec 100644 --- a/packages/ui/src/components/Visualization/Custom/ItemInsertStep.tsx +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemInsertStep.tsx @@ -1,9 +1,9 @@ import { ContextMenuItem } from '@patternfly/react-topology'; import { FunctionComponent, PropsWithChildren, useCallback, useContext } from 'react'; -import { IDataTestID } from '../../../models'; -import { AddStepMode, IVisualizationNode } from '../../../models/visualization/base-visual-entity'; -import { CatalogModalContext } from '../../../providers/catalog-modal.provider'; -import { EntitiesContext } from '../../../providers/entities.provider'; +import { IDataTestID } from '../../../../models'; +import { AddStepMode, IVisualizationNode } from '../../../../models/visualization/base-visual-entity'; +import { CatalogModalContext } from '../../../../providers/catalog-modal.provider'; +import { EntitiesContext } from '../../../../providers/entities.provider'; interface ItemInsertStepProps extends PropsWithChildren { mode: AddStepMode.InsertChildStep | AddStepMode.InsertSpecialChildStep; diff --git a/packages/ui/src/components/Visualization/Custom/ItemReplaceStep.tsx b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemReplaceStep.tsx similarity index 81% rename from packages/ui/src/components/Visualization/Custom/ItemReplaceStep.tsx rename to packages/ui/src/components/Visualization/Custom/ContextMenu/ItemReplaceStep.tsx index 18cbc5547..c96db72b0 100644 --- a/packages/ui/src/components/Visualization/Custom/ItemReplaceStep.tsx +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/ItemReplaceStep.tsx @@ -1,10 +1,10 @@ import { SyncAltIcon } from '@patternfly/react-icons'; import { ContextMenuItem } from '@patternfly/react-topology'; import { FunctionComponent, PropsWithChildren, useCallback, useContext } from 'react'; -import { IDataTestID } from '../../../models'; -import { AddStepMode, IVisualizationNode } from '../../../models/visualization/base-visual-entity'; -import { CatalogModalContext } from '../../../providers/catalog-modal.provider'; -import { EntitiesContext } from '../../../providers/entities.provider'; +import { IDataTestID } from '../../../../models'; +import { AddStepMode, IVisualizationNode } from '../../../../models/visualization/base-visual-entity'; +import { CatalogModalContext } from '../../../../providers/catalog-modal.provider'; +import { EntitiesContext } from '../../../../providers/entities.provider'; interface ItemReplaceStepProps extends PropsWithChildren { vizNode: IVisualizationNode; diff --git a/packages/ui/src/components/Visualization/Custom/ContextMenu/NodeContextMenu.test.tsx b/packages/ui/src/components/Visualization/Custom/ContextMenu/NodeContextMenu.test.tsx new file mode 100644 index 000000000..9d2dd1bf3 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/NodeContextMenu.test.tsx @@ -0,0 +1,110 @@ +import { ElementModel, GraphElement } from '@patternfly/react-topology'; +import { render } from '@testing-library/react'; +import { CanvasNode } from '../../Canvas'; +import { NodeContextMenu } from './NodeContextMenu'; +import { createVisualizationNode, IVisualizationNode, NodeInteraction } from '../../../../models'; + +describe('NodeContextMenu', () => { + let element: GraphElement; + let vizNode: IVisualizationNode | undefined; + let nodeInteractions: NodeInteraction; + + beforeEach(() => { + nodeInteractions = { + canHavePreviousStep: false, + canHaveNextStep: false, + canHaveChildren: false, + canHaveSpecialChildren: false, + canReplaceStep: false, + canRemoveStep: false, + canRemoveFlow: false, + canBeDisabled: false, + }; + vizNode = createVisualizationNode('test', {}); + jest.spyOn(vizNode, 'getNodeInteraction').mockReturnValue(nodeInteractions); + element = { + getData: () => { + return { vizNode } as CanvasNode['data']; + }, + } as unknown as GraphElement; + }); + + it('should render an empty component when there is no vizNode', () => { + vizNode = undefined; + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('should render a PrependStep item if canHavePreviousStep is true', () => { + nodeInteractions.canHavePreviousStep = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-prepend'); + + expect(item).toBeInTheDocument(); + }); + + it('should render an AppendStep item if canHaveNextStep is true', () => { + nodeInteractions.canHaveNextStep = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-append'); + + expect(item).toBeInTheDocument(); + }); + + it('should render an InsertStep item if canHaveChildren is true', () => { + nodeInteractions.canHaveChildren = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-insert'); + + expect(item).toBeInTheDocument(); + }); + + it('should render an InsertSpecialStep item if canHaveSpecialChildren is true', () => { + nodeInteractions.canHaveSpecialChildren = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-insert-special'); + + expect(item).toBeInTheDocument(); + }); + + it('should render an ItemDisableStep item if canBeDisabled is true', () => { + nodeInteractions.canBeDisabled = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-disable'); + + expect(item).toBeInTheDocument(); + }); + + it('should render an ItemReplaceStep item if canReplaceStep is true', () => { + nodeInteractions.canReplaceStep = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-replace'); + + expect(item).toBeInTheDocument(); + }); + + it('should render an ItemDeleteStep item if canRemoveStep is true', () => { + nodeInteractions.canRemoveStep = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-delete'); + + expect(item).toBeInTheDocument(); + }); + + it('should render an ItemDeleteGroup item if canRemoveFlow is true', () => { + nodeInteractions.canRemoveFlow = true; + const wrapper = render(); + + const item = wrapper.getByTestId('context-menu-item-container-remove'); + + expect(item).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Visualization/Custom/ContextMenu/NodeContextMenu.tsx b/packages/ui/src/components/Visualization/Custom/ContextMenu/NodeContextMenu.tsx new file mode 100644 index 000000000..a4e0934d5 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/NodeContextMenu.tsx @@ -0,0 +1,123 @@ +import { ArrowDownIcon, ArrowUpIcon, CodeBranchIcon, PlusIcon } from '@patternfly/react-icons'; +import { ContextMenuSeparator, ElementModel, GraphElement } from '@patternfly/react-topology'; +import { forwardRef, ReactElement } from 'react'; +import { AddStepMode } from '../../../../models/visualization/base-visual-entity'; +import { CanvasNode } from '../../Canvas/canvas.models'; +import { ItemAddStep } from './ItemAddStep'; +import { ItemDeleteGroup } from './ItemDeleteGroup'; +import { ItemDeleteStep } from './ItemDeleteStep'; +import { ItemDisableStep } from './ItemDisableStep'; +import { ItemInsertStep } from './ItemInsertStep'; +import { ItemReplaceStep } from './ItemReplaceStep'; + +export const NodeContextMenuFn = (element: GraphElement) => { + const items: ReactElement[] = []; + const vizNode = element.getData()?.vizNode; + if (!vizNode) return items; + + const nodeInteractions = vizNode.getNodeInteraction(); + + if (nodeInteractions.canHavePreviousStep) { + items.push( + + Prepend + , + ); + } + if (nodeInteractions.canHaveNextStep) { + items.push( + + Append + , + ); + } + if (nodeInteractions.canHavePreviousStep || nodeInteractions.canHaveNextStep) { + items.push(); + } + + if (nodeInteractions.canHaveChildren) { + items.push( + + Add step + , + ); + } + if (nodeInteractions.canHaveSpecialChildren) { + items.push( + + Add branch + , + ); + } + if (nodeInteractions.canHaveChildren || nodeInteractions.canHaveSpecialChildren) { + items.push(); + } + + if (nodeInteractions.canBeDisabled) { + items.push( + , + ); + } + if (nodeInteractions.canReplaceStep) { + items.push( + , + ); + } + if (nodeInteractions.canBeDisabled || nodeInteractions.canReplaceStep) { + items.push(); + } + + if (nodeInteractions.canRemoveStep) { + const childrenNodes = vizNode.getChildren(); + const shouldConfirmBeforeDeletion = childrenNodes !== undefined && childrenNodes.length > 0; + items.push( + , + ); + } + if (nodeInteractions.canRemoveFlow) { + items.push( + , + ); + } + + return items; +}; + +export const NodeContextMenu = forwardRef }>( + ({ element }, forwardedRef) => { + return ( +
+ {NodeContextMenuFn(element)} +
+ ); + }, +); diff --git a/packages/ui/src/components/Visualization/Custom/ContextMenu/__snapshots__/NodeContextMenu.test.tsx.snap b/packages/ui/src/components/Visualization/Custom/ContextMenu/__snapshots__/NodeContextMenu.test.tsx.snap new file mode 100644 index 000000000..a037224d4 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/ContextMenu/__snapshots__/NodeContextMenu.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NodeContextMenu should render an empty component when there is no vizNode 1`] = ` +
+
+
+`; diff --git a/packages/ui/src/components/Visualization/Custom/CustomGroup.tsx b/packages/ui/src/components/Visualization/Custom/CustomGroup.tsx deleted file mode 100644 index 89aea0503..000000000 --- a/packages/ui/src/components/Visualization/Custom/CustomGroup.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { CodeBranchIcon } from '@patternfly/react-icons'; -import { - ContextMenuSeparator, - DefaultGroup, - ElementModel, - GraphElement, - Layer, - isNode, - observer, - withContextMenu, - withSelection, -} from '@patternfly/react-topology'; -import { FunctionComponent, ReactElement } from 'react'; -import { AddStepMode } from '../../../models/visualization/base-visual-entity'; -import { CanvasNode } from '../Canvas/canvas.models'; -import { ItemDeleteGroup } from './ItemDeleteGroup'; -import { ItemInsertStep } from './ItemInsertStep'; -import { doTruncateLabel } from '../../../utils/truncate-label'; - -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(); - - if (!isNode(element)) { - throw new Error('DefaultGroup must be used only on Node elements'); - } - - return ( - - - - - - ); -}); - -export const CustomGroupWithSelection = withSelection()( - withContextMenu((element: GraphElement) => { - const items: ReactElement[] = []; - const vizNode = element.getData()?.vizNode; - if (!vizNode) return items; - - const nodeInteractions = vizNode.getNodeInteraction(); - - if (nodeInteractions.canHaveSpecialChildren) { - items.push( - - Add branch - , - ); - items.push(); - } - - if (nodeInteractions.canRemoveFlow) { - items.push( - , - ); - } - - return items; - })(CustomGroup), -); diff --git a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx b/packages/ui/src/components/Visualization/Custom/CustomNode.tsx deleted file mode 100644 index 429d25e89..000000000 --- a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { Tooltip } from '@patternfly/react-core'; -import { ArrowDownIcon, ArrowUpIcon, BanIcon, CodeBranchIcon, PlusIcon } from '@patternfly/react-icons'; -import { - ContextMenuSeparator, - Decorator, - DefaultNode, - ElementModel, - GraphElement, - Node, - NodeStatus, - WithSelectionProps, - observer, - withContextMenu, - withSelection, -} from '@patternfly/react-topology'; -import clsx from 'clsx'; -import { FunctionComponent, ReactElement, useContext } from 'react'; -import { AddStepMode } from '../../../models/visualization/base-visual-entity'; -import { CanvasDefaults } from '../Canvas/canvas.defaults'; -import { CanvasNode } from '../Canvas/canvas.models'; -import './CustomNode.scss'; -import { ItemAddStep } from './ItemAddStep'; -import { ItemDeleteGroup } from './ItemDeleteGroup'; -import { ItemDeleteStep } from './ItemDeleteStep'; -import { ItemDisableStep } from './ItemDisableStep'; -import { ItemInsertStep } from './ItemInsertStep'; -import { ItemReplaceStep } from './ItemReplaceStep'; -import { doTruncateLabel } from '../../../utils/truncate-label'; -import { SettingsContext } from '../../../providers'; - -interface CustomNodeProps extends WithSelectionProps { - element: Node; -} -const noopFn = () => {}; - -const CustomNode: FunctionComponent = observer(({ element, ...rest }) => { - const vizNode = element.getData()?.vizNode; - const settingsAdapter = useContext(SettingsContext); - const label = vizNode?.getNodeLabel(settingsAdapter.getSettings().nodeLabel); - const isDisabled = !!vizNode?.getComponentSchema()?.definition?.disabled; - const tooltipContent = vizNode?.getTooltipContent(); - const statusDecoratorTooltip = vizNode?.getNodeValidationText(); - const nodeStatus = !statusDecoratorTooltip || isDisabled ? NodeStatus.default : NodeStatus.warning; - - return ( - - - - -
- -
-
-
- - {isDisabled && ( - } - showBackground - /> - )} -
-
- ); -}); - -export const CustomNodeWithSelection: typeof DefaultNode = withSelection()( - withContextMenu((element: GraphElement) => { - const items: ReactElement[] = []; - const vizNode = element.getData()?.vizNode; - if (!vizNode) return items; - - const nodeInteractions = vizNode.getNodeInteraction(); - - if (nodeInteractions.canHavePreviousStep) { - items.push( - - Prepend - , - ); - } - if (nodeInteractions.canHaveNextStep) { - items.push( - - Append - , - ); - } - if (nodeInteractions.canHavePreviousStep || nodeInteractions.canHaveNextStep) { - items.push(); - } - - if (nodeInteractions.canHaveChildren) { - items.push( - - Add step - , - ); - } - if (nodeInteractions.canHaveSpecialChildren) { - items.push( - - Add branch - , - ); - } - if (nodeInteractions.canHaveChildren || nodeInteractions.canHaveSpecialChildren) { - items.push(); - } - - if (nodeInteractions.canBeDisabled) { - items.push( - , - ); - } - if (nodeInteractions.canReplaceStep) { - items.push( - , - ); - } - if (nodeInteractions.canBeDisabled || nodeInteractions.canReplaceStep) { - items.push(); - } - - if (nodeInteractions.canRemoveStep) { - const childrenNodes = vizNode.getChildren(); - const shouldConfirmBeforeDeletion = childrenNodes !== undefined && childrenNodes.length > 0; - items.push( - , - ); - } - if (nodeInteractions.canRemoveFlow) { - items.push( - , - ); - } - - return items; - })(CustomNode as typeof DefaultNode), -) as typeof DefaultNode; diff --git a/packages/ui/src/components/Visualization/Custom/Group/CollapseButton.test.tsx b/packages/ui/src/components/Visualization/Custom/Group/CollapseButton.test.tsx new file mode 100644 index 000000000..e8e47c168 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/CollapseButton.test.tsx @@ -0,0 +1,53 @@ +import { CollapsibleGroupProps } from '@patternfly/react-topology'; +import { fireEvent } from '@testing-library/dom'; +import { act, render } from '@testing-library/react'; +import { CollapseButton } from './CollapseButton'; +import { CustomGroupProps } from './Group.models'; + +describe('CollapseButton', () => { + let element: CustomGroupProps['element']; + let onCollapseChange: CollapsibleGroupProps['onCollapseChange']; + + beforeEach(() => { + element = { + getId: () => 'test', + isCollapsed: () => false, + } as unknown as CustomGroupProps['element']; + onCollapseChange = jest.fn(); + }); + + it('should render the collapse button', () => { + const wrapper = render(); + const button = wrapper.getByTestId('collapseButton-test'); + + expect(button).toBeInTheDocument(); + expect(button).toMatchSnapshot(); + }); + + it('should render the collapse icon', () => { + const wrapper = render(); + const icon = wrapper.getByTestId('collapse-icon'); + + expect(icon).toBeInTheDocument(); + }); + + it('should render the expand icon', () => { + element.isCollapsed = () => true; + + const wrapper = render(); + const icon = wrapper.getByTestId('expand-icon'); + + expect(icon).toBeInTheDocument(); + }); + + it('should call onCollapseChange when the button is clicked', () => { + const wrapper = render(); + const button = wrapper.getByTestId('collapseButton-test'); + + act(() => { + fireEvent.click(button); + }); + + expect(onCollapseChange).toHaveBeenCalledWith(element, true); + }); +}); diff --git a/packages/ui/src/components/Visualization/Custom/Group/CollapseButton.tsx b/packages/ui/src/components/Visualization/Custom/Group/CollapseButton.tsx new file mode 100644 index 000000000..d4b81b6e3 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/CollapseButton.tsx @@ -0,0 +1,30 @@ +import { Button, Tooltip } from '@patternfly/react-core'; +import { CompressArrowsAltIcon, ExpandArrowsAltIcon } from '@patternfly/react-icons'; +import { CollapsibleGroupProps } from '@patternfly/react-topology'; +import { FunctionComponent, MouseEventHandler } from 'react'; +import { CustomGroupProps } from './Group.models'; + +interface CollapseButtonProps { + element: CustomGroupProps['element']; + onCollapseChange?: CollapsibleGroupProps['onCollapseChange']; +} + +export const CollapseButton: FunctionComponent = ({ element, onCollapseChange }) => { + const id = element.getId(); + const onClick: MouseEventHandler = (event) => { + event.stopPropagation(); + onCollapseChange?.(element, !element.isCollapsed()); + }; + + return ( + + + + ); +}; diff --git a/packages/ui/src/components/Visualization/Custom/Group/ContextMenuButton.test.tsx b/packages/ui/src/components/Visualization/Custom/Group/ContextMenuButton.test.tsx new file mode 100644 index 000000000..f0216b982 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/ContextMenuButton.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent } from '@testing-library/dom'; +import { act, render } from '@testing-library/react'; +import { ContextMenuButton } from './ContextMenuButton'; +import { CustomGroupProps } from './Group.models'; + +describe('ContextMenuButton', () => { + let element: CustomGroupProps['element']; + + beforeEach(() => { + element = { + getId: () => 'test', + getData: () => undefined, + } as unknown as CustomGroupProps['element']; + }); + + it('should render the button', () => { + const wrapper = render(); + const button = wrapper.getByTestId('contextualMenu-test'); + + expect(button).toBeInTheDocument(); + expect(button).toMatchSnapshot(); + }); + + it('should open the context menu when the button is clicked', () => { + const wrapper = render(); + const button = wrapper.getByTestId('contextualMenu-test'); + + act(() => { + fireEvent.click(button); + }); + + const contextMenu = wrapper.getByTestId('node-context-menu'); + + expect(contextMenu).toBeInTheDocument(); + }); + + it('should close the context menu when the button is clicked twice', () => { + const wrapper = render(); + const button = wrapper.getByTestId('contextualMenu-test'); + + fireEvent.click(button); + fireEvent.click(button); + + const contextMenu = wrapper.queryByTestId('node-context-menu'); + + expect(contextMenu).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Visualization/Custom/Group/ContextMenuButton.tsx b/packages/ui/src/components/Visualization/Custom/Group/ContextMenuButton.tsx new file mode 100644 index 000000000..3ae633db9 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/ContextMenuButton.tsx @@ -0,0 +1,41 @@ +import { Button, Tooltip } from '@patternfly/react-core'; +import { EllipsisVIcon } from '@patternfly/react-icons'; +import { ContextMenu, PointIface } from '@patternfly/react-topology'; +import { FunctionComponent, MouseEventHandler, useRef, useState } from 'react'; +import { NodeContextMenu } from '../ContextMenu/NodeContextMenu'; +import { CustomGroupProps } from './Group.models'; + +interface ContextMenuButtonProps { + element: CustomGroupProps['element']; +} + +export const ContextMenuButton: FunctionComponent = ({ element }) => { + const [isOpen, setIsOpen] = useState(false); + const reference = useRef({ x: 0, y: 0 }); + const id = element.getId(); + + const onClick: MouseEventHandler & MouseEventHandler = (event) => { + event.stopPropagation(); + reference.current = { x: event.clientX, y: event.clientY }; + setIsOpen(!isOpen); + }; + + return ( + <> + + + + { + setIsOpen(false); + }} + > + + + + ); +}; diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.scss b/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.scss new file mode 100644 index 000000000..7264a38ac --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.scss @@ -0,0 +1,87 @@ +.phantom-rect { + stroke: transparent; + fill: transparent; +} + +.foreign-object { + overflow: auto; +} + +.container-controls { + padding: var(--pf-v5-global--BorderWidth--xl); +} + +.custom-group { + --custom-group--BorderColor: var(--pf-v5-global--palette--black-400); + --custom-group--BorderSize: var(--pf-v5-global--BorderWidth--md); + --custom-group--Background: var(--pf-v5-global--palette--white); + --custom-group-TitleHeight: 32px; + + display: flex; + flex-flow: column; + height: 100%; + width: 100%; + background-color: var(--custom-group--Background); + border-radius: var(--pf-v5-global--BorderWidth--xl); + border: var(--custom-group--BorderSize) solid var(--custom-group--BorderColor); + + &__title { + display: flex; + align-items: center; + font-size: var(--pf-v5-global--FontSize--md); + font-weight: bold; + color: var(--pf-v5-global--palette--black-900); + background-color: var(--pf-v5-global--palette--light-blue-400); + height: var(--custom-group-TitleHeight); + + &__img-circle { + margin: 5px -10px 5px 5px; + display: flex; + align-items: center; + justify-content: center; + width: calc(var(--custom-group-TitleHeight) * 0.9); + height: calc(var(--custom-group-TitleHeight) * 0.9); + border: var(--custom-group--BorderSize) solid var(--custom-group--BorderColor); + background-color: var(--pf-v5-global--palette--white); + border-radius: 50px; + + img { + max-width: calc(var(--custom-group-TitleHeight) * 0.6); + max-height: calc(var(--custom-group-TitleHeight) * 0.6); + } + } + + span { + margin: 0 var(--pf-v5-global--FontSize--md); + flex: 1 1 auto; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + + &:hover { + --custom-group--BorderColor: var(--pf-v5-global--primary-color--light-100); + --custom-group--Background: var(--pf-v5-global--palette--black-200); + } + + &--selected { + --custom-group--BorderColor: var(--pf-v5-global--palette--light-blue-400); + --custom-group--Background: var(--pf-v5-global--palette--blue-50); + --custom-group--BorderSize: var(--pf-v5-global--BorderWidth--lg); + + &:hover { + --custom-group--BorderColor: var(--pf-v5-global--palette--light-blue-400); + --custom-group--Background: var(--pf-v5-global--palette--blue-50); + } + } + + &--disabled { + filter: grayscale(100%); + + span { + font-style: italic; + text-decoration: line-through; + } + } +} diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.tsx new file mode 100644 index 000000000..fd7ed5b2b --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroup.tsx @@ -0,0 +1,43 @@ +import { + DefaultGroup, + GraphElement, + isNode, + observer, + withContextMenu, + 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 './CustomGroup.scss'; +import { CustomGroupCollapsible } from './CustomGroupCollapsible'; + +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(); + + if (!isNode(element)) { + throw new Error('DefaultGroup 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/CustomGroupCollapsed.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsed.tsx new file mode 100644 index 000000000..d268addad --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsed.tsx @@ -0,0 +1,100 @@ +import { Tooltip } from '@patternfly/react-core'; +import { ExpandArrowsAltIcon } from '@patternfly/react-icons'; +import { + CollapsibleGroupProps, + GROUPS_LAYER, + LabelBadge, + Layer, + NodeLabel, + WithContextMenuProps, + WithDndDropProps, + WithDragNodeProps, + WithSelectionProps, + getShapeComponent, + observer, + useSvgAnchor, +} from '@patternfly/react-topology'; +import { FunctionComponent, useCallback } from 'react'; +import { CanvasDefaults } from '../../Canvas/canvas.defaults'; +import { CustomGroupProps } from './Group.models'; + +type CustomGroupExpandedProps = CustomGroupProps & + CollapsibleGroupProps & + WithDragNodeProps & + WithSelectionProps & + WithDndDropProps & + WithContextMenuProps; + +export const CustomGroupCollapsed: FunctionComponent = observer( + ({ + className, + children, + collapsedWidth = CanvasDefaults.DEFAULT_NODE_DIAMETER, + collapsedHeight = CanvasDefaults.DEFAULT_NODE_DIAMETER, + collapsedShadowOffset = 8, + element, + onSelect, + label: propsLabel, + onContextMenu, + contextMenuOpen, + onCollapseChange, + }) => { + const ShapeComponent = getShapeComponent(element); + const label = propsLabel || element.getLabel(); + const childCount = element.getAllNodeChildren().length; + const vizNode = element.getData()?.vizNode; + const tooltipContent = vizNode?.getTooltipContent(); + const anchorRef = useSvgAnchor(); + + const onActionIconClick = useCallback(() => { + onCollapseChange?.(element, false); + }, [element, onCollapseChange]); + + return ( + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+ + {childCount && } + + } + onActionIconClick={onActionIconClick} + > + {label || element.getLabel()} + + {children} +
+ ); + }, +); diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx new file mode 100644 index 000000000..9ad975856 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx @@ -0,0 +1,33 @@ +import { action, Dimensions, Node, observer } from '@patternfly/react-topology'; +import clsx from 'clsx'; +import { FunctionComponent } from 'react'; +import { CustomGroupCollapsed } from './CustomGroupCollapsed'; +import { CustomGroupExpanded } from './CustomGroupExpanded'; +import { CustomGroupProps } from './Group.models'; + +export const CustomGroupCollapsible: FunctionComponent = observer( + ({ className, element, selected, onCollapseChange, ...rest }) => { + const handleCollapse = (group: Node, collapsed: boolean): void => { + action(() => { + if (collapsed && rest.collapsedWidth !== undefined && rest.collapsedHeight !== undefined) { + group.setDimensions(new Dimensions(rest.collapsedWidth, rest.collapsedHeight)); + } + + group.setCollapsed(collapsed); + group.getController().getGraph().layout(); + })(); + + onCollapseChange?.(group, collapsed); + }; + const vizNode = element.getData()?.vizNode; + const isDisabled = !!vizNode?.getComponentSchema()?.definition?.disabled; + const classNames = clsx(className, { 'custom-group--selected': selected, 'custom-group--disabled': isDisabled }); + + if (element.isCollapsed()) { + return ( + + ); + } + return ; + }, +); diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx new file mode 100644 index 000000000..b3fea2282 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx @@ -0,0 +1,126 @@ +import { + AbstractAnchor, + AnchorEnd, + CollapsibleGroupProps, + GROUPS_LAYER, + Layer, + Node, + Point, + Rect, + WithContextMenuProps, + WithDndDropProps, + WithDragNodeProps, + WithSelectionProps, + observer, + useAnchor, +} from '@patternfly/react-topology'; +import { FunctionComponent, useRef } from 'react'; +import { CollapseButton } from './CollapseButton'; +import { ContextMenuButton } from './ContextMenuButton'; +import { CustomGroupProps } from './Group.models'; + +type CustomGroupExpandedProps = CustomGroupProps & + CollapsibleGroupProps & + WithDragNodeProps & + WithSelectionProps & + WithDndDropProps & + WithContextMenuProps; + +class TargetAnchor extends AbstractAnchor { + getLocation(reference: Point): Point { + return this.closestPointOnRectangle(this.owner.getBounds(), reference); + } + + getReferencePoint(): Point { + return super.getReferencePoint(); + } + + private closestPointOnRectangle(rect: Rect, point: Point): Point { + // Deconstruct the rectangle and point parameters + const { x: rx, y: ry, width, height } = rect; + const { x: px, y: py } = point; + + // Calculate the projections on the edges + // For left edge + const leftX = rx; + const leftY = Math.max(ry, Math.min(py, ry + height)); + + // For right edge + const rightX = rx + width; + const rightY = Math.max(ry, Math.min(py, ry + height)); + + // For top edge + const topX = Math.max(rx, Math.min(px, rx + width)); + const topY = ry; + + // For bottom edge + const bottomX = Math.max(rx, Math.min(px, rx + width)); + const bottomY = ry + height; + + // Calculate distances to each edge projection + const distances = [ + { x: leftX, y: leftY, dist: Math.hypot(px - leftX, py - leftY) }, + { x: rightX, y: rightY, dist: Math.hypot(px - rightX, py - rightY) }, + { x: topX, y: topY, dist: Math.hypot(px - topX, py - topY) }, + { x: bottomX, y: bottomY, dist: Math.hypot(px - bottomX, py - bottomY) }, + ]; + + // Find the minimum distance + const closestPoint = distances.reduce((minPoint, currentPoint) => + currentPoint.dist < minPoint.dist ? currentPoint : minPoint, + ); + + // Return the closest point + return new Point(closestPoint.x, closestPoint.y); + } +} + +export const CustomGroupExpanded: FunctionComponent = observer( + ({ className, element, onSelect, label: propsLabel, onContextMenu, onCollapseChange }) => { + const label = propsLabel || element.getLabel(); + const boxRef = useRef(element.getBounds()); + const vizNode = element.getData()?.vizNode; + + useAnchor((element: Node) => { + return new TargetAnchor(element); + }, AnchorEnd.both); + + boxRef.current = element.getBounds(); + + return ( + + + + + +
+
+
+ +
+ {label} + + + +
+
+
+
+
+
+ ); + }, +); diff --git a/packages/ui/src/components/Visualization/Custom/Group/Group.models.ts b/packages/ui/src/components/Visualization/Custom/Group/Group.models.ts new file mode 100644 index 000000000..d88de46f9 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/Group.models.ts @@ -0,0 +1,9 @@ +import { Node, DefaultGroup as TopologyDefaultGroup } from '@patternfly/react-topology'; +import { CanvasNode } from '../../Canvas'; + +type DefaultGroupProps = Parameters[0]; +export interface CustomGroupProps extends DefaultGroupProps { + element: Node; +} + +export type PointWithSize = [number, number, number]; diff --git a/packages/ui/src/components/Visualization/Custom/Group/__snapshots__/CollapseButton.test.tsx.snap b/packages/ui/src/components/Visualization/Custom/Group/__snapshots__/CollapseButton.test.tsx.snap new file mode 100644 index 000000000..3dd654b77 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/__snapshots__/CollapseButton.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollapseButton should render the collapse button 1`] = ` + +`; diff --git a/packages/ui/src/components/Visualization/Custom/Group/__snapshots__/ContextMenuButton.test.tsx.snap b/packages/ui/src/components/Visualization/Custom/Group/__snapshots__/ContextMenuButton.test.tsx.snap new file mode 100644 index 000000000..59cd7baa3 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Group/__snapshots__/ContextMenuButton.test.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextMenuButton should render the button 1`] = ` + +`; diff --git a/packages/ui/src/components/Visualization/Custom/CustomNode.scss b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.scss similarity index 100% rename from packages/ui/src/components/Visualization/Custom/CustomNode.scss rename to packages/ui/src/components/Visualization/Custom/Node/CustomNode.scss diff --git a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx new file mode 100644 index 000000000..7b4aeb4a4 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx @@ -0,0 +1,82 @@ +import { Tooltip } from '@patternfly/react-core'; +import { BanIcon } from '@patternfly/react-icons'; +import { + Decorator, + DefaultNode, + Node, + NodeStatus, + WithSelectionProps, + observer, + withContextMenu, + withSelection, +} from '@patternfly/react-topology'; +import clsx from 'clsx'; +import { FunctionComponent, useContext } from 'react'; +import { SettingsContext } from '../../../../providers'; +import { doTruncateLabel } from '../../../../utils/truncate-label'; +import { CanvasDefaults } from '../../Canvas/canvas.defaults'; +import { CanvasNode } from '../../Canvas/canvas.models'; +import './CustomNode.scss'; +import { NodeContextMenuFn } from '../ContextMenu/NodeContextMenu'; + +interface CustomNodeProps extends WithSelectionProps { + element: Node; +} +const noopFn = () => {}; + +const CustomNode: FunctionComponent = observer(({ element, ...rest }) => { + const vizNode = element.getData()?.vizNode; + const settingsAdapter = useContext(SettingsContext); + const label = vizNode?.getNodeLabel(settingsAdapter.getSettings().nodeLabel); + const isDisabled = !!vizNode?.getComponentSchema()?.definition?.disabled; + const tooltipContent = vizNode?.getTooltipContent(); + const statusDecoratorTooltip = vizNode?.getNodeValidationText(); + const nodeStatus = !statusDecoratorTooltip || isDisabled ? NodeStatus.default : NodeStatus.warning; + + return ( + + + + +
+ +
+
+
+ + {isDisabled && ( + } + showBackground + /> + )} +
+
+ ); +}); + +export const CustomNodeWithSelection: typeof DefaultNode = withSelection()( + withContextMenu(NodeContextMenuFn)(CustomNode as typeof DefaultNode), +) as typeof DefaultNode; diff --git a/packages/ui/src/components/Visualization/Custom/index.ts b/packages/ui/src/components/Visualization/Custom/index.ts index cc33063f8..9ccc83cda 100644 --- a/packages/ui/src/components/Visualization/Custom/index.ts +++ b/packages/ui/src/components/Visualization/Custom/index.ts @@ -1,2 +1,2 @@ -export * from './CustomGroup'; -export * from './CustomNode'; +export * from './Group/CustomGroup'; +export * from './Node/CustomNode'; diff --git a/packages/ui/src/models/visualization/flows/__snapshots__/abstract-camel-visual-entity.test.ts.snap b/packages/ui/src/models/visualization/flows/__snapshots__/abstract-camel-visual-entity.test.ts.snap index 18b75a40e..5391cf71e 100644 --- a/packages/ui/src/models/visualization/flows/__snapshots__/abstract-camel-visual-entity.test.ts.snap +++ b/packages/ui/src/models/visualization/flows/__snapshots__/abstract-camel-visual-entity.test.ts.snap @@ -95,12 +95,12 @@ exports[`AbstractCamelVisualEntity getNodeInteraction should return the correct { "canBeDisabled": false, "canHaveChildren": false, - "canHaveNextStep": true, - "canHavePreviousStep": true, + "canHaveNextStep": false, + "canHavePreviousStep": false, "canHaveSpecialChildren": false, "canRemoveFlow": false, - "canRemoveStep": true, - "canReplaceStep": true, + "canRemoveStep": false, + "canReplaceStep": false, } `; diff --git a/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-from-visual-entity.test.ts.snap b/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-from-visual-entity.test.ts.snap index 332891d58..bf497276b 100644 --- a/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-from-visual-entity.test.ts.snap +++ b/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-from-visual-entity.test.ts.snap @@ -98,8 +98,8 @@ exports[`CamelInterceptFromVisualEntity getNodeInteraction should return the cor { "canBeDisabled": false, "canHaveChildren": false, - "canHaveNextStep": true, - "canHavePreviousStep": true, + "canHaveNextStep": false, + "canHavePreviousStep": false, "canHaveSpecialChildren": false, "canRemoveFlow": false, "canRemoveStep": true, diff --git a/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-send-to-endpoint-visual-entity.test.ts.snap b/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-send-to-endpoint-visual-entity.test.ts.snap index f50b05489..8a3e14cc4 100644 --- a/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-send-to-endpoint-visual-entity.test.ts.snap +++ b/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-send-to-endpoint-visual-entity.test.ts.snap @@ -98,8 +98,8 @@ exports[`CamelInterceptSendToEndpointVisualEntity getNodeInteraction should retu { "canBeDisabled": false, "canHaveChildren": false, - "canHaveNextStep": true, - "canHavePreviousStep": true, + "canHaveNextStep": false, + "canHavePreviousStep": false, "canHaveSpecialChildren": false, "canRemoveFlow": false, "canRemoveStep": true, diff --git a/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-visual-entity.test.ts.snap b/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-visual-entity.test.ts.snap index af0463688..242f253b1 100644 --- a/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-visual-entity.test.ts.snap +++ b/packages/ui/src/models/visualization/flows/__snapshots__/camel-intercept-visual-entity.test.ts.snap @@ -98,8 +98,8 @@ exports[`CamelInterceptVisualEntity getNodeInteraction should return the correct { "canBeDisabled": false, "canHaveChildren": false, - "canHaveNextStep": true, - "canHavePreviousStep": true, + "canHaveNextStep": false, + "canHavePreviousStep": false, "canHaveSpecialChildren": false, "canRemoveFlow": false, "canRemoveStep": true, diff --git a/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-completion-visual-entity.test.ts.snap b/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-completion-visual-entity.test.ts.snap index a6a97e8d0..bc4f210e2 100644 --- a/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-completion-visual-entity.test.ts.snap +++ b/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-completion-visual-entity.test.ts.snap @@ -98,8 +98,8 @@ exports[`CamelOnCompletionVisualEntity getNodeInteraction should return the corr { "canBeDisabled": false, "canHaveChildren": false, - "canHaveNextStep": true, - "canHavePreviousStep": true, + "canHaveNextStep": false, + "canHavePreviousStep": false, "canHaveSpecialChildren": false, "canRemoveFlow": false, "canRemoveStep": true, diff --git a/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-exception-visual-entity.test.ts.snap b/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-exception-visual-entity.test.ts.snap index b9c73ff91..45c71e388 100644 --- a/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-exception-visual-entity.test.ts.snap +++ b/packages/ui/src/models/visualization/flows/__snapshots__/camel-on-exception-visual-entity.test.ts.snap @@ -95,8 +95,8 @@ exports[`CamelOnExceptionVisualEntity getNodeInteraction should return the corre { "canBeDisabled": false, "canHaveChildren": false, - "canHaveNextStep": true, - "canHavePreviousStep": true, + "canHaveNextStep": false, + "canHavePreviousStep": false, "canHaveSpecialChildren": false, "canRemoveFlow": false, "canRemoveStep": true, diff --git a/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts b/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts index 052715e9e..7093f08d8 100644 --- a/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts @@ -190,7 +190,7 @@ export abstract class AbstractCamelVisualEntity implements Bas const canHaveChildren = stepsProperties.find((property) => property.type === 'branch') !== undefined; const canHaveSpecialChildren = Object.keys(stepsProperties).length > 1; const canReplaceStep = CamelComponentSchemaService.canReplaceStep(processorName); - const canRemoveStep = processorName !== ('from' as keyof ProcessorDefinition); + const canRemoveStep = !CamelComponentSchemaService.DISABLED_REMOVE_STEPS.includes(processorName); const canRemoveFlow = data.path === ROOT_PATH; const canBeDisabled = CamelComponentSchemaService.canBeDisabled(processorName); @@ -219,6 +219,7 @@ export abstract class AbstractCamelVisualEntity implements Bas entity: this, isGroup: true, icon: NodeIconResolver.getIcon(this.type, NodeIconType.VisualEntity), + processorName: 'route', }); const fromNode = CamelStepsService.getVizNodeFromProcessor( diff --git a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts index 59dd24605..bf2264424 100644 --- a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts +++ b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts @@ -12,6 +12,7 @@ import { CamelProcessorStepsProperties, ICamelElementLookupResult } from './came export class CamelComponentSchemaService { static DISABLED_SIBLING_STEPS = [ + 'route', 'from', 'onWhen', 'when', @@ -24,6 +25,7 @@ export class CamelComponentSchemaService { 'onException', 'onCompletion', ]; + static DISABLED_REMOVE_STEPS = ['from', 'route'] as unknown as (keyof ProcessorDefinition)[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any static getVisualComponentSchema(path: string, definition: any): VisualComponentSchema | undefined {