[Table] style refactor (#2157)

* [Table] Use Vue SFCs

Use Vue SFCs.  Use inject/provide to pass services to components
instead of wrapping components in closures.

* Convert CSS to BEM  - WIP!

- All in progress;
- Headers table divorced from old;
- Sizing working properly at this point;

* Reset legacy file, undo unintended change commit

* Convert CSS to BEM  - WIP!

- All in progress;
- Sizing table divorced from legacy;

* Convert CSS to BEM  - WIP!

- All in progress;
- Table body divorced from legacy;

* Convert CSS to BEM  - WIP!

- Near done, converted tabular-holder from legacy;
- Unit testing in main view and in Layout frames;
- Modded legacy CSS to properly hide control-bar with new naming
when in Layout frame;

* Convert CSS to BEM - done

- Cleanup and organization;

* Convert CSS to BEM

- Further code cleanup;

* Convert CSS to BEM

- Further code cleanup;
- Remove legacy table style imports;
This commit is contained in:
Pete Richards 2018-09-05 15:05:40 -07:00 committed by GitHub
parent e2e0cf17db
commit 0301d88033
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 760 additions and 592 deletions

View File

@ -1,87 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2018, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT 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 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.
*****************************************************************************/
define([
'lodash',
'vue',
'./table-configuration.html',
'./TelemetryTableConfiguration'
],function (
_,
Vue,
TableConfigurationTemplate,
TelemetryTableConfiguration
) {
return function TableConfigurationComponent(domainObject, openmct) {
const tableConfiguration = new TelemetryTableConfiguration(domainObject, openmct);
let unlisteners = [];
return new Vue({
template: TableConfigurationTemplate,
data() {
return {
headers: {},
configuration: tableConfiguration.getConfiguration()
}
},
methods: {
updateHeaders(headers) {
this.headers = headers;
},
toggleColumn(key) {
let isHidden = this.configuration.hiddenColumns[key] === true;
this.configuration.hiddenColumns[key] = !isHidden;
tableConfiguration.updateConfiguration(this.configuration);
},
addObject(domainObject) {
tableConfiguration.addColumnsForObject(domainObject, true);
this.updateHeaders(tableConfiguration.getAllHeaders());
},
removeObject(objectIdentifier) {
tableConfiguration.removeColumnsForObject(objectIdentifier, true);
this.updateHeaders(tableConfiguration.getAllHeaders());
}
},
mounted() {
let compositionCollection = openmct.composition.get(domainObject);
compositionCollection.load()
.then((composition) => {
tableConfiguration.addColumnsForAllObjects(composition);
this.updateHeaders(tableConfiguration.getAllHeaders());
compositionCollection.on('add', this.addObject);
unlisteners.push(compositionCollection.off.bind(compositionCollection, 'add', this.addObject));
compositionCollection.on('remove', this.removeObject);
unlisteners.push(compositionCollection.off.bind(compositionCollection, 'remove', this.removeObject));
});
},
destroyed() {
tableConfiguration.destroy();
unlisteners.forEach((unlisten) => unlisten());
}
});
}
});

View File

@ -22,11 +22,16 @@
define([
'../../api/objects/object-utils',
'./TableConfigurationComponent'
'./components/table-configuration.vue',
'./TelemetryTableConfiguration',
'vue'
], function (
objectUtils,
TableConfigurationComponent
TableConfigurationComponent,
TelemetryTableConfiguration,
Vue
) {
function TableConfigurationViewProvider(openmct) {
let instantiateService;
@ -51,27 +56,38 @@ define([
return instantiateService(model, id);
}
return {
key: 'table-configuration',
name: 'Telemetry Table Configuration',
canView: function (selection) {
if (selection.length === 0) {
return false;
}
let object = selection[0].context.item;
return selection.length > 0 &&
object.type === 'table' &&
return object.type === 'table' &&
isBeingEdited(object);
},
view: function (selection) {
let component;
let domainObject = selection[0].context.item;
const tableConfiguration = new TelemetryTableConfiguration(domainObject, openmct);
return {
show: function (element) {
component = TableConfigurationComponent(domainObject, openmct);
element.appendChild(component.$mount().$el);
},
component = new Vue({
provide: {
openmct,
tableConfiguration
},
components: {
TableConfiguration: TableConfigurationComponent.default
},
template: '<table-configuration></table-configuration>',
el: element
});
},
destroy: function (element) {
component.$destroy();
element.removeChild(component.$el);
component = undefined;
}
}
@ -82,4 +98,4 @@ define([
}
}
return TableConfigurationViewProvider;
});
});

View File

@ -36,12 +36,12 @@ define([
TelemetryTableConfiguration
) {
class TelemetryTable extends EventEmitter {
constructor(domainObject, rowCount, openmct) {
constructor(domainObject, openmct) {
super();
this.domainObject = domainObject;
this.openmct = openmct;
this.rowCount = rowCount;
this.rowCount = 100;
this.subscriptions = {};
this.tableComposition = undefined;
this.telemetryObjects = [];
@ -85,10 +85,10 @@ define([
this.configuration.addColumnsForAllObjects(composition);
composition.forEach(this.addTelemetryObject);
this.tableComposition.on('add', this.addTelemetryObject);
this.tableComposition.on('remove', this.removeTelemetryObject);
});
});
}
}
@ -158,7 +158,7 @@ define([
getColumnMapForObject(objectKeyString) {
let columns = this.configuration.getColumns();
return columns[objectKeyString].reduce((map, column) => {
map[column.getKey()] = column;
return map;
@ -189,7 +189,7 @@ define([
this.filteredRows.destroy();
Object.keys(this.subscriptions).forEach(this.unsubscribe, this);
this.openmct.time.off('bounds', this.refreshData);
if (this.tableComposition !== undefined) {
this.tableComposition.off('add', this.addTelemetryObject);
this.tableComposition.off('remove', this.removeTelemetryObject);
@ -198,4 +198,4 @@ define([
}
return TelemetryTable;
});
});

View File

@ -1,315 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2018, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT 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 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.
*****************************************************************************/
define([
'lodash',
'vue',
'./telemetry-table.html',
'./TelemetryTable',
'./TelemetryTableRowComponent',
'../../exporters/CSVExporter'
],function (
_,
Vue,
TelemetryTableTemplate,
TelemetryTable,
TelemetryTableRowComponent,
CSVExporter
) {
const VISIBLE_ROW_COUNT = 100;
const ROW_HEIGHT = 17;
const RESIZE_POLL_INTERVAL = 200;
const AUTO_SCROLL_TRIGGER_HEIGHT = 20;
return function TelemetryTableComponent(domainObject, openmct) {
const csvExporter = new CSVExporter();
const table = new TelemetryTable(domainObject, VISIBLE_ROW_COUNT, openmct);
let processingScroll = false;
let updatingView = false;
return new Vue({
template: TelemetryTableTemplate,
components: {
'telemetry-table-row': TelemetryTableRowComponent
},
data() {
return {
headers: {},
configuration: table.configuration.getConfiguration(),
headersCount: 0,
visibleRows: [],
columnWidths: [],
sizingRows: {},
rowHeight: ROW_HEIGHT,
scrollOffset: 0,
totalHeight: 0,
totalWidth: 0,
rowOffset: 0,
autoScroll: true,
sortOptions: {},
filters: {},
loading: false,
scrollable: undefined,
tableEl: undefined,
headersHolderEl: undefined,
calcTableWidth: '100%'
}
},
methods: {
updateVisibleRows() {
let start = 0;
let end = VISIBLE_ROW_COUNT;
let filteredRows = table.filteredRows.getRows();
let filteredRowsLength = filteredRows.length;
this.totalHeight = this.rowHeight * filteredRowsLength - 1;
if (filteredRowsLength < VISIBLE_ROW_COUNT) {
end = filteredRowsLength;
} else {
let firstVisible = this.calculateFirstVisibleRow();
let lastVisible = this.calculateLastVisibleRow();
let totalVisible = lastVisible - firstVisible;
let numberOffscreen = VISIBLE_ROW_COUNT - totalVisible;
start = firstVisible - Math.floor(numberOffscreen / 2);
end = lastVisible + Math.ceil(numberOffscreen / 2);
if (start < 0) {
start = 0;
end = Math.min(VISIBLE_ROW_COUNT, filteredRowsLength);
} else if (end >= filteredRowsLength) {
end = filteredRowsLength;
start = end - VISIBLE_ROW_COUNT + 1;
}
}
this.rowOffset = start;
this.visibleRows = filteredRows.slice(start, end);
},
calculateFirstVisibleRow() {
return Math.floor(this.scrollable.scrollTop / this.rowHeight);
},
calculateLastVisibleRow() {
let bottomScroll = this.scrollable.scrollTop + this.scrollable.offsetHeight;
return Math.floor(bottomScroll / this.rowHeight);
},
updateHeaders() {
let headers = table.configuration.getVisibleHeaders();
this.headers = headers;
this.headersCount = Object.values(headers).length;
Vue.nextTick().then(this.calculateColumnWidths);
},
setSizingTableWidth() {
let scrollW = this.scrollable.offsetWidth - this.scrollable.clientWidth;
if (scrollW && scrollW > 0) {
this.calcTableWidth = 'calc(100% - ' + scrollW + 'px)';
}
},
calculateColumnWidths() {
let columnWidths = [];
let totalWidth = 0;
let sizingRowEl = this.sizingTable.children[0];
let sizingCells = Array.from(sizingRowEl.children);
sizingCells.forEach((cell) => {
let columnWidth = cell.offsetWidth;
columnWidths.push(columnWidth + 'px');
totalWidth += columnWidth;
});
this.columnWidths = columnWidths;
this.totalWidth = totalWidth;
},
sortBy(columnKey) {
// If sorting by the same column, flip the sort direction.
if (this.sortOptions.key === columnKey) {
if (this.sortOptions.direction === 'asc') {
this.sortOptions.direction = 'desc';
} else {
this.sortOptions.direction = 'asc';
}
} else {
this.sortOptions = {
key: columnKey,
direction: 'asc'
}
}
table.filteredRows.sortBy(this.sortOptions);
},
scroll() {
if (!processingScroll) {
processingScroll = true;
requestAnimationFrame(()=> {
this.updateVisibleRows();
this.synchronizeScrollX();
if (this.shouldSnapToBottom()) {
this.autoScroll = true;
} else {
// If user scrolls away from bottom, disable auto-scroll.
// Auto-scroll will be re-enabled if user scrolls to bottom again.
this.autoScroll = false;
}
processingScroll = false;
});
}
},
shouldSnapToBottom() {
return this.scrollable.scrollTop >= (this.scrollable.scrollHeight - this.scrollable.offsetHeight - AUTO_SCROLL_TRIGGER_HEIGHT);
},
scrollToBottom() {
this.scrollable.scrollTop = this.scrollable.scrollHeight;
},
synchronizeScrollX() {
this.headersHolderEl.scrollLeft = this.scrollable.scrollLeft;
},
filterChanged(columnKey) {
table.filteredRows.setColumnFilter(columnKey, this.filters[columnKey]);
},
clearFilter(columnKey) {
this.filters[columnKey] = '';
table.filteredRows.setColumnFilter(columnKey, '');
},
rowsAdded(rows) {
let sizingRow;
if (Array.isArray(rows)) {
sizingRow = rows[0];
} else {
sizingRow = rows;
}
if (!this.sizingRows[sizingRow.objectKeyString]) {
this.sizingRows[sizingRow.objectKeyString] = sizingRow;
Vue.nextTick().then(this.calculateColumnWidths);
}
if (!updatingView) {
updatingView = true;
requestAnimationFrame(()=> {
this.updateVisibleRows();
if (this.autoScroll) {
Vue.nextTick().then(this.scrollToBottom);
}
updatingView = false;
});
}
},
rowsRemoved(rows) {
if (!updatingView) {
updatingView = true;
requestAnimationFrame(()=> {
this.updateVisibleRows();
updatingView = false;
});
}
},
exportAsCSV() {
const justTheData = table.filteredRows.getRows()
.map(row => row.getFormattedDatum());
const headers = Object.keys(this.headers);
csvExporter.export(justTheData, {
filename: table.domainObject.name + '.csv',
headers: headers
});
},
outstandingRequests(loading) {
this.loading = loading;
},
calculateTableSize() {
this.setSizingTableWidth();
Vue.nextTick().then(this.calculateColumnWidths);
},
pollForResize() {
let el = this.$el;
let width = el.clientWidth;
let height = el.clientHeight;
this.resizePollHandle = setInterval(() => {
if (el.clientWidth !== width || el.clientHeight !== height) {
this.calculateTableSize();
width = el.clientWidth;
height = el.clientHeight;
}
}, RESIZE_POLL_INTERVAL);
},
updateConfiguration(configuration) {
this.configuration = configuration;
this.updateHeaders();
},
addObject() {
this.updateHeaders();
},
removeObject(objectIdentifier) {
let objectKeyString = openmct.objects.makeKeyString(objectIdentifier);
delete this.sizingRows[objectKeyString];
this.updateHeaders();
}
},
created() {
this.filterChanged = _.debounce(this.filterChanged, 500);
},
mounted() {
table.on('object-added', this.addObject);
table.on('object-removed', this.removeObject);
table.on('outstanding-requests', this.outstandingRequests);
table.filteredRows.on('add', this.rowsAdded);
table.filteredRows.on('remove', this.rowsRemoved);
table.filteredRows.on('sort', this.updateVisibleRows);
table.filteredRows.on('filter', this.updateVisibleRows);
//Default sort
this.sortOptions = table.filteredRows.sortBy();
this.scrollable = this.$el.querySelector('.t-scrolling');
this.sizingTable = this.$el.querySelector('.js-sizing-table');
this.headersHolderEl = this.$el.querySelector('.mct-table-headers-w');
table.configuration.on('change', this.updateConfiguration);
this.calculateTableSize();
this.pollForResize();
table.initialize();
},
destroyed() {
table.off('object-added', this.addObject);
table.off('object-removed', this.removeObject);
table.off('outstanding-requests', this.outstandingRequests);
table.filteredRows.off('add', this.rowsAdded);
table.filteredRows.off('remove', this.rowsRemoved);
table.filteredRows.off('sort', this.updateVisibleRows);
table.filteredRows.off('filter', this.updateVisibleRows);
table.configuration.off('change', this.updateConfiguration);
clearInterval(this.resizePollHandle);
table.configuration.destroy();
table.destroy();
}
});
}
});

View File

@ -1,90 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2018, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT 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 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.
*****************************************************************************/
define([
'./telemetry-table-row.html',
],function (
TelemetryTableRowTemplate
) {
return {
template: TelemetryTableRowTemplate,
data: function () {
return {
rowTop: (this.rowOffset + this.rowIndex) * this.rowHeight + 'px',
formattedRow: this.row.getFormattedDatum(),
rowLimitClass: this.row.getRowLimitClass(),
cellLimitClasses: this.row.getCellLimitClasses()
}
},
props: {
headers: {
type: Object,
required: true
},
row: {
type: Object,
required: true
},
columnWidths: {
type: Array,
required: false,
default: [],
},
rowIndex: {
type: Number,
required: false,
default: undefined
},
rowOffset: {
type: Number,
required: false,
default: 0
},
rowHeight: {
type: Number,
required: false,
default: 0
},
configuration: {
type: Object,
required: true
}
},
methods: {
calculateRowTop: function (rowOffset) {
this.rowTop = (rowOffset + this.rowIndex) * this.rowHeight + 'px';
},
formatRow: function (row) {
this.formattedRow = row.getFormattedDatum();
this.rowLimitClass = row.getRowLimitClass();
this.cellLimitClasses = row.getCellLimitClasses();
}
},
watch: {
rowOffset: 'calculateRowTop',
row: {
handler: 'formatRow',
deep: false
}
}
};
});

View File

@ -20,7 +20,17 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
define(['./TelemetryTableComponent'], function (TelemetryTableComponent) {
define([
'./components/table.vue',
'../../exporters/CSVExporter',
'./TelemetryTable',
'vue'
], function (
TableComponent,
CSVExporter,
TelemetryTable,
Vue
) {
function TelemetryTableViewProvider(openmct) {
return {
key: 'table',
@ -30,15 +40,26 @@ define(['./TelemetryTableComponent'], function (TelemetryTableComponent) {
return domainObject.type === 'table' || domainObject.hasOwnProperty('telemetry');
},
view: function (domainObject) {
let csvExporter = new CSVExporter();
let table = new TelemetryTable(domainObject, openmct);
let component;
return {
show: function (element) {
component = new TelemetryTableComponent(domainObject, openmct);
element.appendChild(component.$mount().$el);
},
component = new Vue({
components: {
TableComponent: TableComponent.default,
},
provide: {
openmct,
csvExporter,
table
},
el: element,
template: '<table-component></table-component>'
});
},
destroy: function (element) {
component.$destroy();
element.removeChild(component.$el);
component = undefined;
}
}
@ -49,4 +70,4 @@ define(['./TelemetryTableComponent'], function (TelemetryTableComponent) {
}
}
return TelemetryTableViewProvider;
});
});

View File

@ -0,0 +1,68 @@
<template>
<div class="grid-properties">
<!--form class="form" -->
<ul class="l-inspector-part">
<h2>Table Columns</h2>
<li class="grid-row" v-for="(title, key) in headers">
<div class="grid-cell label" title="Show or Hide Column"><label :for="key + 'ColumnControl'">{{title}}</label></div>
<div class="grid-cell value"><input type="checkbox" :id="key + 'ColumnControl'" :checked="configuration.hiddenColumns[key] !== true" @change="toggleColumn(key)"></div>
</li>
</ul>
<!--/form -->
</div>
</template>
<style>
</style>
<script>
export default {
inject: ['tableConfiguration', 'openmct'],
data() {
return {
headers: {},
configuration: this.tableConfiguration.getConfiguration()
}
},
methods: {
updateHeaders(headers) {
this.headers = headers;
},
toggleColumn(key) {
let isHidden = this.configuration.hiddenColumns[key] === true;
this.configuration.hiddenColumns[key] = !isHidden;
this.tableConfiguration.updateConfiguration(this.configuration);
},
addObject(domainObject) {
this.tableConfiguration.addColumnsForObject(domainObject, true);
this.updateHeaders(this.tableConfiguration.getAllHeaders());
},
removeObject(objectIdentifier) {
this.tableConfiguration.removeColumnsForObject(objectIdentifier, true);
this.updateHeaders(this.tableConfiguration.getAllHeaders());
}
},
mounted() {
this.unlisteners = [];
let compositionCollection = this.openmct.composition.get(this.tableConfiguration.domainObject);
compositionCollection.load()
.then((composition) => {
this.tableConfiguration.addColumnsForAllObjects(composition);
this.updateHeaders(this.tableConfiguration.getAllHeaders());
compositionCollection.on('add', this.addObject);
this.unlisteners.push(compositionCollection.off.bind(compositionCollection, 'add', this.addObject));
compositionCollection.on('remove', this.removeObject);
this.unlisteners.push(compositionCollection.off.bind(compositionCollection, 'remove', this.removeObject));
});
},
destroyed() {
this.tableConfiguration.destroy();
this.unlisteners.forEach((unlisten) => unlisten());
}
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<tr :style="{ top: rowTop }" :class="rowLimitClass">
<td v-for="(title, key, headerIndex) in headers"
:style="{ width: columnWidths[headerIndex], 'max-width': columnWidths[headerIndex]}"
:title="formattedRow[key]"
:class="cellLimitClasses[key]">{{formattedRow[key]}}</td>
</tr>
</template>
<style>
</style>
<script>
export default {
data: function () {
return {
rowTop: (this.rowOffset + this.rowIndex) * this.rowHeight + 'px',
formattedRow: this.row.getFormattedDatum(),
rowLimitClass: this.row.getRowLimitClass(),
cellLimitClasses: this.row.getCellLimitClasses()
}
},
props: {
headers: {
type: Object,
required: true
},
row: {
type: Object,
required: true
},
columnWidths: {
type: Array,
required: false,
default: [],
},
rowIndex: {
type: Number,
required: false,
default: undefined
},
rowOffset: {
type: Number,
required: false,
default: 0
},
rowHeight: {
type: Number,
required: false,
default: 0
},
configuration: {
type: Object,
required: true
}
},
methods: {
calculateRowTop: function (rowOffset) {
this.rowTop = (rowOffset + this.rowIndex) * this.rowHeight + 'px';
},
formatRow: function (row) {
this.formattedRow = row.getFormattedDatum();
this.rowLimitClass = row.getRowLimitClass();
this.cellLimitClasses = row.getCellLimitClasses();
}
},
// TODO: use computed properties
watch: {
rowOffset: 'calculateRowTop',
row: {
handler: 'formatRow',
deep: false
}
}
}
</script>

View File

@ -0,0 +1,543 @@
<template>
<div class="c-table c-telemetry-table c-table--filterable c-table--sortable has-control-bar"
:class="{'loading': loading}">
<div class="c-table__control-bar c-control-bar">
<a class="s-button t-export icon-download labeled"
v-on:click="exportAsCSV()"
title="Export This View's Data">
Export As CSV
</a>
</div>
<!-- Headers table -->
<div class="c-table__headers-w js-table__headers-w">
<table class="c-table__headers c-telemetry-table__headers"
:style="{ 'max-width': totalWidth + 'px'}">
<thead>
<tr>
<th v-for="(title, key, headerIndex) in headers"
v-on:click="sortBy(key)"
:class="['is-sortable', sortOptions.key === key ? 'is-sorting' : '', sortOptions.direction].join(' ')"
:style="{ width: columnWidths[headerIndex], 'max-width': columnWidths[headerIndex]}">{{title}}</th>
</tr>
<tr class="s-filters">
<th v-for="(title, key, headerIndex) in headers"
:style="{
width: columnWidths[headerIndex],
'max-width': columnWidths[headerIndex],
}">
<div class="holder l-filter flex-elem grows" :class="{active: filters[key]}">
<input type="text" v-model="filters[key]" v-on:input="filterChanged(key)" />
<a class="clear-icon clear-input icon-x-in-circle" :class="{show: filters[key]}" @click="clearFilter(key)"></a>
</div>
</th>
</tr>
</thead>
</table>
</div>
<!-- Content table -->
<div class="c-table__body-w c-telemetry-table__body-w js-telemetry-table__body-w" @scroll="scroll">
<div class="c-telemetry-table__scroll-forcer" :style="{ width: totalWidth }"></div>
<table class="c-table__body c-telemetry-table__body"
:style="{ height: totalHeight + 'px', 'max-width': totalWidth + 'px'}">
<tbody>
<telemetry-table-row v-for="(row, rowIndex) in visibleRows"
:headers="headers"
:columnWidths="columnWidths"
:rowIndex="rowIndex"
:rowOffset="rowOffset"
:rowHeight="rowHeight"
:row="row"
>
</telemetry-table-row>
</tbody>
</table>
</div>
<!-- Sizing table -->
<table class="c-telemetry-table__sizing js-telemetry-table__sizing"
:style="{width: calcTableWidth}">
<tr>
<th v-for="(title, key, headerIndex) in headers">{{title}}</th>
</tr>
<telemetry-table-row v-for="(sizingRowData, objectKeyString) in sizingRows"
:headers="headers"
:row="sizingRowData">
</telemetry-table-row>
</table>
</div>
</template>
<style lang="scss">
@import "~styles/sass-base";
.c-table {
// Can be used by any type of table, scrolling, LAD, etc.
$min-w: 50px;
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
overflow: hidden;
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
&__control-bar,
&__headers-w {
// Don't allow top level elements to grow or shrink
flex: 0 0 auto;
}
/******************************* ELEMENTS */
th, td {
display: block;
flex: 1 0 auto;
font-size: 0.7rem; // TEMP LEGACY TODO: refactor this when __main-container font-size is dealt with
white-space: nowrap;
min-width: $min-w;
padding: $tabularTdPadTB $tabularTdPadLR;
vertical-align: middle; // This is crucial to hiding f**king 4px height injected by browser by default
}
td {
color: $colorTelemFresh;
vertical-align: top;
}
&__control-bar {
margin-bottom: $interiorMarginSm;
}
/******************************* WRAPPERS */
&__headers-w {
// Wraps __headers table
background: $colorTabHeaderBg;
overflow: hidden;
}
/******************************* TABLES */
&__headers,
&__body {
tr {
display: flex;
align-items: stretch;
}
}
&__headers {
// A table
thead {
display: block;
}
th {
&:not(:first-child) {
border-left: 1px solid $colorTabHeaderBorder;
}
}
}
&__body {
// A table
tr {
&:not(:first-child) {
border-top: 1px solid $colorTabBorder;
}
}
}
/******************************* MODIFIERS */
&--filterable {
// TODO: discuss using the search.vue custom control here
.l-filter {
input[type="text"],
input[type="search"] {
$p: 20px;
transition: padding 200ms ease-in-out;
box-sizing: border-box;
padding-right: $p; // Fend off from icon
padding-left: $p; // Fend off from icon
width: 100%;
}
&.active {
// When user has typed something, hide the icon and collapse left padding
&:before {
opacity: 0;
}
input[type="text"],
input[type="search"] {
padding-left: $interiorMargin;
}
}
}
}
&--sortable {
.is-sorting {
&:after {
color: $colorIconLink;
content: $glyph-icon-arrow-tall-up;
font-family: symbolsfont;
font-size: 8px;
display: inline-block;
margin-left: $interiorMarginSm;
}
&.desc:after {
content: $glyph-icon-arrow-tall-down;
}
}
.is-sortable {
cursor: pointer;
}
}
}
.c-telemetry-table {
// Table that displays telemetry in a scrolling body area
/******************************* ELEMENTS */
&__scroll-forcer {
// Force horz scroll when needed; width set via JS
font-size: 0;
height: 1px; // Height 0 won't force scroll properly
position: relative;
}
/******************************* WRAPPERS */
&__body-w {
// Wraps __body table provides scrolling
flex: 1 1 100%;
overflow-x: auto;
overflow-y: scroll;
}
/******************************* TABLES */
&__body {
// A table
flex: 1 1 100%;
overflow-x: auto;
tr {
display: flex; // flex-flow defaults to row nowrap (which is what we want) so no need to define
align-items: stretch;
position: absolute;
height: 18px; // Needed when a row has empty values in its cells
}
td {
overflow: hidden;
text-overflow: ellipsis;
}
}
&__sizing {
// A table
display: table;
z-index: -1;
visibility: hidden;
pointer-events: none;
position: absolute;
//Add some padding to allow for decorations such as limits indicator
tr {
display: table-row;
}
th, td {
display: table-cell;
padding-right: 10px;
padding-left: 10px;
white-space: nowrap;
}
}
}
.c-table__control-bar {
margin-bottom: $interiorMarginSm;
}
/******************************* LEGACY */
.s-status-taking-snapshot,
.overlay.snapshot {
// Handle overflow-y issues with tables and html2canvas
// Replaces .l-sticky-headers .l-tabular-body { overflow: auto; }
.c-table__body-w { overflow: auto; }
}
</style>
<script>
import TelemetryTableRow from './table-row.vue';
import _ from 'lodash';
const VISIBLE_ROW_COUNT = 100;
const ROW_HEIGHT = 17;
const RESIZE_POLL_INTERVAL = 200;
const AUTO_SCROLL_TRIGGER_HEIGHT = 20;
export default {
components: {
TelemetryTableRow
},
inject: ['table', 'openmct', 'csvExporter'],
props: ['configuration'],
data() {
return {
headers: {},
headersCount: 0,
visibleRows: [],
columnWidths: [],
sizingRows: {},
rowHeight: ROW_HEIGHT,
scrollOffset: 0,
totalHeight: 0,
totalWidth: 0,
rowOffset: 0,
autoScroll: true,
sortOptions: {},
filters: {},
loading: false,
scrollable: undefined,
tableEl: undefined,
headersHolderEl: undefined,
calcTableWidth: '100%',
processingScroll: false,
updatingView: false
}
},
methods: {
updateVisibleRows() {
let start = 0;
let end = VISIBLE_ROW_COUNT;
let filteredRows = this.table.filteredRows.getRows();
let filteredRowsLength = filteredRows.length;
this.totalHeight = this.rowHeight * filteredRowsLength - 1;
if (filteredRowsLength < VISIBLE_ROW_COUNT) {
end = filteredRowsLength;
} else {
let firstVisible = this.calculateFirstVisibleRow();
let lastVisible = this.calculateLastVisibleRow();
let totalVisible = lastVisible - firstVisible;
let numberOffscreen = VISIBLE_ROW_COUNT - totalVisible;
start = firstVisible - Math.floor(numberOffscreen / 2);
end = lastVisible + Math.ceil(numberOffscreen / 2);
if (start < 0) {
start = 0;
end = Math.min(VISIBLE_ROW_COUNT, filteredRowsLength);
} else if (end >= filteredRowsLength) {
end = filteredRowsLength;
start = end - VISIBLE_ROW_COUNT + 1;
}
}
this.rowOffset = start;
this.visibleRows = filteredRows.slice(start, end);
},
calculateFirstVisibleRow() {
return Math.floor(this.scrollable.scrollTop / this.rowHeight);
},
calculateLastVisibleRow() {
let bottomScroll = this.scrollable.scrollTop + this.scrollable.offsetHeight;
return Math.floor(bottomScroll / this.rowHeight);
},
updateHeaders() {
let headers = this.table.configuration.getVisibleHeaders();
this.headers = headers;
this.headersCount = Object.values(headers).length;
this.$nextTick().then(this.calculateColumnWidths);
},
setSizingTableWidth() {
let scrollW = this.scrollable.offsetWidth - this.scrollable.clientWidth;
if (scrollW && scrollW > 0) {
this.calcTableWidth = 'calc(100% - ' + scrollW + 'px)';
}
},
calculateColumnWidths() {
let columnWidths = [];
let totalWidth = 0;
let sizingRowEl = this.sizingTable.children[0];
let sizingCells = Array.from(sizingRowEl.children);
sizingCells.forEach((cell) => {
let columnWidth = cell.offsetWidth;
columnWidths.push(columnWidth + 'px');
totalWidth += columnWidth;
});
this.columnWidths = columnWidths;
this.totalWidth = totalWidth;
},
sortBy(columnKey) {
// If sorting by the same column, flip the sort direction.
if (this.sortOptions.key === columnKey) {
if (this.sortOptions.direction === 'asc') {
this.sortOptions.direction = 'desc';
} else {
this.sortOptions.direction = 'asc';
}
} else {
this.sortOptions = {
key: columnKey,
direction: 'asc'
}
}
this.table.filteredRows.sortBy(this.sortOptions);
},
scroll() {
if (!this.processingScroll) {
this.processingScroll = true;
requestAnimationFrame(()=> {
this.updateVisibleRows();
this.synchronizeScrollX();
if (this.shouldSnapToBottom()) {
this.autoScroll = true;
} else {
// If user scrolls away from bottom, disable auto-scroll.
// Auto-scroll will be re-enabled if user scrolls to bottom again.
this.autoScroll = false;
}
this.processingScroll = false;
});
}
},
shouldSnapToBottom() {
return this.scrollable.scrollTop >= (this.scrollable.scrollHeight - this.scrollable.offsetHeight - AUTO_SCROLL_TRIGGER_HEIGHT);
},
scrollToBottom() {
this.scrollable.scrollTop = this.scrollable.scrollHeight;
},
synchronizeScrollX() {
this.headersHolderEl.scrollLeft = this.scrollable.scrollLeft;
},
filterChanged(columnKey) {
this.table.filteredRows.setColumnFilter(columnKey, this.filters[columnKey]);
},
clearFilter(columnKey) {
this.filters[columnKey] = '';
this.table.filteredRows.setColumnFilter(columnKey, '');
},
rowsAdded(rows) {
let sizingRow;
if (Array.isArray(rows)) {
sizingRow = rows[0];
} else {
sizingRow = rows;
}
if (!this.sizingRows[sizingRow.objectKeyString]) {
this.sizingRows[sizingRow.objectKeyString] = sizingRow;
this.$nextTick().then(this.calculateColumnWidths);
}
if (!this.updatingView) {
this.updatingView = true;
requestAnimationFrame(()=> {
this.updateVisibleRows();
if (this.autoScroll) {
this.$nextTick().then(this.scrollToBottom);
}
this.updatingView = false;
});
}
},
rowsRemoved(rows) {
if (!this.updatingView) {
this.updatingView = true;
requestAnimationFrame(()=> {
this.updateVisibleRows();
this.updatingView = false;
});
}
},
exportAsCSV() {
const justTheData = this.table.filteredRows.getRows()
.map(row => row.getFormattedDatum());
const headers = Object.keys(this.headers);
this.csvExporter.export(justTheData, {
filename: this.table.domainObject.name + '.csv',
headers: headers
});
},
outstandingRequests(loading) {
this.loading = loading;
},
calculateTableSize() {
this.setSizingTableWidth();
this.$nextTick().then(this.calculateColumnWidths);
},
pollForResize() {
let el = this.$el;
let width = el.clientWidth;
let height = el.clientHeight;
this.resizePollHandle = setInterval(() => {
if (el.clientWidth !== width || el.clientHeight !== height) {
this.calculateTableSize();
width = el.clientWidth;
height = el.clientHeight;
}
}, RESIZE_POLL_INTERVAL);
},
updateConfiguration(configuration) {
this.configuration = configuration;
this.updateHeaders();
},
addObject() {
this.updateHeaders();
},
removeObject(objectIdentifier) {
let objectKeyString = this.openmct.objects.makeKeyString(objectIdentifier);
delete this.sizingRows[objectKeyString];
this.updateHeaders();
}
},
created() {
this.filterChanged = _.debounce(this.filterChanged, 500);
},
mounted() {
this.table.on('object-added', this.addObject);
this.table.on('object-removed', this.removeObject);
this.table.on('outstanding-requests', this.outstandingRequests);
this.table.filteredRows.on('add', this.rowsAdded);
this.table.filteredRows.on('remove', this.rowsRemoved);
this.table.filteredRows.on('sort', this.updateVisibleRows);
this.table.filteredRows.on('filter', this.updateVisibleRows);
//Default sort
this.sortOptions = this.table.filteredRows.sortBy();
this.scrollable = this.$el.querySelector('.js-telemetry-table__body-w');
this.sizingTable = this.$el.querySelector('.js-telemetry-table__sizing');
this.headersHolderEl = this.$el.querySelector('.js-table__headers-w');
this.table.configuration.on('change', this.updateConfiguration);
this.calculateTableSize();
this.pollForResize();
this.table.initialize();
},
destroyed() {
this.table.off('object-added', this.addObject);
this.table.off('object-removed', this.removeObject);
this.table.off('outstanding-requests', this.outstandingRequests);
this.table.filteredRows.off('add', this.rowsAdded);
this.table.filteredRows.off('remove', this.rowsRemoved);
this.table.filteredRows.off('sort', this.updateVisibleRows);
this.table.filteredRows.off('filter', this.updateVisibleRows);
this.table.configuration.off('change', this.updateConfiguration);
clearInterval(this.resizePollHandle);
this.table.configuration.destroy();
this.table.destroy();
}
}
</script>

View File

@ -1,11 +0,0 @@
<div class="grid-properties">
<!--form class="form" -->
<ul class="l-inspector-part">
<h2>Table Columns</h2>
<li class="grid-row" v-for="(title, key) in headers">
<div class="grid-cell label" title="Show or Hide Column"><label :for="key + 'ColumnControl'">{{title}}</label></div>
<div class="grid-cell value"><input type="checkbox" :id="key + 'ColumnControl'" :checked="configuration.hiddenColumns[key] !== true" @change="toggleColumn(key)"></div>
</li>
</ul>
<!--/form -->
</div>

View File

@ -1,64 +0,0 @@
<div class="tabular-holder l-sticky-headers has-control-bar l-telemetry-table" :class="{'loading': loading}">
<div class="l-control-bar">
<a class="s-button t-export icon-download labeled"
v-on:click="exportAsCSV()"
title="Export This View's Data">
Export As CSV
</a>
</div>
<!-- Headers table -->
<div class="mct-table-headers-w">
<table class="mct-table l-tabular-headers filterable" :style="{ 'max-width': totalWidth + 'px'}">
<thead>
<tr>
<th v-for="(title, key, headerIndex) in headers"
v-on:click="sortBy(key)"
:class="['sortable', sortOptions.key === key ? 'sort' : '', sortOptions.direction].join(' ')"
:style="{ width: columnWidths[headerIndex], 'max-width': columnWidths[headerIndex]}">{{title}}</th>
</tr>
<tr class="s-filters">
<th v-for="(title, key, headerIndex) in headers"
:style="{
width: columnWidths[headerIndex],
'max-width': columnWidths[headerIndex],
}">
<div class="holder l-filter flex-elem grows" :class="{active: filters[key]}">
<input type="text" v-model="filters[key]" v-on:input="filterChanged(key)" />
<a class="clear-icon clear-input icon-x-in-circle" :class="{show: filters[key]}" @click="clearFilter(key)"></a>
</div>
</th>
</tr>
</thead>
</table>
</div>
<!-- Content table -->
<div @scroll="scroll" class="l-tabular-body t-scrolling vscroll--persist">
<div class="mct-table-scroll-forcer"
:style="{
width: totalWidth
}"></div>
<table class="mct-table js-telemetry-table" :style="{ height: totalHeight + 'px', 'max-width': totalWidth + 'px'}">
<tbody>
<telemetry-table-row v-for="(row, rowIndex) in visibleRows"
:headers="headers"
:columnWidths="columnWidths"
:rowIndex="rowIndex"
:rowOffset="rowOffset"
:rowHeight="rowHeight"
:row="row"
>
</telemetry-table-row>
</tbody>
</table>
</div>
<!-- Sizing table -->
<table class="mct-sizing-table t-sizing-table js-sizing-table" :style="{width: calcTableWidth}">
<tr>
<th v-for="(title, key, headerIndex) in headers">{{title}}</th>
</tr>
<telemetry-table-row v-for="(sizingRowData, objectKeyString) in sizingRows"
:headers="headers"
:row="sizingRowData">
</telemetry-table-row>
</table>
</div>

View File

@ -19,7 +19,12 @@ $treeItemIndent: 16px;
$treeTypeIconW: 18px;
/*************** Items */
$itemPadLR: 5px;
$ueBrowseGridItemLg: 200px;
/*************** Tabular */
$tabularHeaderH: 22px;
$tabularTdPadLR: $itemPadLR;
$tabularTdPadTB: 2px;
/************************** VISUAL */

View File

@ -146,6 +146,11 @@ ol, ul {
padding-left: 0;
}
table {
border-spacing: 0;
border-collapse: collapse;
}
/************************** LEGACY */
mct-container {

View File

@ -56,7 +56,7 @@
//
//!********************************* VIEWS *!
@import "../styles/fixed-position";
@import "../styles/lists/tabular";
//@import "../styles/lists/tabular";
@import "../styles/plots/plots-main";
@import "../styles/plots/legend";
@import "../styles/iframe";

View File

@ -30,7 +30,8 @@
.child-frame {
.has-control-bar {
$btnExportH: $btnFrameH;
.l-control-bar {
.l-control-bar,
.c-control-bar {
display: none;
}
.l-view-section {