[7.x] Adds capability to show percentages for data table columns (#41206)

* Bring table vis params styles inline with others

* Add percentage column option to table vis
This commit is contained in:
Michail Yasonik 2019-07-15 21:34:43 -07:00 committed by GitHub
parent 85dfc42aa8
commit 25d25ace42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 461 additions and 139 deletions

View file

@ -27,6 +27,7 @@ import { legacyResponseHandlerProvider } from 'ui/vis/response_handlers/legacy';
import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern';
import { VisProvider } from 'ui/vis';
import { tabifyAggResponse } from 'ui/agg_response/tabify';
import { round } from 'lodash';
describe('AggTable Directive', function () {
@ -211,7 +212,7 @@ describe('AggTable Directive', function () {
expect($cells.length).to.be(6);
for (let i = 0; i < 6; i++) {
expect($($cells[i]).text()).to.be(expected[i]);
expect($($cells[i]).text().trim()).to.be(expected[i]);
}
settings.set('dateFormat:tz', oldTimezoneSetting);
off();
@ -353,6 +354,60 @@ describe('AggTable Directive', function () {
});
});
it('renders percentage columns', async function () {
$scope.dimensions = {
buckets: [
{ accessor: 0, params: {} },
{ accessor: 1, format: { id: 'date', params: { pattern: 'YYYY-MM-DD' } } },
],
metrics: [
{ accessor: 2, format: { id: 'number' } },
{ accessor: 3, format: { id: 'date' } },
{ accessor: 4, format: { id: 'number' } },
{ accessor: 5, format: { id: 'number' } },
],
};
const response = await tableAggResponse(
tabifiedData.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative,
$scope.dimensions
);
$scope.table = response.tables[0];
$scope.percentageCol = 'Average bytes';
const $el = $(`<kbn-agg-table
table="table"
dimensions="dimensions"
percentage-col="percentageCol"
></kbn-agg-table>`);
$compile($el)($scope);
$scope.$digest();
const $headings = $el.find('th');
expect($headings.length).to.be(7);
expect(
$headings
.eq(3)
.text()
.trim()
).to.be('Average bytes percentages');
const countColId = $scope.table.columns.find(col => col.name === $scope.percentageCol).id;
const counts = $scope.table.rows.map(row => row[countColId]);
const total = counts.reduce((sum, curr) => sum + curr, 0);
const $percentageColValues = $el.find('tbody tr').map((i, el) =>
$(el)
.find('td')
.eq(3)
.text()
);
$percentageColValues.each((i, value) => {
const percentage = `${round((counts[i] / total) * 100, 1)}%`;
expect(value).to.be(percentage);
});
});
describe('aggTable.exportAsCsv()', function () {
let origBlob;
function FakeBlob(slices, opts) {

View file

@ -6,6 +6,7 @@
per-page="perPage"
sort="sort"
show-total="showTotal"
percentage-col="percentageCol"
filter="filter"
totalFunc="totalFunc">

View file

@ -24,6 +24,7 @@ import _ from 'lodash';
import { uiModules } from 'ui/modules';
import aggTableTemplate from './agg_table.html';
import { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities';
import { i18n } from '@kbn/i18n';
uiModules
.get('kibana', ['RecursionHelper'])
@ -40,6 +41,7 @@ uiModules
exportTitle: '=?',
showTotal: '=',
totalFunc: '=',
percentageCol: '=',
filter: '=',
},
controllerAs: 'aggTable',
@ -94,78 +96,177 @@ uiModules
}).join('');
};
$scope.$watch('table', function () {
const table = $scope.table;
$scope.$watchMulti(
['table', 'exportTitle', 'percentageCol', 'totalFunc', '=scope.dimensions'],
function () {
const { table, exportTitle, percentageCol } = $scope;
const showPercentage = percentageCol !== '';
if (!table) {
$scope.rows = null;
$scope.formattedColumns = null;
return;
if (!table) {
$scope.rows = null;
$scope.formattedColumns = null;
return;
}
self.csv.filename = (exportTitle || table.title || 'table') + '.csv';
$scope.rows = table.rows;
$scope.formattedColumns = [];
if (typeof $scope.dimensions === 'undefined') return;
const { buckets, metrics } = $scope.dimensions;
$scope.formattedColumns = table.columns
.map(function (col, i) {
const isBucket = buckets.find(bucket => bucket.accessor === i);
const dimension = isBucket || metrics.find(metric => metric.accessor === i);
if (!dimension) return;
const formatter = getFormat(dimension.format);
const formattedColumn = {
id: col.id,
title: col.name,
formatter: formatter,
filterable: !!isBucket,
};
const last = i === table.columns.length - 1;
if (last || !isBucket) {
formattedColumn.class = 'visualize-table-right';
}
const isDate =
_.get(dimension, 'format.id') === 'date' ||
_.get(dimension, 'format.params.id') === 'date';
const isNumeric =
_.get(dimension, 'format.id') === 'number' ||
_.get(dimension, 'format.params.id') === 'number';
let { totalFunc } = $scope;
if (typeof totalFunc === 'undefined' && showPercentage) {
totalFunc = 'sum';
}
if (isNumeric || isDate || totalFunc === 'count') {
const sum = tableRows => {
return _.reduce(
tableRows,
function (prev, curr) {
// some metrics return undefined for some of the values
// derivative is an example of this as it returns undefined in the first row
if (curr[col.id] === undefined) return prev;
return prev + curr[col.id];
},
0
);
};
formattedColumn.sumTotal = sum(table.rows);
switch (totalFunc) {
case 'sum': {
if (!isDate) {
const total = formattedColumn.sumTotal;
formattedColumn.formattedTotal = formatter.convert(total);
formattedColumn.total = formattedColumn.sumTotal;
}
break;
}
case 'avg': {
if (!isDate) {
const total = sum(table.rows) / table.rows.length;
formattedColumn.formattedTotal = formatter.convert(total);
formattedColumn.total = total;
}
break;
}
case 'min': {
const total = _.chain(table.rows)
.map(col.id)
.min()
.value();
formattedColumn.formattedTotal = formatter.convert(total);
formattedColumn.total = total;
break;
}
case 'max': {
const total = _.chain(table.rows)
.map(col.id)
.max()
.value();
formattedColumn.formattedTotal = formatter.convert(total);
formattedColumn.total = total;
break;
}
case 'count': {
const total = table.rows.length;
formattedColumn.formattedTotal = total;
formattedColumn.total = total;
break;
}
default:
break;
}
}
return formattedColumn;
})
.filter(column => column);
if (showPercentage) {
const insertAtIndex = _.findIndex($scope.formattedColumns, { title: percentageCol });
// column to show percentage for was removed
if (insertAtIndex < 0) return;
const { cols, rows } = addPercentageCol(
$scope.formattedColumns,
percentageCol,
table.rows,
insertAtIndex
);
$scope.rows = rows;
$scope.formattedColumns = cols;
}
}
self.csv.filename = ($scope.exportTitle || table.title || 'table') + '.csv';
$scope.rows = table.rows;
$scope.formattedColumns = table.columns.map(function (col, i) {
const isBucket = $scope.dimensions.buckets.find(bucket => bucket.accessor === i);
const dimension = isBucket || $scope.dimensions.metrics.find(metric => metric.accessor === i);
if (!dimension) return;
const formatter = getFormat(dimension.format);
const formattedColumn = {
id: col.id,
title: col.name,
formatter: formatter,
filterable: !!isBucket
};
const last = i === (table.columns.length - 1);
if (last || !isBucket) {
formattedColumn.class = 'visualize-table-right';
}
const isDate = _.get(dimension, 'format.id') === 'date' || _.get(dimension, 'format.params.id') === 'date';
const isNumeric = _.get(dimension, 'format.id') === 'number' || _.get(dimension, 'format.params.id') === 'number';
if (isNumeric || isDate || $scope.totalFunc === 'count') {
const sum = tableRows => {
return _.reduce(tableRows, function (prev, curr) {
// some metrics return undefined for some of the values
// derivative is an example of this as it returns undefined in the first row
if (curr[col.id] === undefined) return prev;
return prev + curr[col.id];
}, 0);
};
switch ($scope.totalFunc) {
case 'sum':
if (!isDate) {
formattedColumn.total = formatter.convert(sum(table.rows));
}
break;
case 'avg':
if (!isDate) {
formattedColumn.total = formatter.convert(sum(table.rows) / table.rows.length);
}
break;
case 'min':
formattedColumn.total = formatter.convert(_.chain(table.rows).map(col.id).min().value());
break;
case 'max':
formattedColumn.total = formatter.convert(_.chain(table.rows).map(col.id).max().value());
break;
case 'count':
formattedColumn.total = table.rows.length;
break;
default:
break;
}
}
return formattedColumn;
}).filter(column => column);
});
}
);
},
};
});
/**
* @param {[]Object} columns - the formatted columns that will be displayed
* @param {String} title - the title of the column to add to
* @param {[]Object} rows - the row data for the columns
* @param {Number} insertAtIndex - the index to insert the percentage column at
* @returns {Object} - cols and rows for the table to render now included percentage column(s)
*/
function addPercentageCol(columns, title, rows, insertAtIndex) {
const { id, sumTotal } = columns[insertAtIndex];
const newId = `${id}-percents`;
const formatter = getFormat({ id: 'percent' });
const i18nTitle = i18n.translate('tableVis.params.percentageTableColumnName', {
defaultMessage: '{title} percentages',
values: { title },
});
const newCols = insert(columns, insertAtIndex, {
title: i18nTitle,
id: newId,
formatter,
});
const newRows = rows.map(row => ({
[newId]: formatter.convert(row[id] / sumTotal / 100),
...row,
}));
return { cols: newCols, rows: newRows };
}
function insert(arr, index, ...items) {
const newArray = [...arr];
newArray.splice(index + 1, 0, ...items);
return newArray;
}

