diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx index 8cfc419d0..d8aa69673 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx @@ -11,6 +11,7 @@ import { DataFormatEditor } from './DataFormatEditor'; import { LoadBalancerEditor } from './LoadBalancerEditor'; import { StepExpressionEditor } from './StepExpressionEditor'; import { CanvasNode } from './canvas.models'; +import { ModelValidationService } from '../../../models/visualization/flows/support/model-validation.service'; interface CanvasFormProps { selectedNode: CanvasNode; @@ -53,10 +54,13 @@ export const CanvasForm: FunctionComponent = (props) => { const handleOnChange = useCallback( (newModel: Record) => { - props.selectedNode.data?.vizNode?.updateModel(newModel); + if (props.selectedNode.data?.vizNode) { + props.selectedNode.data.vizNode.updateModel(newModel); + ModelValidationService.validateNodeStatus(visualComponentSchema, newModel, props.selectedNode.data.vizNode); + } entitiesContext?.updateSourceCodeFromEntities(); }, - [entitiesContext, props.selectedNode.data?.vizNode, props.selectedNode.id], + [entitiesContext, props.selectedNode.data?.vizNode, visualComponentSchema], ); const isExpressionAwareStep = useMemo(() => { diff --git a/packages/ui/src/components/Visualization/Canvas/canvas.defaults.ts b/packages/ui/src/components/Visualization/Canvas/canvas.defaults.ts index b4f26feeb..2d2078eed 100644 --- a/packages/ui/src/components/Visualization/Canvas/canvas.defaults.ts +++ b/packages/ui/src/components/Visualization/Canvas/canvas.defaults.ts @@ -1,4 +1,4 @@ -import { NodeShape } from '@patternfly/react-topology'; +import { NodeShape, NodeStatus } from '@patternfly/react-topology'; import { LayoutType } from './canvas.models'; export class CanvasDefaults { @@ -6,4 +6,5 @@ export class CanvasDefaults { static readonly DEFAULT_NODE_SHAPE = NodeShape.rect; static readonly DEFAULT_NODE_DIAMETER = 75; static readonly DEFAULT_GROUP_PADDING = 50; + static DEFAULT_NODE_STATUS = NodeStatus.default; } 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 8491f207a..d02c2dee9 100644 --- a/packages/ui/src/components/Visualization/Canvas/canvas.service.test.ts +++ b/packages/ui/src/components/Visualization/Canvas/canvas.service.test.ts @@ -22,6 +22,7 @@ describe('CanvasService', () => { type: 'node', data: undefined, shape: CanvasDefaults.DEFAULT_NODE_SHAPE, + status: CanvasDefaults.DEFAULT_NODE_STATUS, width: CanvasDefaults.DEFAULT_NODE_DIAMETER, height: CanvasDefaults.DEFAULT_NODE_DIAMETER, }; diff --git a/packages/ui/src/components/Visualization/Canvas/canvas.service.ts b/packages/ui/src/components/Visualization/Canvas/canvas.service.ts index 24dcd5e6e..a363e7b21 100644 --- a/packages/ui/src/components/Visualization/Canvas/canvas.service.ts +++ b/packages/ui/src/components/Visualization/Canvas/canvas.service.ts @@ -216,6 +216,7 @@ export class CanvasService { width: CanvasDefaults.DEFAULT_NODE_DIAMETER, height: CanvasDefaults.DEFAULT_NODE_DIAMETER, shape: CanvasDefaults.DEFAULT_NODE_SHAPE, + status: options.data?.vizNode?.getNodeStatus(), }; } diff --git a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx b/packages/ui/src/components/Visualization/Custom/CustomNode.tsx index 379e40e53..bae702ae9 100644 --- a/packages/ui/src/components/Visualization/Custom/CustomNode.tsx +++ b/packages/ui/src/components/Visualization/Custom/CustomNode.tsx @@ -1,5 +1,12 @@ -import { DefaultNode, Node, WithSelectionProps, withContextMenu, withSelection } from '@patternfly/react-topology'; -import { FunctionComponent } from 'react'; +import { + DefaultNode, + Node, + NodeStatus, + withContextMenu, + withSelection, + WithSelectionProps, +} from '@patternfly/react-topology'; +import { FunctionComponent, useState } from 'react'; import { AddStepMode } from '../../../models/visualization/base-visual-entity'; import { CanvasDefaults } from '../Canvas/canvas.defaults'; import { CanvasNode } from '../Canvas/canvas.models'; @@ -16,9 +23,22 @@ interface CustomNodeProps extends WithSelectionProps { const CustomNode: FunctionComponent = ({ element, ...rest }) => { const vizNode = element.getData()?.vizNode; const label = vizNode?.getNodeLabel(); + const [statusDecoratorTooltip, setStatusDecoratorTooltip] = useState(vizNode?.getNodeStatusMessage()); + vizNode?.addNodeStatusListener((status: NodeStatus, message: string | undefined) => { + element.setNodeStatus(status); + setStatusDecoratorTooltip(message); + }); return ( - + {}} + > { id: route-1234 from: id: from-1234 - uri: timer:template + uri: timer parameters: period: "1000" + timerName: template steps: - log: id: log-1234 diff --git a/packages/ui/src/models/camel/__snapshots__/camel-resource.test.ts.snap b/packages/ui/src/models/camel/__snapshots__/camel-resource.test.ts.snap index 42fa19f01..39e0a0c79 100644 --- a/packages/ui/src/models/camel/__snapshots__/camel-resource.test.ts.snap +++ b/packages/ui/src/models/camel/__snapshots__/camel-resource.test.ts.snap @@ -8,6 +8,7 @@ exports[`createCamelResource should create an empty KameletResource if no args i "id": "from-1234", "parameters": { "period": "{{period}}", + "timerName": "user", }, "steps": [ { @@ -17,7 +18,7 @@ exports[`createCamelResource should create an empty KameletResource if no args i "to": "kamelet:sink", }, ], - "uri": "timer:user", + "uri": "timer", }, "id": "kamelet-1234", }, diff --git a/packages/ui/src/models/camel/__snapshots__/kamelet-resource.test.ts.snap b/packages/ui/src/models/camel/__snapshots__/kamelet-resource.test.ts.snap index aa454c089..a35319ed2 100644 --- a/packages/ui/src/models/camel/__snapshots__/kamelet-resource.test.ts.snap +++ b/packages/ui/src/models/camel/__snapshots__/kamelet-resource.test.ts.snap @@ -41,6 +41,7 @@ exports[`KameletResource should convert to JSON 1`] = ` "id": "from-1234", "parameters": { "period": "{{period}}", + "timerName": "user", }, "steps": [ { @@ -50,7 +51,7 @@ exports[`KameletResource should convert to JSON 1`] = ` "to": "kamelet:sink", }, ], - "uri": "timer:user", + "uri": "timer", }, }, "types": { @@ -103,6 +104,7 @@ exports[`KameletResource should create a new KameletResource 1`] = ` "id": "from-1234", "parameters": { "period": "{{period}}", + "timerName": "user", }, "steps": [ { @@ -112,7 +114,7 @@ exports[`KameletResource should create a new KameletResource 1`] = ` "to": "kamelet:sink", }, ], - "uri": "timer:user", + "uri": "timer", }, }, "types": { @@ -167,6 +169,7 @@ exports[`KameletResource should get the visual entities (Camel Route Visual Enti "id": "from-1234", "parameters": { "period": "{{period}}", + "timerName": "user", }, "steps": [ { @@ -176,7 +179,7 @@ exports[`KameletResource should get the visual entities (Camel Route Visual Enti "to": "kamelet:sink", }, ], - "uri": "timer:user", + "uri": "timer", }, "id": "kamelet-1234", }, diff --git a/packages/ui/src/models/visualization/base-visual-entity.ts b/packages/ui/src/models/visualization/base-visual-entity.ts index f6c5a21ad..c96365749 100644 --- a/packages/ui/src/models/visualization/base-visual-entity.ts +++ b/packages/ui/src/models/visualization/base-visual-entity.ts @@ -1,6 +1,7 @@ import type { JSONSchemaType } from 'ajv'; import { DefinedComponent } from '../camel-catalog-index'; import { BaseCamelEntity, EntityType } from '../camel/entities'; +import { NodeStatus } from '@patternfly/react-topology'; /** * BaseVisualCamelEntity @@ -94,6 +95,14 @@ export interface IVisualizationNode void): void; + + getNodeStatus(): NodeStatus; + + setNodeStatus(status: NodeStatus, message: string | undefined): void; + + getNodeStatusMessage(): string | undefined; } export interface IVisualizationNodeData { 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 f6c5a749e..ec07b1a94 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 @@ -22,6 +22,7 @@ import { ICamelElementLookupResult, } from './support/camel-component-types'; import { EntityType } from '../../camel/entities'; +import { ModelValidationService } from './support/model-validation.service'; export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity { constructor(public route: RouteDefinition) {} @@ -240,6 +241,9 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity childrenVizNodes.forEach((childVizNode) => vizNode.addChild(childVizNode)); }); + const schema = this.getComponentSchema(path); + const model = get(this.route, path); + ModelValidationService.validateNodeStatus(schema, model, vizNode); return vizNode; } diff --git a/packages/ui/src/models/visualization/flows/support/model-validation.service.test.ts b/packages/ui/src/models/visualization/flows/support/model-validation.service.test.ts new file mode 100644 index 000000000..599b7d322 --- /dev/null +++ b/packages/ui/src/models/visualization/flows/support/model-validation.service.test.ts @@ -0,0 +1,68 @@ +import { ModelValidationService } from './model-validation.service'; +import * as componentCatalogMap from '@kaoto-next/camel-catalog/camel-catalog-aggregate-components.json'; +import * as modelCatalogMap from '@kaoto-next/camel-catalog/camel-catalog-aggregate-models.json'; +import * as patternCatalogMap from '@kaoto-next/camel-catalog/camel-catalog-aggregate-patterns.json'; +import { createVisualizationNode } from '../../visualization-node'; +import { NodeStatus } from '@patternfly/react-topology'; +import { CamelComponentSchemaService } from './camel-component-schema.service'; +import { CamelCatalogService } from '../camel-catalog.service'; +import { CatalogKind } from '../../../catalog-kind'; +import { ICamelComponentDefinition } from '../../../camel-components-catalog'; +import { ICamelProcessorDefinition } from '../../../camel-processors-catalog'; +describe('ModelValidationService', () => { + const camelRoute = { + route: { + id: 'route-8888', + from: { + uri: 'timer:tutorial', + steps: [ + { + to: { + uri: 'activemq', + parameters: {}, + }, + }, + ], + }, + }, + }; + + beforeAll(() => { + CamelCatalogService.setCatalogKey( + CatalogKind.Component, + componentCatalogMap as unknown as Record, + ); + CamelCatalogService.setCatalogKey( + CatalogKind.Processor, + modelCatalogMap as unknown as Record, + ); + CamelCatalogService.setCatalogKey( + CatalogKind.Pattern, + patternCatalogMap as unknown as Record, + ); + }); + + describe('validateNodeStatus()', () => { + it('should set warning status if required parameter is missing', () => { + const model = camelRoute.route.from.steps[0].to; + const path = 'route.from.steps[0].to'; + const schema = CamelComponentSchemaService.getVisualComponentSchema(path, model); + const vizNode = createVisualizationNode('dummy', {}); + ModelValidationService.validateNodeStatus(schema, model, vizNode); + expect(vizNode.getNodeStatus()).toEqual(NodeStatus.warning); + expect(vizNode.getNodeStatusMessage()).toContain('destinationName'); + }); + + it('should set default status if required parameter is set', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = { ...camelRoute.route.from.steps[0].to } as any; + model.parameters['destinationName'] = 'myQueue'; + const path = 'route.from.steps[0].to'; + const schema = CamelComponentSchemaService.getVisualComponentSchema(path, model); + const vizNode = createVisualizationNode('dummy', {}); + ModelValidationService.validateNodeStatus(schema, model, vizNode); + expect(vizNode.getNodeStatus()).toEqual(NodeStatus.default); + expect(vizNode.getNodeStatusMessage()).toBeUndefined(); + }); + }); +}); diff --git a/packages/ui/src/models/visualization/flows/support/model-validation.service.ts b/packages/ui/src/models/visualization/flows/support/model-validation.service.ts new file mode 100644 index 000000000..d55123bac --- /dev/null +++ b/packages/ui/src/models/visualization/flows/support/model-validation.service.ts @@ -0,0 +1,66 @@ +import { IVisualizationNode, VisualComponentSchema } from '../../base-visual-entity'; +import { NodeStatus } from '@patternfly/react-topology'; +import { JSONSchemaType } from 'ajv'; + +export interface IValidationResult { + level: 'error' | 'warning' | 'info'; + type: 'missingRequired'; + parentPath: string; + propertyName: string; + message: string; +} + +export class ModelValidationService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private static validateModel(schema: JSONSchemaType, model: any, parentPath: string): IValidationResult[] { + const answer = [] as IValidationResult[]; + if (schema.properties) { + Object.entries(schema.properties).forEach(([propertyName, propertyValue]) => { + const propertySchema = propertyValue as JSONSchemaType; + // TODO + if (propertySchema.type === 'array') return; + if (propertySchema.type === 'object') { + const path = parentPath ? `${parentPath}.${propertyName}` : propertyName; + if (model) { + answer.push(...ModelValidationService.validateModel(propertySchema, model[propertyName], path)); + } + return; + } + // check missing required parameter + if ( + schema.required?.includes(propertyName) && + propertySchema.default === undefined && + (!model || !model[propertyName]) + ) { + answer.push({ + level: 'error', + type: 'missingRequired', + parentPath: parentPath, + propertyName: propertyName, + message: `Missing required property ${propertyName}`, + }); + } + }); + } + return answer; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static validateNodeStatus(schema: VisualComponentSchema | undefined, model: any, vizNode: IVisualizationNode): void { + if (!schema?.schema) return; + + const validationResult = ModelValidationService.validateModel(schema.schema, model, ''); + const missingProperties = validationResult + .filter((result) => result.type === 'missingRequired') + .map((result) => result.propertyName); + if (missingProperties.length > 0) { + const message = + missingProperties.length > 1 + ? `${missingProperties.length} required properties are not yet configured: [ ${missingProperties} ]` + : `1 required property is not yet configured: [ ${missingProperties} ]`; + vizNode.setNodeStatus(NodeStatus.warning, message); + } else { + vizNode.setNodeStatus(NodeStatus.default, undefined); + } + } +} diff --git a/packages/ui/src/models/visualization/flows/templates/kamelet.ts b/packages/ui/src/models/visualization/flows/templates/kamelet.ts index ee3a87805..e04424478 100644 --- a/packages/ui/src/models/visualization/flows/templates/kamelet.ts +++ b/packages/ui/src/models/visualization/flows/templates/kamelet.ts @@ -36,8 +36,9 @@ spec: template: from: id: ${getCamelRandomId('from')} - uri: "timer:user" + uri: "timer" parameters: + timerName: user period: "{{period}}" steps: - to: https://random-data-api.com/api/v2/users diff --git a/packages/ui/src/models/visualization/flows/templates/route.ts b/packages/ui/src/models/visualization/flows/templates/route.ts index 36584b3ac..9e58adfde 100644 --- a/packages/ui/src/models/visualization/flows/templates/route.ts +++ b/packages/ui/src/models/visualization/flows/templates/route.ts @@ -5,8 +5,9 @@ export const routeTemplate = () => { id: ${getCamelRandomId('route')} from: id: ${getCamelRandomId('from')} - uri: timer:template + uri: timer parameters: + timerName: template period: "1000" steps: - log: diff --git a/packages/ui/src/models/visualization/visualization-node.ts b/packages/ui/src/models/visualization/visualization-node.ts index 4ed7d7db3..058494294 100644 --- a/packages/ui/src/models/visualization/visualization-node.ts +++ b/packages/ui/src/models/visualization/visualization-node.ts @@ -8,6 +8,7 @@ import { NodeInteraction, VisualComponentSchema, } from './base-visual-entity'; +import { NodeStatus } from '@patternfly/react-topology'; export const createVisualizationNode = ( id: string, @@ -24,9 +25,13 @@ class VisualizationNode void)[] = []; + private nodeStatus: NodeStatus = NodeStatus.default; + private nodeStatusMessage: string | undefined = undefined; constructor( public readonly id: string, + public data: T, ) {} @@ -128,4 +133,26 @@ class VisualizationNode child.populateLeafNodesIds(ids)); } + + addNodeStatusListener(listener: (status: NodeStatus, message: string | undefined) => void) { + this.nodeStatusListeners.push(listener); + } + + private notifyNodeStatusListeners() { + this.nodeStatusListeners.forEach((listener) => listener(this.nodeStatus, this.nodeStatusMessage)); + } + + getNodeStatus(): NodeStatus { + return this.nodeStatus; + } + + setNodeStatus(status: NodeStatus, message: string | undefined) { + this.nodeStatus = status; + this.nodeStatusMessage = message; + this.notifyNodeStatusListeners(); + } + + getNodeStatusMessage(): string | undefined { + return this.nodeStatusMessage; + } }