diff --git a/platform/commonUI/edit/src/capabilities/EditableContextCapability.js b/platform/commonUI/edit/src/capabilities/EditableContextCapability.js index b8658aa19a..a21dc9ba31 100644 --- a/platform/commonUI/edit/src/capabilities/EditableContextCapability.js +++ b/platform/commonUI/edit/src/capabilities/EditableContextCapability.js @@ -28,7 +28,7 @@ define( editableObject, domainObject, cache, - true // Not idempotent + true // Idempotent ); }; } diff --git a/platform/commonUI/edit/src/capabilities/EditableRelationshipCapability.js b/platform/commonUI/edit/src/capabilities/EditableRelationshipCapability.js new file mode 100644 index 0000000000..cc0082757a --- /dev/null +++ b/platform/commonUI/edit/src/capabilities/EditableRelationshipCapability.js @@ -0,0 +1,36 @@ +/*global define*/ + + +define( + ['./EditableLookupCapability'], + function (EditableLookupCapability) { + 'use strict'; + + /** + * Wrapper for the "relationship" capability; + * ensures that any domain objects reachable in Edit mode + * are also wrapped as EditableDomainObjects. + * + * Meant specifically for use by EditableDomainObject and the + * associated cache; the constructor signature is particular + * to a pattern used there and may contain unused arguments. + */ + return function EditableRelationshipCapability( + relationshipCapability, + editableObject, + domainObject, + cache + ) { + // This is a "lookup" style capability (it looks up other + // domain objects), but we do not want to return the same + // specific value every time (composition may change) + return new EditableLookupCapability( + relationshipCapability, + editableObject, + domainObject, + cache, + false // Not idempotent + ); + }; + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/src/objects/EditableDomainObject.js b/platform/commonUI/edit/src/objects/EditableDomainObject.js index a6b3d503d7..4e3363c8e9 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObject.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObject.js @@ -14,6 +14,7 @@ define( '../capabilities/EditablePersistenceCapability', '../capabilities/EditableContextCapability', '../capabilities/EditableCompositionCapability', + '../capabilities/EditableRelationshipCapability', '../capabilities/EditorCapability', './EditableDomainObjectCache' ], @@ -21,6 +22,7 @@ define( EditablePersistenceCapability, EditableContextCapability, EditableCompositionCapability, + EditableRelationshipCapability, EditorCapability, EditableDomainObjectCache ) { @@ -30,6 +32,7 @@ define( persistence: EditablePersistenceCapability, context: EditableContextCapability, composition: EditableCompositionCapability, + relationship: EditableRelationshipCapability, editor: EditorCapability }; @@ -64,7 +67,10 @@ define( // Override certain capabilities editableObject.getCapability = function (name) { var delegateArguments = getDelegateArguments(name, arguments), - capability = domainObject.getCapability.apply(this, delegateArguments), + capability = domainObject.getCapability.apply( + this, + delegateArguments + ), factory = capabilityFactories[name]; return (factory && capability) ? diff --git a/platform/commonUI/edit/test/capabilities/EditableRelationshipCapabilitySpec.js b/platform/commonUI/edit/test/capabilities/EditableRelationshipCapabilitySpec.js new file mode 100644 index 0000000000..65d044d7c8 --- /dev/null +++ b/platform/commonUI/edit/test/capabilities/EditableRelationshipCapabilitySpec.js @@ -0,0 +1,54 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ["../../src/capabilities/EditableRelationshipCapability"], + function (EditableRelationshipCapability) { + "use strict"; + + describe("An editable relationship capability", function () { + var mockContext, + mockEditableObject, + mockDomainObject, + mockTestObject, + someValue, + mockFactory, + capability; + + beforeEach(function () { + // EditableContextCapability should watch ALL + // methods for domain objects, so give it an + // arbitrary interface to wrap. + mockContext = + jasmine.createSpyObj("context", [ "getDomainObject" ]); + mockTestObject = jasmine.createSpyObj( + "domainObject", + [ "getId", "getModel", "getCapability" ] + ); + mockFactory = + jasmine.createSpyObj("factory", ["getEditableObject"]); + + someValue = { x: 42 }; + + mockContext.getDomainObject.andReturn(mockTestObject); + mockFactory.getEditableObject.andReturn(someValue); + + capability = new EditableRelationshipCapability( + mockContext, + mockEditableObject, + mockDomainObject, + mockFactory + ); + + }); + + // Most behavior is tested for EditableLookupCapability, + // so just verify that this isse + it("presumes non-idempotence of its wrapped capability", function () { + expect(capability.getDomainObject()) + .toEqual(capability.getDomainObject()); + expect(mockContext.getDomainObject.calls.length).toEqual(2); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/suite.json b/platform/commonUI/edit/test/suite.json index 46f0443b41..49fffec4a7 100644 --- a/platform/commonUI/edit/test/suite.json +++ b/platform/commonUI/edit/test/suite.json @@ -9,6 +9,7 @@ "capabilities/EditableContextCapability", "capabilities/EditableLookupCapability", "capabilities/EditablePersistenceCapability", + "capabilities/EditableRelationshipCapability", "capabilities/EditorCapability", "controllers/EditActionController", "controllers/EditController", diff --git a/platform/commonUI/general/bundle.json b/platform/commonUI/general/bundle.json index bd94eaabdf..3dfc0c0447 100644 --- a/platform/commonUI/general/bundle.json +++ b/platform/commonUI/general/bundle.json @@ -88,6 +88,11 @@ { "key": "SplitPaneController", "implementation": "controllers/SplitPaneController.js" + }, + { + "key": "SelectorController", + "implementation": "controllers/SelectorController.js", + "depends": [ "objectService", "$scope" ] } ], "directives": [ @@ -185,6 +190,12 @@ "uses": [ "view" ] } ], + "controls": [ + { + "key": "selector", + "templateUrl": "templates/controls/selector.html" + } + ], "licenses": [ { "name": "Modernizr", diff --git a/platform/commonUI/general/res/templates/controls/selector.html b/platform/commonUI/general/res/templates/controls/selector.html new file mode 100644 index 0000000000..538b6b2a83 --- /dev/null +++ b/platform/commonUI/general/res/templates/controls/selector.html @@ -0,0 +1,62 @@ +
+
+
Available
+ + +
+ + +
+
+
+ +
+
+
Selected
+ + +
+
    +
  • + + + +
  • +
+
+
+
\ No newline at end of file diff --git a/platform/commonUI/general/src/controllers/SelectorController.js b/platform/commonUI/general/src/controllers/SelectorController.js new file mode 100644 index 0000000000..7ef26cd2e6 --- /dev/null +++ b/platform/commonUI/general/src/controllers/SelectorController.js @@ -0,0 +1,135 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + var ROOT_ID = "ROOT"; + + /** + * Controller for the domain object selector control. + * @constructor + * @param {ObjectService} objectService service from which to + * read domain objects + * @param $scope Angular scope for this controller + */ + function SelectorController(objectService, $scope) { + var treeModel = {}, + listModel = {}, + selectedObjects = [], + rootObject, + previousSelected; + + // For watch; look at the user's selection in the tree + function getTreeSelection() { + return treeModel.selectedObject; + } + + // Get the value of the field being edited + function getField() { + return $scope.ngModel[$scope.field] || []; + } + + // Get the value of the field being edited + function setField(value) { + $scope.ngModel[$scope.field] = value; + } + + // Store root object for subsequent exposure to template + function storeRoot(objects) { + rootObject = objects[ROOT_ID]; + } + + // Check that a selection is of the valid type + function validateTreeSelection(selectedObject) { + var type = selectedObject && + selectedObject.getCapability('type'); + + // Delegate type-checking to the capability... + if (!type || !type.instanceOf($scope.structure.type)) { + treeModel.selectedObject = previousSelected; + } + + // Track current selection to restore it if an invalid + // selection is made later. + previousSelected = treeModel.selectedObject; + } + + // Update the right-hand list of currently-selected objects + function updateList(ids) { + function updateSelectedObjects(objects) { + // Look up from the + function getObject(id) { return objects[id]; } + selectedObjects = ids.filter(getObject).map(getObject); + } + + // Look up objects by id, then populate right-hand list + objectService.getObjects(ids).then(updateSelectedObjects); + } + + // Reject attempts to select objects of the wrong type + $scope.$watch(getTreeSelection, validateTreeSelection); + + // Make sure right-hand list matches underlying model + $scope.$watchCollection(getField, updateList); + + // Look up root object, then store it + objectService.getObjects([ROOT_ID]).then(storeRoot); + + return { + /** + * Get the root object to show in the left-hand tree. + * @returns {DomainObject} the root object + */ + root: function () { + return rootObject; + }, + /** + * Add a domain object to the list of selected objects. + * @param {DomainObject} the domain object to select + */ + select: function (domainObject) { + var id = domainObject && domainObject.getId(), + list = getField() || []; + // Only select if we have a valid id, + // and it isn't already selected + if (id && list.indexOf(id) === -1) { + setField(list.concat([id])); + } + }, + /** + * Remove a domain object from the list of selected objects. + * @param {DomainObject} the domain object to select + */ + deselect: function (domainObject) { + var id = domainObject && domainObject.getId(), + list = getField() || []; + // Only change if this was a valid id, + // for an object which was already selected + if (id && list.indexOf(id) !== -1) { + // Filter it out of the current field + setField(list.filter(function (otherId) { + return otherId !== id; + })); + // Clear the current list selection + delete listModel.selectedObject; + } + }, + /** + * Get the currently-selected domain objects. + * @returns {DomainObject[]} the current selection + */ + selected: function () { + return selectedObjects; + }, + // Expose tree/list model for use in template directly + treeModel: treeModel, + listModel: listModel + }; + } + + + return SelectorController; + } +); \ No newline at end of file diff --git a/platform/commonUI/general/test/controllers/SelectorControllerSpec.js b/platform/commonUI/general/test/controllers/SelectorControllerSpec.js new file mode 100644 index 0000000000..0f03e72c45 --- /dev/null +++ b/platform/commonUI/general/test/controllers/SelectorControllerSpec.js @@ -0,0 +1,163 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/controllers/SelectorController"], + function (SelectorController) { + "use strict"; + + describe("The controller for the 'selector' control", function () { + var mockObjectService, + mockScope, + mockDomainObject, + mockType, + mockDomainObjects, + controller; + + function promiseOf(v) { + return (v || {}).then ? v : { + then: function (callback) { + return promiseOf(callback(v)); + } + }; + } + + function makeMockObject(id) { + var mockObject = jasmine.createSpyObj( + 'object-' + id, + [ 'getId' ] + ); + mockObject.getId.andReturn(id); + return mockObject; + } + + beforeEach(function () { + mockObjectService = jasmine.createSpyObj( + 'objectService', + ['getObjects'] + ); + mockScope = jasmine.createSpyObj( + '$scope', + ['$watch', '$watchCollection'] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability', 'hasCapability' ] + ); + mockType = jasmine.createSpyObj( + 'type', + [ 'instanceOf' ] + ); + mockDomainObjects = {}; + + [ "ROOT", "abc", "def", "xyz" ].forEach(function (id) { + mockDomainObjects[id] = makeMockObject(id); + }); + + mockDomainObject.getCapability.andReturn(mockType); + mockObjectService.getObjects.andReturn(promiseOf(mockDomainObjects)); + mockScope.field = "testField"; + mockScope.ngModel = {}; + + controller = new SelectorController( + mockObjectService, + mockScope + ); + }); + + it("loads the root object", function () { + expect(mockObjectService.getObjects) + .toHaveBeenCalledWith(["ROOT"]); + }); + + it("watches for changes in selection in left-hand tree", function () { + var testObject = { a: 123, b: 456 }; + // This test is sensitive to ordering of watch calls + expect(mockScope.$watch.calls.length).toEqual(1); + // Make sure we're watching the correct object + controller.treeModel.selectedObject = testObject; + expect(mockScope.$watch.calls[0].args[0]()).toBe(testObject); + }); + + it("watches for changes in controlled property", function () { + var testValue = [ "a", "b", 1, 2 ]; + // This test is sensitive to ordering of watch calls + expect(mockScope.$watchCollection.calls.length).toEqual(1); + // Make sure we're watching the correct object + mockScope.ngModel = { testField: testValue }; + expect(mockScope.$watchCollection.calls[0].args[0]()).toBe(testValue); + }); + + it("rejects selection of incorrect types", function () { + mockScope.structure = { type: "someType" }; + mockType.instanceOf.andReturn(false); + controller.treeModel.selectedObject = mockDomainObject; + // Fire the watch + mockScope.$watch.calls[0].args[1](mockDomainObject); + // Should have cleared the selection + expect(controller.treeModel.selectedObject).toBeUndefined(); + // Verify interaction (that instanceOf got a useful argument) + expect(mockType.instanceOf).toHaveBeenCalledWith("someType"); + }); + + it("permits selection of matching types", function () { + mockScope.structure = { type: "someType" }; + mockType.instanceOf.andReturn(true); + controller.treeModel.selectedObject = mockDomainObject; + // Fire the watch + mockScope.$watch.calls[0].args[1](mockDomainObject); + // Should have preserved the selection + expect(controller.treeModel.selectedObject).toEqual(mockDomainObject); + // Verify interaction (that instanceOf got a useful argument) + expect(mockType.instanceOf).toHaveBeenCalledWith("someType"); + }); + + it("loads objects when the underlying list changes", function () { + var testIds = [ "abc", "def", "xyz" ]; + // This test is sensitive to ordering of watch calls + expect(mockScope.$watchCollection.calls.length).toEqual(1); + // Make sure we're watching the correct object + mockScope.ngModel = { testField: testIds }; + // Fire the watch + mockScope.$watchCollection.calls[0].args[1](testIds); + // Should have loaded the corresponding objects + expect(mockObjectService.getObjects).toHaveBeenCalledWith(testIds); + }); + + it("exposes the root object to populate the left-hand tree", function () { + expect(controller.root()).toEqual(mockDomainObjects.ROOT); + }); + + it("adds objects to the underlying model", function () { + expect(mockScope.ngModel.testField).toBeUndefined(); + controller.select(mockDomainObjects.def); + expect(mockScope.ngModel.testField).toEqual(["def"]); + controller.select(mockDomainObjects.abc); + expect(mockScope.ngModel.testField).toEqual(["def", "abc"]); + }); + + it("removes objects to the underlying model", function () { + controller.select(mockDomainObjects.def); + controller.select(mockDomainObjects.abc); + expect(mockScope.ngModel.testField).toEqual(["def", "abc"]); + controller.deselect(mockDomainObjects.def); + expect(mockScope.ngModel.testField).toEqual(["abc"]); + }); + + it("provides a list of currently-selected objects", function () { + // Verify precondition + expect(controller.selected()).toEqual([]); + // Select some objects + controller.select(mockDomainObjects.def); + controller.select(mockDomainObjects.abc); + // Fire the watch for the id changes... + mockScope.$watchCollection.calls[0].args[1]( + mockScope.$watchCollection.calls[0].args[0]() + ); + // Should have loaded and exposed those objects + expect(controller.selected()).toEqual( + [mockDomainObjects.def, mockDomainObjects.abc] + ); + }); + }); + } +); diff --git a/platform/commonUI/general/test/suite.json b/platform/commonUI/general/test/suite.json index 58d94a4d95..e8c9674b44 100644 --- a/platform/commonUI/general/test/suite.json +++ b/platform/commonUI/general/test/suite.json @@ -4,6 +4,7 @@ "controllers/ClickAwayController", "controllers/ContextMenuController", "controllers/GetterSetterController", + "controllers/SelectorController", "controllers/SplitPaneController", "controllers/ToggleController", "controllers/TreeNodeController", diff --git a/platform/core/src/capabilities/RelationshipCapability.js b/platform/core/src/capabilities/RelationshipCapability.js new file mode 100644 index 0000000000..bebc90aba7 --- /dev/null +++ b/platform/core/src/capabilities/RelationshipCapability.js @@ -0,0 +1,118 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Relationship capability. Describes a domain objects relationship + * to other domain objects within the system, and provides a way to + * access related objects. + * + * For most cases, this is not the capability to use; the + * `composition` capability describes the more general relationship + * between objects typically seen (e.g. in the tree.) This capability + * is instead intended for the more unusual case of relationships + * which are not intended to appear in the tree, but are instead + * intended only for special, limited usage. + * + * @constructor + */ + function RelationshipCapability($injector, domainObject) { + var objectService, + lastPromise = {}, + lastModified; + + // Get a reference to the object service from $injector + function injectObjectService() { + objectService = $injector.get("objectService"); + return objectService; + } + + // Get a reference to the object service (either cached or + // from the injector) + function getObjectService() { + return objectService || injectObjectService(); + } + + // Promise this domain object's composition (an array of domain + // object instances corresponding to ids in its model.) + function promiseRelationships(key) { + var model = domainObject.getModel(), + ids; + + // Package objects as an array + function packageObject(objects) { + return ids.map(function (id) { + return objects[id]; + }).filter(function (obj) { + return obj; + }); + } + + // Clear cached promises if modification has occurred + if (lastModified !== model.modified) { + lastPromise = {}; + lastModified = model.modified; + } + + // Make a new request if needed + if (!lastPromise[key]) { + ids = (model.relationships || {})[key] || []; + lastModified = model.modified; + // Load from the underlying object service + lastPromise[key] = getObjectService().getObjects(ids) + .then(packageObject); + } + + return lastPromise; + } + + // List types of relationships which this object has + function listRelationships() { + var relationships = + (domainObject.getModel() || {}).relationships || {}; + + // Check if this key really does expose an array of ids + // (to filter out malformed relationships) + function isArray(key) { + return Array.isArray(relationships[key]); + } + + return Object.keys(relationships).filter(isArray).sort(); + } + + return { + /** + * List all types of relationships exposed by this + * object. + * @returns {string[]} a list of all relationship types + */ + listRelationships: listRelationships, + /** + * Request related objects, with a given relationship type. + * This will typically require asynchronous lookup, so this + * returns a promise. + * @param {string} key the type of relationship + * @returns {Promise.} a promise for related + * domain objects + */ + getRelatedObjects: promiseRelationships + }; + } + + /** + * Test to determine whether or not this capability should be exposed + * by a domain object based on its model. Checks for the presence of + * a `relationships` field, that must be an object. + * @param model the domain object model + * @returns {boolean} true if this object has relationships + */ + RelationshipCapability.appliesTo = function (model) { + return !!(model || {}).relationships; + }; + + return RelationshipCapability; + } +); \ No newline at end of file diff --git a/platform/core/test/capabilities/RelationshipCapabilitySpec.js b/platform/core/test/capabilities/RelationshipCapabilitySpec.js new file mode 100644 index 0000000000..8421531305 --- /dev/null +++ b/platform/core/test/capabilities/RelationshipCapabilitySpec.js @@ -0,0 +1,125 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * CompositionCapabilitySpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../../src/capabilities/RelationshipCapability"], + function (RelationshipCapability) { + "use strict"; + + var DOMAIN_OBJECT_METHODS = [ + "getId", + "getModel", + "getCapability", + "hasCapability", + "useCapability" + ]; + + describe("The relationship capability", function () { + var mockDomainObject, + mockInjector, + mockObjectService, + relationship; + + // Composition Capability makes use of promise chaining, + // so support that, but don't introduce complication of + // native promises. + function mockPromise(value) { + return { + then: function (callback) { + return mockPromise(callback(value)); + } + }; + } + + beforeEach(function () { + mockDomainObject = jasmine.createSpyObj( + "domainObject", + DOMAIN_OBJECT_METHODS + ); + + mockObjectService = jasmine.createSpyObj( + "objectService", + [ "getObjects" ] + ); + + mockInjector = { + get: function (name) { + return (name === "objectService") && mockObjectService; + } + }; + + mockObjectService.getObjects.andReturn(mockPromise([])); + + relationship = new RelationshipCapability( + mockInjector, + mockDomainObject + ); + }); + + it("applies only to models with a 'relationships' field", function () { + expect(RelationshipCapability.appliesTo({ relationships: {} })) + .toBeTruthy(); + expect(RelationshipCapability.appliesTo({})) + .toBeFalsy(); + }); + + it("requests ids found in model's composition from the object service", function () { + var ids = [ "a", "b", "c", "xyz" ]; + + mockDomainObject.getModel.andReturn({ relationships: { xyz: ids } }); + + relationship.getRelatedObjects('xyz'); + + expect(mockObjectService.getObjects).toHaveBeenCalledWith(ids); + }); + + it("provides a list of relationship types", function () { + mockDomainObject.getModel.andReturn({ relationships: { + abc: [ 'a', 'b' ], + def: "not an array, should be ignored", + xyz: [] + } }); + expect(relationship.listRelationships()).toEqual(['abc', 'xyz']); + }); + + it("avoids redundant requests", function () { + // Lookups can be expensive, so this capability + // should have some self-caching + var response; + + mockDomainObject.getModel + .andReturn({ relationships: { xyz: ['a'] } }); + + // Call twice; response should be the same object instance + expect(relationship.getRelatedObjects('xyz')) + .toBe(relationship.getRelatedObjects('xyz')); + + // Should have only made one call + expect(mockObjectService.getObjects.calls.length) + .toEqual(1); + }); + + it("makes new requests on modification", function () { + // Lookups can be expensive, so this capability + // should have some self-caching + var response, testModel; + + testModel = { relationships: { xyz: ['a'] } }; + + mockDomainObject.getModel.andReturn(testModel); + + // Call twice, but as if modification had occurred in between + relationship.getRelatedObjects('xyz'); + testModel.modified = 123; + relationship.getRelatedObjects('xyz'); + + // Should have only made one call + expect(mockObjectService.getObjects.calls.length) + .toEqual(2); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/core/test/suite.json b/platform/core/test/suite.json index 335b86d190..68990a191e 100644 --- a/platform/core/test/suite.json +++ b/platform/core/test/suite.json @@ -11,6 +11,7 @@ "capabilities/DelegationCapability", "capabilities/MutationCapability", "capabilities/PersistenceCapability", + "capabilities/RelationshipCapability", "models/ModelAggregator", "models/PersistedModelProvider", diff --git a/platform/features/layout/bundle.json b/platform/features/layout/bundle.json index 61c5a87fd2..558bd7222a 100644 --- a/platform/features/layout/bundle.json +++ b/platform/features/layout/bundle.json @@ -221,6 +221,12 @@ ], "key": "layoutGrid", "conversion": "number[]" + }, + { + "label": "Panel(s)", + "control": "selector", + "type": "telemetry.panel", + "key": "somethingElse" } ] },