Merge conflicts with wtd593

This commit is contained in:
bwyu 2014-12-09 13:33:58 -08:00
commit a9d6009610
38 changed files with 790 additions and 226 deletions

@ -6,5 +6,13 @@
"platform/commonUI/edit",
"platform/commonUI/dialog",
"platform/commonUI/general",
"platform/persistence"
]
"platform/telemetry",
"platform/features/layout",
"platform/features/plot",
"platform/features/scrolling",
"platform/forms",
"platform/persistence",
"example/generator",
"example/persistence"
]

@ -26,8 +26,8 @@
},
"properties": [
{
"label": "Period",
"control": "_textfield",
"name": "Period",
"control": "textfield",
"key": "period",
"required": true,
"property": [ "telemetry", "period" ],

@ -21,15 +21,21 @@
"implementation": "ViewSwitcherController.js",
"depends": [ "$scope" ]
},
{
"key": "CreateButtonController",
"implementation": "creation/CreateButtonController",
"depends": [ "$scope", "$document" ]
},
{
"key": "CreateMenuController",
"implementation": "creation/CreateMenuController",
"depends": [ "$scope" ]
},
{
"key": "LocatorController",
"implementation": "creation/LocatorController",
"depends": [ "$scope" ]
}
],
"controls": [
{
"key": "locator",
"templateUrl": "templates/create/locator.html"
}
],
"templates": [
@ -46,11 +52,11 @@
},
{
"key": "create-button",
"templateUrl": "templates/create-button.html"
"templateUrl": "templates/create/create-button.html"
},
{
"key": "create-menu",
"templateUrl": "templates/create-menu.html",
"templateUrl": "templates/create/create-menu.html",
"uses": [ "action" ]
},
{

@ -1,6 +1,6 @@
<div content="jquery-wrapper" class="abs holder-all browse-mode">
<mct-include key="'topbar-browse'"></mct-include>
<div class="holder browse-area outline abs" ng-controller="BrowseController as browse">
<div class="holder browse-area outline abs" ng-controller="BrowseController">
<div class='split-layout vertical contents abs'>
<div class='split-pane-component treeview pane' style="width: 200px;">
<mct-representation key="'create-button'" mct-object="navigatedObject">
@ -8,7 +8,7 @@
<div class='holder tree-holder abs'>
<mct-representation key="'tree'"
mct-object="domainObject"
parameters="{callback: browse.setNavigation}">
ng-model="treeModel">
</mct-representation>
</div>
</div>

@ -0,0 +1,8 @@
<div ng-controller="LocatorController" class="selector-list">
<div>
<mct-representation key="'tree'"
mct-object="rootObject"
ng-model="treeModel">
</mct-representation>
</div>
</div>

@ -24,6 +24,7 @@ define(
// that is currently navigated-to.
function setNavigation(domainObject) {
$scope.navigatedObject = domainObject;
$scope.treeModel.selectedObject = domainObject;
}
// Load the root object, put it in the scope.
@ -48,30 +49,22 @@ define(
}
});
// Provide a model for the tree to modify
$scope.treeModel = {
selectedObject: navigationService.getNavigation()
};
// Listen for changes in navigation state.
navigationService.addListener(setNavigation);
// Also listen for changes which come from the tree
$scope.$watch("treeModel.selectedObject", setNavigation);
// Clean up when the scope is destroyed
$scope.$on("$destroy", function () {
navigationService.removeListener(setNavigation);
});
return {
/**
* Navigate to a specific domain object.
*
* This is exposed so that the browse tree has a callback
* to invoke when the user clicks on a new object to navigate
* to it.
*
* @method
* @memberof BrowseController
* @param {DomainObject} domainObject the object to navigate to
*/
setNavigation: function (domainObject) {
navigationService.setNavigation(domainObject);
}
};
}
return BrowseController;

@ -63,7 +63,8 @@ define(
}
return dialogService.getUserInput(
wizard.getFormModel()
wizard.getFormStructure(),
wizard.getInitialFormValue()
).then(persistResult, doNothing);
}

