diff --git a/Procfile b/Procfile index aa6094edfe..1e13b4ae05 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: node app.js --port $PORT --include example/localstorage \ No newline at end of file +web: node app.js --port $PORT diff --git a/bundles.json b/bundles.json index a0f7edd7a8..2751dbd3d8 100644 --- a/bundles.json +++ b/bundles.json @@ -22,7 +22,7 @@ "platform/forms", "platform/identity", "platform/persistence/local", - "platform/persistence/queue", + "platform/persistence/elastic", "platform/policy", "platform/entanglement", "platform/search", diff --git a/docs/gendocs.js b/docs/gendocs.js index 329a9b607f..cd61b9a9bf 100644 --- a/docs/gendocs.js +++ b/docs/gendocs.js @@ -30,7 +30,8 @@ var CONSTANTS = { DIAGRAM_WIDTH: 800, DIAGRAM_HEIGHT: 500 - }; + }, + TOC_HEAD = "# Table of Contents"; GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined (function () { @@ -44,6 +45,7 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define split = require("split"), stream = require("stream"), nomnoml = require('nomnoml'), + toc = require("markdown-toc"), Canvas = require('canvas'), options = require("minimist")(process.argv.slice(2)); @@ -110,6 +112,9 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define done(); }; transform._flush = function (done) { + // Prepend table of contents + markdown = + [ TOC_HEAD, toc(markdown).content, "", markdown ].join("\n"); this.push("\n"); this.push(marked(markdown)); this.push("\n\n"); @@ -133,8 +138,8 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define customRenderer.link = function (href, title, text) { // ...but only if they look like relative paths return (href || "").indexOf(":") === -1 && href[0] !== "/" ? - renderer.link(href.replace(/\.md/, ".html"), title, text) : - renderer.link.apply(renderer, arguments); + renderer.link(href.replace(/\.md/, ".html"), title, text) : + renderer.link.apply(renderer, arguments); }; return customRenderer; } diff --git a/example/generator/src/SinewaveLimitCapability.js b/example/generator/src/SinewaveLimitCapability.js index 30d222b0c7..ac4f4718a2 100644 --- a/example/generator/src/SinewaveLimitCapability.js +++ b/example/generator/src/SinewaveLimitCapability.js @@ -30,25 +30,25 @@ define( YELLOW = 0.5, LIMITS = { rh: { - cssClass: "s-limit-upr-red", + cssClass: "s-limit-upr s-limit-red", low: RED, high: Number.POSITIVE_INFINITY, name: "Red High" }, rl: { - cssClass: "s-limit-lwr-red", + cssClass: "s-limit-lwr s-limit-red", high: -RED, low: Number.NEGATIVE_INFINITY, name: "Red Low" }, yh: { - cssClass: "s-limit-upr-yellow", + cssClass: "s-limit-upr s-limit-yellow", low: YELLOW, high: RED, name: "Yellow High" }, yl: { - cssClass: "s-limit-lwr-yellow", + cssClass: "s-limit-lwr s-limit-yellow", low: -RED, high: -YELLOW, name: "Yellow Low" diff --git a/package.json b/package.json index a1ac01f437..c96642129c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "split": "^1.0.0", "mkdirp": "^0.5.1", "nomnoml": "^0.0.3", - "canvas": "^1.2.7" + "canvas": "^1.2.7", + "markdown-toc": "^0.11.7" }, "scripts": { "start": "node app.js", diff --git a/platform/commonUI/browse/bundle.json b/platform/commonUI/browse/bundle.json index 34448dd0d7..9e7772c66d 100644 --- a/platform/commonUI/browse/bundle.json +++ b/platform/commonUI/browse/bundle.json @@ -1,4 +1,9 @@ { + "configuration": { + "paths": { + "uuid": "uuid" + } + }, "extensions": { "routes": [ { diff --git a/platform/commonUI/browse/src/creation/CreationService.js b/platform/commonUI/browse/src/creation/CreationService.js index 667863ef20..2b059724b3 100644 --- a/platform/commonUI/browse/src/creation/CreationService.js +++ b/platform/commonUI/browse/src/creation/CreationService.js @@ -25,7 +25,7 @@ * Module defining CreateService. Created by vwoeltje on 11/10/14. */ define( - ["../../lib/uuid"], + ["uuid"], function (uuid) { "use strict"; diff --git a/platform/commonUI/general/res/sass/_constants.scss b/platform/commonUI/general/res/sass/_constants.scss index 9ba1c962c1..e5a7c90eb0 100644 --- a/platform/commonUI/general/res/sass/_constants.scss +++ b/platform/commonUI/general/res/sass/_constants.scss @@ -110,3 +110,8 @@ $dirImgs: $dirCommonRes + 'images/'; /************************** TIMINGS */ $controlFadeMs: 100ms; + +/************************** LIMITS */ +$glyphLimit: '\e603'; +$glyphLimitUpr: '\0000eb'; +$glyphLimitLwr: '\0000ee'; diff --git a/platform/commonUI/general/res/sass/_limits.scss b/platform/commonUI/general/res/sass/_limits.scss index 12411554cd..51f9a1c863 100644 --- a/platform/commonUI/general/res/sass/_limits.scss +++ b/platform/commonUI/general/res/sass/_limits.scss @@ -1,26 +1,39 @@ -@mixin limit($bg, $ic, $glyph) { - background: $bg !important; - //color: $fg !important; - &:before { - //@include pulse(1000ms); - color: $ic; - content: $glyph; - } +@mixin limitGlyph($iconColor, $glyph: $glyphLimit) { + &:before { + color: $iconColor; + content: $glyph; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: $interiorMarginSm; + } + } -[class*="s-limit"] { - //white-space: nowrap; - &:before { - display: inline-block; - font-family: symbolsfont; - font-size: 0.75em; - font-style: normal !important; - margin-right: $interiorMarginSm; - vertical-align: middle; - } +.s-limit-red { background: $colorLimitRedBg !important; } +.s-limit-yellow { background: $colorLimitYellowBg !important; } + +// Handle limit when applied to a tr +tr[class*="s-limit"] { + &.s-limit-red td:first-child { + @include limitGlyph($colorLimitRedIc); + } + &.s-limit-yellow td:first-child { + @include limitGlyph($colorLimitYellowIc); + } + &.s-limit-upr td:first-child:before { content:$glyphLimitUpr; } + &.s-limit-lwr td:first-child:before { content:$glyphLimitLwr; } } -.s-limit-upr-red { @include limit($colorLimitRedBg, $colorLimitRedIc, "\0000eb"); }; -.s-limit-upr-yellow { @include limit($colorLimitYellowBg, $colorLimitYellowIc, "\0000ed"); }; -.s-limit-lwr-yellow { @include limit($colorLimitYellowBg, $colorLimitYellowIc, "\0000ec"); }; -.s-limit-lwr-red { @include limit($colorLimitRedBg, $colorLimitRedIc, "\0000ee"); }; \ No newline at end of file +// Handle limit when applied directly to a non-tr element +// Assume this is applied to the element that displays the limit value +:not(tr)[class*="s-limit"] { + &.s-limit-red { + @include limitGlyph($colorLimitRedIc); + } + &.s-limit-yellow { + @include limitGlyph($colorLimitYellowIc); + } + &.s-limit-upr:before { content:$glyphLimitUpr; } + &.s-limit-lwr:before { content:$glyphLimitLwr; } +} \ No newline at end of file diff --git a/platform/commonUI/general/res/templates/controls/time-controller.html b/platform/commonUI/general/res/templates/controls/time-controller.html index ff142ea189..300e56c381 100644 --- a/platform/commonUI/general/res/templates/controls/time-controller.html +++ b/platform/commonUI/general/res/templates/controls/time-controller.html @@ -19,84 +19,90 @@ this source code distribution or the Licensing information page available at runtime from the About dialog for additional information. --> -
-
- C - - - - {{startOuterText}} - - -
- - -
-
-
-
+
+ C + + + + + + + +
+ + +
+
+
+
- to + to - - - - {{endOuterText}} - - -
- - -
-
-
  -
-
+ + + + + + + + +
+ + +
+
+
  +
+
-
-
-
-
-
{{startInnerText}}
-
-
-
{{endInnerText}}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
{{startInnerText}}
+
+
+
{{endInnerText}}
+
+
+
+
+
+
+
+
+
-
-
-
- {{tick}} -
-
-
-
\ No newline at end of file +
+
+
+ {{tick}} +
+
+
+ diff --git a/platform/commonUI/general/src/controllers/TimeRangeController.js b/platform/commonUI/general/src/controllers/TimeRangeController.js index 55bddd712f..d4fb21be08 100644 --- a/platform/commonUI/general/src/controllers/TimeRangeController.js +++ b/platform/commonUI/general/src/controllers/TimeRangeController.js @@ -26,9 +26,8 @@ define( function (moment) { "use strict"; - var - DATE_FORMAT = "YYYY-MM-DD HH:mm:ss", - TICK_SPACING_PX = 150; + var DATE_FORMAT = "YYYY-MM-DD HH:mm:ss", + TICK_SPACING_PX = 150; /** * @memberof platform/commonUI/general @@ -44,6 +43,15 @@ define( return moment.utc(ts).format(DATE_FORMAT); } + function parseTimestamp(text) { + var m = moment.utc(text, DATE_FORMAT); + if (m.isValid()) { + return m.valueOf(); + } else { + throw new Error("Could not parse " + text); + } + } + // From 0.0-1.0 to "0%"-"1%" function toPercent(p) { return (100 * p) + "%"; @@ -93,6 +101,25 @@ define( return { start: bounds.start, end: bounds.end }; } + function updateBoundsTextForProperty(ngModel, property) { + try { + if (!$scope.boundsModel[property] || + parseTimestamp($scope.boundsModel[property]) !== + ngModel.outer[property]) { + $scope.boundsModel[property] = + formatTimestamp(ngModel.outer[property]); + } + } catch (e) { + // User-entered text is invalid, so leave it be + // until they fix it. + } + } + + function updateBoundsText(ngModel) { + updateBoundsTextForProperty(ngModel, 'start'); + updateBoundsTextForProperty(ngModel, 'end'); + } + function updateViewFromModel(ngModel) { var t = now(); @@ -101,8 +128,7 @@ define( ngModel.inner = ngModel.inner || copyBounds(ngModel.outer); // First, dates for the date pickers for outer bounds - $scope.startOuterDate = new Date(ngModel.outer.start); - $scope.endOuterDate = new Date(ngModel.outer.end); + updateBoundsText(ngModel); // Then various updates for the inner span updateViewForInnerSpanFromModel(ngModel); @@ -178,6 +204,8 @@ define( function updateOuterStart(t) { var ngModel = $scope.ngModel; + ngModel.outer.start = t; + ngModel.outer.end = Math.max( ngModel.outer.start + outerMinimumSpan, ngModel.outer.end @@ -190,14 +218,15 @@ define( ngModel.inner.end ); - $scope.startOuterText = formatTimestamp(t); - updateViewForInnerSpanFromModel(ngModel); + updateTicks(); } function updateOuterEnd(t) { var ngModel = $scope.ngModel; + ngModel.outer.end = t; + ngModel.outer.start = Math.min( ngModel.outer.end - outerMinimumSpan, ngModel.outer.start @@ -210,9 +239,40 @@ define( ngModel.inner.start ); - $scope.endOuterText = formatTimestamp(t); - updateViewForInnerSpanFromModel(ngModel); + updateTicks(); + } + + function updateStartFromText(value) { + try { + updateOuterStart(parseTimestamp(value)); + updateBoundsTextForProperty($scope.ngModel, 'end'); + $scope.boundsModel.startValid = true; + } catch (e) { + $scope.boundsModel.startValid = false; + return; + } + } + + function updateEndFromText(value) { + try { + updateOuterEnd(parseTimestamp(value)); + updateBoundsTextForProperty($scope.ngModel, 'start'); + $scope.boundsModel.endValid = true; + } catch (e) { + $scope.boundsModel.endValid = false; + return; + } + } + + function updateStartFromPicker(value) { + updateOuterStart(value); + updateBoundsText($scope.ngModel); + } + + function updateEndFromPicker(value) { + updateOuterEnd(value); + updateBoundsText($scope.ngModel); } $scope.startLeftDrag = startLeftDrag; @@ -224,14 +284,17 @@ define( $scope.state = false; $scope.ticks = []; + $scope.boundsModel = {}; // Initialize scope to defaults updateViewFromModel($scope.ngModel); $scope.$watchCollection("ngModel", updateViewFromModel); $scope.$watch("spanWidth", updateSpanWidth); - $scope.$watch("ngModel.outer.start", updateOuterStart); - $scope.$watch("ngModel.outer.end", updateOuterEnd); + $scope.$watch("ngModel.outer.start", updateStartFromPicker); + $scope.$watch("ngModel.outer.end", updateEndFromPicker); + $scope.$watch("boundsModel.start", updateStartFromText); + $scope.$watch("boundsModel.end", updateEndFromText); } return TimeConductorController; diff --git a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js index 9d7a6a9f52..91d3ecb9db 100644 --- a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js +++ b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js @@ -22,8 +22,8 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../../src/controllers/TimeRangeController"], - function (TimeRangeController) { + ["../../src/controllers/TimeRangeController", "moment"], + function (TimeRangeController, moment) { "use strict"; var SEC = 1000, @@ -166,8 +166,72 @@ define( expect(mockScope.ngModel.inner.end) .toBeGreaterThan(mockScope.ngModel.inner.start); }); + + describe("by typing", function () { + it("updates models", function () { + var newStart = "1977-05-25 17:30:00", + newEnd = "2015-12-18 03:30:00"; + + mockScope.boundsModel.start = newStart; + fireWatch("boundsModel.start", newStart); + expect(mockScope.ngModel.outer.start) + .toEqual(moment.utc(newStart).valueOf()); + expect(mockScope.boundsModel.startValid) + .toBeTruthy(); + + mockScope.boundsModel.end = newEnd; + fireWatch("boundsModel.end", newEnd); + expect(mockScope.ngModel.outer.end) + .toEqual(moment.utc(newEnd).valueOf()); + expect(mockScope.boundsModel.endValid) + .toBeTruthy(); + }); + + it("displays error state", function () { + var newStart = "Not a date", + newEnd = "Definitely not a date", + oldStart = mockScope.ngModel.outer.start, + oldEnd = mockScope.ngModel.outer.end; + + mockScope.boundsModel.start = newStart; + fireWatch("boundsModel.start", newStart); + expect(mockScope.ngModel.outer.start) + .toEqual(oldStart); + expect(mockScope.boundsModel.startValid) + .toBeFalsy(); + + mockScope.boundsModel.end = newEnd; + fireWatch("boundsModel.end", newEnd); + expect(mockScope.ngModel.outer.end) + .toEqual(oldEnd); + expect(mockScope.boundsModel.endValid) + .toBeFalsy(); + }); + + it("does not modify user input", function () { + // Don't want the controller "fixing" bad or + // irregularly-formatted input out from under + // the user's fingertips. + var newStart = "Not a date", + newEnd = "2015-3-3 01:02:04", + oldStart = mockScope.ngModel.outer.start, + oldEnd = mockScope.ngModel.outer.end; + + mockScope.boundsModel.start = newStart; + fireWatch("boundsModel.start", newStart); + expect(mockScope.boundsModel.start) + .toEqual(newStart); + + mockScope.boundsModel.end = newEnd; + fireWatch("boundsModel.end", newEnd); + expect(mockScope.boundsModel.end) + .toEqual(newEnd); + }); + }); }); + + }); } ); diff --git a/platform/commonUI/themes/espresso/res/css/theme-espresso.css b/platform/commonUI/themes/espresso/res/css/theme-espresso.css index 988b759608..4679297105 100644 --- a/platform/commonUI/themes/espresso/res/css/theme-espresso.css +++ b/platform/commonUI/themes/espresso/res/css/theme-espresso.css @@ -147,6 +147,7 @@ article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, /************************** CONTROLS */ /************************** PATHS */ /************************** TIMINGS */ +/************************** LIMITS */ /***************************************************************************** * Open MCT Web, Copyright (c) 2014-2015, United States Government * as represented by the Administrator of the National Aeronautics and Space @@ -667,45 +668,58 @@ mct-container { content: "!"; } /* line 13, ../../../../general/res/sass/_limits.scss */ -[class*="s-limit"]:before { - display: inline-block; +.s-limit-red { + background: rgba(255, 0, 0, 0.3) !important; } + +/* line 14, ../../../../general/res/sass/_limits.scss */ +.s-limit-yellow { + background: rgba(255, 170, 0, 0.3) !important; } + +/* line 2, ../../../../general/res/sass/_limits.scss */ +tr[class*="s-limit"].s-limit-red td:first-child:before { + color: red; + content: ""; font-family: symbolsfont; - font-size: 0.75em; - font-style: normal !important; - margin-right: 3px; - vertical-align: middle; } - -/* line 23, ../../../../general/res/sass/_limits.scss */ -.s-limit-upr-red { - background: rgba(255, 0, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-upr-red:before { - color: red; - content: "ë"; } - + font-size: 0.8em; + display: inline; + margin-right: 3px; } +/* line 2, ../../../../general/res/sass/_limits.scss */ +tr[class*="s-limit"].s-limit-yellow td:first-child:before { + color: #ffaa00; + content: ""; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: 3px; } /* line 24, ../../../../general/res/sass/_limits.scss */ -.s-limit-upr-yellow { - background: rgba(255, 170, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-upr-yellow:before { - color: #ffaa00; - content: "í"; } - +tr[class*="s-limit"].s-limit-upr td:first-child:before { + content: "ë"; } /* line 25, ../../../../general/res/sass/_limits.scss */ -.s-limit-lwr-yellow { - background: rgba(255, 170, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-lwr-yellow:before { - color: #ffaa00; - content: "ì"; } +tr[class*="s-limit"].s-limit-lwr td:first-child:before { + content: "î"; } -/* line 26, ../../../../general/res/sass/_limits.scss */ -.s-limit-lwr-red { - background: rgba(255, 0, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-lwr-red:before { - color: red; - content: "î"; } +/* line 2, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-red:before { + color: red; + content: ""; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: 3px; } +/* line 2, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-yellow:before { + color: #ffaa00; + content: ""; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: 3px; } +/* line 37, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-upr:before { + content: "ë"; } +/* line 38, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-lwr:before { + content: "î"; } /* line 1, ../../../../general/res/sass/_data-status.scss */ .s-stale { @@ -4275,13 +4289,45 @@ span.req { /* line 65, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-hidden .pane.left.treeview { - right: 100% !important; - width: auto !important; - overflow-y: hidden; - overflow-x: hidden; } + -moz-transition-property: opacity; + -o-transition-property: opacity; + -webkit-transition-property: opacity; + transition-property: opacity; + -moz-transition-duration: 150ms; + -o-transition-duration: 150ms; + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + -moz-transition-timing-function: ease-in-out; + -o-transition-timing-function: ease-in-out; + -webkit-transition-timing-function: ease-in-out; + transition-timing-function: ease-in-out; + -moz-transition-delay: 0; + -o-transition-delay: 0; + -webkit-transition-delay: 0; + transition-delay: 0; + opacity: 0 !important; } + /* line 73, ../../../../general/res/sass/mobile/_layout.scss */ + .pane-tree-hidden .pane.right.items { + left: 0 !important; } - /* line 82, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 87, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.left.treeview { + -moz-transition-property: opacity; + -o-transition-property: opacity; + -webkit-transition-property: opacity; + transition-property: opacity; + -moz-transition-duration: 250ms; + -o-transition-duration: 250ms; + -webkit-transition-duration: 250ms; + transition-duration: 250ms; + -moz-transition-timing-function: ease-in-out; + -o-transition-timing-function: ease-in-out; + -webkit-transition-timing-function: ease-in-out; + transition-timing-function: ease-in-out; + -moz-transition-delay: 250ms; + -o-transition-delay: 250ms; + -webkit-transition-delay: 250ms; + transition-delay: 250ms; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuMCIgeTE9IjAuNSIgeDI9IjEuMCIgeTI9IjAuNSI+PHN0b3Agb2Zmc2V0PSI5OCUiIHN0b3AtY29sb3I9IiMwMDAwMDAiIHN0b3Atb3BhY2l0eT0iMC4wIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMDAwMDAwIiBzdG9wLW9wYWNpdHk9IjAuMyIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=='); background-size: 100%; background-image: -moz-linear-gradient(0deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%); @@ -4289,52 +4335,52 @@ span.req { background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%); right: auto !important; width: 40% !important; } - /* line 88, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 94, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.right.items { left: 40% !important; } - /* line 93, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 99, ../../../../general/res/sass/mobile/_layout.scss */ .toggle-tree { color: #0099cc !important; font-size: 110%; position: absolute; top: 12px; left: 10px; } - /* line 99, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 105, ../../../../general/res/sass/mobile/_layout.scss */ .toggle-tree:after { content: 'm' !important; font-family: symbolsfont; } - /* line 105, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 111, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar { left: 30px !important; } - /* line 108, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 114, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar .context-available { opacity: 1 !important; } - /* line 111, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 117, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar .view-switcher { margin-right: 0 !important; } - /* line 113, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 119, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar .view-switcher .title-label { display: none; } - /* line 120, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 126, ../../../../general/res/sass/mobile/_layout.scss */ .tree-holder { overflow-x: hidden !important; } - /* line 124, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 130, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-disable-select { -moz-user-select: -moz-none; -ms-user-select: none; -webkit-user-select: none; user-select: none; } - /* line 129, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 135, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-hide, .mobile-hide-important { display: none !important; } - /* line 134, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 140, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-back-hide { pointer-events: none; -moz-transition-property: opacity; @@ -4355,7 +4401,7 @@ span.req { transition-delay: 0; opacity: 0; } - /* line 139, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 145, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-back-unhide { pointer-events: all; -moz-transition-property: opacity; @@ -4376,21 +4422,21 @@ span.req { transition-delay: 0; opacity: 1; } } @media screen and (orientation: portrait) and (max-width: 514px) and (max-height: 740px) and (max-device-width: 799px) and (max-device-height: 1024px) { - /* line 148, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 154, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.left.treeview { width: 90% !important; } - /* line 151, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 157, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.right.items { left: 0 !important; -moz-transform: translateX(90%); -ms-transform: translateX(90%); -webkit-transform: translateX(90%); transform: translateX(90%); } - /* line 154, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 160, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.right.items #content-area { opacity: 0; } } @media screen and (min-device-width: 800px) and (min-device-height: 1025px), screen and (min-device-width: 1025px) and (min-device-height: 800px) { - /* line 162, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 168, ../../../../general/res/sass/mobile/_layout.scss */ .desktop-hide { display: none; } } /***************************************************************************** diff --git a/platform/commonUI/themes/snow/res/css/theme-snow.css b/platform/commonUI/themes/snow/res/css/theme-snow.css index 4956c9fcde..ed04d1e5b2 100644 --- a/platform/commonUI/themes/snow/res/css/theme-snow.css +++ b/platform/commonUI/themes/snow/res/css/theme-snow.css @@ -147,6 +147,7 @@ article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, /************************** CONTROLS */ /************************** PATHS */ /************************** TIMINGS */ +/************************** LIMITS */ /***************************************************************************** * Open MCT Web, Copyright (c) 2014-2015, United States Government * as represented by the Administrator of the National Aeronautics and Space @@ -667,45 +668,58 @@ mct-container { content: "!"; } /* line 13, ../../../../general/res/sass/_limits.scss */ -[class*="s-limit"]:before { - display: inline-block; +.s-limit-red { + background: rgba(255, 0, 0, 0.3) !important; } + +/* line 14, ../../../../general/res/sass/_limits.scss */ +.s-limit-yellow { + background: rgba(255, 170, 0, 0.3) !important; } + +/* line 2, ../../../../general/res/sass/_limits.scss */ +tr[class*="s-limit"].s-limit-red td:first-child:before { + color: red; + content: ""; font-family: symbolsfont; - font-size: 0.75em; - font-style: normal !important; - margin-right: 3px; - vertical-align: middle; } - -/* line 23, ../../../../general/res/sass/_limits.scss */ -.s-limit-upr-red { - background: rgba(255, 0, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-upr-red:before { - color: red; - content: "ë"; } - + font-size: 0.8em; + display: inline; + margin-right: 3px; } +/* line 2, ../../../../general/res/sass/_limits.scss */ +tr[class*="s-limit"].s-limit-yellow td:first-child:before { + color: #ffaa00; + content: ""; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: 3px; } /* line 24, ../../../../general/res/sass/_limits.scss */ -.s-limit-upr-yellow { - background: rgba(255, 170, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-upr-yellow:before { - color: #ffaa00; - content: "í"; } - +tr[class*="s-limit"].s-limit-upr td:first-child:before { + content: "ë"; } /* line 25, ../../../../general/res/sass/_limits.scss */ -.s-limit-lwr-yellow { - background: rgba(255, 170, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-lwr-yellow:before { - color: #ffaa00; - content: "ì"; } +tr[class*="s-limit"].s-limit-lwr td:first-child:before { + content: "î"; } -/* line 26, ../../../../general/res/sass/_limits.scss */ -.s-limit-lwr-red { - background: rgba(255, 0, 0, 0.3) !important; } - /* line 4, ../../../../general/res/sass/_limits.scss */ - .s-limit-lwr-red:before { - color: red; - content: "î"; } +/* line 2, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-red:before { + color: red; + content: ""; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: 3px; } +/* line 2, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-yellow:before { + color: #ffaa00; + content: ""; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: 3px; } +/* line 37, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-upr:before { + content: "ë"; } +/* line 38, ../../../../general/res/sass/_limits.scss */ +:not(tr)[class*="s-limit"].s-limit-lwr:before { + content: "î"; } /* line 1, ../../../../general/res/sass/_data-status.scss */ .s-stale { @@ -4216,13 +4230,45 @@ span.req { /* line 65, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-hidden .pane.left.treeview { - right: 100% !important; - width: auto !important; - overflow-y: hidden; - overflow-x: hidden; } + -moz-transition-property: opacity; + -o-transition-property: opacity; + -webkit-transition-property: opacity; + transition-property: opacity; + -moz-transition-duration: 150ms; + -o-transition-duration: 150ms; + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + -moz-transition-timing-function: ease-in-out; + -o-transition-timing-function: ease-in-out; + -webkit-transition-timing-function: ease-in-out; + transition-timing-function: ease-in-out; + -moz-transition-delay: 0; + -o-transition-delay: 0; + -webkit-transition-delay: 0; + transition-delay: 0; + opacity: 0 !important; } + /* line 73, ../../../../general/res/sass/mobile/_layout.scss */ + .pane-tree-hidden .pane.right.items { + left: 0 !important; } - /* line 82, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 87, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.left.treeview { + -moz-transition-property: opacity; + -o-transition-property: opacity; + -webkit-transition-property: opacity; + transition-property: opacity; + -moz-transition-duration: 250ms; + -o-transition-duration: 250ms; + -webkit-transition-duration: 250ms; + transition-duration: 250ms; + -moz-transition-timing-function: ease-in-out; + -o-transition-timing-function: ease-in-out; + -webkit-transition-timing-function: ease-in-out; + transition-timing-function: ease-in-out; + -moz-transition-delay: 250ms; + -o-transition-delay: 250ms; + -webkit-transition-delay: 250ms; + transition-delay: 250ms; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuMCIgeTE9IjAuNSIgeDI9IjEuMCIgeTI9IjAuNSI+PHN0b3Agb2Zmc2V0PSI5OCUiIHN0b3AtY29sb3I9IiMwMDAwMDAiIHN0b3Atb3BhY2l0eT0iMC4wIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMDAwMDAwIiBzdG9wLW9wYWNpdHk9IjAuMyIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=='); background-size: 100%; background-image: -moz-linear-gradient(0deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%); @@ -4230,52 +4276,52 @@ span.req { background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%); right: auto !important; width: 40% !important; } - /* line 88, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 94, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.right.items { left: 40% !important; } - /* line 93, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 99, ../../../../general/res/sass/mobile/_layout.scss */ .toggle-tree { color: #0099cc !important; font-size: 110%; position: absolute; top: 12px; left: 10px; } - /* line 99, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 105, ../../../../general/res/sass/mobile/_layout.scss */ .toggle-tree:after { content: 'm' !important; font-family: symbolsfont; } - /* line 105, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 111, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar { left: 30px !important; } - /* line 108, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 114, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar .context-available { opacity: 1 !important; } - /* line 111, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 117, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar .view-switcher { margin-right: 0 !important; } - /* line 113, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 119, ../../../../general/res/sass/mobile/_layout.scss */ .object-browse-bar .view-switcher .title-label { display: none; } - /* line 120, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 126, ../../../../general/res/sass/mobile/_layout.scss */ .tree-holder { overflow-x: hidden !important; } - /* line 124, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 130, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-disable-select { -moz-user-select: -moz-none; -ms-user-select: none; -webkit-user-select: none; user-select: none; } - /* line 129, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 135, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-hide, .mobile-hide-important { display: none !important; } - /* line 134, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 140, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-back-hide { pointer-events: none; -moz-transition-property: opacity; @@ -4296,7 +4342,7 @@ span.req { transition-delay: 0; opacity: 0; } - /* line 139, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 145, ../../../../general/res/sass/mobile/_layout.scss */ .mobile-back-unhide { pointer-events: all; -moz-transition-property: opacity; @@ -4317,21 +4363,21 @@ span.req { transition-delay: 0; opacity: 1; } } @media screen and (orientation: portrait) and (max-width: 514px) and (max-height: 740px) and (max-device-width: 799px) and (max-device-height: 1024px) { - /* line 148, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 154, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.left.treeview { width: 90% !important; } - /* line 151, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 157, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.right.items { left: 0 !important; -moz-transform: translateX(90%); -ms-transform: translateX(90%); -webkit-transform: translateX(90%); transform: translateX(90%); } - /* line 154, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 160, ../../../../general/res/sass/mobile/_layout.scss */ .pane-tree-showing .pane.right.items #content-area { opacity: 0; } } @media screen and (min-device-width: 800px) and (min-device-height: 1025px), screen and (min-device-width: 1025px) and (min-device-height: 800px) { - /* line 162, ../../../../general/res/sass/mobile/_layout.scss */ + /* line 168, ../../../../general/res/sass/mobile/_layout.scss */ .desktop-hide { display: none; } } /***************************************************************************** diff --git a/platform/entanglement/bundle.json b/platform/entanglement/bundle.json index 61c3d90539..d8cde0ada6 100644 --- a/platform/entanglement/bundle.json +++ b/platform/entanglement/bundle.json @@ -30,6 +30,14 @@ "category": "contextual", "implementation": "actions/LinkAction.js", "depends": ["locationService", "linkService"] + }, + { + "key": "follow", + "name": "Go To Original", + "description": "Go to the original, un-linked instance of this object.", + "glyph": "\u00F4", + "category": "contextual", + "implementation": "actions/GoToOriginalAction.js" } ], "components": [ @@ -52,7 +60,8 @@ "key": "location", "name": "Location Capability", "description": "Provides a capability for retrieving the location of an object based upon it's context.", - "implementation": "capabilities/LocationCapability" + "implementation": "capabilities/LocationCapability", + "depends": [ "$q", "$injector" ] } ], "services": [ diff --git a/platform/entanglement/src/actions/GoToOriginalAction.js b/platform/entanglement/src/actions/GoToOriginalAction.js new file mode 100644 index 0000000000..9722915ad6 --- /dev/null +++ b/platform/entanglement/src/actions/GoToOriginalAction.js @@ -0,0 +1,62 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/*global define */ +define( + function () { + "use strict"; + + /** + * Implements the "Go To Original" action, which follows a link back + * to an original instance of an object. + * + * @implements {Action} + * @constructor + * @private + * @memberof platform/entanglement + * @param {ActionContext} context the context in which the action + * will be performed + */ + function GoToOriginalAction(context) { + this.domainObject = context.domainObject; + } + + GoToOriginalAction.prototype.perform = function () { + return this.domainObject.getCapability("location").getOriginal() + .then(function (originalObject) { + var actionCapability = + originalObject.getCapability("action"); + return actionCapability && + actionCapability.perform("navigate"); + }); + }; + + GoToOriginalAction.appliesTo = function (context) { + var domainObject = context.domainObject; + return domainObject && domainObject.hasCapability("location") + && domainObject.getCapability("location").isLink(); + }; + + return GoToOriginalAction; + } +); + diff --git a/platform/entanglement/src/capabilities/LocationCapability.js b/platform/entanglement/src/capabilities/LocationCapability.js index 17d678f57e..27e1f74c74 100644 --- a/platform/entanglement/src/capabilities/LocationCapability.js +++ b/platform/entanglement/src/capabilities/LocationCapability.js @@ -1,3 +1,25 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + /*global define */ define( @@ -12,11 +34,41 @@ define( * * @constructor */ - function LocationCapability(domainObject) { + function LocationCapability($q, $injector, domainObject) { this.domainObject = domainObject; + this.$q = $q; + this.$injector = $injector; return this; } + /** + * Get an instance of this domain object in its original location. + * + * @returns {Promise.} a promise for the original + * instance of this domain object + */ + LocationCapability.prototype.getOriginal = function () { + var id; + + if (this.isOriginal()) { + return this.$q.when(this.domainObject); + } + + id = this.domainObject.getId(); + + this.objectService = + this.objectService || this.$injector.get("objectService"); + + // Assume that an object will be correctly contextualized when + // loaded directly from the object service; this is true + // so long as LocatingObjectDecorator is present, and that + // decorator is also contained in this bundle. + return this.objectService.getObjects([id]) + .then(function (objects) { + return objects[id]; + }); + }; + /** * Set the primary location (the parent id) of the current domain * object. @@ -78,10 +130,6 @@ define( return !this.isLink(); }; - function createLocationCapability(domainObject) { - return new LocationCapability(domainObject); - } - - return createLocationCapability; + return LocationCapability; } ); diff --git a/platform/entanglement/test/actions/GoToOriginalActionSpec.js b/platform/entanglement/test/actions/GoToOriginalActionSpec.js new file mode 100644 index 0000000000..40c2f213ce --- /dev/null +++ b/platform/entanglement/test/actions/GoToOriginalActionSpec.js @@ -0,0 +1,95 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/*global define,describe,beforeEach,it,jasmine,expect */ + +define( + [ + '../../src/actions/GoToOriginalAction', + '../DomainObjectFactory', + '../ControlledPromise' + ], + function (GoToOriginalAction, domainObjectFactory, ControlledPromise) { + 'use strict'; + + describe("The 'go to original' action", function () { + var testContext, + originalDomainObject, + mockLocationCapability, + mockOriginalActionCapability, + originalPromise, + action; + + beforeEach(function () { + mockLocationCapability = jasmine.createSpyObj( + 'location', + [ 'isLink', 'isOriginal', 'getOriginal' ] + ); + mockOriginalActionCapability = jasmine.createSpyObj( + 'action', + [ 'perform', 'getActions' ] + ); + originalPromise = new ControlledPromise(); + mockLocationCapability.getOriginal.andReturn(originalPromise); + mockLocationCapability.isLink.andReturn(true); + mockLocationCapability.isOriginal.andCallFake(function () { + return !mockLocationCapability.isLink(); + }); + testContext = { + domainObject: domainObjectFactory({ + capabilities: { + location: mockLocationCapability + } + }) + }; + originalDomainObject = domainObjectFactory({ + capabilities: { + action: mockOriginalActionCapability + } + }); + + action = new GoToOriginalAction(testContext); + }); + + it("is applicable to links", function () { + expect(GoToOriginalAction.appliesTo(testContext)) + .toBeTruthy(); + }); + + it("is not applicable to originals", function () { + mockLocationCapability.isLink.andReturn(false); + expect(GoToOriginalAction.appliesTo(testContext)) + .toBeFalsy(); + }); + + it("navigates to original objects when performed", function () { + expect(mockOriginalActionCapability.perform) + .not.toHaveBeenCalled(); + action.perform(); + originalPromise.resolve(originalDomainObject); + expect(mockOriginalActionCapability.perform) + .toHaveBeenCalledWith('navigate'); + }); + + }); + } +); diff --git a/platform/entanglement/test/capabilities/LocationCapabilitySpec.js b/platform/entanglement/test/capabilities/LocationCapabilitySpec.js index 9cbfcc1bea..442bfe20aa 100644 --- a/platform/entanglement/test/capabilities/LocationCapabilitySpec.js +++ b/platform/entanglement/test/capabilities/LocationCapabilitySpec.js @@ -1,3 +1,25 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + /*global define,describe,it,expect,beforeEach,jasmine */ define( @@ -7,6 +29,7 @@ define( '../ControlledPromise' ], function (LocationCapability, domainObjectFactory, ControlledPromise) { + 'use strict'; describe("LocationCapability", function () { @@ -14,13 +37,17 @@ define( var locationCapability, persistencePromise, mutationPromise, + mockQ, + mockInjector, + mockObjectService, domainObject; beforeEach(function () { domainObject = domainObjectFactory({ + id: "testObject", capabilities: { context: { - getParent: function() { + getParent: function () { return domainObjectFactory({id: 'root'}); } }, @@ -35,6 +62,11 @@ define( } }); + mockQ = jasmine.createSpyObj("$q", ["when"]); + mockInjector = jasmine.createSpyObj("$injector", ["get"]); + mockObjectService = + jasmine.createSpyObj("objectService", ["getObjects"]); + persistencePromise = new ControlledPromise(); domainObject.capabilities.persistence.persist.andReturn( persistencePromise @@ -49,7 +81,11 @@ define( } ); - locationCapability = new LocationCapability(domainObject); + locationCapability = new LocationCapability( + mockQ, + mockInjector, + domainObject + ); }); it("returns contextual location", function () { @@ -88,6 +124,57 @@ define( expect(whenComplete).toHaveBeenCalled(); }); + describe("when used to load an original instance", function () { + var objectPromise, + qPromise, + originalObjects, + mockCallback; + + function resolvePromises() { + if (mockQ.when.calls.length > 0) { + qPromise.resolve(mockQ.when.mostRecentCall.args[0]); + } + if (mockObjectService.getObjects.calls.length > 0) { + objectPromise.resolve(originalObjects); + } + } + + beforeEach(function () { + objectPromise = new ControlledPromise(); + qPromise = new ControlledPromise(); + originalObjects = { + testObject: domainObjectFactory() + }; + + mockInjector.get.andCallFake(function (key) { + return key === 'objectService' && mockObjectService; + }); + mockObjectService.getObjects.andReturn(objectPromise); + mockQ.when.andReturn(qPromise); + + mockCallback = jasmine.createSpy('callback'); + }); + + it("provides originals directly", function () { + domainObject.model.location = 'root'; + locationCapability.getOriginal().then(mockCallback); + expect(mockCallback).not.toHaveBeenCalled(); + resolvePromises(); + expect(mockCallback) + .toHaveBeenCalledWith(domainObject); + }); + + it("loads from the object service for links", function () { + domainObject.model.location = 'some-other-root'; + locationCapability.getOriginal().then(mockCallback); + expect(mockCallback).not.toHaveBeenCalled(); + resolvePromises(); + expect(mockCallback) + .toHaveBeenCalledWith(originalObjects.testObject); + }); + }); + + }); }); } diff --git a/platform/entanglement/test/suite.json b/platform/entanglement/test/suite.json index 12831b407a..89c082f9c8 100644 --- a/platform/entanglement/test/suite.json +++ b/platform/entanglement/test/suite.json @@ -1,5 +1,9 @@ [ "actions/AbstractComposeAction", + "actions/CopyAction", + "actions/GoToOriginalAction", + "actions/LinkAction", + "actions/MoveAction", "services/CopyService", "services/LinkService", "services/MoveService", diff --git a/platform/features/plot/src/elements/PlotUpdater.js b/platform/features/plot/src/elements/PlotUpdater.js index d4b4ad3eec..53cfd1c4c4 100644 --- a/platform/features/plot/src/elements/PlotUpdater.js +++ b/platform/features/plot/src/elements/PlotUpdater.js @@ -159,7 +159,9 @@ define( // Update dimensions and origin based on extrema of plots PlotUpdater.prototype.updateBounds = function () { - var bufferArray = this.bufferArray, + var bufferArray = this.bufferArray.filter(function (lineBuffer) { + return lineBuffer.getLength() > 0; // Ignore empty lines + }), priorDomainOrigin = this.origin[0], priorDomainDimensions = this.dimensions[0]; diff --git a/platform/features/plot/test/elements/PlotUpdaterSpec.js b/platform/features/plot/test/elements/PlotUpdaterSpec.js index 9d7ab563de..c287dbfdf8 100644 --- a/platform/features/plot/test/elements/PlotUpdaterSpec.js +++ b/platform/features/plot/test/elements/PlotUpdaterSpec.js @@ -202,6 +202,38 @@ define( expect(updater.getDimensions()[1]).toBeGreaterThan(20); }); + describe("when no data is initially available", function () { + beforeEach(function () { + testDomainValues = {}; + testRangeValues = {}; + updater = new PlotUpdater( + mockSubscription, + testDomain, + testRange, + 1350 // Smaller max size for easier testing + ); + }); + + it("has no line data", function () { + // Either no lines, or empty lines are fine + expect(updater.getLineBuffers().map(function (lineBuffer) { + return lineBuffer.getLength(); + }).reduce(function (a, b) { + return a + b; + }, 0)).toEqual(0); + }); + + it("determines initial domain bounds from first available data", function () { + testDomainValues.a = 123; + testRangeValues.a = 456; + updater.update(); + expect(updater.getOrigin()[0]).toEqual(jasmine.any(Number)); + expect(updater.getOrigin()[1]).toEqual(jasmine.any(Number)); + expect(isNaN(updater.getOrigin()[0])).toBeFalsy(); + expect(isNaN(updater.getOrigin()[1])).toBeFalsy(); + }); + }); + }); } ); diff --git a/platform/persistence/elastic/bundle.json b/platform/persistence/elastic/bundle.json index f78d8504f2..3e1383351e 100644 --- a/platform/persistence/elastic/bundle.json +++ b/platform/persistence/elastic/bundle.json @@ -13,7 +13,7 @@ "provides": "searchService", "type": "provider", "implementation": "ElasticSearchProvider.js", - "depends": [ "$http", "objectService", "ELASTIC_ROOT" ] + "depends": [ "$http", "ELASTIC_ROOT" ] } ], "constants": [ diff --git a/platform/persistence/elastic/src/ElasticSearchProvider.js b/platform/persistence/elastic/src/ElasticSearchProvider.js index 604e3d0ed3..84290d999a 100644 --- a/platform/persistence/elastic/src/ElasticSearchProvider.js +++ b/platform/persistence/elastic/src/ElasticSearchProvider.js @@ -24,190 +24,122 @@ /** * Module defining ElasticSearchProvider. Created by shale on 07/16/2015. */ -define( - [], - function () { - "use strict"; +define([ - // JSLint doesn't like underscore-prefixed properties, - // so hide them here. - var ID = "_id", - SCORE = "_score", - DEFAULT_MAX_RESULTS = 100; - - /** - * A search service which searches through domain objects in - * the filetree using ElasticSearch. - * - * @constructor - * @param $http Angular's $http service, for working with urls. - * @param {ObjectService} objectService the service from which - * domain objects can be gotten. - * @param ROOT the constant `ELASTIC_ROOT` which allows us to - * interact with ElasticSearch. - */ - function ElasticSearchProvider($http, objectService, ROOT) { - this.$http = $http; - this.objectService = objectService; - this.root = ROOT; - } +], function ( - /** - * Searches through the filetree for domain objects using a search - * term. This is done through querying elasticsearch. Returns a - * promise for a result object that has the format - * {hits: searchResult[], total: number, timedOut: boolean} - * where a searchResult has the format - * {id: string, object: domainObject, score: number} - * - * Notes: - * * The order of the results is from highest to lowest score, - * as elsaticsearch determines them to be. - * * Uses the fuzziness operator to get more results. - * * More about this search's behavior at - * https://www.elastic.co/guide/en/elasticsearch/reference/current/search-uri-request.html - * - * @param searchTerm The text input that is the query. - * @param timestamp The time at which this function was called. - * This timestamp is used as a unique identifier for this - * query and the corresponding results. - * @param maxResults (optional) The maximum number of results - * that this function should return. - * @param timeout (optional) The time after which the search should - * stop calculations and return partial results. Elasticsearch - * does not guarentee that this timeout will be strictly followed. - */ - ElasticSearchProvider.prototype.query = function query(searchTerm, timestamp, maxResults, timeout) { - var $http = this.$http, - objectService = this.objectService, - root = this.root, - esQuery; - - function addFuzziness(searchTerm, editDistance) { - if (!editDistance) { - editDistance = ''; - } +) { + "use strict"; - return searchTerm.split(' ').map(function (s) { - // Don't add fuzziness for quoted strings - if (s.indexOf('"') !== -1) { - return s; - } else { - return s + '~' + editDistance; - } - }).join(' '); - } + var ID_PROPERTY = '_id', + SOURCE_PROPERTY = '_source', + SCORE_PROPERTY = '_score'; - // Currently specific to elasticsearch - function processSearchTerm(searchTerm) { - var spaceIndex; - - // Cut out any extra spaces - while (searchTerm.substr(0, 1) === ' ') { - searchTerm = searchTerm.substring(1, searchTerm.length); - } - while (searchTerm.substr(searchTerm.length - 1, 1) === ' ') { - searchTerm = searchTerm.substring(0, searchTerm.length - 1); - } - spaceIndex = searchTerm.indexOf(' '); - while (spaceIndex !== -1) { - searchTerm = searchTerm.substring(0, spaceIndex) + - searchTerm.substring(spaceIndex + 1, searchTerm.length); - spaceIndex = searchTerm.indexOf(' '); - } - - // Add fuzziness for completeness - searchTerm = addFuzziness(searchTerm); - - return searchTerm; - } - - // Processes results from the format that elasticsearch returns to - // a list of searchResult objects, then returns a result object - // (See documentation for query for object descriptions) - function processResults(rawResults, timestamp) { - var results = rawResults.data.hits.hits, - resultsLength = results.length, - ids = [], - scores = {}, - searchResults = [], - i; - - // Get the result objects' IDs - for (i = 0; i < resultsLength; i += 1) { - ids.push(results[i][ID]); - } - - // Get the result objects' scores - for (i = 0; i < resultsLength; i += 1) { - scores[ids[i]] = results[i][SCORE]; - } - - // Get the domain objects from their IDs - return objectService.getObjects(ids).then(function (objects) { - var j, - id; - - for (j = 0; j < resultsLength; j += 1) { - id = ids[j]; - - // Include items we can get models for - if (objects[id].getModel) { - // Format the results as searchResult objects - searchResults.push({ - id: id, - object: objects[id], - score: scores[id] - }); - } - } - - return { - hits: searchResults, - total: rawResults.data.hits.total, - timedOut: rawResults.data.timed_out - }; - }); - } - - - // Check to see if the user provided a maximum - // number of results to display - if (!maxResults) { - // Else, we provide a default value. - maxResults = DEFAULT_MAX_RESULTS; - } - - // If the user input is empty, we want to have no search results. - if (searchTerm !== '' && searchTerm !== undefined) { - // Process the search term - searchTerm = processSearchTerm(searchTerm); - - // Create the query to elasticsearch - esQuery = root + "/_search/?q=" + searchTerm + - "&size=" + maxResults; - if (timeout) { - esQuery += "&timeout=" + timeout; - } - - // Get the data... - return this.$http({ - method: "GET", - url: esQuery - }).then(function (rawResults) { - // ...then process the data - return processResults(rawResults, timestamp); - }, function (err) { - // In case of error, return nothing. (To prevent - // infinite loading time.) - return {hits: [], total: 0}; - }); - } else { - return {hits: [], total: 0}; - } - }; - - - return ElasticSearchProvider; + /** + * A search service which searches through domain objects in + * the filetree using ElasticSearch. + * + * @constructor + * @param $http Angular's $http service, for working with urls. + * @param ROOT the constant `ELASTIC_ROOT` which allows us to + * interact with ElasticSearch. + */ + function ElasticSearchProvider($http, ROOT) { + this.$http = $http; + this.root = ROOT; } -); \ No newline at end of file + + /** + * Search for domain objects using elasticsearch as a search provider. + * + * @param {String} searchTerm the term to search by. + * @param {Number} [maxResults] the max numer of results to return. + * @returns {Promise} promise for a modelResults object. + */ + ElasticSearchProvider.prototype.query = function (searchTerm, maxResults) { + var searchUrl = this.root + '/_search/', + params = {}, + provider = this; + + searchTerm = this.cleanTerm(searchTerm); + searchTerm = this.fuzzyMatchUnquotedTerms(searchTerm); + + params.q = searchTerm; + params.size = maxResults; + + return this + .$http({ + method: "GET", + url: searchUrl, + params: params + }) + .then(function success(succesResponse) { + return provider.parseResponse(succesResponse); + }, function error(errorResponse) { + // Gracefully fail. + return { + hits: [], + total: 0 + }; + }); + }; + + + /** + * Clean excess whitespace from a search term and return the cleaned + * version. + * + * @private + * @param {string} the search term to clean. + * @returns {string} search terms cleaned of excess whitespace. + */ + ElasticSearchProvider.prototype.cleanTerm = function (term) { + return term.trim().replace(/ +/g, ' '); + }; + + /** + * Add fuzzy matching markup to search terms that are not quoted. + * + * The following: + * hello welcome "to quoted village" have fun + * will become + * hello~ welcome~ "to quoted village" have~ fun~ + * + * @private + */ + ElasticSearchProvider.prototype.fuzzyMatchUnquotedTerms = function (query) { + var matchUnquotedSpaces = '\\s+(?=([^"]*"[^"]*")*[^"]*$)', + matcher = new RegExp(matchUnquotedSpaces, 'g'); + + return query + .replace(matcher, '~ ') + .replace(/$/, '~') + .replace(/"~+/, '"'); + }; + + /** + * Parse the response from ElasticSearch and convert it to a + * modelResults object. + * + * @private + * @param response a ES response object from $http + * @returns modelResults + */ + ElasticSearchProvider.prototype.parseResponse = function (response) { + var results = response.data.hits.hits, + searchResults = results.map(function (result) { + return { + id: result[ID_PROPERTY], + model: result[SOURCE_PROPERTY], + score: result[SCORE_PROPERTY] + }; + }); + + return { + hits: searchResults, + total: response.data.hits.total + }; + }; + + return ElasticSearchProvider; +}); diff --git a/platform/persistence/elastic/test/ElasticSearchProviderSpec.js b/platform/persistence/elastic/test/ElasticSearchProviderSpec.js index decb1a5c98..f8337e0862 100644 --- a/platform/persistence/elastic/test/ElasticSearchProviderSpec.js +++ b/platform/persistence/elastic/test/ElasticSearchProviderSpec.js @@ -19,97 +19,151 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define,describe,it,expect,beforeEach,jasmine*/ +/*global define,describe,it,expect,beforeEach,jasmine,spyOn,Promise,waitsFor*/ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - ["../src/ElasticSearchProvider"], - function (ElasticSearchProvider) { - "use strict"; +define([ + '../src/ElasticSearchProvider' +], function ( + ElasticSearchProvider +) { + 'use strict'; - // JSLint doesn't like underscore-prefixed properties, - // so hide them here. - var ID = "_id", - SCORE = "_score"; - - describe("The ElasticSearch search provider ", function () { - var mockHttp, - mockHttpPromise, - mockObjectPromise, - mockObjectService, - mockDomainObject, - provider, - mockProviderResults; + describe('ElasticSearchProvider', function () { + var $http, + ROOT, + provider; + beforeEach(function () { + $http = jasmine.createSpy('$http'); + ROOT = 'http://localhost:9200'; + + provider = new ElasticSearchProvider($http, ROOT); + }); + + describe('query', function () { beforeEach(function () { - mockHttp = jasmine.createSpy("$http"); - mockHttpPromise = jasmine.createSpyObj( - "promise", - [ "then" ] - ); - mockHttp.andReturn(mockHttpPromise); - // allow chaining of promise.then().catch(); - mockHttpPromise.then.andReturn(mockHttpPromise); - - mockObjectService = jasmine.createSpyObj( - "objectService", - [ "getObjects" ] - ); - mockObjectPromise = jasmine.createSpyObj( - "promise", - [ "then" ] - ); - mockObjectService.getObjects.andReturn(mockObjectPromise); - - mockDomainObject = jasmine.createSpyObj( - "domainObject", - [ "getId", "getModel" ] - ); - - provider = new ElasticSearchProvider(mockHttp, mockObjectService, ""); - provider.query(' test "query" ', 0, undefined, 1000); + spyOn(provider, 'cleanTerm').andReturn('cleanedTerm'); + spyOn(provider, 'fuzzyMatchUnquotedTerms').andReturn('fuzzy'); + spyOn(provider, 'parseResponse').andReturn('parsedResponse'); + $http.andReturn(Promise.resolve({})); }); - - it("sends a query to ElasticSearch", function () { - expect(mockHttp).toHaveBeenCalled(); + + it('cleans terms and adds fuzzyness', function () { + provider.query('hello', 10); + expect(provider.cleanTerm).toHaveBeenCalledWith('hello'); + expect(provider.fuzzyMatchUnquotedTerms) + .toHaveBeenCalledWith('cleanedTerm'); }); - - it("gets data from ElasticSearch", function () { - var data = { - hits: { - hits: [ - {}, - {} - ], - total: 0 + + it('calls through to $http', function () { + provider.query('hello', 10); + expect($http).toHaveBeenCalledWith({ + method: 'GET', + params: { + q: 'fuzzy', + size: 10 }, - timed_out: false - }; - data.hits.hits[0][ID] = 1; - data.hits.hits[0][SCORE] = 1; - data.hits.hits[1][ID] = 2; - data.hits.hits[1][SCORE] = 2; - - mockProviderResults = mockHttpPromise.then.mostRecentCall.args[0]({data: data}); - - expect( - mockObjectPromise.then.mostRecentCall.args[0]({ - 1: mockDomainObject, - 2: mockDomainObject - }).hits.length - ).toEqual(2); + url: 'http://localhost:9200/_search/' + }); }); - - it("returns nothing for an empty string query", function () { - expect(provider.query("").hits).toEqual([]); + + it('gracefully fails when http fails', function () { + var promiseChainResolved = false; + $http.andReturn(Promise.reject()); + + provider + .query('hello', 10) + .then(function (results) { + expect(results).toEqual({ + hits: [], + total: 0 + }); + promiseChainResolved = true; + }); + + waitsFor(function () { + return promiseChainResolved; + }); }); - - it("returns something when there is an ElasticSearch error", function () { - mockProviderResults = mockHttpPromise.then.mostRecentCall.args[1](); - expect(mockProviderResults).toBeDefined(); + + it('parses and returns when http succeeds', function () { + var promiseChainResolved = false; + $http.andReturn(Promise.resolve('successResponse')); + + provider + .query('hello', 10) + .then(function (results) { + expect(provider.parseResponse) + .toHaveBeenCalledWith('successResponse'); + expect(results).toBe('parsedResponse'); + promiseChainResolved = true; + }); + + waitsFor(function () { + return promiseChainResolved; + }); }); }); - } -); \ No newline at end of file + + it('can clean terms', function () { + expect(provider.cleanTerm(' asdhs ')).toBe('asdhs'); + expect(provider.cleanTerm(' and some words')) + .toBe('and some words'); + expect(provider.cleanTerm('Nice input')).toBe('Nice input'); + }); + + it('can create fuzzy term matchers', function () { + expect(provider.fuzzyMatchUnquotedTerms('pwr dvc 43')) + .toBe('pwr~ dvc~ 43~'); + + expect(provider.fuzzyMatchUnquotedTerms( + 'hello welcome "to quoted village" have fun' + )).toBe( + 'hello~ welcome~ "to quoted village" have~ fun~' + ); + }); + + it('can parse responses', function () { + var elasticSearchResponse = { + data: { + hits: { + total: 2, + hits: [ + { + '_id': 'hit1Id', + '_source': 'hit1Model', + '_score': 0.56 + }, + { + '_id': 'hit2Id', + '_source': 'hit2Model', + '_score': 0.34 + } + ] + } + } + }; + + expect(provider.parseResponse(elasticSearchResponse)) + .toEqual({ + hits: [ + { + id: 'hit1Id', + model: 'hit1Model', + score: 0.56 + }, + { + id: 'hit2Id', + model: 'hit2Model', + score: 0.34 + } + ], + total: 2 + }); + }); + }); + +}); diff --git a/platform/search/bundle.json b/platform/search/bundle.json index 67dc058ed9..9e39e28d03 100644 --- a/platform/search/bundle.json +++ b/platform/search/bundle.json @@ -48,8 +48,7 @@ "depends": [ "$q", "$log", - "throttle", - "objectService", + "modelService", "workerService", "topic", "GENERIC_SEARCH_ROOTS" @@ -59,7 +58,7 @@ "provides": "searchService", "type": "aggregator", "implementation": "services/SearchAggregator.js", - "depends": [ "$q" ] + "depends": [ "$q", "objectService" ] } ], "workers": [ diff --git a/platform/search/res/templates/search.html b/platform/search/res/templates/search.html index e65ab072af..225df353b0 100644 --- a/platform/search/res/templates/search.html +++ b/platform/search/res/templates/search.html @@ -21,21 +21,16 @@ --> - +
- + Filtered by: {{ ngModel.filtersString }} - - +
- +
- +
Loading...
- +
+ ng-click="controller.loadMore()"> More Results
- +
- - \ No newline at end of file + + diff --git a/platform/search/src/controllers/SearchController.js b/platform/search/src/controllers/SearchController.js index 10cf056b4f..629e495331 100644 --- a/platform/search/src/controllers/SearchController.js +++ b/platform/search/src/controllers/SearchController.js @@ -26,146 +26,155 @@ */ define(function () { "use strict"; - - var INITIAL_LOAD_NUMBER = 20, - LOAD_INCREMENT = 20; - + + /** + * Controller for search in Tree View. + * + * Filtering is currently buggy; it filters after receiving results from + * search providers, the downside of this is that it requires search + * providers to provide objects for all possible results, which is + * potentially a hit to persistence, thus can be very very slow. + * + * Ideally, filtering should be handled before loading objects from the persistence + * store, the downside to this is that filters must be applied to object + * models, not object instances. + * + * @constructor + * @param $scope + * @param searchService + */ function SearchController($scope, searchService) { - // numResults is the amount of results to display. Will get increased. - // fullResults holds the most recent complete searchService response object - var numResults = INITIAL_LOAD_NUMBER, - fullResults = {hits: []}; - - // Scope variables are: - // Variables used only in SearchController: - // results, an array of searchResult objects - // loading, whether search() is loading - // ngModel.input, the text of the search query - // ngModel.search, a boolean of whether to display search or the tree - // Variables used also in SearchMenuController: - // ngModel.filter, the function filter defined below - // ngModel.types, an array of type objects - // ngModel.checked, a dictionary of which type filter options are checked - // ngModel.checkAll, a boolean of whether to search all types - // ngModel.filtersString, a string list of what filters on the results are active - $scope.results = []; - $scope.loading = false; - - - // Filters searchResult objects by type. Allows types that are - // checked. (ngModel.checked['typekey'] === true) - // If hits is not provided, will use fullResults.hits - function filter(hits) { - var newResults = [], - i = 0; - - if (!hits) { - hits = fullResults.hits; - } - - // If checkAll is checked, search everything no matter what the other - // checkboxes' statuses are. Otherwise filter the search by types. - if ($scope.ngModel.checkAll) { - newResults = fullResults.hits.slice(0, numResults); - } else { - while (newResults.length < numResults && i < hits.length) { - // If this is of an acceptable type, add it to the list - if ($scope.ngModel.checked[hits[i].object.getModel().type]) { - newResults.push(fullResults.hits[i]); - } - i += 1; - } - } - - $scope.results = newResults; - return newResults; - } - - // Make function accessible from SearchMenuController - $scope.ngModel.filter = filter; - - // For documentation, see search below - function search(maxResults) { - var inputText = $scope.ngModel.input; - - if (inputText !== '' && inputText !== undefined) { - // We are starting to load. - $scope.loading = true; - - // Update whether the file tree should be displayed - // Hide tree only when starting search - $scope.ngModel.search = true; - } - - if (!maxResults) { - // Reset 'load more' - numResults = INITIAL_LOAD_NUMBER; - } - - // Send the query - searchService.query(inputText, maxResults).then(function (result) { - // Store all the results before splicing off the front, so that - // we can load more to display later. - fullResults = result; - $scope.results = filter(result.hits); - - // Update whether the file tree should be displayed - // Reveal tree only when finishing search - if (inputText === '' || inputText === undefined) { - $scope.ngModel.search = false; - } - - // Now we are done loading. - $scope.loading = false; - }); - } - - return { - /** - * Search the filetree. Assumes that any search text will - * be in ngModel.input - * - * @param maxResults (optional) The maximum number of results - * that this function should return. If not provided, search - * service default will be used. - */ - search: search, - - /** - * Checks to see if there are more search results to display. If the answer is - * unclear, this function will err toward saying that there are more results. - */ - areMore: function () { - var i; - - // Check to see if any of the not displayed results are of an allowed type - for (i = numResults; i < fullResults.hits.length; i += 1) { - if ($scope.ngModel.checkAll || $scope.ngModel.checked[fullResults.hits[i].object.getModel().type]) { - return true; - } - } - - // If none of the ones at hand are correct, there still may be more if we - // re-search with a larger maxResults - return fullResults.hits.length < fullResults.total; - }, - - /** - * Increases the number of search results to display, and then - * loads them, adding to the displayed results. - */ - loadMore: function () { - numResults += LOAD_INCREMENT; - - if (numResults > fullResults.hits.length && fullResults.hits.length < fullResults.total) { - // Resend the query if we are out of items to display, but there are more to get - search(numResults); - } else { - // Otherwise just take from what we already have - $scope.results = filter(fullResults.hits); - } - } + var controller = this; + this.$scope = $scope; + this.searchService = searchService; + this.numberToDisplay = this.RESULTS_PER_PAGE; + this.availabileResults = 0; + this.$scope.results = []; + this.$scope.loading = false; + this.pendingQuery = undefined; + this.$scope.ngModel.filter = function () { + return controller.onFilterChange.apply(controller, arguments); }; } + + SearchController.prototype.RESULTS_PER_PAGE = 20; + + /** + * Returns true if there are more results than currently displayed for the + * for the current query and filters. + */ + SearchController.prototype.areMore = function () { + return this.$scope.results.length < this.availableResults; + }; + + /** + * Display more results for the currently displayed query and filters. + */ + SearchController.prototype.loadMore = function () { + this.numberToDisplay += this.RESULTS_PER_PAGE; + this.dispatchSearch(); + }; + + /** + * Reset search results, then search for the query string specified in + * scope. + */ + SearchController.prototype.search = function () { + var inputText = this.$scope.ngModel.input; + + this.clearResults(); + + if (inputText) { + this.$scope.loading = true; + this.$scope.ngModel.search = true; + } else { + this.pendingQuery = undefined; + this.$scope.ngModel.search = false; + this.$scope.loading = false; + return; + } + + this.dispatchSearch(); + }; + + /** + * Dispatch a search to the search service if it hasn't already been + * dispatched. + * + * @private + */ + SearchController.prototype.dispatchSearch = function () { + var inputText = this.$scope.ngModel.input, + controller = this, + queryId = inputText + this.numberToDisplay; + + if (this.pendingQuery === queryId) { + return; // don't issue multiple queries for the same term. + } + + this.pendingQuery = queryId; + + this + .searchService + .query(inputText, this.numberToDisplay, this.filterPredicate()) + .then(function (results) { + if (controller.pendingQuery !== queryId) { + return; // another query in progress, so skip this one. + } + controller.onSearchComplete(results); + }); + }; + + SearchController.prototype.filter = SearchController.prototype.onFilterChange; + + /** + * Refilter results and update visible results when filters have changed. + */ + SearchController.prototype.onFilterChange = function () { + this.pendingQuery = undefined; + this.search(); + }; + + /** + * Returns a predicate function that can be used to filter object models. + * + * @private + */ + SearchController.prototype.filterPredicate = function () { + if (this.$scope.ngModel.checkAll) { + return function () { + return true; + }; + } + var includeTypes = this.$scope.ngModel.checked; + return function (model) { + return !!includeTypes[model.type]; + }; + }; + + /** + * Clear the search results. + * + * @private + */ + SearchController.prototype.clearResults = function () { + this.$scope.results = []; + this.availableResults = 0; + this.numberToDisplay = this.RESULTS_PER_PAGE; + }; + + + /** + * Update search results from given `results`. + * + * @private + */ + SearchController.prototype.onSearchComplete = function (results) { + this.availableResults = results.total; + this.$scope.results = results.hits; + this.$scope.loading = false; + this.pendingQuery = undefined; + }; + return SearchController; }); diff --git a/platform/search/src/services/GenericSearchProvider.js b/platform/search/src/services/GenericSearchProvider.js index 9574d156fb..71dfe8c0ed 100644 --- a/platform/search/src/services/GenericSearchProvider.js +++ b/platform/search/src/services/GenericSearchProvider.js @@ -19,234 +19,262 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define*/ +/*global define,setTimeout*/ /** * Module defining GenericSearchProvider. Created by shale on 07/16/2015. */ -define( - [], - function () { - "use strict"; +define([ - var DEFAULT_MAX_RESULTS = 100, - DEFAULT_TIMEOUT = 1000, - MAX_CONCURRENT_REQUESTS = 100, - FLUSH_INTERVAL = 0, - stopTime; +], function ( - /** - * A search service which searches through domain objects in - * the filetree without using external search implementations. - * - * @constructor - * @param $q Angular's $q, for promise consolidation. - * @param $log Anglar's $log, for logging. - * @param {Function} throttle a function to throttle function invocations - * @param {ObjectService} objectService The service from which - * domain objects can be gotten. - * @param {WorkerService} workerService The service which allows - * more easy creation of web workers. - * @param {GENERIC_SEARCH_ROOTS} ROOTS An array of the root - * domain objects' IDs. - */ - function GenericSearchProvider($q, $log, throttle, objectService, workerService, topic, ROOTS) { - var indexed = {}, - pendingIndex = {}, - pendingQueries = {}, - toRequest = [], - worker = workerService.run('genericSearchWorker'), - mutationTopic = topic("mutation"), - indexingStarted = Date.now(), - pendingRequests = 0, - scheduleFlush; +) { + "use strict"; - this.worker = worker; - this.pendingQueries = pendingQueries; - this.$q = $q; - // pendingQueries is a dictionary with the key value pairs st - // the key is the timestamp and the value is the promise + /** + * A search service which searches through domain objects in + * the filetree without using external search implementations. + * + * @constructor + * @param $q Angular's $q, for promise consolidation. + * @param $log Anglar's $log, for logging. + * @param {ModelService} modelService the model service. + * @param {WorkerService} workerService the workerService. + * @param {TopicService} topic the topic service. + * @param {Array} ROOTS An array of object Ids to begin indexing. + */ + function GenericSearchProvider($q, $log, modelService, workerService, topic, ROOTS) { + var provider = this; + this.$q = $q; + this.$log = $log; + this.modelService = modelService; - function scheduleIdsForIndexing(ids) { - ids.forEach(function (id) { - if (!indexed[id] && !pendingIndex[id]) { - indexed[id] = true; - pendingIndex[id] = true; - toRequest.push(id); - } - }); - scheduleFlush(); - } + this.indexedIds = {}; + this.idsToIndex = []; + this.pendingIndex = {}; + this.pendingRequests = 0; - // Tell the web worker to add a domain object's model to its list of items. - function indexItem(domainObject) { - var model = domainObject.getModel(); + this.pendingQueries = {}; - worker.postMessage({ - request: 'index', - model: model, - id: domainObject.getId() - }); + this.worker = this.startWorker(workerService); + this.indexOnMutation(topic); - if (Array.isArray(model.composition)) { - scheduleIdsForIndexing(model.composition); - } - } + ROOTS.forEach(function indexRoot(rootId) { + provider.scheduleForIndexing(rootId); + }); - // Handles responses from the web worker. Namely, the results of - // a search request. - function handleResponse(event) { - var ids = [], - id; - // If we have the results from a search - if (event.data.request === 'search') { - // Convert the ids given from the web worker into domain objects - for (id in event.data.results) { - ids.push(id); - } - objectService.getObjects(ids).then(function (objects) { - var searchResults = [], - id; + } - // Create searchResult objects - for (id in objects) { - searchResults.push({ - object: objects[id], - id: id, - score: event.data.results[id] - }); - } + /** + * Maximum number of concurrent index requests to allow. + */ + GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100; - // Resove the promise corresponding to this - pendingQueries[event.data.timestamp].resolve({ - hits: searchResults, - total: event.data.total, - timedOut: event.data.timedOut - }); - }); - } - } + /** + * Query the search provider for results. + * + * @param {String} input the string to search by. + * @param {Number} maxResults max number of results to return. + * @returns {Promise} a promise for a modelResults object. + */ + GenericSearchProvider.prototype.query = function ( + input, + maxResults + ) { - function requestAndIndex(id) { - pendingRequests += 1; - objectService.getObjects([id]).then(function (objects) { - delete pendingIndex[id]; - if (objects[id]) { - indexItem(objects[id]); - } - }, function () { - $log.warn("Failed to index domain object " + id); - }).then(function () { - pendingRequests -= 1; - scheduleFlush(); - }); - } + var queryId = this.dispatchSearch(input, maxResults), + pendingQuery = this.$q.defer(); - scheduleFlush = throttle(function flush() { - var batchSize = - Math.max(MAX_CONCURRENT_REQUESTS - pendingRequests, 0); + this.pendingQueries[queryId] = pendingQuery; - if (toRequest.length + pendingRequests < 1) { - $log.info([ - 'GenericSearch finished indexing after ', - ((Date.now() - indexingStarted) / 1000).toFixed(2), - ' seconds.' - ].join('')); - } else { - toRequest.splice(-batchSize, batchSize) - .forEach(requestAndIndex); - } - }, FLUSH_INTERVAL); + return pendingQuery.promise; + }; - worker.onmessage = handleResponse; + /** + * Creates a search worker and attaches handlers. + * + * @private + * @param workerService + * @returns worker the created search worker. + */ + GenericSearchProvider.prototype.startWorker = function (workerService) { + var worker = workerService.run('genericSearchWorker'), + provider = this; - // Index the tree's contents once at the beginning - scheduleIdsForIndexing(ROOTS); + worker.addEventListener('message', function (messageEvent) { + provider.onWorkerMessage(messageEvent); + }); - // Re-index items when they are mutated - mutationTopic.listen(function (domainObject) { - var id = domainObject.getId(); - indexed[id] = false; - scheduleIdsForIndexing([id]); + return worker; + }; + + /** + * Listen to the mutation topic and re-index objects when they are + * mutated. + * + * @private + * @param topic the topicService. + */ + GenericSearchProvider.prototype.indexOnMutation = function (topic) { + var mutationTopic = topic('mutation'), + provider = this; + + mutationTopic.listen(function (mutatedObject) { + var id = mutatedObject.getId(); + provider.indexedIds[id] = false; + provider.scheduleForIndexing(id); + }); + }; + + /** + * Schedule an id to be indexed at a later date. If there are less + * pending requests then allowed, will kick off an indexing request. + * + * @private + * @param {String} id to be indexed. + */ + GenericSearchProvider.prototype.scheduleForIndexing = function (id) { + if (!this.indexedIds[id] && !this.pendingIndex[id]) { + this.indexedIds[id] = true; + this.pendingIndex[id] = true; + this.idsToIndex.push(id); + } + this.keepIndexing(); + }; + + /** + * If there are less pending requests than concurrent requests, keep + * firing requests. + * + * @private + */ + GenericSearchProvider.prototype.keepIndexing = function () { + while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS && + this.idsToIndex.length + ) { + this.beginIndexRequest(); + } + }; + + /** + * Pass an id and model to the worker to be indexed. If the model has + * composition, schedule those ids for later indexing. + * + * @private + * @param id a model id + * @param model a model + */ + GenericSearchProvider.prototype.index = function (id, model) { + var provider = this; + + this.worker.postMessage({ + request: 'index', + model: model, + id: id + }); + + if (Array.isArray(model.composition)) { + model.composition.forEach(function (id) { + provider.scheduleForIndexing(id); }); } + }; - /** - * Searches through the filetree for domain objects which match - * the search term. This function is to be used as a fallback - * in the case where other search services are not avaliable. - * Returns a promise for a result object that has the format - * {hits: searchResult[], total: number, timedOut: boolean} - * where a searchResult has the format - * {id: string, object: domainObject, score: number} - * - * Notes: - * * The order of the results is not guarenteed. - * * A domain object qualifies as a match for a search input if - * the object's name property contains any of the search terms - * (which are generated by splitting the input at spaces). - * * Scores are higher for matches that have more of the terms - * as substrings. - * - * @param input The text input that is the query. - * @param timestamp The time at which this function was called. - * This timestamp is used as a unique identifier for this - * query and the corresponding results. - * @param maxResults (optional) The maximum number of results - * that this function should return. - * @param timeout (optional) The time after which the search should - * stop calculations and return partial results. - */ - GenericSearchProvider.prototype.query = function query(input, timestamp, maxResults, timeout) { - var terms = [], - searchResults = [], - pendingQueries = this.pendingQueries, - worker = this.worker, - defer = this.$q.defer(); + /** + * Pulls an id from the indexing queue, loads it from the model service, + * and indexes it. Upon completion, tells the provider to keep + * indexing. + * + * @private + */ + GenericSearchProvider.prototype.beginIndexRequest = function () { + var idToIndex = this.idsToIndex.shift(), + provider = this; - // Tell the worker to search for items it has that match this searchInput. - // Takes the searchInput, as well as a max number of results (will return - // less than that if there are fewer matches). - function workerSearch(searchInput, maxResults, timestamp, timeout) { - var message = { - request: 'search', - input: searchInput, - maxNumber: maxResults, - timestamp: timestamp, - timeout: timeout - }; - worker.postMessage(message); - } - - // If the input is nonempty, do a search - if (input !== '' && input !== undefined) { - - // Allow us to access this promise later to resolve it later - pendingQueries[timestamp] = defer; - - // Check to see if the user provided a maximum - // number of results to display - if (!maxResults) { - // Else, we provide a default value - maxResults = DEFAULT_MAX_RESULTS; - } - // Similarly, check if timeout was provided - if (!timeout) { - timeout = DEFAULT_TIMEOUT; + this.pendingRequests += 1; + this.modelService + .getModels([idToIndex]) + .then(function (models) { + delete provider.pendingIndex[idToIndex]; + if (models[idToIndex]) { + provider.index(idToIndex, models[idToIndex]); } + }, function () { + provider + .$log + .warn('Failed to index domain object ' + idToIndex); + }) + .then(function () { + setTimeout(function () { + provider.pendingRequests -= 1; + provider.keepIndexing(); + }, 0); + }); + }; - // Send the query to the worker - workerSearch(input, maxResults, timestamp, timeout); + /** + * Handle messages from the worker. Only really knows how to handle search + * results, which are parsed, transformed into a modelResult object, which + * is used to resolve the corresponding promise. + * @private + */ + GenericSearchProvider.prototype.onWorkerMessage = function (event) { + if (event.data.request !== 'search') { + return; + } - return defer.promise; - } else { - // Otherwise return an empty result - return { hits: [], total: 0 }; - } - }; + var pendingQuery = this.pendingQueries[event.data.queryId], + modelResults = { + total: event.data.total + }; + + modelResults.hits = event.data.results.map(function (hit) { + return { + id: hit.item.id, + model: hit.item.model, + score: hit.matchCount + }; + }); + + pendingQuery.resolve(modelResults); + delete this.pendingQueries[event.data.queryId]; + }; + + /** + * @private + * @returns {Number} a unique, unusued query Id. + */ + GenericSearchProvider.prototype.makeQueryId = function () { + var queryId = Math.ceil(Math.random() * 100000); + while (this.pendingQueries[queryId]) { + queryId = Math.ceil(Math.random() * 100000); + } + return queryId; + }; + + /** + * Dispatch a search query to the worker and return a queryId. + * + * @private + * @returns {Number} a unique query Id for the query. + */ + GenericSearchProvider.prototype.dispatchSearch = function ( + searchInput, + maxResults + ) { + var queryId = this.makeQueryId(); + + this.worker.postMessage({ + request: 'search', + input: searchInput, + maxResults: maxResults, + queryId: queryId + }); + + return queryId; + }; - return GenericSearchProvider; - } -); + return GenericSearchProvider; +}); diff --git a/platform/search/src/services/GenericSearchWorker.js b/platform/search/src/services/GenericSearchWorker.js index 57be98b423..928f66cab8 100644 --- a/platform/search/src/services/GenericSearchWorker.js +++ b/platform/search/src/services/GenericSearchWorker.js @@ -26,133 +26,132 @@ */ (function () { "use strict"; - + // An array of objects composed of domain object IDs and models // {id: domainObject's ID, model: domainObject's model} - var indexedItems = []; - - // Helper function for serach() - function convertToTerms(input) { - var terms = input; - // Shave any spaces off of the ends of the input - while (terms.substr(0, 1) === ' ') { - terms = terms.substring(1, terms.length); - } - while (terms.substr(terms.length - 1, 1) === ' ') { - terms = terms.substring(0, terms.length - 1); - } - - // Then split it at spaces and asterisks - terms = terms.split(/ |\*/); - - // Remove any empty strings from the terms - while (terms.indexOf('') !== -1) { - terms.splice(terms.indexOf(''), 1); - } - - return terms; + var indexedItems = [], + TERM_SPLITTER = /[ _\*]/; + + function indexItem(id, model) { + var vector = { + name: model.name + }; + vector.cleanName = model.name.trim(); + vector.lowerCaseName = vector.cleanName.toLocaleLowerCase(); + vector.terms = vector.lowerCaseName.split(TERM_SPLITTER); + + indexedItems.push({ + id: id, + vector: vector, + model: model + }); } - + // Helper function for search() - function scoreItem(item, input, terms) { - var name = item.model.name.toLocaleLowerCase(), - weight = 0.65, - score = 0.0, - i; - - // Make the score really big if the item name and - // the original search input are the same - if (name === input) { - score = 42; - } - - for (i = 0; i < terms.length; i += 1) { - // Increase the score if the term is in the item name - if (name.indexOf(terms[i]) !== -1) { - score += 1; - - // Add extra to the score if the search term exists - // as its own term within the items - if (name.split(' ').indexOf(terms[i]) !== -1) { - score += 0.5; - } - } - } - - return score * weight; + function convertToTerms(input) { + var query = { + exactInput: input + }; + query.inputClean = input.trim(); + query.inputLowerCase = query.inputClean.toLocaleLowerCase(); + query.terms = query.inputLowerCase.split(TERM_SPLITTER); + query.exactTerms = query.inputClean.split(TERM_SPLITTER); + return query; } - - /** + + /** * Gets search results from the indexedItems based on provided search - * input. Returns matching results from indexedItems, as well as the - * timestamp that was passed to it. - * + * input. Returns matching results from indexedItems + * * @param data An object which contains: * * input: The original string which we are searching with - * * maxNumber: The maximum number of search results desired - * * timestamp: The time identifier from when the query was made + * * maxResults: The maximum number of search results desired + * * queryId: an id identifying this query, will be returned. */ function search(data) { - // This results dictionary will have domain object ID keys which - // point to the value the domain object's score. - var results = {}, - input = data.input.toLocaleLowerCase(), - terms = convertToTerms(input), + // This results dictionary will have domain object ID keys which + // point to the value the domain object's score. + var results, + input = data.input, + query = convertToTerms(input), message = { request: 'search', results: {}, total: 0, - timestamp: data.timestamp, - timedOut: false + queryId: data.queryId }, - score, - i, - id; - - // If the user input is empty, we want to have no search results. - if (input !== '') { - for (i = 0; i < indexedItems.length; i += 1) { - // If this is taking too long, then stop - if (Date.now() > data.timestamp + data.timeout) { - message.timedOut = true; - break; - } - - // Score and add items - score = scoreItem(indexedItems[i], input, terms); - if (score > 0) { - results[indexedItems[i].id] = score; - message.total += 1; - } - } + matches = {}; + + if (!query.inputClean) { + // No search terms, no results; + return message; } - - // Truncate results if there are more than maxResults - if (message.total > data.maxResults) { - i = 0; - for (id in results) { - message.results[id] = results[id]; - i += 1; - if (i >= data.maxResults) { - break; + + // Two phases: find matches, then score matches. + // Idea being that match finding should be fast, so that future scoring + // operations process fewer objects. + + query.terms.forEach(function findMatchingItems(term) { + indexedItems + .filter(function matchesItem(item) { + return item.vector.lowerCaseName.indexOf(term) !== -1; + }) + .forEach(function trackMatch(matchedItem) { + if (!matches[matchedItem.id]) { + matches[matchedItem.id] = { + matchCount: 0, + item: matchedItem + }; + } + matches[matchedItem.id].matchCount += 1; + }); + }); + + // Then, score matching items. + results = Object + .keys(matches) + .map(function asMatches(matchId) { + return matches[matchId]; + }) + .map(function prioritizeExactMatches(match) { + if (match.item.vector.name === query.exactInput) { + match.matchCount += 100; + } else if (match.item.vector.lowerCaseName === + query.inputLowerCase) { + match.matchCount += 50; } - } - // TODO: This seems inefficient. - } else { - message.results = results; - } - + return match; + }) + .map(function prioritizeCompleteTermMatches(match) { + match.item.vector.terms.forEach(function (term) { + if (query.terms.indexOf(term) !== -1) { + match.matchCount += 0.5; + } + }); + return match; + }) + .sort(function compare(a, b) { + if (a.matchCount > b.matchCount) { + return -1; + } + if (a.matchCount < b.matchCount) { + return 1; + } + return 0; + }); + + message.total = results.length; + message.results = results + .slice(0, data.maxResults); + return message; } - + self.onmessage = function (event) { if (event.data.request === 'index') { - indexedItems.push({ - id: event.data.id, - model: event.data.model - }); + indexItem(event.data.id, event.data.model); } else if (event.data.request === 'search') { self.postMessage(search(event.data)); } }; -}()); \ No newline at end of file +}()); diff --git a/platform/search/src/services/SearchAggregator.js b/platform/search/src/services/SearchAggregator.js index 2324090595..00988f81a8 100644 --- a/platform/search/src/services/SearchAggregator.js +++ b/platform/search/src/services/SearchAggregator.js @@ -24,122 +24,201 @@ /** * Module defining SearchAggregator. Created by shale on 07/16/2015. */ -define( - [], - function () { - "use strict"; +define([ - var DEFUALT_TIMEOUT = 1000, - DEFAULT_MAX_RESULTS = 100; - - /** - * Allows multiple services which provide search functionality - * to be treated as one. - * - * @constructor - * @param $q Angular's $q, for promise consolidation. - * @param {SearchProvider[]} providers The search providers to be - * aggregated. - */ - function SearchAggregator($q, providers) { - this.$q = $q; - this.providers = providers; +], function ( + +) { + "use strict"; + + /** + * Aggregates multiple search providers as a singular search provider. + * Search providers are expected to implement a `query` method which returns + * a promise for a `modelResults` object. + * + * The search aggregator combines the results from multiple providers, + * removes aggregates, and converts the results to domain objects. + * + * @constructor + * @param $q Angular's $q, for promise consolidation. + * @param objectService + * @param {SearchProvider[]} providers The search providers to be + * aggregated. + */ + function SearchAggregator($q, objectService, providers) { + this.$q = $q; + this.objectService = objectService; + this.providers = providers; + } + + /** + * If max results is not specified in query, use this as default. + */ + SearchAggregator.prototype.DEFAULT_MAX_RESULTS = 100; + + /** + * Because filtering isn't implemented inside each provider, the fudge + * factor is a multiplier on the number of results returned-- more results + * than requested will be fetched, and then will be filtered. This helps + * provide more predictable pagination when large numbers of results are + * returned but very few results match filters. + * + * If a provider level filter implementation is implemented in the future, + * remove this. + */ + SearchAggregator.prototype.FUDGE_FACTOR = 5; + + /** + * Sends a query to each of the providers. Returns a promise for + * a result object that has the format + * {hits: searchResult[], total: number} + * where a searchResult has the format + * {id: string, object: domainObject, score: number} + * + * @param {String} inputText The text input that is the query. + * @param {Number} maxResults (optional) The maximum number of results + * that this function should return. If not provided, a + * default of 100 will be used. + * @param {Function} [filter] if provided, will be called for every + * potential modelResult. If it returns false, the model result will be + * excluded from the search results. + * @returns {Promise} A Promise for a search result object. + */ + SearchAggregator.prototype.query = function ( + inputText, + maxResults, + filter + ) { + + var aggregator = this, + resultPromises; + + if (!maxResults) { + maxResults = this.DEFAULT_MAX_RESULTS; } - /** - * Sends a query to each of the providers. Returns a promise for - * a result object that has the format - * {hits: searchResult[], total: number, timedOut: boolean} - * where a searchResult has the format - * {id: string, object: domainObject, score: number} - * - * @param inputText The text input that is the query. - * @param maxResults (optional) The maximum number of results - * that this function should return. If not provided, a - * default of 100 will be used. - */ - SearchAggregator.prototype.query = function queryAll(inputText, maxResults) { - var $q = this.$q, - providers = this.providers, - i, - timestamp = Date.now(), - resultPromises = []; + resultPromises = this.providers.map(function (provider) { + return provider.query( + inputText, + maxResults * aggregator.FUDGE_FACTOR + ); + }); - // Remove duplicate objects that have the same ID. Modifies the passed - // array, and returns the number that were removed. - function filterDuplicates(results, total) { - var ids = {}, - numRemoved = 0, - i; + return this.$q + .all(resultPromises) + .then(function (providerResults) { + var modelResults = { + hits: [], + total: 0 + }; - for (i = 0; i < results.length; i += 1) { - if (ids[results[i].id]) { - // If this result's ID is already there, remove the object - results.splice(i, 1); - numRemoved += 1; - - // Reduce loop index because we shortened the array - i -= 1; - } else { - // Otherwise add the ID to the list of the ones we have seen - ids[results[i].id] = true; - } - } - - return numRemoved; - } - - // Order the objects from highest to lowest score in the array. - // Modifies the passed array, as well as returns the modified array. - function orderByScore(results) { - results.sort(function (a, b) { - if (a.score > b.score) { - return -1; - } else if (b.score > a.score) { - return 1; - } else { - return 0; - } + providerResults.forEach(function (providerResult) { + modelResults.hits = + modelResults.hits.concat(providerResult.hits); + modelResults.total += providerResult.total; }); - return results; - } - if (!maxResults) { - maxResults = DEFAULT_MAX_RESULTS; - } + modelResults = aggregator.orderByScore(modelResults); + modelResults = aggregator.applyFilter(modelResults, filter); + modelResults = aggregator.removeDuplicates(modelResults); - // Send the query to all the providers - for (i = 0; i < providers.length; i += 1) { - resultPromises.push( - providers[i].query(inputText, timestamp, maxResults, DEFUALT_TIMEOUT) - ); - } - - // Get promises for results arrays - return $q.all(resultPromises).then(function (resultObjects) { - var results = [], - totalSum = 0, - i; - - // Merge results - for (i = 0; i < resultObjects.length; i += 1) { - results = results.concat(resultObjects[i].hits); - totalSum += resultObjects[i].total; - } - // Order by score first, so that when removing repeats we keep the higher scored ones - orderByScore(results); - totalSum -= filterDuplicates(results, totalSum); - - return { - hits: results, - total: totalSum, - timedOut: resultObjects.some(function (obj) { - return obj.timedOut; - }) - }; + return aggregator.asObjectResults(modelResults); }); - }; + }; - return SearchAggregator; - } -); \ No newline at end of file + /** + * Order model results by score descending and return them. + */ + SearchAggregator.prototype.orderByScore = function (modelResults) { + modelResults.hits.sort(function (a, b) { + if (a.score > b.score) { + return -1; + } else if (b.score > a.score) { + return 1; + } else { + return 0; + } + }); + return modelResults; + }; + + /** + * Apply a filter to each model result, removing it from search results + * if it does not match. + */ + SearchAggregator.prototype.applyFilter = function (modelResults, filter) { + if (!filter) { + return modelResults; + } + var initialLength = modelResults.hits.length, + finalLength, + removedByFilter; + + modelResults.hits = modelResults.hits.filter(function (hit) { + return filter(hit.model); + }); + + finalLength = modelResults.hits.length; + removedByFilter = initialLength - finalLength; + modelResults.total -= removedByFilter; + + return modelResults; + }; + + /** + * Remove duplicate hits in a modelResults object, and decrement `total` + * each time a duplicate is removed. + */ + SearchAggregator.prototype.removeDuplicates = function (modelResults) { + var includedIds = {}; + + modelResults.hits = modelResults + .hits + .filter(function alreadyInResults(hit) { + if (includedIds[hit.id]) { + modelResults.total -= 1; + return false; + } + includedIds[hit.id] = true; + return true; + }); + + return modelResults; + }; + + /** + * Convert modelResults to objectResults by fetching them from the object + * service. + * + * @returns {Promise} for an objectResults object. + */ + SearchAggregator.prototype.asObjectResults = function (modelResults) { + var objectIds = modelResults.hits.map(function (modelResult) { + return modelResult.id; + }); + + return this + .objectService + .getObjects(objectIds) + .then(function (objects) { + + var objectResults = { + total: modelResults.total + }; + + objectResults.hits = modelResults + .hits + .map(function asObjectResult(hit) { + return { + id: hit.id, + object: objects[hit.id], + score: hit.score + }; + }); + + return objectResults; + }); + }; + + return SearchAggregator; +}); diff --git a/platform/search/test/controllers/SearchControllerSpec.js b/platform/search/test/controllers/SearchControllerSpec.js index 720d9bd64a..a755594d58 100644 --- a/platform/search/test/controllers/SearchControllerSpec.js +++ b/platform/search/test/controllers/SearchControllerSpec.js @@ -4,12 +4,12 @@ * Administration. All rights reserved. * * Open MCT Web is licensed under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance with the License. + * 'License'); you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0. * * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. @@ -24,185 +24,162 @@ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - ["../../src/controllers/SearchController"], - function (SearchController) { - "use strict"; +define([ + '../../src/controllers/SearchController' +], function ( + SearchController +) { + 'use strict'; - // These should be the same as the ones on the top of the search controller - var INITIAL_LOAD_NUMBER = 20, - LOAD_INCREMENT = 20; - - describe("The search controller", function () { - var mockScope, - mockSearchService, - mockPromise, - mockSearchResult, - mockDomainObject, - mockTypes, - controller; + describe('The search controller', function () { + var mockScope, + mockSearchService, + mockPromise, + mockSearchResult, + mockDomainObject, + mockTypes, + controller; - function bigArray(size) { - var array = [], - i; - for (i = 0; i < size; i += 1) { - array.push(mockSearchResult); - } - return array; + function bigArray(size) { + var array = [], + i; + for (i = 0; i < size; i += 1) { + array.push(mockSearchResult); } - - - beforeEach(function () { - mockScope = jasmine.createSpyObj( - "$scope", - [ "$watch" ] - ); - mockScope.ngModel = {}; - mockScope.ngModel.input = "test input"; - mockScope.ngModel.checked = {}; - mockScope.ngModel.checked['mock.type'] = true; + return array; + } + + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + '$scope', + [ '$watch' ] + ); + mockScope.ngModel = {}; + mockScope.ngModel.input = 'test input'; + mockScope.ngModel.checked = {}; + mockScope.ngModel.checked['mock.type'] = true; + mockScope.ngModel.checkAll = true; + + mockSearchService = jasmine.createSpyObj( + 'searchService', + [ 'query' ] + ); + mockPromise = jasmine.createSpyObj( + 'promise', + [ 'then' ] + ); + mockSearchService.query.andReturn(mockPromise); + + mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}]; + + mockSearchResult = jasmine.createSpyObj( + 'searchResult', + [ '' ] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getModel' ] + ); + mockSearchResult.object = mockDomainObject; + mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'}); + + controller = new SearchController(mockScope, mockSearchService, mockTypes); + controller.search(); + }); + + it('has a default number of results per page', function () { + expect(controller.RESULTS_PER_PAGE).toBe(20); + }); + + it('sends queries to the search service', function () { + expect(mockSearchService.query).toHaveBeenCalledWith( + 'test input', + controller.RESULTS_PER_PAGE, + jasmine.any(Function) + ); + }); + + describe('filter query function', function () { + it('returns true when all types allowed', function () { mockScope.ngModel.checkAll = true; - - mockSearchService = jasmine.createSpyObj( - "searchService", - [ "query" ] - ); - mockPromise = jasmine.createSpyObj( - "promise", - [ "then" ] - ); - mockSearchService.query.andReturn(mockPromise); - - mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}]; - - mockSearchResult = jasmine.createSpyObj( - "searchResult", - [ "" ] - ); - mockDomainObject = jasmine.createSpyObj( - "domainObject", - [ "getModel" ] - ); - mockSearchResult.object = mockDomainObject; - mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'}); - - controller = new SearchController(mockScope, mockSearchService, mockTypes); - controller.search(); - }); - - it("sends queries to the search service", function () { - expect(mockSearchService.query).toHaveBeenCalled(); - }); - - it("populates the results with results from the search service", function () { - expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); - mockPromise.then.mostRecentCall.args[0]({hits: []}); - - expect(mockScope.results).toBeDefined(); - }); - - it("is loading until the service's promise fufills", function () { - // Send query - controller.search(); - expect(mockScope.loading).toBeTruthy(); - - // Then resolve the promises - mockPromise.then.mostRecentCall.args[0]({hits: []}); - expect(mockScope.loading).toBeFalsy(); + controller.onFilterChange(); + var filterFn = mockSearchService.query.mostRecentCall.args[2]; + expect(filterFn('askbfa')).toBe(true); }); - - it("displays only some results when there are many", function () { - expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); - mockPromise.then.mostRecentCall.args[0]({hits: bigArray(100)}); - - expect(mockScope.results).toBeDefined(); - expect(mockScope.results.length).toBeLessThan(100); - }); - - it("detects when there are more results", function () { + it('returns true only for matching checked types', function () { mockScope.ngModel.checkAll = false; - - expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + 5), - total: INITIAL_LOAD_NUMBER + 5 - }); - // bigArray gives searchResults of type 'mock.type' - mockScope.ngModel.checked['mock.type'] = false; - mockScope.ngModel.checked['mock.type.2'] = true; - - expect(controller.areMore()).toBeFalsy(); - - mockScope.ngModel.checked['mock.type'] = true; - - expect(controller.areMore()).toBeTruthy(); - }); - - it("can load more results", function () { - var oldSize; - - expect(mockPromise.then).toHaveBeenCalled(); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1), - total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1 - }); - // These hits and total lengths are the case where the controller - // DOES NOT have to re-search to load more results - oldSize = mockScope.results.length; - - expect(controller.areMore()).toBeTruthy(); - - controller.loadMore(); - expect(mockScope.results.length).toBeGreaterThan(oldSize); - }); - - it("can re-search to load more results", function () { - var oldSize, - oldCallCount; - - expect(mockPromise.then).toHaveBeenCalled(); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT - 1), - total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1 - }); - // These hits and total lengths are the case where the controller - // DOES have to re-search to load more results - oldSize = mockScope.results.length; - oldCallCount = mockPromise.then.callCount; - expect(controller.areMore()).toBeTruthy(); - - controller.loadMore(); - expect(mockPromise.then).toHaveBeenCalled(); - // Make sure that a NEW call to search has been made - expect(oldCallCount).toBeLessThan(mockPromise.then.callCount); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1), - total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1 - }); - expect(mockScope.results.length).toBeGreaterThan(oldSize); - }); - - it("sets the ngModel.search flag", function () { - // Flag should be true with nonempty input - expect(mockScope.ngModel.search).toEqual(true); - - // Flag should be flase with empty input - mockScope.ngModel.input = ""; - controller.search(); - mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); - expect(mockScope.ngModel.search).toEqual(false); - - // Both the empty string and undefined should be 'empty input' - mockScope.ngModel.input = undefined; - controller.search(); - mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); - expect(mockScope.ngModel.search).toEqual(false); - }); - - it("has a default results list to filter from", function () { - expect(mockScope.ngModel.filter()).toBeDefined(); + controller.onFilterChange(); + var filterFn = mockSearchService.query.mostRecentCall.args[2]; + expect(filterFn({type: 'mock.type'})).toBe(true); + expect(filterFn({type: 'other.type'})).toBe(false); }); }); - } -); \ No newline at end of file + + it('populates the results with results from the search service', function () { + expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); + mockPromise.then.mostRecentCall.args[0]({hits: ['a']}); + + expect(mockScope.results.length).toBe(1); + expect(mockScope.results).toContain('a'); + }); + + it('is loading until the service\'s promise fufills', function () { + expect(mockScope.loading).toBeTruthy(); + + // Then resolve the promises + mockPromise.then.mostRecentCall.args[0]({hits: []}); + expect(mockScope.loading).toBeFalsy(); + }); + + it('detects when there are more results', function () { + mockPromise.then.mostRecentCall.args[0]({ + hits: bigArray(controller.RESULTS_PER_PAGE), + total: controller.RESULTS_PER_PAGE + 5 + }); + + expect(mockScope.results.length).toBe(controller.RESULTS_PER_PAGE); + expect(controller.areMore()).toBeTruthy(); + + controller.loadMore(); + + expect(mockSearchService.query).toHaveBeenCalledWith( + 'test input', + controller.RESULTS_PER_PAGE * 2, + jasmine.any(Function) + ); + + mockPromise.then.mostRecentCall.args[0]({ + hits: bigArray(controller.RESULTS_PER_PAGE + 5), + total: controller.RESULTS_PER_PAGE + 5 + }); + + expect(mockScope.results.length) + .toBe(controller.RESULTS_PER_PAGE + 5); + + expect(controller.areMore()).toBe(false); + }); + + it('sets the ngModel.search flag', function () { + // Flag should be true with nonempty input + expect(mockScope.ngModel.search).toEqual(true); + + // Flag should be flase with empty input + mockScope.ngModel.input = ''; + controller.search(); + mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); + expect(mockScope.ngModel.search).toEqual(false); + + // Both the empty string and undefined should be 'empty input' + mockScope.ngModel.input = undefined; + controller.search(); + mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); + expect(mockScope.ngModel.search).toEqual(false); + }); + + it('attaches a filter function to scope', function () { + expect(mockScope.ngModel.filter).toEqual(jasmine.any(Function)); + }); + }); +}); diff --git a/platform/search/test/services/GenericSearchProviderSpec.js b/platform/search/test/services/GenericSearchProviderSpec.js index e3ee0a97ba..cc80e4210d 100644 --- a/platform/search/test/services/GenericSearchProviderSpec.js +++ b/platform/search/test/services/GenericSearchProviderSpec.js @@ -19,275 +19,321 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define,describe,it,expect,beforeEach,jasmine*/ +/*global define,describe,it,expect,beforeEach,jasmine,Promise,spyOn,waitsFor, + runs*/ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - ["../../src/services/GenericSearchProvider"], - function (GenericSearchProvider) { - "use strict"; +define([ + "../../src/services/GenericSearchProvider" +], function ( + GenericSearchProvider +) { + "use strict"; - describe("The generic search provider ", function () { - var mockQ, - mockLog, - mockThrottle, - mockDeferred, - mockObjectService, - mockObjectPromise, - mockChainedPromise, - mockDomainObjects, - mockCapability, - mockCapabilityPromise, - mockWorkerService, - mockWorker, - mockTopic, - mockMutationTopic, - mockRoots = ['root1', 'root2'], - mockThrottledFn, - throttledCallCount, - provider, - mockProviderResults; + describe('GenericSearchProvider', function () { + var $q, + $log, + modelService, + models, + workerService, + worker, + topic, + mutationTopic, + ROOTS, + provider; - function resolveObjectPromises() { - var i; - for (i = 0; i < mockObjectPromise.then.calls.length; i += 1) { - mockChainedPromise.then.calls[i].args[0]( - mockObjectPromise.then.calls[i] - .args[0](mockDomainObjects) - ); - } - } + beforeEach(function () { + $q = jasmine.createSpyObj( + '$q', + ['defer'] + ); + $log = jasmine.createSpyObj( + '$log', + ['warn'] + ); + models = {}; + modelService = jasmine.createSpyObj( + 'modelService', + ['getModels'] + ); + modelService.getModels.andReturn(Promise.resolve(models)); + workerService = jasmine.createSpyObj( + 'workerService', + ['run'] + ); + worker = jasmine.createSpyObj( + 'worker', + [ + 'postMessage', + 'addEventListener' + ] + ); + workerService.run.andReturn(worker); + topic = jasmine.createSpy('topic'); + mutationTopic = jasmine.createSpyObj( + 'mutationTopic', + ['listen'] + ); + topic.andReturn(mutationTopic); + ROOTS = [ + 'mine' + ]; - function resolveThrottledFn() { - if (mockThrottledFn.calls.length > throttledCallCount) { - mockThrottle.mostRecentCall.args[0](); - throttledCallCount = mockThrottledFn.calls.length; - } - } + spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing'); - function resolveAsyncTasks() { - resolveThrottledFn(); - resolveObjectPromises(); - } + provider = new GenericSearchProvider( + $q, + $log, + modelService, + workerService, + topic, + ROOTS + ); + }); + + it('listens for general mutation', function () { + expect(topic).toHaveBeenCalledWith('mutation'); + expect(mutationTopic.listen) + .toHaveBeenCalledWith(jasmine.any(Function)); + }); + + it('reschedules indexing when mutation occurs', function () { + var mockDomainObject = + jasmine.createSpyObj('domainObj', ['getId']); + mockDomainObject.getId.andReturn("some-id"); + mutationTopic.listen.mostRecentCall.args[0](mockDomainObject); + expect(provider.scheduleForIndexing).toHaveBeenCalledWith('some-id'); + }); + + it('starts indexing roots', function () { + expect(provider.scheduleForIndexing).toHaveBeenCalledWith('mine'); + }); + + it('runs a worker', function () { + expect(workerService.run) + .toHaveBeenCalledWith('genericSearchWorker'); + }); + + it('listens for messages from worker', function () { + expect(worker.addEventListener) + .toHaveBeenCalledWith('message', jasmine.any(Function)); + spyOn(provider, 'onWorkerMessage'); + worker.addEventListener.mostRecentCall.args[1]('mymessage'); + expect(provider.onWorkerMessage).toHaveBeenCalledWith('mymessage'); + }); + + it('has a maximum number of concurrent requests', function () { + expect(provider.MAX_CONCURRENT_REQUESTS).toBe(100); + }); + + describe('scheduleForIndexing', function () { + beforeEach(function () { + provider.scheduleForIndexing.andCallThrough(); + spyOn(provider, 'keepIndexing'); + }); + + it('tracks ids to index', function () { + expect(provider.indexedIds.a).not.toBeDefined(); + expect(provider.pendingIndex.a).not.toBeDefined(); + expect(provider.idsToIndex).not.toContain('a'); + provider.scheduleForIndexing('a'); + expect(provider.indexedIds.a).toBeDefined(); + expect(provider.pendingIndex.a).toBeDefined(); + expect(provider.idsToIndex).toContain('a'); + }); + + it('calls keep indexing', function () { + provider.scheduleForIndexing('a'); + expect(provider.keepIndexing).toHaveBeenCalled(); + }); + }); + + describe('keepIndexing', function () { + it('calls beginIndexRequest until at maximum', function () { + spyOn(provider, 'beginIndexRequest').andCallThrough(); + provider.pendingRequests = 9; + provider.idsToIndex = ['a', 'b', 'c']; + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).toHaveBeenCalled(); + expect(provider.beginIndexRequest.calls.length).toBe(1); + }); + + it('calls beginIndexRequest for all ids to index', function () { + spyOn(provider, 'beginIndexRequest').andCallThrough(); + provider.pendingRequests = 0; + provider.idsToIndex = ['a', 'b', 'c']; + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).toHaveBeenCalled(); + expect(provider.beginIndexRequest.calls.length).toBe(3); + }); + + it('does not index when at capacity', function () { + spyOn(provider, 'beginIndexRequest'); + provider.pendingRequests = 10; + provider.idsToIndex.push('a'); + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).not.toHaveBeenCalled(); + }); + + it('does not index when no ids to index', function () { + spyOn(provider, 'beginIndexRequest'); + provider.pendingRequests = 0; + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).not.toHaveBeenCalled(); + }); + }); + + describe('index', function () { + it('sends index message to worker', function () { + var id = 'anId', + model = {}; + + provider.index(id, model); + expect(worker.postMessage).toHaveBeenCalledWith({ + request: 'index', + id: id, + model: model + }); + }); + + it('schedules composed ids for indexing', function () { + var id = 'anId', + model = {composition: ['abc', 'def']}; + + provider.index(id, model); + expect(provider.scheduleForIndexing) + .toHaveBeenCalledWith('abc'); + expect(provider.scheduleForIndexing) + .toHaveBeenCalledWith('def'); + }); + }); + + describe('beginIndexRequest', function () { beforeEach(function () { - mockQ = jasmine.createSpyObj( - "$q", - [ "defer" ] - ); - mockLog = jasmine.createSpyObj( - "$log", - [ "error", "warn", "info", "debug" ] - ); - mockDeferred = jasmine.createSpyObj( - "deferred", - [ "resolve", "reject"] - ); - mockDeferred.promise = "mock promise"; - mockQ.defer.andReturn(mockDeferred); - - mockThrottle = jasmine.createSpy("throttle"); - mockThrottledFn = jasmine.createSpy("throttledFn"); - throttledCallCount = 0; - - mockObjectService = jasmine.createSpyObj( - "objectService", - [ "getObjects" ] - ); - mockObjectPromise = jasmine.createSpyObj( - "promise", - [ "then", "catch" ] - ); - mockChainedPromise = jasmine.createSpyObj( - "chainedPromise", - [ "then" ] - ); - mockObjectService.getObjects.andReturn(mockObjectPromise); - - mockTopic = jasmine.createSpy('topic'); - - mockWorkerService = jasmine.createSpyObj( - "workerService", - [ "run" ] - ); - mockWorker = jasmine.createSpyObj( - "worker", - [ "postMessage" ] - ); - mockWorkerService.run.andReturn(mockWorker); - - mockCapabilityPromise = jasmine.createSpyObj( - "promise", - [ "then", "catch" ] - ); - - mockDomainObjects = {}; - ['a', 'root1', 'root2'].forEach(function (id) { - mockDomainObjects[id] = ( - jasmine.createSpyObj( - "domainObject", - [ - "getId", - "getModel", - "hasCapability", - "getCapability", - "useCapability" - ] - ) - ); - mockDomainObjects[id].getId.andReturn(id); - mockDomainObjects[id].getCapability.andReturn(mockCapability); - mockDomainObjects[id].useCapability.andReturn(mockCapabilityPromise); - mockDomainObjects[id].getModel.andReturn({}); - }); - - mockCapability = jasmine.createSpyObj( - "capability", - [ "invoke", "listen" ] - ); - mockCapability.invoke.andReturn(mockCapabilityPromise); - mockDomainObjects.a.getCapability.andReturn(mockCapability); - mockMutationTopic = jasmine.createSpyObj( - 'mutationTopic', - [ 'listen' ] - ); - mockTopic.andCallFake(function (key) { - return key === 'mutation' && mockMutationTopic; - }); - mockThrottle.andReturn(mockThrottledFn); - mockObjectPromise.then.andReturn(mockChainedPromise); - - provider = new GenericSearchProvider( - mockQ, - mockLog, - mockThrottle, - mockObjectService, - mockWorkerService, - mockTopic, - mockRoots - ); + provider.pendingRequests = 0; + provider.pendingIds = {'abc': true}; + provider.idsToIndex = ['abc']; + models.abc = {}; + spyOn(provider, 'index'); }); - it("indexes tree on initialization", function () { - var i; + it('removes items from queue', function () { + provider.beginIndexRequest(); + expect(provider.idsToIndex.length).toBe(0); + }); - resolveThrottledFn(); - - expect(mockObjectService.getObjects).toHaveBeenCalled(); - expect(mockObjectPromise.then).toHaveBeenCalled(); - - // Call through the root-getting part - resolveObjectPromises(); - - mockRoots.forEach(function (id) { - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: 'index', - model: mockDomainObjects[id].getModel(), - id: id - }); + it('tracks number of pending requests', function () { + provider.beginIndexRequest(); + expect(provider.pendingRequests).toBe(1); + waitsFor(function () { + return provider.pendingRequests === 0; + }); + runs(function () { + expect(provider.pendingRequests).toBe(0); }); }); - it("indexes members of composition", function () { - mockDomainObjects.root1.getModel.andReturn({ - composition: ['a'] + it('indexes objects', function () { + provider.beginIndexRequest(); + waitsFor(function () { + return provider.pendingRequests === 0; }); - - resolveAsyncTasks(); - resolveAsyncTasks(); - - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: 'index', - model: mockDomainObjects.a.getModel(), - id: 'a' + runs(function () { + expect(provider.index) + .toHaveBeenCalledWith('abc', models.abc); }); }); - it("listens for changes to mutation", function () { - expect(mockMutationTopic.listen) - .toHaveBeenCalledWith(jasmine.any(Function)); - mockMutationTopic.listen.mostRecentCall - .args[0](mockDomainObjects.a); - - resolveAsyncTasks(); - - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: 'index', - model: mockDomainObjects.a.getModel(), - id: mockDomainObjects.a.getId() - }); - }); - - it("sends search queries to the worker", function () { - var timestamp = Date.now(); - provider.query(' test "query" ', timestamp, 1, 2); - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: "search", - input: ' test "query" ', - timestamp: timestamp, - maxNumber: 1, - timeout: 2 - }); - }); - - it("gives an empty result for an empty query", function () { - var timestamp = Date.now(), - queryOutput; - - queryOutput = provider.query('', timestamp, 1, 2); - expect(queryOutput.hits).toEqual([]); - expect(queryOutput.total).toEqual(0); - - queryOutput = provider.query(); - expect(queryOutput.hits).toEqual([]); - expect(queryOutput.total).toEqual(0); - }); - - it("handles responses from the worker", function () { - var timestamp = Date.now(), - event = { - data: { - request: "search", - results: { - 1: 1, - 2: 2 - }, - total: 2, - timedOut: false, - timestamp: timestamp - } - }; - - provider.query(' test "query" ', timestamp); - mockWorker.onmessage(event); - mockObjectPromise.then.mostRecentCall.args[0](mockDomainObjects); - expect(mockDeferred.resolve).toHaveBeenCalled(); - }); - - it("warns when objects are unavailable", function () { - resolveAsyncTasks(); - expect(mockLog.warn).not.toHaveBeenCalled(); - mockChainedPromise.then.mostRecentCall.args[0]( - mockObjectPromise.then.mostRecentCall.args[1]() - ); - expect(mockLog.warn).toHaveBeenCalled(); - }); - - it("throttles the loading of objects to index", function () { - expect(mockObjectService.getObjects).not.toHaveBeenCalled(); - resolveThrottledFn(); - expect(mockObjectService.getObjects).toHaveBeenCalled(); - }); - - it("logs when all objects have been processed", function () { - expect(mockLog.info).not.toHaveBeenCalled(); - resolveAsyncTasks(); - resolveThrottledFn(); - expect(mockLog.info).toHaveBeenCalled(); - }); - }); - } -); + + + it('can dispatch searches to worker', function () { + spyOn(provider, 'makeQueryId').andReturn(428); + expect(provider.dispatchSearch('searchTerm', 100)) + .toBe(428); + + expect(worker.postMessage).toHaveBeenCalledWith({ + request: 'search', + input: 'searchTerm', + maxResults: 100, + queryId: 428 + }); + }); + + it('can generate queryIds', function () { + expect(provider.makeQueryId()).toEqual(jasmine.any(Number)); + }); + + it('can query for terms', function () { + var deferred = {promise: {}}; + spyOn(provider, 'dispatchSearch').andReturn(303); + $q.defer.andReturn(deferred); + + expect(provider.query('someTerm', 100)).toBe(deferred.promise); + expect(provider.pendingQueries[303]).toBe(deferred); + }); + + describe('onWorkerMessage', function () { + var pendingQuery; + beforeEach(function () { + pendingQuery = jasmine.createSpyObj( + 'pendingQuery', + ['resolve'] + ); + provider.pendingQueries[143] = pendingQuery; + }); + + it('resolves pending searches', function () { + provider.onWorkerMessage({ + data: { + request: 'search', + total: 2, + results: [ + { + item: { + id: 'abc', + model: {id: 'abc'} + }, + matchCount: 4 + }, + { + item: { + id: 'def', + model: {id: 'def'} + }, + matchCount: 2 + } + ], + queryId: 143 + } + }); + + expect(pendingQuery.resolve) + .toHaveBeenCalledWith({ + total: 2, + hits: [{ + id: 'abc', + model: {id: 'abc'}, + score: 4 + }, { + id: 'def', + model: {id: 'def'}, + score: 2 + }] + }); + + expect(provider.pendingQueries[143]).not.toBeDefined(); + + }); + + }); + + }); +}); diff --git a/platform/search/test/services/GenericSearchWorkerSpec.js b/platform/search/test/services/GenericSearchWorkerSpec.js index b95ec5a1bb..20afb4c781 100644 --- a/platform/search/test/services/GenericSearchWorkerSpec.js +++ b/platform/search/test/services/GenericSearchWorkerSpec.js @@ -4,12 +4,12 @@ * Administration. All rights reserved. * * Open MCT Web is licensed under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance with the License. + * 'License'); you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0. * * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. @@ -19,114 +19,205 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,require*/ +/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker, + require,afterEach*/ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - [], - function () { - "use strict"; +define([ - describe("The generic search worker ", function () { - // If this test fails, make sure this path is correct - var worker = new Worker(require.toUrl('platform/search/src/services/GenericSearchWorker.js')), - numObjects = 5; - - beforeEach(function () { - var i; - for (i = 0; i < numObjects; i += 1) { - worker.postMessage( - { - request: "index", - id: i, - model: { - name: "object " + i, - id: i, - type: "something" - } - } - ); - } - }); - - it("searches can reach all objects", function () { - var flag = false, - workerOutput, - resultsLength = 0; - - // Search something that should return all objects - runs(function () { - worker.postMessage( - { - request: "search", - input: "object", - maxNumber: 100, - timestamp: Date.now(), - timeout: 1000 - } - ); - }); - - worker.onmessage = function (event) { - var id; - - workerOutput = event.data; - for (id in workerOutput.results) { - resultsLength += 1; - } - flag = true; - }; - - waitsFor(function () { - return flag; - }, "The worker should be searching", 1000); - - runs(function () { - expect(workerOutput).toBeDefined(); - expect(resultsLength).toEqual(numObjects); +], function ( + +) { + 'use strict'; + + describe('GenericSearchWorker', function () { + // If this test fails, make sure this path is correct + var worker, + objectX, + objectY, + objectZ, + itemsToIndex, + onMessage, + data, + waitForResult; + + beforeEach(function () { + worker = new Worker( + require.toUrl('platform/search/src/services/GenericSearchWorker.js') + ); + + objectX = { + id: 'x', + model: {name: 'object xx'} + }; + objectY = { + id: 'y', + model: {name: 'object yy'} + }; + objectZ = { + id: 'z', + model: {name: 'object zz'} + }; + itemsToIndex = [ + objectX, + objectY, + objectZ + ]; + + itemsToIndex.forEach(function (item) { + worker.postMessage({ + request: 'index', + id: item.id, + model: item.model }); }); - - it("searches return only matches", function () { - var flag = false, - workerOutput, - resultsLength = 0; - - // Search something that should return 1 object - runs(function () { - worker.postMessage( - { - request: "search", - input: "2", - maxNumber: 100, - timestamp: Date.now(), - timeout: 1000 - } - ); - }); - - worker.onmessage = function (event) { - var id; - - workerOutput = event.data; - for (id in workerOutput.results) { - resultsLength += 1; - } - flag = true; - }; - + + onMessage = jasmine.createSpy('onMessage'); + worker.addEventListener('message', onMessage); + + waitForResult = function () { waitsFor(function () { - return flag; - }, "The worker should be searching", 1000); - - runs(function () { - expect(workerOutput).toBeDefined(); - expect(resultsLength).toEqual(1); - expect(workerOutput.results[2]).toBeDefined(); + if (onMessage.calls.length > 0) { + data = onMessage.calls[0].args[0].data; + return true; + } + return false; }); + }; + }); + + afterEach(function () { + worker.terminate(); + }); + + it('returns search results for partial term matches', function () { + + worker.postMessage({ + request: 'search', + input: 'obj', + maxResults: 100, + queryId: 123 + }); + + waitForResult(); + + runs(function () { + expect(onMessage).toHaveBeenCalled(); + + expect(data.request).toBe('search'); + expect(data.total).toBe(3); + expect(data.queryId).toBe(123); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].item.model).toEqual(objectX.model); + expect(data.results[0].matchCount).toBe(1); + expect(data.results[1].item.id).toBe('y'); + expect(data.results[1].item.model).toEqual(objectY.model); + expect(data.results[1].matchCount).toBe(1); + expect(data.results[2].item.id).toBe('z'); + expect(data.results[2].item.model).toEqual(objectZ.model); + expect(data.results[2].matchCount).toBe(1); }); }); - } -); \ No newline at end of file + + it('scores exact term matches higher', function () { + worker.postMessage({ + request: 'search', + input: 'object', + maxResults: 100, + queryId: 234 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(234); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(1.5); + }); + }); + + it('can find partial term matches', function () { + worker.postMessage({ + request: 'search', + input: 'x', + maxResults: 100, + queryId: 345 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(345); + expect(data.results.length).toBe(1); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(1); + }); + }); + + it('matches individual terms', function () { + worker.postMessage({ + request: 'search', + input: 'x y z', + maxResults: 100, + queryId: 456 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(456); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(1); + expect(data.results[1].item.id).toBe('y'); + expect(data.results[1].matchCount).toBe(1); + expect(data.results[2].item.id).toBe('z'); + expect(data.results[1].matchCount).toBe(1); + }); + }); + + it('scores exact matches highest', function () { + worker.postMessage({ + request: 'search', + input: 'object xx', + maxResults: 100, + queryId: 567 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(567); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(103); + expect(data.results[1].matchCount).toBe(1.5); + expect(data.results[2].matchCount).toBe(1.5); + }); + }); + + it('scores multiple term match above single match', function () { + worker.postMessage({ + request: 'search', + input: 'obj x', + maxResults: 100, + queryId: 678 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(678); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(2); + expect(data.results[1].matchCount).toBe(1); + expect(data.results[2].matchCount).toBe(1); + }); + }); + }); +}); diff --git a/platform/search/test/services/SearchAggregatorSpec.js b/platform/search/test/services/SearchAggregatorSpec.js index 3205f0f9ec..f8bee0dcc0 100644 --- a/platform/search/test/services/SearchAggregatorSpec.js +++ b/platform/search/test/services/SearchAggregatorSpec.js @@ -19,83 +19,244 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define,describe,it,expect,beforeEach,jasmine*/ +/*global define,describe,it,expect,beforeEach,jasmine,Promise,waitsFor,spyOn*/ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - ["../../src/services/SearchAggregator"], - function (SearchAggregator) { - "use strict"; +define([ + "../../src/services/SearchAggregator" +], function (SearchAggregator) { + "use strict"; - describe("The search aggregator ", function () { - var mockQ, - mockPromise, - mockProviders = [], - aggregator, - mockProviderResults = [], - mockAggregatorResults, - i; + describe("SearchAggregator", function () { + var $q, + objectService, + providers, + aggregator; - beforeEach(function () { - mockQ = jasmine.createSpyObj( - "$q", - [ "all" ] - ); - mockPromise = jasmine.createSpyObj( - "promise", - [ "then" ] - ); - for (i = 0; i < 3; i += 1) { - mockProviders.push( - jasmine.createSpyObj( - "mockProvider" + i, - [ "query" ] - ) - ); - mockProviders[i].query.andReturn(mockPromise); - } - mockQ.all.andReturn(mockPromise); - - aggregator = new SearchAggregator(mockQ, mockProviders); - aggregator.query(); - - for (i = 0; i < mockProviders.length; i += 1) { - mockProviderResults.push({ - hits: [ - { - id: i, - score: 42 - i - }, - { - id: i + 1, - score: 42 - (2 * i) - } - ] - }); - } - mockAggregatorResults = mockPromise.then.mostRecentCall.args[0](mockProviderResults); - }); - - it("sends queries to all providers", function () { - for (i = 0; i < mockProviders.length; i += 1) { - expect(mockProviders[i].query).toHaveBeenCalled(); - } - }); - - it("filters out duplicate objects", function () { - expect(mockAggregatorResults.hits.length).toEqual(mockProviders.length + 1); - expect(mockAggregatorResults.total).not.toBeLessThan(mockAggregatorResults.hits.length); - }); - - it("orders results by score", function () { - for (i = 1; i < mockAggregatorResults.hits.length; i += 1) { - expect(mockAggregatorResults.hits[i].score) - .not.toBeGreaterThan(mockAggregatorResults.hits[i - 1].score); - } - }); - + beforeEach(function () { + $q = jasmine.createSpyObj( + '$q', + ['all'] + ); + $q.all.andReturn(Promise.resolve([])); + objectService = jasmine.createSpyObj( + 'objectService', + ['getObjects'] + ); + providers = []; + aggregator = new SearchAggregator($q, objectService, providers); }); - } -); \ No newline at end of file + + it("has a fudge factor", function () { + expect(aggregator.FUDGE_FACTOR).toBe(5); + }); + + it("has default max results", function () { + expect(aggregator.DEFAULT_MAX_RESULTS).toBe(100); + }); + + it("can order model results by score", function () { + var modelResults = { + hits: [ + {score: 1}, + {score: 23}, + {score: 11} + ] + }, + sorted = aggregator.orderByScore(modelResults); + + expect(sorted.hits).toEqual([ + {score: 23}, + {score: 11}, + {score: 1} + ]); + }); + + it('filters results without a function', function () { + var modelResults = { + hits: [ + {thing: 1}, + {thing: 2} + ], + total: 2 + }, + filtered = aggregator.applyFilter(modelResults); + + expect(filtered.hits).toEqual([ + {thing: 1}, + {thing: 2} + ]); + + expect(filtered.total).toBe(2); + }); + + it('filters results with a function', function () { + var modelResults = { + hits: [ + {model: {thing: 1}}, + {model: {thing: 2}}, + {model: {thing: 3}} + ], + total: 3 + }, + filterFunc = function (model) { + return model.thing < 2; + }, + filtered = aggregator.applyFilter(modelResults, filterFunc); + + expect(filtered.hits).toEqual([ + {model: {thing: 1}} + ]); + expect(filtered.total).toBe(1); + }); + + it('can remove duplicates', function () { + var modelResults = { + hits: [ + {id: 15}, + {id: 23}, + {id: 14}, + {id: 23} + ], + total: 4 + }, + deduped = aggregator.removeDuplicates(modelResults); + + expect(deduped.hits).toEqual([ + {id: 15}, + {id: 23}, + {id: 14} + ]); + expect(deduped.total).toBe(3); + }); + + it('can convert model results to object results', function () { + var modelResults = { + hits: [ + {id: 123, score: 5}, + {id: 234, score: 1} + ], + total: 2 + }, + objects = { + 123: '123-object-hey', + 234: '234-object-hello' + }, + promiseChainComplete = false; + + objectService.getObjects.andReturn(Promise.resolve(objects)); + + aggregator + .asObjectResults(modelResults) + .then(function (objectResults) { + expect(objectResults).toEqual({ + hits: [ + {id: 123, score: 5, object: '123-object-hey'}, + {id: 234, score: 1, object: '234-object-hello'} + ], + total: 2 + }); + }) + .then(function () { + promiseChainComplete = true; + }); + + waitsFor(function () { + return promiseChainComplete; + }); + }); + + it('can send queries to providers', function () { + var provider = jasmine.createSpyObj( + 'provider', + ['query'] + ); + provider.query.andReturn('i prooomise!'); + providers.push(provider); + + aggregator.query('find me', 123, 'filter'); + expect(provider.query) + .toHaveBeenCalledWith( + 'find me', + 123 * aggregator.FUDGE_FACTOR + ); + expect($q.all).toHaveBeenCalledWith(['i prooomise!']); + }); + + it('supplies max results when none is provided', function () { + var provider = jasmine.createSpyObj( + 'provider', + ['query'] + ); + providers.push(provider); + aggregator.query('find me'); + expect(provider.query).toHaveBeenCalledWith( + 'find me', + aggregator.DEFAULT_MAX_RESULTS * aggregator.FUDGE_FACTOR + ); + }); + + it('can combine responses from multiple providers', function () { + var providerResponses = [ + { + hits: [ + 'oneHit', + 'twoHit' + ], + total: 2 + }, + { + hits: [ + 'redHit', + 'blueHit', + 'by', + 'Pete' + ], + total: 4 + } + ], + promiseChainResolved = false; + + $q.all.andReturn(Promise.resolve(providerResponses)); + spyOn(aggregator, 'orderByScore').andReturn('orderedByScore!'); + spyOn(aggregator, 'applyFilter').andReturn('filterApplied!'); + spyOn(aggregator, 'removeDuplicates') + .andReturn('duplicatesRemoved!'); + spyOn(aggregator, 'asObjectResults').andReturn('objectResults'); + + aggregator + .query('something', 10, 'filter') + .then(function (objectResults) { + expect(aggregator.orderByScore).toHaveBeenCalledWith({ + hits: [ + 'oneHit', + 'twoHit', + 'redHit', + 'blueHit', + 'by', + 'Pete' + ], + total: 6 + }); + expect(aggregator.applyFilter) + .toHaveBeenCalledWith('orderedByScore!', 'filter'); + expect(aggregator.removeDuplicates) + .toHaveBeenCalledWith('filterApplied!'); + expect(aggregator.asObjectResults) + .toHaveBeenCalledWith('duplicatesRemoved!'); + + expect(objectResults).toBe('objectResults'); + }) + .then(function () { + promiseChainResolved = true; + }); + + waitsFor(function () { + return promiseChainResolved; + }); + }); + + }); +}); diff --git a/test-main.js b/test-main.js index 46740a93b2..18b5f2a0d4 100644 --- a/test-main.js +++ b/test-main.js @@ -44,7 +44,8 @@ require.config({ paths: { 'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min', - 'moment-duration-format': 'warp/clock/lib/moment-duration-format' + 'moment-duration-format': 'warp/clock/lib/moment-duration-format', + 'uuid': 'platform/commonUI/browse/lib/uuid' }, // dynamically load all test files