Merge branch 'open684' into open615

Merge in toolbar support for use in user interface for
Fixed Position view, WTD-615.
This commit is contained in:
Victor Woeltjen 2015-01-15 16:32:58 -08:00
commit 61c3472266
28 changed files with 881 additions and 90 deletions

View File

@ -1,4 +1,7 @@
<div ng-controller="ExampleFormController">
<mct-toolbar structure="toolbar" ng-model="state" name="aToolbar">
</mct-toolbar>
<mct-form structure="form" ng-model="state" name="aForm">
</mct-form>

View File

@ -1,4 +1,4 @@
/*global define*/
/*global define,window*/
define(
[],
@ -10,6 +10,89 @@ define(
};
$scope.toolbar = {
name: "An example toolbar.",
sections: [
{
description: "First section",
items: [
{
name: "X",
description: "X coordinate",
control: "textfield",
pattern: "^\\d+$",
disabled: true,
size: 2,
key: "x"
},
{
name: "Y",
description: "Y coordinate",
control: "textfield",
pattern: "^\\d+$",
size: 2,
key: "y"
},
{
name: "W",
description: "Cell width",
control: "textfield",
pattern: "^\\d+$",
size: 2,
key: "w"
},
{
name: "H",
description: "Cell height",
control: "textfield",
pattern: "^\\d+$",
size: 2,
key: "h"
}
]
},
{
description: "Second section",
items: [
{
control: "button",
glyph: "1",
description: "Button A",
click: function () {
window.alert("A");
}
},
{
control: "button",
glyph: "2",
description: "Button B",
click: function () {
window.alert("B");
}
},
{
control: "button",
glyph: "3",
description: "Button C",
disabled: true,
click: function () {
window.alert("C");
}
}
]
},
{
items: [
{
control: "color",
key: "color"
}
]
}
]
};
$scope.form = {
name: "An example form.",
sections: [

View File

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

View File

@ -73,7 +73,7 @@ define(
// Handle a specific representation of a specific domain object
function represent(representation, representedObject) {
// Track the key, to know which view configuration to save to.
key = representation.key;
key = (representation || {}).key;
// Track the represented object
domainObject = representedObject;

View File

@ -1,4 +1,10 @@
<ul class="tree">
<li ng-if="!composition">
<span class="tree-item">
<span class="icon wait-spinner"></span>
<span class="title-label">Loading...</span>
</span>
</li>
<li ng-repeat="child in composition">
<mct-representation key="'tree-node'"
mct-object="child"

View File

@ -20,16 +20,30 @@ define(
* models should be retrieved.
*/
function PersistedModelProvider(persistenceService, $q, SPACE) {
// Load a single object model from persistence
function loadModel(id) {
return persistenceService.readObject(SPACE, id);
}
// Promise all persisted models (in id->model form)
function promiseModels(ids) {
return $q.all(ids.map(function (id) {
return persistenceService.readObject(SPACE, id);
})).then(function (models) {
// Package the result as id->model
function packageResult(models) {
var result = {};
ids.forEach(function (id, index) {
result[id] = models[index];
});
return result;
}
// Filter out "namespaced" identifiers; these are
// not expected to be found in database. See WTD-659.
ids = ids.filter(function (id) {
return id.indexOf(":") === -1;
});
// Give a promise for all persistence lookups...
return $q.all(ids.map(loadModel)).then(packageResult);
}
return {

View File

@ -81,15 +81,32 @@ define(
return capabilities.map(hasCapability).reduce(and, true);
}
// Check if a view and domain object type can be paired;
// both can restrict the others they accept.
function viewMatchesType(view, type) {
var views = type && (type.getDefinition() || {}).views,
matches = true;
// View is restricted to a certain type
if (view.type) {
matches = matches && type && type.instanceOf(view.type);
}
// Type wishes to restrict its specific views
if (Array.isArray(views)) {
matches = matches && (views.indexOf(view.key) > -1);
}
return matches;
}
function getViews(domainObject) {
var type = domainObject.useCapability("type");
// First, filter views by type (matched to domain object type.)
// Second, filter by matching capabilities.
return views.filter(function (view) {
return (!view.type) || type.instanceOf(view.type);
}).filter(function (view) {
return capabilitiesMatch(
return viewMatchesType(view, type) && capabilitiesMatch(
domainObject,
view.needs || [],
view.delegation || false

View File

@ -87,6 +87,57 @@ define(
expect(mockLog.warn).toHaveBeenCalledWith(jasmine.any(String));
});
it("restricts typed views to matching types", function () {
var testType = "testType",
testView = { key: "x", type: testType },
provider = new ViewProvider([testView], mockLog);
// Include a "type" capability
capabilities.type = jasmine.createSpyObj(
"type",
["instanceOf", "invoke", "getDefinition"]
);
capabilities.type.invoke.andReturn(capabilities.type);
// Should be included when types match
capabilities.type.instanceOf.andReturn(true);
expect(provider.getViews(mockDomainObject))
.toEqual([testView]);
expect(capabilities.type.instanceOf)
.toHaveBeenCalledWith(testType);
// ...but not when they don't
capabilities.type.instanceOf.andReturn(false);
expect(provider.getViews(mockDomainObject))
.toEqual([]);
});
it("enforces view restrictions from types", function () {
var testType = "testType",
testView = { key: "x" },
provider = new ViewProvider([testView], mockLog);
// Include a "type" capability
capabilities.type = jasmine.createSpyObj(
"type",
["instanceOf", "invoke", "getDefinition"]
);
capabilities.type.invoke.andReturn(capabilities.type);
// Should be included when view keys match
capabilities.type.getDefinition
.andReturn({ views: [testView.key]});
expect(provider.getViews(mockDomainObject))
.toEqual([testView]);
// ...but not when they don't
capabilities.type.getDefinition
.andReturn({ views: ["somethingElse"]});
expect(provider.getViews(mockDomainObject))
.toEqual([]);
});
});
}
);

View File

@ -12,6 +12,11 @@ directive, e.g.:
<mct-form ng-model="myModel" structure="myStructure" name="myForm">
</mct-form>
Using toolbars is similar:
<mct-toolbar ng-model="myModel" structure="myStructure" name="myToolbar">
</mct-toolbar>
The attributes utilized by this form are as follows:
* `ng-model`: The object which should contain the full form input. Individual
@ -55,6 +60,44 @@ Note that `pattern` may be specified as a string, to simplify storing
for structures as JSON when necessary. The string should be given in
a form appropriate to pass to a `RegExp` constructor.
## Toolbar structure
A toolbar's structure is described similarly to forms, except that there
is no notion of rows; instead, there are `items`.
{
"name": ... title to display for the form, as a string ...,
"sections": [
{
"name": ... title to display for the section ...,
"items": [
{
"name": ... title to display for this row ...,
"control": ... symbolic key for the control ...,
"key": ... field name in ng-model ...
"pattern": ... optional, reg exp to match against ...
"required": ... optional boolean ...
"options": [
"name": ... name to display (e.g. in a select) ...,
"value": ... value to store in the model ...
],
"disabled": ... true if control should be disabled ...
"size": ... size of the control (for textfields) ...
"click": ... function to invoke (for buttons) ...
"glyph": ... glyph to display (for buttons) ...
"text": ... text withiin control (for buttons) ...
},
... and other rows ...
]
},
... and other sections ...
]
}
Note that `pattern` may be specified as a string, to simplify storing
for structures as JSON when necessary. The string should be given in
a form appropriate to pass to a `RegExp` constructor.
## Adding controls
Four standard control types are included in the forms bundle:
@ -62,6 +105,8 @@ Four standard control types are included in the forms bundle:
* `textfield`: An area to enter plain text.
* `select`: A drop-down list of options.
* `checkbox`: A box which may be checked/unchecked.
* `color`: A color picker.
* `button`: A button.
* `datetime`: An input for UTC date/time entry; gives result as a
UNIX timestamp, in milliseconds since start of 1970, UTC.

View File

@ -7,6 +7,10 @@
"key": "mctForm",
"implementation": "MCTForm.js"
},
{
"key": "mctToolbar",
"implementation": "MCTToolbar.js"
},
{
"key": "mctControl",
"implementation": "MCTControl.js",
@ -30,6 +34,14 @@
"key": "textfield",
"templateUrl": "templates/controls/textfield.html"
},
{
"key": "button",
"templateUrl": "templates/controls/button.html"
},
{
"key": "color",
"templateUrl": "templates/controls/color.html"
},
{
"key": "composite",
"templateUrl": "templates/controls/composite.html"
@ -45,36 +57,6 @@
"key": "CompositeController",
"implementation": "controllers/CompositeController.js"
}
],
"templates": [
{
"key": "_checkbox",
"templateUrl": "templates/_checkbox.html"
},
{
"key": "_checkboxes",
"templateUrl": "templates/_checkboxes.html"
},
{
"key": "_datetime",
"templateUrl": "templates/_datetime.html"
},
{
"key": "_select",
"templateUrl": "templates/_select.html"
},
{
"key": "_selects",
"templateUrl": "templates/_selects.html"
},
{
"key": "_textfield",
"templateUrl": "templates/_textfield.html"
},
{
"key": "_textfields",
"templateUrl": "templates/_textfields.html"
}
]
}
}

View File

@ -0,0 +1,11 @@
<a href=""
class="t-btn l-btn s-btn s-icon-btn s-very-subtle"
ng-class="{ labeled: structure.text }"
ng-click="structure.click()">
<span class="ui-symbol icon">
{{structure.glyph}}
</span>
<span class="title-label" ng-if="structure.text">
{{structure.text}}
</span>
</a>

View File

@ -0,0 +1,3 @@
<input type="color"
name="mctControl"
ng-model="ngModel[field]">

View File

@ -4,6 +4,7 @@
ng-required="ngRequired"
ng-model="ngModel[field]"
ng-pattern="ngPattern"
size="{{structure.size}}"
name="mctControl">
</span>
</span>

View File

@ -0,0 +1,32 @@
<form name="tool-bar btn-bar contents" novalidate>
<div class="form">
<span ng-repeat="section in structure.sections"
class="control-group coordinates"
title="{{section.description}}">
<ng-form ng-repeat="item in section.items"
ng-class="{ 'input-labeled': item.name }"
class="inline"
title="{{item.description}}"
name="mctFormInner">
<label ng-if="item.name">
{{item.name}}:
</label>
<mct-control key="item.control"
ng-class="{ disabled: item.disabled }"
ng-model="ngModel"
ng-required="item.required"
ng-pattern="getRegExp(item.pattern)"
options="item.options"
structure="item"
field="item.key">
</mct-control>
</ng-form>
</span>
</div>
</form>

View File

@ -4,13 +4,10 @@
* Module defining MCTForm. Created by vwoeltje on 11/10/14.
*/
define(
[],
function () {
["./controllers/FormController"],
function (FormController) {
"use strict";
// Default ng-pattern; any non whitespace
var NON_WHITESPACE = /\S/;
/**
* The mct-form directive allows generation of displayable
* forms based on a declarative description of the form's
@ -37,45 +34,6 @@ define(
"templates/form.html"
].join("/");
function controller($scope) {
var regexps = [];
// ng-pattern seems to want a RegExp, and not a
// string (despite what documentation says) but
// we want form structure to be JSON-expressible,
// so we make RegExp's from strings as-needed
function getRegExp(pattern) {
// If undefined, don't apply a pattern
if (!pattern) {
return NON_WHITESPACE;
}
// Just echo if it's already a regexp
if (pattern instanceof RegExp) {
return pattern;
}
// Otherwise, assume a string
// Cache for easy lookup later (so we don't
// creat a new RegExp every digest cycle)
if (!regexps[pattern]) {
regexps[pattern] = new RegExp(pattern);
}
return regexps[pattern];
}
// Publish the form state under the requested
// name in the parent scope
$scope.$watch("mctForm", function (mctForm) {
if ($scope.name) {
$scope.$parent[$scope.name] = mctForm;
}
});
$scope.getRegExp = getRegExp;
}
return {
// Only show at the element level
restrict: "E",
@ -83,9 +41,8 @@ define(
// Load the forms template
templateUrl: templatePath,
// Use the controller defined above to
// populate/respond to changes in scope
controller: controller,
// Use FormController to populate/respond to changes in scope
controller: FormController,
// Initial an isolate scope
scope: {

View File

@ -0,0 +1,64 @@
/*global define,Promise*/
/**
* Module defining MCTForm. Created by vwoeltje on 11/10/14.
*/
define(
["./controllers/FormController"],
function (FormController) {
"use strict";
/**
* The mct-toolbar directive allows generation of displayable
* forms based on a declarative description of the form's
* structure.
*
* This directive accepts three attributes:
*
* * `ng-model`: The model for the form; where user input
* where be stored.
* * `structure`: The declarative structure of the toolbar.
* Describes what controls should be shown and where
* their values should be read/written in the model.
* * `name`: The name under which to expose the form's
* dirty/valid state. This is similar to ng-form's use
* of name, except this will be made available in the
* parent scope.
*
* @constructor
*/
function MCTForm() {
var templatePath = [
"platform/forms", //MCTForm.bundle.path,
"res", //MCTForm.bundle.resources,
"templates/toolbar.html"
].join("/");
return {
// Only show at the element level
restrict: "E",
// Load the forms template
templateUrl: templatePath,
// Use FormController to populate/respond to changes in scope
controller: FormController,
// Initial an isolate scope
scope: {
// The model: Where form input will actually go
ngModel: "=",
// Form structure; what sections/rows to show
structure: "=",
// Name under which to publish the form
name: "@"
}
};
}
return MCTForm;
}
);

View File

@ -0,0 +1,57 @@
/*global define*/
define(
[],
function () {
"use strict";
// Default ng-pattern; any non whitespace
var NON_WHITESPACE = /\S/;
/**
* Controller for mct-form and mct-toolbar directives.
* @constructor
*/
function FormController($scope) {
var regexps = [];
// ng-pattern seems to want a RegExp, and not a
// string (despite what documentation says) but
// we want form structure to be JSON-expressible,
// so we make RegExp's from strings as-needed
function getRegExp(pattern) {
// If undefined, don't apply a pattern
if (!pattern) {
return NON_WHITESPACE;
}
// Just echo if it's already a regexp
if (pattern instanceof RegExp) {
return pattern;
}
// Otherwise, assume a string
// Cache for easy lookup later (so we don't
// creat a new RegExp every digest cycle)
if (!regexps[pattern]) {
regexps[pattern] = new RegExp(pattern);
}
return regexps[pattern];
}
// Publish the form state under the requested
// name in the parent scope
$scope.$watch("mctForm", function (mctForm) {
if ($scope.name) {
$scope.$parent[$scope.name] = mctForm;
}
});
// Expose the regexp lookup
$scope.getRegExp = getRegExp;
}
return FormController;
}
);

View File

@ -0,0 +1,90 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/MCTToolbar"],
function (MCTToolbar) {
"use strict";
describe("The mct-toolbar directive", function () {
var mockScope,
mctToolbar;
beforeEach(function () {
mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]);
mockScope.$parent = {};
mctToolbar = new MCTToolbar();
});
it("is restricted to elements", function () {
expect(mctToolbar.restrict).toEqual("E");
});
it("watches for changes in form by name", function () {
// mct-form needs to watch for the form by name
// in order to convey changes in $valid, $dirty, etc
// up to the parent scope.
mctToolbar.controller(mockScope);
expect(mockScope.$watch).toHaveBeenCalledWith(
"mctForm",
jasmine.any(Function)
);
});
it("conveys form status to parent scope", function () {
var someState = { someKey: "some value" };
mockScope.name = "someName";
mctToolbar.controller(mockScope);
mockScope.$watch.mostRecentCall.args[1](someState);
expect(mockScope.$parent.someName).toBe(someState);
});
it("allows strings to be converted to RegExps", function () {
// This is needed to support ng-pattern in the template
mctToolbar.controller(mockScope);
// Should have added getRegExp to the scope,
// to convert strings to regular expressions
expect(mockScope.getRegExp("^\\d+$")).toEqual(/^\d+$/);
});
it("returns the same regexp instance for the same string", function () {
// Don't want new instances each digest cycle, for performance
var strRegExp = "^[a-z]\\d+$",
regExp;
// Add getRegExp to scope
mctToolbar.controller(mockScope);
regExp = mockScope.getRegExp(strRegExp);
// Same object instance each time...
expect(mockScope.getRegExp(strRegExp)).toBe(regExp);
expect(mockScope.getRegExp(strRegExp)).toBe(regExp);
});
it("passes RegExp objects through untouched", function () {
// Permit using forms to simply provide their own RegExp object
var regExp = /^\d+[a-d]$/;
// Add getRegExp to scope
mctToolbar.controller(mockScope);
// Should have added getRegExp to the scope,
// to convert strings to regular expressions
expect(mockScope.getRegExp(regExp)).toBe(regExp);
});
it("passes a non-whitespace regexp when no pattern is defined", function () {
// If no pattern is supplied, ng-pattern should match anything
mctToolbar.controller(mockScope);
expect(mockScope.getRegExp()).toEqual(/\S/);
expect(mockScope.getRegExp(undefined)).toEqual(/\S/);
});
});
}
);

View File

@ -0,0 +1,69 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../../src/controllers/FormController"],
function (FormController) {
"use strict";
describe("The form controller", function () {
var mockScope,
controller;
beforeEach(function () {
mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]);
mockScope.$parent = {};
controller = new FormController(mockScope);
});
it("watches for changes in form by name", function () {
expect(mockScope.$watch).toHaveBeenCalledWith(
"mctForm",
jasmine.any(Function)
);
});
it("conveys form status to parent scope", function () {
var someState = { someKey: "some value" };
mockScope.name = "someName";
mockScope.$watch.mostRecentCall.args[1](someState);
expect(mockScope.$parent.someName).toBe(someState);
});
it("allows strings to be converted to RegExps", function () {
// Should have added getRegExp to the scope,
// to convert strings to regular expressions
expect(mockScope.getRegExp("^\\d+$")).toEqual(/^\d+$/);
});
it("returns the same regexp instance for the same string", function () {
// Don't want new instances each digest cycle, for performance
var strRegExp = "^[a-z]\\d+$",
regExp;
// Add getRegExp to scope
regExp = mockScope.getRegExp(strRegExp);
// Same object instance each time...
expect(mockScope.getRegExp(strRegExp)).toBe(regExp);
expect(mockScope.getRegExp(strRegExp)).toBe(regExp);
});
it("passes RegExp objects through untouched", function () {
// Permit using forms to simply provide their own RegExp object
var regExp = /^\d+[a-d]$/;
// Should have added getRegExp to the scope,
// to convert strings to regular expressions
expect(mockScope.getRegExp(regExp)).toBe(regExp);
});
it("passes a non-whitespace regexp when no pattern is defined", function () {
// If no pattern is supplied, ng-pattern should match anything
expect(mockScope.getRegExp()).toEqual(/\S/);
expect(mockScope.getRegExp(undefined)).toEqual(/\S/);
});
});
}
);

View File

@ -2,5 +2,6 @@
"MCTControl",
"MCTForm",
"controllers/CompositeController",
"controllers/DateTimeController"
"controllers/DateTimeController",
"controllers/FormController"
]

View File

@ -8,6 +8,12 @@
"type": "provider",
"implementation": "CouchPersistenceProvider.js",
"depends": [ "$http", "$q", "PERSISTENCE_SPACE", "COUCHDB_PATH" ]
},
{
"provides": "persistenceService",
"type": "decorator",
"implementation": "CachingPersistenceDecorator.js",
"depends": [ "PERSISTENCE_SPACE" ]
}
],
"constants": [

View File

@ -0,0 +1,155 @@
/*global define*/
define(
[],
function () {
'use strict';
/**
* A caching persistence decorator maintains local copies of persistent objects
* that have been loaded, and keeps them in sync after writes. This allows
* retrievals to occur more quickly after the first load.
*
* @constructor
* @param {string[]} CACHE_SPACES persistence space names which
* should be cached
* @param {PersistenceService} persistenceService the service which
* implements object persistence, whose inputs/outputs
* should be cached.
*/
function CachingPersistenceDecorator(CACHE_SPACES, persistenceService) {
var spaces = CACHE_SPACES || [], // List of spaces to cache
cache = {}; // Where objects will be stored
// Utility function; avoid sharing one instance everywhere.
function clone(value) {
// Only clone truthy values (no need to clone undefined, false...)
return value && JSON.parse(JSON.stringify(value));
}
// Place value in the cache for space, if there is one.
function addToCache(space, key, value) {
if (cache[space]) {
cache[space][key] = { value: clone(value) };
}
}
// Create a function for putting value into a cache;
// useful for then-chaining.
function putCache(space, key) {
return function (value) {
addToCache(space, key, value);
return value;
};
}
// Wrap as a thenable; used instead of $q.when because that
// will resolve on a future tick, which can cause latency
// issues (which this decorator is intended to address.)
function fastPromise(value) {
return {
then: function (callback) {
return fastPromise(callback(value));
}
};
}
// Arrayify list of spaces to cache, if necessary.
spaces = Array.isArray(spaces) ? spaces : [ spaces ];
// Initialize caches
spaces.forEach(function (space) {
cache[space] = {};
});
// Provide PersistenceService interface; mostly delegate to the
// decorated service, intervene and cache where appropriate.
return {
/**
* List all persistence spaces that are supported by the
* decorated service.
* @memberof CachingPersistenceDecorator#
* @returns {Promise.<string[]>} spaces supported
*/
listSpaces: function () {
return persistenceService.listSpaces();
},
/**
* List all objects in a specific space.
* @memberof CachingPersistenceDecorator#
* @param {string} space the space in which to list objects
* @returns {Promise.<string[]>} keys for objects in this space
*/
listObjects: function (space) {
return persistenceService.listObjects(space);
},
/**
* Create an object in a specific space. This will
* be cached to expedite subsequent retrieval.
* @memberof CachingPersistenceDecorator#
* @param {string} space the space in which to create the object
* @param {string} key the key associate with the object for
* subsequent lookup
* @param {object} value a JSONifiable object to store
* @returns {Promise.<boolean>} an indicator of the success or
* failure of this request
*/
createObject: function (space, key, value) {
addToCache(space, key, value);
return persistenceService.createObject(space, key, value);
},
/**
* Read an object from a specific space. This will read from a
* cache if the object is available.
* @memberof CachingPersistenceDecorator#
* @param {string} space the space in which to create the object
* @param {string} key the key which identifies the object
* @returns {Promise.<object>} a promise for the object; may
* resolve to undefined (if the object does not exist
* in this space)
*/
readObject: function (space, key) {
return (cache[space] && cache[space][key]) ?
fastPromise(clone(cache[space][key].value)) :
persistenceService.readObject(space, key)
.then(putCache(space, key));
},
/**
* Update an object in a specific space. This will
* be cached to expedite subsequent retrieval.
* @memberof CachingPersistenceDecorator#
* @param {string} space the space in which to create the object
* @param {string} key the key associate with the object for
* subsequent lookup
* @param {object} value a JSONifiable object to store
* @returns {Promise.<boolean>} an indicator of the success or
* failure of this request
*/
updateObject: function (space, key, value) {
addToCache(space, key, value);
return persistenceService.updateObject(space, key, value);
},
/**
* Delete an object in a specific space. This will
* additionally be cleared from the cache.
* @memberof CachingPersistenceDecorator#
* @param {string} space the space in which to create the object
* @param {string} key the key associate with the object for
* subsequent lookup
* @param {object} value a JSONifiable object to delete
* @returns {Promise.<boolean>} an indicator of the success or
* failure of this request
*/
deleteObject: function (space, key, value) {
if (cache[space]) {
delete cache[space][key];
}
return persistenceService.deleteObject(space, key, value);
}
};
}
return CachingPersistenceDecorator;
}
);

View File

@ -0,0 +1,82 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/CachingPersistenceDecorator"],
function (CachingPersistenceDecorator) {
"use strict";
var PERSISTENCE_METHODS = [
"listSpaces",
"listObjects",
"createObject",
"readObject",
"updateObject",
"deleteObject"
];
describe("The caching persistence decorator", function () {
var testSpace,
mockPersistence,
mockCallback,
decorator;
function mockPromise(value) {
return {
then: function (callback) {
return mockPromise(callback(value));
}
};
}
beforeEach(function () {
testSpace = "TEST";
mockPersistence = jasmine.createSpyObj(
"persistenceService",
PERSISTENCE_METHODS
);
mockCallback = jasmine.createSpy("callback");
PERSISTENCE_METHODS.forEach(function (m) {
mockPersistence[m].andReturn(mockPromise({
method: m
}));
});
decorator = new CachingPersistenceDecorator(
testSpace,
mockPersistence
);
});
it("delegates all methods", function () {
PERSISTENCE_METHODS.forEach(function (m) {
// Reset the callback
mockCallback = jasmine.createSpy("callback");
// Invoke the method; avoid using a key that will be cached
decorator[m](testSpace, "testKey" + m, "testValue")
.then(mockCallback);
// Should have gotten that method's plain response
expect(mockCallback).toHaveBeenCalledWith({ method: m });
});
});
it("does not repeat reads of cached objects", function () {
// Perform two reads
decorator.readObject(testSpace, "someKey", "someValue")
.then(mockCallback);
decorator.readObject(testSpace, "someKey", "someValue")
.then(mockCallback);
// Should have only delegated once
expect(mockPersistence.readObject.calls.length).toEqual(1);
// But both promises should have resolved
expect(mockCallback.calls.length).toEqual(2);
});
});
}
);

