Select, Mark and export selected table rows (#2420)

* first pass

* add a unmark all rows button

* enable shift click to select multiple rows

* support row selection backwards

* Styling for marked table rows

- CSS class applied;
- Export button label modified;

* working pause

* working multi select
tables are paused when user selects a row

* Layout improvements for table and control bar elements

- Table markup re-org'd;
- New .c-separator css class;
- Renamed .c-table__control-bar to .c-table-control-bar;
- Added label to Pause button;
- TODO: refine styling for table within frame in Layouts;

* Refined styling for c-button in an object frame

- More compact, better alignment, font sizing and padding;

* change logic to marking/selecting

* use command key to mark multiple

* Fixed regression errors in markup

* add isSelectable functionality

* make reviewer requested changes

* remove key from v-for in table.vue
This commit is contained in:
Deep Tailor 2019-07-25 13:47:40 -07:00 committed by Andrew Henry
parent c760190a29
commit 768d99d928
9 changed files with 383 additions and 129 deletions

View File

@ -49,6 +49,7 @@ define([
this.telemetryObjects = []; this.telemetryObjects = [];
this.outstandingRequests = 0; this.outstandingRequests = 0;
this.configuration = new TelemetryTableConfiguration(domainObject, openmct); this.configuration = new TelemetryTableConfiguration(domainObject, openmct);
this.paused = false;
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier); this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.addTelemetryObject = this.addTelemetryObject.bind(this); this.addTelemetryObject = this.addTelemetryObject.bind(this);
@ -219,7 +220,10 @@ define([
if (!this.telemetryObjects.includes(telemetryObject)) { if (!this.telemetryObjects.includes(telemetryObject)) {
return; return;
} }
this.processRealtimeDatum(datum, columnMap, keyString, limitEvaluator);
if (!this.paused) {
this.processRealtimeDatum(datum, columnMap, keyString, limitEvaluator);
}
}, subscribeOptions); }, subscribeOptions);
} }
@ -255,6 +259,17 @@ define([
} }
} }
pause() {
this.paused = true;
this.boundedRows.unsubscribeFromBounds();
}
unpause() {
this.paused = false;
this.boundedRows.subscribeToBounds();
this.refreshData();
}
destroy() { destroy() {
this.boundedRows.destroy(); this.boundedRows.destroy();
this.filteredRows.destroy(); this.filteredRows.destroy();

View File

@ -21,10 +21,11 @@
*****************************************************************************/ *****************************************************************************/
define(function () { define(function () {
class TelemetryTableColumn { class TelemetryTableColumn {
constructor (openmct, metadatum) { constructor (openmct, metadatum, options = {selectable: false}) {
this.metadatum = metadatum; this.metadatum = metadatum;
this.formatter = openmct.telemetry.getValueFormatter(metadatum); this.formatter = openmct.telemetry.getValueFormatter(metadatum);
this.titleValue = this.metadatum.name; this.titleValue = this.metadatum.name;
this.selectable = options.selectable;
} }
getKey() { getKey() {
@ -55,8 +56,7 @@ define(function () {
return formattedValue; return formattedValue;
} }
} }
}
};
return TelemetryTableColumn; return TelemetryTableColumn;
}); });

View File

@ -67,7 +67,7 @@ define([
table table
}, },
el: element, el: element,
template: '<table-component :isEditing="isEditing"></table-component>' template: '<table-component :isEditing="isEditing" :enableMarking="true"></table-component>'
}); });
}, },
onEditModeChange(isEditing) { onEditModeChange(isEditing) {

View File

@ -43,7 +43,8 @@ define(
this.sortByTimeSystem(openmct.time.timeSystem()); this.sortByTimeSystem(openmct.time.timeSystem());
this.lastBounds = openmct.time.bounds(); this.lastBounds = openmct.time.bounds();
openmct.time.on('bounds', this.bounds);
this.subscribeToBounds();
} }
addOne(item) { addOne(item) {
@ -140,9 +141,17 @@ define(
return this.parseTime(row.datum[this.sortOptions.key]); return this.parseTime(row.datum[this.sortOptions.key]);
} }
destroy() { unsubscribeFromBounds() {
this.openmct.time.off('bounds', this.bounds); this.openmct.time.off('bounds', this.bounds);
} }
subscribeToBounds() {
this.openmct.time.on('bounds', this.bounds);
}
destroy() {
this.unsubscribeFromBounds();
}
} }
return BoundedTableRowCollection; return BoundedTableRowCollection;
}); });

