From 81c59489f336f04fbfcc2d314aed616b6e44a515 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 11 Jan 2016 18:06:29 -0700 Subject: [PATCH] Add search and filtering support to the timeline. This adds a new UI for searching and filtering through tests in a timeline. A new dropdown for filter options is added to the timeline panel header, where users can query and select tests based on name and metadata (pass/fail/skip). A list of results is displayed which can be selected from directly, but results are also highlighted on the timeline directly. Some rearchitecting of the HTML layout for the timeline directive was needed to allow part of the timeline to be inside a panel header, so the entire panel layout was moved inside the timeline directive. A new `filterFunction` field was added to the main timeline controller to support communicating the filtering parameters to other components of the timeline. Additionally, a new `contextClass` filter was added to avoid excessive code duplication for highlighting element color based on test status - existing uses were replaced with this. Change-Id: I5f35091ab2b605e0821125e79de47c4c6067f644 --- app/js/directives/timeline-overview.js | 21 ++++ app/js/directives/timeline-search.js | 98 +++++++++++++++++++ app/js/directives/timeline-viewport.js | 17 ++++ app/js/directives/timeline.js | 18 +++- app/js/filters/bootstrap-filters.js | 24 +++++ app/styles/directives/_timeline-overview.scss | 9 ++ app/styles/directives/_timeline-search.scss | 28 ++++++ app/styles/directives/_timeline-viewport.scss | 9 ++ app/styles/main.scss | 3 + app/views/directives/timeline-details.html | 11 +-- .../directives/timeline-search-popover.html | 38 +++++++ app/views/directives/timeline-search.html | 7 ++ app/views/directives/timeline.html | 13 +++ app/views/timeline.html | 18 +--- 14 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 app/js/directives/timeline-search.js create mode 100644 app/js/filters/bootstrap-filters.js create mode 100644 app/styles/directives/_timeline-overview.scss create mode 100644 app/styles/directives/_timeline-search.scss create mode 100644 app/styles/directives/_timeline-viewport.scss create mode 100644 app/views/directives/timeline-search-popover.html create mode 100644 app/views/directives/timeline-search.html create mode 100644 app/views/directives/timeline.html diff --git a/app/js/directives/timeline-overview.js b/app/js/directives/timeline-overview.js index a4c8669..36635fd 100644 --- a/app/js/directives/timeline-overview.js +++ b/app/js/directives/timeline-overview.js @@ -9,6 +9,7 @@ function timelineOverview() { var margin = timelineController.margin; var height = 80; var laneHeight = 10; + var loaded = false; var x = timelineController.axes.x; var y = d3.scale.linear(); @@ -49,6 +50,17 @@ function timelineOverview() { .attr('stroke', 'rgba(100, 100, 100, 0.25)') .attr('fill', function(d) { return timelineController.statusColorMap[d.status]; + }) + .attr('class', function(d) { + if (timelineController.filterFunction) { + if (timelineController.filterFunction(d)) { + return 'filter-hit'; + } else { + return 'filter-miss'; + } + } else { + return null; + } }); rects.exit().remove(); @@ -144,6 +156,8 @@ function timelineOverview() { .attr('height', height - 1); timelineController.setViewExtents(brush.extent()); + + loaded = true; }); scope.$on('update', function() { @@ -160,6 +174,13 @@ function timelineOverview() { shiftViewport(selection.item); } }); + + scope.$on('filter', function() { + if (loaded) { + console.log('filtering'); + updateItems(timelineController.data); + } + }); }; return { diff --git a/app/js/directives/timeline-search.js b/app/js/directives/timeline-search.js new file mode 100644 index 0000000..e60d748 --- /dev/null +++ b/app/js/directives/timeline-search.js @@ -0,0 +1,98 @@ +'use strict'; + +var directivesModule = require('./_index.js'); + +/** + * @ngInject + */ +function timelineSearch() { + + /** + * @ngInject + */ + var controller = function($scope, $element) { + var self = this; + + this.open = false; + this.query = ''; + this.showSuccess = true; + this.showSkip = true; + this.showFail = true; + + this.results = []; + + var doFilter = function(item) { + if ((item.status === 'success' && !self.showSuccess) || + (item.status === 'skip' && !self.showSkip) || + (item.status === 'fail' && !self.showFail)) { + return false; + } + + if (item.name.toLowerCase().indexOf(self.query.toLowerCase()) < 0) { + return false; + } + + return true; + }; + + this.updateResults = function() { + var timeline = $element.controller('timeline'); + timeline.setFilterFunction(function(item) { + return doFilter(item); + }); + + var ret = []; + for (var i = 0; i < timeline.dataRaw.length; i++) { + var item = timeline.dataRaw[i]; + + if (!doFilter(item)) { + continue; + } + + ret.push(timeline.dataRaw[i]); + if (ret.length > 25) { + break; + } + } + + this.results = ret; + }; + + this.select = function(item) { + var timeline = $element.controller('timeline'); + timeline.selectItem(item); + timeline.setFilterFunction(null); + + self.query = ''; + self.open = false; + }; + + var update = function(a, b) { + if (a === b) { + return; + } + + self.updateResults(); + }; + + $scope.$watch(function() { return self.query; }, update); + $scope.$watch(function() { return self.showSuccess; }, update); + $scope.$watch(function() { return self.showSkip; }, update); + $scope.$watch(function() { return self.showFail; }, update); + + $scope.$on('dataLoaded', function() { + self.updateResults(); + }); + }; + + return { + restrict: 'EA', + require: ['^timelineSearch', '^timeline'], + scope: true, + controller: controller, + controllerAs: 'search', + templateUrl: 'directives/timeline-search.html' + }; +} + +directivesModule.directive('timelineSearch', timelineSearch); diff --git a/app/js/directives/timeline-viewport.js b/app/js/directives/timeline-viewport.js index 81cfae4..ea90738 100644 --- a/app/js/directives/timeline-viewport.js +++ b/app/js/directives/timeline-viewport.js @@ -202,6 +202,17 @@ function timelineViewport($document) { return null; } }) + .attr('class', function(d) { + if (timelineController.filterFunction) { + if (timelineController.filterFunction(d)) { + return 'filter-hit'; + } else { + return 'filter-miss'; + } + } else { + return null; + } + }) .on("mouseover", rectMouseOver) .on('mouseout', rectMouseOut) .on('click', rectClick); @@ -311,6 +322,12 @@ function timelineViewport($document) { select(null); } }); + + scope.$on('filter', function() { + if (loaded) { + update(); + } + }); }; return { diff --git a/app/js/directives/timeline.js b/app/js/directives/timeline.js index cf51b1e..125ad46 100644 --- a/app/js/directives/timeline.js +++ b/app/js/directives/timeline.js @@ -56,6 +56,7 @@ function timeline($log, datasetService) { self.selectionName = null; self.selection = null; self.hover = null; + self.filterFunction = null; self.setViewExtents = function(extents) { if (angular.isNumber(extents[0])) { @@ -104,6 +105,12 @@ function timeline($log, datasetService) { $scope.$broadcast('postSelect', self.selection); }; + self.setFilterFunction = function(fn) { + self.filterFunction = fn; + + $scope.$broadcast('filter', fn); + }; + self.selectItem = function(item) { var workerItems = self.data[item.worker].values; var index = -1; @@ -249,9 +256,14 @@ function timeline($log, datasetService) { var link = function(scope, el, attrs, ctrl) { var updateWidth = function() { - ctrl.width = el.parent()[0].clientWidth - + var body = el[0].querySelector('div.panel div.panel-body'); + var style = getComputedStyle(body); + + ctrl.width = body.clientWidth - ctrl.margin.left - - ctrl.margin.right; + ctrl.margin.right - + parseFloat(style.paddingLeft) - + parseFloat(style.paddingRight); }; scope.$on('windowResize', updateWidth); @@ -276,7 +288,7 @@ function timeline($log, datasetService) { controllerAs: 'timeline', restrict: 'EA', transclude: true, - template: '', + templateUrl: 'directives/timeline.html', scope: { 'dataset': '=', 'hoveredItem': '=', diff --git a/app/js/filters/bootstrap-filters.js b/app/js/filters/bootstrap-filters.js new file mode 100644 index 0000000..b72b3fe --- /dev/null +++ b/app/js/filters/bootstrap-filters.js @@ -0,0 +1,24 @@ +'use strict'; + +var filtersModule = require('./_index.js'); + +var contextClass = function(test, type) { + var clazz; + if (test.status === 'success') { + clazz = 'success'; + } else if (test.status === 'skip') { + clazz = 'info'; + } else if (test.status === 'fail') { + clazz = 'danger'; + } else { + clazz = 'default'; + } + + if (type) { + return type + '-' + clazz; + } else { + return clazz; + } +}; + +filtersModule.filter('contextClass', function() { return contextClass; }); diff --git a/app/styles/directives/_timeline-overview.scss b/app/styles/directives/_timeline-overview.scss new file mode 100644 index 0000000..a574704 --- /dev/null +++ b/app/styles/directives/_timeline-overview.scss @@ -0,0 +1,9 @@ +timeline-overview svg { + .filter-hit { + + } + + .filter-miss { + opacity: 0.15; + } +} diff --git a/app/styles/directives/_timeline-search.scss b/app/styles/directives/_timeline-search.scss new file mode 100644 index 0000000..3dad1e4 --- /dev/null +++ b/app/styles/directives/_timeline-search.scss @@ -0,0 +1,28 @@ +timeline-search { + display: inline-block; + + .popover { + max-width: 500px; + + .timeline-search-popover { + width: 300px; + .input-group { + width: 100%; + } + + .status-group label { + font-family: monospace; + } + + .jump-group li { + cursor: pointer; + } + + .jump-group ul { + max-height: 20em; + overflow-x: hidden; + overflow-y: auto; + } + } + } +} diff --git a/app/styles/directives/_timeline-viewport.scss b/app/styles/directives/_timeline-viewport.scss new file mode 100644 index 0000000..3456ae8 --- /dev/null +++ b/app/styles/directives/_timeline-viewport.scss @@ -0,0 +1,9 @@ +timeline-viewport svg { + .filter-hit { + + } + + .filter-miss { + opacity: 0.15; + } +} diff --git a/app/styles/main.scss b/app/styles/main.scss index 03765b3..a8e6407 100644 --- a/app/styles/main.scss +++ b/app/styles/main.scss @@ -5,3 +5,6 @@ @import 'sb-admin-2'; @import 'directives/_timeline-details.scss'; +@import 'directives/_timeline-search.scss'; +@import 'directives/_timeline-viewport.scss'; +@import 'directives/_timeline-overview.scss'; diff --git a/app/views/directives/timeline-details.html b/app/views/directives/timeline-details.html index 0033f2d..40c4d37 100644 --- a/app/views/directives/timeline-details.html +++ b/app/views/directives/timeline-details.html @@ -9,16 +9,13 @@
+ ng-class="item | contextClass:'panel'">

Details: {{item.name | split:'.' | pickRight:1}} - success - skip - fail + + {{item.status}} +

diff --git a/app/views/directives/timeline-search-popover.html b/app/views/directives/timeline-search-popover.html new file mode 100644 index 0000000..1ad38fb --- /dev/null +++ b/app/views/directives/timeline-search-popover.html @@ -0,0 +1,38 @@ +
+ +
+ + +
+
+ + +
+ + + +
+
+ +
+ +
    +
  • + {{item.name | split:'.' | pickRight:1}} +
  • +
+
+
diff --git a/app/views/directives/timeline-search.html b/app/views/directives/timeline-search.html new file mode 100644 index 0000000..a3048a7 --- /dev/null +++ b/app/views/directives/timeline-search.html @@ -0,0 +1,7 @@ + + + + diff --git a/app/views/directives/timeline.html b/app/views/directives/timeline.html new file mode 100644 index 0000000..53e26fa --- /dev/null +++ b/app/views/directives/timeline.html @@ -0,0 +1,13 @@ +
+
+

+ Timeline + +

+
+
+ + + +
+
diff --git a/app/views/timeline.html b/app/views/timeline.html index 9d03a5e..139a91d 100644 --- a/app/views/timeline.html +++ b/app/views/timeline.html @@ -17,20 +17,10 @@
-
-
-

Timeline

-
- - - - - -
+