@ -32,33 +32,54 @@ define(
* @return {FormModel} formModel the form model to
* show in the create dialog
*/
getFormModel: function () {
var parentRow = Object.create(parent),
sections = [];
getFormStructure: function () {
var sections = [];
sections.push({
name: "Properties",
rows: properties.map(function (property) {
rows: properties.map(function (property, index) {
// Property definition is same as form row definition
var row = Object.create(property.getDefinition());
// But pull an initial value from the model
row.value = property.getValue(model);
// Use index as the key into the formValue;
// this correlates to the indexing provided by
// getInitialFormValue
row.key = index;
return row;
})
});
// Ensure there is always a "save in" section
parentRow.name = "Save In";
parentRow.cssclass = "selector-list";
parentRow.control = "_locator";
parentRow.key = "createParent";
sections.push({ label: 'Location', rows: [parentRow]});
sections.push({ name: 'Location', rows: [{
name: "Save In",
control: "locator",
key: "createParent"
}]});
return {
sections: sections,
name: "Create a New " + type.getName()
};
},
/**
* Get the initial value for the form being described.
* This will include the values for all properties described
* in the structure.
*
* @returns {object} the initial value of the form
*/
getInitialFormValue: function () {
// Start with initial values for properties
var formValue = properties.map(function (property) {
return property.getValue(model);
});
// Include the createParent
formValue.createParent = parent;
return formValue;
},
/**
* Based on a populated form, get the domain object which
* should be used as a parent for the newly-created object.
@ -80,9 +101,8 @@ define(
newModel.type = type.getKey();
// Update all properties
properties.forEach(function (property) {
var value = formValue[property.getDefinition().key];
property.setValue(newModel, value);
properties.forEach(function (property, index) {
property.setValue(newModel, formValue[index]);
});
return newModel;

@ -0,0 +1,39 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Controller for the "locator" control, which provides the
* user with the ability to select a domain object as the
* destination for a newly-created object in the Create menu.
* @constructor
*/
function LocatorController($scope) {
// Populate values needed by the locator control. These are:
// * rootObject: The top-level object, since we want to show
// the full tree
// * treeModel: The model for the embedded tree representation,
// used for bi-directional object selection.
function setLocatingObject(domainObject) {
var context = domainObject &&
domainObject.getCapability("context");
$scope.rootObject = context && context.getRoot();
$scope.treeModel.selectedObject = domainObject;
$scope.ngModel[$scope.field] = domainObject;
}
// Initial state for the tree's model
$scope.treeModel =
{ selectedObject: $scope.ngModel[$scope.field] };
// Watch for changes from the tree
$scope.$watch("treeModel.selectedObject", setLocatingObject);
}
return LocatorController;
}
);

@ -25,7 +25,10 @@ define(
}
beforeEach(function () {
mockScope = jasmine.createSpyObj("$scope", [ "$on" ]);
mockScope = jasmine.createSpyObj(
"$scope",
[ "$on", "$watch" ]
);
mockObjectService = jasmine.createSpyObj(
"objectService",
[ "getObjects" ]
@ -60,20 +63,6 @@ define(
);
});
it("provides a means of changing navigation", function () {
// Browse template needs a setNavigation function
// Verify precondition
expect(mockNavigationService.setNavigation)
.not.toHaveBeenCalled();
// Set navigation via controller
controller.setNavigation(mockDomainObject);
expect(mockNavigationService.setNavigation)
.toHaveBeenCalledWith(mockDomainObject);
});
it("uses composition to set the navigated object, if there is none", function () {
mockRootObject.useCapability.andReturn(mockPromise([
mockDomainObject

@ -12,6 +12,7 @@ define(
var mockType,
mockParent,
mockProperties,
testModel,
wizard;
function createMockProperty(name) {
@ -19,10 +20,8 @@ define(
"property" + name,
[ "getDefinition", "getValue", "setValue" ]
);
mockProperty.getDefinition.andReturn({
name: name,
key: name.toLowerCase()
});
mockProperty.getDefinition.andReturn({});
mockProperty.getValue.andReturn(name);
return mockProperty;
}
@ -48,11 +47,13 @@ define(
);
mockProperties = [ "A", "B", "C" ].map(createMockProperty);
testModel = { someKey: "some value" };
mockType.getKey.andReturn("test");
mockType.getGlyph.andReturn("T");
mockType.getDescription.andReturn("a test type");
mockType.getName.andReturn("Test");
mockType.getInitialModel.andReturn({});
mockType.getInitialModel.andReturn(testModel);
mockType.getProperties.andReturn(mockProperties);
wizard = new CreateWizard(
@ -62,33 +63,45 @@ define(
});
it("creates a form model with a Properties section", function () {
expect(wizard.getFormModel().sections[0].name)
expect(wizard.getFormStructure().sections[0].name)
.toEqual("Properties");
});
it("adds one row per defined type property", function () {
// Three properties were defined in the mock type
expect(wizard.getFormModel().sections[0].rows.length)
expect(wizard.getFormStructure().sections[0].rows.length)
.toEqual(3);
});
it("interprets form data using type-defined properties", function () {
// Use key names from mock properties
wizard.createModel({
a: "field 0",
b: "field 1",
c: "field 2"
});
wizard.createModel([
"field 0",
"field 1",
"field 2"
]);
// Should have gotten a setValue call
mockProperties.forEach(function (mockProperty, i) {
expect(mockProperty.setValue).toHaveBeenCalledWith(
{ type: 'test' },
{ someKey: "some value", type: 'test' },
"field " + i
);
});
});
it("looks up initial values from properties", function () {
var initialValue = wizard.getInitialFormValue();
expect(initialValue[0]).toEqual("A");
expect(initialValue[1]).toEqual("B");
expect(initialValue[2]).toEqual("C");
// Verify that expected argument was passed
mockProperties.forEach(function (mockProperty) {
expect(mockProperty.getValue)
.toHaveBeenCalledWith(testModel);
});
});

@ -0,0 +1,73 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
/**
* MCTRepresentationSpec. Created by vwoeltje on 11/6/14.
*/
define(
["../../src/creation/LocatorController"],
function (LocatorController) {
"use strict";
describe("The locator controller", function () {
var mockScope,
mockDomainObject,
mockRootObject,
mockContext,
controller;
beforeEach(function () {
mockScope = jasmine.createSpyObj(
"$scope",
[ "$watch" ]
);
mockDomainObject = jasmine.createSpyObj(
"domainObject",
[ "getCapability" ]
);
mockRootObject = jasmine.createSpyObj(
"rootObject",
[ "getCapability" ]
);
mockContext = jasmine.createSpyObj(
"context",
[ "getRoot" ]
);
mockDomainObject.getCapability.andReturn(mockContext);
mockContext.getRoot.andReturn(mockRootObject);
mockScope.ngModel = {};
mockScope.field = "someField";
controller = new LocatorController(mockScope);
});
it("adds a treeModel to scope", function () {
expect(mockScope.treeModel).toBeDefined();
});
it("watches for changes to treeModel", function () {
// This is what the embedded tree representation
// will be modifying.
expect(mockScope.$watch).toHaveBeenCalledWith(
"treeModel.selectedObject",
jasmine.any(Function)
);
});
it("changes its own model on embedded model updates", function () {
// Need to pass on selection changes as updates to
// the control's value
mockScope.$watch.mostRecentCall.args[1](mockDomainObject);
expect(mockScope.ngModel.someField).toEqual(mockDomainObject);
expect(mockScope.rootObject).toEqual(mockRootObject);
// Verify that the capability we expect to have been used
// was used.
expect(mockDomainObject.getCapability)
.toHaveBeenCalledWith("context");
});
});
}
);

@ -6,6 +6,7 @@
"creation/CreateMenuController",
"creation/CreateWizard",
"creation/CreationService",
"creation/LocatorController",
"navigation/NavigateAction",
"navigation/NavigationService",
"windowing/FullscreenAction"

@ -12,11 +12,17 @@
<div class="abs form outline editor">
<div class='abs contents'>
<!-- mct-include key="'form'" ng-model="" -->
<textarea ng-model="ngModel.value"></textarea>
<mct-form ng-model="ngModel.value"
structure="ngModel.structure"
name="createForm">
</mct-form>
</div>
</div>
<div class="abs bottom-bar">
<a class='btn lg major' href='' ng-click="ngModel.confirm()">
<a class='btn lg major'
href=''
ng-class="{ disabled: !createForm.$valid }"
ng-click="ngModel.confirm()">
OK
</a>
<a class='btn lg subtle' href='' ng-click="ngModel.cancel()">

@ -1,4 +1,4 @@
/*global define,Promise*/
/*global define*/
/**
* Module defining DialogService. Created by vwoeltje on 11/10/14.
@ -36,14 +36,8 @@ define(
// overlay-dialog template and associated with a
// OK button click
function confirm() {
var resultingValue;
// Temporary workaround, in the absence of a
// forms package.
resultingValue = JSON.parse(overlayModel.value);
// Pass along the result
deferred.resolve(resultingValue);
deferred.resolve(overlayModel.value);
// Stop showing the dialog
dismiss();
@ -73,8 +67,8 @@ define(
overlayModel = {
title: formModel.name,
message: formModel.message,
formModel: formModel,
value: JSON.stringify(value),
structure: formModel,
value: value,
confirm: confirm,
cancel: cancel
};

@ -27,6 +27,15 @@
"category": "view-control",
"glyph": "p"
},
{
"key": "properties",
"category": "contextual",
"implementation": "actions/PropertiesAction.js",
"glyph": "p",
"name": "Edit Properties...",
"description": "Edit properties of this object.",
"depends": [ "dialogService" ]
},
{
"key": "remove",
"category": "contextual",

@ -0,0 +1,82 @@
/*global define*/
/**
* Edit the properties of a domain object. Shows a dialog
* which should display a set of properties similar to that
* shown in the Create wizard.
*/
define(
['./PropertiesDialog'],
function (PropertiesDialog) {
'use strict';
/**
* Construct an action which will allow an object's metadata to be
* edited.
*
* @param {DialogService} dialogService a service which will show the dialog
* @param {DomainObject} object the object to be edited
* @param {ActionContext} context the context in which this action is performed
* @constructor
*/
function PropertiesAction(dialogService, context) {
var object = context.domainObject;
// Persist modifications to this domain object
function doPersist() {
var persistence = object.getCapability('persistence');
return persistence && persistence.persist();
}
// Update the domain object model based on user input
function updateModel(userInput, dialog) {
return object.useCapability('mutation', function (model) {
dialog.updateModel(model, userInput);
});
}
function showDialog(type) {
// Create a dialog object to generate the form structure, etc.
var dialog = new PropertiesDialog(type, object.getModel());
// Show the dialog
return dialogService.getUserInput(
dialog.getFormStructure(),
dialog.getInitialFormValue()
).then(function (userInput) {
// Update the model, if user input was provided
return userInput && updateModel(userInput, dialog);
}).then(function (result) {
return result && doPersist();
});
}
return {
/**
* Perform this action.
* @return {Promise} a promise which will be
* fulfilled when the action has completed.
*/
perform: function () {
var type = object.getCapability('type');
return type && showDialog(type);
}
};
}
/**
* Filter this action for applicability against a given context.
* This will ensure that a domain object is present in the
* context.
*/
PropertiesAction.appliesTo = function (context) {
return (context || {}).domainObject &&
(context || {}).domainObject.hasCapability("type") &&
(context || {}).domainObject.hasCapability("persistence");
};
return PropertiesAction;
}
);

@ -0,0 +1,73 @@
/*global define*/
/**
* Defines the PropertiesDialog, used by the PropertiesAction to
* populate the form shown in dialog based on the created type.
*
* @module common/actions/properties-dialog
*/
define(
function () {
'use strict';
/**
* Construct a new Properties dialog.
*
* @param {TypeImpl} type the type of domain object for which properties
* will be specified
* @param {DomainObject} the object for which properties will be set
* @constructor
* @memberof module:common/actions/properties-dialog
*/
function PropertiesDialog(type, model) {
var properties = type.getProperties();
return {
/**
* Get sections provided by this dialog.
* @return {FormStructure} the structure of this form
*/
getFormStructure: function () {
return {
name: "Edit " + model.name,
sections: [{
name: "Properties",
rows: properties.map(function (property, index) {
// Property definition is same as form row definition
var row = Object.create(property.getDefinition());
row.key = index;
return row;
})
}]
};
},
/**
* Get the initial state of the form shown by this dialog
* (based on the object model)
* @returns {object} initial state of the form
*/
getInitialFormValue: function () {
// Start with initial values for properties
// Note that index needs to correlate to row.key
// from getFormStructure
return properties.map(function (property) {
return property.getValue(model);
});
},
/**
* Update a domain object model based on the value of a form.
*/
updateModel: function (model, formValue) {
// Update all properties
properties.forEach(function (property, index) {
property.setValue(model, formValue[index]);
});
}
};
}
return PropertiesDialog;
}
);

@ -0,0 +1,71 @@
/*global define,describe,it,xit,expect,beforeEach,jasmine*/
define(
['../../src/actions/PropertiesAction'],
function (PropertiesAction) {
"use strict";
describe("Properties action", function () {
var capabilities, model, object, context, input, dialogService, action;
function mockPromise(value) {
return {
then: function (callback) {
return mockPromise(callback(value));
}
};
}
beforeEach(function () {
capabilities = {
type: { getProperties: function () { return []; } },
persistence: jasmine.createSpyObj("persistence", ["persist"]),
mutation: jasmine.createSpy("mutation")
};
model = {};
input = {};
object = {
getId: function () { return 'test-id'; },
getCapability: function (k) { return capabilities[k]; },
getModel: function () { return model; },
useCapability: function (k, v) { return capabilities[k](v); },
hasCapability: function () { return true; }
};
context = { someKey: "some value", domainObject: object };
dialogService = {
getUserInput: function () {
return mockPromise(input);
}
};
capabilities.mutation.andReturn(true);
action = new PropertiesAction(dialogService, context);
});
it("persists when an action is performed", function () {
action.perform();
expect(capabilities.persistence.persist)
.toHaveBeenCalled();
});
it("does not persist any changes upon cancel", function () {
input = undefined;
action.perform();
expect(capabilities.persistence.persist)
.not.toHaveBeenCalled();
});
it("mutates an object when performed", function () {
action.perform();
expect(capabilities.mutation).toHaveBeenCalled();
capabilities.mutation.mostRecentCall.args[0]({});
});
it("is only applicable when a domain object is in context", function () {
expect(PropertiesAction.appliesTo(context)).toBeTruthy();
expect(PropertiesAction.appliesTo({})).toBeFalsy();
});
});
}
);

@ -0,0 +1,53 @@
/*global define,describe,it,xit,expect,beforeEach*/
define(
["../../src/actions/PropertiesDialog"],
function (PropertiesDialog) {
"use strict";
describe("Properties dialog", function () {
var type, properties, domainObject, model, dialog;
beforeEach(function () {
type = {
getProperties: function () { return properties; }
};
model = { x: "initial value" };
properties = ["x", "y", "z"].map(function (k) {
return {
getValue: function (model) { return model[k]; },
setValue: function (model, v) { model[k] = v; },
getDefinition: function () { return {}; }
};
});
dialog = new PropertiesDialog(type, model);
});
it("provides sections based on type properties", function () {
expect(dialog.getFormStructure().sections[0].rows.length)
.toEqual(properties.length);
});
it("pulls initial values from object model", function () {
expect(dialog.getInitialFormValue()[0])
.toEqual("initial value");
});
it("populates models with form results", function () {
dialog.updateModel(model, [
"new value",
"other new value",
42
]);
expect(model).toEqual({
x: "new value",
y: "other new value",
z: 42
});
});
});
}
);

@ -3,6 +3,8 @@
"EditController",
"actions/CancelAction",
"actions/EditAction",
"actions/PropertiesAction",
"actions/PropertiesDialog",
"actions/RemoveAction",
"actions/SaveAction",
"capabilities/EditableContextCapability",

@ -8,9 +8,9 @@
</span>
<mct-representation key="'label'"
mct-object="domainObject"
parameters="parameters"
ng-click="parameters.callback(domainObject)"
ng-class="{selected: treeNode.isNavigated()}">
ng-model="ngModel"
ng-click="ngModel.selectedObject = domainObject"
ng-class="{selected: treeNode.isSelected()}">
</mct-representation>
</span>
<span class="tree-item-subtree"
@ -18,7 +18,7 @@
ng-if="model.composition !== undefined">
<mct-representation key="'tree'"
parameters="parameters"
ng-model="ngModel"
mct-object="treeNode.hasBeenExpanded() && domainObject">
</mct-representation>

@ -1,6 +1,8 @@
<ul class="tree">
<li ng-repeat="child in composition">
<mct-representation key="'tree-node'" mct-object="child" parameters="parameters">
<mct-representation key="'tree-node'"
mct-object="child"
ng-model="ngModel">
</mct-representation>
</li>
</ul>

@ -29,9 +29,9 @@ define(
* expand-to-show-navigated-object behavior.)
* @constructor
*/
function TreeNodeController($scope, navigationService) {
var navigatedObject = navigationService.getNavigation(),
isNavigated = false,
function TreeNodeController($scope) {
var selectedObject = ($scope.ngModel || {}).selectedObject,
isSelected = false,
hasBeenExpanded = false;
// Look up the id for a domain object. A convenience
@ -73,7 +73,7 @@ define(
// Check if the navigated object is in the subtree of this
// node's domain object, by comparing the paths reported
// by their context capability.
function isOnNavigationPath(nodeObject, navObject) {
function isOnSelectionPath(nodeObject, navObject) {
var nodeContext = nodeObject &&
nodeObject.getCapability('context'),
navContext = navObject &&
@ -92,19 +92,19 @@ define(
// Consider the currently-navigated object and update
// parameters which support display.
function checkNavigation() {
function checkSelection() {
var nodeObject = $scope.domainObject;
// Check if we are the navigated object. Check the parent
// as well to make sure we are the same instance of the
// navigated object.
isNavigated =
idsEqual(nodeObject, navigatedObject) &&
idsEqual(parentOf(nodeObject), parentOf(navigatedObject));
isSelected =
idsEqual(nodeObject, selectedObject) &&
idsEqual(parentOf(nodeObject), parentOf(selectedObject));
// Expand if necessary (if the navigated object will
// be in this node's subtree)
if (isOnNavigationPath(nodeObject, navigatedObject) &&
if (isOnSelectionPath(nodeObject, selectedObject) &&
$scope.toggle !== undefined) {
$scope.toggle.setState(true);
hasBeenExpanded = true;
@ -113,17 +113,14 @@ define(
// Callback for the navigation service; track the currently
// navigated object and update display parameters as needed.
function setNavigation(object) {
navigatedObject = object;
checkNavigation();
function setSelection(object) {
selectedObject = object;
checkSelection();
}
// Listen for changes which will effect display parameters
navigationService.addListener(setNavigation);
$scope.$on("$destroy", function () {
navigationService.removeListener(setNavigation);
});
$scope.$watch("domainObject", checkNavigation);
$scope.$watch("ngModel.selectedObject", setSelection);
$scope.$watch("domainObject", checkSelection);
return {
/**
@ -143,11 +140,13 @@ define(
},
/**
* Check whether or not the domain object represented by
* this tree node is currently the navigated object.
* @returns true if this is the navigated object
* this tree node should be highlighted.
* An object will be highlighted if it matches
* ngModel.selectedObject
* @returns true if this should be highlighted
*/
isNavigated: function () {
return isNavigated;
isSelected: function () {
return isSelected;
}
};
}

@ -7,7 +7,6 @@ define(
describe("The tree node controller", function () {
var mockScope,
mockNavigationService,
controller;
function TestObject(id, context) {
@ -24,26 +23,11 @@ define(
"$scope",
[ "$watch", "$on" ]
);
mockNavigationService = jasmine.createSpyObj(
"navigationService",
[
"getNavigation",
"setNavigation",
"addListener",
"removeListener"
]
);
controller = new TreeNodeController(
mockScope,
mockNavigationService
mockScope
);
});
it("listens for navigation changes", function () {
expect(mockNavigationService.addListener)
.toHaveBeenCalledWith(jasmine.any(Function));
});
it("allows tracking of expansion state", function () {
// The tree node tracks whether or not it has ever
// been expanded in order to lazily load the expanded
@ -66,13 +50,15 @@ define(
mockContext.getPath.andReturn([obj]);
// Verify precondition
expect(controller.isNavigated()).toBeFalsy();
expect(controller.isSelected()).toBeFalsy();
mockNavigationService.getNavigation.andReturn(obj);
// Change the represented domain object
mockScope.domainObject = obj;
mockNavigationService.addListener.mostRecentCall.args[0](obj);
expect(controller.isNavigated()).toBeTruthy();
// Invoke the watch with the new selection
mockScope.$watch.calls[0].args[1](obj);
expect(controller.isSelected()).toBeTruthy();
});
it("expands a node if it is on the navigation path", function () {
@ -92,16 +78,16 @@ define(
mockParentContext.getPath.andReturn([parent]);
// Set up such that we are on, but not at the end of, a path
mockNavigationService.getNavigation.andReturn(child);
mockScope.ngModel = { selectedObject: child };
mockScope.domainObject = parent;
mockScope.toggle = jasmine.createSpyObj("toggle", ["setState"]);
// Trigger update
mockNavigationService.addListener.mostRecentCall.args[0](child);
// Invoke the watch with the new selection
mockScope.$watch.calls[0].args[1](child);
expect(mockScope.toggle.setState).toHaveBeenCalledWith(true);
expect(controller.hasBeenExpanded()).toBeTruthy();
expect(controller.isNavigated()).toBeFalsy();
expect(controller.isSelected()).toBeFalsy();
});
@ -122,42 +108,18 @@ define(
mockParentContext.getPath.andReturn([parent]);
// Set up such that we are on, but not at the end of, a path
mockNavigationService.getNavigation.andReturn(child);
mockScope.ngModel = { selectedObject: child };
mockScope.domainObject = parent;
mockScope.toggle = jasmine.createSpyObj("toggle", ["setState"]);
// Trigger update
mockNavigationService.addListener.mostRecentCall.args[0](child);
// Invoke the watch with the new selection
mockScope.$watch.calls[0].args[1](child);
expect(mockScope.toggle.setState).not.toHaveBeenCalled();
expect(controller.hasBeenExpanded()).toBeFalsy();
expect(controller.isNavigated()).toBeFalsy();
expect(controller.isSelected()).toBeFalsy();
});
it("removes its navigation listener when the scope is destroyed", function () {
var navCallback =
mockNavigationService.addListener.mostRecentCall.args[0];
// Make sure the controller is listening in the first place
expect(mockScope.$on).toHaveBeenCalledWith(
"$destroy",
jasmine.any(Function)
);
// Verify precondition - no removeListener called
expect(mockNavigationService.removeListener)
.not.toHaveBeenCalled();
// Call that listener (act as if scope is being destroyed)
mockScope.$on.mostRecentCall.args[1]();
// Verify precondition - no removeListener called
expect(mockNavigationService.removeListener)
.toHaveBeenCalledWith(navCallback);
});
});
}
);

@ -74,16 +74,16 @@
{
"properties": [
{
"control": "_textfield",
"label": "Title",
"control": "textfield",
"name": "Title",
"key": "name",
"property": "name",
"pattern": "\\S+",
"required": true
},
{
"control": "_checkbox",
"label": "Display title by default",
"control": "checkbox",
"name": "Display title by default",
"key": "displayTitle",
"property": [ "display", "title" ]
}

@ -24,6 +24,24 @@ define(
propertyDefinition.conversion || "identity"
);
// Check if a value is defined; used to check if initial array
// values have been populated.
function isUnpopulatedArray(value) {
var i;
if (!Array.isArray(value) || value.length === 0) {
return false;
}
for (i = 0; i < value.length; i += 1) {
if (value[i] !== undefined) {
return false;
}
}
return true;
}
// Perform a lookup for a value from an object,
// which may recursively look at contained objects
// based on the path provided.
@ -78,11 +96,18 @@ define(
*/
getValue: function (model) {
var property = propertyDefinition.property ||
propertyDefinition.key;
propertyDefinition.key,
initialValue =
property && lookupValue(model, property);
return property ? conversion.toFormValue(
lookupValue(model, property)
) : undefined;
// Provide an empty array if this is a multi-item
// property.
if (Array.isArray(propertyDefinition.items)) {
initialValue = initialValue ||
new Array(propertyDefinition.items.length);
}
return conversion.toFormValue(initialValue);
},
/**
* Set a value associated with this property in
@ -92,6 +117,13 @@ define(
var property = propertyDefinition.property ||
propertyDefinition.key;
// If an array contains all undefined values, treat it
// as undefined, to filter back out arrays for input
// that never got entered.
value = isUnpopulatedArray(value) ? undefined : value;
// Convert to a value suitable for storage in the
// domain object's model
value = conversion.toModelValue(value);
return property ?

@ -75,6 +75,32 @@ define(
expect(property.getValue(model)).toBeUndefined();
});
it("provides empty arrays for values that are array-like", function () {
var definition = {
property: "someProperty",
items: [ {}, {}, {} ]
},
model = {},
property = new TypeProperty(definition);
expect(property.getValue(model))
.toEqual([undefined, undefined, undefined]);
});
it("detects and ignores empty arrays on setValue", function () {
var definition = {
property: "someProperty",
items: [ {}, {}, {} ]
},
model = {},
property = new TypeProperty(definition);
property.setValue(model, [undefined, undefined, undefined]);
expect(model.someProperty).toBeUndefined();
// Verify that this only happens when all are undefined
property.setValue(model, [undefined, "x", 42]);
expect(model.someProperty).toEqual([undefined, "x", 42]);
});
});
}

@ -13,43 +13,45 @@
"model": { "composition": [] },
"properties": [
{
"label": "Preferred Size",
"control": "_textfields",
"values": [
"name": "Preferred Size",
"control": "composite",
"items": [
{
"label": "Width (px)",
"pattern": "^(\\d*[1-9]\\d*)?$"
"name": "Width (px)",
"control": "textfield"
},
{
"label": "Height (px)",
"pattern": "^(\\d*[1-9]\\d*)?$"
"name": "Height (px)",
"control": "textfield"
}
],
"key": "preferredSize",
"pattern": "^(\\d*[1-9]\\d*)?$",
"property": "preferredSize",
"conversion": "number[]"
},
{
"label": "Layout Grid",
"control": "_textfields",
"values": [
"name": "Layout Grid",
"control": "composite",
"items": [
{
"label": "Horizontal grid (px)",
"pattern": "^(\\d*[1-9]\\d*)?$"
"name": "Horizontal grid (px)",
"control": "textfield"
},
{
"label": "Vertical grid (px)",
"pattern": "^(\\d*[1-9]\\d*)?$"
"name": "Vertical grid (px)",
"control": "textfield"
}
],
"key": "layoutGrid",
"pattern": "^(\\d*[1-9]\\d*)?$",
"property": "layoutGrid",
"conversion": "number[]"
},
{
"label": "Default View",
"control": "_select",
"values": [
"Plot",
"Scrolling"
"name": "Default View",
"control": "select",
"options": [
{ "name": "Plot", "value": "plot" },
{ "name": "Scrolling", "value": "scrolling" }
],
"comment": "TODO: Infer values from type",
"key": "defaultView"

@ -29,6 +29,10 @@
{
"key": "textfield",
"templateUrl": "templates/controls/textfield.html"
},
{
"key": "composite",
"templateUrl": "templates/controls/composite.html"
}
],
"controllers": [
@ -36,6 +40,10 @@
"key": "DateTimeController",
"implementation": "controllers/DateTimeController.js",
"depends": [ "$scope" ]
},
{
"key": "CompositeController",
"implementation": "controllers/CompositeController.js"
}
],
"templates": [

@ -0,0 +1,15 @@
<span ng-controller="CompositeController as compositeCtrl">
<ng-form name="mctFormItem" ng-repeat="item in structure.items">
<mct-control key="item.control"
ng-model="ngModel[field]"
ng-required="ngRequired || compositeCtrl.isNonEmpty(ngModel[field])"
ng-pattern="ngPattern"
options="item.options"
structure="row"
field="$index">
</mct-control>
<span class="composite-control-label">
{{item.name}}
</span>
</ng-form>
</span>

@ -7,46 +7,33 @@
</div>
<div class="form-section">
<ng-form name="mctFormInner" ng-repeat="row in section.rows">
<div class="form-row validates"
ng-class="{
req: row.required,
valid: mctFormInner.$dirty && mctFormInner.$valid,
invalid: mctFormInner.$dirty && !mctFormInner.$valid
}">
<div class="form-row validates"
ng-class="{
req: row.required,
valid: mctFormInner.$dirty && mctFormInner.$valid,
invalid: mctFormInner.$dirty && !mctFormInner.$valid
}">
<div class='label' title="{{row.description}}">
{{row.name}}
<span ng-if="row.description"
class="ui-symbol">
i
</span>
</div>
<div class='controls'>
<div class="wrapper" ng-if="row.control">
<mct-control key="row.control"
ng-model="ngModel"
ng-required="row.required"
ng-pattern="getRegExp(row.pattern)"
options="row.options"
structure="row"
field="row.key">
</mct-control>
<div class='label' title="{{row.description}}">
{{row.name}}
<span ng-if="row.description"
class="ui-symbol">
i
</span>
</div>
<div ng-repeat="item in row.items" class="validates">
<ng-form name="mctFormItem">
<mct-control key="item.control"
<div class='controls'>
<div class="wrapper" ng-if="row.control">
<mct-control key="row.control"
ng-model="ngModel"
ng-required="item.required"
ng-pattern="getRegExp(item.pattern)"
options="item.options"
ng-required="row.required"
ng-pattern="getRegExp(row.pattern)"
options="row.options"
structure="row"
field="item.key">
field="row.key">
</mct-control>
{{item.name}}
</ng-form>
</div>
</div>
</div>
</div>
</ng-form>
</div>
</span>

@ -0,0 +1,48 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The CompositeController supports the "composite" control type,
* which provides an array of other controls. It is used specifically
* to support validation when a particular row is not marked as
* required; in this case, empty input should be allowed, but partial
* input (where some but not all of the composite controls have been
* filled in) should be disallowed. This is enforced in the template
* by an ng-required directive, but that is supported by the
* isNonEmpty check that this controller provides.
* @constructor
*/
function CompositeController() {
// Check if an element is defined; the map step of isNonEmpty
function isDefined(element) {
return typeof element !== 'undefined';
}
// Boolean or; the reduce step of isNonEmpty
function or(a, b) {
return a || b;
}
return {
/**
* Check if an array contains anything other than
* undefined elements.
* @param {Array} value the array to check
* @returns {boolean} true if any non-undefined
* element is in the array
*/
isNonEmpty: function (value) {
return Array.isArray(value) &&
value.map(isDefined).reduce(or, false);
}
};
}
return CompositeController;
}
);

@ -0,0 +1,36 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../../src/controllers/CompositeController"],
function (CompositeController) {
"use strict";
describe("The composite controller", function () {
var controller;
beforeEach(function () {
controller = new CompositeController();
});
it("detects non-empty arrays", function () {
expect(controller.isNonEmpty(["a", "b", undefined]))
.toBeTruthy();
expect(controller.isNonEmpty([3]))
.toBeTruthy();
});
it("detects empty arrays", function () {
expect(controller.isNonEmpty([undefined, undefined, undefined]))
.toBeFalsy();
expect(controller.isNonEmpty([]))
.toBeFalsy();
});
it("ignores non-arrays", function () {
expect(controller.isNonEmpty("this is not an array"))
.toBeFalsy();
});
});
}
);

@ -1,5 +1,6 @@
[
"MCTControl",
"MCTForm",
"controllers/CompositeController",
"controllers/DateTimeController"
]

@ -141,7 +141,12 @@ define(
// Two-way bind key and parameters, get the represented domain
// object as "mct-object"
scope: { key: "=", domainObject: "=mctObject", parameters: "=" }
scope: {
key: "=",
domainObject: "=mctObject",
ngModel: "=",
parameters: "="
}
};
}