[Tables] #707 Added auto-scroll, addressed race condition in Sinewave and event telemetry providers

Fixed issue with visible padding row

Incremental improvements

Added tests

Added tests for sorted insert, and fixed lint errors
This commit is contained in:
Henry 2016-03-08 21:36:33 -08:00
parent a4eb9d6a94
commit 7da1a218ba
15 changed files with 548 additions and 125 deletions

View File

@ -57,6 +57,13 @@ define([
}, },
"telemetry": { "telemetry": {
"source": "eventGenerator", "source": "eventGenerator",
"domains": [
{
"key": "time",
"name": "Time",
"format": "utc"
}
],
"ranges": [ "ranges": [
{ {
"format": "string" "format": "string"

View File

@ -36,7 +36,9 @@ define(
function EventTelemetryProvider($q, $timeout) { function EventTelemetryProvider($q, $timeout) {
var var
subscriptions = [], subscriptions = [],
genInterval = 1000; genInterval = 1000,
generating = false,
id = Math.random() * 100000;
// //
function matchesSource(request) { function matchesSource(request) {
@ -78,10 +80,13 @@ define(
} }
function startGenerating() { function startGenerating() {
generating = true;
$timeout(function () { $timeout(function () {
handleSubscriptions(); handleSubscriptions();
if (subscriptions.length > 0) { if (generating && subscriptions.length > 0) {
startGenerating(); startGenerating();
} else {
generating = false;
} }
}, genInterval); }, genInterval);
} }
@ -91,8 +96,6 @@ define(
callback: callback, callback: callback,
requests: requests requests: requests
}; };
console.log("subscribe... " + Date.now() / 1000 + " request:" +
" " + requests[0].id);
function unsubscribe() { function unsubscribe() {
subscriptions = subscriptions.filter(function (s) { subscriptions = subscriptions.filter(function (s) {
return s !== subscription; return s !== subscription;
@ -100,7 +103,7 @@ define(
} }
subscriptions.push(subscription); subscriptions.push(subscription);
if (subscriptions.length === 1) { if (!generating) {
startGenerating(); startGenerating();
} }

View File

@ -34,7 +34,8 @@ define(
* @constructor * @constructor
*/ */
function SinewaveTelemetryProvider($q, $timeout) { function SinewaveTelemetryProvider($q, $timeout) {
var subscriptions = []; var subscriptions = [],
generating = false;
// //
function matchesSource(request) { function matchesSource(request) {
@ -75,10 +76,13 @@ define(
} }
function startGenerating() { function startGenerating() {
generating = true;
$timeout(function () { $timeout(function () {
handleSubscriptions(); handleSubscriptions();
if (subscriptions.length > 0) { if (generating && subscriptions.length > 0) {
startGenerating(); startGenerating();
} else {
generating = false;
} }
}, 1000); }, 1000);
} }
@ -97,7 +101,7 @@ define(
subscriptions.push(subscription); subscriptions.push(subscription);
if (subscriptions.length === 1) { if (!generating) {
startGenerating(); startGenerating();
} }

View File

@ -101,7 +101,8 @@ define([
"composition": [] "composition": []
}, },
"views": [ "views": [
"realtime" "rt-table",
"scrolling-table"
] ]
} }
], ],
@ -137,7 +138,7 @@ define([
}, },
{ {
"name": "Real-time Table", "name": "Real-time Table",
"key": "realtime", "key": "rt-table",
"glyph": "\ue605", "glyph": "\ue605",
"templateUrl": "templates/rt-table.html", "templateUrl": "templates/rt-table.html",
"needs": [ "needs": [

View File

@ -1,5 +1,7 @@
<div class="l-view-section scrolling" <div class="l-view-section scrolling"
style="overflow: auto;" ng-style="overrideRowPositioning ?
{'overflow': 'auto'} :
{'overflow': 'scroll'}"
> >
<table class="filterable" <table class="filterable"
ng-style="overrideRowPositioning && { ng-style="overrideRowPositioning && {
@ -59,6 +61,5 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -3,6 +3,7 @@
headers="headers" headers="headers"
rows="rows" rows="rows"
enableFilter="true" enableFilter="true"
enableSort="true"> enableSort="true"
auto-scroll="autoScroll">
</mct-table> </mct-table>
</div> </div>

View File

@ -0,0 +1,9 @@
<div ng-controller="RTTelemetryTableController">
<mct-table
headers="headers"
rows="rows"
enableFilter="true"
enableSort="true"
auto-scroll="true">
</mct-table>
</div>

View File

@ -54,10 +54,6 @@ define(
if (metadata) { if (metadata) {
if (metadata.length > 1){
self.addColumn(new NameColumn(), 0);
}
metadata.forEach(function (metadatum) { metadata.forEach(function (metadatum) {
//Push domains first //Push domains first
(metadatum.domains || []).forEach(function (domainMetadata) { (metadatum.domains || []).forEach(function (domainMetadata) {
@ -67,6 +63,10 @@ define(
self.addColumn(new RangeColumn(rangeMetadata, self.telemetryFormatter)); self.addColumn(new RangeColumn(rangeMetadata, self.telemetryFormatter));
}); });
}); });
if (this.columns.length > 0){
self.addColumn(new NameColumn(), 0);
}
} }
return this; return this;
}; };

View File

@ -5,6 +5,15 @@ define(
function () { function () {
"use strict"; "use strict";
/**
* A controller for the MCTTable directive. Populates scope with
* data used for populating, sorting, and filtering
* tables.
* @param $scope
* @param $timeout
* @param element
* @constructor
*/
function MCTTableController($scope, $timeout, element) { function MCTTableController($scope, $timeout, element) {
var self = this; var self = this;
@ -12,10 +21,11 @@ define(
this.element = element; this.element = element;
this.$timeout = $timeout; this.$timeout = $timeout;
this.maxDisplayRows = 50; this.maxDisplayRows = 50;
element.find('div').on('scroll', this.setVisibleRows.bind(this));
this.scrollable = element.find('div')[0];
$scope.visibleRows = []; this.scrollable = element.find('div');
this.scrollable.on('scroll', this.onScroll.bind(this));
$scope.visibleRows = [];
$scope.overrideRowPositioning = false; $scope.overrideRowPositioning = false;
/** /**
@ -51,27 +61,82 @@ define(
self.updateRows($scope.rows); self.updateRows($scope.rows);
}; };
/*
* Define watches to listen for changes to headers and rows.
*/
$scope.$watchCollection('filters', function () { $scope.$watchCollection('filters', function () {
self.updateRows(self.$scope.displayRows); self.updateRows($scope.rows);
}); });
$scope.$watchCollection('headers', this.updateHeaders.bind(this)); $scope.$watch('headers', this.updateHeaders.bind(this));
$scope.$watch('rows', this.updateRows.bind(this)); $scope.$watch('rows', this.updateRows.bind(this));
$scope.$on('newRow', this.newRow.bind(this));
}
MCTTableController.prototype.newRow = function (event, newRow) { /*
this.$scope.displayRows.push(newRow); * Listen for rows added individually (eg. for real-time tables)
this.filterAndSort(this.$scope.displayRows); */
this.$timeout(this.setElementSizes(), 0); $scope.$on('addRow', this.newRow.bind(this));
} }
/** /**
* Re-synchronize between data rows and visible rows, based on array * If auto-scroll is enabled, this function will scroll to the
* content and scroll state. * bottom of the page
* @private
*/
MCTTableController.prototype.scrollToBottom = function() {
var self = this;
//Use timeout to defer execution until next digest when any
// pending UI changes have completed, eg. a new row in the table.
if (this.$scope.autoScroll) {
this.$timeout(function(){
self.scrollable[0].scrollTop = self.scrollable[0].scrollHeight;
});
}
};
/**
* Handles a row add event. Rows can be added as needed using the
* `addRow` broadcast event.
* @private
*/
MCTTableController.prototype.newRow = function (event, row) {
//Add row to the filtered, sorted list of all rows
if (this.filterRows([row]).length > 0) {
this.insertSorted(this.$scope.displayRows, row);
}
//Keep 'rows' synchronized as it provides the unsorted,
// unfiltered model for this view
if (!this.$scope.rows) {
this.$scope.rows = [];
}
this.$scope.rows.push(row);
this.$timeout(this.setElementSizes.bind(this))
.then(this.scrollToBottom.bind(this));
};
/**
* @private
*/
MCTTableController.prototype.onScroll = function (event) {
//If user scrolls away from bottom, disable auto-scroll.
// Auto-scroll will be re-enabled if user scrolls to bottom again.
if (this.scrollable[0].scrollTop < (this.scrollable[0].scrollHeight - this.scrollable[0].offsetHeight)) {
this.$scope.autoScroll = false;
} else {
this.$scope.autoScroll = true;
}
this.setVisibleRows();
this.$scope.$digest();
};
/**
* Sets visible rows based on array
* content and current scroll state.
*/ */
MCTTableController.prototype.setVisibleRows = function () { MCTTableController.prototype.setVisibleRows = function () {
var self = this, var self = this,
target = this.scrollable, target = this.scrollable[0],
topScroll = target.scrollTop, topScroll = target.scrollTop,
bottomScroll = topScroll + target.offsetHeight, bottomScroll = topScroll + target.offsetHeight,
firstVisible, firstVisible,
@ -87,7 +152,7 @@ define(
// rows (if data added) // rows (if data added)
if (this.$scope.visibleRows.length != this.$scope.displayRows.length){ if (this.$scope.visibleRows.length != this.$scope.displayRows.length){
start = 0; start = 0;
end = this.$scope.displayRows.length-1; end = this.$scope.displayRows.length;
} else { } else {
//Data is in sync, and no need to calculate scroll, //Data is in sync, and no need to calculate scroll,
// so do nothing. // so do nothing.
@ -114,13 +179,12 @@ define(
if (start < 0) { if (start < 0) {
start = 0; start = 0;
//end = this.$scope.visibleRows.length - 1; end = Math.min(this.maxDisplayRows, this.$scope.displayRows.length);
end = Math.min(this.maxDisplayRows, this.$scope.displayRows.length) - 1;
} else if (end >= this.$scope.displayRows.length) { } else if (end >= this.$scope.displayRows.length) {
end = this.$scope.displayRows.length - 1; end = this.$scope.displayRows.length;
start = end - this.maxDisplayRows + 1; start = end - this.maxDisplayRows + 1;
} }
if (this.$scope.visibleRows[0].rowIndex === start && if (this.$scope.visibleRows[0] && this.$scope.visibleRows[0].rowIndex === start &&
this.$scope.visibleRows[this.$scope.visibleRows.length - 1] this.$scope.visibleRows[this.$scope.visibleRows.length - 1]
.rowIndex === end) { .rowIndex === end) {
@ -137,8 +201,6 @@ define(
contents: row contents: row
}; };
}); });
this.$scope.$digest();
}; };
/** /**
@ -156,7 +218,7 @@ define(
this.$scope.filters = {}; this.$scope.filters = {};
} }
// Reset column sort information unless the new headers // Reset column sort information unless the new headers
// contain the column current sorted on. // contain the column currently sorted on.
if (this.$scope.enableSort && newHeaders.indexOf(this.$scope.sortColumn) === -1) { if (this.$scope.enableSort && newHeaders.indexOf(this.$scope.sortColumn) === -1) {
this.$scope.sortColumn = undefined; this.$scope.sortColumn = undefined;
this.$scope.sortDirection = undefined; this.$scope.sortDirection = undefined;
@ -175,7 +237,6 @@ define(
firstRow = tbody.find('tr'), firstRow = tbody.find('tr'),
column = firstRow.find('td'), column = firstRow.find('td'),
headerHeight = thead.prop('offsetHeight'), headerHeight = thead.prop('offsetHeight'),
//row height is hard-coded for now.
rowHeight = 20, rowHeight = 20,
overallHeight = headerHeight + (rowHeight * (this.$scope.displayRows ? this.$scope.displayRows.length - 1 : 0)); overallHeight = headerHeight + (rowHeight * (this.$scope.displayRows ? this.$scope.displayRows.length - 1 : 0));
@ -192,6 +253,80 @@ define(
this.$scope.overrideRowPositioning = true; this.$scope.overrideRowPositioning = true;
}; };
/**
* @private
*/
MCTTableController.prototype.insertSorted = function(array, element) {
var index = -1,
self = this,
sortKey = this.$scope.sortColumn;
function binarySearch(searchArray, searchElement, min, max){
var sampleAt = Math.floor((max - min) / 2) + min;
if (max < min) {
return min; // Element is not in array, min gives direction
}
switch(self.sortComparator(searchElement[sortKey].text, searchArray[sampleAt][sortKey].text)) {
case -1:
return binarySearch(searchArray, searchElement, min, sampleAt - 1);
case 0 :
return sampleAt;
case 1 :
return binarySearch(searchArray, searchElement, sampleAt + 1, max);
}
}
if (!this.$scope.sortColumn || !this.$scope.sortDirection) {
//No sorting applied, push it on the end.
index = array.length;
} else {
//Sort is enabled, perform binary search to find insertion point
index = binarySearch(array, element, 0, array.length - 1);
}
if (index === -1){
array.unshift(element);
} else if (index === array.length){
array.push(element);
} else {
array.splice(index, 0, element);
}
};
/**
* Compare two variables, returning a number that represents
* which is larger. Similar to the default array sort
* comparator, but does not coerce all values to string before
* conversion. Strings are lowercased before comparison.
*
* @private
*/
MCTTableController.prototype.sortComparator = function(a, b) {
var result = 0,
sortDirectionMultiplier;
if (typeof a === "string" && typeof b === "string") {
a = a.toLowerCase();
b = b.toLowerCase();
}
if (a < b) {
result = -1;
}
if (a > b) {
result = 1;
}
if (this.$scope.sortDirection === 'asc') {
sortDirectionMultiplier = 1;
} else if (this.$scope.sortDirection === 'desc') {
sortDirectionMultiplier = -1;
}
return result * sortDirectionMultiplier;
};
/** /**
* Returns a new array which is a result of applying the sort * Returns a new array which is a result of applying the sort
* criteria defined in $scope. * criteria defined in $scope.
@ -199,38 +334,12 @@ define(
* Does not modify the array that was passed in. * Does not modify the array that was passed in.
*/ */
MCTTableController.prototype.sortRows = function(rowsToSort) { MCTTableController.prototype.sortRows = function(rowsToSort) {
/** var self = this,
* Compare two variables, returning a number that represents sortKey = this.$scope.sortColumn;
* which is larger. Similar to the default array sort
* comparator, but does not coerce all values to string before
* conversion. Strings are lowercased before comparison.
*/
function genericComparator(a, b) {
if (typeof a === "string" && typeof b === "string") {
a = a.toLowerCase();
b = b.toLowerCase();
}
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
if (!this.$scope.sortColumn || !this.$scope.sortDirection) { if (!this.$scope.sortColumn || !this.$scope.sortDirection) {
return rowsToSort; return rowsToSort;
} }
var sortKey = this.$scope.sortColumn,
sortDirectionMultiplier;
if (this.$scope.sortDirection === 'asc') {
sortDirectionMultiplier = 1;
} else if (this.$scope.sortDirection === 'desc') {
sortDirectionMultiplier = -1;
}
return rowsToSort.slice(0).sort(function(a, b) { return rowsToSort.slice(0).sort(function(a, b) {
//If the values to compare can be compared as //If the values to compare can be compared as
@ -239,8 +348,7 @@ define(
var valA = isNaN(a[sortKey].text) ? a[sortKey].text : parseFloat(a[sortKey].text), var valA = isNaN(a[sortKey].text) ? a[sortKey].text : parseFloat(a[sortKey].text),
valB = isNaN(b[sortKey].text) ? b[sortKey].text : parseFloat(b[sortKey].text); valB = isNaN(b[sortKey].text) ? b[sortKey].text : parseFloat(b[sortKey].text);
return genericComparator(valA, valB) * return self.sortComparator(valA, valB);
sortDirectionMultiplier;
}); });
}; };
@ -271,9 +379,10 @@ define(
return largestRow; return largestRow;
}, JSON.parse(JSON.stringify(rows[0] || {}))); }, JSON.parse(JSON.stringify(rows[0] || {})));
largestRow = JSON.parse(JSON.stringify(largestRow));
// Pad with characters to accomodate variable-width fonts, // Pad with characters to accomodate variable-width fonts,
// and remove characters that would allow word-wrapping. // and remove characters that would allow word-wrapping.
largestRow = JSON.parse(JSON.stringify(largestRow));
Object.keys(largestRow).forEach(function(key) { Object.keys(largestRow).forEach(function(key) {
var padCharacters, var padCharacters,
i; i;
@ -289,8 +398,15 @@ define(
return largestRow; return largestRow;
}; };
/**
* Calculates the widest row in the table, pads that row, and adds
* it to the table. Allows the table to size itself, then uses this
* as basis for column dimensions.
* @private
*/
MCTTableController.prototype.resize = function (){ MCTTableController.prototype.resize = function (){
var largestRow = this.findLargestRow(this.$scope.displayRows); var largestRow = this.findLargestRow(this.$scope.displayRows),
self = this;
this.$scope.visibleRows = [ this.$scope.visibleRows = [
{ {
rowIndex: 0, rowIndex: 0,
@ -299,9 +415,18 @@ define(
} }
]; ];
this.$timeout(this.setElementSizes.bind(this)); //Wait a timeout to allow digest of previous change to visible
// rows to happen.
this.$timeout(function() {
//Remove temporary padding row used for setting column widths
self.$scope.visibleRows = [];
self.setElementSizes();
});
}; };
/**
* @priate
*/
MCTTableController.prototype.filterAndSort = function(rows) { MCTTableController.prototype.filterAndSort = function(rows) {
var displayRows = rows; var displayRows = rows;
if (this.$scope.enableFilter) { if (this.$scope.enableFilter) {
@ -319,19 +444,25 @@ define(
* will be sorted before display. * will be sorted before display.
*/ */
MCTTableController.prototype.updateRows = function (newRows) { MCTTableController.prototype.updateRows = function (newRows) {
//Reset visible rows because new row data available.
this.$scope.visibleRows = []; this.$scope.visibleRows = [];
this.$scope.overrideRowPositioning = false; this.$scope.overrideRowPositioning = false;
//Nothing to show because no columns visible
if (!this.$scope.displayHeaders) { if (!this.$scope.displayHeaders) {
return; return;
} }
this.filterAndSort(newRows || []); //Apply filters and sort a copy of the the new rows
this.filterAndSort((newRows || []).slice(0));
//Resize columns appropriately
this.resize(); this.resize();
}; };
/** /**
* Filter rows. * Applies user defined filters to rows. These filters are based on
* the text entered in the search areas in each column
*/ */
MCTTableController.prototype.filterRows = function(rowsToFilter) { MCTTableController.prototype.filterRows = function(rowsToFilter) {
var filters = {}, var filters = {},

View File

@ -21,23 +21,16 @@
*****************************************************************************/ *****************************************************************************/
/*global define*/ /*global define*/
/**
* This bundle adds a table view for displaying telemetry data.
* @namespace platform/features/table
*/
define( define(
[ [
'./TelemetryTableController', './TelemetryTableController'
'../TableConfiguration',
'../NameColumn'
], ],
function (TableController, Table, NameColumn) { function (TableController) {
"use strict"; "use strict";
/** /**
* The TableController is responsible for getting data onto the page * Extends TelemetryTableController and adds real-time streaming
* in the table widget. This includes handling composition, * support.
* configuration, and telemetry subscriptions.
* @memberof platform/features/table * @memberof platform/features/table
* @param $scope * @param $scope
* @param telemetryHandler * @param telemetryHandler
@ -46,16 +39,44 @@ define(
*/ */
function RTTelemetryTableController($scope, telemetryHandler, telemetryFormatter) { function RTTelemetryTableController($scope, telemetryHandler, telemetryFormatter) {
TableController.call(this, $scope, telemetryHandler, telemetryFormatter); TableController.call(this, $scope, telemetryHandler, telemetryFormatter);
$scope.autoScroll = false;
/*
* Determine if auto-scroll should be enabled. Is enabled
* automatically when telemetry type is string
*/
function hasStringTelemetry(domainObject) {
var telemetry = domainObject &&
domainObject.getCapability('telemetry'),
metadata = telemetry ? telemetry.getMetadata() : {},
ranges = metadata.ranges || [];
return ranges.some(function (range) {
return range.format === 'string';
});
}
$scope.$watch('domainObject', function(domainObject) {
//When a domain object becomes available, check whether the
// view should auto-scroll to the bottom.
if (domainObject && hasStringTelemetry(domainObject)){
$scope.autoScroll = true;
}
});
} }
RTTelemetryTableController.prototype = Object.create(TableController.prototype); RTTelemetryTableController.prototype = Object.create(TableController.prototype);
/** /**
Create a new telemetry subscription. Override the subscribe function defined on the parent controller in
order to handle realtime telemetry instead of historical.
*/ */
RTTelemetryTableController.prototype.subscribe = function() { RTTelemetryTableController.prototype.subscribe = function() {
console.trace();
var self = this; var self = this;
self.$scope.rows = undefined;
(this.subscriptions || []).forEach(function(unsubscribe){
unsubscribe();
});
if (this.handle) { if (this.handle) {
this.handle.unsubscribe(); this.handle.unsubscribe();
@ -66,11 +87,8 @@ define(
self.handle.getTelemetryObjects().forEach(function(telemetryObject){ self.handle.getTelemetryObjects().forEach(function(telemetryObject){
datum = self.handle.getDatum(telemetryObject); datum = self.handle.getDatum(telemetryObject);
if (datum) { if (datum) {
if (!self.$scope.rows) { var rowValue = self.table.getRowValues(telemetryObject, datum);
self.$scope.rows = [self.table.getRowValues(telemetryObject, datum)]; self.$scope.$broadcast('addRow', rowValue);
} else {
self.updateRows(telemetryObject, datum);
}
} }
}); });
@ -85,16 +103,6 @@ define(
this.setup(); this.setup();
}; };
/**
* Add data to rows
* @param object The object for which data is available (table may
* be composed of multiple objects)
* @param datum The data received from the telemetry source
*/
RTTelemetryTableController.prototype.updateRows = function (object, datum) {
this.$scope.$broadcast('newRow', this.table.getRowValues(object, datum));
};
return RTTelemetryTableController; return RTTelemetryTableController;
} }
); );

View File

@ -72,21 +72,22 @@ define(
this.$scope.$on("$destroy", this.destroy.bind(this)); this.$scope.$on("$destroy", this.destroy.bind(this));
} }
/**
* Defer registration of change listeners until domain object is
* available in order to avoid race conditions
* @private
*/
TelemetryTableController.prototype.registerChangeListeners = function() { TelemetryTableController.prototype.registerChangeListeners = function() {
//Defer registration of change listeners until domain object is
// available in order to avoid race conditions
this.changeListeners.forEach(function (listener) { this.changeListeners.forEach(function (listener) {
return listener && listener(); return listener && listener();
}); });
this.changeListeners = []; this.changeListeners = [];
// When composition changes, re-subscribe to the various // When composition changes, re-subscribe to the various
// telemetry subscriptions // telemetry subscriptions
//this.changeListeners.push(this.$scope.$watchCollection('domainObject.getModel().composition', this.subscribe.bind(this))); this.changeListeners.push(this.$scope.$watchCollection('domainObject.getModel().composition', this.subscribe.bind(this)));
//Change of bounds in time conductor //Change of bounds in time conductor
this.changeListeners.push(this.$scope.$on('telemetry:display:bounds', this.subscribe.bind(this))); this.changeListeners.push(this.$scope.$on('telemetry:display:bounds', this.subscribe.bind(this)));
}; };
/** /**
@ -100,16 +101,15 @@ define(
}; };
/** /**
Create a new subscription. This is called when Create a new subscription. This can be overridden by children to
change default behaviour (which is to retrieve historical telemetry
only).
*/ */
TelemetryTableController.prototype.subscribe = function() { TelemetryTableController.prototype.subscribe = function() {
console.trace();
if (this.handle) { if (this.handle) {
this.handle.unsubscribe(); this.handle.unsubscribe();
} }
this.$scope.rows = [];
//Noop because not supporting realtime data right now //Noop because not supporting realtime data right now
function noop(){ function noop(){
} }
@ -127,12 +127,17 @@ define(
/** /**
* Add any historical data available * Add any historical data available
* @private
*/ */
TelemetryTableController.prototype.addHistoricalData = function(domainObject, series) { TelemetryTableController.prototype.addHistoricalData = function(domainObject, series) {
var i; var i,
newRows = [];
for (i=0; i < series.getPointCount(); i++) { for (i=0; i < series.getPointCount(); i++) {
this.updateRows(domainObject, this.handle.makeDatum(domainObject, series, i)); newRows.push(this.table.getRowValues(domainObject, this.handle.makeDatum(domainObject, series, i)));
} }
this.$scope.rows = newRows;
}; };
/** /**
@ -160,7 +165,7 @@ define(
}; };
/** /**
* Add data to rows * @private
* @param object The object for which data is available (table may * @param object The object for which data is available (table may
* be composed of multiple objects) * be composed of multiple objects)
* @param datum The data received from the telemetry source * @param datum The data received from the telemetry source
@ -172,6 +177,7 @@ define(
/** /**
* When column configuration changes, update the visible headers * When column configuration changes, update the visible headers
* accordingly. * accordingly.
* @private
*/ */
TelemetryTableController.prototype.filterColumns = function (columnConfig) { TelemetryTableController.prototype.filterColumns = function (columnConfig) {
if (!columnConfig){ if (!columnConfig){

View File

@ -1,20 +1,30 @@
/*global define*/ /*global define*/
define( define(
["../controllers/MCTTableController"], [
function (MCTTableController) { "../controllers/MCTTableController",
"text!../../res/templates/mct-table.html"
],
function (MCTTableController, TableTemplate) {
"use strict"; "use strict";
/**
* Defines a generic 'Table' component. The table can be populated
* en-masse by setting the rows attribute, or rows can be added as
* needed via a broadcast 'addRow' event.
* @constructor
*/
function MCTTable($timeout) { function MCTTable($timeout) {
return { return {
restrict: "E", restrict: "E",
templateUrl: "platform/features/table/res/templates/mct-data-table.html", template: TableTemplate,
controller: ['$scope', '$timeout', '$element', MCTTableController], controller: ['$scope', '$timeout', '$element', MCTTableController],
scope: { scope: {
headers: "=", headers: "=",
rows: "=", rows: "=",
enableFilter: "=?", enableFilter: "=?",
enableSort: "=?" enableSort: "=?",
autoScroll: "=?"
}, },
}; };
} }

View File

@ -120,13 +120,13 @@ define(
}); });
it("populates the columns attribute", function() { it("populates the columns attribute", function() {
expect(table.columns.length).toBe(4); expect(table.columns.length).toBe(5);
}); });
it("Build columns populates columns with domains to the left", function() { it("Build columns populates columns with domains to the left", function() {
expect(table.columns[0] instanceof DomainColumn).toBeTruthy();
expect(table.columns[1] instanceof DomainColumn).toBeTruthy(); expect(table.columns[1] instanceof DomainColumn).toBeTruthy();
expect(table.columns[2] instanceof DomainColumn).toBeFalsy(); expect(table.columns[2] instanceof DomainColumn).toBeTruthy();
expect(table.columns[3] instanceof DomainColumn).toBeFalsy();
}); });
it("Produces headers for each column based on title", function() { it("Produces headers for each column based on title", function() {
@ -135,7 +135,7 @@ define(
spyOn(firstColumn, 'getTitle'); spyOn(firstColumn, 'getTitle');
headers = table.getHeaders(); headers = table.getHeaders();
expect(headers.length).toBe(4); expect(headers.length).toBe(5);
expect(firstColumn.getTitle).toHaveBeenCalled(); expect(firstColumn.getTitle).toHaveBeenCalled();
}); });

View File

@ -48,6 +48,8 @@ define(
watches = {}; watches = {};
mockScope = jasmine.createSpyObj('scope', [ mockScope = jasmine.createSpyObj('scope', [
'$watch',
'$on',
'$watchCollection' '$watchCollection'
]); ]);
mockScope.$watchCollection.andCallFake(function(event, callback) { mockScope.$watchCollection.andCallFake(function(event, callback) {
@ -62,14 +64,15 @@ define(
mockScope.displayHeaders = true; mockScope.displayHeaders = true;
mockTimeout = jasmine.createSpy('$timeout'); mockTimeout = jasmine.createSpy('$timeout');
mockTimeout.andReturn(promise(undefined));
controller = new MCTTableController(mockScope, mockTimeout, mockElement); controller = new MCTTableController(mockScope, mockTimeout, mockElement);
}); });
it('Reacts to changes to filters, headers, and rows', function() { it('Reacts to changes to filters, headers, and rows', function() {
expect(mockScope.$watchCollection).toHaveBeenCalledWith('filters', jasmine.any(Function)); expect(mockScope.$watchCollection).toHaveBeenCalledWith('filters', jasmine.any(Function));
expect(mockScope.$watchCollection).toHaveBeenCalledWith('headers', jasmine.any(Function)); expect(mockScope.$watch).toHaveBeenCalledWith('headers', jasmine.any(Function));
expect(mockScope.$watchCollection).toHaveBeenCalledWith('rows', jasmine.any(Function)); expect(mockScope.$watch).toHaveBeenCalledWith('rows', jasmine.any(Function));
}); });
describe('rows', function() { describe('rows', function() {
@ -116,6 +119,19 @@ define(
expect(mockScope.displayRows).toEqual(testRows); expect(mockScope.displayRows).toEqual(testRows);
}); });
it('Supports adding rows individually', function() {
var addRowFunc = mockScope.$on.mostRecentCall.args[1],
row4 = {
'col1': {'text': 'row3 col1'},
'col2': {'text': 'ghi'},
'col3': {'text': 'row3 col3'}
};
controller.updateRows(testRows);
expect(mockScope.displayRows.length).toBe(3);
addRowFunc(row4);
expect(mockScope.displayRows.length).toBe(4);
});
describe('sorting', function() { describe('sorting', function() {
var sortedRows; var sortedRows;
@ -149,7 +165,87 @@ define(
expect(sortedRows[1].col2.text).toEqual('def'); expect(sortedRows[1].col2.text).toEqual('def');
expect(sortedRows[2].col2.text).toEqual('abc'); expect(sortedRows[2].col2.text).toEqual('abc');
}); });
describe('Adding new rows', function() {
var row4,
row5,
row6;
beforeEach(function() {
row4 = {
'col1': {'text': 'row5 col1'},
'col2': {'text': 'xyz'},
'col3': {'text': 'row5 col3'}
};
row5 = {
'col1': {'text': 'row6 col1'},
'col2': {'text': 'aaa'},
'col3': {'text': 'row6 col3'}
};
row6 = {
'col1': {'text': 'row6 col1'},
'col2': {'text': 'ggg'},
'col3': {'text': 'row6 col3'}
};
});
it('Adds new rows at the correct sort position when' +
' sorted ', function() {
mockScope.sortColumn = 'col2';
mockScope.sortDirection = 'desc';
mockScope.displayRows = controller.sortRows(testRows);
controller.newRow(undefined, row4);
expect(mockScope.displayRows[0].col2.text).toEqual('xyz');
controller.newRow(undefined, row5);
expect(mockScope.displayRows[4].col2.text).toEqual('aaa');
controller.newRow(undefined, row6);
expect(mockScope.displayRows[2].col2.text).toEqual('ggg');
//Add a duplicate row
controller.newRow(undefined, row6);
expect(mockScope.displayRows[2].col2.text).toEqual('ggg');
expect(mockScope.displayRows[3].col2.text).toEqual('ggg');
});
it('Adds new rows at the correct sort position when' +
' sorted and filtered', function() {
mockScope.sortColumn = 'col2';
mockScope.sortDirection = 'desc';
mockScope.filters = {'col2': 'a'};//Include only
// rows with 'a'
mockScope.displayRows = controller.sortRows(testRows);
mockScope.displayRows = controller.filterRows(testRows);
controller.newRow(undefined, row5);
expect(mockScope.displayRows.length).toBe(2);
expect(mockScope.displayRows[1].col2.text).toEqual('aaa');
controller.newRow(undefined, row6);
expect(mockScope.displayRows.length).toBe(2);
//Row was not added because does not match filter
});
it('Adds new rows at the correct sort position when' +
' not sorted ', function() {
mockScope.sortColumn = undefined;
mockScope.sortDirection = undefined;
mockScope.filters = {};
mockScope.displayRows = testRows;
controller.newRow(undefined, row5);
expect(mockScope.displayRows[3].col2.text).toEqual('aaa');
controller.newRow(undefined, row6);
expect(mockScope.displayRows[4].col2.text).toEqual('ggg');
});
});
}); });
}); });
}); });
}); });

View File

@ -0,0 +1,146 @@
/*****************************************************************************
* 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,waitsFor,jasmine,xit*/
define(
[
"../../src/controllers/RTTelemetryTableController"
],
function (TableController) {
"use strict";
describe('The real-time table controller', function() {
var mockScope,
mockTelemetryHandler,
mockTelemetryHandle,
mockTelemetryFormatter,
mockDomainObject,
mockTable,
mockConfiguration,
watches,
mockTableRow,
controller;
function promise(value) {
return {
then: function (callback){
return promise(callback(value));
}
};
}
beforeEach(function() {
watches = {};
mockTableRow = {'col1': 'val1', 'col2': 'row2'};
mockScope = jasmine.createSpyObj('scope', [
'$on',
'$watch',
'$watchCollection',
'$broadcast'
]);
mockScope.$on.andCallFake(function(expression, callback){
watches[expression] = callback;
});
mockScope.$watch.andCallFake(function(expression, callback){
watches[expression] = callback;
});
mockScope.$watchCollection.andCallFake(function(expression, callback){
watches[expression] = callback;
});
mockConfiguration = {
'range1': true,
'range2': true,
'domain1': true
};
mockTable = jasmine.createSpyObj('table',
[
'buildColumns',
'getColumnConfiguration',
'getRowValues',
'saveColumnConfiguration'
]
);
mockTable.columns = [];
mockTable.getColumnConfiguration.andReturn(mockConfiguration);
mockTable.getRowValues.andReturn(mockTableRow);
mockDomainObject= jasmine.createSpyObj('domainObject', [
'getCapability',
'useCapability',
'getModel'
]);
mockDomainObject.getModel.andReturn({});
mockDomainObject.getCapability.andReturn(
{
getMetadata: function(){
return {ranges: [{format: 'string'}]};
}
});
mockScope.domainObject = mockDomainObject;
mockTelemetryHandle = jasmine.createSpyObj('telemetryHandle', [
'getMetadata',
'unsubscribe',
'getDatum',
'promiseTelemetryObjects',
'getTelemetryObjects'
]);
// Arbitrary array with non-zero length, contents are not
// used by mocks
mockTelemetryHandle.getTelemetryObjects.andReturn([{}]);
mockTelemetryHandle.promiseTelemetryObjects.andReturn(promise(undefined));
mockTelemetryHandle.getDatum.andReturn({});
mockTelemetryHandler = jasmine.createSpyObj('telemetryHandler', [
'handle'
]);
mockTelemetryHandler.handle.andReturn(mockTelemetryHandle);
controller = new TableController(mockScope, mockTelemetryHandler, mockTelemetryFormatter);
controller.table = mockTable;
controller.handle = mockTelemetryHandle;
});
it('registers for streaming telemetry', function() {
controller.subscribe();
expect(mockTelemetryHandler.handle).toHaveBeenCalledWith(jasmine.any(Object), jasmine.any(Function), true);
});
it('updates table with new streaming telemetry', function() {
controller.subscribe();
mockTelemetryHandler.handle.mostRecentCall.args[1]();
expect(mockScope.$broadcast).toHaveBeenCalledWith('addRow', mockTableRow);
});
it('enables autoscroll for event telemetry', function() {
controller.subscribe();
mockScope.$watch.mostRecentCall.args[1](mockDomainObject);
expect(mockScope.autoScroll).toBe(true);
});
});
}
);