diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/create-nested-rulechain-dialog.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/create-nested-rulechain-dialog.component.html
new file mode 100644
index 0000000000..a8e719dbab
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/rulechain/create-nested-rulechain-dialog.component.html
@@ -0,0 +1,65 @@
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss
index 24b1ecc9b0..a05c80bb04 100644
--- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss
+++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss
@@ -325,7 +325,7 @@
}
.tb-rule-chain-context-menu {
- min-width: 256px;
+ min-width: 320px;
max-height: 404px;
border-radius: 8px;
margin-left: -20px;
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts
index 8784716861..b240fc104b 100644
--- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts
+++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts
@@ -54,7 +54,7 @@ import {
FcRuleNode,
FcRuleNodeType,
getRuleNodeHelpLink,
- LinkLabel,
+ LinkLabel, outputNodeClazz, ruleChainNodeClazz,
RuleNode,
RuleNodeComponentDescriptor,
RuleNodeType,
@@ -405,6 +405,17 @@ export class RuleChainPageComponent extends PageComponent
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulenode.delete-selected-objects'))
);
+ this.hotKeys.push(
+ new Hotkey('ctrl+r', (event: KeyboardEvent) => {
+ if (this.enableHotKeys && this.canCreateNestedRuleChain()) {
+ event.preventDefault();
+ this.createNestedRuleChain();
+ return false;
+ }
+ return true;
+ }, ['INPUT', 'SELECT', 'TEXTAREA'],
+ this.translate.instant('rulenode.create-nested-rulechain'))
+ );
}
}
@@ -684,6 +695,19 @@ export class RuleChainPageComponent extends PageComponent
shortcut: 'Esc'
}
);
+ if (this.canCreateNestedRuleChain()) {
+ contextInfo.menuItems.push(
+ {
+ action: () => {
+ this.createNestedRuleChain();
+ },
+ enabled: true,
+ value: 'rulenode.create-nested-rulechain',
+ icon: 'settings_ethernet',
+ shortcut: 'M-R'
+ }
+ );
+ }
contextInfo.menuItems.push(
{
action: () => {
@@ -818,6 +842,208 @@ export class RuleChainPageComponent extends PageComponent
return contextInfo;
}
+ private canCreateNestedRuleChain(): boolean {
+ const selectedNodes = this.ruleChainCanvas.modelService.nodes.getSelectedNodes();
+ const selectedEdges = this.ruleChainCanvas.modelService.edges.getSelectedEdges();
+ if (selectedNodes.length > 1) {
+ const toIndexSet = new Set();
+ selectedEdges.forEach((edge: FcRuleEdge) => {
+ const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source);
+ const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination);
+ const fromIndex = selectedNodes.indexOf(sourceNode);
+ const toIndex = selectedNodes.indexOf(destNode);
+ if (fromIndex > -1 && toIndex > -1) {
+ toIndexSet.add(toIndex);
+ }
+ });
+ const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index));
+ return noInputNodes.filter((node: FcRuleNode) => node.component.configurationDescriptor.nodeDefinition.inEnabled).length <= 1;
+ }
+ return false;
+ }
+
+ private createNestedRuleChain() {
+ const selectedNodes = this.ruleChainCanvas.modelService.nodes.getSelectedNodes();
+ const selectedEdges = this.ruleChainCanvas.modelService.edges.getSelectedEdges();
+ this.dialog.open(CreateNestedRuleChainDialogComponent, {
+ disableClose: true,
+ panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
+ data: {
+ ruleChainType: this.ruleChainType
+ }
+ }).afterClosed().subscribe((ruleChain) => {
+ if (ruleChain) {
+ this.ruleChainCanvas.modelService.deselectAll();
+ const ruleChainMetaData: RuleChainMetaData = {
+ ruleChainId: ruleChain.id,
+ nodes: [],
+ connections: []
+ };
+ let outputEdges: FcRuleEdge[] = [];
+ let minX: number = null;
+ let maxX = 0;
+ let minY = null;
+ let maxY = 0;
+
+ selectedNodes.forEach((node: FcRuleNode) => {
+ const ruleNode: RuleNode = {
+ type: node.component.clazz,
+ name: node.name,
+ configuration: deepClone(node.configuration),
+ additionalInfo: node.additionalInfo ? deepClone(node.additionalInfo) : {},
+ debugMode: node.debugMode
+ };
+ if (minX === null) {
+ minX = node.x;
+ } else {
+ minX = Math.min(minX, node.x);
+ }
+ if (minY === null) {
+ minY = node.y;
+ } else {
+ minY = Math.min(minY, node.y);
+ }
+ maxX = Math.max(maxX, node.x);
+ maxY = Math.max(maxY, node.y);
+ ruleNode.additionalInfo.layoutX = Math.round(node.x);
+ ruleNode.additionalInfo.layoutY = Math.round(node.y);
+ ruleChainMetaData.nodes.push(ruleNode);
+ const outputConnectors = this.ruleChainCanvas.modelService.nodes.getConnectorsByType(node, FlowchartConstants.rightConnectorType);
+ outputConnectors.forEach(connector => {
+ const nodeOutputEdges = this.ruleChainCanvas.modelService.model.edges.filter(edge => edge.source === connector.id);
+ const outerEdges = nodeOutputEdges.filter(edge => {
+ const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination);
+ return selectedNodes.indexOf(destNode) === -1;
+ });
+ outputEdges = outputEdges.concat(outerEdges);
+ });
+ });
+ const toIndexSet = new Set();
+ selectedEdges.forEach((edge: FcRuleEdge) => {
+ const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source);
+ const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination);
+ if (sourceNode.component.type !== RuleNodeType.INPUT) {
+ const fromIndex = selectedNodes.indexOf(sourceNode);
+ const toIndex = selectedNodes.indexOf(destNode);
+ if (fromIndex > -1 && toIndex > -1) {
+ const nodeConnection = {
+ fromIndex,
+ toIndex
+ } as NodeConnectionInfo;
+ edge.labels.forEach((label) => {
+ const newNodeConnection = deepClone(nodeConnection);
+ newNodeConnection.type = label;
+ ruleChainMetaData.connections.push(newNodeConnection);
+ });
+ toIndexSet.add(toIndex);
+ }
+ }
+ });
+ const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index));
+ const possibleInputNodes = noInputNodes.filter((node: FcRuleNode) =>
+ node.component.configurationDescriptor.nodeDefinition.inEnabled);
+ let inputEdges: FcRuleEdge[] = [];
+ if (possibleInputNodes.length) {
+ const firstNode = possibleInputNodes[0];
+ const inputConnectors = this.ruleChainCanvas.modelService.nodes
+ .getConnectorsByType(firstNode, FlowchartConstants.leftConnectorType);
+ if (inputConnectors.length) {
+ const inputConnector = inputConnectors[0];
+ const nodeInputEdges = this.ruleChainCanvas.modelService.model.edges.filter(edge => edge.destination === inputConnector.id);
+ inputEdges = inputEdges.concat(nodeInputEdges);
+ }
+ ruleChainMetaData.firstNodeIndex = selectedNodes.indexOf(firstNode);
+ }
+ outputEdges.forEach((outputEdge) => {
+ const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(outputEdge.source);
+ const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(outputEdge.destination);
+ const outputNode: RuleNode = {
+ type: outputNodeClazz,
+ name: outputEdge.label,
+ configuration: {},
+ additionalInfo: {},
+ debugMode: false
+ };
+ outputNode.additionalInfo.layoutX = Math.round(destNode.x);
+ outputNode.additionalInfo.layoutY = Math.round(destNode.y);
+ ruleChainMetaData.nodes.push(outputNode);
+ const fromIndex = selectedNodes.indexOf(sourceNode);
+ const toIndex = ruleChainMetaData.nodes.length - 1;
+ const nodeConnection = {
+ fromIndex,
+ toIndex
+ } as NodeConnectionInfo;
+ outputEdge.labels.forEach((label) => {
+ const newNodeConnection = deepClone(nodeConnection);
+ newNodeConnection.type = label;
+ ruleChainMetaData.connections.push(newNodeConnection);
+ });
+ });
+ const deltaX = Math.round(minX - 375);
+ const deltaY = Math.round(minY - 150);
+ ruleChainMetaData.nodes.forEach((node) => {
+ node.additionalInfo.layoutX -= deltaX;
+ node.additionalInfo.layoutY -= deltaY;
+ });
+ this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData).subscribe(() => {
+ const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleChainNodeClazz);
+ const descriptor = ruleNodeTypeDescriptors.get(component.type);
+ let icon = descriptor.icon;
+ let iconUrl = null;
+ if (component.configurationDescriptor.nodeDefinition.icon) {
+ icon = component.configurationDescriptor.nodeDefinition.icon;
+ }
+ if (component.configurationDescriptor.nodeDefinition.iconUrl) {
+ iconUrl = component.configurationDescriptor.nodeDefinition.iconUrl;
+ }
+ const ruleChainNodeX = (minX + maxX) / 2;
+ const ruleChainNodeY = (minY + maxY) / 2;
+ const ruleChainInputId = (this.nextConnectorID++) + '';
+ const ruleChainOutputId = (this.nextConnectorID++) + '';
+ const ruleChainNode: FcRuleNode = {
+ name: ruleChain.name,
+ component,
+ id: 'rule-chain-node-' + this.nextNodeID++,
+ configuration: {
+ ruleChainId: ruleChain.id.id
+ },
+ debugMode: false,
+ x: Math.round(ruleChainNodeX),
+ y: Math.round(ruleChainNodeY),
+ nodeClass: descriptor.nodeClass,
+ icon,
+ iconUrl,
+ ruleChainType: this.ruleChainType,
+ connectors: [
+ {
+ type: FlowchartConstants.leftConnectorType,
+ id: ruleChainInputId
+ },
+ {
+ type: FlowchartConstants.rightConnectorType,
+ id: ruleChainOutputId
+ }
+ ]
+ };
+ this.ruleChainModel.nodes.push(ruleChainNode);
+ inputEdges.forEach((inputEdge) => {
+ inputEdge.destination = ruleChainInputId;
+ });
+ outputEdges.forEach((outputEdge) => {
+ outputEdge.source = ruleChainOutputId;
+ outputEdge.labels = [outputEdge.label];
+ });
+ selectedNodes.forEach((node) => {
+ this.ruleChainCanvas.modelService.nodes.delete(node);
+ });
+ this.onModelChanged();
+ this.updateRuleNodesHighlight();
+ });
+ }
+ });
+ }
+
onModelChanged() {
this.isDirtyValue = true;
this.validate();
@@ -1498,3 +1724,73 @@ export class AddRuleNodeDialogComponent extends DialogComponent
+ implements OnInit, ErrorStateMatcher {
+
+ createNestedRuleChainFormGroup: FormGroup;
+
+ submitted = false;
+
+ constructor(protected store: Store,
+ protected router: Router,
+ @Inject(MAT_DIALOG_DATA) public data: CreateNestedRuleChainDialogData,
+ @SkipSelf() private errorStateMatcher: ErrorStateMatcher,
+ private fb: FormBuilder,
+ private ruleChainService: RuleChainService,
+ public dialogRef: MatDialogRef) {
+ super(store, router, dialogRef);
+
+ }
+
+ ngOnInit(): void {
+ this.createNestedRuleChainFormGroup = this.fb.group(
+ {
+ name: ['', [Validators.required, Validators.maxLength(255)]],
+ additionalInfo: this.fb.group(
+ {
+ description: [''],
+ }
+ )
+ }
+ );
+ }
+
+ isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
+ const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
+ const customErrorState = !!(control && control.invalid && this.submitted);
+ return originalErrorState || customErrorState;
+ }
+
+ cancel(): void {
+ this.dialogRef.close(null);
+ }
+
+ add(): void {
+ this.submitted = true;
+ const ruleChain = {
+ name: this.createNestedRuleChainFormGroup.get('name').value,
+ debugMode: false,
+ type: this.data.ruleChainType,
+ additionalInfo: {
+ description: this.createNestedRuleChainFormGroup.get('additionalInfo').get('description').value
+ }
+ } as RuleChain;
+ this.ruleChainService.saveRuleChain(ruleChain).subscribe(
+ (savedRuleChain) => {
+ this.dialogRef.close(savedRuleChain);
+ }
+ );
+ }
+}
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts
index 95bb4bf3f8..73df594f89 100644
--- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts
@@ -23,7 +23,7 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m
import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component';
import {
AddRuleNodeDialogComponent,
- AddRuleNodeLinkDialogComponent,
+ AddRuleNodeLinkDialogComponent, CreateNestedRuleChainDialogComponent,
RuleChainPageComponent
} from './rulechain-page.component';
import { RuleNodeComponent } from '@home/pages/rulechain/rulenode.component';
@@ -44,7 +44,8 @@ import { RuleNodeConfigComponent } from './rule-node-config.component';
LinkLabelsComponent,
RuleNodeLinkComponent,
AddRuleNodeLinkDialogComponent,
- AddRuleNodeDialogComponent
+ AddRuleNodeDialogComponent,
+ CreateNestedRuleChainDialogComponent
],
providers: [
{
diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts
index b30ed084ab..9bf120f8b7 100644
--- a/ui-ngx/src/app/shared/models/constants.ts
+++ b/ui-ngx/src/app/shared/models/constants.ts
@@ -100,7 +100,8 @@ export const HelpLinks = {
ruleNodeSaveAttributes: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#save-attributes-node',
ruleNodeSaveTimeseries: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#save-timeseries-node',
ruleNodeSaveToCustomTable: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table',
- ruleNodeRuleChain: helpBaseUrl + '/docs/user-guide/ui/rule-chains/',
+ ruleNodeRuleChain: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/flow-nodes/#rule-chain-node',
+ ruleNodeOutputNode: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/flow-nodes/#output-node',
ruleNodeAwsSns: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#aws-sns-node',
ruleNodeAwsSqs: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#aws-sqs-node',
ruleNodeKafka: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#kafka-node',
diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts
index ce2563826f..f30574ce7b 100644
--- a/ui-ngx/src/app/shared/models/rule-node.models.ts
+++ b/ui-ngx/src/app/shared/models/rule-node.models.ts
@@ -394,6 +394,9 @@ export const messageTypeNames = new Map(
]
);
+export const ruleChainNodeClazz = 'org.thingsboard.rule.engine.flow.TbRuleChainInputNode';
+export const outputNodeClazz = 'org.thingsboard.rule.engine.flow.TbRuleChainOutputNode';
+
const ruleNodeClazzHelpLinkMap = {
'org.thingsboard.rule.engine.filter.TbCheckRelationNode': 'ruleNodeCheckRelation',
'org.thingsboard.rule.engine.filter.TbCheckMessageNode': 'ruleNodeCheckExistenceFields',
@@ -431,7 +434,6 @@ const ruleNodeClazzHelpLinkMap = {
'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode': 'ruleNodeSaveAttributes',
'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode': 'ruleNodeSaveTimeseries',
'org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode': 'ruleNodeSaveToCustomTable',
- 'tb.internal.RuleChain': 'ruleNodeRuleChain',
'org.thingsboard.rule.engine.aws.sns.TbSnsNode': 'ruleNodeAwsSns',
'org.thingsboard.rule.engine.aws.sqs.TbSqsNode': 'ruleNodeAwsSqs',
'org.thingsboard.rule.engine.kafka.TbKafkaNode': 'ruleNodeKafka',
@@ -442,7 +444,9 @@ const ruleNodeClazzHelpLinkMap = {
'org.thingsboard.rule.engine.mail.TbSendEmailNode': 'ruleNodeSendEmail',
'org.thingsboard.rule.engine.sms.TbSendSmsNode': 'ruleNodeSendSms',
'org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode': 'ruleNodePushToCloud',
- 'org.thingsboard.rule.engine.edge.TbMsgPushToEdgeNode': 'ruleNodePushToEdge'
+ 'org.thingsboard.rule.engine.edge.TbMsgPushToEdgeNode': 'ruleNodePushToEdge',
+ 'org.thingsboard.rule.engine.flow.TbRuleChainInputNode': 'ruleNodeRuleChain',
+ 'org.thingsboard.rule.engine.flow.TbRuleChainOutputNode': 'ruleNodeOutputNode',
};
export function getRuleNodeHelpLink(component: RuleNodeComponentDescriptor): string {
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index 05e2039633..3982bfedda 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -2587,6 +2587,7 @@
"deselect-all-objects": "Deselect all nodes and connections",
"delete-selected-objects": "Delete selected nodes and connections",
"delete-selected": "Delete selected",
+ "create-nested-rulechain": "Create nested rule chain",
"select-all": "Select all",
"copy-selected": "Copy selected",
"deselect-all": "Deselect all",