View file

@ -16,6 +16,7 @@
dimensions="dimensions"
per-page="perPage"
sort="sort"
percentage-col="percentageCol"
show-total="showTotal"
total-func="totalFunc"></kbn-agg-table-group>
<kbn-agg-table
@ -26,6 +27,7 @@
export-title="exportTitle"
per-page="perPage"
sort="sort"
percentage-col="percentageCol"
show-total="showTotal"
total-func="totalFunc">
</kbn-agg-table>
@ -53,6 +55,7 @@
per-page="perPage"
sort="sort"
show-total="showTotal"
percentage-col="percentageCol"
total-func="totalFunc"></kbn-agg-table-group>
<kbn-agg-table
ng-if="table.rows"
@ -63,6 +66,7 @@
per-page="perPage"
sort="sort"
show-total="showTotal"
percentage-col="percentageCol"
total-func="totalFunc">
</kbn-agg-table>
</td>

View file

@ -37,6 +37,7 @@ uiModules
exportTitle: '=?',
showTotal: '=',
totalFunc: '=',
percentageCol: '=',
filter: '=',
},
compile: function ($el) {

View file

@ -41,7 +41,9 @@
</tbody>
<tfoot ng-if="showTotal">
<tr>
<th scope="col" ng-repeat="col in columns" class="numeric-value">{{col.total}}</th>
<th scope="col" ng-repeat="col in columns" class="numeric-value">
{{ col.formattedTotal }}
</th>
</tr>
</tfoot>
</table>

View file

@ -45,6 +45,7 @@ uiModules
showTotal: '=',
totalFunc: '=',
filter: '=',
percentageCol: '=',
},
controllerAs: 'paginatedTable',
controller: function ($scope) {

View file

@ -21,6 +21,7 @@
export-title="visState.title"
per-page="visState.params.perPage"
sort="sort"
percentage-col="visState.params.percentageCol"
show-total="visState.params.showTotal"
total-func="visState.params.totalFunc">
</kbn-agg-table-group>

View file

@ -70,7 +70,8 @@ function TableVisTypeProvider(Private) {
direction: null
},
showTotal: false,
totalFunc: 'sum'
totalFunc: 'sum',
percentageCol: '',
},
template: tableVisTemplate,
},

