From d5aa998b4c40c91059497ef9564ee805097de040 Mon Sep 17 00:00:00 2001 From: Pete Richards Date: Fri, 22 Jul 2016 13:53:03 -0700 Subject: [PATCH] [API] Draft Composition API (#1068) * [Objects] util for equality checking Add a method for checking object equality, useful for other services. * [Composition] Draft Composition API Draft composition API. Composition collections provide an observable for watching and mutating the composition of an object. Composition providers implement the loading and modification of composition. The default composition provider uses the composition attribute of domain objects, while allowing other providers to implement their own loading and mutation behavior. * add todo about event listener bindings * [Type] Add form property for defining form fields * [tutorial] Add Composition tutorial * provider doesn't have to implement events, load returns array of children * use new composition in old api * correct key name * Override instantiate to provide model ids Override instantiate in public API adapter to prevent making changes to platform code. Instantiate now passes the id of the domain object with the model so that capabilities can convert to a new-style domain object and use that to detect functionality. * Implement mutation capability with decorator Implementation mutation capability override with decorator to adapter code outside of platform. Capability override ensures that models are kept in sync even though they are no longer shared objects. * override composition cleanly Override composition capability without making changes inside platform. * cleanup after temporary collections * remove unused try/catch --- API.md | 1 + composition-test.html | 65 ++++++++++ .../src/capabilities/MutationCapability.js | 5 +- src/adapter/bundle.js | 33 ++++- .../capabilities/APICapabilityDecorator.js | 37 ++++++ .../AlternateCompositionCapability.js | 102 ++++++++++++++++ .../synchronizeMutationCapability.js | 27 ++++ src/adapter/services/Instantiate.js | 49 ++++++++ src/api/Type.js | 1 + src/api/api.js | 9 +- src/api/composition/CompositionAPI.js | 39 ++++++ src/api/composition/CompositionCollection.js | 115 ++++++++++++++++++ .../composition/DefaultCompositionProvider.js | 76 ++++++++++++ src/api/composition/README.md | 37 ++++++ src/api/objects/object-utils.js | 7 +- 15 files changed, 593 insertions(+), 10 deletions(-) create mode 100644 composition-test.html create mode 100644 src/adapter/capabilities/APICapabilityDecorator.js create mode 100644 src/adapter/capabilities/AlternateCompositionCapability.js create mode 100644 src/adapter/capabilities/synchronizeMutationCapability.js create mode 100644 src/adapter/services/Instantiate.js create mode 100644 src/api/composition/CompositionAPI.js create mode 100644 src/api/composition/CompositionCollection.js create mode 100644 src/api/composition/DefaultCompositionProvider.js create mode 100644 src/api/composition/README.md diff --git a/API.md b/API.md index cff48d3d81..ca8d040544 100644 --- a/API.md +++ b/API.md @@ -33,6 +33,7 @@ Returns a `typeInstance`. `options` is an object supporting the following prope * `description`: `string`, a human readible description of the object and what it is for. * `initialize`: `function` which initializes new instances of this type. it is called with an object, should add any default properties to that object. * `creatable`: `boolean`, if true, this object will be visible in the create menu. +* `form`: `Array` an array of form fields, as defined... somewhere! Generates a property sheet that is visible while editing this object. ### `MCT.type(typeKey, typeInstance)` Status: First Draft diff --git a/composition-test.html b/composition-test.html new file mode 100644 index 0000000000..3067b4d9cd --- /dev/null +++ b/composition-test.html @@ -0,0 +1,65 @@ + + + + + Implementing a composition provider + + + + + + diff --git a/platform/core/src/capabilities/MutationCapability.js b/platform/core/src/capabilities/MutationCapability.js index 7e8ae257cc..592f613438 100644 --- a/platform/core/src/capabilities/MutationCapability.js +++ b/platform/core/src/capabilities/MutationCapability.js @@ -167,10 +167,7 @@ define( * @memberof platform/core.MutationCapability# */ MutationCapability.prototype.listen = function (listener) { - return this.specificMutationTopic.listen(function (newModel) { - this.domainObject.model = JSON.parse(JSON.stringify(newModel)); - listener(newModel); - }.bind(this)); + return this.specificMutationTopic.listen(listener); }; /** diff --git a/src/adapter/bundle.js b/src/adapter/bundle.js index 2816cb5dc5..30c18594ec 100644 --- a/src/adapter/bundle.js +++ b/src/adapter/bundle.js @@ -1,7 +1,14 @@ define([ 'legacyRegistry', - './directives/MCTView' -], function (legacyRegistry, MCTView) { + './directives/MCTView', + './services/Instantiate', + './capabilities/APICapabilityDecorator' +], function ( + legacyRegistry, + MCTView, + Instantiate, + APICapabilityDecorator +) { legacyRegistry.register('src/adapter', { "extensions": { "directives": [ @@ -13,6 +20,28 @@ define([ "PublicAPI" ] } + ], + services: [ + { + key: "instantiate", + priority: "mandatory", + implementation: Instantiate, + depends: [ + "capabilityService", + "identifierService", + "cacheService" + ] + } + ], + components: [ + { + type: "decorator", + provides: "capabilityService", + implementation: APICapabilityDecorator, + depends: [ + "$injector" + ] + } ] } }); diff --git a/src/adapter/capabilities/APICapabilityDecorator.js b/src/adapter/capabilities/APICapabilityDecorator.js new file mode 100644 index 0000000000..e7850cc99f --- /dev/null +++ b/src/adapter/capabilities/APICapabilityDecorator.js @@ -0,0 +1,37 @@ +define([ + './synchronizeMutationCapability', + './AlternateCompositionCapability' +], function ( + synchronizeMutationCapability, + AlternateCompositionCapability +) { + + /** + * Overrides certain capabilities to keep consistency between old API + * and new API. + */ + function APICapabilityDecorator($injector, capabilityService) { + this.$injector = $injector; + this.capabilityService = capabilityService; + } + + APICapabilityDecorator.prototype.getCapabilities = function ( + model + ) { + var capabilities = this.capabilityService.getCapabilities(model); + if (capabilities.mutation) { + capabilities.mutation = + synchronizeMutationCapability(capabilities.mutation); + } + if (AlternateCompositionCapability.appliesTo(model)) { + capabilities.composition = function (domainObject) { + return new AlternateCompositionCapability(this.$injector, domainObject) + }.bind(this); + } + + return capabilities; + }; + + return APICapabilityDecorator; + +}); diff --git a/src/adapter/capabilities/AlternateCompositionCapability.js b/src/adapter/capabilities/AlternateCompositionCapability.js new file mode 100644 index 0000000000..2eb5224aa1 --- /dev/null +++ b/src/adapter/capabilities/AlternateCompositionCapability.js @@ -0,0 +1,102 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/** + * Module defining AlternateCompositionCapability. Created by vwoeltje on 11/7/14. + */ +define([ + '../../api/objects/object-utils', + '../../api/composition/CompositionAPI' +], function (objectUtils, CompositionAPI) { + + function AlternateCompositionCapability($injector, domainObject) { + this.domainObject = domainObject; + + this.getDependencies = function () { + this.instantiate = $injector.get("instantiate"); + this.contextualize = $injector.get("contextualize"); + this.getDependencies = undefined; + }.bind(this); + } + + AlternateCompositionCapability.prototype.add = function (child, index) { + if (typeof index !== 'undefined') { + // At first glance I don't see a location in the existing + // codebase where add is called with an index. Won't support. + throw new Error( + 'Composition Capability does not support adding at index' + ); + } + + function addChildToComposition(model) { + var existingIndex = model.composition.indexOf(child.getId()); + if (existingIndex === -1) { + model.composition.push(child.getId()) + } + } + + return this.domainObject.useCapability( + 'mutation', + addChildToComposition + ) + .then(this.invoke.bind(this)) + .then(function (children) { + return children.filter(function (c) { + return c.getId() === child.getId(); + })[0]; + }); + }; + + AlternateCompositionCapability.prototype.contextualizeChild = function ( + child + ) { + if (this.getDependencies) { + this.getDependencies(); + } + + var keyString = objectUtils.makeKeyString(child.key); + var oldModel = objectUtils.toOldFormat(child); + var newDO = this.instantiate(oldModel, keyString); + return this.contextualize(newDO, this.domainObject); + + }; + + AlternateCompositionCapability.prototype.invoke = function () { + var newFormatDO = objectUtils.toNewFormat( + this.domainObject.getModel(), + this.domainObject.getId() + ); + var collection = CompositionAPI(newFormatDO); + return collection.load() + .then(function (children) { + collection.destroy(); + return children.map(this.contextualizeChild, this); + }.bind(this)); + }; + + AlternateCompositionCapability.appliesTo = function (model) { + return !!CompositionAPI(objectUtils.toNewFormat(model, model.id)); + }; + + return AlternateCompositionCapability; + } +); diff --git a/src/adapter/capabilities/synchronizeMutationCapability.js b/src/adapter/capabilities/synchronizeMutationCapability.js new file mode 100644 index 0000000000..bee80f6f0e --- /dev/null +++ b/src/adapter/capabilities/synchronizeMutationCapability.js @@ -0,0 +1,27 @@ +define([ + +], function ( + +) { + + /** + * Wraps the mutation capability and synchronizes the mutation + */ + function synchronizeMutationCapability(mutationConstructor) { + + return function makeCapability(domainObject) { + var capability = mutationConstructor(domainObject); + var oldListen = capability.listen.bind(capability); + capability.listen = function (listener) { + return oldListen(function (newModel) { + capability.domainObject.model = + JSON.parse(JSON.stringify(newModel)); + listener(newModel); + }); + }; + return capability; + } + }; + + return synchronizeMutationCapability; +}); diff --git a/src/adapter/services/Instantiate.js b/src/adapter/services/Instantiate.js new file mode 100644 index 0000000000..2f7c25fbca --- /dev/null +++ b/src/adapter/services/Instantiate.js @@ -0,0 +1,49 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +define( + ['../../../platform/core/src/objects/DomainObjectImpl'], + function (DomainObjectImpl) { + + /** + * Overrides platform version of instantiate, passes Id with model such + * that capability detection can utilize new format domain objects. + */ + function Instantiate( + capabilityService, + identifierService, + cacheService + ) { + return function (model, id) { + id = id || identifierService.generate(); + var old_id = model.id; + model.id = id; + var capabilities = capabilityService.getCapabilities(model); + model.id = old_id; + cacheService.put(id, model); + return new DomainObjectImpl(id, model, capabilities); + }; + } + + return Instantiate; + } +); diff --git a/src/api/Type.js b/src/api/Type.js index 15cc500fb3..8354640a9e 100644 --- a/src/api/Type.js +++ b/src/api/Type.js @@ -37,6 +37,7 @@ define(function () { def.name = this.definition.metadata.label; def.glyph = this.definition.metadata.glyph; def.description = this.definition.metadata.description; + def.properties = this.definition.form; if (this.definition.initialize) { def.model = {}; diff --git a/src/api/api.js b/src/api/api.js index cf675f2998..4c221c3f13 100644 --- a/src/api/api.js +++ b/src/api/api.js @@ -2,17 +2,20 @@ define([ './Type', './TimeConductor', './View', - './objects/ObjectAPI' + './objects/ObjectAPI', + './composition/CompositionAPI' ], function ( Type, TimeConductor, View, - ObjectAPI + ObjectAPI, + CompositionAPI ) { return { Type: Type, TimeConductor: new TimeConductor(), View: View, - Objects: ObjectAPI + Objects: ObjectAPI, + Composition: CompositionAPI }; }); diff --git a/src/api/composition/CompositionAPI.js b/src/api/composition/CompositionAPI.js new file mode 100644 index 0000000000..fec2e90449 --- /dev/null +++ b/src/api/composition/CompositionAPI.js @@ -0,0 +1,39 @@ +define([ + 'lodash', + 'EventEmitter', + './DefaultCompositionProvider', + './CompositionCollection' +], function ( + _, + EventEmitter, + DefaultCompositionProvider, + CompositionCollection +) { + + var PROVIDER_REGISTRY = []; + + function getProvider (object) { + return _.find(PROVIDER_REGISTRY, function (p) { + return p.appliesTo(object); + }); + }; + + function composition(object) { + var provider = getProvider(object); + + if (!provider) { + return; + } + + return new CompositionCollection(object, provider); + }; + + composition.addProvider = function (provider) { + PROVIDER_REGISTRY.unshift(provider); + }; + + composition.addProvider(new DefaultCompositionProvider()); + + return composition; + +}); diff --git a/src/api/composition/CompositionCollection.js b/src/api/composition/CompositionCollection.js new file mode 100644 index 0000000000..9ab216e2e3 --- /dev/null +++ b/src/api/composition/CompositionCollection.js @@ -0,0 +1,115 @@ +define([ + 'EventEmitter', + 'lodash', + '../objects/object-utils' +], function ( + EventEmitter, + _, + objectUtils +) { + + function CompositionCollection(domainObject, provider) { + EventEmitter.call(this); + this.domainObject = domainObject; + this.provider = provider; + if (this.provider.on) { + this.provider.on( + this.domainObject, + 'add', + this.onProviderAdd, + this + ); + this.provider.on( + this.domainObject, + 'remove', + this.onProviderRemove, + this + ); + } + }; + + CompositionCollection.prototype = Object.create(EventEmitter.prototype); + + CompositionCollection.prototype.onProviderAdd = function (child) { + this.add(child, true); + }; + + CompositionCollection.prototype.onProviderRemove = function (child) { + this.remove(child, true); + }; + + CompositionCollection.prototype.indexOf = function (child) { + return _.findIndex(this._children, function (other) { + return objectUtils.equals(child, other); + }); + }; + + CompositionCollection.prototype.contains = function (child) { + return this.indexOf(child) !== -1; + }; + + CompositionCollection.prototype.add = function (child, skipMutate) { + if (!this._children) { + throw new Error("Must load composition before you can add!"); + } + if (this.contains(child)) { + if (skipMutate) { + return; // don't add twice, don't error. + } + throw new Error("Unable to add child: already in composition"); + } + this._children.push(child); + this.emit('add', child); + if (!skipMutate) { + // add after we have added. + this.provider.add(this.domainObject, child); + } + }; + + CompositionCollection.prototype.load = function () { + return this.provider.load(this.domainObject) + .then(function (children) { + this._children = []; + children.map(function (c) { + this.add(c, true); + }, this); + this.emit('load'); + return this._children.slice(); + }.bind(this)); + }; + + CompositionCollection.prototype.remove = function (child, skipMutate) { + if (!this.contains(child)) { + if (skipMutate) { + return; + } + throw new Error("Unable to remove child: not found in composition"); + } + var index = this.indexOf(child); + var removed = this._children.splice(index, 1)[0]; + this.emit('remove', index, child); + if (!skipMutate) { + // trigger removal after we have internally removed it. + this.provider.remove(this.domainObject, removed); + } + }; + + CompositionCollection.prototype.destroy = function () { + if (this.provider.off) { + this.provider.off( + this.domainObject, + 'add', + this.onProviderAdd, + this + ); + this.provider.off( + this.domainObject, + 'remove', + this.onProviderRemove, + this + ); + } + }; + + return CompositionCollection +}); diff --git a/src/api/composition/DefaultCompositionProvider.js b/src/api/composition/DefaultCompositionProvider.js new file mode 100644 index 0000000000..d5cfb610cb --- /dev/null +++ b/src/api/composition/DefaultCompositionProvider.js @@ -0,0 +1,76 @@ +define([ + 'lodash', + 'EventEmitter', + '../objects/ObjectAPI', + '../objects/object-utils' +], function ( + _, + EventEmitter, + ObjectAPI, + objectUtils +) { + + function makeEventName(domainObject, event) { + return event + ':' + objectUtils.makeKeyString(domainObject.key); + } + + function DefaultCompositionProvider() { + EventEmitter.call(this); + } + + DefaultCompositionProvider.prototype = + Object.create(EventEmitter.prototype); + + DefaultCompositionProvider.prototype.appliesTo = function (domainObject) { + return !!domainObject.composition; + }; + + DefaultCompositionProvider.prototype.load = function (domainObject) { + return Promise.all(domainObject.composition.map(ObjectAPI.get)); + }; + + DefaultCompositionProvider.prototype.on = function ( + domainObject, + event, + listener, + context + ) { + // these can likely be passed through to the mutation service instead + // of using an eventemitter. + this.addListener( + makeEventName(domainObject, event), + listener, + context + ); + }; + + DefaultCompositionProvider.prototype.off = function ( + domainObject, + event, + listener, + context + ) { + // these can likely be passed through to the mutation service instead + // of using an eventemitter. + this.removeListener( + makeEventName(domainObject, event), + listener, + context + ); + }; + + DefaultCompositionProvider.prototype.remove = function (domainObject, child) { + // TODO: this needs to be synchronized via mutation + var index = domainObject.composition.indexOf(child); + domainObject.composition.splice(index, 1); + this.emit(makeEventName(domainObject, 'remove'), child); + }; + + DefaultCompositionProvider.prototype.add = function (domainObject, child) { + // TODO: this needs to be synchronized via mutation + domainObject.composition.push(child.key); + this.emit(makeEventName(domainObject, 'add'), child); + }; + + return DefaultCompositionProvider; +}); diff --git a/src/api/composition/README.md b/src/api/composition/README.md new file mode 100644 index 0000000000..a1da246a3b --- /dev/null +++ b/src/api/composition/README.md @@ -0,0 +1,37 @@ +# Composition API - Overview + +The composition API is straightforward: + +MCT.composition(object) -- returns a `CompositionCollection` if the object has +composition, returns undefined if it doesn't. + +## CompositionCollection + +Has three events: +* `load`: when the collection has completed loading. +* `add`: when a new object has been added to the collection. +* `remove` when an object has been removed from the collection. + +Has three methods: + +`Collection.load()` -- returns a promise that is fulfilled when the composition + has loaded. +`Collection.add(object)` -- add a domain object to the composition. +`Collection.remove(object)` -- remove the object from the composition. + +## Composition providers +composition providers are anything that meets the following interface: + +* `provider.appliesTo(domainObject)` -> return true if this provider can provide + composition for a given domain object. +* `provider.add(domainObject, childObject)` -> adds object +* `provider.remove(domainObject, childObject)` -> immediately removes objects +* `provider.load(domainObject)` -> returns promise for array of children + +TODO: need to figure out how to make the provider event listeners invisible to the provider. + +There is a default composition provider which handles loading composition for +any object with a `composition` property. If you want specialized composition +loading behavior, implement your own composition provider and register it with + +`MCT.composition.addProvider(myProvider)` diff --git a/src/api/objects/object-utils.js b/src/api/objects/object-utils.js index 9c5dd597f8..69fe5645c0 100644 --- a/src/api/objects/object-utils.js +++ b/src/api/objects/object-utils.js @@ -74,10 +74,15 @@ define([ return model; }; + var equals = function (a, b) { + return makeKeyString(a.key) === makeKeyString(b.key); + }; + return { toOldFormat: toOldFormat, toNewFormat: toNewFormat, makeKeyString: makeKeyString, - parseKeyString: parseKeyString + parseKeyString: parseKeyString, + equals: equals }; });