Merge branch 'master' into jcCompactList
diff --git a/ui-modules/blueprint-composer/app/components/blueprint-data-manager/blueprint-data-manager.directive.js b/ui-modules/blueprint-composer/app/components/blueprint-data-manager/blueprint-data-manager.directive.js
index b3e0eac..e448d53 100644
--- a/ui-modules/blueprint-composer/app/components/blueprint-data-manager/blueprint-data-manager.directive.js
+++ b/ui-modules/blueprint-composer/app/components/blueprint-data-manager/blueprint-data-manager.directive.js
@@ -16,15 +16,27 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import template from "./blueprint-data-manager.template.html";
-import {saveAs} from "file-saver/FileSaver";
-const VALID_FILENAME_REGEX = /^.*\.ya?ml$/
-const FILETYPE_TOKEN_REGEX = /^.*\.(.*)$/
+import angular from 'angular';
+import template from './blueprint-data-manager.template.html';
+import {saveAs} from 'file-saver/FileSaver';
+
+const MODULE_NAME = 'brooklyn.components.blueprint-data-manager';
+const TEMPLATE_URL = 'blueprint-composer/component/blueprint-data-manager/index.html';
+const VALID_FILENAME_REGEX = /^.*\.ya?ml$/;
+const FILETYPE_TOKEN_REGEX = /^.*\.(.*)$/;
+
+angular.module(MODULE_NAME, [])
+    .directive('blueprintDataManager', blueprintDataManagerDirective)
+    .run(['$templateCache', templateCache]);
+
+export default MODULE_NAME;
 
 export function blueprintDataManagerDirective() {
     return {
         restrict: 'E',
-        template: template,
+        templateUrl: function(tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
         controller: ['$rootScope', '$scope', '$element', '$document', 'blueprintService', 'brSnackbar', controller]
     };
 
@@ -88,7 +100,7 @@
         function readFile(file) {
             if (VALID_FILENAME_REGEX.test(file.name)) {
                 var reader = new FileReader();
-                reader.addEventListener("load", function () {
+                reader.addEventListener('load', function () {
                     try {
                         var yaml = reader.result;
                         blueprintService.setFromYaml(yaml, true);
@@ -118,3 +130,7 @@
         });
     }
 }
+
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, template);
+}
diff --git a/ui-modules/blueprint-composer/app/components/breacrumbs/breadcrumbs.directive.js b/ui-modules/blueprint-composer/app/components/breacrumbs/breadcrumbs.directive.js
index 72b6ea8..6c5edd1 100644
--- a/ui-modules/blueprint-composer/app/components/breacrumbs/breadcrumbs.directive.js
+++ b/ui-modules/blueprint-composer/app/components/breacrumbs/breadcrumbs.directive.js
@@ -16,33 +16,49 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
 import template from './breadcrumbs.template.html';
 
+const MODULE_NAME = 'brooklyn.components.breadcrumbs';
+const TEMPLATE_URL = 'blueprint-composer/component/breadcrumbs/index.html';
+
+angular.module(MODULE_NAME, [])
+    .directive('breadcrumbs', breadcrumbsDirective)
+    .run(['$templateCache', templateCache]);
+
+export default MODULE_NAME;
+
 export function breadcrumbsDirective() {
     return {
         restrict: 'E',
+        templateUrl: function(tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
         scope: {
             entity: '<',
             current: '<'
         },
-        template: template,
         link: link
+    };
+
+    function link(scope) {
+        if (scope.entity) {
+            scope.breadcrumbs = [];
+            if (scope.current) {
+                scope.breadcrumbs.push(scope.current);
+            }
+
+            let currentEntity = scope.entity;
+            while (currentEntity.hasParent()) {
+                scope.breadcrumbs.push(currentEntity);
+                currentEntity = currentEntity.parent;
+            }
+            scope.breadcrumbs.push(currentEntity);
+            scope.breadcrumbs.reverse();
+        }
     }
 }
 
-function link(scope) {
-    if (scope.entity) {
-        scope.breadcrumbs = [];
-        if (scope.current) {
-            scope.breadcrumbs.push(scope.current);
-        }
-
-        let currentEntity = scope.entity;
-        while (currentEntity.hasParent()) {
-            scope.breadcrumbs.push(currentEntity);
-            currentEntity = currentEntity.parent;
-        }
-        scope.breadcrumbs.push(currentEntity);
-        scope.breadcrumbs.reverse();
-    }
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, template);
 }
\ No newline at end of file
diff --git a/ui-modules/blueprint-composer/app/components/catalog-saver/catalog-saver.directive.js b/ui-modules/blueprint-composer/app/components/catalog-saver/catalog-saver.directive.js
index cb6f0ea..bdfaa67 100644
--- a/ui-modules/blueprint-composer/app/components/catalog-saver/catalog-saver.directive.js
+++ b/ui-modules/blueprint-composer/app/components/catalog-saver/catalog-saver.directive.js
@@ -23,10 +23,10 @@
 import modalTemplate from './catalog-saver.modal.template.html';
 import jsYaml from 'js-yaml';
 import brUtils from 'brooklyn-ui-utils/utils/general';
-import {yamlState} from "../../views/main/yaml/yaml.state";
-import {graphicalState} from "../../views/main/graphical/graphical.state";
 
 const MODULE_NAME = 'brooklyn.components.catalog-saver';
+const TEMPLATE_URL = 'blueprint-composer/component/catalog-saver/index.html';
+const TEMPLATE_MODAL_URL = 'blueprint-composer/component/catalog-saver/modal.html';
 
 const REASONS = {
     new: 0,
@@ -43,14 +43,17 @@
 
 angular.module(MODULE_NAME, [angularAnimate, uibModal, brUtils])
     .directive('catalogSaver', ['$rootScope', '$uibModal', '$injector', 'composerOverrides', saveToCatalogModalDirective])
-    .directive('catalogVersion', ['$parse', catalogVersionDirective]);
+    .directive('catalogVersion', ['$parse', catalogVersionDirective])
+    .run(['$templateCache', templateCache]);
 
 export default MODULE_NAME;
 
 export function saveToCatalogModalDirective($rootScope, $uibModal, $injector, composerOverrides) {
     return {
         restrict: 'E',
-        template: template,
+        templateUrl: function (tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
         scope: {
             config: '='
         },
@@ -60,14 +63,12 @@
     function link($scope, $element) {
         $scope.buttonText = $scope.config.label || ($scope.config.itemType ? `Update ${$scope.config.name || $scope.config.symbolicName}` : 'Add to catalog');
 
-        $injector.get('$templateCache').put('catalog-saver.modal.template.html', modalTemplate);
-
         $scope.activateModal = () => {
             // Override callback to update catalog configuration data in other applications
             $scope.config = (composerOverrides.updateCatalogConfig || (($scope, $element) => $scope.config))($scope, $element);
 
             let modalInstance = $uibModal.open({
-                templateUrl: 'catalog-saver.modal.template.html',
+                templateUrl: TEMPLATE_MODAL_URL,
                 size: 'save',
                 controller: ['$scope', 'blueprintService', 'paletteApi', 'brUtilsGeneral', CatalogItemModalController],
                 scope: $scope,
@@ -162,7 +163,7 @@
         link: link
     };
 
-    function link (scope, elm, attr, ctrl) {
+    function link(scope, elm, attr, ctrl) {
         if (!ctrl) {
             return;
         }
@@ -188,3 +189,8 @@
         };
     }
 }
+
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, template);
+    $templateCache.put(TEMPLATE_MODAL_URL, modalTemplate);
+}
diff --git a/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.directive.js b/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.directive.js
index 298ba80..3d90b92 100644
--- a/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.directive.js
+++ b/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.directive.js
@@ -22,90 +22,382 @@
 import footerTemplate from './catalog-selector-palette-footer.html';
 import { distanceInWordsToNow } from 'date-fns';
 
+const MODULE_NAME = 'brooklyn.composer.component.catalog-selector';
+const TEMPLATE_URL = 'blueprint-composer/component/catalog-selector/index.html';
+const TEMPLATE_SUBHEAD_URL = 'blueprint-composer/component/catalog-selector/subhead.html';
+const TEMPLATE_FOOTER_URL = 'blueprint-composer/component/catalog-selector/footer.html';
 const MIN_ROWS_PER_PAGE = 4;
 
 const PALETTE_VIEW_ORDERS = {
-        name: { label: "Name", field: "displayName" },
-        lastUsed: { label: "Recent", field: "-lastUsed" }, 
-        bundle: { label: "Bundle", field: "containingBundle" }, 
-        id: { label: "ID", field: "symbolicName" }, 
+        relevance: { id: "relevance", label: "Relevance", field: "relevance" },
+        name: { id: "name", label: "Name", field: "displayName" },
+        lastUsed: { id: "lastUsed", label: "Recent", field: "-lastUsed" }, 
+        bundle: { id: "bundle", label: "Bundle", field: "containingBundle" }, 
+        id: { id: "id", label: "ID", field: "symbolicName" }, 
     };
 
 const PALETTE_VIEW_MODES = {
-        compact: { name: "Compact", classes: "col-xs-2 item-compact", itemsPerRow: 6, rowHeightPx: 75, hideName: true },
-        normal: { name: "Normal", classes: "col-xs-3", itemsPerRow: 4 },
-        large: { name: "Large", classes: "col-xs-4", itemsPerRow: 3 },
+        tiny: { name: "Tiny", classes: "col-xs-2 item-compact", itemsPerRow: 6, rowHeightPx: 75, hideName: true },
+        compact: { name: "Compact", classes: "col-xs-3", itemsPerRow: 4 },
+        normal: { name: "Normal", classes: "col-xs-4", itemsPerRow: 3 },
+        large: { name: "Large", classes: "col-xs-6", itemsPerRow: 2 },
         list: { name: "List", classes: "col-xs-12 item-full-width", itemsPerRow: 1 },
         compactList: { name: "Compact list", classes: "col-xs-12 item-compact-list", itemsPerRow: 1, rowHeightPx: 30 },
     };
 
 // fields in either bundle or type record:
-const FIELDS_TO_SEARCH = ['name', 'displayName', 'symbolicName', 'version', 'type', 'supertypes', 'containingBundle', 'description', 'displayTags', 'tags'];
+const FIELDS_TO_SEARCH = ['displayName', 'name', 'symbolicName', 'type', 'version', 'containingBundle', 'description', 'displayTags', 'tags', 'supertypes'];
+
+angular.module(MODULE_NAME, [])
+    .directive('catalogSelector', catalogSelectorDirective)
+    .filter('catalogSelectorSearch', catalogSelectorSearchFilter)
+    .filter('catalogSelectorFilters', catalogSelectorFiltersFilter)
+    .run(['$templateCache', templateCache]);
+
+export default MODULE_NAME;
 
 export function catalogSelectorDirective() {
     return {
         restrict: 'E',
+        templateUrl: function (tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
         scope: {
             family: '<',
-            onSelect: '&',
-            rowsPerPage: '<?',  // if unset then fill
+            onSelect: '&', // action to do when item is selected
+            onSelectText: "&?", // function returning text to show in the "on select" button for an item
+            iconSelects: '<?',  // boolean whether clicking the icon triggers selection directly or shows popup (false, default) 
+            rowsPerPage: '<?',  // optionally show fixed number of rows; unset (default and normal) computes based on available height
             reservedKeys: '<?',
-            state: '<?',
-            mode: '@?',  // for use by downstream projects to pass in special modes
+            state: '<?', // for shared state usage
+            mode: '@?',  // for use by downstream projects to pass in special modes to do add'l processing / rendering
         },
-        template: template,
         controller: ['$scope', '$element', '$timeout', '$q', '$uibModal', '$log', '$templateCache', 'paletteApi', 'paletteDragAndDropService', 'iconGenerator', 'composerOverrides', 'recentlyUsedService', controller],
         link: link,
     };
-}
 
-function link($scope, $element, attrs, controller) {
-    let main = angular.element($element[0].querySelector(".catalog-palette-main"));
-
-    // repaginate when load completes (and items are shown), or it is resized
-    $scope.$watchGroup(
-        [ () => $scope.isLoading, () => main[0].offsetHeight, () => $scope.state.viewMode.name ],
-        (values) => controller.$timeout( () => repaginate($scope, $element) ) );
-    // also repaginate on window resize    
-    angular.element(window).bind('resize', () => repaginate($scope, $element));
-}
-
-function repaginate($scope, $element) {
-    let rowsPerPage = $scope.rowsPerPage;
-    if (!rowsPerPage) {
+    function link($scope, $element, attrs, controller) {
         let main = angular.element($element[0].querySelector(".catalog-palette-main"));
-        if (!main || main[0].offsetHeight==0) {
-            // no main, or hidden, or items per page fixed
-            return;
+
+        // repaginate when load completes (and items are shown), or it is resized
+        $scope.$watchGroup(
+            [() => $scope.isLoading, () => main[0].offsetHeight, () => $scope.state.viewMode.name],
+            (values) => controller.$timeout(() => repaginate($scope, $element)));
+        // also repaginate on window resize
+        angular.element(window).bind('resize', () => repaginate($scope, $element));
+
+        $scope.templateUrls = {
+            subhead: TEMPLATE_SUBHEAD_URL,
+            footer: TEMPLATE_FOOTER_URL
         }
-        let header = angular.element(main[0].querySelector(".catalog-palette-header"));
-        let footer = angular.element(main[0].querySelector(".catalog-palette-footer"));
-        rowsPerPage = Math.max(MIN_ROWS_PER_PAGE, Math.floor( (main[0].offsetHeight - header[0].offsetHeight - footer[0].offsetHeight - 16) / ($scope.state.viewMode.rowHeightPx || 96)) );
     }
-    $scope.$apply( () => $scope.pagination.itemsPerPage = rowsPerPage * $scope.state.viewMode.itemsPerRow );
+
+    function controller($scope, $element, $timeout, $q, $uibModal, $log, $templateCache, paletteApi, paletteDragAndDropService, iconGenerator, composerOverrides, recentlyUsedService) {
+        this.$timeout = $timeout;
+
+        $scope.viewModes = PALETTE_VIEW_MODES;
+        $scope.viewOrders = PALETTE_VIEW_ORDERS;
+
+        if (!$scope.state) $scope.state = {};
+        if (!$scope.state.viewMode) $scope.state.viewMode = PALETTE_VIEW_MODES.normal;
+
+        $scope.pagination = {
+            page: 1,
+            itemsPerPage: $scope.state.viewMode.itemsPerRow * ($scope.rowsPerPage || 1)  // will fill out after load
+        };
+
+        $scope.getEntityNameForPalette = function(item, entityName) {
+            return (composerOverrides.getEntityNameForPalette ||
+                // above can be overridden with function of signature below to customize display name in palette
+                function(item, entityName, scope) { return entityName; }
+            )(item, entityName, $scope);
+        };
+
+        $scope.getPlaceHolder = function () {
+            return 'Search';
+        };
+
+        $scope.isLoading = true;
+
+        $scope.$watch('search', () => {
+            $scope.freeFormTile = {
+                symbolicName: $scope.search,
+                name: $scope.search,
+                displayName: $scope.search,
+                supertypes: ($scope.family ? [ $scope.family.superType ] : []),
+            };
+        });
+
+        $scope.getItems = function (search) {
+            let defer = $q.resolve([]);
+
+            switch ($scope.family) {
+                case EntityFamily.ENTITY:
+                case EntityFamily.SPEC:
+                    defer = paletteApi.getTypes({params: {supertype: 'entity', fragment: search}});
+                    break;
+                case EntityFamily.POLICY:
+                    defer = paletteApi.getTypes({params: {supertype: 'policy', fragment: search}});
+                    break;
+                case EntityFamily.ENRICHER:
+                    defer = paletteApi.getTypes({params: {supertype: 'enricher', fragment: search}});
+                    break;
+                case EntityFamily.LOCATION:
+                    defer = paletteApi.getLocations();
+                    break;
+            }
+
+            return defer.then(data => {
+                data = $scope.filterPaletteItemsForMode(data, $scope);
+                data.forEach( recentlyUsedService.embellish );
+                return data;
+
+            }).catch(error => {
+                return [];
+            }).finally(() => {
+                $scope.isLoading = false;
+            });
+        };
+        function tryMarkUsed(item) {
+            try {
+                recentlyUsedService.markUsed(item);
+            } catch (e) {
+                // session storage can get full; usually the culprit is icons not this,
+                // but we may wish to clear out old items to ensure we don't bleed here
+                $log.warn("Could not mark item as used: "+item, e);
+            }
+        }
+        $scope.mouseInfoPopover = (item, enter) => {
+            if ($scope.popoverModal && $scope.popoverVisible && $scope.popover==item) {
+                // ignore if modal
+                return;
+            }
+            $scope.popoverModal = false;
+            if (enter) {
+                $scope.popover = item;
+                $scope.popoverVisible = true;
+            } else {
+                $scope.popoverVisible = false;
+            }
+        };
+        $scope.onClickItem = (item, isInfoIcon, $event) => {
+            if (!isInfoIcon && $scope.iconSelects) {
+                $scope.onSelectItem(item);
+            } else if ($scope.popoverModal && $scope.popoverVisible && $scope.popover == item) {
+                $scope.closePopover();
+            } else {
+                $scope.popover = item;
+                $scope.popoverVisible = true;
+                $scope.popoverModal = true;
+            }
+            $event.stopPropagation();
+        };
+        $scope.closePopover = () => {
+            $scope.popoverVisible = false;
+            $scope.popoverModal = false;
+        };
+        $scope.getOnSelectText = function (item) {
+            if (!($scope.onSelectText)) return "Select";
+            return $scope.onSelectText({item: item});
+        };
+        $scope.onSelectItem = function (item) {
+            $scope.closePopover();
+            if (angular.isFunction($scope.onSelect)) {
+                tryMarkUsed(item);
+                $scope.onSelect({item: item});
+            }
+            $scope.search = '';
+        };
+        $scope.onDragItem = function (item, event) {
+            let frame = document.createElement('div');
+            frame.classList.add('drag-frame');
+            event.target.appendChild(frame);
+            setTimeout(function() {
+                // can remove at end of this cycle, browser will have grabbed its drag image
+                frame.parentNode.removeChild(frame);
+            }, 0);
+            /* have tried many other ways to get a nice drag image;
+               this seems to work best, adding an empty div which forces the size to be larger,
+               so when grabbing the image it grabs the drop-shadow.
+               things that _didn't_ work include:
+               - styling event.target now then unstyling (normally this would work, in posts on the web, but it doesn't here; angular?)
+               - make a restyled cloned copy offscreen (this comes so close but remote img srcs aren't loaded
+             */
+
+            paletteDragAndDropService.dragStart(item);
+        };
+        $scope.onDragEnd = function (item, event) {
+            paletteDragAndDropService.dragEnd();
+            tryMarkUsed(item);
+        };
+
+        $scope.getOpenCatalogLink = (item) => {
+            return "/brooklyn-ui-catalog/#!/bundles/"+item.containingBundle.replace(":","/")+"/types/"+item.symbolicName+"/"+item.version;
+        };
+        $scope.sortBy = function (order) {
+            let newFirst = {};
+            if (order) {
+                newFirst[order.id] = order;
+            }
+            $scope.state.currentOrder = Object.assign(newFirst, $scope.state.currentOrder, newFirst);
+            $scope.state.currentOrderFields = [];
+            $scope.state.currentOrderValues = [];
+            Object.values($scope.state.currentOrder).forEach( it => {
+                $scope.state.currentOrderValues.push(it);
+                $scope.state.currentOrderFields.push(it.field);
+            });
+        };
+        if (!$scope.state.currentOrder) $scope.state.currentOrder = Object.assign({}, PALETTE_VIEW_ORDERS);
+        $scope.sortBy();
+
+        $scope.allowFreeForm = function () {
+            return [
+                EntityFamily.LOCATION
+            ].indexOf($scope.family) > -1;
+        };
+        $scope.isReserved = function () {
+            if (!$scope.reservedKeys || !angular.isArray($scope.reservedKeys)) {
+                return false;
+            }
+            return $scope.reservedKeys.indexOf($scope.search) > -1;
+        };
+        $scope.onImageError = (scope, el, attrs) => {
+            $log.warn("Icon for "+attrs.itemId+" at "+angular.element(el).attr("src")+" could not be loaded; generating icon");
+            angular.element(el).attr("src", iconGenerator(attrs.itemId));
+        };
+
+        // Init
+        $scope.items = [];
+        function getDisplayTags(tags) {
+            if (!tags || !tags.length || !tags.reduce) return tags;
+            return tags.reduce((result, tag) => {
+                if (!(/[=:\[\]()]/.exec(tag))) {
+                    result.push(tag);
+                }
+                return result;
+            }, []);
+        }
+        $scope.getItems().then((items)=> {
+            // add displayTags, as any tag that doesn't contain = : or ( ) [ ]
+            // any tag that is an object will be eliminated as it is toStringed to make [ object object ]
+            items.forEach(item => {
+                if (item.tags) {
+                    item.displayTags = getDisplayTags(item.tags);
+                }
+            });
+            $scope.items = items;
+        });
+        $scope.lastUsedText = (item) => {
+            if (item==null) return "";
+            let l = (Number)(item.lastUsed);
+            if (!l || isNaN(l) || l<=0) return "";
+            if (l < 100000) return 'Preselected for inclusion in "Recent" filter.';
+            return 'Last used: ' + distanceInWordsToNow(l, { includeSeconds: true, addSuffix: true });
+        };
+
+        $scope.showPaletteControls = false;
+        $scope.onFiltersShown = () => {
+            $timeout( () => {
+                // check do we need to show the multiline
+                let filters = angular.element($element[0].querySelector(".filters"));
+                $scope.$apply( () => $scope.filterSettings.filtersMultilineAvailable = filters[0].scrollHeight > filters[0].offsetHeight + 6 );
+
+                repaginate($scope, $element);
+            } );
+        };
+        $scope.togglePaletteControls = () => {
+            $scope.showPaletteControls = !$scope.showPaletteControls;
+            $timeout( () => repaginate($scope, $element) );
+        };
+        $scope.toggleShowAllFilters = () => {
+            $scope.filterSettings.showAllFilters = !$scope.filterSettings.showAllFilters;
+            $timeout( () => repaginate($scope, $element) );
+        };
+        $scope.filterSettings = {};
+
+        $scope.filters = [
+            { label: 'Recent', icon: 'clock-o', title: "Recently used and standard favorites", limitToOnePage: true,
+                filterInit: items => {
+                    $scope.recentItems = items.filter( i => i.lastUsed && i.lastUsed>0 );
+                    $scope.recentItems.sort( (a,b) => b.lastUsed - a.lastUsed );
+                    return $scope.recentItems;
+                }, enabled: false },
+        ];
+        $scope.disableFilters = (showFilters) => {
+            $scope.filters.forEach( f => f.enabled = false );
+            if (showFilters !== false) {
+                $scope.showPaletteControls = true;
+            }
+        };
+
+        // can be overridden to disable "open in catalog" button
+        $scope.allowOpenInCatalog = true;
+
+        // this can be overridden for palette sections/modes which show a subset of the types returned by the server;
+        // this is applied when the data is received from the server.
+        // it is used by catalogSelectorFiltersFilter;
+        $scope.filterPaletteItemsForMode = (items) => items;
+
+        // allow downstream to configure this controller and/or scope
+        (composerOverrides.configurePaletteController || function() {})(this, $scope, $element);
+    }
+
+    function repaginate($scope, $element) {
+        let rowsPerPage = $scope.rowsPerPage;
+        if (!rowsPerPage) {
+            let main = angular.element($element[0].querySelector(".catalog-palette-main"));
+            if (!main || main[0].offsetHeight == 0) {
+                // no main, or hidden, or items per page fixed
+                return;
+            }
+            let header = angular.element(main[0].querySelector(".catalog-palette-header"));
+            let footer = angular.element(main[0].querySelector(".catalog-palette-footer"));
+            rowsPerPage = Math.max(MIN_ROWS_PER_PAGE, Math.floor((main[0].offsetHeight - header[0].offsetHeight - footer[0].offsetHeight - 16) / ($scope.state.viewMode.rowHeightPx || 96)));
+        }
+        $scope.$apply(() => $scope.pagination.itemsPerPage = rowsPerPage * $scope.state.viewMode.itemsPerRow);
+    }
 }
 
 export function catalogSelectorSearchFilter() {
     return function (items, search) {
         if (search) {
             return items.filter(function (item) {
-                return search.toLowerCase().split(' ').reduce( (found, part) => 
-                    found &&
-                    FIELDS_TO_SEARCH
-                        .filter(field => item.hasOwnProperty(field) && item[field])
-                        .reduce((match, field) => {
+                item.relevance = 0;
+                let wordNum = 0;
+                return search.toLowerCase().split(' ').reduce( (found, part) => {
+                    wordNum++;
+                    let fieldNum = 0;
+                    return found &&
+                        FIELDS_TO_SEARCH.reduce((match, field) => {
                             if (match) return true;
+                            fieldNum++;
+                            if (!item.hasOwnProperty(field) || !item[field]) return false;
                             let text = item[field];
                             if (!text.toLowerCase) {
                                 text = JSON.stringify(text).toLowerCase();
                             } else {
                                 text = text.toLowerCase();
                             }
-                            return match || text.indexOf(part) > -1;
+                            let index = text.indexOf(part);
+                            if (index == -1) return false;
+                            // found, set relevance -- uses an ad hoc heuristic preferring first fields and short text length,
+                            // earlier occurrences and earlier words weighted more highly (smaller number is better)
+                            let score = fieldNum * (2 / (1 + wordNum)) * Math.log(1 + text.length * index);
+                            /* to debug the scoring function:
+                            if (item.symbolicName.indexOf("EIP") >= 0 || item.symbolicName.indexOf("OpsWorks") >= 0) { 
+                                console.log(item.symbolicName, ": match", part, "in", field,
+                                    "#", fieldNum, wordNum, 
+                                    "pos", index, "/", text.length, 
+                                    ":", item.relevance, "+=", score);
+                            }
+                            */
+                            item.relevance += score;
+                            return true;
                         }, false)
-                , true);
+                }, true);
             });
         } else {
+            items.forEach( item => item.relevance = 0 );
             return items;
         }
     }
@@ -143,206 +435,8 @@
     }
 }
 
-function controller($scope, $element, $timeout, $q, $uibModal, $log, $templateCache, paletteApi, paletteDragAndDropService, iconGenerator, composerOverrides, recentlyUsedService) {
-    this.$timeout = $timeout;
-
-    $scope.viewModes = PALETTE_VIEW_MODES;
-    $scope.viewOrders = PALETTE_VIEW_ORDERS;
-    
-    if (!$scope.state) $scope.state = {};
-    if (!$scope.state.viewMode) $scope.state.viewMode = PALETTE_VIEW_MODES.normal;
-    if (!$scope.state.currentOrder) $scope.state.currentOrder = [ PALETTE_VIEW_ORDERS.name.field, '-version' ];
-    
-    $scope.pagination = {
-        page: 1,
-        itemsPerPage: $scope.state.viewMode.itemsPerRow * ($scope.rowsPerPage || 1)  // will fill out after load
-    };
-    
-    $scope.getEntityNameForPalette = function(item, entityName) {
-        return (composerOverrides.getEntityNameForPalette || 
-            // above can be overridden with function of signature below to customize display name in palette
-            function(item, entityName, scope) { return entityName; }
-        )(item, entityName, $scope);
-    }
-
-    $scope.getPlaceHolder = function () {
-        return 'Search';
-    };
-    
-    $scope.isLoading = true;
-
-    $scope.$watch('search', () => {
-        $scope.freeFormTile = {
-            symbolicName: $scope.search,
-            name: $scope.search,
-            displayName: $scope.search,
-            supertypes: ($scope.family ? [ $scope.family.superType ] : []),
-        };
-    });
-
-    $scope.getItems = function (search) {
-        let defer = $q.resolve([]);
-
-        switch ($scope.family) {
-            case EntityFamily.ENTITY:
-            case EntityFamily.SPEC:
-                defer = paletteApi.getTypes({params: {supertype: 'entity', fragment: search}});
-                break;
-            case EntityFamily.POLICY:
-                defer = paletteApi.getTypes({params: {supertype: 'policy', fragment: search}});
-                break;
-            case EntityFamily.ENRICHER:
-                defer = paletteApi.getTypes({params: {supertype: 'enricher', fragment: search}});
-                break;
-            case EntityFamily.LOCATION:
-                defer = paletteApi.getLocations();
-                break;
-        }
-
-        return defer.then(data => {
-            data = $scope.filterPaletteItemsForMode(data, $scope);
-            data.forEach( recentlyUsedService.embellish );
-            return data;
-            
-        }).catch(error => {
-            return [];
-        }).finally(() => {
-            $scope.isLoading = false;
-        });
-    };
-    function tryMarkUsed(item) {
-        try {
-            recentlyUsedService.markUsed(item);
-        } catch (e) {
-            // session storage can get full; usually the culprit is icons not this,
-            // but we may wish to clear out old items to ensure we don't bleed here
-            $log.warn("Could not mark item as used: "+item, e);
-        }
-    }
-    $scope.onSelectItem = function (item) {
-        if (angular.isFunction($scope.onSelect)) {
-            tryMarkUsed(item);
-            $scope.onSelect({item: item});
-        }
-        $scope.search = '';
-    };
-    $scope.onDragItem = function (item, event) {
-        let frame = document.createElement('div');
-        frame.classList.add('drag-frame');
-        event.target.appendChild(frame);
-        setTimeout(function() {
-            // can remove at end of this cycle, browser will have grabbed its drag image
-            frame.parentNode.removeChild(frame);
-        }, 0);
-        /* have tried many other ways to get a nice drag image;
-           this seems to work best, adding an empty div which forces the size to be larger,
-           so when grabbing the image it grabs the drop-shadow.
-           things that _didn't_ work include:
-           - styling event.target now then unstyling (normally this would work, in posts on the web, but it doesn't here; angular?)
-           - make a restyled cloned copy offscreen (this comes so close but remote img srcs aren't loaded
-         */
-        
-        paletteDragAndDropService.dragStart(item);
-    };
-    $scope.onDragEnd = function (item, event) {
-        paletteDragAndDropService.dragEnd();
-        tryMarkUsed(item);
-    };
-    $scope.sortBy = function (order) {
-        let newOrder = [].concat($scope.state.currentOrder);
-        newOrder = newOrder.filter( (o) => o !== order.field );
-        $scope.state.currentOrder = [order.field].concat(newOrder);
-    };
-    $scope.allowFreeForm = function () {
-        return [
-            EntityFamily.LOCATION
-        ].indexOf($scope.family) > -1;
-    };
-    $scope.isReserved = function () {
-        if (!$scope.reservedKeys || !angular.isArray($scope.reservedKeys)) {
-            return false;
-        }
-        return $scope.reservedKeys.indexOf($scope.search) > -1;
-    };
-    $scope.onImageError = (scope, el, attrs) => {
-        $log.warn("Icon for "+attrs.itemId+" at "+angular.element(el).attr("src")+" could not be loaded; generating icon");
-        angular.element(el).attr("src", iconGenerator(attrs.itemId));
-    };
-
-    // Init
-    $scope.items = [];
-    function getDisplayTags(tags) {
-        if (!tags || !tags.length || !tags.reduce) return tags;
-        return tags.reduce((result, tag) => { 
-            if (!(/[=:\[\]()]/.exec(tag))) {
-                result.push(tag);
-            }
-            return result; 
-        }, []);
-    }
-    $scope.getItems().then((items)=> {
-        // add displayTags, as any tag that doesn't contain = : or ( ) [ ]
-        // any tag that is an object will be eliminated as it is toStringed to make [ object object ]
-        items.forEach(item => { 
-            if (item.tags) {
-                item.displayTags = getDisplayTags(item.tags); 
-            } 
-        });
-        $scope.items = items;
-    });
-    $scope.lastUsedText = (item) => {
-        let l = (Number)(item.lastUsed);
-        if (!l || isNaN(l) || l<=0) return "";
-        if (l < 100000) return 'Preselected for inclusion in "Recent" filter.';
-        return 'Last used: ' + distanceInWordsToNow(l, { includeSeconds: true, addSuffix: true });
-    }; 
-    $scope.showPaletteControls = false;
-    $scope.onFiltersShown = () => {
-      $timeout( () => {
-        // check do we need to show the multiline
-        let filters = angular.element($element[0].querySelector(".filters"));
-        $scope.$apply( () => $scope.filterSettings.filtersMultilineAvailable = filters[0].scrollHeight > filters[0].offsetHeight + 6 );
-        
-        repaginate($scope, $element);
-      } );
-    };
-    $scope.togglePaletteControls = () => {
-        $scope.showPaletteControls = !$scope.showPaletteControls;
-        $timeout( () => repaginate($scope, $element) );
-    }
-    $scope.toggleShowAllFilters = () => {
-        $scope.filterSettings.showAllFilters = !$scope.filterSettings.showAllFilters;
-        $timeout( () => repaginate($scope, $element) );
-    };
-    $scope.filterSettings = {};
-
-    $scope.filters = [
-        { label: 'Recent', icon: 'clock-o', title: "Recently used and standard favorites", limitToOnePage: true,
-            filterInit: items => {
-                $scope.recentItems = items.filter( i => i.lastUsed && i.lastUsed>0 );
-                $scope.recentItems.sort( (a,b) => b.lastUsed - a.lastUsed );
-                return $scope.recentItems; 
-            }, enabled: false },
-    ];
-    $scope.disableFilters = (showFilters) => {
-        $scope.filters.forEach( f => f.enabled = false );
-        if (showFilters !== false) {
-            $scope.showPaletteControls = true;
-        }
-    }
-    
-    // this can be overridden for palette sections/modes which show a subset of the types returned by the server;
-    // this is applied when the data is received from the server.
-    // it is used by catalogSelectorFiltersFilter; 
-    $scope.filterPaletteItemsForMode = (items) => items;
-
-    // downstream can override this to insert lines below the header
-    $scope.customSubHeadTemplateName = 'composer-palette-empty-sub-head';
-    $templateCache.put($scope.customSubHeadTemplateName, '');
-    
-    $scope.customFooterTemplateName = 'composer-palette-default-footer';
-    $templateCache.put($scope.customFooterTemplateName, footerTemplate);
-
-    // allow downstream to configure this controller and/or scope
-    (composerOverrides.configurePaletteController || function() {})(this, $scope, $element);
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, template);
+    $templateCache.put(TEMPLATE_SUBHEAD_URL, '');
+    $templateCache.put(TEMPLATE_FOOTER_URL, footerTemplate);
 }
diff --git a/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.less b/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.less
index 95e550e..56f2318 100644
--- a/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.less
+++ b/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.less
@@ -280,6 +280,9 @@
       .label {
         line-height: 2.2;
       }
+      .closer .br-icon-close-bar {
+        stroke: @brand-primary;
+      }
     }
     .deprecated-marker {
       float: right;
@@ -296,6 +299,44 @@
     .palette-item-tag {
         margin-right: 4px;
     }
+    .quick-info-title {
+        background-color: @gray-lightest;
+        border-bottom: 1px solid @popover-border-color;
+        margin-left: -15px;
+        margin-right: -15px;
+        margin-top: -10px;
+        margin-bottom: 12px;
+        padding: 8px 16px 6px 16px;
+        border-radius: 5px 5px 0 0;
+        .closer {
+            margin-top: 6px;
+        }
+    }
+    .closer {
+        width: 10px;
+        > svg { width: 10px; } 
+        cursor: pointer;
+        margin-left: 6px;
+    }
+    .quick-info-buttons {
+        border-top: 1px solid @popover-border-color;
+        margin-top: 10px;
+        padding-top: 10px;
+        display: flex;
+        div.spacer {
+            flex: 1 1 auto;
+        }
+        button {
+            padding: 6px 9px;
+            line-height: 1;
+            margin-left: 12px;
+        }
+        .select-item-button {
+            flex: 0 1 auto;
+            text-overflow: ellipsis;
+            overflow: hidden;
+        }
+    }
   }
 
   p:last-child {
diff --git a/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.template.html b/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.template.html
index 870600c..79ea8e7 100644
--- a/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.template.html
+++ b/ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.template.html
@@ -74,7 +74,7 @@
                     <i class="fa fa-sort"></i></div>
                 </a>
                 <ul class="dropdown-menu right-align-icon" role="menu" uib-dropdown-menu aria-labelledby="palette-sort">
-                        <li role="menuitem" ng-repeat="order in viewOrders track by $index" ng-class="{'active': state.currentOrder[0] === order.field}" class="layer">
+                        <li role="menuitem" ng-repeat="order in state.currentOrderValues track by $index" class="layer">
                             <a ng-click="sortBy(order)"><i class="fa fa-fw fa-circle"></i> {{ order.label }}</a>
                         </li>
                 </ul>
@@ -92,7 +92,7 @@
                 </ul>
               </span>
             </div>
-            <ng-include src="customSubHeadTemplateName"/>
+            <ng-include src="templateUrls.subhead"/>
         </div>
       </div>
 
@@ -100,8 +100,8 @@
             <!-- here and below, col-xs-3 or -4 or -2 all work giving different densities;
                  this could be configurable ("compressed"=xs-2 w no labels, "normal"=xs-3, "big"=xs-4) -->
             <div class="catalog-palette-item" ng-class="state.viewMode.classes"
-                    ng-repeat="item in searchedItems = (items | catalogSelectorSearch:search | catalogSelectorFilters:this) | orderBy:state.currentOrder | limitTo:pagination.itemsPerPage:(pagination.page-1)*pagination.itemsPerPage track by (item.containingBundle + ':' + item.symbolicName + ':' + item.version)"
-                    ng-click="onSelectItem(item)">
+                    ng-repeat="item in searchedItems = (items | catalogSelectorSearch:search | catalogSelectorFilters:this) | orderBy:state.currentOrderFields | limitTo:pagination.itemsPerPage:(pagination.page-1)*pagination.itemsPerPage track by (item.containingBundle + ':' + item.symbolicName + ':' + item.version)"
+                    ng-click="onClickItem(item, false, $event)">
                 <div class="item" draggable="true" ng-dragstart="onDragItem(item, $event)" ng-dragend="onDragEnd(item, $event)">
                     <div class="item-logo">
                         <img ng-src="{{item | iconGeneratorPipe:'symbolicName'}}" alt="{{item.displayName}} logo" on-error="onImageError" item-id="{{item.symbolicName}}"/>
@@ -110,17 +110,19 @@
                         <h3>{{ getEntityNameForPalette(item, item | entityName) }}</h3>
                     </div>
                     <i class="fa fa-info-circle"
-                        uib-popover-template="'QuickInfoTemplate.html'"
-                        popover-title="{{item | entityName}}"
-                        popover-placement="right-top" popover-trigger="'mouseenter'"
+                        uib-popover-template="'blueprint-composer/component/catalog-selector/quick-info.html'"
+                        ng-click="onClickItem(item, true, $event)"
+                        popover-is-open="popover == item && popoverVisible"
+                        popover-placement="right" popover-trigger="'none'"
                         popover-class="catalog-selector-popover" popover-append-to-body="true"
-                        ng-click="$event.stopPropagation()"></i>
+                        ng-mouseenter="mouseInfoPopover(item, true)"
+                        ng-mouseleave="mouseInfoPopover(item, false)"></i>
                 </div>
             </div>
 
             <div class="catalog-palette-item"
                     ng-class="state.viewMode.classes" 
-                    ng-if="searchedItems.length === 0 && search && allowFreeForm()" ng-click="onSelectItem(freeFormTile)">
+                    ng-if="searchedItems.length === 0 && search && allowFreeForm()" ng-click="onClickItem(freeFormTile, $event)">
                 <div class="item" draggable="true" ng-dragstart="onDragItem(freeFormTile, $event)" ng-dragend="onDragEnd(freeFormTile, $event)">
                     <div class="item-logo">
                         <img ng-src="{{freeFormTile | iconGeneratorPipe:'symbolicName'}}" alt="{{freeFormTile.displayName}} logo" on-error="onImageError" item-id="{{freeFormTile.symbolicName}}"/>
@@ -128,6 +130,13 @@
                     <div class="item-content" ng-hide="state.viewMode.hideName">
                         <h3>{{freeFormTile | entityName}}</h3>
                     </div>
+                    <i class="fa fa-info-circle"
+                        uib-popover-template="'blueprint-composer/component/catalog-selector/quick-info.html'"
+                        popover-is-open="popover == freeFormTile && popoverVisible"
+                        popover-placement="right-top" popover-trigger="'none'"
+                        popover-class="catalog-selector-popover" popover-append-to-body="true"
+                        ng-mouseenter="mouseInfoPopover(freeFormTile, true)"
+                        ng-mouseleave="mouseInfoPopover(freeFormTile, false)"></i>
                 </div>
                 <div class="text-danger" ng-if="isReserved()">
                     Cannot add <code>{{freeFormTile.symbolicName}}</code> because it is reserved.
@@ -137,26 +146,38 @@
 
         <div class="catalog-palette-footer">
             <div uib-pagination total-items="searchedItems.length" items-per-page="pagination.itemsPerPage" ng-model="pagination.page" boundary-link-numbers="true" rotate="false" max-size="4" ng-show="searchedItems.length > pagination.itemsPerPage" class="pagination-sm pull-right"></div>
-            <ng-include src="customFooterTemplateName"/>
+            <ng-include src="templateUrls.footer"/>
         </div>
     </div>
 </div>
 
 <!-- QUICK INFO TEMPLATE :: START-->
-<script type="text/ng-template" id="QuickInfoTemplate.html">
+<script type="text/ng-template" id="blueprint-composer/component/catalog-selector/quick-info.html">
     <div class="palette-item-quick-info">
-        <div class="deprecated-marker" ng-if="item.deprecated">DEPRECATED</div>
-        <div class="quick-info-metadata">
-            <p><i class="mini-icon fa fa-fw fa-bookmark"></i> <samp class="type-symbolic-name">{{item.symbolicName}}</samp></p>
-            <p ng-if="item.version"><i class="mini-icon fa fa-fw fa-code-fork"></i> {{item.version}}</p>
+        <div class="quick-info-title">{{ popover | entityName }}
+            <br-svg type="close" class="pull-right closer" ng-click="closePopover()"></br-svg>
         </div>
-        <p class="quick-info-description" ng-if="item.description">{{item.description}}</p>
+        <div class="deprecated-marker" ng-if="popover.deprecated">DEPRECATED</div>
+        <div class="quick-info-metadata">
+            <p><i class="mini-icon fa fa-fw fa-bookmark"></i> <samp class="type-symbolic-name">{{popover.symbolicName}}</samp></p>
+            <p ng-if="popover.version"><i class="mini-icon fa fa-fw fa-code-fork"></i> {{popover.version}}</p>
+        </div>
+        <p class="quick-info-description" ng-if="popover.description">{{popover.description}}</p>
+        <p class="quick-info-description" ng-if="popover == freeFormTile">This is an ad hoc tile for an item entered by the user not known in the catalog.</p>
         <div class="quick-info-metadata bundle">
-            <p ng-if="lastUsedText(item)"><i class="mini-icon fa fa-clock-o"></i> {{ lastUsedText(item) }}</p>
-            <p ng-if="item.displayTags && item.displayTags.length"><i class="mini-icon fa fa-fw fa-tags"></i> 
-                <span ng-repeat-start="tag in item.displayTags" class="label label-primary palette-item-tag">{{ tag }}</span>
+            <p ng-if="lastUsedText(popover)"><i class="mini-icon fa fa-clock-o"></i> {{ lastUsedText(popover) }}
+              <br-svg type="close" class="closer" ng-click="popover.lastUsed = 0"></br-svg>
+            </p>
+            <p ng-if="popover.displayTags && popover.displayTags.length"><i class="mini-icon fa fa-fw fa-tags"></i> 
+                <span ng-repeat-start="tag in popover.displayTags" class="label label-primary palette-item-tag">{{ tag }}</span>
                 <span ng-repeat-end> </span> </p>
-            <p><i class="mini-icon fa fa-fw fa-file-zip-o"></i> {{item.containingBundle}}</p>
+            <p ng-if="popover.containingBundle"><i class="mini-icon fa fa-fw fa-file-zip-o"></i> {{popover.containingBundle}}</p>
+            <p ng-if="popover.relevance"><i class="mini-icon fa fa-sort-numeric-asc"></i> Relevance score: {{ popover.relevance | number:2 }}</p>
+        </div>
+        <div class="quick-info-buttons">
+            <div class="spacer"></div>
+            <button class="btn btn-primary btn-outline select-item-button" ng-click="onSelectItem(popover, false, $event)">{{ getOnSelectText(popover) }}</button>
+            <a ng-if="popover.containingBundle && allowOpenInCatalog" href="{{ getOpenCatalogLink(popover) }}" target="_blank"><button class="btn btn-info btn-outline">Open in catalog</button></a>
         </div>
     </div>
 </script>
diff --git a/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.html b/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.html
index ad59161..93d35ac 100644
--- a/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.html
+++ b/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.html
@@ -22,7 +22,7 @@
         <span class="info-spec-configuration">
             <i class="fa fa-fw fa-info-circle" popover-trigger="'mouseenter'"
                 popover-title="{{item.label || item.name}}"
-                uib-popover-template="'ConfigInfoTemplate.html'"
+                uib-popover-template="'blueprint-composer/component/spec-editor/config-info.html'"
                 popover-class="spec-editor-popover" popover-placement="top-left" popover-append-to-body="true"></i>
             </span>
     </label>
diff --git a/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.js b/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.js
index c32bc72..d58ec88 100644
--- a/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.js
+++ b/ui-modules/blueprint-composer/app/components/custom-config-widget/suggestion-dropdown.js
@@ -21,9 +21,11 @@
 import template from './suggestion-dropdown.html';
 
 const MODULE_NAME = 'brooklyn.components.custom-config-widget.suggestion-dropdown';
+const TEMPLATE_URL = 'blueprint-composer/component/suggestion-dropdown/index.html';
 
 angular.module(MODULE_NAME, [])
-    .directive('suggestionDropdown', ['$rootScope', suggestionDropdownDirective]);
+    .directive('suggestionDropdown', ['$rootScope', suggestionDropdownDirective])
+    .run(['$templateCache', templateCache]);
 
 export default MODULE_NAME;
 
@@ -31,13 +33,15 @@
     return {
         require: "^^specEditor",  // only intended for use in spec editor, and calls functions on that controller
         restrict: 'E',
+        templateUrl: function (tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
         scope: {
             item: '=',
             params: '=',
             config: '=',
             model: '=',
         },
-        template: template,
         link: link,
     };
 
@@ -58,5 +62,8 @@
             }
         };
     }
-    
+}
+
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, template);
 }
\ No newline at end of file
diff --git a/ui-modules/blueprint-composer/app/components/designer/designer.directive.js b/ui-modules/blueprint-composer/app/components/designer/designer.directive.js
index 2f91745..cbcb0ff 100644
--- a/ui-modules/blueprint-composer/app/components/designer/designer.directive.js
+++ b/ui-modules/blueprint-composer/app/components/designer/designer.directive.js
@@ -16,6 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
 import {Entity} from "../util/model/entity.model";
 import {D3Blueprint} from "../util/d3-blueprint";
 import {EntityFamily} from '../util/model/entity.model';
@@ -23,16 +24,29 @@
 import {graphicalEditSpecState} from '../../views/main/graphical/edit/spec/edit.spec.controller';
 import {graphicalEditPolicyState} from '../../views/main/graphical/edit/policy/edit.policy.controller';
 import {graphicalEditEnricherState} from '../../views/main/graphical/edit/enricher/edit.enricher.controller';
+
+const MODULE_NAME = 'brooklyn.components.designer';
+const TEMPLATE_URL = 'blueprint-composer/component/designer/index.html';
 const ANY_MEMBERSPEC_REGEX = /(^.*[m,M]ember[s,S]pec$)/;
 const TAG = 'DIRECTIVE :: DESIGNER :: ';
 
+angular.module(MODULE_NAME, [])
+    .directive('designer', ['$log', '$state', '$q', 'iconGenerator', 'catalogApi', 'blueprintService', 'brSnackbar', 'paletteDragAndDropService', designerDirective])
+    .run(['$templateCache', templateCache]);
+
+export default MODULE_NAME;
+
 export function designerDirective($log, $state, $q, iconGenerator, catalogApi, blueprintService, brSnackbar, paletteDragAndDropService) {
-    let directive = {
+    return {
         restrict: 'E',
-        template: '',
+        templateUrl: function (tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
+        scope: {
+            onSelectionChange: '<?'
+        },
         link: link
     };
-    return directive;
 
     function link($scope, $element) {
         let blueprintGraph = new D3Blueprint($element[0]).center();
@@ -109,13 +123,16 @@
                     break;
             }
             if (angular.isDefined(id)) {
+                $log.debug(TAG + 'Select canvas, selected node: ' + id);
                 $scope.selectedEntity = blueprintService.findAny(id);
+                if ($scope.onSelectionChange) $scope.onSelectionChange($scope.selectedEntity);
             }
         });
 
         $element.bind('click-svg', (event)=> {
             $log.debug(TAG + 'Select canvas, un-select node (if one selected before)');
             $scope.selectedEntity = null;
+            if ($scope.onSelectionChange) $scope.onSelectionChange($scope.selectedEntity);
             $scope.$apply(()=> {
                 redrawGraph();
                 $state.go('main.graphical');
@@ -229,3 +246,7 @@
         }
     }
 }
+
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, '');
+}
\ No newline at end of file
diff --git a/ui-modules/blueprint-composer/app/components/dsl-editor/dsl-editor.js b/ui-modules/blueprint-composer/app/components/dsl-editor/dsl-editor.js
index 707560b..6fb1d26 100644
--- a/ui-modules/blueprint-composer/app/components/dsl-editor/dsl-editor.js
+++ b/ui-modules/blueprint-composer/app/components/dsl-editor/dsl-editor.js
@@ -24,6 +24,7 @@
 import brUtils from 'brooklyn-ui-utils/utils/general';
 
 const MODULE_NAME = 'brooklyn.components.dsl-editor';
+const TEMPLATE_URL = 'blueprint-composer/component/dsl-editor/index.html';
 const DSL_KINDS = {
     ALL: {
         id: 'all',
@@ -48,14 +49,17 @@
 };
 
 angular.module(MODULE_NAME, [angularSanitize, brAutoFocus, brUtils])
-    .directive('dslEditor', ['$rootScope', '$filter', '$log', 'brUtilsGeneral', 'blueprintService', dslEditorDirective]);
+    .directive('dslEditor', ['$rootScope', '$filter', '$log', 'brUtilsGeneral', 'blueprintService', dslEditorDirective])
+    .run(['$templateCache', templateCache]);
 
 export default MODULE_NAME;
 
 export function dslEditorDirective($rootScope, $filter, $log, brUtilsGeneral, blueprintService) {
     return {
         restrict: 'E',
-        template: template,
+        templateUrl: function (tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
         scope: {
             definition: '=',
             entity: '=',
@@ -361,3 +365,7 @@
         return dsl && dsl.kind === KIND.TARGET && dsl.name === 'self';
     }
 }
+
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, template);
+}
diff --git a/ui-modules/blueprint-composer/app/components/dsl-viewer/dsl-viewer.js b/ui-modules/blueprint-composer/app/components/dsl-viewer/dsl-viewer.js
index abdd233..e751638 100644
--- a/ui-modules/blueprint-composer/app/components/dsl-viewer/dsl-viewer.js
+++ b/ui-modules/blueprint-composer/app/components/dsl-viewer/dsl-viewer.js
@@ -21,19 +21,23 @@
 import {KIND} from '../util/model/dsl.model';
 
 const MODULE_NAME = 'brooklyn.components.dsl-viewer';
+const TEMPLATE_URL = 'blueprint-composer/component/dsl-viewer/index.html';
 
 angular.module(MODULE_NAME, [])
-    .directive('dslViewer', dslViewerDirective);
+    .directive('dslViewer', dslViewerDirective)
+    .run(['$templateCache', templateCache]);
 
 export default MODULE_NAME;
 
 export function dslViewerDirective() {
     return {
         restrict: 'E',
+        templateUrl: function (tElement, tAttrs) {
+            return tAttrs.templateUrl || TEMPLATE_URL;
+        },
         scope: {
             dsl: '<'
         },
-        template: template,
         link: link
     };
 
@@ -64,3 +68,7 @@
         }
     }
 }
+
+function templateCache($templateCache) {
+    $templateCache.put(TEMPLATE_URL, template);
+}
diff --git a/ui-modules/blueprint-composer/app/components/factories/object-cache.factory.js b/ui-modules/blueprint-composer/app/components/factories/object-cache.factory.js
index cf714b4..a37f3a6 100644
--- a/ui-modules/blueprint-composer/app/components/factories/object-cache.factory.js
+++ b/ui-modules/blueprint-composer/app/components/factories/object-cache.factory.js
@@ -16,6 +16,15 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
+
+const MODULE_NAME = 'brooklyn.factory.object-cache';
+
+angular.module(MODULE_NAME, [])
+    .factory('objectCache', ['$cacheFactory', objectCacheFactory]);
+
+export default MODULE_NAME;
+
 export function objectCacheFactory($cacheFactory) {
     return $cacheFactory('blueprint-composer');
 }
\ No newline at end of file
diff --git a/ui-modules/blueprint-composer/app/components/factories/recursion-helper.factory.js b/ui-modules/blueprint-composer/app/components/factories/recursion-helper.factory.js
deleted file mode 100644
index cf0abae..0000000
--- a/ui-modules/blueprint-composer/app/components/factories/recursion-helper.factory.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you 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.
- */
-export function recursionHelperFactory($compile) {
-    return {
-        /**
-         * Manually compiles the element, fixing the recursion loop.
-         *
-         * @param {object} element
-         * @param {function} link a post link function,
-         *                   or an object with function(s) registered via pre and post properties.
-         *
-         */
-        compile: function (element, link) {
-            // Normalize the link parameter
-            if (angular.isFunction(link)) {
-                link = {post: link};
-            }
-
-            // Break the recursion loop by removing the contents
-            var contents = element.contents().remove();
-            var compiledContents;
-            return {
-                pre: (link && link.pre) ? link.pre : null,
-                /**
-                 * Compiles and re-adds the contents
-                 */
-                post: function (scope, element) {
-                    // Compile the contents
-                    if (!compiledContents) {
-                        compiledContents = $compile(contents);
-                    }
-                    // Re-add the compiled contents to the element
-                    compiledContents(scope, function (clone) {
-                        element.append(clone);
-                    });
-
-                    // Call the post-linking function, if any
-                    if (link && link.post) {
-                        link.post.apply(null, arguments);
-                    }
-                }
-            };
-        }
-    };
-}
\ No newline at end of file
diff --git a/ui-modules/blueprint-composer/app/components/filters/entity.filter.js b/ui-modules/blueprint-composer/app/components/filters/entity.filter.js
index 2fa5c47..34a1930 100644
--- a/ui-modules/blueprint-composer/app/components/filters/entity.filter.js
+++ b/ui-modules/blueprint-composer/app/components/filters/entity.filter.js
@@ -16,11 +16,24 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-const DEFAULT = '';
+import angular from 'angular';
+
+const MODULE_NAME = 'brooklyn.filters.entity';
+
+angular.module(MODULE_NAME, [])
+    .filter('entityName', entityNameFilter)
+    .filter('entityVersion', entityVersionFilter)
+    .filter('entityTypes', entityTypesFilter);
+
+export default MODULE_NAME;
 
 export function entityNameFilter() {
     return function (input) {
-        var result = input ? (input.displayName || input.name || input.symbolicName || input.type || DEFAULT) : DEFAULT;
+        var result = input ? (input.displayName || input.name || input.symbolicName || input.type || null) : null;
+        if (!result) {
+            if (input && !input.parent) result = 'Application';
+            else result = 'Unnamed entity';
+        }
         if (result.match(/^[^\w]*deprecated[^\w]*/i)) {
             result = result.replace(/^[^\w]*deprecated[^\w]*/i, '');
         }
diff --git a/ui-modules/blueprint-composer/app/components/filters/locations.filter.js b/ui-modules/blueprint-composer/app/components/filters/locations.filter.js
index c096944..7a1378c 100644
--- a/ui-modules/blueprint-composer/app/components/filters/locations.filter.js
+++ b/ui-modules/blueprint-composer/app/components/filters/locations.filter.js
@@ -16,6 +16,15 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
+
+const MODULE_NAME = 'brooklyn.filters.location';
+
+angular.module(MODULE_NAME, [])
+    .filter('locations', locationsFilter);
+
+export default MODULE_NAME;
+
 export function locationsFilter() {
     return function (input, search) {
         return input.then(function (response) {
diff --git a/ui-modules/blueprint-composer/app/components/providers/action-service.provider.js b/ui-modules/blueprint-composer/app/components/providers/action-service.provider.js
index 9f00374..a699d67 100644
--- a/ui-modules/blueprint-composer/app/components/providers/action-service.provider.js
+++ b/ui-modules/blueprint-composer/app/components/providers/action-service.provider.js
@@ -16,6 +16,15 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
+
+const MODULE_NAME = 'brooklyn.composer.service.action-service';
+
+angular.module(MODULE_NAME, [])
+    .provider('actionService', actionServiceProvider);
+
+export default MODULE_NAME;
+
 export function actionServiceProvider() {
     let actions = {};
     return {
@@ -41,7 +50,7 @@
     return {
         addAction: addAction,
         getActions: getActions
-    }
+    };
 
     function addAction(id, action) {
         if (!action || !action.hasOwnProperty('html')) {
diff --git a/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js b/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js
index 69ba3f6..0140faf 100644
--- a/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js
+++ b/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js
@@ -16,13 +16,21 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
 import {Entity, EntityFamily} from "../util/model/entity.model";
 import {Issue, ISSUE_LEVEL} from '../util/model/issue.model';
 import {Dsl} from "../util/model/dsl.model";
 import jsYaml from "js-yaml";
 import typeNotFoundIcon from "../../img/icon-not-found.svg";
 
+const MODULE_NAME = 'brooklyn.composer.service.blueprint-service';
 const TAG = 'SERVICE :: BLUEPRINT :: ';
+
+angular.module(MODULE_NAME, [])
+    .provider('blueprintService', blueprintServiceProvider);
+
+export default MODULE_NAME;
+
 export const RESERVED_KEYS = ['name', 'location', 'locations', 'type', 'services', 'brooklyn.config', 'brooklyn.children', 'brooklyn.enrichers', 'brooklyn.policies'];
 export const DSL_ENTITY_SPEC = '$brooklyn:entitySpec';
 
diff --git a/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js b/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js
index 3d5f665..ae89b37 100644
--- a/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js
+++ b/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js
@@ -16,11 +16,18 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
 import {Dsl, DslParser, KIND} from "../util/model/dsl.model";
 import {Entity} from "../util/model/entity.model";
 
+const MODULE_NAME = 'brooklyn.composer.service.dsl-service';
 const TAG = 'SERVICE :: DSL :: ';
 
+angular.module(MODULE_NAME, [])
+    .provider('dslService', dslServiceProvider);
+
+export default MODULE_NAME;
+
 export function dslServiceProvider() {
     return {
         $get: ['$log', function ($log) {
diff --git a/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js b/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js
index dee0836..1105880 100644
--- a/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js
+++ b/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js
@@ -16,10 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
 import {Entity} from '../util/model/entity.model';
 
+const MODULE_NAME = 'brooklyn.composer.service.palette-dragndrop-service';
 const TAG = 'SERVICE :: DRAGNDROP :: ';
 
+angular.module(MODULE_NAME, [])
+    .provider('paletteDragAndDropService', paletteDragAndDropServiceProvider);
+
+export default MODULE_NAME;
+
 export function paletteDragAndDropServiceProvider() {
     return {
         $get: ['$log', function ($log) {
diff --git a/ui-modules/blueprint-composer/app/components/providers/recently-used-service.provider.js b/ui-modules/blueprint-composer/app/components/providers/recently-used-service.provider.js
index cc48ab5..994932d 100644
--- a/ui-modules/blueprint-composer/app/components/providers/recently-used-service.provider.js
+++ b/ui-modules/blueprint-composer/app/components/providers/recently-used-service.provider.js
@@ -16,6 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import angular from 'angular';
+
+const MODULE_NAME = 'brooklyn.composer.service.recently-user';
+
+angular.module(MODULE_NAME, [])
+    .provider('recentlyUsedService', recentlyUsedServiceProvider);
+
+export default MODULE_NAME;
 
 export function recentlyUsedServiceProvider() {
     return {
diff --git a/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js b/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js
index a5341b9..9c18129 100644
--- a/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js
+++ b/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js
@@ -78,13 +78,15 @@
         scope: {
             model: '='
         },
-        controller: controller,
+        controller: ['$scope', '$element', controller],
         template: template,
         link: link,
         controllerAs: 'specEditor',
     };
 
-    function controller() {
+    function controller($scope, $element) {
+        (composerOverrides.configureSpecEditorController || function() {})(this, $scope, $element);
+        
         // does very little currently, but link adds to this
         return this;
     }
@@ -318,7 +320,7 @@
                 scope.state.config.filter.values.all = true;
             }
         };
-        scope.recordFocus = specEditor.recordFocus = ($item)=> {
+        scope.recordFocus = specEditor.recordFocus = ($item) => {
             scope.state.config.focus = $item.name;
         };
 
diff --git a/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.template.html b/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.template.html
index 2b0a4c4..bfd51a9 100644
--- a/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.template.html
+++ b/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.template.html
@@ -86,7 +86,7 @@
              placeholder="Add a new configuration key or open existing"
              class="form-control"
              uib-typeahead="config.name for config in state.config.add.list | filter:{name:$viewValue}"
-             typeahead-template-url="ConfigItemTemplate.html"
+             typeahead-template-url="blueprint-composer/component/spec-editor/config-item.html"
              typeahead-editable="true"
              typeahead-show-hint="true"
              typeahead-min-length="0"
@@ -146,7 +146,7 @@
                         <span class="info-spec-configuration">
                            <i class="fa fa-fw fa-info-circle" popover-trigger="'mouseenter'"
                                popover-title="{{item.label || item.name}}"
-                               uib-popover-template="'ConfigInfoTemplate.html'"
+                               uib-popover-template="'blueprint-composer/component/spec-editor/config-info.html'"
                                popover-class="spec-editor-popover" popover-placement="top-left" popover-append-to-body="true"></i>
                         </span>
                     </label>
@@ -309,7 +309,7 @@
                                             class="open-entity-spec"
                                             title="Open in spec editor"
                                             ng-focus="specEditor.recordFocus(item)"></a>
-                                <ng-include src="'AdjunctTemplate.html'"></ng-include>
+                                <ng-include src="'blueprint-composer/component/spec-editor/adjunct.html'"></ng-include>
                             </div>
                             <a ng-if="!config[item.name][REPLACED_DSL_ENTITYSPEC]" ui-sref="main.graphical.edit.add({entityId: model._id, family: 'spec', configKey: item.name})" class="no-spec">
                                 (no spec set)
@@ -341,7 +341,9 @@
 </br-collapsible>
 
 <!-- ENTITY LOCATION -->
-<br-collapsible ng-if="[FAMILIES.ENTITY, FAMILIES.SPEC].indexOf(model.family) > -1" state="state.location.open">
+<ng-include src="'blueprint-composer/component/spec-editor/section-locations.html'"></ng-include>
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/section-locations.html">
+  <br-collapsible ng-if="[FAMILIES.ENTITY, FAMILIES.SPEC].indexOf(model.family) > -1" state="state.location.open">
     <heading>
         Location
         <span ng-if="(model.issues | filter:{group:'location'}).length> 0" class="badge" ng-class="getBadgeClass((model.issues | filter:{group:'location'}))">{{(model.issues | filter:{group:'location'}).length}}</span>
@@ -365,16 +367,19 @@
             <button class="btn btn-danger btn-link" ng-click="model.clearIssues({group: 'location'}).removeLocation()">Remove</button>
         </div>
     </div>
-</br-collapsible>
+  </br-collapsible>
+</script>
 
 <!-- ENTITY POLICIES -->
-<br-collapsible ng-if="[FAMILIES.ENTITY, FAMILIES.SPEC].indexOf(model.family) > -1" state="state.policy.open">
+<ng-include src="'blueprint-composer/component/spec-editor/section-policies.html'"></ng-include>
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/section-policies.html">
+  <br-collapsible ng-if="[FAMILIES.ENTITY, FAMILIES.SPEC].indexOf(model.family) > -1" state="state.policy.open">
     <heading>
         Policies
         <span ng-if="getPoliciesIssues().length> 0" class="badge" ng-class="getBadgeClass(getPoliciesIssues())">{{getPoliciesIssues().length}}</span>
 
         <span class="pull-right" ng-show="$parent.stateWrapped.state">
-            <i class="fa fa-search collapsible-action" title="Filter policies" ng-click="$event.stopPropagation(); $event.preventDefault();" ng-class="{'text-success': state.policy.search.length > 0}" uib-popover-template="'SearchPolicyTemplate.html'" popover-placement="bottom-right" popover-trigger="'outsideClick'"></i>
+            <i class="fa fa-search collapsible-action" title="Filter policies" ng-click="$event.stopPropagation(); $event.preventDefault();" ng-class="{'text-success': state.policy.search.length > 0}" uib-popover-template="'blueprint-composer/component/spec-editor/search-policy.html'" popover-placement="bottom-right" popover-trigger="'outsideClick'"></i>
             <a class="fa fa-plus collapsible-action" title="Add policy" ui-sref="main.graphical.edit.add({entityId: model._id, family: 'policy'})" ng-click="$event.stopPropagation()" ></a>
         </span>
     </heading>
@@ -395,19 +400,22 @@
 
         <div ng-repeat="adjunct in filteredPolicies = (model.getPoliciesAsArray() | specEditorType:state.policy.search) track by adjunct._id" class="spec-policy spec-adjunct">
             <a ui-sref="main.graphical.edit.policy({entityId: model._id, policyId: adjunct._id})"></a>
-            <ng-include src="'AdjunctTemplate.html'"></ng-include>
+            <ng-include src="'blueprint-composer/component/spec-editor/adjunct.html'"></ng-include>
         </div>
     </div>
-</br-collapsible>
+  </br-collapsible>
+</script>
 
 <!-- ENTITY ENRICHERS -->
-<br-collapsible ng-if="[FAMILIES.ENTITY, FAMILIES.SPEC].indexOf(model.family) > -1" state="state.enricher.open">
+<ng-include src="'blueprint-composer/component/spec-editor/section-enrichers.html'"></ng-include>
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/section-enrichers.html">
+  <br-collapsible ng-if="[FAMILIES.ENTITY, FAMILIES.SPEC].indexOf(model.family) > -1" state="state.enricher.open">
     <heading>
         Enrichers
         <span ng-if="getEnrichersIssues().length> 0" class="badge" ng-class="getBadgeClass(getEnrichersIssues())">{{getEnrichersIssues().length}}</span>
 
         <span class="pull-right" ng-show="$parent.stateWrapped.state">
-            <i class="fa fa-search collapsible-action" title="Search enrichers" ng-click="$event.stopPropagation(); $event.preventDefault();" ng-class="{'text-success': state.enricher.search.length > 0}" uib-popover-template="'SearchEnricherTemplate.html'" popover-placement="bottom-right" popover-trigger="'outsideClick'"></i>
+            <i class="fa fa-search collapsible-action" title="Search enrichers" ng-click="$event.stopPropagation(); $event.preventDefault();" ng-class="{'text-success': state.enricher.search.length > 0}" uib-popover-template="'blueprint-composer/component/spec-editor/search-enricher.html'" popover-placement="bottom-right" popover-trigger="'outsideClick'"></i>
             <a class="fa fa-plus collapsible-action" title="Add enricher" ui-sref="main.graphical.edit.add({entityId: model._id, family: 'enricher'})" ng-click="$event.stopPropagation()" ></a>
         </span>
     </heading>
@@ -428,13 +436,19 @@
 
         <div ng-repeat="adjunct in filteredEnrichers = (model.getEnrichersAsArray() | specEditorType:state.enricher.search) track by adjunct._id" class="spec-enricher spec-adjunct">
             <a ui-sref="main.graphical.edit.enricher({entityId: model._id, enricherId: adjunct._id})"></a>
-            <ng-include src="'AdjunctTemplate.html'"></ng-include>
+            <ng-include src="'blueprint-composer/component/spec-editor/adjunct.html'"></ng-include>
         </div>
     </div>
-</br-collapsible>
+  </br-collapsible>
+</script>
+
+<ng-include src="'blueprint-composer/component/spec-editor/section-others.html'"></ng-include>
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/section-others.html">
+</script>
+
 
 <!-- CONFIG INFO TEMPLATE :: START -->
-<script type="text/ng-template" id="ConfigInfoTemplate.html">
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/config-info.html">
     <div class="config-item-quick-info">
         <div class="quick-info-metadata">
             <p><i class="mini-icon fa fa-fw fa-cog"></i> <samp class="type-symbolic-name">{{item.name}}</samp>
@@ -449,7 +463,7 @@
 <!-- CONFIG INFO TEMPLATE :: START-->
 
 <!-- SEARCH POLICY TEMPLATE :: START -->
-<script type="text/ng-template" id="SearchPolicyTemplate.html">
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/search-policy.html">
     <div ng-click="$event.stopPropagation(); $event.preventDefault();">
         <input ng-model="state.policy.search" type="text" class="form-control" placeholder="Search for a policy" auto-focus blur-on-enter />
     </div>
@@ -457,7 +471,7 @@
 <!--SEARCH POLICY TEMPLATE :: START-->
 
 <!-- SEARCH ENRICHER TEMPLATE :: START -->
-<script type="text/ng-template" id="SearchEnricherTemplate.html">
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/search-enricher.html">
     <div ng-click="$event.stopPropagation(); $event.preventDefault();">
         <input ng-model="state.enricher.search" type="text" class="form-control" placeholder="Search for an enricher" auto-focus blur-on-enter />
     </div>
@@ -465,7 +479,7 @@
 <!--SEARCH ENRICHER TEMPLATE :: START-->
 
 <!--TYPEAHEAD TEMPLATE :: START-->
-<script type="text/ng-template" id="ConfigItemTemplate.html">
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/config-item.html">
     <div class="dropdown-item" ng-init="item = match.model">
         <div class="dropdown-row">
             <span ng-bind-html="match.model.name | uibTypeaheadHighlight:query" class="config-name"></span>
@@ -480,7 +494,7 @@
 <!--TYPEAHEAD TEMPLATE :: END-->
 
 <!--ADJUNCT TEMPLATE :: START-->
-<script type="text/ng-template" id="AdjunctTemplate.html">
+<script type="text/ng-template" id="blueprint-composer/component/spec-editor/adjunct.html">
     <div class="media" ng-class="{'has-issues': adjunct.hasIssues()}">
         <div class="media-left media-middle">
             <img ng-src="{{adjunct.icon}}" alt="{{adjunct | entityName}} logo" class="media-object" />
diff --git a/ui-modules/blueprint-composer/app/index.js b/ui-modules/blueprint-composer/app/index.js
index 7dc3720..34b3c5b 100755
--- a/ui-modules/blueprint-composer/app/index.js
+++ b/ui-modules/blueprint-composer/app/index.js
@@ -43,25 +43,19 @@
 import blueprintLoaderApiProvider from "./components/providers/blueprint-loader-api.provider";
 
 import brooklynApi from "brooklyn-ui-utils/brooklyn.api/brooklyn.api";
-import {designerDirective} from "./components/designer/designer.directive";
-import {
-    catalogSelectorDirective,
-    catalogSelectorSearchFilter,
-    catalogSelectorFiltersFilter,
-} from "./components/catalog-selector/catalog-selector.directive";
+import designer from './components/designer/designer.directive';
+import catalogSelector from './components/catalog-selector/catalog-selector.directive';
 import customActionDirective from "./components/custom-action/custom-action.directive";
 import customConfigSuggestionDropdown from "./components/custom-config-widget/suggestion-dropdown";
-import {onErrorDirective} from "./components/catalog-selector/on-error.directive";
-import {breadcrumbsDirective} from "./components/breacrumbs/breadcrumbs.directive";
-import {recursionHelperFactory} from "./components/factories/recursion-helper.factory";
-import {objectCacheFactory} from './components/factories/object-cache.factory';
-import {entityNameFilter, entityVersionFilter, entityTypesFilter} from "./components/filters/entity.filter";
-import {locationsFilter} from "./components/filters/locations.filter";
-import {blueprintServiceProvider} from "./components/providers/blueprint-service.provider";
-import {recentlyUsedServiceProvider} from "./components/providers/recently-used-service.provider";
-import {dslServiceProvider} from "./components/providers/dsl-service.provider";
-import {paletteDragAndDropServiceProvider} from "./components/providers/palette-dragndrop.provider";
-import {actionServiceProvider} from "./components/providers/action-service.provider";
+import breadcrumbs from "./components/breacrumbs/breadcrumbs.directive";
+import objectCache from './components/factories/object-cache.factory';
+import entityFilters from "./components/filters/entity.filter";
+import locationFilter from "./components/filters/locations.filter";
+import blueprintService from "./components/providers/blueprint-service.provider";
+import recentlyUsedService from "./components/providers/recently-used-service.provider";
+import dslService from "./components/providers/dsl-service.provider";
+import paletteDragAndDropService from "./components/providers/palette-dragndrop.provider";
+import actionService from "./components/providers/action-service.provider";
 import {mainState} from "./views/main/main.controller";
 import {yamlState} from "./views/main/yaml/yaml.state";
 import {graphicalState} from "./views/main/graphical/graphical.state";
@@ -76,28 +70,13 @@
 import stackViewer from 'angular-java-stack-viewer';
 import {EntityFamily} from "./components/util/model/entity.model";
 
-angular.module('app', [ngAnimate, ngResource, ngCookies, ngClipboard, uiRouter, 'ui.router.state.events', brCore, 
-        brServerStatus, brAutoFocus, brIconGenerator, brInterstitialSpinner, brooklynModuleLinks, brooklynUserManagement, 
-        brYamlEditor, brUtils, brSpecEditor, brooklynCatalogSaver, brooklynApi, bottomSheet, stackViewer, brDragndrop, 
-        customActionDirective, customConfigSuggestionDropdown, paletteApiProvider, paletteServiceProvider, blueprintLoaderApiProvider])
-    .directive('designer', ['$log', '$state', '$q', 'iconGenerator', 'catalogApi', 'blueprintService', 'brSnackbar', 'paletteDragAndDropService', designerDirective])
-    .directive('onError', onErrorDirective)
-    .directive('catalogSelector', catalogSelectorDirective)
-    .directive('breadcrumbs', breadcrumbsDirective)
-    .provider('blueprintService', blueprintServiceProvider)
-    .provider('recentlyUsedService', recentlyUsedServiceProvider)
-    .provider('dslService', dslServiceProvider)
-    .provider('paletteDragAndDropService', paletteDragAndDropServiceProvider)
-    .provider('actionService', actionServiceProvider)
+angular.module('app', [ngAnimate, ngResource, ngCookies, ngClipboard, uiRouter, 'ui.router.state.events', brCore,
+    brServerStatus, brAutoFocus, brIconGenerator, brInterstitialSpinner, brooklynModuleLinks, brooklynUserManagement,
+    brYamlEditor, brUtils, brSpecEditor, brooklynCatalogSaver, brooklynApi, bottomSheet, stackViewer, brDragndrop,
+    customActionDirective, customConfigSuggestionDropdown, paletteApiProvider, paletteServiceProvider, blueprintLoaderApiProvider,
+    breadcrumbs, catalogSelector, designer, objectCache, entityFilters, locationFilter, actionService, blueprintService,
+    dslService, paletteDragAndDropService, recentlyUsedService])
     .provider('composerOverrides', composerOverridesProvider)
-    .factory('recursionHelper', ['$compile', recursionHelperFactory])
-    .factory('objectCache', ['$cacheFactory', objectCacheFactory])
-    .filter('entityName', entityNameFilter)
-    .filter('entityVersion', entityVersionFilter)
-    .filter('entityTypes', entityTypesFilter)
-    .filter('locations', locationsFilter)
-    .filter('catalogSelectorSearch', catalogSelectorSearchFilter)
-    .filter('catalogSelectorFilters', catalogSelectorFiltersFilter)
     .filter('dslParamLabel', ['$filter', dslParamLabelFilter])
     .config(['$urlRouterProvider', '$stateProvider', '$logProvider', applicationConfig])
     .config(['actionServiceProvider', actionConfig])
diff --git a/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.html b/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.html
index dfabeaf..24acefc 100644
--- a/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.html
+++ b/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.html
@@ -45,7 +45,7 @@
                 <br-svg type="close" class="pull-right" ng-click="vm.selectedSection = undefined"></br-svg>
             </h3>
         </div>
-        <catalog-selector state="paletteState" family="section.type" mode="{{ section.mode }}" on-select="vm.onTypeSelected(item)" class="palette-full-height-wrapper"></catalog-selector>
+        <catalog-selector state="paletteState" family="section.type" mode="{{ section.mode }}" on-select="vm.onTypeSelected(item)" on-select-text="vm.getOnSelectText(item)" icon-selects="true" class="palette-full-height-wrapper"></catalog-selector>
     </div>
   </div>
  </div>
diff --git a/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.js b/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.js
index e409655..f1b92f9 100644
--- a/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.js
+++ b/ui-modules/blueprint-composer/app/views/main/graphical/edit/add/add.js
@@ -88,6 +88,17 @@
 
         return label;
     };
+    
+    this.getOnSelectText = () => {
+        switch ($scope.family) {
+            case EntityFamily.ENTITY: return "Add as child";
+            case EntityFamily.SPEC: return "Set as spec";
+            case EntityFamily.POLICY: return "Add this policy";
+            case EntityFamily.ENRICHER: return "Add this enricher";
+            case EntityFamily.LOCATION: return "Add this location";
+        }
+        return "Select";
+    };
 
     this.onTypeSelected = (type)=> {
         switch ($scope.family) {
diff --git a/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.html b/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.html
index fd69c55..711d4c1 100644
--- a/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.html
+++ b/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.html
@@ -31,7 +31,7 @@
         </div>
     </div>
 
-    <designer></designer>
+    <designer on-selection-change="vm.onCanvasSelection"></designer>
   </div>
 
   <div class="pane pane-palette" ng-if="vm.selectedSection">
@@ -44,7 +44,7 @@
                 <br-svg type="close" class="pull-right" ng-click="vm.selectedSection = undefined"></br-svg>
             </h3>
         </div>
-        <catalog-selector state="paletteState" family="section.type" mode="{{ section.mode }}" on-select="vm.onTypeSelected(item)" class="palette-full-height-wrapper"></catalog-selector>
+        <catalog-selector state="paletteState" family="section.type" mode="{{ section.mode }}" on-select="vm.addSelectedTypeToTargetEntity(item)" on-select-text="vm.getOnSelectText()" class="palette-full-height-wrapper"></catalog-selector>
     </div>
   </div>
 
diff --git a/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.js b/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.js
index 4f053cc..ffb9616 100644
--- a/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.js
+++ b/ui-modules/blueprint-composer/app/views/main/graphical/graphical.state.js
@@ -28,47 +28,53 @@
     templateProvider: function(composerOverrides) {
         return composerOverrides.paletteGraphicalStateTemplate || template;
     },
-    controller: ['$scope', '$state', 'blueprintService', 'paletteService', graphicalController],
+    controller: ['$scope', '$state', '$filter', 'blueprintService', 'paletteService', graphicalController],
     controllerAs: 'vm',
     data: {
         label: 'Graphical Designer'
     }
 };
 
-function graphicalController($scope, $state, blueprintService, paletteService) {
+function graphicalController($scope, $state, $filter, blueprintService, paletteService) {
     this.EntityFamily = EntityFamily;
 
     this.sections = paletteService.getSections();
     this.selectedSection = Object.values(this.sections).find(section => section.type === EntityFamily.ENTITY);
     $scope.paletteState = {};  // share state among all sections
 
-    this.onTypeSelected = (selectedType)=> {
-        let rootEntity = blueprintService.get();
+    this.onCanvasSelection = (item) => {
+        $scope.canvasSelectedItem = item;
+    }
+    this.getOnSelectText = (selectableType) => $scope.canvasSelectedItem ? "Add to " + $filter('entityName')($scope.canvasSelectedItem) : "Add to application";
+    
+    this.addSelectedTypeToTargetEntity = (selectedType, targetEntity) => {
+        if (!targetEntity) targetEntity = $scope.canvasSelectedItem;
+        if (!targetEntity) targetEntity = blueprintService.get();
 
         if (selectedType.supertypes.includes(EntityFamily.ENTITY.superType)) {
             let newEntity = blueprintService.populateEntityFromApi(new Entity(), selectedType);
-            rootEntity.addChild(newEntity);
+            targetEntity.addChild(newEntity);
             blueprintService.refreshEntityMetadata(newEntity, EntityFamily.ENTITY).then(() => {
                 $state.go(graphicalEditEntityState, {entityId: newEntity._id});
             })
         }
         else if (selectedType.supertypes.includes(EntityFamily.POLICY.superType)) {
             let newPolicy = blueprintService.populateEntityFromApi(new Entity(), selectedType);
-            rootEntity.addPolicy(newPolicy);
+            targetEntity.addPolicy(newPolicy);
             blueprintService.refreshEntityMetadata(newPolicy, EntityFamily.POLICY).then(() => {
-                $state.go(graphicalEditPolicyState, {entityId: rootEntity._id, policyId: newPolicy._id});
+                $state.go(graphicalEditPolicyState, {entityId: targetEntity._id, policyId: newPolicy._id});
             });
         }
         else if (selectedType.supertypes.includes(EntityFamily.ENRICHER.superType)) {
             let newEnricher = blueprintService.populateEntityFromApi(new Entity(), selectedType);
-            rootEntity.addEnricher(newEnricher);
+            targetEntity.addEnricher(newEnricher);
             blueprintService.refreshEntityMetadata(newEnricher, EntityFamily.ENRICHER).then(() => {
-                $state.go(graphicalEditEnricherState, {entityId: rootEntity._id, enricherId: newEnricher._id});
+                $state.go(graphicalEditEnricherState, {entityId: targetEntity._id, enricherId: newEnricher._id});
             });
         }
         else if (selectedType.supertypes.includes(EntityFamily.LOCATION.superType)) {
-            blueprintService.populateLocationFromApi(rootEntity, selectedType);
-            $state.go(graphicalEditEntityState, {entityId: rootEntity._id});
+            blueprintService.populateLocationFromApi(targetEntity, selectedType);
+            $state.go(graphicalEditEntityState, {entityId: targetEntity._id});
         }
     };
 }