View file

@ -1,61 +1,115 @@
<div class="visEditorSidebar__section">
<div class="form-group">
<div class="visEditorSidebar__sectionTitle">
<div
i18n-id="tableVis.params.showMetricsLabel.optionsTitle"
i18n-default-message="Options"
></div>
</div>
<div class="visEditorSidebar__formRow">
<label
class="visEditorSidebar__formLabel"
for="datatableVisualizationPerPage"
i18n-id="tableVis.params.perPageLabel"
i18n-default-message="Per Page"
></label>
<input type="number" ng-model="editorState.params.perPage" class="form-control" id="datatableVisualizationPerPage">
<div class="visEditorSidebar__formControl">
<input
class="kuiInput visEditorSidebar__input"
id="datatableVisualizationPerPage"
type="number"
ng-model="editorState.params.perPage"
/>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="editorState.params.showMetricsAtAllLevels" data-test-subj="showMetricsAtAllLevels">
<span
i18n-id="tableVis.params.showMetricsLabel"
i18n-default-message="Show metrics for every bucket/level"
></span>
</label>
<div class="visEditorSidebar__formRow">
<label
class="visEditorSidebar__formLabel"
for="showMetricsAtAllLevelsCheckbox"
i18n-id="tableVis.params.showMetricsLabel"
i18n-default-message="Show metrics for every bucket/level"
></label>
<div class="visEditorSidebar__formControl">
<input
class="kuiCheckBox"
id="showMetricsAtAllLevelsCheckbox"
type="checkbox"
ng-model="editorState.params.showMetricsAtAllLevels"
data-test-subj="showMetricsAtAllLevels"
/>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="editorState.params.showPartialRows" data-test-subj="showPartialRows">
<span
<div class="visEditorSidebar__formRow">
<div class="visEditorSidebar__formLabel">
<label
for="showPartialRowsCheckbox"
i18n-id="tableVis.params.showPartialRowsLabel"
i18n-default-message="Show partial rows"
></span>
&nbsp;
>
</label>
<icon-tip
content="::'tableVis.params.showPartialRowsTip' | i18n: {
defaultMessage: 'Show rows that have partial data. This will still calculate metrics for every bucket/level, even if they are not displayed.'
}"
position="'right'"
></icon-tip>
</label>
</div>
<div class="visEditorSidebar__formControl">
<input
class="kuiCheckBox"
id="showPartialRowsCheckbox"
type="checkbox"
ng-model="editorState.params.showPartialRows"
data-test-subj="showPartialRows"
/>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="editorState.params.showTotal">
<span
i18n-id="tableVis.params.showTotalLabel"
i18n-default-message="Show total"
></span>
</label>
</div>
<div>
<div class="visEditorSidebar__formRow">
<label
class="visEditorSidebar__formLabel"
for="showTotalCheckbox"
i18n-id="tableVis.params.showTotalLabel"
i18n-default-message="Show total"
></label>
<div class="visEditorSidebar__formControl">
<input
class="kuiCheckBox"
id="showTotalCheckbox"
type="checkbox"
ng-model="editorState.params.showTotal"
/>
</div>
</div>
<div class="visEditorSidebar__formRow">
<label
class="visEditorSidebar__formLabel"
for="datatableVisualizationTotalFunction"
i18n-id="tableVis.params.totalFunctionLabel"
i18n-default-message="Total function"
></label>
<select ng-disabled="!editorState.params.showTotal"
class="form-control"
ng-model="editorState.params.totalFunc"
ng-options="x for x in totalAggregations" id="datatableVisualizationTotalFunction">
</select>
<div class="visEditorSidebar__formControl">
<select
id="datatableVisualizationTotalFunction"
ng-disabled="!editorState.params.showTotal"
class="kuiSelect visEditorSidebar__select"
ng-model="editorState.params.totalFunc"
ng-options="x for x in totalAggregations"
></select>
</div>
</div>
<div class="visEditorSidebar__formRow">
<label
class="visEditorSidebar__formLabel"
for="datatableVisualizationPercentageCol"
i18n-id="tableVis.params.PercentageColLabel"
i18n-default-message="Percentage column"
></label>
<div class="visEditorSidebar__formControl">
<select
id="datatableVisualizationPercentageCol"
data-test-subj="percentageCol"
class="kuiSelect visEditorSidebar__select"
ng-model="editorState.params.percentageCol"
ng-options="col.value as col.name for col in percentageColumns"
></select>
</div>
</div>
</div>

