/// /// Copyright © 2016-2025 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// import { AfterViewChecked, AfterViewInit, ChangeDetectorRef, Component, EventEmitter, HostBinding, Inject, OnDestroy, OnInit, QueryList, Renderer2, SecurityContext, SkipSelf, ViewChild, ViewChildren, ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { FormBuilder, FormGroupDirective, NgForm, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard'; import { TranslateService } from '@ngx-translate/core'; import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatExpansionPanel } from '@angular/material/expansion'; import { DialogService } from '@core/services/dialog.service'; import { AuthService } from '@core/auth/auth.service'; import { ActivatedRoute, Router } from '@angular/router'; import { inputNodeComponent, NodeConnectionInfo, RuleChain, RuleChainImport, RuleChainMetaData, RuleChainType } from '@shared/models/rule-chain.models'; import { FcItemInfo, FlowchartConstants, NgxFlowchartComponent, UserCallbacks } from 'ngx-flowchart'; import { FcRuleEdge, FcRuleNode, FcRuleNodeType, getRuleNodeHelpLink, LinkLabel, outputNodeClazz, ruleChainNodeClazz, RuleNode, RuleNodeComponentDescriptor, RuleNodeType, ruleNodeTypeDescriptors, ruleNodeTypesLibrary } from '@shared/models/rule-node.models'; import { FcRuleNodeModel, FcRuleNodeTypeModel, RuleChainMenuContextInfo } from './rulechain-page.models'; import { RuleChainService } from '@core/http/rule-chain.service'; import { NEVER, Observable, of, ReplaySubject, skip, startWith, Subject, throwError } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, mergeMap, takeUntil, tap } from 'rxjs/operators'; import { ISearchableComponent } from '../../models/searchable-component.models'; import { deepClone, isDefinedAndNotNull } from '@core/utils'; import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component'; import { RuleNodeLinkComponent } from './rule-node-link.component'; import { DialogComponent } from '@shared/components/dialog.component'; import { MatMenuTrigger } from '@angular/material/menu'; import { ItemBufferService, RuleNodeConnection } from '@core/services/item-buffer.service'; import { Hotkey } from 'angular2-hotkeys'; import { DebugEventType, DebugRuleNodeEventBody, EventType } from '@shared/models/event.models'; import { MatMiniFabButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { VersionControlComponent } from '@home/components/vc/version-control.component'; import { ComponentClusteringMode } from '@shared/models/component-descriptor.models'; import { MatDrawer } from '@angular/material/sidenav'; import { HttpStatusCode } from '@angular/common/http'; import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; import { EntityDebugSettings } from '@shared/models/entity.models'; import Timeout = NodeJS.Timeout; import { DomSanitizer } from '@angular/platform-browser'; @Component({ selector: 'tb-rulechain-page', templateUrl: './rulechain-page.component.html', styleUrls: ['./rulechain-page.component.scss'], encapsulation: ViewEncapsulation.None }) export class RuleChainPageComponent extends PageComponent implements AfterViewInit, OnInit, OnDestroy, HasDirtyFlag, ISearchableComponent, AfterViewChecked { get isDirty(): boolean { return this.isDirtyValue || this.isImport; } set isDirty(value: boolean) { this.isDirtyValue = value; } @HostBinding('style.width') width = '100%'; @HostBinding('style.height') height = '100%'; @ViewChild('ruleChainCanvas', {static: true}) ruleChainCanvas: NgxFlowchartComponent; @ViewChildren('ruleNodeTypeExpansionPanels', {read: MatExpansionPanel}) expansionPanels: QueryList; @ViewChild('ruleChainMenuTrigger', {static: true}) ruleChainMenuTrigger: MatMenuTrigger; @ViewChild('drawer') drawer: MatDrawer; eventTypes = EventType; debugEventTypes = DebugEventType; ruleChainMenuPosition = { x: '0px', y: '0px' }; contextMenuEvent: TbContextMenuEvent; ruleNodeTypeDescriptorsMap = ruleNodeTypeDescriptors; ruleNodeTypesLibraryArray = ruleNodeTypesLibrary; isImport: boolean; isDirtyValue: boolean; isInvalid = false; ruleChainType: RuleChainType; errorTooltips: {[nodeId: string]: JQueryTooltipster.ITooltipsterInstance} = {}; isFullscreen = false; selectedRuleNodeTabIndex = 0; editingRuleNode: FcRuleNode = null; isEditingRuleNode = false; editingRuleNodeIndex = -1; editingRuleNodeAllowCustomLabels = false; editingRuleNodeLinkLabels: {[label: string]: LinkLabel}; editingRuleNodeSourceRuleChainId: string; ruleNodeTestButtonLabel: string; @ViewChild('tbRuleNode') ruleNodeComponent: RuleNodeDetailsComponent; @ViewChild('tbRuleNodeLink') ruleNodeLinkComponent: RuleNodeLinkComponent; editingRuleNodeLink: FcRuleEdge = null; isEditingRuleNodeLink = false; editingRuleNodeLinkIndex = -1; hotKeys: Hotkey[] = []; enableHotKeys = true; ruleNodeSearch = ''; ruleNodeTypeSearch = this.fb.control('', {nonNullable: true}); ruleChain: RuleChain; ruleChainMetaData: RuleChainMetaData; ruleChainModel: FcRuleNodeModel = { nodes: [], edges: [] }; selectedObjects = []; editCallbacks: UserCallbacks = { edgeDoubleClick: (event, edge) => { this.openLinkDetails(edge); }, edgeEdit: (event, edge) => { this.openLinkDetails(edge); }, nodeCallbacks: { doubleClick: (event, node: FcRuleNode) => { this.openNodeDetails(node); }, nodeEdit: (event, node: FcRuleNode) => { this.openNodeDetails(node); }, mouseEnter: this.displayNodeDescriptionTooltip.bind(this), mouseLeave: this.destroyTooltips.bind(this), mouseDown: this.destroyTooltips.bind(this) }, isValidEdge: (source, destination) => source.type === FlowchartConstants.rightConnectorType && destination.type === FlowchartConstants.leftConnectorType, createEdge: (event, edge: FcRuleEdge) => { const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source) as FcRuleNode; if (sourceNode.component.type === RuleNodeType.INPUT) { const found = this.ruleChainModel.edges.find(theEdge => theEdge.source === (this.inputConnectorId + '')); if (found) { this.ruleChainCanvas.modelService.edges.delete(found); } return of(edge); } else { if (edge.label) { if (!edge.labels) { edge.labels = edge.label.split(' / '); } return of(edge); } else { const labels = this.ruleChainService.getRuleNodeSupportedLinks(sourceNode.component); const allowCustomLabels = this.ruleChainService.ruleNodeAllowCustomLinks(sourceNode.component); const sourceRuleChainId = this.ruleChainService.ruleNodeSourceRuleChainId(sourceNode.component, sourceNode.configuration); this.enableHotKeys = false; return this.addRuleNodeLink(edge, labels, allowCustomLabels, sourceRuleChainId).pipe( tap(() => { this.enableHotKeys = true; }), mergeMap((res) => { if (res) { return of(res); } else { return NEVER; } }) ); } } }, dropNode: (event, node: FcRuleNode) => { this.addRuleNode(node); } }; nextNodeID: number; nextConnectorID: number; inputConnectorId: number; ruleNodeTypesModel: {[type: string]: {model: FcRuleNodeTypeModel; selectedObjects: any[]}} = {}; nodeLibCallbacks: UserCallbacks = { nodeCallbacks: { mouseEnter: this.displayLibNodeDescriptionTooltip.bind(this), mouseLeave: this.destroyTooltips.bind(this), mouseDown: this.destroyTooltips.bind(this) } }; ruleNodeComponents: Array; flowchartConstants = FlowchartConstants; updateBreadcrumbs = new EventEmitter(); private destroy$ = new Subject(); private tooltipTimeout: Timeout; constructor(protected store: Store, private route: ActivatedRoute, private router: Router, private ruleChainService: RuleChainService, private authService: AuthService, private translate: TranslateService, private itembuffer: ItemBufferService, private popoverService: TbPopoverService, private renderer: Renderer2, private viewContainerRef: ViewContainerRef, private changeDetector: ChangeDetectorRef, private sanitizer:DomSanitizer, public dialog: MatDialog, public dialogService: DialogService, public fb: FormBuilder) { super(store); this.route.data.pipe( takeUntil(this.destroy$) ).subscribe( () => { this.reset(); this.init(); } ); } ngOnInit() { this.ruleNodeTypeSearch.valueChanges.pipe( debounceTime(150), startWith(''), distinctUntilChanged((a: string, b: string) => a.trim() === b.trim()), skip(1), takeUntil(this.destroy$) ).subscribe(() => this.updateRuleChainLibrary()); } ngAfterViewChecked(){ this.changeDetector.detectChanges(); } ngAfterViewInit() { this.ruleChainCanvas.adjustCanvasSize(true); } ngOnDestroy() { super.ngOnDestroy(); this.destroy$.next(); this.destroy$.complete(); } currentRuleChainIdChanged(ruleChainId: string) { if (this.ruleChainType === RuleChainType.CORE) { this.router.navigateByUrl(`ruleChains/${ruleChainId}`); } else { this.router.navigateByUrl(`edgeManagement/ruleChains/${ruleChainId}`); } } onSearchTextUpdated(searchText: string) { this.ruleNodeSearch = searchText; this.updateRuleNodesHighlight(); } private init() { this.initHotKeys(); this.isImport = this.route.snapshot.data.import; this.ruleChainType = this.route.snapshot.data.ruleChainType; if (this.isImport) { const ruleChainImport: RuleChainImport = this.itembuffer.getRuleChainImport(); this.ruleChain = ruleChainImport.ruleChain; this.ruleChainMetaData = ruleChainImport.metadata; } else { this.ruleChain = this.route.snapshot.data.ruleChain; this.ruleChainMetaData = this.route.snapshot.data.ruleChainMetaData; } this.ruleNodeComponents = this.route.snapshot.data.ruleNodeComponents; for (const type of ruleNodeTypesLibrary) { const desc = ruleNodeTypeDescriptors.get(type); if (!desc.special) { this.ruleNodeTypesModel[type] = { model: { nodes: [], edges: [] }, selectedObjects: [] }; } } this.updateRuleChainLibrary(); this.createRuleChainModel(); } private reset(): void { this.selectedObjects = []; this.ruleChainModel.nodes = []; this.ruleChainModel.edges = []; this.ruleNodeTypesModel = {}; if (this.ruleChainCanvas) { this.ruleChainCanvas.adjustCanvasSize(true); } this.isEditingRuleNode = false; this.isEditingRuleNodeLink = false; this.updateRuleNodesHighlight(); } private initHotKeys(): void { if (!this.hotKeys.length) { this.hotKeys.push( new Hotkey(['ctrl+a', 'meta+a'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.ruleChainCanvas.modelService.selectAll(); return false; } return true; }, [], this.translate.instant('rulenode.select-all-objects')) ); this.hotKeys.push( new Hotkey(['ctrl+c', 'meta+c'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.copyRuleNodes(); return false; } return true; }, [], this.translate.instant('rulenode.copy-selected')) ); this.hotKeys.push( new Hotkey(['ctrl+v', 'meta+v'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); if (this.itembuffer.hasRuleNodes()) { this.pasteRuleNodes(); } return false; } return true; }, [], this.translate.instant('action.paste')) ); this.hotKeys.push( new Hotkey('esc', (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); event.stopPropagation(); this.ruleChainCanvas.modelService.deselectAll(); return false; } return true; }, ['INPUT', 'SELECT', 'TEXTAREA'], this.translate.instant('rulenode.deselect-all-objects')) ); this.hotKeys.push( new Hotkey(['ctrl+s', 'meta+s'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.saveRuleChain(); return false; } return true; }, ['INPUT', 'SELECT', 'TEXTAREA'], this.translate.instant('action.apply')) ); this.hotKeys.push( new Hotkey(['ctrl+z', 'meta+z'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.revertRuleChain(); return false; } return true; }, ['INPUT', 'SELECT', 'TEXTAREA'], this.translate.instant('action.decline-changes')) ); this.hotKeys.push( new Hotkey('del', (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.ruleChainCanvas.modelService.deleteSelected(); return false; } return true; }, [], this.translate.instant('rulenode.delete-selected-objects')) ); this.hotKeys.push( new Hotkey(['ctrl+r', 'meta+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')) ); } } updateRuleChainLibrary() { const search = this.ruleNodeTypeSearch.value.trim().toUpperCase(); const res = this.ruleNodeComponents.filter( (ruleNodeComponent) => ruleNodeComponent.name.toUpperCase().includes(search)); this.loadRuleChainLibrary(res); } private loadRuleChainLibrary(ruleNodeComponents: Array) { for (const componentType of Object.keys(this.ruleNodeTypesModel)) { this.ruleNodeTypesModel[componentType].model.nodes.length = 0; } ruleNodeComponents.forEach((ruleNodeComponent) => { const componentType = ruleNodeComponent.type; const model = this.ruleNodeTypesModel[componentType].model; const desc = ruleNodeTypeDescriptors.get(RuleNodeType[componentType]); let icon = desc.icon; let iconUrl = null; if (ruleNodeComponent.configurationDescriptor.nodeDefinition.icon) { icon = ruleNodeComponent.configurationDescriptor.nodeDefinition.icon; } if (ruleNodeComponent.configurationDescriptor.nodeDefinition.iconUrl) { iconUrl = ruleNodeComponent.configurationDescriptor.nodeDefinition.iconUrl; } const node: FcRuleNodeType = { id: 'node-lib-' + componentType + '-' + model.nodes.length, component: ruleNodeComponent, name: '', nodeClass: desc.nodeClass, singletonMode: ruleNodeComponent.clusteringMode !== ComponentClusteringMode.ENABLED, icon, iconUrl, x: 30, y: 10 + 50 * model.nodes.length, connectors: [] }; if (ruleNodeComponent.configurationDescriptor.nodeDefinition.inEnabled) { node.connectors.push( { type: FlowchartConstants.leftConnectorType, id: (model.nodes.length * 2) + '' } ); } if (ruleNodeComponent.configurationDescriptor.nodeDefinition.outEnabled) { node.connectors.push( { type: FlowchartConstants.rightConnectorType, id: (model.nodes.length * 2 + 1) + '' } ); } model.nodes.push(node); }); if (this.expansionPanels) { for (let i = 0; i < ruleNodeTypesLibrary.length; i++) { const panel = this.expansionPanels.find((item, index) => index === i); if (panel) { const type = ruleNodeTypesLibrary[i]; if (!this.ruleNodeTypesModel[type].model.nodes.length) { panel.close(); } else { panel.open(); } } } } } private createRuleChainModel() { this.nextNodeID = 1; this.nextConnectorID = 1; this.selectedObjects = []; this.ruleChainModel.nodes = []; this.ruleChainModel.edges = []; this.inputConnectorId = this.nextConnectorID++; this.ruleChainModel.nodes.push( { id: 'rule-chain-node-' + this.nextNodeID++, component: inputNodeComponent, name: '', nodeClass: ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).nodeClass, icon: ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).icon, readonly: true, x: 50, y: 150, connectors: [ { type: FlowchartConstants.rightConnectorType, id: this.inputConnectorId + '' }, ] } ); const nodes: FcRuleNode[] = []; this.ruleChainMetaData.nodes.forEach((ruleNode) => { const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleNode.type); 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 node: FcRuleNode = { id: 'rule-chain-node-' + this.nextNodeID++, ruleNodeId: ruleNode.id, additionalInfo: ruleNode.additionalInfo, configuration: ruleNode.configuration, configurationVersion: isDefinedAndNotNull(ruleNode.configurationVersion) ? ruleNode.configurationVersion : 0, debugSettings: ruleNode.debugSettings, singletonMode: ruleNode.singletonMode, queueName: ruleNode.queueName, x: Math.round(ruleNode.additionalInfo.layoutX), y: Math.round(ruleNode.additionalInfo.layoutY), component, name: ruleNode.name, nodeClass: descriptor.nodeClass, icon, iconUrl, connectors: [], ruleChainType: this.ruleChainType }; if (component.configurationDescriptor.nodeDefinition.inEnabled) { node.connectors.push( { type: FlowchartConstants.leftConnectorType, id: (this.nextConnectorID++) + '' } ); } if (component.configurationDescriptor.nodeDefinition.outEnabled) { node.connectors.push( { type: FlowchartConstants.rightConnectorType, id: (this.nextConnectorID++) + '' } ); } nodes.push(node); this.ruleChainModel.nodes.push(node); }); if (this.ruleChainMetaData.firstNodeIndex > -1) { const destNode = nodes[this.ruleChainMetaData.firstNodeIndex]; if (destNode) { const connectors = destNode.connectors.filter(connector => connector.type === FlowchartConstants.leftConnectorType); if (connectors && connectors.length) { const edge: FcRuleEdge = { source: this.inputConnectorId + '', destination: connectors[0].id }; this.ruleChainModel.edges.push(edge); } } } if (this.ruleChainMetaData.connections) { const edgeMap: {[edgeKey: string]: FcRuleEdge} = {}; this.ruleChainMetaData.connections.forEach((connection) => { const sourceNode = nodes[connection.fromIndex]; const destNode = nodes[connection.toIndex]; if (sourceNode && destNode) { const sourceConnectors = sourceNode.connectors.filter(connector => connector.type === FlowchartConstants.rightConnectorType); const destConnectors = destNode.connectors.filter(connector => connector.type === FlowchartConstants.leftConnectorType); if (sourceConnectors && sourceConnectors.length && destConnectors && destConnectors.length) { const sourceId = sourceConnectors[0].id; const destId = destConnectors[0].id; const edgeKey = sourceId + '_' + destId; let edge = edgeMap[edgeKey]; if (!edge) { edge = { source: sourceId, destination: destId, label: connection.type, labels: [connection.type] }; edgeMap[edgeKey] = edge; this.ruleChainModel.edges.push(edge); } else { edge.label += ' / ' + connection.type; edge.labels.push(connection.type); } } } }); } if (this.ruleChainCanvas) { this.ruleChainCanvas.adjustCanvasSize(true); } this.isDirtyValue = false; this.updateRuleNodesHighlight(); this.validate(); } openRuleChainContextMenu($event: TbContextMenuEvent) { if (this.ruleChainCanvas.modelService && !$event.ctrlKey && !$event.metaKey) { const x = $event.clientX; const y = $event.clientY; const item = this.ruleChainCanvas.modelService.getItemInfoAtPoint(x, y); const contextInfo = this.prepareContextMenu(item); if (contextInfo.menuItems && contextInfo.menuItems.length > 0) { $event.preventDefault(); $event.stopPropagation(); this.contextMenuEvent = $event; this.ruleChainMenuPosition.x = x + 'px'; this.ruleChainMenuPosition.y = y + 'px'; this.ruleChainMenuTrigger.menuData = { contextInfo }; this.ruleChainMenuTrigger.openMenu(); } } } onRuleChainContextMenuMouseLeave() { this.ruleChainMenuTrigger.closeMenu(); } private prepareContextMenu(item: FcItemInfo): RuleChainMenuContextInfo { if (this.objectsSelected() || (!item.node && !item.edge)) { return this.prepareRuleChainContextMenu(); } else if (item.node) { return this.prepareRuleNodeContextMenu(item.node); } else if (item.edge) { return this.prepareEdgeContextMenu(item.edge); } } private prepareRuleChainContextMenu(): RuleChainMenuContextInfo { const contextInfo: RuleChainMenuContextInfo = { headerClass: 'tb-rulechain-header', icon: 'settings_ethernet', title: this.ruleChain.name, subtitle: this.translate.instant('rulechain.rulechain'), menuItems: [] }; if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length) { contextInfo.menuItems.push( { action: () => { this.copyRuleNodes(); }, enabled: true, value: 'rulenode.copy-selected', icon: 'content_copy', shortcut: 'M-C' } ); } contextInfo.menuItems.push( { action: ($event) => { this.pasteRuleNodes($event); }, enabled: this.itembuffer.hasRuleNodes(), value: 'action.paste', icon: 'content_paste', shortcut: 'M-V' } ); contextInfo.menuItems.push( { divider: true } ); if (this.objectsSelected()) { contextInfo.menuItems.push( { action: () => { this.ruleChainCanvas.modelService.deselectAll(); }, enabled: true, value: 'rulenode.deselect-all', icon: 'tab_unselected', 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: () => { this.ruleChainCanvas.modelService.deleteSelected(); }, enabled: true, value: 'rulenode.delete-selected', icon: 'clear', shortcut: 'Del' } ); } else { contextInfo.menuItems.push( { action: () => { this.ruleChainCanvas.modelService.selectAll(); }, enabled: true, value: 'rulenode.select-all', icon: 'select_all', shortcut: 'M-A' } ); } contextInfo.menuItems.push( { divider: true } ); contextInfo.menuItems.push( { action: () => { this.saveRuleChain(); }, enabled: !(this.isInvalid || (!this.isDirty && !this.isImport)), value: 'action.apply-changes', icon: 'done', shortcut: 'M-S' } ); contextInfo.menuItems.push( { action: () => { this.revertRuleChain(); }, enabled: this.isDirty, value: 'action.decline-changes', icon: 'close', shortcut: 'M-Z' } ); return contextInfo; } private prepareRuleNodeContextMenu(node: FcRuleNode): RuleChainMenuContextInfo { const contextInfo: RuleChainMenuContextInfo = { headerClass: node.nodeClass, icon: node.icon, iconUrl: node.iconUrl, title: node.name, subtitle: node.component.name, menuItems: [] }; if (!node.readonly) { contextInfo.menuItems.push( { action: () => { this.openNodeDetails(node); }, enabled: true, value: 'rulenode.details', icon: 'menu' } ); contextInfo.menuItems.push( { action: () => { this.copyNode(node); }, enabled: true, value: 'action.copy', icon: 'content_copy' } ); contextInfo.menuItems.push( { action: () => { this.ruleChainCanvas.modelService.nodes.delete(node); }, enabled: true, value: 'action.delete', icon: 'clear', shortcut: 'M-X' } ); } return contextInfo; } private prepareEdgeContextMenu(edge: FcRuleEdge): RuleChainMenuContextInfo { const contextInfo: RuleChainMenuContextInfo = { headerClass: 'tb-link-header', icon: 'trending_flat', title: edge.label, subtitle: this.translate.instant('rulenode.link'), menuItems: [] }; const sourceNode: FcRuleNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); if (sourceNode.component.type !== RuleNodeType.INPUT) { contextInfo.menuItems.push( { action: () => { this.openLinkDetails(edge); }, enabled: true, value: 'rulenode.details', icon: 'menu' } ); } contextInfo.menuItems.push( { action: () => { this.ruleChainCanvas.modelService.edges.delete(edge); }, enabled: true, value: 'action.delete', icon: 'clear', shortcut: 'M-X' } ); 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) : {}, debugSettings: node.debugSettings, singletonMode: node.singletonMode, queueName: node.queueName }; 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: {}, singletonMode: 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 }, singletonMode: 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(); } helpLinkIdForRuleNodeType(): string { let component: RuleNodeComponentDescriptor = null; if (this.editingRuleNode) { component = this.editingRuleNode.component; } return getRuleNodeHelpLink(component); } openNodeDetails(node: FcRuleNode) { if (node.component.type !== RuleNodeType.INPUT) { this.enableHotKeys = false; this.updateErrorTooltips(true); this.isEditingRuleNodeLink = false; this.editingRuleNodeLink = null; this.isEditingRuleNode = true; this.editingRuleNodeIndex = this.ruleChainModel.nodes.indexOf(node); this.editingRuleNode = deepClone(node, ['component']); setTimeout(() => { this.ruleNodeComponent.ruleNodeFormGroup.markAsPristine(); }, 0); } } openLinkDetails(edge: FcRuleEdge) { const sourceNode: FcRuleNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source) as FcRuleNode; if (sourceNode.component.type !== RuleNodeType.INPUT) { this.enableHotKeys = false; this.updateErrorTooltips(true); this.isEditingRuleNode = false; this.editingRuleNode = null; this.editingRuleNodeLinkLabels = this.ruleChainService.getRuleNodeSupportedLinks(sourceNode.component); this.editingRuleNodeAllowCustomLabels = this.ruleChainService.ruleNodeAllowCustomLinks(sourceNode.component); this.editingRuleNodeSourceRuleChainId = this.ruleChainService.ruleNodeSourceRuleChainId(sourceNode.component, sourceNode.configuration); this.isEditingRuleNodeLink = true; this.editingRuleNodeLinkIndex = this.ruleChainModel.edges.indexOf(edge); this.editingRuleNodeLink = deepClone(edge); setTimeout(() => { this.ruleNodeLinkComponent.ruleNodeLinkFormGroup.markAsPristine(); }, 0); } } private copyNode(node: FcRuleNode) { this.itembuffer.copyRuleNodes([node], []); } private copyRuleNodes() { const nodes: FcRuleNode[] = this.ruleChainCanvas.modelService.nodes.getSelectedNodes(); const edges: FcRuleEdge[] = this.ruleChainCanvas.modelService.edges.getSelectedEdges(); const connections: RuleNodeConnection[] = []; edges.forEach((edge) => { const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination); const isInputSource = sourceNode.component.type === RuleNodeType.INPUT; const fromIndex = nodes.indexOf(sourceNode); const toIndex = nodes.indexOf(destNode); if ( (isInputSource || fromIndex > -1) && toIndex > -1 ) { const connection: RuleNodeConnection = { isInputSource, fromIndex, toIndex, label: edge.label, labels: edge.labels }; connections.push(connection); } }); this.itembuffer.copyRuleNodes(nodes, connections); } private pasteRuleNodes(event?: MouseEvent) { const canvas = $(this.ruleChainCanvas.modelService.canvasHtmlElement); let x: number; let y: number; if (event) { const offset = canvas.offset(); x = Math.round(event.clientX - offset.left); y = Math.round(event.clientY - offset.top); } else { const scrollParent = canvas.parent(); const scrollTop = scrollParent.scrollTop(); const scrollLeft = scrollParent.scrollLeft(); x = scrollLeft + scrollParent.width() / 2; y = scrollTop + scrollParent.height() / 2; } const ruleNodes = this.itembuffer.pasteRuleNodes(x, y); if (ruleNodes) { this.ruleChainCanvas.modelService.deselectAll(); const nodes: FcRuleNode[] = []; ruleNodes.nodes.forEach((node) => { node.id = 'rule-chain-node-' + this.nextNodeID++; const component = node.component; if (component.configurationDescriptor.nodeDefinition.inEnabled) { node.connectors.push( { type: FlowchartConstants.leftConnectorType, id: (this.nextConnectorID++) + '' } ); } if (component.configurationDescriptor.nodeDefinition.outEnabled) { node.connectors.push( { type: FlowchartConstants.rightConnectorType, id: (this.nextConnectorID++) + '' } ); } nodes.push(node); this.ruleChainModel.nodes.push(node); this.ruleChainCanvas.modelService.nodes.select(node); }); ruleNodes.connections.forEach((connection) => { const sourceNode = nodes[connection.fromIndex]; const destNode = nodes[connection.toIndex]; if ( (connection.isInputSource || sourceNode) && destNode ) { let source: string; let destination: string; if (connection.isInputSource) { source = this.inputConnectorId + ''; const found = this.ruleChainModel.edges.find(theEdge => theEdge.source === (this.inputConnectorId + '')); if (found) { this.ruleChainCanvas.modelService.edges.delete(found); } } else { const sourceConnectors = this.ruleChainCanvas.modelService.nodes .getConnectorsByType(sourceNode, FlowchartConstants.rightConnectorType); if (sourceConnectors && sourceConnectors.length) { source = sourceConnectors[0].id; } } const destConnectors = this.ruleChainCanvas.modelService.nodes .getConnectorsByType(destNode, FlowchartConstants.leftConnectorType); if (destConnectors && destConnectors.length) { destination = destConnectors[0].id; } if (source && destination) { const edge: FcRuleEdge = { source, destination, label: connection.label, labels: connection.labels }; this.ruleChainModel.edges.push(edge); this.ruleChainCanvas.modelService.edges.select(edge); } } }); this.updateRuleNodesHighlight(); this.validate(); this.onModelChanged(); } } onDetailsDrawerClosed() { this.onEditRuleNodeClosed(); this.onEditRuleNodeLinkClosed(); this.enableHotKeys = true; this.updateErrorTooltips(false); } onEditRuleNodeClosed() { this.editingRuleNode = null; this.isEditingRuleNode = false; } onEditRuleNodeLinkClosed() { this.editingRuleNodeLink = null; this.isEditingRuleNodeLink = false; } onRevertRuleNodeEdit() { this.ruleNodeComponent.ruleNodeFormGroup.markAsPristine(); const node = this.ruleChainModel.nodes[this.editingRuleNodeIndex]; this.editingRuleNode = deepClone(node, ['component']); } onRevertRuleNodeLinkEdit() { this.ruleNodeLinkComponent.ruleNodeLinkFormGroup.markAsPristine(); const edge = this.ruleChainModel.edges[this.editingRuleNodeLinkIndex]; this.editingRuleNodeLink = deepClone(edge); } onDebugEventSelected(debugEventBody: DebugRuleNodeEventBody) { const ruleNodeConfigComponent = this.ruleNodeComponent.ruleNodeConfigComponent; const ruleNodeConfigDefinedComponent = ruleNodeConfigComponent.definedConfigComponent; if (ruleNodeConfigComponent.useDefinedDirective() && ruleNodeConfigDefinedComponent.hasScript && ruleNodeConfigDefinedComponent.testScript) { ruleNodeConfigDefinedComponent.testScript(debugEventBody); } } onRuleNodeInit() { const ruleNodeConfigDefinedComponent = this.ruleNodeComponent.ruleNodeConfigComponent.definedConfigComponent; if (this.ruleNodeComponent.ruleNodeConfigComponent.useDefinedDirective() && ruleNodeConfigDefinedComponent.hasScript) { this.ruleNodeTestButtonLabel = ruleNodeConfigDefinedComponent.testScriptLabel; } else { this.ruleNodeTestButtonLabel = ''; } } switchToFirstTab() { this.selectedRuleNodeTabIndex = 0; } saveRuleNode() { this.ruleNodeComponent.validate(); if (this.ruleNodeComponent.ruleNodeFormGroup.valid) { this.ruleNodeComponent.ruleNodeFormGroup.markAsPristine(); if (this.editingRuleNode.error) { delete this.editingRuleNode.error; } this.ruleChainModel.nodes[this.editingRuleNodeIndex] = this.editingRuleNode; this.editingRuleNode = deepClone(this.editingRuleNode, ['component']); this.onModelChanged(); this.updateRuleNodesHighlight(); } } saveRuleNodeLink() { this.ruleNodeLinkComponent.ruleNodeLinkFormGroup.markAsPristine(); this.ruleChainModel.edges[this.editingRuleNodeLinkIndex] = this.editingRuleNodeLink; this.editingRuleNodeLink = deepClone(this.editingRuleNodeLink); this.onModelChanged(); } typeHeaderMouseEnter(event: MouseEvent, ruleNodeType: RuleNodeType) { const type = ruleNodeTypeDescriptors.get(ruleNodeType); this.displayTooltip(event, '
' + '
' + '
' + this.translate.instant(type.name) + '
' + '
' + this.translate.instant(type.details) + '
' + '
' + '
' ); } displayLibNodeDescriptionTooltip(event: MouseEvent, node: FcRuleNodeType) { this.displayTooltip(event, '
' + '
' + '
' + node.component.name + '
' + '
' + node.component.configurationDescriptor.nodeDefinition.description + '
' + '
' + node.component.configurationDescriptor.nodeDefinition.details + '
' + '
' + '
' ); } displayNodeDescriptionTooltip(event: MouseEvent, node: FcRuleNode) { if (!this.errorTooltips[node.id]) { let name: string; let desc: string; let details: string; if (node.component.type === RuleNodeType.INPUT) { name = this.translate.instant(ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).name); desc = this.translate.instant(ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).details); } else { name = node.name; desc = this.translate.instant(ruleNodeTypeDescriptors.get(node.component.type).name) + ' - ' + node.component.name; if (node.additionalInfo) { details = this.sanitizer.sanitize(SecurityContext.HTML, node.additionalInfo.description); } } name = this.sanitizer.sanitize(SecurityContext.HTML, name); desc = this.sanitizer.sanitize(SecurityContext.HTML, desc); let tooltipContent = '
' + '
' + '
' + name + '
' + '
' + desc + '
'; if (details) { tooltipContent += '
' + details + '
'; } tooltipContent += '
' + '
'; this.displayTooltip(event, tooltipContent); } } destroyTooltips() { if (this.tooltipTimeout) { clearTimeout(this.tooltipTimeout); this.tooltipTimeout = null; } const instances = $.tooltipster.instances(); instances.forEach((instance) => { if (!instance.isErrorTooltip) { instance.destroy(); } }); } private updateRuleNodesHighlight() { for (const ruleNode of this.ruleChainModel.nodes) { ruleNode.highlighted = false; } if (this.ruleNodeSearch) { const search = this.ruleNodeSearch.toUpperCase(); const res = this.ruleChainModel.nodes.filter(node => node.name.toUpperCase().includes(search)); if (res) { for (const ruleNode of res) { ruleNode.highlighted = true; } } } if (this.ruleChainCanvas) { this.ruleChainCanvas.modelService.detectChanges(); } } objectsSelected(): boolean { return this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length > 0 || this.ruleChainCanvas.modelService.edges.getSelectedEdges().length > 0; } deleteSelected() { this.ruleChainCanvas.modelService.deleteSelected(); } isDebugSettingsEnabled(): boolean { const res = this.ruleChainModel.nodes.find((node) => node?.debugSettings && this.isDebugSettingsActive(node.debugSettings)); return typeof res !== 'undefined'; } resetDebugSettingsInAllNodes(): void { let changed = false; this.ruleChainModel.nodes.forEach((node) => { if (node.component.type !== RuleNodeType.INPUT) { const nodeHasActiveDebugSettings = node?.debugSettings && this.isDebugSettingsActive(node.debugSettings); changed = changed || nodeHasActiveDebugSettings; if (nodeHasActiveDebugSettings) { node.debugSettings = { allEnabled: false, failuresEnabled: false, allEnabledUntil: 0 }; } } }); if (changed) { this.onModelChanged(); } } private isDebugSettingsActive(debugSettings: EntityDebugSettings): boolean { return debugSettings.allEnabled || debugSettings.failuresEnabled || debugSettings.allEnabledUntil > new Date().getTime(); } validate() { setTimeout(() => { this.isInvalid = false; this.ruleChainModel.nodes.forEach((node) => { if (node.error) { this.isInvalid = true; } this.updateNodeErrorTooltip(node); }); }, 0); } saveRuleChain(): Observable { const saveResult = new ReplaySubject(); let saveRuleChainObservable: Observable; if (this.isImport) { saveRuleChainObservable = this.ruleChainService.saveRuleChain(this.ruleChain); } else { saveRuleChainObservable = of(this.ruleChain); } saveRuleChainObservable.subscribe((ruleChain) => { this.ruleChain = ruleChain; const ruleChainMetaData: RuleChainMetaData = { ruleChainId: this.ruleChain.id, nodes: [], connections: [], version: ruleChain.version }; const nodes: FcRuleNode[] = []; this.ruleChainModel.nodes.forEach((node) => { if (node.component.type !== RuleNodeType.INPUT) { const ruleNode: RuleNode = { id: node.ruleNodeId, type: node.component.clazz, name: node.name, configurationVersion: isDefinedAndNotNull(node.configurationVersion) ? node.configurationVersion : node.component.configurationVersion, configuration: node.configuration, additionalInfo: node.additionalInfo ? node.additionalInfo : {}, debugSettings: node.debugSettings, singletonMode: node.singletonMode, queueName: node.queueName }; ruleNode.additionalInfo.layoutX = Math.round(node.x); ruleNode.additionalInfo.layoutY = Math.round(node.y); ruleChainMetaData.nodes.push(ruleNode); nodes.push(node); } }); const firstNodeEdge = this.ruleChainModel.edges.find((edge) => edge.source === this.inputConnectorId + ''); if (firstNodeEdge) { const firstNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(firstNodeEdge.destination); ruleChainMetaData.firstNodeIndex = nodes.indexOf(firstNode); } this.ruleChainModel.edges.forEach((edge) => { 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 = nodes.indexOf(sourceNode); const toIndex = nodes.indexOf(destNode); const nodeConnection = { fromIndex, toIndex } as NodeConnectionInfo; edge.labels.forEach((label) => { const newNodeConnection = deepClone(nodeConnection); newNodeConnection.type = label; ruleChainMetaData.connections.push(newNodeConnection); }); } }); this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData) .pipe( catchError(err => { if (err.status === HttpStatusCode.Conflict) { return this.ruleChainService.getRuleChainMetadata(ruleChainMetaData.ruleChainId.id); } return throwError(() => err); }) ) .subscribe((savedRuleChainMetaData) => { this.ruleChain.version = savedRuleChainMetaData.version; this.ruleChainMetaData = savedRuleChainMetaData; if (this.isImport) { this.isDirtyValue = false; this.isImport = false; if (this.ruleChainType !== RuleChainType.EDGE) { this.router.navigateByUrl(`ruleChains/${this.ruleChain.id.id}`); } else { this.router.navigateByUrl(`edgeManagement/ruleChains/${this.ruleChain.id.id}`); } } else { this.createRuleChainModel(); } saveResult.next(); }); }); return saveResult; } reloadRuleChain() { this.ruleChainService.getRuleChain(this.ruleChain.id.id).subscribe((ruleChain) => { this.ruleChain = ruleChain; this.updateBreadcrumbs.emit(); this.ruleChainService.getRuleChainMetadata(this.ruleChain.id.id).subscribe((ruleChainMetaData) => { this.ruleChainMetaData = ruleChainMetaData; this.isDirtyValue = false; this.createRuleChainModel(); }); }); } revertRuleChain() { this.createRuleChainModel(); } addRuleNode(ruleNode: FcRuleNode) { ruleNode.configuration = deepClone(ruleNode.component.configurationDescriptor.nodeDefinition.defaultConfiguration); const ruleChainId = this.ruleChain.id ? this.ruleChain.id.id : null; this.enableHotKeys = false; const ruleChainType = this.ruleChainType; this.dialog.open(AddRuleNodeDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { ruleNode, ruleChainId, ruleChainType } }).afterClosed().subscribe( (addedRuleNode) => { if (addedRuleNode) { addedRuleNode.id = 'rule-chain-node-' + this.nextNodeID++; addedRuleNode.connectors = []; if (addedRuleNode.component.configurationDescriptor.nodeDefinition.inEnabled) { addedRuleNode.connectors.push( { id: (this.nextConnectorID++) + '', type: FlowchartConstants.leftConnectorType } ); } if (addedRuleNode.component.configurationDescriptor.nodeDefinition.outEnabled) { addedRuleNode.connectors.push( { id: (this.nextConnectorID++) + '', type: FlowchartConstants.rightConnectorType } ); } this.ruleChainModel.nodes.push(addedRuleNode); this.onModelChanged(); this.updateRuleNodesHighlight(); } this.enableHotKeys = true; } ); } addRuleNodeLink(link: FcRuleEdge, labels: {[label: string]: LinkLabel}, allowCustomLabels: boolean, sourceRuleChainId: string): Observable { return this.dialog.open(AddRuleNodeLinkDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { link, labels, allowCustomLabels, sourceRuleChainId } }).afterClosed(); } toggleVersionControl($event: Event, versionControlButton: MatMiniFabButton) { if ($event) { $event.stopPropagation(); } const trigger = versionControlButton._elementRef.nativeElement; if (this.popoverService.hasPopover(trigger)) { this.popoverService.hidePopover(trigger); } else { const versionControlPopover = this.popoverService.displayPopover(trigger, this.renderer, this.viewContainerRef, VersionControlComponent, 'leftTop', true, null, { detailsMode: true, active: true, singleEntityMode: true, externalEntityId: this.ruleChain.externalId || this.ruleChain.id, entityId: this.ruleChain.id, entityName: this.ruleChain.name, onBeforeCreateVersion: () => { if (this.isDirty) { return this.saveRuleChain(); } else { return of(null); } } }, {}, {}, {}, true); versionControlPopover.tbComponentRef.instance.popoverComponent = versionControlPopover; versionControlPopover.tbComponentRef.instance.versionRestored.subscribe(() => { this.reloadRuleChain(); }); } } private updateNodeErrorTooltip(node: FcRuleNode) { if (node.error) { const element = $('#' + node.id); let tooltip = this.errorTooltips[node.id]; if (!tooltip || !element.hasClass('tooltipstered')) { element.tooltipster( { theme: 'tooltipster-shadow', delay: 0, animationDuration: 0, trigger: 'custom', triggerOpen: { click: false, tap: false }, triggerClose: { click: false, tap: false, scroll: false }, side: 'top', trackOrigin: true } ); const content = '
' + '
' + '
' + node.error + '
' + '
' + '
'; const contentElement = $(content); tooltip = element.tooltipster('instance'); tooltip.isErrorTooltip = true; tooltip.content(contentElement); this.errorTooltips[node.id] = tooltip; } setTimeout(() => { tooltip.open(); }, 0); } else { if (this.errorTooltips[node.id]) { const tooltip = this.errorTooltips[node.id]; tooltip.destroy(); delete this.errorTooltips[node.id]; } } } private updateErrorTooltips(hide: boolean) { for (const nodeId of Object.keys(this.errorTooltips)) { const tooltip = this.errorTooltips[nodeId]; if (hide) { tooltip.close(); } else { tooltip.open(); } } } private displayTooltip(event: MouseEvent, content: string) { this.destroyTooltips(); this.tooltipTimeout = setTimeout(() => { const element = $(event.target); element.tooltipster( { theme: 'tooltipster-shadow', delay: 100, trigger: 'custom', triggerOpen: { click: false, tap: false }, triggerClose: { click: true, tap: true, scroll: true }, side: 'right', distance: 12, trackOrigin: true } ); const contentElement = $(content); const tooltip = element.tooltipster('instance'); tooltip.content(contentElement); tooltip.open(); }, 500); } } export interface AddRuleNodeLinkDialogData { link: FcRuleEdge; labels: {[label: string]: LinkLabel}; allowCustomLabels: boolean; sourceRuleChainId: string; } @Component({ selector: 'tb-add-rule-node-link-dialog', templateUrl: './add-rule-node-link-dialog.component.html', providers: [{provide: ErrorStateMatcher, useExisting: AddRuleNodeLinkDialogComponent}], styleUrls: ['./add-rule-node-link-dialog.component.scss'] }) export class AddRuleNodeLinkDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { ruleNodeLinkFormGroup: UntypedFormGroup; link: FcRuleEdge; labels: {[label: string]: LinkLabel}; allowCustomLabels: boolean; sourceRuleChainId: string; submitted = false; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: AddRuleNodeLinkDialogData, @SkipSelf() private errorStateMatcher: ErrorStateMatcher, public dialogRef: MatDialogRef, private fb: UntypedFormBuilder) { super(store, router, dialogRef); this.link = this.data.link; this.labels = this.data.labels; this.allowCustomLabels = this.data.allowCustomLabels; this.sourceRuleChainId = this.data.sourceRuleChainId; this.ruleNodeLinkFormGroup = this.fb.group({ link: [deepClone(this.link), [Validators.required]] } ); } ngOnInit(): void { } isErrorState(control: UntypedFormControl | 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 link: FcRuleEdge = this.ruleNodeLinkFormGroup.get('link').value; this.link = {...this.link, ...link}; this.dialogRef.close(this.link); } } export interface AddRuleNodeDialogData { ruleNode: FcRuleNode; ruleChainId: string; ruleChainType: RuleChainType; } @Component({ selector: 'tb-add-rule-node-dialog', templateUrl: './add-rule-node-dialog.component.html', providers: [{provide: ErrorStateMatcher, useExisting: AddRuleNodeDialogComponent}], styleUrls: ['./add-rule-node-dialog.component.scss'] }) export class AddRuleNodeDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { @ViewChild('tbRuleNode', {static: true}) ruleNodeDetailsComponent: RuleNodeDetailsComponent; ruleNode: FcRuleNode; ruleChainId: string; ruleChainType: RuleChainType; submitted = false; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: AddRuleNodeDialogData, @SkipSelf() private errorStateMatcher: ErrorStateMatcher, public dialogRef: MatDialogRef) { super(store, router, dialogRef); this.ruleNode = this.data.ruleNode; this.ruleChainId = this.data.ruleChainId; this.ruleChainType = this.data.ruleChainType; } ngOnInit(): void { } isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { const originalErrorState = this.errorStateMatcher.isErrorState(control, form); const customErrorState = !!(control && control.invalid && this.submitted); return originalErrorState || customErrorState; } helpLinkIdForRuleNodeType(): string { return getRuleNodeHelpLink(this.ruleNode.component); } cancel(): void { this.dialogRef.close(null); } add(): void { this.submitted = true; this.ruleNodeDetailsComponent.validate(); if (this.ruleNodeDetailsComponent.ruleNodeFormGroup.valid) { this.dialogRef.close(this.ruleNode); } } } export interface CreateNestedRuleChainDialogData { ruleChainType: RuleChainType; } @Component({ selector: 'tb-create-nested-rulechain-dialog', templateUrl: './create-nested-rulechain-dialog.component.html', providers: [{provide: ErrorStateMatcher, useExisting: CreateNestedRuleChainDialogComponent}], styleUrls: [] }) export class CreateNestedRuleChainDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { createNestedRuleChainFormGroup: UntypedFormGroup; submitted = false; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: CreateNestedRuleChainDialogData, @SkipSelf() private errorStateMatcher: ErrorStateMatcher, private fb: UntypedFormBuilder, 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: UntypedFormControl | 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); } ); } }