Merge branch 'open-master' into open1051

Merge in latest from master branch to add license headers,
WTD-1051.
This commit is contained in:
Victor Woeltjen 2015-04-17 15:24:35 -07:00
commit 3feb0c1a57
113 changed files with 4758 additions and 254 deletions

View File

@ -7,13 +7,15 @@
"platform/commonUI/edit",
"platform/commonUI/dialog",
"platform/commonUI/general",
"platform/containment",
"platform/telemetry",
"platform/features/layout",
"platform/features/plot",
"platform/features/scrolling",
"platform/forms",
"platform/persistence/cache",
"platform/persistence/couch",
"platform/persistence/queue",
"platform/persistence/elastic",
"platform/policy",
"example/generator"
]

View File

@ -0,0 +1,12 @@
{
"name": "Example Policy",
"description": "Provides an example of using policies to prohibit actions.",
"extensions": {
"policies": [
{
"implementation": "ExamplePolicy.js",
"category": "action"
}
]
}
}

View File

@ -0,0 +1,26 @@
/*global define*/
define(
[],
function () {
"use strict";
function ExamplePolicy() {
return {
/**
* Disallow the Remove action on objects whose name contains
* "foo."
*/
allow: function (action, context) {
var domainObject = (context || {}).domainObject,
model = (domainObject && domainObject.getModel()) || {},
name = model.name || "",
metadata = action.getMetadata() || {};
return metadata.key !== 'remove' || name.indexOf('foo') < 0;
}
};
}
return ExamplePolicy;
}
);

View File

