thingsboard-aplcache

Multiple labels support.

6/18/2018 8:33:50 AM

Details

diff --git a/ui/src/app/api/rule-chain.service.js b/ui/src/app/api/rule-chain.service.js
index e7436de..186e31c 100644
--- a/ui/src/app/api/rule-chain.service.js
+++ b/ui/src/app/api/rule-chain.service.js
@@ -32,6 +32,7 @@ function RuleChainService($http, $q, $filter, $ocLazyLoad, $translate, types, co
         getRuleNodeComponents: getRuleNodeComponents,
         getRuleNodeComponentByClazz: getRuleNodeComponentByClazz,
         getRuleNodeSupportedLinks: getRuleNodeSupportedLinks,
+        ruleNodeAllowCustomLinks: ruleNodeAllowCustomLinks,
         resolveTargetRuleChains: resolveTargetRuleChains,
         testScript: testScript,
         getLatestRuleNodeDebugInput: getLatestRuleNodeDebugInput
@@ -127,21 +128,21 @@ function RuleChainService($http, $q, $filter, $ocLazyLoad, $translate, types, co
 
     function getRuleNodeSupportedLinks(component) {
         var relationTypes = component.configurationDescriptor.nodeDefinition.relationTypes;
-        var customRelations = component.configurationDescriptor.nodeDefinition.customRelations;
-        var linkLabels = [];
+        var linkLabels = {};
         for (var i=0;i<relationTypes.length;i++) {
-            linkLabels.push({
-                name: relationTypes[i], custom: false
-            });
-        }
-        if (customRelations) {
-            linkLabels.push(
-                { name: 'Custom', custom: true }
-            );
+            var label = relationTypes[i];
+            linkLabels[label] = {
+                name: label,
+                value: label
+            };
         }
         return linkLabels;
     }
 
+    function ruleNodeAllowCustomLinks(component) {
+        return component.configurationDescriptor.nodeDefinition.customRelations;
+    }
+
     function getRuleNodeComponents() {
         var deferred = $q.defer();
         if (ruleNodeComponents) {
@@ -226,7 +227,10 @@ function RuleChainService($http, $q, $filter, $ocLazyLoad, $translate, types, co
         if (res && res.length) {
             return res[0];
         }
-        return null;
+        var unknownComponent = angular.copy(types.unknownNodeComponent);
+        unknownComponent.clazz = clazz;
+        unknownComponent.configurationDescriptor.nodeDefinition.details = "Unknown Rule Node class: " + clazz;
+        return unknownComponent;
     }
 
     function resolveTargetRuleChains(ruleChainConnections) {
diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js
index cf1023e..1aa7539 100644
--- a/ui/src/app/common/types.constant.js
+++ b/ui/src/app/common/types.constant.js
@@ -510,6 +510,22 @@ export default angular.module('thingsboard.types', [])
                     }
                 }
             },
+            unknownNodeComponent: {
+                type: 'UNKNOWN',
+                name: 'unknown',
+                clazz: 'tb.internal.Unknown',
+                configurationDescriptor: {
+                    nodeDefinition: {
+                        description: "",
+                        details: "",
+                        inEnabled: true,
+                        outEnabled: true,
+                        relationTypes: [],
+                        customRelations: false,
+                        defaultConfiguration: {}
+                    }
+                }
+            },
             inputNodeComponent: {
                 type: 'INPUT',
                 name: 'Input',
@@ -565,6 +581,13 @@ export default angular.module('thingsboard.types', [])
                     nodeClass: "tb-input-type",
                     icon: "input",
                     special: true
+                },
+                UNKNOWN: {
+                    value: "UNKNOWN",
+                    name: "rulenode.type-unknown",
+                    details: "rulenode.type-unknown-details",
+                    nodeClass: "tb-unknown-type",
+                    icon: "help_outline"
                 }
             },
             valueType: {
diff --git a/ui/src/app/device/device-card.tpl.html b/ui/src/app/device/device-card.tpl.html
index de8b7ee..fbda549 100644
--- a/ui/src/app/device/device-card.tpl.html
+++ b/ui/src/app/device/device-card.tpl.html
@@ -16,8 +16,8 @@
 
 -->
 <div flex layout="column" style="margin-top: -10px;">
-	<div flex>{{vm.item.additionalInfo.description}}</div>
-    <div flex style="text-transform: uppercase; padding-bottom: 10px;">{{vm.item.type}}</div>
-    <div class="tb-small" ng-show="vm.isAssignedToCustomer()">{{'device.assignedToCustomer' | translate}} '{{vm.item.assignedCustomer.title}}'</div>
-    <div class="tb-small" ng-show="vm.isPublic()">{{'device.public' | translate}}</div>
+    <div style="text-transform: uppercase; padding-bottom: 5px;">{{vm.item.type}}</div>
+    <div class="tb-card-description">{{vm.item.additionalInfo.description}}</div>
+    <div style="padding-top: 5px;" class="tb-small" ng-show="vm.isAssignedToCustomer()">{{'device.assignedToCustomer' | translate}} '{{vm.item.assignedCustomer.title}}'</div>
+    <div style="padding-top: 5px;" class="tb-small" ng-show="vm.isPublic()">{{'device.public' | translate}}</div>
 </div>
diff --git a/ui/src/app/locale/locale.constant.js b/ui/src/app/locale/locale.constant.js
index 29f6f8e..4cf34f8 100644
--- a/ui/src/app/locale/locale.constant.js
+++ b/ui/src/app/locale/locale.constant.js
@@ -1157,6 +1157,11 @@ export default angular.module('thingsboard.locale', [])
                     "link-label-required": "Link label is required.",
                     "custom-link-label": "Custom link label",
                     "custom-link-label-required": "Custom link label is required.",
+                    "link-labels": "Link labels",
+                    "link-labels-required": "Link labels is required.",
+                    "no-link-labels-found": "No link labels found",
+                    "no-link-label-matching": "'{{label}}' not found.",
+                    "create-new-link-label": "Create a new one!",
                     "type-filter": "Filter",
                     "type-filter-details": "Filter incoming messages with configured conditions",
                     "type-enrichment": "Enrichment",
@@ -1171,6 +1176,8 @@ export default angular.module('thingsboard.locale', [])
                     "type-rule-chain-details": "Forwards incoming messages to specified Rule Chain",
                     "type-input": "Input",
                     "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node",
+                    "type-unknown": "Unknown",
+                    "type-unknown-details": "Unresolved Rule Node",
                     "directive-is-not-loaded": "Defined configuration directive '{{directiveName}}' is not available.",
                     "ui-resources-load-error": "Failed to load configuration ui resources.",
                     "invalid-target-rulechain": "Unable to resolve target rule chain!",
diff --git a/ui/src/app/rulechain/add-link.tpl.html b/ui/src/app/rulechain/add-link.tpl.html
index 0a21104..c5855b7 100644
--- a/ui/src/app/rulechain/add-link.tpl.html
+++ b/ui/src/app/rulechain/add-link.tpl.html
@@ -31,7 +31,7 @@
         <span style="min-height: 5px;" flex="" ng-show="!$root.loading"></span>
         <md-dialog-content>
             <div class="md-dialog-content">
-                <tb-rule-node-link link="vm.link" labels="vm.labels" is-edit="true" the-form="theForm"></tb-rule-node-link>
+                <tb-rule-node-link ng-model="vm.link" allowed-labels="vm.labels" is-edit="true" allow-custom="vm.allowCustomLabels"></tb-rule-node-link>
             </div>
         </md-dialog-content>
         <md-dialog-actions layout="row">
diff --git a/ui/src/app/rulechain/link.directive.js b/ui/src/app/rulechain/link.directive.js
index b3565a3..dd43821 100644
--- a/ui/src/app/rulechain/link.directive.js
+++ b/ui/src/app/rulechain/link.directive.js
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+import './link.scss';
+
 /* eslint-disable import/no-unresolved, import/default */
 
 import linkFieldsetTemplate from './link-fieldset.tpl.html';
@@ -22,13 +24,18 @@ import linkFieldsetTemplate from './link-fieldset.tpl.html';
 
 /*@ngInject*/
 export default function LinkDirective($compile, $templateCache, $filter) {
-    var linker = function (scope, element) {
+    var linker = function (scope, element, attrs, ngModelCtrl) {
         var template = $templateCache.get(linkFieldsetTemplate);
         element.html(template);
 
         scope.selectedLabel = null;
+        scope.labelSearchText = null;
+
+        scope.ngModelCtrl = ngModelCtrl;
+
+        var labelsList = [];
 
-        scope.$watch('link', function() {
+        /*scope.$watch('link', function() {
             scope.selectedLabel = null;
              if (scope.link && scope.labels) {
                  if (scope.link.label) {
@@ -53,19 +60,100 @@ export default function LinkDirective($compile, $templateCache, $filter) {
                     scope.link.label = "";
                 }
             }
+        };*/
+
+        scope.transformLinkLabelChip = function (chip) {
+            var res = $filter('filter')(labelsList, {name: chip}, true);
+            var result;
+            if (res && res.length) {
+                result = angular.copy(res[0]);
+            } else {
+                result = {
+                    name: chip,
+                    value: chip
+                };
+            }
+            return result;
+        };
+
+        scope.labelsSearch = function (searchText) {
+            var labels = searchText ? $filter('filter')(labelsList, {name: searchText}) : labelsList;
+            return labels.map((label) => label.name);
+        };
+
+        scope.createLinkLabel = function (event, chipsId) {
+            var chipsChild = angular.element(chipsId, element)[0].firstElementChild;
+            var el = angular.element(chipsChild);
+            var chipBuffer = el.scope().$mdChipsCtrl.getChipBuffer();
+            event.preventDefault();
+            event.stopPropagation();
+            el.scope().$mdChipsCtrl.appendChip(chipBuffer.trim());
+            el.scope().$mdChipsCtrl.resetChipBuffer();
         };
 
+
+        ngModelCtrl.$render = function () {
+            labelsList.length = 0;
+            for (var label in scope.allowedLabels) {
+                var linkLabel = {
+                    name: scope.allowedLabels[label].name,
+                    value: scope.allowedLabels[label].value
+                };
+                labelsList.push(linkLabel);
+            }
+
+            var link = ngModelCtrl.$viewValue;
+            var labels = [];
+            if (link && link.labels) {
+                for (var i = 0; i < link.labels.length; i++) {
+                    label = link.labels[i];
+                    if (scope.allowedLabels[label]) {
+                        labels.push(angular.copy(scope.allowedLabels[label]));
+                    } else {
+                        labels.push({
+                            name: label,
+                            value: label
+                        });
+                    }
+                }
+            }
+            scope.labels = labels;
+            scope.$watch('labels', function (newVal, prevVal) {
+                if (!angular.equals(newVal, prevVal)) {
+                    updateLabels();
+                }
+            }, true);
+        };
+
+        function updateLabels() {
+            if (ngModelCtrl.$viewValue) {
+                var labels = [];
+                for (var i = 0; i < scope.labels.length; i++) {
+                    labels.push(scope.labels[i].value);
+                }
+                ngModelCtrl.$viewValue.labels = labels;
+                ngModelCtrl.$viewValue.label = labels.join(' / ');
+                updateValidity();
+            }
+        }
+
+        function updateValidity() {
+            var valid = ngModelCtrl.$viewValue.labels &&
+            ngModelCtrl.$viewValue.labels.length ? true : false;
+            ngModelCtrl.$setValidity('linkLabels', valid);
+        }
+
         $compile(element.contents())(scope);
     }
     return {
         restrict: "E",
+        require: "^ngModel",
         link: linker,
         scope: {
-            link: '=',
-            labels: '=',
+            allowedLabels: '=',
+            allowCustom: '=',
             isEdit: '=',
-            isReadOnly: '=',
-            theForm: '='
+            isReadOnly: '='
         }
     };
 }
diff --git a/ui/src/app/rulechain/link.scss b/ui/src/app/rulechain/link.scss
new file mode 100644
index 0000000..3e86619
--- /dev/null
+++ b/ui/src/app/rulechain/link.scss
@@ -0,0 +1,30 @@
+/**
+ * Copyright © 2016-2018 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.
+ */
+
+.tb-link-label-autocomplete {
+  .tb-not-found {
+    display: block;
+    line-height: 1.5;
+    height: 48px;
+    .tb-no-entries {
+      line-height: 48px;
+    }
+  }
+  li {
+    height: auto !important;
+    white-space: normal !important;
+  }
+}
diff --git a/ui/src/app/rulechain/link-fieldset.tpl.html b/ui/src/app/rulechain/link-fieldset.tpl.html
index 13ec6c3..3dbe33f 100644
--- a/ui/src/app/rulechain/link-fieldset.tpl.html
+++ b/ui/src/app/rulechain/link-fieldset.tpl.html
@@ -17,7 +17,46 @@
 -->
 <md-content class="md-padding tb-link" layout="column">
     <fieldset ng-disabled="$root.loading || !isEdit || isReadOnly">
-        <md-input-container class="md-block">
+        <label translate class="tb-title no-padding" ng-class="{'tb-required': required}">rulenode.link-labels</label>
+        <md-chips id="link_label_chips"
+                  ng-required="true"
+                  readonly="$root.loading || !isEdit || isReadOnly"
+                  ng-model="labels" md-autocomplete-snap
+                  md-transform-chip="transformLinkLabelChip($chip)"
+                  md-require-match="!allowCustom">
+            <md-autocomplete
+                    id="link_label"
+                    md-no-cache="true"
+                    md-selected-item="selectedLabel"
+                    md-search-text="labelSearchText"
+                    md-items="item in labelsSearch(labelSearchText)"
+                    md-item-text="item.name"
+                    md-min-length="0"
+                    placeholder="{{'rulenode.link-label' | translate }}"
+                    md-menu-class="tb-link-label-autocomplete">
+                <span md-highlight-text="labelSearchText" md-highlight-flags="^i">{{item}}</span>
+                <md-not-found>
+                    <div class="tb-not-found">
+                        <div class="tb-no-entries" ng-if="!labelSearchText || !labelSearchText.length">
+                            <span translate>rulenode.no-link-labels-found</span>
+                        </div>
+                        <div ng-if="labelSearchText && labelSearchText.length">
+                            <span translate translate-values='{ label: "{{labelSearchText | truncate:true:6:&apos;...&apos;}}" }'>rulenode.no-link-label-matching</span>
+                            <span ng-if="allowCustom">
+                                <a translate ng-click="createLinkLabel($event, '#link_label_chips')">rulenode.create-new-link-label</a>
+                            </span>
+                        </div>
+                    </div>
+                </md-not-found>
+            </md-autocomplete>
+            <md-chip-template>
+                <span>{{$chip.name}}</span>
+            </md-chip-template>
+        </md-chips>
+        <div class="tb-error-messages" ng-messages="ngModelCtrl.$error" role="alert">
+            <div translate ng-message="linkLabels" class="tb-error-message">rulenode.link-labels-required</div>
+        </div>
+        <!--md-input-container class="md-block">
             <label translate>rulenode.link-label</label>
             <md-select ng-model="selectedLabel" ng-change="selectedLabelChanged()">
                 <md-option ng-repeat="label in labels" ng-value="label">
@@ -34,6 +73,6 @@
             <div ng-messages="theForm.customLinkLabel.$error">
                 <div translate ng-message="required">rulenode.custom-link-label-required</div>
             </div>
-        </md-input-container>
+        </md-input-container-->
     </fieldset>
 </md-content>
diff --git a/ui/src/app/rulechain/rulechain.controller.js b/ui/src/app/rulechain/rulechain.controller.js
index dfd1a97..a38f8a8 100644
--- a/ui/src/app/rulechain/rulechain.controller.js
+++ b/ui/src/app/rulechain/rulechain.controller.js
@@ -669,11 +669,15 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
                 }
             } else {
                 if (edge.label) {
+                    if (!edge.labels) {
+                        edge.labels = edge.label.split(' / ');
+                    }
                     deferred.resolve(edge);
                 } else {
                     var labels = ruleChainService.getRuleNodeSupportedLinks(sourceNode.component);
+                    var allowCustomLabels = ruleChainService.ruleNodeAllowCustomLinks(sourceNode.component);
                     vm.enableHotKeys = false;
-                    addRuleNodeLink(event, edge, labels).then(
+                    addRuleNodeLink(event, edge, labels, allowCustomLabels).then(
                         (link) => {
                             deferred.resolve(link);
                             vm.enableHotKeys = true;
@@ -713,6 +717,7 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
             vm.isEditingRuleNode = false;
             vm.editingRuleNode = null;
             vm.editingRuleNodeLinkLabels = ruleChainService.getRuleNodeSupportedLinks(sourceNode.component);
+            vm.editingRuleNodeAllowCustomLabels = ruleChainService.ruleNodeAllowCustomLinks(sourceNode.component);
             vm.isEditingRuleNodeLink = true;
             vm.editingRuleNodeLinkIndex = vm.ruleChainModel.edges.indexOf(edge);
             vm.editingRuleNodeLink = angular.copy(edge);
@@ -744,7 +749,8 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
                     isInputSource: isInputSource,
                     fromIndex: fromIndex,
                     toIndex: toIndex,
-                    label: edge.label
+                    label: edge.label,
+                    labels: edge.labels
                 };
                 connections.push(connection);
             }
@@ -816,7 +822,8 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
                         var edge = {
                             source: source,
                             destination: destination,
-                            label: connection.label
+                            label: connection.label,
+                            labels: connection.labels
                         };
                         vm.ruleChainModel.edges.push(edge);
                         vm.modelservice.edges.select(edge);
@@ -1024,6 +1031,7 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
         }
 
         if (vm.ruleChainMetaData.connections) {
+            var edgeMap = {};
             for (i = 0; i < vm.ruleChainMetaData.connections.length; i++) {
                 var connection = vm.ruleChainMetaData.connections[i];
                 var sourceNode = nodes[connection.fromIndex];
@@ -1032,12 +1040,23 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
                     var sourceConnectors = vm.modelservice.nodes.getConnectorsByType(sourceNode, flowchartConstants.rightConnectorType);
                     var destConnectors = vm.modelservice.nodes.getConnectorsByType(destNode, flowchartConstants.leftConnectorType);
                     if (sourceConnectors && sourceConnectors.length && destConnectors && destConnectors.length) {
-                        edge = {
-                            source: sourceConnectors[0].id,
-                            destination: destConnectors[0].id,
-                            label: connection.type
-                        };
-                        vm.ruleChainModel.edges.push(edge);
+                        var sourceId = sourceConnectors[0].id;
+                        var destId = destConnectors[0].id;
+                        var edgeKey = sourceId + '_' + destId;
+                        edge = edgeMap[edgeKey];
+                        if (!edge) {
+                            edge = {
+                                source: sourceId,
+                                destination: destId,
+                                label: connection.type,
+                                labels: [connection.type]
+                            };
+                            edgeMap[edgeKey] = edge;
+                            vm.ruleChainModel.edges.push(edge);
+                        } else {
+                            edge.label += ' / ' +connection.type;
+                            edge.labels.push(connection.type);
+                        }
                     }
                 }
             }
@@ -1045,6 +1064,7 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
 
         if (vm.ruleChainMetaData.ruleChainConnections) {
             var ruleChainNodesMap = {};
+            var ruleChainEdgeMap = {};
             for (i = 0; i < vm.ruleChainMetaData.ruleChainConnections.length; i++) {
                 var ruleChainConnection = vm.ruleChainMetaData.ruleChainConnections[i];
                 var ruleChain = ruleChainsMap[ruleChainConnection.targetRuleChainId.id];
@@ -1081,12 +1101,23 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
                     if (sourceNode) {
                         connectors = vm.modelservice.nodes.getConnectorsByType(sourceNode, flowchartConstants.rightConnectorType);
                         if (connectors && connectors.length) {
-                            var ruleChainEdge = {
-                                source: connectors[0].id,
-                                destination: ruleChainNode.connectors[0].id,
-                                label: ruleChainConnection.type
-                            };
-                            vm.ruleChainModel.edges.push(ruleChainEdge);
+                            sourceId = connectors[0].id;
+                            destId = ruleChainNode.connectors[0].id;
+                            edgeKey = sourceId + '_' + destId;
+                            var ruleChainEdge = ruleChainEdgeMap[edgeKey];
+                            if (!ruleChainEdge) {
+                                ruleChainEdge = {
+                                    source: sourceId,
+                                    destination: destId,
+                                    label: ruleChainConnection.type,
+                                    labels: [ruleChainConnection.type]
+                                };
+                                ruleChainEdgeMap[edgeKey] = ruleChainEdge;
+                                vm.ruleChainModel.edges.push(ruleChainEdge);
+                            } else {
+                                ruleChainEdge.label += ' / ' +ruleChainConnection.type;
+                                ruleChainEdge.labels.push(ruleChainConnection.type);
+                            }
                         }
                     }
                 }
@@ -1199,8 +1230,7 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
                             var ruleChainConnection = {
                                 fromIndex: fromIndex,
                                 targetRuleChainId: {entityType: vm.types.entityType.rulechain, id: destNode.targetRuleChainId},
-                                additionalInfo: destNode.additionalInfo,
-                                type: edge.label
+                                additionalInfo: destNode.additionalInfo
                             };
                             if (!ruleChainConnection.additionalInfo) {
                                 ruleChainConnection.additionalInfo = {};
@@ -1208,15 +1238,22 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
                             ruleChainConnection.additionalInfo.layoutX = Math.round(destNode.x);
                             ruleChainConnection.additionalInfo.layoutY = Math.round(destNode.y);
                             ruleChainConnection.additionalInfo.ruleChainNodeId = destNode.id;
-                            ruleChainMetaData.ruleChainConnections.push(ruleChainConnection);
+                            for (var rcIndex=0;rcIndex<edge.labels.length;rcIndex++) {
+                                var newRuleChainConnection = angular.copy(ruleChainConnection);
+                                newRuleChainConnection.type = edge.labels[rcIndex];
+                                ruleChainMetaData.ruleChainConnections.push(newRuleChainConnection);
+                            }
                         } else {
                             var toIndex = nodes.indexOf(destNode);
                             var nodeConnection = {
                                 fromIndex: fromIndex,
-                                toIndex: toIndex,
-                                type: edge.label
+                                toIndex: toIndex
                             };
-                            ruleChainMetaData.connections.push(nodeConnection);
+                            for (var cIndex=0;cIndex<edge.labels.length;cIndex++) {
+                                var newNodeConnection = angular.copy(nodeConnection);
+                                newNodeConnection.type = edge.labels[cIndex];
+                                ruleChainMetaData.connections.push(newNodeConnection);
+                            }
                         }
                     }
                 }
@@ -1285,13 +1322,13 @@ export function RuleChainController($state, $scope, $compile, $q, $mdUtil, $time
         });
     }
 
-    function addRuleNodeLink($event, link, labels) {
+    function addRuleNodeLink($event, link, labels, allowCustomLabels) {
         return $mdDialog.show({
             controller: 'AddRuleNodeLinkController',
             controllerAs: 'vm',
             templateUrl: addRuleNodeLinkTemplate,
             parent: angular.element($document[0].body),
-            locals: {link: link, labels: labels},
+            locals: {link: link, labels: labels, allowCustomLabels: allowCustomLabels},
             fullscreen: true,
             targetEvent: $event
         });
@@ -1335,13 +1372,14 @@ export function AddRuleNodeController($scope, $mdDialog, ruleNode, ruleChainId, 
 }
 
 /*@ngInject*/
-export function AddRuleNodeLinkController($scope, $mdDialog, link, labels, helpLinks) {
+export function AddRuleNodeLinkController($scope, $mdDialog, link, labels, allowCustomLabels, helpLinks) {
 
     var vm = this;
 
     vm.helpLinks = helpLinks;
     vm.link = link;
     vm.labels = labels;
+    vm.allowCustomLabels = allowCustomLabels;
 
     vm.add = add;
     vm.cancel = cancel;
diff --git a/ui/src/app/rulechain/rulechain.scss b/ui/src/app/rulechain/rulechain.scss
index c51a955..5999b7e 100644
--- a/ui/src/app/rulechain/rulechain.scss
+++ b/ui/src/app/rulechain/rulechain.scss
@@ -170,6 +170,9 @@
   &.tb-rule-chain-type {
     background-color: #d6c4f1;
   }
+  &.tb-unknown-type {
+    background-color: #f16c29;
+  }
 }
 
 .tb-rule-node {
@@ -202,6 +205,7 @@
     background-color: #a3eaa9;
     user-select: none;
   }
+
   md-icon {
     font-size: 20px;
     width: 20px;
diff --git a/ui/src/app/rulechain/rulechain.tpl.html b/ui/src/app/rulechain/rulechain.tpl.html
index a84df90..9c77ae4 100644
--- a/ui/src/app/rulechain/rulechain.tpl.html
+++ b/ui/src/app/rulechain/rulechain.tpl.html
@@ -207,11 +207,11 @@
             </details-buttons>
             <form name="vm.ruleNodeLinkForm" ng-if="vm.isEditingRuleNodeLink">
                 <tb-rule-node-link
-                        link="vm.editingRuleNodeLink"
-                        labels="vm.editingRuleNodeLinkLabels"
+                        ng-model="vm.editingRuleNodeLink"
+                        allowed-labels="vm.editingRuleNodeLinkLabels"
+                        allow-custom="vm.editingRuleNodeAllowCustomLabels"
                         is-edit="true"
-                        is-read-only="false"
-                        the-form="vm.ruleNodeLinkForm">
+                        is-read-only="false">
                 </tb-rule-node-link>
             </form>
         </tb-details-sidenav>
diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss
index 8fed892..2852a7b 100644
--- a/ui/src/scss/main.scss
+++ b/ui/src/scss/main.scss
@@ -16,6 +16,7 @@
 @import "~compass-sass-mixins/lib/compass";
 @import "constants";
 @import "animations";
+@import "mixins";
 @import "fonts";
 
 /***************
@@ -437,6 +438,12 @@ pre.tb-highlight {
   }
 }
 
+.tb-card-description {
+  color: rgba(0,0,0,0.54);
+  font-size: 13px;
+  @include line-clamp(2, 1.1);
+}
+
 /***********************
  * Flow
  ***********************/
diff --git a/ui/src/scss/mixins.scss b/ui/src/scss/mixins.scss
index 9e8b7df..cb66171 100644
--- a/ui/src/scss/mixins.scss
+++ b/ui/src/scss/mixins.scss
@@ -31,4 +31,29 @@
   &:-ms-input-placeholder {
     @content;
   }
-}
\ No newline at end of file
+}
+
+@mixin line-clamp($numLines: 1, $lineHeight: 1.412) {
+  overflow: hidden;
+  position: relative;
+  line-height: $lineHeight;
+  text-align: justify;
+  margin-right: -1em;
+  padding-right: 2em;
+  max-height: ($numLines*$lineHeight)+em;
+  &:before {
+    content: '...';
+    position: absolute;
+    right: 1em;
+    bottom: 0;
+  }
+  &:after {
+    content: '';
+    position: absolute;
+    right: 1em;
+    width: 1em;
+    height: 1em;
+    margin-top: 0.2em;
+    background: white;
+  }
+}