+
\ 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