diff --git a/platform/commonUI/browse/bundle.json b/platform/commonUI/browse/bundle.json index 1aa9068d81..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": [ diff --git a/platform/commonUI/edit/bundle.json b/platform/commonUI/edit/bundle.json index e09db0b647..fbfd44b42a 100644 --- a/platform/commonUI/edit/bundle.json +++ b/platform/commonUI/edit/bundle.json @@ -45,7 +45,7 @@ }, { "key": "properties", - "category": "contextual", + "category": ["contextual", "view-control"], "implementation": "actions/PropertiesAction.js", "glyph": "p", "name": "Edit Properties...", @@ -79,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/src/objects/EditableDomainObjectCache.js b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js index 062d9fa15e..53faaa7ed5 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js @@ -51,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, 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/controllers/EditControllerSpec.js b/platform/commonUI/edit/test/controllers/EditControllerSpec.js index 6384859de2..09bd4d35cc 100644 --- a/platform/commonUI/edit/test/controllers/EditControllerSpec.js +++ b/platform/commonUI/edit/test/controllers/EditControllerSpec.js @@ -25,7 +25,7 @@ define( ); mockObject = jasmine.createSpyObj( "domainObject", - [ "getId", "getModel", "getCapability" ] + [ "getId", "getModel", "getCapability", "hasCapability" ] ); mockCapability = jasmine.createSpyObj( "capability", diff --git a/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js b/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js index 001b5a4ad1..1c0d372119 100644 --- a/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js +++ b/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js @@ -21,6 +21,9 @@ define( getModel: function () { return {}; }, getCapability: function (name) { return completionCapability; + }, + hasCapability: function (name) { + return false; } }; } @@ -29,6 +32,9 @@ 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; } @@ -112,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 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/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/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/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/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