Adding filter feature to web UI (#2297)

Fixes #1792.

This PR adds filter functionality to the web UI via an optional Material-UI <Toolbar> on the top of the table which contains the table's title and a filter icon. The toolbar only shows if the enableFilter={true} prop is passed down from the parent component. The PR modifies the MetricsTable test and adds tests for BaseTable and TopRoutesTable.

Note: The previous Ant-based UI allowed certain tables to be filtered by individual table column; this capacity is not part of this PR but can be added later if useful.
This commit is contained in:
Carol A. Scott 2019-03-01 13:47:42 -08:00 committed by GitHub
parent 4cd1f99e89
commit 1ff04af024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 297 additions and 25 deletions

View File

@ -1,3 +1,5 @@
import CloseIcon from '@material-ui/icons/Close';
import FilterListIcon from '@material-ui/icons/FilterList';
import Paper from '@material-ui/core/Paper';
import PropTypes from 'prop-types';
import React from 'react';
@ -7,7 +9,10 @@ import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import TableSortLabel from '@material-ui/core/TableSortLabel';
import TextField from '@material-ui/core/TextField';
import Toolbar from '@material-ui/core/Toolbar';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import _find from 'lodash/find';
import _get from 'lodash/get';
import _isNil from 'lodash/isNil';
@ -25,12 +30,18 @@ const styles = theme => ({
activeSortIcon: {
opacity: 1,
},
toolbarIcon: {
cursor: "pointer",
opacity: 0.8
},
inactiveSortIcon: {
opacity: 0.4,
},
denseTable: {
paddingRight: "8px",
paddingLeft: "8px"
paddingRight: "8px"
},
title: {
flexGrow: 1
}
});
@ -39,8 +50,11 @@ class BaseTable extends React.Component {
super(props);
this.state = {
order: this.props.defaultOrder || "asc",
orderBy: this.props.defaultOrderBy
orderBy: this.props.defaultOrderBy,
filterBy: ""
};
this.handleFilterInputChange = this.handleFilterInputChange.bind(this);
this.handleFilterToggle = this.handleFilterToggle.bind(this);
}
createSortHandler = col => () => {
@ -54,14 +68,37 @@ class BaseTable extends React.Component {
this.setState({ order, orderBy });
};
sortRows = (tableRows, tableColumns, order, orderBy) => {
if (!orderBy) {
return tableRows;
handleFilterInputChange = e => {
let input = e.target.value.replace(/[^A-Z0-9/.\-_]/gi, "").toLowerCase();
let swapWildCard = /[*]/g; // replace "*" in input with wildcard
let filterBy = new RegExp(input.replace(swapWildCard, ".+"), "i");
if (filterBy !== this.state.filterBy) {
this.setState({ filterBy });
}
}
handleFilterToggle = () => {
let newFilterStatus = !this.state.showFilter;
this.setState({ showFilter: newFilterStatus, filterBy: "" });
}
generateRows = (tableRows, tableColumns, order, orderBy, filterBy) => {
let rows = tableRows;
let col = _find(tableColumns, d => d.dataIndex === orderBy);
let sorted = tableRows.sort(col.sorter);
return order === 'desc' ? sorted.reverse() : sorted;
if (orderBy) {
rows = tableRows.sort(col.sorter);
}
if (filterBy) {
let columnsToFilter = tableColumns.filter(col => col.filter);
let filteredRows = tableRows.filter(row => {
return columnsToFilter.some(col => {
let rowText = col.filter(row);
return rowText.match(filterBy);
});
});
rows = filteredRows;
}
return order === 'desc' ? rows.reverse() : rows;
}
renderHeaderCell = (col, order, orderBy) => {
@ -100,13 +137,41 @@ class BaseTable extends React.Component {
<Tooltip key={col.key || col.dataIndex} placement="top" title={col.tooltip}>{tableCell}</Tooltip>;
}
renderToolbar = (classes, title) => {
return (
<Toolbar>
<Typography
className={classes.title}
variant="h5">
{title}
</Typography>
{this.state.showFilter &&
<TextField
id="input-with-icon-textfield"
onChange={this.handleFilterInputChange}
placeholder="Filter by text"
autoFocus />}
{!this.state.showFilter &&
<FilterListIcon
className={classes.toolbarIcon}
onClick={this.handleFilterToggle} />}
{this.state.showFilter &&
<CloseIcon
className={classes.toolbarIcon}
onClick={this.handleFilterToggle} />}
</Toolbar>
);
}
render() {
const { classes, tableRows, tableColumns, tableClassName, rowKey, padding} = this.props;
const {order, orderBy} = this.state;
const sortedTableRows = this.sortRows(tableRows, tableColumns, order, orderBy);
const { classes, enableFilter, tableRows, tableColumns, tableClassName, title, rowKey, padding} = this.props;
const {order, orderBy, filterBy} = this.state;
const sortedTableRows = tableRows.length > 0 ? this.generateRows(tableRows, tableColumns, order, orderBy, filterBy) : tableRows;
return (
<Paper className={classes.root}>
{enableFilter &&
this.renderToolbar(classes, title)}
<Table className={`${classes.table} ${tableClassName}`} padding={padding}>
<TableHead>
<TableRow>
@ -147,6 +212,7 @@ BaseTable.propTypes = {
classes: PropTypes.shape({}).isRequired,
defaultOrder: PropTypes.string,
defaultOrderBy: PropTypes.string,
enableFilter: PropTypes.bool,
padding: PropTypes.string,
rowKey: PropTypes.func,
tableClassName: PropTypes.string,
@ -158,16 +224,19 @@ BaseTable.propTypes = {
sorter: PropTypes.func,
title: PropTypes.string
})).isRequired,
tableRows: PropTypes.arrayOf(PropTypes.shape({}))
tableRows: PropTypes.arrayOf(PropTypes.shape({})),
title: PropTypes.string
};
BaseTable.defaultProps = {
defaultOrder: "asc",
defaultOrderBy: null,
enableFilter: false,
padding: "default",
rowKey: null,
tableClassName: "",
tableRows: [],
title: ""
};
export default withStyles(styles)(BaseTable);

View File

@ -0,0 +1,71 @@
import _merge from 'lodash/merge';
import ApiHelpers from './util/ApiHelpers.jsx';
import BaseTable from './BaseTable.jsx';
import { routerWrap } from '../../test/testHelpers.jsx';
import { mount } from 'enzyme';
describe("Tests for <BaseTable>", () => {
const defaultProps = {
api: ApiHelpers(""),
};
const tableColumns = [{
dataIndex: "pods.totalPods",
title: "Meshed"
},
{
dataIndex: "deployment",
title: "Name"
},
{
dataIndex: "namespace",
title: "Namespace"
}];
it("renders the table with sample data", () => {
let extraProps = _merge({}, defaultProps, {
tableRows: [{
deployment: "authors",
namespace: "default",
key: "default-deployment-authors",
pods: {totalPods: "1", meshedPods: "1"}
}],
tableColumns: tableColumns,
});
const component = mount(routerWrap(BaseTable, extraProps));
const table = component.find("BaseTable");
expect(table).toBeDefined();
expect(table.props().tableRows).toHaveLength(1);
expect(table.props().tableColumns).toHaveLength(3);
expect(table.find("td")).toHaveLength(3);
expect(table.find("tr")).toHaveLength(2);
});
it("if enableFilter is true, user is shown the filter dialog", () => {
let extraProps = _merge({}, defaultProps, {
tableRows: [{
deployment: "authors",
namespace: "default",
key: "default-deployment-authors",
pods: {totalPods: "1", meshedPods: "1"}
},
{
deployment: "books",
namespace: "default",
key: "default-deployment-books",
pods: {totalPods: "2", meshedPods: "1"}
}],
tableColumns: tableColumns,
enableFilter: true
});
const component = mount(routerWrap(BaseTable, extraProps));
const table = component.find("BaseTable");
const enableFilter = table.prop("enableFilter");
const filterIcon = table.find("FilterListIcon");
expect(enableFilter).toEqual(true);
expect(filterIcon).toBeDefined();
});
});

View File

@ -94,6 +94,7 @@ const columnDefinitions = (resource, showNamespaceColumn, PrefixedLink, isTcpTab
{
title: "Namespace",
dataIndex: "namespace",
filter: d => d.namespace,
isNumeric: false,
render: d => !d.namespace ? "---" : <PrefixedLink to={"/namespaces/" + d.namespace}>{d.namespace}</PrefixedLink>,
sorter: (a, b) => (a.namespace || "").localeCompare(b.namespace)
@ -131,6 +132,7 @@ const columnDefinitions = (resource, showNamespaceColumn, PrefixedLink, isTcpTab
title: isMultiResourceTable ? "Resource" : friendlyTitle(resource).singular,
dataIndex: "name",
isNumeric: false,
filter: d => d.name,
render: d => {
let nameContents;
if (resource === "namespace") {
@ -197,16 +199,18 @@ class MetricsTable extends React.Component {
metrics: PropTypes.arrayOf(processedMetricsPropType),
resource: PropTypes.string.isRequired,
showNamespaceColumn: PropTypes.bool,
title: PropTypes.string
};
static defaultProps = {
showNamespaceColumn: true,
title: "",
isTcpTable: false,
metrics: []
};
render() {
const { metrics, resource, showNamespaceColumn, api, isTcpTable } = this.props;
const { metrics, resource, showNamespaceColumn, title, api, isTcpTable } = this.props;
let showNsColumn = resource === "namespace" ? false : showNamespaceColumn;
@ -214,9 +218,11 @@ class MetricsTable extends React.Component {
let rows = preprocessMetrics(metrics);
return (
<BaseTable
enableFilter={true}
tableRows={rows}
tableColumns={columns}
tableClassName="metric-table"
title={title}
defaultOrderBy="name"
padding="dense" />
);

View File

@ -28,6 +28,43 @@ describe('Tests for <MetricsTable>', () => {
expect(table.props().tableColumns).toHaveLength(10);
});
it('if enableFilter is true, user can filter rows by search term', () => {
let extraProps = _merge({}, defaultProps, {
metrics: [{
name: 'authors',
namespace: 'default',
key: 'authors-default-deploy',
totalRequests: 0,
}, {
name: 'books',
namespace: 'default',
key: 'books-default-deploy',
totalRequests: 0,
}],
resource: 'deployment',
enableFilter: true
});
const component = mount(routerWrap(MetricsTable, extraProps));
const table = component.find('BaseTable');
const enableFilter = table.prop('enableFilter');
const filterIcon = table.find("FilterListIcon");
expect(enableFilter).toEqual(true);
expect(filterIcon).toBeDefined();
expect(table.html()).toContain('books');
expect(table.html()).toContain('authors');
filterIcon.simulate("click");
setTimeout(() => {
const input = table.find("input");
input.simulate("change", {target: {value: "authors"}});
expect(table.html()).not.toContain('books');
expect(table.html()).toContain('authors');
}, 100);
});
it('omits the namespace column for the namespace resource', () => {
let extraProps = _merge({}, defaultProps, { metrics: [], resource: "namespace"});
const component = mount(routerWrap(MetricsTable, extraProps));

View File

@ -126,12 +126,9 @@ class NamespaceLanding extends React.Component {
}
return (
<Grid container direction="column" justify="center">
<Grid item>
<Typography variant="h5">{friendlyTitle(resource).plural}</Typography>
</Grid>
<Grid item>
<MetricsTable
title={friendlyTitle(resource).plural}
resource={resource}
metrics={metrics}
showNamespaceColumn={false} />

View File

@ -303,9 +303,9 @@ export class ResourceDetailBase extends React.Component {
{ _isEmpty(upstreams) ? null : (
<React.Fragment>
<Typography variant="h5">Inbound</Typography>
<MetricsTable
resource="multi_resource"
title="Inbound"
metrics={upstreamMetrics} />
</React.Fragment>
)
@ -313,9 +313,9 @@ export class ResourceDetailBase extends React.Component {
{ _isEmpty(this.state.downstreamMetrics) ? null : (
<React.Fragment>
<Typography variant="h5">Outbound</Typography>
<MetricsTable
resource="multi_resource"
title="Outbound"
metrics={downstreamMetrics} />
</React.Fragment>
)
@ -324,18 +324,18 @@ export class ResourceDetailBase extends React.Component {
{
this.state.resource.type === "pod" ? null : (
<React.Fragment>
<Typography variant="h5">Pods</Typography>
<MetricsTable
resource="pod"
title="Pods"
metrics={this.state.podMetrics} />
</React.Fragment>
)
}
<React.Fragment>
<Typography variant="h5">TCP</Typography>
<MetricsTable
resource="pod"
title="TCP"
isTcpTable={true}
metrics={this.state.podMetrics} />
</React.Fragment>

View File

@ -1,10 +1,11 @@
import { directionColumn, srcDstColumn, tapLink } from './util/TapUtils.jsx';
import { formatLatencySec, numericSort } from './util/Utils.js';
import { directionColumn, extractDisplayName, srcDstColumn, tapLink } from './util/TapUtils.jsx';
import { formatLatencySec, numericSort, toShortResourceName } from './util/Utils.js';
import BaseTable from './BaseTable.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import SuccessRateMiniChart from './util/SuccessRateMiniChart.jsx';
import _isEmpty from 'lodash/isEmpty';
import _isNil from 'lodash/isNil';
import { withContext } from './util/AppContext.jsx';
@ -16,17 +17,25 @@ const topColumns = (resourceType, ResourceLink, PrefixedLink) => [
},
{
title: "Name",
filter: d => {
let [labels, display] = extractDisplayName(d);
return _isEmpty(labels[resourceType]) ?
display.str :
toShortResourceName(resourceType) + "/" + labels[resourceType];
},
key: "src-dst",
render: d => srcDstColumn(d, resourceType, ResourceLink)
},
{
title: "Method",
dataIndex: "httpMethod",
filter: d => d.httpMethod,
sorter: (a, b) => a.httpMethod.localeCompare(b.httpMethod)
},
{
title: "Path",
dataIndex: "path",
filter: d => d.path,
sorter: (a, b) => a.path.localeCompare(b.path)
},
{
@ -91,6 +100,7 @@ class TopEventTable extends React.Component {
let columns = topColumns(resourceType, api.ResourceLink, api.PrefixedLink);
return (
<BaseTable
enableFilter={true}
tableRows={tableRows}
tableColumns={columns}
tableClassName="metric-table"

View File

@ -9,6 +9,7 @@ const routesColumns = [
{
title: "Route",
dataIndex: "route",
filter: d => d.route,
sorter: (a, b) => {
if (a.route === DefaultRoute) {
return 1;
@ -24,6 +25,7 @@ const routesColumns = [
title: "Service",
tooltip: "hostname:port used when communicating with this target",
dataIndex: "authority",
filter: d => d.authority,
sorter: (a, b) => (a.authority).localeCompare(b.authority)
},
{
@ -76,6 +78,7 @@ export default class TopRoutesTable extends React.Component {
const { rows } = this.props;
return (
<BaseTable
enableFilter={true}
tableRows={rows}
tableColumns={routesColumns}
tableClassName="metric-table"

View File

@ -0,0 +1,75 @@
import _merge from 'lodash/merge';
import ApiHelpers from './util/ApiHelpers.jsx';
import TopRoutesTable from './TopRoutesTable.jsx';
import { routerWrap } from '../../test/testHelpers.jsx';
import { mount } from 'enzyme';
describe("Tests for <TopRoutesTable>", () => {
const defaultProps = {
api: ApiHelpers(""),
};
it("renders the table with all columns", () => {
let extraProps = _merge({}, defaultProps, {
rows: [{
route: "[DEFAULT]",
latency: {
P50: 133,
P95: 291,
P99: 188
},
authority: "webapp",
name: "authors:7001"
}],
});
const component = mount(routerWrap(TopRoutesTable, extraProps));
const table = component.find("BaseTable");
expect(table).toBeDefined();
expect(table.html()).toContain("[DEFAULT]");
expect(table.props().tableRows).toHaveLength(1);
expect(table.props().tableColumns).toHaveLength(7);
});
it("if enableFilter is true, user can filter rows by search term", () => {
let extraProps = _merge({}, defaultProps, {
rows: [{
route: "[DEFAULT]",
latency: {
P50: 133,
P95: 291,
P99: 188
},
authority: "authors:7001"
},{
route: "[DEFAULT]",
latency: {
P50: 500,
P95: 1,
P99: 2
},
authority: "localhost:6443"
}],
enableFilter: true
});
const component = mount(routerWrap(TopRoutesTable, extraProps));
const table = component.find("BaseTable");
const enableFilter = table.prop("enableFilter");
const filterIcon = table.find("FilterListIcon");
expect(enableFilter).toEqual(true);
expect(filterIcon).toBeDefined();
expect(table.html()).toContain("authors:7001");
expect(table.html()).toContain("localhost");
filterIcon.simulate("click");
setTimeout(() => {
const input = table.find("input");
input.simulate("change", {target: {value: "localhost"}});
expect(table.html()).not.toContain("authors:7001");
expect(table.html()).toContain("localhost");
}, 100);
});
});

View File

@ -312,8 +312,7 @@ export const directionColumn = d => (
</Tooltip>
);
export const srcDstColumn = (d, resourceType, ResourceLink) => {
export const extractDisplayName = d => {
let display = {};
let labels = {};
@ -324,6 +323,11 @@ export const srcDstColumn = (d, resourceType, ResourceLink) => {
display = d.destination;
labels = d.destinationLabels;
}
return [labels, display];
};
export const srcDstColumn = (d, resourceType, ResourceLink) => {
let [labels, display] = extractDisplayName(d);
let link = (
!_isEmpty(labels[resourceType]) ?