From c932e953bc1a580b672a0596d8b437e995db3a48 Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Mon, 14 Sep 2015 16:45:38 -0700 Subject: [PATCH 01/16] [Plugins] Bring over timeline, clock plugins WTD-1239 --- bundles.json | 2 + platform/features/clock/bundle.json | 173 +++++++ .../clock/lib/moment-duration-format.js | 482 ++++++++++++++++++ .../features/clock/res/templates/clock.html | 13 + .../features/clock/res/templates/timer.html | 21 + .../src/actions/AbstractStartTimerAction.js | 41 ++ .../clock/src/actions/RestartTimerAction.js | 33 ++ .../clock/src/actions/StartTimerAction.js | 34 ++ .../clock/src/controllers/ClockController.js | 79 +++ .../src/controllers/RefreshingController.js | 29 ++ .../clock/src/controllers/TimerController.js | 146 ++++++ .../clock/src/controllers/TimerFormatter.js | 60 +++ .../clock/src/indicators/ClockIndicator.js | 38 ++ .../clock/src/services/TickerService.js | 68 +++ .../actions/AbstractStartTimerActionSpec.js | 66 +++ .../test/actions/RestartTimerActionSpec.js | 76 +++ .../test/actions/StartTimerActionSpec.js | 76 +++ .../test/controllers/ClockControllerSpec.js | 83 +++ .../controllers/RefreshingControllerSpec.js | 63 +++ .../test/controllers/TimerControllerSpec.js | 178 +++++++ .../test/controllers/TimerFormatterSpec.js | 96 ++++ .../test/indicators/ClockIndicatorSpec.js | 40 ++ .../clock/test/services/TickerServiceSpec.js | 43 ++ platform/features/clock/test/suite.json | 11 + platform/features/timeline/README.md | 70 +++ platform/features/timeline/bundle.json | 372 ++++++++++++++ .../res/templates/activity-gantt.html | 18 + .../res/templates/controls/datetime.html | 62 +++ .../timeline/res/templates/legend-item.html | 13 + .../res/templates/resource-graph-labels.html | 16 + .../res/templates/resource-graphs.html | 13 + .../templates/tabular-swimlane-cols-data.html | 16 + .../templates/tabular-swimlane-cols-tree.html | 36 ++ .../timeline/res/templates/ticks.html | 18 + .../timeline/res/templates/timeline.html | 197 +++++++ .../timeline/res/templates/values.html | 6 + .../timeline/src/TimelineConstants.js | 11 + .../timeline/src/TimelineFormatter.js | 57 +++ .../src/capabilities/ActivityTimespan.js | 100 ++++ .../ActivityTimespanCapability.js | 42 ++ .../src/capabilities/ActivityUtilization.js | 31 ++ .../src/capabilities/CostCapability.js | 56 ++ .../src/capabilities/CumulativeGraph.js | 134 +++++ .../src/capabilities/GraphCapability.js | 78 +++ .../src/capabilities/ResourceGraph.js | 128 +++++ .../src/capabilities/TimelineTimespan.js | 105 ++++ .../TimelineTimespanCapability.js | 68 +++ .../src/capabilities/TimelineUtilization.js | 31 ++ .../src/capabilities/UtilizationCapability.js | 198 +++++++ .../ActivityModeValuesController.js | 41 ++ .../src/controllers/TimelineController.js | 128 +++++ .../controllers/TimelineGanttController.js | 67 +++ .../controllers/TimelineGraphController.js | 76 +++ .../controllers/TimelineTableController.js | 32 ++ .../src/controllers/TimelineTickController.js | 97 ++++ .../src/controllers/TimelineZoomController.js | 109 ++++ .../src/controllers/WARPDateTimeController.js | 72 +++ .../drag/TimelineDragHandleFactory.js | 55 ++ .../controllers/drag/TimelineDragHandler.js | 237 +++++++++ .../controllers/drag/TimelineDragPopulator.js | 76 +++ .../src/controllers/drag/TimelineEndHandle.js | 77 +++ .../controllers/drag/TimelineMoveHandle.js | 115 +++++ .../controllers/drag/TimelineSnapHandler.js | 85 +++ .../controllers/drag/TimelineStartHandle.js | 77 +++ .../src/controllers/graph/TimelineGraph.js | 172 +++++++ .../graph/TimelineGraphPopulator.js | 136 +++++ .../graph/TimelineGraphRenderer.js | 62 +++ .../swimlane/TimelineColorAssigner.js | 101 ++++ .../src/controllers/swimlane/TimelineProxy.js | 58 +++ .../controllers/swimlane/TimelineSwimlane.js | 156 ++++++ .../swimlane/TimelineSwimlaneDecorator.js | 93 ++++ .../swimlane/TimelineSwimlaneDropHandler.js | 186 +++++++ .../swimlane/TimelineSwimlanePopulator.js | 164 ++++++ .../src/directives/SwimlaneDragConstants.js | 20 + .../src/directives/WARPSwimlaneDrag.js | 47 ++ .../src/directives/WARPSwimlaneDrop.js | 106 ++++ .../timeline/src/services/ObjectLoader.js | 114 +++++ .../timeline/test/TimelineConstantsSpec.js | 14 + .../timeline/test/TimelineFormatterSpec.js | 41 ++ .../ActivityTimespanCapabilitySpec.js | 71 +++ .../test/capabilities/ActivityTimespanSpec.js | 80 +++ .../capabilities/ActivityUtilizationSpec.js | 20 + .../test/capabilities/CostCapabilitySpec.js | 60 +++ .../test/capabilities/CumulativeGraphSpec.js | 67 +++ .../test/capabilities/GraphCapabilitySpec.js | 98 ++++ .../test/capabilities/ResourceGraphSpec.js | 56 ++ .../TimelineTimespanCapabilitySpec.js | 115 +++++ .../test/capabilities/TimelineTimespanSpec.js | 91 ++++ .../capabilities/TimelineUtilizationSpec.js | 20 + .../capabilities/UtilizationCapabilitySpec.js | 195 +++++++ .../ActivityModeValuesControllerSpec.js | 32 ++ .../controllers/TimelineControllerSpec.js | 229 +++++++++ .../TimelineGanttControllerSpec.js | 80 +++ .../TimelineGraphControllerSpec.js | 68 +++ .../TimelineTableControllerSpec.js | 31 ++ .../controllers/TimelineTickControllerSpec.js | 67 +++ .../controllers/TimelineZoomControllerSpec.js | 80 +++ .../controllers/WARPDateTimeControllerSpec.js | 57 +++ .../drag/TimelineDragHandleFactorySpec.js | 66 +++ .../drag/TimelineDragHandlerSpec.js | 209 ++++++++ .../drag/TimelineDragPopulatorSpec.js | 53 ++ .../controllers/drag/TimelineEndHandleSpec.js | 96 ++++ .../drag/TimelineMoveHandleSpec.js | 163 ++++++ .../drag/TimelineSnapHandlerSpec.js | 60 +++ .../drag/TimelineStartHandleSpec.js | 95 ++++ .../graph/TimelineGraphPopulatorSpec.js | 132 +++++ .../graph/TimelineGraphRendererSpec.js | 56 ++ .../controllers/graph/TimelineGraphSpec.js | 151 ++++++ .../swimlane/TimelineColorAssignerSpec.js | 65 +++ .../controllers/swimlane/TimelineProxySpec.js | 87 ++++ .../swimlane/TimelineSwimlaneDecoratorSpec.js | 160 ++++++ .../TimelineSwimlaneDropHandlerSpec.js | 173 +++++++ .../swimlane/TimelineSwimlanePopulatorSpec.js | 135 +++++ .../swimlane/TimelineSwimlaneSpec.js | 202 ++++++++ .../directives/SwimlaneDragConstantsSpec.js | 15 + .../test/directives/WARPSwimlaneDragSpec.js | 76 +++ .../test/directives/WARPSwimlaneDropSpec.js | 147 ++++++ .../test/services/ObjectLoaderSpec.js | 136 +++++ platform/features/timeline/test/suite.json | 50 ++ 119 files changed, 10485 insertions(+) create mode 100644 platform/features/clock/bundle.json create mode 100644 platform/features/clock/lib/moment-duration-format.js create mode 100644 platform/features/clock/res/templates/clock.html create mode 100644 platform/features/clock/res/templates/timer.html create mode 100644 platform/features/clock/src/actions/AbstractStartTimerAction.js create mode 100644 platform/features/clock/src/actions/RestartTimerAction.js create mode 100644 platform/features/clock/src/actions/StartTimerAction.js create mode 100644 platform/features/clock/src/controllers/ClockController.js create mode 100644 platform/features/clock/src/controllers/RefreshingController.js create mode 100644 platform/features/clock/src/controllers/TimerController.js create mode 100644 platform/features/clock/src/controllers/TimerFormatter.js create mode 100644 platform/features/clock/src/indicators/ClockIndicator.js create mode 100644 platform/features/clock/src/services/TickerService.js create mode 100644 platform/features/clock/test/actions/AbstractStartTimerActionSpec.js create mode 100644 platform/features/clock/test/actions/RestartTimerActionSpec.js create mode 100644 platform/features/clock/test/actions/StartTimerActionSpec.js create mode 100644 platform/features/clock/test/controllers/ClockControllerSpec.js create mode 100644 platform/features/clock/test/controllers/RefreshingControllerSpec.js create mode 100644 platform/features/clock/test/controllers/TimerControllerSpec.js create mode 100644 platform/features/clock/test/controllers/TimerFormatterSpec.js create mode 100644 platform/features/clock/test/indicators/ClockIndicatorSpec.js create mode 100644 platform/features/clock/test/services/TickerServiceSpec.js create mode 100644 platform/features/clock/test/suite.json create mode 100644 platform/features/timeline/README.md create mode 100644 platform/features/timeline/bundle.json create mode 100644 platform/features/timeline/res/templates/activity-gantt.html create mode 100644 platform/features/timeline/res/templates/controls/datetime.html create mode 100644 platform/features/timeline/res/templates/legend-item.html create mode 100644 platform/features/timeline/res/templates/resource-graph-labels.html create mode 100644 platform/features/timeline/res/templates/resource-graphs.html create mode 100644 platform/features/timeline/res/templates/tabular-swimlane-cols-data.html create mode 100644 platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html create mode 100644 platform/features/timeline/res/templates/ticks.html create mode 100644 platform/features/timeline/res/templates/timeline.html create mode 100644 platform/features/timeline/res/templates/values.html create mode 100644 platform/features/timeline/src/TimelineConstants.js create mode 100644 platform/features/timeline/src/TimelineFormatter.js create mode 100644 platform/features/timeline/src/capabilities/ActivityTimespan.js create mode 100644 platform/features/timeline/src/capabilities/ActivityTimespanCapability.js create mode 100644 platform/features/timeline/src/capabilities/ActivityUtilization.js create mode 100644 platform/features/timeline/src/capabilities/CostCapability.js create mode 100644 platform/features/timeline/src/capabilities/CumulativeGraph.js create mode 100644 platform/features/timeline/src/capabilities/GraphCapability.js create mode 100644 platform/features/timeline/src/capabilities/ResourceGraph.js create mode 100644 platform/features/timeline/src/capabilities/TimelineTimespan.js create mode 100644 platform/features/timeline/src/capabilities/TimelineTimespanCapability.js create mode 100644 platform/features/timeline/src/capabilities/TimelineUtilization.js create mode 100644 platform/features/timeline/src/capabilities/UtilizationCapability.js create mode 100644 platform/features/timeline/src/controllers/ActivityModeValuesController.js create mode 100644 platform/features/timeline/src/controllers/TimelineController.js create mode 100644 platform/features/timeline/src/controllers/TimelineGanttController.js create mode 100644 platform/features/timeline/src/controllers/TimelineGraphController.js create mode 100644 platform/features/timeline/src/controllers/TimelineTableController.js create mode 100644 platform/features/timeline/src/controllers/TimelineTickController.js create mode 100644 platform/features/timeline/src/controllers/TimelineZoomController.js create mode 100644 platform/features/timeline/src/controllers/WARPDateTimeController.js create mode 100644 platform/features/timeline/src/controllers/drag/TimelineDragHandleFactory.js create mode 100644 platform/features/timeline/src/controllers/drag/TimelineDragHandler.js create mode 100644 platform/features/timeline/src/controllers/drag/TimelineDragPopulator.js create mode 100644 platform/features/timeline/src/controllers/drag/TimelineEndHandle.js create mode 100644 platform/features/timeline/src/controllers/drag/TimelineMoveHandle.js create mode 100644 platform/features/timeline/src/controllers/drag/TimelineSnapHandler.js create mode 100644 platform/features/timeline/src/controllers/drag/TimelineStartHandle.js create mode 100644 platform/features/timeline/src/controllers/graph/TimelineGraph.js create mode 100644 platform/features/timeline/src/controllers/graph/TimelineGraphPopulator.js create mode 100644 platform/features/timeline/src/controllers/graph/TimelineGraphRenderer.js create mode 100644 platform/features/timeline/src/controllers/swimlane/TimelineColorAssigner.js create mode 100644 platform/features/timeline/src/controllers/swimlane/TimelineProxy.js create mode 100644 platform/features/timeline/src/controllers/swimlane/TimelineSwimlane.js create mode 100644 platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDecorator.js create mode 100644 platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDropHandler.js create mode 100644 platform/features/timeline/src/controllers/swimlane/TimelineSwimlanePopulator.js create mode 100644 platform/features/timeline/src/directives/SwimlaneDragConstants.js create mode 100644 platform/features/timeline/src/directives/WARPSwimlaneDrag.js create mode 100644 platform/features/timeline/src/directives/WARPSwimlaneDrop.js create mode 100644 platform/features/timeline/src/services/ObjectLoader.js create mode 100644 platform/features/timeline/test/TimelineConstantsSpec.js create mode 100644 platform/features/timeline/test/TimelineFormatterSpec.js create mode 100644 platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js create mode 100644 platform/features/timeline/test/capabilities/ActivityTimespanSpec.js create mode 100644 platform/features/timeline/test/capabilities/ActivityUtilizationSpec.js create mode 100644 platform/features/timeline/test/capabilities/CostCapabilitySpec.js create mode 100644 platform/features/timeline/test/capabilities/CumulativeGraphSpec.js create mode 100644 platform/features/timeline/test/capabilities/GraphCapabilitySpec.js create mode 100644 platform/features/timeline/test/capabilities/ResourceGraphSpec.js create mode 100644 platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js create mode 100644 platform/features/timeline/test/capabilities/TimelineTimespanSpec.js create mode 100644 platform/features/timeline/test/capabilities/TimelineUtilizationSpec.js create mode 100644 platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js create mode 100644 platform/features/timeline/test/controllers/ActivityModeValuesControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/TimelineControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/TimelineGanttControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/TimelineGraphControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/TimelineTableControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/TimelineTickControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/TimelineZoomControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/WARPDateTimeControllerSpec.js create mode 100644 platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js create mode 100644 platform/features/timeline/test/controllers/drag/TimelineDragHandlerSpec.js create mode 100644 platform/features/timeline/test/controllers/drag/TimelineDragPopulatorSpec.js create mode 100644 platform/features/timeline/test/controllers/drag/TimelineEndHandleSpec.js create mode 100644 platform/features/timeline/test/controllers/drag/TimelineMoveHandleSpec.js create mode 100644 platform/features/timeline/test/controllers/drag/TimelineSnapHandlerSpec.js create mode 100644 platform/features/timeline/test/controllers/drag/TimelineStartHandleSpec.js create mode 100644 platform/features/timeline/test/controllers/graph/TimelineGraphPopulatorSpec.js create mode 100644 platform/features/timeline/test/controllers/graph/TimelineGraphRendererSpec.js create mode 100644 platform/features/timeline/test/controllers/graph/TimelineGraphSpec.js create mode 100644 platform/features/timeline/test/controllers/swimlane/TimelineColorAssignerSpec.js create mode 100644 platform/features/timeline/test/controllers/swimlane/TimelineProxySpec.js create mode 100644 platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js create mode 100644 platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDropHandlerSpec.js create mode 100644 platform/features/timeline/test/controllers/swimlane/TimelineSwimlanePopulatorSpec.js create mode 100644 platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneSpec.js create mode 100644 platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js create mode 100644 platform/features/timeline/test/directives/WARPSwimlaneDragSpec.js create mode 100644 platform/features/timeline/test/directives/WARPSwimlaneDropSpec.js create mode 100644 platform/features/timeline/test/services/ObjectLoaderSpec.js create mode 100644 platform/features/timeline/test/suite.json diff --git a/bundles.json b/bundles.json index 35d6f11728..f32f21abf5 100644 --- a/bundles.json +++ b/bundles.json @@ -11,11 +11,13 @@ "platform/containment", "platform/execution", "platform/telemetry", + "platform/features/clock", "platform/features/imagery", "platform/features/layout", "platform/features/pages", "platform/features/plot", "platform/features/scrolling", + "platform/features/timeline", "platform/features/events", "platform/forms", "platform/identity", diff --git a/platform/features/clock/bundle.json b/platform/features/clock/bundle.json new file mode 100644 index 0000000000..ee40b028b9 --- /dev/null +++ b/platform/features/clock/bundle.json @@ -0,0 +1,173 @@ +{ + "name": "WARP Clocks/Timers", + "descriptions": "Domain objects for displaying current & relative times.", + "configuration": { + "paths": { + "moment-duration-format": "moment-duration-format" + } + }, + "extensions": { + "constants": [ + { + "key": "CLOCK_INDICATOR_FORMAT", + "value": "YYYY/MM/DD HH:mm:ss" + } + + ], + "indicators": [ + { + "implementation": "indicators/ClockIndicator.js", + "depends": [ "warp.tickerService", "CLOCK_INDICATOR_FORMAT" ], + "priority": "preferred" + } + ], + "services": [ + { + "key": "warp.tickerService", + "implementation": "services/TickerService.js", + "depends": [ "$timeout", "now" ] + } + ], + "controllers": [ + { + "key": "ClockController", + "implementation": "controllers/ClockController.js", + "depends": [ "$scope", "warp.tickerService" ] + }, + { + "key": "TimerController", + "implementation": "controllers/TimerController.js", + "depends": [ "$scope", "$window", "now" ] + }, + { + "key": "RefreshingController", + "implementation": "controllers/RefreshingController.js", + "depends": [ "$scope", "warp.tickerService" ] + } + ], + "views": [ + { + "key": "warp.clock", + "type": "warp.clock", + "templateUrl": "templates/clock.html" + }, + { + "key": "warp.timer", + "type": "warp.timer", + "templateUrl": "templates/timer.html" + } + ], + "actions": [ + { + "key": "warp.timer.start", + "implementation": "actions/StartTimerAction.js", + "depends": ["now"], + "category": "contextual", + "name": "Start", + "glyph": "\u00EF", + "priority": "preferred" + }, + { + "key": "warp.timer.restart", + "implementation": "actions/RestartTimerAction.js", + "depends": ["now"], + "category": "contextual", + "name": "Restart at 0", + "glyph": "r", + "priority": "preferred" + } + ], + "types": [ + { + "key": "warp.clock", + "name": "Clock", + "glyph": "C", + "features": [ "creation" ], + "properties": [ + { + "key": "clockFormat", + "name": "Display Format", + "control": "composite", + "items": [ + { + "control": "select", + "options": [ + { + "value": "YYYY/MM/DD hh:mm:ss", + "name": "YYYY/MM/DD hh:mm:ss" + }, + { + "value": "YYYY/DDD hh:mm:ss", + "name": "YYYY/DDD hh:mm:ss" + }, + { + "value": "hh:mm:ss", + "name": "hh:mm:ss" + } + ] + }, + { + "control": "select", + "options": [ + { + "value": "clock12", + "name": "12hr" + }, + { + "value": "clock24", + "name": "24hr" + } + ] + } + ] + } + ], + "model": { + "clockFormat": [ "YYYY/MM/DD hh:mm:ss", "clock12" ] + } + }, + { + "key": "warp.timer", + "name": "Timer", + "glyph": "\u00F5", + "features": [ "creation" ], + "properties": [ + { + "key": "timestamp", + "control": "datetime", + "name": "Target" + }, + { + "key": "timerFormat", + "control": "select", + "options": [ + { + "value": "long", + "name": "DDD hh:mm:ss" + }, + { + "value": "short", + "name": "hh:mm:ss" + } + ] + } + ], + "model": { + "timerFormat": "DDD hh:mm:ss" + } + } + ], + "licenses": [ + { + "name": "moment-duration-format", + "version": "1.3.0", + "author": "John Madhavan-Reese", + "description": "Duration parsing/formatting", + "website": "https://github.com/jsmreese/moment-duration-format", + "copyright": "Copyright 2014 John Madhavan-Reese", + "license": "license-mit", + "link": "https://github.com/jsmreese/moment-duration-format/blob/master/LICENSE" + } + ] + } +} diff --git a/platform/features/clock/lib/moment-duration-format.js b/platform/features/clock/lib/moment-duration-format.js new file mode 100644 index 0000000000..9e85421003 --- /dev/null +++ b/platform/features/clock/lib/moment-duration-format.js @@ -0,0 +1,482 @@ +/*! Moment Duration Format v1.3.0 + * https://github.com/jsmreese/moment-duration-format + * Date: 2014-07-15 + * + * Duration format plugin function for the Moment.js library + * http://momentjs.com/ + * + * Copyright 2014 John Madhavan-Reese + * Released under the MIT license + */ + +(function (root, undefined) { + + // repeatZero(qty) + // returns "0" repeated qty times + function repeatZero(qty) { + var result = ""; + + // exit early + // if qty is 0 or a negative number + // or doesn't coerce to an integer + qty = parseInt(qty, 10); + if (!qty || qty < 1) { return result; } + + while (qty) { + result += "0"; + qty -= 1; + } + + return result; + } + + // padZero(str, len [, isRight]) + // pads a string with zeros up to a specified length + // will not pad a string if its length is aready + // greater than or equal to the specified length + // default output pads with zeros on the left + // set isRight to `true` to pad with zeros on the right + function padZero(str, len, isRight) { + if (str == null) { str = ""; } + str = "" + str; + + return (isRight ? str : "") + repeatZero(len - str.length) + (isRight ? "" : str); + } + + // isArray + function isArray(array) { + return Object.prototype.toString.call(array) === "[object Array]"; + } + + // isObject + function isObject(obj) { + return Object.prototype.toString.call(obj) === "[object Object]"; + } + + // findLast + function findLast(array, callback) { + var index = array.length; + + while (index -= 1) { + if (callback(array[index])) { return array[index]; } + } + } + + // find + function find(array, callback) { + var index = 0, + max = array.length, + match; + + if (typeof callback !== "function") { + match = callback; + callback = function (item) { + return item === match; + }; + } + + while (index < max) { + if (callback(array[index])) { return array[index]; } + index += 1; + } + } + + // each + function each(array, callback) { + var index = 0, + max = array.length; + + if (!array || !max) { return; } + + while (index < max) { + if (callback(array[index], index) === false) { return; } + index += 1; + } + } + + // map + function map(array, callback) { + var index = 0, + max = array.length, + ret = []; + + if (!array || !max) { return ret; } + + while (index < max) { + ret[index] = callback(array[index], index); + index += 1; + } + + return ret; + } + + // pluck + function pluck(array, prop) { + return map(array, function (item) { + return item[prop]; + }); + } + + // compact + function compact(array) { + var ret = []; + + each(array, function (item) { + if (item) { ret.push(item); } + }); + + return ret; + } + + // unique + function unique(array) { + var ret = []; + + each(array, function (_a) { + if (!find(ret, _a)) { ret.push(_a); } + }); + + return ret; + } + + // intersection + function intersection(a, b) { + var ret = []; + + each(a, function (_a) { + each(b, function (_b) { + if (_a === _b) { ret.push(_a); } + }); + }); + + return unique(ret); + } + + // rest + function rest(array, callback) { + var ret = []; + + each(array, function (item, index) { + if (!callback(item)) { + ret = array.slice(index); + return false; + } + }); + + return ret; + } + + // initial + function initial(array, callback) { + var reversed = array.slice().reverse(); + + return rest(reversed, callback).reverse(); + } + + // extend + function extend(a, b) { + for (var key in b) { + if (b.hasOwnProperty(key)) { a[key] = b[key]; } + } + + return a; + } + + // define internal moment reference + var moment; + + if (typeof require === "function") { + try { moment = require('moment'); } + catch (e) {} + } + + if (!moment && root.moment) { + moment = root.moment; + } + + if (!moment) { + throw "Moment Duration Format cannot find Moment.js"; + } + + // moment.duration.format([template] [, precision] [, settings]) + moment.duration.fn.format = function () { + + var tokenizer, tokens, types, typeMap, momentTypes, foundFirst, trimIndex, + args = [].slice.call(arguments), + settings = extend({}, this.format.defaults), + // keep a shadow copy of this moment for calculating remainders + remainder = moment.duration(this); + + // add a reference to this duration object to the settings for use + // in a template function + settings.duration = this; + + // parse arguments + each(args, function (arg) { + if (typeof arg === "string" || typeof arg === "function") { + settings.template = arg; + return; + } + + if (typeof arg === "number") { + settings.precision = arg; + return; + } + + if (isObject(arg)) { + extend(settings, arg); + } + }); + + // types + types = settings.types = (isArray(settings.types) ? settings.types : settings.types.split(" ")); + + // template + if (typeof settings.template === "function") { + settings.template = settings.template.apply(settings); + } + + // tokenizer regexp + tokenizer = new RegExp(map(types, function (type) { + return settings[type].source; + }).join("|"), "g"); + + // token type map function + typeMap = function (token) { + return find(types, function (type) { + return settings[type].test(token); + }); + }; + + // tokens array + tokens = map(settings.template.match(tokenizer), function (token, index) { + var type = typeMap(token), + length = token.length; + + return { + index: index, + length: length, + + // replace escaped tokens with the non-escaped token text + token: (type === "escape" ? token.replace(settings.escape, "$1") : token), + + // ignore type on non-moment tokens + type: ((type === "escape" || type === "general") ? null : type) + + // calculate base value for all moment tokens + //baseValue: ((type === "escape" || type === "general") ? null : this.as(type)) + }; + }, this); + + // unique moment token types in the template (in order of descending magnitude) + momentTypes = intersection(types, unique(compact(pluck(tokens, "type")))); + + // exit early if there are no momentTypes + if (!momentTypes.length) { + return pluck(tokens, "token").join(""); + } + + // calculate values for each token type in the template + each(momentTypes, function (momentType, index) { + var value, wholeValue, decimalValue, isLeast, isMost; + + // calculate integer and decimal value portions + value = remainder.as(momentType); + wholeValue = (value > 0 ? Math.floor(value) : Math.ceil(value)); + decimalValue = value - wholeValue; + + // is this the least-significant moment token found? + isLeast = ((index + 1) === momentTypes.length); + + // is this the most-significant moment token found? + isMost = (!index); + + // update tokens array + // using this algorithm to not assume anything about + // the order or frequency of any tokens + each(tokens, function (token) { + if (token.type === momentType) { + extend(token, { + value: value, + wholeValue: wholeValue, + decimalValue: decimalValue, + isLeast: isLeast, + isMost: isMost + }); + + if (isMost) { + // note the length of the most-significant moment token: + // if it is greater than one and forceLength is not set, default forceLength to `true` + if (settings.forceLength == null && token.length > 1) { + settings.forceLength = true; + } + + // rationale is this: + // if the template is "h:mm:ss" and the moment value is 5 minutes, the user-friendly output is "5:00", not "05:00" + // shouldn't pad the `minutes` token even though it has length of two + // if the template is "hh:mm:ss", the user clearly wanted everything padded so we should output "05:00" + // if the user wanted the full padded output, they can set `{ trim: false }` to get "00:05:00" + } + } + }); + + // update remainder + remainder.subtract(wholeValue, momentType); + }); + + // trim tokens array + if (settings.trim) { + tokens = (settings.trim === "left" ? rest : initial)(tokens, function (token) { + // return `true` if: + // the token is not the least moment token (don't trim the least moment token) + // the token is a moment token that does not have a value (don't trim moment tokens that have a whole value) + return !(token.isLeast || (token.type != null && token.wholeValue)); + }); + } + + + // build output + + // the first moment token can have special handling + foundFirst = false; + + // run the map in reverse order if trimming from the right + if (settings.trim === "right") { + tokens.reverse(); + } + + tokens = map(tokens, function (token) { + var val, + decVal; + + if (!token.type) { + // if it is not a moment token, use the token as its own value + return token.token; + } + + // apply negative precision formatting to the least-significant moment token + if (token.isLeast && (settings.precision < 0)) { + val = (Math.floor(token.wholeValue * Math.pow(10, settings.precision)) * Math.pow(10, -settings.precision)).toString(); + } else { + val = token.wholeValue.toString(); + } + + // remove negative sign from the beginning + val = val.replace(/^\-/, ""); + + // apply token length formatting + // special handling for the first moment token that is not the most significant in a trimmed template + if (token.length > 1 && (foundFirst || token.isMost || settings.forceLength)) { + val = padZero(val, token.length); + } + + // add decimal value if precision > 0 + if (token.isLeast && (settings.precision > 0)) { + decVal = token.decimalValue.toString().replace(/^\-/, "").split(/\.|e\-/); + switch (decVal.length) { + case 1: + val += "." + padZero(decVal[0], settings.precision, true).slice(0, settings.precision); + break; + + case 2: + val += "." + padZero(decVal[1], settings.precision, true).slice(0, settings.precision); + break; + + case 3: + val += "." + padZero(repeatZero((+decVal[2]) - 1) + (decVal[0] || "0") + decVal[1], settings.precision, true).slice(0, settings.precision); + break; + + default: + throw "Moment Duration Format: unable to parse token decimal value."; + } + } + + // add a negative sign if the value is negative and token is most significant + if (token.isMost && token.value < 0) { + val = "-" + val; + } + + foundFirst = true; + + return val; + }); + + // undo the reverse if trimming from the right + if (settings.trim === "right") { + tokens.reverse(); + } + + return tokens.join(""); + }; + + moment.duration.fn.format.defaults = { + // token definitions + escape: /\[(.+?)\]/, + years: /[Yy]+/, + months: /M+/, + weeks: /[Ww]+/, + days: /[Dd]+/, + hours: /[Hh]+/, + minutes: /m+/, + seconds: /s+/, + milliseconds: /S+/, + general: /.+?/, + + // token type names + // in order of descending magnitude + // can be a space-separated token name list or an array of token names + types: "escape years months weeks days hours minutes seconds milliseconds general", + + // format options + + // trim + // "left" - template tokens are trimmed from the left until the first moment token that has a value >= 1 + // "right" - template tokens are trimmed from the right until the first moment token that has a value >= 1 + // (the final moment token is not trimmed, regardless of value) + // `false` - template tokens are not trimmed + trim: "left", + + // precision + // number of decimal digits to include after (to the right of) the decimal point (positive integer) + // or the number of digits to truncate to 0 before (to the left of) the decimal point (negative integer) + precision: 0, + + // force first moment token with a value to render at full length even when template is trimmed and first moment token has length of 1 + forceLength: null, + + // template used to format duration + // may be a function or a string + // template functions are executed with the `this` binding of the settings object + // so that template strings may be dynamically generated based on the duration object + // (accessible via `this.duration`) + // or any of the other settings + template: function () { + var types = this.types, + dur = this.duration, + lastType = findLast(types, function (type) { + return dur._data[type]; + }); + + // default template strings for each duration dimension type + switch (lastType) { + case "seconds": + return "h:mm:ss"; + case "minutes": + return "d[d] h:mm"; + case "hours": + return "d[d] h[h]"; + case "days": + return "M[m] d[d]"; + case "weeks": + return "y[y] w[w]"; + case "months": + return "y[y] M[m]"; + case "years": + return "y[y]"; + default: + return "y[y] M[m] d[d] h:mm:ss"; + } + } + }; + +})(this); diff --git a/platform/features/clock/res/templates/clock.html b/platform/features/clock/res/templates/clock.html new file mode 100644 index 0000000000..69c690e69d --- /dev/null +++ b/platform/features/clock/res/templates/clock.html @@ -0,0 +1,13 @@ +
+
+ + {{clock.zone()}} + + + {{clock.text()}} + + + {{clock.ampm()}} + +
+
\ No newline at end of file diff --git a/platform/features/clock/res/templates/timer.html b/platform/features/clock/res/templates/timer.html new file mode 100644 index 0000000000..36bfe3f972 --- /dev/null +++ b/platform/features/clock/res/templates/timer.html @@ -0,0 +1,21 @@ +
+
+ + {{timer.buttonGlyph()}} + + + {{timer.sign()}} + {{timer.text() || "--:--:--"}} + + + + +
+
\ No newline at end of file diff --git a/platform/features/clock/src/actions/AbstractStartTimerAction.js b/platform/features/clock/src/actions/AbstractStartTimerAction.js new file mode 100644 index 0000000000..bf520ff6c7 --- /dev/null +++ b/platform/features/clock/src/actions/AbstractStartTimerAction.js @@ -0,0 +1,41 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Implements the "Start" and "Restart" action for timers. + * + * Sets the reference timestamp in a timer to the current + * time, such that it begins counting up. + * + * Both "Start" and "Restart" share this implementation, but + * control their visibility with different `appliesTo` behavior. + * + * @implements Action + */ + function AbstractStartTimerAction(now, context) { + var domainObject = context.domainObject; + + function doPersist() { + var persistence = domainObject.getCapability('persistence'); + return persistence && persistence.persist(); + } + + function setTimestamp(model) { + model.timestamp = now(); + } + + return { + perform: function () { + return domainObject.useCapability('mutation', setTimestamp) + .then(doPersist); + } + }; + } + + return AbstractStartTimerAction; + } +); diff --git a/platform/features/clock/src/actions/RestartTimerAction.js b/platform/features/clock/src/actions/RestartTimerAction.js new file mode 100644 index 0000000000..42723887c5 --- /dev/null +++ b/platform/features/clock/src/actions/RestartTimerAction.js @@ -0,0 +1,33 @@ +/*global define*/ + +define( + ['./AbstractStartTimerAction'], + function (AbstractStartTimerAction) { + "use strict"; + + /** + * Implements the "Restart at 0" action. + * + * Behaves the same as (and delegates functionality to) + * the "Start" action. + * @implements Action + */ + function RestartTimerAction(now, context) { + return new AbstractStartTimerAction(now, context); + } + + RestartTimerAction.appliesTo = function (context) { + var model = + (context.domainObject && context.domainObject.getModel()) + || {}; + + // We show this variant for timers which already have + // a target time. + return model.type === 'warp.timer' && + model.timestamp !== undefined; + }; + + return RestartTimerAction; + + } +); \ No newline at end of file diff --git a/platform/features/clock/src/actions/StartTimerAction.js b/platform/features/clock/src/actions/StartTimerAction.js new file mode 100644 index 0000000000..39f604d784 --- /dev/null +++ b/platform/features/clock/src/actions/StartTimerAction.js @@ -0,0 +1,34 @@ +/*global define*/ + +define( + ['./AbstractStartTimerAction'], + function (AbstractStartTimerAction) { + "use strict"; + + /** + * Implements the "Start" action for timers. + * + * Sets the reference timestamp in a timer to the current + * time, such that it begins counting up. + * + * @implements Action + */ + function StartTimerAction(now, context) { + return new AbstractStartTimerAction(now, context); + } + + StartTimerAction.appliesTo = function (context) { + var model = + (context.domainObject && context.domainObject.getModel()) + || {}; + + // We show this variant for timers which do not yet have + // a target time. + return model.type === 'warp.timer' && + model.timestamp === undefined; + }; + + return StartTimerAction; + + } +); \ No newline at end of file diff --git a/platform/features/clock/src/controllers/ClockController.js b/platform/features/clock/src/controllers/ClockController.js new file mode 100644 index 0000000000..5bfc99a652 --- /dev/null +++ b/platform/features/clock/src/controllers/ClockController.js @@ -0,0 +1,79 @@ +/*global define*/ + +define( + ['moment'], + function (moment) { + "use strict"; + + /** + * Controller for views of a Clock domain object. + * + * @constructor + */ + function ClockController($scope, tickerService) { + var text, + ampm, + use24, + lastTimestamp, + unlisten, + timeFormat; + + function update() { + var m = moment.utc(lastTimestamp); + text = timeFormat && m.format(timeFormat); + ampm = m.format("A"); // Just the AM or PM part + } + + function tick(timestamp) { + lastTimestamp = timestamp; + update(); + } + + function updateFormat(clockFormat) { + var baseFormat; + + if (clockFormat !== undefined) { + baseFormat = clockFormat[0]; + + use24 = clockFormat[1] === 'clock24'; + timeFormat = use24 ? + baseFormat.replace('hh', "HH") : baseFormat; + + update(); + } + } + // Pull in the clock format from the domain object model + $scope.$watch('model.clockFormat', updateFormat); + + // Listen for clock ticks ... and stop listening on destroy + unlisten = tickerService.listen(tick); + $scope.$on('$destroy', unlisten); + + return { + /** + * Get the clock's time zone, as displayable text. + * @returns {string} + */ + zone: function () { + return "UTC"; + }, + /** + * Get the current time, as displayable text. + * @returns {string} + */ + text: function () { + return text; + }, + /** + * Get the text to display to qualify a time as AM or PM. + * @returns {string} + */ + ampm: function () { + return use24 ? '' : ampm; + } + }; + } + + return ClockController; + } +); diff --git a/platform/features/clock/src/controllers/RefreshingController.js b/platform/features/clock/src/controllers/RefreshingController.js new file mode 100644 index 0000000000..49af341308 --- /dev/null +++ b/platform/features/clock/src/controllers/RefreshingController.js @@ -0,0 +1,29 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Continually refreshes the represented domain object. + * + * This is a short-term workaround to assure Timer views stay + * up-to-date; should be replaced by a global auto-refresh. + */ + function RefreshingController($scope, tickerService) { + var unlisten; + + function triggerRefresh() { + var persistence = $scope.domainObject && + $scope.domainObject.getCapability('persistence'); + return persistence && persistence.refresh(); + } + + unlisten = tickerService.listen(triggerRefresh); + $scope.$on('$destroy', unlisten); + } + + return RefreshingController; + } +); diff --git a/platform/features/clock/src/controllers/TimerController.js b/platform/features/clock/src/controllers/TimerController.js new file mode 100644 index 0000000000..9538f914dd --- /dev/null +++ b/platform/features/clock/src/controllers/TimerController.js @@ -0,0 +1,146 @@ +/*global define*/ + +define( + ['./TimerFormatter'], + function (TimerFormatter) { + "use strict"; + + var FORMATTER = new TimerFormatter(); + + + /** + * Controller for views of a Timer domain object. + * + * @constructor + */ + function TimerController($scope, $window, now) { + var timerObject, + relevantAction, + sign = '', + text = '', + formatter, + active = true, + relativeTimestamp, + lastTimestamp; + + function update() { + var timeDelta = lastTimestamp - relativeTimestamp; + + if (formatter && !isNaN(timeDelta)) { + text = formatter(timeDelta); + sign = timeDelta < 0 ? "-" : timeDelta >= 1000 ? "+" : ""; + } else { + text = ""; + sign = ""; + } + } + + function updateFormat(key) { + formatter = FORMATTER[key] || FORMATTER.long; + } + + function updateTimestamp(timestamp) { + relativeTimestamp = timestamp; + } + + function updateObject(domainObject) { + var model = domainObject.getModel(), + timestamp = model.timestamp, + formatKey = model.timerFormat, + actionCapability = domainObject.getCapability('action'), + actionKey = (timestamp === undefined) ? + 'warp.timer.start' : 'warp.timer.restart'; + + updateFormat(formatKey); + updateTimestamp(timestamp); + + relevantAction = actionCapability && + actionCapability.getActions(actionKey)[0]; + + update(); + } + + function handleObjectChange(domainObject) { + if (domainObject) { + updateObject(domainObject); + } + } + + function handleModification() { + handleObjectChange($scope.domainObject); + } + + function tick() { + var lastSign = sign, lastText = text; + lastTimestamp = now(); + update(); + // We're running in an animation frame, not in a digest cycle. + // We need to trigger a digest cycle if our displayable data + // changes. + if (lastSign !== sign || lastText !== text) { + $scope.$apply(); + } + if (active) { + $window.requestAnimationFrame(tick); + } + } + + $window.requestAnimationFrame(tick); + + // Pull in the timer format from the domain object model + $scope.$watch('domainObject', handleObjectChange); + $scope.$watch('model.modified', handleModification); + + // When the scope is destroyed, stop requesting anim. frames + $scope.$on('$destroy', function () { + active = false; + }); + + return { + /** + * Get the glyph to display for the start/restart button. + * @returns {string} glyph to display + */ + buttonGlyph: function () { + return relevantAction ? + relevantAction.getMetadata().glyph : ""; + }, + /** + * Get the text to show for the start/restart button + * (e.g. in a tooltip) + * @returns {string} name of the action + */ + buttonText: function () { + return relevantAction ? + relevantAction.getMetadata().name : ""; + }, + /** + * Perform the action associated with the start/restart button. + */ + clickButton: function () { + if (relevantAction) { + relevantAction.perform(); + updateObject($scope.domainObject); + } + }, + /** + * Get the sign (+ or -) of the current timer value, as + * displayable text. + * @returns {string} sign of the current timer value + */ + sign: function () { + return sign; + }, + /** + * Get the text to display for the current timer value. + * @returns {string} current timer value + */ + text: function () { + return text; + } + }; + } + + return TimerController; + } +); diff --git a/platform/features/clock/src/controllers/TimerFormatter.js b/platform/features/clock/src/controllers/TimerFormatter.js new file mode 100644 index 0000000000..5b4db6f7cf --- /dev/null +++ b/platform/features/clock/src/controllers/TimerFormatter.js @@ -0,0 +1,60 @@ +/*global define*/ + +define( + ['moment', 'moment-duration-format'], + function (moment) { + "use strict"; + + var SHORT_FORMAT = "HH:mm:ss", + LONG_FORMAT = "d[D] HH:mm:ss"; + + /** + * Provides formatting functions for Timers. + * + * Display formats for timers are a little different from what + * moment.js provides, so we have custom logic here. This specifically + * supports `TimerController`. + * + * @constructor + */ + function TimerFormatter() { + + // Round this timestamp down to the second boundary + // (e.g. 1124ms goes down to 1000ms, -2400ms goes down to -3000ms) + function toWholeSeconds(duration) { + return Math.abs(Math.floor(duration / 1000) * 1000); + } + + // Short-form format, e.g. 02:22:11 + function short(duration) { + return moment.duration(toWholeSeconds(duration), 'ms') + .format(SHORT_FORMAT, { trim: false }); + } + + // Long-form format, e.g. 3d 02:22:11 + function long(duration) { + return moment.duration(toWholeSeconds(duration), 'ms') + .format(LONG_FORMAT, { trim: false }); + } + + return { + /** + * Format a duration for display, using the short form. + * (e.g. 03:33:11) + * @param {number} duration the duration, in milliseconds + * @param {boolean} sign true if positive + */ + short: short, + /** + * Format a duration for display, using the long form. + * (e.g. 0d 03:33:11) + * @param {number} duration the duration, in milliseconds + * @param {boolean} sign true if positive + */ + long: long + }; + } + + return TimerFormatter; + } +); diff --git a/platform/features/clock/src/indicators/ClockIndicator.js b/platform/features/clock/src/indicators/ClockIndicator.js new file mode 100644 index 0000000000..55f43fc0f7 --- /dev/null +++ b/platform/features/clock/src/indicators/ClockIndicator.js @@ -0,0 +1,38 @@ +/*global define*/ + +define( + ['moment'], + function (moment) { + "use strict"; + + /** + * Indicator that displays the current UTC time in the status area. + * @implements Indicator + */ + function ClockIndicator(tickerService, CLOCK_INDICATOR_FORMAT) { + var text = ""; + + tickerService.listen(function (timestamp) { + text = moment.utc(timestamp).format(CLOCK_INDICATOR_FORMAT) + " UTC"; + }); + + return { + getGlyph: function () { + return "C"; + }, + getGlyphClass: function () { + return ""; + }, + getText: function () { + return text; + }, + getDescription: function () { + return ""; + } + }; + + } + + return ClockIndicator; + } +); diff --git a/platform/features/clock/src/services/TickerService.js b/platform/features/clock/src/services/TickerService.js new file mode 100644 index 0000000000..0304b57c2a --- /dev/null +++ b/platform/features/clock/src/services/TickerService.js @@ -0,0 +1,68 @@ +/*global define*/ + +define( + ['moment'], + function (moment) { + "use strict"; + + /** + * Calls functions every second, as close to the actual second + * tick as is feasible. + * @constructor + * @param $timeout Angular's $timeout + * @param {Function} now function to provide the current time in ms + */ + function TickerService($timeout, now) { + var callbacks = [], + last = now() - 1000; + + function tick() { + var timestamp = now(), + millis = timestamp % 1000; + + // Only update callbacks if a second has actually passed. + if (timestamp >= last + 1000) { + callbacks.forEach(function (callback) { + callback(timestamp); + }); + last = timestamp - millis; + } + + // Try to update at exactly the next second + $timeout(tick, 1000 - millis, true); + } + + tick(); + + return { + /** + * Listen for clock ticks. The provided callback will + * be invoked with the current timestamp (in milliseconds + * since Jan 1 1970) at regular intervals, as near to the + * second boundary as possible. + * + * @method listen + * @name TickerService#listen + * @param {Function} callback callback to invoke + * @returns {Function} a function to unregister this listener + */ + listen: function (callback) { + callbacks.push(callback); + + // Provide immediate feedback + callback(last); + + // Provide a deregistration function + return function () { + callbacks = callbacks.filter(function (cb) { + return cb !== callback; + }); + }; + } + }; + + } + + return TickerService; + } +); diff --git a/platform/features/clock/test/actions/AbstractStartTimerActionSpec.js b/platform/features/clock/test/actions/AbstractStartTimerActionSpec.js new file mode 100644 index 0000000000..8714392a07 --- /dev/null +++ b/platform/features/clock/test/actions/AbstractStartTimerActionSpec.js @@ -0,0 +1,66 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/actions/AbstractStartTimerAction"], + function (AbstractStartTimerAction) { + "use strict"; + + describe("A timer's start/restart action", function () { + var mockNow, + mockDomainObject, + mockPersistence, + testModel, + action; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + beforeEach(function () { + mockNow = jasmine.createSpy('now'); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability', 'useCapability' ] + ); + mockPersistence = jasmine.createSpyObj( + 'persistence', + ['persist'] + ); + + mockDomainObject.getCapability.andCallFake(function (c) { + return (c === 'persistence') && mockPersistence; + }); + mockDomainObject.useCapability.andCallFake(function (c, v) { + if (c === 'mutation') { + testModel = v(testModel) || testModel; + return asPromise(true); + } + }); + + testModel = {}; + + action = new AbstractStartTimerAction(mockNow, { + domainObject: mockDomainObject + }); + }); + + it("updates the model with a timestamp and persists", function () { + mockNow.andReturn(12000); + action.perform(); + expect(testModel.timestamp).toEqual(12000); + expect(mockPersistence.persist).toHaveBeenCalled(); + }); + + it("does not truncate milliseconds", function () { + mockNow.andReturn(42321); + action.perform(); + expect(testModel.timestamp).toEqual(42321); + expect(mockPersistence.persist).toHaveBeenCalled(); + }); + }); + } +); diff --git a/platform/features/clock/test/actions/RestartTimerActionSpec.js b/platform/features/clock/test/actions/RestartTimerActionSpec.js new file mode 100644 index 0000000000..23df5f3142 --- /dev/null +++ b/platform/features/clock/test/actions/RestartTimerActionSpec.js @@ -0,0 +1,76 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/actions/RestartTimerAction"], + function (RestartTimerAction) { + "use strict"; + + describe("A timer's restart action", function () { + var mockNow, + mockDomainObject, + mockPersistence, + testModel, + testContext, + action; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + beforeEach(function () { + mockNow = jasmine.createSpy('now'); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability', 'useCapability', 'getModel' ] + ); + mockPersistence = jasmine.createSpyObj( + 'persistence', + ['persist'] + ); + + mockDomainObject.getCapability.andCallFake(function (c) { + return (c === 'persistence') && mockPersistence; + }); + mockDomainObject.useCapability.andCallFake(function (c, v) { + if (c === 'mutation') { + testModel = v(testModel) || testModel; + return asPromise(true); + } + }); + mockDomainObject.getModel.andCallFake(function () { + return testModel; + }); + + testModel = {}; + testContext = { domainObject: mockDomainObject }; + + action = new RestartTimerAction(mockNow, testContext); + }); + + it("updates the model with a timestamp and persists", function () { + mockNow.andReturn(12000); + action.perform(); + expect(testModel.timestamp).toEqual(12000); + expect(mockPersistence.persist).toHaveBeenCalled(); + }); + + it("applies only to timers with a target time", function () { + testModel.type = 'warp.timer'; + testModel.timestamp = 12000; + expect(RestartTimerAction.appliesTo(testContext)).toBeTruthy(); + + testModel.type = 'warp.timer'; + testModel.timestamp = undefined; + expect(RestartTimerAction.appliesTo(testContext)).toBeFalsy(); + + testModel.type = 'warp.clock'; + testModel.timestamp = 12000; + expect(RestartTimerAction.appliesTo(testContext)).toBeFalsy(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/clock/test/actions/StartTimerActionSpec.js b/platform/features/clock/test/actions/StartTimerActionSpec.js new file mode 100644 index 0000000000..10f60595d5 --- /dev/null +++ b/platform/features/clock/test/actions/StartTimerActionSpec.js @@ -0,0 +1,76 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/actions/StartTimerAction"], + function (StartTimerAction) { + "use strict"; + + describe("A timer's start action", function () { + var mockNow, + mockDomainObject, + mockPersistence, + testModel, + testContext, + action; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + beforeEach(function () { + mockNow = jasmine.createSpy('now'); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability', 'useCapability', 'getModel' ] + ); + mockPersistence = jasmine.createSpyObj( + 'persistence', + ['persist'] + ); + + mockDomainObject.getCapability.andCallFake(function (c) { + return (c === 'persistence') && mockPersistence; + }); + mockDomainObject.useCapability.andCallFake(function (c, v) { + if (c === 'mutation') { + testModel = v(testModel) || testModel; + return asPromise(true); + } + }); + mockDomainObject.getModel.andCallFake(function () { + return testModel; + }); + + testModel = {}; + testContext = { domainObject: mockDomainObject }; + + action = new StartTimerAction(mockNow, testContext); + }); + + it("updates the model with a timestamp and persists", function () { + mockNow.andReturn(12000); + action.perform(); + expect(testModel.timestamp).toEqual(12000); + expect(mockPersistence.persist).toHaveBeenCalled(); + }); + + it("applies only to timers without a target time", function () { + testModel.type = 'warp.timer'; + testModel.timestamp = 12000; + expect(StartTimerAction.appliesTo(testContext)).toBeFalsy(); + + testModel.type = 'warp.timer'; + testModel.timestamp = undefined; + expect(StartTimerAction.appliesTo(testContext)).toBeTruthy(); + + testModel.type = 'warp.clock'; + testModel.timestamp = 12000; + expect(StartTimerAction.appliesTo(testContext)).toBeFalsy(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/clock/test/controllers/ClockControllerSpec.js b/platform/features/clock/test/controllers/ClockControllerSpec.js new file mode 100644 index 0000000000..640b180a40 --- /dev/null +++ b/platform/features/clock/test/controllers/ClockControllerSpec.js @@ -0,0 +1,83 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/controllers/ClockController"], + function (ClockController) { + "use strict"; + + // Wed, 03 Jun 2015 17:56:14 GMT + var TEST_TIMESTAMP = 1433354174000; + + describe("A clock view's controller", function () { + var mockScope, + mockTicker, + mockUnticker, + mockDomainObject, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj('$scope', ['$watch', '$on']); + mockTicker = jasmine.createSpyObj('ticker', ['listen']); + mockUnticker = jasmine.createSpy('unticker'); + + mockTicker.listen.andReturn(mockUnticker); + + controller = new ClockController(mockScope, mockTicker); + }); + + it("watches for clock format from the domain object model", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "model.clockFormat", + jasmine.any(Function) + ); + }); + + it("subscribes to clock ticks", function () { + expect(mockTicker.listen) + .toHaveBeenCalledWith(jasmine.any(Function)); + }); + + it("unsubscribes to ticks when destroyed", function () { + // Make sure $destroy is being listened for... + expect(mockScope.$on.mostRecentCall.args[0]).toEqual('$destroy'); + expect(mockUnticker).not.toHaveBeenCalled(); + + // ...and makes sure that its listener unsubscribes from ticker + mockScope.$on.mostRecentCall.args[1](); + expect(mockUnticker).toHaveBeenCalled(); + }); + + it("formats using the format string from the model", function () { + mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP); + mockScope.$watch.mostRecentCall.args[1]([ + "YYYY-DDD hh:mm:ss", + "clock24" + ]); + + expect(controller.zone()).toEqual("UTC"); + expect(controller.text()).toEqual("2015-154 17:56:14"); + expect(controller.ampm()).toEqual(""); + }); + + it("formats 12-hour time", function () { + mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP); + mockScope.$watch.mostRecentCall.args[1]([ + "YYYY-DDD hh:mm:ss", + "clock12" + ]); + + expect(controller.zone()).toEqual("UTC"); + expect(controller.text()).toEqual("2015-154 05:56:14"); + expect(controller.ampm()).toEqual("PM"); + }); + + it("does not throw exceptions when clockFormat is undefined", function () { + mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP); + expect(function () { + mockScope.$watch.mostRecentCall.args[1](undefined); + }).not.toThrow(); + }); + + }); + } +); diff --git a/platform/features/clock/test/controllers/RefreshingControllerSpec.js b/platform/features/clock/test/controllers/RefreshingControllerSpec.js new file mode 100644 index 0000000000..bf1cbceeb9 --- /dev/null +++ b/platform/features/clock/test/controllers/RefreshingControllerSpec.js @@ -0,0 +1,63 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/controllers/RefreshingController"], + function (RefreshingController) { + "use strict"; + + + + describe("The refreshing controller", function () { + var mockScope, + mockTicker, + mockUnticker, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj('$scope', ['$on']); + mockTicker = jasmine.createSpyObj('ticker', ['listen']); + mockUnticker = jasmine.createSpy('unticker'); + + mockTicker.listen.andReturn(mockUnticker); + + controller = new RefreshingController(mockScope, mockTicker); + }); + + it("refreshes the represented object on every tick", function () { + var mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability' ] + ), + mockPersistence = jasmine.createSpyObj( + 'persistence', + [ 'persist', 'refresh' ] + ); + + mockDomainObject.getCapability.andCallFake(function (c) { + return (c === 'persistence') && mockPersistence; + }); + + mockScope.domainObject = mockDomainObject; + + mockTicker.listen.mostRecentCall.args[0](12321); + expect(mockPersistence.refresh).toHaveBeenCalled(); + expect(mockPersistence.persist).not.toHaveBeenCalled(); + }); + + it("subscribes to clock ticks", function () { + expect(mockTicker.listen) + .toHaveBeenCalledWith(jasmine.any(Function)); + }); + + it("unsubscribes to ticks when destroyed", function () { + // Make sure $destroy is being listened for... + expect(mockScope.$on.mostRecentCall.args[0]).toEqual('$destroy'); + expect(mockUnticker).not.toHaveBeenCalled(); + + // ...and makes sure that its listener unsubscribes from ticker + mockScope.$on.mostRecentCall.args[1](); + expect(mockUnticker).toHaveBeenCalled(); + }); + }); + } +); diff --git a/platform/features/clock/test/controllers/TimerControllerSpec.js b/platform/features/clock/test/controllers/TimerControllerSpec.js new file mode 100644 index 0000000000..9f80e21d9d --- /dev/null +++ b/platform/features/clock/test/controllers/TimerControllerSpec.js @@ -0,0 +1,178 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/controllers/TimerController"], + function (TimerController) { + "use strict"; + + // Wed, 03 Jun 2015 17:56:14 GMT + var TEST_TIMESTAMP = 1433354174000; + + describe("A timer view's controller", function () { + var mockScope, + mockWindow, + mockNow, + mockDomainObject, + mockActionCapability, + mockStart, + mockRestart, + testModel, + controller; + + function invokeWatch(expr, value) { + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === expr) { + call.args[1](value); + } + }); + } + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + '$scope', + ['$watch', '$on', '$apply'] + ); + mockWindow = jasmine.createSpyObj( + '$window', + ['requestAnimationFrame'] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability', 'useCapability', 'getModel' ] + ); + mockActionCapability = jasmine.createSpyObj( + 'action', + ['getActions'] + ); + mockStart = jasmine.createSpyObj( + 'start', + ['getMetadata', 'perform'] + ); + mockRestart = jasmine.createSpyObj( + 'restart', + ['getMetadata', 'perform'] + ); + mockNow = jasmine.createSpy('now'); + + mockDomainObject.getCapability.andCallFake(function (c) { + return (c === 'action') && mockActionCapability; + }); + mockDomainObject.getModel.andCallFake(function () { + return testModel; + }); + mockActionCapability.getActions.andCallFake(function (k) { + return [{ + 'warp.timer.start': mockStart, + 'warp.timer.restart': mockRestart + }[k]]; + }); + mockStart.getMetadata.andReturn({ glyph: "S", name: "Start" }); + mockRestart.getMetadata.andReturn({ glyph: "R", name: "Restart" }); + mockScope.domainObject = mockDomainObject; + + testModel = {}; + + controller = new TimerController(mockScope, mockWindow, mockNow); + }); + + it("watches for the domain object in view", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "domainObject", + jasmine.any(Function) + ); + }); + + it("watches for domain object modifications", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "model.modified", + jasmine.any(Function) + ); + }); + + it("updates on a timer", function () { + expect(mockWindow.requestAnimationFrame) + .toHaveBeenCalledWith(jasmine.any(Function)); + }); + + it("displays nothing when there is no target", function () { + // Notify that domain object is available via scope + invokeWatch('domainObject', mockDomainObject); + mockNow.andReturn(TEST_TIMESTAMP); + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(controller.sign()).toEqual(""); + expect(controller.text()).toEqual(""); + }); + + it("formats time to display relative to target", function () { + testModel.timestamp = TEST_TIMESTAMP; + testModel.timerFormat = 'long'; + // Notify that domain object is available via scope + invokeWatch('domainObject', mockDomainObject); + + mockNow.andReturn(TEST_TIMESTAMP + 121000); + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(controller.sign()).toEqual("+"); + expect(controller.text()).toEqual("0D 00:02:01"); + + mockNow.andReturn(TEST_TIMESTAMP - 121000); + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(controller.sign()).toEqual("-"); + expect(controller.text()).toEqual("0D 00:02:01"); + + mockNow.andReturn(TEST_TIMESTAMP); + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(controller.sign()).toEqual(""); + expect(controller.text()).toEqual("0D 00:00:00"); + }); + + it("shows glyph & name for the applicable start/restart action", function () { + invokeWatch('domainObject', mockDomainObject); + expect(controller.buttonGlyph()).toEqual("S"); + expect(controller.buttonText()).toEqual("Start"); + + testModel.timestamp = 12321; + invokeWatch('model.modified', 1); + expect(controller.buttonGlyph()).toEqual("R"); + expect(controller.buttonText()).toEqual("Restart"); + }); + + it("performs correct start/restart action on click", function () { + invokeWatch('domainObject', mockDomainObject); + expect(mockStart.perform).not.toHaveBeenCalled(); + controller.clickButton(); + expect(mockStart.perform).toHaveBeenCalled(); + + testModel.timestamp = 12321; + invokeWatch('model.modified', 1); + expect(mockRestart.perform).not.toHaveBeenCalled(); + controller.clickButton(); + expect(mockRestart.perform).toHaveBeenCalled(); + }); + + it("stops requesting animation frames when destroyed", function () { + var initialCount = mockWindow.requestAnimationFrame.calls.length; + + // First, check that normally new frames keep getting requested + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(mockWindow.requestAnimationFrame.calls.length) + .toEqual(initialCount + 1); + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(mockWindow.requestAnimationFrame.calls.length) + .toEqual(initialCount + 2); + + // Now, verify that it stops after $destroy + expect(mockScope.$on.mostRecentCall.args[0]) + .toEqual('$destroy'); + mockScope.$on.mostRecentCall.args[1](); + + // Frames should no longer get requested + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(mockWindow.requestAnimationFrame.calls.length) + .toEqual(initialCount + 2); + mockWindow.requestAnimationFrame.mostRecentCall.args[0](); + expect(mockWindow.requestAnimationFrame.calls.length) + .toEqual(initialCount + 2); + }); + }); + } +); diff --git a/platform/features/clock/test/controllers/TimerFormatterSpec.js b/platform/features/clock/test/controllers/TimerFormatterSpec.js new file mode 100644 index 0000000000..f0f4d954c3 --- /dev/null +++ b/platform/features/clock/test/controllers/TimerFormatterSpec.js @@ -0,0 +1,96 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/controllers/TimerFormatter"], + function (TimerFormatter) { + "use strict"; + + var MS_IN_SEC = 1000, + MS_IN_MIN = MS_IN_SEC * 60, + MS_IN_HR = MS_IN_MIN * 60, + MS_IN_DAY = MS_IN_HR * 24; + + describe("The timer value formatter", function () { + var formatter = new TimerFormatter(); + + function sum(a, b) { + return a + b; + } + + function toDuration(days, hours, mins, secs) { + return [ + days * MS_IN_DAY, + hours * MS_IN_HR, + mins * MS_IN_MIN, + secs * MS_IN_SEC + ].reduce(sum, 0); + } + + function twoDigits(n) { + return n < 10 ? ('0' + n) : n; + } + + it("formats short-form values (no days)", function () { + expect(formatter.short(toDuration(0, 123, 2, 3) + 123)) + .toEqual("123:02:03"); + }); + + it("formats negative short-form values (no days)", function () { + expect(formatter.short(-toDuration(0, 123, 2, 3) + 123)) + .toEqual("123:02:03"); + }); + + it("formats long-form values (with days)", function () { + expect(formatter.long(toDuration(0, 123, 2, 3) + 123)) + .toEqual("5D 03:02:03"); + }); + + it("formats negative long-form values (no days)", function () { + expect(formatter.long(-toDuration(0, 123, 2, 3) + 123)) + .toEqual("5D 03:02:03"); + }); + + it("rounds seconds down for positive durations", function () { + expect(formatter.short(MS_IN_SEC + 600)) + .toEqual("00:00:01"); + }); + + it("rounds seconds up for negative durations", function () { + expect(formatter.short(-MS_IN_SEC - 600)) + .toEqual("00:00:02"); + }); + + it("short-formats correctly around negative time borders", function () { + expect(formatter.short(-1)).toEqual("00:00:01"); + expect(formatter.short(-1000)).toEqual("00:00:01"); + expect(formatter.short(-1001)).toEqual("00:00:02"); + expect(formatter.short(-2000)).toEqual("00:00:02"); + expect(formatter.short(-59001)).toEqual("00:01:00"); + expect(formatter.short(-60000)).toEqual("00:01:00"); + + expect(formatter.short(-MS_IN_HR + 999)).toEqual("01:00:00"); + expect(formatter.short(-MS_IN_HR)).toEqual("01:00:00"); + }); + + it("differentiates between values around zero", function () { + // These are more than 1000 ms apart so should not appear + // as the same second + expect(formatter.short(-999)) + .not.toEqual(formatter.short(999)); + }); + + it("handles negative days", function () { + expect(formatter.long(-10 * MS_IN_DAY)) + .toEqual("10D 00:00:00"); + expect(formatter.long(-10 * MS_IN_DAY + 100)) + .toEqual("10D 00:00:00"); + expect(formatter.long(-10 * MS_IN_DAY + 999)) + .toEqual("10D 00:00:00"); + + expect(formatter.short(-10 * MS_IN_DAY + 100)) + .toEqual("240:00:00"); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/clock/test/indicators/ClockIndicatorSpec.js b/platform/features/clock/test/indicators/ClockIndicatorSpec.js new file mode 100644 index 0000000000..30c8fe3e26 --- /dev/null +++ b/platform/features/clock/test/indicators/ClockIndicatorSpec.js @@ -0,0 +1,40 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/indicators/ClockIndicator"], + function (ClockIndicator) { + "use strict"; + + // Wed, 03 Jun 2015 17:56:14 GMT + var TEST_TIMESTAMP = 1433354174000, + TEST_FORMAT = "YYYY-DDD HH:mm:ss"; + + describe("The clock indicator", function () { + var mockTicker, + mockUnticker, + indicator; + + beforeEach(function () { + mockTicker = jasmine.createSpyObj('ticker', ['listen']); + mockUnticker = jasmine.createSpy('unticker'); + + mockTicker.listen.andReturn(mockUnticker); + + indicator = new ClockIndicator(mockTicker, TEST_FORMAT); + }); + + it("displays the current time", function () { + mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP); + expect(indicator.getText()).toEqual("2015-154 17:56:14 UTC"); + }); + + it("implements the Indicator interface", function () { + expect(indicator.getGlyph()).toEqual(jasmine.any(String)); + expect(indicator.getGlyphClass()).toEqual(jasmine.any(String)); + expect(indicator.getText()).toEqual(jasmine.any(String)); + expect(indicator.getDescription()).toEqual(jasmine.any(String)); + }); + + }); + } +); diff --git a/platform/features/clock/test/services/TickerServiceSpec.js b/platform/features/clock/test/services/TickerServiceSpec.js new file mode 100644 index 0000000000..d4456ee756 --- /dev/null +++ b/platform/features/clock/test/services/TickerServiceSpec.js @@ -0,0 +1,43 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/services/TickerService"], + function (TickerService) { + "use strict"; + + var TEST_TIMESTAMP = 1433354174000; + + describe("The ticker service", function () { + var mockTimeout, + mockNow, + mockCallback, + tickerService; + + beforeEach(function () { + mockTimeout = jasmine.createSpy('$timeout'); + mockNow = jasmine.createSpy('now'); + mockCallback = jasmine.createSpy('callback'); + + mockNow.andReturn(TEST_TIMESTAMP); + + tickerService = new TickerService(mockTimeout, mockNow); + }); + + it("notifies listeners of clock ticks", function () { + tickerService.listen(mockCallback); + mockNow.andReturn(TEST_TIMESTAMP + 12321); + mockTimeout.mostRecentCall.args[0](); + expect(mockCallback) + .toHaveBeenCalledWith(TEST_TIMESTAMP + 12321); + }); + + it("allows listeners to unregister", function () { + tickerService.listen(mockCallback)(); // Unregister immediately + mockNow.andReturn(TEST_TIMESTAMP + 12321); + mockTimeout.mostRecentCall.args[0](); + expect(mockCallback).not + .toHaveBeenCalledWith(TEST_TIMESTAMP + 12321); + }); + }); + } +); diff --git a/platform/features/clock/test/suite.json b/platform/features/clock/test/suite.json new file mode 100644 index 0000000000..be10ff57f8 --- /dev/null +++ b/platform/features/clock/test/suite.json @@ -0,0 +1,11 @@ +[ + "actions/AbstractStartTimerAction", + "actions/RestartTimerAction", + "actions/StartTimerAction", + "controllers/ClockController", + "controllers/RefreshingController", + "controllers/TimerController", + "controllers/TimerFormatter", + "indicators/ClockIndicator", + "services/TickerService" +] diff --git a/platform/features/timeline/README.md b/platform/features/timeline/README.md new file mode 100644 index 0000000000..5dd9561c2c --- /dev/null +++ b/platform/features/timeline/README.md @@ -0,0 +1,70 @@ +This bundle provides the Timeline domain object type, as well +as other associated domain object types and relevant views. + +# Implementation notes + +## Model Properties + +The properties below record properties relevant to using and +understanding timelines based on their JSON representation. +Additional common properties, such as `modified` +or `persisted` timestamps, may also be present. + +### Timeline Model + +A timeline's model looks like: + +``` +{ + "type": "warp.timeline", + "start": { + "timestamp": (milliseconds since epoch), + "epoch": (currently, always "SET") + }, + "capacity": (optional; battery capacity in watt-hours) + "composition": (array of identifiers for contained objects) +} +``` + +The identifiers in a timeline's `composition` field should refer to +other Timeline objects, or to Activity objects. + +### Activity Model + +An activity's model looks like: + +``` +{ + "type": "warp.activity", + "start": { + "timestamp": (milliseconds since epoch), + "epoch": (currently, always "SET") + }, + "duration": { + "timestamp": (duration of this activity, in milliseconds) + "epoch": "SET" (this is ignored) + }, + "relationships": { + "modes": (array of applicable Activity Mode ids) + }, + "link": (optional; URL linking to associated external resource) + "composition": (array of identifiers for contained objects) +} +``` + +The identifiers in a timeline's `composition` field should only refer to +other Activity objects. + +### Activity Mode Model + +An activity mode's model looks like: + +``` +{ + "type": "warp.mode", + "resources": { + "comms": (communications utilization, in Kbps) + "power": (power utilization, in watts) + } +} +``` diff --git a/platform/features/timeline/bundle.json b/platform/features/timeline/bundle.json new file mode 100644 index 0000000000..1132ed1840 --- /dev/null +++ b/platform/features/timeline/bundle.json @@ -0,0 +1,372 @@ +{ + "name": "WARP Timeline", + "description": "Resources, templates, CSS, and code for Timelines.", + "resources": "res", + "extensions": { + "constants": [ + { + "key": "TIMELINE_MINIMUM_DURATION", + "description": "The minimum duration to display in a timeline view (one hour.)", + "value": 3600000 + }, + { + "key": "TIMELINE_MAXIMUM_OFFSCREEN", + "description": "Maximum amount, in pixels, of a Gantt bar which may go off screen.", + "value": 1000 + }, + { + "key": "TIMELINE_ZOOM_CONFIGURATION", + "description": "Describes major tick sizes in milliseconds, and width in pixels.", + "value": { + "levels": [ + 1000, + 2000, + 5000, + + 10000, + 20000, + 30000, + 60000, + + 120000, + 300000, + 600000, + + 1200000, + 1800000, + 3600000, + 7200000, + + 14400000, + 28800000, + 43200000, + 86400000 + ], + "width": 200 + } + } + ], + "types": [ + { + "key": "warp.timeline", + "name": "Timeline", + "glyph": "S", + "description": "A container for arranging Timelines and Activities in time.", + "features": [ "creation" ], + "contains": [ "warp.timeline", "warp.activity" ], + "properties": [ + { + "name": "Start date/time", + "control": "warp.datetime", + "required": true, + "property": [ "start" ], + "options": [ "SET" ] + }, + { + "name": "Battery capacity (Watt-hours)", + "control": "textfield", + "required": false, + "conversion": "number", + "property": [ "capacity" ], + "pattern": "^-?\\d+(\\.\\d*)?$" + } + ], + "model": { "composition": [] } + }, + { + "key": "warp.activity", + "name": "Activity", + "glyph": "a", + "features": [ "creation" ], + "contains": [ "warp.activity" ], + "description": "An action that takes place in time. You can define a start time and duration. Activities can be nested within other Activities, or within Timelines.", + "properties": [ + { + "name": "Start date/time", + "control": "warp.datetime", + "required": true, + "property": [ "start" ], + "options": [ "SET" ] + }, + { + "name": "Duration", + "control": "warp.duration", + "required": true, + "property": [ "duration" ] + } + ], + "model": { "composition": [], "relationships": { "modes": [] } } + }, + { + "key": "warp.mode", + "name": "Activity Mode", + "glyph": "A", + "features": [ "creation" ], + "description": "Define resource utilizations over time, then apply to an Activity.", + "model": { "resources": { "comms": 0, "power": 0 } }, + "properties": [ + { + "name": "Comms (Kbps)", + "control": "textfield", + "conversion": "number", + "pattern": "^-?\\d+(\\.\\d*)?$", + "property": [ "resources", "comms" ] + }, + { + "name": "Power (watts)", + "control": "textfield", + "conversion": "number", + "pattern": "^-?\\d+(\\.\\d*)?$", + "property": [ "resources", "power" ] + } + ] + } + ], + "views": [ + { + "key": "warp.values", + "name": "Values", + "glyph": "A", + "templateUrl": "templates/values.html", + "type": "warp.mode", + "uses": [ "cost" ], + "editable": false + }, + { + "key": "warp.timeline", + "name": "Timeline", + "glyph": "S", + "type": "warp.timeline", + "description": "A timeline view of Timelines and Activities.", + "templateUrl": "templates/timeline.html", + "toolbar": { + "sections": [ + { + "items": [ + { + "method": "add", + "glyph": "+", + "control": "menu-button", + "text": "Add", + "options": [ + { + "name": "Timeline", + "glyph": "S", + "key": "warp.timeline" + }, + { + "name": "Activity", + "glyph": "a", + "key": "warp.activity" + } + ] + } + ] + }, + { + "items": [ + { + "glyph": "\u00E9", + "description": "Graph resource utilization", + "control": "button", + "method": "toggleGraph" + }, + { + "glyph": "A", + "control": "dialog-button", + "description": "Apply Activity Modes...", + "title": "Apply Activity Modes", + "dialog": { + "control": "selector", + "name": "Modes", + "type": "warp.mode" + }, + "property": "modes" + }, + { + "glyph": "\u00E8", + "description": "Edit Activity Link", + "title": "Activity Link", + "control": "dialog-button", + "dialog": { + "control": "textfield", + "name": "Link", + "pattern": "^(ftp|https?)\\:\\/\\/\\w+(\\.\\w+)*(\\:\\d+)?(\\/\\S*)*$" + }, + "property": "link" + }, + { + "glyph": "\u0047", + "description": "Edit Properties...", + "control": "button", + "method": "properties" + } + ] + }, + { + "items": [ + { + "method": "remove", + "description": "Remove item", + "control": "button", + "glyph": "Z" + } + ] + } + ] + } + } + ], + "representations": [ + { + "key": "warp.gantt", + "templateUrl": "templates/activity-gantt.html", + "uses": [ "timespan", "type" ] + } + ], + "templates": [ + { + "key": "timeline-tabular-swimlane-cols-tree", + "priority": "mandatory", + "templateUrl": "templates/tabular-swimlane-cols-tree.html" + }, + { + "key": "timeline-tabular-swimlane-cols-data", + "priority": "mandatory", + "templateUrl": "templates/tabular-swimlane-cols-data.html" + }, + { + "key": "timeline-resource-graphs", + "priority": "mandatory", + "templateUrl": "templates/resource-graphs.html" + }, + { + "key": "timeline-resource-graph-labels", + "priority": "mandatory", + "templateUrl": "templates/resource-graph-labels.html" + }, + { + "key": "timeline-legend-item", + "priority": "mandatory", + "templateUrl": "templates/legend-item.html" + }, + { + "key": "timeline-ticks", + "priority": "mandatory", + "templateUrl": "templates/ticks.html" + } + ], + "controls": [ + { + "key": "warp.datetime", + "templateUrl": "templates/controls/datetime.html" + }, + { + "key": "warp.duration", + "templateUrl": "templates/controls/datetime.html" + } + ], + "controllers": [ + { + "key": "TimelineController", + "implementation": "controllers/TimelineController.js", + "depends": [ "$scope", "$q", "warp.objectLoader", "TIMELINE_MINIMUM_DURATION" ] + }, + { + "key": "TimelineGraphController", + "implementation": "controllers/TimelineGraphController.js", + "depends": [ "$scope", "warp.resources[]" ] + }, + { + "key": "WARPDateTimeController", + "implementation": "controllers/WARPDateTimeController.js", + "depends": [ "$scope" ] + }, + { + "key": "TimelineZoomController", + "implementation": "controllers/TimelineZoomController.js", + "depends": [ "$scope", "TIMELINE_ZOOM_CONFIGURATION" ] + }, + { + "key": "TimelineTickController", + "implementation": "controllers/TimelineTickController.js" + }, + { + "key": "TimelineTableController", + "implementation": "controllers/TimelineTableController.js" + }, + { + "key": "TimelineGanttController", + "implementation": "controllers/TimelineGanttController.js", + "depends": [ "TIMELINE_MAXIMUM_OFFSCREEN" ] + }, + { + "key": "ActivityModeValuesController", + "implementation": "controllers/ActivityModeValuesController.js", + "depends": [ "warp.resources[]" ] + } + ], + "capabilities": [ + { + "key": "timespan", + "implementation": "capabilities/ActivityTimespanCapability.js", + "depends": [ "$q" ] + }, + { + "key": "timespan", + "implementation": "capabilities/TimelineTimespanCapability.js", + "depends": [ "$q" ] + }, + { + "key": "utilization", + "implementation": "capabilities/UtilizationCapability.js", + "depends": [ "$q" ] + }, + { + "key": "graph", + "implementation": "capabilities/GraphCapability.js", + "depends": [ "$q" ] + }, + { + "key": "cost", + "implementation": "capabilities/CostCapability.js" + } + ], + "directives": [ + { + "key": "warpSwimlaneDrop", + "implementation": "directives/WARPSwimlaneDrop.js", + "depends": [ "dndService" ] + }, + { + "key": "warpSwimlaneDrag", + "implementation": "directives/WARPSwimlaneDrag.js", + "depends": [ "dndService" ] + } + ], + "services": [ + { + "key": "warp.objectLoader", + "implementation": "services/ObjectLoader.js", + "depends": [ "$q" ] + } + ], + "warp.resources": [ + { + "key": "power", + "name": "Power", + "units": "watts" + }, + { + "key": "comms", + "name": "Comms", + "units": "Kbps" + }, + { + "key": "battery", + "name": "Battery State-of-Charge", + "units": "%" + } + ] + } +} diff --git a/platform/features/timeline/res/templates/activity-gantt.html b/platform/features/timeline/res/templates/activity-gantt.html new file mode 100644 index 0000000000..3edc77e567 --- /dev/null +++ b/platform/features/timeline/res/templates/activity-gantt.html @@ -0,0 +1,18 @@ +
+ +
+ + {{type.getGlyph()}} + + + {{model.name}} + +
+ +
diff --git a/platform/features/timeline/res/templates/controls/datetime.html b/platform/features/timeline/res/templates/controls/datetime.html new file mode 100644 index 0000000000..65386ed3ef --- /dev/null +++ b/platform/features/timeline/res/templates/controls/datetime.html @@ -0,0 +1,62 @@ +
+ +
+ Days + Hours + Minutes + Seconds + Time System +
+ + +
+ + + + + + + + + + + + + + SET + +
+
+ + +
\ No newline at end of file diff --git a/platform/features/timeline/res/templates/legend-item.html b/platform/features/timeline/res/templates/legend-item.html new file mode 100644 index 0000000000..9908e701ba --- /dev/null +++ b/platform/features/timeline/res/templates/legend-item.html @@ -0,0 +1,13 @@ + + + + + + {{ngModel.path}} + {{ngModel.domainObject.getModel().name}} + + \ No newline at end of file diff --git a/platform/features/timeline/res/templates/resource-graph-labels.html b/platform/features/timeline/res/templates/resource-graph-labels.html new file mode 100644 index 0000000000..918643f691 --- /dev/null +++ b/platform/features/timeline/res/templates/resource-graph-labels.html @@ -0,0 +1,16 @@ +
+ {{parameters.title}} +
+
+
+
+ {{parameters.high}} +
+
+ {{parameters.middle}} +
+
+ {{parameters.low}} +
+
+
\ No newline at end of file diff --git a/platform/features/timeline/res/templates/resource-graphs.html b/platform/features/timeline/res/templates/resource-graphs.html new file mode 100644 index 0000000000..317d4976ac --- /dev/null +++ b/platform/features/timeline/res/templates/resource-graphs.html @@ -0,0 +1,13 @@ + +
+
+ +
+
+ + +
+
+
\ No newline at end of file diff --git a/platform/features/timeline/res/templates/tabular-swimlane-cols-data.html b/platform/features/timeline/res/templates/tabular-swimlane-cols-data.html new file mode 100644 index 0000000000..63c618974f --- /dev/null +++ b/platform/features/timeline/res/templates/tabular-swimlane-cols-data.html @@ -0,0 +1,16 @@ +
+
+ {{tabularVal.niceTime(ngModel.timespan().getStart())}} + {{tabularVal.niceTime(ngModel.timespan().getEnd())}} + {{tabularVal.niceTime(ngModel.timespan().getDuration())}} + +
+
\ No newline at end of file diff --git a/platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html b/platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html new file mode 100644 index 0000000000..de071ed4f8 --- /dev/null +++ b/platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html @@ -0,0 +1,36 @@ +
+
+ + + é + + + + + è + + + + + + +
+
\ No newline at end of file diff --git a/platform/features/timeline/res/templates/ticks.html b/platform/features/timeline/res/templates/ticks.html new file mode 100644 index 0000000000..6726c831b8 --- /dev/null +++ b/platform/features/timeline/res/templates/ticks.html @@ -0,0 +1,18 @@ + diff --git a/platform/features/timeline/res/templates/timeline.html b/platform/features/timeline/res/templates/timeline.html new file mode 100644 index 0000000000..f0e29c577f --- /dev/null +++ b/platform/features/timeline/res/templates/timeline.html @@ -0,0 +1,197 @@ +
+ + + + + +
+ +
+ + +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+ + + + + +
+
{{ngModel.title}}Resource Graph Legend
+
+ + +
+
+
+ + + + + + + + + + + +
+ + +
+ + +
+ + +
+
+
+ + + + + + + + + +
+
+
+ + +
+ + + + + +
+ +
+ + +
+ + +
+ +
+ +
+
+
+
+
+
+
+
+
diff --git a/platform/features/timeline/res/templates/values.html b/platform/features/timeline/res/templates/values.html new file mode 100644 index 0000000000..01dc980444 --- /dev/null +++ b/platform/features/timeline/res/templates/values.html @@ -0,0 +1,6 @@ +
    +
  • + {{controller.metadata(key).name}} + {{value}} {{controller.metadata(key).units}} +
  • +
\ No newline at end of file diff --git a/platform/features/timeline/src/TimelineConstants.js b/platform/features/timeline/src/TimelineConstants.js new file mode 100644 index 0000000000..c68f9c2240 --- /dev/null +++ b/platform/features/timeline/src/TimelineConstants.js @@ -0,0 +1,11 @@ +/*global define*/ + +/** + * Defines constant values for use in timeline view. + */ +define({ + // Pixel width of start/end handles + HANDLE_WIDTH: 32, + // Pixel tolerance for snapping behavior + SNAP_WIDTH: 16 +}); \ No newline at end of file diff --git a/platform/features/timeline/src/TimelineFormatter.js b/platform/features/timeline/src/TimelineFormatter.js new file mode 100644 index 0000000000..ccbf63b8ca --- /dev/null +++ b/platform/features/timeline/src/TimelineFormatter.js @@ -0,0 +1,57 @@ +/*global define*/ + +define( + [], + function () { + 'use strict'; + + // Conversion factors from time units to milliseconds + var SECONDS = 1000, + MINUTES = SECONDS * 60, + HOURS = MINUTES * 60, + DAYS = HOURS * 24; + + /** + * Formatters for durations shown in a timeline view. + * @constructor + */ + function TimelineFormatter() { + + // Format a numeric value to a string with some number of digits + function formatValue(value, digits) { + var v = value.toString(10); + // Pad with zeroes + while (v.length < digits) { + v = "0" + v; + } + return v; + } + + // Format duration to string + function formatDuration(duration) { + var days = Math.floor(duration / DAYS), + hours = Math.floor(duration / HOURS) % 24, + minutes = Math.floor(duration / MINUTES) % 60, + seconds = Math.floor(duration / SECONDS) % 60, + millis = Math.floor(duration) % 1000; + + return formatValue(days, 3) + " " + + formatValue(hours, 2) + ":" + + formatValue(minutes, 2) + ":" + + formatValue(seconds, 2) + "." + + formatValue(millis, 3); + } + + return { + /** + * Format the provided duration. + * @param {number} duration duration, in milliseconds + * @returns {string} displayable representation of duration + */ + format: formatDuration + }; + } + + return TimelineFormatter; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/ActivityTimespan.js b/platform/features/timeline/src/capabilities/ActivityTimespan.js new file mode 100644 index 0000000000..69b0039b2d --- /dev/null +++ b/platform/features/timeline/src/capabilities/ActivityTimespan.js @@ -0,0 +1,100 @@ +/*global define*/ + +define( + [], + function () { + 'use strict'; + + /** + * Describes the time span of an activity object. + * @param model the activity's object model + */ + function ActivityTimespan(model, mutation) { + // Get the start time for this timeline + function getStart() { + return model.start.timestamp; + } + + // Get the end time for this timeline + function getEnd() { + return model.start.timestamp + model.duration.timestamp; + } + + // Get the duration of this timeline + function getDuration() { + return model.duration.timestamp; + } + + // Get the epoch used by this timeline + function getEpoch() { + return model.start.epoch; // Surface elapsed time + } + + // Set the start time associated with this object + function setStart(value) { + var end = getEnd(); + mutation.mutate(function (model) { + model.start.timestamp = Math.max(value, 0); + // Update duration to keep end time + model.duration.timestamp = Math.max(end - value, 0); + }, model.modified); + } + + // Set the duration associated with this object + function setDuration(value) { + mutation.mutate(function (model) { + model.duration.timestamp = Math.max(value, 0); + }, model.modified); + } + + // Set the end time associated with this object + function setEnd(value) { + var start = getStart(); + mutation.mutate(function (model) { + model.duration.timestamp = Math.max(value - start, 0); + }, model.modified); + } + + return { + /** + * Get the start time, in milliseconds relative to the epoch. + * @returns {number} the start time + */ + getStart: getStart, + /** + * Get the duration, in milliseconds. + * @returns {number} the duration + */ + getDuration: getDuration, + /** + * Get the end time, in milliseconds relative to the epoch. + * @returns {number} the end time + */ + getEnd: getEnd, + /** + * Set the start time, in milliseconds relative to the epoch. + * @param {number} the new value + */ + setStart: setStart, + /** + * Set the duration, in milliseconds. + * @param {number} the new value + */ + setDuration: setDuration, + /** + * Set the end time, in milliseconds relative to the epoch. + * @param {number} the new value + */ + setEnd: setEnd, + /** + * Get a string identifying the reference epoch used for + * start and end times. + * @returns {string} the epoch + */ + getEpoch: getEpoch + }; + } + + return ActivityTimespan; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/ActivityTimespanCapability.js b/platform/features/timeline/src/capabilities/ActivityTimespanCapability.js new file mode 100644 index 0000000000..f4927a10dd --- /dev/null +++ b/platform/features/timeline/src/capabilities/ActivityTimespanCapability.js @@ -0,0 +1,42 @@ +/*global define*/ + +define( + ['./ActivityTimespan'], + function (ActivityTimespan) { + 'use strict'; + + /** + * Implements the `warp.timespan` capability for Activity objects. + * + * @constructor + * @param $q Angular's $q, for promise-handling + * @param {DomainObject} domainObject the Activity + */ + function ActivityTimespanCapability($q, domainObject) { + // Promise time span + function promiseTimeSpan() { + return $q.when(new ActivityTimespan( + domainObject.getModel(), + domainObject.getCapability('mutation') + )); + } + + return { + /** + * Get the time span (start, end, duration) of this activity. + * @returns {Promise.} the time span of + * this activity + */ + invoke: promiseTimeSpan + }; + } + + // Only applies to timeline objects + ActivityTimespanCapability.appliesTo = function (model) { + return model && (model.type === 'warp.activity'); + }; + + return ActivityTimespanCapability; + + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/ActivityUtilization.js b/platform/features/timeline/src/capabilities/ActivityUtilization.js new file mode 100644 index 0000000000..bf77cb1ff1 --- /dev/null +++ b/platform/features/timeline/src/capabilities/ActivityUtilization.js @@ -0,0 +1,31 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Provides data to populate resource graphs associated + * with activities in a timeline view. + * This is a placeholder until WTD-918. + * @constructor + */ + function ActivityUtilization() { + return { + getPointCount: function () { + return 0; + }, + getDomainValue: function (index) { + return 0; + }, + getRangeValue: function (index) { + return 0; + } + }; + } + + return ActivityUtilization; + } + +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/CostCapability.js b/platform/features/timeline/src/capabilities/CostCapability.js new file mode 100644 index 0000000000..56d2dafaa7 --- /dev/null +++ b/platform/features/timeline/src/capabilities/CostCapability.js @@ -0,0 +1,56 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Exposes costs associated with a subsystem mode. + * @constructor + */ + function CostCapability(domainObject) { + var model = domainObject.getModel(); + + return { + /** + * Get a list of resource types which have associated + * costs for this object. Returned values are machine-readable + * keys, and should be paired with external metadata for + * presentation (see category of extension `warp.resources`). + * @returns {string[]} resource types + */ + resources: function () { + return Object.keys(model.resources || {}).sort(); + }, + /** + * Get the cost associated with a resource of an identified + * type (typically, one of the types reported from a + * `resources` call.) + * @param {string} key the resource type + * @returns {number} the associated cost + */ + cost: function (key) { + return (model.resources || {})[key] || 0; + }, + /** + * Get an object containing key-value pairs describing + * resource utilization as described by this object. + * Keys are resource types; values are levels of associated + * resource utilization. + * @returns {object} resource utilizations + */ + invoke: function () { + return model.resources || {}; + } + }; + } + + // Only applies to subsystem modes. + CostCapability.appliesTo = function (model) { + return (model || {}).type === 'warp.mode'; + }; + + return CostCapability; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/CumulativeGraph.js b/platform/features/timeline/src/capabilities/CumulativeGraph.js new file mode 100644 index 0000000000..b6694e3834 --- /dev/null +++ b/platform/features/timeline/src/capabilities/CumulativeGraph.js @@ -0,0 +1,134 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Provide points for a cumulative resource summary graph, using + * a provided instantaneous resource summary graph. + * + * @param {ResourceGraph} graph the resource graph + * @param {number} minimum the minimum allowable level + * @param {number} maximum the maximum allowable level + * @param {number} initial the initial state of the resource + * @param {number} rate the rate at which one unit of instantaneous + * utilization changes the available level in one unit + * of domain values (that is, per millisecond) + * @constructor + */ + function CumulativeGraph(graph, minimum, maximum, initial, rate) { + var values; + + // Calculate the domain value at which a line starting at + // (domain, range) and proceeding with the specified slope + // will have the specified range value. + function intercept(domain, range, slope, value) { + // value = slope * (intercept - domain) + range + // value - range = slope * ... + // intercept - domain = (value - range) / slope + // intercept = domain + (value - range) / slope + return domain + (value - range) / slope; + } + + // Initialize the data values + function initializeValues() { + var values = [], + slope = 0, + previous = 0, + i; + + // Add a point (or points, if needed) reaching to the provided + // domain and/or range value + function addPoint(domain, range) { + var previous = values[values.length - 1], + delta = domain - previous.domain, // time delta + change = delta * slope * rate, // change + next = previous.range + change; + + // Crop to minimum boundary... + if (next < minimum) { + values.push({ + domain: intercept( + previous.domain, + previous.range, + slope * rate, + minimum + ), + range: minimum + }); + next = minimum; + } + + // ...and maximum boundary + if (next > maximum) { + values.push({ + domain: intercept( + previous.domain, + previous.range, + slope * rate, + maximum + ), + range: maximum + }); + next = maximum; + } + + // Add the new data value + if (delta > 0) { + values.push({ domain: domain, range: next }); + } + + slope = range; + } + + values.push({ domain: 0, range: initial }); + + for (i = 0; i < graph.getPointCount(); i += 1) { + addPoint(graph.getDomainValue(i), graph.getRangeValue(i)); + } + + return values; + } + + function convertToPercent(point) { + point.range = 100 * + (point.range - minimum) / (maximum - minimum); + } + + // Calculate cumulative values... + values = initializeValues(); + + // ...and convert to percentages. + values.forEach(convertToPercent); + + return { + /** + * Get the total number of points in this graph. + * @returns {number} the total number of points + */ + getPointCount: function () { + return values.length; + }, + /** + * Get the domain value (timestamp) for a point in this graph. + * @returns {number} the domain value + */ + getDomainValue: function (index) { + return values[index].domain; + }, + /** + * Get the range value (utilization level) for a point in + * this graph. + * @returns {number} the range value + */ + getRangeValue: function (index) { + return values[index].range; + } + }; + } + + return CumulativeGraph; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/GraphCapability.js b/platform/features/timeline/src/capabilities/GraphCapability.js new file mode 100644 index 0000000000..fb59272678 --- /dev/null +++ b/platform/features/timeline/src/capabilities/GraphCapability.js @@ -0,0 +1,78 @@ +/*global define*/ + +define( + ['./ResourceGraph', './CumulativeGraph'], + function (ResourceGraph, CumulativeGraph) { + 'use strict'; + + /** + * Implements the `graph` capability for Timeline and + * Activity objects. + * + * @constructor + * @param {DomainObject} domainObject the Timeline or Activity + */ + function GraphCapability($q, domainObject) { + + + // Build graphs for this group of utilizations + function buildGraphs(utilizations) { + var utilizationMap = {}, + result = {}; + + // Bucket utilizations by type + utilizations.forEach(function (u) { + var k = u.key; + utilizationMap[k] = utilizationMap[k] || []; + utilizationMap[k].push(u); + }); + + // ...then convert to graphs + Object.keys(utilizationMap).forEach(function (k) { + result[k] = new ResourceGraph(utilizationMap[k]); + }); + + // Add battery state of charge + if (domainObject.getModel().type === 'warp.timeline' && + result.power && + domainObject.getModel().capacity > 0) { + + result.battery = new CumulativeGraph( + result.power, + 0, + domainObject.getModel().capacity, // Watts + domainObject.getModel().capacity, + 1 / 3600000 // millis-to-hour (since units are watt-hours) + ); + } + + return result; + } + + return { + /** + * Get resource graphs associated with this object. + * This is given as a promise for key-value pairs, + * where keys are resource types and values are graph + * objects. + * @returns {Promise} a promise for resource graphs + */ + invoke: function () { + return $q.when( + domainObject.useCapability('utilization') || [] + ).then(buildGraphs); + } + }; + } + + // Only applies to timeline objects + GraphCapability.appliesTo = function (model) { + return model && + ((model.type === 'warp.timeline') || + (model.type === 'warp.activity')); + }; + + return GraphCapability; + + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/ResourceGraph.js b/platform/features/timeline/src/capabilities/ResourceGraph.js new file mode 100644 index 0000000000..7fd135ebbf --- /dev/null +++ b/platform/features/timeline/src/capabilities/ResourceGraph.js @@ -0,0 +1,128 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + // Utility function to copy an array, sorted by a specific field + function sort(array, field) { + return array.slice().sort(function (a, b) { + return a[field] - b[field]; + }); + } + + /** + * Provides data to populate resource graphs associated + * with timelines and activities. + * @param {Array} utilizations resource utilizations + * @constructor + */ + function ResourceGraph(utilizations) { + // Overview of algorithm here: + // * Goal: Have a list of time/value pairs which represents + // points along a stepped chart of resource utilization. + // Each change (stepping up or down) should have two points, + // at the bottom and top of the step respectively. + // * Step 1: Prepare two lists of utilizations sorted by start + // and end times. The "starts" will become step-ups, the + // "ends" will become step-downs. + // * Step 2: Initialize empty arrays for results, and a variable + // for the current utilization level. + // * Step 3: While there are still start or end times to add... + // * Step 3a: Determine whether the next change should be a + // step-up (start) or step-down (end) based on which of the + // next start/end times comes next (note that starts and ends + // are both sorted, so we look at the head of the array.) + // * Step 3b: Pull the next start or end (per previous decision) + // and convert it to a time-delta pair, negating if it's an + // end time (to step down or "un-step") + // * Step 3c: Add a point at the new time and the current + // running total (first point in the step, before the change) + // then increment the running total and add a new point + // (second point in the step, after the change) + // * Step 4: Filter out unnecessary points (if two activities + // run up against each other, there will be a zero-duration + // spike if we don't filter out the extra points from their + // start/end times.) + // + var starts = sort(utilizations, "start"), + ends = sort(utilizations, "end"), + values = [], + running = 0; + + // If there are sequences of points with the same timestamp, + // allow only the first and last. + function filterPoint(value, index, values) { + // Allow the first or last point as a base case; aside from + // that, allow only points that have different timestamps + // from their predecessor or successor. + return (index === 0) || (index === values.length - 1) || + (value.domain !== values[index - 1].domain) || + (value.domain !== values[index + 1].domain); + } + + // Add a step up or down (Step 3c above) + function addDelta(time, delta) { + values.push({ domain: time, range: running }); + running += delta; + values.push({ domain: time, range: running }); + } + + // Add a start time (Step 3b above) + function addStart() { + var next = starts.shift(); + addDelta(next.start, next.value); + } + + // Add an end time (Step 3b above) + function addEnd() { + var next = ends.shift(); + addDelta(next.end, -next.value); + } + + // Decide whether next step should correspond to a start or + // an end. (Step 3c above) + function pickStart() { + return ends.length < 1 || + (starts.length > 0 && starts[0].start <= ends[0].end); + } + + // Build up start/end arrays (step 3 above) + while (starts.length > 0 || ends.length > 0) { + (pickStart() ? addStart : addEnd)(); + } + + // Filter out excess points + values = values.filter(filterPoint); + + return { + /** + * Get the total number of points in this graph. + * @returns {number} the total number of points + */ + getPointCount: function () { + return values.length; + }, + /** + * Get the domain value (timestamp) for a point in this graph. + * @returns {number} the domain value + */ + getDomainValue: function (index) { + return values[index].domain; + }, + /** + * Get the range value (utilization level) for a point in + * this graph. + * @returns {number} the range value + */ + getRangeValue: function (index) { + return values[index].range; + } + }; + } + + return ResourceGraph; + } + +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/TimelineTimespan.js b/platform/features/timeline/src/capabilities/TimelineTimespan.js new file mode 100644 index 0000000000..9cb427f678 --- /dev/null +++ b/platform/features/timeline/src/capabilities/TimelineTimespan.js @@ -0,0 +1,105 @@ +/*global define*/ + +define( + [], + function () { + 'use strict'; + + /** + * Describes the time span of a timeline object. + * @param model the timeline's object model + * @param {Timespan[]} time spans of contained activities + */ + function TimelineTimespan(model, mutation, timespans) { + // Get the start time for this timeline + function getStart() { + return model.start.timestamp; + } + + // Get the end time for another time span + function getTimespanEnd(timespan) { + return timespan.getEnd(); + } + + // Wrapper for Math.max; used for max-finding of end time + function max(a, b) { + return Math.max(a, b); + } + + // Get the end time for this timeline + function getEnd() { + return timespans.map(getTimespanEnd).reduce(max, getStart()); + } + + // Get the duration of this timeline + function getDuration() { + return getEnd() - getStart(); + } + + // Set the start time associated with this object + function setStart(value) { + mutation.mutate(function (model) { + model.start.timestamp = Math.max(value, 0); + }, model.modified); + } + + // Set the duration associated with this object + function setDuration(value) { + // No-op; duration is implicit + } + + // Set the end time associated with this object + function setEnd(value) { + // No-op; end time is implicit + } + + // Get the epoch used by this timeline + function getEpoch() { + return model.start.epoch; + } + + return { + /** + * Get the start time, in milliseconds relative to the epoch. + * @returns {number} the start time + */ + getStart: getStart, + /** + * Get the duration, in milliseconds. + * @returns {number} the duration + */ + getDuration: getDuration, + /** + * Get the end time, in milliseconds relative to the epoch. + * @returns {number} the end time + */ + getEnd: getEnd, + /** + * Set the start time, in milliseconds relative to the epoch. + * @param {number} the new value + */ + setStart: setStart, + /** + * Set the duration, in milliseconds. Timeline durations are + * implicit, so this is actually a no-op + * @param {number} the new value + */ + setDuration: setDuration, + /** + * Set the end time, in milliseconds. Timeline end times are + * implicit, so this is actually a no-op. + * @param {number} the new value + */ + setEnd: setEnd, + /** + * Get a string identifying the reference epoch used for + * start and end times. + * @returns {string} the epoch + */ + getEpoch: getEpoch + }; + } + + return TimelineTimespan; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/TimelineTimespanCapability.js b/platform/features/timeline/src/capabilities/TimelineTimespanCapability.js new file mode 100644 index 0000000000..30ca28b873 --- /dev/null +++ b/platform/features/timeline/src/capabilities/TimelineTimespanCapability.js @@ -0,0 +1,68 @@ +/*global define*/ + +define( + ['./TimelineTimespan'], + function (TimelineTimespan) { + 'use strict'; + + /** + * Implements the `timespan` capability for Timeline objects. + * + * @constructor + * @param $q Angular's $q, for promise-handling + * @param {DomainObject} domainObject the Timeline + */ + function TimelineTimespanCapability($q, domainObject) { + // Check if a capability is defin + + // Look up a child object's time span + function lookupTimeSpan(childObject) { + return childObject.useCapability('timespan'); + } + + // Check if a child object exposes a time span + function hasTimeSpan(childObject) { + return childObject.hasCapability('timespan'); + } + + // Instantiate a time span bounding other time spans + function giveTimeSpan(timespans) { + return new TimelineTimespan( + domainObject.getModel(), + domainObject.getCapability('mutation'), + timespans + ); + } + + // Build a time span object that fits all children + function buildTimeSpan(childObjects) { + return $q.all( + childObjects.filter(hasTimeSpan).map(lookupTimeSpan) + ).then(giveTimeSpan); + } + + // Promise + function promiseTimeSpan() { + return domainObject.useCapability('composition') + .then(buildTimeSpan); + } + + return { + /** + * Get the time span (start, end, duration) of this timeline. + * @returns {Promise.} the time span of + * this timeline + */ + invoke: promiseTimeSpan + }; + } + + // Only applies to timeline objects + TimelineTimespanCapability.appliesTo = function (model) { + return model && (model.type === 'warp.timeline'); + }; + + return TimelineTimespanCapability; + + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/TimelineUtilization.js b/platform/features/timeline/src/capabilities/TimelineUtilization.js new file mode 100644 index 0000000000..d6a3e649ba --- /dev/null +++ b/platform/features/timeline/src/capabilities/TimelineUtilization.js @@ -0,0 +1,31 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Provides data to populate resource graphs associated + * with timelines in a timeline view. + * This is a placeholder until WTD-918. + * @constructor + */ + function TimelineUtilization() { + return { + getPointCount: function () { + return 1000; + }, + getDomainValue: function (index) { + return 60000 * index; + }, + getRangeValue: function (index) { + return Math.sin(index) * (index % 10); + } + }; + } + + return TimelineUtilization; + } + +); \ No newline at end of file diff --git a/platform/features/timeline/src/capabilities/UtilizationCapability.js b/platform/features/timeline/src/capabilities/UtilizationCapability.js new file mode 100644 index 0000000000..4add7de939 --- /dev/null +++ b/platform/features/timeline/src/capabilities/UtilizationCapability.js @@ -0,0 +1,198 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Provide the resource utilization over time for a timeline + * or activity object. A utilization is presented as an object + * with four properties: + * * `key`: The resource being utilized. + * * `value`: The numeric utilization of that resource. + * * `start`: The start time of the resource's utilization. + * * `end`: The duration of this resource's utilization. + * * `epoch`: The epoch to which `start` is relative. + * @constructor + */ + function UtilizationCapability($q, domainObject) { + + // Utility function for array reduction + function concatenate(a, b) { + return (a || []).concat(b || []); + } + + // Check whether an element in an array looks unique (for below) + function unique(element, index, array) { + return (index === 0) || (array[index - 1] !== element); + } + + // Utility function to ensure sorted array is all unique + function uniquify(array) { + return array.filter(unique); + } + + // Utility function for sorting strings arrays + function sort(array) { + return array.sort(); + } + + // Combine into one big array + function flatten(arrayOfArrays) { + return arrayOfArrays.reduce(concatenate, []); + } + + // Promise the objects contained by this timeline/activity + function promiseComposition() { + return $q.when(domainObject.useCapability('composition') || []); + } + + // Promise all subsystem modes associated with this object + function promiseModes() { + var relationship = domainObject.getCapability('relationship'), + modes = relationship && relationship.getRelatedObjects('modes'); + return $q.when(modes || []); + } + + // Promise the utilization which results directly from this object + function promiseInternalUtilization() { + var utilizations = {}; + + // Record the cost of a given activity mode + function addUtilization(mode) { + var cost = mode.getCapability('cost'); + if (cost) { + cost.resources().forEach(function (k) { + utilizations[k] = utilizations[k] || 0; + utilizations[k] += cost.cost(k); + }); + } + } + + // Record costs for these modes + function addUtilizations(modes) { + modes.forEach(addUtilization); + } + + // Look up start/end times for this object + function lookupTimespan() { + return domainObject.useCapability('timespan'); + } + + // Provide the result + function giveResult(timespan) { + // Convert to utilization objects + return Object.keys(utilizations).sort().map(function (k) { + return { + key: k, + value: utilizations[k], + start: timespan.getStart(), + end: timespan.getEnd(), + epoch: timespan.getEpoch() + }; + }); + } + + return promiseModes() + .then(addUtilizations) + .then(lookupTimespan) + .then(giveResult); + } + + // Look up a specific object's resource utilization + function lookupUtilization(domainObject) { + return domainObject.useCapability('utilization'); + } + + // Look up a specific object's resource utilization keys + function lookupUtilizationResources(domainObject) { + var utilization = domainObject.getCapability('utilization'); + return utilization && utilization.resources(); + } + + // Promise a consolidated list of resource utilizations + function mapUtilization(objects) { + return $q.all(objects.map(lookupUtilization)) + .then(flatten); + } + + // Promise a consolidated list of resource utilization keys + function mapUtilizationResources(objects) { + return $q.all(objects.map(lookupUtilizationResources)) + .then(flatten); + } + + // Promise utilization associated with contained objects + function promiseExternalUtilization() { + // Get the composition, then consolidate their utilizations + return promiseComposition().then(mapUtilization); + } + + // Get resource keys for this mode + function getModeKeys(mode) { + var cost = mode.getCapability('cost'); + return cost ? cost.resources() : []; + } + + // Map the above (for use in below) + function mapModeKeys(modes) { + return modes.map(getModeKeys); + } + + // Promise identifiers for resources associated with modes + function promiseInternalKeys() { + return promiseModes().then(mapModeKeys).then(flatten); + } + + // Promise identifiers for resources associated with modes + function promiseExternalKeys() { + return promiseComposition().then(mapUtilizationResources); + } + + // Promise identifiers for resources used + function promiseResourceKeys() { + return $q.all([ + promiseInternalKeys(), + promiseExternalKeys() + ]).then(flatten).then(sort).then(uniquify); + } + + // Promise all utilization + function promiseAllUtilization() { + // Concatenate internal utilization (from activity modes) + // with external utilization (from subactivities) + return $q.all([ + promiseInternalUtilization(), + promiseExternalUtilization() + ]).then(flatten); + } + + return { + /** + * Get the keys for resources associated with this object. + * @returns {Promise.} a promise for resource identifiers + */ + resources: promiseResourceKeys, + /** + * Get the resource utilization associated with this + * object. Results are not sorted. This requires looking + * at contained objects, which in turn must happen + * asynchronously, so this returns a promise. + * @returns {Promise.} a promise for all resource + * utilizations + */ + invoke: promiseAllUtilization + }; + } + + // Only applies to timelines and activities + UtilizationCapability.appliesTo = function (model) { + return model && + ((model.type === 'warp.timeline') || + (model.type === 'warp.activity')); + }; + + return UtilizationCapability; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/ActivityModeValuesController.js b/platform/features/timeline/src/controllers/ActivityModeValuesController.js new file mode 100644 index 0000000000..be6d461d09 --- /dev/null +++ b/platform/features/timeline/src/controllers/ActivityModeValuesController.js @@ -0,0 +1,41 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Controller which support the Values view of Activity Modes. + * @constructor + * @param {Array} resources definitions for extensions of + * category `warp.resources` + */ + function ActivityModeValuesController(resources) { + var metadata = {}; + + // Store metadata for a specific resource type + function storeMetadata(resource) { + var key = (resource || {}).key; + if (key) { + metadata[key] = resource; + } + } + + // Populate the lookup table to resource metadata + resources.forEach(storeMetadata); + + return { + /** + * Look up metadata associated with the specified + * resource type. + */ + metadata: function (key) { + return metadata[key]; + } + }; + } + + return ActivityModeValuesController; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/TimelineController.js b/platform/features/timeline/src/controllers/TimelineController.js new file mode 100644 index 0000000000..0ce156888f --- /dev/null +++ b/platform/features/timeline/src/controllers/TimelineController.js @@ -0,0 +1,128 @@ +/*global define*/ + +define( + [ + './swimlane/TimelineSwimlanePopulator', + './graph/TimelineGraphPopulator', + './drag/TimelineDragPopulator' + ], + function ( + TimelineSwimlanePopulator, + TimelineGraphPopulator, + TimelineDragPopulator + ) { + 'use strict'; + + /** + * Controller for the Timeline view. + * @constructor + */ + function TimelineController($scope, $q, objectLoader, MINIMUM_DURATION) { + var swimlanePopulator = new TimelineSwimlanePopulator( + objectLoader, + $scope.configuration || {}, + $scope.selection + ), + graphPopulator = new TimelineGraphPopulator($q), + dragPopulator = new TimelineDragPopulator(objectLoader); + + // Hash together all modification times. A sum is sufficient here, + // since modified timestamps should be non-decreasing. + function modificationSum() { + var sum = 0; + swimlanePopulator.get().forEach(function (swimlane) { + sum += swimlane.domainObject.getModel().modified || 0; + }); + return sum; + } + + // Reduce graph states to a watch-able number. A bitmask is + // sufficient here, since only ~30 graphed elements make sense + // (due to limits on recognizably unique line colors) + function graphMask() { + var mask = 0, bit = 1; + swimlanePopulator.get().forEach(function (swimlane) { + mask += swimlane.graph() ? 0 : bit; + bit *= 2; + }); + return mask; + } + + // Repopulate based on detected modification to in-view objects + function repopulateSwimlanes() { + swimlanePopulator.populate($scope.domainObject); + dragPopulator.populate($scope.domainObject); + graphPopulator.populate(swimlanePopulator.get()); + } + + // Repopulate graphs based on modification to swimlane graph state + function repopulateGraphs() { + graphPopulator.populate(swimlanePopulator.get()); + } + + // Get pixel width for right pane, using zoom controller + function width(zoomController) { + var start = swimlanePopulator.start(), + end = swimlanePopulator.end(); + return zoomController.toPixels(zoomController.duration( + Math.max(end - start, MINIMUM_DURATION) + )); + } + + // Refresh resource graphs + function refresh() { + if (graphPopulator) { + graphPopulator.get().forEach(function (graph) { + graph.refresh(); + }); + } + } + + // Recalculate swimlane state on changes + $scope.$watch("domainObject", swimlanePopulator.populate); + + // Also recalculate whenever anything in view is modified + $scope.$watch(modificationSum, repopulateSwimlanes); + + // Carry over changes in swimlane set to changes in graphs + $scope.$watch(graphMask, repopulateGraphs); + + // Convey current selection to drag handle populator + $scope.$watch("selection.get()", dragPopulator.select); + + // Provide initial scroll bar state, container for pane positions + $scope.scroll = { x: 0, y: 0 }; + $scope.panes = {}; + + // Expose active set of swimlanes + return { + /** + * Get the width, in pixels, of the timeline area + * @returns {number} width, in pixels + */ + width: width, + /** + * Get the swimlanes which should currently be displayed. + * @returns {TimelineSwimlane[]} the swimlanes + */ + swimlanes: swimlanePopulator.get, + /** + * Get the resource graphs which should currently be displayed. + * @returns {TimelineGraph[]} the graphs + */ + graphs: graphPopulator.get, + /** + * Get drag handles for the current selection. + * @returns {TimelineDragHandle[]} the drag handles + */ + handles: dragPopulator.get, + /** + * Refresh resource graphs (during drag.) + */ + refresh: refresh + }; + } + + return TimelineController; + } +); diff --git a/platform/features/timeline/src/controllers/TimelineGanttController.js b/platform/features/timeline/src/controllers/TimelineGanttController.js new file mode 100644 index 0000000000..0a48ed6087 --- /dev/null +++ b/platform/features/timeline/src/controllers/TimelineGanttController.js @@ -0,0 +1,67 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Control for Gantt bars in a timeline view. + * Primarily reesponsible for supporting the positioning of Gantt + * bars; particularly, this ensures that the left and right edges + * never go to far off screen, because in some environments this + * will effect rendering performance without visible results. + * @constructor + * @param {number} MAXIMUM_OFFSCREEN the maximum number of pixels + * allowed to go off-screen (to either the left or the right) + */ + function TimelineGanttController(MAXIMUM_OFFSCREEN) { + // Pixel position for the CSS left property + function left(timespan, scroll, toPixels) { + return Math.max( + toPixels(timespan.getStart()), + scroll.x - MAXIMUM_OFFSCREEN + ); + } + + // Pixel value for the CSS width property + function width(timespan, scroll, toPixels) { + var x = left(timespan, scroll, toPixels), + right = Math.min( + toPixels(timespan.getEnd()), + scroll.x + scroll.width + MAXIMUM_OFFSCREEN + ); + return right - x; + } + + return { + /** + * Get the pixel position for the `left` style property + * of a Gantt bar for the specified timespan. + * @param {Timespan} timespan the timespan to be represented + * @param scroll an object containing an `x` and `width` + * property, representing the scroll position and + * visible width, respectively. + * @param {Function} toPixels a function to convert + * a timestamp to a pixel position + * @returns {number} the pixel position of the left edge + */ + left: left, + /** + * Get the pixel value for the `width` style property + * of a Gantt bar for the specified timespan. + * @param {Timespan} timespan the timespan to be represented + * @param scroll an object containing an `x` and `width` + * property, representing the scroll position and + * visible width, respectively. + * @param {Function} toPixels a function to convert + * a timestamp to a pixel position + * @returns {number} the pixel width of this Gantt bar + */ + width: width + }; + } + + return TimelineGanttController; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/TimelineGraphController.js b/platform/features/timeline/src/controllers/TimelineGraphController.js new file mode 100644 index 0000000000..5fa870002d --- /dev/null +++ b/platform/features/timeline/src/controllers/TimelineGraphController.js @@ -0,0 +1,76 @@ +/*global define*/ +define( + [], + function () { + 'use strict'; + + /** + * Controller for the graph area of a timeline view. + * The set of graphs to show is provided by the timeline + * controller and communicated into the template via "parameters" + * in scope. + * @constructor + */ + function TimelineGraphController($scope, resources) { + var resourceMap = {}, + labelCache = {}; + + // Add an element to the resource map + function addToResourceMap(resource) { + var key = resource.key; + if (key && !resourceMap[key]) { + resourceMap[key] = resource; + } + } + + // Update the display bounds for all graphs to match + // scroll and/or width. + function updateGraphs(parameters) { + (parameters.graphs || []).forEach(function (graph) { + graph.setBounds(parameters.origin, parameters.duration); + }); + } + + // Add all resources to map for simpler lookup + resources.forEach(addToResourceMap); + + // Update graphs as parameters change + $scope.$watchCollection("parameters", updateGraphs); + + return { + /** + * Get a label object (suitable to pass into the + * `timeline-resource-graph-labels` template) for + * the specified graph. + * @param {TimelineGraph} the graph to label + * @returns {object} an object containing labels + */ + label: function (graph) { + var key = graph.key, + resource = resourceMap[key] || {}, + name = resource.name || "", + units = resource.units, + min = graph.minimum() || 0, + max = graph.maximum() || 0, + label = labelCache[key] || {}; + + // Cache the label (this is passed into a template, + // so avoid excessive digest cycles) + labelCache[key] = label; + + // Include units in title + label.title = name + (units ? (" (" + units + ")") : ""); + + // Provide low, middle, high data values + label.low = min.toFixed(3); + label.middle = ((min + max) / 2).toFixed(3); + label.high = max.toFixed(3); + + return label; + } + }; + } + + return TimelineGraphController; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/TimelineTableController.js b/platform/features/timeline/src/controllers/TimelineTableController.js new file mode 100644 index 0000000000..d38d6e6bdd --- /dev/null +++ b/platform/features/timeline/src/controllers/TimelineTableController.js @@ -0,0 +1,32 @@ +/*global define*/ + +define( + ["../TimelineFormatter"], + function (TimelineFormatter) { + "use strict"; + + var FORMATTER = new TimelineFormatter(); + + /** + * Provides tabular data for the Timeline's tabular view area. + */ + function TimelineTableController() { + + function getNiceTime(millis) { + return FORMATTER.format(millis); + } + + return { + /** + * Return human-readable time in the expected format, + * currently SET. + * @param {number} millis duration, in millisecond + * @return {string} human-readable duration + */ + niceTime: getNiceTime + }; + } + + return TimelineTableController; + } +); diff --git a/platform/features/timeline/src/controllers/TimelineTickController.js b/platform/features/timeline/src/controllers/TimelineTickController.js new file mode 100644 index 0000000000..131ad7722c --- /dev/null +++ b/platform/features/timeline/src/controllers/TimelineTickController.js @@ -0,0 +1,97 @@ +/*global define*/ + +define( + ["../TimelineFormatter"], + function (TimelineFormatter) { + "use strict"; + + var FORMATTER = new TimelineFormatter(); + + /** + * Provides labels for the tick mark area of a timeline view. + * Since the tick mark regin is potentially extremeley large, + * only the subset of ticks which will actually be shown in + * view are provided. + * @constructor + */ + function TimelineTickController() { + var labels = [], + lastFirst, + lastStep, + lastCount, + lastStartMillis, + lastEndMillis; + + // Actually recalculate the labels from scratch + function calculateLabels(first, count, step, toMillis) { + var result = [], + current; + + // Create enough labels to fill the visible area + while (result.length < count) { + current = first + step * result.length; + result.push({ + // Horizontal pixel position of this label + left: current, + // Text to display in this label + text: FORMATTER.format(toMillis(current)) + }); + } + + return result; + } + + // Get tick labels for this pixel span (recalculating if needed) + function getLabels(start, width, step, toMillis) { + // Calculate parameters for labels (first pixel position, last + // pixel position.) These are checked to detect changes. + var first = Math.floor(start / step) * step, + last = Math.ceil((start + width) / step) * step, + count = ((last - first) / step) + 1, + startMillis = toMillis(first), + endMillis = toMillis(last), + changed = (lastFirst !== first) || + (lastCount !== count) || + (lastStep !== step) || + (lastStartMillis !== startMillis) || + (lastEndMillis !== endMillis); + + // This will be used in a template, so only recalculate on + // change. + if (changed) { + labels = calculateLabels(first, count, step, toMillis); + // Cache to avoid recomputing later + lastFirst = first; + lastCount = count; + lastStep = step; + lastStartMillis = startMillis; + lastEndMillis = endMillis; + } + + return labels; + } + + + return { + /** + * Get labels for use in the visible region of a timeline's + * tick mark area. This will return the same array instance + * (without recalculating its contents) if called with the + * same parameters (and same apparent zoom state, as determined + * via `toMillis`), so it is safe to use in a template. + * + * @param {number} start left-most pixel position in view + * @param {number} width pixel width in view + * @param {number} step size, in pixels, of each major tick + * @param {Function} toMillis function to convert from pixel + * positions to milliseconds + * @returns {Array} an array of tick mark labels, suitable + * for use in the `timeline-ticks` template + */ + labels: getLabels + }; + } + + return TimelineTickController; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/TimelineZoomController.js b/platform/features/timeline/src/controllers/TimelineZoomController.js new file mode 100644 index 0000000000..6d6a72f516 --- /dev/null +++ b/platform/features/timeline/src/controllers/TimelineZoomController.js @@ -0,0 +1,109 @@ +/*global define*/ +define( + ['../TimelineFormatter'], + function (TimelineFormatter) { + "use strict"; + + + var FORMATTER = new TimelineFormatter(); + + /** + * Controls the pan-zoom state of a timeline view. + * @constructor + */ + function TimelineZoomController($scope, ZOOM_CONFIGURATION) { + // Prefer to start with the middle index + var zoomLevels = ZOOM_CONFIGURATION.levels || [ 1000 ], + zoomIndex = Math.floor(zoomLevels.length / 2), + tickWidth = ZOOM_CONFIGURATION.width || 200, + duration = 86400000; // Default duration in view + + // Round a duration to a larger value, to ensure space for editing + function roundDuration(value) { + // Ensure there's always an extra day or so + var sz = zoomLevels[zoomLevels.length - 1]; + value *= 1.25; // Add 25% padding to start + return Math.ceil(value / sz) * sz; + } + + // Get/set zoom level + function setZoomLevel(level) { + if (!isNaN(level)) { + // Modify zoom level, keeping it in range + zoomIndex = Math.min( + Math.max(level, 0), + zoomLevels.length - 1 + ); + } + } + + // Persist current zoom level + function storeZoom() { + var isEditMode = $scope.commit && + $scope.domainObject && + $scope.domainObject.hasCapability('editor'); + if (isEditMode) { + $scope.configuration = $scope.configuration || {}; + $scope.configuration.zoomLevel = zoomIndex; + $scope.commit(); + } + } + + $scope.$watch("configuration.zoomLevel", setZoomLevel); + + return { + /** + * Increase or decrease the current zoom level by a given + * number of steps. Positive steps zoom in, negative steps + * zoom out. + * If called with no arguments, this returns the current + * zoom level, expressed as the number of milliseconds + * associated with a given tick mark. + * @param {number} steps how many steps to zoom in + * @returns {number} current zoom level (as the size of a + * major tick mark, in pixels) + */ + zoom: function (amount) { + // Update the zoom level if called with an argument + if (arguments.length > 0 && !isNaN(amount)) { + setZoomLevel(zoomIndex + amount); + storeZoom(zoomIndex); + } + return zoomLevels[zoomIndex]; + }, + /** + * Get the width, in pixels, of a specific time duration at + * the current zoom level. + * @returns {number} the number of pixels + */ + toPixels: function (millis) { + return tickWidth * millis / zoomLevels[zoomIndex]; + }, + /** + * Get the time duration, in milliseconds, occupied by the + * width (specified in pixels) at the current zoom level. + * @returns {number} the number of pixels + */ + toMillis: function (pixels) { + return (pixels / tickWidth) * zoomLevels[zoomIndex]; + }, + /** + * Get or set the current displayed duration. If used as a + * setter, this will typically be rounded up to ensure extra + * space is available at the right. + * @returns {number} duration, in milliseconds + */ + duration: function (value) { + var prior = duration; + if (arguments.length > 0) { + duration = roundDuration(value); + } + return duration; + } + }; + } + + return TimelineZoomController; + + } +); diff --git a/platform/features/timeline/src/controllers/WARPDateTimeController.js b/platform/features/timeline/src/controllers/WARPDateTimeController.js new file mode 100644 index 0000000000..35a660551a --- /dev/null +++ b/platform/features/timeline/src/controllers/WARPDateTimeController.js @@ -0,0 +1,72 @@ +/*global define,moment*/ + +define( + [], + function () { + "use strict"; + + /** + * Controller for the `datetime` form control. + * This is a composite control; it includes multiple + * input fields but outputs a single timestamp (in + * milliseconds since start of 1970) to the ngModel. + * + * @constructor + */ + function DateTimeController($scope) { + + // Update the data model + function updateModel(datetime) { + var days = parseInt(datetime.days, 10) || 0, + hour = parseInt(datetime.hours, 10) || 0, + min = parseInt(datetime.minutes, 10) || 0, + sec = parseInt(datetime.seconds, 10) || 0, + epoch = "SET", // Only permit SET, for now + timestamp; + + // Build up timestamp + timestamp = days * 24; + timestamp = (hour + timestamp) * 60; + timestamp = (min + timestamp) * 60; + timestamp = (sec + timestamp) * 1000; + + // Set in the model + $scope.ngModel[$scope.field] = { + timestamp: timestamp, + epoch: epoch + }; + } + + // Update the displayed state + function updateForm(modelState) { + var timestamp = (modelState || {}).timestamp || 0, + datetime = $scope.datetime; + + timestamp = Math.floor(timestamp / 1000); + datetime.seconds = timestamp % 60; + timestamp = Math.floor(timestamp / 60); + datetime.minutes = timestamp % 60; + timestamp = Math.floor(timestamp / 60); + datetime.hours = timestamp % 24; + timestamp = Math.floor(timestamp / 24); + datetime.days = timestamp; + } + + // Retrieve state from field, for watch + function getModelState() { + return $scope.ngModel[$scope.field]; + } + + // Update value whenever any field changes. + $scope.$watchCollection("datetime", updateModel); + $scope.$watchCollection(getModelState, updateForm); + + // Initialize the scope + $scope.datetime = {}; + updateForm(getModelState()); + } + + return DateTimeController; + + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/drag/TimelineDragHandleFactory.js b/platform/features/timeline/src/controllers/drag/TimelineDragHandleFactory.js new file mode 100644 index 0000000000..7f36e303db --- /dev/null +++ b/platform/features/timeline/src/controllers/drag/TimelineDragHandleFactory.js @@ -0,0 +1,55 @@ +/*global define*/ + +define( + ['./TimelineStartHandle', './TimelineEndHandle', './TimelineMoveHandle'], + function (TimelineStartHandle, TimelineEndHandle, TimelineMoveHandle) { + "use strict"; + + + var DEFAULT_HANDLES = [ + TimelineStartHandle, + TimelineMoveHandle, + TimelineEndHandle + ], + TIMELINE_HANDLES = [ + TimelineStartHandle, + TimelineMoveHandle + ]; + + /** + * Create a factory for drag handles for timelines/activities + * in a timeline view. + * @constructor + */ + function TimelineDragHandleFactory(dragHandler, snapHandler) { + return { + /** + * Create drag handles for this domain object. + * @param {DomainObject} domainObject the object to be + * manipulated by these gestures + * @returns {Array} array of drag handles + */ + handles: function (domainObject) { + var type = domainObject.getCapability('type'), + id = domainObject.getId(); + + // Instantiate a handle + function instantiate(Handle) { + return new Handle( + id, + dragHandler, + snapHandler + ); + } + + // Instantiate smaller set of handles for timelines + return (type && type.instanceOf('warp.timeline') ? + TIMELINE_HANDLES : DEFAULT_HANDLES) + .map(instantiate); + } + }; + } + + return TimelineDragHandleFactory; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/drag/TimelineDragHandler.js b/platform/features/timeline/src/controllers/drag/TimelineDragHandler.js new file mode 100644 index 0000000000..b1e5011280 --- /dev/null +++ b/platform/features/timeline/src/controllers/drag/TimelineDragHandler.js @@ -0,0 +1,237 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Handles business logic (mutation of objects, retrieval of start/end + * times) associated with drag gestures to manipulate start/end times + * of activities and timelines in a Timeline view. + * @constructor + * @param {DomainObject} domainObject the object being viewed + * @param {ObjectLoader} objectLoader service to assist in loading + * subtrees + */ + function TimelineDragHandler(domainObject, objectLoader) { + var timespans = {}, + persists = {}, + mutations = {}, + compositions = {}, + dirty = {}; + + // "Cast" a domainObject to an id, if necessary + function toId(value) { + return (typeof value !== 'string' && value.getId) ? + value.getId() : value; + } + + // Get the timespan associated with this domain object + function populateCapabilityMaps(domainObject) { + var id = domainObject.getId(), + timespanPromise = domainObject.useCapability('timespan'); + if (timespanPromise) { + timespanPromise.then(function (timespan) { + // Cache that timespan + timespans[id] = timespan; + // And its mutation capability + mutations[id] = domainObject.getCapability('mutation'); + // Also cache the persistence capability for later + persists[id] = domainObject.getCapability('persistence'); + // And the composition, for bulk moves + compositions[id] = domainObject.getModel().composition || []; + }); + } + } + + // Populate the id->timespan map + function populateTimespans(subgraph) { + populateCapabilityMaps(subgraph.domainObject); + subgraph.composition.forEach(populateTimespans); + } + + // Persist changes for objects by id (when dragging ends) + function doPersist(id) { + var persistence = persists[id], + mutation = mutations[id]; + if (mutation) { + // Mutate just to update the timestamp (since we + // explicitly don't do this during the drag to + // avoid firing a ton of refreshes.) + mutation.mutate(function () {}); + } + if (persistence) { + // Persist the changes + persistence.persist(); + } + } + + // Use the object loader to get objects which have timespans + objectLoader.load(domainObject, 'timespan').then(populateTimespans); + + return { + /** + * Get a list of identifiers for domain objects which have + * timespans that are managed here. + * @returns {string[]} ids for all objects which have managed + * timespans here + */ + ids: function () { + return Object.keys(timespans).sort(); + }, + /** + * Persist any changes to timespans that have been made through + * this handler. + */ + persist: function () { + // Persist every dirty object... + Object.keys(dirty).forEach(doPersist); + // Clear out the dirty list + dirty = {}; + }, + /** + * Get the start time for a specific domain object. The domain + * object may be specified by its identifier, or passed as a + * domain object instance. If a second, numeric argument is + * passed, this functions as a setter. + * @returns {number} the start time + * @param {string|DomainObject} id the domain object to modify + * @param {number} [value] the new value + */ + start: function (id, value) { + // Convert to domain object id, look up timespan + var timespan = timespans[toId(id)]; + // Use as setter if argument is present + if ((typeof value === 'number') && timespan) { + // Set the start (ensuring that it's non-negative, + // and not after the end time.) + timespan.setStart( + Math.min(Math.max(value, 0), timespan.getEnd()) + ); + // Mark as dirty for subsequent persistence + dirty[toId(id)] = true; + } + // Return value from the timespan + return timespan && timespan.getStart(); + }, + /** + * Get the end time for a specific domain object. The domain + * object may be specified by its identifier, or passed as a + * domain object instance. If a second, numeric argument is + * passed, this functions as a setter. + * @returns {number} the end time + * @param {string|DomainObject} id the domain object to modify + * @param {number} [value] the new value + */ + end: function (id, value) { + // Convert to domain object id, look up timespan + var timespan = timespans[toId(id)]; + // Use as setter if argument is present + if ((typeof value === 'number') && timespan) { + // Set the end (ensuring it doesn't preceed start) + timespan.setEnd( + Math.max(value, timespan.getStart()) + ); + // Mark as dirty for subsequent persistence + dirty[toId(id)] = true; + } + // Return value from the timespan + return timespan && timespan.getEnd(); + }, + /** + * Get the duration for a specific domain object. The domain + * object may be specified by its identifier, or passed as a + * domain object instance. If a second, numeric argument is + * passed, this functions as a setter. + * @returns {number} the duration + * @param {string|DomainObject} id the domain object to modify + * @param {number} [value] the new value + */ + duration: function (id, value) { + // Convert to domain object id, look up timespan + var timespan = timespans[toId(id)]; + // Use as setter if argument is present + if ((typeof value === 'number') && timespan) { + // Set duration (ensure that it's non-negative) + timespan.setDuration( + Math.max(value, 0) + ); + // Mark as dirty for subsequent persistence + dirty[toId(id)] = true; + } + // Return value from the timespan + return timespan && timespan.getDuration(); + }, + /** + * Move the start and end of this domain object by the + * specified delta. Contained objects will move as well. + * @param {string|DomainObject} id the domain object to modify + * @param {number} delta the amount by which to change + */ + move: function (id, delta) { + // Overview of algorithm used here: + // - Build up list of ids to actually move + // - Find the minimum start time + // - Change delta so it cannot move minimum past 0 + // - Update start, then end time + var ids = {}, + queue = [toId(id)], + minStart; + + // Update start & end, in that order + function updateStartEnd(id) { + var timespan = timespans[id], start, end; + if (timespan) { + // Get start/end so we don't get fooled by our + // own adjustments + start = timespan.getStart(); + end = timespan.getEnd(); + // Update start, then end + timespan.setStart(start + delta); + timespan.setEnd(end + delta); + // Mark as dirty for subsequent persistence + dirty[toId(id)] = true; + } + } + + // Build up set of ids + while (queue.length > 0) { + // Get the next id to consider + id = queue.shift(); + // If we haven't already considered this... + if (!ids[id]) { + // Add it to the set + ids[id] = true; + // And queue up its composition + queue = queue.concat(compositions[id] || []); + } + } + + // Find the minimum start time + minStart = Object.keys(ids).map(function (id) { + // Get the start time; default to +Inf if not + // found, since this will not survive a min + // test if any real timespans are present + return timespans[id] ? + timespans[id].getStart() : + Number.POSITIVE_INFINITY; + }).reduce(function (a, b) { + // Reduce with a minimum test + return Math.min(a, b); + }, Number.POSITIVE_INFINITY); + + // Ensure delta doesn't exceed bounds + delta = Math.max(delta, -minStart); + + // Update start/end times + if (delta !== 0) { + Object.keys(ids).forEach(updateStartEnd); + } + } + }; + } + + return TimelineDragHandler; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/drag/TimelineDragPopulator.js b/platform/features/timeline/src/controllers/drag/TimelineDragPopulator.js new file mode 100644 index 0000000000..65f68a88ed --- /dev/null +++ b/platform/features/timeline/src/controllers/drag/TimelineDragPopulator.js @@ -0,0 +1,76 @@ +/*global define*/ + +define( + ['./TimelineDragHandler', './TimelineSnapHandler', './TimelineDragHandleFactory'], + function (TimelineDragHandler, TimelineSnapHandler, TimelineDragHandleFactory) { + "use strict"; + + /** + * Provides drag handles for the active selection in a timeline view. + * @constructor + */ + function TimelineDragPopulator(objectLoader) { + var handles = [], + factory, + selectedObject; + + // Refresh active set of drag handles + function refreshHandles() { + handles = (factory && selectedObject) ? + factory.handles(selectedObject) : + []; + } + + // Create a new factory for handles, based on root object in view + function populateForObject(domainObject) { + var dragHandler = domainObject && new TimelineDragHandler( + domainObject, + objectLoader + ); + + // Reinstantiate the factory + factory = dragHandler && new TimelineDragHandleFactory( + dragHandler, + new TimelineSnapHandler(dragHandler) + ); + + // If there's a selected object, restore the handles + refreshHandles(); + } + + // Change the current selection + function select(swimlane) { + // Cache selection to restore handles if other changes occur + selectedObject = swimlane && swimlane.domainObject; + + // Provide handles for this selection, if it's defined + refreshHandles(); + } + + return { + /** + * Get the currently-applicable set of drag handles. + * @returns {Array} drag handles + */ + get: function () { + return handles; + }, + /** + * Set the root object in view. Drag interactions consider + * the full graph for snapping behavior, so this is needed. + * @param {DomainObject} domainObject the timeline object + * being viewed + */ + populate: populateForObject, + /** + * Update selection state. Passing undefined means there + * is no selection. + * @param {TimelineSwimlane} swimlane the selected swimlane + */ + select: select + }; + } + + return TimelineDragPopulator; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/drag/TimelineEndHandle.js b/platform/features/timeline/src/controllers/drag/TimelineEndHandle.js new file mode 100644 index 0000000000..2d013a8139 --- /dev/null +++ b/platform/features/timeline/src/controllers/drag/TimelineEndHandle.js @@ -0,0 +1,77 @@ +/*global define*/ + +define( + ['../../TimelineConstants'], + function (Constants) { + "use strict"; + + /** + * Handle for changing the end time of a timeline or + * activity in the Timeline view. + * @constructor + * @param {string} id identifier of the domain object + * @param {TimelineDragHandler} dragHandler the handler which + * will update object state + * @param {TimelineSnapHandler} snapHandler the handler which + * provides candidate snap-to locations. + */ + function TimelineEndHandle(id, dragHandler, snapHandler) { + var initialEnd; + + // Get the snap-to location for a timestamp + function snap(timestamp, zoom) { + return snapHandler.snap( + timestamp, + zoom.toMillis(Constants.SNAP_WIDTH), + id + ); + } + + return { + /** + * Start dragging this handle. + */ + begin: function () { + // Cache the initial state + initialEnd = dragHandler.end(id); + }, + /** + * Drag this handle. + * @param {number} delta pixel delta from start + * @param {TimelineZoomController} zoom provider of zoom state + */ + drag: function (delta, zoom) { + if (initialEnd !== undefined) { + // Update the state + dragHandler.end( + id, + snap(initialEnd + zoom.toMillis(delta), zoom) + ); + } + }, + /** + * Finish dragging this handle. + */ + finish: function () { + // Clear initial state + initialEnd = undefined; + // Persist changes + dragHandler.persist(); + }, + /** + * Get a style object (suitable for passing into `ng-style`) + * for this handle. + * @param {TimelineZoomController} zoom provider of zoom state + */ + style: function (zoom) { + return { + left: zoom.toPixels(dragHandler.end(id)) - Constants.HANDLE_WIDTH + 'px', + width: Constants.HANDLE_WIDTH + 'px' + }; + } + }; + } + + return TimelineEndHandle; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/drag/TimelineMoveHandle.js b/platform/features/timeline/src/controllers/drag/TimelineMoveHandle.js new file mode 100644 index 0000000000..a5fbe8f0d5 --- /dev/null +++ b/platform/features/timeline/src/controllers/drag/TimelineMoveHandle.js @@ -0,0 +1,115 @@ +/*global define*/ + +define( + ['../../TimelineConstants'], + function (Constants) { + "use strict"; + + /** + * Handle for moving (by drag) a timeline or + * activity in the Timeline view. + * @constructor + * @param {string} id identifier of the domain object + * @param {TimelineDragHandler} dragHandler the handler which + * will update object state + * @param {TimelineSnapHandler} snapHandler the handler which + * provides candidate snap-to locations. + */ + function TimelineMoveHandle(id, dragHandler, snapHandler) { + var initialStart, + initialEnd; + + // Get the snap-to location for a timestamp + function snap(timestamp, zoom) { + return snapHandler.snap( + timestamp, + zoom.toMillis(Constants.SNAP_WIDTH), + id + ); + } + + // Convert a pixel delta to a millisecond delta that will align + // with some useful snap location + function snapDelta(delta, zoom) { + var timeDelta = zoom.toMillis(delta), + desiredStart = initialStart + timeDelta, + desiredEnd = initialEnd + timeDelta, + snappedStart = snap(desiredStart, zoom), + snappedEnd = snap(desiredEnd, zoom), + diffStart = Math.abs(snappedStart - desiredStart), + diffEnd = Math.abs(snappedEnd - desiredEnd), + chooseEnd = false; + + // First, check for case where both changed... + if ((diffStart > 0) && (diffEnd > 0)) { + // ...and choose the smallest change that snaps. + chooseEnd = diffEnd < diffStart; + } else { + // ...otherwise, snap toward the end if it changed. + chooseEnd = diffEnd > 0; + } + // Start is chosen if diffEnd didn't snap, or nothing snapped + + // Our delta is relative to our initial state, but + // dragHandler.move is relative to current state, so whichever + // end we're snapping to, we need to compute a delta + // relative to the current state to get the desired result. + return chooseEnd ? + (snappedEnd - dragHandler.end(id)) : + (snappedStart - dragHandler.start(id)); + } + + return { + /** + * Start dragging this handle. + */ + begin: function () { + // Cache the initial state + initialStart = dragHandler.start(id); + initialEnd = dragHandler.end(id); + }, + /** + * Drag this handle. + * @param {number} delta pixel delta from start + * @param {TimelineZoomController} zoom provider of zoom state + */ + drag: function (delta, zoom) { + if (initialStart !== undefined && initialEnd !== undefined) { + if (delta !== 0) { + dragHandler.move(id, snapDelta(delta, zoom)); + } + } + }, + /** + * Finish dragging this handle. + */ + finish: function () { + // Clear initial state + initialStart = undefined; + initialEnd = undefined; + // Persist changes + dragHandler.persist(); + }, + /** + * Get a style object (suitable for passing into `ng-style`) + * for this handle. + * @param {TimelineZoomController} zoom provider of zoom state + */ + + style: function (zoom) { + return { + left: zoom.toPixels(dragHandler.start(id)) + + Constants.HANDLE_WIDTH + + 'px', + width: zoom.toPixels(dragHandler.duration(id)) - + Constants.HANDLE_WIDTH * 2 + + 'px' + //cursor: initialStart === undefined ? 'grab' : 'grabbing' + }; + } + }; + } + + return TimelineMoveHandle; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/drag/TimelineSnapHandler.js b/platform/features/timeline/src/controllers/drag/TimelineSnapHandler.js new file mode 100644 index 0000000000..b56bed2fad --- /dev/null +++ b/platform/features/timeline/src/controllers/drag/TimelineSnapHandler.js @@ -0,0 +1,85 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Snaps timestamps to match other timestamps within a + * certain tolerance, to support the snap-to-start-and-end + * behavior of drag interactions in a timeline view. + * @constructor + * @param {TimelineDragHandler} dragHandler the handler + * for drag interactions, which maintains start/end + * information for timelines in this view. + */ + function TimelineSnapHandler(dragHandler) { + // Snap to other end points + function snap(timestamp, tolerance, exclude) { + var result = timestamp, + closest = tolerance, + ids, + candidates; + + // Filter an id for inclustion + function include(id) { return id !== exclude; } + + // Evaluate a candidate timestamp as a snap-to location + function evaluate(candidate) { + var difference = Math.abs(candidate - timestamp); + // Is this closer than anything else we've found? + if (difference < closest) { + // ...then this is our new result + result = candidate; + // Track how close it was, for subsequent comparison. + closest = difference; + } + } + + // Look up start time; for mapping below + function getStart(id) { + return dragHandler.start(id); + } + + // Look up end time; for mapping below + function getEnd(id) { + return dragHandler.end(id); + } + + // Get list of candidate ids + ids = dragHandler.ids().filter(include); + + // Get candidate timestamps + candidates = ids.map(getStart).concat(ids.map(getEnd)); + + // ...and find the best one + candidates.forEach(evaluate); + + // Closest candidate (or original timestamp) is our result + // now, so return it. + return result; + } + + return { + /** + * Get a timestamp location that is near this + * timestamp (or simply return the provided + * timestamp if none are near enough, according + * to the specified tolerance.) + * Start/end times associated with the domain object + * with the specified identifier will be excluded + * from consideration (to avoid an undesired snap-to-self + * behavior.) + * @param {number} timestamp the timestamp to snap + * @param {number} tolerance the difference within which + * to snap + * @param {string} id the identifier to exclude + */ + snap: snap + }; + } + + return TimelineSnapHandler; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/drag/TimelineStartHandle.js b/platform/features/timeline/src/controllers/drag/TimelineStartHandle.js new file mode 100644 index 0000000000..efaba1a8e1 --- /dev/null +++ b/platform/features/timeline/src/controllers/drag/TimelineStartHandle.js @@ -0,0 +1,77 @@ +/*global define*/ + +define( + ['../../TimelineConstants'], + function (Constants) { + "use strict"; + + /** + * Handle for changing the start time of a timeline or + * activity in the Timeline view. + * @constructor + * @param {string} id identifier of the domain object + * @param {TimelineDragHandler} dragHandler the handler which + * will update object state + * @param {TimelineSnapHandler} snapHandler the handler which + * provides candidate snap-to locations. + */ + function TimelineStartHandle(id, dragHandler, snapHandler) { + var initialStart; + + // Get the snap-to location for a timestamp + function snap(timestamp, zoom) { + return snapHandler.snap( + timestamp, + zoom.toMillis(Constants.SNAP_WIDTH), + id + ); + } + + return { + /** + * Start dragging this handle. + */ + begin: function () { + // Cache the initial state + initialStart = dragHandler.start(id); + }, + /** + * Drag this handle. + * @param {number} delta pixel delta from start + * @param {TimelineZoomController} zoom provider of zoom state + */ + drag: function (delta, zoom) { + if (initialStart !== undefined) { + // Update the state + dragHandler.start( + id, + snap(initialStart + zoom.toMillis(delta), zoom) + ); + } + }, + /** + * Finish dragging this handle. + */ + finish: function () { + // Clear initial state + initialStart = undefined; + // Persist changes + dragHandler.persist(); + }, + /** + * Get a style object (suitable for passing into `ng-style`) + * for this handle. + * @param {TimelineZoomController} zoom provider of zoom state + */ + style: function (zoom) { + return { + left: zoom.toPixels(dragHandler.start(id)) + 'px', + width: Constants.HANDLE_WIDTH + 'px' + }; + } + }; + } + + return TimelineStartHandle; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/graph/TimelineGraph.js b/platform/features/timeline/src/controllers/graph/TimelineGraph.js new file mode 100644 index 0000000000..84ae3a2e74 --- /dev/null +++ b/platform/features/timeline/src/controllers/graph/TimelineGraph.js @@ -0,0 +1,172 @@ +/*global define*/ + +define( + [], + function () { + 'use strict'; + + /** + * Provides data to populate a graph in a timeline view. + * @constructor + * @param {string} key the resource's identifying key + * @param {Object.} domainObjects and object + * containing key-value pairs where keys are colors, and + * values are DomainObject instances to be drawn in that + * color + * @param {TimelineGraphRenderer} renderer a renderer which + * can be used to prepare Float32Array instances + */ + function TimelineGraph(key, domainObjects, renderer) { + var drawingObject = { origin: [0, 0], dimensions: [0, 0], modified: 0}, + // lines for the drawing object, by swimlane index + lines = [], + // min/max seen for a given swimlane, by swimlane index + extrema = [], + // current minimum + min = 0, + // current maximum + max = 0, + // current displayed time span + duration = 1000, + // line colors to display + colors = Object.keys(domainObjects); + + // Get minimum value, ensure there's some room + function minimum() { + return (min >= max) ? (max - 1) : min; + } + + // Get maximum value, ensure there's some room + function maximum() { + return (min >= max) ? (min + 1) : max; + } + + // Update minimum and maximum values + function updateMinMax() { + // Find the minimum among plot lines + min = extrema.map(function (ex) { + return ex.min; + }).reduce(function (a, b) { + return Math.min(a, b); + }, Number.POSITIVE_INFINITY); + + // Do the same for the maximum + max = extrema.map(function (ex) { + return ex.max; + }).reduce(function (a, b) { + return Math.max(a, b); + }, Number.NEGATIVE_INFINITY); + + // Ensure the infinities don't survive + min = min === Number.POSITIVE_INFINITY ? max : min; + min = min === Number.NEGATIVE_INFINITY ? 0 : min; + max = max === Number.NEGATIVE_INFINITY ? min : max; + } + + // Change contents of the drawing object (to trigger redraw) + function updateDrawingObject() { + // Update drawing object to include non-empty lines + drawingObject.lines = lines.filter(function (line) { + return line.points > 1; + }); + + // Update drawing bounds to fit data + drawingObject.origin[1] = minimum(); + drawingObject.dimensions[1] = maximum() - minimum(); + } + + // Update a specific line, by index + function updateLine(graph, index) { + var buffer = renderer.render(graph), + line = lines[index], + ex = extrema[index], + i; + + // Track minimum/maximum; note we skip x values + for (i = 1; i < buffer.length; i += 2) { + ex.min = Math.min(buffer[i], ex.min); + ex.max = Math.max(buffer[i], ex.max); + } + + // Update line in drawing object + line.buffer = buffer; + line.points = graph.getPointCount(); + line.color = renderer.decode(colors[index]); + + // Update the graph's total min/max + if (line.points > 0) { + updateMinMax(); + } + + // Update the drawing object (used to draw the graph) + updateDrawingObject(); + } + + // Request initialization for a line's contents + function populateLine(color, index) { + var domainObject = domainObjects[color], + graphPromise = domainObject.useCapability('graph'); + + if (graphPromise) { + graphPromise.then(function (g) { + if (g[key]) { + updateLine(g[key], index); + } + }); + } + } + + // Create empty lines + lines = colors.map(function () { + // Sentinel value to exclude these lines + return { points: 0 }; + }); + + // Specify initial min/max state per-line + extrema = colors.map(function () { + return { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY + }; + }); + + // Start creating lines for all swimlanes + colors.forEach(populateLine); + + return { + /** + * Get the minimum resource value that appears in this graph. + * @returns {number} the minimum value + */ + minimum: minimum, + /** + * Get the maximum resource value that appears in this graph. + * @returns {number} the maximum value + */ + maximum: maximum, + /** + * Set the displayed origin and duration, in milliseconds. + * @param {number} [value] value to set, if setting + */ + setBounds: function (offset, duration) { + // We don't update in-place, because we need the change + // to trigger a watch in mct-chart. + drawingObject.origin = [ offset, drawingObject.origin[1] ]; + drawingObject.dimensions = [ duration, drawingObject.dimensions[1] ]; + }, + /** + * Redraw lines in this graph. + */ + refresh: function () { + colors.forEach(populateLine); + }, + // Expose key, drawing object directly for use in templates + key: key, + drawingObject: drawingObject + }; + + } + + return TimelineGraph; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/graph/TimelineGraphPopulator.js b/platform/features/timeline/src/controllers/graph/TimelineGraphPopulator.js new file mode 100644 index 0000000000..b3037bd404 --- /dev/null +++ b/platform/features/timeline/src/controllers/graph/TimelineGraphPopulator.js @@ -0,0 +1,136 @@ +/*global define*/ +define( + ['./TimelineGraph', './TimelineGraphRenderer'], + function (TimelineGraph, TimelineGraphRenderer) { + 'use strict'; + + /** + * Responsible for determining which resource graphs + * to display (based on capabilities exposed by included + * domain objects) and allocating data to those different + * graphs. + * @constructor + */ + function TimelineGraphPopulator($q) { + var graphs = [], + cachedAssignments = {}, + renderer = new TimelineGraphRenderer(); + + // Compare two domain objects + function idsMatch(objA, objB) { + return (objA && objA.getId && objA.getId()) === + (objB && objB.getId && objB.getId()); + } + + // Compare two object sets for equality, to detect + // when graph updates are truly needed. + function deepEquals(objA, objB) { + var keysA, keysB; + + // Check if all keys in both objects match + function keysMatch(keys) { + return keys.map(function (k) { + return deepEquals(objA[k], objB[k]); + }).reduce(function (a, b) { + return a && b; + }, true); + } + + // First, check if they're matching domain objects + if (typeof (objA && objA.getId) === 'function') { + return idsMatch(objA, objB); + } + + // Otherwise, assume key-value pairs + keysA = Object.keys(objA || {}).sort(); + keysB = Object.keys(objB || {}).sort(); + + return (keysA.length === keysB.length) && keysMatch(keysA); + } + + // Populate the graphs for these swimlanes + function populate(swimlanes) { + // Somewhere to store resource assignments + // (as key -> swimlane[]) + var assignments = {}; + + // Look up resources for a domain object + function lookupResources(swimlane) { + var graphs = swimlane.domainObject.useCapability('graph'); + function getKeys(obj) { + return Object.keys(obj); + } + return $q.when(graphs ? (graphs.then(getKeys)) : []); + } + + // Add all graph assignments appropriate for this swimlane + function buildAssignments(swimlane) { + // Assign this swimlane to graphs for its resource keys + return lookupResources(swimlane).then(function (resources) { + resources.forEach(function (key) { + assignments[key] = assignments[key] || {}; + assignments[key][swimlane.color()] = + swimlane.domainObject; + }); + }); + } + + // Make a graph for this resource (after assigning) + function makeGraph(key) { + return new TimelineGraph( + key, + assignments[key], + renderer + ); + } + + // Used to filter down to swimlanes which need graphs + function needsGraph(swimlane) { + // Only show swimlanes with graphs & resources to graph + return swimlane.graph() && + swimlane.domainObject.hasCapability('graph'); + } + + // Create graphs according to assignments that have been built + function createGraphs() { + // Only refresh graphs if our assignments actually changed + if (!deepEquals(cachedAssignments, assignments)) { + // Make new graphs + graphs = Object.keys(assignments).sort().map(makeGraph); + // Save resource->color->object assignments + cachedAssignments = assignments; + } else { + // Just refresh the existing graphs + graphs.forEach(function (graph) { + graph.refresh(); + }); + } + } + + // Build up list of assignments, then create graphs + $q.all(swimlanes.filter(needsGraph).map(buildAssignments)) + .then(createGraphs); + } + + return { + /** + * Populate (or re-populate) the list of available resource + * graphs, based on the provided list of swimlanes (and their + * current state.) + * @param {TimelineSwimlane[]} swimlanes the swimlanes to use + */ + populate: populate, + /** + * Get the current list of displayable resource graphs. + * @returns {TimelineGraph[]} the resource graphs + */ + get: function () { + return graphs; + } + }; + } + + return TimelineGraphPopulator; + + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/graph/TimelineGraphRenderer.js b/platform/features/timeline/src/controllers/graph/TimelineGraphRenderer.js new file mode 100644 index 0000000000..d19e81ceaa --- /dev/null +++ b/platform/features/timeline/src/controllers/graph/TimelineGraphRenderer.js @@ -0,0 +1,62 @@ +/*global define,Float32Array*/ + +define( + [], + function () { + 'use strict'; + + /** + * Responsible for preparing data for display by + * `mct-chart` in a timeline's resource graph. + * @constructor + */ + function TimelineGraphRenderer() { + return { + /** + * Render a resource utilization to a Float32Array, + * to be passed to WebGL for display. + * @param {ResourceGraph} graph the resource utilization + * @returns {Float32Array} the rendered buffer + */ + render: function (graph) { + var count = graph.getPointCount(), + buffer = new Float32Array(count * 2), + i; + + // Populate the buffer + for (i = 0; i < count; i += 1) { + buffer[i * 2] = graph.getDomainValue(i); + buffer[i * 2 + 1] = graph.getRangeValue(i); + } + + return buffer; + }, + /** + * Convert an HTML color (in #-prefixed 6-digit hexadecimal) + * to an array of floating point values in a range of 0.0-1.0. + * An alpha element is included to facilitate display in an + * `mct-chart` (which uses WebGL.) + * @param {string} the color + * @returns {number[]} the same color, in floating-point format + */ + decode: function (color) { + // Check for bad input, default to black if needed + color = /^#[A-Fa-f0-9]{6}$/.test(color) ? color : "#000000"; + + // Pull out R, G, B hex values + return [ + color.substring(1, 3), + color.substring(3, 5), + color.substring(5, 7) + ].map(function (c) { + // Hex -> number + return parseInt(c, 16) / 255; + }).concat([1]); // Add the alpha channel + } + }; + } + + return TimelineGraphRenderer; + + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/swimlane/TimelineColorAssigner.js b/platform/features/timeline/src/controllers/swimlane/TimelineColorAssigner.js new file mode 100644 index 0000000000..ceb629e2e5 --- /dev/null +++ b/platform/features/timeline/src/controllers/swimlane/TimelineColorAssigner.js @@ -0,0 +1,101 @@ +/*global define*/ +define( + [], + function () { + "use strict"; + + var COLOR_OPTIONS = [ + "#20b2aa", + "#9acd32", + "#ff8c00", + "#d2b48c", + "#40e0d0", + "#4169ff", + "#ffd700", + "#6a5acd", + "#ee82ee", + "#cc9966", + "#99cccc", + "#66cc33", + "#ffcc00", + "#ff6633", + "#cc66ff", + "#ff0066", + "#ffff00", + "#800080", + "#00868b", + "#008a00", + "#ff0000", + "#0000ff", + "#f5deb3", + "#bc8f8f", + "#4682b4", + "#ffafaf", + "#43cd80", + "#cdc1c5", + "#a0522d", + "#6495ed" + ], + // Fall back to black, as "no more colors available" + FALLBACK_COLOR = "#000000"; + + /** + * Responsible for choosing unique colors for the resource + * graph listing of a timeline view. Supports TimelineController. + * @constructor + * @param colors an object to store color configuration into; + * typically, this should be a property from the view's + * configuration, but TimelineSwimlane manages this. + */ + function TimelineColorAssigner(colors) { + // Find an unused color + function freeColor() { + // Set of used colors + var set = {}, found; + + // Build up a set of used colors + Object.keys(colors).forEach(function (id) { + set[colors[id]] = true; + }); + + // Find an unused color + COLOR_OPTIONS.forEach(function (c) { + found = (!set[c] && !found) ? c : found; + }); + + // Provide the color + return found || FALLBACK_COLOR; + } + + return { + /** + * Get the current color assignment. + * @param {string} id the id to which the color is assigned + */ + get: function (id) { + return colors[id]; + }, + /** + * Assign a new color to this id. If no color is specified, + * an unused color will be chosen. + * @param {string} id the id to which the color is assigned + * @param {string} [color] the new color to assign + */ + assign: function (id, color) { + colors[id] = typeof color === 'string' ? color : freeColor(); + }, + /** + * Release the color assignment for this id. That id will + * no longer have a color associated with it, and its color + * will be free to use in subsequent calls. + * @param {string} id the id whose color should be released + */ + release: function (id) { + delete colors[id]; + } + }; + } + + return TimelineColorAssigner; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/swimlane/TimelineProxy.js b/platform/features/timeline/src/controllers/swimlane/TimelineProxy.js new file mode 100644 index 0000000000..055af7335f --- /dev/null +++ b/platform/features/timeline/src/controllers/swimlane/TimelineProxy.js @@ -0,0 +1,58 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Selection proxy for the Timeline view. Implements + * behavior associated with the Add button in the + * timeline's toolbar. + * @constructor + */ + function TimelineProxy(domainObject, selection) { + var actionMap = {}; + + // Populate available Create actions for this domain object + function populateActionMap(domainObject) { + var actionCapability = domainObject.getCapability('action'), + actions = actionCapability ? + actionCapability.getActions('create') : []; + actions.forEach(function (action) { + actionMap[action.getMetadata().type] = action; + }); + } + + // Populate available actions based on current selection + // (defaulting to object-in-view if there is none.) + function populateForSelection() { + var swimlane = selection && selection.get(), + selectedObject = swimlane && swimlane.domainObject; + populateActionMap(selectedObject || domainObject); + } + + populateActionMap(domainObject); + + return { + /** + * Add a domain object of the specified type. + * @param {string} type the type of domain object to add + */ + add: function (type) { + // Update list of create actions; this needs to reflect + // the current selection so that Save in defaults + // appropriately. + populateForSelection(); + + // Create an object of that type + if (actionMap[type]) { + return actionMap[type].perform(); + } + } + }; + } + + return TimelineProxy; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/swimlane/TimelineSwimlane.js b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlane.js new file mode 100644 index 0000000000..045987a2d8 --- /dev/null +++ b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlane.js @@ -0,0 +1,156 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Describes a swimlane in a timeline view. This will be + * used directly from timeline view. + * + * Only general properties of swimlanes are included here. + * Since swimlanes are also directly selected and exposed to the + * toolbar, the TimelineSwimlaneDecorator should also be used + * to add additional properties to specific swimlanes. + * + * @constructor + * @param {DomainObject} domainObject the represented object + * @param {TimelineColorAssigner} assigner color assignment handler + * @param configuration the view's configuration object + * @param {TimelineSwimlane} parent the parent swim lane (if any) + */ + function TimelineSwimlane(domainObject, assigner, configuration, parent, index) { + var id = domainObject.getId(), + highlight = false, // Drop highlight (middle) + highlightBottom = false, // Drop highlight (lower) + idPath = (parent ? parent.idPath : []).concat([domainObject.getId()]), + depth = parent ? (parent.depth + 1) : 0, + timespan, + path = (!parent || !parent.parent) ? "" : parent.path + + //(parent.path.length > 0 ? " / " : "") + + parent.domainObject.getModel().name + + " > "; + + // Look up timespan for this object + domainObject.useCapability('timespan').then(function (t) { + timespan = t; + }); + + return { + /** + * Check if this swimlane is currently visible. (That is, + * check to see if its parents are expanded.) + * @returns {boolean} true if it is visible + */ + visible: function () { + return !parent || (parent.expanded && parent.visible()); + }, + /** + * Show the Edit Properties dialog. + */ + properties: function () { + return domainObject.getCapability("action").perform("properties"); + }, + /** + * Toggle inclusion of this swimlane's represented object in + * the resource graph area. + */ + toggleGraph: function () { + configuration.graph = configuration.graph || {}; + configuration.graph[id] = !configuration.graph[id]; + // Assign or release legend color + assigner[configuration.graph[id] ? 'assign' : 'release'](id); + }, + /** + * Get (or set, if an argument is provided) the flag which + * determines if the object in this swimlane is included in + * the set of active resource graphs. + * @param {boolean} [value] the state to set (if setting) + * @returns {boolean} true if included; otherwise false + */ + graph: function (value) { + // Set if an argument was provided + if (arguments.length > 0) { + configuration.graph = configuration.graph || {}; + configuration.graph[id] = !!value; + // Assign or release the legend color + assigner[value ? 'assign' : 'release'](id); + } + // Provide the current state + return (configuration.graph || {})[id]; + }, + /** + * Get (or set, if an argument is provided) the color + * associated with this swimlane when its contents are + * included in the set of active resource graphs. + * @param {string} [value] the color to set (if setting) + * @returns {string} the color for resource graphing + */ + color: function (value) { + // Set if an argument was provided + if (arguments.length > 0) { + // Defer to the color assigner + assigner.assign(id, value); + } + // Provide the current value + return assigner.get(id); + }, + /** + * Get (or set, if an argument is provided) the drag + * highlight state for this swimlane. True means the body + * of the swimlane should be highlighted for drop into. + */ + highlight: function (value) { + // Set if an argument was provided + if (arguments.length > 0) { + highlight = value; + } + // Provide current value + return highlight; + }, + /** + * Get (or set, if an argument is provided) the drag + * highlight state for this swimlane. True means the body + * of the swimlane should be highlighted for drop after. + */ + highlightBottom: function (value) { + // Set if an argument was provided + if (arguments.length > 0) { + highlightBottom = value; + } + // Provide current value + return highlightBottom; + }, + /** + * Check if a swimlane exceeds the bounds of its parent. + * @returns {boolean} true if there is a bounds violation + */ + exceeded: function () { + var parentTimespan = parent && parent.timespan(); + return timespan && parentTimespan && + (timespan.getStart() < parentTimespan.getStart() || + timespan.getEnd() > parentTimespan.getEnd()); + }, + /** + * Get the timespan associated with this swimlane + */ + timespan: function () { + return timespan; + }, + // Expose domain object, expansion state, indentation depth + domainObject: domainObject, + expanded: true, + depth: depth, + path: path, + id: id, + idPath: idPath, + parent: parent, + index: index, + children: [] // Populated by populator + }; + } + + return TimelineSwimlane; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDecorator.js b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDecorator.js new file mode 100644 index 0000000000..28fabbb94b --- /dev/null +++ b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDecorator.js @@ -0,0 +1,93 @@ +/*global define*/ + +define( + ['./TimelineSwimlaneDropHandler'], + function (TimelineSwimlaneDropHandler) { + "use strict"; + + var ACTIVITY_RELATIONSHIP = "modes"; + + /** + * Adds optional methods to TimelineSwimlanes, in order + * to conditionally make available options in the toolbar. + * @constructor + */ + function TimelineSwimlaneDecorator(swimlane, selection) { + var domainObject = swimlane && swimlane.domainObject, + model = (domainObject && domainObject.getModel()) || {}, + mutator = domainObject && domainObject.getCapability('mutation'), + persister = domainObject && domainObject.getCapability('persistence'), + type = domainObject && domainObject.getCapability('type'), + dropHandler = new TimelineSwimlaneDropHandler(swimlane); + + // Activity Modes dialog + function modes(value) { + // Can be used as a setter... + if (arguments.length > 0 && Array.isArray(value)) { + // Update the relationships + mutator.mutate(function (model) { + model.relationships = model.relationships || {}; + model.relationships[ACTIVITY_RELATIONSHIP] = value; + }).then(persister.persist); + } + // ...otherwise, use as a getter + return (model.relationships || {})[ACTIVITY_RELATIONSHIP] || []; + } + + // Activity Link dialog + function link(value) { + // Can be used as a setter... + if (arguments.length > 0 && (typeof value === 'string') && + value !== model.link) { + // Update the link + mutator.mutate(function (model) { + model.link = value; + }).then(persister.persist); + } + return model.link; + } + + // Fire the Remove action + function remove() { + return domainObject.getCapability("action").perform("remove"); + } + + // Select the current swimlane + function select() { + selection.select(swimlane); + } + + // Check if the swimlane is selected + function selected() { + return selection.get() === swimlane; + } + + // Activities should have the Activity Modes and Activity Link dialog + if (type && type.instanceOf("warp.activity") && mutator && persister) { + swimlane.modes = modes; + swimlane.link = link; + } + + // Everything but the top-level object should have Remove + if (swimlane.parent) { + swimlane.remove = remove; + } + + // We're in edit mode, if a selection is available + if (selection) { + // Add shorthands to select, and check for selection + swimlane.select = select; + swimlane.selected = selected; + } + + // Expose drop handlers (which needed a reference to the swimlane) + swimlane.allowDropIn = dropHandler.allowDropIn; + swimlane.allowDropAfter = dropHandler.allowDropAfter; + swimlane.drop = dropHandler.drop; + + return swimlane; + } + + return TimelineSwimlaneDecorator; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDropHandler.js b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDropHandler.js new file mode 100644 index 0000000000..0c45531572 --- /dev/null +++ b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlaneDropHandler.js @@ -0,0 +1,186 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Handles drop (from drag-and-drop) initiated changes to a swimlane. + * @constructor + */ + function TimelineSwimlaneDropHandler(swimlane) { + // Utility function; like $q.when, but synchronous (to reduce + // performance impact when wrapping synchronous values) + function asPromise(value) { + return (value && value.then) ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + // Check if we are in edit mode + function inEditMode() { + return swimlane.domainObject.hasCapability("editor"); + } + + // Boolean and (for reduce below) + function or(a, b) { + return a || b; + } + + // Check if pathA entirely contains pathB + function pathContains(swimlane, id) { + // Check if id at a specific index matches (for map below) + function matches(pathId) { + return pathId === id; + } + + // Path A contains Path B if it is longer, and all of + // B's ids match the ids in A. + return swimlane.idPath.map(matches).reduce(or, false); + } + + // Check if a swimlane contains a child with the specified id + function contains(swimlane, id) { + // Check if a child swimlane has a matching domain object id + function matches(child) { + return child.domainObject.getId() === id; + } + + // Find any one child id that matches this id + return swimlane.children.map(matches).reduce(or, false); + } + + // Remove a domain object from its current location + function remove(domainObject) { + return domainObject && + domainObject.getCapability('action').perform('remove'); + } + + // Initiate mutation of a domain object + function doMutate(domainObject, mutator) { + return asPromise( + domainObject.useCapability("mutation", mutator) + ).then(function () { + // Persist the results of mutation + var persistence = domainObject.getCapability("persistence"); + if (persistence) { + // Persist the changes + persistence.persist(); + } + }); + } + + // Check if this swimlane is in a state where a drop-after will + // act as a drop-into-at-first position (expanded and non-empty) + function expandedForDropInto() { + return swimlane.expanded && swimlane.children.length > 0; + } + + // Check if the swimlane is ready to accept a drop-into + // (instead of drop-after) + function isDropInto() { + return swimlane.highlight() || expandedForDropInto(); + } + + // Choose an index for insertion in a domain object's composition + function chooseTargetIndex(id, offset, composition) { + return Math.max( + Math.min( + (composition || []).indexOf(id) + offset, + (composition || []).length + ), + 0 + ); + } + + // Insert an id into target's composition + function insert(id, target, indexOffset) { + var myId = swimlane.domainObject.getId(); + return doMutate(target, function (model) { + model.composition.splice( + chooseTargetIndex(myId, indexOffset, model.composition), + 0, + id + ); + }); + } + + // Check if a compose action is allowed for the object in this + // swimlane (we handle the link differently to set the index, + // but check for the existence of the action to invole the + // relevant policies.) + function allowsCompose(swimlane, domainObject) { + var actionCapability = + swimlane.domainObject.getCapability('action'); + return actionCapability && actionCapability.getActions({ + key: 'compose', + selectedObject: domainObject + }).length > 0; + } + + return { + /** + * Check if a drop-into should be allowed for this swimlane, + * for the provided domain object identifier. + * @param {string} id identifier for the domain object to be + * dropped + * @returns {boolean} true if this should be allowed + */ + allowDropIn: function (id, domainObject) { + return inEditMode() && + !pathContains(swimlane, id) && + !contains(swimlane, id) && + allowsCompose(swimlane, domainObject); + }, + /** + * Check if a drop-after should be allowed for this swimlane, + * for the provided domain object identifier. + * @param {string} id identifier for the domain object to be + * dropped + * @returns {boolean} true if this should be allowed + */ + allowDropAfter: function (id, domainObject) { + var target = expandedForDropInto() ? + swimlane : swimlane.parent; + return inEditMode() && + target && + !pathContains(target, id) && + allowsCompose(target, domainObject); + }, + /** + * Drop the provided domain object into a timeline. This is + * provided as a mandatory id, and an optional domain object + * instance; if the latter is provided, it will be removed + * from its parent before being added. (This is specifically + * to support moving Activity objects around within a Timeline.) + * @param {string} id the identifier for the domain object + * @param {DomainObject} [domainObject] the object itself + */ + drop: function (id, domainObject) { + // Get the desired drop object, and destination index + var dropInto = isDropInto(), + dropTarget = dropInto ? + swimlane.domainObject : + swimlane.parent.domainObject, + dropIndexOffset = (!dropInto) ? 1 : + (swimlane.expanded && swimlane.highlightBottom()) ? + Number.NEGATIVE_INFINITY : + Number.POSITIVE_INFINITY; + + if (swimlane.highlight() || swimlane.highlightBottom()) { + // Remove the domain object from its original location... + return asPromise(remove(domainObject)).then(function () { + // ...then insert it at its new location. + insert(id, dropTarget, dropIndexOffset); + }); + } + } + }; + } + + return TimelineSwimlaneDropHandler; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/controllers/swimlane/TimelineSwimlanePopulator.js b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlanePopulator.js new file mode 100644 index 0000000000..24c72582d7 --- /dev/null +++ b/platform/features/timeline/src/controllers/swimlane/TimelineSwimlanePopulator.js @@ -0,0 +1,164 @@ +/*global define*/ + +define( + [ + './TimelineSwimlane', + './TimelineSwimlaneDecorator', + './TimelineColorAssigner', + './TimelineProxy' + ], + function ( + TimelineSwimlane, + TimelineSwimlaneDecorator, + TimelineColorAssigner, + TimelineProxy + ) { + 'use strict'; + + /** + * Populates and maintains a list of swimlanes for a given + * timeline view. + * @constructor + */ + function TimelineSwimlanePopulator(objectLoader, configuration, selection) { + var swimlanes = [], + start = Number.POSITIVE_INFINITY, + end = Number.NEGATIVE_INFINITY, + colors = (configuration.colors || {}), + assigner = new TimelineColorAssigner(colors); + + // Track extremes of start/end times + function trackStartEnd(timespan) { + if (timespan) { + start = Math.min(start, timespan.getStart()); + end = Math.max(end, timespan.getEnd()); + } + } + + // Add domain object (and its subgraph) in as swimlanes + function populateSwimlanes(subgraph, parent, index) { + var domainObject = subgraph.domainObject, + swimlane; + + // For the recursive step + function populate(childSubgraph, index) { + populateSwimlanes(childSubgraph, swimlane, index); + } + + // Make sure we have a valid object instance... + if (domainObject) { + // Create the new swimlane + swimlane = new TimelineSwimlaneDecorator(new TimelineSwimlane( + domainObject, + assigner, + configuration, + parent, + index || 0 + ), selection); + // Track start & end times of this domain object + domainObject.useCapability('timespan').then(trackStartEnd); + // Add it to our list + swimlanes.push(swimlane); + // Fill in parent's children + ((parent || {}).children || []).push(swimlane); + // Add in children + subgraph.composition.forEach(populate); + } + } + + // Restore a selection + function reselect(path, candidates, depth) { + // Next ID on the path + var next = path[depth || 0]; + + // Ensure a default + depth = depth || 0; + + // Search through this layer of candidates to see + // if they might contain our selection (based on id path) + candidates.forEach(function (swimlane) { + // Check if we're on the right path... + if (swimlane.id === next) { + // Do we still have ids to check? + if (depth < path.length - 1) { + // Yes, so recursively explore that path + reselect(path, swimlane.children, depth + 1); + } else { + // Nope, we found the object to select + selection.select(swimlane); + } + } + }); + } + + // Handle population of swimlanes + function recalculateSwimlanes(domainObject) { + function populate(subgraph) { + // Cache current selection state during refresh + var selected = selection && selection.get(), + selectedIdPath = selected && selected.idPath; + + // Clear existing swimlanes + swimlanes = []; + + // Build new set of swimlanes + populateSwimlanes(subgraph); + + // Restore selection, if there was one + if (selectedIdPath && swimlanes.length > 0) { + reselect(selectedIdPath, [swimlanes[0]]); + } + } + + // Repopulate swimlanes for this object + if (!domainObject) { + populate({}); + } else { + objectLoader.load(domainObject, 'timespan').then(populate); + } + + // Set the selection proxy as well (for the Add button) + if (selection) { + selection.proxy( + domainObject && new TimelineProxy(domainObject, selection) + ); + } + } + + // Ensure colors are exposed in configuration + configuration.colors = colors; + + return { + /** + * Update list of swimlanes to match those reachable from this + * object. + * @param {DomainObject} the timeline being viewed + */ + populate: recalculateSwimlanes, + /** + * Get a list of swimlanes for this timeline view. + * @returns {TimelineSwimlane[]} current swimlanes + */ + get: function () { + return swimlanes; + }, + /** + * Get the first timestamp in the set of swimlanes. + * @returns {number} first timestamp + */ + start: function () { + return start; + }, + /** + * Get the last timestamp in the set of swimlanes. + * @returns {number} first timestamp + */ + end: function () { + return end; + } + }; + } + + return TimelineSwimlanePopulator; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/directives/SwimlaneDragConstants.js b/platform/features/timeline/src/directives/SwimlaneDragConstants.js new file mode 100644 index 0000000000..d88b466dd6 --- /dev/null +++ b/platform/features/timeline/src/directives/SwimlaneDragConstants.js @@ -0,0 +1,20 @@ +/*global define*/ + +define({ + /** + * The string identifier for the data type used for drag-and-drop + * composition of domain objects. (e.g. in event.dataTransfer.setData + * calls.) + */ + MCT_DRAG_TYPE: 'mct-domain-object-id', + /** + * The string identifier for the data type used for drag-and-drop + * composition of domain objects, by object instance (passed through + * the dndService) + */ + MCT_EXTENDED_DRAG_TYPE: 'mct-domain-object', + /** + * String identifier for swimlanes being dragged. + */ + WARP_SWIMLANE_DRAG_TYPE: 'warp-swimlane' +}); \ No newline at end of file diff --git a/platform/features/timeline/src/directives/WARPSwimlaneDrag.js b/platform/features/timeline/src/directives/WARPSwimlaneDrag.js new file mode 100644 index 0000000000..83a4e41bb6 --- /dev/null +++ b/platform/features/timeline/src/directives/WARPSwimlaneDrag.js @@ -0,0 +1,47 @@ +/*global define*/ + +define( + ['./SwimlaneDragConstants'], + function (SwimlaneDragConstants) { + "use strict"; + + /** + * Defines the `warp-swimlane-drag` directive. When a drag is initiated + * form an element with this attribute, the swimlane being dragged + * (identified by the value of this attribute, as an Angular expression) + * will be exported to the `dndService` as part of the active drag-drop + * state. + * @param {DndService} dndService drag-and-drop service + */ + function WARPSwimlaneDrag(dndService) { + function link(scope, element, attrs) { + // Look up the swimlane from the provided expression + function swimlane() { + return scope.$eval(attrs.warpSwimlaneDrag); + } + // When drag starts, publish via dndService + element.on('dragstart', function () { + dndService.setData( + SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE, + swimlane() + ); + }); + // When drag ends, clear via dndService + element.on('dragend', function () { + dndService.removeData( + SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE + ); + }); + } + + return { + // Applies to attributes + restrict: "A", + // Link using above function + link: link + }; + } + + return WARPSwimlaneDrag; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/directives/WARPSwimlaneDrop.js b/platform/features/timeline/src/directives/WARPSwimlaneDrop.js new file mode 100644 index 0000000000..916fb82bd7 --- /dev/null +++ b/platform/features/timeline/src/directives/WARPSwimlaneDrop.js @@ -0,0 +1,106 @@ +/*global define*/ + +define( + ['./SwimlaneDragConstants'], + function (SwimlaneDragConstants) { + "use strict"; + + /** + * Defines the `warp-swimlane-drop` directive. When a drop occurs + * on an element with this attribute, the swimlane targeted by the drop + * (identified by the value of this attribute, as an Angular expression) + * will receive the dropped domain object (at which point it can handle + * the drop, typically by inserting/reordering.) + * @param {DndService} dndService drag-and-drop service + */ + function WARPSwimlaneDrop(dndService) { + + // Handle dragover events + function dragOver(e, element, swimlane) { + var event = (e || {}).originalEvent || e, + height = element[0].offsetHeight, + rect = element[0].getBoundingClientRect(), + offset = event.pageY - rect.top, + dataTransfer = event.dataTransfer, + id = dndService.getData( + SwimlaneDragConstants.MCT_DRAG_TYPE + ), + draggedObject = dndService.getData( + SwimlaneDragConstants.MCT_EXTENDED_DRAG_TYPE + ); + + if (id) { + // TODO: Vary this based on modifier keys + event.dataTransfer.dropEffect = 'move'; + + // Set the swimlane's drop highlight state; top 75% is + // for drop-into, bottom 25% is for drop-after. + swimlane.highlight( + offset < (height * 0.75) && + swimlane.allowDropIn(id, draggedObject) + ); + swimlane.highlightBottom( + offset >= (height * 0.75) && + swimlane.allowDropAfter(id, draggedObject) + ); + + // Indicate that we will accept the drag + if (swimlane.highlight() || swimlane.highlightBottom()) { + event.preventDefault(); // Required in Chrome? + return false; + } + } + } + + // Handle drop events + function drop(e, element, swimlane) { + var event = (e || {}).originalEvent || e, + dataTransfer = event.dataTransfer, + id = dataTransfer.getData( + SwimlaneDragConstants.MCT_DRAG_TYPE + ), + draggedSwimlane = dndService.getData( + SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE + ); + + if (id) { + // Delegate the drop to the swimlane itself + swimlane.drop(id, (draggedSwimlane || {}).domainObject); + } + + // Clear the swimlane highlights + swimlane.highlight(false); + swimlane.highlightBottom(false); + } + + function link(scope, element, attrs) { + // Lookup swimlane by evaluating this attribute + function swimlane() { + return scope.$eval(attrs.warpSwimlaneDrop); + } + // Handle dragover + element.on('dragover', function (e) { + dragOver(e, element, swimlane()); + }); + // Handle drops + element.on('drop', function (e) { + drop(e, element, swimlane()); + }); + // Clear highlights when drag leaves this swimlane + element.on('dragleave', function () { + swimlane().highlight(false); + swimlane().highlightBottom(false); + }); + } + + return { + // Applies to attributes + restrict: "A", + // Link using above function + link: link + }; + } + + return WARPSwimlaneDrop; + } +); \ No newline at end of file diff --git a/platform/features/timeline/src/services/ObjectLoader.js b/platform/features/timeline/src/services/ObjectLoader.js new file mode 100644 index 0000000000..38596557bf --- /dev/null +++ b/platform/features/timeline/src/services/ObjectLoader.js @@ -0,0 +1,114 @@ +/*global define*/ + +define( + [], + function () { + 'use strict'; + + /** + * The ObjectLoader is a utility service for loading subgraphs + * of the composition hierarchy, starting at a provided object, + * and optionally filtering out objects which fail to meet certain + * criteria. + * @constructor + */ + function ObjectLoader($q) { + + // Build up an object containing id->object pairs + // for the subset of the graph that is relevant. + function loadSubGraph(domainObject, criterion) { + var result = { domainObject: domainObject, composition: [] }, + visiting = {}, + filter; + + // Check object existence (for criterion-less filtering) + function exists(domainObject) { + return !!domainObject; + } + + // Check for capability matching criterion + function hasCapability(domainObject) { + return domainObject && domainObject.hasCapability(criterion); + } + + // For the recursive step... + function loadSubGraphFor(childObject) { + return loadSubGraph(childObject, filter); + } + + // Store loaded subgraphs into the result + function storeSubgraphs(subgraphs) { + result.composition = subgraphs; + } + + // Avoid infinite recursion + function notVisiting(domainObject) { + return !visiting[domainObject.getId()]; + } + + // Put the composition of this domain object into the result + function mapIntoResult(composition) { + return $q.all( + composition.filter(filter).filter(notVisiting) + .map(loadSubGraphFor) + ).then(storeSubgraphs); + } + + // Used to give the final result after promise chaining + function giveResult() { + // Stop suppressing recursive visitation + visiting[domainObject.getId()] = true; + // And return the expecting result value + return result; + } + + // Load composition for + function loadComposition() { + // First, record that we're looking at this domain + // object to detect cycles and avoid an infinite loop + visiting[domainObject.getId()] = true; + // Look up the composition, store it to the graph structure + return domainObject.useCapability('composition') + .then(mapIntoResult) + .then(giveResult); + } + + // Choose the filter function to use + filter = typeof criterion === 'function' ? criterion : + (typeof criterion === 'string' ? hasCapability : + exists); + + // Load child hierarchy, then provide the flat list + return domainObject.hasCapability('composition') ? + loadComposition() : $q.when(result); + } + + return { + /** + * Load domain objects contained in the subgraph of the + * composition hierarchy which starts at the specified + * domain object, optionally pruning out objects (and their + * subgraphs) which match a certain criterion. + * The result is given as a promise for an object containing + * key-value pairs, where keys are domain object identifiers + * and values are domain objects in the subgraph. + * The criterion may be omitted (in which case no pruning is + * done) or specified as a string, in which case it will be + * treated as the name of a required capability, or specified + * as a function, which should return a truthy/falsy value + * when called with a domain object to indicate whether or + * not it should be included in the result set. + * + * @param {DomainObject} domainObject the domain object to + * start from + * @param {string|Function} [criterion] the criterion used + * to prune domain objects + * @returns {Promise} a promise for loaded domain objects + */ + load: loadSubGraph + }; + } + + return ObjectLoader; + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/TimelineConstantsSpec.js b/platform/features/timeline/test/TimelineConstantsSpec.js new file mode 100644 index 0000000000..303bb6b2d7 --- /dev/null +++ b/platform/features/timeline/test/TimelineConstantsSpec.js @@ -0,0 +1,14 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../src/TimelineConstants'], + function (TimelineConstants) { + "use strict"; + describe("The set of Timeline constants", function () { + it("specifies a handle width", function () { + expect(TimelineConstants.HANDLE_WIDTH) + .toEqual(jasmine.any(Number)); + }); + }); + } +); diff --git a/platform/features/timeline/test/TimelineFormatterSpec.js b/platform/features/timeline/test/TimelineFormatterSpec.js new file mode 100644 index 0000000000..01ca2f7204 --- /dev/null +++ b/platform/features/timeline/test/TimelineFormatterSpec.js @@ -0,0 +1,41 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../src/TimelineFormatter'], + function (TimelineFormatter) { + 'use strict'; + + var SECOND = 1000, + MINUTE = SECOND * 60, + HOUR = MINUTE * 60, + DAY = HOUR * 24; + + describe("The timeline formatter", function () { + var formatter; + + beforeEach(function () { + formatter = new TimelineFormatter(); + }); + + it("formats durations with seconds", function () { + expect(formatter.format(SECOND)).toEqual("000 00:00:01.000"); + }); + + it("formats durations with milliseconds", function () { + expect(formatter.format(SECOND + 42)).toEqual("000 00:00:01.042"); + }); + + it("formats durations with days", function () { + expect(formatter.format(3 * DAY + SECOND)).toEqual("003 00:00:01.000"); + }); + + it("formats durations with hours", function () { + expect(formatter.format(DAY + HOUR * 11 + SECOND)).toEqual("001 11:00:01.000"); + }); + + it("formats durations with minutes", function () { + expect(formatter.format(HOUR + MINUTE * 21)).toEqual("000 01:21:00.000"); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js b/platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js new file mode 100644 index 0000000000..9728fd2181 --- /dev/null +++ b/platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js @@ -0,0 +1,71 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/ActivityTimespanCapability'], + function (ActivityTimespanCapability) { + 'use strict'; + + describe("An Activity's timespan capability", function () { + var mockQ, + mockDomainObject, + capability; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (callback) { + return asPromise(callback(v)); + } + }; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['when']); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getModel', 'getCapability' ] + ); + + mockQ.when.andCallFake(asPromise); + mockDomainObject.getModel.andReturn({ + start: { + timestamp: 42000, + epoch: "TEST" + }, + duration: { + timestamp: 12321 + } + }); + + capability = new ActivityTimespanCapability( + mockQ, + mockDomainObject + ); + }); + + it("applies only to activity objects", function () { + expect(ActivityTimespanCapability.appliesTo({ + type: 'warp.activity' + })).toBeTruthy(); + expect(ActivityTimespanCapability.appliesTo({ + type: 'folder' + })).toBeFalsy(); + }); + + it("provides timespan based on model", function () { + var mockCallback = jasmine.createSpy('callback'); + capability.invoke().then(mockCallback); + // We verify other methods in ActivityTimespanSpec, + // so just make sure we got something that looks right. + expect(mockCallback).toHaveBeenCalledWith({ + getStart: jasmine.any(Function), + getEnd: jasmine.any(Function), + getDuration: jasmine.any(Function), + setStart: jasmine.any(Function), + setEnd: jasmine.any(Function), + setDuration: jasmine.any(Function), + getEpoch: jasmine.any(Function) + }); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/ActivityTimespanSpec.js b/platform/features/timeline/test/capabilities/ActivityTimespanSpec.js new file mode 100644 index 0000000000..da256ed12f --- /dev/null +++ b/platform/features/timeline/test/capabilities/ActivityTimespanSpec.js @@ -0,0 +1,80 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/ActivityTimespan'], + function (ActivityTimespan) { + 'use strict'; + + describe("An Activity's timespan", function () { + var testModel, + mutatorModel, + mockMutation, + timespan; + + beforeEach(function () { + testModel = { + start: { + timestamp: 42000, + epoch: "TEST" + }, + duration: { + timestamp: 12321 + } + }; + // Provide a cloned model for mutation purposes + // It is important to distinguish mutation made to + // the model provided via the mutation capability from + // changes made to the model directly (the latter is + // not intended usage.) + mutatorModel = JSON.parse(JSON.stringify(testModel)); + mockMutation = jasmine.createSpyObj("mutation", ["mutate"]); + mockMutation.mutate.andCallFake(function (mutator) { + mutator(mutatorModel); + }); + timespan = new ActivityTimespan(testModel, mockMutation); + }); + + it("provides a start time", function () { + expect(timespan.getStart()).toEqual(42000); + }); + + it("provides an end time", function () { + expect(timespan.getEnd()).toEqual(54321); + }); + + it("provides duration", function () { + expect(timespan.getDuration()).toEqual(12321); + }); + + it("provides an epoch", function () { + expect(timespan.getEpoch()).toEqual("TEST"); + }); + + it("sets start time using mutation capability", function () { + timespan.setStart(52000); + expect(mutatorModel.start.timestamp).toEqual(52000); + // Should have also changed duration to preserve end + expect(mutatorModel.duration.timestamp).toEqual(2321); + // Original model should still be the same + expect(testModel.start.timestamp).toEqual(42000); + }); + + it("sets end time using mutation capability", function () { + timespan.setEnd(44000); + // Should have also changed duration to preserve end + expect(mutatorModel.duration.timestamp).toEqual(2000); + // Original model should still be the same + expect(testModel.duration.timestamp).toEqual(12321); + }); + + it("sets duration using mutation capability", function () { + timespan.setDuration(8000); + // Should have also changed duration to preserve end + expect(mutatorModel.duration.timestamp).toEqual(8000); + // Original model should still be the same + expect(testModel.duration.timestamp).toEqual(12321); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/ActivityUtilizationSpec.js b/platform/features/timeline/test/capabilities/ActivityUtilizationSpec.js new file mode 100644 index 0000000000..6450f08491 --- /dev/null +++ b/platform/features/timeline/test/capabilities/ActivityUtilizationSpec.js @@ -0,0 +1,20 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/ActivityUtilization'], + function (ActivityUtilization) { + 'use strict'; + + describe("An Activity's resource utilization", function () { + + // Placeholder; WTD-918 will implement + it("has the expected interface", function () { + var utilization = new ActivityUtilization(); + expect(utilization.getPointCount()).toEqual(jasmine.any(Number)); + expect(utilization.getDomainValue()).toEqual(jasmine.any(Number)); + expect(utilization.getRangeValue()).toEqual(jasmine.any(Number)); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/CostCapabilitySpec.js b/platform/features/timeline/test/capabilities/CostCapabilitySpec.js new file mode 100644 index 0000000000..1d9ddbc0e7 --- /dev/null +++ b/platform/features/timeline/test/capabilities/CostCapabilitySpec.js @@ -0,0 +1,60 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/CostCapability'], + function (CostCapability) { + 'use strict'; + + describe("A subsystem mode's cost capability", function () { + var testModel, + capability; + + beforeEach(function () { + var mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getModel', 'getId' ] + ); + + testModel = { + resources: { + abc: -1, + power: 12321, + comms: 42 + } + }; + + mockDomainObject.getModel.andReturn(testModel); + + capability = new CostCapability(mockDomainObject); + }); + + it("provides a list of resource types", function () { + expect(capability.resources()) + .toEqual(['abc', 'comms', 'power']); + }); + + it("provides resource costs", function () { + expect(capability.cost('abc')).toEqual(-1); + expect(capability.cost('power')).toEqual(12321); + expect(capability.cost('comms')).toEqual(42); + }); + + it("provides all resources in a group", function () { + expect(capability.invoke()).toEqual(testModel.resources); + }); + + it("applies to subsystem modes", function () { + expect(CostCapability.appliesTo({ + type: "warp.mode" + })).toBeTruthy(); + expect(CostCapability.appliesTo({ + type: "warp.activity" + })).toBeFalsy(); + expect(CostCapability.appliesTo({ + type: "warp.other" + })).toBeFalsy(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/CumulativeGraphSpec.js b/platform/features/timeline/test/capabilities/CumulativeGraphSpec.js new file mode 100644 index 0000000000..dd9a797ac9 --- /dev/null +++ b/platform/features/timeline/test/capabilities/CumulativeGraphSpec.js @@ -0,0 +1,67 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/CumulativeGraph'], + function (CumulativeGraph) { + 'use strict'; + + describe("A cumulative resource graph", function () { + var mockGraph, + points, + graph; + + beforeEach(function () { + points = [ 0, 10, -10, -100, 20, 100, 0 ]; + + mockGraph = jasmine.createSpyObj( + 'graph', + [ 'getPointCount', 'getDomainValue', 'getRangeValue' ] + ); + + mockGraph.getPointCount.andReturn(points.length * 2); + mockGraph.getDomainValue.andCallFake(function (i) { + return Math.floor(i / 2) * 100 + 25; + }); + mockGraph.getRangeValue.andCallFake(function (i) { + return points[Math.floor(i / 2) + i % 2]; + }); + + graph = new CumulativeGraph( + mockGraph, + 1000, + 2000, + 1500, + 1 / 10 + ); + }); + + it("accumulates its wrapped instantaneous graph", function () { + // Note that range values are percentages + expect(graph.getDomainValue(0)).toEqual(0); + expect(graph.getRangeValue(0)).toEqual(50); // initial state + expect(graph.getDomainValue(1)).toEqual(25); + expect(graph.getRangeValue(1)).toEqual(50); // initial state + expect(graph.getDomainValue(2)).toEqual(125); + expect(graph.getRangeValue(2)).toEqual(60); // +10 + expect(graph.getDomainValue(3)).toEqual(225); + expect(graph.getRangeValue(3)).toEqual(50); // -10 + expect(graph.getDomainValue(4)).toEqual(275); + expect(graph.getRangeValue(4)).toEqual(0); // -100 (hit bottom) + expect(graph.getDomainValue(5)).toEqual(325); + expect(graph.getRangeValue(5)).toEqual(0); // still at 0... + expect(graph.getDomainValue(6)).toEqual(425); + expect(graph.getRangeValue(6)).toEqual(20); // +20 + expect(graph.getDomainValue(7)).toEqual(505); + expect(graph.getRangeValue(7)).toEqual(100); // +100 + expect(graph.getDomainValue(8)).toEqual(525); + expect(graph.getRangeValue(8)).toEqual(100); // still full + expect(graph.getDomainValue(9)).toEqual(625); + expect(graph.getRangeValue(9)).toEqual(100); // still full + expect(graph.getPointCount()).toEqual(10); + }); + + + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/GraphCapabilitySpec.js b/platform/features/timeline/test/capabilities/GraphCapabilitySpec.js new file mode 100644 index 0000000000..0ec3c7f58c --- /dev/null +++ b/platform/features/timeline/test/capabilities/GraphCapabilitySpec.js @@ -0,0 +1,98 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/GraphCapability'], + function (GraphCapability) { + 'use strict'; + + describe("A Timeline's graph capability", function () { + var mockQ, + mockDomainObject, + testModel, + capability; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (cb) { + return asPromise(cb(v)); + } + }; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['when']); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getId', 'getModel', 'useCapability' ] + ); + + testModel = { + type: "warp.activity", + resources: { + abc: 100, + xyz: 42 + } + }; + + mockQ.when.andCallFake(asPromise); + mockDomainObject.getModel.andReturn(testModel); + + capability = new GraphCapability( + mockQ, + mockDomainObject + ); + }); + + it("is applicable to timelines", function () { + expect(GraphCapability.appliesTo({ + type: "warp.timeline" + })).toBeTruthy(); + }); + + it("is applicable to activities", function () { + expect(GraphCapability.appliesTo(testModel)) + .toBeTruthy(); + }); + + it("is not applicable to other objects", function () { + expect(GraphCapability.appliesTo({ + type: "something" + })).toBeFalsy(); + }); + + it("provides one graph per resource type", function () { + var mockCallback = jasmine.createSpy('callback'); + + mockDomainObject.useCapability.andReturn(asPromise([ + { key: "abc", start: 0, end: 15 }, + { key: "abc", start: 0, end: 15 }, + { key: "def", start: 4, end: 15 }, + { key: "xyz", start: 0, end: 20 } + ])); + + capability.invoke().then(mockCallback); + + expect(mockCallback).toHaveBeenCalledWith({ + abc: jasmine.any(Object), + def: jasmine.any(Object), + xyz: jasmine.any(Object) + }); + }); + + it("provides a battery graph for timelines with capacity", function () { + var mockCallback = jasmine.createSpy('callback'); + testModel.capacity = 1000; + testModel.type = "warp.timeline"; + mockDomainObject.useCapability.andReturn(asPromise([ + { key: "power", start: 0, end: 15 } + ])); + capability.invoke().then(mockCallback); + expect(mockCallback).toHaveBeenCalledWith({ + power: jasmine.any(Object), + battery: jasmine.any(Object) + }); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/ResourceGraphSpec.js b/platform/features/timeline/test/capabilities/ResourceGraphSpec.js new file mode 100644 index 0000000000..c6a47c8c55 --- /dev/null +++ b/platform/features/timeline/test/capabilities/ResourceGraphSpec.js @@ -0,0 +1,56 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/ResourceGraph'], + function (ResourceGraph) { + 'use strict'; + + describe("A resource graph capability", function () { + + // Placeholder; WTD-918 will implement + it("has zero points for zero utilization changes", function () { + var graph = new ResourceGraph([]); + expect(graph.getPointCount()).toEqual(0); + }); + + it("creates steps based on resource utilizations", function () { + var graph = new ResourceGraph([ + { start: 5, end: 100, value: 42 }, + { start: 50, end: 120, value: -22 }, + { start: 15, end: 40, value: 30 }, + { start: 150, end: 180, value: -10 } + ]); + + expect(graph.getPointCount()).toEqual(16); + + // Should get two values at every time stamp, for step-like appearance + [ 5, 15, 40, 50, 100, 120, 150, 180].forEach(function (v, i) { + expect(graph.getDomainValue(i * 2)).toEqual(v); + expect(graph.getDomainValue(i * 2 + 1)).toEqual(v); + }); + + // Should also repeat values at subsequent indexes, but offset differently, + // for horizontal spans between steps + [ 0, 42, 72, 42, 20, -22, 0, -10].forEach(function (v, i) { + expect(graph.getRangeValue(i * 2)).toEqual(v); + // Offset backwards; wrap around end of the series + expect(graph.getRangeValue((16 + i * 2 - 1) % 16)).toEqual(v); + }); + }); + + it("filters out zero-duration spikes", function () { + var graph = new ResourceGraph([ + { start: 5, end: 100, value: 42 }, + { start: 100, end: 120, value: -22 }, + { start: 100, end: 180, value: 30 }, + { start: 130, end: 180, value: -10 } + ]); + + // There are only 5 unique timestamps there, so there should + // be 5 steps, for 10 total points + expect(graph.getPointCount()).toEqual(10); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js b/platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js new file mode 100644 index 0000000000..2208ec0002 --- /dev/null +++ b/platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js @@ -0,0 +1,115 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/TimelineTimespanCapability'], + function (TimelineTimespanCapability) { + 'use strict'; + + describe("A Timeline's timespan capability", function () { + var mockQ, + mockDomainObject, + mockChildA, + mockChildB, + mockTimespanA, + mockTimespanB, + capability; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (callback) { + return asPromise(callback(v)); + } + }; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['when', 'all']); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getModel', 'getCapability', 'useCapability' ] + ); + mockChildA = jasmine.createSpyObj( + 'childA', + [ 'getModel', 'useCapability', 'hasCapability' ] + ); + mockChildB = jasmine.createSpyObj( + 'childA', + [ 'getModel', 'useCapability', 'hasCapability' ] + ); + mockTimespanA = jasmine.createSpyObj( + 'timespanA', + [ 'getEnd' ] + ); + mockTimespanB = jasmine.createSpyObj( + 'timespanB', + [ 'getEnd' ] + ); + + mockQ.when.andCallFake(asPromise); + mockQ.all.andCallFake(function (values) { + var result = []; + function addResult(v) { result.push(v); } + function promiseResult(v) { asPromise(v).then(addResult); } + values.forEach(promiseResult); + return asPromise(result); + }); + mockDomainObject.getModel.andReturn({ + start: { + timestamp: 42000, + epoch: "TEST" + }, + duration: { + timestamp: 12321 + } + }); + mockDomainObject.useCapability.andCallFake(function (c) { + if (c === 'composition') { + return asPromise([ mockChildA, mockChildB ]); + } + }); + mockChildA.hasCapability.andReturn(true); + mockChildB.hasCapability.andReturn(true); + mockChildA.useCapability.andCallFake(function (c) { + return c === 'timespan' && mockTimespanA; + }); + mockChildB.useCapability.andCallFake(function (c) { + return c === 'timespan' && mockTimespanB; + }); + + capability = new TimelineTimespanCapability( + mockQ, + mockDomainObject + ); + }); + + it("applies only to timeline objects", function () { + expect(TimelineTimespanCapability.appliesTo({ + type: 'warp.timeline' + })).toBeTruthy(); + expect(TimelineTimespanCapability.appliesTo({ + type: 'folder' + })).toBeFalsy(); + }); + + it("provides timespan based on model", function () { + var mockCallback = jasmine.createSpy('callback'); + capability.invoke().then(mockCallback); + // We verify other methods in ActivityTimespanSpec, + // so just make sure we got something that looks right. + expect(mockCallback).toHaveBeenCalledWith({ + getStart: jasmine.any(Function), + getEnd: jasmine.any(Function), + getDuration: jasmine.any(Function), + setStart: jasmine.any(Function), + setEnd: jasmine.any(Function), + setDuration: jasmine.any(Function), + getEpoch: jasmine.any(Function) + }); + // Finally, verify that getEnd recurses + mockCallback.mostRecentCall.args[0].getEnd(); + expect(mockTimespanA.getEnd).toHaveBeenCalled(); + expect(mockTimespanB.getEnd).toHaveBeenCalled(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/TimelineTimespanSpec.js b/platform/features/timeline/test/capabilities/TimelineTimespanSpec.js new file mode 100644 index 0000000000..1216762fba --- /dev/null +++ b/platform/features/timeline/test/capabilities/TimelineTimespanSpec.js @@ -0,0 +1,91 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/TimelineTimespan'], + function (TimelineTimespan) { + 'use strict'; + + describe("A Timeline's timespan", function () { + var testModel, + mockTimespans, + mockMutation, + mutationModel, + timespan; + + function makeMockTimespan(end) { + var mockTimespan = jasmine.createSpyObj( + 'timespan-' + end, + ['getEnd'] + ); + mockTimespan.getEnd.andReturn(end); + return mockTimespan; + } + + beforeEach(function () { + testModel = { + start: { + timestamp: 42000, + epoch: "TEST" + } + }; + + mutationModel = JSON.parse(JSON.stringify(testModel)); + + mockMutation = jasmine.createSpyObj("mutation", ["mutate"]); + mockTimespans = [ 44000, 65000, 1100 ].map(makeMockTimespan); + + mockMutation.mutate.andCallFake(function (mutator) { + mutator(mutationModel); + }); + + timespan = new TimelineTimespan( + testModel, + mockMutation, + mockTimespans + ); + }); + + it("provides a start time", function () { + expect(timespan.getStart()).toEqual(42000); + }); + + it("provides an end time", function () { + expect(timespan.getEnd()).toEqual(65000); + }); + + it("provides duration", function () { + expect(timespan.getDuration()).toEqual(65000 - 42000); + }); + + it("provides an epoch", function () { + expect(timespan.getEpoch()).toEqual("TEST"); + }); + + + it("sets start time using mutation capability", function () { + timespan.setStart(52000); + expect(mutationModel.start.timestamp).toEqual(52000); + // Original model should still be the same + expect(testModel.start.timestamp).toEqual(42000); + }); + + it("makes no changes with setEnd", function () { + // Copy initial state to verify that it doesn't change + var initialModel = JSON.parse(JSON.stringify(testModel)); + timespan.setEnd(123454321); + // Neither model should have changed + expect(testModel).toEqual(initialModel); + expect(mutationModel).toEqual(initialModel); + }); + + it("makes no changes with setDuration", function () { + // Copy initial state to verify that it doesn't change + var initialModel = JSON.parse(JSON.stringify(testModel)); + timespan.setDuration(123454321); + // Neither model should have changed + expect(testModel).toEqual(initialModel); + expect(mutationModel).toEqual(initialModel); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/TimelineUtilizationSpec.js b/platform/features/timeline/test/capabilities/TimelineUtilizationSpec.js new file mode 100644 index 0000000000..82ff0468eb --- /dev/null +++ b/platform/features/timeline/test/capabilities/TimelineUtilizationSpec.js @@ -0,0 +1,20 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/TimelineUtilization'], + function (TimelineUtilization) { + 'use strict'; + + describe("A Timeline's resource utilization", function () { + + // Placeholder; WTD-918 will implement + it("has the expected interface", function () { + var utilization = new TimelineUtilization(); + expect(utilization.getPointCount()).toEqual(jasmine.any(Number)); + expect(utilization.getDomainValue()).toEqual(jasmine.any(Number)); + expect(utilization.getRangeValue()).toEqual(jasmine.any(Number)); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js b/platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js new file mode 100644 index 0000000000..e9693d89db --- /dev/null +++ b/platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js @@ -0,0 +1,195 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/capabilities/UtilizationCapability'], + function (UtilizationCapability) { + 'use strict'; + + describe("A Timeline's utilization capability", function () { + var mockQ, + mockDomainObject, + testModel, + testCapabilities, + mockRelationship, + mockComposition, + mockCallback, + capability; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (callback) { + return asPromise(callback(v)); + }, + testValue: v + }; + } + + function allPromises(promises) { + return asPromise(promises.map(function (p) { + return (p || {}).then ? p.testValue : p; + })); + } + + // Utility function for making domain objects with utilization + // and/or cost capabilities + function fakeDomainObject(resources, start, end, costs) { + return { + getCapability: function (c) { + return ((c === 'utilization') && { + // Utilization capability + resources: function () { + return asPromise(resources); + }, + invoke: function () { + return asPromise(resources.map(function (k) { + return { key: k, start: start, end: end }; + })); + } + }) || ((c === 'cost') && { + // Cost capability + resources: function () { + return Object.keys(costs).sort(); + }, + cost: function (c) { + return costs[c]; + } + }); + }, + useCapability: function (c) { + return this.getCapability(c).invoke(); + } + }; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['when', 'all']); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getId', 'getModel', 'getCapability', 'useCapability' ] + ); + mockRelationship = jasmine.createSpyObj( + 'relationship', + [ 'getRelatedObjects' ] + ); + mockComposition = jasmine.createSpyObj( + 'composition', + [ 'invoke' ] + ); + mockCallback = jasmine.createSpy('callback'); + + testModel = { + type: "warp.activity", + resources: { + abc: 100, + xyz: 42 + } + }; + testCapabilities = { + composition: mockComposition, + relationship: mockRelationship + }; + + mockQ.when.andCallFake(asPromise); + mockQ.all.andCallFake(allPromises); + mockDomainObject.getModel.andReturn(testModel); + mockDomainObject.getCapability.andCallFake(function (c) { + return testCapabilities[c]; + }); + mockDomainObject.useCapability.andCallFake(function (c) { + return testCapabilities[c] && testCapabilities[c].invoke(); + }); + + capability = new UtilizationCapability( + mockQ, + mockDomainObject + ); + }); + + it("is applicable to timelines", function () { + expect(UtilizationCapability.appliesTo({ + type: "warp.timeline" + })).toBeTruthy(); + }); + + it("is applicable to activities", function () { + expect(UtilizationCapability.appliesTo(testModel)) + .toBeTruthy(); + }); + + it("is not applicable to other objects", function () { + expect(UtilizationCapability.appliesTo({ + type: "something" + })).toBeFalsy(); + }); + + it("accumulates resources from composition", function () { + mockComposition.invoke.andReturn(asPromise([ + fakeDomainObject(['abc', 'def']), + fakeDomainObject(['def', 'xyz']), + fakeDomainObject(['abc', 'xyz']) + ])); + + capability.resources().then(mockCallback); + + expect(mockCallback) + .toHaveBeenCalledWith(['abc', 'def', 'xyz']); + }); + + it("accumulates utilizations from composition", function () { + mockComposition.invoke.andReturn(asPromise([ + fakeDomainObject(['abc', 'def'], 10, 100), + fakeDomainObject(['def', 'xyz'], 50, 90) + ])); + + capability.invoke().then(mockCallback); + + expect(mockCallback).toHaveBeenCalledWith([ + { key: 'abc', start: 10, end: 100 }, + { key: 'def', start: 10, end: 100 }, + { key: 'def', start: 50, end: 90 }, + { key: 'xyz', start: 50, end: 90 } + ]); + }); + + it("provides intrinsic utilization from related objects", function () { + var mockTimespan = jasmine.createSpyObj( + 'timespan', + ['getStart', 'getEnd', 'getEpoch'] + ), + mockTimespanCapability = jasmine.createSpyObj( + 'timespanCapability', + ['invoke'] + ); + mockComposition.invoke.andReturn(asPromise([])); + mockRelationship.getRelatedObjects.andReturn(asPromise([ + fakeDomainObject([], 0, 0, { abc: 5, xyz: 15 }) + ])); + + testCapabilities.timespan = mockTimespanCapability; + mockTimespanCapability.invoke.andReturn(asPromise(mockTimespan)); + mockTimespan.getStart.andReturn(42); + mockTimespan.getEnd.andReturn(12321); + mockTimespan.getEpoch.andReturn("TEST"); + + capability.invoke().then(mockCallback); + + expect(mockCallback).toHaveBeenCalledWith([ + { key: 'abc', start: 42, end: 12321, value: 5, epoch: "TEST" }, + { key: 'xyz', start: 42, end: 12321, value: 15, epoch: "TEST" } + ]); + }); + + it("provides resource keys from related objects", function () { + mockComposition.invoke.andReturn(asPromise([])); + mockRelationship.getRelatedObjects.andReturn(asPromise([ + fakeDomainObject([], 0, 0, { abc: 5, xyz: 15 }) + ])); + + capability.resources().then(mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(['abc', 'xyz']); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/ActivityModeValuesControllerSpec.js b/platform/features/timeline/test/controllers/ActivityModeValuesControllerSpec.js new file mode 100644 index 0000000000..9c11262deb --- /dev/null +++ b/platform/features/timeline/test/controllers/ActivityModeValuesControllerSpec.js @@ -0,0 +1,32 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/controllers/ActivityModeValuesController'], + function (ActivityModeValuesController) { + 'use strict'; + + describe("An Activity Mode's Values view controller", function () { + var testResources, + controller; + + beforeEach(function () { + testResources = [ + { key: 'abc', name: "Some name" }, + { key: 'def', name: "Test type", units: "Test units" }, + { key: 'xyz', name: "Something else" } + ]; + controller = new ActivityModeValuesController(testResources); + }); + + it("exposes resource metadata by key", function () { + expect(controller.metadata('abc')).toEqual(testResources[0]); + expect(controller.metadata('def')).toEqual(testResources[1]); + expect(controller.metadata('xyz')).toEqual(testResources[2]); + }); + + it("exposes no metadata for unknown keys", function () { + expect(controller.metadata('???')).toBeUndefined(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/TimelineControllerSpec.js b/platform/features/timeline/test/controllers/TimelineControllerSpec.js new file mode 100644 index 0000000000..e1cb518535 --- /dev/null +++ b/platform/features/timeline/test/controllers/TimelineControllerSpec.js @@ -0,0 +1,229 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/controllers/TimelineController'], + function (TimelineController) { + 'use strict'; + + var DOMAIN_OBJECT_METHODS = [ + 'getModel', + 'getId', + 'useCapability', + 'hasCapability', + 'getCapability' + ]; + + describe("The timeline controller", function () { + var mockScope, + mockQ, + mockLoader, + mockDomainObject, + mockSpan, + testModels, + testConfiguration, + controller; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (callback) { + return asPromise(callback(v)); + }, + testValue: v + }; + } + + function allPromises(promises) { + return asPromise(promises.map(function (p) { + return (p || {}).then ? p.testValue : p; + })); + } + + function subgraph(domainObject, objects) { + function lookupSubgraph(id) { + return subgraph(objects[id], objects); + } + return { + domainObject: domainObject, + composition: (domainObject.getModel().composition || []) + .map(lookupSubgraph) + }; + } + + + beforeEach(function () { + var mockA, mockB, mockUtilization, mockPromise, mockGraph, testCapabilities; + + function getCapability(c) { + return testCapabilities[c]; + } + + function useCapability(c) { + return c === 'timespan' ? asPromise(mockSpan) : + c === 'graph' ? asPromise({ abc: mockGraph, xyz: mockGraph }) : + undefined; + } + + testModels = { + a: { modified: 40, composition: ['b'] }, + b: { modified: 2 } + }; + + testConfiguration = {}; + + mockQ = jasmine.createSpyObj('$q', ['when', 'all']); + mockA = jasmine.createSpyObj('a', DOMAIN_OBJECT_METHODS); + mockB = jasmine.createSpyObj('b', DOMAIN_OBJECT_METHODS); + mockSpan = jasmine.createSpyObj('span', ['getStart', 'getEnd']); + mockUtilization = jasmine.createSpyObj('utilization', ['resources', 'utilization']); + mockGraph = jasmine.createSpyObj('graph', ['getPointCount']); + mockPromise = jasmine.createSpyObj('promise', ['then']); + + mockScope = jasmine.createSpyObj( + "$scope", + [ '$watch', '$on' ] + ); + mockLoader = jasmine.createSpyObj('objectLoader', ['load']); + mockDomainObject = mockA; + + mockScope.domainObject = mockDomainObject; + mockScope.configuration = testConfiguration; + mockQ.when.andCallFake(asPromise); + mockQ.all.andCallFake(allPromises); + mockA.getId.andReturn('a'); + mockA.getModel.andReturn(testModels.a); + mockB.getId.andReturn('b'); + mockB.getModel.andReturn(testModels.b); + mockA.useCapability.andCallFake(useCapability); + mockB.useCapability.andCallFake(useCapability); + mockA.hasCapability.andReturn(true); + mockB.hasCapability.andReturn(true); + mockA.getCapability.andCallFake(getCapability); + mockB.getCapability.andCallFake(getCapability); + mockSpan.getStart.andReturn(42); + mockSpan.getEnd.andReturn(12321); + mockUtilization.resources.andReturn(['abc', 'xyz']); + mockUtilization.utilization.andReturn(mockPromise); + mockLoader.load.andCallFake(function () { + return asPromise(subgraph(mockA, { + a: mockA, + b: mockB + })); + }); + + testCapabilities = { + "utilization": mockUtilization + }; + + controller = new TimelineController(mockScope, mockQ, mockLoader, 0); + }); + + it("exposes scroll state tracker in scope", function () { + expect(mockScope.scroll.x).toEqual(0); + expect(mockScope.scroll.y).toEqual(0); + }); + + it("repopulates when modifications are made", function () { + var fnWatchCall, + strWatchCall; + + // Find the $watch that was given a function + mockScope.$watch.calls.forEach(function (call) { + if (typeof call.args[0] === 'function') { + // white-box: we know the first call is + // the one we're looking for + fnWatchCall = fnWatchCall || call; + } else if (typeof call.args[0] === 'string') { + strWatchCall = strWatchCall || call; + } + }); + + // Make sure string watch was for domainObject + expect(strWatchCall.args[0]).toEqual('domainObject'); + // Initially populate + strWatchCall.args[1](mockDomainObject); + + // There should be to swimlanes + expect(controller.swimlanes().length).toEqual(2); + + // Watch should be for sum of modified flags... + expect(fnWatchCall.args[0]()).toEqual(42); + + // Remove the child, then fire the watch + testModels.a.composition = []; + fnWatchCall.args[1](); + + // Swimlanes should have updated + expect(controller.swimlanes().length).toEqual(1); + }); + + it("repopulates graphs when graph choices change", function () { + var tmp; + + // Note that this test is brittle; it relies upon the + // order of $watch calls in TimelineController. + + // Initially populate + mockScope.$watch.calls[0].args[1](mockDomainObject); + + // Verify precondition - no graphs + expect(controller.graphs().length).toEqual(0); + + // Execute the watch function for graph state + tmp = mockScope.$watch.calls[2].args[0](); + + // Change graph state + testConfiguration.graph = { a: true, b: true }; + + // Verify that this would have triggered a watch + expect(mockScope.$watch.calls[2].args[0]()) + .not.toEqual(tmp); + + // Run the function the watch would have triggered + mockScope.$watch.calls[2].args[1](); + + // Should have some graphs now + expect(controller.graphs().length).toEqual(2); + + }); + + it("reports full scrollable width using zoom controller", function () { + var mockZoom = jasmine.createSpyObj('zoom', ['toPixels', 'duration']); + mockZoom.toPixels.andReturn(54321); + mockZoom.duration.andReturn(12345); + + // Initially populate + mockScope.$watch.calls[0].args[1](mockDomainObject); + + expect(controller.width(mockZoom)).toEqual(54321); + // Verify interactions; we took zoom's duration for our start/end, + // and converted it to pixels. + // First, check that we used the start/end (from above) + expect(mockZoom.duration).toHaveBeenCalledWith(12321 - 42); + // Next, verify that the result was passed to toPixels + expect(mockZoom.toPixels).toHaveBeenCalledWith(12345); + }); + + it("provides drag handles", function () { + // TimelineDragPopulator et al are tested for these, + // so just verify that handles are indeed exposed. + expect(controller.handles()).toEqual(jasmine.any(Array)); + }); + + it("refreshes graphs on request", function () { + var mockGraph = jasmine.createSpyObj('graph', ['refresh']); + + // Sneak a mock graph into the graph populator... + // This is whiteboxy and will have to change if + // GraphPopulator changes + controller.graphs().push(mockGraph); + + // Refresh + controller.refresh(); + + // Should have refreshed the graph + expect(mockGraph.refresh).toHaveBeenCalled(); + }); + }); + + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/TimelineGanttControllerSpec.js b/platform/features/timeline/test/controllers/TimelineGanttControllerSpec.js new file mode 100644 index 0000000000..b38417d1ed --- /dev/null +++ b/platform/features/timeline/test/controllers/TimelineGanttControllerSpec.js @@ -0,0 +1,80 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/controllers/TimelineGanttController'], + function (TimelineGanttController) { + "use strict"; + + var TEST_MAX_OFFSCREEN = 50; + + describe("The timeline Gantt bar controller", function () { + var mockTimespan, + testScroll, + mockToPixels, + controller; + + // Shorthands for passing these arguments to the controller + function width() { + return controller.width( + mockTimespan, + testScroll, + mockToPixels + ); + } + function left() { + return controller.left( + mockTimespan, + testScroll, + mockToPixels + ); + } + + + beforeEach(function () { + mockTimespan = jasmine.createSpyObj( + 'timespan', + ['getStart', 'getEnd', 'getDuration'] + ); + testScroll = { x: 0, width: 2000 }; + mockToPixels = jasmine.createSpy('toPixels'); + + mockTimespan.getStart.andReturn(100); + mockTimespan.getDuration.andReturn(50); + mockTimespan.getEnd.andReturn(150); + + mockToPixels.andCallFake(function (t) { return t * 10; }); + + controller = new TimelineGanttController(TEST_MAX_OFFSCREEN); + }); + + it("positions start and end points correctly on-screen", function () { + // Test's initial conditions are nominal, so should have + // the same return value as mockToPixels + expect(left()).toEqual(1000); + expect(width()).toEqual(500); + }); + + it("prevents excessive off screen values to the left", function () { + testScroll.x = 1200; + expect(left()).toEqual(1150); + expect(width()).toEqual(350); // ...such that right edge is 1500 + }); + + it("prevents excessive off screen values to the right", function () { + testScroll.width = 1200; + expect(left()).toEqual(1000); + expect(width()).toEqual(250); // ...such that right edge is 1250 + }); + + it("prevents excessive off screen values on both edges", function () { + testScroll.x = 1100; + testScroll.width = 200; // Visible right edge is now 1300 + expect(left()).toEqual(1050); + expect(width()).toEqual(300); // ...such that right edge is 1350 + }); + + + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/TimelineGraphControllerSpec.js b/platform/features/timeline/test/controllers/TimelineGraphControllerSpec.js new file mode 100644 index 0000000000..edadead54d --- /dev/null +++ b/platform/features/timeline/test/controllers/TimelineGraphControllerSpec.js @@ -0,0 +1,68 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/controllers/TimelineGraphController'], + function (TimelineGraphController) { + 'use strict'; + + describe("The Timeline graph controller", function () { + var mockScope, + testResources, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + '$scope', + [ '$watchCollection' ] + ); + testResources = [ + { key: 'abc', name: "Some name" }, + { key: 'def', name: "Test type", units: "Test units" }, + { key: 'xyz', name: "Something else" } + ]; + controller = new TimelineGraphController( + mockScope, + testResources + ); + }); + + it("watches for parameter changes", function () { + expect(mockScope.$watchCollection).toHaveBeenCalledWith( + 'parameters', + jasmine.any(Function) + ); + }); + + it("updates graphs when parameters change", function () { + var mockGraphA = jasmine.createSpyObj('graph-a', ['setBounds']), + mockGraphB = jasmine.createSpyObj('graph-b', ['setBounds']); + + // Supply new parameters + mockScope.$watchCollection.mostRecentCall.args[1]({ + graphs: [ mockGraphA, mockGraphB ], + origin: 9, + duration: 144 + }); + + // Graphs should have both been updated + expect(mockGraphA.setBounds).toHaveBeenCalledWith(9, 144); + expect(mockGraphB.setBounds).toHaveBeenCalledWith(9, 144); + }); + + it("provides labels for graphs", function () { + var mockGraph = jasmine.createSpyObj('graph', ['minimum', 'maximum']); + + mockGraph.minimum.andReturn(12.3412121); + mockGraph.maximum.andReturn(88.7555555); + mockGraph.key = "def"; + + expect(controller.label(mockGraph)).toEqual({ + title: "Test type (Test units)", + low: "12.341", + middle: "50.548", + high: "88.756" + }); + }); + }); + } +); diff --git a/platform/features/timeline/test/controllers/TimelineTableControllerSpec.js b/platform/features/timeline/test/controllers/TimelineTableControllerSpec.js new file mode 100644 index 0000000000..fff1cfeab2 --- /dev/null +++ b/platform/features/timeline/test/controllers/TimelineTableControllerSpec.js @@ -0,0 +1,31 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + [ + '../../src/controllers/TimelineTableController', + '../../src/TimelineFormatter' + ], + function (TimelineTableController, TimelineFormatter) { + "use strict"; + + describe("The timeline table controller", function () { + var formatter, controller; + + beforeEach(function () { + controller = new TimelineTableController(); + formatter = new TimelineFormatter(); + }); + + // This controller's job is just to expose the formatter + // in scope, so simply verify that the two agree. + it("formats durations", function () { + [ 0, 100, 4123, 93600, 748801230012].forEach(function (n) { + expect(controller.niceTime(n)) + .toEqual(formatter.format(n)); + }); + }); + + + }); + } +); diff --git a/platform/features/timeline/test/controllers/TimelineTickControllerSpec.js b/platform/features/timeline/test/controllers/TimelineTickControllerSpec.js new file mode 100644 index 0000000000..b51fe674f8 --- /dev/null +++ b/platform/features/timeline/test/controllers/TimelineTickControllerSpec.js @@ -0,0 +1,67 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/controllers/TimelineTickController', '../../src/TimelineFormatter'], + function (TimelineTickController, TimelineFormatter) { + 'use strict'; + + var BILLION = 1000000000, + FORMATTER = new TimelineFormatter(); + + describe("The timeline tick controller", function () { + var mockToMillis, + controller; + + function expectedTick(pixelValue) { + return { + left: pixelValue, + text: FORMATTER.format(pixelValue * 2 + BILLION) + }; + } + + beforeEach(function () { + mockToMillis = jasmine.createSpy('toMillis'); + mockToMillis.andCallFake(function (v) { + return v * 2 + BILLION; + }); + controller = new TimelineTickController(); + }); + + it("exposes tick marks within a requested pixel span", function () { + // Simple case + expect(controller.labels(8000, 300, 100, mockToMillis)) + .toEqual([8000, 8100, 8200, 8300].map(expectedTick)); + + // Slightly more complicated case + expect(controller.labels(7480, 4500, 1000, mockToMillis)) + .toEqual([7000, 8000, 9000, 10000, 11000, 12000].map(expectedTick)); + }); + + it("does not rebuild arrays for same inputs", function () { + var firstValue = controller.labels(800, 300, 100, mockToMillis); + + expect(controller.labels(800, 300, 100, mockToMillis)) + .toEqual(firstValue); + + expect(controller.labels(800, 300, 100, mockToMillis)) + .toBe(firstValue); + }); + + it("does rebuild arrays when zoom changes", function () { + var firstValue = controller.labels(800, 300, 100, mockToMillis); + + mockToMillis.andCallFake(function (v) { + return BILLION * 2 + v; + }); + + expect(controller.labels(800, 300, 100, mockToMillis)) + .not.toEqual(firstValue); + + expect(controller.labels(800, 300, 100, mockToMillis)) + .not.toBe(firstValue); + }); + + }); + + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/TimelineZoomControllerSpec.js b/platform/features/timeline/test/controllers/TimelineZoomControllerSpec.js new file mode 100644 index 0000000000..1849c9d9c2 --- /dev/null +++ b/platform/features/timeline/test/controllers/TimelineZoomControllerSpec.js @@ -0,0 +1,80 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + + +define( + ['../../src/controllers/TimelineZoomController'], + function (TimelineZoomController) { + 'use strict'; + + describe("The timeline zoom state controller", function () { + var testConfiguration, + mockScope, + controller; + + beforeEach(function () { + testConfiguration = { + levels: [ + 1000, + 2000, + 3500 + ], + width: 12321 + }; + mockScope = jasmine.createSpyObj("$scope", ['$watch']); + mockScope.commit = jasmine.createSpy('commit'); + controller = new TimelineZoomController( + mockScope, + testConfiguration + ); + }); + + it("starts off at a middle zoom level", function () { + expect(controller.zoom()).toEqual(2000); + }); + + it("allows duration to be changed", function () { + var initial = controller.duration(); + controller.duration(initial * 3.33); + expect(controller.duration() > initial).toBeTruthy(); + }); + + it("handles time-to-pixel conversions", function () { + var zoomLevel = controller.zoom(); + expect(controller.toPixels(zoomLevel)).toEqual(12321); + expect(controller.toPixels(zoomLevel * 2)).toEqual(24642); + }); + + it("handles pixel-to-time conversions", function () { + var zoomLevel = controller.zoom(); + expect(controller.toMillis(12321)).toEqual(zoomLevel); + expect(controller.toMillis(24642)).toEqual(zoomLevel * 2); + }); + + it("allows zoom to be changed", function () { + controller.zoom(1); + expect(controller.zoom()).toEqual(3500); + }); + + it("does not normally persist zoom changes", function () { + controller.zoom(1); + expect(mockScope.commit).not.toHaveBeenCalled(); + }); + + it("persists zoom changes in Edit mode", function () { + mockScope.domainObject = jasmine.createSpyObj( + 'domainObject', + ['hasCapability'] + ); + mockScope.domainObject.hasCapability.andCallFake(function (c) { + return c === 'editor'; + }); + controller.zoom(1); + expect(mockScope.commit).toHaveBeenCalled(); + expect(mockScope.configuration.zoomLevel) + .toEqual(jasmine.any(Number)); + }); + + }); + + } +); diff --git a/platform/features/timeline/test/controllers/WARPDateTimeControllerSpec.js b/platform/features/timeline/test/controllers/WARPDateTimeControllerSpec.js new file mode 100644 index 0000000000..fadd6b5bf0 --- /dev/null +++ b/platform/features/timeline/test/controllers/WARPDateTimeControllerSpec.js @@ -0,0 +1,57 @@ + +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ["../../src/controllers/WARPDateTimeController"], + function (WARPDateTimeController) { + "use strict"; + + describe("The date-time controller for timeline creation", function () { + var mockScope, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj('$scope', ['$watchCollection']); + mockScope.field = 'testField'; + mockScope.ngModel = { testField: { timestamp: 0, epoch: "SET" } }; + controller = new WARPDateTimeController(mockScope); + }); + + + // Verify two-way binding support + it("updates model on changes to entry fields", function () { + // Make sure we're looking at the right watch + expect(mockScope.$watchCollection.calls[0].args[0]) + .toEqual("datetime"); + mockScope.$watchCollection.calls[0].args[1]({ + days: 4, + hours: 12, + minutes: 30, + seconds: 11 + }); + expect(mockScope.ngModel.testField.timestamp).toEqual( + ((((((4 * 24) + 12) * 60) + 30) * 60) + 11) * 1000 + ); + }); + + it("updates form when model changes", function () { + // Make sure we're looking at the right watch + expect(mockScope.$watchCollection.calls[1].args[0]) + .toEqual(jasmine.any(Function)); + // ...and that it's really looking at the field in ngModel + expect(mockScope.$watchCollection.calls[1].args[0]()) + .toBe(mockScope.ngModel.testField); + mockScope.$watchCollection.calls[1].args[1]({ + timestamp: ((((((4 * 24) + 12) * 60) + 30) * 60) + 11) * 1000 + }); + expect(mockScope.datetime).toEqual({ + days: 4, + hours: 12, + minutes: 30, + seconds: 11 + }); + }); + + }); + } +); diff --git a/platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js b/platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js new file mode 100644 index 0000000000..fc577ba782 --- /dev/null +++ b/platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js @@ -0,0 +1,66 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/drag/TimelineDragHandleFactory'], + function (TimelineDragHandleFactory) { + 'use strict'; + + describe("A Timeline drag handle factory", function () { + var mockDragHandler, + mockSnapHandler, + mockDomainObject, + mockType, + testType, + factory; + + beforeEach(function () { + mockDragHandler = jasmine.createSpyObj( + 'dragHandler', + [ 'start' ] + ); + mockSnapHandler = jasmine.createSpyObj( + 'snapHandler', + [ 'snap' ] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability', 'getId' ] + ); + mockType = jasmine.createSpyObj( + 'type', + [ 'instanceOf' ] + ); + + mockDomainObject.getId.andReturn('test-id'); + mockDomainObject.getCapability.andReturn(mockType); + mockType.instanceOf.andCallFake(function (t) { + return t === testType; + }); + + factory = new TimelineDragHandleFactory( + mockDragHandler, + mockSnapHandler + ); + }); + + it("inspects an object's type capability", function () { + factory.handles(mockDomainObject); + expect(mockDomainObject.getCapability) + .toHaveBeenCalledWith('type'); + }); + + it("provides three handles for activities", function () { + testType = "warp.activity"; + expect(factory.handles(mockDomainObject).length) + .toEqual(3); + }); + + it("provides two handles for timelines", function () { + testType = "warp.timeline"; + expect(factory.handles(mockDomainObject).length) + .toEqual(2); + }); + + }); + } +); diff --git a/platform/features/timeline/test/controllers/drag/TimelineDragHandlerSpec.js b/platform/features/timeline/test/controllers/drag/TimelineDragHandlerSpec.js new file mode 100644 index 0000000000..9cd4c86a53 --- /dev/null +++ b/platform/features/timeline/test/controllers/drag/TimelineDragHandlerSpec.js @@ -0,0 +1,209 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/drag/TimelineDragHandler'], + function (TimelineDragHandler) { + 'use strict'; + + describe("A Timeline drag handler", function () { + var mockLoader, + mockSelection, + testConfiguration, + mockDomainObject, + mockDomainObjects, + mockTimespans, + mockMutations, + mockPersists, + mockCallback, + handler; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + function subgraph(domainObject, objects) { + function lookupSubgraph(id) { + return subgraph(objects[id], objects); + } + return { + domainObject: domainObject, + composition: (domainObject.getModel().composition || []) + .map(lookupSubgraph) + }; + } + + function makeMockDomainObject(id, composition) { + var mockDomainObject = jasmine.createSpyObj( + 'domainObject-' + id, + ['getId', 'getModel', 'getCapability', 'useCapability'] + ); + + mockDomainObject.getId.andReturn(id); + mockDomainObject.getModel.andReturn({ composition: composition }); + mockDomainObject.useCapability.andReturn(asPromise(mockTimespans[id])); + mockDomainObject.getCapability.andCallFake(function (c) { + return { + persistence: mockPersists[id], + mutation: mockMutations[id] + }[c]; + }); + + return mockDomainObject; + } + + beforeEach(function () { + mockTimespans = {}; + mockPersists = {}; + mockMutations = {}; + ['a', 'b', 'c', 'd', 'e', 'f'].forEach(function (id, index) { + mockTimespans[id] = jasmine.createSpyObj( + 'timespan-' + id, + [ 'getStart', 'getEnd', 'getDuration', 'setStart', 'setEnd', 'setDuration' ] + ); + mockPersists[id] = jasmine.createSpyObj( + 'persistence-' + id, + [ 'persist' ] + ); + mockMutations[id] = jasmine.createSpyObj( + 'mutation-' + id, + [ 'mutate' ] + ); + mockTimespans[id].getStart.andReturn(index * 1000); + mockTimespans[id].getDuration.andReturn(4000 + index); + mockTimespans[id].getEnd.andReturn(4000 + index + index * 1000); + }); + + mockLoader = jasmine.createSpyObj('objectLoader', ['load']); + mockDomainObject = makeMockDomainObject('a', ['b', 'c']); + mockDomainObjects = { + a: mockDomainObject, + b: makeMockDomainObject('b', ['d']), + c: makeMockDomainObject('c', ['e', 'f']), + d: makeMockDomainObject('d', []), + e: makeMockDomainObject('e', []), + f: makeMockDomainObject('f', []) + }; + mockSelection = jasmine.createSpyObj('selection', ['get', 'select']); + mockCallback = jasmine.createSpy('callback'); + + testConfiguration = {}; + + mockLoader.load.andReturn(asPromise( + subgraph(mockDomainObject, mockDomainObjects) + )); + + handler = new TimelineDragHandler( + mockDomainObject, + mockLoader + ); + }); + + it("uses the loader to find subgraph", function () { + expect(mockLoader.load).toHaveBeenCalledWith( + mockDomainObject, + 'timespan' + ); + }); + + it("reports available object identifiers", function () { + expect(handler.ids()) + .toEqual(Object.keys(mockDomainObjects).sort()); + }); + + it("exposes start/end/duration from timespan capabilities", function () { + expect(handler.start('a')).toEqual(0); + expect(handler.start('b')).toEqual(1000); + expect(handler.start('c')).toEqual(2000); + expect(handler.duration('a')).toEqual(4000); + expect(handler.duration('b')).toEqual(4001); + expect(handler.duration('c')).toEqual(4002); + expect(handler.end('a')).toEqual(4000); + expect(handler.end('b')).toEqual(5001); + expect(handler.end('c')).toEqual(6002); + }); + + it("accepts objects instead of identifiers for start/end/duration calls", function () { + Object.keys(mockDomainObjects).forEach(function (id) { + expect(handler.start(mockDomainObjects[id])).toEqual(handler.start(id)); + expect(handler.duration(mockDomainObjects[id])).toEqual(handler.duration(id)); + expect(handler.end(mockDomainObjects[id])).toEqual(handler.end(id)); + }); + }); + + it("mutates objects", function () { + handler.start('a', 123); + expect(mockTimespans.a.setStart).toHaveBeenCalledWith(123); + handler.duration('b', 42); + expect(mockTimespans.b.setDuration).toHaveBeenCalledWith(42); + handler.end('c', 12321); + expect(mockTimespans.c.setEnd).toHaveBeenCalledWith(12321); + }); + + it("disallows negative starts, durations", function () { + handler.start('a', -100); + handler.duration('b', -1000); + expect(mockTimespans.a.setStart).toHaveBeenCalledWith(0); + expect(mockTimespans.b.setDuration).toHaveBeenCalledWith(0); + }); + + it("disallows starts greater than ends violations", function () { + handler.start('a', 5000); + handler.end('b', 500); + expect(mockTimespans.a.setStart).toHaveBeenCalledWith(4000); // end time + expect(mockTimespans.b.setEnd).toHaveBeenCalledWith(1000); // start time + }); + + it("moves objects in groups", function () { + handler.move('b', 42); + expect(mockTimespans.b.setStart).toHaveBeenCalledWith(1042); + expect(mockTimespans.b.setEnd).toHaveBeenCalledWith(5043); + expect(mockTimespans.d.setStart).toHaveBeenCalledWith(3042); + expect(mockTimespans.d.setEnd).toHaveBeenCalledWith(7045); + // Verify no other interactions + ['a', 'c', 'e', 'f'].forEach(function (id) { + expect(mockTimespans[id].setStart).not.toHaveBeenCalled(); + expect(mockTimespans[id].setEnd).not.toHaveBeenCalled(); + }); + }); + + it("moves whole subtrees", function () { + handler.move('a', 12321); + // We verify the math in the previous test, so just verify + // that the whole tree is effected here. + Object.keys(mockTimespans).forEach(function (id) { + expect(mockTimespans[id].setStart).toHaveBeenCalled(); + }); + }); + + it("prevents bulk moves past 0", function () { + // Have a start later; new lowest start is b, at 1000 + mockTimespans.a.getStart.andReturn(10000); + handler.move('a', -10000); + // Verify that move was stopped at 0, for b, even though + // move was initiated at a + expect(mockTimespans.a.setStart).toHaveBeenCalledWith(9000); + expect(mockTimespans.b.setStart).toHaveBeenCalledWith(0); + expect(mockTimespans.c.setStart).toHaveBeenCalledWith(1000); + }); + + it("persists mutated objects", function () { + handler.start('a', 20); + handler.end('b', 50); + handler.duration('c', 30); + handler.persist(); + expect(mockPersists.a.persist).toHaveBeenCalled(); + expect(mockPersists.b.persist).toHaveBeenCalled(); + expect(mockPersists.c.persist).toHaveBeenCalled(); + expect(mockPersists.d.persist).not.toHaveBeenCalled(); + expect(mockPersists.e.persist).not.toHaveBeenCalled(); + expect(mockPersists.f.persist).not.toHaveBeenCalled(); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/drag/TimelineDragPopulatorSpec.js b/platform/features/timeline/test/controllers/drag/TimelineDragPopulatorSpec.js new file mode 100644 index 0000000000..feb1ff10f7 --- /dev/null +++ b/platform/features/timeline/test/controllers/drag/TimelineDragPopulatorSpec.js @@ -0,0 +1,53 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + + +define( + ['../../../src/controllers/drag/TimelineDragPopulator'], + function (TimelineDragPopulator) { + "use strict"; + + describe("The timeline drag populator", function () { + var mockObjectLoader, + mockPromise, + mockSwimlane, + mockDomainObject, + populator; + + beforeEach(function () { + mockObjectLoader = jasmine.createSpyObj("objectLoader", ["load"]); + mockPromise = jasmine.createSpyObj("promise", ["then"]); + mockSwimlane = jasmine.createSpyObj("swimlane", ["color"]); + mockDomainObject = jasmine.createSpyObj( + "domainObject", + ["getCapability", "getId"] + ); + + mockSwimlane.domainObject = mockDomainObject; + mockObjectLoader.load.andReturn(mockPromise); + + populator = new TimelineDragPopulator(mockObjectLoader); + }); + + it("loads timespans for the represented object's subgraph", function () { + populator.populate(mockDomainObject); + expect(mockObjectLoader.load).toHaveBeenCalledWith( + mockDomainObject, + 'timespan' + ); + }); + + it("updates handles for selections", function () { + // Ensure we have a represented object context + populator.populate(mockDomainObject); + // Initially, no selection and no handles + expect(populator.get()).toEqual([]); + // Select the swimlane + populator.select(mockSwimlane); + // We should have handles now + expect(populator.get().length).toEqual(3); + }); + + }); + + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/drag/TimelineEndHandleSpec.js b/platform/features/timeline/test/controllers/drag/TimelineEndHandleSpec.js new file mode 100644 index 0000000000..6841b9fa1f --- /dev/null +++ b/platform/features/timeline/test/controllers/drag/TimelineEndHandleSpec.js @@ -0,0 +1,96 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/drag/TimelineEndHandle', '../../../src/TimelineConstants'], + function (TimelineEndHandle, TimelineConstants) { + 'use strict'; + + describe("A Timeline end drag handle", function () { + var mockDragHandler, + mockSnapHandler, + mockZoomController, + handle; + + beforeEach(function () { + mockDragHandler = jasmine.createSpyObj( + 'dragHandler', + [ 'end', 'persist' ] + ); + mockSnapHandler = jasmine.createSpyObj( + 'snapHandler', + [ 'snap' ] + ); + mockZoomController = jasmine.createSpyObj( + 'zoom', + [ 'toMillis', 'toPixels' ] + ); + + mockDragHandler.end.andReturn(12321); + + // Echo back the value from snapper for most tests + mockSnapHandler.snap.andCallFake(function (ts) { + return ts; + }); + + // Double pixels to get millis, for test purposes + mockZoomController.toMillis.andCallFake(function (px) { + return px * 2; + }); + + mockZoomController.toPixels.andCallFake(function (ms) { + return ms / 2; + }); + + handle = new TimelineEndHandle( + 'test-id', + mockDragHandler, + mockSnapHandler + ); + }); + + it("provides a style for templates", function () { + var w = TimelineConstants.HANDLE_WIDTH; + expect(handle.style(mockZoomController)).toEqual({ + // Left should be adjusted by zoom controller + left: (12321 / 2) - w + 'px', + // Width should match the defined constant + width: w + 'px' + }); + }); + + it("forwards drags to the drag handler", function () { + handle.begin(); + handle.drag(100, mockZoomController); + // Should have been interpreted as a +200 ms change + expect(mockDragHandler.end).toHaveBeenCalledWith( + "test-id", + 12521 + ); + }); + + it("snaps drags to other end points", function () { + mockSnapHandler.snap.andReturn(42); + handle.begin(); + handle.drag(-10, mockZoomController); + // Should have used snap-to timestamp + expect(mockDragHandler.end).toHaveBeenCalledWith( + "test-id", + 42 + ); + }); + + it("persists when a move is complete", function () { + // Simulate normal drag cycle + handle.begin(); + handle.drag(100, mockZoomController); + // Should not have persisted yet + expect(mockDragHandler.persist).not.toHaveBeenCalled(); + // Finish the drag + handle.finish(); + // Now it should have persisted + expect(mockDragHandler.persist).toHaveBeenCalled(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/drag/TimelineMoveHandleSpec.js b/platform/features/timeline/test/controllers/drag/TimelineMoveHandleSpec.js new file mode 100644 index 0000000000..df1a398fc4 --- /dev/null +++ b/platform/features/timeline/test/controllers/drag/TimelineMoveHandleSpec.js @@ -0,0 +1,163 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/drag/TimelineMoveHandle', '../../../src/TimelineConstants'], + function (TimelineMoveHandle, TimelineConstants) { + 'use strict'; + + describe("A Timeline move drag handle", function () { + var mockDragHandler, + mockSnapHandler, + mockZoomController, + handle; + + beforeEach(function () { + mockDragHandler = jasmine.createSpyObj( + 'dragHandler', + [ 'start', 'duration', 'end', 'move', 'persist' ] + ); + mockSnapHandler = jasmine.createSpyObj( + 'snapHandler', + [ 'snap' ] + ); + mockZoomController = jasmine.createSpyObj( + 'zoom', + [ 'toMillis', 'toPixels' ] + ); + + mockDragHandler.start.andReturn(12321); + mockDragHandler.duration.andReturn(4200); + mockDragHandler.end.andReturn(12321 + 4200); + + // Echo back the value from snapper for most tests + mockSnapHandler.snap.andCallFake(function (ts) { + return ts; + }); + + // Double pixels to get millis, for test purposes + mockZoomController.toMillis.andCallFake(function (px) { + return px * 2; + }); + + mockZoomController.toPixels.andCallFake(function (ms) { + return ms / 2; + }); + + handle = new TimelineMoveHandle( + 'test-id', + mockDragHandler, + mockSnapHandler + ); + }); + + it("provides a style for templates", function () { + var w = TimelineConstants.HANDLE_WIDTH; + expect(handle.style(mockZoomController)).toEqual({ + // Left should be adjusted by zoom controller + left: (12321 / 2) + w + 'px', + // Width should be duration minus end points + width: 2100 - (w * 2) + 'px' + }); + }); + + it("forwards drags to the drag handler", function () { + handle.begin(); + handle.drag(100, mockZoomController); + // Should have been interpreted as a +200 ms change + expect(mockDragHandler.move).toHaveBeenCalledWith( + "test-id", + 200 + ); + }); + + it("tracks drags incrementally", function () { + handle.begin(); + + handle.drag(100, mockZoomController); + // Should have been interpreted as a +200 ms change... + expect(mockDragHandler.move).toHaveBeenCalledWith( + "test-id", + 200 + ); + + // Reflect the change from the drag handler + mockDragHandler.start.andReturn(12521); + mockDragHandler.end.andReturn(12521 + 4200); + + // ....followed by a +100 ms change. + handle.drag(150, mockZoomController); + expect(mockDragHandler.move).toHaveBeenCalledWith( + "test-id", + 100 + ); + }); + + it("snaps drags to other end points", function () { + mockSnapHandler.snap.andCallFake(function (ts) { + return ts + 10; + }); + handle.begin(); + handle.drag(100, mockZoomController); + // Should have used snap-to timestamp, which was 10 + // ms greater than the provided one + expect(mockDragHandler.move).toHaveBeenCalledWith( + "test-id", + 210 + ); + }); + + it("considers snaps for both endpoints", function () { + handle.begin(); + expect(mockSnapHandler.snap).not.toHaveBeenCalled(); + handle.drag(100, mockZoomController); + expect(mockSnapHandler.snap.calls.length).toEqual(2); + }); + + it("chooses the closest snap-to location", function () { + // Use a toggle to give snapped timestamps that are + // different distances away from the original. + // The move handle needs to choose the closest snap-to, + // regardless of whether it is the start/end (which + // will vary based on the initial state of this toggle.) + var toggle = false; + mockSnapHandler.snap.andCallFake(function (ts) { + toggle = !toggle; + return ts + (toggle ? -5 : 10); + }); + handle.begin(); + handle.drag(100, mockZoomController); + expect(mockDragHandler.move).toHaveBeenCalledWith( + "test-id", + 195 // Chose the -5 + ); + + // Reflect the change from the drag handler + mockDragHandler.start.andReturn(12521 - 5); + mockDragHandler.end.andReturn(12521 + 4200 - 5); + + toggle = true; // Change going-in state + handle.drag(300, mockZoomController); + // Note that the -5 offset is shown in the current state, + // so snapping to the -5 implies that the full 400ms will + // be moved (again, relative to dragHandler's reported state) + expect(mockDragHandler.move).toHaveBeenCalledWith( + "test-id", + 400 // Still chose the -5 + ); + }); + + it("persists when a move is complete", function () { + // Simulate normal drag cycle + handle.begin(); + handle.drag(100, mockZoomController); + // Should not have persisted yet + expect(mockDragHandler.persist).not.toHaveBeenCalled(); + // Finish the drag + handle.finish(); + // Now it should have persisted + expect(mockDragHandler.persist).toHaveBeenCalled(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/drag/TimelineSnapHandlerSpec.js b/platform/features/timeline/test/controllers/drag/TimelineSnapHandlerSpec.js new file mode 100644 index 0000000000..73560c53d7 --- /dev/null +++ b/platform/features/timeline/test/controllers/drag/TimelineSnapHandlerSpec.js @@ -0,0 +1,60 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/drag/TimelineSnapHandler'], + function (TimelineSnapHandler) { + 'use strict'; + + describe("A Timeline snap handler", function () { + var mockDragHandler, + handler; + + beforeEach(function () { + var starts = { a: 1000, b: 2000, c: 2500, d: 2600 }, + ends = { a: 2050, b: 3000, c: 2700, d: 10000 }; + + mockDragHandler = jasmine.createSpyObj( + 'dragHandler', + [ 'start', 'end', 'ids' ] + ); + + mockDragHandler.ids.andReturn(['a', 'b', 'c', 'd']); + mockDragHandler.start.andCallFake(function (id) { + return starts[id]; + }); + mockDragHandler.end.andCallFake(function (id) { + return ends[id]; + }); + + handler = new TimelineSnapHandler(mockDragHandler); + }); + + it("provides a preferred snap location within tolerance", function () { + expect(handler.snap(2511, 15, 'a')).toEqual(2500); // c's start + expect(handler.snap(2488, 15, 'a')).toEqual(2500); // c's start + expect(handler.snap(10, 1000, 'b')).toEqual(1000); // a's start + expect(handler.snap(2711, 20, 'd')).toEqual(2700); // c's end + }); + + it("excludes provided id from snapping", function () { + // Don't want objects to snap to themselves, so we need + // this exclusion. + expect(handler.snap(2010, 50, 'b')).toEqual(2050); // a's end + // Verify that b's start would have been used had the + // id not been provided + expect(handler.snap(2010, 50, 'd')).toEqual(2000); + }); + + it("snaps to the closest point, when multiple match", function () { + // 2600 and 2700 (plus others) are both in range here + expect(handler.snap(2651, 1000, 'a')).toEqual(2700); + }); + + it("does not snap if no points are within tolerance", function () { + // Closest are 1000 and 2000, which are well outside of tolerance + expect(handler.snap(1503, 100, 'd')).toEqual(1503); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/drag/TimelineStartHandleSpec.js b/platform/features/timeline/test/controllers/drag/TimelineStartHandleSpec.js new file mode 100644 index 0000000000..6c752678b3 --- /dev/null +++ b/platform/features/timeline/test/controllers/drag/TimelineStartHandleSpec.js @@ -0,0 +1,95 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/drag/TimelineStartHandle', '../../../src/TimelineConstants'], + function (TimelineStartHandle, TimelineConstants) { + 'use strict'; + + describe("A Timeline start drag handle", function () { + var mockDragHandler, + mockSnapHandler, + mockZoomController, + handle; + + beforeEach(function () { + mockDragHandler = jasmine.createSpyObj( + 'dragHandler', + [ 'start', 'persist' ] + ); + mockSnapHandler = jasmine.createSpyObj( + 'snapHandler', + [ 'snap' ] + ); + mockZoomController = jasmine.createSpyObj( + 'zoom', + [ 'toMillis', 'toPixels' ] + ); + + mockDragHandler.start.andReturn(12321); + + // Echo back the value from snapper for most tests + mockSnapHandler.snap.andCallFake(function (ts) { + return ts; + }); + + // Double pixels to get millis, for test purposes + mockZoomController.toMillis.andCallFake(function (px) { + return px * 2; + }); + + mockZoomController.toPixels.andCallFake(function (ms) { + return ms / 2; + }); + + handle = new TimelineStartHandle( + 'test-id', + mockDragHandler, + mockSnapHandler + ); + }); + + it("provides a style for templates", function () { + expect(handle.style(mockZoomController)).toEqual({ + // Left should be adjusted by zoom controller + left: (12321 / 2) + 'px', + // Width should match the defined constant + width: TimelineConstants.HANDLE_WIDTH + 'px' + }); + }); + + it("forwards drags to the drag handler", function () { + handle.begin(); + handle.drag(100, mockZoomController); + // Should have been interpreted as a +200 ms change + expect(mockDragHandler.start).toHaveBeenCalledWith( + "test-id", + 12521 + ); + }); + + it("snaps drags to other end points", function () { + mockSnapHandler.snap.andReturn(42); + handle.begin(); + handle.drag(-10, mockZoomController); + // Should have used snap-to timestamp + expect(mockDragHandler.start).toHaveBeenCalledWith( + "test-id", + 42 + ); + }); + + it("persists when a move is complete", function () { + // Simulate normal drag cycle + handle.begin(); + handle.drag(100, mockZoomController); + // Should not have persisted yet + expect(mockDragHandler.persist).not.toHaveBeenCalled(); + // Finish the drag + handle.finish(); + // Now it should have persisted + expect(mockDragHandler.persist).toHaveBeenCalled(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/graph/TimelineGraphPopulatorSpec.js b/platform/features/timeline/test/controllers/graph/TimelineGraphPopulatorSpec.js new file mode 100644 index 0000000000..3ecd93a66f --- /dev/null +++ b/platform/features/timeline/test/controllers/graph/TimelineGraphPopulatorSpec.js @@ -0,0 +1,132 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/graph/TimelineGraphPopulator'], + function (TimelineGraphPopulator) { + 'use strict'; + + describe("A Timeline's resource graph populator", function () { + var mockSwimlanes, + mockQ, + testResources, + populator; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (callback) { + return asPromise(callback(v)); + }, + testValue: v + }; + } + + function allPromises(promises) { + return asPromise(promises.map(function (p) { + return (p || {}).then ? p.testValue : p; + })); + } + + + beforeEach(function () { + testResources = { + a: [ 'xyz', 'abc' ], + b: [ 'xyz' ], + c: [ 'xyz', 'abc', 'def', 'ghi' ] + }; + + mockQ = jasmine.createSpyObj('$q', ['when', 'all']); + + mockSwimlanes = ['a', 'b', 'c'].map(function (k) { + var mockSwimlane = jasmine.createSpyObj( + 'swimlane-' + k, + [ 'graph', 'color' ] + ), + mockGraph = jasmine.createSpyObj( + 'graph-' + k, + [ 'getPointCount', 'getDomainValue', 'getRangeValue' ] + ); + mockSwimlane.graph.andReturn(true); + mockSwimlane.domainObject = jasmine.createSpyObj( + 'domainObject-' + k, + [ 'getCapability', 'hasCapability', 'useCapability', 'getId' ] + ); + mockSwimlane.color.andReturn('#' + k); + // Provide just enough information about graphs to support + // determination of which graphs to show + mockSwimlane.domainObject.useCapability.andCallFake(function () { + var obj = {}; + testResources[k].forEach(function (r) { + obj[r] = mockGraph; + }); + return asPromise(obj); + }); + mockSwimlane.domainObject.hasCapability + .andReturn(true); + mockSwimlane.domainObject.getId + .andReturn(k); + mockGraph.getPointCount.andReturn(0); + + return mockSwimlane; + }); + + mockQ.when.andCallFake(asPromise); + mockQ.all.andCallFake(allPromises); + + populator = new TimelineGraphPopulator(mockQ); + }); + + it("provides no graphs by default", function () { + expect(populator.get()).toEqual([]); + }); + + it("provides one graph per resource type", function () { + populator.populate(mockSwimlanes); + // There are 4 unique resource types shared here... + expect(populator.get().length).toEqual(4); + }); + + it("does not include graphs based on swimlane configuration", function () { + mockSwimlanes[2].graph.andReturn(false); + populator.populate(mockSwimlanes); + // Only two unique swimlanes in the other two + expect(populator.get().length).toEqual(2); + // Verify interactions as well + expect(mockSwimlanes[0].domainObject.useCapability) + .toHaveBeenCalledWith('graph'); + expect(mockSwimlanes[1].domainObject.useCapability) + .toHaveBeenCalledWith('graph'); + expect(mockSwimlanes[2].domainObject.useCapability) + .not.toHaveBeenCalled(); + }); + + it("does not recreate graphs when swimlanes don't change", function () { + var initialValue; + // Get an initial set of graphs + populator.populate(mockSwimlanes); + initialValue = populator.get(); + // Repopulate with same data; should get same graphs + populator.populate(mockSwimlanes); + expect(populator.get()).toBe(initialValue); + // Something changed... + mockSwimlanes.pop(); + populator.populate(mockSwimlanes); + // Now we should get different graphs + expect(populator.get()).not.toBe(initialValue); + }); + + // Regression test for WTD-1155 + it("does recreate graphs when inclusions change", function () { + var initialValue; + // Get an initial set of graphs + populator.populate(mockSwimlanes); + initialValue = populator.get(); + // Change resource graph inclusion... + mockSwimlanes[1].graph.andReturn(false); + populator.populate(mockSwimlanes); + // Now we should get different graphs + expect(populator.get()).not.toBe(initialValue); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/graph/TimelineGraphRendererSpec.js b/platform/features/timeline/test/controllers/graph/TimelineGraphRendererSpec.js new file mode 100644 index 0000000000..303ee0f539 --- /dev/null +++ b/platform/features/timeline/test/controllers/graph/TimelineGraphRendererSpec.js @@ -0,0 +1,56 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/graph/TimelineGraphRenderer'], + function (TimelineGraphRenderer) { + 'use strict'; + + describe("A Timeline's graph renderer", function () { + var renderer; + + beforeEach(function () { + renderer = new TimelineGraphRenderer(); + }); + + it("converts utilizations to buffers", function () { + var utilization = { + getPointCount: function () { + return 10; + }, + getDomainValue: function (i) { + return i * 10; + }, + getRangeValue: function (i) { + return Math.sin(i); + } + }, + buffer = renderer.render(utilization), + i; + + // Should be flat list of alternating x/y, + // so 20 elements + expect(buffer.length).toEqual(20); + + // Verify contents + for (i = 0; i < 10; i += 1) { + // Check for 6 decimal digits of precision, roughly + // what we expect after conversion to 32-bit float + expect(buffer[i * 2]).toBeCloseTo(i * 10, 6); + expect(buffer[i * 2 + 1]).toBeCloseTo(Math.sin(i), 6); + } + }); + + it("decodes color strings", function () { + // Note that decoded color should have alpha channel as well + expect(renderer.decode('#FFFFFF')) + .toEqual([1, 1, 1, 1]); + expect(renderer.decode('#000000')) + .toEqual([0, 0, 0, 1]); + expect(renderer.decode('#FF8000')) + .toEqual([1, 0.5019607843137255, 0, 1]); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/graph/TimelineGraphSpec.js b/platform/features/timeline/test/controllers/graph/TimelineGraphSpec.js new file mode 100644 index 0000000000..2b577b823f --- /dev/null +++ b/platform/features/timeline/test/controllers/graph/TimelineGraphSpec.js @@ -0,0 +1,151 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/graph/TimelineGraph'], + function (TimelineGraph) { + 'use strict'; + + describe("A Timeline's resource graph", function () { + var mockDomainObjects, + mockRenderer, + testColors, + mockGraphs, + graph; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (callback) { + return asPromise(callback(v)); + } + }; + } + + + beforeEach(function () { + testColors = { + a: [ 0, 1, 0 ], + b: [ 1, 0, 1 ], + c: [ 1, 0, 0 ] + }; + + mockGraphs = []; + mockDomainObjects = {}; + + ['a', 'b', 'c'].forEach(function (k, i) { + var mockGraph = jasmine.createSpyObj( + 'utilization-' + k, + [ 'getPointCount', 'getDomainValue', 'getRangeValue' ] + ); + mockDomainObjects[k] = jasmine.createSpyObj( + 'domainObject-' + k, + [ 'getCapability', 'useCapability' ] + ); + mockDomainObjects[k].useCapability.andReturn(asPromise({ + testResource: mockGraph + })); + mockGraph.getPointCount.andReturn(i + 2); + mockGraph.testField = k; + mockGraph.testIndex = i; + + // Store to allow changes later + mockGraphs.push(mockGraph); + }); + + mockRenderer = jasmine.createSpyObj( + 'renderer', + [ 'render', 'decode' ] + ); + + mockRenderer.render.andCallFake(function (utilization) { + var result = []; + while (result.length < (utilization.testIndex + 2) * 2) { + result.push(Math.floor(result.length / 2)); + // Alternate +/- to give something to test to + result.push( + ((result.length % 4 > 1) ? -1 : 1) * + (10 * utilization.testIndex) + ); + } + return result; + }); + + mockRenderer.decode.andCallFake(function (color) { + return testColors[color]; + }); + + graph = new TimelineGraph( + 'testResource', + mockDomainObjects, + mockRenderer + ); + }); + + it("exposes minimum/maximum", function () { + expect(graph.minimum()).toEqual(-20); + expect(graph.maximum()).toEqual(20); + }); + + it("exposes resource key", function () { + expect(graph.key).toEqual('testResource'); + }); + + it("exposes a rendered drawing object", function () { + // Much of the work here is done by the renderer, + // so just check that it got used and assigned + expect(graph.drawingObject.lines).toEqual([ + { + color: testColors.a, + buffer: [0, 0, 1, 0], + points: 2 + }, + { + color: testColors.b, + buffer: [0, 10, 1, -10, 2, 10], + points: 3 + }, + { + color: testColors.c, + buffer: [0, 20, 1, -20, 2, 20, 3, -20], + points: 4 + } + ]); + }); + + it("allows its bounds to be specified", function () { + graph.setBounds(42, 12321); + expect(graph.drawingObject.origin[0]).toEqual(42); + expect(graph.drawingObject.dimensions[0]).toEqual(12321); + }); + + it("provides a minimum/maximum even with no data", function () { + mockGraphs.forEach(function (mockGraph) { + mockGraph.getPointCount.andReturn(0); + }); + + // Create a graph of these utilizations + graph = new TimelineGraph( + 'testResource', + mockDomainObjects, + mockRenderer + ); + + // Verify that we get some usable defaults + expect(graph.minimum()).toEqual(jasmine.any(Number)); + expect(graph.maximum()).toEqual(jasmine.any(Number)); + expect(graph.maximum() > graph.minimum()).toBeTruthy(); + }); + + it("refreshes lines upon request", function () { + // Mock renderer looks at testIndex, so change it here... + mockGraphs[0].testIndex = 3; + // Should trigger a new render + graph.refresh(); + // Values correspond to the new index now + expect(graph.drawingObject.lines[0].buffer).toEqual( + [0, 30, 1, -30, 2, 30, 3, -30, 4, 30] + ); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/swimlane/TimelineColorAssignerSpec.js b/platform/features/timeline/test/controllers/swimlane/TimelineColorAssignerSpec.js new file mode 100644 index 0000000000..f1433bc025 --- /dev/null +++ b/platform/features/timeline/test/controllers/swimlane/TimelineColorAssignerSpec.js @@ -0,0 +1,65 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/swimlane/TimelineColorAssigner'], + function (TimelineColorAssigner) { + 'use strict'; + + describe("The Timeline legend color assigner", function () { + var testConfiguration, + assigner; + + beforeEach(function () { + testConfiguration = {}; + assigner = new TimelineColorAssigner(testConfiguration); + }); + + it("assigns colors by identifier", function () { + expect(assigner.get('xyz')).toBeUndefined(); + assigner.assign('xyz'); + expect(assigner.get('xyz')).toEqual(jasmine.any(String)); + }); + + it("releases colors", function () { + assigner.assign('xyz'); + assigner.release('xyz'); + expect(assigner.get('xyz')).toBeUndefined(); + }); + + it("provides at least 30 unique colors", function () { + var colors = {}, i, ids = []; + + // Add item to set + function set(c) { colors[c] = true; } + + // Generate ids + for (i = 0; i < 30; i += 1) { ids.push("id" + i); } + + // Assign colors to each id, then retrieve colors, + // storing into the set + ids.forEach(assigner.assign); + ids.map(assigner.get).map(set); + + // Should now be 30 elements in that set + expect(Object.keys(colors).length).toEqual(30); + }); + + it("populates the configuration with colors", function () { + assigner.assign('xyz'); + expect(testConfiguration.xyz).toBeDefined(); + }); + + it("preserves other colors when releases occur", function () { + var c; + assigner.assign('xyz'); + c = assigner.get('xyz'); + // Assign/release a different color + assigner.assign('abc'); + assigner.release('abc'); + // Our original assignment should be preserved + expect(assigner.get('xyz')).toEqual(c); + }); + + }); + } +); diff --git a/platform/features/timeline/test/controllers/swimlane/TimelineProxySpec.js b/platform/features/timeline/test/controllers/swimlane/TimelineProxySpec.js new file mode 100644 index 0000000000..25b807070f --- /dev/null +++ b/platform/features/timeline/test/controllers/swimlane/TimelineProxySpec.js @@ -0,0 +1,87 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/swimlane/TimelineProxy'], + function (TimelineProxy) { + 'use strict'; + + describe("The Timeline's selection proxy", function () { + var mockDomainObject, + mockSelection, + mockActionCapability, + mockActions, + proxy; + + beforeEach(function () { + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + ['getCapability'] + ); + mockSelection = jasmine.createSpyObj( + 'selection', + [ 'get' ] + ); + mockActionCapability = jasmine.createSpyObj( + 'action', + [ 'getActions' ] + ); + mockActions = ['a', 'b', 'c'].map(function (type) { + var mockAction = jasmine.createSpyObj( + 'action-' + type, + [ 'perform', 'getMetadata' ] + ); + mockAction.getMetadata.andReturn({ type: type }); + return mockAction; + }); + + mockDomainObject.getCapability.andReturn(mockActionCapability); + mockActionCapability.getActions.andReturn(mockActions); + + proxy = new TimelineProxy(mockDomainObject, mockSelection); + }); + + it("triggers a create action on add", function () { + // Should trigger b's create action + proxy.add('b'); + expect(mockActions[1].perform).toHaveBeenCalled(); + + // Also check that other actions weren't invoked + expect(mockActions[0].perform).not.toHaveBeenCalled(); + expect(mockActions[2].perform).not.toHaveBeenCalled(); + + // Verify that interactions were for correct keys + expect(mockDomainObject.getCapability) + .toHaveBeenCalledWith('action'); + expect(mockActionCapability.getActions) + .toHaveBeenCalledWith('create'); + }); + + it("invokes the action on the selection, if any", function () { + var mockOtherObject = jasmine.createSpyObj( + 'other', + ['getCapability'] + ), + mockOtherAction = jasmine.createSpyObj( + 'actionCapability', + ['getActions'] + ), + mockAction = jasmine.createSpyObj( + 'action', + ['perform', 'getMetadata'] + ); + + // Set up mocks + mockSelection.get.andReturn({ domainObject: mockOtherObject }); + mockOtherObject.getCapability.andReturn(mockOtherAction); + mockOtherAction.getActions.andReturn([mockAction]); + mockAction.getMetadata.andReturn({ type: 'z' }); + + // Invoke add method; should create with selected object + proxy.add('z'); + expect(mockAction.perform).toHaveBeenCalled(); + }); + + + }); + } +); diff --git a/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js new file mode 100644 index 0000000000..ba4b72dca2 --- /dev/null +++ b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js @@ -0,0 +1,160 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/swimlane/TimelineSwimlaneDecorator'], + function (TimelineSwimlaneDecorator) { + 'use strict'; + + describe("A Timeline swimlane decorator", function () { + var mockSwimlane, + mockSelection, + mockCapabilities, + testModel, + mockPromise, + decorator; + + beforeEach(function () { + mockSwimlane = {}; + mockCapabilities = {}; + testModel = {}; + + mockSelection = jasmine.createSpyObj('selection', ['select', 'get']); + + mockSwimlane.domainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getCapability', 'getModel' ] + ); + + mockCapabilities.mutation = jasmine.createSpyObj( + 'mutation', + ['mutate'] + ); + mockCapabilities.persistence = jasmine.createSpyObj( + 'persistence', + ['persist'] + ); + mockCapabilities.type = jasmine.createSpyObj( + 'type', + ['instanceOf'] + ); + mockPromise = jasmine.createSpyObj('promise', ['then']); + + mockSwimlane.domainObject.getCapability.andCallFake(function (c) { + return mockCapabilities[c]; + }); + mockSwimlane.domainObject.getModel.andReturn(testModel); + + mockCapabilities.type.instanceOf.andReturn(true); + mockCapabilities.mutation.mutate.andReturn(mockPromise); + + decorator = new TimelineSwimlaneDecorator( + mockSwimlane, + mockSelection + ); + }); + + it("returns the same object instance", function () { + // Decoration should occur in-place + expect(decorator).toBe(mockSwimlane); + }); + + it("adds a 'modes' getter-setter to activities", function () { + expect(mockSwimlane.modes).toEqual(jasmine.any(Function)); + expect(mockCapabilities.type.instanceOf) + .toHaveBeenCalledWith('warp.activity'); + }); + + it("adds a 'link' getter-setter to activities", function () { + expect(mockSwimlane.link).toEqual(jasmine.any(Function)); + expect(mockCapabilities.type.instanceOf) + .toHaveBeenCalledWith('warp.activity'); + }); + + it("gets modes from the domain object model", function () { + testModel.relationships = { modes: ['a', 'b', 'c'] }; + expect(decorator.modes()).toEqual(['a', 'b', 'c']); + testModel.relationships = { modes: ['x', 'y', 'z'] }; + expect(decorator.modes()).toEqual(['x', 'y', 'z']); + // Verify that it worked as a getter + expect(mockCapabilities.mutation.mutate) + .not.toHaveBeenCalled(); + }); + + it("gets links from the domain object model", function () { + testModel.link = "http://www.nasa.gov"; + expect(decorator.link()).toEqual("http://www.nasa.gov"); + // Verify that it worked as a getter + expect(mockCapabilities.mutation.mutate) + .not.toHaveBeenCalled(); + }); + + it("mutates modes when used as a setter", function () { + decorator.modes(['abc', 'xyz']); + expect(mockCapabilities.mutation.mutate) + .toHaveBeenCalledWith(jasmine.any(Function)); + mockCapabilities.mutation.mutate.mostRecentCall.args[0](testModel); + expect(testModel.relationships.modes).toEqual(['abc', 'xyz']); + + // Verify that persistence is called when promise resolves + expect(mockCapabilities.persistence.persist).not.toHaveBeenCalled(); + mockPromise.then.mostRecentCall.args[0](); + expect(mockCapabilities.persistence.persist).toHaveBeenCalled(); + }); + + it("mutates modes when used as a setter", function () { + decorator.link("http://www.noaa.gov"); + expect(mockCapabilities.mutation.mutate) + .toHaveBeenCalledWith(jasmine.any(Function)); + mockCapabilities.mutation.mutate.mostRecentCall.args[0](testModel); + expect(testModel.link).toEqual("http://www.noaa.gov"); + + // Verify that persistence is called when promise resolves + expect(mockCapabilities.persistence.persist).not.toHaveBeenCalled(); + mockPromise.then.mostRecentCall.args[0](); + expect(mockCapabilities.persistence.persist).toHaveBeenCalled(); + }); + + it("does not provide a 'remove' method with no parent", function () { + expect(decorator.remove).not.toEqual(jasmine.any(Function)); + }); + + it("fires the 'remove' action when remove is called", function () { + var mockChild = jasmine.createSpyObj( + 'childObject', + [ 'getCapability', 'getModel' ] + ), + mockAction = jasmine.createSpyObj( + 'action', + [ 'perform' ] + ); + + mockChild.getCapability.andCallFake(function (c) { + return c === 'action' ? mockAction : undefined; + }); + + // Create a child swimlane; it should have a remove action + new TimelineSwimlaneDecorator({ + domainObject: mockChild, + parent: decorator, + depth: 1 + }).remove(); + + expect(mockChild.getCapability).toHaveBeenCalledWith('action'); + expect(mockAction.perform).toHaveBeenCalledWith('remove'); + }); + + it("allows the swimlane to be selected", function () { + decorator.select(); + expect(mockSelection.select).toHaveBeenCalledWith(decorator); + }); + + it("allows checking for swimlane selection state", function () { + expect(decorator.selected()).toBeFalsy(); + mockSelection.get.andReturn(decorator); + expect(decorator.selected()).toBeTruthy(); + }); + + }); + + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDropHandlerSpec.js b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDropHandlerSpec.js new file mode 100644 index 0000000000..1019379326 --- /dev/null +++ b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDropHandlerSpec.js @@ -0,0 +1,173 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/swimlane/TimelineSwimlaneDropHandler'], + function (TimelineSwimlaneDropHandler) { + "use strict"; + + describe("A timeline's swimlane drop handler", function () { + var mockSwimlane, + mockOtherObject, + mockActionCapability, + mockPersistence, + handler; + + beforeEach(function () { + mockSwimlane = jasmine.createSpyObj( + "swimlane", + [ "highlight", "highlightBottom" ] + ); + // domainObject, idPath, children, expanded + mockSwimlane.domainObject = jasmine.createSpyObj( + "domainObject", + [ "getId", "getCapability", "useCapability", "hasCapability" ] + ); + mockSwimlane.idPath = [ 'a', 'b' ]; + mockSwimlane.children = [ {} ]; + mockSwimlane.expanded = true; + + mockSwimlane.parent = {}; + mockSwimlane.parent.idPath = ['a']; + mockSwimlane.parent.domainObject = jasmine.createSpyObj( + "domainObject", + [ "getId", "getCapability", "useCapability", "hasCapability" ] + ); + mockSwimlane.parent.children = [ mockSwimlane ]; + + mockSwimlane.children[0].domainObject = jasmine.createSpyObj( + "domainObject", + [ "getId", "getCapability", "useCapability", "hasCapability" ] + ); + + + mockOtherObject = jasmine.createSpyObj( + "domainObject", + [ "getId", "getCapability", "useCapability", "hasCapability" ] + ); + mockActionCapability = jasmine.createSpyObj("action", ["perform", "getActions"]); + mockPersistence = jasmine.createSpyObj("persistence", ["persist"]); + + mockActionCapability.getActions.andReturn([{}]); + mockSwimlane.parent.domainObject.getId.andReturn('a'); + mockSwimlane.domainObject.getId.andReturn('b'); + mockSwimlane.children[0].domainObject.getId.andReturn('c'); + mockOtherObject.getId.andReturn('d'); + + mockSwimlane.domainObject.getCapability.andCallFake(function (c) { + return { + action: mockActionCapability, + persistence: mockPersistence + }[c]; + }); + mockOtherObject.getCapability.andReturn(mockActionCapability); + + mockSwimlane.domainObject.hasCapability.andReturn(true); + + handler = new TimelineSwimlaneDropHandler(mockSwimlane); + }); + + it("disallows drop outside of edit mode", function () { + // Verify precondition + expect(handler.allowDropIn('d')).toBeTruthy(); + expect(handler.allowDropAfter('d')).toBeTruthy(); + // Act as if we're not in edit mode + mockSwimlane.domainObject.hasCapability.andReturn(false); + // Now, they should be disallowed + expect(handler.allowDropIn('d')).toBeFalsy(); + expect(handler.allowDropAfter('d')).toBeFalsy(); + + // Verify that editor capability was really checked for + expect(mockSwimlane.domainObject.hasCapability) + .toHaveBeenCalledWith('editor'); + }); + + it("disallows dropping of parents", function () { + expect(handler.allowDropIn('a')).toBeFalsy(); + expect(handler.allowDropAfter('a')).toBeFalsy(); + }); + + it("does not drop when no highlight state is present", function () { + // If there's no hover highlight, there's no drop allowed + handler.drop('d', mockOtherObject); + expect(mockOtherObject.getCapability) + .not.toHaveBeenCalled(); + expect(mockSwimlane.domainObject.useCapability) + .not.toHaveBeenCalled(); + expect(mockSwimlane.parent.domainObject.useCapability) + .not.toHaveBeenCalled(); + }); + + it("inserts into when highlighted", function () { + var testModel = { composition: [ 'c' ] }; + mockSwimlane.highlight.andReturn(true); + handler.drop('d'); + // Should have mutated + expect(mockSwimlane.domainObject.useCapability) + .toHaveBeenCalledWith("mutation", jasmine.any(Function)); + // Run the mutator + mockSwimlane.domainObject.useCapability.mostRecentCall + .args[1](testModel); + expect(testModel.composition).toEqual(['c', 'd']); + // Finally, should also have persisted + expect(mockPersistence.persist).toHaveBeenCalled(); + }); + + it("removes objects before insertion, if provided", function () { + var testModel = { composition: [ 'c' ] }; + mockSwimlane.highlight.andReturn(true); + handler.drop('d', mockOtherObject); + // Should have invoked a remove action + expect(mockActionCapability.perform) + .toHaveBeenCalledWith('remove'); + // Verify that mutator still ran as expected + mockSwimlane.domainObject.useCapability.mostRecentCall + .args[1](testModel); + expect(testModel.composition).toEqual(['c', 'd']); + }); + + it("inserts after as a peer when highlighted at the bottom", function () { + var testModel = { composition: [ 'x', 'b', 'y' ] }; + mockSwimlane.highlightBottom.andReturn(true); + mockSwimlane.expanded = false; + handler.drop('d'); + // Should have mutated + expect(mockSwimlane.parent.domainObject.useCapability) + .toHaveBeenCalledWith("mutation", jasmine.any(Function)); + // Run the mutator + mockSwimlane.parent.domainObject.useCapability.mostRecentCall + .args[1](testModel); + expect(testModel.composition).toEqual([ 'x', 'b', 'd', 'y']); + }); + + it("inserts into when highlighted at the bottom and expanded", function () { + var testModel = { composition: [ 'c' ] }; + mockSwimlane.highlightBottom.andReturn(true); + mockSwimlane.expanded = true; + handler.drop('d'); + // Should have mutated + expect(mockSwimlane.domainObject.useCapability) + .toHaveBeenCalledWith("mutation", jasmine.any(Function)); + // Run the mutator + mockSwimlane.domainObject.useCapability.mostRecentCall + .args[1](testModel); + expect(testModel.composition).toEqual([ 'd', 'c' ]); + }); + + it("inserts after as a peer when highlighted at the bottom and childless", function () { + var testModel = { composition: [ 'x', 'b', 'y' ] }; + mockSwimlane.highlightBottom.andReturn(true); + mockSwimlane.expanded = true; + mockSwimlane.children = []; + handler.drop('d'); + // Should have mutated + expect(mockSwimlane.parent.domainObject.useCapability) + .toHaveBeenCalledWith("mutation", jasmine.any(Function)); + // Run the mutator + mockSwimlane.parent.domainObject.useCapability.mostRecentCall + .args[1](testModel); + expect(testModel.composition).toEqual([ 'x', 'b', 'd', 'y']); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/swimlane/TimelineSwimlanePopulatorSpec.js b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlanePopulatorSpec.js new file mode 100644 index 0000000000..6d5c370de6 --- /dev/null +++ b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlanePopulatorSpec.js @@ -0,0 +1,135 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/swimlane/TimelineSwimlanePopulator'], + function (TimelineSwimlanePopulator) { + 'use strict'; + + describe("A Timeline swimlane populator", function () { + var mockLoader, + mockSelection, + testConfiguration, + mockDomainObject, + mockDomainObjects, + mockCallback, + populator; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + function makeMockDomainObject(id, composition) { + var mockDomainObject = jasmine.createSpyObj( + 'domainObject-' + id, + ['getId', 'getModel', 'getCapability', 'useCapability'] + ); + + mockDomainObject.getId.andReturn(id); + mockDomainObject.getModel.andReturn({ composition: composition }); + mockDomainObject.useCapability.andReturn(asPromise(false)); + + return mockDomainObject; + } + + function subgraph(domainObject, objects) { + function lookupSubgraph(id) { + return subgraph(objects[id], objects); + } + return { + domainObject: domainObject, + composition: domainObject.getModel().composition + .map(lookupSubgraph) + }; + } + + beforeEach(function () { + mockLoader = jasmine.createSpyObj('objectLoader', ['load']); + mockDomainObject = makeMockDomainObject('a', ['b', 'c']); + mockDomainObjects = { + a: mockDomainObject, + b: makeMockDomainObject('b', ['d']), + c: makeMockDomainObject('c', ['e', 'f']), + d: makeMockDomainObject('d', []), + e: makeMockDomainObject('e', []), + f: makeMockDomainObject('f', []) + }; + mockSelection = jasmine.createSpyObj( + 'selection', + ['get', 'select', 'proxy'] + ); + mockCallback = jasmine.createSpy('callback'); + + testConfiguration = {}; + + mockLoader.load.andReturn(asPromise(subgraph( + mockDomainObject, + mockDomainObjects + ))); + + populator = new TimelineSwimlanePopulator( + mockLoader, + testConfiguration, + mockSelection + ); + }); + + it("uses the loader to find subgraph", function () { + populator.populate(mockDomainObject); + expect(mockLoader.load).toHaveBeenCalledWith( + mockDomainObject, + 'timespan' + ); + }); + + it("provides a list of swimlanes", function () { + populator.populate(mockDomainObject); + // Ensure swimlane order matches depth-first search + expect(populator.get().map(function (swimlane) { + return swimlane.domainObject; + })).toEqual([ + mockDomainObjects.a, + mockDomainObjects.b, + mockDomainObjects.d, + mockDomainObjects.c, + mockDomainObjects.e, + mockDomainObjects.f + ]); + }); + + it("clears swimlanes if no object is provided", function () { + populator.populate(); + expect(populator.get()).toEqual([]); + }); + + it("preserves selection state when possible", function () { + // Repopulate swimlanes + populator.populate(mockDomainObject); + + // Act as if something is already selected + mockSelection.get.andReturn(populator.get()[1]); + + // Verify precondition + expect(mockSelection.select).not.toHaveBeenCalled(); + + // Repopulate swimlanes + populator.populate(mockDomainObject); + + // Selection should have been preserved + expect(mockSelection.select).toHaveBeenCalled(); + expect(mockSelection.select.mostRecentCall.args[0].domainObject) + .toEqual(mockDomainObjects.b); + }); + + it("exposes a selection proxy for the timeline", function () { + populator.populate(mockDomainObject); + expect(mockSelection.proxy).toHaveBeenCalled(); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneSpec.js b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneSpec.js new file mode 100644 index 0000000000..2351786973 --- /dev/null +++ b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneSpec.js @@ -0,0 +1,202 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../../src/controllers/swimlane/TimelineSwimlane'], + function (TimelineSwimlane) { + 'use strict'; + + describe("A Timeline swimlane", function () { + var parent, + child, + mockParentObject, + mockChildObject, + mockAssigner, + mockActionCapability, + mockParentTimespan, + mockChildTimespan, + testConfiguration; + + function asPromise(v) { + return { then: function (cb) { cb(v); } }; + } + + beforeEach(function () { + mockParentObject = jasmine.createSpyObj( + 'parent', + ['getId', 'getCapability', 'useCapability', 'getModel'] + ); + mockChildObject = jasmine.createSpyObj( + 'child', + ['getId', 'getCapability', 'useCapability', 'getModel'] + ); + mockAssigner = jasmine.createSpyObj( + 'assigner', + ['get', 'assign', 'release'] + ); + mockParentTimespan = jasmine.createSpyObj( + 'parentTimespan', + ['getStart', 'getEnd'] + ); + mockChildTimespan = jasmine.createSpyObj( + 'childTimespan', + ['getStart', 'getEnd'] + ); + mockActionCapability = jasmine.createSpyObj('action', ['perform']); + + mockParentObject.getId.andReturn('test-parent'); + mockParentObject.getCapability.andReturn(mockActionCapability); + mockParentObject.useCapability.andReturn(asPromise(mockParentTimespan)); + mockParentObject.getModel.andReturn({ name: "Test Parent" }); + mockChildObject.getModel.andReturn({ name: "Test Child" }); + mockChildObject.useCapability.andReturn(asPromise(mockChildTimespan)); + + testConfiguration = { graph: {} }; + + parent = new TimelineSwimlane( + mockParentObject, + mockAssigner, + testConfiguration + ); + child = new TimelineSwimlane( + mockChildObject, + mockAssigner, + testConfiguration, + parent + ); + }); + + it("exposes its domain object", function () { + expect(parent.domainObject).toEqual(mockParentObject); + expect(child.domainObject).toEqual(mockChildObject); + }); + + it("exposes its depth", function () { + expect(parent.depth).toEqual(0); + expect(child.depth).toEqual(1); + expect(new TimelineSwimlane(mockParentObject, {}, {}, child).depth) + .toEqual(2); + }); + + it("exposes its path as readable text", function () { + var grandchild = new TimelineSwimlane(mockParentObject, {}, {}, child), + ggc = new TimelineSwimlane(mockParentObject, {}, {}, grandchild); + + expect(parent.path).toEqual(""); + expect(child.path).toEqual(""); + expect(grandchild.path).toEqual("Test Child > "); + expect(ggc.path).toEqual("Test Child > Test Parent > "); + }); + + it("starts off expanded", function () { + expect(parent.expanded).toBeTruthy(); + expect(child.expanded).toBeTruthy(); + }); + + it("determines visibility based on parent expansion", function () { + parent.expanded = false; + expect(child.visible()).toBeFalsy(); + parent.expanded = true; + expect(child.visible()).toBeTruthy(); + }); + + it("is visible when it is the root of the timeline subgraph", function () { + expect(parent.visible()).toBeTruthy(); + }); + + it("fires the Edit Properties action on request", function () { + parent.properties(); + expect(mockParentObject.getCapability).toHaveBeenCalledWith('action'); + expect(mockActionCapability.perform).toHaveBeenCalledWith('properties'); + }); + + it("allows resource graph inclusion to be toggled", function () { + expect(testConfiguration.graph['test-parent']).toBeFalsy(); + parent.toggleGraph(); + expect(testConfiguration.graph['test-parent']).toBeTruthy(); + parent.toggleGraph(); + expect(testConfiguration.graph['test-parent']).toBeFalsy(); + }); + + it("provides a getter-setter for graph inclusion", function () { + expect(testConfiguration.graph['test-parent']).toBeFalsy(); + expect(parent.graph(true)).toBeTruthy(); + expect(parent.graph()).toBeTruthy(); + expect(testConfiguration.graph['test-parent']).toBeTruthy(); + expect(parent.graph(false)).toBeFalsy(); + expect(parent.graph()).toBeFalsy(); + expect(testConfiguration.graph['test-parent']).toBeFalsy(); + }); + + it("gets colors from the provided assigner", function () { + mockAssigner.get.andReturn("#ABCABC"); + expect(parent.color()).toEqual("#ABCABC"); + // Verify that id was passed, and no other interactions + expect(mockAssigner.get).toHaveBeenCalledWith('test-parent'); + expect(mockAssigner.assign).not.toHaveBeenCalled(); + expect(mockAssigner.release).not.toHaveBeenCalled(); + }); + + it("allows colors to be set", function () { + parent.color("#F0000D"); + expect(mockAssigner.assign).toHaveBeenCalledWith( + 'test-parent', + "#F0000D" + ); + }); + + it("assigns colors when resource graph state is toggled", function () { + expect(mockAssigner.assign).not.toHaveBeenCalled(); + parent.toggleGraph(); + expect(mockAssigner.assign).toHaveBeenCalledWith('test-parent'); + expect(mockAssigner.release).not.toHaveBeenCalled(); + parent.toggleGraph(); + expect(mockAssigner.release).toHaveBeenCalledWith('test-parent'); + }); + + it("assigns colors when resource graph state is set", function () { + expect(mockAssigner.assign).not.toHaveBeenCalled(); + parent.graph(true); + expect(mockAssigner.assign).toHaveBeenCalledWith('test-parent'); + expect(mockAssigner.release).not.toHaveBeenCalled(); + parent.graph(false); + expect(mockAssigner.release).toHaveBeenCalledWith('test-parent'); + }); + + it("provides getter-setters for drag-drop highlights", function () { + expect(parent.highlight()).toBeFalsy(); + parent.highlight(true); + expect(parent.highlight()).toBeTruthy(); + + expect(parent.highlightBottom()).toBeFalsy(); + parent.highlightBottom(true); + expect(parent.highlightBottom()).toBeTruthy(); + }); + + it("detects start/end violations", function () { + mockParentTimespan.getStart.andReturn(42); + mockParentTimespan.getEnd.andReturn(12321); + + // First, start with a valid timespan + mockChildTimespan.getStart.andReturn(84); + mockChildTimespan.getEnd.andReturn(100); + expect(child.exceeded()).toBeFalsy(); + + // Start time violation + mockChildTimespan.getStart.andReturn(21); + expect(child.exceeded()).toBeTruthy(); + + // Now both in violation + mockChildTimespan.getEnd.andReturn(20000); + expect(child.exceeded()).toBeTruthy(); + + // And just the end + mockChildTimespan.getStart.andReturn(100); + expect(child.exceeded()).toBeTruthy(); + + // Now back to everything's-just-fine + mockChildTimespan.getEnd.andReturn(10000); + expect(child.exceeded()).toBeFalsy(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js b/platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js new file mode 100644 index 0000000000..b9fe7caf50 --- /dev/null +++ b/platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js @@ -0,0 +1,15 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/directives/SwimlaneDragConstants'], + function (SwimlaneDragConstants) { + "use strict"; + + describe("Timeline swimlane drag constants", function () { + it("define a custom type for swimlane drag-drop", function () { + expect(SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE) + .toEqual(jasmine.any(String)); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/directives/WARPSwimlaneDragSpec.js b/platform/features/timeline/test/directives/WARPSwimlaneDragSpec.js new file mode 100644 index 0000000000..360fff4b69 --- /dev/null +++ b/platform/features/timeline/test/directives/WARPSwimlaneDragSpec.js @@ -0,0 +1,76 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/directives/WARPSwimlaneDrag', '../../src/directives/SwimlaneDragConstants'], + function (WARPSwimlaneDrag, SwimlaneDragConstants) { + "use strict"; + + describe("The warp-swimlane-drag directive", function () { + var mockDndService, + mockScope, + mockElement, + testAttrs, + handlers, + directive; + + beforeEach(function () { + var scopeExprs = { + someTestExpr: "some swimlane" + }; + + handlers = {}; + + mockDndService = jasmine.createSpyObj( + 'dndService', + ['setData', 'getData', 'removeData'] + ); + mockScope = jasmine.createSpyObj('$scope', ['$eval']); + mockElement = jasmine.createSpyObj('element', ['on']); + testAttrs = { warpSwimlaneDrag: "someTestExpr" }; + + // Simulate evaluation of expressions in scope + mockScope.$eval.andCallFake(function (expr) { + return scopeExprs[expr]; + }); + + directive = new WARPSwimlaneDrag(mockDndService); + + // Run the link function, then capture the event handlers + // for testing. + directive.link(mockScope, mockElement, testAttrs); + + mockElement.on.calls.forEach(function (call) { + handlers[call.args[0]] = call.args[1]; + }); + + }); + + it("is available as an attribute", function () { + expect(directive.restrict).toEqual("A"); + }); + + it("exposes the swimlane when dragging starts", function () { + // Verify precondition + expect(mockDndService.setData).not.toHaveBeenCalled(); + // Start a drag + handlers.dragstart(); + // Should have exposed the swimlane + expect(mockDndService.setData).toHaveBeenCalledWith( + SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE, + "some swimlane" + ); + }); + + it("clears the swimlane when dragging ends", function () { + // Verify precondition + expect(mockDndService.removeData).not.toHaveBeenCalled(); + // Start a drag + handlers.dragend(); + // Should have exposed the swimlane + expect(mockDndService.removeData).toHaveBeenCalledWith( + SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE + ); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/directives/WARPSwimlaneDropSpec.js b/platform/features/timeline/test/directives/WARPSwimlaneDropSpec.js new file mode 100644 index 0000000000..f1f0858503 --- /dev/null +++ b/platform/features/timeline/test/directives/WARPSwimlaneDropSpec.js @@ -0,0 +1,147 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/directives/WARPSwimlaneDrop'], + function (WARPSwimlaneDrop) { + "use strict"; + + var TEST_HEIGHT = 100, + TEST_TOP = 600; + + describe("The warp-swimlane-drop directive", function () { + var mockDndService, + mockScope, + mockElement, + testAttrs, + mockSwimlane, + mockRealElement, + testEvent, + handlers, + directive; + + function getterSetter(value) { + return function (newValue) { + return (value = (arguments.length > 0) ? newValue : value); + }; + } + + beforeEach(function () { + var scopeExprs = {}; + + handlers = {}; + + mockDndService = jasmine.createSpyObj( + 'dndService', + ['setData', 'getData', 'removeData'] + ); + mockScope = jasmine.createSpyObj('$scope', ['$eval']); + mockElement = jasmine.createSpyObj('element', ['on']); + testAttrs = { warpSwimlaneDrop: "mockSwimlane" }; + mockSwimlane = jasmine.createSpyObj( + "swimlane", + [ "allowDropIn", "allowDropAfter", "drop", "highlight", "highlightBottom" ] + ); + mockElement[0] = jasmine.createSpyObj( + "realElement", + [ "getBoundingClientRect" ] + ); + mockElement[0].offsetHeight = TEST_HEIGHT; + mockElement[0].getBoundingClientRect.andReturn({ top: TEST_TOP }); + + // Simulate evaluation of expressions in scope + scopeExprs.mockSwimlane = mockSwimlane; + mockScope.$eval.andCallFake(function (expr) { + return scopeExprs[expr]; + }); + + + mockSwimlane.allowDropIn.andReturn(true); + mockSwimlane.allowDropAfter.andReturn(true); + // Simulate getter-setter behavior + mockSwimlane.highlight.andCallFake(getterSetter(false)); + mockSwimlane.highlightBottom.andCallFake(getterSetter(false)); + + + + testEvent = { + pageY: TEST_TOP + TEST_HEIGHT / 10, + dataTransfer: { getData: jasmine.createSpy() }, + preventDefault: jasmine.createSpy() + }; + + testEvent.dataTransfer.getData.andReturn('abc'); + mockDndService.getData.andReturn({ domainObject: 'someDomainObject' }); + + directive = new WARPSwimlaneDrop(mockDndService); + + // Run the link function, then capture the event handlers + // for testing. + directive.link(mockScope, mockElement, testAttrs); + + mockElement.on.calls.forEach(function (call) { + handlers[call.args[0]] = call.args[1]; + }); + + }); + + it("is available as an attribute", function () { + expect(directive.restrict).toEqual("A"); + }); + + it("updates highlights on drag over", function () { + // Near the top + testEvent.pageY = TEST_TOP + TEST_HEIGHT / 10; + + handlers.dragover(testEvent); + + expect(mockSwimlane.highlight).toHaveBeenCalledWith(true); + expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false); + }); + + it("updates bottom highlights on drag over", function () { + // Near the bottom + testEvent.pageY = TEST_TOP + TEST_HEIGHT - TEST_HEIGHT / 10; + + handlers.dragover(testEvent); + + expect(mockSwimlane.highlight).toHaveBeenCalledWith(false); + expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(true); + }); + + it("respects swimlane's allowDropIn response", function () { + // Near the top + testEvent.pageY = TEST_TOP + TEST_HEIGHT / 10; + + mockSwimlane.allowDropIn.andReturn(false); + + handlers.dragover(testEvent); + + expect(mockSwimlane.highlight).toHaveBeenCalledWith(false); + expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false); + }); + + it("respects swimlane's allowDropAfter response", function () { + // Near the top + testEvent.pageY = TEST_TOP + TEST_HEIGHT - TEST_HEIGHT / 10; + + mockSwimlane.allowDropAfter.andReturn(false); + + handlers.dragover(testEvent); + + expect(mockSwimlane.highlight).toHaveBeenCalledWith(false); + expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false); + }); + + it("notifies swimlane on drop", function () { + handlers.drop(testEvent); + expect(mockSwimlane.drop).toHaveBeenCalledWith('abc', 'someDomainObject'); + }); + + it("clears highlights when drag leaves", function () { + handlers.dragleave(); + expect(mockSwimlane.highlight).toHaveBeenCalledWith(false); + expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/services/ObjectLoaderSpec.js b/platform/features/timeline/test/services/ObjectLoaderSpec.js new file mode 100644 index 0000000000..77b5d13c36 --- /dev/null +++ b/platform/features/timeline/test/services/ObjectLoaderSpec.js @@ -0,0 +1,136 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ + +define( + ['../../src/services/ObjectLoader'], + function (ObjectLoader) { + "use strict"; + + describe("The domain object loader", function () { + var mockQ, + mockCallback, + mockDomainObjects, + testCompositions, + objectLoader; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + function lookupObject(id) { + return mockDomainObjects[id]; + } + + function fullSubgraph(id) { + return { + domainObject: mockDomainObjects[id], + composition: (testCompositions[id] || []) + .map(fullSubgraph) + }; + } + + function addDomainObject(id, children, capabilities) { + var mockDomainObject = jasmine.createSpyObj( + 'object-' + id, + [ 'useCapability', 'hasCapability', 'getId' ] + ); + + mockDomainObject.getId.andReturn(id); + mockDomainObject.useCapability.andCallFake(function (c) { + return c === 'composition' ? + asPromise(children.map(lookupObject)) : + undefined; + }); + mockDomainObject.hasCapability.andCallFake(function (c) { + return (capabilities.indexOf(c) !== -1) || (c === 'composition'); + }); + mockDomainObjects[id] = mockDomainObject; + + testCompositions[id] = children; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', [ 'when', 'all' ]); + mockCallback = jasmine.createSpy('callback'); + mockDomainObjects = {}; + testCompositions = {}; + + // Provide subset of q's actual behavior which we + // expect object loader to really need + mockQ.when.andCallFake(asPromise); + mockQ.all.andCallFake(function (values) { + var result = []; + function addResult(v) { result.push(v); } + function promiseResult(v) { asPromise(v).then(addResult); } + values.forEach(promiseResult); + return asPromise(result); + }); + + // Populate some mock domain objects + addDomainObject('a', ['b', 'c', 'd'], ['test']); + addDomainObject('b', ['c', 'd', 'ba'], []); + addDomainObject('c', ['ca'], ['test']); + addDomainObject('d', [], ['test']); + addDomainObject('ba', [], ['test']); + addDomainObject('ca', [], ['test']); + + objectLoader = new ObjectLoader(mockQ); + }); + + + + it("loads sub-graphs of composition hierarchy", function () { + objectLoader.load(mockDomainObjects.a).then(mockCallback); + // Should have loaded full graph + expect(mockCallback).toHaveBeenCalledWith(fullSubgraph('a')); + }); + + it("filters based on capabilities, if requested", function () { + objectLoader.load(mockDomainObjects.a, 'test') + .then(mockCallback); + // Should have pruned 'b' + expect(mockCallback).toHaveBeenCalledWith({ + domainObject: mockDomainObjects.a, + composition: [ + fullSubgraph('c'), + fullSubgraph('d') + ] + }); + }); + + it("filters with a function, if requested", function () { + function shortName(domainObject) { + return domainObject.getId().length === 1; + } + objectLoader.load(mockDomainObjects.a, shortName) + .then(mockCallback); + // Should have pruned 'ba' and 'ca' + expect(mockCallback).toHaveBeenCalledWith({ + domainObject: mockDomainObjects.a, + composition: [ + { + domainObject: mockDomainObjects.b, + composition: [ + { + domainObject: mockDomainObjects.c, + composition: [] + }, + fullSubgraph('d') + ] + }, + { + domainObject: mockDomainObjects.c, + composition: [] + }, + fullSubgraph('d') + ] + }); + }); + + }); + + } +); \ No newline at end of file diff --git a/platform/features/timeline/test/suite.json b/platform/features/timeline/test/suite.json new file mode 100644 index 0000000000..f04528a61c --- /dev/null +++ b/platform/features/timeline/test/suite.json @@ -0,0 +1,50 @@ +[ + "TimelineConstants", + "TimelineFormatter", + + "capabilities/ActivityTimespan", + "capabilities/ActivityTimespanCapability", + "capabilities/ActivityUtilization", + "capabilities/CostCapability", + "capabilities/GraphCapability", + "capabilities/CumulativeGraph", + "capabilities/ResourceGraph", + "capabilities/TimelineTimespan", + "capabilities/TimelineTimespanCapability", + "capabilities/TimelineUtilization", + "capabilities/UtilizationCapability", + + "controllers/ActivityModeValuesController", + "controllers/TimelineController", + "controllers/TimelineGanttController", + "controllers/TimelineGraphController", + "controllers/TimelineTableController", + "controllers/TimelineTickController", + "controllers/TimelineZoomController", + "controllers/WARPDateTimeController", + + "controllers/drag/TimelineDragHandler", + "controllers/drag/TimelineDragHandleFactory", + "controllers/drag/TimelineDragPopulator", + "controllers/drag/TimelineSnapHandler", + "controllers/drag/TimelineStartHandle", + "controllers/drag/TimelineMoveHandle", + "controllers/drag/TimelineEndHandle", + + "controllers/graph/TimelineGraph", + "controllers/graph/TimelineGraphPopulator", + "controllers/graph/TimelineGraphRenderer", + + "controllers/swimlane/TimelineColorAssigner", + "controllers/swimlane/TimelineProxy", + "controllers/swimlane/TimelineSwimlane", + "controllers/swimlane/TimelineSwimlaneDecorator", + "controllers/swimlane/TimelineSwimlaneDropHandler", + "controllers/swimlane/TimelineSwimlanePopulator", + + "directives/SwimlaneDragConstants", + "directives/WARPSwimlaneDrag", + "directives/WARPSwimlaneDrop", + + "services/ObjectLoader" +] From 96f72b376573ea29c23a188beb8ddb81150c0709 Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Mon, 14 Sep 2015 16:52:56 -0700 Subject: [PATCH 02/16] [Clocks] Add license, change keys Un-namespace keys in clock plugin, and add licensing info for moment-duration-format. WTD-1239 --- LICENSES.md | 35 +++++++++++++++++++ platform/features/clock/bundle.json | 26 +++++++------- .../clock/src/actions/RestartTimerAction.js | 4 +-- .../clock/src/actions/StartTimerAction.js | 4 +-- .../clock/src/controllers/TimerController.js | 2 +- .../test/actions/RestartTimerActionSpec.js | 8 ++--- .../test/controllers/TimerControllerSpec.js | 4 +-- 7 files changed, 59 insertions(+), 24 deletions(-) diff --git a/LICENSES.md b/LICENSES.md index 0224a1ae4d..f7b6c98b11 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -345,6 +345,41 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --- +### moment-duration-format + +#### Info + +* Link: https://github.com/jsmreese/moment-duration-format + +* Version: 1.3.0 + +* Authors: John Madhavan-Reese + +* Description: Duration parsing/formatting + +#### License + +Copyright 2014 John Madhavan-Reese + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + ### Json.NET #### Info diff --git a/platform/features/clock/bundle.json b/platform/features/clock/bundle.json index ee40b028b9..0d1ac5611a 100644 --- a/platform/features/clock/bundle.json +++ b/platform/features/clock/bundle.json @@ -1,5 +1,5 @@ { - "name": "WARP Clocks/Timers", + "name": "Clocks/Timers", "descriptions": "Domain objects for displaying current & relative times.", "configuration": { "paths": { @@ -17,13 +17,13 @@ "indicators": [ { "implementation": "indicators/ClockIndicator.js", - "depends": [ "warp.tickerService", "CLOCK_INDICATOR_FORMAT" ], + "depends": [ "tickerService", "CLOCK_INDICATOR_FORMAT" ], "priority": "preferred" } ], "services": [ { - "key": "warp.tickerService", + "key": "tickerService", "implementation": "services/TickerService.js", "depends": [ "$timeout", "now" ] } @@ -32,7 +32,7 @@ { "key": "ClockController", "implementation": "controllers/ClockController.js", - "depends": [ "$scope", "warp.tickerService" ] + "depends": [ "$scope", "tickerService" ] }, { "key": "TimerController", @@ -42,24 +42,24 @@ { "key": "RefreshingController", "implementation": "controllers/RefreshingController.js", - "depends": [ "$scope", "warp.tickerService" ] + "depends": [ "$scope", "tickerService" ] } ], "views": [ { - "key": "warp.clock", - "type": "warp.clock", + "key": "clock", + "type": "clock", "templateUrl": "templates/clock.html" }, { - "key": "warp.timer", - "type": "warp.timer", + "key": "timer", + "type": "timer", "templateUrl": "templates/timer.html" } ], "actions": [ { - "key": "warp.timer.start", + "key": "timer.start", "implementation": "actions/StartTimerAction.js", "depends": ["now"], "category": "contextual", @@ -68,7 +68,7 @@ "priority": "preferred" }, { - "key": "warp.timer.restart", + "key": "timer.restart", "implementation": "actions/RestartTimerAction.js", "depends": ["now"], "category": "contextual", @@ -79,7 +79,7 @@ ], "types": [ { - "key": "warp.clock", + "key": "clock", "name": "Clock", "glyph": "C", "features": [ "creation" ], @@ -127,7 +127,7 @@ } }, { - "key": "warp.timer", + "key": "timer", "name": "Timer", "glyph": "\u00F5", "features": [ "creation" ], diff --git a/platform/features/clock/src/actions/RestartTimerAction.js b/platform/features/clock/src/actions/RestartTimerAction.js index 42723887c5..9a89b877cb 100644 --- a/platform/features/clock/src/actions/RestartTimerAction.js +++ b/platform/features/clock/src/actions/RestartTimerAction.js @@ -23,11 +23,11 @@ define( // We show this variant for timers which already have // a target time. - return model.type === 'warp.timer' && + return model.type === 'timer' && model.timestamp !== undefined; }; return RestartTimerAction; } -); \ No newline at end of file +); diff --git a/platform/features/clock/src/actions/StartTimerAction.js b/platform/features/clock/src/actions/StartTimerAction.js index 39f604d784..bb0ad4b9d3 100644 --- a/platform/features/clock/src/actions/StartTimerAction.js +++ b/platform/features/clock/src/actions/StartTimerAction.js @@ -24,11 +24,11 @@ define( // We show this variant for timers which do not yet have // a target time. - return model.type === 'warp.timer' && + return model.type === 'timer' && model.timestamp === undefined; }; return StartTimerAction; } -); \ No newline at end of file +); diff --git a/platform/features/clock/src/controllers/TimerController.js b/platform/features/clock/src/controllers/TimerController.js index 9538f914dd..0c4e021d84 100644 --- a/platform/features/clock/src/controllers/TimerController.js +++ b/platform/features/clock/src/controllers/TimerController.js @@ -49,7 +49,7 @@ define( formatKey = model.timerFormat, actionCapability = domainObject.getCapability('action'), actionKey = (timestamp === undefined) ? - 'warp.timer.start' : 'warp.timer.restart'; + 'timer.start' : 'timer.restart'; updateFormat(formatKey); updateTimestamp(timestamp); diff --git a/platform/features/clock/test/actions/RestartTimerActionSpec.js b/platform/features/clock/test/actions/RestartTimerActionSpec.js index 23df5f3142..e96cd7f8cc 100644 --- a/platform/features/clock/test/actions/RestartTimerActionSpec.js +++ b/platform/features/clock/test/actions/RestartTimerActionSpec.js @@ -59,18 +59,18 @@ define( }); it("applies only to timers with a target time", function () { - testModel.type = 'warp.timer'; + testModel.type = 'timer'; testModel.timestamp = 12000; expect(RestartTimerAction.appliesTo(testContext)).toBeTruthy(); - testModel.type = 'warp.timer'; + testModel.type = 'timer'; testModel.timestamp = undefined; expect(RestartTimerAction.appliesTo(testContext)).toBeFalsy(); - testModel.type = 'warp.clock'; + testModel.type = 'clock'; testModel.timestamp = 12000; expect(RestartTimerAction.appliesTo(testContext)).toBeFalsy(); }); }); } -); \ No newline at end of file +); diff --git a/platform/features/clock/test/controllers/TimerControllerSpec.js b/platform/features/clock/test/controllers/TimerControllerSpec.js index 9f80e21d9d..8d081570a2 100644 --- a/platform/features/clock/test/controllers/TimerControllerSpec.js +++ b/platform/features/clock/test/controllers/TimerControllerSpec.js @@ -62,8 +62,8 @@ define( }); mockActionCapability.getActions.andCallFake(function (k) { return [{ - 'warp.timer.start': mockStart, - 'warp.timer.restart': mockRestart + 'timer.start': mockStart, + 'timer.restart': mockRestart }[k]]; }); mockStart.getMetadata.andReturn({ glyph: "S", name: "Start" }); From 8ce8080253f446734a8e1b028920811fff66201f Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Tue, 15 Sep 2015 08:34:15 -0700 Subject: [PATCH 03/16] [Timelines] Begin removing namespacing Begin de-namespacing timelines/activities for inclusion in open source. WTD-1239 --- platform/features/timeline/bundle.json | 50 +++++++++---------- .../src/directives/SwimlaneDragConstants.js | 4 +- .../src/directives/WARPSwimlaneDrag.js | 8 +-- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/platform/features/timeline/bundle.json b/platform/features/timeline/bundle.json index 1132ed1840..6cf2f9be8d 100644 --- a/platform/features/timeline/bundle.json +++ b/platform/features/timeline/bundle.json @@ -48,16 +48,16 @@ ], "types": [ { - "key": "warp.timeline", + "key": "timeline", "name": "Timeline", "glyph": "S", "description": "A container for arranging Timelines and Activities in time.", "features": [ "creation" ], - "contains": [ "warp.timeline", "warp.activity" ], + "contains": [ "timeline", "activity" ], "properties": [ { "name": "Start date/time", - "control": "warp.datetime", + "control": "datetime", "required": true, "property": [ "start" ], "options": [ "SET" ] @@ -74,23 +74,23 @@ "model": { "composition": [] } }, { - "key": "warp.activity", + "key": "activity", "name": "Activity", "glyph": "a", "features": [ "creation" ], - "contains": [ "warp.activity" ], + "contains": [ "activity" ], "description": "An action that takes place in time. You can define a start time and duration. Activities can be nested within other Activities, or within Timelines.", "properties": [ { "name": "Start date/time", - "control": "warp.datetime", + "control": "datetime", "required": true, "property": [ "start" ], "options": [ "SET" ] }, { "name": "Duration", - "control": "warp.duration", + "control": "duration", "required": true, "property": [ "duration" ] } @@ -98,7 +98,7 @@ "model": { "composition": [], "relationships": { "modes": [] } } }, { - "key": "warp.mode", + "key": "mode", "name": "Activity Mode", "glyph": "A", "features": [ "creation" ], @@ -124,19 +124,19 @@ ], "views": [ { - "key": "warp.values", + "key": "values", "name": "Values", "glyph": "A", "templateUrl": "templates/values.html", - "type": "warp.mode", + "type": "mode", "uses": [ "cost" ], "editable": false }, { - "key": "warp.timeline", + "key": "timeline", "name": "Timeline", "glyph": "S", - "type": "warp.timeline", + "type": "timeline", "description": "A timeline view of Timelines and Activities.", "templateUrl": "templates/timeline.html", "toolbar": { @@ -152,12 +152,12 @@ { "name": "Timeline", "glyph": "S", - "key": "warp.timeline" + "key": "timeline" }, { "name": "Activity", "glyph": "a", - "key": "warp.activity" + "key": "activity" } ] } @@ -179,7 +179,7 @@ "dialog": { "control": "selector", "name": "Modes", - "type": "warp.mode" + "type": "mode" }, "property": "modes" }, @@ -219,7 +219,7 @@ ], "representations": [ { - "key": "warp.gantt", + "key": "gantt", "templateUrl": "templates/activity-gantt.html", "uses": [ "timespan", "type" ] } @@ -258,11 +258,11 @@ ], "controls": [ { - "key": "warp.datetime", + "key": "datetime", "templateUrl": "templates/controls/datetime.html" }, { - "key": "warp.duration", + "key": "duration", "templateUrl": "templates/controls/datetime.html" } ], @@ -270,12 +270,12 @@ { "key": "TimelineController", "implementation": "controllers/TimelineController.js", - "depends": [ "$scope", "$q", "warp.objectLoader", "TIMELINE_MINIMUM_DURATION" ] + "depends": [ "$scope", "$q", "objectLoader", "TIMELINE_MINIMUM_DURATION" ] }, { "key": "TimelineGraphController", "implementation": "controllers/TimelineGraphController.js", - "depends": [ "$scope", "warp.resources[]" ] + "depends": [ "$scope", "resources[]" ] }, { "key": "WARPDateTimeController", @@ -303,7 +303,7 @@ { "key": "ActivityModeValuesController", "implementation": "controllers/ActivityModeValuesController.js", - "depends": [ "warp.resources[]" ] + "depends": [ "resources[]" ] } ], "capabilities": [ @@ -334,24 +334,24 @@ ], "directives": [ { - "key": "warpSwimlaneDrop", + "key": "mctSwimlaneDrop", "implementation": "directives/WARPSwimlaneDrop.js", "depends": [ "dndService" ] }, { - "key": "warpSwimlaneDrag", + "key": "mctSwimlaneDrag", "implementation": "directives/WARPSwimlaneDrag.js", "depends": [ "dndService" ] } ], "services": [ { - "key": "warp.objectLoader", + "key": "objectLoader", "implementation": "services/ObjectLoader.js", "depends": [ "$q" ] } ], - "warp.resources": [ + "resources": [ { "key": "power", "name": "Power", diff --git a/platform/features/timeline/src/directives/SwimlaneDragConstants.js b/platform/features/timeline/src/directives/SwimlaneDragConstants.js index d88b466dd6..8adcffc4ae 100644 --- a/platform/features/timeline/src/directives/SwimlaneDragConstants.js +++ b/platform/features/timeline/src/directives/SwimlaneDragConstants.js @@ -16,5 +16,5 @@ define({ /** * String identifier for swimlanes being dragged. */ - WARP_SWIMLANE_DRAG_TYPE: 'warp-swimlane' -}); \ No newline at end of file + TIMELINE_SWIMLANE_DRAG_TYPE: 'timeline-swimlane' +}); diff --git a/platform/features/timeline/src/directives/WARPSwimlaneDrag.js b/platform/features/timeline/src/directives/WARPSwimlaneDrag.js index 83a4e41bb6..cd0aed252e 100644 --- a/platform/features/timeline/src/directives/WARPSwimlaneDrag.js +++ b/platform/features/timeline/src/directives/WARPSwimlaneDrag.js @@ -17,19 +17,19 @@ define( function link(scope, element, attrs) { // Look up the swimlane from the provided expression function swimlane() { - return scope.$eval(attrs.warpSwimlaneDrag); + return scope.$eval(attrs.mctSwimlaneDrag); } // When drag starts, publish via dndService element.on('dragstart', function () { dndService.setData( - SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE, + SwimlaneDragConstants.TIMELINE_SWIMLANE_DRAG_TYPE, swimlane() ); }); // When drag ends, clear via dndService element.on('dragend', function () { dndService.removeData( - SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE + SwimlaneDragConstants.TIMELINE_SWIMLANE_DRAG_TYPE ); }); } @@ -44,4 +44,4 @@ define( return WARPSwimlaneDrag; } -); \ No newline at end of file +); From 307047d3acf96ad66e2e30aa58f26ac391b071cb Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 13:44:39 -0700 Subject: [PATCH 04/16] [Clocks/Timers] Remove namespacing from specs WTD-1239 --- .../features/clock/test/actions/StartTimerActionSpec.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/platform/features/clock/test/actions/StartTimerActionSpec.js b/platform/features/clock/test/actions/StartTimerActionSpec.js index 10f60595d5..5d3c9b8e16 100644 --- a/platform/features/clock/test/actions/StartTimerActionSpec.js +++ b/platform/features/clock/test/actions/StartTimerActionSpec.js @@ -59,18 +59,18 @@ define( }); it("applies only to timers without a target time", function () { - testModel.type = 'warp.timer'; + testModel.type = 'timer'; testModel.timestamp = 12000; expect(StartTimerAction.appliesTo(testContext)).toBeFalsy(); - testModel.type = 'warp.timer'; + testModel.type = 'timer'; testModel.timestamp = undefined; expect(StartTimerAction.appliesTo(testContext)).toBeTruthy(); - testModel.type = 'warp.clock'; + testModel.type = 'clock'; testModel.timestamp = 12000; expect(StartTimerAction.appliesTo(testContext)).toBeFalsy(); }); }); } -); \ No newline at end of file +); From 8159c365b57c98af582ebdb2a3e56b5475e1b7fc Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 13:45:54 -0700 Subject: [PATCH 05/16] [Timeline] Remove namespacing from README WTD-1239 --- platform/features/timeline/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platform/features/timeline/README.md b/platform/features/timeline/README.md index 5dd9561c2c..38439df43e 100644 --- a/platform/features/timeline/README.md +++ b/platform/features/timeline/README.md @@ -16,7 +16,7 @@ A timeline's model looks like: ``` { - "type": "warp.timeline", + "type": "timeline", "start": { "timestamp": (milliseconds since epoch), "epoch": (currently, always "SET") @@ -35,7 +35,7 @@ An activity's model looks like: ``` { - "type": "warp.activity", + "type": "activity", "start": { "timestamp": (milliseconds since epoch), "epoch": (currently, always "SET") @@ -61,7 +61,7 @@ An activity mode's model looks like: ``` { - "type": "warp.mode", + "type": "mode", "resources": { "comms": (communications utilization, in Kbps) "power": (power utilization, in watts) From 19ad4c8174a7475102d7977fc34e7a3df5a80d98 Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 13:48:07 -0700 Subject: [PATCH 06/16] [Timelines] Change namespacing for directives WTD-1239 --- platform/features/timeline/bundle.json | 4 ++-- .../{WARPSwimlaneDrag.js => MCTSwimlaneDrag.js} | 6 +++--- .../{WARPSwimlaneDrop.js => MCTSwimlaneDrop.js} | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) rename platform/features/timeline/src/directives/{WARPSwimlaneDrag.js => MCTSwimlaneDrag.js} (90%) rename platform/features/timeline/src/directives/{WARPSwimlaneDrop.js => MCTSwimlaneDrop.js} (92%) diff --git a/platform/features/timeline/bundle.json b/platform/features/timeline/bundle.json index 6cf2f9be8d..28080de247 100644 --- a/platform/features/timeline/bundle.json +++ b/platform/features/timeline/bundle.json @@ -335,12 +335,12 @@ "directives": [ { "key": "mctSwimlaneDrop", - "implementation": "directives/WARPSwimlaneDrop.js", + "implementation": "directives/MCTSwimlaneDrop.js", "depends": [ "dndService" ] }, { "key": "mctSwimlaneDrag", - "implementation": "directives/WARPSwimlaneDrag.js", + "implementation": "directives/MCTSwimlaneDrag.js", "depends": [ "dndService" ] } ], diff --git a/platform/features/timeline/src/directives/WARPSwimlaneDrag.js b/platform/features/timeline/src/directives/MCTSwimlaneDrag.js similarity index 90% rename from platform/features/timeline/src/directives/WARPSwimlaneDrag.js rename to platform/features/timeline/src/directives/MCTSwimlaneDrag.js index cd0aed252e..87bd829adf 100644 --- a/platform/features/timeline/src/directives/WARPSwimlaneDrag.js +++ b/platform/features/timeline/src/directives/MCTSwimlaneDrag.js @@ -6,14 +6,14 @@ define( "use strict"; /** - * Defines the `warp-swimlane-drag` directive. When a drag is initiated + * Defines the `mct-swimlane-drag` directive. When a drag is initiated * form an element with this attribute, the swimlane being dragged * (identified by the value of this attribute, as an Angular expression) * will be exported to the `dndService` as part of the active drag-drop * state. * @param {DndService} dndService drag-and-drop service */ - function WARPSwimlaneDrag(dndService) { + function MCTSwimlaneDrag(dndService) { function link(scope, element, attrs) { // Look up the swimlane from the provided expression function swimlane() { @@ -42,6 +42,6 @@ define( }; } - return WARPSwimlaneDrag; + return MCTSwimlaneDrag; } ); diff --git a/platform/features/timeline/src/directives/WARPSwimlaneDrop.js b/platform/features/timeline/src/directives/MCTSwimlaneDrop.js similarity index 92% rename from platform/features/timeline/src/directives/WARPSwimlaneDrop.js rename to platform/features/timeline/src/directives/MCTSwimlaneDrop.js index 916fb82bd7..980191bac8 100644 --- a/platform/features/timeline/src/directives/WARPSwimlaneDrop.js +++ b/platform/features/timeline/src/directives/MCTSwimlaneDrop.js @@ -6,14 +6,14 @@ define( "use strict"; /** - * Defines the `warp-swimlane-drop` directive. When a drop occurs + * Defines the `mct-swimlane-drop` directive. When a drop occurs * on an element with this attribute, the swimlane targeted by the drop * (identified by the value of this attribute, as an Angular expression) * will receive the dropped domain object (at which point it can handle * the drop, typically by inserting/reordering.) * @param {DndService} dndService drag-and-drop service */ - function WARPSwimlaneDrop(dndService) { + function MCTSwimlaneDrop(dndService) { // Handle dragover events function dragOver(e, element, swimlane) { @@ -60,7 +60,7 @@ define( SwimlaneDragConstants.MCT_DRAG_TYPE ), draggedSwimlane = dndService.getData( - SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE + SwimlaneDragConstants.TIMELINE_SWIMLANE_DRAG_TYPE ); if (id) { @@ -76,7 +76,7 @@ define( function link(scope, element, attrs) { // Lookup swimlane by evaluating this attribute function swimlane() { - return scope.$eval(attrs.warpSwimlaneDrop); + return scope.$eval(attrs.mctSwimlaneDrop); } // Handle dragover element.on('dragover', function (e) { @@ -101,6 +101,6 @@ define( }; } - return WARPSwimlaneDrop; + return MCTSwimlaneDrop; } -); \ No newline at end of file +); From 3e0534c4c3fe329eb69648dfdd0a5072920a523c Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 13:49:46 -0700 Subject: [PATCH 07/16] [Timeline] Update namespacing in templates WTD-1239 --- .../res/templates/tabular-swimlane-cols-tree.html | 14 +++++++------- .../features/timeline/res/templates/timeline.html | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html b/platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html index de071ed4f8..98adf7868e 100644 --- a/platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html +++ b/platform/features/timeline/res/templates/tabular-swimlane-cols-tree.html @@ -1,12 +1,12 @@
-
+
+ > è - + mct-swimlane-drag="ngModel"> -
-
\ No newline at end of file +
+ diff --git a/platform/features/timeline/res/templates/timeline.html b/platform/features/timeline/res/templates/timeline.html index f0e29c577f..7a00345fef 100644 --- a/platform/features/timeline/res/templates/timeline.html +++ b/platform/features/timeline/res/templates/timeline.html @@ -133,9 +133,9 @@ 'drop-after': swimlane.highlightBottom() }" ng-click="selection.select(swimlane)" - warp-swimlane-drop="swimlane"> + mct-swimlane-drop="swimlane"> - Date: Fri, 23 Oct 2015 13:56:34 -0700 Subject: [PATCH 10/16] [Timelines] De-namespace timeline's DateTimeController --- platform/features/timeline/bundle.json | 4 ++-- .../features/timeline/res/templates/controls/datetime.html | 4 ++-- ...ARPDateTimeController.js => TimelineDateTimeController.js} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename platform/features/timeline/src/controllers/{WARPDateTimeController.js => TimelineDateTimeController.js} (100%) diff --git a/platform/features/timeline/bundle.json b/platform/features/timeline/bundle.json index 28080de247..d5ceca3c9f 100644 --- a/platform/features/timeline/bundle.json +++ b/platform/features/timeline/bundle.json @@ -278,8 +278,8 @@ "depends": [ "$scope", "resources[]" ] }, { - "key": "WARPDateTimeController", - "implementation": "controllers/WARPDateTimeController.js", + "key": "TimelineDateTimeController", + "implementation": "controllers/TimelineDateTimeController.js", "depends": [ "$scope" ] }, { diff --git a/platform/features/timeline/res/templates/controls/datetime.html b/platform/features/timeline/res/templates/controls/datetime.html index 65386ed3ef..a017c7fa8a 100644 --- a/platform/features/timeline/res/templates/controls/datetime.html +++ b/platform/features/timeline/res/templates/controls/datetime.html @@ -9,7 +9,7 @@ -
+
-
\ No newline at end of file +
diff --git a/platform/features/timeline/src/controllers/WARPDateTimeController.js b/platform/features/timeline/src/controllers/TimelineDateTimeController.js similarity index 100% rename from platform/features/timeline/src/controllers/WARPDateTimeController.js rename to platform/features/timeline/src/controllers/TimelineDateTimeController.js From 26db524f0e386b87d4e0f550e548288e538a27f4 Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 13:57:15 -0700 Subject: [PATCH 11/16] [Timelines] Rename bundle --- platform/features/timeline/bundle.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/features/timeline/bundle.json b/platform/features/timeline/bundle.json index d5ceca3c9f..4bd6c9dd7c 100644 --- a/platform/features/timeline/bundle.json +++ b/platform/features/timeline/bundle.json @@ -1,5 +1,5 @@ { - "name": "WARP Timeline", + "name": "Timelines", "description": "Resources, templates, CSS, and code for Timelines.", "resources": "res", "extensions": { From fffe07e7e631fda9e0bcbd234b68f00441047ecb Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 14:06:55 -0700 Subject: [PATCH 12/16] [Timeline] Remove namespacing from specs --- .../ActivityTimespanCapabilitySpec.js | 4 ++-- .../test/capabilities/CostCapabilitySpec.js | 8 ++++---- .../test/capabilities/GraphCapabilitySpec.js | 8 ++++---- .../TimelineTimespanCapabilitySpec.js | 4 ++-- .../capabilities/UtilizationCapabilitySpec.js | 6 +++--- ...Spec.js => TimelineDateTimeControllerSpec.js} | 6 +++--- .../drag/TimelineDragHandleFactorySpec.js | 4 ++-- .../swimlane/TimelineSwimlaneDecoratorSpec.js | 6 +++--- ...wimlaneDragSpec.js => MCTSwimlaneDragSpec.js} | 16 ++++++++-------- ...wimlaneDropSpec.js => MCTSwimlaneDropSpec.js} | 12 ++++++------ .../test/directives/SwimlaneDragConstantsSpec.js | 4 ++-- platform/features/timeline/test/suite.json | 6 +++--- 12 files changed, 42 insertions(+), 42 deletions(-) rename platform/features/timeline/test/controllers/{WARPDateTimeControllerSpec.js => TimelineDateTimeControllerSpec.js} (92%) rename platform/features/timeline/test/directives/{WARPSwimlaneDragSpec.js => MCTSwimlaneDragSpec.js} (82%) rename platform/features/timeline/test/directives/{WARPSwimlaneDropSpec.js => MCTSwimlaneDropSpec.js} (94%) diff --git a/platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js b/platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js index 9728fd2181..7874424975 100644 --- a/platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js +++ b/platform/features/timeline/test/capabilities/ActivityTimespanCapabilitySpec.js @@ -44,7 +44,7 @@ define( it("applies only to activity objects", function () { expect(ActivityTimespanCapability.appliesTo({ - type: 'warp.activity' + type: 'activity' })).toBeTruthy(); expect(ActivityTimespanCapability.appliesTo({ type: 'folder' @@ -68,4 +68,4 @@ define( }); }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/capabilities/CostCapabilitySpec.js b/platform/features/timeline/test/capabilities/CostCapabilitySpec.js index 1d9ddbc0e7..15edbf5df5 100644 --- a/platform/features/timeline/test/capabilities/CostCapabilitySpec.js +++ b/platform/features/timeline/test/capabilities/CostCapabilitySpec.js @@ -45,16 +45,16 @@ define( it("applies to subsystem modes", function () { expect(CostCapability.appliesTo({ - type: "warp.mode" + type: "mode" })).toBeTruthy(); expect(CostCapability.appliesTo({ - type: "warp.activity" + type: "activity" })).toBeFalsy(); expect(CostCapability.appliesTo({ - type: "warp.other" + type: "other" })).toBeFalsy(); }); }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/capabilities/GraphCapabilitySpec.js b/platform/features/timeline/test/capabilities/GraphCapabilitySpec.js index 0ec3c7f58c..977a5ee766 100644 --- a/platform/features/timeline/test/capabilities/GraphCapabilitySpec.js +++ b/platform/features/timeline/test/capabilities/GraphCapabilitySpec.js @@ -27,7 +27,7 @@ define( ); testModel = { - type: "warp.activity", + type: "activity", resources: { abc: 100, xyz: 42 @@ -45,7 +45,7 @@ define( it("is applicable to timelines", function () { expect(GraphCapability.appliesTo({ - type: "warp.timeline" + type: "timeline" })).toBeTruthy(); }); @@ -82,7 +82,7 @@ define( it("provides a battery graph for timelines with capacity", function () { var mockCallback = jasmine.createSpy('callback'); testModel.capacity = 1000; - testModel.type = "warp.timeline"; + testModel.type = "timeline"; mockDomainObject.useCapability.andReturn(asPromise([ { key: "power", start: 0, end: 15 } ])); @@ -95,4 +95,4 @@ define( }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js b/platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js index 2208ec0002..564f15c532 100644 --- a/platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js +++ b/platform/features/timeline/test/capabilities/TimelineTimespanCapabilitySpec.js @@ -84,7 +84,7 @@ define( it("applies only to timeline objects", function () { expect(TimelineTimespanCapability.appliesTo({ - type: 'warp.timeline' + type: 'timeline' })).toBeTruthy(); expect(TimelineTimespanCapability.appliesTo({ type: 'folder' @@ -112,4 +112,4 @@ define( }); }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js b/platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js index e9693d89db..2453910250 100644 --- a/platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js +++ b/platform/features/timeline/test/capabilities/UtilizationCapabilitySpec.js @@ -78,7 +78,7 @@ define( mockCallback = jasmine.createSpy('callback'); testModel = { - type: "warp.activity", + type: "activity", resources: { abc: 100, xyz: 42 @@ -107,7 +107,7 @@ define( it("is applicable to timelines", function () { expect(UtilizationCapability.appliesTo({ - type: "warp.timeline" + type: "timeline" })).toBeTruthy(); }); @@ -192,4 +192,4 @@ define( }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/controllers/WARPDateTimeControllerSpec.js b/platform/features/timeline/test/controllers/TimelineDateTimeControllerSpec.js similarity index 92% rename from platform/features/timeline/test/controllers/WARPDateTimeControllerSpec.js rename to platform/features/timeline/test/controllers/TimelineDateTimeControllerSpec.js index fadd6b5bf0..613e713c94 100644 --- a/platform/features/timeline/test/controllers/WARPDateTimeControllerSpec.js +++ b/platform/features/timeline/test/controllers/TimelineDateTimeControllerSpec.js @@ -2,8 +2,8 @@ /*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ define( - ["../../src/controllers/WARPDateTimeController"], - function (WARPDateTimeController) { + ["../../src/controllers/TimelineDateTimeController"], + function (TimelineDateTimeController) { "use strict"; describe("The date-time controller for timeline creation", function () { @@ -14,7 +14,7 @@ define( mockScope = jasmine.createSpyObj('$scope', ['$watchCollection']); mockScope.field = 'testField'; mockScope.ngModel = { testField: { timestamp: 0, epoch: "SET" } }; - controller = new WARPDateTimeController(mockScope); + controller = new TimelineDateTimeController(mockScope); }); diff --git a/platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js b/platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js index fc577ba782..e49a8c17c3 100644 --- a/platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js +++ b/platform/features/timeline/test/controllers/drag/TimelineDragHandleFactorySpec.js @@ -50,13 +50,13 @@ define( }); it("provides three handles for activities", function () { - testType = "warp.activity"; + testType = "activity"; expect(factory.handles(mockDomainObject).length) .toEqual(3); }); it("provides two handles for timelines", function () { - testType = "warp.timeline"; + testType = "timeline"; expect(factory.handles(mockDomainObject).length) .toEqual(2); }); diff --git a/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js index ba4b72dca2..2d49ad0e31 100644 --- a/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js +++ b/platform/features/timeline/test/controllers/swimlane/TimelineSwimlaneDecoratorSpec.js @@ -61,13 +61,13 @@ define( it("adds a 'modes' getter-setter to activities", function () { expect(mockSwimlane.modes).toEqual(jasmine.any(Function)); expect(mockCapabilities.type.instanceOf) - .toHaveBeenCalledWith('warp.activity'); + .toHaveBeenCalledWith('activity'); }); it("adds a 'link' getter-setter to activities", function () { expect(mockSwimlane.link).toEqual(jasmine.any(Function)); expect(mockCapabilities.type.instanceOf) - .toHaveBeenCalledWith('warp.activity'); + .toHaveBeenCalledWith('activity'); }); it("gets modes from the domain object model", function () { @@ -157,4 +157,4 @@ define( }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/directives/WARPSwimlaneDragSpec.js b/platform/features/timeline/test/directives/MCTSwimlaneDragSpec.js similarity index 82% rename from platform/features/timeline/test/directives/WARPSwimlaneDragSpec.js rename to platform/features/timeline/test/directives/MCTSwimlaneDragSpec.js index 360fff4b69..28f060dc6c 100644 --- a/platform/features/timeline/test/directives/WARPSwimlaneDragSpec.js +++ b/platform/features/timeline/test/directives/MCTSwimlaneDragSpec.js @@ -1,11 +1,11 @@ /*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ define( - ['../../src/directives/WARPSwimlaneDrag', '../../src/directives/SwimlaneDragConstants'], - function (WARPSwimlaneDrag, SwimlaneDragConstants) { + ['../../src/directives/MCTSwimlaneDrag', '../../src/directives/SwimlaneDragConstants'], + function (MCTSwimlaneDrag, SwimlaneDragConstants) { "use strict"; - describe("The warp-swimlane-drag directive", function () { + describe("The mct-swimlane-drag directive", function () { var mockDndService, mockScope, mockElement, @@ -26,14 +26,14 @@ define( ); mockScope = jasmine.createSpyObj('$scope', ['$eval']); mockElement = jasmine.createSpyObj('element', ['on']); - testAttrs = { warpSwimlaneDrag: "someTestExpr" }; + testAttrs = { mctSwimlaneDrag: "someTestExpr" }; // Simulate evaluation of expressions in scope mockScope.$eval.andCallFake(function (expr) { return scopeExprs[expr]; }); - directive = new WARPSwimlaneDrag(mockDndService); + directive = new MCTSwimlaneDrag(mockDndService); // Run the link function, then capture the event handlers // for testing. @@ -56,7 +56,7 @@ define( handlers.dragstart(); // Should have exposed the swimlane expect(mockDndService.setData).toHaveBeenCalledWith( - SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE, + SwimlaneDragConstants.TIMELINE_SWIMLANE_DRAG_TYPE, "some swimlane" ); }); @@ -68,9 +68,9 @@ define( handlers.dragend(); // Should have exposed the swimlane expect(mockDndService.removeData).toHaveBeenCalledWith( - SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE + SwimlaneDragConstants.TIMELINE_SWIMLANE_DRAG_TYPE ); }); }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/directives/WARPSwimlaneDropSpec.js b/platform/features/timeline/test/directives/MCTSwimlaneDropSpec.js similarity index 94% rename from platform/features/timeline/test/directives/WARPSwimlaneDropSpec.js rename to platform/features/timeline/test/directives/MCTSwimlaneDropSpec.js index f1f0858503..b8a9c1620c 100644 --- a/platform/features/timeline/test/directives/WARPSwimlaneDropSpec.js +++ b/platform/features/timeline/test/directives/MCTSwimlaneDropSpec.js @@ -1,14 +1,14 @@ /*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/ define( - ['../../src/directives/WARPSwimlaneDrop'], - function (WARPSwimlaneDrop) { + ['../../src/directives/MCTSwimlaneDrop'], + function (MCTSwimlaneDrop) { "use strict"; var TEST_HEIGHT = 100, TEST_TOP = 600; - describe("The warp-swimlane-drop directive", function () { + describe("The mct-swimlane-drop directive", function () { var mockDndService, mockScope, mockElement, @@ -36,7 +36,7 @@ define( ); mockScope = jasmine.createSpyObj('$scope', ['$eval']); mockElement = jasmine.createSpyObj('element', ['on']); - testAttrs = { warpSwimlaneDrop: "mockSwimlane" }; + testAttrs = { mctSwimlaneDrop: "mockSwimlane" }; mockSwimlane = jasmine.createSpyObj( "swimlane", [ "allowDropIn", "allowDropAfter", "drop", "highlight", "highlightBottom" ] @@ -72,7 +72,7 @@ define( testEvent.dataTransfer.getData.andReturn('abc'); mockDndService.getData.andReturn({ domainObject: 'someDomainObject' }); - directive = new WARPSwimlaneDrop(mockDndService); + directive = new MCTSwimlaneDrop(mockDndService); // Run the link function, then capture the event handlers // for testing. @@ -144,4 +144,4 @@ define( }); }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js b/platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js index b9fe7caf50..f84a9600d1 100644 --- a/platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js +++ b/platform/features/timeline/test/directives/SwimlaneDragConstantsSpec.js @@ -7,9 +7,9 @@ define( describe("Timeline swimlane drag constants", function () { it("define a custom type for swimlane drag-drop", function () { - expect(SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE) + expect(SwimlaneDragConstants.TIMELINE_SWIMLANE_DRAG_TYPE) .toEqual(jasmine.any(String)); }); }); } -); \ No newline at end of file +); diff --git a/platform/features/timeline/test/suite.json b/platform/features/timeline/test/suite.json index f04528a61c..e5028ef25d 100644 --- a/platform/features/timeline/test/suite.json +++ b/platform/features/timeline/test/suite.json @@ -21,7 +21,7 @@ "controllers/TimelineTableController", "controllers/TimelineTickController", "controllers/TimelineZoomController", - "controllers/WARPDateTimeController", + "controllers/TimelineDateTimeController", "controllers/drag/TimelineDragHandler", "controllers/drag/TimelineDragHandleFactory", @@ -43,8 +43,8 @@ "controllers/swimlane/TimelineSwimlanePopulator", "directives/SwimlaneDragConstants", - "directives/WARPSwimlaneDrag", - "directives/WARPSwimlaneDrop", + "directives/MCTSwimlaneDrag", + "directives/MCTSwimlaneDrop", "services/ObjectLoader" ] From a43c8f2ce8cc8da0edafdeb23bdb0f276ef28e79 Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 14:14:24 -0700 Subject: [PATCH 13/16] [Timeline] Disable timelines due to missing styles WTD-1239. --- bundles.json | 1 - 1 file changed, 1 deletion(-) diff --git a/bundles.json b/bundles.json index d0033ca754..2b5c5f862d 100644 --- a/bundles.json +++ b/bundles.json @@ -19,7 +19,6 @@ "platform/features/pages", "platform/features/plot", "platform/features/scrolling", - "platform/features/timeline", "platform/features/events", "platform/forms", "platform/identity", From ddb567aeb5bb25b8a27f33092420f1838c41f8a1 Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 15:41:59 -0700 Subject: [PATCH 14/16] [Clocks/Timers] Declare dependency of moment-duration-format ...to ensure that moment is loaded first, such that the plugin can correctly install itself. nasa/openmctweb#204 --- platform/features/clock/bundle.json | 5 +++++ .../features/clock/test/controllers/TimerFormatterSpec.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/platform/features/clock/bundle.json b/platform/features/clock/bundle.json index 0d1ac5611a..5468c46207 100644 --- a/platform/features/clock/bundle.json +++ b/platform/features/clock/bundle.json @@ -4,6 +4,11 @@ "configuration": { "paths": { "moment-duration-format": "moment-duration-format" + }, + "shim": { + "moment-duration-format": { + "deps": [ "moment" ] + } } }, "extensions": { diff --git a/platform/features/clock/test/controllers/TimerFormatterSpec.js b/platform/features/clock/test/controllers/TimerFormatterSpec.js index f0f4d954c3..e485811988 100644 --- a/platform/features/clock/test/controllers/TimerFormatterSpec.js +++ b/platform/features/clock/test/controllers/TimerFormatterSpec.js @@ -93,4 +93,4 @@ define( }); } -); \ No newline at end of file +); From 8acf01ebdfc0056f0355a4cfaa982af9b7a2ee33 Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Fri, 23 Oct 2015 16:03:57 -0700 Subject: [PATCH 15/16] [Tests] Declare deps for moment-duration-format ...in karma test runners. --- karma.conf.js | 1 - test-main.js | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/karma.conf.js b/karma.conf.js index 47aa3f059b..16175556ae 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -34,7 +34,6 @@ module.exports = function(config) { // List of files / patterns to load in the browser. // By default, files are also included in a script tag. files: [ - '**/moment*', {pattern: 'example/**/*.js', included: false}, {pattern: 'platform/**/*.js', included: false}, {pattern: 'warp/**/*.js', included: false}, diff --git a/test-main.js b/test-main.js index 46740a93b2..d3bbb1fcc3 100644 --- a/test-main.js +++ b/test-main.js @@ -44,7 +44,14 @@ require.config({ paths: { 'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min', - 'moment-duration-format': 'warp/clock/lib/moment-duration-format' + 'moment': 'platform/telemetry/lib/moment.min', + 'moment-duration-format': 'platform/features/clock/lib/moment-duration-format' + }, + + shim: { + 'moment-duration-format': { + deps: [ 'moment' ] + } }, // dynamically load all test files From 5274923c495be4d6819d64bd5ac088a9b32cbc1e Mon Sep 17 00:00:00 2001 From: Victor Woeltjen Date: Thu, 5 Nov 2015 12:45:51 -0800 Subject: [PATCH 16/16] [Licenses] Add license headers ...to sources for Clocks/Timers and Timelines, as noted in review nasa/openmctweb#207 --- .../features/clock/res/templates/clock.html | 21 +++++++++++++++++++ .../features/clock/res/templates/timer.html | 21 +++++++++++++++++++ .../src/actions/AbstractStartTimerAction.js | 21 +++++++++++++++++++ .../clock/src/actions/RestartTimerAction.js | 21 +++++++++++++++++++ .../clock/src/actions/StartTimerAction.js | 21 +++++++++++++++++++ .../clock/src/controllers/ClockController.js | 21 +++++++++++++++++++ .../src/controllers/RefreshingController.js | 21 +++++++++++++++++++ .../clock/src/controllers/TimerController.js | 21 +++++++++++++++++++ .../clock/src/controllers/TimerFormatter.js | 21 +++++++++++++++++++ .../clock/src/indicators/ClockIndicator.js | 21 +++++++++++++++++++ .../clock/src/services/TickerService.js | 21 +++++++++++++++++++ .../actions/AbstractStartTimerActionSpec.js | 21 +++++++++++++++++++ .../test/actions/RestartTimerActionSpec.js | 21 +++++++++++++++++++ .../test/actions/StartTimerActionSpec.js | 21 +++++++++++++++++++ .../test/controllers/ClockControllerSpec.js | 21 +++++++++++++++++++ .../controllers/RefreshingControllerSpec.js | 21 +++++++++++++++++++ .../test/controllers/TimerControllerSpec.js | 21 +++++++++++++++++++ .../test/controllers/TimerFormatterSpec.js | 21 +++++++++++++++++++ .../test/indicators/ClockIndicatorSpec.js | 21 +++++++++++++++++++ .../clock/test/services/TickerServiceSpec.js | 21 +++++++++++++++++++ .../res/templates/activity-gantt.html | 21 +++++++++++++++++++ .../res/templates/controls/datetime.html | 21 +++++++++++++++++++ .../timeline/res/templates/legend-item.html | 21 +++++++++++++++++++ .../res/templates/resource-graph-labels.html | 21 +++++++++++++++++++ .../res/templates/resource-graphs.html | 21 +++++++++++++++++++ .../templates/tabular-swimlane-cols-data.html | 21 +++++++++++++++++++ .../templates/tabular-swimlane-cols-tree.html | 21 +++++++++++++++++++ .../timeline/res/templates/ticks.html | 21 +++++++++++++++++++ .../timeline/res/templates/timeline.html | 21 +++++++++++++++++++ .../timeline/res/templates/values.html | 21 +++++++++++++++++++ .../timeline/src/TimelineConstants.js | 21 +++++++++++++++++++ .../timeline/src/TimelineFormatter.js | 21 +++++++++++++++++++ .../src/capabilities/ActivityTimespan.js | 21 +++++++++++++++++++ .../ActivityTimespanCapability.js | 21 +++++++++++++++++++ .../src/capabilities/ActivityUtilization.js | 21 +++++++++++++++++++ .../src/capabilities/CostCapability.js | 21 +++++++++++++++++++ .../src/capabilities/CumulativeGraph.js | 21 +++++++++++++++++++ .../src/capabilities/GraphCapability.js | 21 +++++++++++++++++++ .../src/capabilities/ResourceGraph.js | 21 +++++++++++++++++++ .../src/capabilities/TimelineTimespan.js | 21 +++++++++++++++++++ .../TimelineTimespanCapability.js | 21 +++++++++++++++++++ .../src/capabilities/TimelineUtilization.js | 21 +++++++++++++++++++ .../src/capabilities/UtilizationCapability.js | 21 +++++++++++++++++++ .../ActivityModeValuesController.js | 21 +++++++++++++++++++ .../src/controllers/TimelineController.js | 21 +++++++++++++++++++ .../controllers/TimelineDateTimeController.js | 21 +++++++++++++++++++ .../controllers/TimelineGanttController.js | 21 +++++++++++++++++++ .../controllers/TimelineGraphController.js | 21 +++++++++++++++++++ .../controllers/TimelineTableController.js | 21 +++++++++++++++++++ .../src/controllers/TimelineTickController.js | 21 +++++++++++++++++++ .../src/controllers/TimelineZoomController.js | 21 +++++++++++++++++++ .../drag/TimelineDragHandleFactory.js | 21 +++++++++++++++++++ .../controllers/drag/TimelineDragHandler.js | 21 +++++++++++++++++++ .../controllers/drag/TimelineDragPopulator.js | 21 +++++++++++++++++++ .../src/controllers/drag/TimelineEndHandle.js | 21 +++++++++++++++++++ .../controllers/drag/TimelineMoveHandle.js | 21 +++++++++++++++++++ .../controllers/drag/TimelineSnapHandler.js | 21 +++++++++++++++++++ .../controllers/drag/TimelineStartHandle.js | 21 +++++++++++++++++++ .../src/controllers/graph/TimelineGraph.js | 21 +++++++++++++++++++ .../graph/TimelineGraphPopulator.js | 21 +++++++++++++++++++ .../graph/TimelineGraphRenderer.js | 21 +++++++++++++++++++ .../swimlane/TimelineColorAssigner.js | 21 +++++++++++++++++++ .../src/controllers/swimlane/TimelineProxy.js | 21 +++++++++++++++++++ .../controllers/swimlane/TimelineSwimlane.js | 21 +++++++++++++++++++ .../swimlane/TimelineSwimlaneDecorator.js | 21 +++++++++++++++++++ .../swimlane/TimelineSwimlaneDropHandler.js | 21 +++++++++++++++++++ .../swimlane/TimelineSwimlanePopulator.js | 21 +++++++++++++++++++ .../src/directives/MCTSwimlaneDrag.js | 21 +++++++++++++++++++ .../src/directives/MCTSwimlaneDrop.js | 21 +++++++++++++++++++ .../src/directives/SwimlaneDragConstants.js | 21 +++++++++++++++++++ .../timeline/src/services/ObjectLoader.js | 21 +++++++++++++++++++ .../timeline/test/TimelineConstantsSpec.js | 21 +++++++++++++++++++ .../timeline/test/TimelineFormatterSpec.js | 21 +++++++++++++++++++ .../ActivityTimespanCapabilitySpec.js | 21 +++++++++++++++++++ .../test/capabilities/ActivityTimespanSpec.js | 21 +++++++++++++++++++ .../capabilities/ActivityUtilizationSpec.js | 21 +++++++++++++++++++ .../test/capabilities/CostCapabilitySpec.js | 21 +++++++++++++++++++ .../test/capabilities/CumulativeGraphSpec.js | 21 +++++++++++++++++++ .../test/capabilities/GraphCapabilitySpec.js | 21 +++++++++++++++++++ .../test/capabilities/ResourceGraphSpec.js | 21 +++++++++++++++++++ .../TimelineTimespanCapabilitySpec.js | 21 +++++++++++++++++++ .../test/capabilities/TimelineTimespanSpec.js | 21 +++++++++++++++++++ .../capabilities/TimelineUtilizationSpec.js | 21 +++++++++++++++++++ .../capabilities/UtilizationCapabilitySpec.js | 21 +++++++++++++++++++ .../ActivityModeValuesControllerSpec.js | 21 +++++++++++++++++++ .../controllers/TimelineControllerSpec.js | 21 +++++++++++++++++++ .../TimelineDateTimeControllerSpec.js | 21 +++++++++++++++++++ .../TimelineGanttControllerSpec.js | 21 +++++++++++++++++++ .../TimelineGraphControllerSpec.js | 21 +++++++++++++++++++ .../TimelineTableControllerSpec.js | 21 +++++++++++++++++++ .../controllers/TimelineTickControllerSpec.js | 21 +++++++++++++++++++ .../controllers/TimelineZoomControllerSpec.js | 21 +++++++++++++++++++ .../drag/TimelineDragHandleFactorySpec.js | 21 +++++++++++++++++++ .../drag/TimelineDragHandlerSpec.js | 21 +++++++++++++++++++ .../drag/TimelineDragPopulatorSpec.js | 21 +++++++++++++++++++ .../controllers/drag/TimelineEndHandleSpec.js | 21 +++++++++++++++++++ .../drag/TimelineMoveHandleSpec.js | 21 +++++++++++++++++++ .../drag/TimelineSnapHandlerSpec.js | 21 +++++++++++++++++++ .../drag/TimelineStartHandleSpec.js | 21 +++++++++++++++++++ .../graph/TimelineGraphPopulatorSpec.js | 21 +++++++++++++++++++ .../graph/TimelineGraphRendererSpec.js | 21 +++++++++++++++++++ .../controllers/graph/TimelineGraphSpec.js | 21 +++++++++++++++++++ .../swimlane/TimelineColorAssignerSpec.js | 21 +++++++++++++++++++ .../controllers/swimlane/TimelineProxySpec.js | 21 +++++++++++++++++++ .../swimlane/TimelineSwimlaneDecoratorSpec.js | 21 +++++++++++++++++++ .../TimelineSwimlaneDropHandlerSpec.js | 21 +++++++++++++++++++ .../swimlane/TimelineSwimlanePopulatorSpec.js | 21 +++++++++++++++++++ .../swimlane/TimelineSwimlaneSpec.js | 21 +++++++++++++++++++ .../test/directives/MCTSwimlaneDragSpec.js | 21 +++++++++++++++++++ .../test/directives/MCTSwimlaneDropSpec.js | 21 +++++++++++++++++++ .../directives/SwimlaneDragConstantsSpec.js | 21 +++++++++++++++++++ .../test/services/ObjectLoaderSpec.js | 21 +++++++++++++++++++ 112 files changed, 2352 insertions(+) diff --git a/platform/features/clock/res/templates/clock.html b/platform/features/clock/res/templates/clock.html index 69c690e69d..d3df62a095 100644 --- a/platform/features/clock/res/templates/clock.html +++ b/platform/features/clock/res/templates/clock.html @@ -1,3 +1,24 @@ +
diff --git a/platform/features/clock/res/templates/timer.html b/platform/features/clock/res/templates/timer.html index 36bfe3f972..ebd593435f 100644 --- a/platform/features/clock/res/templates/timer.html +++ b/platform/features/clock/res/templates/timer.html @@ -1,3 +1,24 @@ +
diff --git a/platform/features/timeline/res/templates/legend-item.html b/platform/features/timeline/res/templates/legend-item.html index 9908e701ba..8ea8df9c06 100644 --- a/platform/features/timeline/res/templates/legend-item.html +++ b/platform/features/timeline/res/templates/legend-item.html @@ -1,3 +1,24 @@ +
{{parameters.title}}
diff --git a/platform/features/timeline/res/templates/resource-graphs.html b/platform/features/timeline/res/templates/resource-graphs.html index 317d4976ac..b96973c80f 100644 --- a/platform/features/timeline/res/templates/resource-graphs.html +++ b/platform/features/timeline/res/templates/resource-graphs.html @@ -1,3 +1,24 @@ +
diff --git a/platform/features/timeline/res/templates/tabular-swimlane-cols-data.html b/platform/features/timeline/res/templates/tabular-swimlane-cols-data.html index 63c618974f..273ffb8734 100644 --- a/platform/features/timeline/res/templates/tabular-swimlane-cols-data.html +++ b/platform/features/timeline/res/templates/tabular-swimlane-cols-data.html @@ -1,3 +1,24 @@ +