You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@brooklyn.apache.org by he...@apache.org on 2018/07/26 10:50:35 UTC

[18/28] brooklyn-ui git commit: This adds the contributed brooklyn-ui-angular code in the folder new/.

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js b/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js
new file mode 100644
index 0000000..0e4b360
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.js
@@ -0,0 +1,590 @@
+/*
+ * 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.
+ */
+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 TAG = 'SERVICE :: BLUEPRINT :: ';
+export const RESERVED_KEYS = ['name', 'location', 'locations', 'type', 'services', 'brooklyn.config', 'brooklyn.children', 'brooklyn.enrichers', 'brooklyn.policies'];
+export const DSL_ENTITY_SPEC = '$brooklyn:entitySpec';
+
+export function blueprintServiceProvider() {
+    return {
+        $get: ['$log', '$q', '$sce', 'paletteApi', 'iconGenerator', 'dslService',
+            function ($log, $q, $sce, paletteApi, iconGenerator, dslService) {
+            return new BlueprintService($log, $q, $sce, paletteApi, iconGenerator, dslService);
+        }]
+    }
+}
+
+function BlueprintService($log, $q, $sce, paletteApi, iconGenerator, dslService) {
+    let blueprint = new Entity();
+
+    return {
+        setFromJson: setBlueprintFromJson,
+        setFromYaml: setBlueprintFromYaml,
+        get: getBlueprint,
+        getAsJson: getBlueprintAsJson,
+        getAsYaml: getBlueprintAsYaml,
+        reset: resetBlueprint,
+        find: findEntity,
+        findAny: findAnyEntity,
+        getEntityMetadata: getEntityMetadata,
+        entityHasMetadata: entityHasMetadata,
+        refreshBlueprintMetadata: refreshBlueprintMetadata,
+        refreshEntityMetadata: refreshEntityMetadata,
+        refreshConfigConstraints: refreshConfigConstraints,
+        refreshRelationships: refreshRelationships,
+        refreshAllRelationships: refreshAllRelationships,
+        isReservedKey: isReservedKey,
+        getIssues: getIssues,
+        hasIssues: hasIssues,
+        populateEntityFromApi: populateEntityFromApiSuccess,
+        populateLocationFromApi: populateLocationFromApiSuccess,
+        addConfigKeyDefinition: addConfigKeyDefinition,
+        getRelationships: getRelationships,
+    };
+
+    function setBlueprintFromJson(jsonBlueprint) {
+        if (jsonBlueprint) {
+            blueprint.setEntityFromJson(jsonBlueprint);
+            $log.debug(TAG + 'Blueprint set from JS', blueprint);
+        } else {
+            resetBlueprint();
+        }
+    }
+
+    function setBlueprintFromYaml(yamlBlueprint, reset = false) {
+        let newBlueprint = jsYaml.safeLoad(yamlBlueprint);
+        if (reset) {
+            resetBlueprint();
+        }
+        setBlueprintFromJson(newBlueprint);
+        $log.debug(TAG + 'Blueprint set from YAML', blueprint);
+    }
+
+    function getBlueprint() {
+        return blueprint;
+    }
+
+    function getBlueprintAsJson() {
+        return blueprint.getData();
+    }
+
+    function getBlueprintAsYaml() {
+        try {
+            let currentModel = blueprint.getData();
+            return (Object.keys(currentModel).length === 0) ? '' : jsYaml.safeDump(currentModel, { lineWidth: 127 });
+        } catch (err) {
+            $log.error("Error converting brooklyn blueprint (rethrowing)", err);
+            throw 'Could not convert the Brooklyn blueprint into YAML format';
+        }
+    }
+
+    function resetBlueprint() {
+        blueprint.reset();
+    }
+
+    function findEntity(id) {
+        if (!id) {
+            return null;
+        }
+        return lookup(blueprint, id);
+    }
+
+
+    function findAnyEntity(id) {
+        if (!id) {
+            return null;
+        }
+        return lookup(blueprint, id, true);
+    }
+
+    function getEntityMetadata(entity) {
+        let metadata = new Map();
+        if (entity instanceof Entity) {
+            entity.metadata.forEach((value, key)=> {
+                if (RESERVED_KEYS.indexOf(key) === -1) {
+                    metadata.set(key, value);
+                }
+            });
+        }
+        return metadata;
+    }
+
+    function entityHasMetadata(entity) {
+        return this.getEntityMetadata(entity).size > 0;
+    }
+
+    function isReservedKey(key) {
+        return RESERVED_KEYS.indexOf(key) > -1;
+    }
+
+    function getIssues(entity = blueprint) {
+        let issues = [];
+
+        if (entity.hasIssues()) {
+            issues = issues.concat(entity.issues.map((issue)=> {
+                let newIssue = Issue.builder().message(issue.message).group(issue.group).ref(issue.ref).level(issue.level).build();
+                newIssue.entity = entity;
+                return newIssue;
+            }));
+        }
+
+        entity.policies.forEach((policy)=> {
+            issues = issues.concat(getIssues(policy));
+        });
+
+        entity.enrichers.forEach((enricher)=> {
+            issues = issues.concat(getIssues(enricher));
+        });
+
+        entity.children.forEach((child)=> {
+            issues = issues.concat(getIssues(child));
+        });
+
+        return issues;
+    }
+
+    function hasIssues() {
+        return this.getIssues().length > 0;
+    }
+
+    function lookup(entity, id, any = false) {
+        if (entity._id === id) {
+            return entity;
+        }
+        if (entity.childrenAsMap.has(id)) {
+            return entity.childrenAsMap.get(id);
+        }
+        if (entity.policies.has(id)) {
+            return entity.policies.get(id);
+        }
+        if (entity.enrichers.has(id)) {
+            return entity.enrichers.get(id);
+        }
+        let memberSpecs = Object.values(entity.getClusterMemberspecEntities()).filter((memberSpec)=>(memberSpec._id === id));
+        if (memberSpecs.length === 1) {
+            return memberSpecs[0];
+        }
+        for (let child of entity.childrenAsMap.values()) {
+            let ret = lookup(child, id, any);
+            if (ret !== null) {
+                return ret;
+            }
+        }
+        return null;
+    }
+
+    function refreshBlueprintMetadata(entity = blueprint, family = 'ENTITY') {
+        return refreshEntityMetadata(entity, family).then(()=> {
+            return $q.all(entity.children.reduce((result, child) => {
+                result.push(refreshBlueprintMetadata(child));
+                return result;
+            }, []));
+        }).then(() => {
+            return entity;
+        });
+    }
+
+    function refreshEntityMetadata(entity, family) {
+        entity.miscData.set('loading', true);
+        return $q.all([refreshTypeMetadata(entity, family), refreshLocationMetadata(entity)]).then(()=> {
+            entity.miscData.set('loading', false);
+            return refreshRelationships(entity);
+        }).then(() => {
+            return $q.all([
+                refreshConfigConstraints(entity),
+                refreshConfigMemberspecsMetadata(entity),
+                refreshPoliciesMetadata(entity),
+                refreshEnrichersMetadata(entity),
+            ]);
+        }).then(()=> {
+            return entity;
+        });
+    }
+
+    function refreshTypeMetadata(entity, family) {
+        let deferred = $q.defer();
+
+        if (entity.hasType()) {
+            entity.family = family;
+
+            let promise = entity.miscData.has('bundle')
+                ? paletteApi.getBundleType(entity.miscData.get('bundle').symbolicName, entity.miscData.get('bundle').version, entity.type, entity.version)
+                : paletteApi.getType(entity.type, entity.version);
+
+            promise.then((data)=> {
+                deferred.resolve(populateEntityFromApiSuccess(entity, data));
+            }).catch(function () {
+                deferred.resolve(populateEntityFromApiError(entity));
+            });
+        } else if (entity.parent) {
+            entity.clearIssues({group: 'type'}).addIssue(Issue.builder().group('type').message('Entity needs a type').level(ISSUE_LEVEL.WARN).build());
+            entity.miscData.set('sensors', []);
+            entity.miscData.set('traits', []);
+            deferred.resolve(entity);
+            addUnlistedConfigKeysDefinitions(entity);
+        } else {
+            entity.miscData.set('sensors', []);
+            entity.miscData.set('traits', []);
+            deferred.resolve(entity);
+            addUnlistedConfigKeysDefinitions(entity);
+        }
+
+        return deferred.promise;
+    }
+
+    function refreshLocationMetadata(entity) {
+        let deferred = $q.defer();
+
+        if (entity.hasLocation()) {
+            paletteApi.getLocation(entity.location).then((location)=> {
+                deferred.resolve(populateLocationFromApiSuccess(entity, location.catalog || location));
+            }).catch(function () {
+                deferred.resolve(populateLocationFromApiError(entity));
+            });
+        } else {
+            deferred.resolve(entity);
+        }
+
+        return deferred.promise;
+    }
+
+    function refreshConfigConstraints(entity) {
+        return $q((resolve) => {
+            if (entity.miscData.has('config')) {
+                entity.miscData.get('config')
+                    .filter(config => config.constraints && Object.keys(config.constraints).length > 0)
+                    .forEach(config => {
+                        for (let [key, constraint] of Object.entries(config.constraints) ) {
+                            let message = null;
+                            switch (key) {
+                                case 'Predicates.notNull()':
+                                case 'required':
+                                    if (!angular.isDefined(config.defaultValue) && (!entity.config.has(config.name) || !angular.isDefined(entity.config.get(config.name)))) {
+                                        message = `<samp>${config.name}</samp> is required`;
+                                    }
+                                    break;
+                                case 'regex':
+                                    if (!entity.config.has(config.name) || !angular.isDefined(entity.config.get(config.name)) || !(new RegExp(constraint).test(entity.config.get(config.name)))) {
+                                        message = `<samp>${config.name}</samp> does not match the required format: <samp>${config.constraints.regex}</samp>`;
+                                    }
+                                    break;
+                            }
+                            if (message !== null) {
+                                entity.addIssue(Issue.builder().group('config').ref(config.name).message($sce.trustAsHtml(message)).build());
+                            }
+                        }
+                    });
+            }
+            resolve();
+        });
+    }
+
+    function refreshConfigMemberspecsMetadata(entity) {
+        let promiseArray = [];
+        if (entity.isCluster()) {
+            Object.values(entity.getClusterMemberspecEntities()).forEach((memberSpec)=> {
+                // memberSpec can be `undefined` if the member spec is not a `$brooklyn:entitySpec`, e.g. it is `$brooklyn:config("spec")`.
+                // there may be a better way but this seems to handle it.
+                if (memberSpec) promiseArray.push(refreshBlueprintMetadata(memberSpec, 'SPEC'));
+            });
+        }
+        return $q.all(promiseArray);
+    }
+
+    function refreshPoliciesMetadata(entity) {
+        return $q.all(entity.getPoliciesAsArray().reduce((result, policy)=> {
+            policy.miscData.set('loading', true);
+            policy.family = 'POLICY';
+
+            let deferred = $q.defer();
+
+            paletteApi.getType(policy.type, policy.version).then((data)=> {
+                deferred.resolve(populateEntityFromApiSuccess(policy, data));
+            }).catch(function () {
+                deferred.resolve(populateEntityFromApiError(policy));
+            }).finally(()=> {
+                policy.miscData.set('loading', false);
+            });
+            result.push(deferred);
+            return result;
+        }, []));
+    }
+
+    function refreshEnrichersMetadata(entity) {
+        return $q.all(entity.getEnrichersAsArray().reduce((result, enricher)=> {
+            enricher.miscData.set('loading', true);
+            enricher.family = 'ENRICHER';
+
+            let deferred = $q.defer();
+
+            paletteApi.getType(enricher.type, enricher.version).then((data)=> {
+                deferred.resolve(populateEntityFromApiSuccess(enricher, data));
+            }).catch(function () {
+                deferred.resolve(populateEntityFromApiError(enricher));
+            }).finally(()=> {
+                enricher.miscData.set('loading', false);
+            });
+            result.push(deferred);
+            return result;
+        }, []));
+    }
+
+    function parseInput(input, entity) {
+        return $q((resolve, reject) => {
+            try {
+                let parsed = dslService.parse(input, entity, getBlueprint());
+                if (parsed.kind && parsed.kind.family === 'constant') {
+                    reject('constants not interpreted as DSL when parsed', input);
+                } else {
+                    resolve(parsed);
+                }
+            } catch (ex) {
+                $log.debug(ex);
+                reject(ex, input);
+            }
+        });
+    }
+
+    function refreshRelationships(entity) {
+        return $q.all(Array.from(entity.config.keys()).reduce((promises, key) => {
+            let value = entity.config.get(key);
+
+            // Return promises that returns an object like { key: "my-config-key-key", issues: [] }
+            if (value instanceof Dsl) {
+                promises.push(
+                    parseInput(value.toString(), entity).then(dsl => {
+                        entity.config.set(key, dsl);
+                        return {
+                            key: key,
+                            issues: dsl.getAllIssues()
+                        };
+                    }).catch(() => $q.resolve({
+                        key: key,
+                        issues: []
+                    }))
+                );
+            } else if (value instanceof Array) {
+                promises.push(
+                    $q.all(value.reduce((issues, itemValue, itemIndex) => {
+                        return issues.concat(
+                            parseInput(itemValue, entity).then(dsl => {
+                                value[itemIndex] = dsl;
+                                return dsl.getAllIssues();
+                            }).catch(() => [])
+                        );
+                    }, [])).then(issues => {
+                        return {
+                            key: key,
+                            issues: issues.reduce((acc, issue) => acc.concat(issue), [])
+                        }
+                    })
+                );
+            } else if (value instanceof Object) {
+                promises.push(
+                    $q.all(Object.keys(value).reduce((issues, itemKey) => {
+                        return issues.concat(
+                            parseInput(value[itemKey], entity).then(dsl => {
+                                value[itemKey] = dsl;
+                                return dsl.getAllIssues();
+                            }).catch(() => [])
+                        );
+                    }, [])).then(issues => {
+                        return {
+                            key: key,
+                            issues: issues.reduce((acc, issue) => acc.concat(issue), [])
+                        }
+                    })
+                );
+            } else {
+                promises.push(
+                    parseInput(value, entity).then(dsl => {
+                        entity.config.set(key, dsl);
+                        return {
+                            key: key,
+                            issues: dsl.getAllIssues()
+                        };
+                    }).catch(() => $q.resolve({
+                        key: key,
+                        issues: []
+                    }))
+                );
+            }
+
+            return promises;
+        }, [])).then(results => {
+            results.forEach(result => {
+                entity.clearIssues({ref: result.key});
+                result.issues.forEach(issue => {
+                    entity.addIssue(Issue.builder().group('config').ref(result.key).message($sce.trustAsHtml(issue)).build());
+                });
+            })
+        });
+    }
+
+    function refreshAllRelationships(entity = blueprint) {
+        let promises = [];
+        promises.push(refreshRelationships(entity));
+        promises.concat(entity.children.map(child => refreshAllRelationships(child)));
+
+        return $q.all(promises);
+    }
+
+    function addConfigKeyDefinition(config, key) {
+        config.push({
+            "constraints": [],
+            "description": "",
+            "name": key,
+            "label": key,
+            "priority": 1,
+            "pinned": true,
+            "type": "java.lang.String",
+        });
+    }
+
+    function addUnlistedConfigKeysDefinitions(entity) {
+        let allConfig = entity.miscData.get('config') || [];
+        entity.config.forEach((value, key) => {
+            if (!allConfig.some((e) => e.name === key)) {
+                addConfigKeyDefinition(allConfig, key);
+            }
+        });
+        entity.miscData.set('config', allConfig);
+    }
+
+    function populateEntityFromApiSuccess(entity, data) {
+        entity.clearIssues({group: 'type'});
+        entity.type = data.symbolicName;
+        entity.icon = data.iconUrl || iconGenerator(data.symbolicName);
+        entity.miscData.set('important', !!data.iconUrl);
+        entity.miscData.set('bundle', {
+            symbolicName: data.containingBundle.split(':')[0],
+            version: data.containingBundle.split(':')[1]
+        });
+        entity.miscData.set('typeName', data.displayName || data.symbolicName);
+        entity.miscData.set('config', (data.config || []).map(config => {
+            if (config.constraints) {
+                config.constraints = config.constraints.reduce((map, constraint) => {
+                    if (constraint.startsWith('matchesRegex')) {
+                        map.regex = constraint.replace(/matchesRegex\(([^)]*)\)/, '$1');
+                    } else {
+                        // The constraint "required" falls back into this case. Good enough for now as we only support
+                        // 2 types of constraints.
+                        map[constraint] = true;
+                    }
+                    return map;
+                }, {});
+            }
+            return config;
+        }));
+        entity.miscData.set('sensors', data.sensors || []);
+        entity.miscData.set('traits', data.supertypes || []);
+        entity.miscData.set('virtual', data.virtual || null);
+        addUnlistedConfigKeysDefinitions(entity);
+        return entity;
+    }
+
+    function populateEntityFromApiError(entity) {
+        entity.clearIssues({group: 'type'});
+        entity.addIssue(Issue.builder().group('type').message($sce.trustAsHtml(`Type <samp>${entity.type + (entity.hasVersion ? ':' + entity.version : '')}</samp> does not exist`)).build());
+        entity.miscData.set('typeName', entity.type || '');
+        entity.miscData.set('config', []);
+        entity.miscData.set('sensors', []);
+        entity.miscData.set('traits', []);
+        entity.miscData.set('virtual', null);
+        entity.icon = typeNotFoundIcon;
+        addUnlistedConfigKeysDefinitions(entity);
+        return entity;
+    }
+
+    function populateLocationFromApiSuccess(entity, data) {
+        entity.clearIssues({group: 'location'});
+        entity.location = data.symbolicName;
+        entity.miscData.set('locationName', data.name);
+        entity.miscData.set('locationIcon', data.iconUrl || iconGenerator(data.symbolicName));
+        return entity;
+    }
+
+    function populateLocationFromApiError(entity) {
+        entity.clearIssues({group: 'location'});
+        entity.addIssue(Issue.builder().level(ISSUE_LEVEL.WARN).group('location').message($sce.trustAsHtml(`Location <samp>${!(entity.location instanceof String) ? JSON.stringify(entity.location) : entity.location}</samp> does not exist in your local catalog. Deployment might fail.`)).build());
+        entity.miscData.set('locationName', entity.location);
+        entity.miscData.set('locationIcon', typeNotFoundIcon);
+        return entity;
+    }
+
+    /**
+     * Retrieves all the Entities referenced by an Entity
+     * @param {Entity} entity the Entity to resolve relative references from
+     * @return {Array} of objects that contains source and target entities
+     */
+    function getRelationships(entity = blueprint) {
+        let set = Array.from(entity.config.values())
+            .reduce((set, config)=> {
+                if (config instanceof Dsl) {
+                    config.relationships.forEach((entity) => {
+                        if (entity !== null) {
+                            set.add(entity);
+                        }
+                    });
+                }
+                if (config instanceof Array) {
+                    config
+                        .filter(conf => conf instanceof Dsl)
+                        .reduce((set, config)=> {
+                            config.relationships.forEach((entity)=> {
+                                if (entity !== null) {
+                                    set.add(entity);
+                                }
+                            });
+                            return set;
+                        }, set);
+                }
+                if (config instanceof Object) {
+                    Object.keys(config)
+                        .filter(key => config[key] instanceof Dsl)
+                        .reduce((set, key)=> {
+                            config[key].relationships.forEach((entity)=> {
+                                if (entity !== null) {
+                                    set.add(entity);
+                                }
+                            });
+                            return set;
+                        }, set);
+                }
+                return set;
+            }, new Set());
+
+        let relationships = Array.from(set).map((relation) => {
+            return {
+                source: entity,
+                target: relation
+            };
+        });
+
+        return entity.children.reduce((relationships, child) => {
+            return relationships.concat(getRelationships(child))
+        }, relationships);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.spec.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.spec.js b/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.spec.js
new file mode 100644
index 0000000..2514e9e
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/providers/blueprint-service.provider.spec.js
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ */
+import {blueprintServiceProvider} from "./blueprint-service.provider";
+import {dslServiceProvider} from "./dsl-service.provider";
+import {catalogApiProvider} from "brooklyn-ui-utils/providers/catalog-api.provider";
+import {locationApiProvider} from "brooklyn-ui-utils/providers/location-api.provider";
+import {iconGeneratorProvider} from "brooklyn-ui-utils/icon-generator/icon-generator";
+import {Entity} from "../util/model/entity.model";
+import {paletteApiProvider} from './palette-api.provider';
+
+
+describe('Blueprint Service', ()=> {
+    let $rootScope, blueprintService;
+
+    beforeEach(angular.mock.module(($provide)=> {
+        $provide.provider('catalogApi', catalogApiProvider);
+        $provide.provider('locationApi', locationApiProvider);
+        $provide.provider('paletteApi', paletteApiProvider);
+        $provide.provider('iconGenerator', iconGeneratorProvider);
+        $provide.provider('dslService', dslServiceProvider);
+        $provide.provider('blueprintService', blueprintServiceProvider);
+    }));
+    beforeEach(angular.mock.inject((_$rootScope_, _blueprintService_)=> {
+        $rootScope = _$rootScope_;
+        blueprintService = _blueprintService_;
+    }));
+
+    it('should load blueprint from YAML', ()=> {
+        blueprintService.setFromYaml(YAML_BLUEPRINT);
+        let bp = blueprintService.get();
+        expect(bp instanceof Entity).toBe(true);
+        expect(bp.hasName()).toBe(true);
+        expect(bp.children.length).toBe(3);
+        expect(bp.children[0].hasType()).toBe(true);
+        expect(bp.children[0].type).toBe('com.example.Entity1');
+        expect(blueprintService.getAsYaml()).toEqual(YAML_BLUEPRINT);
+    });
+
+    it('should return an entity by internal id', ()=> {
+        blueprintService.setFromYaml(YAML_BLUEPRINT);
+        for (let childId of blueprintService.get().childrenAsMap.keys()) {
+            let childEntity = blueprintService.find(childId);
+            expect(childEntity).toBeDefined();
+            expect(childEntity).not.toBeNull();
+            expect(childEntity._id).toEqual(childId);
+        }
+        let unknownEntity = blueprintService.find(Math.random().toString(36).slice(2))
+        expect(unknownEntity).toBeDefined();
+        expect(unknownEntity).toBeNull();
+    });
+
+});
+
+const YAML_BLUEPRINT = '' +
+    'name: Test Blueprint\n' +
+    'services:\n' +
+    '  - type: com.example.Entity1\n' +
+    '  - type: com.example.Entity2\n' +
+    '  - type: com.example.Entity3\n';

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js b/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js
new file mode 100644
index 0000000..3d5f665
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.js
@@ -0,0 +1,82 @@
+/*
+ * 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.
+ */
+import {Dsl, DslParser, KIND} from "../util/model/dsl.model";
+import {Entity} from "../util/model/entity.model";
+
+const TAG = 'SERVICE :: DSL :: ';
+
+export function dslServiceProvider() {
+    return {
+        $get: ['$log', function ($log) {
+            return new DslService($log);
+        }]
+    }
+}
+
+/**
+ *
+ * @param $log
+ * @return {{parse: parse, generate: generate}}
+ * @constructor
+ */
+function DslService($log) {
+
+    return {
+        parse: parse,
+        generate: generate,
+    };
+
+    /**
+     * Parses a string containing a DSL expression and resolve its references to Entities
+     * @param dsl the string to parse
+     * @param {Entity} entity the base Entity to resolve relative references from
+     * @param {Entity} blueprint the root Entity
+     * @return {Dsl} a Dsl object containing relationships (referenced Entities)
+     */
+    function parse(dsl, entity, blueprint) {
+        return new DslParser(dsl).parse(entity, function lookupById(id, entity = blueprint) {
+            if (entity.id === id) {
+                return entity;
+            }
+            for (let child of entity.childrenAsMap.values()) {
+                let ret = lookupById(id, child);
+                if (ret !== null) {
+                    return ret;
+                }
+            }
+            return null;
+        });
+    }
+
+    /**
+     * Generates the DSL representation for a Dsl object
+     * @param dsl
+     * @return {string}
+     */
+    function generate(dsl) {
+        if (!dsl) {
+            throw new Error('"dsl" param must be specified');
+        }
+        if (!(dsl instanceof Dsl)) {
+            throw new Error('"dsl" param must be an instance of Dsl');
+        }
+        return dsl.getRoot().toString();
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.spec.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.spec.js b/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.spec.js
new file mode 100644
index 0000000..aaa0063
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/providers/dsl-service.provider.spec.js
@@ -0,0 +1,119 @@
+/*
+ * 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.
+ */
+import {Dsl, DslParser, KIND} from "../util/model/dsl.model";
+import {Entity} from "../util/model/entity.model";
+import {dslServiceProvider} from "./dsl-service.provider";
+import {blueprintServiceProvider} from "./blueprint-service.provider";
+import {catalogApiProvider} from "brooklyn-ui-utils/providers/catalog-api.provider";
+import {locationApiProvider} from "brooklyn-ui-utils/providers/location-api.provider";
+import {iconGeneratorProvider} from "brooklyn-ui-utils/icon-generator/icon-generator";
+
+
+describe('Dsl Service', ()=> {
+    let $rootScope, dslService;
+
+    beforeEach(angular.mock.module(($provide)=> {
+        $provide.provider('catalogApi', catalogApiProvider);
+        $provide.provider('locationApi', locationApiProvider);
+        $provide.provider('iconGenerator', iconGeneratorProvider);
+        $provide.provider('blueprintService', blueprintServiceProvider);
+        $provide.provider('dslService', dslServiceProvider);
+    }));
+    beforeEach(angular.mock.inject((_$rootScope_, _dslService_)=> {
+        $rootScope = _$rootScope_;
+        dslService = _dslService_;
+    }));
+
+    it('should skip relationships to self()', ()=> {
+        let entity1 = new Entity();
+        const CONFIG_OBJECT = {
+            textKey: 'textValue1',
+            boolKey: false,
+            numKey: 123456789,
+            nullKey: null,
+            objectKey: {
+                key: 'val1',
+            }
+        };
+        entity1.setEntityFromJson(CONFIG_OBJECT);
+
+        let target_expr = '$brooklyn:self().attributeWhenReady("http.port")';
+        let dsl = dslService.parse(target_expr, entity1, entity1);
+
+        expect(dsl.relationships.length).toBe(0);
+    });
+
+    it('should resolve relationships to parent()', ()=> {
+        let entity1 = new Entity();
+        const CONFIG_OBJECT1 = {
+            textKey: 'textValue1',
+            boolKey: false,
+            numKey: 123456789,
+            nullKey: null,
+            objectKey: {
+                key: 'val1',
+            }
+        };
+        entity1.setEntityFromJson(CONFIG_OBJECT1);
+
+        let entity2 = new Entity();
+        const CONFIG_OBJECT2 = {
+            textKey: 'textValue2',
+            boolKey: false,
+            numKey: 123456789,
+            nullKey: null,
+            objectKey: {
+                key: 'val2',
+            }
+        };
+        entity2.setEntityFromJson(CONFIG_OBJECT2);
+
+        entity2.parent = entity1;
+
+        let target_expr = '$brooklyn:self().parent().attributeWhenReady("http.port")';
+        let dsl = dslService.parse(target_expr, entity2, entity1);
+
+        expect(dsl.relationships.length).toBe(1);
+        expect(dsl.relationships.includes(entity1)).toBe(true);
+    });
+
+    it('should populate issues for non-existing nested entities', ()=> {
+        let entity1 = new Entity();
+        const CONFIG_OBJECT = {
+            textKey: 'textValue1',
+            boolKey: false,
+            numKey: 123456789,
+            nullKey: null,
+            objectKey: {
+                key: 'val1',
+            }
+        };
+        entity1.setEntityFromJson(CONFIG_OBJECT);
+
+        let target_expr = '$brooklyn:formatString("%s:%s", $brooklyn:component("foo").attributeWhenReady("host.name"), $brooklyn:component("bar").attributeWhenReady("http.port"))';
+        let dsl = dslService.parse(target_expr, entity1, entity1);
+
+        expect(dsl.relationships.length).toBe(0);
+        // look for issues containing "foo", "bar"
+        let issues = dsl.getAllIssues();
+        expect(issues.length).toBe(2);
+        expect(issues[0].search(/foo|bar/)).toBeGreaterThan(-1);
+        expect(issues[1].search(/foo|bar/)).toBeGreaterThan(-1);
+    });
+});

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/providers/palette-api.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/providers/palette-api.js b/new/ui-modules/blueprint-composer/app/components/providers/palette-api.js
new file mode 100644
index 0000000..28ada7b
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/providers/palette-api.js
@@ -0,0 +1,62 @@
+/*
+ * 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 class PaletteApi {
+    constructor($http, $q) {
+        this.$http = $http;
+        this.$q = $q;
+    }
+
+    getTypes(family, search) {
+        // no-op
+        return Promise.resolve();
+    }
+
+    getType(typeSymbolicName, typeVersion) {
+        // no-op
+        return Promise.resolve();
+    }
+
+    getTypeVersion(typeSymbolicName) {
+        // no-op
+        return Promise.resolve();
+    }
+
+    getBundleType(bundleSymbolicName, bundleVersion, typeSymbolicName, typeVersion) {
+        // no-op
+        return Promise.resolve();
+    }
+
+    getLocations() {
+        // no-op
+        return Promise.resolve();
+    }
+
+    getLocation(locationSymbolicName) {
+        // no-op
+        return Promise.resolve();
+    }
+
+    create(bom, params) {
+        // no-op
+        return Promise.resolve();
+    }
+}
+
+window.PaletteApi = PaletteApi;
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/providers/palette-api.provider.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/providers/palette-api.provider.js b/new/ui-modules/blueprint-composer/app/components/providers/palette-api.provider.js
new file mode 100644
index 0000000..a2e9639
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/providers/palette-api.provider.js
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+import angular from 'angular';
+import brApiModule from 'brooklyn-ui-utils/brooklyn.api/brooklyn.api';
+import {PaletteApi} from './palette-api';
+
+const MODULE_NAME = 'brooklyn.composer.api.palette';
+
+angular.module(MODULE_NAME, [brApiModule])
+    .provider('paletteApi', paletteApiProvider);
+
+export default MODULE_NAME;
+
+export function paletteApiProvider() {
+    let implementation = PaletteApiProvider;
+
+    return {
+        implementation: function (impl) {
+            if (!(impl.prototype === PaletteApi.prototype)) {
+                throw new Error(`Expected an implementation extending ${PaletteApi} but got ${impl}`);
+            }
+            implementation = impl;
+        },
+        $get: ['catalogApi', 'locationApi', '$http', '$q', function (catalogApi, locationApi, $http, $q) {
+            return new implementation(catalogApi, locationApi, $http, $q);
+        }]
+    }
+}
+
+class PaletteApiProvider extends PaletteApi {
+    constructor(catalogApi, locationApi, $http, $q) {
+        super($http, $q);
+        this.catalogApi = catalogApi;
+        this.locationApi = locationApi;
+    }
+
+    getTypes(params) {
+        return this.catalogApi.getTypes(params);
+    }
+
+    getType(typeSymbolicName, typeVersion) {
+        return this.catalogApi.getType(typeSymbolicName, typeVersion);
+    }
+
+    getTypeVersion(typeSymbolicName) {
+        return this.catalogApi.getTypeVersion(typeSymbolicName);
+    }
+
+    getBundleType(bundleSymbolicName, bundleVersion, typeSymbolicName, typeVersion) {
+        return this.catalogApi.getBundleType(bundleSymbolicName, bundleVersion, typeSymbolicName, typeVersion);
+    }
+
+    getLocations() {
+        return this.locationApi.getLocations().then(locations => {
+            let supertypes = ['org.apache.brooklyn.api.location'];
+            return locations.map(location => {
+                if (location.catalog) {
+                    location.catalog.displayName = location.config.displayName;
+                    location.catalog.supertypes = supertypes;
+                    return location.catalog;
+                }
+                return {
+                    symbolicName: location.name,
+                    displayName: location.config ? location.config.displayName : location.name,
+                    supertypes: supertypes
+                };
+            });
+        });
+    }
+
+    getLocation(locationSymbolicName) {
+        return this.locationApi.getLocation(locationSymbolicName);
+    }
+
+    create(bom, params) {
+        return this.catalogApi.create(bom, params);
+    }
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js b/new/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js
new file mode 100644
index 0000000..dee0836
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/providers/palette-dragndrop.provider.js
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+import {Entity} from '../util/model/entity.model';
+
+const TAG = 'SERVICE :: DRAGNDROP :: ';
+
+export function paletteDragAndDropServiceProvider() {
+    return {
+        $get: ['$log', function ($log) {
+            return new paletteDragAndDropService($log);
+        }]
+    }
+}
+
+/**
+ * Provides state for Drag and Drop operations
+ * from the palette into the blueprint graph
+ * @param $log
+ * @constructor
+ */
+function paletteDragAndDropService($log) {
+
+    let state = 0; /* 0 = no drag; 1 = drag in progress; 2 = over target */
+    let draggedItem = null;
+    let dropTarget = null;
+    let targetIndex = -1;
+
+    return {
+        dragStart: dragStart,
+        dragEnd: dragEnd,
+        dragEnter: dragEnter,
+        dragLeave: dragLeave,
+        isDropAllowed: isDropAllowed,
+        draggedItem: () => draggedItem,
+        dropTarget: () => dropTarget,
+        targetIndex: () => targetIndex,
+    };
+
+    function isDropAllowed() {
+        return state === 2 && draggedItem && dropTarget;
+    }
+
+    function dragLeave(id) {
+        if (state === 2 && dropTarget && dropTarget._id === id) {
+            state = 1;
+            dropTarget = null;
+            targetIndex = -1;
+        }
+    }
+
+    function dragEnd() {
+        state = 0;
+        draggedItem = null;
+        dropTarget = null;
+        targetIndex = -1;
+    }
+
+    /**
+     * @param {Entity} entity the Entity
+     */
+    function dragEnter(entity, index = -1) {
+        if (state === 1) {
+            state = 2;
+        }
+        dropTarget = entity;
+        targetIndex = index;
+    }
+
+    /**
+     * @param item
+     */
+    function dragStart(item) {
+        state = 1;
+        if (!item) {
+            throw new Error('"item" param must be specified');
+        }
+        draggedItem = item;
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/29927b73/new/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js
----------------------------------------------------------------------
diff --git a/new/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js b/new/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js
new file mode 100644
index 0000000..7a581b7
--- /dev/null
+++ b/new/ui-modules/blueprint-composer/app/components/spec-editor/spec-editor.directive.js
@@ -0,0 +1,794 @@
+/*
+ * 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.
+ */
+import angular from 'angular';
+import onEnter from 'brooklyn-ui-utils/on-enter/index';
+import autoGrow from 'brooklyn-ui-utils/autogrow/index';
+import blurOnEnter from 'brooklyn-ui-utils/blur-on-enter/index';
+import {EntityFamily} from '../util/model/entity.model';
+import {Dsl} from '../util/model/dsl.model';
+import {Issue, ISSUE_LEVEL} from '../util/model/issue.model';
+import {RESERVED_KEYS, DSL_ENTITY_SPEC} from '../providers/blueprint-service.provider';
+import brooklynDslEditor from '../dsl-editor/dsl-editor';
+import brooklynDslViewer from '../dsl-viewer/dsl-viewer';
+import template from './spec-editor.template.html';
+
+const MODULE_NAME = 'brooklyn.components.spec-editor';
+const ANY_MEMBERSPEC_REGEX = /(^.*[m,M]ember[s,S]pec$)/;
+const REPLACED_DSL_ENTITYSPEC = '___brooklyn:entitySpec';
+
+angular.module(MODULE_NAME, [onEnter, autoGrow, blurOnEnter, brooklynDslEditor, brooklynDslViewer])
+    .directive('specEditor', ['$rootScope', '$filter', '$log', '$sce', '$timeout', '$document', 'blueprintService', specEditorDirective])
+    .filter('specEditorConfig', specEditorConfigFilter)
+    .filter('specEditorType', specEditorTypeFilter);
+
+export default MODULE_NAME;
+
+export const CONFIG_FILTERS = [
+    {
+        id: 'suggested',
+        label: 'Suggested',
+        filter: (item)=> {
+            return item.pinned && item.priority > -1;
+        }
+    },
+    {
+        id: 'required',
+        label: 'Required',
+        filter: (item)=> {
+            return item.constraints && item.constraints.required;
+        }
+    },
+    {
+        id: 'inuse',
+        label: 'In Use',
+        filter: (item, currentConfig)=> {
+            return currentConfig.has(item.name);
+        }
+    },
+    {
+        id: 'all',
+        label: 'All',
+        filter: (item)=> {
+            return item;
+        }
+    }
+];
+
+export function specEditorDirective($rootScope, $filter, $log, $sce, $timeout, $document, blueprintService) {
+    return {
+        restrict: 'E',
+        scope: {
+            model: '='
+        },
+        template: template,
+        link: link
+    };
+
+    function link(scope) {
+        scope.addConfigKey = addConfigKey;
+        scope.FAMILIES = EntityFamily;
+        scope.RESERVED_KEYS = RESERVED_KEYS;
+        scope.REPLACED_DSL_ENTITYSPEC = REPLACED_DSL_ENTITYSPEC;
+        
+        let defaultState = {
+            config: {
+                add: {
+                    value: '',
+                    open: false
+                },
+                search: '',
+                focus: '',
+                filter: {
+                    values: { suggested: true, required: true, inuse: true },
+                    open: false,
+                },
+                // TODO would be nice to set null here, then have it go true if there is filtered config
+                // but the collapsible widget doesn't seem to respond to subsequent state changed
+                open: true,
+                
+                dslViewerManualOverride: {},
+                codeModeActive: {},
+                codeModeForced: {},
+                codeModeError: {},
+            },
+            location: {
+                open: false
+            },
+            policy: {
+                search: '',
+                open: false
+            },
+            enricher: {
+                search: '',
+                open: false
+            }
+        };
+
+        scope.filters = {
+            config: CONFIG_FILTERS
+        };
+        scope.isFilterDisabled = (filter) => filter.id!='all' && scope.state.config.filter.values['all'];
+        scope.onFilterClicked = (filter) => {
+            if (!scope.isFilterDisabled(filter)) scope.state.config.filter.values[ filter.id ] = !scope.state.config.filter.values[ filter.id ];
+        };
+         
+        scope.state = sessionStorage && sessionStorage.getItem(scope.model._id)
+            ? JSON.parse(sessionStorage.getItem(scope.model._id))
+            : defaultState;
+        scope.$watch('state', () => {
+            if (sessionStorage) {
+                try {
+                    sessionStorage.setItem(scope.model._id, JSON.stringify(scope.state));
+                } catch (ex) {
+                    $log.error('Cannot save edit state: ' + ex.message);
+                }
+            }
+        }, true);
+        scope.$watch('state.config.filter.values', ()=> {
+            scope.state.config.add.list = getAddListConfig();
+        });
+
+        scope.config = {};
+        scope.$watch('model', (newVal, oldVal)=> {
+            if (newVal && !newVal.equals(oldVal)) {
+                loadLocalConfigFromModel();
+            }
+        }, true);
+
+        scope.$watch('model.id', ()=> {
+            blueprintService.refreshAllRelationships();
+        });
+
+        // Config
+        scope.$watch('config', (newVal, oldVal)=> {
+            setModelFromLocalConfig();
+            scope.model.clearIssues({group: 'config'});
+            blueprintService.refreshRelationships(scope.model).then(() => {
+                return blueprintService.refreshConfigConstraints(scope.model);
+            }).then(() => {
+                refreshCustomValidation(scope.model);
+            });
+        }, true);
+
+        scope.getObjectSize = (object)=> {
+            return scope.defined(object) && object!=null ? Object.keys(object).length : 0;
+        };
+        function findNext(element, clazz, stopClazz) {
+            let el = element, last = null;
+            while (last!=el) {
+                last = el;
+                if (el.children().length) {
+                    el = angular.element(el.children()[0]);
+                } else {
+                    while (el.length && !el.next().length) {
+                        el = el.parent();
+                    }
+                    el = el.next();
+                }
+                if (el.hasClass(clazz)) {
+                    return el;
+                }
+                if (stopClazz && el.hasClass(stopClazz)) break;
+            }
+            return null;
+        }
+        function findPrev(element, clazz, stopClazz) {
+            let el = element, last = null;
+            while (last!=el) {
+                last = el;
+                if (el.children().length) {
+                    // this still does children first, not ideal but okay for how we use it
+                    el = angular.element(el.children()[el.children().length-1]);
+                } else {
+                    while (el.length && !el[0].previousElementSibling) {
+                        el = el.parent();
+                    }
+                    el = angular.element(el[0].previousElementSibling);
+                }
+                if (el.hasClass(clazz)) {
+                    return el;
+                }
+                if (stopClazz && el.hasClass(stopClazz)) break;
+            }
+            return null;
+        }
+        function findAncestor(element, clazz, stopClazz) {
+            let el = element, last = null;
+            while (last!=el) {
+                last = el;
+                el = el.parent();
+                if (el.hasClass(clazz)) {
+                    return el;
+                }
+                if (stopClazz && el.hasClass(stopClazz)) return null;
+            }
+            return null;
+        }
+        function focusIfPossible(event, next) {
+            if (next && next.length) {
+                if (event) event.preventDefault();
+                if (event) event.stopPropagation();
+                next[0].focus();
+                return true;
+            } else {
+                return false;
+            }
+        }
+        scope.nonempty = (o) => o && Object.keys(o).length;
+        scope.defined = (o) => (typeof o !== 'undefined');
+        scope.advanceOutToFormGroupInPanel = (element, event) => {
+            focusIfPossible(event, findAncestor(element, "form-group", "panel-body")) || element[0].blur(); 
+        };
+        scope.advanceControlInFormGroup = (element, event) => {
+            focusIfPossible(event, findNext(element, "form-control", "form-group")) ||
+                scope.advanceOutToFormGroupInPanel(element, event);
+        };
+        
+        scope.onAddMapProperty = (configKey, key, ev)=> {
+            if (key) {
+                if (!scope.config[configKey]) {
+                    scope.config[configKey] = {};
+                }
+                if (!scope.config[configKey].hasOwnProperty(key)) {
+                    scope.config[configKey][key] = '';
+                    
+                    // defer so control exists, then focus on previous item (new control)
+                    $timeout(() => {
+                        let element = angular.element(ev.target);
+                        let prev = findPrev(element, "form-control", "form-group");
+                        focusIfPossible(null, prev) ||
+                            scope.advanceOutToFormGroupInPanel(element, null);
+                    }, 0);
+                } else {
+                    // user entered a key that already exists;
+                    // would be nice to select the item they requested but with just jqlite that's more pain than it's worth!
+                }
+            }
+        };
+        scope.cycleExpandMode = function (expandMode, ctx, item, focus) {
+            return expandMode == 'default' ? 'open' :
+                expandMode == 'open' ? 'closed' :
+                'default';
+        }
+        scope.onDeleteMapProperty = function (model, key) {
+            if (model && model.hasOwnProperty(key)) {
+                delete model[key];
+            }
+        };
+        scope.onAddListItem = (configKey, item, ev)=> {
+            if (item) {
+                if (!scope.config[configKey]) {
+                    scope.config[configKey] = [];
+                }
+                if (scope.config[configKey].indexOf(item) === -1) {
+                    scope.config[configKey].push(item);
+                }
+                ev.target.focus();
+            }
+        };
+        scope.onDeleteListItem = function (model, index) {
+            if (model && index < model.length) {
+                model.splice(index, 1);
+            }
+        };
+        scope.isConfigHidden = (config)=> {
+            let allConfig = scope.model.miscData.get('config');
+            if (allConfig.indexOf(config) === -1) {
+                return false;
+            }
+            return $filter('specEditorConfig')(allConfig, scope.state.config.filter.values).indexOf(config) === -1;
+        };
+        scope.onFocusOnConfig = ($item)=> {
+            scope.state.config.search = '';
+            scope.state.config.add.value = '';
+            scope.state.config.add.open = false;
+            scope.state.config.focus = $item.name;
+            if ($item.isHidden) {
+                scope.state.config.filter.values[ CONFIG_FILTERS[CONFIG_FILTERS.length - 1] ].id = true;
+            }
+        };
+        scope.recordFocus = ($item)=> {
+            scope.state.config.focus = $item.name;
+        };
+
+        scope.removeAdjunct = ($event, adjunct)=> {
+            $event.preventDefault();
+            $event.stopPropagation();
+            switch(adjunct.family) {
+                case EntityFamily.POLICY:
+                    scope.model.removePolicy(adjunct._id);
+                    break;
+                case EntityFamily.ENRICHER:
+                    scope.model.removeEnricher(adjunct._id);
+                    break;
+            }
+        };
+
+        scope.removeEntity = ()=> {
+            $rootScope.$broadcast('d3.remove', scope.model);
+        };
+
+        scope.getConfigIssues = ()=> {
+            return scope.model.issues
+                .filter((issue)=>(issue.group === 'config'))
+                .concat(Object.values(scope.model.getClusterMemberspecEntities())
+                    .filter((spec)=>(spec && spec.hasIssues()))
+                    .reduce((acc, spec)=>(acc.concat(spec.issues)), []));
+        };
+
+        scope.getPoliciesIssues = ()=> {
+            return scope.model.getPoliciesAsArray().reduce((acc, policy)=> {
+                if (policy.hasIssues()) {
+                    acc = acc.concat(policy.issues)
+                }
+                return acc;
+            }, []);
+        };
+
+        scope.getEnrichersIssues = ()=> {
+            return scope.model.getEnrichersAsArray().reduce((acc, enricher)=> {
+                if (enricher.hasIssues()) {
+                    acc = acc.concat(enricher.issues)
+                }
+                return acc;
+            }, []);
+        };
+
+        scope.getBadgeClass = (issues) => {
+            return issues.some(issue => issue.level === ISSUE_LEVEL.ERROR) ? 'badge-danger' : 'badge-warning';
+        };
+
+        function getConfigWidgetModeInternal(item, val) {
+            if (angular.element($document[0].activeElement).hasClass("form-control") && item.widgetMode) {
+                // don't switch mode in mid-edit, e.g. if you are manually typing $brooklyn:component("x").config("y")
+                // don't interrupt when the user has typed $brooklyn:component("x")!
+                return item.widgetMode;
+            }
+            
+            if (scope.isDsl(item.name)) {
+                return scope.state.config.dslViewerManualOverride && scope.state.config.dslViewerManualOverride[item.name] ? 
+                    "dsl-manual" // causes default string editor
+                    : "dsl-viewer"; 
+            }
+            
+            if (!scope.defined(val)) val = scope.config[item.name];
+            let type = item.type;
+            
+            // if actual value's type does not match declared type,
+            // e.g. object is a map when declared type is object or string or something else,
+            // we could render e.g. w map editor as a convenience;
+            // but for now the logic is only based on the declared type
+            
+            if (type === 'java.lang.Boolean') type = 'boolean';
+            else if (type === 'java.util.Map') type = 'map';
+            else if (type === 'java.util.Set' || type === 'java.util.List' || type === 'java.util.Collection') type = 'array';
+            
+            if (scope.defined(val)) {
+                // override to use string editor if the editor doesn't support the value
+                // (probably this is an error, though type-coercion might make it not so)
+                if (type === 'boolean') { 
+                    if (typeof val !== type) return type+'-manual';  // causes default string editor
+                } else if (type === 'map') {
+                    if (typeof val !== 'object') return type+'-manual';  // causes default string editor
+                } else if (type === 'array') {
+                    if (!Array.isArray(val)) return type+'-manual';  // causes default string editor
+                }
+            }
+            if (scope.state.config.codeModeActive[item.name]) {
+                // code mode forces manual editor
+                return type+'-manual';
+            }
+            
+            return type;
+        };
+        scope.getConfigWidgetMode = (item, value) => {
+            // record the value as `item.widgetMode` so we can reference it subsequently, as well as returning it
+            
+            if (item.type) {
+                return item.widgetMode = getConfigWidgetModeInternal(item, value);
+            }
+            
+            // if type isn't set then infer
+            if (value instanceof Array) {
+                definition.widgetMode = 'array';
+            } else if (value instanceof Object) {
+                definition.widgetMode = 'map';
+            } else {
+                definition.widgetMode = 'unknown';
+            }
+        }
+        scope.isCodeModeAvailable = (item) => {
+            let val = scope.config[item.name];
+            
+            if (scope.state.config.codeModeActive[item.name]) {
+                // simple check to update whether it is forced when active -- if it starts with { or [
+                scope.state.config.codeModeForced[item.name] = (typeof val === 'string' && val.trim().match(/^[{\[]/));
+                // always available if user has set it active, and poor-man's forced check above should apply
+                return true;
+            }
+            // else don't force unless the parse confirms it
+            scope.state.config.codeModeForced[item.name] = false;
+            // otherwise true if the text entered is json
+            if (!scope.defined(val)) return false;
+            if (typeof val == 'string') {
+                try {
+                    // a YAML parse would be nicer, but JSON is simpler, and sufficient
+                    // (esp as we export a JSON stringify; preferable would be to export the exact YAML from model,
+                    // including comments, but again lower priority)
+                    let parsed = JSON.parse(val);
+                    // if parse returns a string or a complex object, then it looks like JSON was entered
+                    // (e.g. "foo" or {a:1}); if not, eg user entered boolean or number (45 or true),
+                    // then don't offer code mode; if it was complex, additionally force code mode
+                    scope.state.config.codeModeForced[item.name] = typeof parsed === 'object' || typeof parsed === 'array'; 
+                    return (scope.state.config.codeModeForced[item.name] || typeof parsed === 'string');
+                } catch (ex) {
+                    // not valid text input for code mode
+                    // if "codeModeForced" with poor-man's check was enabled we should disable that
+                    scope.state.config.codeModeForced[item.name] = false;
+                    return false;
+                }
+            }
+            if (val instanceof Dsl) return false;
+            if (val instanceof Array || val instanceof Object) return true;
+            // other primitive
+            return false;
+        }
+        scope.codeModeClick = (item) => {
+            if (scope.state.config.codeModeForced[item.name] && scope.state.config.codeModeActive[item.name]) {
+                // if forced and active, don't allow clicks
+                return;
+            }
+            let oldMode = !!(scope.state.config.codeModeActive[item.name]);
+            // convert local config from json to non-json or vice-versa
+            let value = scope.config[item.name];
+            if (!scope.defined(value)) {
+                value = null;
+            }
+            if (value!=null) {
+                if (oldMode) {
+                    // leaving code mode
+                    try {
+                        // if it's a parseable string, then parse it
+                        if (typeof value === 'string' && value.length) {
+                            value = JSON.parse(value);
+                            if (value instanceof Array || value instanceof Object) {
+                                // if result is not a primitive then don't allow user to leave code mode
+                                // (if type is known as a map/list then likely we should allow leaving code mode,
+                                // if we check and confirm that all entries are json primitives, but that can be left as a TODO;
+                                // only would enter that state if user clicked to enter code for a map/list,
+                                // and they can reset by switching to yaml)
+                                $log.warn("JSON code is not a primitive; leaving as code mode:", item.name, value);
+                                return;
+                            }
+                            // parsed value is a primitive, make that be the value shown
+                        } else {
+                            // if type is not a string, then show unchanged value
+                            // (would only come here on weird changes) 
+                            value = null;
+                        }
+                    } catch (notJson) {
+                        // if not parseable then leave alone
+                        value = null;
+                    }
+                } else {
+                    // entering code mode
+                    try {
+                        if (typeof value === 'string') {
+                            if (value.length) {
+                                JSON.parse(value);
+                                // if it parses, treat as a json object
+                            } else {
+                                // if empty, leave empty
+                            }
+                        } else {
+                            // if not a string then treat as not parseable
+                            throw 'not json';
+                        }
+                    } catch (notJson) {
+                        // if not parseable or not a string, then convert to json
+                        value = JSON.stringify(value);
+                    }
+                }
+                if (value!=null) {
+                    scope.config[item.name] = value;
+                }
+            }
+            scope.state.config.codeModeActive[item.name] = !oldMode;
+            if (value!=null) {
+                // local config changed, make sure model is updated too
+                setModelFromLocalConfig();
+            }
+        }
+        scope.getJsonModeTitle = (itemName) => {
+            if (!scope.state.config.codeModeActive[itemName]) {
+                return "Treat this value as a JSON-encoded object ["+itemName+"]";
+            }
+            if (scope.state.config.codeModeForced[itemName]) {
+                return "This data is a complex object and can only be entered as JSON ["+itemName+"]";
+            } else {
+                return "Edit in simple mode, unwrapping JSON if possible ["+itemName+"]";
+            }
+        }
+        
+        scope.isDsl = (key, index) => {
+            let val = scope.model.config.get(key);
+            if (scope.defined(val) && scope.defined(index) && index!=null) val = val[index];
+            return scope.isDslVal(val);
+        };
+        scope.isDslVal = (val) => {
+            // don't treat constants as DSL (not helpful, and the DSL editor doesn't support it)
+            return (val instanceof Dsl) && val.kind && val.kind.family != 'constant';
+        };
+        scope.isDslWizardButtonAllowed = (key, index) => {
+            let val = scope.model.config.get(key);
+            if (scope.defined(val) && scope.defined(index) && index!=null) val = val[index];
+            if (!scope.defined(val) || val===null || val==='') return true;
+            if (scope.isDslVal(val)) {
+                return true;
+            }
+            // non-DSL values cannot be opened in DSL editor
+            return false;
+        };
+        
+        function getAddListConfig() {
+            if (!angular.isArray(scope.model.miscData.get('config'))) {
+                return [];
+            }
+            let filteredConfig = $filter('specEditorConfig')(scope.model.miscData.get('config'), scope.state.config.filter.values, scope.model.config);
+            return scope.model.miscData.get('config').map((config)=> {
+                config.isHidden = scope.model.miscData.get('config').indexOf(config) > -1 ? filteredConfig.indexOf(config) === -1 : false;
+                return config;
+            });
+        }
+
+        /* config state for each item is stored in multiple places:
+         * * scope.config = map of values used/set by editor (strings, map of strings, json code if using code mode, etc);
+         *   this should be suitable for ng-model to work with, so e.g. if using code mode we need to put JSON.stringify value in here,
+         *   and note any change here immediately (on next digest) updates scope.model.config, which e.g. in code mode
+         *   will JSON.parse
+         * * scope.model.config = map of values used in internal model
+         * * scope.model.miscData.get('config') = list of config keys with their metadata, including derived widgetMode
+         * * scope.state.config.{codeModeActive,dslManualOverride} = maps of booleans where edit modes are set and remembered for configs
+         */
+
+        function loadLocalConfigFromModel() {
+            let map = scope.model.config;
+            let result = {};
+            for (let [key, value] of map) {
+                if (blueprintService.isReservedKey(key)) {
+                    // skip
+                    continue;
+                }
+
+                result[key] = getLocalConfigValueFromModelValue(key, value);
+            }
+            scope.config = result;
+        }
+
+        function getLocalConfigValueFromModelValue(key, value) {
+            if (!scope.defined(value) || value==null) {
+                return value;
+            }
+             
+            if (ANY_MEMBERSPEC_REGEX.test(key) && value.hasOwnProperty(DSL_ENTITY_SPEC)) {
+                let wrapper = {};
+                wrapper[REPLACED_DSL_ENTITYSPEC] = value[DSL_ENTITY_SPEC];
+                return wrapper;
+            }
+            if (value instanceof Dsl) {
+                return value.toString();
+            }
+            
+            let definition = scope.model.miscData.get('config').find(config => config.name === key);
+            if (!definition) {
+                // odd, no def'n for this key
+                definition = {};
+                scope.getConfigWidgetMode(definition, value)
+            } else {
+                if (!definition.widgetMode) scope.getConfigWidgetMode(definition);
+            }
+            
+            // if json mode, then just stringify
+            if (scope.state.config.codeModeActive[key]) {
+                if (scope.state.config.codeModeError[key]) {
+                    // errors don't get stringified because model value was not parsed
+                    return value;
+                }
+                return JSON.stringify(value);
+            }
+            
+            // else treat as value, with array/map special
+            
+            try {
+                if (definition.widgetMode === 'array') {
+                    return value.map(item => {
+                        if (item instanceof Dsl) {
+                            return item.toString();
+                        } else if (item instanceof Array  || item instanceof Object) {
+                            throw 'not simple json in array';
+                        } else {
+                            return item;
+                        }
+                    });
+                } else if (definition.widgetMode === 'map') {
+                    let object = {};
+                    for (let keyObject in value) {
+                        if (value[keyObject]  instanceof Dsl) {
+                            object[keyObject] = value[keyObject].toString();
+                        } else if (value[keyObject] instanceof Array || value[keyObject] instanceof Object) {
+                            throw 'not simple json in map';
+                        } else {
+                            object[keyObject] = value[keyObject];
+                        }
+                    }
+                    return object;
+                } else if (!(value instanceof Dsl) && (value instanceof Object || value instanceof Array)) {
+                    throw 'must use code editor';
+                }
+            } catch (hasComplexJson) {
+                // any map/array with complex json inside, or other value of complex json,
+                // will force the code editor
+                // (previously we did stringify on entries then tried to parse, but that was inconsistent)
+                scope.state.config.codeModeActive[key] = true;
+                // and the widget mode updated to be 'manual'
+                scope.getConfigWidgetMode(definition, value);
+                
+                return JSON.stringify(value);
+            }
+            
+            // if boolean, return as primitive type
+            if (typeof value === 'boolean') {
+                return value;
+            }
+            
+            // all other primitives treat as string (as they will get a string-based widget)
+            return ""+value;
+        }
+
+        function getModelValueFromString(val) {
+            if (!scope.defined(val) || val==null || typeof val !== 'string') {
+                // only strings will have primitive inference applied
+                // (and this is only invoked when not in code mode)
+                return val;
+            }
+            try {
+                let prim = JSON.parse(val);
+                if (prim instanceof Object || prim instanceof Array || typeof prim === 'string') {
+                    throw 'not primitive';
+                }
+                if (val.match(/^[0-9]*\.([0-9]*0)?$/)) {
+                    // if user enters "3.0" or "1." we should treat it as a string to prevent
+                    // yaml editor from simplifying it to be "3" or "1".
+                    throw 'decimal ending with dot or 0, eg version, treat as string';
+                }
+                // numbers and booleans are unquoted 
+                return prim;
+            } catch (ex) {
+                return val;
+            }
+        }
+        
+        function setModelFromLocalConfig() {
+            let localConfig = scope.config;
+            let result = {};
+            for (let keyRef in localConfig) {
+                if (angular.isUndefined(localConfig[keyRef]) || localConfig[keyRef] === null || localConfig[keyRef].length < 1) {
+                    continue;
+                }
+                if (ANY_MEMBERSPEC_REGEX.test(keyRef) && localConfig[keyRef].hasOwnProperty(REPLACED_DSL_ENTITYSPEC)) {
+                    result[keyRef] = {};
+                    result[keyRef][DSL_ENTITY_SPEC] = localConfig[keyRef][REPLACED_DSL_ENTITYSPEC];
+                    continue;
+                }
+                
+                let definition = scope.model.miscData.get('config').find(config => config.name === keyRef);
+                if (!definition) {
+                    // odd, no def'n for this key; infer
+                    definition = {};
+                    scope.getConfigWidgetMode(definition, localConfig[keyRef])
+                }
+                
+                // if JSON mode then parse
+                scope.state.config.codeModeError[keyRef] = null;
+                if (scope.state.config.codeModeActive && scope.state.config.codeModeActive[keyRef]) {
+                    try {
+                        result[keyRef] = JSON.parse(localConfig[keyRef]);
+                    } catch (ex) {
+                        scope.state.config.codeModeError[keyRef] = "Invalid JSON"; 
+                        result[keyRef] = localConfig[keyRef];
+                    }                    
+                    continue;
+                }
+                
+                // else return as is, or introspect for array/map
+                
+                if (definition.widgetMode === 'array') {
+                    result[keyRef] = localConfig[keyRef].map(getModelValueFromString);
+                    continue;
+                }
+                if (definition.widgetMode === 'map') {
+                    result[keyRef] = {};
+                    for (let keyObject in localConfig[keyRef]) {
+                        result[keyRef][keyObject] = getModelValueFromString(localConfig[keyRef][keyObject]);
+                    }
+                    continue;
+                }
+                
+                result[keyRef] = getModelValueFromString(localConfig[keyRef]);
+            }
+            
+            scope.model.setConfigFromJson(result);
+        }
+
+        function addConfigKey() {
+            let name = scope.state.config.add.value;
+            if (name) {
+                let allConfig = scope.model.miscData.get('config');
+                blueprintService.addConfigKeyDefinition(allConfig, name);
+                scope.model.addConfig(name, '');
+                loadLocalConfigFromModel();
+                scope.state.config.add.value = '';
+                scope.state.config.add.open = false;
+                scope.state.config.filter.values[ CONFIG_FILTERS[CONFIG_FILTERS.length - 1].id ] = true;
+                scope.state.config.focus = name;
+            }
+        }
+
+        function refreshCustomValidation(model) {
+            model.config.forEach((value, key) => {
+                let config = model.miscData.get('config').find(config => config.name === key);
+                // If we need an integer, check if the value is a number
+                if (config.type === 'java.lang.Integer' && !angular.isNumber(value) && !(value instanceof Dsl)) {
+                    model.addIssue(Issue.builder().group('config').ref(key).message($sce.trustAsHtml(`<code>${value}</code> is not a number`)).build());
+                }
+                if (scope.state.config.codeModeError[key]) {
+                    model.addIssue(Issue.builder().group('config').ref(key).message($sce.trustAsHtml(`<code>${value}</code> is not valid JSON`)).build());
+                }
+            });
+        }
+    }
+}
+
+export function specEditorConfigFilter() {
+    return function (input, filtersMapById, currentConfig) {
+        let filters = [];
+        Object.keys(filtersMapById).forEach( (k) => { if (filtersMapById[k]) filters.push(k); } );
+        
+        if (!filters.every(filterId => CONFIG_FILTERS.some(filter => filter.id === filterId))) {
+            return input;
+        }
+        if (!(input instanceof Array)) {
+            return input;
+        }
+        return input.filter((item)=> {
+            return filters.some(filterId => CONFIG_FILTERS.find(filter => filter.id === filterId).filter(item, currentConfig));
+        });
+    }
+}
+
+export function specEditorTypeFilter() {
+    return function (input, search) {
+        if (search.length < 1) {
+            return input;
+        }
+        return input.filter((item)=> {
+            return item.miscData.get('typeName').indexOf(search) > -1 || item.type.indexOf(search) > -1;
+        });
+    }
+}