diff --git a/platform/commonUI/edit/README.md b/platform/commonUI/edit/README.md index 525c852229..3691e8211a 100644 --- a/platform/commonUI/edit/README.md +++ b/platform/commonUI/edit/README.md @@ -1 +1,41 @@ -Contains sources and resources associated with Edit mode. \ No newline at end of file +Contains sources and resources associated with Edit mode. + +# Toolbars + +Views may specify the contents of a toolbar through a `toolbar` +property in their bundle definition. This should appear as the +structure one would provide to the `mct-toolbar` directive, +except additional properties are recognized to support the +mediation between toolbar contents, user interaction, and the +current selection (as read from the `selection` property of the +view's scope.) These additional properties are: + +* `property`: Name of the property within a selected object. If, + for any given object in the selection, that field is a function, + then that function is assumed to be an accessor-mutator function + (that is, it will be called with no arguments to get, and with + an argument to set.) +* `inclusive`: Optional; true if this control should be considered + applicable whenever at least one element in the selection has + the associated property. Otherwise, all members of the current + selection must have this property for the control to be shown. + +Controls in the toolbar are shown based on applicability to the +current selection. Applicability for a given member of the selection +is determined by the presence of absence of the named `property` +field. As a consequence of this, if `undefined` is a valid value for +that property, an accessor-mutator function must be used. Likewise, +if toolbar properties are meant to be view-global (as opposed to +per-selection) then the view must include some object to act as its +proxy in the current selection (in addition to whatever objects the +user will conceive of as part of the current selection), typically +with `inclusive` set to `true`. + +## Selection + +The `selection` property of a view's scope in Edit mode will be +initialized to an empty array. This array's contents may be modified +to implicitly change the contents of the toolbar based on the rules +described above. Care should be taken to modify this array in-place +instead of shadowing it (as the selection will typically +be a few scopes up the hierarchy from the view's actual scope.) diff --git a/platform/commonUI/edit/bundle.json b/platform/commonUI/edit/bundle.json index 475fae23db..953497a388 100644 --- a/platform/commonUI/edit/bundle.json +++ b/platform/commonUI/edit/bundle.json @@ -94,6 +94,9 @@ { "implementation": "EditRepresenter.js", "depends": [ "$q", "$log" ] + }, + { + "implementation": "representers/EditToolbarRepresenter.js" } ] } diff --git a/platform/commonUI/edit/res/templates/edit-object.html b/platform/commonUI/edit/res/templates/edit-object.html index c7339d0c4e..1f9d5763ae 100644 --- a/platform/commonUI/edit/res/templates/edit-object.html +++ b/platform/commonUI/edit/res/templates/edit-object.html @@ -2,20 +2,17 @@ mct-object="domainObject" ng-model="representation"> -
- +
-
+ mct-object="domainObject">
diff --git a/platform/commonUI/edit/src/representers/EditToolbar.js b/platform/commonUI/edit/src/representers/EditToolbar.js new file mode 100644 index 0000000000..591a312aa3 --- /dev/null +++ b/platform/commonUI/edit/src/representers/EditToolbar.js @@ -0,0 +1,170 @@ +/*global define*/ +define( + [], + function () { + "use strict"; + + // Utility functions for reducing truth arrays + function and(a, b) { return a && b; } + function or(a, b) { return a || b; } + + + /** + * Provides initial structure and state (as suitable for provision + * to the `mct-toolbar` directive) for a view's tool bar, based on + * that view's declaration of what belongs in its tool bar and on + * the current selection. + * + * @param structure toolbar structure, as provided by view definition + * @param {Array} selection the current selection state + * @constructor + */ + function EditToolbar(structure, selection) { + var toolbarStructure = Object.create(structure || {}), + toolbarState, + properties = []; + + // Generate a new key for an item's property + function addKey(property) { + properties.push(property); + return properties.length - 1; // Return index of property + } + + // Update value for this property in all elements of the + // selection which have this property. + function updateProperties(property, value) { + // Update property in a selected element + function updateProperty(selected) { + // Ignore selected elements which don't have this property + if (selected[property] !== undefined) { + // Check if this is a setter, or just assignable + if (typeof selected[property] === 'function') { + selected[property](value); + } else { + selected[property] = value; + } + } + } + + // Update property in all selected elements + selection.forEach(updateProperty); + } + + // Look up the current value associated with a property + // in selection i + function lookupState(property, selected) { + var value = selected[property]; + return (typeof value === 'function') ? value() : value; + } + + // Get initial value for a given property + function initializeState(property) { + var result; + // Look through all selections for this property; + // values should all match by the time we perform + // this lookup anyway. + selection.forEach(function (selected) { + result = (selected[property] !== undefined) ? + lookupState(property, selected) : + result; + }); + return result; + } + + // Check if all elements of the selection which have this + // property have the same value for this property. + function isConsistent(property) { + var consistent = true, + observed = false, + state; + + // Check if a given element of the selection is consistent + // with previously-observed elements for this property. + function checkConsistency(selected) { + var next; + // Ignore selections which don't have this property + if (selected[property] !== undefined) { + // Look up state of this element in the selection + next = lookupState(property, selected); + // Detect inconsistency + if (observed) { + consistent = consistent && (next === state); + } + // Track state for next iteration + state = next; + observed = true; + } + } + + // Iterate through selections + selection.forEach(checkConsistency); + + return consistent; + } + + // Used to filter out items which are applicable (or not) + // to the current selection. + function isApplicable(item) { + var property = (item || {}).property, + exclusive = !(item || {}).inclusive; + + // Check if a selected item defines this property + function hasProperty(selected) { + return selected[property] !== undefined; + } + + return property && selection.map(hasProperty).reduce( + exclusive ? and : or, + exclusive + ) && isConsistent(property); + } + + // Prepare a toolbar item based on current selection + function convertItem(item) { + var converted = Object.create(item || {}); + converted.key = addKey(item.property); + return converted; + } + + // Used to filter out sections that have become empty + function nonEmpty(section) { + return section && section.items && section.items.length > 0; + } + + // Prepare a toolbar section based on current selection + function convertSection(section) { + var converted = Object.create(section || {}); + converted.items = + ((section || {}).items || []) + .map(convertItem) + .filter(isApplicable); + return converted; + } + + toolbarStructure.sections = + ((structure || {}).sections || []) + .map(convertSection) + .filter(nonEmpty); + + toolbarState = properties.map(initializeState); + + return { + /** + * + */ + getStructure: function () { + return toolbarStructure; + }, + getState: function () { + return toolbarState; + }, + updateState: function (key, value) { + updateProperties(properties[key], value); + } + }; + } + + return EditToolbar; + } +); + diff --git a/platform/commonUI/edit/src/representers/EditToolbarRepresenter.js b/platform/commonUI/edit/src/representers/EditToolbarRepresenter.js new file mode 100644 index 0000000000..feeb5bcd53 --- /dev/null +++ b/platform/commonUI/edit/src/representers/EditToolbarRepresenter.js @@ -0,0 +1,97 @@ +/*global define*/ + +define( + ['./EditToolbar'], + function (EditToolbar) { + "use strict"; + + // No operation + function noop() {} + + /** + * The EditToolbarRepresenter populates the toolbar in Edit mode + * based on a view's definition. + * @param {Scope} scope the Angular scope of the representation + * @constructor + */ + function EditToolbarRepresenter(scope, element, attrs) { + var definition, + unwatch, + toolbar, + toolbarObject = {}; + + // Handle changes to the current selection + function updateSelection(selection) { + // Make sure selection is array-like + selection = Array.isArray(selection) ? + selection : + (selection ? [selection] : []); + + // Instantiate a new toolbar... + toolbar = new EditToolbar(definition, selection); + + // ...and expose its structure/state + toolbarObject.structure = toolbar.getStructure(); + toolbarObject.state = toolbar.getState(); + } + + // Update selection models to match changed toolbar state + function updateState(state) { + state.forEach(function (value, index) { + toolbar.updateState(index, value); + }); + } + + // Represent a domain object using this definition + function represent(representation) { + // Clear any existing selection + scope.selection = []; + // Get the newest toolbar definition from the view + definition = (representation || {}).toolbar || {}; + // Initialize toolbar to an empty selection + updateSelection([]); + } + + // Destroy; stop watching the parent for changes in + // toolbar state. + function destroy() { + if (unwatch) { + unwatch(); + unwatch = undefined; + } + } + + // If we have been asked to expose toolbar state... + if (attrs.toolbar) { + // Expose toolbar state under that name + scope.$parent[attrs.toolbar] = toolbarObject; + // Detect and handle changes to state from the toolbar + unwatch = scope.$parent.$watchCollection( + attrs.toolbar + ".state", + updateState + ); + // Watch for changes in the current selection state + scope.$watchCollection("selection", updateSelection); + } + + return { + /** + * Set the current representation in use, and the domain + * object being represented. + * + * @param {RepresentationDefinition} representation the + * definition of the representation in use + * @param {DomainObject} domainObject the domain object + * being represented + */ + represent: (attrs || {}).toolbar ? represent : noop, + /** + * Release any resources associated with this representer. + */ + destroy: (attrs || {}).toolbar ? destroy : noop + }; + } + + return EditToolbarRepresenter; + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/representers/EditToolbarRepresenterSpec.js b/platform/commonUI/edit/test/representers/EditToolbarRepresenterSpec.js new file mode 100644 index 0000000000..134f5c0261 --- /dev/null +++ b/platform/commonUI/edit/test/representers/EditToolbarRepresenterSpec.js @@ -0,0 +1,91 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ["../../src/representers/EditToolbarRepresenter"], + function (EditToolbarRepresenter) { + "use strict"; + + describe("The Edit mode toolbar representer", function () { + var mockScope, + mockElement, + testAttrs, + mockUnwatch, + representer; + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + '$scope', + [ '$on', '$watch', '$watchCollection' ] + ); + mockElement = {}; + testAttrs = { toolbar: 'testToolbar' }; + mockScope.$parent = jasmine.createSpyObj( + '$parent', + [ '$watch', '$watchCollection' ] + ); + mockUnwatch = jasmine.createSpy('unwatch'); + + mockScope.$parent.$watchCollection.andReturn(mockUnwatch); + + representer = new EditToolbarRepresenter( + mockScope, + mockElement, + testAttrs + ); + }); + + it("exposes toolbar state under a attr-defined name", function () { + // A strucutre/state object should have been added to the + // parent scope under the name provided in the "toolbar" + // attribute + expect(mockScope.$parent.testToolbar).toBeDefined(); + }); + + it("is robust against lack of a toolbar definition", function () { + expect(function () { + representer.represent({}); + }).not.toThrow(); + }); + + it("watches for toolbar state changes", function () { + expect(mockScope.$parent.$watchCollection).toHaveBeenCalledWith( + "testToolbar.state", + jasmine.any(Function) + ); + }); + + it("stops watching toolbar state when destroyed", function () { + expect(mockUnwatch).not.toHaveBeenCalled(); + representer.destroy(); + expect(mockUnwatch).toHaveBeenCalled(); + }); + + // Verify a simple interaction between selection state and toolbar + // state; more complicated interactions are tested in EditToolbar. + it("conveys state changes", function () { + var testObject = { k: 123 }; + + // Provide a view which has a toolbar + representer.represent({ + toolbar: { sections: [ { items: [ { property: 'k' } ] } ] } + }); + + // Update the selection + mockScope.selection.push(testObject); + expect(mockScope.$watchCollection.mostRecentCall.args[0]) + .toEqual('selection'); // Make sure we're using right watch + mockScope.$watchCollection.mostRecentCall.args[1]([testObject]); + + // Update the state + mockScope.$parent.testToolbar.state[0] = 456; + mockScope.$parent.$watchCollection.mostRecentCall.args[1]( + mockScope.$parent.testToolbar.state + ); + + // Should have updated the original object + expect(testObject.k).toEqual(456); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/representers/EditToolbarSpec.js b/platform/commonUI/edit/test/representers/EditToolbarSpec.js new file mode 100644 index 0000000000..de22fe03e4 --- /dev/null +++ b/platform/commonUI/edit/test/representers/EditToolbarSpec.js @@ -0,0 +1,189 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ['../../src/representers/EditToolbar'], + function (EditToolbar) { + "use strict"; + + describe("An Edit mode toolbar", function () { + var testStructure, + testAB, + testABC, + testABC2, + testABCXYZ, + testABCYZ; + + beforeEach(function () { + testStructure = { + sections: [ + { + items: [ + { name: "A", property: "a" }, + { name: "B", property: "b" }, + { name: "C", property: "c" } + ] + }, + { + items: [ + { name: "X", property: "x", inclusive: true }, + { name: "Y", property: "y" }, + { name: "Z", property: "z" } + ] + } + ] + }; + testAB = { a: 0, b: 1 }; + testABC = { a: 0, b: 1, c: 2 }; + testABC2 = { a: 4, b: 1, c: 2 }; // For inconsistent-state checking + testABCXYZ = { a: 0, b: 1, c: 2, x: 'X!', y: 'Y!', z: 'Z!' }; + testABCYZ = { a: 0, b: 1, c: 2, y: 'Y!', z: 'Z!' }; + }); + + it("provides properties from the original structure", function () { + expect( + new EditToolbar(testStructure, [ testABC ]) + .getStructure() + .sections[0] + .items[1] + .name + ).toEqual("B"); + }); + + // This is needed by mct-toolbar + it("adds keys to form structure", function () { + expect( + new EditToolbar(testStructure, [ testABC ]) + .getStructure() + .sections[0] + .items[1] + .key + ).not.toBeUndefined(); + }); + + it("prunes empty sections", function () { + // Verify that all sections are included when applicable... + expect( + new EditToolbar(testStructure, [ testABCXYZ ]) + .getStructure() + .sections + .length + ).toEqual(2); + // ...but omitted when only some are applicable + expect( + new EditToolbar(testStructure, [ testABC ]) + .getStructure() + .sections + .length + ).toEqual(1); + }); + + it("reads properties from selections", function () { + var toolbar = new EditToolbar(testStructure, [ testABC ]), + structure = toolbar.getStructure(), + state = toolbar.getState(); + + expect(state[structure.sections[0].items[0].key]) + .toEqual(testABC.a); + expect(state[structure.sections[0].items[1].key]) + .toEqual(testABC.b); + expect(state[structure.sections[0].items[2].key]) + .toEqual(testABC.c); + }); + + it("reads properties from getters", function () { + var toolbar, structure, state; + + testABC.a = function () { return "from a getter!"; }; + + toolbar = new EditToolbar(testStructure, [ testABC ]); + structure = toolbar.getStructure(); + state = toolbar.getState(); + + expect(state[structure.sections[0].items[0].key]) + .toEqual("from a getter!"); + }); + + it("sets properties on update", function () { + var toolbar = new EditToolbar(testStructure, [ testABC ]), + structure = toolbar.getStructure(); + toolbar.updateState( + structure.sections[0].items[0].key, + "new value" + ); + // Should have updated the underlying object + expect(testABC.a).toEqual("new value"); + }); + + it("invokes setters on update", function () { + var toolbar, structure, state; + + testABC.a = jasmine.createSpy('a'); + + toolbar = new EditToolbar(testStructure, [ testABC ]); + structure = toolbar.getStructure(); + + toolbar.updateState( + structure.sections[0].items[0].key, + "new value" + ); + // Should have updated the underlying object + expect(testABC.a).toHaveBeenCalledWith("new value"); + }); + + it("removes inapplicable items", function () { + // First, verify with all items + expect( + new EditToolbar(testStructure, [ testABC ]) + .getStructure() + .sections[0] + .items + .length + ).toEqual(3); + // Then, try with some items omitted + expect( + new EditToolbar(testStructure, [ testABC, testAB ]) + .getStructure() + .sections[0] + .items + .length + ).toEqual(2); + }); + + it("removes inconsistent states", function () { + // Only two of three values match among these selections + expect( + new EditToolbar(testStructure, [ testABC, testABC2 ]) + .getStructure() + .sections[0] + .items + .length + ).toEqual(2); + }); + + it("allows inclusive items", function () { + // One inclusive item is in the set, property 'x' of the + // second section; make sure items are pruned down + // when only some of the selection has x,y,z properties + expect( + new EditToolbar(testStructure, [ testABC, testABCXYZ ]) + .getStructure() + .sections[1] + .items + .length + ).toEqual(1); + }); + + it("removes inclusive items when there are no matches", function () { + expect( + new EditToolbar(testStructure, [ testABCYZ ]) + .getStructure() + .sections[1] + .items + .length + ).toEqual(2); + }); + }); + } +); + + diff --git a/platform/commonUI/edit/test/suite.json b/platform/commonUI/edit/test/suite.json index e50fb236d4..5744ff8ea8 100644 --- a/platform/commonUI/edit/test/suite.json +++ b/platform/commonUI/edit/test/suite.json @@ -15,5 +15,7 @@ "capabilities/EditorCapability", "objects/EditableDomainObject", "objects/EditableDomainObjectCache", - "objects/EditableModelCache" + "objects/EditableModelCache", + "representers/EditToolbar", + "representers/EditToolbarRepresenter" ] \ No newline at end of file