@ -108,7 +108,8 @@
"templateUrl": "templates/items/items.html",
"uses": [ "composition" ],
"gestures": [ "drop" ],
"type": "folder"
"type": "folder",
"editable": false
}
],
"components": [
@ -117,7 +118,7 @@
"provides": "actionService",
"type": "provider",
"implementation": "creation/CreateActionProvider.js",
"depends": [ "typeService", "dialogService", "creationService" ]
"depends": [ "typeService", "dialogService", "creationService", "policyService" ]
}
],
"licenses": [

View File

@ -27,7 +27,7 @@ define(
* which handles the actual instantiation and persistence
* of the newly-created domain object
*/
function CreateAction(type, parent, context, dialogService, creationService) {
function CreateAction(type, parent, context, dialogService, creationService, policyService) {
/*
Overview of steps in object creation:
@ -47,7 +47,7 @@ define(
function perform() {
// The wizard will handle creating the form model based
// on the type...
var wizard = new CreateWizard(type, parent);
var wizard = new CreateWizard(type, parent, policyService);
// Create and persist the new object, based on user
// input.

View File

@ -22,7 +22,7 @@ define(
* introduced in this bundle), responsible for handling actual
* object creation.
*/
function CreateActionProvider(typeService, dialogService, creationService) {
function CreateActionProvider(typeService, dialogService, creationService, policyService) {
return {
/**
* Get all Create actions which are applicable in the provided
@ -53,7 +53,8 @@ define(
destination,
context,
dialogService,
creationService
creationService,
policyService
);
});
}

View File

@ -19,10 +19,19 @@ define(
* @constructor
* @memberof module:core/action/create-wizard
*/
function CreateWizard(type, parent) {
function CreateWizard(type, parent, policyService) {
var model = type.getInitialModel(),
properties = type.getProperties();
function validateLocation(locatingObject) {
var locatingType = locatingObject.getCapability('type');
return policyService.allow(
"composition",
locatingType,
type
);
}
return {
/**
* Get the form model for this wizard; this is a description
@ -54,6 +63,7 @@ define(
sections.push({ name: 'Location', rows: [{
name: "Save In",
control: "locator",
validate: validateLocation,
key: "createParent"
}]});

View File

@ -17,13 +17,34 @@ define(
// the full tree
// * treeModel: The model for the embedded tree representation,
// used for bi-directional object selection.
function setLocatingObject(domainObject) {
function setLocatingObject(domainObject, priorObject) {
var context = domainObject &&
domainObject.getCapability("context");
$scope.rootObject = context && context.getRoot();
$scope.rootObject = (context && context.getRoot()) || $scope.rootObject;
$scope.treeModel.selectedObject = domainObject;
$scope.ngModel[$scope.field] = domainObject;
// Restrict which locations can be selected
if (domainObject &&
$scope.structure &&
$scope.structure.validate) {
if (!$scope.structure.validate(domainObject)) {
setLocatingObject(
$scope.structure.validate(priorObject) ?
priorObject : undefined
);
return;
}
}
// Set validity
if ($scope.ngModelController) {
$scope.ngModelController.$setValidity(
'composition',
!!$scope.treeModel.selectedObject
);
}
}
// Initial state for the tree's model

View File

@ -12,6 +12,7 @@ define(
var mockType,
mockParent,
mockProperties,
mockPolicyService,
testModel,
wizard;
@ -46,6 +47,7 @@ define(
]
);
mockProperties = [ "A", "B", "C" ].map(createMockProperty);
mockPolicyService = jasmine.createSpyObj('policyService', ['allow']);
testModel = { someKey: "some value" };
@ -58,7 +60,8 @@ define(
wizard = new CreateWizard(
mockType,
mockParent
mockParent,
mockPolicyService
);
});
@ -104,6 +107,32 @@ define(
});
});
it("validates selection types using policy", function () {
var mockDomainObject = jasmine.createSpyObj(
'domainObject',
['getCapability']
),
mockOtherType = jasmine.createSpyObj(
'otherType',
['getKey']
),
structure = wizard.getFormStructure(),
sections = structure.sections,
rows = structure.sections[sections.length - 1].rows,
locationRow = rows[rows.length - 1];
mockDomainObject.getCapability.andReturn(mockOtherType);
locationRow.validate(mockDomainObject);
// Should check policy to see if the user-selected location
// can actually contain objects of this type
expect(mockPolicyService.allow).toHaveBeenCalledWith(
'composition',
mockOtherType,
mockType
);
});
});
}

View File

@ -68,6 +68,33 @@ define(
.toHaveBeenCalledWith("context");
});
it("rejects changes which fail validation", function () {
mockScope.structure = { validate: jasmine.createSpy('validate') };
mockScope.structure.validate.andReturn(false);
// Pass selection change
mockScope.$watch.mostRecentCall.args[1](mockDomainObject);
expect(mockScope.structure.validate).toHaveBeenCalled();
// Change should have been rejected
expect(mockScope.ngModel.someField).not.toEqual(mockDomainObject);
});
it("treats a lack of a selection as invalid", function () {
mockScope.ngModelController = jasmine.createSpyObj(
'ngModelController',
[ '$setValidity' ]
);
mockScope.$watch.mostRecentCall.args[1](mockDomainObject);
expect(mockScope.ngModelController.$setValidity)
.toHaveBeenCalledWith(jasmine.any(String), true);
mockScope.$watch.mostRecentCall.args[1](undefined);
expect(mockScope.ngModelController.$setValidity)
.toHaveBeenCalledWith(jasmine.any(String), false);
});
});
}
);

View File

@ -0,0 +1,27 @@
This bundle provides `dialogService`, which can be used to prompt
for user input.
## `getUserChoice`
The `getUserChoice` method is useful for displaying a message and a set of
buttons. This method returns a promise which will resolve to the user's
chosen option (or, more specifically, its `key`), and will be rejected if
the user closes the dialog with the X in the top-right;
The `dialogModel` given as an argument to this method should have the
following properties.
* `title`: The title to display at the top of the dialog.
* `hint`: Short message to display below the title.
* `template`: Identifying key (as will be passed to `mct-include`) for
the template which will be used to populate the inner area of the dialog.
* `model`: Model to pass in the `ng-model` attribute of
`mct-include`.
* `parameters`: Parameters to pass in the `parameters` attribute of
`mct-include`.
* `options`: An array of options describing each button at the bottom.
Each option may have the following properties:
* `name`: Human-readable name to display in the button.
* `key`: Machine-readable key, to pass as the result of the resolved
promise when clicked.
* `description`: Description to show in tool tip on hover.

View File

@ -17,6 +17,10 @@
"key": "overlay-dialog",
"templateUrl": "templates/overlay-dialog.html"
},
{
"key": "overlay-options",
"templateUrl": "templates/overlay-options.html"
},
{
"key": "form-dialog",
"templateUrl": "templates/dialog.html"

View File

@ -0,0 +1,24 @@
<mct-container key="overlay">
<div class="abs top-bar">
<div class="title">{{ngModel.dialog.title}}</div>
<div class="hint">{{ngModel.dialog.hint}}</div>
</div>
<div class="abs form outline editor">
<div class='abs contents l-dialog'>
<mct-include key="ngModel.dialog.template"
parameters="ngModel.dialog.parameters"
ng-model="ngModel.dialog.model">
</mct-include>
</div>
</div>
<div class="abs bottom-bar">
<a ng-repeat="option in ngModel.dialog.options"
href=''
class="btn lg"
title="{{option.description}}"
ng-click="ngModel.confirm(option.key)"
ng-class="{ major: $first, subtle: !$first }">
{{option.name}}
</a>
</div>
</mct-container>

View File

@ -26,7 +26,7 @@ define(
dialogVisible = false;
}
function getUserInput(formModel, value) {
function getDialogResponse(key, model, resultGetter) {
// We will return this result as a promise, because user
// input is asynchronous.
var deferred = $q.defer(),
@ -35,9 +35,9 @@ define(
// Confirm function; this will be passed in to the
// overlay-dialog template and associated with a
// OK button click
function confirm() {
function confirm(value) {
// Pass along the result
deferred.resolve(overlayModel.value);
deferred.resolve(resultGetter ? resultGetter() : value);
// Stop showing the dialog
dismiss();
@ -51,6 +51,10 @@ define(
dismiss();
}
// Add confirm/cancel callbacks
model.confirm = confirm;
model.cancel = cancel;
if (dialogVisible) {
// Only one dialog should be shown at a time.
// The application design should be such that
@ -58,26 +62,15 @@ define(
$log.warn([
"Dialog already showing; ",
"unable to show ",
formModel.name
model.name
].join(""));
deferred.reject();
} else {
// To be passed to the overlay-dialog template,
// via ng-model
overlayModel = {
title: formModel.name,
message: formModel.message,
structure: formModel,
value: value,
confirm: confirm,
cancel: cancel
};
// Add the overlay using the OverlayService, which
// will handle actual insertion into the DOM
overlay = overlayService.createOverlay(
"overlay-dialog",
overlayModel
key,
model
);
// Track that a dialog is already visible, to
@ -88,6 +81,35 @@ define(
return deferred.promise;
}
function getUserInput(formModel, value) {
var overlayModel = {
title: formModel.name,
message: formModel.message,
structure: formModel,
value: value
};
// Provide result from the model
function resultGetter() {
return overlayModel.value;
}
// Show the overlay-dialog
return getDialogResponse(
"overlay-dialog",
overlayModel,
resultGetter
);
}
function getUserChoice(dialogModel) {
// Show the overlay-options dialog
return getDialogResponse(
"overlay-options",
{ dialog: dialogModel }
);
}
return {
/**
* Request user input via a window-modal dialog.
@ -100,7 +122,14 @@ define(
* user input cannot be obtained (for instance,
* because the user cancelled the dialog)
*/
getUserInput: getUserInput
getUserInput: getUserInput,
/**
* Request that the user chooses from a set of options,
* which will be shown as buttons.
*
* @param dialogModel a description of the dialog to show
*/
getUserChoice: getUserChoice
};
}

View File

@ -86,6 +86,19 @@ define(
expect(mockDeferred.reject).not.toHaveBeenCalled();
});
it("provides an options dialogs", function () {
var dialogModel = {};
dialogService.getUserChoice(dialogModel);
expect(mockOverlayService.createOverlay).toHaveBeenCalledWith(
'overlay-options',
{
dialog: dialogModel,
confirm: jasmine.any(Function),
cancel: jasmine.any(Function)
}
);
});
});
}
);

View File

@ -1,5 +1,26 @@
Contains sources and resources associated with Edit mode.
# Extensions
## Directives
This bundle introduces the `mct-before-unload` directive, primarily for
internal use (to prompt the user to confirm navigation away from unsaved
changes in Edit mode.)
The `mct-before-unload` directive is used as an attribute whose value is
an Angular expression that is evaluated when navigation changes (either
via browser-level changes, such as the refresh button, or changes to
the Angular route, which happens when hitting the back button in Edit
mode.) The result of this evaluation, when truthy, is shown in a browser
dialog to allow the user to confirm navigation. When falsy, no prompt is
shown, allowing these dialogs to be shown conditionally. (For instance, in
Edit mode, prompts are only shown if user-initiated changes have
occurred.)
This directive may be attached to any element; its behavior will be enforced
so long as that element remains within the DOM.
# Toolbars
Views may specify the contents of a toolbar through a `toolbar`

View File

@ -10,7 +10,7 @@
{
"key": "EditController",
"implementation": "controllers/EditController.js",
"depends": [ "$scope", "navigationService" ]
"depends": [ "$scope", "$q", "navigationService" ]
},
{
"key": "EditActionController",
@ -23,7 +23,18 @@
"depends": [ "$scope" ]
}
],
"directives": [
{
"key": "mctBeforeUnload",
"implementation": "directives/MCTBeforeUnload.js",
"depends": [ "$window" ]
}
],
"actions": [
{
"key": "compose",
"implementation": "actions/LinkAction.js"
},
{
"key": "edit",
"implementation": "actions/EditAction.js",
@ -34,7 +45,7 @@
},
{
"key": "properties",
"category": "contextual",
"category": ["contextual", "view-control"],
"implementation": "actions/PropertiesAction.js",
"glyph": "p",
"name": "Edit Properties...",
@ -68,6 +79,16 @@
"depends": [ "$location" ]
}
],
"policies": [
{
"category": "action",
"implementation": "policies/EditActionPolicy.js"
},
{
"category": "view",
"implementation": "policies/EditableViewPolicy.js"
}
],
"templates": [
{
"key": "edit-library",

View File

@ -1,8 +1,9 @@
<div content="jquery-wrapper"
class="abs holder-all edit-mode"
ng-controller="EditController">
ng-controller="EditController as editMode"
mct-before-unload="editMode.getUnloadWarning()">
<mct-representation key="'edit-object'" mct-object="navigatedObject">
<mct-representation key="'edit-object'" mct-object="editMode.navigatedObject()">
</mct-representation>
<mct-include key="'bottombar'"></mct-include>

View File

@ -0,0 +1,49 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Add one domain object to another's composition.
*/
function LinkAction(context) {
var domainObject = (context || {}).domainObject,
selectedObject = (context || {}).selectedObject,
selectedId = selectedObject && selectedObject.getId();
// Add this domain object's identifier
function addId(model) {
if (Array.isArray(model.composition) &&
model.composition.indexOf(selectedId) < 0) {
model.composition.push(selectedId);
}
}
// Persist changes to the domain object
function doPersist() {
var persistence = domainObject.getCapability('persistence');
return persistence.persist();
}
// Link these objects
function doLink() {
return domainObject.useCapability("mutation", addId)
.then(doPersist);
}
return {
/**
* Perform this action.
*/
perform: function () {
return selectedId && doLink();
}
};
}
return LinkAction;
}
);

View File

@ -13,24 +13,18 @@ define(
function SaveAction($location, context) {
var domainObject = context.domainObject;
// Look up the object's "editor.completion" capability;
// Invoke any save behavior introduced by the editor capability;
// this is introduced by EditableDomainObject which is
// used to insulate underlying objects from changes made
// during editing.
function getEditorCapability() {
return domainObject.getCapability("editor");
}
// Invoke any save behavior introduced by the editor.completion
// capability.
function doSave(editor) {
return editor.save();
function doSave() {
return domainObject.getCapability("editor").save();
}
// Discard the current root view (which will be the editing
// UI, which will have been pushed atop the Browise UI.)
function returnToBrowse() {
$location.path("/browse");
return $location.path("/browse");
}
return {
@ -41,7 +35,7 @@ define(
* cancellation has completed
*/
perform: function () {
return doSave(getEditorCapability()).then(returnToBrowse);
return doSave().then(returnToBrowse);
}
};
}

View File

@ -29,6 +29,13 @@ define(
cache.markDirty(editableObject);
};
// Delegate refresh to the original object; this avoids refreshing
// the editable instance of the object, and ensures that refresh
// correctly targets the "real" version of the object.
persistence.refresh = function () {
return domainObject.getCapability('persistence').refresh();
};
return persistence;
}

View File

@ -33,7 +33,7 @@ define(
// removed from the layer which gets dependency
// injection.
function resolvePromise(value) {
return value && value.then ? value : {
return (value && value.then) ? value : {
then: function (callback) {
return resolvePromise(callback(value));
}
@ -50,19 +50,7 @@ define(
// Persist the underlying domain object
function doPersist() {
return persistenceCapability.persist();
}
// Save any other objects that have been modified in the cache.
// IMPORTANT: This must not be called until after this object
// has been marked as clean.
function saveOthers() {
return cache.saveAll();
}
// Indicate that this object has been saved.
function markClean() {
return cache.markClean(editableObject);
return domainObject.getCapability('persistence').persist();
}
return {
@ -70,14 +58,15 @@ define(
* Save any changes that have been made to this domain object
* (as well as to others that might have been retrieved and
* modified during the editing session)
* @param {boolean} nonrecursive if true, save only this
* object (and not other objects with associated changes)
* @returns {Promise} a promise that will be fulfilled after
* persistence has completed.
*/
save: function () {
return resolvePromise(doMutate())
.then(doPersist)
.then(markClean)
.then(saveOthers);
save: function (nonrecursive) {
return nonrecursive ?
resolvePromise(doMutate()).then(doPersist) :
resolvePromise(cache.saveAll());
},
/**
* Cancel editing; Discard any changes that have been made to
@ -88,6 +77,13 @@ define(
*/
cancel: function () {
return resolvePromise(undefined);
},
/**
* Check if there are any unsaved changes.
* @returns {boolean} true if there are unsaved changes
*/
dirty: function () {
return cache.dirty();
}
};
};

View File

@ -14,12 +14,14 @@ define(
* navigated domain object into the scope.
* @constructor
*/
function EditController($scope, navigationService) {
function EditController($scope, $q, navigationService) {
var navigatedObject;
function setNavigation(domainObject) {
// Wrap the domain object such that all mutation is
// confined to edit mode (until Save)
$scope.navigatedObject =
domainObject && new EditableDomainObject(domainObject);
navigatedObject =
domainObject && new EditableDomainObject(domainObject, $q);
}
setNavigation(navigationService.getNavigation());
@ -27,6 +29,31 @@ define(
$scope.$on("$destroy", function () {
navigationService.removeListener(setNavigation);
});
return {
/**
* Get the domain object which is navigated-to.
* @returns {DomainObject} the domain object that is navigated-to
*/
navigatedObject: function () {
return navigatedObject;
},
/**
* Get the warning to show if the user attempts to navigate
* away from Edit mode while unsaved changes are present.
* @returns {string} the warning to show, or undefined if
* there are no unsaved changes
*/
getUnloadWarning: function () {
var editorCapability = navigatedObject &&
navigatedObject.getCapability("editor"),
hasChanges = editorCapability && editorCapability.dirty();
return hasChanges ?
"Unsaved changes will be lost if you leave this page." :
undefined;
}
};
}
return EditController;

View File

@ -0,0 +1,84 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Defines the `mct-before-unload` directive. The expression bound
* to this attribute will be evaluated during page navigation events
* and, if it returns a truthy value, will be used to populate a
* prompt to the user to confirm this navigation.
* @constructor
* @param $window the window
*/
function MCTBeforeUnload($window) {
var unloads = [],
oldBeforeUnload = $window.onbeforeunload;
// Run all unload functions, returning the first returns truthily.
function checkUnloads() {
var result;
unloads.forEach(function (unload) {
result = result || unload();
});
return result;
}
// Link function for an mct-before-unload directive usage
function link(scope, element, attrs) {
// Invoke the
function unload() {
return scope.$eval(attrs.mctBeforeUnload);
}
// Stop using this unload expression
function removeUnload() {
unloads = unloads.filter(function (callback) {
return callback !== unload;
});
if (unloads.length === 0) {
$window.onbeforeunload = oldBeforeUnload;
}
}
// Show a dialog before allowing a location change
function checkLocationChange(event) {
// Get an unload message (if any)
var warning = unload();
// Prompt the user if there's an unload message
if (warning && !$window.confirm(warning)) {
// ...and prevent the route change if it was confirmed
event.preventDefault();
}
}
// If this is the first active instance of this directive,
// register as the window's beforeunload handler
if (unloads.length === 0) {
$window.onbeforeunload = checkUnloads;
}
// Include this instance of the directive's unload function
unloads.push(unload);
// Remove it when the scope is destroyed
scope.$on("$destroy", removeUnload);
// Also handle route changes
scope.$on("$locationChangeStart", checkLocationChange);
}
return {
// Applicable as an attribute
restrict: "A",
// Link with the provided function
link: link
};
}
return MCTBeforeUnload;
}
);

View File

@ -48,7 +48,7 @@ define(
* and provides a "working copy" of the object's
* model to allow changes to be easily cancelled.
*/
function EditableDomainObject(domainObject) {
function EditableDomainObject(domainObject, $q) {
// The cache will hold all domain objects reached from
// the initial EditableDomainObject; this ensures that
// different versions of the same editable domain object
@ -81,7 +81,7 @@ define(
return editableObject;
}
cache = new EditableDomainObjectCache(EditableDomainObjectImpl);
cache = new EditableDomainObjectCache(EditableDomainObjectImpl, $q);
return cache.getEditableObject(domainObject);
}

View File

@ -29,10 +29,11 @@ define(
* constructor function which takes a regular domain object as
* an argument, and returns an editable domain object as its
* result.
* @param $q Angular's $q, for promise handling
* @constructor
* @memberof module:editor/object/editable-domain-object-cache
*/
function EditableDomainObjectCache(EditableDomainObject) {
function EditableDomainObjectCache(EditableDomainObject, $q) {
var cache = new EditableModelCache(),
dirty = {},
root;
@ -50,6 +51,11 @@ define(
// some special behavior for its context capability.
root = root || domainObject;
// Avoid double-wrapping (WTD-1017)
if (domainObject.hasCapability('editor')) {
return domainObject;
}
// Provide an editable form of the object
return new EditableDomainObject(
domainObject,
@ -88,23 +94,27 @@ define(
* Initiate a save on all objects that have been cached.
*/
saveAll: function () {
var object;
// Get a list of all dirty objects
var objects = Object.keys(dirty).map(function (k) {
return dirty[k];
});
// Clear dirty set, since we're about to save.
dirty = {};
// Most save logic is handled by the "editor.completion"
// capability, but this in turn will typically invoke
// Save All. An infinite loop is avoided by marking
// objects as clean as we go.
while (Object.keys(dirty).length > 0) {
// Pick the first dirty object
object = dirty[Object.keys(dirty)[0]];
// Mark non-dirty to avoid successive invocations
this.markClean(object);
// Invoke its save behavior
object.getCapability('editor').save();
}
// capability, so that is delegated here.
return $q.all(objects.map(function (object) {
// Save; pass a nonrecursive flag to avoid looping
return object.getCapability('editor').save(true);
}));
},
/**
* Check if any objects have been marked dirty in this cache.
* @returns {boolean} true if objects are dirty
*/
dirty: function () {
return Object.keys(dirty).length > 0;
}
};
}

View File

@ -0,0 +1,61 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Policy controlling when the `edit` and/or `properties` actions
* can appear as applicable actions of the `view-control` category
* (shown as buttons in the top-right of browse mode.)
* @constructor
*/
function EditActionPolicy() {
// Get a count of views which are not flagged as non-editable.
function countEditableViews(context) {
var domainObject = (context || {}).domainObject,
views = domainObject && domainObject.useCapability('view'),
count = 0;
// A view is editable unless explicitly flagged as not
(views || []).forEach(function (view) {
count += (view.editable !== false) ? 1 : 0;
});
return count;
}
return {
/**
* Check whether or not a given action is allowed by this
* policy.
* @param {Action} action the action
* @param context the context
* @returns {boolean} true if not disallowed
*/
allow: function (action, context) {
var key = action.getMetadata().key,
category = (context || {}).category;
// Only worry about actions in the view-control category
if (category === 'view-control') {
// Restrict 'edit' to cases where there are editable
// views (similarly, restrict 'properties' to when
// the converse is true)
if (key === 'edit') {
return countEditableViews(context) > 0;
} else if (key === 'properties') {
return countEditableViews(context) < 1;
}
}
// Like all policies, allow by default.
return true;
}
};
}
return EditActionPolicy;
}
);

View File

@ -0,0 +1,36 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Policy controlling which views should be visible in Edit mode.
* @constructor
*/
function EditableViewPolicy() {
return {
/**
* Check whether or not a given action is allowed by this
* policy.
* @param {Action} action the action
* @param domainObject the domain object which will be viewed
* @returns {boolean} true if not disallowed
*/
allow: function (view, domainObject) {
// If a view is flagged as non-editable, only allow it
// while we're not in Edit mode.
if ((view || {}).editable === false) {
return !domainObject.hasCapability('editor');
}
// Like all policies, allow by default.
return true;
}
};
}
return EditableViewPolicy;
}
);

View File

@ -0,0 +1,107 @@
/*global define,describe,it,expect,beforeEach,jasmine,spyOn*/
define(
["../../src/actions/LinkAction"],
function (LinkAction) {
"use strict";
describe("The Link action", function () {
var mockQ,
mockDomainObject,
mockParent,
mockContext,
mockMutation,
mockPersistence,
mockType,
actionContext,
model,
capabilities,
action;
function mockPromise(value) {
return {
then: function (callback) {
return mockPromise(callback(value));
}
};
}
beforeEach(function () {
mockDomainObject = jasmine.createSpyObj(
"domainObject",
[ "getId", "getCapability" ]
);
mockQ = { when: mockPromise };
mockParent = {
getModel: function () {
return model;
},
getCapability: function (k) {
return capabilities[k];
},
useCapability: function (k, v) {
return capabilities[k].invoke(v);
}
};
mockContext = jasmine.createSpyObj("context", [ "getParent" ]);
mockMutation = jasmine.createSpyObj("mutation", [ "invoke" ]);
mockPersistence = jasmine.createSpyObj("persistence", [ "persist" ]);
mockType = jasmine.createSpyObj("type", [ "hasFeature" ]);
mockDomainObject.getId.andReturn("test");
mockDomainObject.getCapability.andReturn(mockContext);
mockContext.getParent.andReturn(mockParent);
mockType.hasFeature.andReturn(true);
mockMutation.invoke.andReturn(mockPromise(true));
capabilities = {
mutation: mockMutation,
persistence: mockPersistence,
type: mockType
};
model = {
composition: [ "a", "b", "c" ]
};
actionContext = {
domainObject: mockParent,
selectedObject: mockDomainObject
};
action = new LinkAction(actionContext);
});
it("mutates the parent when performed", function () {
action.perform();
expect(mockMutation.invoke)
.toHaveBeenCalledWith(jasmine.any(Function));
});
it("changes composition from its mutation function", function () {
var mutator, result;
action.perform();
mutator = mockMutation.invoke.mostRecentCall.args[0];
result = mutator(model);
// Should not have cancelled the mutation
expect(result).not.toBe(false);
// Simulate mutate's behavior (remove can either return a
// new model or modify this one in-place)
result = result || model;
// Should have removed "test" - that was our
// mock domain object's id.
expect(result.composition).toEqual(["a", "b", "c", "test"]);
// Finally, should have persisted
expect(mockPersistence.persist).toHaveBeenCalled();
});
});
}
);

View File

@ -15,7 +15,7 @@ define(
beforeEach(function () {
mockPersistence = jasmine.createSpyObj(
"persistence",
[ "persist" ]
[ "persist", "refresh" ]
);
mockEditableObject = jasmine.createSpyObj(
"editableObject",
@ -30,6 +30,8 @@ define(
[ "markDirty" ]
);
mockDomainObject.getCapability.andReturn(mockPersistence);
capability = new EditablePersistenceCapability(
mockPersistence,
mockEditableObject,
@ -49,6 +51,18 @@ define(
expect(mockPersistence.persist).not.toHaveBeenCalled();
});
it("refreshes using the original domain object's persistence", function () {
// Refreshing needs to delegate via the unwrapped domain object.
// Otherwise, only the editable version of the object will be updated;
// we instead want the real version of the object to receive these
// changes.
expect(mockDomainObject.getCapability).not.toHaveBeenCalled();
expect(mockPersistence.refresh).not.toHaveBeenCalled();
capability.refresh();
expect(mockDomainObject.getCapability).toHaveBeenCalledWith('persistence');
expect(mockPersistence.refresh).toHaveBeenCalled();
});
});
}
);

View File

@ -5,7 +5,7 @@ define(
function (EditorCapability) {
"use strict";
describe("An editable context capability", function () {
describe("The editor capability", function () {
var mockPersistence,
mockEditableObject,
mockDomainObject,
@ -32,6 +32,8 @@ define(
);
mockCallback = jasmine.createSpy("callback");
mockDomainObject.getCapability.andReturn(mockPersistence);
model = { someKey: "some value", x: 42 };
capability = new EditorCapability(
@ -42,8 +44,8 @@ define(
);
});
it("mutates the real domain object on save", function () {
capability.save().then(mockCallback);
it("mutates the real domain object on nonrecursive save", function () {
capability.save(true).then(mockCallback);
// Wait for promise to resolve
waitsFor(function () {
@ -60,19 +62,6 @@ define(
});
});
it("marks the saved object as clean in the editing cache", function () {
capability.save().then(mockCallback);
// Wait for promise to resolve
waitsFor(function () {
return mockCallback.calls.length > 0;
}, 250);
runs(function () {
expect(mockCache.markClean).toHaveBeenCalledWith(mockEditableObject);
});
});
it("tells the cache to save others", function () {
capability.save().then(mockCallback);

View File

@ -7,6 +7,7 @@ define(
describe("The Edit mode controller", function () {
var mockScope,
mockQ,
mockNavigationService,
mockObject,
mockCapability,
@ -17,13 +18,14 @@ define(
"$scope",
[ "$on" ]
);
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
mockNavigationService = jasmine.createSpyObj(
"navigationService",
[ "getNavigation", "addListener", "removeListener" ]
);
mockObject = jasmine.createSpyObj(
"domainObject",
[ "getId", "getModel", "getCapability" ]
[ "getId", "getModel", "getCapability", "hasCapability" ]
);
mockCapability = jasmine.createSpyObj(
"capability",
@ -37,21 +39,22 @@ define(
controller = new EditController(
mockScope,
mockQ,
mockNavigationService
);
});
it("places the currently-navigated object in scope", function () {
expect(mockScope.navigatedObject).toBeDefined();
expect(mockScope.navigatedObject.getId()).toEqual("test");
it("exposes the currently-navigated object", function () {
expect(controller.navigatedObject()).toBeDefined();
expect(controller.navigatedObject().getId()).toEqual("test");
});
it("adds an editor capability to the navigated object", function () {
// Should provide an editor capability...
expect(mockScope.navigatedObject.getCapability("editor"))
expect(controller.navigatedObject().getCapability("editor"))
.toBeDefined();
// Shouldn't have been the mock capability we provided
expect(mockScope.navigatedObject.getCapability("editor"))
expect(controller.navigatedObject().getCapability("editor"))
.not.toEqual(mockCapability);
});
@ -76,6 +79,23 @@ define(
.toHaveBeenCalledWith(navCallback);
});
it("exposes a warning message for unload", function () {
var obj = controller.navigatedObject(),
mockEditor = jasmine.createSpyObj('editor', ['dirty']);
// Normally, should be undefined
expect(controller.getUnloadWarning()).toBeUndefined();
// Override the object's editor capability, make it look
// like there are unsaved changes.
obj.getCapability = jasmine.createSpy();
obj.getCapability.andReturn(mockEditor);
mockEditor.dirty.andReturn(true);
// Should have some warning message here now
expect(controller.getUnloadWarning()).toEqual(jasmine.any(String));
});
});
}
);

View File

@ -0,0 +1,95 @@
/*global define,describe,it,expect,beforeEach,jasmine*/
define(
["../../src/directives/MCTBeforeUnload"],
function (MCTBeforeUnload) {
"use strict";
describe("The mct-before-unload directive", function () {
var mockWindow,
mockScope,
testAttrs,
mockEvent,
directive;
function fireListener(eventType, value) {
mockScope.$on.calls.forEach(function (call) {
if (call.args[0] === eventType) {
call.args[1](value);
}
});
}
beforeEach(function () {
mockWindow = jasmine.createSpyObj("$window", ['confirm']);
mockScope = jasmine.createSpyObj("$scope", ['$eval', '$on']);
testAttrs = { mctBeforeUnload: "someExpression" };
mockEvent = jasmine.createSpyObj("event", ["preventDefault"]);
directive = new MCTBeforeUnload(mockWindow);
directive.link(mockScope, {}, testAttrs);
});
it("can be used only as an attribute", function () {
expect(directive.restrict).toEqual('A');
});
it("listens for beforeunload", function () {
expect(mockWindow.onbeforeunload).toEqual(jasmine.any(Function));
});
it("listens for route changes", function () {
expect(mockScope.$on).toHaveBeenCalledWith(
"$locationChangeStart",
jasmine.any(Function)
);
});
it("listens for its scope's destroy event", function () {
expect(mockScope.$on).toHaveBeenCalledWith(
"$destroy",
jasmine.any(Function)
);
});
it("uses result of evaluated expression as a warning", function () {
mockScope.$eval.andReturn(undefined);
expect(mockWindow.onbeforeunload(mockEvent)).toBeUndefined();
mockScope.$eval.andReturn("some message");
expect(mockWindow.onbeforeunload(mockEvent)).toEqual("some message");
// Verify that the right expression was evaluated
expect(mockScope.$eval).toHaveBeenCalledWith(testAttrs.mctBeforeUnload);
});
it("confirms route changes", function () {
// First, try with no unsaved changes;
// should not confirm or preventDefault
mockScope.$eval.andReturn(undefined);
fireListener("$locationChangeStart", mockEvent);
expect(mockWindow.confirm).not.toHaveBeenCalled();
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
// Next, try with unsaved changes that the user confirms;
// should prompt, but not preventDefault
mockScope.$eval.andReturn("some message");
mockWindow.confirm.andReturn(true);
fireListener("$locationChangeStart", mockEvent);
expect(mockWindow.confirm).toHaveBeenCalledWith("some message");
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
// Finally, act as if the user said no to this dialog;
// this should preventDefault on the location change.
mockWindow.confirm.andReturn(false);
fireListener("$locationChangeStart", mockEvent);
expect(mockWindow.confirm).toHaveBeenCalledWith("some message");
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it("cleans up listeners when destroyed", function () {
fireListener("$destroy", mockEvent);
expect(mockWindow.onbeforeunload).toBeUndefined();
});
});
}
);

View File

@ -1,4 +1,4 @@
/*global define,describe,it,expect,beforeEach*/
/*global define,describe,it,expect,beforeEach,jasmine*/
define(
["../../src/objects/EditableDomainObjectCache"],
@ -10,6 +10,7 @@ define(
var captured,
completionCapability,
object,
mockQ,
cache;
@ -20,6 +21,9 @@ define(
getModel: function () { return {}; },
getCapability: function (name) {
return completionCapability;
},
hasCapability: function (name) {
return false;
}
};
}
@ -28,11 +32,15 @@ define(
var result = Object.create(domainObject);
result.wrapped = true;
result.wrappedModel = model;
result.hasCapability = function (name) {
return name === 'editor';
};
captured.wraps = (captured.wraps || 0) + 1;
return result;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
captured = {};
completionCapability = {
save: function () {
@ -40,7 +48,7 @@ define(
}
};
cache = new EditableDomainObjectCache(WrapObject);
cache = new EditableDomainObjectCache(WrapObject, mockQ);
});
it("wraps objects using provided constructor", function () {
@ -110,6 +118,19 @@ define(
expect(cache.isRoot(domainObjects[2])).toBeFalsy();
});
it("does not double-wrap objects", function () {
var domainObject = new TestObject('test-id'),
wrappedObject = cache.getEditableObject(domainObject);
// Same instance should be returned if you try to wrap
// twice. This is necessary, since it's possible to (e.g.)
// use a context capability on an object retrieved via
// composition, in which case a result will already be
// wrapped.
expect(cache.getEditableObject(wrappedObject))
.toBe(wrappedObject);
});
});
}

View File

@ -0,0 +1,78 @@
/*global define,describe,it,expect,beforeEach,jasmine*/
define(
["../../src/policies/EditActionPolicy"],
function (EditActionPolicy) {
"use strict";
describe("The Edit action policy", function () {
var editableView,
nonEditableView,
undefinedView,
testViews,
testContext,
mockDomainObject,
mockEditAction,
mockPropertiesAction,
policy;
beforeEach(function () {
mockDomainObject = jasmine.createSpyObj(
'domainObject',
[ 'useCapability' ]
);
mockEditAction = jasmine.createSpyObj('edit', ['getMetadata']);
mockPropertiesAction = jasmine.createSpyObj('edit', ['getMetadata']);
editableView = { editable: true };
nonEditableView = { editable: false };
undefinedView = { someKey: "some value" };
testViews = [];
mockDomainObject.useCapability.andCallFake(function (c) {
// Provide test views, only for the view capability
return c === 'view' && testViews;
});
mockEditAction.getMetadata.andReturn({ key: 'edit' });
mockPropertiesAction.getMetadata.andReturn({ key: 'properties' });
testContext = {
domainObject: mockDomainObject,
category: 'view-control'
};
policy = new EditActionPolicy();
});
it("allows the edit action when there are editable views", function () {
testViews = [ editableView ];
expect(policy.allow(mockEditAction, testContext)).toBeTruthy();
// No edit flag defined; should be treated as editable
testViews = [ undefinedView, undefinedView ];
expect(policy.allow(mockEditAction, testContext)).toBeTruthy();
});
it("allows the edit properties action when there are no editable views", function () {
testViews = [ nonEditableView, nonEditableView ];
expect(policy.allow(mockPropertiesAction, testContext)).toBeTruthy();
});
it("disallows the edit action when there are no editable views", function () {
testViews = [ nonEditableView, nonEditableView ];
expect(policy.allow(mockEditAction, testContext)).toBeFalsy();
});
it("disallows the edit properties action when there are editable views", function () {
testViews = [ editableView ];
expect(policy.allow(mockPropertiesAction, testContext)).toBeFalsy();
});
it("allows the edit properties outside of the 'view-control' category", function () {
testViews = [ nonEditableView ];
testContext.category = "something-else";
expect(policy.allow(mockPropertiesAction, testContext)).toBeTruthy();
});
});
}
);

View File

@ -0,0 +1,56 @@
/*global define,describe,it,expect,beforeEach,jasmine*/
define(
["../../src/policies/EditableViewPolicy"],
function (EditableViewPolicy) {
"use strict";
describe("The editable view policy", function () {
var testView,
mockDomainObject,
testMode,
policy;
beforeEach(function () {
testMode = true; // Act as if we're in Edit mode by default
mockDomainObject = jasmine.createSpyObj(
'domainObject',
['hasCapability']
);
mockDomainObject.hasCapability.andCallFake(function (c) {
return (c === 'editor') && testMode;
});
policy = new EditableViewPolicy();
});
it("disallows views in edit mode that are flagged as non-editable", function () {
expect(policy.allow({ editable: false }, mockDomainObject))
.toBeFalsy();
});
it("allows views in edit mode that are flagged as editable", function () {
expect(policy.allow({ editable: true }, mockDomainObject))
.toBeTruthy();
});
it("allows any view outside of edit mode", function () {
var testViews = [
{ editable: false },
{ editable: true },
{ someKey: "some value" }
];
testMode = false; // Act as if we're not in Edit mode
testViews.forEach(function (testView) {
expect(policy.allow(testView, mockDomainObject)).toBeTruthy();
});
});
it("treats views with no defined 'editable' property as editable", function () {
expect(policy.allow({ someKey: "some value" }, mockDomainObject))
.toBeTruthy();
});
});
}
);

View File

@ -1,6 +1,7 @@
[
"actions/CancelAction",
"actions/EditAction",
"actions/LinkAction",
"actions/PropertiesAction",
"actions/PropertiesDialog",
"actions/RemoveAction",
@ -14,9 +15,12 @@
"controllers/EditActionController",
"controllers/EditController",
"controllers/EditPanesController",
"directives/MCTBeforeUnload",
"objects/EditableDomainObject",
"objects/EditableDomainObjectCache",
"objects/EditableModelCache",
"policies/EditableViewPolicy",
"policies/EditActionPolicy",
"representers/EditRepresenter",
"representers/EditToolbar",
"representers/EditToolbarRepresenter",

View File

@ -1,5 +1,5 @@
/* CONSTANTS */
/* line 17, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 17, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
@ -20,38 +20,38 @@ time, mark, audio, video {
font-size: 100%;
vertical-align: baseline; }
/* line 22, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 22, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
html {
line-height: 1; }
/* line 24, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 24, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
ol, ul {
list-style: none; }
/* line 26, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 26, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
table {
border-collapse: collapse;
border-spacing: 0; }
/* line 28, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 28, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
caption, th, td {
text-align: left;
font-weight: normal;
vertical-align: middle; }
/* line 30, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 30, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
q, blockquote {
quotes: none; }
/* line 103, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 103, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
q:before, q:after, blockquote:before, blockquote:after {
content: "";
content: none; }
/* line 32, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 32, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
a img {
border: none; }
/* line 116, ../../../../../../../../../../Library/Ruby/Gems/1.8/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
/* line 116, ../../../../../../../../../Library/Ruby/Gems/2.0.0/gems/compass-0.12.2/frameworks/compass/stylesheets/compass/reset/_utilities.scss */
article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary {
display: block; }
@ -302,68 +302,80 @@ span {
min-width: 250px;
width: 48.5%; }
/* line 127, ../sass/user-environ/_layout.scss */
.cols.cols-2-ff .col-100px {
width: 100px; }
/* line 134, ../sass/user-environ/_layout.scss */
.cols.cols-6 .col-1 {
min-width: 83.33333px;
width: 15.16667%; }
/* line 140, ../sass/user-environ/_layout.scss */
.cols.cols-16 .col-1 {
min-width: 31.25px;
width: 4.75%; }
/* line 130, ../sass/user-environ/_layout.scss */
/* line 143, ../sass/user-environ/_layout.scss */
.cols.cols-16 .col-2 {
min-width: 62.5px;
width: 11%; }
/* line 133, ../sass/user-environ/_layout.scss */
/* line 146, ../sass/user-environ/_layout.scss */
.cols.cols-16 .col-7 {
min-width: 218.75px;
width: 42.25%; }
/* line 139, ../sass/user-environ/_layout.scss */
/* line 152, ../sass/user-environ/_layout.scss */
.cols.cols-32 .col-2 {
min-width: 31.25px;
width: 4.75%; }
/* line 142, ../sass/user-environ/_layout.scss */
/* line 155, ../sass/user-environ/_layout.scss */
.cols.cols-32 .col-15 {
min-width: 234.375px;
width: 45.375%; }
/* line 159, ../sass/user-environ/_layout.scss */
.cols .l-row {
overflow: hidden;
*zoom: 1;
padding: 5px 0; }
/* line 148, ../sass/user-environ/_layout.scss */
/* line 165, ../sass/user-environ/_layout.scss */
.pane {
position: absolute; }
/* line 151, ../sass/user-environ/_layout.scss */
/* line 168, ../sass/user-environ/_layout.scss */
.pane.treeview .create-btn-holder {
bottom: auto;
height: 35px; }
/* line 154, ../sass/user-environ/_layout.scss */
/* line 171, ../sass/user-environ/_layout.scss */
.pane.treeview .tree-holder {
overflow: auto;
top: 40px; }
/* line 163, ../sass/user-environ/_layout.scss */
/* line 180, ../sass/user-environ/_layout.scss */
.pane.items .object-holder {
top: 40px; }
/* line 168, ../sass/user-environ/_layout.scss */
/* line 185, ../sass/user-environ/_layout.scss */
.pane.edit-main .object-holder {
top: 0; }
/* line 174, ../sass/user-environ/_layout.scss */
/* line 191, ../sass/user-environ/_layout.scss */
.pane .object-holder {
overflow: auto; }
/* line 182, ../sass/user-environ/_layout.scss */
/* line 199, ../sass/user-environ/_layout.scss */
.split-layout.horizontal > .pane {
margin-top: 5px; }
/* line 185, ../sass/user-environ/_layout.scss */
/* line 202, ../sass/user-environ/_layout.scss */
.split-layout.horizontal > .pane:first-child {
margin-top: 0; }
/* line 192, ../sass/user-environ/_layout.scss */
/* line 209, ../sass/user-environ/_layout.scss */
.split-layout.vertical > .pane {
margin-left: 5px; }
/* line 194, ../sass/user-environ/_layout.scss */
/* line 211, ../sass/user-environ/_layout.scss */
.split-layout.vertical > .pane > .holder {
left: 0;
right: 0; }
/* line 198, ../sass/user-environ/_layout.scss */
/* line 215, ../sass/user-environ/_layout.scss */
.split-layout.vertical > .pane:first-child {
margin-left: 0; }
/* line 200, ../sass/user-environ/_layout.scss */
/* line 217, ../sass/user-environ/_layout.scss */
.split-layout.vertical > .pane:first-child .holder {
right: 5px; }
/* line 209, ../sass/user-environ/_layout.scss */
/* line 226, ../sass/user-environ/_layout.scss */
.vscroll {
overflow-y: auto; }
@ -2821,10 +2833,10 @@ input[type="text"] {
.wait-spinner {
display: block;
position: absolute;
-webkit-animation: rotation 0.6s infinite linear;
-moz-animation: rotation 0.6s infinite linear;
-o-animation: rotation 0.6s infinite linear;
animation: rotation 0.6s infinite linear;
-webkit-animation: rotation .6s infinite linear;
-moz-animation: rotation .6s infinite linear;
-o-animation: rotation .6s infinite linear;
animation: rotation .6s infinite linear;
border-color: rgba(0, 153, 204, 0.25);
border-top-color: #0099cc;
border-style: solid;
@ -2863,10 +2875,10 @@ input[type="text"] {
.treeview .wait-spinner {
display: block;
position: absolute;
-webkit-animation: rotation 0.6s infinite linear;
-moz-animation: rotation 0.6s infinite linear;
-o-animation: rotation 0.6s infinite linear;
animation: rotation 0.6s infinite linear;
-webkit-animation: rotation .6s infinite linear;
-moz-animation: rotation .6s infinite linear;
-o-animation: rotation .6s infinite linear;
animation: rotation .6s infinite linear;
border-color: rgba(0, 153, 204, 0.25);
border-top-color: #0099cc;
border-style: solid;
@ -2879,6 +2891,18 @@ input[type="text"] {
top: 2px;
left: 0; }
/* Classes to be used for lists of properties and values */
/* line 4, ../sass/_properties.scss */
.properties .s-row {
border-top: 1px solid #4d4d4d;
font-size: 0.8em; }
/* line 7, ../sass/_properties.scss */
.properties .s-row:first-child {
border: none; }
/* line 10, ../sass/_properties.scss */
.properties .s-row .s-value {
color: #fff; }
/* line 1, ../sass/_autoflow.scss */
.autoflow {
font-size: 0.75rem; }

View File

@ -37,4 +37,5 @@
@import "helpers/bubbles";
@import "helpers/splitter";
@import "helpers/wait-spinner";
@import "properties";
@import "autoflow";

View File

@ -0,0 +1,14 @@
/* Classes to be used for lists of properties and values */
.properties {
.s-row {
border-top: 1px solid $colorInteriorBorder;
font-size: 0.8em;
&:first-child {
border: none;
}
.s-value {
color: #fff;
}
}
}

View File

@ -122,6 +122,19 @@
@include cols($nc, 1);
}
}
&.cols-2-ff {
// 2 columns, first column is fixed, second is fluid
.col-100px {
width: 100px;
}
}
&.cols-6 {
$nc: 6;
.col-1 {
@include cols($nc, 1);
}
}
&.cols-16 {
$nc: 16;
.col-1 {
@ -143,6 +156,10 @@
@include cols($nc, 15);
}
}
.l-row {
@include clearfix;
padding: $interiorMargin 0;
}
}
.pane {

View File

@ -0,0 +1,2 @@
Implements support for rules which determine which objects are allowed
to contain other objects, typically by type.

View File

@ -0,0 +1,18 @@
{
"extensions": {
"policies": [
{
"category": "composition",
"implementation": "CompositionPolicy.js",
"depends": [ "$injector" ],
"message": "Objects of this type cannot contain objects of that type."
},
{
"category": "action",
"implementation": "ComposeActionPolicy.js",
"depends": [ "$injector" ],
"message": "Objects of this type cannot contain objects of that type."
}
]
}
}

View File

@ -0,0 +1,55 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Build a table indicating which types are expected to expose
* which capabilities. This supports composition policy (rules
* for which objects can contain which other objects) which
* sometimes is determined based on the presence of capabilities.
*/
function CapabilityTable(typeService, capabilityService) {
var table = {};
// Build an initial model for a type
function buildModel(type) {
var model = Object.create(type.getInitialModel() || {});
model.type = type.getKey();
return model;
}
// Get capabilities expected for this type
function getCapabilities(type) {
return capabilityService.getCapabilities(buildModel(type));
}
// Populate the lookup table for this type's capabilities
function addToTable(type) {
var typeKey = type.getKey();
Object.keys(getCapabilities(type)).forEach(function (key) {
table[key] = table[key] || {};
table[key][typeKey] = true;
});
}
// Build the table
(typeService.listTypes() || []).forEach(addToTable);
return {
/**
* Check if a type is expected to expose a specific
* capability.
*/
hasCapability: function (typeKey, capabilityKey) {
return (table[capabilityKey] || {})[typeKey];
}
};
}
return CapabilityTable;
}
);

View File

@ -0,0 +1,59 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Restrict `compose` actions to cases where composition
* is explicitly allowed.
*
* Note that this is a policy that needs the `policyService`,
* since it's delegated to a different policy category.
* To avoid a circular dependency, the service is obtained via
* Angular's `$injector`.
*/
function ComposeActionPolicy($injector) {
var policyService;
function allowComposition(containerObject, selectedObject) {
// Get the object types involved in the compose action
var containerType = containerObject &&
containerObject.getCapability('type'),
selectedType = selectedObject &&
selectedObject.getCapability('type');
// Get a reference to the policy service if needed...
policyService = policyService || $injector.get('policyService');
// ...and delegate to the composition policy
return policyService.allow(
'composition',
containerType,
selectedType
);
}
return {
/**
* Check whether or not a compose action should be allowed
* in this context.
* @returns {boolean} true if it may be allowed
*/
allow: function (candidate, context) {
if (candidate.getMetadata().key === 'compose') {
return allowComposition(
(context || {}).domainObject,
(context || {}).selectedObject
);
}
return true;
}
};
}
return ComposeActionPolicy;
}
);

View File

@ -0,0 +1,36 @@
/*global define*/
define(
['./ContainmentTable'],
function (ContainmentTable) {
"use strict";
/**
* Defines composition policy as driven by type metadata.
*/
function CompositionPolicy($injector) {
// We're really just wrapping the containment table and rephrasing
// it as a policy decision.
var table;
function getTable() {
return (table = table || new ContainmentTable(
$injector.get('typeService'),
$injector.get('capabilityService')
));
}
return {
/**
* Is the type identified by the candidate allowed to
* contain the type described by the context?
*/
allow: function (candidate, context) {
return getTable().canContain(candidate, context);
}
};
}
return CompositionPolicy;
}
);

View File

@ -0,0 +1,98 @@
/*global define*/
define(
['./CapabilityTable'],
function (CapabilityTable) {
"use strict";
// Symbolic value for the type table for cases when any type
// is allowed to be contained.
var ANY = true;
/**
* Supports composition policy by maintaining a table of
* domain object types, to determine if they can contain
* other domain object types. This is determined at application
* start time (plug-in support means this cannot be determined
* prior to that, but we don't want to redo these calculations
* every time policy is checked.)
*/
function ContainmentTable(typeService, capabilityService) {
var types = typeService.listTypes(),
capabilityTable = new CapabilityTable(typeService, capabilityService),
table = {};
// Check if one type can contain another
function canContain(containerType, containedType) {
}
// Add types which have all these capabilities to the set
// of allowed types
function addToSetByCapability(set, has) {
has = Array.isArray(has) ? has : [has];
types.forEach(function (type) {
var typeKey = type.getKey();
set[typeKey] = has.map(function (capabilityKey) {
return capabilityTable.hasCapability(typeKey, capabilityKey);
}).reduce(function (a, b) {
return a && b;
}, true);
});
}
// Add this type (or type description) to the set of allowed types
function addToSet(set, type) {
// Is this a simple case of an explicit type identifier?
if (typeof type === 'string') {
// If so, add it to the set of allowed types
set[type] = true;
} else {
// Otherwise, populate that set based on capabilities
addToSetByCapability(set, (type || {}).has || []);
}
}
// Add to the lookup table for this type
function addToTable(type) {
var key = type.getKey(),
definition = type.getDefinition() || {},
contains = definition.contains;
// Check for defined containment restrictions
if (contains === undefined) {
// If not, accept anything
table[key] = ANY;
} else {
// Start with an empty set...
table[key] = {};
// ...cast accepted types to array if necessary...
contains = Array.isArray(contains) ? contains : [contains];
// ...and add all containment rules to that set
contains.forEach(function (c) {
addToSet(table[key], c);
});
}
}
// Build the table
types.forEach(addToTable);
return {
/**
* Check if domain objects of one type can contain domain
* objects of another type.
* @returns {boolean} true if allowable
*/
canContain: function (containerType, containedType) {
var set = table[containerType.getKey()] || {};
// Recognize either the symbolic value for "can contain
// anything", or lookup the specific type from the set.
return (set === ANY) || set[containedType.getKey()];
}
};
}
return ContainmentTable;
}
);

View File

@ -0,0 +1,66 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/CapabilityTable"],
function (CapabilityTable) {
"use strict";
describe("Composition policy's capability table", function () {
var mockTypeService,
mockCapabilityService,
mockTypes,
table;
beforeEach(function () {
mockTypeService = jasmine.createSpyObj(
'typeService',
[ 'listTypes' ]
);
mockCapabilityService = jasmine.createSpyObj(
'capabilityService',
[ 'getCapabilities' ]
);
// Both types can only contain b, let's say
mockTypes = ['a', 'b'].map(function (type, index) {
var mockType = jasmine.createSpyObj(
'type-' + type,
['getKey', 'getDefinition', 'getInitialModel']
);
mockType.getKey.andReturn(type);
// Return a model to drive apparant capabilities
mockType.getInitialModel.andReturn({ id: type });
return mockType;
});
mockTypeService.listTypes.andReturn(mockTypes);
mockCapabilityService.getCapabilities.andCallFake(function (model) {
var capabilities = {};
capabilities[model.id + '-capability'] = true;
return capabilities;
});
table = new CapabilityTable(
mockTypeService,
mockCapabilityService
);
});
it("provides for lookup of capabilities by type", function () {
// Based on initial model, should report the presence
// of particular capabilities - suffixed above with -capability
expect(table.hasCapability('a', 'a-capability'))
.toBeTruthy();
expect(table.hasCapability('a', 'b-capability'))
.toBeFalsy();
expect(table.hasCapability('a', 'c-capability'))
.toBeFalsy();
expect(table.hasCapability('b', 'a-capability'))
.toBeFalsy();
expect(table.hasCapability('b', 'b-capability'))
.toBeTruthy();
expect(table.hasCapability('b', 'c-capability'))
.toBeFalsy();
});
});
}
);

View File

@ -0,0 +1,74 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/ComposeActionPolicy"],
function (ComposeActionPolicy) {
"use strict";
describe("The compose action policy", function () {
var mockInjector,
mockPolicyService,
mockTypes,
mockDomainObjects,
mockAction,
testContext,
policy;
beforeEach(function () {
mockInjector = jasmine.createSpyObj('$injector', ['get']);
mockPolicyService = jasmine.createSpyObj(
'policyService',
[ 'allow' ]
);
mockTypes = ['a', 'b'].map(function (type) {
var mockType = jasmine.createSpyObj('type-' + type, ['getKey']);
mockType.getKey.andReturn(type);
return mockType;
});
mockDomainObjects = ['a', 'b'].map(function (id, index) {
var mockDomainObject = jasmine.createSpyObj(
'domainObject-' + id,
['getId', 'getCapability']
);
mockDomainObject.getId.andReturn(id);
mockDomainObject.getCapability.andCallFake(function (c) {
return c === 'type' && mockTypes[index];
});
return mockDomainObject;
});
mockAction = jasmine.createSpyObj('action', ['getMetadata']);
testContext = {
key: 'compose',
domainObject: mockDomainObjects[0],
selectedObject: mockDomainObjects[1]
};
mockAction.getMetadata.andReturn(testContext);
mockInjector.get.andCallFake(function (service) {
return service === 'policyService' && mockPolicyService;
});
policy = new ComposeActionPolicy(mockInjector);
});
it("defers to composition policy", function () {
mockPolicyService.allow.andReturn(false);
expect(policy.allow(mockAction, testContext)).toBeFalsy();
mockPolicyService.allow.andReturn(true);
expect(policy.allow(mockAction, testContext)).toBeTruthy();
expect(mockPolicyService.allow).toHaveBeenCalledWith(
'composition',
mockTypes[0],
mockTypes[1]
);
});
it("allows actions other than compose", function () {
testContext.key = 'somethingElse';
mockPolicyService.allow.andReturn(false);
expect(policy.allow(mockAction, testContext)).toBeTruthy();
});
});
}
);

View File

@ -0,0 +1,66 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/CompositionPolicy"],
function (CompositionPolicy) {
"use strict";
describe("Composition policy", function () {
var mockInjector,
mockTypeService,
mockCapabilityService,
mockTypes,
policy;
beforeEach(function () {
mockInjector = jasmine.createSpyObj('$injector', ['get']);
mockTypeService = jasmine.createSpyObj(
'typeService',
[ 'listTypes' ]
);
mockCapabilityService = jasmine.createSpyObj(
'capabilityService',
[ 'getCapabilities' ]
);
// Both types can only contain b, let's say
mockTypes = ['a', 'b'].map(function (type) {
var mockType = jasmine.createSpyObj(
'type-' + type,
['getKey', 'getDefinition', 'getInitialModel']
);
mockType.getKey.andReturn(type);
mockType.getDefinition.andReturn({
contains: ['b']
});
mockType.getInitialModel.andReturn({});
return mockType;
});
mockInjector.get.andCallFake(function (name) {
return {
typeService: mockTypeService,
capabilityService: mockCapabilityService
}[name];
});
mockTypeService.listTypes.andReturn(mockTypes);
mockCapabilityService.getCapabilities.andReturn({});
policy = new CompositionPolicy(mockInjector);
});
// Test basic composition policy here; test more closely at
// the unit level in ContainmentTable for 'has' support, et al
it("enforces containment rules defined by types", function () {
expect(policy.allow(mockTypes[0], mockTypes[1]))
.toBeTruthy();
expect(policy.allow(mockTypes[1], mockTypes[1]))
.toBeTruthy();
expect(policy.allow(mockTypes[1], mockTypes[0]))
.toBeFalsy();
expect(policy.allow(mockTypes[0], mockTypes[0]))
.toBeFalsy();
});
});
}
);

View File

@ -0,0 +1,77 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/ContainmentTable"],
function (ContainmentTable) {
"use strict";
describe("Composition policy's containment table", function () {
var mockTypeService,
mockCapabilityService,
mockTypes,
table;
beforeEach(function () {
mockTypeService = jasmine.createSpyObj(
'typeService',
[ 'listTypes' ]
);
mockCapabilityService = jasmine.createSpyObj(
'capabilityService',
[ 'getCapabilities' ]
);
// Both types can only contain b, let's say
mockTypes = ['a', 'b', 'c'].map(function (type, index) {
var mockType = jasmine.createSpyObj(
'type-' + type,
['getKey', 'getDefinition', 'getInitialModel']
);
mockType.getKey.andReturn(type);
mockType.getDefinition.andReturn({
// First two contain objects with capability 'b';
// third one defines no containership rules
contains: (index < 2) ? [ { has: 'b' } ] : undefined
});
// Return a model to drive apparant capabilities
mockType.getInitialModel.andReturn({ id: type });
return mockType;
});
mockTypeService.listTypes.andReturn(mockTypes);
mockCapabilityService.getCapabilities.andCallFake(function (model) {
var capabilities = {};
capabilities[model.id] = true;
return capabilities;
});
table = new ContainmentTable(
mockTypeService,
mockCapabilityService
);
});
// The plain type case is tested in CompositionPolicySpec,
// so just test for special syntax ('has', or no contains rules) here
it("enforces 'has' containment rules related to capabilities", function () {
expect(table.canContain(mockTypes[0], mockTypes[1]))
.toBeTruthy();
expect(table.canContain(mockTypes[1], mockTypes[1]))
.toBeTruthy();
expect(table.canContain(mockTypes[1], mockTypes[0]))
.toBeFalsy();
expect(table.canContain(mockTypes[0], mockTypes[0]))
.toBeFalsy();
});
it("allows anything when no containership rules are defined", function () {
expect(table.canContain(mockTypes[2], mockTypes[0]))
.toBeTruthy();
expect(table.canContain(mockTypes[2], mockTypes[1]))
.toBeTruthy();
expect(table.canContain(mockTypes[2], mockTypes[2]))
.toBeTruthy();
});
});
}
);

View File

@ -0,0 +1,6 @@
[
"CapabilityTable",
"ComposeActionPolicy",
"CompositionPolicy",
"ContainmentTable"
]

View File

@ -65,6 +65,11 @@
"implementation": "models/PersistedModelProvider.js",
"depends": [ "persistenceService", "$q", "PERSISTENCE_SPACE" ]
},
{
"provides": "modelService",
"type": "decorator",
"implementation": "models/CachingModelDecorator.js"
},
{
"provides": "typeService",
"type": "provider",
@ -162,7 +167,8 @@
},
{
"key": "mutation",
"implementation": "capabilities/MutationCapability.js"
"implementation": "capabilities/MutationCapability.js",
"depends": [ "now" ]
},
{
"key": "delegation",

View File

@ -84,11 +84,21 @@ define(
// Build up look-up tables
actions.forEach(function (Action) {
if (Action.category) {
actionsByCategory[Action.category] =
actionsByCategory[Action.category] || [];
actionsByCategory[Action.category].push(Action);
}
// Get an action's category or categories
var categories = Action.category || [];
// Convert to an array if necessary
categories = Array.isArray(categories) ?
categories : [categories];
// Store action under all relevant categories
categories.forEach(function (category) {
actionsByCategory[category] =
actionsByCategory[category] || [];
actionsByCategory[category].push(Action);
});
// Store action by ekey as well
if (Action.key) {
actionsByKey[Action.key] =
actionsByKey[Action.key] || [];

View File

@ -50,9 +50,9 @@ define(
* which will expose this capability
* @constructor
*/
function MutationCapability(domainObject) {
function MutationCapability(now, domainObject) {
function mutate(mutator) {
function mutate(mutator, timestamp) {
// Get the object's model and clone it, so the
// mutator function has a temporary copy to work with.
var model = domainObject.getModel(),
@ -73,7 +73,8 @@ define(
if (model !== result) {
copyValues(model, result);
}
model.modified = Date.now();
model.modified = (typeof timestamp === 'number') ?
timestamp : now();
}
// Report the result of the mutation
@ -109,8 +110,11 @@ define(
* handled as one of the above.
*
*
* @params {function} mutator the function which will make
* @param {function} mutator the function which will make
* changes to the domain object's model.
* @param {number} [timestamp] timestamp to record for
* this mutation (otherwise, system time will be
* used)
* @returns {Promise.<boolean>} a promise for the result
* of the mutation; true if changes were made.
*/

View File

@ -22,6 +22,36 @@ define(
* @constructor
*/
function PersistenceCapability(persistenceService, SPACE, domainObject) {
// Cache modified timestamp
var modified = domainObject.getModel().modified;
// Update a domain object's model upon refresh
function updateModel(model) {
var modified = model.modified;
return domainObject.useCapability("mutation", function () {
return model;
}, modified);
}
// For refresh; update a domain object model, only if there
// are no unsaved changes.
function updatePersistenceTimestamp() {
var modified = domainObject.getModel().modified;
domainObject.useCapability("mutation", function (model) {
model.persisted = modified;
}, modified);
}
// Utility function for creating promise-like objects which
// resolve synchronously when possible
function fastPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return fastPromise(callback(value));
}
};
}
return {
/**
* Persist any changes which have been made to this
@ -31,12 +61,29 @@ define(
* if not.
*/
persist: function () {
updatePersistenceTimestamp();
return persistenceService.updateObject(
SPACE,
domainObject.getId(),
domainObject.getModel()
);
},
/**
* Update this domain object to match the latest from
* persistence.
* @returns {Promise} a promise which will be resolved
* when the update is complete
*/
refresh: function () {
var model = domainObject.getModel();
// Only update if we don't have unsaved changes
return (model.modified === model.persisted) ?
persistenceService.readObject(
SPACE,
domainObject.getId()
).then(updateModel) :
fastPromise(false);
},
/**
* Get the space in which this domain object is persisted;
* this is useful when, for example, decided which space a

View File

@ -0,0 +1,115 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The caching model decorator maintains a cache of loaded domain
* object models, and ensures that duplicate models for the same
* object are not provided.
* @constructor
*/
function CachingModelDecorator(modelService) {
var cache = {},
cached = {};
// Update the cached instance of a model to a new value.
// We update in-place to ensure there is only ever one instance
// of any given model exposed by the modelService as a whole.
function updateModel(id, model) {
var oldModel = cache[id];
// Same object instance is a possibility, so don't copy
if (oldModel === model) {
return model;
}
// If we'd previously cached an undefined value, or are now
// seeing undefined, replace the item in the cache entirely.
if (oldModel === undefined || model === undefined) {
cache[id] = model;
return model;
}
// Otherwise, empty out the old model...
Object.keys(oldModel).forEach(function (k) {
delete oldModel[k];
});
// ...and replace it with the contents of the new model.
Object.keys(model).forEach(function (k) {
oldModel[k] = model[k];
});
return oldModel;
}
// Fast-resolving promise
function fastPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return fastPromise(callback(value));
}
};
}
// Store this model in the cache
function cacheModel(id, model) {
cache[id] = cached[id] ? updateModel(id, model) : model;
cached[id] = true;
}
// Check if an id is not in cache, for lookup filtering
function notCached(id) {
return !cached[id];
}
// Store the provided models in our cache
function cacheAll(models) {
Object.keys(models).forEach(function (id) {
cacheModel(id, models[id]);
});
}
// Expose the cache (for promise chaining)
function giveCache() {
return cache;
}
return {
/**
* Get models for these specified string identifiers.
* These will be given as an object containing keys
* and values, where keys are object identifiers and
* values are models.
* This result may contain either a subset or a
* superset of the total objects.
*
* @param {Array<string>} ids the string identifiers for
* models of interest.
* @returns {Promise<object>} a promise for an object
* containing key-value pairs, where keys are
* ids and values are models
* @method
*/
getModels: function (ids) {
var neededIds = ids.filter(notCached);
// Look up if we have unknown IDs
if (neededIds.length > 0) {
return modelService.getModels(neededIds)
.then(cacheAll)
.then(giveCache);
}
// Otherwise, just expose the cache directly
return fastPromise(cache);
}
};
}
return CachingModelDecorator;
}
);

View File

@ -18,6 +18,14 @@ define(
*/
function ModelAggregator($q, providers) {
// Pick a domain object model to use, favoring the one
// with the most recent timestamp
function pick(a, b) {
var aModified = (a || {}).modified || Number.NEGATIVE_INFINITY,
bModified = (b || {}).modified || Number.NEGATIVE_INFINITY;
return (aModified > bModified) ? a : (b || a);
}
// Merge results from multiple providers into one
// large result object.
function mergeModels(provided, ids) {
@ -25,7 +33,7 @@ define(
ids.forEach(function (id) {
provided.forEach(function (models) {
if (models[id]) {
result[id] = models[id];
result[id] = pick(result[id], models[id]);
}
});
});

View File

@ -10,12 +10,15 @@ define(
describe("The mutation capability", function () {
var testModel,
mockNow,
domainObject = { getModel: function () { return testModel; } },
mutation;
beforeEach(function () {
testModel = { number: 6 };
mutation = new MutationCapability(domainObject);
mockNow = jasmine.createSpy('now');
mockNow.andReturn(12321);
mutation = new MutationCapability(mockNow, domainObject);
});
it("allows mutation of a model", function () {
@ -41,6 +44,24 @@ define(
// Number should not have been changed
expect(testModel.number).toEqual(6);
});
it("attaches a timestamp on mutation", function () {
// Verify precondition
expect(testModel.modified).toBeUndefined();
mutation.invoke(function (m) {
m.number = m.number * 7;
});
// Should have gotten a timestamp from 'now'
expect(testModel.modified).toEqual(12321);
});
it("allows a timestamp to be provided", function () {
mutation.invoke(function (m) {
m.number = m.number * 7;
}, 42);
// Should have gotten a timestamp from 'now'
expect(testModel.modified).toEqual(42);
});
});
}
);

View File

@ -16,15 +16,30 @@ define(
SPACE = "some space",
persistence;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
beforeEach(function () {
mockPersistenceService = jasmine.createSpyObj(
"persistenceService",
[ "updateObject" ]
[ "updateObject", "readObject" ]
);
mockDomainObject = {
getId: function () { return id; },
getModel: function () { return model; }
getModel: function () { return model; },
useCapability: jasmine.createSpy()
};
// Simulate mutation capability
mockDomainObject.useCapability.andCallFake(function (capability, mutator) {
if (capability === 'mutation') {
model = mutator(model) || model;
}
});
persistence = new PersistenceCapability(
mockPersistenceService,
SPACE,
@ -49,6 +64,31 @@ define(
expect(persistence.getSpace()).toEqual(SPACE);
});
it("updates persisted timestamp on persistence", function () {
model.modified = 12321;
persistence.persist();
expect(model.persisted).toEqual(12321);
});
it("refreshes the domain object model from persistence", function () {
var refreshModel = { someOtherKey: "some other value" };
mockPersistenceService.readObject.andReturn(asPromise(refreshModel));
persistence.refresh();
expect(model).toEqual(refreshModel);
});
it("does not overwrite unpersisted changes on refresh", function () {
var refreshModel = { someOtherKey: "some other value" },
mockCallback = jasmine.createSpy();
model.modified = 2;
model.persisted = 1;
mockPersistenceService.readObject.andReturn(asPromise(refreshModel));
persistence.refresh().then(mockCallback);
expect(model).not.toEqual(refreshModel);
// Should have also indicated that no changes were actually made
expect(mockCallback).toHaveBeenCalledWith(false);
});
});
}
);

View File

@ -0,0 +1,132 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../../src/models/CachingModelDecorator"],
function (CachingModelDecorator) {
"use strict";
describe("The caching model decorator", function () {
var mockModelService,
mockCallback,
testModels,
decorator;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
function fakePromise() {
var chains = [],
callbacks = [];
return {
then: function (callback) {
var next = fakePromise();
callbacks.push(callback);
chains.push(next);
return next;
},
resolve: function (value) {
callbacks.forEach(function (cb, i) {
chains[i].resolve(cb(value));
});
}
};
}
beforeEach(function () {
mockCallback = jasmine.createSpy();
mockModelService = jasmine.createSpyObj('modelService', ['getModels']);
testModels = {
a: { someKey: "some value" },
b: { someOtherKey: "some other value" }
};
mockModelService.getModels.andReturn(asPromise(testModels));
decorator = new CachingModelDecorator(mockModelService);
});
it("loads models from its wrapped model service", function () {
decorator.getModels(['a', 'b']).then(mockCallback);
expect(mockCallback).toHaveBeenCalledWith(testModels);
});
it("does not try to reload cached models", function () {
mockModelService.getModels.andReturn(asPromise({ a: testModels.a }));
decorator.getModels(['a']);
mockModelService.getModels.andReturn(asPromise(testModels));
decorator.getModels(['a', 'b']);
expect(mockModelService.getModels).not.toHaveBeenCalledWith(['a', 'b']);
expect(mockModelService.getModels.mostRecentCall.args[0]).toEqual(['b']);
});
it("does not call its wrapped model service if not needed", function () {
decorator.getModels(['a', 'b']);
expect(mockModelService.getModels.calls.length).toEqual(1);
decorator.getModels(['a', 'b']).then(mockCallback);
expect(mockModelService.getModels.calls.length).toEqual(1);
// Verify that we still got back our models, even though
// no new call to the wrapped service was made
expect(mockCallback).toHaveBeenCalledWith(testModels);
});
it("ensures a single object instance, even for multiple concurrent calls", function () {
var promiseA, promiseB, mockCallback = jasmine.createSpy();
promiseA = fakePromise();
promiseB = fakePromise();
// Issue two calls before those promises resolve
mockModelService.getModels.andReturn(promiseA);
decorator.getModels(['a']);
mockModelService.getModels.andReturn(promiseB);
decorator.getModels(['a']).then(mockCallback);
// Then resolve those promises. Note that we're whiteboxing here
// to figure out which promises to resolve (that is, we know that
// two thens are chained after each getModels)
promiseA.resolve(testModels);
promiseB.resolve({
a: { someNewKey: "some other value" }
});
// Ensure that we have a pointer-identical instance
expect(mockCallback.mostRecentCall.args[0].a)
.toEqual({ someNewKey: "some other value" });
expect(mockCallback.mostRecentCall.args[0].a)
.toBe(testModels.a);
});
it("is robust against updating with undefined values", function () {
var promiseA, promiseB, mockCallback = jasmine.createSpy();
promiseA = fakePromise();
promiseB = fakePromise();
// Issue two calls before those promises resolve
mockModelService.getModels.andReturn(promiseA);
decorator.getModels(['a']);
mockModelService.getModels.andReturn(promiseB);
decorator.getModels(['a']).then(mockCallback);
// Some model providers might erroneously add undefined values
// under requested keys, so handle that
promiseA.resolve({
a: undefined
});
promiseB.resolve({
a: { someNewKey: "some other value" }
});
// Should still have gotten the model
expect(mockCallback.mostRecentCall.args[0].a)
.toEqual({ someNewKey: "some other value" });
});
});
}
);

View File

@ -12,8 +12,8 @@ define(
var mockQ,
mockProviders,
modelList = [
{ "a": { someKey: "some value" } },
{ "b": { someOtherKey: "some other value" } }
{ "a": { someKey: "some value" }, "b": undefined },
{ "b": { someOtherKey: "some other value" }, "a": undefined }
],
aggregator;

View File

@ -17,6 +17,7 @@
"models/PersistedModelProvider",
"models/RootModelProvider",
"models/StaticModelProvider",
"models/CachingModelDecorator",
"objects/DomainObject",
"objects/DomainObjectProvider",

View File

@ -231,6 +231,7 @@
"description": "A panel for collecting telemetry elements.",
"delegates": [ "telemetry" ],
"features": "creation",
"contains": [ { "has": "telemetry" } ],
"model": { "composition": [] },
"properties": [
{

View File

@ -0,0 +1,120 @@
/*global define,Float32Array*/
define(
[],
function () {
"use strict";
/**
* Create a new chart which uses Canvas's 2D API for rendering.
*
* @constructor
* @param {CanvasElement} canvas the canvas object to render upon
* @throws {Error} an error is thrown if Canvas's 2D API is unavailable.
*/
function Canvas2DChart(canvas) {
var c2d = canvas.getContext('2d'),
width = canvas.width,
height = canvas.height,
dimensions = [ width, height ],
origin = [ 0, 0 ];
// Convert from logical to physical x coordinates
function x(v) {
return ((v - origin[0]) / dimensions[0]) * width;
}
// Convert from logical to physical y coordinates
function y(v) {
return height - ((v - origin[1]) / dimensions[1]) * height;
}
// Set the color to be used for drawing operations
function setColor(color) {
var mappedColor = color.map(function (c, i) {
return i < 3 ? Math.floor(c * 255) : (c);
}).join(',');
c2d.strokeStyle = "rgba(" + mappedColor + ")";
c2d.fillStyle = "rgba(" + mappedColor + ")";
}
if (!c2d) {
throw new Error("Canvas 2d API unavailable.");
}
return {
/**
* Clear the chart.
*/
clear: function () {
width = canvas.width;
height = canvas.height;
c2d.clearRect(0, 0, width, height);
},
/**
* Set the logical boundaries of the chart.
* @param {number[]} dimensions the horizontal and
* vertical dimensions of the chart
* @param {number[]} origin the horizontal/vertical
* origin of the chart
*/
setDimensions: function (newDimensions, newOrigin) {
dimensions = newDimensions;
origin = newOrigin;
},
/**
* Draw the supplied buffer as a line strip (a sequence
* of line segments), in the chosen color.
* @param {Float32Array} buf the line strip to draw,
* in alternating x/y positions
* @param {number[]} color the color to use when drawing
* the line, as an RGBA color where each element
* is in the range of 0.0-1.0
* @param {number} points the number of points to draw
*/
drawLine: function (buf, color, points) {
var i;
setColor(color);
// Configure context to draw two-pixel-thick lines
c2d.lineWidth = 2;
// Start a new path...
if (buf.length > 1) {
c2d.beginPath();
c2d.moveTo(x(buf[0]), y(buf[1]));
}
// ...and add points to it...
for (i = 2; i < points * 2; i = i + 2) {
c2d.lineTo(x(buf[i]), y(buf[i + 1]));
}
// ...before finally drawing it.
c2d.stroke();
},
/**
* Draw a rectangle extending from one corner to another,
* in the chosen color.
* @param {number[]} min the first corner of the rectangle
* @param {number[]} max the opposite corner
* @param {number[]} color the color to use when drawing
* the rectangle, as an RGBA color where each element
* is in the range of 0.0-1.0
*/
drawSquare: function (min, max, color) {
var x1 = x(min[0]),
y1 = y(min[1]),
w = x(max[0]) - x1,
h = y(max[1]) - y1;
setColor(color);
c2d.fillRect(x1, y1, w, h);
}
};
}
return Canvas2DChart;
}
);

View File

@ -4,8 +4,8 @@
* Module defining MCTChart. Created by vwoeltje on 11/12/14.
*/
define(
["./GLChart"],
function (GLChart) {
["./GLChart", "./Canvas2DChart"],
function (GLChart, Canvas2DChart) {
"use strict";
var TEMPLATE = "<canvas style='position: absolute; background: none; width: 100%; height: 100%;'></canvas>";
@ -43,22 +43,38 @@ define(
* @constructor
*/
function MCTChart($interval, $log) {
// Get an underlying chart implementation
function getChart(Charts, canvas) {
// Try the first available option...
var Chart = Charts[0];
// This function recursively try-catches all options;
// if these all fail, issue a warning.
if (!Chart) {
$log.warn("Cannot initialize mct-chart.");
return undefined;
}
// Try first option; if it fails, try remaining options
try {
return new Chart(canvas);
} catch (e) {
$log.warn([
"Could not instantiate chart",
Chart.name,
";",
e.message
].join(" "));
return getChart(Charts.slice(1), canvas);
}
}
function linkChart(scope, element) {
var canvas = element.find("canvas")[0],
activeInterval,
chart;
// Try to initialize GLChart, which allows drawing using WebGL.
// This may fail, particularly where browsers do not support
// WebGL, so catch that here.
try {
chart = new GLChart(canvas);
} catch (e) {
$log.warn("Cannot initialize mct-chart; " + e.message);
return;
}
// Handle drawing, based on contents of the "draw" object
// in scope
function doDraw(draw) {
@ -118,6 +134,15 @@ define(
}
}
// Try to initialize a chart.
chart = getChart([GLChart, Canvas2DChart], canvas);
// If that failed, there's nothing more we can do here.
// (A warning will already have been issued)
if (!chart) {
return;
}
// Check for resize, on a timer
activeInterval = $interval(drawIfResized, 1000);

View File

@ -0,0 +1,76 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
/**
* MergeModelsSpec. Created by vwoeltje on 11/6/14.
*/
define(
["../src/Canvas2DChart"],
function (Canvas2DChart) {
"use strict";
describe("A canvas 2d chart", function () {
var mockCanvas,
mock2d,
chart;
beforeEach(function () {
mockCanvas = jasmine.createSpyObj("canvas", [ "getContext" ]);
mock2d = jasmine.createSpyObj(
"2d",
[
"clearRect",
"beginPath",
"moveTo",
"lineTo",
"stroke",
"fillRect"
]
);
mockCanvas.getContext.andReturn(mock2d);
chart = new Canvas2DChart(mockCanvas);
});
// Note that tests below are less specific than they
// could be, esp. w.r.t. arguments to drawing calls;
// this is a fallback option so is a lower test priority.
it("allows the canvas to be cleared", function () {
chart.clear();
expect(mock2d.clearRect).toHaveBeenCalled();
});
it("doees not construct if 2D is unavailable", function () {
mockCanvas.getContext.andReturn(undefined);
expect(function () {
return new Canvas2DChart(mockCanvas);
}).toThrow();
});
it("allows dimensions to be set", function () {
// No return value, just verify API is present
chart.setDimensions([120, 120], [0, 10]);
});
it("allows lines to be drawn", function () {
var testBuffer = [ 0, 1, 3, 8 ],
testColor = [ 0.25, 0.33, 0.66, 1.0 ],
testPoints = 2;
chart.drawLine(testBuffer, testColor, testPoints);
expect(mock2d.beginPath).toHaveBeenCalled();
expect(mock2d.lineTo.calls.length).toEqual(1);
expect(mock2d.stroke).toHaveBeenCalled();
});
it("allows squares to be drawn", function () {
var testMin = [0, 1],
testMax = [10, 10],
testColor = [ 0.25, 0.33, 0.66, 1.0 ];
chart.drawSquare(testMin, testMax, testColor);
expect(mock2d.fillRect).toHaveBeenCalled();
});
});
}
);

View File

@ -1,4 +1,5 @@
[
"Canvas2DChart",
"GLChart",
"MCTChart",
"PlotController",

View File

@ -26,20 +26,18 @@ define(
controlMap[control.key] = path;
});
function controller($scope) {
$scope.$watch("key", function (key) {
function link(scope, element, attrs, ngModelController) {
scope.$watch("key", function (key) {
// Pass the template URL to ng-include via scope.
$scope.inclusion = controlMap[key];
scope.inclusion = controlMap[key];
});
scope.ngModelController = ngModelController;
}
return {
// Only show at the element level
restrict: "E",
// Use the included controller to populate scope
controller: controller,
// Use ng-include as a template; "inclusion" will be the real
// template path
template: '<ng-include src="inclusion"></ng-include>',
@ -47,6 +45,12 @@ define(
// ngOptions is terminal, so we need to be higher priority
priority: 1000,
// Get the ngModelController, so that controls can set validity
require: '?ngModel',
// Link function
link: link,
// Pass through Angular's normal input field attributes
scope: {
// Used to choose which form control to use

View File

@ -34,7 +34,7 @@ define(
});
it("watches its passed key to choose a template", function () {
mctControl.controller(mockScope);
mctControl.link(mockScope);
expect(mockScope.$watch).toHaveBeenCalledWith(
"key",
@ -43,7 +43,7 @@ define(
});
it("changes its template dynamically", function () {
mctControl.controller(mockScope);
mctControl.link(mockScope);
mockScope.key = "xyz";
mockScope.$watch.mostRecentCall.args[1]("xyz");

View File

@ -148,8 +148,11 @@ define(
* failure of this request
*/
updateObject: function (space, key, value) {
addToCache(space, key, value);
return persistenceService.updateObject(space, key, value);
return persistenceService.updateObject(space, key, value)
.then(function (result) {
addToCache(space, key, value);
return result;
});
},
/**
* Delete an object in a specific space. This will

View File

@ -0,0 +1,2 @@
This bundle implements a connection to an external ElasticSearch persistence
store in Open MCT Web.

View File

@ -0,0 +1,43 @@
{
"name": "Couch Persistence",
"description": "Adapter to read and write objects using a CouchDB instance.",
"extensions": {
"components": [
{
"provides": "persistenceService",
"type": "provider",
"implementation": "ElasticPersistenceProvider.js",
"depends": [ "$http", "$q", "PERSISTENCE_SPACE", "ELASTIC_ROOT", "ELASTIC_PATH" ]
}
],
"constants": [
{
"key": "PERSISTENCE_SPACE",
"value": "mct"
},
{
"key": "ELASTIC_ROOT",
"value": "/elastic"
},
{
"key": "ELASTIC_PATH",
"value": "mct/domain_object"
},
{
"key": "ELASTIC_INDICATOR_INTERVAL",
"value": 15000
}
],
"indicators": [
{
"implementation": "ElasticIndicator.js",
"depends": [
"$http",
"$interval",
"ELASTIC_ROOT",
"ELASTIC_INDICATOR_INTERVAL"
]
}
]
}
}

View File

@ -0,0 +1,95 @@
/*global define*/
define(
[],
function () {
"use strict";
// Set of connection states; changing among these states will be
// reflected in the indicator's appearance.
// CONNECTED: Everything nominal, expect to be able to read/write.
// DISCONNECTED: HTTP failed; maybe misconfigured, disconnected.
// PENDING: Still trying to connect, and haven't failed yet.
var CONNECTED = {
text: "Connected",
glyphClass: "ok",
description: "Connected to the domain object database."
},
DISCONNECTED = {
text: "Disconnected",
glyphClass: "err",
description: "Unable to connect to the domain object database."
},
PENDING = {
text: "Checking connection..."
};
/**
* Indicator for the current CouchDB connection. Polls CouchDB
* at a regular interval (defined by bundle constants) to ensure
* that the database is available.
*/
function ElasticIndicator($http, $interval, PATH, INTERVAL) {
// Track the current connection state
var state = PENDING;
// Callback if the HTTP request to Couch fails
function handleError(err) {
state = DISCONNECTED;
}
// Callback if the HTTP request succeeds.
function handleResponse(response) {
state = CONNECTED;
}
// Try to connect to CouchDB, and update the indicator.
function updateIndicator() {
$http.get(PATH).then(handleResponse, handleError);
}
// Update the indicator initially, and start polling.
updateIndicator();
$interval(updateIndicator, INTERVAL, false);
return {
/**
* Get the glyph (single character used as an icon)
* to display in this indicator. This will return "D",
* which should appear as a database icon.
* @returns {string} the character of the database icon
*/
getGlyph: function () {
return "D";
},
/**
* Get the name of the CSS class to apply to the glyph.
* This is used to color the glyph to match its
* state (one of ok, caution or err)
* @returns {string} the CSS class to apply to this glyph
*/
getGlyphClass: function () {
return state.glyphClass;
},
/**
* Get the text that should appear in the indicator.
* @returns {string} brief summary of connection status
*/
getText: function () {
return state.text;
},
/**
* Get a longer-form description of the current connection
* space, suitable for display in a tooltip
* @returns {string} longer summary of connection status
*/
getDescription: function () {
return state.description;
}
};
}
return ElasticIndicator;
}
);

View File

@ -0,0 +1,179 @@
/*global define*/
define(
[],
function () {
'use strict';
// JSLint doesn't like underscore-prefixed properties,
// so hide them here.
var SRC = "_source",
REV = "_version",
ID = "_id",
CONFLICT = 409;
/**
* The ElasticPersistenceProvider reads and writes JSON documents
* (more specifically, domain object models) to/from an ElasticSearch
* instance.
* @constructor
*/
function ElasticPersistenceProvider($http, $q, SPACE, ROOT, PATH) {
var spaces = [ SPACE ],
revs = {};
// Convert a subpath to a full path, suitable to pass
// to $http.
function url(subpath) {
return ROOT + '/' + PATH + '/' + subpath;
}
// Issue a request using $http; get back the plain JS object
// from the expected JSON response
function request(subpath, method, value, params) {
return $http({
method: method,
url: url(subpath),
params: params,
data: value
}).then(function (response) {
return response.data;
}, function (response) {
return (response || {}).data;
});
}
// Shorthand methods for GET/PUT methods
function get(subpath) {
return request(subpath, "GET");
}
function put(subpath, value, params) {
return request(subpath, "PUT", value, params);
}
function del(subpath) {
return request(subpath, "DELETE");
}
// Get a domain object model out of CouchDB's response
function getModel(response) {
if (response && response[SRC]) {
revs[response[ID]] = response[REV];
return response[SRC];
} else {
return undefined;
}
}
// Handle an update error
function handleError(response, key) {
var error = new Error("Persistence error.");
if ((response || {}).status === CONFLICT) {
error.key = "revision";
// Load the updated model, then reject the promise
return get(key).then(function (response) {
error.model = response[SRC];
return $q.reject(error);
});
}
// Reject the promise
return $q.reject(error);
}
// Check the response to a create/update/delete request;
// track the rev if it's valid, otherwise return false to
// indicate that the request failed.
function checkResponse(response, key) {
var error;
if (response && !response.error) {
revs[key] = response[REV];
return response;
} else {
return handleError(response, key);
}
}
return {
/**
* List all persistence spaces which this provider
* recognizes.
*
* @returns {Promise.<string[]>} a promise for a list of
* spaces supported by this provider
*/
listSpaces: function () {
return $q.when(spaces);
},
/**
* List all objects (by their identifiers) that are stored
* in the given persistence space, per this provider.
* @param {string} space the space to check
* @returns {Promise.<string[]>} a promise for the list of
* identifiers
*/
listObjects: function (space) {
return $q.when([]);
},
/**
* Create a new object in the specified persistence space.
* @param {string} space the space in which to store the object
* @param {string} key the identifier for the persisted object
* @param {object} value a JSONifiable object that should be
* stored and associated with the provided identifier
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
createObject: function (space, key, value) {
return put(key, value).then(checkResponse);
},
/**
* Read an existing object back from persistence.
* @param {string} space the space in which to look for
* the object
* @param {string} key the identifier for the persisted object
* @returns {Promise.<object>} a promise for the stored
* object; this will resolve to undefined if no such
* object is found.
*/
readObject: function (space, key) {
return get(key).then(getModel);
},
/**
* Update an existing object in the specified persistence space.
* @param {string} space the space in which to store the object
* @param {string} key the identifier for the persisted object
* @param {object} value a JSONifiable object that should be
* stored and associated with the provided identifier
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
updateObject: function (space, key, value) {
function checkUpdate(response) {
return checkResponse(response, key);
}
return put(key, value, { version: revs[key] })
.then(checkUpdate);
},
/**
* Delete an object in the specified persistence space.
* @param {string} space the space from which to delete this
* object
* @param {string} key the identifier of the persisted object
* @param {object} value a JSONifiable object that should be
* deleted
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
deleteObject: function (space, key, value) {
return del(key).then(checkResponse);
}
};
}
return ElasticPersistenceProvider;
}
);

View File

@ -0,0 +1,90 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/ElasticIndicator"],
function (ElasticIndicator) {
"use strict";
describe("The ElasticSearch status indicator", function () {
var mockHttp,
mockInterval,
testPath,
testInterval,
mockPromise,
indicator;
beforeEach(function () {
mockHttp = jasmine.createSpyObj("$http", [ "get" ]);
mockInterval = jasmine.createSpy("$interval");
mockPromise = jasmine.createSpyObj("promise", [ "then" ]);
testPath = "/test/path";
testInterval = 12321; // Some number
mockHttp.get.andReturn(mockPromise);
indicator = new ElasticIndicator(
mockHttp,
mockInterval,
testPath,
testInterval
);
});
it("polls for changes", function () {
expect(mockInterval).toHaveBeenCalledWith(
jasmine.any(Function),
testInterval,
false
);
});
it("has a database icon", function () {
expect(indicator.getGlyph()).toEqual("D");
});
it("consults the database at the configured path", function () {
expect(mockHttp.get).toHaveBeenCalledWith(testPath);
});
it("changes when the database connection is nominal", function () {
var initialText = indicator.getText(),
initialDescrption = indicator.getDescription(),
initialGlyphClass = indicator.getGlyphClass();
// Nominal just means getting back an objeect, without
// an error field.
mockPromise.then.mostRecentCall.args[0]({ data: {} });
// Verify that these values changed;
// don't test for specific text.
expect(indicator.getText()).not.toEqual(initialText);
expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass);
expect(indicator.getDescription()).not.toEqual(initialDescrption);
// Do check for specific class
expect(indicator.getGlyphClass()).toEqual("ok");
});
it("changes when the server cannot be reached", function () {
var initialText = indicator.getText(),
initialDescrption = indicator.getDescription(),
initialGlyphClass = indicator.getGlyphClass();
// Nominal just means getting back an objeect, without
// an error field.
mockPromise.then.mostRecentCall.args[1]({ data: {} });
// Verify that these values changed;
// don't test for specific text.
expect(indicator.getText()).not.toEqual(initialText);
expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass);
expect(indicator.getDescription()).not.toEqual(initialDescrption);
// Do check for specific class
expect(indicator.getGlyphClass()).toEqual("err");
});
});
}
);

View File

@ -0,0 +1,195 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/ElasticPersistenceProvider"],
function (ElasticPersistenceProvider) {
"use strict";
describe("The ElasticSearch persistence provider", function () {
var mockHttp,
mockQ,
testSpace = "testSpace",
testRoot = "/test",
testPath = "db",
capture,
provider;
function mockPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return mockPromise(callback(value));
}
};
}
beforeEach(function () {
mockHttp = jasmine.createSpy("$http");
mockQ = jasmine.createSpyObj("$q", ["when", "reject"]);
mockQ.when.andCallFake(mockPromise);
mockQ.reject.andCallFake(function (value) {
return {
then: function (ignored, callback) {
return mockPromise(callback(value));
}
};
});
// Capture promise results
capture = jasmine.createSpy("capture");
provider = new ElasticPersistenceProvider(
mockHttp,
mockQ,
testSpace,
testRoot,
testPath
);
});
it("reports available spaces", function () {
provider.listSpaces().then(capture);
expect(capture).toHaveBeenCalledWith([testSpace]);
});
// General pattern of tests below is to simulate ElasticSearch's
// response, verify that request looks like what ElasticSearch
// would expect, and finally verify that ElasticPersistenceProvider's
// return values match what is expected.
it("lists all available documents", function () {
// Not implemented yet
provider.listObjects().then(capture);
expect(capture).toHaveBeenCalledWith([]);
});
it("allows object creation", function () {
var model = { someKey: "some value" };
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 1 }
}));
provider.createObject("testSpace", "abc", model).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "PUT",
data: model
});
expect(capture.mostRecentCall.args[0]).toBeTruthy();
});
it("allows object models to be read back", function () {
var model = { someKey: "some value" };
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 1, "_source": model }
}));
provider.readObject("testSpace", "abc").then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "GET"
});
expect(capture).toHaveBeenCalledWith(model);
});
it("allows object update", function () {
var model = { someKey: "some value" };
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 43, "_source": {} }
}));
provider.updateObject("testSpace", "abc", model).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "PUT",
params: { version: 42 },
data: model
});
expect(capture.mostRecentCall.args[0]).toBeTruthy();
});
it("allows object deletion", function () {
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.deleteObject("testSpace", "abc", {}).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "DELETE"
});
expect(capture.mostRecentCall.args[0]).toBeTruthy();
});
it("returns undefined when objects are not found", function () {
// Act like a 404
mockHttp.andReturn({
then: function (success, fail) {
return mockPromise(fail());
}
});
provider.readObject("testSpace", "abc").then(capture);
expect(capture).toHaveBeenCalledWith(undefined);
});
it("handles rejection due to version", function () {
var model = { someKey: "some value" },
mockErrorCallback = jasmine.createSpy('error');
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "status": 409, "error": "Revision error..." }
}));
provider.updateObject("testSpace", "abc", model).then(
capture,
mockErrorCallback
);
expect(capture).not.toHaveBeenCalled();
expect(mockErrorCallback).toHaveBeenCalled();
});
it("handles rejection due to unknown reasons", function () {
var model = { someKey: "some value" },
mockErrorCallback = jasmine.createSpy('error');
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "status": 410, "error": "Revision error..." }
}));
provider.updateObject("testSpace", "abc", model).then(
capture,
mockErrorCallback
);
expect(capture).not.toHaveBeenCalled();
expect(mockErrorCallback).toHaveBeenCalled();
});
});
}
);

View File

@ -0,0 +1,4 @@
[
"ElasticIndicator",
"ElasticPersistenceProvider"
]

View File

@ -0,0 +1,5 @@
This bundle provides an Overwrite/Cancel dialog when persisting
domain objects, if persistence fails. It is meant to be paired
with a persistence adapter which performs revision-checking
on update calls, in order to provide the user interface for
choosing between Overwrite and Cancel in that situation.

View File

@ -0,0 +1,42 @@
{
"extensions": {
"components": [
{
"type": "decorator",
"provides": "capabilityService",
"implementation": "QueuingPersistenceCapabilityDecorator.js",
"depends": [ "persistenceQueue" ]
}
],
"services": [
{
"key": "persistenceQueue",
"implementation": "PersistenceQueue.js",
"depends": [
"$q",
"$timeout",
"dialogService",
"PERSISTENCE_QUEUE_DELAY"
]
}
],
"constants": [
{
"key": "PERSISTENCE_QUEUE_DELAY",
"value": 5
}
],
"templates": [
{
"key": "persistence-failure-dialog",
"templateUrl": "templates/persistence-failure-dialog.html"
}
],
"controllers": [
{
"key": "PersistenceFailureController",
"implementation": "PersistenceFailureController.js"
}
]
}
}

View File

@ -0,0 +1,31 @@
<span ng-controller="PersistenceFailureController as controller">
<div ng-if="ngModel.revised.length > 0">
External changes have been made to the following objects:
<ul>
<li ng-repeat="failure in ngModel.revised">
<mct-representation key="'label'"
mct-object="failure.domainObject">
</mct-representation>
was modified at
<b>{{controller.formatTimestamp(failure.error.model.modified)}}</b>
by
<i>{{controller.formatUsername(failure.error.model.modifier)}}</i>
</li>
</ul>
You may overwrite these objects, or discard your changes to keep
the updates that were made externally.
</div>
<div ng-if="ngModel.unrecoverable.length > 0">
Changes to these objects could not be saved for unknown reasons:
<ul>
<li ng-repeat="failure in ngModel.unrecoverable">
<mct-representation key="'label'"
mct-object="failure.domainObject">
</mct-representation>
</li>
</ul>
</div>
</span>

View File

@ -0,0 +1,8 @@
/*global define*/
define({
REVISION_ERROR_KEY: "revision",
OVERWRITE_KEY: "overwrite",
TIMESTAMP_FORMAT: "YYYY-MM-DD HH:mm:ss\\Z",
UNKNOWN_USER: "unknown user"
});

View File

@ -0,0 +1,32 @@
/*global define*/
define(
['moment', './PersistenceFailureConstants'],
function (moment, Constants) {
"use strict";
/**
* Controller to support the template to be shown in the
* dialog shown for persistence failures.
*/
function PersistenceFailureController() {
return {
/**
* Format a timestamp for display in the dialog.
*/
formatTimestamp: function (timestamp) {
return moment.utc(timestamp)
.format(Constants.TIMESTAMP_FORMAT);
},
/**
* Format a user name for display in the dialog.
*/
formatUsername: function (username) {
return username || Constants.UNKNOWN_USER;
}
};
}
return PersistenceFailureController;
}
);

View File

@ -0,0 +1,54 @@
/*global define*/
define(
['./PersistenceFailureConstants'],
function (PersistenceFailureConstants) {
"use strict";
var OVERWRITE_CANCEL_OPTIONS = [
{
name: "Overwrite",
key: PersistenceFailureConstants.OVERWRITE_KEY
},
{
name: "Discard",
key: "cancel"
}
],
OK_OPTIONS = [ { name: "OK", key: "ok" } ];
/**
* Populates a `dialogModel` to pass to `dialogService.getUserChoise`
* in order to choose between Overwrite and Cancel.
*/
function PersistenceFailureDialog(failures) {
var revisionErrors = [],
otherErrors = [];
// Place this failure into an appropriate group
function categorizeFailure(failure) {
// Check if the error is due to object revision
var isRevisionError = ((failure || {}).error || {}).key ===
PersistenceFailureConstants.REVISION_ERROR_KEY;
// Push the failure into the appropriate group
(isRevisionError ? revisionErrors : otherErrors).push(failure);
}
// Separate into revision errors, and other errors
failures.forEach(categorizeFailure);
return {
title: "Save Error",
template: "persistence-failure-dialog",
model: {
revised: revisionErrors,
unrecoverable: otherErrors
},
options: revisionErrors.length > 0 ?
OVERWRITE_CANCEL_OPTIONS : OK_OPTIONS
};
}
return PersistenceFailureDialog;
}
);

View File

@ -0,0 +1,110 @@
/*global define*/
define(
['./PersistenceFailureDialog', './PersistenceFailureConstants'],
function (PersistenceFailureDialog, PersistenceFailureConstants) {
"use strict";
function PersistenceFailureHandler($q, dialogService) {
// Refresh revision information for the domain object associated
// with this persistence failure
function refresh(failure) {
// Refresh the domain object to the latest from persistence
return failure.persistence.refresh();
}
// Issue a new persist call for the domain object associated with
// this failure.
function persist(failure) {
// Note that we reissue the persist request here, but don't
// return it, to avoid a circular wait. We trust that the
// PersistenceQueue will behave correctly on the next round
// of flushing.
failure.requeue();
}
// Retry persistence (overwrite) for this set of failed attempts
function retry(failures) {
var models = {};
// Cache a copy of the model
function cacheModel(failure) {
// Clone...
models[failure.id] = JSON.parse(JSON.stringify(
failure.domainObject.getModel()
));
}
// Mutate a domain object to restore its model
function remutate(failure) {
var model = models[failure.id];
return failure.domainObject.useCapability(
"mutation",
function () { return model; },
model.modified
);
}
// Cache the object models we might want to save
failures.forEach(cacheModel);
// Strategy here:
// * Cache all of the models we might want to save (above)
// * Refresh all domain objects (so they are latest versions)
// * Re-insert the cached domain object models
// * Invoke persistence again
return $q.all(failures.map(refresh)).then(function () {
return $q.all(failures.map(remutate));
}).then(function () {
return $q.all(failures.map(persist));
});
}
// Discard changes for a failed refresh
function discard(failure) {
var persistence =
failure.domainObject.getCapability('persistence');
return persistence.refresh();
}
// Discard changes associated with a failed save
function discardAll(failures) {
return $q.all(failures.map(discard));
}
// Handle failures in persistence
function handleFailures(failures) {
// Prepare dialog for display
var dialogModel = new PersistenceFailureDialog(failures),
revisionErrors = dialogModel.model.revised;
// Handle user input (did they choose to overwrite?)
function handleChoice(key) {
// If so, try again
if (key === PersistenceFailureConstants.OVERWRITE_KEY) {
return retry(revisionErrors);
} else {
return discardAll(revisionErrors);
}
}
// Prompt for user input, the overwrite if they said so.
return dialogService.getUserChoice(dialogModel)
.then(handleChoice, handleChoice);
}
return {
/**
* Handle persistence failures by providing the user with a
* dialog summarizing these failures, and giving the option
* to overwrite/cancel as appropriate.
* @param {Array} failures persistence failures, as prepared
* by PersistenceQueueHandler
*/
handle: handleFailures
};
}
return PersistenceFailureHandler;
}
);

View File

@ -0,0 +1,56 @@
/*global define*/
define(
[
'./PersistenceQueueImpl',
'./PersistenceQueueHandler',
'./PersistenceFailureHandler'
],
function (
PersistenceQueueImpl,
PersistenceQueueHandler,
PersistenceFailureHandler
) {
"use strict";
/**
* The PersistenceQueue is used by the QueuingPersistenceCapability
* to aggregrate calls for object persistence. These are then issued
* in a group, such that if some or all are rejected, this result can
* be shown to the user (again, in a group.)
*
* This constructor is exposed as a service, but handles only the
* wiring of injected dependencies; behavior is implemented in the
* various component parts.
*
* @param $timeout Angular's $timeout
* @param {PersistenceQueueHandler} handler handles actual
* persistence when the queue is flushed
* @param {number} [DELAY] optional; delay in milliseconds between
* attempts to flush the queue
*/
function PersistenceQueue(
$q,
$timeout,
dialogService,
PERSISTENCE_QUEUE_DELAY
) {
// Wire up injected dependencies
return new PersistenceQueueImpl(
$q,
$timeout,
new PersistenceQueueHandler(
$q,
new PersistenceFailureHandler(
$q,
dialogService
)
),
PERSISTENCE_QUEUE_DELAY
);
}
return PersistenceQueue;
}
);

View File

@ -0,0 +1,90 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Handles actual persistence invocations for queeud persistence
* attempts, in a group. Handling in a group in this manner
* also allows failure to be handled in a group (e.g. in a single
* summary dialog.)
* @param $q Angular's $q, for promises
* @param {PersistenceFailureHandler} handler to invoke in the event
* that a persistence attempt fails.
*/
function PersistenceQueueHandler($q, failureHandler) {
// Handle a group of persistence invocations
function persistGroup(ids, persistences, domainObjects, queue) {
var failures = [];
// Try to persist a specific domain object
function tryPersist(id) {
// Look up its persistence capability from the provided
// id->persistence object.
var persistence = persistences[id],
domainObject = domainObjects[id];
// Put a domain object back in the queue
// (e.g. after Overwrite)
function requeue() {
return queue.put(domainObject, persistence);
}
// Handle success
function succeed(value) {
return value;
}
// Handle failure (build up a list of failures)
function fail(error) {
failures.push({
id: id,
persistence: persistence,
domainObject: domainObject,
requeue: requeue,
error: error
});
return false;
}
// Invoke the actual persistence capability, then
// note success or failures
return persistence.persist().then(succeed, fail);
}
// Handle any failures from the full operation
function handleFailure(value) {
return failures.length > 0 ?
failureHandler.handle(failures) :
value;
}
// Try to persist everything, then handle any failures
return $q.all(ids.map(tryPersist)).then(handleFailure);
}
return {
/**
* Invoke the persist method on the provided persistence
* capabilities.
* @param {Object.<string,PersistenceCapability>} persistences
* capabilities to invoke, in id->capability pairs.
* @param {Object.<string,DomainObject>} domainObjects
* associated domain objects, in id->object pairs.
* @param {PersistenceQueue} queue the persistence queue,
* to requeue as necessary
*/
persist: function (persistences, domainObjects, queue) {
var ids = Object.keys(persistences);
return persistGroup(ids, persistences, domainObjects, queue);
}
};
}
return PersistenceQueueHandler;
}
);

View File

@ -0,0 +1,114 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The PersistenceQueue is used by the QueuingPersistenceCapability
* to aggregrate calls for object persistence. These are then issued
* in a group, such that if some or all are rejected, this result can
* be shown to the user (again, in a group.)
*
* This implementation is separate out from PersistenceQueue, which
* handles the wiring of injected dependencies into an instance of
* this class.
*
* @param $timeout Angular's $timeout
* @param {PersistenceQueueHandler} handler handles actual
* persistence when the queue is flushed
* @param {number} [DELAY] optional; delay in milliseconds between
* attempts to flush the queue
*/
function PersistenceQueueImpl($q, $timeout, handler, DELAY) {
var self,
persistences = {},
objects = {},
lastObservedSize = 0,
pendingTimeout,
flushPromise,
activeDefer = $q.defer();
// Check if the queue's size has stopped increasing)
function quiescent() {
return Object.keys(persistences).length === lastObservedSize;
}
// Persist all queued objects
function flush() {
// Get a local reference to the active promise;
// this will be replaced with a promise for the next round
// of persistence calls, so we want to make sure we clear
// the correct one when this flush completes.
var flushingDefer = activeDefer;
// Clear the active promise for a queue flush
function clearFlushPromise(value) {
flushPromise = undefined;
flushingDefer.resolve(value);
return value;
}
// Persist all queued objects
flushPromise = handler.persist(persistences, objects, self)
.then(clearFlushPromise, clearFlushPromise);
// Reset queue, etc.
persistences = {};
objects = {};
lastObservedSize = 0;
pendingTimeout = undefined;
activeDefer = $q.defer();
}
// Schedule a flushing of the queue (that is, plan to flush
// all objects in the queue)
function scheduleFlush() {
function maybeFlush() {
// Timeout fired, so clear it
pendingTimeout = undefined;
// Only flush when we've stopped receiving updates
(quiescent() ? flush : scheduleFlush)();
// Update lastObservedSize to detect quiescence
lastObservedSize = Object.keys(persistences).length;
}
// If we are already flushing the queue...
if (flushPromise) {
// Wait until that's over before considering a flush
flushPromise.then(maybeFlush);
} else {
// Otherwise, schedule a flush on a timeout (to give
// a window for other updates to get aggregated)
pendingTimeout = pendingTimeout ||
$timeout(maybeFlush, DELAY, false);
}
return activeDefer.promise;
}
// If no delay is provided, use a default
DELAY = DELAY || 0;
self = {
/**
* Queue persistence of a domain object.
* @param {DomainObject} domainObject the domain object
* @param {PersistenceCapability} persistence the object's
* undecorated persistence capability
*/
put: function (domainObject, persistence) {
var id = domainObject.getId();
persistences[id] = persistence;
objects[id] = domainObject;
return scheduleFlush();
}
};
return self;
}
return PersistenceQueueImpl;
}
);

View File

@ -0,0 +1,30 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The QueuingPersistenceCapability places `persist` calls in a queue
* to be handled in batches.
* @param {PersistenceQueue} queue of persistence calls
* @param {PersistenceCapability} persistence the wrapped persistence
* capability
* @param {DomainObject} domainObject the domain object which exposes
* the capability
*/
function QueuingPersistenceCapability(queue, persistence, domainObject) {
var queuingPersistence = Object.create(persistence);
// Override persist calls to queue them instead
queuingPersistence.persist = function () {
return queue.put(domainObject, persistence);
};
return queuingPersistence;
}
return QueuingPersistenceCapability;
}
);

View File

@ -0,0 +1,76 @@
/*global define,Promise*/
/**
* Module defining CoreCapabilityProvider. Created by vwoeltje on 11/7/14.
*/
define(
['./QueuingPersistenceCapability'],
function (QueuingPersistenceCapability) {
"use strict";
/**
* Capability decorator. Adds queueing support to persistence
* capabilities for domain objects, such that persistence attempts
* will be handled in batches (allowing failure notification to
* also be presented in batches.)
*
* @constructor
*/
function QueuingPersistenceCapabilityDecorator(
persistenceQueue,
capabilityService
) {
function decoratePersistence(capabilities) {
var originalPersistence = capabilities.persistence;
if (originalPersistence) {
capabilities.persistence = function (domainObject) {
// Get/instantiate the original
var original =
(typeof originalPersistence === 'function') ?
originalPersistence(domainObject) :
originalPersistence;
// Provide a decorated version
return new QueuingPersistenceCapability(
persistenceQueue,
original,
domainObject
);
};
}
return capabilities;
}
function getCapabilities(model) {
return decoratePersistence(
capabilityService.getCapabilities(model)
);
}
return {
/**
* Get all capabilities associated with a given domain
* object.
*
* This returns a promise for an object containing key-value
* pairs, where keys are capability names and values are
* either:
*
* * Capability instances
* * Capability constructors (which take a domain object
* as their argument.)
*
*
* @param {*} model the object model
* @returns {Object.<string,function|Capability>} all
* capabilities known to be valid for this model, as
* key-value pairs
*/
getCapabilities: getCapabilities
};
}
return QueuingPersistenceCapabilityDecorator;
}
);

View File

@ -0,0 +1,16 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureConstants"],
function (PersistenceFailureConstants) {
"use strict";
describe("Persistence failure constants", function () {
it("defines an overwrite key", function () {
expect(PersistenceFailureConstants.OVERWRITE_KEY)
.toEqual(jasmine.any(String));
});
});
}
);

View File

@ -0,0 +1,27 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureController"],
function (PersistenceFailureController) {
"use strict";
describe("The persistence failure controller", function () {
var controller;
beforeEach(function () {
controller = new PersistenceFailureController();
});
it("converts timestamps to human-readable dates", function () {
expect(controller.formatTimestamp(402514331000))
.toEqual("1982-10-03 17:32:11Z");
});
it("provides default user names", function () {
expect(controller.formatUsername(undefined))
.toEqual(jasmine.any(String));
});
});
}
);

View File

@ -0,0 +1,38 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureDialog", "../src/PersistenceFailureConstants"],
function (PersistenceFailureDialog, Constants) {
"use strict";
describe("The persistence failure dialog", function () {
var testFailures,
dialog;
beforeEach(function () {
testFailures = [
{ error: { key: Constants.REVISION_ERROR_KEY }, someKey: "abc" },
{ error: { key: "..." }, someKey: "def" },
{ error: { key: Constants.REVISION_ERROR_KEY }, someKey: "ghi" },
{ error: { key: Constants.REVISION_ERROR_KEY }, someKey: "jkl" },
{ error: { key: "..." }, someKey: "mno" }
];
dialog = new PersistenceFailureDialog(testFailures);
});
it("categorizes failures", function () {
expect(dialog.model.revised).toEqual([
testFailures[0], testFailures[2], testFailures[3]
]);
expect(dialog.model.unrecoverable).toEqual([
testFailures[1], testFailures[4]
]);
});
it("provides an overwrite option", function () {
expect(dialog.options[0].key).toEqual(Constants.OVERWRITE_KEY);
});
});
}
);

View File

@ -0,0 +1,97 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureHandler", "../src/PersistenceFailureConstants"],
function (PersistenceFailureHandler, Constants) {
"use strict";
describe("The persistence failure handler", function () {
var mockQ,
mockDialogService,
mockFailures,
mockPromise,
handler;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
function makeMockFailure(id, index) {
var mockFailure = jasmine.createSpyObj(
'failure-' + id,
['requeue']
),
mockPersistence = jasmine.createSpyObj(
'persistence-' + id,
['refresh', 'persist']
);
mockFailure.domainObject = jasmine.createSpyObj(
'domainObject',
['getCapability', 'useCapability', 'getModel']
);
mockFailure.domainObject.getCapability.andCallFake(function (c) {
return (c === 'persistence') && mockPersistence;
});
mockFailure.domainObject.getModel.andReturn({ id: id, modified: index });
mockFailure.persistence = mockPersistence;
mockFailure.id = id;
mockFailure.error = { key: Constants.REVISION_ERROR_KEY };
return mockFailure;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['all', 'when']);
mockDialogService = jasmine.createSpyObj('dialogService', ['getUserChoice']);
mockFailures = ['a', 'b', 'c'].map(makeMockFailure);
mockPromise = jasmine.createSpyObj('promise', ['then']);
mockDialogService.getUserChoice.andReturn(mockPromise);
mockQ.all.andReturn(mockPromise);
mockPromise.then.andReturn(mockPromise);
handler = new PersistenceFailureHandler(mockQ, mockDialogService);
});
it("shows a dialog to handle failures", function () {
handler.handle(mockFailures);
expect(mockDialogService.getUserChoice).toHaveBeenCalled();
});
it("overwrites on request", function () {
mockQ.all.andReturn(asPromise([]));
handler.handle(mockFailures);
// User chooses overwrite
mockPromise.then.mostRecentCall.args[0](Constants.OVERWRITE_KEY);
// Should refresh, remutate, and requeue all objects
mockFailures.forEach(function (mockFailure, i) {
expect(mockFailure.persistence.refresh).toHaveBeenCalled();
expect(mockFailure.requeue).toHaveBeenCalled();
expect(mockFailure.domainObject.useCapability).toHaveBeenCalledWith(
'mutation',
jasmine.any(Function),
i // timestamp
);
expect(mockFailure.domainObject.useCapability.mostRecentCall.args[1]())
.toEqual({ id: mockFailure.id, modified: i });
});
});
it("discards on request", function () {
mockQ.all.andReturn(asPromise([]));
handler.handle(mockFailures);
// User chooses overwrite
mockPromise.then.mostRecentCall.args[0](false);
// Should refresh, but not remutate, and requeue all objects
mockFailures.forEach(function (mockFailure, i) {
expect(mockFailure.persistence.refresh).toHaveBeenCalled();
expect(mockFailure.requeue).not.toHaveBeenCalled();
expect(mockFailure.domainObject.useCapability).not.toHaveBeenCalled();
});
});
});
}
);

View File

@ -0,0 +1,116 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceQueueHandler"],
function (PersistenceQueueHandler) {
"use strict";
var TEST_ERROR = { someKey: "some value" };
describe("The persistence queue handler", function () {
var mockQ,
mockFailureHandler,
mockPersistences,
mockDomainObjects,
mockQueue,
mockRejection,
handler;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
function makeMockPersistence(id) {
var mockPersistence = jasmine.createSpyObj(
'persistence-' + id,
[ 'persist', 'refresh' ]
);
mockPersistence.persist.andReturn(asPromise(true));
return mockPersistence;
}
function makeMockDomainObject(id) {
var mockDomainObject = jasmine.createSpyObj(
'domainObject-' + id,
[ 'getId' ]
);
mockDomainObject.getId.andReturn(id);
return mockDomainObject;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['all']);
mockFailureHandler = jasmine.createSpyObj('handler', ['handle']);
mockQueue = jasmine.createSpyObj('queue', ['put']);
mockPersistences = {};
mockDomainObjects = {};
['a', 'b', 'c'].forEach(function (id) {
mockPersistences[id] = makeMockPersistence(id);
mockDomainObjects[id] = makeMockDomainObject(id);
});
mockRejection = jasmine.createSpyObj('rejection', ['then']);
mockQ.all.andReturn(asPromise([]));
mockRejection.then.andCallFake(function (callback, fallback) {
return asPromise(fallback({ someKey: "some value" }));
});
handler = new PersistenceQueueHandler(mockQ, mockFailureHandler);
});
it("invokes persistence on all members in the group", function () {
handler.persist(mockPersistences, mockDomainObjects, mockQueue);
expect(mockPersistences.a.persist).toHaveBeenCalled();
expect(mockPersistences.b.persist).toHaveBeenCalled();
expect(mockPersistences.c.persist).toHaveBeenCalled();
// No failures in this group
expect(mockFailureHandler.handle).not.toHaveBeenCalled();
});
it("handles failures that occur", function () {
mockPersistences.b.persist.andReturn(mockRejection);
mockPersistences.c.persist.andReturn(mockRejection);
handler.persist(mockPersistences, mockDomainObjects, mockQueue);
expect(mockFailureHandler.handle).toHaveBeenCalledWith([
{
id: 'b',
persistence: mockPersistences.b,
domainObject: mockDomainObjects.b,
requeue: jasmine.any(Function),
error: TEST_ERROR
},
{
id: 'c',
persistence: mockPersistences.c,
domainObject: mockDomainObjects.c,
requeue: jasmine.any(Function),
error: TEST_ERROR
}
]);
});
it("provides a requeue method for failures", function () {
// This method is needed by PersistenceFailureHandler
// to allow requeuing of objects for persistence when
// Overwrite is chosen.
mockPersistences.b.persist.andReturn(mockRejection);
handler.persist(mockPersistences, mockDomainObjects, mockQueue);
// Verify precondition
expect(mockQueue.put).not.toHaveBeenCalled();
// Invoke requeue
mockFailureHandler.handle.mostRecentCall.args[0][0].requeue();
// Should have returned the object to the queue
expect(mockQueue.put).toHaveBeenCalledWith(
mockDomainObjects.b,
mockPersistences.b
);
});
});
}
);

View File

@ -0,0 +1,133 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceQueueImpl"],
function (PersistenceQueueImpl) {
"use strict";
var TEST_DELAY = 42;
describe("The implemented persistence queue", function () {
var mockQ,
mockTimeout,
mockHandler,
mockDeferred,
mockPromise,
queue;
function makeMockDomainObject(id) {
var mockDomainObject = jasmine.createSpyObj(
'domainObject-' + id,
[ 'getId' ]
);
mockDomainObject.getId.andReturn(id);
return mockDomainObject;
}
function makeMockPersistence(id) {
var mockPersistence = jasmine.createSpyObj(
'persistence-' + id,
[ 'persist' ]
);
return mockPersistence;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['when', 'defer']);
mockTimeout = jasmine.createSpy('$timeout');
mockHandler = jasmine.createSpyObj('handler', ['persist']);
mockDeferred = jasmine.createSpyObj('deferred', ['resolve']);
mockDeferred.promise = jasmine.createSpyObj('promise', ['then']);
mockPromise = jasmine.createSpyObj('promise', ['then']);
mockQ.defer.andReturn(mockDeferred);
mockTimeout.andReturn({});
mockHandler.persist.andReturn(mockPromise);
mockPromise.then.andReturn(mockPromise);
queue = new PersistenceQueueImpl(
mockQ,
mockTimeout,
mockHandler,
TEST_DELAY
);
});
it("schedules a timeout to persist objects", function () {
expect(mockTimeout).not.toHaveBeenCalled();
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
expect(mockTimeout).toHaveBeenCalledWith(
jasmine.any(Function),
TEST_DELAY,
false
);
});
it("does not schedule multiple timeouts for multiple objects", function () {
// Put three objects in without triggering the timeout;
// shouldn't schedule multiple timeouts
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(1);
});
it("returns a promise", function () {
expect(queue.put(makeMockDomainObject('a'), makeMockPersistence('a')))
.toEqual(mockDeferred.promise);
});
it("waits for quiescence to proceed", function () {
// Keep adding objects to the queue between timeouts.
// Should keep scheduling timeouts instead of resolving.
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
expect(mockTimeout.calls.length).toEqual(1);
mockTimeout.mostRecentCall.args[0]();
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
expect(mockTimeout.calls.length).toEqual(2);
mockTimeout.mostRecentCall.args[0]();
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(3);
mockTimeout.mostRecentCall.args[0]();
expect(mockHandler.persist).not.toHaveBeenCalled();
});
it("persists upon quiescence", function () {
// Add objects to the queue, but fire two timeouts afterward
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
mockTimeout.mostRecentCall.args[0]();
mockTimeout.mostRecentCall.args[0]();
expect(mockHandler.persist).toHaveBeenCalled();
});
it("waits on an active flush, while flushing", function () {
// Persist some objects
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
mockTimeout.mostRecentCall.args[0]();
mockTimeout.mostRecentCall.args[0]();
expect(mockTimeout.calls.length).toEqual(2);
// Adding a new object should not trigger a new timeout,
// because we haven't completed the previous flush
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(2);
});
it("clears the active flush after it has completed", function () {
// Persist some objects
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
mockTimeout.mostRecentCall.args[0]();
mockTimeout.mostRecentCall.args[0]();
expect(mockTimeout.calls.length).toEqual(2);
// Resolve the promise from handler.persist
mockPromise.then.calls[0].args[0](true);
// Adding a new object should now trigger a new timeout,
// because we have completed the previous flush
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(3);
});
});
}
);

View File

@ -0,0 +1,35 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceQueue"],
function (PersistenceQueue) {
"use strict";
describe("The persistence queue", function () {
var mockQ,
mockTimeout,
mockDialogService,
queue;
beforeEach(function () {
mockQ = jasmine.createSpyObj("$q", ['defer']);
mockTimeout = jasmine.createSpy("$timeout");
mockDialogService = jasmine.createSpyObj(
'dialogService',
['getUserChoice']
);
queue = new PersistenceQueue(mockQ, mockTimeout, mockDialogService);
});
// PersistenceQueue is just responsible for handling injected
// dependencies and wiring the PersistenceQueueImpl and its
// handlers. Functionality is tested there, so our test here is
// minimal (get back expected interface, no exceptions)
it("provides a queue with a put method", function () {
expect(queue.put).toEqual(jasmine.any(Function));
});
});
}
);

View File

@ -0,0 +1,63 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/QueuingPersistenceCapabilityDecorator"],
function (QueuingPersistenceCapabilityDecorator) {
"use strict";
describe("A queuing persistence capability decorator", function () {
var mockQueue,
mockCapabilityService,
mockPersistenceConstructor,
mockPersistence,
mockDomainObject,
testModel,
decorator;
beforeEach(function () {
mockQueue = jasmine.createSpyObj('queue', ['put']);
mockCapabilityService = jasmine.createSpyObj(
'capabilityService',
['getCapabilities']
);
testModel = { someKey: "some value" };
mockPersistence = jasmine.createSpyObj(
'persistence',
['persist', 'refresh']
);
mockPersistenceConstructor = jasmine.createSpy();
mockDomainObject = jasmine.createSpyObj(
'domainObject',
['getId']
);
mockCapabilityService.getCapabilities.andReturn({
persistence: mockPersistenceConstructor
});
mockPersistenceConstructor.andReturn(mockPersistence);
decorator = new QueuingPersistenceCapabilityDecorator(
mockQueue,
mockCapabilityService
);
});
// Here, we verify that the decorator wraps the calls it is expected
// to wrap; remaining responsibility belongs to
// QueuingPersistenceCapability itself, which has its own tests.
it("delegates to its wrapped service", function () {
decorator.getCapabilities(testModel);
expect(mockCapabilityService.getCapabilities)
.toHaveBeenCalledWith(testModel);
});
it("wraps its persistence capability's constructor", function () {
decorator.getCapabilities(testModel).persistence(mockDomainObject);
expect(mockPersistenceConstructor).toHaveBeenCalledWith(mockDomainObject);
});
});
}
);

View File

@ -0,0 +1,46 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/QueuingPersistenceCapability"],
function (QueuingPersistenceCapability) {
"use strict";
describe("A queuing persistence capability", function () {
var mockQueue,
mockPersistence,
mockDomainObject,
persistence;
beforeEach(function () {
mockQueue = jasmine.createSpyObj('queue', ['put']);
mockPersistence = jasmine.createSpyObj(
'persistence',
['persist', 'refresh']
);
mockDomainObject = {};
persistence = new QueuingPersistenceCapability(
mockQueue,
mockPersistence,
mockDomainObject
);
});
it("puts a request for persistence into the queue on persist", function () {
// Verify precondition
expect(mockQueue.put).not.toHaveBeenCalled();
// Invoke persistence
persistence.persist();
// Should have queued
expect(mockQueue.put).toHaveBeenCalledWith(
mockDomainObject,
mockPersistence
);
});
it("exposes other methods from the wrapped persistence capability", function () {
expect(persistence.refresh).toBe(mockPersistence.refresh);
});
});
}
);

View File

@ -0,0 +1,11 @@
[
"PersistenceFailureConstants",
"PersistenceFailureController",
"PersistenceFailureDialog",
"PersistenceFailureHandler",
"PersistenceQueue",
"PersistenceQueueHandler",
"PersistenceQueueImpl",
"QueuingPersistenceCapability",
"QueuingPersistenceCapabilityDecorator"
]

Some files were not shown because too many files have changed in this diff Show More