View file

@ -18,29 +18,67 @@
*/
import { uiModules } from 'ui/modules';
import { tabifyGetColumns } from 'ui/agg_response/tabify/_get_columns.js';
import tableVisParamsTemplate from './table_vis_params.html';
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
uiModules.get('kibana/table_vis')
.directive('tableVisParams', function () {
return {
restrict: 'E',
template: tableVisParamsTemplate,
link: function ($scope) {
$scope.totalAggregations = ['sum', 'avg', 'min', 'max', 'count'];
uiModules.get('kibana/table_vis').directive('tableVisParams', function () {
return {
restrict: 'E',
template: tableVisParamsTemplate,
link: function ($scope) {
const noCol = {
value: '',
name: i18n.translate('tableVis.params.defaultPercetangeCol', {
defaultMessage: 'Dont show',
})
};
$scope.totalAggregations = ['sum', 'avg', 'min', 'max', 'count'];
$scope.percentageColumns = [noCol];
$scope.$watchMulti([
'editorState.params.showPartialRows',
'editorState.params.showMetricsAtAllLevels'
], function () {
$scope.$watchMulti([
'[]editorState.aggs',
'editorState.params.percentageCol',
'=editorState.params.dimensions.buckets',
'=editorState.params.dimensions.metrics',
'vis.dirty' // though not used directly in the callback, it is a strong indicator that we should recompute
], function () {
const { aggs, params } = $scope.editorState;
$scope.percentageColumns = [noCol, ...tabifyGetColumns(aggs.getResponseAggs(), true)
.filter(col => isNumeric(_.get(col, 'aggConfig.type.name'), params.dimensions))
.map(col => ({ value: col.name, name: col.name }))];
if (!_.find($scope.percentageColumns, { value: params.percentageCol })) {
params.percentageCol = $scope.percentageColumns[0].value;
}
}, true);
$scope.$watchMulti(
['editorState.params.showPartialRows', 'editorState.params.showMetricsAtAllLevels'],
function () {
if (!$scope.vis) return;
const params = $scope.editorState.params;
if (params.showPartialRows || params.showMetricsAtAllLevels) {
$scope.metricsAtAllLevels = true;
} else {
$scope.metricsAtAllLevels = false;
}
});
}
};
});
$scope.metricsAtAllLevels = params.showPartialRows || params.showMetricsAtAllLevels;
}
);
},
};
});
/**
* Determines if a aggConfig is numeric
* @param {String} type - the type of the aggConfig
* @param {Object} obj - dimensions of the current visualization or editor
* @param {Object} obj.buckets
* @param {Object} obj.metrics
* @returns {Boolean}
*/
export function isNumeric(type, { buckets = [], metrics = [] } = {}) {
const dimension =
buckets.find(({ aggType }) => aggType === type) ||
metrics.find(({ aggType }) => aggType === type);
const formatType = _.get(dimension, 'format.id') || _.get(dimension, 'format.params.id');
return formatType === 'number';
}

