diff --git a/bundles.json b/bundles.json index 31aedf69bb..4aba0715b1 100644 --- a/bundles.json +++ b/bundles.json @@ -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" ] diff --git a/example/policy/bundle.json b/example/policy/bundle.json new file mode 100644 index 0000000000..cec350ffd0 --- /dev/null +++ b/example/policy/bundle.json @@ -0,0 +1,12 @@ +{ + "name": "Example Policy", + "description": "Provides an example of using policies to prohibit actions.", + "extensions": { + "policies": [ + { + "implementation": "ExamplePolicy.js", + "category": "action" + } + ] + } +} \ No newline at end of file diff --git a/example/policy/src/ExamplePolicy.js b/example/policy/src/ExamplePolicy.js new file mode 100644 index 0000000000..ba9fd68812 --- /dev/null +++ b/example/policy/src/ExamplePolicy.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/commonUI/browse/bundle.json b/platform/commonUI/browse/bundle.json index d4e7a85036..7ee37c4f20 100644 --- a/platform/commonUI/browse/bundle.json +++ b/platform/commonUI/browse/bundle.json @@ -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": [ diff --git a/platform/commonUI/browse/src/creation/CreateAction.js b/platform/commonUI/browse/src/creation/CreateAction.js index f21c7a8ba0..73132a7a5c 100644 --- a/platform/commonUI/browse/src/creation/CreateAction.js +++ b/platform/commonUI/browse/src/creation/CreateAction.js @@ -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. diff --git a/platform/commonUI/browse/src/creation/CreateActionProvider.js b/platform/commonUI/browse/src/creation/CreateActionProvider.js index 56c4396fdf..576e088947 100644 --- a/platform/commonUI/browse/src/creation/CreateActionProvider.js +++ b/platform/commonUI/browse/src/creation/CreateActionProvider.js @@ -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 ); }); } diff --git a/platform/commonUI/browse/src/creation/CreateWizard.js b/platform/commonUI/browse/src/creation/CreateWizard.js index b2986e5e06..8d19244e68 100644 --- a/platform/commonUI/browse/src/creation/CreateWizard.js +++ b/platform/commonUI/browse/src/creation/CreateWizard.js @@ -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" }]}); diff --git a/platform/commonUI/browse/src/creation/LocatorController.js b/platform/commonUI/browse/src/creation/LocatorController.js index 5b2ea72e35..bd727739c8 100644 --- a/platform/commonUI/browse/src/creation/LocatorController.js +++ b/platform/commonUI/browse/src/creation/LocatorController.js @@ -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 diff --git a/platform/commonUI/browse/test/creation/CreateWizardSpec.js b/platform/commonUI/browse/test/creation/CreateWizardSpec.js index fc46f2904b..6b21c5ae72 100644 --- a/platform/commonUI/browse/test/creation/CreateWizardSpec.js +++ b/platform/commonUI/browse/test/creation/CreateWizardSpec.js @@ -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 + ); + }); + }); } diff --git a/platform/commonUI/browse/test/creation/LocatorControllerSpec.js b/platform/commonUI/browse/test/creation/LocatorControllerSpec.js index 605e98f1dc..a39f899477 100644 --- a/platform/commonUI/browse/test/creation/LocatorControllerSpec.js +++ b/platform/commonUI/browse/test/creation/LocatorControllerSpec.js @@ -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); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/dialog/README.md b/platform/commonUI/dialog/README.md new file mode 100644 index 0000000000..a56fe0bb4a --- /dev/null +++ b/platform/commonUI/dialog/README.md @@ -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. \ No newline at end of file diff --git a/platform/commonUI/dialog/bundle.json b/platform/commonUI/dialog/bundle.json index ae1c89cc05..9a2d541419 100644 --- a/platform/commonUI/dialog/bundle.json +++ b/platform/commonUI/dialog/bundle.json @@ -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" diff --git a/platform/commonUI/dialog/res/templates/overlay-options.html b/platform/commonUI/dialog/res/templates/overlay-options.html new file mode 100644 index 0000000000..6c0b51e991 --- /dev/null +++ b/platform/commonUI/dialog/res/templates/overlay-options.html @@ -0,0 +1,24 @@ + +
+
{{ngModel.dialog.title}}
+
{{ngModel.dialog.hint}}
+
+
+
+ + +
+
+
+ + {{option.name}} + +
+
\ No newline at end of file diff --git a/platform/commonUI/dialog/src/DialogService.js b/platform/commonUI/dialog/src/DialogService.js index 344a407b94..147666765d 100644 --- a/platform/commonUI/dialog/src/DialogService.js +++ b/platform/commonUI/dialog/src/DialogService.js @@ -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 }; } diff --git a/platform/commonUI/dialog/test/DialogServiceSpec.js b/platform/commonUI/dialog/test/DialogServiceSpec.js index 9f3635cd9e..7df54b6e8c 100644 --- a/platform/commonUI/dialog/test/DialogServiceSpec.js +++ b/platform/commonUI/dialog/test/DialogServiceSpec.js @@ -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) + } + ); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/edit/README.md b/platform/commonUI/edit/README.md index 7ddf90b699..a1e88d330c 100644 --- a/platform/commonUI/edit/README.md +++ b/platform/commonUI/edit/README.md @@ -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` diff --git a/platform/commonUI/edit/bundle.json b/platform/commonUI/edit/bundle.json index eccaeaf787..fbfd44b42a 100644 --- a/platform/commonUI/edit/bundle.json +++ b/platform/commonUI/edit/bundle.json @@ -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", diff --git a/platform/commonUI/edit/res/templates/edit.html b/platform/commonUI/edit/res/templates/edit.html index ed42b2dcf2..56f8a0b30e 100644 --- a/platform/commonUI/edit/res/templates/edit.html +++ b/platform/commonUI/edit/res/templates/edit.html @@ -1,8 +1,9 @@
+ ng-controller="EditController as editMode" + mct-before-unload="editMode.getUnloadWarning()"> - + diff --git a/platform/commonUI/edit/src/actions/LinkAction.js b/platform/commonUI/edit/src/actions/LinkAction.js new file mode 100644 index 0000000000..79d772738d --- /dev/null +++ b/platform/commonUI/edit/src/actions/LinkAction.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/src/actions/SaveAction.js b/platform/commonUI/edit/src/actions/SaveAction.js index 3b79f74614..22da4b0751 100644 --- a/platform/commonUI/edit/src/actions/SaveAction.js +++ b/platform/commonUI/edit/src/actions/SaveAction.js @@ -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); } }; } diff --git a/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js b/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js index 8d39b61992..04eb83d7c6 100644 --- a/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js +++ b/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js @@ -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; } diff --git a/platform/commonUI/edit/src/capabilities/EditorCapability.js b/platform/commonUI/edit/src/capabilities/EditorCapability.js index 5ac88d0b68..e7ae35ec01 100644 --- a/platform/commonUI/edit/src/capabilities/EditorCapability.js +++ b/platform/commonUI/edit/src/capabilities/EditorCapability.js @@ -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(); } }; }; diff --git a/platform/commonUI/edit/src/controllers/EditController.js b/platform/commonUI/edit/src/controllers/EditController.js index cf07797429..c91bd2cb3e 100644 --- a/platform/commonUI/edit/src/controllers/EditController.js +++ b/platform/commonUI/edit/src/controllers/EditController.js @@ -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; diff --git a/platform/commonUI/edit/src/directives/MCTBeforeUnload.js b/platform/commonUI/edit/src/directives/MCTBeforeUnload.js new file mode 100644 index 0000000000..fad91bcdc6 --- /dev/null +++ b/platform/commonUI/edit/src/directives/MCTBeforeUnload.js @@ -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; + + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/src/objects/EditableDomainObject.js b/platform/commonUI/edit/src/objects/EditableDomainObject.js index 4e3363c8e9..a7a4e7be3d 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObject.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObject.js @@ -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); } diff --git a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js index 3509b9675a..53faaa7ed5 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js @@ -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; } }; } diff --git a/platform/commonUI/edit/src/policies/EditActionPolicy.js b/platform/commonUI/edit/src/policies/EditActionPolicy.js new file mode 100644 index 0000000000..7467a01e63 --- /dev/null +++ b/platform/commonUI/edit/src/policies/EditActionPolicy.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/src/policies/EditableViewPolicy.js b/platform/commonUI/edit/src/policies/EditableViewPolicy.js new file mode 100644 index 0000000000..283ad4f485 --- /dev/null +++ b/platform/commonUI/edit/src/policies/EditableViewPolicy.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/actions/LinkActionSpec.js b/platform/commonUI/edit/test/actions/LinkActionSpec.js new file mode 100644 index 0000000000..ae1985911b --- /dev/null +++ b/platform/commonUI/edit/test/actions/LinkActionSpec.js @@ -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(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js b/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js index 327c49bdc1..af89bee75f 100644 --- a/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js +++ b/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js @@ -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(); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js b/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js index 041f5c6734..72d1cb9750 100644 --- a/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js +++ b/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js @@ -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); diff --git a/platform/commonUI/edit/test/controllers/EditControllerSpec.js b/platform/commonUI/edit/test/controllers/EditControllerSpec.js index f945c499b8..09bd4d35cc 100644 --- a/platform/commonUI/edit/test/controllers/EditControllerSpec.js +++ b/platform/commonUI/edit/test/controllers/EditControllerSpec.js @@ -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)); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/edit/test/directives/MCTBeforeUnloadSpec.js b/platform/commonUI/edit/test/directives/MCTBeforeUnloadSpec.js new file mode 100644 index 0000000000..e26652444e --- /dev/null +++ b/platform/commonUI/edit/test/directives/MCTBeforeUnloadSpec.js @@ -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(); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js b/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js index 5e71368553..1c0d372119 100644 --- a/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js +++ b/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js @@ -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); + }); + }); } diff --git a/platform/commonUI/edit/test/policies/EditActionPolicySpec.js b/platform/commonUI/edit/test/policies/EditActionPolicySpec.js new file mode 100644 index 0000000000..1a2be2bad1 --- /dev/null +++ b/platform/commonUI/edit/test/policies/EditActionPolicySpec.js @@ -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(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/policies/EditableViewPolicySpec.js b/platform/commonUI/edit/test/policies/EditableViewPolicySpec.js new file mode 100644 index 0000000000..b4a4730125 --- /dev/null +++ b/platform/commonUI/edit/test/policies/EditableViewPolicySpec.js @@ -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(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/suite.json b/platform/commonUI/edit/test/suite.json index 49fffec4a7..5e3a909c25 100644 --- a/platform/commonUI/edit/test/suite.json +++ b/platform/commonUI/edit/test/suite.json @@ -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", diff --git a/platform/commonUI/general/res/css/theme-espresso.css b/platform/commonUI/general/res/css/theme-espresso.css index b4494bc29a..e810f9d517 100644 --- a/platform/commonUI/general/res/css/theme-espresso.css +++ b/platform/commonUI/general/res/css/theme-espresso.css @@ -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; } diff --git a/platform/commonUI/general/res/sass/_main.scss b/platform/commonUI/general/res/sass/_main.scss index 20c08d4312..fb21912d56 100755 --- a/platform/commonUI/general/res/sass/_main.scss +++ b/platform/commonUI/general/res/sass/_main.scss @@ -37,4 +37,5 @@ @import "helpers/bubbles"; @import "helpers/splitter"; @import "helpers/wait-spinner"; +@import "properties"; @import "autoflow"; diff --git a/platform/commonUI/general/res/sass/_properties.scss b/platform/commonUI/general/res/sass/_properties.scss new file mode 100644 index 0000000000..eb9cb3b23b --- /dev/null +++ b/platform/commonUI/general/res/sass/_properties.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/user-environ/_layout.scss b/platform/commonUI/general/res/sass/user-environ/_layout.scss index 8feab2cb7e..810a0c412e 100644 --- a/platform/commonUI/general/res/sass/user-environ/_layout.scss +++ b/platform/commonUI/general/res/sass/user-environ/_layout.scss @@ -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 { diff --git a/platform/containment/README.md b/platform/containment/README.md new file mode 100644 index 0000000000..aceef4d963 --- /dev/null +++ b/platform/containment/README.md @@ -0,0 +1,2 @@ +Implements support for rules which determine which objects are allowed +to contain other objects, typically by type. \ No newline at end of file diff --git a/platform/containment/bundle.json b/platform/containment/bundle.json new file mode 100644 index 0000000000..a31eb60c4a --- /dev/null +++ b/platform/containment/bundle.json @@ -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." + } + ] + } +} \ No newline at end of file diff --git a/platform/containment/src/CapabilityTable.js b/platform/containment/src/CapabilityTable.js new file mode 100644 index 0000000000..7b33ea681f --- /dev/null +++ b/platform/containment/src/CapabilityTable.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/containment/src/ComposeActionPolicy.js b/platform/containment/src/ComposeActionPolicy.js new file mode 100644 index 0000000000..50c36ea0c5 --- /dev/null +++ b/platform/containment/src/ComposeActionPolicy.js @@ -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; + + } +); \ No newline at end of file diff --git a/platform/containment/src/CompositionPolicy.js b/platform/containment/src/CompositionPolicy.js new file mode 100644 index 0000000000..4e8b358627 --- /dev/null +++ b/platform/containment/src/CompositionPolicy.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/containment/src/ContainmentTable.js b/platform/containment/src/ContainmentTable.js new file mode 100644 index 0000000000..b408fe677f --- /dev/null +++ b/platform/containment/src/ContainmentTable.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/containment/test/CapabilityTableSpec.js b/platform/containment/test/CapabilityTableSpec.js new file mode 100644 index 0000000000..5f6612dce4 --- /dev/null +++ b/platform/containment/test/CapabilityTableSpec.js @@ -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(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/containment/test/ComposeActionPolicySpec.js b/platform/containment/test/ComposeActionPolicySpec.js new file mode 100644 index 0000000000..15a15ebad3 --- /dev/null +++ b/platform/containment/test/ComposeActionPolicySpec.js @@ -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(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/containment/test/CompositionPolicySpec.js b/platform/containment/test/CompositionPolicySpec.js new file mode 100644 index 0000000000..bb9720d441 --- /dev/null +++ b/platform/containment/test/CompositionPolicySpec.js @@ -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(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/containment/test/ContainmentTableSpec.js b/platform/containment/test/ContainmentTableSpec.js new file mode 100644 index 0000000000..209a5c97fc --- /dev/null +++ b/platform/containment/test/ContainmentTableSpec.js @@ -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(); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/containment/test/suite.json b/platform/containment/test/suite.json new file mode 100644 index 0000000000..a82d203c53 --- /dev/null +++ b/platform/containment/test/suite.json @@ -0,0 +1,6 @@ +[ + "CapabilityTable", + "ComposeActionPolicy", + "CompositionPolicy", + "ContainmentTable" +] \ No newline at end of file diff --git a/platform/core/bundle.json b/platform/core/bundle.json index 46f4b81f72..5a2727eb75 100644 --- a/platform/core/bundle.json +++ b/platform/core/bundle.json @@ -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", diff --git a/platform/core/src/actions/ActionProvider.js b/platform/core/src/actions/ActionProvider.js index 995dd68bc3..16976b51c8 100644 --- a/platform/core/src/actions/ActionProvider.js +++ b/platform/core/src/actions/ActionProvider.js @@ -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] || []; diff --git a/platform/core/src/capabilities/MutationCapability.js b/platform/core/src/capabilities/MutationCapability.js index 9a36d60180..2e8912c531 100644 --- a/platform/core/src/capabilities/MutationCapability.js +++ b/platform/core/src/capabilities/MutationCapability.js @@ -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.} a promise for the result * of the mutation; true if changes were made. */ diff --git a/platform/core/src/capabilities/PersistenceCapability.js b/platform/core/src/capabilities/PersistenceCapability.js index d26426d82a..bc9479802c 100644 --- a/platform/core/src/capabilities/PersistenceCapability.js +++ b/platform/core/src/capabilities/PersistenceCapability.js @@ -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 diff --git a/platform/core/src/models/CachingModelDecorator.js b/platform/core/src/models/CachingModelDecorator.js new file mode 100644 index 0000000000..a33e1f0647 --- /dev/null +++ b/platform/core/src/models/CachingModelDecorator.js @@ -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} ids the string identifiers for + * models of interest. + * @returns {Promise} 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; + } +); \ No newline at end of file diff --git a/platform/core/src/models/ModelAggregator.js b/platform/core/src/models/ModelAggregator.js index 933674f83a..d02192830e 100644 --- a/platform/core/src/models/ModelAggregator.js +++ b/platform/core/src/models/ModelAggregator.js @@ -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]); } }); }); diff --git a/platform/core/test/capabilities/MutationCapabilitySpec.js b/platform/core/test/capabilities/MutationCapabilitySpec.js index 83536347f3..55a5ea7957 100644 --- a/platform/core/test/capabilities/MutationCapabilitySpec.js +++ b/platform/core/test/capabilities/MutationCapabilitySpec.js @@ -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); + }); }); } ); \ No newline at end of file diff --git a/platform/core/test/capabilities/PersistenceCapabilitySpec.js b/platform/core/test/capabilities/PersistenceCapabilitySpec.js index a758745b9d..d5be5bf659 100644 --- a/platform/core/test/capabilities/PersistenceCapabilitySpec.js +++ b/platform/core/test/capabilities/PersistenceCapabilitySpec.js @@ -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); + }); + }); } ); \ No newline at end of file diff --git a/platform/core/test/models/CachingModelDecoratorSpec.js b/platform/core/test/models/CachingModelDecoratorSpec.js new file mode 100644 index 0000000000..6095c0aaed --- /dev/null +++ b/platform/core/test/models/CachingModelDecoratorSpec.js @@ -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" }); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/core/test/models/ModelAggregatorSpec.js b/platform/core/test/models/ModelAggregatorSpec.js index d01aeb022d..ff8e8ebf43 100644 --- a/platform/core/test/models/ModelAggregatorSpec.js +++ b/platform/core/test/models/ModelAggregatorSpec.js @@ -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; diff --git a/platform/core/test/suite.json b/platform/core/test/suite.json index 68990a191e..36f3e81980 100644 --- a/platform/core/test/suite.json +++ b/platform/core/test/suite.json @@ -17,6 +17,7 @@ "models/PersistedModelProvider", "models/RootModelProvider", "models/StaticModelProvider", + "models/CachingModelDecorator", "objects/DomainObject", "objects/DomainObjectProvider", diff --git a/platform/features/layout/bundle.json b/platform/features/layout/bundle.json index f5d753c7ac..0df6413ed0 100644 --- a/platform/features/layout/bundle.json +++ b/platform/features/layout/bundle.json @@ -231,6 +231,7 @@ "description": "A panel for collecting telemetry elements.", "delegates": [ "telemetry" ], "features": "creation", + "contains": [ { "has": "telemetry" } ], "model": { "composition": [] }, "properties": [ { diff --git a/platform/features/plot/src/Canvas2DChart.js b/platform/features/plot/src/Canvas2DChart.js new file mode 100644 index 0000000000..d3d344d56a --- /dev/null +++ b/platform/features/plot/src/Canvas2DChart.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/features/plot/src/MCTChart.js b/platform/features/plot/src/MCTChart.js index 43ebaa891f..da044fe39f 100644 --- a/platform/features/plot/src/MCTChart.js +++ b/platform/features/plot/src/MCTChart.js @@ -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 = ""; @@ -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); diff --git a/platform/features/plot/test/Canvas2DChartSpec.js b/platform/features/plot/test/Canvas2DChartSpec.js new file mode 100644 index 0000000000..4dd1da81ec --- /dev/null +++ b/platform/features/plot/test/Canvas2DChartSpec.js @@ -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(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/plot/test/suite.json b/platform/features/plot/test/suite.json index 2df3badfef..92ee3b07c8 100644 --- a/platform/features/plot/test/suite.json +++ b/platform/features/plot/test/suite.json @@ -1,4 +1,5 @@ [ + "Canvas2DChart", "GLChart", "MCTChart", "PlotController", diff --git a/platform/forms/src/MCTControl.js b/platform/forms/src/MCTControl.js index 2197ac4424..bde07e118b 100644 --- a/platform/forms/src/MCTControl.js +++ b/platform/forms/src/MCTControl.js @@ -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: '', @@ -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 diff --git a/platform/forms/test/MCTControlSpec.js b/platform/forms/test/MCTControlSpec.js index 448892758a..32495e78be 100644 --- a/platform/forms/test/MCTControlSpec.js +++ b/platform/forms/test/MCTControlSpec.js @@ -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"); diff --git a/platform/persistence/cache/src/CachingPersistenceDecorator.js b/platform/persistence/cache/src/CachingPersistenceDecorator.js index 06e784d928..f495c03ca9 100644 --- a/platform/persistence/cache/src/CachingPersistenceDecorator.js +++ b/platform/persistence/cache/src/CachingPersistenceDecorator.js @@ -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 diff --git a/platform/persistence/elastic/README.md b/platform/persistence/elastic/README.md new file mode 100644 index 0000000000..2874386784 --- /dev/null +++ b/platform/persistence/elastic/README.md @@ -0,0 +1,2 @@ +This bundle implements a connection to an external ElasticSearch persistence +store in Open MCT Web. diff --git a/platform/persistence/elastic/bundle.json b/platform/persistence/elastic/bundle.json new file mode 100644 index 0000000000..53f8571e1a --- /dev/null +++ b/platform/persistence/elastic/bundle.json @@ -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" + ] + } + ] + } +} \ No newline at end of file diff --git a/platform/persistence/elastic/src/ElasticIndicator.js b/platform/persistence/elastic/src/ElasticIndicator.js new file mode 100644 index 0000000000..a1653a8093 --- /dev/null +++ b/platform/persistence/elastic/src/ElasticIndicator.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/src/ElasticPersistenceProvider.js b/platform/persistence/elastic/src/ElasticPersistenceProvider.js new file mode 100644 index 0000000000..9b8651d7f2 --- /dev/null +++ b/platform/persistence/elastic/src/ElasticPersistenceProvider.js @@ -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.} 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.} 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.} 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.} 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.} 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.} 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; + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/test/ElasticIndicatorSpec.js b/platform/persistence/elastic/test/ElasticIndicatorSpec.js new file mode 100644 index 0000000000..1285020f08 --- /dev/null +++ b/platform/persistence/elastic/test/ElasticIndicatorSpec.js @@ -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"); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/test/ElasticPersistenceProviderSpec.js b/platform/persistence/elastic/test/ElasticPersistenceProviderSpec.js new file mode 100644 index 0000000000..a474f74c8a --- /dev/null +++ b/platform/persistence/elastic/test/ElasticPersistenceProviderSpec.js @@ -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(); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/test/suite.json b/platform/persistence/elastic/test/suite.json new file mode 100644 index 0000000000..cc8dc2ce0c --- /dev/null +++ b/platform/persistence/elastic/test/suite.json @@ -0,0 +1,4 @@ +[ + "ElasticIndicator", + "ElasticPersistenceProvider" +] diff --git a/platform/persistence/queue/README.md b/platform/persistence/queue/README.md new file mode 100644 index 0000000000..ffd8db4d3e --- /dev/null +++ b/platform/persistence/queue/README.md @@ -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. \ No newline at end of file diff --git a/platform/persistence/queue/bundle.json b/platform/persistence/queue/bundle.json new file mode 100644 index 0000000000..c67e241e35 --- /dev/null +++ b/platform/persistence/queue/bundle.json @@ -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" + } + ] + } +} \ No newline at end of file diff --git a/platform/persistence/queue/res/templates/persistence-failure-dialog.html b/platform/persistence/queue/res/templates/persistence-failure-dialog.html new file mode 100644 index 0000000000..ef2423a00c --- /dev/null +++ b/platform/persistence/queue/res/templates/persistence-failure-dialog.html @@ -0,0 +1,31 @@ + + +
+ External changes have been made to the following objects: +
    +
  • + + + was modified at + {{controller.formatTimestamp(failure.error.model.modified)}} + by + {{controller.formatUsername(failure.error.model.modifier)}} +
  • +
+ You may overwrite these objects, or discard your changes to keep + the updates that were made externally. +
+ +
+ Changes to these objects could not be saved for unknown reasons: +
    +
  • + + +
  • +
+
+ +
\ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureConstants.js b/platform/persistence/queue/src/PersistenceFailureConstants.js new file mode 100644 index 0000000000..a130a3b14b --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureConstants.js @@ -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" +}); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureController.js b/platform/persistence/queue/src/PersistenceFailureController.js new file mode 100644 index 0000000000..551e759c22 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureController.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureDialog.js b/platform/persistence/queue/src/PersistenceFailureDialog.js new file mode 100644 index 0000000000..2840bd1599 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureDialog.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureHandler.js b/platform/persistence/queue/src/PersistenceFailureHandler.js new file mode 100644 index 0000000000..ac5d5a0eca --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureHandler.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceQueue.js b/platform/persistence/queue/src/PersistenceQueue.js new file mode 100644 index 0000000000..13a20479c3 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceQueue.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceQueueHandler.js b/platform/persistence/queue/src/PersistenceQueueHandler.js new file mode 100644 index 0000000000..d56f04b686 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceQueueHandler.js @@ -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.} persistences + * capabilities to invoke, in id->capability pairs. + * @param {Object.} 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; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceQueueImpl.js b/platform/persistence/queue/src/PersistenceQueueImpl.js new file mode 100644 index 0000000000..fdc68e6725 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceQueueImpl.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/QueuingPersistenceCapability.js b/platform/persistence/queue/src/QueuingPersistenceCapability.js new file mode 100644 index 0000000000..a4d94a4acc --- /dev/null +++ b/platform/persistence/queue/src/QueuingPersistenceCapability.js @@ -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; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/QueuingPersistenceCapabilityDecorator.js b/platform/persistence/queue/src/QueuingPersistenceCapabilityDecorator.js new file mode 100644 index 0000000000..40d0f0c470 --- /dev/null +++ b/platform/persistence/queue/src/QueuingPersistenceCapabilityDecorator.js @@ -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.} all + * capabilities known to be valid for this model, as + * key-value pairs + */ + getCapabilities: getCapabilities + }; + } + + return QueuingPersistenceCapabilityDecorator; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureConstantsSpec.js b/platform/persistence/queue/test/PersistenceFailureConstantsSpec.js new file mode 100644 index 0000000000..475880facd --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureConstantsSpec.js @@ -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)); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureControllerSpec.js b/platform/persistence/queue/test/PersistenceFailureControllerSpec.js new file mode 100644 index 0000000000..6592d880e3 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureControllerSpec.js @@ -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)); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureDialogSpec.js b/platform/persistence/queue/test/PersistenceFailureDialogSpec.js new file mode 100644 index 0000000000..9de400be79 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureDialogSpec.js @@ -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); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureHandlerSpec.js b/platform/persistence/queue/test/PersistenceFailureHandlerSpec.js new file mode 100644 index 0000000000..2dd910619f --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureHandlerSpec.js @@ -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(); + }); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceQueueHandlerSpec.js b/platform/persistence/queue/test/PersistenceQueueHandlerSpec.js new file mode 100644 index 0000000000..8a975b1273 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceQueueHandlerSpec.js @@ -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 + ); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceQueueImplSpec.js b/platform/persistence/queue/test/PersistenceQueueImplSpec.js new file mode 100644 index 0000000000..8bc4687ea5 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceQueueImplSpec.js @@ -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); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceQueueSpec.js b/platform/persistence/queue/test/PersistenceQueueSpec.js new file mode 100644 index 0000000000..8f05b36a06 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceQueueSpec.js @@ -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)); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/QueuingPersistenceCapabilityDecoratorSpec.js b/platform/persistence/queue/test/QueuingPersistenceCapabilityDecoratorSpec.js new file mode 100644 index 0000000000..cd1ef01f5f --- /dev/null +++ b/platform/persistence/queue/test/QueuingPersistenceCapabilityDecoratorSpec.js @@ -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); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/QueuingPersistenceCapabilitySpec.js b/platform/persistence/queue/test/QueuingPersistenceCapabilitySpec.js new file mode 100644 index 0000000000..e383bd77ba --- /dev/null +++ b/platform/persistence/queue/test/QueuingPersistenceCapabilitySpec.js @@ -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); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/suite.json b/platform/persistence/queue/test/suite.json new file mode 100644 index 0000000000..3c32be4155 --- /dev/null +++ b/platform/persistence/queue/test/suite.json @@ -0,0 +1,11 @@ +[ + "PersistenceFailureConstants", + "PersistenceFailureController", + "PersistenceFailureDialog", + "PersistenceFailureHandler", + "PersistenceQueue", + "PersistenceQueueHandler", + "PersistenceQueueImpl", + "QueuingPersistenceCapability", + "QueuingPersistenceCapabilityDecorator" +] \ No newline at end of file diff --git a/platform/policy/README.md b/platform/policy/README.md new file mode 100644 index 0000000000..a35a9a8d3e --- /dev/null +++ b/platform/policy/README.md @@ -0,0 +1,93 @@ +# Overview + +This bundle provides support for policy in Open MCT Web. Policy can be +used to limit the applicability of certain actions, or more broadly, +to provide an extension point for arbitrary decisions. + +# Services + +This bundle introduces the `policyService`, which may be consulted for +various decisions which are intended to be open for extension. + +The `policyService` has a single method, `allow`, which takes three +arguments and returns a boolean value (true if policy says this decision +should be allowed, false if not): + +* `category`: A string identifying which kind of decision is being made. + Typically, this will be a non-plural form of an extension type that is + being filtered down; for instance, to check whether or not a given + action should be returned by an `actionService`, one would use the + `action` category of extension. +* `candidate`: An object representing the thing which shall or shall not + be allowed. Usually, this will be an instance of an extension of the + category defined above. + * This does need to be the case; additional + policies which are not specific to any extension may also be defined + and consulted using unique `category` identifiers. In this case, the + type of the object delivered for the candidate may be unique to the + policy type. +* `context`: An object representing the context in which the decision is + occurring. Its contents are specific to each policy category. +* `callback`: Optional; a function to call if the policy decision is + rejected. This function will be called with the `message` string + (which may be undefined) of whichever individual policy caused the + operation to fail. + +_Design rationale_: Returning a boolean here limits the amount of +information that can be conveyed by a policy decision, but has the +benefit of simplicity. In MCT on the desktop, the policy service +returned a more complex object with both a boolean status and a string +message; the string message was used rarely (by only around 15% of +policy user code) and as such is made optional in the call itself here. + +_Design rationale_: Returning a boolean instead of a promise here implies +that policy decisions must occur synchronously. This limits the logic +which can be involved in a policy decision, but broadens its applicability; +policy is meant to be used by a variety of other services to separate out +a certain category of business logic, and a synchronous response means +that this capability may be utilized by both synchronous and asynchronous +services. Additionally, policies will often be used in loops (e.g. to filter +down a set of applicable actions) where latency will have the result of +harming the user experience (e.g. the user right-clicks and gets stuck +waiting for a bunch of policy decisions to complete before a menu showing +available actions can appear.) + +The `policyService` is a composite service; it may be modified by adding +decorators, aggregators, etc. + +## Service Components + +The policy service is most often used by decorators for other composite +services. For instance, this bundle contains a decorator for `actionService` +which filters down the applicable actions exposed by that service based +on policy. + +# Policy Categories + +This bundle introduces `action` as a policy category. Policies of this +category shall take action instances as their candidate argument, and +action contexts as their context argument. + +# Extensions + +This bundle introduces the `policies` category of extension. An extension +of this category should have both an implementation, as well as the following +metadata: + +* `category`: A string identifying which kind of policy decision this + effects. +* `message`: Optional; a human-readable string describing the policy + decision when it fails. + +An extension of this category must also have an implementation which +takes no arguments to its constructor and provides a single method, +`allow`, which takes two arguments, `candidate` and `context` (see +descriptions above under documentation for `actionService`) and returns +a boolean indicating whether or not it allows the policy decision. + +Policy decisions require consensus among all policies; that is, if a +single policy returns false, then the policy decision as a whole returns +false. As a consequence, policies should be written in a permissive +manner; that is, they should be designed to prohibit behavior under a +specific set of conditions (by returning false), and allow any behavior +which does not match those conditions (by returning true.) \ No newline at end of file diff --git a/platform/policy/bundle.json b/platform/policy/bundle.json new file mode 100644 index 0000000000..0f27b51136 --- /dev/null +++ b/platform/policy/bundle.json @@ -0,0 +1,27 @@ +{ + "name": "Policy Service", + "description": "Provides support for extension-driven decisions.", + "sources": "src", + "extensions": { + "components": [ + { + "type": "decorator", + "provides": "actionService", + "implementation": "PolicyActionDecorator.js", + "depends": [ "policyService" ] + }, + { + "type": "decorator", + "provides": "viewService", + "implementation": "PolicyViewDecorator.js", + "depends": [ "policyService" ] + }, + { + "type": "provider", + "provides": "policyService", + "implementation": "PolicyProvider.js", + "depends": [ "policies[]" ] + } + ] + } +} \ No newline at end of file diff --git a/platform/policy/src/PolicyActionDecorator.js b/platform/policy/src/PolicyActionDecorator.js new file mode 100644 index 0000000000..08cadd5423 --- /dev/null +++ b/platform/policy/src/PolicyActionDecorator.js @@ -0,0 +1,37 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Filters out actions based on policy. + * @param {PolicyService} policyService the service which provides + * policy decisions + * @param {ActionService} actionService the service to decorate + */ + function PolicyActionDecorator(policyService, actionService) { + return { + /** + * Get actions which are applicable in this context. + * These will be filtered to remove any actions which + * are deemed inapplicable by policy. + * @param context the context in which the action will occur + * @returns {Action[]} applicable actions + */ + getActions: function (context) { + // Check if an action is allowed by policy. + function allow(action) { + return policyService.allow('action', action, context); + } + + // Look up actions, filter out the disallowed ones. + return actionService.getActions(context).filter(allow); + } + }; + } + + return PolicyActionDecorator; + } +); \ No newline at end of file diff --git a/platform/policy/src/PolicyProvider.js b/platform/policy/src/PolicyProvider.js new file mode 100644 index 0000000000..5e35a58515 --- /dev/null +++ b/platform/policy/src/PolicyProvider.js @@ -0,0 +1,85 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Provides an implementation of `policyService` which consults + * various policy extensions to determine whether or not a specific + * decision should be allowed. + * @constructor + */ + function PolicyProvider(policies) { + var policyMap = {}; + + // Instantiate a policy. Mostly just a constructor call, but + // we also track the message (which was provided as metadata + // along with the constructor) so that we can expose this later. + function instantiate(Policy) { + var policy = Object.create(new Policy()); + policy.message = Policy.message; + return policy; + } + + // Add a specific policy to the map for later lookup, + // according to its category. Note that policy extensions are + // provided as constructors, so they are instantiated here. + function addToMap(Policy) { + var category = (Policy || {}).category; + if (category) { + // Create a new list for that category if needed... + policyMap[category] = policyMap[category] || []; + // ...and put an instance of this policy in that list. + policyMap[category].push(instantiate(Policy)); + } + } + + // Populate the map for subsequent lookup + policies.forEach(addToMap); + + return { + /** + * Check whether or not a certain decision is allowed by + * policy. + * @param {string} category a machine-readable identifier + * for the kind of decision being made + * @param candidate the object about which the decision is + * being made + * @param context the context in which the decision occurs + * @param {Function} [callback] callback to invoke with a + * string message describing the reason a decision + * was disallowed (if its disallowed) + * @returns {boolean} true if the decision is allowed, + * otherwise false. + */ + allow: function (category, candidate, context, callback) { + var policyList = policyMap[category] || [], + i; + + // Iterate through policies. We do this instead of map or + // forEach so that we can return immediately if a policy + // chooses to disallow this decision. + for (i = 0; i < policyList.length; i += 1) { + // Consult the policy... + if (!policyList[i].allow(candidate, context)) { + // ...it disallowed, so pass its message to + // the callback (if any) + if (callback) { + callback(policyList[i].message); + } + // And return the failed result. + return false; + } + } + + // No policy disallowed this decision. + return true; + } + }; + } + + return PolicyProvider; + } +); \ No newline at end of file diff --git a/platform/policy/src/PolicyViewDecorator.js b/platform/policy/src/PolicyViewDecorator.js new file mode 100644 index 0000000000..e236ba6066 --- /dev/null +++ b/platform/policy/src/PolicyViewDecorator.js @@ -0,0 +1,37 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Filters out views based on policy. + * @param {PolicyService} policyService the service which provides + * policy decisions + * @param {ViewService} viewService the service to decorate + */ + function PolicyActionDecorator(policyService, viewService) { + return { + /** + * Get views which are applicable to this domain object. + * These will be filtered to remove any views which + * are deemed inapplicable by policy. + * @param {DomainObject} the domain object to view + * @returns {View[]} applicable views + */ + getViews: function (domainObject) { + // Check if an action is allowed by policy. + function allow(view) { + return policyService.allow('view', view, domainObject); + } + + // Look up actions, filter out the disallowed ones. + return viewService.getViews(domainObject).filter(allow); + } + }; + } + + return PolicyActionDecorator; + } +); \ No newline at end of file diff --git a/platform/policy/test/PolicyActionDecoratorSpec.js b/platform/policy/test/PolicyActionDecoratorSpec.js new file mode 100644 index 0000000000..d372a1a110 --- /dev/null +++ b/platform/policy/test/PolicyActionDecoratorSpec.js @@ -0,0 +1,79 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/PolicyActionDecorator"], + function (PolicyActionDecorator) { + "use strict"; + + describe("The policy action decorator", function () { + var mockPolicyService, + mockActionService, + testContext, + testActions, + decorator; + + beforeEach(function () { + mockPolicyService = jasmine.createSpyObj( + 'policyService', + ['allow'] + ); + mockActionService = jasmine.createSpyObj( + 'actionService', + ['getActions'] + ); + + // Content of actions should be irrelevant to this + // decorator, so just give it some objects to pass + // around. + testActions = [ + { someKey: "a" }, + { someKey: "b" }, + { someKey: "c" } + ]; + testContext = { someKey: "some value" }; + + mockActionService.getActions.andReturn(testActions); + mockPolicyService.allow.andReturn(true); + + decorator = new PolicyActionDecorator( + mockPolicyService, + mockActionService + ); + }); + + it("delegates to its decorated action service", function () { + decorator.getActions(testContext); + expect(mockActionService.getActions) + .toHaveBeenCalledWith(testContext); + }); + + it("provides actions from its decorated action service", function () { + // Mock policy service allows everything by default, + // so everything should be returned + expect(decorator.getActions(testContext)) + .toEqual(testActions); + }); + + it("consults the policy service for each candidate action", function () { + decorator.getActions(testContext); + testActions.forEach(function (testAction) { + expect(mockPolicyService.allow).toHaveBeenCalledWith( + 'action', + testAction, + testContext + ); + }); + }); + + it("filters out policy-disallowed actions", function () { + // Disallow the second action + mockPolicyService.allow.andCallFake(function (cat, candidate, ctxt) { + return candidate.someKey !== 'b'; + }); + expect(decorator.getActions(testContext)) + .toEqual([ testActions[0], testActions[2] ]); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/policy/test/PolicyProviderSpec.js b/platform/policy/test/PolicyProviderSpec.js new file mode 100644 index 0000000000..71f3c67012 --- /dev/null +++ b/platform/policy/test/PolicyProviderSpec.js @@ -0,0 +1,85 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/PolicyProvider"], + function (PolicyProvider) { + "use strict"; + + describe("The policy provider", function () { + var testPolicies, + mockPolicies, + mockPolicyConstructors, + testCandidate, + testContext, + provider; + + beforeEach(function () { + testPolicies = [ + { category: "a", message: "some message", result: true }, + { category: "a", result: true }, + { category: "a", result: true }, + { category: "b", message: "some message", result: true }, + { category: "b", result: true }, + { category: "b", result: true } + ]; + mockPolicies = testPolicies.map(function (p) { + var mockPolicy = jasmine.createSpyObj("policy", ['allow']); + mockPolicy.allow.andCallFake(function () { return p.result; }); + return mockPolicy; + }); + mockPolicyConstructors = testPolicies.map(function (p, i) { + var mockPolicyConstructor = jasmine.createSpy(); + mockPolicyConstructor.andReturn(mockPolicies[i]); + mockPolicyConstructor.message = p.message; + mockPolicyConstructor.category = p.category; + return mockPolicyConstructor; + }); + + testCandidate = { someKey: "some value" }; + testContext = { someOtherKey: "some other value" }; + + provider = new PolicyProvider(mockPolicyConstructors); + }); + + it("has an allow method", function () { + expect(provider.allow).toEqual(jasmine.any(Function)); + }); + + it("consults all relevant policies", function () { + provider.allow("a", testCandidate, testContext); + expect(mockPolicies[0].allow) + .toHaveBeenCalledWith(testCandidate, testContext); + expect(mockPolicies[1].allow) + .toHaveBeenCalledWith(testCandidate, testContext); + expect(mockPolicies[2].allow) + .toHaveBeenCalledWith(testCandidate, testContext); + expect(mockPolicies[3].allow) + .not.toHaveBeenCalled(); + expect(mockPolicies[4].allow) + .not.toHaveBeenCalled(); + expect(mockPolicies[5].allow) + .not.toHaveBeenCalled(); + }); + + it("allows what all policies allow", function () { + expect(provider.allow("a", testCandidate, testContext)) + .toBeTruthy(); + }); + + it("disallows what any one policy disallows", function () { + testPolicies[1].result = false; + expect(provider.allow("a", testCandidate, testContext)) + .toBeFalsy(); + }); + + it("provides a message for policy failure, when available", function () { + var mockCallback = jasmine.createSpy(); + testPolicies[0].result = false; + expect(provider.allow("a", testCandidate, testContext, mockCallback)) + .toBeFalsy(); + expect(mockCallback).toHaveBeenCalledWith(testPolicies[0].message); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/policy/test/PolicyViewDecoratorSpec.js b/platform/policy/test/PolicyViewDecoratorSpec.js new file mode 100644 index 0000000000..3931a23507 --- /dev/null +++ b/platform/policy/test/PolicyViewDecoratorSpec.js @@ -0,0 +1,83 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/PolicyViewDecorator"], + function (PolicyViewDecorator) { + "use strict"; + + describe("The policy view decorator", function () { + var mockPolicyService, + mockViewService, + mockDomainObject, + testViews, + decorator; + + beforeEach(function () { + mockPolicyService = jasmine.createSpyObj( + 'policyService', + ['allow'] + ); + mockViewService = jasmine.createSpyObj( + 'viewService', + ['getViews'] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + ['getId'] + ); + + // Content of actions should be irrelevant to this + // decorator, so just give it some objects to pass + // around. + testViews = [ + { someKey: "a" }, + { someKey: "b" }, + { someKey: "c" } + ]; + + mockDomainObject.getId.andReturn('xyz'); + mockViewService.getViews.andReturn(testViews); + mockPolicyService.allow.andReturn(true); + + decorator = new PolicyViewDecorator( + mockPolicyService, + mockViewService + ); + }); + + it("delegates to its decorated view service", function () { + decorator.getViews(mockDomainObject); + expect(mockViewService.getViews) + .toHaveBeenCalledWith(mockDomainObject); + }); + + it("provides views from its decorated view service", function () { + // Mock policy service allows everything by default, + // so everything should be returned + expect(decorator.getViews(mockDomainObject)) + .toEqual(testViews); + }); + + it("consults the policy service for each candidate view", function () { + decorator.getViews(mockDomainObject); + testViews.forEach(function (testView) { + expect(mockPolicyService.allow).toHaveBeenCalledWith( + 'view', + testView, + mockDomainObject + ); + }); + }); + + it("filters out policy-disallowed views", function () { + // Disallow the second action + mockPolicyService.allow.andCallFake(function (cat, candidate, ctxt) { + return candidate.someKey !== 'b'; + }); + expect(decorator.getViews(mockDomainObject)) + .toEqual([ testViews[0], testViews[2] ]); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/policy/test/suite.json b/platform/policy/test/suite.json new file mode 100644 index 0000000000..c695797527 --- /dev/null +++ b/platform/policy/test/suite.json @@ -0,0 +1,5 @@ +[ + "PolicyActionDecorator", + "PolicyViewDecorator", + "PolicyProvider" +] \ No newline at end of file diff --git a/platform/representation/bundle.json b/platform/representation/bundle.json index 250fc2f730..84120541e0 100644 --- a/platform/representation/bundle.json +++ b/platform/representation/bundle.json @@ -21,7 +21,7 @@ { "key": "drop", "implementation": "gestures/DropGesture.js", - "depends": [ "$q" ] + "depends": [ "dndService", "$q" ] }, { "key": "menu", diff --git a/platform/representation/src/gestures/DropGesture.js b/platform/representation/src/gestures/DropGesture.js index 985fe102d3..2185bf0e21 100644 --- a/platform/representation/src/gestures/DropGesture.js +++ b/platform/representation/src/gestures/DropGesture.js @@ -19,7 +19,10 @@ define( * composition should be modified as a result of the drop. */ - function DropGesture($q, element, domainObject) { + function DropGesture(dndService, $q, element, domainObject) { + var actionCapability = domainObject.getCapability('action'), + action; // Action for the drop, when it occurs + function broadcastDrop(id, event) { // Find the relevant scope... var scope = element && element.scope && element.scope(), @@ -43,20 +46,27 @@ define( } } - function doPersist() { - var persistence = domainObject.getCapability("persistence"); - return $q.when(persistence && persistence.persist()); - } - function dragOver(e) { - var event = (e || {}).originalEvent || e; + var event = (e || {}).originalEvent || e, + selectedObject = dndService.getData( + GestureConstants.MCT_EXTENDED_DRAG_TYPE + ); - // TODO: Vary this based on modifier keys - event.dataTransfer.dropEffect = 'move'; + if (selectedObject) { + // TODO: Vary this based on modifier keys + action = actionCapability.getActions({ + key: 'compose', + selectedObject: selectedObject + })[0]; - // Indicate that we will accept the drag - event.preventDefault(); // Required in Chrome? - return false; + if (action) { + event.dataTransfer.dropEffect = 'move'; + + // Indicate that we will accept the drag + event.preventDefault(); // Required in Chrome? + return false; + } + } } function drop(e) { @@ -67,38 +77,21 @@ define( // destination domain object's composition, and persist // the change. if (id) { - $q.when(domainObject.useCapability( - 'mutation', - function (model) { - var composition = model.composition; - // Don't store the same id more than once - if (composition && // not-contains - !(composition.map(function (i) { - return i === id; - }).reduce(function (a, b) { - return a || b; - }, false))) { - model.composition.push(id); - } - } - )).then(function (result) { - // Broadcast the drop event if it was successful - if (result) { - broadcastDrop(id, event); - } - - // If mutation was successful, persist the change - return result && doPersist(); + $q.when(action && action.perform()).then(function (result) { + broadcastDrop(id, event); }); } } - // Listen for dragover, to indicate we'll accept a drag - element.on('dragover', dragOver); + // We can only handle drops if we have access to actions... + if (actionCapability) { + // Listen for dragover, to indicate we'll accept a drag + element.on('dragover', dragOver); - // Listen for the drop itself - element.on('drop', drop); + // Listen for the drop itself + element.on('drop', drop); + } return { /** diff --git a/platform/representation/test/gestures/DropGestureSpec.js b/platform/representation/test/gestures/DropGestureSpec.js index d24dcf2132..e12f4f89f3 100644 --- a/platform/representation/test/gestures/DropGestureSpec.js +++ b/platform/representation/test/gestures/DropGestureSpec.js @@ -16,13 +16,17 @@ define( DROP_ID = "drop-id"; describe("The drop gesture", function () { - var mockQ, + var mockDndService, + mockQ, mockElement, mockDomainObject, mockPersistence, + mockAction, mockEvent, mockScope, mockUnwrappedElement, + mockDraggedObject, + mockCompose, testModel, testRect, gesture, @@ -40,25 +44,41 @@ define( testModel = { composition: [] }; testRect = {}; + mockDndService = jasmine.createSpyObj('dndService', ['getData']); mockQ = { when: mockPromise }; mockElement = jasmine.createSpyObj("element", JQLITE_FUNCTIONS); mockDomainObject = jasmine.createSpyObj("domainObject", DOMAIN_OBJECT_METHODS); + mockDraggedObject = jasmine.createSpyObj("draggedObject", DOMAIN_OBJECT_METHODS); mockPersistence = jasmine.createSpyObj("persistence", [ "persist" ]); mockEvent = jasmine.createSpyObj("event", ["preventDefault"]); mockEvent.dataTransfer = jasmine.createSpyObj("dataTransfer", [ "getData" ]); mockScope = jasmine.createSpyObj("$scope", ["$broadcast"]); mockUnwrappedElement = jasmine.createSpyObj("unwrapped", ["getBoundingClientRect"]); + mockAction = jasmine.createSpyObj('action', ['getActions']); + mockCompose = jasmine.createSpyObj('compose', ['perform']); mockDomainObject.getId.andReturn(TEST_ID); mockDomainObject.getModel.andReturn(testModel); - mockDomainObject.getCapability.andReturn(mockPersistence); + mockDomainObject.getCapability.andCallFake(function (c) { + return { + persistence: mockPersistence, + action: mockAction + }[c]; + }); mockDomainObject.useCapability.andReturn(true); mockEvent.dataTransfer.getData.andReturn(DROP_ID); mockElement[0] = mockUnwrappedElement; mockElement.scope.andReturn(mockScope); mockUnwrappedElement.getBoundingClientRect.andReturn(testRect); + mockDndService.getData.andReturn(mockDraggedObject); + mockAction.getActions.andReturn([mockCompose]); - gesture = new DropGesture(mockQ, mockElement, mockDomainObject); + gesture = new DropGesture( + mockDndService, + mockQ, + mockElement, + mockDomainObject + ); // Get a reference to all callbacks registered during constructor callbacks = {}; @@ -91,38 +111,16 @@ define( expect(mockEvent.dataTransfer.dropEffect).toBeDefined(); }); - it("mutates composition on drop", function () { + it("invokes compose on drop", function () { + callbacks.dragover(mockEvent); + expect(mockAction.getActions).toHaveBeenCalledWith({ + key: 'compose', + selectedObject: mockDraggedObject + }); callbacks.drop(mockEvent); - expect(mockDomainObject.useCapability).toHaveBeenCalledWith("mutation", jasmine.any(Function)); - - // Call the mutation function, as the mutation capability would - testModel = mockDomainObject.useCapability.mostRecentCall.args[1](testModel) || testModel; - - // Should have the test id - expect(testModel.composition).toEqual([DROP_ID]); + expect(mockCompose.perform).toHaveBeenCalled(); }); - it("does not permit redundant IDs in composition", function () { - testModel.composition = [DROP_ID]; - - callbacks.drop(mockEvent); - expect(mockDomainObject.useCapability).toHaveBeenCalledWith("mutation", jasmine.any(Function)); - - // Call the mutation function, as the mutation capability would - testModel = mockDomainObject.useCapability.mostRecentCall.args[1](testModel) || testModel; - - // Should still just have the one instance of DROP_ID - expect(testModel.composition).toEqual([DROP_ID]); - }); - - - it("persists when mutation is successful", function () { - mockDomainObject.getCapability.andReturn(mockPersistence); - mockDomainObject.useCapability.andReturn(true); - callbacks.drop(mockEvent); - expect(mockDomainObject.useCapability).toHaveBeenCalledWith("mutation", jasmine.any(Function)); - expect(mockDomainObject.getCapability).toHaveBeenCalledWith("persistence"); - }); it("broadcasts drop position", function () { testRect.left = 42; diff --git a/pom.xml b/pom.xml index 533bfe3d33..af26614f96 100644 --- a/pom.xml +++ b/pom.xml @@ -5,8 +5,8 @@ 4.0.0 gov.nasa.arc.wtd open-mct-web - 0.6.1-SNAPSHOT Open MCT Web + 0.6.2-SNAPSHOT war