View File

@ -20,22 +20,36 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
<template> <template>
<tr :style="{ top: rowTop }" :class="rowClass"> <tr :style="{ top: rowTop }"
<component class="noselect"
v-for="(title, key) in headers" :class="[
rowClass,
{'is-selected': marked}
]"
@click="markRow">
<component v-for="(title, key) in headers"
:key="key" :key="key"
:is="componentList[key]" :is="componentList[key]"
:columnKey="key" :columnKey="key"
:style="columnWidths[key] === undefined ? {} : { width: columnWidths[key] + 'px', 'max-width': columnWidths[key] + 'px'}" :style="columnWidths[key] === undefined ? {} : { width: columnWidths[key] + 'px', 'max-width': columnWidths[key] + 'px'}"
:title="formattedRow[key]" :title="formattedRow[key]"
:class="cellLimitClasses[key]" :class="[cellLimitClasses[key], selectableColumns[key] ? 'is-selectable' : '']"
class="is-selectable"
@click="selectCell($event.currentTarget, key)" @click="selectCell($event.currentTarget, key)"
:row="row"></component> :row="row">
</component>
</tr> </tr>
</template> </template>
<style> <style>
.noselect {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
supported by Chrome and Opera */
}
</style> </style>
<script> <script>
@ -51,6 +65,10 @@ export default {
componentList: Object.keys(this.headers).reduce((components, header) => { componentList: Object.keys(this.headers).reduce((components, header) => {
components[header] = this.row.getCellComponentName(header) || 'table-cell'; components[header] = this.row.getCellComponentName(header) || 'table-cell';
return components return components
}, {}),
selectableColumns : Object.keys(this.row.columns).reduce((selectable, columnKeys) => {
selectable[columnKeys] = this.row.columns[columnKeys].selectable;
return selectable;
}, {}) }, {})
} }
}, },
@ -81,6 +99,11 @@ export default {
type: Number, type: Number,
required: false, required: false,
default: 0 default: 0
},
marked: {
type: Boolean,
required: false,
default: false
} }
}, },
methods: { methods: {
@ -92,22 +115,41 @@ export default {
this.rowClass = row.getRowClass(); this.rowClass = row.getRowClass();
this.cellLimitClasses = row.getCellLimitClasses(); this.cellLimitClasses = row.getCellLimitClasses();
}, },
markRow: function (event) {
let keyCtrlModifier = false;
if (event.ctrlKey || event.metaKey) {
keyCtrlModifier = true;
}
if (event.shiftKey) {
this.$emit('markMultipleConcurrent', this.rowIndex);
} else {
if (this.marked) {
this.$emit('unmark', this.rowIndex, keyCtrlModifier);
} else {
this.$emit('mark', this.rowIndex, keyCtrlModifier);
}
}
},
selectCell(element, columnKey) { selectCell(element, columnKey) {
//TODO: This is a hack. Cannot get parent this way. if (this.selectableColumns[columnKey]) {
this.openmct.selection.select([{ //TODO: This is a hack. Cannot get parent this way.
element: element, this.openmct.selection.select([{
context: { element: element,
type: 'table-cell', context: {
row: this.row.objectKeyString, type: 'table-cell',
column: columnKey row: this.row.objectKeyString,
} column: columnKey
},{ }
element: this.openmct.layout.$refs.browseObject.$el, },{
context: { element: this.openmct.layout.$refs.browseObject.$el,
item: this.openmct.router.path[0] context: {
} item: this.openmct.router.path[0]
}], false); }
event.stopPropagation(); }], false);
event.stopPropagation();
}
} }
}, },
// TODO: use computed properties // TODO: use computed properties

