diff --git a/platform/commonUI/edit/README.md b/platform/commonUI/edit/README.md index 3691e8211a..a8c552fb3e 100644 --- a/platform/commonUI/edit/README.md +++ b/platform/commonUI/edit/README.md @@ -15,6 +15,8 @@ view's scope.) These additional properties are: 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.) +* `method`: Name of a method to invoke upon a selected object when + a control is activated, e.g. on a button click. * `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 diff --git a/platform/commonUI/edit/res/templates/edit-object.html b/platform/commonUI/edit/res/templates/edit-object.html index 1f9d5763ae..2ff322e37c 100644 --- a/platform/commonUI/edit/res/templates/edit-object.html +++ b/platform/commonUI/edit/res/templates/edit-object.html @@ -12,7 +12,7 @@
+ mct-object="representation.selected.key && domainObject">
diff --git a/platform/commonUI/edit/src/representers/EditToolbar.js b/platform/commonUI/edit/src/representers/EditToolbar.js index 591a312aa3..60d6990bb6 100644 --- a/platform/commonUI/edit/src/representers/EditToolbar.js +++ b/platform/commonUI/edit/src/representers/EditToolbar.js @@ -17,9 +17,10 @@ define( * * @param structure toolbar structure, as provided by view definition * @param {Array} selection the current selection state + * @param {Function} commit callback to invoke after changes * @constructor */ - function EditToolbar(structure, selection) { + function EditToolbar(structure, selection, commit) { var toolbarStructure = Object.create(structure || {}), toolbarState, properties = []; @@ -106,23 +107,46 @@ define( // to the current selection. function isApplicable(item) { var property = (item || {}).property, + method = (item || {}).method, exclusive = !(item || {}).inclusive; // Check if a selected item defines this property function hasProperty(selected) { - return selected[property] !== undefined; + return (property && (selected[property] !== undefined)) || + (method && (typeof selected[method] === 'function')); } - return property && selection.map(hasProperty).reduce( + return selection.map(hasProperty).reduce( exclusive ? and : or, exclusive ) && isConsistent(property); } + // Invoke all functions in selections with the given name + function invoke(method, value) { + if (method) { + // Make the change in the selection + selection.forEach(function (selected) { + if (typeof selected[method] === 'function') { + selected[method](value); + } + }); + // ...and commit! + commit(); + } + } + // Prepare a toolbar item based on current selection function convertItem(item) { var converted = Object.create(item || {}); - converted.key = addKey(item.property); + if (item.property) { + converted.key = addKey(item.property); + } + if (item.method) { + converted.click = function (v) { + invoke(item.method, v); + }; + } return converted; } diff --git a/platform/commonUI/edit/src/representers/EditToolbarRepresenter.js b/platform/commonUI/edit/src/representers/EditToolbarRepresenter.js index feeb5bcd53..8e4958d5df 100644 --- a/platform/commonUI/edit/src/representers/EditToolbarRepresenter.js +++ b/platform/commonUI/edit/src/representers/EditToolbarRepresenter.js @@ -20,6 +20,13 @@ define( toolbar, toolbarObject = {}; + // Mark changes as ready to persist + function commit(message) { + if (scope.commit) { + scope.commit(message); + } + } + // Handle changes to the current selection function updateSelection(selection) { // Make sure selection is array-like @@ -28,7 +35,7 @@ define( (selection ? [selection] : []); // Instantiate a new toolbar... - toolbar = new EditToolbar(definition, selection); + toolbar = new EditToolbar(definition, selection, commit); // ...and expose its structure/state toolbarObject.structure = toolbar.getStructure(); @@ -37,9 +44,12 @@ define( // Update selection models to match changed toolbar state function updateState(state) { + // Update underlying state based on toolbar changes state.forEach(function (value, index) { toolbar.updateState(index, value); }); + // Commit the changes. + commit("Changes from toolbar."); } // Represent a domain object using this definition diff --git a/platform/commonUI/edit/test/representers/EditToolbarRepresenterSpec.js b/platform/commonUI/edit/test/representers/EditToolbarRepresenterSpec.js index 134f5c0261..9ca9bb65d4 100644 --- a/platform/commonUI/edit/test/representers/EditToolbarRepresenterSpec.js +++ b/platform/commonUI/edit/test/representers/EditToolbarRepresenterSpec.js @@ -15,7 +15,7 @@ define( beforeEach(function () { mockScope = jasmine.createSpyObj( '$scope', - [ '$on', '$watch', '$watchCollection' ] + [ '$on', '$watch', '$watchCollection', "commit" ] ); mockElement = {}; testAttrs = { toolbar: 'testToolbar' }; diff --git a/platform/commonUI/edit/test/representers/EditToolbarSpec.js b/platform/commonUI/edit/test/representers/EditToolbarSpec.js index de22fe03e4..6f93bf04e8 100644 --- a/platform/commonUI/edit/test/representers/EditToolbarSpec.js +++ b/platform/commonUI/edit/test/representers/EditToolbarSpec.js @@ -11,7 +11,8 @@ define( testABC, testABC2, testABCXYZ, - testABCYZ; + testABCYZ, + testM; beforeEach(function () { testStructure = { @@ -29,6 +30,11 @@ define( { name: "Y", property: "y" }, { name: "Z", property: "z" } ] + }, + { + items: [ + { name: "M", method: "m" } + ] } ] }; @@ -37,6 +43,7 @@ define( 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!' }; + testM = { m: jasmine.createSpy("method") }; }); it("provides properties from the original structure", function () { @@ -182,6 +189,19 @@ define( .length ).toEqual(2); }); + + it("adds click functions when a method is specified", function () { + var testCommit = jasmine.createSpy('commit'), + toolbar = new EditToolbar(testStructure, [ testM ], testCommit); + // Verify precondition + expect(testM.m).not.toHaveBeenCalled(); + // Click! + toolbar.getStructure().sections[0].items[0].click(); + // Should have called the underlying function + expect(testM.m).toHaveBeenCalled(); + // Should also have committed the change + expect(testCommit).toHaveBeenCalled(); + }); }); } ); diff --git a/platform/features/layout/bundle.json b/platform/features/layout/bundle.json index 0ce44d3016..8ded0c2f6f 100644 --- a/platform/features/layout/bundle.json +++ b/platform/features/layout/bundle.json @@ -19,7 +19,31 @@ "type": "telemetry.panel", "templateUrl": "templates/fixed.html", "uses": [ "composition" ], - "gestures": [ "drop" ] + "gestures": [ "drop" ], + "toolbar": { + "sections": [ + { + "items": [ + { + "method": "add", + "control": "button", + "text": "Add", + "inclusive": true + } + ] + }, + { + "items": [ + { + "method": "remove", + "control": "button", + "text": "Remove", + "inclusive": true + } + ] + } + ] + } } ], "representations": [ @@ -40,6 +64,12 @@ "depends": [ "$scope", "telemetrySubscriber", "telemetryFormatter" ] } ], + "templates": [ + { + "key": "fixed.telemetry", + "templateUrl": "templates/elements/telemetry.html" + } + ], "types": [ { "key": "layout", diff --git a/platform/features/layout/res/templates/elements/telemetry.html b/platform/features/layout/res/templates/elements/telemetry.html new file mode 100644 index 0000000000..cb12e916b7 --- /dev/null +++ b/platform/features/layout/res/templates/elements/telemetry.html @@ -0,0 +1,8 @@ +
+
+ {{ngModel.name}} +
+
+ {{ngModel.value}} +
+
\ No newline at end of file diff --git a/platform/features/layout/res/templates/fixed.html b/platform/features/layout/res/templates/fixed.html index b91f3e524b..0b7e1ef056 100644 --- a/platform/features/layout/res/templates/fixed.html +++ b/platform/features/layout/res/templates/fixed.html @@ -1,36 +1,25 @@ - -
-
-
- - -
- -
- {{childObject.getModel().name}} -
-
- {{controller.getValue(childObject.getId())}} + +
+
- - - - - - -
- + + +
\ No newline at end of file diff --git a/platform/features/layout/src/FixedController.js b/platform/features/layout/src/FixedController.js index 07f0f99731..d18d286840 100644 --- a/platform/features/layout/src/FixedController.js +++ b/platform/features/layout/src/FixedController.js @@ -1,8 +1,8 @@ /*global define*/ define( - ['./LayoutDrag'], - function (LayoutDrag) { + ['./LayoutDrag', './LayoutSelection', './FixedProxy', './elements/ElementProxies'], + function (LayoutDrag, LayoutSelection, FixedProxy, ElementProxies) { "use strict"; var DEFAULT_DIMENSIONS = [ 2, 1 ], @@ -20,31 +20,23 @@ define( function FixedController($scope, telemetrySubscriber, telemetryFormatter) { var gridSize = DEFAULT_GRID_SIZE, gridExtent = DEFAULT_GRID_EXTENT, - activeDrag, - activeDragId, + dragging, subscription, - values = {}, cellStyles = [], - rawPositions = {}, - positions = {}; - - // Utility function to copy raw positions from configuration, - // without writing directly to configuration (to avoid triggering - // persistence from watchers during drags). - function shallowCopy(obj, keys) { - var copy = {}; - keys.forEach(function (k) { - copy[k] = obj[k]; - }); - return copy; - } + elementProxies = [], + elementProxiesById = {}, + selection; // Refresh cell styles (e.g. because grid extent changed) function refreshCellStyles() { var x, y; + // Clear previous styles cellStyles = []; + // Update grid size from model + gridSize = ($scope.model || {}).layoutGrid || gridSize; + for (x = 0; x < gridExtent[0]; x += 1) { for (y = 0; y < gridExtent[1]; y += 1) { // Position blocks; subtract out border size from w/h @@ -58,62 +50,28 @@ define( } } - // Convert from { positions: ..., dimensions: ... } to an - // apropriate ng-style argument, to position frames. + // Convert from element x/y/width/height to an + // apropriate ng-style argument, to position elements. function convertPosition(raw) { // Multiply position/dimensions by grid size return { - left: (gridSize[0] * raw.position[0]) + 'px', - top: (gridSize[1] * raw.position[1]) + 'px', - width: (gridSize[0] * raw.dimensions[0]) + 'px', - height: (gridSize[1] * raw.dimensions[1]) + 'px' + left: (gridSize[0] * raw.x) + 'px', + top: (gridSize[1] * raw.y) + 'px', + width: (gridSize[0] * raw.width) + 'px', + height: (gridSize[1] * raw.height) + 'px' }; } - // Generate a default position (in its raw format) for a frame. - // Use an index to ensure that default positions are unique. - function defaultPosition(index) { - return { - position: [index, index], - dimensions: DEFAULT_DIMENSIONS - }; - } - - // Store a computed position for a contained frame by its - // domain object id. Called in a forEach loop, so arguments - // are as expected there. - function populatePosition(id, index) { - rawPositions[id] = - rawPositions[id] || defaultPosition(index || 0); - positions[id] = - convertPosition(rawPositions[id]); - } - - // Compute panel positions based on the layout's object model - function lookupPanels(ids) { - var configuration = $scope.configuration || {}; - ids = ids || []; - - // Pull panel positions from configuration - rawPositions = shallowCopy(configuration.elements || {}, ids); - - // Clear prior computed positions - positions = {}; - - // Update width/height that we are tracking - gridSize = ($scope.model || {}).layoutGrid || DEFAULT_GRID_SIZE; - - // Compute positions and add defaults where needed - ids.forEach(populatePosition); - } - // Update the displayed value for this object function updateValue(telemetryObject) { var id = telemetryObject && telemetryObject.getId(); if (id) { - values[id] = telemetryFormatter.formatRangeValue( - subscription.getRangeValue(telemetryObject) - ); + (elementProxiesById[id] || []).forEach(function (element) { + element.name = telemetryObject.getModel().name; + element.value = telemetryFormatter.formatRangeValue( + subscription.getRangeValue(telemetryObject) + ); + }); } } @@ -124,6 +82,59 @@ define( } } + // Decorate an element for display + function makeProxyElement(element, index, elements) { + var ElementProxy = ElementProxies[element.type], + e = ElementProxy && new ElementProxy(element, index, elements); + + if (e) { + // Provide a displayable position (convert from grid to px) + e.style = convertPosition(element); + // Template names are same as type names, presently + e.template = element.type; + } + + return e; + } + + // Decorate elements in the current configuration + function refreshElements() { + // Cache selection; we are instantiating new proxies + // so we may want to restore this. + var selected = selection && selection.get(), + elements = (($scope.configuration || {}).elements || []), + index = -1; // Start with a 'not-found' value + + // Find the selection in the new array + if (selected !== undefined) { + index = elements.indexOf(selected.element); + } + + // Create the new proxies... + elementProxies = elements.map(makeProxyElement); + + // Clear old selection, and restore if appropriate + if (selection) { + selection.deselect(); + if (index > -1) { + selection.select(elementProxies[index]); + } + } + + // Finally, rebuild lists of elements by id to + // facilitate faster update when new telemetry comes in. + elementProxiesById = {}; + elementProxies.forEach(function (elementProxy) { + var id = elementProxy.id; + if (elementProxy.element.type === 'fixed.telemetry') { + elementProxiesById[id] = elementProxiesById[id] || []; + elementProxiesById[id].push(elementProxy); + } + }); + + // TODO: Ensure elements for all domain objects? + } + // Free up subscription to telemetry function releaseSubscription() { if (subscription) { @@ -134,9 +145,6 @@ define( // Subscribe to telemetry updates for this domain object function subscribe(domainObject) { - // Clear any old values - values = {}; - // Release existing subscription (if any) if (subscription) { subscription.unsubscribe(); @@ -150,7 +158,7 @@ define( // Handle changes in the object's composition function updateComposition(ids) { // Populate panel positions - lookupPanels(ids); + // TODO: Ensure defaults here // Resubscribe - objects in view have changed subscribe($scope.domainObject); } @@ -162,23 +170,33 @@ define( // Make sure there is a "elements" field in the // view configuration. $scope.configuration.elements = - $scope.configuration.elements || {}; + $scope.configuration.elements || []; // Store the position of this element. - $scope.configuration.elements[id] = { - position: [ - Math.floor(position.x / gridSize[0]), - Math.floor(position.y / gridSize[1]) - ], - dimensions: DEFAULT_DIMENSIONS - }; + $scope.configuration.elements.push({ + type: "fixed.telemetry", + x: Math.floor(position.x / gridSize[0]), + y: Math.floor(position.y / gridSize[1]), + id: id, + width: DEFAULT_DIMENSIONS[0], + height: DEFAULT_DIMENSIONS[1] + }); // Mark change as persistable if ($scope.commit) { - $scope.commit("Dropped a frame."); + $scope.commit("Dropped an element."); } - // Populate template-facing position for this id - populatePosition(id); } + // Track current selection state + if (Array.isArray($scope.selection)) { + selection = new LayoutSelection( + $scope.selection, + new FixedProxy($scope.configuration) + ); + } + + // Refresh list of elements whenever model changes + $scope.$watch("model.modified", refreshElements); + // Position panes when the model field changes $scope.$watch("model.composition", updateComposition); @@ -204,15 +222,6 @@ define( getCellStyles: function () { return cellStyles; }, - /** - * Get the current data value for the specified domain object. - * @memberof FixedController# - * @param {string} id the domain object identifier - * @returns {string} the displayable data value - */ - getValue: function (id) { - return values[id]; - }, /** * Set the size of the viewable fixed position area. * @memberof FixedController# @@ -227,17 +236,36 @@ define( } }, /** - * Get a style object for a frame with the specified domain - * object identifier, suitable for use in an `ng-style` - * directive to position a frame as configured for this layout. - * @param {string} id the object identifier - * @returns {Object.} an object with - * appropriate left, width, etc fields for positioning + * Get an array of elements in this panel; these are + * decorated proxies for both selection and display. + * @returns {Array} elements in this panel */ - getStyle: function (id) { - // Called in a loop, so just look up; the "positions" - // object is kept up to date by a watch. - return positions[id]; + getElements: function () { + return elementProxies; + }, + /** + * Check if the element is currently selected. + * @returns {boolean} true if selected + */ + selected: function (element) { + return selection && selection.selected(element); + }, + /** + * Set the active user selection in this view. + * @param element the element to select + */ + select: function (element) { + if (selection) { + selection.select(element); + } + }, + /** + * Clear the current user selection. + */ + clearSelection: function () { + if (selection) { + selection.deselect(); + } }, /** * Start a drag gesture to move/resize a frame. @@ -254,19 +282,18 @@ define( * with the mouse while the horizontal dimensions shrink in * kind (and vertical properties remain unmodified.) * - * @param {string} id the identifier of the domain object - * in the frame being manipulated - * @param {number[]} posFactor the position factor - * @param {number[]} dimFactor the dimensions factor + * @param element the raw (undecorated) element to drag */ - startDrag: function (id, posFactor, dimFactor) { - activeDragId = id; - activeDrag = new LayoutDrag( - rawPositions[id], - posFactor, - dimFactor, - gridSize - ); + startDrag: function (element) { + // Only allow dragging in edit mode + if ($scope.domainObject && + $scope.domainObject.hasCapability('editor')) { + dragging = { + element: element, + x: element.x(), + y: element.y() + }; + } }, /** * Continue an active drag gesture. @@ -275,10 +302,10 @@ define( * to its position when the drag started */ continueDrag: function (delta) { - if (activeDrag) { - rawPositions[activeDragId] = - activeDrag.getAdjustedPosition(delta); - populatePosition(activeDragId); + if (dragging) { + dragging.element.x(dragging.x + Math.round(delta[0] / gridSize[0])); + dragging.element.y(dragging.y + Math.round(delta[1] / gridSize[1])); + dragging.element.style = convertPosition(dragging.element.element); } }, /** @@ -286,19 +313,9 @@ define( * view configuration. */ endDrag: function () { - // Write to configuration; this is watched and - // saved by the EditRepresenter. - $scope.configuration = - $scope.configuration || {}; - // Make sure there is a "panels" field in the - // view configuration. - $scope.configuration.elements = - $scope.configuration.elements || {}; - // Store the position of this panel. - $scope.configuration.elements[activeDragId] = - rawPositions[activeDragId]; // Mark this object as dirty to encourage persistence - if ($scope.commit) { + if (dragging && $scope.commit) { + dragging = undefined; $scope.commit("Moved element."); } } diff --git a/platform/features/layout/src/FixedProxy.js b/platform/features/layout/src/FixedProxy.js new file mode 100644 index 0000000000..c99a0e3f92 --- /dev/null +++ b/platform/features/layout/src/FixedProxy.js @@ -0,0 +1,26 @@ +/*global define,window*/ + +define( + [], + function () { + "use strict"; + + /** + * Proxy for configuring a fixed position view via the toolbar. + * @constructor + * @param configuration the view configuration object + */ + function FixedProxy(configuration) { + return { + /** + * Add a new visual element to this view. + */ + add: function (type) { + window.alert("Placeholder. Should add a " + type + "."); + } + }; + } + + return FixedProxy; + } +); \ No newline at end of file diff --git a/platform/features/layout/src/LayoutSelection.js b/platform/features/layout/src/LayoutSelection.js new file mode 100644 index 0000000000..bb1574f0ff --- /dev/null +++ b/platform/features/layout/src/LayoutSelection.js @@ -0,0 +1,126 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Tracks selection state for Layout and Fixed Position views. + * This manages and mutates the provided selection array in-place, + * and takes care to only modify the array elements it manages + * (the view's proxy, and the single selection); selections may be + * added or removed elsewhere provided that similar care is taken + * elsewhere. + * + * @param {Array} selection the selection array from the view's scope + * @param [proxy] an object which represents the selection of the view + * itself (which handles view-level toolbar behavior) + */ + function LayoutSelection(selection, proxy) { + var selecting = false, + selected; + + // Find the proxy in the array; our selected objects will be + // positioned next to that + function proxyIndex() { + return selection.indexOf(proxy); + } + + // Remove the currently-selected object + function deselect() { + // Nothing to do if we don't have a selected object + if (selecting) { + // Clear state tracking + selecting = false; + selected = undefined; + + // Remove the selection + selection.splice(proxyIndex() + 1, 1); + + return true; + } + return false; + } + + // Select an object + function select(obj) { + // We want this selection to end up near the proxy + var index = proxyIndex() + 1; + + // Proxy is always selected + if (obj === proxy) { + return false; + } + + // Clear any existing selection + deselect(); + + // Note the current selection state + selected = obj; + selecting = true; + + // Are we at the end of the array? + if (selection.length === index) { + // Add it to the end + selection.push(obj); + } else { + // Splice it into the array + selection.splice(index, 0, obj); + } + } + + // Remove any selected object, and the proxy itself + function destroy() { + deselect(); + selection.splice(proxyIndex(), 1); + } + + // Check if an object is selected + function isSelected(obj) { + return (obj === selected) || (obj === proxy); + } + + // Getter for current selection + function get() { + return selected; + } + + // Start with the proxy selected + selection.push(proxy); + + return { + /** + * Check if an object is currently selected. + * @returns true if selected, otherwise false + */ + selected: isSelected, + /** + * Select an object. + * @param obj the object to select + * @returns {boolean} true if selection changed + */ + select: select, + /** + * Clear the current selection. + * @returns {boolean} true if selection changed + */ + deselect: deselect, + /** + * Get the currently-selected object. + * @returns the currently selected object + */ + get: get, + /** + * Clear the selection, including the proxy, and dispose + * of this selection scope. No other calls to methods on + * this object are expected after `destroy` has been + * called; their behavior will be undefined. + */ + destroy: destroy + }; + } + + return LayoutSelection; + } +); \ No newline at end of file diff --git a/platform/features/layout/src/elements/AccessorMutator.js b/platform/features/layout/src/elements/AccessorMutator.js new file mode 100644 index 0000000000..ac296ddd05 --- /dev/null +++ b/platform/features/layout/src/elements/AccessorMutator.js @@ -0,0 +1,26 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Utility function for creating getter-setter functions, + * since these are frequently useful for element proxies. + * @constructor + * @param {Object} object the object to get/set values upon + * @param {string} key the property to get/set + */ + function AccessorMutator(object, key) { + return function (value) { + if (arguments.length > 0) { + object[key] = value; + } + return object[key]; + }; + } + + return AccessorMutator; + } +); \ No newline at end of file diff --git a/platform/features/layout/src/elements/ElementProxies.js b/platform/features/layout/src/elements/ElementProxies.js new file mode 100644 index 0000000000..e8d6b032e5 --- /dev/null +++ b/platform/features/layout/src/elements/ElementProxies.js @@ -0,0 +1,12 @@ +/*global define*/ + +define( + ['./TelemetryProxy'], + function (TelemetryProxy) { + "use strict"; + + return { + "fixed.telemetry": TelemetryProxy + }; + } +); \ No newline at end of file diff --git a/platform/features/layout/src/elements/ElementProxy.js b/platform/features/layout/src/elements/ElementProxy.js new file mode 100644 index 0000000000..f580b509cd --- /dev/null +++ b/platform/features/layout/src/elements/ElementProxy.js @@ -0,0 +1,36 @@ +/*global define*/ + +define( + ['./AccessorMutator'], + function (AccessorMutator) { + "use strict"; + + /** + * Abstract superclass for other classes which provide useful + * interfaces upon an elements in a fixed position view. + * This handles the generic operations (e.g. remove) so that + * subclasses only need to implement element-specific behaviors. + * @constructor + * @param element the telemetry element + * @param index the element's index within its array + * @param {Array} elements the full array of elements + */ + function ElementProxy(element, index, elements) { + return { + element: element, + x: new AccessorMutator(element, 'x'), + y: new AccessorMutator(element, 'y'), + z: new AccessorMutator(element, 'z'), + width: new AccessorMutator(element, 'width'), + height: new AccessorMutator(element, 'height'), + remove: function () { + if (elements[index] === element) { + elements.splice(index, 1); + } + } + }; + } + + return ElementProxy; + } +); \ No newline at end of file diff --git a/platform/features/layout/src/elements/TelemetryProxy.js b/platform/features/layout/src/elements/TelemetryProxy.js new file mode 100644 index 0000000000..610edb895c --- /dev/null +++ b/platform/features/layout/src/elements/TelemetryProxy.js @@ -0,0 +1,21 @@ +/*global define*/ + +define( + ['./ElementProxy'], + function (ElementProxy) { + 'use strict'; + + /** + * + */ + function TelemetryProxy(element, index, elements) { + var proxy = new ElementProxy(element, index, elements); + + proxy.id = element.id; + + return proxy; + } + + return TelemetryProxy; + } +); \ No newline at end of file diff --git a/platform/features/layout/test/FixedControllerSpec.js b/platform/features/layout/test/FixedControllerSpec.js index 61c8773907..460228e362 100644 --- a/platform/features/layout/test/FixedControllerSpec.js +++ b/platform/features/layout/test/FixedControllerSpec.js @@ -1,4 +1,4 @@ -/*global define,describe,it,expect,beforeEach,jasmine*/ +/*global define,describe,it,expect,beforeEach,jasmine,xit*/ define( ["../src/FixedController"], @@ -14,6 +14,7 @@ define( testGrid, testModel, testValues, + testConfiguration, controller; // Utility function; find a watch for a given expression @@ -41,9 +42,10 @@ define( function makeMockDomainObject(id) { var mockObject = jasmine.createSpyObj( 'domainObject-' + id, - [ 'getId' ] + [ 'getId', 'getModel' ] ); mockObject.getId.andReturn(id); + mockObject.getModel.andReturn({ name: "Point " + id}); return mockObject; } @@ -75,6 +77,11 @@ define( layoutGrid: testGrid }; testValues = { a: 10, b: 42, c: 31.42 }; + testConfiguration = { elements: [ + { type: "fixed.telemetry", id: 'a', x: 1, y: 1 }, + { type: "fixed.telemetry", id: 'b', x: 1, y: 1 }, + { type: "fixed.telemetry", id: 'c', x: 1, y: 1 } + ]}; mockSubscriber.subscribe.andReturn(mockSubscription); mockSubscription.getTelemetryObjects.andReturn( @@ -86,6 +93,9 @@ define( mockFormatter.formatRangeValue.andCallFake(function (v) { return "Formatted " + v; }); + mockScope.model = testModel; + mockScope.configuration = testConfiguration; + mockScope.selection = []; // Act like edit mode controller = new FixedController( mockScope, @@ -122,30 +132,85 @@ define( expect(mockSubscriber.subscribe.calls.length).toEqual(2); }); - it("configures view based on model", function () { + it("exposes visible elements based on configuration", function () { + var elements; + mockScope.model = testModel; - findWatch("model.composition")(mockScope.model.composition); - // Should have styles for all elements of composition - expect(controller.getStyle('a')).toBeDefined(); - expect(controller.getStyle('b')).toBeDefined(); - expect(controller.getStyle('c')).toBeDefined(); - expect(controller.getStyle('d')).not.toBeDefined(); + testModel.modified = 1; + findWatch("model.modified")(testModel.modified); + + elements = controller.getElements(); + expect(elements.length).toEqual(3); + expect(elements[0].id).toEqual('a'); + expect(elements[1].id).toEqual('b'); + expect(elements[2].id).toEqual('c'); + }); + + it("allows elements to be selected", function () { + var elements; + + testModel.modified = 1; + findWatch("model.modified")(testModel.modified); + + elements = controller.getElements(); + controller.select(elements[1]); + expect(controller.selected(elements[0])).toBeFalsy(); + expect(controller.selected(elements[1])).toBeTruthy(); + }); + + it("allows selections to be cleared", function () { + var elements; + + testModel.modified = 1; + findWatch("model.modified")(testModel.modified); + + elements = controller.getElements(); + controller.select(elements[1]); + controller.clearSelection(); + expect(controller.selected(elements[1])).toBeFalsy(); + }); + + it("retains selections during refresh", function () { + // Get elements; remove one of them; trigger refresh. + // Same element (at least by index) should still be selected. + var elements; + + testModel.modified = 1; + findWatch("model.modified")(testModel.modified); + + elements = controller.getElements(); + controller.select(elements[1]); + + elements[2].remove(); + testModel.modified = 2; + findWatch("model.modified")(testModel.modified); + + elements = controller.getElements(); + // Verify removal, as test assumes this + expect(elements.length).toEqual(2); + + expect(controller.selected(elements[1])).toBeTruthy(); }); it("provides values for telemetry elements", function () { + var elements; // Initialize mockScope.domainObject = mockDomainObject; mockScope.model = testModel; findWatch("domainObject")(mockDomainObject); + findWatch("model.modified")(1); findWatch("model.composition")(mockScope.model.composition); // Invoke the subscription callback mockSubscriber.subscribe.mostRecentCall.args[1](); + // Get elements that controller is now exposing + elements = controller.getElements(); + // Formatted values should be available - expect(controller.getValue('a')).toEqual("Formatted 10"); - expect(controller.getValue('b')).toEqual("Formatted 42"); - expect(controller.getValue('c')).toEqual("Formatted 31.42"); + expect(elements[0].value).toEqual("Formatted 10"); + expect(elements[1].value).toEqual("Formatted 42"); + expect(elements[2].value).toEqual("Formatted 31.42"); }); it("adds grid cells to fill boundaries", function () { @@ -179,7 +244,7 @@ define( ); // Verify precondition - expect(controller.getStyle('d')).not.toBeDefined(); + expect(testConfiguration.elements.length).toEqual(3); // Notify that a drop occurred testModel.composition.push('d'); @@ -188,13 +253,28 @@ define( 'd', { x: 300, y: 100 } ); - expect(controller.getStyle('d')).toBeDefined(); + + // Should have added an element + expect(testConfiguration.elements.length).toEqual(4); // Should have triggered commit (provided by // EditRepresenter) with some message. expect(mockScope.commit) .toHaveBeenCalledWith(jasmine.any(String)); }); + + + + it("unsubscribes when destroyed", function () { + // Make an object available + findWatch('domainObject')(mockDomainObject); + // Also verify precondition + expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); + // Destroy the scope + findOn('$destroy')(); + // Should have unsubscribed + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + }); }); } ); \ No newline at end of file diff --git a/platform/features/layout/test/FixedProxySpec.js b/platform/features/layout/test/FixedProxySpec.js new file mode 100644 index 0000000000..224b19f022 --- /dev/null +++ b/platform/features/layout/test/FixedProxySpec.js @@ -0,0 +1,18 @@ +/*global define,describe,it,expect,beforeEach,jasmine,xit*/ + +define( + ['../src/FixedProxy'], + function (FixedProxy) { + "use strict"; + + describe("Fixed Position view's selection proxy", function () { + it("has a placeholder message when clicked", function () { + var oldAlert = window.alert; + window.alert = jasmine.createSpy('alert'); + new FixedProxy({}).add(''); + expect(window.alert).toHaveBeenCalledWith(jasmine.any(String)); + window.alert = oldAlert; + }); + }); + } +); diff --git a/platform/features/layout/test/LayoutSelectionSpec.js b/platform/features/layout/test/LayoutSelectionSpec.js new file mode 100644 index 0000000000..3712afec3f --- /dev/null +++ b/platform/features/layout/test/LayoutSelectionSpec.js @@ -0,0 +1,85 @@ +/*global define,describe,it,expect,beforeEach,jasmine,xit*/ + +define( + ['../src/LayoutSelection'], + function (LayoutSelection) { + "use strict"; + + describe("Layout/fixed position selection manager", function () { + var testSelection, + testProxy, + testElement, + otherElement, + selection; + + beforeEach(function () { + testSelection = []; + testProxy = { someKey: "some value" }; + testElement = { someOtherKey: "some other value" }; + otherElement = { yetAnotherKey: 42 }; + selection = new LayoutSelection(testSelection, testProxy); + }); + + it("adds the proxy to the selection array", function () { + expect(testSelection).toEqual([testProxy]); + }); + + it("includes selected objects alongside the proxy", function () { + selection.select(testElement); + expect(testSelection).toEqual([testProxy, testElement]); + }); + + it("allows elements to be deselected", function () { + selection.select(testElement); + selection.deselect(); + expect(testSelection).toEqual([testProxy]); + }); + + it("replaces old selections with new ones", function () { + selection.select(testElement); + selection.select(otherElement); + expect(testSelection).toEqual([testProxy, otherElement]); + }); + + it("allows retrieval of the current selection", function () { + selection.select(testElement); + expect(selection.get()).toBe(testElement); + selection.select(otherElement); + expect(selection.get()).toBe(otherElement); + }); + + it("can check if an element is selected", function () { + selection.select(testElement); + expect(selection.selected(testElement)).toBeTruthy(); + expect(selection.selected(otherElement)).toBeFalsy(); + selection.select(otherElement); + expect(selection.selected(testElement)).toBeFalsy(); + expect(selection.selected(otherElement)).toBeTruthy(); + }); + + it("cleans up the selection on destroy", function () { + selection.destroy(); + expect(testSelection).toEqual([]); + }); + + it("preserves other elements in the array", function () { + testSelection.push(42); + selection.select(testElement); + expect(testSelection).toEqual([testProxy, testElement, 42]); + }); + + it("considers the proxy to be selected", function () { + expect(selection.selected(testProxy)).toBeTruthy(); + selection.select(testElement); + // Even when something else is selected... + expect(selection.selected(testProxy)).toBeTruthy(); + }); + + it("treats selection of the proxy as a no-op", function () { + selection.select(testProxy); + expect(testSelection).toEqual([testProxy]); + }); + + }); + } +); diff --git a/platform/features/layout/test/elements/AccessorMutatorSpec.js b/platform/features/layout/test/elements/AccessorMutatorSpec.js new file mode 100644 index 0000000000..afcede7176 --- /dev/null +++ b/platform/features/layout/test/elements/AccessorMutatorSpec.js @@ -0,0 +1,31 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ['../../src/elements/AccessorMutator'], + function (AccessorMutator) { + "use strict"; + + describe("An accessor-mutator", function () { + var testObject, + am; + + beforeEach(function () { + testObject = { t: 42, other: 100 }; + am = new AccessorMutator(testObject, 't'); + }); + + it("allows access to a property", function () { + expect(am()).toEqual(42); + }); + + it("allows mutation of a property", function () { + expect(am("some other value")).toEqual("some other value"); + expect(testObject).toEqual({ + t: "some other value", + other: 100 + }); + }); + + }); + } +); diff --git a/platform/features/layout/test/elements/ElementProxiesSpec.js b/platform/features/layout/test/elements/ElementProxiesSpec.js new file mode 100644 index 0000000000..cf771a043a --- /dev/null +++ b/platform/features/layout/test/elements/ElementProxiesSpec.js @@ -0,0 +1,27 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ['../../src/elements/ElementProxies'], + function (ElementProxies) { + "use strict"; + + var ELEMENT_TYPES = [ + "fixed.telemetry" + ]; + + // Verify that the set of proxies exposed matches the specific + // list above. + describe("The set of element proxies", function () { + ELEMENT_TYPES.forEach(function (t) { + it("exposes a proxy wrapper for " + t + " elements", function () { + expect(typeof ElementProxies[t]).toEqual('function'); + }); + }); + + it("exposes no additional wrappers", function () { + expect(Object.keys(ElementProxies).length) + .toEqual(ELEMENT_TYPES.length); + }); + }); + } +); diff --git a/platform/features/layout/test/elements/ElementProxySpec.js b/platform/features/layout/test/elements/ElementProxySpec.js new file mode 100644 index 0000000000..e2a9c2880e --- /dev/null +++ b/platform/features/layout/test/elements/ElementProxySpec.js @@ -0,0 +1,41 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ['../../src/elements/ElementProxy'], + function (ElementProxy) { + "use strict"; + + describe("A fixed position element proxy", function () { + var testElement, + testElements, + proxy; + + beforeEach(function () { + testElement = { + x: 1, + y: 2, + z: 3, + width: 42, + height: 24 + }; + testElements = [ {}, {}, testElement, {} ]; + proxy = new ElementProxy( + testElement, + testElements.indexOf(testElement), + testElements + ); + }); + + it("exposes element properties", function () { + Object.keys(testElement).forEach(function (k) { + expect(proxy[k]()).toEqual(testElement[k]); + }); + }); + + it("allows elements to be removed", function () { + proxy.remove(); + expect(testElements).toEqual([{}, {}, {}]); + }); + }); + } +); diff --git a/platform/features/layout/test/elements/TelemetryProxySpec.js b/platform/features/layout/test/elements/TelemetryProxySpec.js new file mode 100644 index 0000000000..fdd7e555d0 --- /dev/null +++ b/platform/features/layout/test/elements/TelemetryProxySpec.js @@ -0,0 +1,35 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ['../../src/elements/TelemetryProxy'], + function (TelemetryProxy) { + "use strict"; + + describe("A fixed position telemetry proxy", function () { + var testElement, + testElements, + proxy; + + beforeEach(function () { + testElement = { + x: 1, + y: 2, + z: 3, + width: 42, + height: 24, + id: "test-id" + }; + testElements = [ {}, {}, testElement, {} ]; + proxy = new TelemetryProxy( + testElement, + testElements.indexOf(testElement), + testElements + ); + }); + + it("exposes the element's id", function () { + expect(proxy.id).toEqual('test-id'); + }); + }); + } +); diff --git a/platform/features/layout/test/suite.json b/platform/features/layout/test/suite.json index 6e62994ff5..af82513e01 100644 --- a/platform/features/layout/test/suite.json +++ b/platform/features/layout/test/suite.json @@ -1,5 +1,11 @@ [ "FixedController", + "FixedProxy", "LayoutController", - "LayoutDrag" + "LayoutDrag", + "LayoutSelection", + "elements/AccessorMutator", + "elements/ElementProxies", + "elements/ElementProxy", + "elements/TelemetryProxy" ] \ No newline at end of file diff --git a/platform/representation/test/gestures/DropGestureSpec.js b/platform/representation/test/gestures/DropGestureSpec.js index 2182cf0997..d24dcf2132 100644 --- a/platform/representation/test/gestures/DropGestureSpec.js +++ b/platform/representation/test/gestures/DropGestureSpec.js @@ -10,7 +10,7 @@ define( // Methods to mock - var JQLITE_FUNCTIONS = [ "on", "off", "attr", "removeAttr" ], + var JQLITE_FUNCTIONS = [ "on", "off", "attr", "removeAttr", "scope" ], DOMAIN_OBJECT_METHODS = [ "getId", "getModel", "getCapability", "hasCapability", "useCapability"], TEST_ID = "test-id", DROP_ID = "drop-id"; @@ -21,7 +21,10 @@ define( mockDomainObject, mockPersistence, mockEvent, + mockScope, + mockUnwrappedElement, testModel, + testRect, gesture, callbacks; @@ -35,6 +38,7 @@ define( beforeEach(function () { testModel = { composition: [] }; + testRect = {}; mockQ = { when: mockPromise }; mockElement = jasmine.createSpyObj("element", JQLITE_FUNCTIONS); @@ -42,11 +46,17 @@ define( mockPersistence = jasmine.createSpyObj("persistence", [ "persist" ]); mockEvent = jasmine.createSpyObj("event", ["preventDefault"]); mockEvent.dataTransfer = jasmine.createSpyObj("dataTransfer", [ "getData" ]); + mockScope = jasmine.createSpyObj("$scope", ["$broadcast"]); + mockUnwrappedElement = jasmine.createSpyObj("unwrapped", ["getBoundingClientRect"]); mockDomainObject.getId.andReturn(TEST_ID); mockDomainObject.getModel.andReturn(testModel); mockDomainObject.getCapability.andReturn(mockPersistence); + mockDomainObject.useCapability.andReturn(true); mockEvent.dataTransfer.getData.andReturn(DROP_ID); + mockElement[0] = mockUnwrappedElement; + mockElement.scope.andReturn(mockScope); + mockUnwrappedElement.getBoundingClientRect.andReturn(testRect); gesture = new DropGesture(mockQ, mockElement, mockDomainObject); @@ -114,6 +124,19 @@ define( expect(mockDomainObject.getCapability).toHaveBeenCalledWith("persistence"); }); + it("broadcasts drop position", function () { + testRect.left = 42; + testRect.top = 36; + mockEvent.pageX = 52; + mockEvent.pageY = 64; + callbacks.drop(mockEvent); + expect(mockScope.$broadcast).toHaveBeenCalledWith( + 'mctDrop', + DROP_ID, + { x: 10, y: 28 } + ); + }); + }); } ); \ No newline at end of file diff --git a/platform/telemetry/src/TelemetrySubscription.js b/platform/telemetry/src/TelemetrySubscription.js index b7deb5d449..cce24d48dd 100644 --- a/platform/telemetry/src/TelemetrySubscription.js +++ b/platform/telemetry/src/TelemetrySubscription.js @@ -138,6 +138,11 @@ define( function cacheObjectReferences(objects) { telemetryObjects = objects; metadatas = objects.map(lookupMetadata); + // Fire callback, as this will be the first time that + // telemetry objects are available + if (callback) { + callback(); + } return objects; } diff --git a/platform/telemetry/test/TelemetrySubscriptionSpec.js b/platform/telemetry/test/TelemetrySubscriptionSpec.js index 469015ee93..6dd27320c9 100644 --- a/platform/telemetry/test/TelemetrySubscriptionSpec.js +++ b/platform/telemetry/test/TelemetrySubscriptionSpec.js @@ -76,7 +76,10 @@ define( }); it("fires callbacks when subscriptions update", function () { - expect(mockCallback).not.toHaveBeenCalled(); + // Callback fires when telemetry objects become available, + // so track initial call count instead of verifying that + // it hasn't been called at all. + var initialCalls = mockCallback.calls.length; mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries); // This gets fired via a timeout, so trigger that expect(mockTimeout).toHaveBeenCalledWith( @@ -86,12 +89,15 @@ define( mockTimeout.mostRecentCall.args[0](); // Should have triggered the callback to alert that // new data was available - expect(mockCallback).toHaveBeenCalled(); + expect(mockCallback.calls.length).toEqual(initialCalls + 1); }); it("fires subscription callbacks once per cycle", function () { var i; + // Verify precondition - one call for telemetryObjects + expect(mockCallback.calls.length).toEqual(1); + for (i = 0; i < 100; i += 1) { mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries); } @@ -100,7 +106,7 @@ define( call.args[0](); }); // Should have only triggered the - expect(mockCallback.calls.length).toEqual(1); + expect(mockCallback.calls.length).toEqual(2); }); it("reports its latest observed data values", function () { @@ -129,7 +135,8 @@ define( // telemetrySubscription, where failure to callback // once-per-update results in loss of data, WTD-784 it("fires one event per update if requested", function () { - var i, domains = [], ranges = [], lastCall; + var i, domains = [], ranges = [], lastCall, initialCalls; + // Clear out the subscription from beforeEach subscription.unsubscribe(); @@ -142,6 +149,9 @@ define( true // Don't drop updates! ); + // Track calls at this point + initialCalls = mockCallback.calls.length; + // Snapshot getDomainValue, getRangeValue at time of callback mockCallback.andCallFake(function () { domains.push(subscription.getDomainValue(mockDomainObject)); @@ -163,13 +173,17 @@ define( } // Should have only triggered the - expect(mockCallback.calls.length).toEqual(100); + expect(mockCallback.calls.length).toEqual(100 + initialCalls); }); it("provides domain object metadata", function () { expect(subscription.getMetadata()[0]) .toEqual(testMetadata); }); + + it("fires callback when telemetry objects are available", function () { + expect(mockCallback.calls.length).toEqual(1); + }); }); } ); \ No newline at end of file