Merge remote-tracking branch 'origin/open879' into open-master

Conflicts:
	platform/commonUI/edit/res/templates/edit-object.html
This commit is contained in:
bwyu
2015-02-26 11:01:00 -08:00
27 changed files with 896 additions and 184 deletions

View File

@ -15,6 +15,8 @@ view's scope.) These additional properties are:
then that function is assumed to be an accessor-mutator 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 (that is, it will be called with no arguments to get, and with
an argument to set.) 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 * `inclusive`: Optional; true if this control should be considered
applicable whenever at least one element in the selection has applicable whenever at least one element in the selection has
the associated property. Otherwise, all members of the current the associated property. Otherwise, all members of the current

View File

@ -12,7 +12,7 @@
<div class='holder abs object-holder'> <div class='holder abs object-holder'>
<mct-representation key="representation.selected.key" <mct-representation key="representation.selected.key"
toolbar="toolbar" toolbar="toolbar"
mct-object="domainObject"> mct-object="representation.selected.key && domainObject">
</mct-representation> </mct-representation>
</div> </div>
</div> </div>

View File

@ -17,9 +17,10 @@ define(
* *
* @param structure toolbar structure, as provided by view definition * @param structure toolbar structure, as provided by view definition
* @param {Array} selection the current selection state * @param {Array} selection the current selection state
* @param {Function} commit callback to invoke after changes
* @constructor * @constructor
*/ */
function EditToolbar(structure, selection) { function EditToolbar(structure, selection, commit) {
var toolbarStructure = Object.create(structure || {}), var toolbarStructure = Object.create(structure || {}),
toolbarState, toolbarState,
properties = []; properties = [];
@ -106,23 +107,46 @@ define(
// to the current selection. // to the current selection.
function isApplicable(item) { function isApplicable(item) {
var property = (item || {}).property, var property = (item || {}).property,
method = (item || {}).method,
exclusive = !(item || {}).inclusive; exclusive = !(item || {}).inclusive;
// Check if a selected item defines this property // Check if a selected item defines this property
function hasProperty(selected) { 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 ? and : or,
exclusive exclusive
) && isConsistent(property); ) && 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 // Prepare a toolbar item based on current selection
function convertItem(item) { function convertItem(item) {
var converted = Object.create(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; return converted;
} }

View File

@ -20,6 +20,13 @@ define(
toolbar, toolbar,
toolbarObject = {}; toolbarObject = {};
// Mark changes as ready to persist
function commit(message) {
if (scope.commit) {
scope.commit(message);
}
}
// Handle changes to the current selection // Handle changes to the current selection
function updateSelection(selection) { function updateSelection(selection) {
// Make sure selection is array-like // Make sure selection is array-like
@ -28,7 +35,7 @@ define(
(selection ? [selection] : []); (selection ? [selection] : []);
// Instantiate a new toolbar... // Instantiate a new toolbar...
toolbar = new EditToolbar(definition, selection); toolbar = new EditToolbar(definition, selection, commit);
// ...and expose its structure/state // ...and expose its structure/state
toolbarObject.structure = toolbar.getStructure(); toolbarObject.structure = toolbar.getStructure();
@ -37,9 +44,12 @@ define(
// Update selection models to match changed toolbar state // Update selection models to match changed toolbar state
function updateState(state) { function updateState(state) {
// Update underlying state based on toolbar changes
state.forEach(function (value, index) { state.forEach(function (value, index) {
toolbar.updateState(index, value); toolbar.updateState(index, value);
}); });
// Commit the changes.
commit("Changes from toolbar.");
} }
// Represent a domain object using this definition // Represent a domain object using this definition

View File

@ -15,7 +15,7 @@ define(
beforeEach(function () { beforeEach(function () {
mockScope = jasmine.createSpyObj( mockScope = jasmine.createSpyObj(
'$scope', '$scope',
[ '$on', '$watch', '$watchCollection' ] [ '$on', '$watch', '$watchCollection', "commit" ]
); );
mockElement = {}; mockElement = {};
testAttrs = { toolbar: 'testToolbar' }; testAttrs = { toolbar: 'testToolbar' };

View File

@ -11,7 +11,8 @@ define(
testABC, testABC,
testABC2, testABC2,
testABCXYZ, testABCXYZ,
testABCYZ; testABCYZ,
testM;
beforeEach(function () { beforeEach(function () {
testStructure = { testStructure = {
@ -29,6 +30,11 @@ define(
{ name: "Y", property: "y" }, { name: "Y", property: "y" },
{ name: "Z", property: "z" } { 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 testABC2 = { a: 4, b: 1, c: 2 }; // For inconsistent-state checking
testABCXYZ = { a: 0, b: 1, c: 2, x: 'X!', y: 'Y!', z: 'Z!' }; testABCXYZ = { a: 0, b: 1, c: 2, x: 'X!', y: 'Y!', z: 'Z!' };
testABCYZ = { a: 0, b: 1, c: 2, 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 () { it("provides properties from the original structure", function () {
@ -182,6 +189,19 @@ define(
.length .length
).toEqual(2); ).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();
});
}); });
} }
); );

View File

@ -19,7 +19,31 @@
"type": "telemetry.panel", "type": "telemetry.panel",
"templateUrl": "templates/fixed.html", "templateUrl": "templates/fixed.html",
"uses": [ "composition" ], "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": [ "representations": [
@ -40,6 +64,12 @@
"depends": [ "$scope", "telemetrySubscriber", "telemetryFormatter" ] "depends": [ "$scope", "telemetrySubscriber", "telemetryFormatter" ]
} }
], ],
"templates": [
{
"key": "fixed.telemetry",
"templateUrl": "templates/elements/telemetry.html"
}
],
"types": [ "types": [
{ {
"key": "layout", "key": "layout",

View File

@ -0,0 +1,8 @@
<div style="background: #444;">
<div style="position: absolute; left: 0px; top: 0px; bottom: 0px; width: 50%; overflow: hidden;">
{{ngModel.name}}
</div>
<div style="position: absolute; right: 0px; top: 0px; bottom: 0px; width: 50%; overflow: hidden;">
{{ngModel.value}}
</div>
</div>

View File

@ -1,36 +1,25 @@
<div style="width: 100%; height: 100%; position: absolute; left: 0px; top: 0px;" <div style="width: 100%; height: 100%; position: absolute; left: 0px; top: 0px;"
ng-controller="FixedController as controller" ng-controller="FixedController as controller"
mct-resize="controller.setBounds(bounds)"> mct-resize="controller.setBounds(bounds)">
<!-- Background grid --> <!-- Background grid -->
<div ng-repeat="cell in controller.getCellStyles()" <span ng-click="controller.clearSelection()">
style="position: absolute; border: 1px gray solid; background: black;" <div ng-repeat="cell in controller.getCellStyles()"
ng-style="cell"> style="position: absolute; border: 1px gray solid; background: black;"
</div> ng-style="cell">
<!-- Telemetry elements -->
<div ng-repeat="childObject in composition"
style="position: absolute; background: #444;"
ng-style="controller.getStyle(childObject.getId())">
<div style="position: absolute; left: 0px; top: 0px; bottom: 0px; width: 50%; overflow: hidden;">
{{childObject.getModel().name}}
</div>
<div style="position: absolute; right: 0px; top: 0px; bottom: 0px; width: 50%; overflow: hidden;">
{{controller.getValue(childObject.getId())}}
</div> </div>
</span>
<!-- Drag handles --> <!-- Fixed position elements -->
<span ng-show="domainObject.hasCapability('editor')"> <mct-include ng-repeat="element in controller.getElements()"
<span style="position: absolute; left: 0px; right: 0px; top: 0px; bottom: 0px; cursor: move;" style="position: absolute;"
mct-drag-down="controller.startDrag(childObject.getId(), [1,1], [0,0])" key="element.template"
mct-drag="controller.continueDrag(delta)" ng-class="{ test: controller.selected(element) }"
mct-drag-up="controller.endDrag()"> ng-style="element.style"
</span> ng-click="controller.select(element)"
</span> ng-model="element"
mct-drag-down="controller.startDrag(element); controller.select(element)"
</div> mct-drag="controller.continueDrag(delta)"
mct-drag-up="controller.endDrag()">
</mct-include>
</div> </div>

View File

@ -1,8 +1,8 @@
/*global define*/ /*global define*/
define( define(
['./LayoutDrag'], ['./LayoutDrag', './LayoutSelection', './FixedProxy', './elements/ElementProxies'],
function (LayoutDrag) { function (LayoutDrag, LayoutSelection, FixedProxy, ElementProxies) {
"use strict"; "use strict";
var DEFAULT_DIMENSIONS = [ 2, 1 ], var DEFAULT_DIMENSIONS = [ 2, 1 ],
@ -20,31 +20,23 @@ define(
function FixedController($scope, telemetrySubscriber, telemetryFormatter) { function FixedController($scope, telemetrySubscriber, telemetryFormatter) {
var gridSize = DEFAULT_GRID_SIZE, var gridSize = DEFAULT_GRID_SIZE,
gridExtent = DEFAULT_GRID_EXTENT, gridExtent = DEFAULT_GRID_EXTENT,
activeDrag, dragging,
activeDragId,
subscription, subscription,
values = {},
cellStyles = [], cellStyles = [],
rawPositions = {}, elementProxies = [],
positions = {}; elementProxiesById = {},
selection;
// 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;
}
// Refresh cell styles (e.g. because grid extent changed) // Refresh cell styles (e.g. because grid extent changed)
function refreshCellStyles() { function refreshCellStyles() {
var x, y; var x, y;
// Clear previous styles
cellStyles = []; cellStyles = [];
// Update grid size from model
gridSize = ($scope.model || {}).layoutGrid || gridSize;
for (x = 0; x < gridExtent[0]; x += 1) { for (x = 0; x < gridExtent[0]; x += 1) {
for (y = 0; y < gridExtent[1]; y += 1) { for (y = 0; y < gridExtent[1]; y += 1) {
// Position blocks; subtract out border size from w/h // Position blocks; subtract out border size from w/h
@ -58,62 +50,28 @@ define(
} }
} }
// Convert from { positions: ..., dimensions: ... } to an // Convert from element x/y/width/height to an
// apropriate ng-style argument, to position frames. // apropriate ng-style argument, to position elements.
function convertPosition(raw) { function convertPosition(raw) {
// Multiply position/dimensions by grid size // Multiply position/dimensions by grid size
return { return {
left: (gridSize[0] * raw.position[0]) + 'px', left: (gridSize[0] * raw.x) + 'px',
top: (gridSize[1] * raw.position[1]) + 'px', top: (gridSize[1] * raw.y) + 'px',
width: (gridSize[0] * raw.dimensions[0]) + 'px', width: (gridSize[0] * raw.width) + 'px',
height: (gridSize[1] * raw.dimensions[1]) + '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 // Update the displayed value for this object
function updateValue(telemetryObject) { function updateValue(telemetryObject) {
var id = telemetryObject && telemetryObject.getId(); var id = telemetryObject && telemetryObject.getId();
if (id) { if (id) {
values[id] = telemetryFormatter.formatRangeValue( (elementProxiesById[id] || []).forEach(function (element) {
subscription.getRangeValue(telemetryObject) 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 // Free up subscription to telemetry
function releaseSubscription() { function releaseSubscription() {
if (subscription) { if (subscription) {
@ -134,9 +145,6 @@ define(
// Subscribe to telemetry updates for this domain object // Subscribe to telemetry updates for this domain object
function subscribe(domainObject) { function subscribe(domainObject) {
// Clear any old values
values = {};
// Release existing subscription (if any) // Release existing subscription (if any)
if (subscription) { if (subscription) {
subscription.unsubscribe(); subscription.unsubscribe();
@ -150,7 +158,7 @@ define(
// Handle changes in the object's composition // Handle changes in the object's composition
function updateComposition(ids) { function updateComposition(ids) {
// Populate panel positions // Populate panel positions
lookupPanels(ids); // TODO: Ensure defaults here
// Resubscribe - objects in view have changed // Resubscribe - objects in view have changed
subscribe($scope.domainObject); subscribe($scope.domainObject);
} }
@ -162,23 +170,33 @@ define(
// Make sure there is a "elements" field in the // Make sure there is a "elements" field in the
// view configuration. // view configuration.
$scope.configuration.elements = $scope.configuration.elements =
$scope.configuration.elements || {}; $scope.configuration.elements || [];
// Store the position of this element. // Store the position of this element.
$scope.configuration.elements[id] = { $scope.configuration.elements.push({
position: [ type: "fixed.telemetry",
Math.floor(position.x / gridSize[0]), x: Math.floor(position.x / gridSize[0]),
Math.floor(position.y / gridSize[1]) y: Math.floor(position.y / gridSize[1]),
], id: id,
dimensions: DEFAULT_DIMENSIONS width: DEFAULT_DIMENSIONS[0],
}; height: DEFAULT_DIMENSIONS[1]
});
// Mark change as persistable // Mark change as persistable
if ($scope.commit) { 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 // Position panes when the model field changes
$scope.$watch("model.composition", updateComposition); $scope.$watch("model.composition", updateComposition);
@ -204,15 +222,6 @@ define(
getCellStyles: function () { getCellStyles: function () {
return cellStyles; 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. * Set the size of the viewable fixed position area.
* @memberof FixedController# * @memberof FixedController#
@ -227,17 +236,36 @@ define(
} }
}, },
/** /**
* Get a style object for a frame with the specified domain * Get an array of elements in this panel; these are
* object identifier, suitable for use in an `ng-style` * decorated proxies for both selection and display.
* directive to position a frame as configured for this layout. * @returns {Array} elements in this panel
* @param {string} id the object identifier
* @returns {Object.<string, string>} an object with
* appropriate left, width, etc fields for positioning
*/ */
getStyle: function (id) { getElements: function () {
// Called in a loop, so just look up; the "positions" return elementProxies;
// object is kept up to date by a watch. },
return positions[id]; /**
* 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. * Start a drag gesture to move/resize a frame.
@ -254,19 +282,18 @@ define(
* with the mouse while the horizontal dimensions shrink in * with the mouse while the horizontal dimensions shrink in
* kind (and vertical properties remain unmodified.) * kind (and vertical properties remain unmodified.)
* *
* @param {string} id the identifier of the domain object * @param element the raw (undecorated) element to drag
* in the frame being manipulated
* @param {number[]} posFactor the position factor
* @param {number[]} dimFactor the dimensions factor
*/ */
startDrag: function (id, posFactor, dimFactor) { startDrag: function (element) {
activeDragId = id; // Only allow dragging in edit mode
activeDrag = new LayoutDrag( if ($scope.domainObject &&
rawPositions[id], $scope.domainObject.hasCapability('editor')) {
posFactor, dragging = {
dimFactor, element: element,
gridSize x: element.x(),
); y: element.y()
};
}
}, },
/** /**
* Continue an active drag gesture. * Continue an active drag gesture.
@ -275,10 +302,10 @@ define(
* to its position when the drag started * to its position when the drag started
*/ */
continueDrag: function (delta) { continueDrag: function (delta) {
if (activeDrag) { if (dragging) {
rawPositions[activeDragId] = dragging.element.x(dragging.x + Math.round(delta[0] / gridSize[0]));
activeDrag.getAdjustedPosition(delta); dragging.element.y(dragging.y + Math.round(delta[1] / gridSize[1]));
populatePosition(activeDragId); dragging.element.style = convertPosition(dragging.element.element);
} }
}, },
/** /**
@ -286,19 +313,9 @@ define(
* view configuration. * view configuration.
*/ */
endDrag: function () { 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 // Mark this object as dirty to encourage persistence
if ($scope.commit) { if (dragging && $scope.commit) {
dragging = undefined;
$scope.commit("Moved element."); $scope.commit("Moved element.");
} }
} }

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -0,0 +1,12 @@
/*global define*/
define(
['./TelemetryProxy'],
function (TelemetryProxy) {
"use strict";
return {
"fixed.telemetry": TelemetryProxy
};
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -1,4 +1,4 @@
/*global define,describe,it,expect,beforeEach,jasmine*/ /*global define,describe,it,expect,beforeEach,jasmine,xit*/
define( define(
["../src/FixedController"], ["../src/FixedController"],
@ -14,6 +14,7 @@ define(
testGrid, testGrid,
testModel, testModel,
testValues, testValues,
testConfiguration,
controller; controller;
// Utility function; find a watch for a given expression // Utility function; find a watch for a given expression
@ -41,9 +42,10 @@ define(
function makeMockDomainObject(id) { function makeMockDomainObject(id) {
var mockObject = jasmine.createSpyObj( var mockObject = jasmine.createSpyObj(
'domainObject-' + id, 'domainObject-' + id,
[ 'getId' ] [ 'getId', 'getModel' ]
); );
mockObject.getId.andReturn(id); mockObject.getId.andReturn(id);
mockObject.getModel.andReturn({ name: "Point " + id});
return mockObject; return mockObject;
} }
@ -75,6 +77,11 @@ define(
layoutGrid: testGrid layoutGrid: testGrid
}; };
testValues = { a: 10, b: 42, c: 31.42 }; 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); mockSubscriber.subscribe.andReturn(mockSubscription);
mockSubscription.getTelemetryObjects.andReturn( mockSubscription.getTelemetryObjects.andReturn(
@ -86,6 +93,9 @@ define(
mockFormatter.formatRangeValue.andCallFake(function (v) { mockFormatter.formatRangeValue.andCallFake(function (v) {
return "Formatted " + v; return "Formatted " + v;
}); });
mockScope.model = testModel;
mockScope.configuration = testConfiguration;
mockScope.selection = []; // Act like edit mode
controller = new FixedController( controller = new FixedController(
mockScope, mockScope,
@ -122,30 +132,85 @@ define(
expect(mockSubscriber.subscribe.calls.length).toEqual(2); 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; mockScope.model = testModel;
findWatch("model.composition")(mockScope.model.composition); testModel.modified = 1;
// Should have styles for all elements of composition findWatch("model.modified")(testModel.modified);
expect(controller.getStyle('a')).toBeDefined();
expect(controller.getStyle('b')).toBeDefined(); elements = controller.getElements();
expect(controller.getStyle('c')).toBeDefined(); expect(elements.length).toEqual(3);
expect(controller.getStyle('d')).not.toBeDefined(); 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 () { it("provides values for telemetry elements", function () {
var elements;
// Initialize // Initialize
mockScope.domainObject = mockDomainObject; mockScope.domainObject = mockDomainObject;
mockScope.model = testModel; mockScope.model = testModel;
findWatch("domainObject")(mockDomainObject); findWatch("domainObject")(mockDomainObject);
findWatch("model.modified")(1);
findWatch("model.composition")(mockScope.model.composition); findWatch("model.composition")(mockScope.model.composition);
// Invoke the subscription callback // Invoke the subscription callback
mockSubscriber.subscribe.mostRecentCall.args[1](); mockSubscriber.subscribe.mostRecentCall.args[1]();
// Get elements that controller is now exposing
elements = controller.getElements();
// Formatted values should be available // Formatted values should be available
expect(controller.getValue('a')).toEqual("Formatted 10"); expect(elements[0].value).toEqual("Formatted 10");
expect(controller.getValue('b')).toEqual("Formatted 42"); expect(elements[1].value).toEqual("Formatted 42");
expect(controller.getValue('c')).toEqual("Formatted 31.42"); expect(elements[2].value).toEqual("Formatted 31.42");
}); });
it("adds grid cells to fill boundaries", function () { it("adds grid cells to fill boundaries", function () {
@ -179,7 +244,7 @@ define(
); );
// Verify precondition // Verify precondition
expect(controller.getStyle('d')).not.toBeDefined(); expect(testConfiguration.elements.length).toEqual(3);
// Notify that a drop occurred // Notify that a drop occurred
testModel.composition.push('d'); testModel.composition.push('d');
@ -188,13 +253,28 @@ define(
'd', 'd',
{ x: 300, y: 100 } { 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 // Should have triggered commit (provided by
// EditRepresenter) with some message. // EditRepresenter) with some message.
expect(mockScope.commit) expect(mockScope.commit)
.toHaveBeenCalledWith(jasmine.any(String)); .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();
});
}); });
} }
); );

View File

@ -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;
});
});
}
);

View File

@ -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]);
});
});
}
);

View File

@ -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
});
});
});
}
);

View File

@ -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);
});
});
}
);

View File

@ -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([{}, {}, {}]);
});
});
}
);

View File

@ -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');
});
});
}
);

View File

@ -1,5 +1,11 @@
[ [
"FixedController", "FixedController",
"FixedProxy",
"LayoutController", "LayoutController",
"LayoutDrag" "LayoutDrag",
"LayoutSelection",
"elements/AccessorMutator",
"elements/ElementProxies",
"elements/ElementProxy",
"elements/TelemetryProxy"
] ]

View File

@ -10,7 +10,7 @@ define(
// Methods to mock // 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"], DOMAIN_OBJECT_METHODS = [ "getId", "getModel", "getCapability", "hasCapability", "useCapability"],
TEST_ID = "test-id", TEST_ID = "test-id",
DROP_ID = "drop-id"; DROP_ID = "drop-id";
@ -21,7 +21,10 @@ define(
mockDomainObject, mockDomainObject,
mockPersistence, mockPersistence,
mockEvent, mockEvent,
mockScope,
mockUnwrappedElement,
testModel, testModel,
testRect,
gesture, gesture,
callbacks; callbacks;
@ -35,6 +38,7 @@ define(
beforeEach(function () { beforeEach(function () {
testModel = { composition: [] }; testModel = { composition: [] };
testRect = {};
mockQ = { when: mockPromise }; mockQ = { when: mockPromise };
mockElement = jasmine.createSpyObj("element", JQLITE_FUNCTIONS); mockElement = jasmine.createSpyObj("element", JQLITE_FUNCTIONS);
@ -42,11 +46,17 @@ define(
mockPersistence = jasmine.createSpyObj("persistence", [ "persist" ]); mockPersistence = jasmine.createSpyObj("persistence", [ "persist" ]);
mockEvent = jasmine.createSpyObj("event", ["preventDefault"]); mockEvent = jasmine.createSpyObj("event", ["preventDefault"]);
mockEvent.dataTransfer = jasmine.createSpyObj("dataTransfer", [ "getData" ]); mockEvent.dataTransfer = jasmine.createSpyObj("dataTransfer", [ "getData" ]);
mockScope = jasmine.createSpyObj("$scope", ["$broadcast"]);
mockUnwrappedElement = jasmine.createSpyObj("unwrapped", ["getBoundingClientRect"]);
mockDomainObject.getId.andReturn(TEST_ID); mockDomainObject.getId.andReturn(TEST_ID);
mockDomainObject.getModel.andReturn(testModel); mockDomainObject.getModel.andReturn(testModel);
mockDomainObject.getCapability.andReturn(mockPersistence); mockDomainObject.getCapability.andReturn(mockPersistence);
mockDomainObject.useCapability.andReturn(true);
mockEvent.dataTransfer.getData.andReturn(DROP_ID); mockEvent.dataTransfer.getData.andReturn(DROP_ID);
mockElement[0] = mockUnwrappedElement;
mockElement.scope.andReturn(mockScope);
mockUnwrappedElement.getBoundingClientRect.andReturn(testRect);
gesture = new DropGesture(mockQ, mockElement, mockDomainObject); gesture = new DropGesture(mockQ, mockElement, mockDomainObject);
@ -114,6 +124,19 @@ define(
expect(mockDomainObject.getCapability).toHaveBeenCalledWith("persistence"); 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 }
);
});
}); });
} }
); );

View File

@ -138,6 +138,11 @@ define(
function cacheObjectReferences(objects) { function cacheObjectReferences(objects) {
telemetryObjects = objects; telemetryObjects = objects;
metadatas = objects.map(lookupMetadata); metadatas = objects.map(lookupMetadata);
// Fire callback, as this will be the first time that
// telemetry objects are available
if (callback) {
callback();
}
return objects; return objects;
} }

View File

@ -76,7 +76,10 @@ define(
}); });
it("fires callbacks when subscriptions update", function () { 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); mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries);
// This gets fired via a timeout, so trigger that // This gets fired via a timeout, so trigger that
expect(mockTimeout).toHaveBeenCalledWith( expect(mockTimeout).toHaveBeenCalledWith(
@ -86,12 +89,15 @@ define(
mockTimeout.mostRecentCall.args[0](); mockTimeout.mostRecentCall.args[0]();
// Should have triggered the callback to alert that // Should have triggered the callback to alert that
// new data was available // new data was available
expect(mockCallback).toHaveBeenCalled(); expect(mockCallback.calls.length).toEqual(initialCalls + 1);
}); });
it("fires subscription callbacks once per cycle", function () { it("fires subscription callbacks once per cycle", function () {
var i; var i;
// Verify precondition - one call for telemetryObjects
expect(mockCallback.calls.length).toEqual(1);
for (i = 0; i < 100; i += 1) { for (i = 0; i < 100; i += 1) {
mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries); mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries);
} }
@ -100,7 +106,7 @@ define(
call.args[0](); call.args[0]();
}); });
// Should have only triggered the // 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 () { it("reports its latest observed data values", function () {
@ -129,7 +135,8 @@ define(
// telemetrySubscription, where failure to callback // telemetrySubscription, where failure to callback
// once-per-update results in loss of data, WTD-784 // once-per-update results in loss of data, WTD-784
it("fires one event per update if requested", function () { 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 // Clear out the subscription from beforeEach
subscription.unsubscribe(); subscription.unsubscribe();
@ -142,6 +149,9 @@ define(
true // Don't drop updates! true // Don't drop updates!
); );
// Track calls at this point
initialCalls = mockCallback.calls.length;
// Snapshot getDomainValue, getRangeValue at time of callback // Snapshot getDomainValue, getRangeValue at time of callback
mockCallback.andCallFake(function () { mockCallback.andCallFake(function () {
domains.push(subscription.getDomainValue(mockDomainObject)); domains.push(subscription.getDomainValue(mockDomainObject));
@ -163,13 +173,17 @@ define(
} }
// Should have only triggered the // Should have only triggered the
expect(mockCallback.calls.length).toEqual(100); expect(mockCallback.calls.length).toEqual(100 + initialCalls);
}); });
it("provides domain object metadata", function () { it("provides domain object metadata", function () {
expect(subscription.getMetadata()[0]) expect(subscription.getMetadata()[0])
.toEqual(testMetadata); .toEqual(testMetadata);
}); });
it("fires callback when telemetry objects are available", function () {
expect(mockCallback.calls.length).toEqual(1);
});
}); });
} }
); );