From a0245ee5ba617b7f5c173c9870798bd10903e137 Mon Sep 17 00:00:00 2001 From: Ricardo M Date: Fri, 13 Oct 2023 20:35:39 +0200 Subject: [PATCH] feat(viz): Remove nodes from the canvas This pull request adds the possibility to remove individual nodes from an integration, currently, it supports `Camel Routes` only Notes: * The `otherwise` property from the `choice` node remains due to a configuration in the `VisualizationNode` class, we'll tackle this in a separate pull request. * Removing nodes from `Pipe` and `KameletBinding` is waiting until we receive the entire flow into the `PieVisualEntity` class Fixes: https://github.com/KaotoIO/kaoto-next/issues/78 --- .../Visualization/Canvas/Canvas.test.tsx | 16 + .../Canvas/__snapshots__/Canvas.test.tsx.snap | 481 ++++++++++++++++++ .../Canvas/canvas.service.test.ts | 5 + .../Visualization/Custom/CustomNode.scss | 7 + .../Visualization/Custom/CustomNode.tsx | 69 ++- .../Visualization/Visualization.scss | 4 - .../visualization/base-visual-entity.ts | 4 + .../flows/camel-route-visual-entity.test.ts | 55 +- .../flows/camel-route-visual-entity.ts | 58 +++ .../flows/kamelet-visual-entity.ts | 4 + .../visualization/flows/pipe-visual-entity.ts | 28 + .../visualization/visualization-node.test.ts | 56 +- .../visualization/visualization-node.ts | 5 +- 13 files changed, 747 insertions(+), 45 deletions(-) create mode 100644 packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx create mode 100644 packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap create mode 100644 packages/ui/src/components/Visualization/Custom/CustomNode.scss diff --git a/packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx b/packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx new file mode 100644 index 000000000..e84a079ca --- /dev/null +++ b/packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx @@ -0,0 +1,16 @@ +import { render, waitFor } from '@testing-library/react'; +import { CamelRouteVisualEntity } from '../../../models/visualization/flows'; +import { camelRouteJson } from '../../../stubs/camel-route'; +import { Canvas } from './Canvas'; + +describe('Canvas', () => { + it('should render correctly', async () => { + const entity = new CamelRouteVisualEntity(camelRouteJson.route); + + const result = render(); + + await waitFor(async () => expect(result.container.querySelector('#fit-to-screen')).toBeInTheDocument()); + + expect(result.container).toMatchSnapshot(); + }); +}); 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 new file mode 100644 index 000000000..ea1243202 --- /dev/null +++ b/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap @@ -0,0 +1,481 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Canvas should render correctly 1`] = ` +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+ +
+
+`; diff --git a/packages/ui/src/components/Visualization/Canvas/canvas.service.test.ts b/packages/ui/src/components/Visualization/Canvas/canvas.service.test.ts index 2db75bb25..eab3203cc 100644 --- a/packages/ui/src/components/Visualization/Canvas/canvas.service.test.ts +++ b/packages/ui/src/components/Visualization/Canvas/canvas.service.test.ts @@ -32,6 +32,11 @@ describe('CanvasService', () => { animationSpeed: EdgeAnimationSpeed.medium, }; + beforeEach(() => { + CanvasService.nodes = []; + CanvasService.edges = []; + }); + it('should start with an empty nodes array', () => { expect(CanvasService.nodes).toEqual([]); }); diff --git a/packages/ui/src/components/Visualization/Custom/CustomNode.scss b/packages/ui/src/components/Visualization/Custom/CustomNode.scss new file mode 100644 index 000000000..aead4fd82 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/CustomNode.scss @@ -0,0 +1,7 @@ +.custom-node__image { + display: flex; + flex-flow: column; + align-items: center; + height: 100%; + justify-content: center; +} diff --git a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx b/packages/ui/src/components/Visualization/Custom/CustomNode.tsx index f99c2ee05..c0260d0cc 100644 --- a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx +++ b/packages/ui/src/components/Visualization/Custom/CustomNode.tsx @@ -1,32 +1,69 @@ -import { DefaultNode, Node, WithSelectionProps, withSelection } from '@patternfly/react-topology'; -import { FunctionComponent } from 'react'; +import { MinusIcon } from '@patternfly/react-icons'; +import { + ContextMenuItem, + DefaultNode, + ElementContext, + ElementModel, + GraphElement, + Node, + WithSelectionProps, + withContextMenu, + withSelection, +} from '@patternfly/react-topology'; +import { FunctionComponent, useCallback, useContext } from 'react'; import defaultCamelIcon from '../../../assets/camel-logo.svg'; +import { EntitiesContext } from '../../../providers/entities.provider'; import { CanvasNode } from '../Canvas/canvas.models'; -import { IVisualizationNode } from '../../../models/visualization/base-visual-entity'; +import { CanvasService } from '../Canvas/canvas.service'; +import './CustomNode.scss'; interface CustomNodeProps extends WithSelectionProps { element: Node; } -function getIcon(data: IVisualizationNode | undefined) { - const iconRadius = 20; - const cx = 25 / 2; - const cy = 25 / 2; - - const icon = data?.iconData ?? defaultCamelIcon; - return ( - - ); -} - const CustomNode: FunctionComponent = ({ element, ...rest }) => { const data = element.getData()?.vizNode; + const icon = data?.iconData ?? defaultCamelIcon; return ( - {getIcon(data)} + + +
+ +
+
+
); }; -export const CustomNodeWithSelection: typeof DefaultNode = withSelection()(CustomNode) as typeof DefaultNode; +const RemoveNode: FunctionComponent = () => { + const entitiesContext = useContext(EntitiesContext); + const element: GraphElement = useContext(ElementContext); + const vizNode = element.getData()?.vizNode; + + const onRemoveNode = useCallback(() => { + vizNode?.removeChild(vizNode); + entitiesContext?.updateCodeFromEntities(); + }, [entitiesContext, vizNode]); + + return ( + + Remove node + + ); +}; + +export const CustomNodeWithSelection: typeof DefaultNode = withContextMenu(() => [ + , +])(withSelection()(CustomNode) as typeof DefaultNode) as typeof DefaultNode; diff --git a/packages/ui/src/components/Visualization/Visualization.scss b/packages/ui/src/components/Visualization/Visualization.scss index de2cb83fa..97a8601db 100644 --- a/packages/ui/src/components/Visualization/Visualization.scss +++ b/packages/ui/src/components/Visualization/Visualization.scss @@ -1,8 +1,4 @@ .canvasSurface { display: flex; flex-flow: column; - - &__graph { - flex-grow: 1; - } } diff --git a/packages/ui/src/models/visualization/base-visual-entity.ts b/packages/ui/src/models/visualization/base-visual-entity.ts index 1b1e64370..c9b5d1b4b 100644 --- a/packages/ui/src/models/visualization/base-visual-entity.ts +++ b/packages/ui/src/models/visualization/base-visual-entity.ts @@ -23,6 +23,9 @@ export interface BaseVisualCamelEntity extends BaseCamelEntity { /** Retrieve the steps from the underlying Camel entity */ getSteps: () => unknown[]; + /** Remove the step at a given path from the underlying Camel entity */ + removeStep: (path?: string) => void; + /** Generates a IVisualizationNode from the underlying Camel entity */ toVizNode: () => IVisualizationNode; } @@ -82,5 +85,6 @@ export interface IVisualizationNode { export interface VisualComponentSchema { title: string; schema: JSONSchemaType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any definition: any; } diff --git a/packages/ui/src/models/visualization/flows/camel-route-visual-entity.test.ts b/packages/ui/src/models/visualization/flows/camel-route-visual-entity.test.ts index 2ebff5987..d8238b68f 100644 --- a/packages/ui/src/models/visualization/flows/camel-route-visual-entity.test.ts +++ b/packages/ui/src/models/visualization/flows/camel-route-visual-entity.test.ts @@ -1,15 +1,16 @@ +import { RouteDefinition } from '@kaoto-next/camel-catalog/types'; import { JSONSchemaType } from 'ajv'; +import cloneDeep from 'lodash.clonedeep'; import { camelRouteJson } from '../../../stubs/camel-route'; import { EntityType } from '../../camel/entities/base-entity'; import { CamelComponentSchemaService } from './camel-component-schema.service'; import { CamelRouteVisualEntity, isCamelRoute } from './camel-route-visual-entity'; -import { RouteDefinition } from '@kaoto-next/camel-catalog/types'; describe('Camel Route', () => { let camelEntity: CamelRouteVisualEntity; beforeEach(() => { - camelEntity = new CamelRouteVisualEntity(JSON.parse(JSON.stringify(camelRouteJson.route))); + camelEntity = new CamelRouteVisualEntity(cloneDeep(camelRouteJson.route)); }); describe('isCamelRoute', () => { @@ -84,7 +85,7 @@ describe('Camel Route', () => { describe('updateModel', () => { it('should not update the model if no path is provided', () => { - const originalObject = JSON.parse(JSON.stringify(camelRouteJson.route)); + const originalObject = cloneDeep(camelRouteJson.route); camelEntity.updateModel(undefined, undefined); @@ -170,6 +171,54 @@ describe('Camel Route', () => { }); }); + describe('removeStep', () => { + it('should not remove any step if no path is provided', () => { + const originalObject = cloneDeep(camelRouteJson.route); + + camelEntity.removeStep(undefined); + + expect(originalObject).toEqual(camelEntity.route); + }); + + it('should set the `from.uri` property to an empty string if the path is `from`', () => { + camelEntity.removeStep('from'); + + expect(camelEntity.route.from?.uri).toEqual(''); + }); + + it('should remove the step if the path is a number', () => { + /** Remove `set-header` step */ + camelEntity.removeStep('from.steps.0'); + + expect(camelEntity.route.from?.steps).toHaveLength(2); + expect(camelEntity.route.from?.steps[0].choice).toBeDefined(); + }); + + it('should remove the step if the path is a word and the penultimate segment is a number', () => { + /** Remove `choice` step */ + camelEntity.removeStep('from.steps.1.choice'); + + expect(camelEntity.route.from?.steps).toHaveLength(2); + expect(camelEntity.route.from?.steps[1].to).toBeDefined(); + }); + + it('should remove the step if the path is a word and the penultimate segment is a word', () => { + /** Remove `to` step */ + camelEntity.removeStep('from.steps.1.choice.otherwise'); + + expect(camelEntity.route.from?.steps).toHaveLength(3); + expect(camelEntity.route.from?.steps[1].choice?.otherwise).toBeUndefined(); + }); + + it('should remove a nested step', () => { + /** Remove second `to: amqp` step form the choice.otherwise step */ + camelEntity.removeStep('from.steps.1.choice.otherwise.steps.1.to'); + + expect(camelEntity.route.from?.steps).toHaveLength(3); + expect(camelEntity.route.from?.steps[1].choice?.otherwise?.steps).toHaveLength(2); + }); + }); + describe('toVizNode', () => { it('should return the viz node and set the initial path to `from`', () => { const vizNode = camelEntity.toVizNode(); diff --git a/packages/ui/src/models/visualization/flows/camel-route-visual-entity.ts b/packages/ui/src/models/visualization/flows/camel-route-visual-entity.ts index ac137c052..cc9910576 100644 --- a/packages/ui/src/models/visualization/flows/camel-route-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/camel-route-visual-entity.ts @@ -62,6 +62,64 @@ export class CamelRouteVisualEntity implements BaseVisualCamelEntity { return this.route.from?.steps ?? []; } + removeStep(path?: string): void { + if (!path) return; + /** + * If there's only one path segment, it means the target is the `from` property of the route + * therefore we replace it with an empty object + */ + if (path === 'from') { + set(this.route, 'from.uri', ''); + return; + } + + const pathArray = path.split('.'); + const last = pathArray[pathArray.length - 1]; + const penultimate = pathArray[pathArray.length - 2]; + + /** + * If the last segment is a number, it means the target object is a member of an array + * therefore we need to look for the array and remove the element at the given index + * + * f.i. from.steps.1.choice.when.0 + * last: 0 + */ + let array = get(this.route, pathArray.slice(0, -1), []); + if (Number.isInteger(Number(last)) && Array.isArray(array)) { + array.splice(Number(last), 1); + + return; + } + + /** + * If the last segment is a word and the penultimate is a number, it means the target is an object + * potentially a Processor, that belongs to an array, therefore we remove it entirely + * + * f.i. from.steps.1.choice + * last: choice + * penultimate: 1 + */ + array = get(this.route, pathArray.slice(0, -2), []); + if (!Number.isInteger(Number(last)) && Number.isInteger(Number(penultimate)) && Array.isArray(array)) { + array.splice(Number(penultimate), 1); + + return; + } + + /** + * If both the last and penultimate segment are words, it means the target is a property of an object + * therefore we delete it + * + * f.i. from.steps.1.choice.otherwise + * last: otherwise + * penultimate: choice + */ + const object = get(this.route, pathArray.slice(0, -1), {}); + if (!Number.isInteger(Number(last)) && !Number.isInteger(Number(penultimate)) && typeof object === 'object') { + delete object[last]; + } + } + toVizNode(): IVisualizationNode { const rootNode = createVisualizationNode((this.route.from?.uri as string) ?? '', this); rootNode.path = 'from'; diff --git a/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts b/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts index 88b84397d..5adecdc95 100644 --- a/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts @@ -25,6 +25,10 @@ export class KameletVisualEntity implements BaseVisualCamelEntity { return []; // TODO } + removeStep(): void { + return; // TODO + } + toJSON(): unknown { return undefined; // TODO } diff --git a/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts b/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts index 724d5ebb7..b124e63e0 100644 --- a/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts @@ -60,6 +60,34 @@ export class PipeVisualEntity implements BaseVisualCamelEntity { return allSteps; } + removeStep(): void { + /** This method needs to be enabled after passing the entire flow to this class*/ + + // if (!path) return; + // /** + // * If the path is `source` or `sink`, we can remove it directly + // */ + // if (path === 'source' || path === 'sink') { + // set(this.flow, path, {}); + // return; + // } + + // const pathArray = path.split('.'); + // const last = pathArray[pathArray.length - 1]; + + // /** + // * If the last segment is a number, it means the target object is a member of an array + // * therefore we need to look for the array and remove the element at the given index + // * + // * f.i. from.steps.1.choice.when.0 + // * last: 0 + // */ + // const array = get(this.flow, pathArray.slice(0, -1), []); + // if (Number.isInteger(Number(last)) && Array.isArray(array)) { + // array.splice(Number(last), 1); + // } + } + toVizNode(): IVisualizationNode { const rootNode = this.getVizNodeFromStep(this.flow?.source, 'source'); const stepNodes = this.flow?.steps && this.getVizNodesFromSteps(this.flow?.steps); diff --git a/packages/ui/src/models/visualization/visualization-node.test.ts b/packages/ui/src/models/visualization/visualization-node.test.ts index e70f21479..bcde37f40 100644 --- a/packages/ui/src/models/visualization/visualization-node.test.ts +++ b/packages/ui/src/models/visualization/visualization-node.test.ts @@ -1,4 +1,6 @@ +import { camelRouteJson } from '../../stubs/camel-route'; import { BaseVisualCamelEntity, IVisualizationNode } from './base-visual-entity'; +import { CamelRouteVisualEntity } from './flows'; import { createVisualizationNode } from './visualization-node'; describe('VisualizationNode', () => { @@ -127,30 +129,46 @@ describe('VisualizationNode', () => { expect(child.getParentNode()).toBeUndefined(); }); - it('should remove a child', () => { - const child = createVisualizationNode('child'); - node.addChild(child); - node.removeChild(child); + describe('removeChild', () => { + it('should remove a child', () => { + const child = createVisualizationNode('child'); + node.addChild(child); + node.removeChild(child); - expect(node.getChildren()).toEqual([]); - expect(child.getParentNode()).toBeUndefined(); - }); + expect(node.getChildren()).toEqual([]); + expect(child.getParentNode()).toBeUndefined(); + }); - it('should remove a child from an existing children array', () => { - const child = createVisualizationNode('child'); - node.setChildren([child]); - node.removeChild(child); + it('should remove a child from an existing children array', () => { + const child = createVisualizationNode('child'); + node.setChildren([child]); + node.removeChild(child); - expect(node.getChildren()).toEqual([]); - expect(child.getParentNode()).toBeUndefined(); - }); + expect(node.getChildren()).toEqual([]); + expect(child.getParentNode()).toBeUndefined(); + }); - it('should not error when removing a non-existing child', () => { - const child = createVisualizationNode('child'); - node.removeChild(child); + it('should not error when removing a non-existing child', () => { + const child = createVisualizationNode('child'); + node.removeChild(child); - expect(node.getChildren()).toBeUndefined(); - expect(child.getParentNode()).toBeUndefined(); + expect(node.getChildren()).toBeUndefined(); + expect(child.getParentNode()).toBeUndefined(); + }); + + it('should delegate to the BaseVisualCamelEntity to remove the underlying child', () => { + const camelRouteVisualEntityStub = new CamelRouteVisualEntity(camelRouteJson.route); + + node = camelRouteVisualEntityStub.toVizNode(); + + /** Remove set-header node */ + node.getNextNode()?.removeChild(node.getNextNode()!); + + /** Refresh the Viz Node */ + node = camelRouteVisualEntityStub.toVizNode(); + + expect(node.getNextNode()?.label).toEqual('choice'); + }); }); it('should populate the leaf nodes ids - simple relationship', () => { diff --git a/packages/ui/src/models/visualization/visualization-node.ts b/packages/ui/src/models/visualization/visualization-node.ts index 4003b7af0..292a3d408 100644 --- a/packages/ui/src/models/visualization/visualization-node.ts +++ b/packages/ui/src/models/visualization/visualization-node.ts @@ -89,6 +89,7 @@ class VisualizationNode implements IVisualizationNode { } removeChild(child: IVisualizationNode): void { + this.getRootNode().getBaseEntity()?.removeStep(this.path); const index = this.children?.findIndex((node) => node.id === child.id); if (index !== undefined && index > -1) { @@ -105,8 +106,6 @@ class VisualizationNode implements IVisualizationNode { } /** If this node has children, populate the leaf nodes ids of each child */ - if (this.children !== undefined) { - this.children.forEach((child) => child.populateLeafNodesIds(ids)); - } + this.children?.forEach((child) => child.populateLeafNodesIds(ids)); } }