mirror of
https://github.com/nasa/openmct.git
synced 2025-06-01 07:00:49 +00:00
520 lines
19 KiB
JavaScript
520 lines
19 KiB
JavaScript
|
|
define(
|
|
[],
|
|
function () {
|
|
|
|
/**
|
|
* 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) {
|
|
var self = this;
|
|
|
|
this.$scope = $scope;
|
|
this.element = element;
|
|
this.$timeout = $timeout;
|
|
this.maxDisplayRows = 50;
|
|
|
|
this.scrollable = element.find('div');
|
|
this.thead = element.find('thead');
|
|
this.tbody = element.find('tbody');
|
|
this.$scope.sizingRow = {};
|
|
|
|
this.scrollable.on('scroll', this.onScroll.bind(this));
|
|
|
|
$scope.visibleRows = [];
|
|
|
|
/**
|
|
* Set default values for optional parameters on a given scope
|
|
*/
|
|
function setDefaults(scope) {
|
|
if (typeof scope.enableFilter === 'undefined') {
|
|
scope.enableFilter = true;
|
|
scope.filters = {};
|
|
}
|
|
if (typeof scope.enableSort === 'undefined') {
|
|
scope.enableSort = true;
|
|
scope.sortColumn = undefined;
|
|
scope.sortDirection = undefined;
|
|
}
|
|
}
|
|
|
|
setDefaults($scope);
|
|
|
|
$scope.exportAsCSV = function () {
|
|
window.alert("Export!");
|
|
};
|
|
|
|
$scope.toggleSort = function (key) {
|
|
if (!$scope.enableSort) {
|
|
return;
|
|
}
|
|
if ($scope.sortColumn !== key) {
|
|
$scope.sortColumn = key;
|
|
$scope.sortDirection = 'asc';
|
|
} else if ($scope.sortDirection === 'asc') {
|
|
$scope.sortDirection = 'desc';
|
|
} else if ($scope.sortDirection === 'desc') {
|
|
$scope.sortColumn = undefined;
|
|
$scope.sortDirection = undefined;
|
|
}
|
|
self.setRows($scope.rows);
|
|
};
|
|
|
|
/*
|
|
* Define watches to listen for changes to headers and rows.
|
|
*/
|
|
$scope.$watchCollection('filters', function () {
|
|
self.setRows($scope.rows);
|
|
});
|
|
$scope.$watch('headers', this.setHeaders.bind(this));
|
|
$scope.$watch('rows', this.setRows.bind(this));
|
|
|
|
/*
|
|
* Listen for rows added individually (eg. for real-time tables)
|
|
*/
|
|
$scope.$on('add:row', this.addRow.bind(this));
|
|
$scope.$on('remove:row', this.removeRow.bind(this));
|
|
}
|
|
|
|
/**
|
|
* If auto-scroll is enabled, this function will scroll to the
|
|
* 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
|
|
* `add:row` broadcast event.
|
|
* @private
|
|
*/
|
|
MCTTableController.prototype.addRow = function (event, rowIndex) {
|
|
var row = this.$scope.rows[rowIndex];
|
|
|
|
//Does the row pass the current filter?
|
|
if (this.filterRows([row]).length === 1) {
|
|
//Insert the row into the correct position in the array
|
|
this.insertSorted(this.$scope.displayRows, row);
|
|
|
|
//Resize the columns , then update the rows visible in the table
|
|
this.resize([this.$scope.sizingRow, row])
|
|
.then(this.setVisibleRows.bind(this))
|
|
.then(this.scrollToBottom.bind(this));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handles a row remove event. Rows can be removed as needed using the
|
|
* `remove:row` broadcast event.
|
|
* @private
|
|
*/
|
|
MCTTableController.prototype.removeRow = function (event, rowIndex) {
|
|
var row = this.$scope.rows[rowIndex],
|
|
// Do a sequential search here. Only way of finding row is by
|
|
// object equality, so array is in effect unsorted.
|
|
indexInDisplayRows = this.$scope.displayRows.indexOf(row);
|
|
if (indexInDisplayRows !== -1) {
|
|
this.$scope.displayRows.splice(indexInDisplayRows, 1);
|
|
this.setVisibleRows();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @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 () {
|
|
var self = this,
|
|
target = this.scrollable[0],
|
|
topScroll = target.scrollTop,
|
|
bottomScroll = topScroll + target.offsetHeight,
|
|
firstVisible,
|
|
lastVisible,
|
|
totalVisible,
|
|
numberOffscreen,
|
|
start,
|
|
end;
|
|
|
|
//No need to scroll
|
|
if (this.$scope.displayRows.length < this.maxDisplayRows) {
|
|
start = 0;
|
|
end = this.$scope.displayRows.length;
|
|
} else {
|
|
//rows has exceeded display maximum, so may be necessary to
|
|
// scroll
|
|
if (topScroll < this.$scope.headerHeight) {
|
|
firstVisible = 0;
|
|
} else {
|
|
firstVisible = Math.floor(
|
|
(topScroll - this.$scope.headerHeight) /
|
|
this.$scope.rowHeight
|
|
);
|
|
}
|
|
lastVisible = Math.ceil(
|
|
(bottomScroll - this.$scope.headerHeight) /
|
|
this.$scope.rowHeight
|
|
);
|
|
|
|
totalVisible = lastVisible - firstVisible;
|
|
numberOffscreen = this.maxDisplayRows - totalVisible;
|
|
start = firstVisible - Math.floor(numberOffscreen / 2);
|
|
end = lastVisible + Math.ceil(numberOffscreen / 2);
|
|
|
|
if (start < 0) {
|
|
start = 0;
|
|
end = Math.min(this.maxDisplayRows,
|
|
this.$scope.displayRows.length);
|
|
} else if (end >= this.$scope.displayRows.length) {
|
|
end = this.$scope.displayRows.length;
|
|
start = end - this.maxDisplayRows + 1;
|
|
}
|
|
if (this.$scope.visibleRows[0] &&
|
|
this.$scope.visibleRows[0].rowIndex === start &&
|
|
this.$scope.visibleRows[this.$scope.visibleRows.length - 1]
|
|
.rowIndex === end) {
|
|
|
|
return; // don't update if no changes are required.
|
|
}
|
|
}
|
|
//Set visible rows from display rows, based on calculated offset.
|
|
this.$scope.visibleRows = this.$scope.displayRows.slice(start, end)
|
|
.map(function (row, i) {
|
|
return {
|
|
rowIndex: start + i,
|
|
offsetY: ((start + i) * self.$scope.rowHeight) +
|
|
self.$scope.headerHeight,
|
|
contents: row
|
|
};
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Update table headers with new headers. If filtering is
|
|
* enabled, reset filters. If sorting is enabled, reset
|
|
* sorting.
|
|
*/
|
|
MCTTableController.prototype.setHeaders = function (newHeaders) {
|
|
if (!newHeaders) {
|
|
return;
|
|
}
|
|
|
|
this.$scope.displayHeaders = newHeaders;
|
|
if (this.$scope.enableFilter) {
|
|
this.$scope.filters = {};
|
|
}
|
|
// Reset column sort information unless the new headers
|
|
// contain the column currently sorted on.
|
|
if (this.$scope.enableSort &&
|
|
newHeaders.indexOf(this.$scope.sortColumn) === -1) {
|
|
this.$scope.sortColumn = undefined;
|
|
this.$scope.sortDirection = undefined;
|
|
}
|
|
this.setRows(this.$scope.rows);
|
|
};
|
|
|
|
/**
|
|
* Read styles from the DOM and use them to calculate offsets
|
|
* for individual rows.
|
|
*/
|
|
MCTTableController.prototype.setElementSizes = function () {
|
|
var thead = this.thead,
|
|
tbody = this.tbody,
|
|
firstRow = tbody.find('tr'),
|
|
column = firstRow.find('td'),
|
|
headerHeight = thead.prop('offsetHeight'),
|
|
rowHeight = firstRow.prop('offsetHeight'),
|
|
columnWidth,
|
|
tableWidth = 0,
|
|
overallHeight = headerHeight + (rowHeight *
|
|
(this.$scope.displayRows ? this.$scope.displayRows.length - 1 : 0));
|
|
|
|
this.$scope.columnWidths = [];
|
|
|
|
while (column.length) {
|
|
columnWidth = column.prop('offsetWidth');
|
|
this.$scope.columnWidths.push(column.prop('offsetWidth'));
|
|
tableWidth += columnWidth;
|
|
column = column.next();
|
|
}
|
|
this.$scope.headerHeight = headerHeight;
|
|
this.$scope.rowHeight = rowHeight;
|
|
this.$scope.totalHeight = overallHeight;
|
|
|
|
if (tableWidth > 0) {
|
|
this.$scope.totalWidth = tableWidth + 'px';
|
|
} else {
|
|
this.$scope.totalWidth = 'none';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @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,
|
|
numberA,
|
|
numberB;
|
|
/**
|
|
* Given a value, if it is a number, or a string representation of a
|
|
* number, then return a number representation. Otherwise, return
|
|
* the original value. It's a little more robust than using just
|
|
* Number() or parseFloat, or isNaN in isolation, all of which are
|
|
* fairly inconsistent in their results.
|
|
* @param value The value to return as a number.
|
|
* @returns {*} The value cast to a Number, or the original value if
|
|
* a Number representation is not possible.
|
|
*/
|
|
function toNumber(value) {
|
|
var val = !isNaN(Number(value)) && !isNaN(parseFloat(value)) ? Number(value) : value;
|
|
return val;
|
|
}
|
|
|
|
numberA = toNumber(a);
|
|
numberB = toNumber(b);
|
|
|
|
//If they're both numbers, then compare them as numbers
|
|
if (typeof numberA === "number" && typeof numberB === "number") {
|
|
a = numberA;
|
|
b = numberB;
|
|
}
|
|
|
|
//If they're both strings, then ignore case
|
|
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
|
|
* criteria defined in $scope.
|
|
*
|
|
* Does not modify the array that was passed in.
|
|
*/
|
|
MCTTableController.prototype.sortRows = function (rowsToSort) {
|
|
var self = this,
|
|
sortKey = this.$scope.sortColumn;
|
|
|
|
if (!this.$scope.sortColumn || !this.$scope.sortDirection) {
|
|
return rowsToSort;
|
|
}
|
|
|
|
return rowsToSort.sort(function (a, b) {
|
|
return self.sortComparator(a[sortKey].text, b[sortKey].text);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Returns an object which contains the largest values
|
|
* for each key in the given set of rows. This is used to
|
|
* pre-calculate optimal column sizes without having to render
|
|
* every row.
|
|
*/
|
|
MCTTableController.prototype.buildLargestRow = function (rows) {
|
|
var largestRow = rows.reduce(function (prevLargest, row) {
|
|
Object.keys(row).forEach(function (key) {
|
|
var currentColumn,
|
|
currentColumnLength,
|
|
largestColumn,
|
|
largestColumnLength;
|
|
if (row[key]) {
|
|
currentColumn = (row[key]).text;
|
|
currentColumnLength =
|
|
(currentColumn && currentColumn.length) ?
|
|
currentColumn.length :
|
|
currentColumn;
|
|
largestColumn = prevLargest[key] ? prevLargest[key].text : "";
|
|
largestColumnLength = largestColumn.length;
|
|
|
|
if (currentColumnLength > largestColumnLength) {
|
|
prevLargest[key] = JSON.parse(JSON.stringify(row[key]));
|
|
}
|
|
}
|
|
});
|
|
return prevLargest;
|
|
}, JSON.parse(JSON.stringify(rows[0] || {})));
|
|
return largestRow;
|
|
};
|
|
|
|
/**
|
|
* Calculates the widest row in the table, and if necessary, resizes
|
|
* the table accordingly
|
|
*
|
|
* @param rows the rows on which to resize
|
|
* @returns {Promise} a promise that will resolve when resizing has
|
|
* occurred.
|
|
* @private
|
|
*/
|
|
MCTTableController.prototype.resize = function (rows) {
|
|
this.$scope.sizingRow = this.buildLargestRow(rows);
|
|
return this.$timeout(this.setElementSizes.bind(this));
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
MCTTableController.prototype.filterAndSort = function (rows) {
|
|
var displayRows = rows;
|
|
if (this.$scope.enableFilter) {
|
|
displayRows = this.filterRows(displayRows);
|
|
}
|
|
|
|
if (this.$scope.enableSort) {
|
|
displayRows = this.sortRows(displayRows.slice(0));
|
|
}
|
|
return displayRows;
|
|
};
|
|
|
|
/**
|
|
* Update rows with new data. If filtering is enabled, rows
|
|
* will be sorted before display.
|
|
*/
|
|
MCTTableController.prototype.setRows = function (newRows) {
|
|
//Nothing to show because no columns visible
|
|
if (!this.$scope.displayHeaders || !newRows) {
|
|
return;
|
|
}
|
|
|
|
this.$scope.displayRows = this.filterAndSort(newRows || []);
|
|
this.resize(newRows).then(this.setVisibleRows.bind(this));
|
|
};
|
|
|
|
/**
|
|
* Applies user defined filters to rows. These filters are based on
|
|
* the text entered in the search areas in each column.
|
|
* @param rowsToFilter {Object[]} The rows to apply filters to
|
|
* @returns {Object[]} A filtered copy of the supplied rows
|
|
*/
|
|
MCTTableController.prototype.filterRows = function (rowsToFilter) {
|
|
var filters = {},
|
|
self = this;
|
|
|
|
/**
|
|
* Returns true if row matches all filters.
|
|
*/
|
|
function matchRow(filterMap, row) {
|
|
return Object.keys(filterMap).every(function (key) {
|
|
if (!row[key]) {
|
|
return false;
|
|
}
|
|
var testVal = String(row[key].text).toLowerCase();
|
|
return testVal.indexOf(filterMap[key]) !== -1;
|
|
});
|
|
}
|
|
|
|
if (!Object.keys(this.$scope.filters).length) {
|
|
return rowsToFilter;
|
|
}
|
|
|
|
Object.keys(this.$scope.filters).forEach(function (key) {
|
|
if (!self.$scope.filters[key]) {
|
|
return;
|
|
}
|
|
filters[key] = self.$scope.filters[key].toLowerCase();
|
|
});
|
|
|
|
return rowsToFilter.filter(matchRow.bind(null, filters));
|
|
};
|
|
|
|
|
|
return MCTTableController;
|
|
}
|
|
);
|