mirror of
https://github.com/nasa/openmct.git
synced 2025-04-08 20:04:27 +00:00
Merge branch 'open-master' into open1051
Merge in latest from master branch to add license headers, WTD-1051.
This commit is contained in:
commit
3feb0c1a57
@ -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"
|
||||
]
|
||||
|
12
example/policy/bundle.json
Normal file
12
example/policy/bundle.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
26
example/policy/src/ExamplePolicy.js
Normal file
26
example/policy/src/ExamplePolicy.js
Normal 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;
|
||||
}
|
||||
);
|
@ -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": [
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -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"
|
||||
}]});
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
27
platform/commonUI/dialog/README.md
Normal file
27
platform/commonUI/dialog/README.md
Normal 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.
|
@ -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"
|
||||
|
24
platform/commonUI/dialog/res/templates/overlay-options.html
Normal file
24
platform/commonUI/dialog/res/templates/overlay-options.html
Normal 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>
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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`
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
49
platform/commonUI/edit/src/actions/LinkAction.js
Normal file
49
platform/commonUI/edit/src/actions/LinkAction.js
Normal 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;
|
||||
}
|
||||
);
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
|
84
platform/commonUI/edit/src/directives/MCTBeforeUnload.js
Normal file
84
platform/commonUI/edit/src/directives/MCTBeforeUnload.js
Normal 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;
|
||||
|
||||
}
|
||||
);
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
61
platform/commonUI/edit/src/policies/EditActionPolicy.js
Normal file
61
platform/commonUI/edit/src/policies/EditActionPolicy.js
Normal 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;
|
||||
}
|
||||
);
|
36
platform/commonUI/edit/src/policies/EditableViewPolicy.js
Normal file
36
platform/commonUI/edit/src/policies/EditableViewPolicy.js
Normal 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;
|
||||
}
|
||||
);
|
107
platform/commonUI/edit/test/actions/LinkActionSpec.js
Normal file
107
platform/commonUI/edit/test/actions/LinkActionSpec.js
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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);
|
||||
|
||||
|
@ -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));
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
78
platform/commonUI/edit/test/policies/EditActionPolicySpec.js
Normal file
78
platform/commonUI/edit/test/policies/EditActionPolicySpec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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",
|
||||
|
@ -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; }
|
||||
|
@ -37,4 +37,5 @@
|
||||
@import "helpers/bubbles";
|
||||
@import "helpers/splitter";
|
||||
@import "helpers/wait-spinner";
|
||||
@import "properties";
|
||||
@import "autoflow";
|
||||
|
14
platform/commonUI/general/res/sass/_properties.scss
Normal file
14
platform/commonUI/general/res/sass/_properties.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
2
platform/containment/README.md
Normal file
2
platform/containment/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
Implements support for rules which determine which objects are allowed
|
||||
to contain other objects, typically by type.
|
18
platform/containment/bundle.json
Normal file
18
platform/containment/bundle.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
55
platform/containment/src/CapabilityTable.js
Normal file
55
platform/containment/src/CapabilityTable.js
Normal 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;
|
||||
}
|
||||
);
|
59
platform/containment/src/ComposeActionPolicy.js
Normal file
59
platform/containment/src/ComposeActionPolicy.js
Normal 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;
|
||||
|
||||
}
|
||||
);
|
36
platform/containment/src/CompositionPolicy.js
Normal file
36
platform/containment/src/CompositionPolicy.js
Normal 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;
|
||||
}
|
||||
);
|
98
platform/containment/src/ContainmentTable.js
Normal file
98
platform/containment/src/ContainmentTable.js
Normal 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;
|
||||
}
|
||||
);
|
66
platform/containment/test/CapabilityTableSpec.js
Normal file
66
platform/containment/test/CapabilityTableSpec.js
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
74
platform/containment/test/ComposeActionPolicySpec.js
Normal file
74
platform/containment/test/ComposeActionPolicySpec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
66
platform/containment/test/CompositionPolicySpec.js
Normal file
66
platform/containment/test/CompositionPolicySpec.js
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
77
platform/containment/test/ContainmentTableSpec.js
Normal file
77
platform/containment/test/ContainmentTableSpec.js
Normal 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();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
);
|
6
platform/containment/test/suite.json
Normal file
6
platform/containment/test/suite.json
Normal file
@ -0,0 +1,6 @@
|
||||
[
|
||||
"CapabilityTable",
|
||||
"ComposeActionPolicy",
|
||||
"CompositionPolicy",
|
||||
"ContainmentTable"
|
||||
]
|
@ -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",
|
||||
|
@ -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] || [];
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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
|
||||
|
115
platform/core/src/models/CachingModelDecorator.js
Normal file
115
platform/core/src/models/CachingModelDecorator.js
Normal 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;
|
||||
}
|
||||
);
|
@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
132
platform/core/test/models/CachingModelDecoratorSpec.js
Normal file
132
platform/core/test/models/CachingModelDecoratorSpec.js
Normal 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" });
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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;
|
||||
|
||||
|
@ -17,6 +17,7 @@
|
||||
"models/PersistedModelProvider",
|
||||
"models/RootModelProvider",
|
||||
"models/StaticModelProvider",
|
||||
"models/CachingModelDecorator",
|
||||
|
||||
"objects/DomainObject",
|
||||
"objects/DomainObjectProvider",
|
||||
|
@ -231,6 +231,7 @@
|
||||
"description": "A panel for collecting telemetry elements.",
|
||||
"delegates": [ "telemetry" ],
|
||||
"features": "creation",
|
||||
"contains": [ { "has": "telemetry" } ],
|
||||
"model": { "composition": [] },
|
||||
"properties": [
|
||||
{
|
||||
|
120
platform/features/plot/src/Canvas2DChart.js
Normal file
120
platform/features/plot/src/Canvas2DChart.js
Normal 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;
|
||||
}
|
||||
);
|
@ -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);
|
||||
|
||||
|
76
platform/features/plot/test/Canvas2DChartSpec.js
Normal file
76
platform/features/plot/test/Canvas2DChartSpec.js
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -1,4 +1,5 @@
|
||||
[
|
||||
"Canvas2DChart",
|
||||
"GLChart",
|
||||
"MCTChart",
|
||||
"PlotController",
|
||||
|
@ -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
|
||||
|
@ -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");
|
||||
|
@ -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
|
||||
|
2
platform/persistence/elastic/README.md
Normal file
2
platform/persistence/elastic/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
This bundle implements a connection to an external ElasticSearch persistence
|
||||
store in Open MCT Web.
|
43
platform/persistence/elastic/bundle.json
Normal file
43
platform/persistence/elastic/bundle.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
95
platform/persistence/elastic/src/ElasticIndicator.js
Normal file
95
platform/persistence/elastic/src/ElasticIndicator.js
Normal 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;
|
||||
}
|
||||
);
|
179
platform/persistence/elastic/src/ElasticPersistenceProvider.js
Normal file
179
platform/persistence/elastic/src/ElasticPersistenceProvider.js
Normal 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;
|
||||
}
|
||||
);
|
90
platform/persistence/elastic/test/ElasticIndicatorSpec.js
Normal file
90
platform/persistence/elastic/test/ElasticIndicatorSpec.js
Normal 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");
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
);
|
4
platform/persistence/elastic/test/suite.json
Normal file
4
platform/persistence/elastic/test/suite.json
Normal file
@ -0,0 +1,4 @@
|
||||
[
|
||||
"ElasticIndicator",
|
||||
"ElasticPersistenceProvider"
|
||||
]
|
5
platform/persistence/queue/README.md
Normal file
5
platform/persistence/queue/README.md
Normal 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.
|
42
platform/persistence/queue/bundle.json
Normal file
42
platform/persistence/queue/bundle.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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>
|
@ -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"
|
||||
});
|
@ -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;
|
||||
}
|
||||
);
|
54
platform/persistence/queue/src/PersistenceFailureDialog.js
Normal file
54
platform/persistence/queue/src/PersistenceFailureDialog.js
Normal 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;
|
||||
}
|
||||
);
|
110
platform/persistence/queue/src/PersistenceFailureHandler.js
Normal file
110
platform/persistence/queue/src/PersistenceFailureHandler.js
Normal 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;
|
||||
}
|
||||
);
|
56
platform/persistence/queue/src/PersistenceQueue.js
Normal file
56
platform/persistence/queue/src/PersistenceQueue.js
Normal 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;
|
||||
}
|
||||
);
|
90
platform/persistence/queue/src/PersistenceQueueHandler.js
Normal file
90
platform/persistence/queue/src/PersistenceQueueHandler.js
Normal 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;
|
||||
}
|
||||
);
|
114
platform/persistence/queue/src/PersistenceQueueImpl.js
Normal file
114
platform/persistence/queue/src/PersistenceQueueImpl.js
Normal 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;
|
||||
}
|
||||
);
|
@ -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;
|
||||
}
|
||||
);
|
@ -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;
|
||||
}
|
||||
);
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
116
platform/persistence/queue/test/PersistenceQueueHandlerSpec.js
Normal file
116
platform/persistence/queue/test/PersistenceQueueHandlerSpec.js
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
133
platform/persistence/queue/test/PersistenceQueueImplSpec.js
Normal file
133
platform/persistence/queue/test/PersistenceQueueImplSpec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
35
platform/persistence/queue/test/PersistenceQueueSpec.js
Normal file
35
platform/persistence/queue/test/PersistenceQueueSpec.js
Normal 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));
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
11
platform/persistence/queue/test/suite.json
Normal file
11
platform/persistence/queue/test/suite.json
Normal 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
Loading…
x
Reference in New Issue
Block a user