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
This commit is contained in:
parent
3a4683b8be
commit
81c59489f3
@ -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 {
|
||||
|
98
app/js/directives/timeline-search.js
Normal file
98
app/js/directives/timeline-search.js
Normal file
@ -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);
|
@ -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 {
|
||||
|
@ -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: '<ng-transclude></ng-transclude>',
|
||||
templateUrl: 'directives/timeline.html',
|
||||
scope: {
|
||||
'dataset': '=',
|
||||
'hoveredItem': '=',
|
||||
|
24
app/js/filters/bootstrap-filters.js
vendored
Normal file
24
app/js/filters/bootstrap-filters.js
vendored
Normal file
@ -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; });
|
9
app/styles/directives/_timeline-overview.scss
Normal file
9
app/styles/directives/_timeline-overview.scss
Normal file
@ -0,0 +1,9 @@
|
||||
timeline-overview svg {
|
||||
.filter-hit {
|
||||
|
||||
}
|
||||
|
||||
.filter-miss {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
28
app/styles/directives/_timeline-search.scss
Normal file
28
app/styles/directives/_timeline-search.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
app/styles/directives/_timeline-viewport.scss
Normal file
9
app/styles/directives/_timeline-viewport.scss
Normal file
@ -0,0 +1,9 @@
|
||||
timeline-viewport svg {
|
||||
.filter-hit {
|
||||
|
||||
}
|
||||
|
||||
.filter-miss {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -9,16 +9,13 @@
|
||||
|
||||
<div ng-if="!!item"
|
||||
class="panel"
|
||||
ng-class="{'panel-success': item.status == 'success', 'panel-info': item.status == 'skip', 'panel-danger': item.status == 'fail'}">
|
||||
ng-class="item | contextClass:'panel'">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
Details: {{item.name | split:'.' | pickRight:1}}
|
||||
<span class="label label-success"
|
||||
ng-if="item.status == 'success'">success</span>
|
||||
<span class="label label-info"
|
||||
ng-if="item.status == 'skip'">skip</span>
|
||||
<span class="label label-danger"
|
||||
ng-if="item.status == 'fail'">fail</span>
|
||||
<span class="label label-{{item | contextClass}}">
|
||||
{{item.status}}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<table class="table table-bordered table-hover table-striped">
|
||||
|
38
app/views/directives/timeline-search-popover.html
Normal file
38
app/views/directives/timeline-search-popover.html
Normal file
@ -0,0 +1,38 @@
|
||||
<div class="timeline-search-popover">
|
||||
<label>Filter by name:</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><fa name="search"></fa></span>
|
||||
<input type="text"
|
||||
placeholder="query..."
|
||||
class="form-control"
|
||||
ng-model="search.query"
|
||||
ng-model-options="{debounce: 250}">
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<label>Filter by status:</label>
|
||||
<div class="status-group btn-group btn-group-justified">
|
||||
<label uib-btn-checkbox
|
||||
ng-model="search.showSuccess"
|
||||
class="btn btn-default">success</label>
|
||||
<label uib-btn-checkbox
|
||||
ng-model="search.showSkip"
|
||||
class="btn btn-default">skip</label>
|
||||
<label uib-btn-checkbox
|
||||
ng-model="search.showFail"
|
||||
class="btn btn-default">fail</label>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div class="jump-group">
|
||||
<label>Jump to:</label>
|
||||
<ul class="list-group">
|
||||
<li ng-repeat="item in search.results"
|
||||
class="list-group-item"
|
||||
ng-class="item | contextClass:'list-group-item'"
|
||||
ng-click="search.select(item)">
|
||||
{{item.name | split:'.' | pickRight:1}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
7
app/views/directives/timeline-search.html
Normal file
7
app/views/directives/timeline-search.html
Normal file
@ -0,0 +1,7 @@
|
||||
<a uib-popover-template="'directives/timeline-search-popover.html'"
|
||||
popover-placement="bottom-right"
|
||||
popover-title="Filter Options"
|
||||
popover-is-open="search.open">
|
||||
<fa name="search"></fa>
|
||||
<fa name="caret-down"></fa>
|
||||
</a>
|
13
app/views/directives/timeline.html
Normal file
13
app/views/directives/timeline.html
Normal file
@ -0,0 +1,13 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
Timeline
|
||||
<timeline-search class="pull-right"></timeline-search>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<timeline-viewport></timeline-viewport>
|
||||
<timeline-dstat></timeline-dstat>
|
||||
<timeline-overview></timeline-overview>
|
||||
</div>
|
||||
</div>
|
@ -17,20 +17,10 @@
|
||||
</div>
|
||||
<div class="row" ng-if="!timeline.error">
|
||||
<div class="col-lg-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Timeline</h3>
|
||||
</div>
|
||||
<timeline class="panel-body"
|
||||
dataset="timeline.dataset"
|
||||
hovered-item="timeline.hoveredItem"
|
||||
selected-item="timeline.selectedItem"
|
||||
preselect="timeline.preselect">
|
||||
<timeline-viewport></timeline-viewport>
|
||||
<timeline-dstat></timeline-dstat>
|
||||
<timeline-overview></timeline-overview>
|
||||
</timeline>
|
||||
</div>
|
||||
<timeline dataset="timeline.dataset"
|
||||
hovered-item="timeline.hoveredItem"
|
||||
selected-item="timeline.selectedItem"
|
||||
preselect="timeline.preselect"></timeline>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
Loading…
Reference in New Issue
Block a user