Add pagination and multi-column sort to live query results table UI (#3647)

* Refactor live query results to react-table
This commit is contained in:
gillespi314 2022-01-13 11:06:32 -06:00 committed by GitHub
parent 51cd0ff148
commit 39b7c7d9f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 218 additions and 176 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

View File

@ -0,0 +1 @@
* Add client-side pagination and multi-column filter to live query results table

View File

@ -24,6 +24,8 @@ describe("DataTable - component", () => {
<DataTable
columns={columns}
data={data}
sortHeader={"name"}
sortDirection={"desc"}
isLoading={false}
onSort={noop}
showMarkAllPages={false}
@ -31,6 +33,7 @@ describe("DataTable - component", () => {
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/>
);
@ -73,6 +76,7 @@ describe("DataTable - component", () => {
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/>
);
@ -99,6 +103,7 @@ describe("DataTable - component", () => {
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/>
);
@ -133,6 +138,7 @@ describe("DataTable - component", () => {
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/>
);
@ -156,6 +162,7 @@ describe("DataTable - component", () => {
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/>
);

View File

@ -1,6 +1,8 @@
/* eslint-disable react/prop-types */
// disable this rule as it was throwing an error in Header and Cell component
// definitions for the selection row for some reason when we dont really need it.
import React, { useMemo, useEffect, useCallback, useContext } from "react";
import { TableContext } from "context/table";
import PropTypes from "prop-types";
import classnames from "classnames";
import {
useTable,
@ -9,6 +11,8 @@ import {
Row,
usePagination,
useFilters,
HeaderGroup,
Column,
} from "react-table";
import { isString, kebabCase, noop } from "lodash";
import { useDebouncedCallback } from "use-debounce/lib";
@ -28,7 +32,7 @@ import ActionButton, { IActionButtonProps } from "./ActionButton";
const baseClass = "data-table-container";
interface IDataTableProps {
columns: any;
columns: Column[];
data: any;
isLoading: boolean;
manualSortBy?: boolean;
@ -256,6 +260,15 @@ const DataTable = ({
[disableMultiRowSelect, onSelectSingleRow, toggleAllRowsSelected]
);
const renderColumnHeader = (column: HeaderGroup) => {
return (
<div className="column-header">
{column.render("Header")}
{column.Filter && column.render("Filter")}
</div>
);
};
const renderSelectedCount = (): JSX.Element => {
return (
<p>
@ -422,7 +435,7 @@ const DataTable = ({
className={column.id ? `${column.id}__header` : ""}
{...column.getHeaderProps(column.getSortByToggleProps())}
>
<div>{column.render("Header")}</div>
{renderColumnHeader(column)}
</th>
))}
</tr>
@ -486,15 +499,4 @@ const DataTable = ({
);
};
DataTable.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object), // TODO: create proper interface for this
data: PropTypes.arrayOf(PropTypes.object), // TODO: create proper interface for this
isLoading: PropTypes.bool,
sortHeader: PropTypes.string,
sortDirection: PropTypes.string,
onSort: PropTypes.func,
onPrimarySelectActionClick: PropTypes.func,
secondarySelectActions: PropTypes.arrayOf(PropTypes.object),
};
export default DataTable;

View File

@ -0,0 +1,23 @@
import React from "react";
import { FilterProps, TableInstance } from "react-table";
import SearchField from "components/forms/fields/SearchField";
const DefaultColumnFilter = ({
column,
}: FilterProps<TableInstance>): JSX.Element => {
const { setFilter } = column;
return (
<div className={"filter-cell"}>
<SearchField
placeholder=""
onChange={(searchString) => {
setFilter(searchString || undefined); // Set undefined to remove the filter entirely
}}
/>
</div>
);
};
export default DefaultColumnFilter;

View File

@ -0,0 +1,27 @@
.filter-cell {
input {
height: 28px;
width: 100%;
font-size: $x-small;
background-color: $ui-off-white;
border: 1px solid $ui-fleet-blue-15;
border-radius: 4px;
padding: 4px;
padding-left: 20px;
margin-top: $pad-xsmall;
}
.search-field__input-wrapper {
position: relative;
color: $core-fleet-blue;
width: 100%;
&::before {
display: inline-block;
position: absolute;
content: url(../assets/images/icon-filter-grey-12x12@2x.png);
transform: scale(0.5);
top: 4px;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./DefaultColumnFilter";

View File

@ -3,9 +3,14 @@ import React from "react";
interface IHeaderCellProps {
value: string;
isSortedDesc?: boolean;
disableSortBy?: boolean;
}
const HeaderCell = ({ value, isSortedDesc }: IHeaderCellProps): JSX.Element => {
const HeaderCell = ({
value,
isSortedDesc,
disableSortBy,
}: IHeaderCellProps): JSX.Element => {
let sortArrowClass = "";
if (isSortedDesc === undefined) {
sortArrowClass = "";
@ -18,10 +23,12 @@ const HeaderCell = ({ value, isSortedDesc }: IHeaderCellProps): JSX.Element => {
return (
<div className={`header-cell ${sortArrowClass}`}>
<span>{value}</span>
<div className="sort-arrows">
<span className="ascending-arrow" />
<span className="descending-arrow" />
</div>
{!disableSortBy && (
<div className="sort-arrows">
<span className="ascending-arrow" />
<span className="descending-arrow" />
</div>
)}
</div>
);
};

View File

@ -4,4 +4,7 @@ export type IDataColumn = Column & {
title?: string;
disableHidden?: boolean;
disableSortBy?: boolean;
filterValue?: any;
preFilteredRows?: any;
setFilter?: any;
};

View File

@ -3,22 +3,22 @@ import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
import moment from "moment";
import classnames from "classnames";
import FileSaver from "file-saver";
import { filter, get, keys, omit } from "lodash";
import { filter, get } from "lodash";
// @ts-ignore
import convertToCSV from "utilities/convert_to_csv"; // @ts-ignore
import filterArrayByHash from "utilities/filter_array_by_hash";
import { ICampaign, ICampaignQueryResult } from "interfaces/campaign";
import { ITarget } from "interfaces/target";
import Button from "components/buttons/Button"; // @ts-ignore
import FleetIcon from "components/icons/FleetIcon"; // @ts-ignore
import InputField from "components/forms/fields/InputField";
import QueryResultsRow from "components/queries/QueryResultsRow";
import Spinner from "components/Spinner";
import TableContainer from "components/TableContainer";
import TabsWrapper from "components/TabsWrapper";
import DownloadIcon from "../../../../../../assets/images/icon-download-12x12@2x.png";
import resultsTableHeaders from "./QueryResultsTableConfig";
interface IQueryResultsProps {
campaign: ICampaign;
isQueryFinished: boolean;
@ -61,10 +61,7 @@ const QueryResults = ({
}`;
const [pageTitle, setPageTitle] = useState<string>(PAGE_TITLES.RUNNING);
const [resultsFilter, setResultsFilter] = useState<{ [key: string]: string }>(
{}
);
const [activeColumn, setActiveColumn] = useState<string>("");
const [navTabIndex, setNavTabIndex] = useState(0);
useEffect(() => {
@ -75,17 +72,6 @@ const QueryResults = ({
}
}, [isQueryFinished]);
const onFilterAttribute = (attribute: string) => {
return (value: string) => {
setResultsFilter({
...resultsFilter,
[attribute]: value,
});
return false;
};
};
const onExportQueryResults = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
@ -133,60 +119,36 @@ const QueryResults = ({
goToQueryEditor();
};
const renderTableHeaderColumn = (column: string, index: number) => {
const filterable = column === "hostname" ? "host_hostname" : column;
const filterIconClassName = classnames(`${baseClass}__filter-icon`, {
[`${baseClass}__filter-icon--is-active`]: activeColumn === column,
});
const renderNoResults = () => {
return (
<th key={`query-results-table-header-${index}`}>
<p className="no-results-message">
Your live query returned no results.
<span>
<FleetIcon className={filterIconClassName} name="filter" />
{column}
Expecting to see results? Check to see if the hosts you targeted
reported &ldquo;Online&rdquo; or check out the &ldquo;Errors&rdquo;
table.
</span>
<InputField
name={column}
onChange={onFilterAttribute(filterable)}
onFocus={() => setActiveColumn(column)}
value={resultsFilter[filterable]}
/>
</th>
</p>
);
};
const renderTableHeaderRow = (rows: ICampaignQueryResult[]) => {
if (!rows) {
return false;
}
const queryAttrs = omit(rows[0], ["host_hostname"]);
const queryResultColumns = keys(queryAttrs);
const renderTable = (tableData: ICampaignQueryResult[]) => {
return (
<tr>
{renderTableHeaderColumn("hostname", -1)}
{queryResultColumns.map((column, i) => {
return renderTableHeaderColumn(column, i);
})}
</tr>
<TableContainer
columns={resultsTableHeaders(tableData || [])}
data={tableData || []}
emptyComponent={renderNoResults}
isLoading={false}
disableCount
isClientSidePagination
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle={"hosts"}
/>
);
};
const renderTableRows = (rows: ICampaignQueryResult[]) => {
const filteredRows = filterArrayByHash(rows, resultsFilter);
return filteredRows.map((row: ICampaignQueryResult) => {
return (
<QueryResultsRow
key={row.uuid || row.host_hostname}
queryResult={row}
/>
);
});
};
const renderTable = () => {
const renderResultsTable = () => {
const emptyResults = !queryResults || !queryResults.length;
const hasNoResultsYet = !isQueryFinished && emptyResults;
const finishedWithNoResults =
@ -197,20 +159,11 @@ const QueryResults = ({
}
if (finishedWithNoResults) {
return (
<p className="no-results-message">
Your live query returned no results.
<span>
Expecting to see results? Check to see if the hosts you targeted
reported &ldquo;Online&rdquo; or check out the &ldquo;Errors&rdquo;
table.
</span>
</p>
);
return renderNoResults();
}
return (
<div className={`${baseClass}__results-table-container`}>
<div>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
@ -220,12 +173,7 @@ const QueryResults = ({
Export results <img alt="" src={DownloadIcon} />
</>
</Button>
<div className={`${baseClass}__results-table-wrapper`}>
<table className={`${baseClass}__table`}>
<thead>{renderTableHeaderRow(queryResults)}</thead>
<tbody>{renderTableRows(queryResults)}</tbody>
</table>
</div>
{renderTable(queryResults)}
</div>
);
};
@ -243,10 +191,7 @@ const QueryResults = ({
</>
</Button>
<div className={`${baseClass}__error-table-wrapper`}>
<table className={`${baseClass}__table`}>
<thead>{renderTableHeaderRow(errors)}</thead>
<tbody>{renderTableRows(errors)}</tbody>
</table>
{renderTable(errors)}
</div>
</div>
);
@ -318,7 +263,7 @@ const QueryResults = ({
{NAV_TITLES.ERRORS}
</Tab>
</TabList>
<TabPanel>{renderTable()}</TabPanel>
<TabPanel>{renderResultsTable()}</TabPanel>
<TabPanel>{renderErrorsTable()}</TabPanel>
</Tabs>
</TabsWrapper>

View File

@ -0,0 +1,70 @@
/* eslint-disable react/prop-types */
// disable this rule as it was throwing an error in Header and Cell component
// definitions for the selection row for some reason when we dont really need it.
import React from "react";
import {
CellProps,
Column,
ColumnInstance,
ColumnInterface,
HeaderProps,
TableInstance,
} from "react-table";
import { ICampaignQueryResult } from "interfaces/campaign";
import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
type IHeaderProps = HeaderProps<TableInstance> & {
column: ColumnInstance & IDataColumn;
};
type ICellProps = CellProps<TableInstance>;
interface IDataColumn extends ColumnInterface {
title?: string;
accessor: string;
}
const _unshiftHostname = (headers: IDataColumn[]) => {
const newHeaders = [...headers];
const i = headers.findIndex((h) => h.id === "host_hostname");
if (i >= 0) {
// remove hostname header from headers
const [hostnameHeader] = newHeaders.splice(i, 1);
// reformat title and insert at start of headers array
newHeaders.unshift({ ...hostnameHeader, title: "hostname" });
}
return newHeaders;
};
const resultsTableHeaders = (results: ICampaignQueryResult[]): Column[] => {
// Table headers are derived from the shape of the first result.
// Note: It is possible that results may vary from the shape of the first result.
// For example, different versions of osquery may have new columns in a table
// However, this is believed to be a very unlikely scenario and there have been
// no reported issues.
const keys = results[0] ? Object.keys(results[0]) : [];
const headers = keys.map((key) => {
return {
id: key,
title: key,
Header: (headerProps: IHeaderProps) => (
<HeaderCell
value={headerProps.column.title || headerProps.column.id}
isSortedDesc={headerProps.column.isSortedDesc}
disableSortBy
/>
),
accessor: key,
Cell: (cellProps: ICellProps) => cellProps?.cell?.value || null,
Filter: DefaultColumnFilter,
// filterType: "text",
disableSortBy: true,
};
});
return _unshiftHostname(headers);
};
export default resultsTableHeaders;

View File

@ -1,6 +1,6 @@
.query-results {
padding: $pad-xxxlarge $pad-xxlarge;
&__text-wrapper {
margin-top: 20px;
display: flex;
@ -64,6 +64,8 @@
}
&__export-btn {
padding-top: 28px;
img {
width: 13px;
height: 13px;
@ -73,81 +75,35 @@
}
}
&__results-table-container,
&__error-table-container {
margin-top: $pad-large;
.table-container {
padding-top: $pad-large;
&__header {
display: none;
}
}
&__results-table-wrapper,
&__error-table-wrapper {
width: calc(100vw - 80px); // minus padding left and right
border: solid 1px $ui-fleet-blue-15;
border-radius: 3px;
margin-top: $pad-large;
box-sizing: border-box;
overflow: auto;
.data-table__wrapper {
overflow-x: scroll;
}
&__table {
background-color: $core-white;
width: 100%;
box-sizing: border-box;
font-size: $x-small;
border-collapse: collapse;
color: $core-fleet-black;
&__filter-icon {
color: $ui-fleet-black-25;
&--is-active {
color: $core-vibrant-blue;
}
.data-table-container .data-table thead th {
min-width: 140px;
padding-left: 0px;
padding-right: $pad-large;
border-right: 0;
&:first-of-type {
padding-left: $pad-large;
}
thead {
background-color: $ui-off-white;
color: $core-fleet-black;
text-align: left;
border-bottom: 1px solid $ui-fleet-blue-15;
th {
padding: 12px $pad-large;
min-width: 125px;
.form-field {
margin-bottom: 0;
}
span {
white-space: nowrap;
.fleeticon {
margin-right: 5px;
}
}
}
.input-field {
width: 100%;
height: 28px;
}
}
tbody {
background-color: $core-white;
td {
padding: 12px $pad-large;
white-space: nowrap;
}
tr {
border-bottom: 1px solid $ui-fleet-blue-15;
&:last-child {
border-bottom: 0;
}
}
}
.data-table-container .data-table tbody td {
padding-left: 0px;
padding-right: $pad-large;
&:first-of-type {
padding-left: $pad-large;
}
}
}