View File

@ -20,95 +20,134 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
<template> <template>
<div class="c-table c-telemetry-table c-table--filterable c-table--sortable has-control-bar" <div class="c-table-wrapper">
:class="{'loading': loading}"> <div class="c-table-control-bar c-control-bar">
<div :style="{ 'max-width': widthWithScroll, 'min-width': '150px'}"><slot></slot></div>
<div v-if="allowExport" class="c-table__control-bar c-control-bar">
<button class="c-button icon-download labeled" <button class="c-button icon-download labeled"
v-on:click="exportAsCSV()" v-if="allowExport"
title="Export This View's Data"> v-on:click="exportAllDataAsCSV()"
<span class="c-button__label">Export As CSV</span> title="Export This View's Data">
<span class="c-button__label">Export Table Data</span>
</button>
<button class="c-button icon-download labeled"
v-if="allowExport"
v-show="markedRows.length"
v-on:click="exportMarkedDataAsCSV()"
title="Export Marked Rows As CSV">
<span class="c-button__label">Export Marked Rows</span>
</button>
<button class="c-button icon-x labeled"
v-show="markedRows.length"
v-on:click="unmarkAllRows()"
title="Unmark All Rows">
<span class="c-button__label">Unmark All Rows</span>
</button>
<div v-if="enableMarking"
class="c-separator">
</div>
<button v-if="enableMarking"
class="c-button icon-pause pause-play labeled"
:class=" paused ? 'icon-play is-paused' : 'icon-pause'"
v-on:click="togglePauseByButton()"
:title="paused ? 'Continue Data Flow' : 'Pause Data Flow'">
<span class="c-button__label">
{{paused ? 'Play' : 'Pause'}}
</span>
</button> </button>
<slot name="buttons"></slot> <slot name="buttons"></slot>
</div> </div>
<div v-if="isDropTargetActive" class="c-telemetry-table__drop-target" :style="dropTargetStyle"></div>
<!-- Headers table --> <div class="c-table c-telemetry-table c-table--filterable c-table--sortable has-control-bar"
<div class="c-telemetry-table__headers-w js-table__headers-w" ref="headersTable" :style="{ 'max-width': widthWithScroll}"> :class="{
<table class="c-table__headers c-telemetry-table__headers"> 'loading': loading,
<thead> 'paused' : paused
<tr class="c-telemetry-table__headers__labels"> }">
<table-column-header
v-for="(title, key, headerIndex) in headers" <div :style="{ 'max-width': widthWithScroll, 'min-width': '150px'}"><slot></slot></div>
:key="key"
:headerKey="key" <div v-if="isDropTargetActive" class="c-telemetry-table__drop-target" :style="dropTargetStyle"></div>
:headerIndex="headerIndex" <!-- Headers table -->
@sort="allowSorting && sortBy(key)" <div class="c-telemetry-table__headers-w js-table__headers-w" ref="headersTable" :style="{ 'max-width': widthWithScroll}">
@resizeColumn="resizeColumn" <table class="c-table__headers c-telemetry-table__headers">
@dropTargetOffsetChanged="setDropTargetOffset" <thead>
@dropTargetActive="dropTargetActive" <tr class="c-telemetry-table__headers__labels">
@reorderColumn="reorderColumn" <table-column-header
@resizeColumnEnd="updateConfiguredColumnWidths" v-for="(title, key, headerIndex) in headers"
:columnWidth="columnWidths[key]" :key="key"
:sortOptions="sortOptions" :headerKey="key"
:isEditing="isEditing" :headerIndex="headerIndex"
><span class="c-telemetry-table__headers__label">{{title}}</span> @sort="allowSorting && sortBy(key)"
</table-column-header> @resizeColumn="resizeColumn"
</tr> @dropTargetOffsetChanged="setDropTargetOffset"
<tr class="c-telemetry-table__headers__filter"> @dropTargetActive="dropTargetActive"
<table-column-header @reorderColumn="reorderColumn"
v-for="(title, key, headerIndex) in headers" @resizeColumnEnd="updateConfiguredColumnWidths"
:key="key" :columnWidth="columnWidths[key]"
:headerKey="key" :sortOptions="sortOptions"
:headerIndex="headerIndex" :isEditing="isEditing"
@resizeColumn="resizeColumn" ><span class="c-telemetry-table__headers__label">{{title}}</span>
@dropTargetOffsetChanged="setDropTargetOffset" </table-column-header>
@dropTargetActive="dropTargetActive" </tr>
@reorderColumn="reorderColumn" <tr class="c-telemetry-table__headers__filter">
@resizeColumnEnd="updateConfiguredColumnWidths" <table-column-header
:columnWidth="columnWidths[key]" v-for="(title, key, headerIndex) in headers"
:isEditing="isEditing" :key="key"
> :headerKey="key"
<search class="c-table__search" :headerIndex="headerIndex"
v-model="filters[key]" @resizeColumn="resizeColumn"
v-on:input="filterChanged(key)" @dropTargetOffsetChanged="setDropTargetOffset"
v-on:clear="clearFilter(key)" /> @dropTargetActive="dropTargetActive"
</table-column-header> @reorderColumn="reorderColumn"
</tr> @resizeColumnEnd="updateConfiguredColumnWidths"
</thead> :columnWidth="columnWidths[key]"
:isEditing="isEditing"
>
<search class="c-table__search"
v-model="filters[key]"
v-on:input="filterChanged(key)"
v-on:clear="clearFilter(key)" />
</table-column-header>
</tr>
</thead>
</table>
</div>
<!-- Content table -->
<div class="c-table__body-w c-telemetry-table__body-w js-telemetry-table__body-w" @scroll="scroll" :style="{ 'max-width': widthWithScroll}">
<div class="c-telemetry-table__scroll-forcer" :style="{ width: totalWidth + 'px' }"></div>
<table class="c-table__body c-telemetry-table__body js-telemetry-table__content"
:style="{ height: totalHeight + 'px'}">
<tbody>
<telemetry-table-row v-for="(row, rowIndex) in visibleRows"
:headers="headers"
:columnWidths="columnWidths"
:rowIndex="rowIndex"
:rowOffset="rowOffset"
:rowHeight="rowHeight"
:row="row"
:marked="row.marked"
@mark="markRow"
@unmark="unmarkRow"
@markMultipleConcurrent="markMultipleConcurrentRows">
</telemetry-table-row>
</tbody>
</table>
</div>
<!-- Sizing table -->
<table class="c-telemetry-table__sizing js-telemetry-table__sizing" :style="sizingTableWidth">
<tr>
<template v-for="(title, key) in headers">
<th :key="key" :style="{ width: configuredColumnWidths[key] + 'px', 'max-width': configuredColumnWidths[key] + 'px'}">{{title}}</th>
</template>
</tr>
<telemetry-table-row v-for="(sizingRowData, objectKeyString) in sizingRows"
:key="objectKeyString"
:headers="headers"
:columnWidths="configuredColumnWidths"
:row="sizingRowData">
</telemetry-table-row>
</table> </table>
<telemetry-filter-indicator></telemetry-filter-indicator>
</div> </div>
<!-- Content table --> </div><!-- closes c-table-wrapper -->
<div class="c-table__body-w c-telemetry-table__body-w js-telemetry-table__body-w" @scroll="scroll" :style="{ 'max-width': widthWithScroll}">
<div class="c-telemetry-table__scroll-forcer" :style="{ width: totalWidth + 'px' }"></div>
<table class="c-table__body c-telemetry-table__body js-telemetry-table__content"
:style="{ height: totalHeight + '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="sizingTableWidth">
<tr>
<template v-for="(title, key) in headers">
<th :key="key" :style="{ width: configuredColumnWidths[key] + 'px', 'max-width': configuredColumnWidths[key] + 'px'}">{{title}}</th>
</template>
</tr>
<telemetry-table-row v-for="(sizingRowData, objectKeyString) in sizingRows"
:headers="headers"
:columnWidths="configuredColumnWidths"
:row="sizingRowData">
</telemetry-table-row>
</table>
<telemetry-filter-indicator></telemetry-filter-indicator>
</div>
</template> </template>
<style lang="scss"> <style lang="scss">
@ -134,7 +173,7 @@
display: block; display: block;
flex: 1 0 auto; flex: 1 0 auto;
width: 100px; width: 100px;
vertical-align: middle; // This is crucial to hiding f**king 4px height injected by browser by default vertical-align: middle; // This is crucial to hiding 4px height injected by browser by default
} }
td { td {
@ -219,6 +258,10 @@
align-items: stretch; align-items: stretch;
position: absolute; position: absolute;
height: 18px; // Needed when a row has empty values in its cells height: 18px; // Needed when a row has empty values in its cells
&.is-selected {
background-color: $colorSelectedBg;
}
} }
td { td {
@ -269,6 +312,10 @@
} }
} }
.paused {
border: 1px solid #ff9900;
}
/******************************* LEGACY */ /******************************* LEGACY */
.s-status-taking-snapshot, .s-status-taking-snapshot,
.overlay.snapshot { .overlay.snapshot {
@ -318,6 +365,10 @@ export default {
allowSorting: { allowSorting: {
'type': Boolean, 'type': Boolean,
'default': true 'default': true
},
enableMarking: {
type: Boolean,
default: false
} }
}, },
data() { data() {
@ -346,7 +397,10 @@ export default {
dropOffsetLeft: undefined, dropOffsetLeft: undefined,
isDropTargetActive: false, isDropTargetActive: false,
isAutosizeEnabled: configuration.autosize, isAutosizeEnabled: configuration.autosize,
scrollW: 0 scrollW: 0,
markCounter: 0,
paused: false,
markedRows: []
} }
}, },
computed: { computed: {
@ -532,15 +586,27 @@ export default {
// which causes subsequent scroll to use an out of date height. // which causes subsequent scroll to use an out of date height.
this.contentTable.style.height = this.totalHeight + 'px'; this.contentTable.style.height = this.totalHeight + 'px';
}, },
exportAsCSV() { exportAsCSV(data) {
const headerKeys = Object.keys(this.headers); const headerKeys = Object.keys(this.headers);
const justTheData = this.table.filteredRows.getRows()
.map(row => row.getFormattedDatum(this.headers)); this.csvExporter.export(data, {
this.csvExporter.export(justTheData, {
filename: this.table.domainObject.name + '.csv', filename: this.table.domainObject.name + '.csv',
headers: headerKeys headers: headerKeys
}); });
}, },
exportAllDataAsCSV() {
const justTheData = this.table.filteredRows.getRows()
.map(row => row.getFormattedDatum(this.headers));
this.exportAsCSV(justTheData);
},
exportMarkedDataAsCSV() {
const data = this.table.filteredRows.getRows()
.filter(row => row.marked === true)
.map(row => row.getFormattedDatum(this.headers));
this.exportAsCSV(data);
},
outstandingRequests(loading) { outstandingRequests(loading) {
this.loading = loading; this.loading = loading;
}, },
@ -632,8 +698,105 @@ export default {
clearRowsAndRerender() { clearRowsAndRerender() {
this.visibleRows = []; this.visibleRows = [];
this.$nextTick().then(this.updateVisibleRows); this.$nextTick().then(this.updateVisibleRows);
} },
pause(pausedByButton) {
if (pausedByButton) {
this.pausedByButton = true;
}
this.paused = true;
this.table.pause();
},
unpause(unpausedByButton) {
if (unpausedByButton) {
this.paused = false;
this.table.unpause();
this.markedRows = [];
this.pausedByButton = false;
} else {
if (!this.pausedByButton) {
this.paused = false;
this.table.unpause();
this.markedRows = [];
}
}
},
togglePauseByButton() {
if (this.paused) {
this.unpause(true);
} else {
this.pause(true);
}
},
undoMarkedRows(unpause) {
this.markedRows.forEach(r => r.marked = false);
this.markedRows = [];
},
unmarkRow(rowIndex) {
this.undoMarkedRows();
this.unpause();
},
markRow(rowIndex, keyModifier) {
if (!this.enableMarking) {
return;
}
let insertMethod = 'unshift';
if (this.markedRows.length && !keyModifier) {
this.undoMarkedRows();
insertMethod = 'push';
}
let markedRow = this.visibleRows[rowIndex];
this.$set(markedRow, 'marked', true);
this.pause();
this.markedRows[insertMethod](markedRow);
},
unmarkAllRows(skipUnpause) {
this.markedRows.forEach(row => row.marked = false);
this.markedRows = [];
this.unpause();
},
markMultipleConcurrentRows(rowIndex) {
if (!this.enableMarking) {
return;
}
if (!this.markedRows.length) {
this.markRow(rowIndex);
} else {
if (this.markedRows.length > 1) {
this.markedRows.forEach((r,i) => {
if (i !== 0) {
r.marked = false;
}
});
this.markedRows.splice(1);
}
let lastRowToBeMarked = this.visibleRows[rowIndex];
let allRows = this.table.filteredRows.getRows(),
firstRowIndex = allRows.indexOf(this.markedRows[0]),
lastRowIndex = allRows.indexOf(lastRowToBeMarked);
//supports backward selection
if (lastRowIndex < firstRowIndex) {
let temp = lastRowIndex;
lastRowIndex = firstRowIndex;
firstRowIndex = temp - 1;
}
for (var i = firstRowIndex + 1; i <= lastRowIndex; i++) {
let row = allRows[i];
row.marked = true;
this.markedRows.push(row);
}
}
}
}, },
created() { created() {
this.filterChanged = _.debounce(this.filterChanged, 500); this.filterChanged = _.debounce(this.filterChanged, 500);

View File

@ -600,15 +600,15 @@ select {
margin-right: $m; margin-right: $m;
} }
.c-separator {
@include cToolbarSeparator();
}
.c-toolbar { .c-toolbar {
> * + * { > * + * {
margin-left: 2px; margin-left: 2px;
} }
&__button {
}
&__separator { &__separator {
@include cToolbarSeparator(); @include cToolbarSeparator();
} }

View File

@ -76,23 +76,43 @@ div.c-table {
height: 100%; height: 100%;
} }
.c-table-wrapper {
// Wraps .c-control-bar and .c-table
@include abs();
overflow: hidden;
display: flex;
flex-direction: column;
> .c-table {
height: auto;
flex: 1 1 auto;
}
> * + * {
margin-top: $interiorMarginSm;
}
}
.c-table-control-bar {
display: flex;
flex: 0 0 auto;
> * + * {
margin-left: $interiorMarginSm;
}
}
.c-table { .c-table {
// Can be used by any type of table, scrolling, LAD, etc. // Can be used by any type of table, scrolling, LAD, etc.
$min-w: 50px; $min-w: 50px;
width: 100%; width: 100%;
&__control-bar,
&__headers-w { &__headers-w {
flex: 0 0 auto; flex: 0 0 auto;
} }
/******************************* ELEMENTS */ /******************************* ELEMENTS */
&__control-bar {
margin-bottom: $interiorMarginSm;
}
thead tr, thead tr,
&.c-table__headers { &.c-table__headers {
background: $colorTabHeaderBg; background: $colorTabHeaderBg;

View File

@ -123,7 +123,12 @@
.c-click-icon, .c-click-icon,
.c-button { .c-button {
// Shrink buttons a bit when they appear in a frame // Shrink buttons a bit when they appear in a frame
font-size: 0.8em; align-items: baseline;
font-size: 0.85em;
padding: 3px 5px;
&:before {
font-size: 0.8em;
}
} }
} }
</style> </style>