View File

@ -1,4 +1,5 @@
[
"CachingPersistenceDecorator",
"CouchDocument",
"CouchIndicator",
"CouchPersistenceProvider"

View File

@ -1,6 +1,16 @@
{
"name": "Data bundle",
"description": "Interfaces and infrastructure for real-time and historical data.",
"configuration": {
"paths": {
"moment": "moment.min.js"
},
"shim": {
"moment": {
"exports": "moment"
}
}
},
"extensions": {
"components": [
{

View File

@ -47,6 +47,9 @@ define(
// is being issued.
broadcasting: false,
// Active subscriptions
subscriptions: [],
// Used for getTelemetryObjects; a reference is
// stored so that this can be called in a watch
telemetryObjects: [],
@ -120,6 +123,21 @@ define(
}));
}
// Subscribe to streaming telemetry updates
function subscribe(telemetryCapability, id) {
return telemetryCapability.subscribe(function () {
requestTelemetryForId(id, false);
});
}
// Stop listening to active subscriptions
function unsubscribe() {
self.subscriptions.forEach(function (s) {
return s && s();
});
self.subscriptions = [];
}
// Look up domain objects which have telemetry capabilities.
// This will either be the object in view, or object that
// this object delegates its telemetry capability to.
@ -149,12 +167,17 @@ define(
function buildResponseContainer(domainObject) {
var telemetry = domainObject &&
domainObject.getCapability("telemetry"),
id,
metadata;
if (telemetry) {
id = domainObject.getId();
self.subscriptions.push(subscribe(telemetry, id));
metadata = telemetry.getMetadata();
self.response[domainObject.getId()] = {
self.response[id] = {
name: domainObject.getModel().name,
domainObject: domainObject,
metadata: metadata,
@ -187,6 +210,9 @@ define(
// domain objects, and update some controller-internal
// state to support subsequent calls.
function buildResponseContainers(domainObjects) {
// Clear out any existing subscriptions
unsubscribe();
// Build the containers
domainObjects.forEach(buildResponseContainer);
@ -217,6 +243,7 @@ define(
// scope. This will be the domain object itself, or
// its telemetry delegates, or both.
function getTelemetryObjects(domainObject) {
unsubscribe();
promiseRelevantDomainObjects(domainObject)
.then(buildResponseContainers);
}
@ -241,6 +268,7 @@ define(
// Stop polling for changes
function deactivate() {
unsubscribe();
self.active = false;
}

View File

@ -22,7 +22,7 @@ define(
}
function formatRangeValue(v, key) {
return isNaN(v) ? "" : v.toFixed(3);
return isNaN(v) ? v : v.toFixed(3);
}
return {

View File

@ -12,6 +12,7 @@ define(
mockLog,
mockDomainObject,
mockTelemetry,
mockUnsubscribe,
controller;
function mockPromise(value) {
@ -44,8 +45,9 @@ define(
mockTelemetry = jasmine.createSpyObj(
"telemetry",
[ "requestData", "getMetadata" ]
[ "requestData", "subscribe", "getMetadata" ]
);
mockUnsubscribe = jasmine.createSpy("unsubscribe");
mockQ.when.andCallFake(mockPromise);
mockQ.all.andReturn(mockPromise([mockDomainObject]));
@ -63,6 +65,7 @@ define(
mockTelemetry.requestData.andReturn(mockPromise({
telemetryKey: "some value"
}));
mockTelemetry.subscribe.andReturn(mockUnsubscribe);
controller = new TelemetryController(
mockScope,
@ -187,6 +190,16 @@ define(
.toHaveBeenCalledWith("telemetryUpdate");
});
it("subscribes for streaming telemetry updates", function () {
// Push into scope to create subscriptions
mockScope.$watch.mostRecentCall.args[1](mockDomainObject);
// Should have subscribed
expect(mockTelemetry.subscribe)
.toHaveBeenCalledWith(jasmine.any(Function));
// Invoke the subscriber function (for coverage)
mockTelemetry.subscribe.mostRecentCall.args[0]({});
});
it("listens for scope destruction to clean up", function () {
expect(mockScope.$on).toHaveBeenCalledWith(
"$destroy",
@ -195,6 +208,16 @@ define(
mockScope.$on.mostRecentCall.args[1]();
});
it("unsubscribes when destroyed", function () {
// Push into scope to create subscriptions
mockScope.$watch.mostRecentCall.args[1](mockDomainObject);
// Invoke "$destroy" listener
mockScope.$on.mostRecentCall.args[1]();
// Should have unsubscribed
expect(mockUnsubscribe).toHaveBeenCalled();
});
});
}
);