diff --git a/platform/commonUI/browse/bundle.json b/platform/commonUI/browse/bundle.json index d4e7a85036..40b215fa2b 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": [ diff --git a/platform/commonUI/edit/bundle.json b/platform/commonUI/edit/bundle.json index b2e7878beb..35ff439f75 100644 --- a/platform/commonUI/edit/bundle.json +++ b/platform/commonUI/edit/bundle.json @@ -41,7 +41,7 @@ }, { "key": "properties", - "category": "contextual", + "category": ["contextual", "view-control"], "implementation": "actions/PropertiesAction.js", "glyph": "p", "name": "Edit Properties...", @@ -75,6 +75,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/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/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 8a239f35b2..a73deeb8ee 100644 --- a/platform/commonUI/edit/test/suite.json +++ b/platform/commonUI/edit/test/suite.json @@ -18,6 +18,8 @@ "objects/EditableDomainObject", "objects/EditableDomainObjectCache", "objects/EditableModelCache", + "policies/EditableViewPolicy", + "policies/EditActionPolicy", "representers/EditRepresenter", "representers/EditToolbar", "representers/EditToolbarRepresenter", 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/policy/bundle.json b/platform/policy/bundle.json index ea836ca732..0f27b51136 100644 --- a/platform/policy/bundle.json +++ b/platform/policy/bundle.json @@ -10,6 +10,12 @@ "implementation": "PolicyActionDecorator.js", "depends": [ "policyService" ] }, + { + "type": "decorator", + "provides": "viewService", + "implementation": "PolicyViewDecorator.js", + "depends": [ "policyService" ] + }, { "type": "provider", "provides": "policyService", diff --git a/platform/policy/src/PolicyActionDecorator.js b/platform/policy/src/PolicyActionDecorator.js index 1057dca905..08cadd5423 100644 --- a/platform/policy/src/PolicyActionDecorator.js +++ b/platform/policy/src/PolicyActionDecorator.js @@ -15,7 +15,7 @@ define( return { /** * Get actions which are applicable in this context. - * These will be filters to remove any actions which + * 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 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/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 index 8706198dc6..c695797527 100644 --- a/platform/policy/test/suite.json +++ b/platform/policy/test/suite.json @@ -1,4 +1,5 @@ [ "PolicyActionDecorator", + "PolicyViewDecorator", "PolicyProvider" ] \ No newline at end of file