diff --git a/packages/ui/src/components/Form/schema.service.ts b/packages/ui/src/components/Form/schema.service.ts index a13489b2d..611bf6fc3 100644 --- a/packages/ui/src/components/Form/schema.service.ts +++ b/packages/ui/src/components/Form/schema.service.ts @@ -4,9 +4,21 @@ import { filterDOMProps, FilterDOMPropsKeys } from 'uniforms'; import { JSONSchemaBridge } from 'uniforms-bridge-json-schema'; export class SchemaService { + static readonly DROPDOWN_PLACEHOLDER = 'Select an option...'; + static readonly OMIT_FORM_FIELDS = [ + 'from', + 'expression', + 'dataFormatType', + 'outputs', + 'steps', + 'when', + 'otherwise', + 'doCatch', + 'doFinally', + 'uri', + ]; private readonly ajv: Ajv; private readonly FILTER_DOM_PROPS = ['$comment', 'additionalProperties']; - static readonly DROPDOWN_PLACEHOLDER = 'Select an option...'; constructor() { this.ajv = new Ajv({ diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx index 3fd5a22ea..aee8891a4 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx @@ -22,7 +22,6 @@ import { act } from 'react-dom/test-utils'; import { RouteDefinition } from '@kaoto-next/camel-catalog/types'; describe('CanvasForm', () => { - const omitFields = ['expression', 'dataFormatType', 'outputs', 'steps', 'when', 'otherwise', 'doCatch', 'doFinally']; const schemaService = new SchemaService(); const schema = { @@ -179,11 +178,12 @@ describe('CanvasForm', () => { } as RouteDefinition; const entity = new CamelRouteVisualEntity(camelRoute); const rootNode: IVisualizationNode = entity.toVizNode(); + const setHeaderNode = rootNode.getChildren()![0].getChildren()![0]; const selectedNode = { id: '1', type: 'node', data: { - vizNode: rootNode.getChildren()![0], + vizNode: setHeaderNode, }, }; @@ -242,11 +242,12 @@ describe('CanvasForm', () => { } as RouteDefinition; const entity = new CamelRouteVisualEntity(camelRoute); const rootNode: IVisualizationNode = entity.toVizNode(); + const setHeaderNode = rootNode.getChildren()![0].getChildren()![0]; const selectedNode = { id: '1', type: 'node', data: { - vizNode: rootNode.getChildren()![0], + vizNode: setHeaderNode, }, }; @@ -306,11 +307,12 @@ describe('CanvasForm', () => { } as RouteDefinition; const entity = new CamelRouteVisualEntity(camelRoute); const rootNode: IVisualizationNode = entity.toVizNode(); + const marshalNode = rootNode.getChildren()![0].getChildren()![0]; const selectedNode = { id: '1', type: 'node', data: { - vizNode: rootNode.getChildren()![0], + vizNode: marshalNode, }, }; @@ -339,6 +341,7 @@ describe('CanvasForm', () => { expect(camelRoute.from.steps[0].marshal!.avro).toBeDefined(); expect(camelRoute.from.steps[0].marshal!.id).toEqual('modified'); }); + it('main form => dataformat', async () => { const camelRoute = { from: { @@ -354,11 +357,12 @@ describe('CanvasForm', () => { } as RouteDefinition; const entity = new CamelRouteVisualEntity(camelRoute); const rootNode: IVisualizationNode = entity.toVizNode(); + const marshalNode = rootNode.getChildren()![0].getChildren()![0]; const selectedNode = { id: '1', type: 'node', data: { - vizNode: rootNode.getChildren()![0], + vizNode: marshalNode, }, }; @@ -405,11 +409,12 @@ describe('CanvasForm', () => { } as RouteDefinition; const entity = new CamelRouteVisualEntity(camelRoute); const rootNode: IVisualizationNode = entity.toVizNode(); + const loadBalanceNode = rootNode.getChildren()![0].getChildren()![0]; const selectedNode = { id: '1', type: 'node', data: { - vizNode: rootNode.getChildren()![0], + vizNode: loadBalanceNode, }, }; @@ -438,6 +443,7 @@ describe('CanvasForm', () => { expect(camelRoute.from.steps[0].loadBalance!.weighted).toBeDefined(); expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('modified'); }); + it('main form => loadbalancer', async () => { const camelRoute = { from: { @@ -453,11 +459,12 @@ describe('CanvasForm', () => { } as RouteDefinition; const entity = new CamelRouteVisualEntity(camelRoute); const rootNode: IVisualizationNode = entity.toVizNode(); + const loadBalanceNode = rootNode.getChildren()![0].getChildren()![0]; const selectedNode = { id: '1', type: 'node', data: { - vizNode: rootNode.getChildren()![0], + vizNode: loadBalanceNode, }, }; @@ -503,7 +510,7 @@ describe('CanvasForm', () => { render( {}}> - + , ); @@ -526,7 +533,7 @@ describe('CanvasForm', () => { render( {}}> - + , ); @@ -549,7 +556,7 @@ describe('CanvasForm', () => { render( {}}> - + , ); diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx index de9f0a3a2..1b30e26fc 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx @@ -17,19 +17,6 @@ interface CanvasFormProps { selectedNode: CanvasNode; } -const omitFields = [ - 'from', - 'expression', - 'dataFormatType', - 'outputs', - 'steps', - 'when', - 'otherwise', - 'doCatch', - 'doFinally', - 'uri', -]; - export const CanvasForm: FunctionComponent = (props) => { const entitiesContext = useContext(EntitiesContext); const formRef = useRef(); @@ -103,7 +90,7 @@ export const CanvasForm: FunctionComponent = (props) => { onChange={handleOnChangeIndividualProp} data-testid="autoform" > - + 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 cf5eef3eb..faccbf313 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 @@ -613,6 +613,53 @@ exports[`Canvas should render correctly 1`] = ` + + + + + + + + + + + + > + + + + + + @@ -863,53 +929,123 @@ exports[`Canvas should render correctly 1`] = ` - - - - - - - + data-type="group" + > + + + + + + route-8888 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + > + + + + + + @@ -1373,53 +1575,123 @@ exports[`Canvas should render correctly with more routes 1`] = ` - - - - - - - + data-type="group" + > + + + + + + route-8888 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + > + + + + + + @@ -1883,53 +2221,123 @@ exports[`Canvas should render the Catalog button if \`CatalogModalContext\` is p - - - - - - - + data-type="group" + > + + + + + + route-8888 + + + + + + + + + + + + + + + + + + + + { const DEFAULT_NODE_PROPS = { @@ -60,7 +61,7 @@ describe('CanvasService', () => { it('should return the correct component for a group', () => { const component = CanvasService.baselineComponentFactory({} as ModelKind, 'group'); - expect(component).toBe(DefaultGroup); + expect(component).toBe(CustomGroupWithSelection); }); it('should return the correct component for a graph', () => { @@ -309,5 +310,29 @@ describe('CanvasService', () => { }, ]); }); + + it('should return a group node for a multiple nodes VisualizationNode with a group', () => { + const routeNode = createVisualizationNode('route', { + entity: { getId: () => 'myId' } as BaseVisualCamelEntity, + isGroup: true, + }); + + const fromNode = createVisualizationNode('timer', { + path: 'from', + icon: undefined, + processorName: 'from', + componentName: 'timer', + }); + routeNode.addChild(fromNode); + + const { nodes, edges } = CanvasService.getFlowDiagram(routeNode); + + expect(nodes).toHaveLength(2); + expect(edges).toHaveLength(0); + + const group = nodes[nodes.length - 1]; + expect(group.children).toEqual(['timer-1234']); + expect(group.group).toBeTruthy(); + }); }); }); diff --git a/packages/ui/src/components/Visualization/Canvas/canvas.service.ts b/packages/ui/src/components/Visualization/Canvas/canvas.service.ts index 24dcd5e6e..91147df9b 100644 --- a/packages/ui/src/components/Visualization/Canvas/canvas.service.ts +++ b/packages/ui/src/components/Visualization/Canvas/canvas.service.ts @@ -6,7 +6,6 @@ import { ConcentricLayout, DagreLayout, DefaultEdge, - DefaultGroup, EdgeAnimationSpeed, EdgeStyle, ForceLayout, @@ -20,7 +19,7 @@ import { withPanZoom, } from '@patternfly/react-topology'; import { IVisualizationNode } from '../../../models/visualization/base-visual-entity'; -import { CustomNodeWithSelection } from '../Custom/CustomNode'; +import { CustomGroupWithSelection, CustomNodeWithSelection } from '../Custom'; import { CanvasDefaults } from './canvas.defaults'; import { CanvasEdge, CanvasNode, CanvasNodesAndEdges, LayoutType } from './canvas.models'; @@ -50,7 +49,7 @@ export class CanvasService { static baselineComponentFactory(kind: ModelKind, type: string): ReturnType { switch (type) { case 'group': - return DefaultGroup; + return CustomGroupWithSelection; default: switch (kind) { case ModelKind.graph: @@ -135,7 +134,20 @@ export class CanvasService { this.nodes = []; this.edges = []; this.visitedNodes = []; - this.appendNodesAndEdges(vizNode); + + const firstChild = vizNode.getChildren()?.[0]; + if (vizNode.data.isGroup && firstChild) { + this.appendNodesAndEdges(firstChild); + const containerId = vizNode.getBaseEntity()?.getId() ?? 'Unknown'; + const group = this.getContainer(containerId, { + label: containerId, + children: this.visitedNodes, + data: { vizNode }, + }); + this.nodes.push(group); + } else { + this.appendNodesAndEdges(vizNode); + } return { nodes: this.nodes, edges: this.edges }; } @@ -189,8 +201,12 @@ export class CanvasService { edges.push(this.getEdge(vizNodeParam.getPreviousNode()!.id, vizNodeParam.id)); } - /** Connect to the parent if there is no previous node */ - if (vizNodeParam.getParentNode() !== undefined && vizNodeParam.getPreviousNode() === undefined) { + /** Connect to the parent if it's not a group and there is no previous node */ + if ( + vizNodeParam.getParentNode() !== undefined && + !vizNodeParam.getParentNode()?.data.isGroup && + vizNodeParam.getPreviousNode() === undefined + ) { edges.push(this.getEdge(vizNodeParam.getParentNode()!.id, vizNodeParam.id)); } @@ -207,6 +223,23 @@ export class CanvasService { return edges; } + private static getContainer( + id: string, + options: { label?: string; children?: string[]; data?: CanvasNode['data'] } = {}, + ): CanvasNode { + return { + id, + type: 'group', + group: true, + label: options.label ?? id, + children: options.children ?? [], + data: options.data, + style: { + padding: CanvasDefaults.DEFAULT_NODE_DIAMETER / 2, + }, + }; + } + private static getNode(id: string, options: { parentNode?: string; data?: CanvasNode['data'] } = {}): CanvasNode { return { id, diff --git a/packages/ui/src/components/Visualization/Custom/CustomGroup.tsx b/packages/ui/src/components/Visualization/Custom/CustomGroup.tsx new file mode 100644 index 000000000..ed532b0f9 --- /dev/null +++ b/packages/ui/src/components/Visualization/Custom/CustomGroup.tsx @@ -0,0 +1,37 @@ +import { DefaultGroup, GraphElement, Layer, isNode, observer, withSelection } from '@patternfly/react-topology'; +import { FunctionComponent } from 'react'; +import { CanvasNode } from '../Canvas/canvas.models'; + +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()(CustomGroup); diff --git a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx b/packages/ui/src/components/Visualization/Custom/CustomNode.tsx index fa69f7bac..8cb8436bc 100644 --- a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx +++ b/packages/ui/src/components/Visualization/Custom/CustomNode.tsx @@ -2,6 +2,7 @@ import { DefaultNode, Node, NodeStatus, + observer, withContextMenu, withSelection, WithSelectionProps, @@ -22,7 +23,7 @@ interface CustomNodeProps extends WithSelectionProps { } const noopFn = () => {}; -const CustomNode: FunctionComponent = ({ element, ...rest }) => { +const CustomNode: FunctionComponent = observer(({ element, ...rest }) => { const vizNode = element.getData()?.vizNode; const label = vizNode?.getNodeLabel(); const tooltipContent = vizNode?.getTooltipContent(); @@ -60,7 +61,7 @@ const CustomNode: FunctionComponent = ({ element, ...rest }) => ); -}; +}); export const CustomNodeWithSelection: typeof DefaultNode = withContextMenu(() => [ { describe('when there are no routes', () => { - it.only('should render the CubesIcon whenever there are no routes', () => { + it('should render the CubesIcon whenever there are no routes', () => { const wrapper = render( diff --git a/packages/ui/src/models/visualization/base-visual-entity.ts b/packages/ui/src/models/visualization/base-visual-entity.ts index 4cf2bc0d6..3c8afedfb 100644 --- a/packages/ui/src/models/visualization/base-visual-entity.ts +++ b/packages/ui/src/models/visualization/base-visual-entity.ts @@ -114,6 +114,7 @@ export interface IVisualizationNodeData { path?: string; entity?: BaseVisualCamelEntity; isPlaceholder?: boolean; + isGroup?: boolean; [key: string]: unknown; } 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 14cb7853d..e8a4050e5 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 @@ -1,8 +1,6 @@ /* eslint-disable no-case-declarations */ import { DoCatch, ProcessorDefinition, RouteDefinition, When1 } from '@kaoto-next/camel-catalog/types'; -import get from 'lodash.get'; -import set from 'lodash.set'; -import { getArrayProperty, isDefined } from '../../../utils'; +import { getArrayProperty, getValue, isDefined, setValue } from '../../../utils'; import { NodeIconResolver } from '../../../utils/node-icon-resolver'; import { DefinedComponent } from '../../camel-catalog-index'; import { EntityType } from '../../camel/entities'; @@ -39,7 +37,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity getNodeLabel(path?: string): string { if (!path) return ''; - const componentModel = get(this.route, path); + const componentModel = getValue(this.route, path); const label = CamelComponentSchemaService.getNodeLabel( CamelComponentSchemaService.getCamelComponentLookup(path, componentModel), componentModel, @@ -50,7 +48,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity getTooltipContent(path?: string): string { if (!path) return ''; - const componentModel = get(this.route, path); + const componentModel = getValue(this.route, path); const content = CamelComponentSchemaService.getTooltipContent( CamelComponentSchemaService.getCamelComponentLookup(path, componentModel), @@ -62,7 +60,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity getComponentSchema(path?: string): VisualComponentSchema | undefined { if (!path) return undefined; - const componentModel = get(this.route, path); + const componentModel = getValue(this.route, path); const visualComponentSchema = CamelComponentSchemaService.getVisualComponentSchema(path, componentModel); return visualComponentSchema; @@ -75,7 +73,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity updateModel(path: string | undefined, value: unknown): void { if (!path) return; - set(this.route, path, value); + setValue(this.route, path, value); } getSteps(): ProcessorDefinition[] { @@ -136,7 +134,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity /** If we're in Replace mode, we need to delete the existing step */ const deleteCount = options.mode === AddStepMode.ReplaceStep ? 1 : 0; - const stepsArray: ProcessorDefinition[] = get(this.route, pathArray.slice(0, -2), []); + const stepsArray: ProcessorDefinition[] = getValue(this.route, pathArray.slice(0, -2), []); stepsArray.splice(desiredStartIndex, deleteCount, defaultValue); return; @@ -150,7 +148,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity * therefore we replace it with an empty object */ if (path === 'from') { - set(this.route, 'from.uri', ''); + setValue(this.route, 'from.uri', ''); return; } @@ -165,7 +163,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity * f.i. from.steps.1.choice.when.0 * last: 0 */ - let array = get(this.route, pathArray.slice(0, -1), []); + let array = getValue(this.route, pathArray.slice(0, -1), []); if (Number.isInteger(Number(last)) && Array.isArray(array)) { array.splice(Number(last), 1); @@ -180,7 +178,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity * last: choice * penultimate: 1 */ - array = get(this.route, pathArray.slice(0, -2), []); + array = getValue(this.route, pathArray.slice(0, -2), []); if (!Number.isInteger(Number(last)) && Number.isInteger(Number(penultimate)) && Array.isArray(array)) { array.splice(Number(penultimate), 1); @@ -195,7 +193,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity * last: otherwise * penultimate: choice */ - const object = get(this.route, pathArray.slice(0, -1), {}); + const object = getValue(this.route, pathArray.slice(0, -1), {}); if (!Number.isInteger(Number(last)) && !Number.isInteger(Number(penultimate)) && typeof object === 'object') { delete object[last]; } @@ -227,17 +225,24 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity } toVizNode(): IVisualizationNode { - const rootNode = this.getVizNodeFromProcessor('from', { + const fromNode = this.getVizNodeFromProcessor('from', { processorName: 'from' as keyof ProcessorDefinition, componentName: CamelComponentSchemaService.getComponentNameFromUri(this.getRootUri()!), }); - rootNode.data.entity = this; if (!this.getRootUri()) { - rootNode.data.icon = NodeIconResolver.getPlaceholderIcon(); + fromNode.data.icon = NodeIconResolver.getPlaceholderIcon(); } - return rootNode; + const routeNode = createVisualizationNode(this.id, { + path: '#', + entity: this, + isGroup: true, + icon: NodeIconResolver.getIcon('route'), + }); + routeNode.addChild(fromNode); + + return routeNode; } private getVizNodeFromProcessor(path: string, componentLookup: ICamelElementLookupResult): IVisualizationNode { @@ -268,14 +273,14 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity switch (stepsProperty.type) { case 'branch': singlePath = `${path}.${stepsProperty.name}`; - const stepsList = get(this.route, singlePath, []) as ProcessorDefinition[]; + const stepsList = getValue(this.route, singlePath, []) as ProcessorDefinition[]; return stepsList.reduce((accStepsNodes, step, index) => { const singlePropertyName = Object.keys(step)[0]; const childPath = `${singlePath}.${index}.${singlePropertyName}`; const childComponentLookup = CamelComponentSchemaService.getCamelComponentLookup( childPath, - get(step, singlePropertyName), + getValue(step, singlePropertyName), ); const vizNode = this.getVizNodeFromProcessor(childPath, childComponentLookup); @@ -295,13 +300,13 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity const childComponentLookup = CamelComponentSchemaService.getCamelComponentLookup(childPath, this.route); /** If the single-clause property is not defined, we don't create a IVisualizationNode for it */ - if (get(this.route, childPath) === undefined) return []; + if (getValue(this.route, childPath) === undefined) return []; return [this.getVizNodeFromProcessor(childPath, childComponentLookup)]; case 'clause-list': singlePath = `${path}.${stepsProperty.name}`; - const expressionList = get(this.route, singlePath, []) as When1[] | DoCatch[]; + const expressionList = getValue(this.route, singlePath, []) as When1[] | DoCatch[]; return expressionList.map((_step, index) => { const childPath = `${singlePath}.${index}`; @@ -326,7 +331,7 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity if (property === undefined) return; if (property.type === 'single-clause') { - set(this.route, `${options.data.path}.${property.name}`, defaultValue); + setValue(this.route, `${options.data.path}.${property.name}`, defaultValue); } else { const arrayPath = getArrayProperty(this.route, `${options.data.path}.${property.name}`); arrayPath.unshift(defaultValue); 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 5ef696013..53f438fd3 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 @@ -3,6 +3,7 @@ import { JSONSchemaType } from 'ajv'; import cloneDeep from 'lodash.clonedeep'; import { camelFromJson } from '../../../stubs/camel-from'; import { camelRouteJson } from '../../../stubs/camel-route'; +import { ROOT_PATH } from '../../../utils'; import { EntityType } from '../../camel/entities/base-entity'; import { IVisualizationNode } from '../base-visual-entity'; import { CamelRouteVisualEntity, isCamelFrom, isCamelRoute } from './camel-route-visual-entity'; @@ -267,30 +268,62 @@ describe('Camel Route', () => { }); describe('toVizNode', () => { - it('should return the viz node and set the initial path to `from`', () => { + it(`should return the group viz node and set the initial path to '${ROOT_PATH}'`, () => { const vizNode = camelEntity.toVizNode(); expect(vizNode).toBeDefined(); - expect(vizNode.data.path).toEqual('from'); + expect(vizNode.data.path).toEqual(ROOT_PATH); + }); + + it('should return the group first child and set the initial path to `from`', () => { + const vizNode = camelEntity.toVizNode(); + const fromNode = vizNode.getChildren()?.[0]; + + expect(fromNode).toBeDefined(); + expect(fromNode?.data.path).toEqual('from'); + }); + + it('should use the route ID as the group label', () => { + const vizNode = camelEntity.toVizNode(); + + expect(vizNode.getNodeLabel()).toEqual('route-8888'); + }); + + it('should use the route description as the group label if available', () => { + camelEntity.route.description = 'This is a route description'; + const vizNode = camelEntity.toVizNode(); + + expect(vizNode.getNodeLabel()).toEqual('This is a route description'); + }); + + it('should use the default group label if the id is not available', () => { + camelEntity.route.id = undefined; + const vizNode = camelEntity.toVizNode(); + + expect(vizNode.getNodeLabel()).toEqual(''); }); it('should use the uri as the node label', () => { const vizNode = camelEntity.toVizNode(); + const fromNode = vizNode.getChildren()?.[0]; - expect(vizNode.getNodeLabel()).toEqual('timer'); + expect(fromNode?.getNodeLabel()).toEqual('timer'); }); it('should set a default label if the uri is not available', () => { camelEntity = new CamelRouteVisualEntity({ from: {} } as RouteDefinition); const vizNode = camelEntity.toVizNode(); + const fromNode = vizNode.getChildren()?.[0]; - expect(vizNode.getNodeLabel()).toEqual('from: Unknown'); + expect(fromNode?.getNodeLabel()).toEqual('from: Unknown'); }); - it('should not create a viz node if a single-clause property is not defined', () => { + it('should populate the viz node chain with simple steps', () => { const vizNode = new CamelRouteVisualEntity({ + id: 'route-1234', from: { uri: 'timer', steps: [{ choice: { when: [{ steps: [{ log: { message: 'We got a one.' } }] }] } }] }, } as unknown as RouteDefinition).toVizNode(); + const fromNode = vizNode.getChildren()![0]; /** Given a structure of * from @@ -299,16 +332,25 @@ describe('Camel Route', () => { * - log */ - /** from */ - expect(vizNode.data.path).toEqual('from'); - expect(vizNode.getNodeLabel()).toEqual('timer'); + /** group node */ + expect(vizNode.data.path).toEqual('#'); + expect(vizNode.data.isGroup).toBeTruthy(); + expect(vizNode.getNodeLabel()).toEqual('route-1234'); /** Since this is the root node, there's no previous step */ expect(vizNode.getPreviousNode()).toBeUndefined(); expect(vizNode.getNextNode()).toBeUndefined(); expect(vizNode.getChildren()).toHaveLength(1); + /** from */ + expect(fromNode.data.path).toEqual('from'); + expect(fromNode.getNodeLabel()).toEqual('timer'); + /** Since this is the first child node, there's no previous step */ + expect(fromNode.getPreviousNode()).toBeUndefined(); + expect(fromNode.getNextNode()).toBeUndefined(); + expect(fromNode.getChildren()).toHaveLength(1); + /** choice */ - const choiceNode = vizNode.getChildren()?.[0] as IVisualizationNode; + const choiceNode = fromNode.getChildren()?.[0] as IVisualizationNode; expect(choiceNode.data.path).toEqual('from.steps.0.choice'); expect(choiceNode.getNodeLabel()).toEqual('choice'); expect(choiceNode.getPreviousNode()).toBeUndefined(); @@ -324,6 +366,7 @@ describe('Camel Route', () => { it('should populate the viz node chain with the steps', () => { const vizNode = camelEntity.toVizNode(); + const fromNode = vizNode.getChildren()![0]; /** Given a structure of * from @@ -338,16 +381,25 @@ describe('Camel Route', () => { * - toDirect */ - /** from */ - expect(vizNode.data.path).toEqual('from'); - expect(vizNode.getNodeLabel()).toEqual('timer'); + /** group node */ + expect(vizNode.data.path).toEqual('#'); + expect(vizNode.data.isGroup).toBeTruthy(); + expect(vizNode.getNodeLabel()).toEqual('route-8888'); /** Since this is the root node, there's no previous step */ expect(vizNode.getPreviousNode()).toBeUndefined(); expect(vizNode.getNextNode()).toBeUndefined(); - expect(vizNode.getChildren()).toHaveLength(3); + expect(vizNode.getChildren()).toHaveLength(1); + + /** from */ + expect(fromNode.data.path).toEqual('from'); + expect(fromNode.getNodeLabel()).toEqual('timer'); + /** Since this is the first child node, there's no previous step */ + expect(fromNode.getPreviousNode()).toBeUndefined(); + expect(fromNode.getNextNode()).toBeUndefined(); + expect(fromNode.getChildren()).toHaveLength(3); /** setHeader */ - const setHeaderNode = vizNode.getChildren()?.[0] as IVisualizationNode; + const setHeaderNode = fromNode.getChildren()?.[0] as IVisualizationNode; expect(setHeaderNode.data.path).toEqual('from.steps.0.set-header'); expect(setHeaderNode.getNodeLabel()).toEqual('set-header'); expect(setHeaderNode.getPreviousNode()).toBeUndefined(); diff --git a/packages/ui/src/models/visualization/flows/kamelet-visual-entity.test.ts b/packages/ui/src/models/visualization/flows/kamelet-visual-entity.test.ts new file mode 100644 index 000000000..0b6c96e1e --- /dev/null +++ b/packages/ui/src/models/visualization/flows/kamelet-visual-entity.test.ts @@ -0,0 +1,110 @@ +import { camelFromJson } from '../../../stubs/camel-from'; +import { ROOT_PATH } from '../../../utils'; +import { SourceSchemaType } from '../../camel'; +import { IKameletDefinition, IKameletMetadata, IKameletSpecProperty } from '../../kamelets-catalog'; +import { AbstractCamelVisualEntity } from './abstract-camel-visual-entity'; +import { KameletVisualEntity } from './kamelet-visual-entity'; + +describe('KameletVisualEntity', () => { + let kameletDef: IKameletDefinition; + + beforeEach(() => { + kameletDef = { + kind: SourceSchemaType.Kamelet, + metadata: { + name: 'My Kamelet', + labels: { + 'camel.apache.org/kamelet.type': '', + }, + annotations: { + 'camel.apache.org/kamelet.support.level': '', + 'camel.apache.org/catalog.version': '', + 'camel.apache.org/kamelet.icon': '', + 'camel.apache.org/provider': '', + 'camel.apache.org/kamelet.group': '', + 'camel.apache.org/kamelet.namespace': '', + }, + }, + spec: { + definition: { + title: 'My Kamelet', + description: 'My Kamelet Description', + required: ['schedule'], + properties: { + schedule: { + title: 'Cron Schedule', + description: 'A cron example', + type: 'number', + }, + message: { + title: 'Message', + description: 'The message to generate', + default: 'hello', + type: 'string', + example: 'secretsmanager.amazonaws.com', + }, + } as Record, + type: 'source', + }, + template: { + from: camelFromJson.from, + }, + dependencies: [], + }, + }; + }); + + it('should create an instance', () => { + expect(new KameletVisualEntity(kameletDef)).toBeTruthy(); + }); + + it('should set the id to the name if provided', () => { + const kamelet = new KameletVisualEntity(kameletDef); + expect(kamelet.id).toEqual('My Kamelet'); + expect(kamelet.metadata.name).toEqual('My Kamelet'); + }); + + it('should set a random id if the kamelet name is not provided', () => { + kameletDef.metadata.name = undefined as unknown as IKameletMetadata['name']; + const kamelet = new KameletVisualEntity(kameletDef); + expect(kamelet.id).toEqual('kamelet-1234'); + expect(kamelet.metadata.name).toEqual('kamelet-1234'); + }); + + it('should set the id', () => { + const kamelet = new KameletVisualEntity(kameletDef); + kamelet.setId('new-id'); + expect(kamelet.id).toEqual('new-id'); + expect(kamelet.metadata.name).toEqual('new-id'); + }); + + describe('getComponentSchema', () => { + it('should return the kamelet root schema when querying the ROOT_PATH', () => { + const kamelet = new KameletVisualEntity(kameletDef); + expect(kamelet.getComponentSchema(ROOT_PATH)).toEqual({ + title: 'Kamelet', + schema: {}, + definition: kamelet.route, + }); + }); + + it('should return the component schema from the underlying AbstractCamelVisualEntity', () => { + const getComponentSchemaSpy = jest.spyOn(AbstractCamelVisualEntity.prototype, 'getComponentSchema'); + + const kamelet = new KameletVisualEntity(kameletDef); + kamelet.getComponentSchema('test-path'); + + expect(getComponentSchemaSpy).toHaveBeenCalledWith('test-path'); + }); + }); + + it('should return the root uri', () => { + class KameletVisualEntityTest extends KameletVisualEntity { + getRootUri(): string | undefined { + return super.getRootUri(); + } + } + const kamelet = new KameletVisualEntityTest(kameletDef); + expect(kamelet.getRootUri()).toEqual('timer:tutorial'); + }); +}); 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 e381e2c55..1bea54dca 100644 --- a/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts @@ -1,8 +1,10 @@ -import { AbstractCamelVisualEntity } from './abstract-camel-visual-entity'; -/* eslint-disable no-case-declarations */ -import { IKameletDefinition, IKameletMetadata, IKameletSpec } from '../..'; +import { JSONSchemaType } from 'ajv'; import { getCamelRandomId } from '../../../camel-utils/camel-random-id'; +import { ROOT_PATH } from '../../../utils'; import { EntityType } from '../../camel/entities'; +import { IKameletDefinition, IKameletMetadata, IKameletSpec } from '../../kamelets-catalog'; +import { VisualComponentSchema } from '../base-visual-entity'; +import { AbstractCamelVisualEntity } from './abstract-camel-visual-entity'; export class KameletVisualEntity extends AbstractCamelVisualEntity { id: string; @@ -14,6 +16,7 @@ export class KameletVisualEntity extends AbstractCamelVisualEntity { super({ id: kamelet.metadata?.name, from: kamelet?.spec.template.from }); this.id = (kamelet?.metadata?.name as string) ?? getCamelRandomId('kamelet'); this.metadata = kamelet?.metadata ?? { name: this.id }; + this.metadata.name = kamelet?.metadata.name ?? this.id; this.spec = kamelet.spec; } @@ -23,6 +26,19 @@ export class KameletVisualEntity extends AbstractCamelVisualEntity { this.metadata.name = this.id; } + getComponentSchema(path?: string | undefined): VisualComponentSchema | undefined { + if (path === ROOT_PATH) { + /** A better schema will be provided at a later stage */ + return { + title: 'Kamelet', + schema: {} as JSONSchemaType, + definition: this.route, + }; + } + + return super.getComponentSchema(path); + } + protected getRootUri(): string | undefined { return this.spec.template.from?.uri; } diff --git a/packages/ui/src/models/visualization/flows/support/__snapshots__/camel-component-schema.service.test.ts.snap b/packages/ui/src/models/visualization/flows/support/__snapshots__/camel-component-schema.service.test.ts.snap index f5e2dec92..8b37b3079 100644 --- a/packages/ui/src/models/visualization/flows/support/__snapshots__/camel-component-schema.service.test.ts.snap +++ b/packages/ui/src/models/visualization/flows/support/__snapshots__/camel-component-schema.service.test.ts.snap @@ -1,5 +1,216 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`CamelComponentSchemaService getVisualComponentSchema should build the appropriate schema for \`route\` entity 1`] = ` +{ + "definition": { + "from": { + "uri": "timer:MyTimer?period=1000", + }, + "id": "route-1234", + }, + "schema": { + "additionalProperties": false, + "definitions": { + "org.apache.camel.model.FromDefinition": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + }, + "id": { + "type": "string", + }, + "parameters": { + "type": "object", + }, + "steps": { + "items": { + "$ref": "#/definitions/org.apache.camel.model.ProcessorDefinition", + }, + "type": "array", + }, + "uri": { + "type": "string", + }, + }, + "required": [ + "steps", + "uri", + ], + "type": "object", + }, + "org.apache.camel.model.InputTypeDefinition": { + "additionalProperties": false, + "description": "Set the expected data type of the input message. If the actual message type is different at runtime, camel look for a required Transformer and apply if exists. If validate attribute is true then camel applies Validator as well. Type name consists of two parts, 'scheme' and 'name' connected with ':'. For Java type 'name' is a fully qualified class name. For example {code java:java.lang.String} , {code json:ABCOrder} . It's also possible to specify only scheme part, so that it works like a wildcard. If only 'xml' is specified, all the XML message matches. It's handy to add only one transformer/validator for all the transformation from/to XML.", + "properties": { + "description": { + "description": "Sets the description of this node", + "title": "Description", + "type": "string", + }, + "id": { + "description": "Sets the id of this node", + "title": "Id", + "type": "string", + }, + "urn": { + "description": "The input type URN.", + "title": "Urn", + "type": "string", + }, + "validate": { + "description": "Whether if validation is required for this input type.", + "title": "Validate", + "type": "boolean", + }, + }, + "required": [ + "urn", + ], + "title": "Input Type", + "type": "object", + }, + "org.apache.camel.model.OutputTypeDefinition": { + "additionalProperties": false, + "description": "Set the expected data type of the output message. If the actual message type is different at runtime, camel look for a required Transformer and apply if exists. If validate attribute is true then camel applies Validator as well. Type name consists of two parts, 'scheme' and 'name' connected with ':'. For Java type 'name' is a fully qualified class name. For example {code java:java.lang.String} , {code json:ABCOrder} . It's also possible to specify only scheme part, so that it works like a wildcard. If only 'xml' is specified, all the XML message matches. It's handy to add only one transformer/validator for all the XML-Java transformation.", + "properties": { + "description": { + "description": "Sets the description of this node", + "title": "Description", + "type": "string", + }, + "id": { + "description": "Sets the id of this node", + "title": "Id", + "type": "string", + }, + "urn": { + "description": "Set output type URN.", + "title": "Urn", + "type": "string", + }, + "validate": { + "description": "Whether if validation is required for this output type.", + "title": "Validate", + "type": "boolean", + }, + }, + "required": [ + "urn", + ], + "title": "Output Type", + "type": "object", + }, + }, + "properties": { + "autoStartup": { + "default": "true", + "description": "Whether to auto start this route", + "title": "Auto Startup", + "type": "boolean", + }, + "description": { + "description": "Sets the description of this node", + "title": "Description", + "type": "string", + }, + "from": { + "$ref": "#/definitions/org.apache.camel.model.FromDefinition", + "description": "From", + "title": "From", + }, + "group": { + "description": "The group that this route belongs to; could be the name of the RouteBuilder class or be explicitly configured in the XML. May be null.", + "title": "Group", + "type": "string", + }, + "id": { + "description": "Sets the id of this node", + "title": "Id", + "type": "string", + }, + "inputType": { + "$ref": "#/definitions/org.apache.camel.model.InputTypeDefinition", + }, + "logMask": { + "default": "false", + "description": "Whether security mask for Logging is enabled on this route.", + "title": "Log Mask", + "type": "boolean", + }, + "messageHistory": { + "description": "Whether message history is enabled on this route.", + "title": "Message History", + "type": "boolean", + }, + "nodePrefixId": { + "description": "Sets a prefix to use for all node ids (not route id).", + "title": "Node Prefix Id", + "type": "string", + }, + "outputType": { + "$ref": "#/definitions/org.apache.camel.model.OutputTypeDefinition", + }, + "precondition": { + "description": "The predicate of the precondition in simple language to evaluate in order to determine if this route should be included or not.", + "title": "Precondition", + "type": "string", + }, + "routeConfigurationId": { + "description": "The route configuration id or pattern this route should use for configuration. Multiple id/pattern can be separated by comma.", + "title": "Route Configuration Id", + "type": "string", + }, + "routePolicy": { + "description": "Reference to custom org.apache.camel.spi.RoutePolicy to use by the route. Multiple policies can be configured by separating values using comma.", + "title": "Route Policy", + "type": "string", + }, + "shutdownRoute": { + "default": "Default", + "description": "To control how to shutdown the route.", + "enum": [ + "Default", + "Defer", + ], + "title": "Shutdown Route", + "type": "string", + }, + "shutdownRunningTask": { + "default": "CompleteCurrentTaskOnly", + "description": "To control how to shut down the route.", + "enum": [ + "CompleteCurrentTaskOnly", + "CompleteAllTasks", + ], + "title": "Shutdown Running Task", + "type": "string", + }, + "startupOrder": { + "description": "To configure the ordering of the routes being started", + "title": "Startup Order", + "type": "number", + }, + "streamCaching": { + "description": "Whether stream caching is enabled on this route.", + "title": "Stream Cache", + "type": "boolean", + }, + "trace": { + "description": "Whether tracing is enabled on this route.", + "title": "Trace", + "type": "boolean", + }, + }, + "required": [ + "from", + ], + "type": "object", + }, + "title": "route", +} +`; + exports[`CamelComponentSchemaService getVisualComponentSchema should build the appropriate schema for processors combined that holds a component 1`] = ` { "definition": { diff --git a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts index 011295b75..5e7e2b944 100644 --- a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts +++ b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts @@ -6,6 +6,7 @@ import { CamelComponentSchemaService } from './camel-component-schema.service'; import { ICamelComponentDefinition } from '../../../camel-components-catalog'; import { ICamelProcessorDefinition } from '../../../camel-processors-catalog'; import { IKameletDefinition } from '../../../kamelets-catalog'; +import { ROOT_PATH } from '../../../../utils'; describe('CamelComponentSchemaService', () => { let path: string; @@ -17,6 +18,7 @@ describe('CamelComponentSchemaService', () => { modelCatalogMap = await import('@kaoto-next/camel-catalog/' + catalogIndex.catalogs.models.file); const patternCatalogMap = await import('@kaoto-next/camel-catalog/' + catalogIndex.catalogs.patterns.file); const kameletCatalogMap = await import('@kaoto-next/camel-catalog/' + catalogIndex.catalogs.kamelets.file); + const entityCatalogMap = await import('@kaoto-next/camel-catalog/' + catalogIndex.catalogs.entities.file); CamelCatalogService.setCatalogKey( CatalogKind.Component, componentCatalogMap as unknown as Record, @@ -33,6 +35,10 @@ describe('CamelComponentSchemaService', () => { CatalogKind.Kamelet, kameletCatalogMap as unknown as Record, ); + CamelCatalogService.setCatalogKey( + CatalogKind.Entity, + entityCatalogMap as unknown as Record, + ); }); beforeEach(() => { @@ -75,6 +81,17 @@ describe('CamelComponentSchemaService', () => { expect(result!.schema).not.toBe(modelCatalogMap.from.propertiesSchema); }); + it('should build the appropriate schema for `route` entity', () => { + const camelCatalogServiceSpy = jest.spyOn(CamelCatalogService, 'getComponent'); + const rootPath = ROOT_PATH; + const routeDefinition = { id: 'route-1234', from: { uri: 'timer:MyTimer?period=1000' } }; + + const result = CamelComponentSchemaService.getVisualComponentSchema(rootPath, routeDefinition); + + expect(camelCatalogServiceSpy).toHaveBeenCalledWith(CatalogKind.Entity, 'route'); + expect(result).toMatchSnapshot(); + }); + it('should build the appropriate schema for standalone processors', () => { const camelCatalogServiceSpy = jest.spyOn(CamelCatalogService, 'getComponent'); const logPath = 'from.steps.0.log'; @@ -212,6 +229,7 @@ describe('CamelComponentSchemaService', () => { describe('getCamelComponentLookup', () => { it.each([ + [ROOT_PATH, { from: { uri: 'timer:foo?delay=1000&period=1000' } }, { processorName: 'route' }], ['from', { uri: 'timer:foo?delay=1000&period=1000' }, { processorName: 'from', componentName: 'timer' }], ['from.steps.0.to', { uri: 'log' }, { processorName: 'to', componentName: 'log' }], ['from.steps.1.toD', { uri: 'log' }, { processorName: 'toD', componentName: 'log' }], @@ -240,6 +258,16 @@ describe('CamelComponentSchemaService', () => { }); it.each([ + [ + { processorName: 'route' as keyof ProcessorDefinition }, + { id: 'route-1234', description: 'My Route description', from: { uri: 'timer:foo' } }, + 'My Route description', + ], + [ + { processorName: 'route' as keyof ProcessorDefinition }, + { id: 'route-1234', from: { uri: 'timer:foo', description: '' } }, + 'route-1234', + ], [{ processorName: 'from' as keyof ProcessorDefinition }, { uri: 'timer:foo', description: '' }, 'timer:foo'], [ { processorName: 'from' as keyof ProcessorDefinition }, 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 48b4d9f67..6cc2d6939 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 @@ -1,7 +1,7 @@ import { ProcessorDefinition } from '@kaoto-next/camel-catalog/types'; import type { JSONSchemaType } from 'ajv'; import cloneDeep from 'lodash/cloneDeep'; -import { CamelUriHelper, isDefined } from '../../../../utils'; +import { CamelUriHelper, ROOT_PATH, isDefined } from '../../../../utils'; import { CatalogKind } from '../../../catalog-kind'; import { VisualComponentSchema } from '../../base-visual-entity'; import { CamelCatalogService } from '../camel-catalog.service'; @@ -30,6 +30,11 @@ export class CamelComponentSchemaService { const lastPathSegment = splitPath[splitPath.length - 1]; const pathAsIndex = Number.parseInt(lastPathSegment, 10); + /** If path is `#` it means the root of the Camel Route */ + if (path === ROOT_PATH) { + return { processorName: 'route' as keyof ProcessorDefinition }; + } + /** * If the last path segment is NaN, it means this is a Camel Processor * for instance, `from`, `otherwise` or `to` properties in a Route @@ -60,6 +65,9 @@ export class CamelComponentSchemaService { const uriString = CamelUriHelper.getUriString(definition); switch (camelElementLookup.processorName) { + case 'route' as keyof ProcessorDefinition: + return definition?.id ?? ''; + case 'from' as keyof ProcessorDefinition: return uriString ?? 'from: Unknown'; @@ -221,9 +229,23 @@ export class CamelComponentSchemaService { } private static getSchema(camelElementLookup: ICamelElementLookupResult): JSONSchemaType { - // 'from' is not a ProcessorDefinition, i.e. causes a type error against keyof ProcessorDefinition - const catalogKind = - (camelElementLookup.processorName as string) === 'from' ? CatalogKind.Processor : CatalogKind.Pattern; + let catalogKind: CatalogKind; + switch (camelElementLookup.processorName) { + case 'route' as keyof ProcessorDefinition: + catalogKind = CatalogKind.Entity; + break; + case 'from' as keyof ProcessorDefinition: + /** + * The `from` processor is a special case, since it's not a ProcessorDefinition + * so its schema is not defined in the Camel Catalog + * @see CamelCatalogProcessor#getModelCatalog() + */ + catalogKind = CatalogKind.Processor; + break; + default: + catalogKind = CatalogKind.Pattern; + } + const processorDefinition = CamelCatalogService.getComponent(catalogKind, camelElementLookup.processorName); if (processorDefinition === undefined) return {} as unknown as JSONSchemaType; diff --git a/packages/ui/src/models/visualization/visualization-node.test.ts b/packages/ui/src/models/visualization/visualization-node.test.ts index aa9e65a4a..390393482 100644 --- a/packages/ui/src/models/visualization/visualization-node.test.ts +++ b/packages/ui/src/models/visualization/visualization-node.test.ts @@ -176,9 +176,10 @@ describe('VisualizationNode', () => { const camelRouteVisualEntityStub = new CamelRouteVisualEntity(camelRouteJson.route); node = camelRouteVisualEntityStub.toVizNode(); + const fromNode = node.getChildren()?.[0]; /** Get set-header node */ - const setHeaderNode = node.getChildren()?.[0]; + const setHeaderNode = fromNode!.getChildren()?.[0]; /** Remove set-header node */ setHeaderNode!.removeChild(); @@ -186,7 +187,9 @@ describe('VisualizationNode', () => { /** Refresh the Viz Node */ node = camelRouteVisualEntityStub.toVizNode(); - expect(node.getChildren()?.[0].getNodeLabel()).toEqual('choice'); + expect(node.getChildren()?.[0].getNodeLabel()).toEqual('timer'); + expect(fromNode!.getChildren()?.[0].getNodeLabel()).toEqual('choice-1234'); + expect(fromNode!.getChildren()).toHaveLength(2); }); });