View file

@ -84,6 +84,7 @@
<!-- remove button -->
<button
data-test-subj="removeDimensionBtn"
ng-if="canRemove(agg)"
aria-label="{{::'common.ui.vis.editors.agg.removeDimensionButtonAriaLabel' | i18n: { defaultMessage: 'Remove Dimension' } }}"
ng-if="stats.count > stats.min"

View file

@ -97,6 +97,62 @@ export default function ({ getService, getPageObjects }) {
});
});
it('should show percentage columns', async () => {
async function expectValidTableData() {
const data = await PageObjects.visualize.getTableVisData();
expect(data.trim().split('\n')).to.be.eql([
'0 to 1000',
'1,351 64.7%',
'1000 to 2000',
'737 35.3%',
]);
}
// load a plain table
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickDataTable();
await PageObjects.visualize.clickNewSearch();
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
await PageObjects.visualize.clickBucket('Split rows');
await PageObjects.visualize.selectAggregation('Range');
await PageObjects.visualize.selectField('bytes');
await PageObjects.visualize.clickGo();
await PageObjects.visualize.clickOptionsTab();
await PageObjects.visualize.setSelectByOptionText(
'datatableVisualizationPercentageCol',
'Count'
);
await PageObjects.visualize.clickGo();
await expectValidTableData();
// check that it works after a save and reload
const SAVE_NAME = 'viz w/ percents';
await PageObjects.visualize.saveVisualizationExpectSuccessAndBreadcrumb(SAVE_NAME);
await PageObjects.visualize.waitForVisualizationSavedToastGone();
await PageObjects.visualize.loadSavedVisualization(SAVE_NAME);
await PageObjects.visualize.waitForVisualization();
await expectValidTableData();
// check that it works after selecting a column that's deleted
await PageObjects.visualize.clickData();
await PageObjects.visualize.clickBucket('Metric', 'metrics');
await PageObjects.visualize.selectAggregation('Average', 'metrics');
await PageObjects.visualize.selectField('bytes', 'metrics');
await PageObjects.visualize.removeDimension(1);
await PageObjects.visualize.clickGo();
await PageObjects.visualize.clickOptionsTab();
const data = await PageObjects.visualize.getTableVisData();
expect(data.trim().split('\n')).to.be.eql([
'0 to 1000',
'344.094B',
'1000 to 2000',
'1.697KB',
]);
});
it('should show correct data when using average pipeline aggregation', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickDataTable();

View file

@ -332,19 +332,22 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
await PageObjects.header.waitUntilLoadingHasFinished();
}
async isChecked(selector) {
const checkbox = await testSubjects.find(selector);
return await checkbox.isSelected();
}
async checkCheckbox(selector) {
const element = await testSubjects.find(selector);
const isSelected = await element.isSelected();
if(!isSelected) {
const isChecked = await this.isChecked(selector);
if (!isChecked) {
log.debug(`checking checkbox ${selector}`);
await testSubjects.click(selector);
}
}
async uncheckCheckbox(selector) {
const element = await testSubjects.find(selector);
const isSelected = await element.isSelected();
if(isSelected) {
const isChecked = await this.isChecked(selector);
if (isChecked) {
log.debug(`unchecking checkbox ${selector}`);
await testSubjects.click(selector);
}
@ -1255,6 +1258,9 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
return result;
}
async removeDimension(agg) {
await testSubjects.click(`aggregationEditor${agg} removeDimensionBtn`);
}
}
return new VisualizePage();