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 <DataTable
columns={columns} columns={columns}
data={data} data={data}
sortHeader={"name"}
sortDirection={"desc"}
isLoading={false} isLoading={false}
onSort={noop} onSort={noop}
showMarkAllPages={false} showMarkAllPages={false}
@ -31,6 +33,7 @@ describe("DataTable - component", () => {
resultsTitle="users" resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE} defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false} disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/> />
); );
@ -73,6 +76,7 @@ describe("DataTable - component", () => {
resultsTitle="users" resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE} defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false} disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/> />
); );
@ -99,6 +103,7 @@ describe("DataTable - component", () => {
resultsTitle="users" resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE} defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false} disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/> />
); );
@ -133,6 +138,7 @@ describe("DataTable - component", () => {
resultsTitle="users" resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE} defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false} disableMultiRowSelect={false}
onPrimarySelectActionClick={noop}
/> />
); );
@ -156,6 +162,7 @@ describe("DataTable - component", () => {
resultsTitle="users" resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE} defaultPageSize={DEFAULT_PAGE_SIZE}
disableMultiRowSelect={false} 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 React, { useMemo, useEffect, useCallback, useContext } from "react";
import { TableContext } from "context/table"; import { TableContext } from "context/table";
import PropTypes from "prop-types";
import classnames from "classnames"; import classnames from "classnames";
import { import {
useTable, useTable,
@ -9,6 +11,8 @@ import {
Row, Row,
usePagination, usePagination,
useFilters, useFilters,
HeaderGroup,
Column,
} from "react-table"; } from "react-table";
import { isString, kebabCase, noop } from "lodash"; import { isString, kebabCase, noop } from "lodash";
import { useDebouncedCallback } from "use-debounce/lib"; import { useDebouncedCallback } from "use-debounce/lib";
@ -28,7 +32,7 @@ import ActionButton, { IActionButtonProps } from "./ActionButton";
const baseClass = "data-table-container"; const baseClass = "data-table-container";
interface IDataTableProps { interface IDataTableProps {
columns: any; columns: Column[];
data: any; data: any;
isLoading: boolean; isLoading: boolean;
manualSortBy?: boolean; manualSortBy?: boolean;
@ -256,6 +260,15 @@ const DataTable = ({
[disableMultiRowSelect, onSelectSingleRow, toggleAllRowsSelected] [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 => { const renderSelectedCount = (): JSX.Element => {
return ( return (
<p> <p>
@ -422,7 +435,7 @@ const DataTable = ({
className={column.id ? `${column.id}__header` : ""} className={column.id ? `${column.id}__header` : ""}
{...column.getHeaderProps(column.getSortByToggleProps())} {...column.getHeaderProps(column.getSortByToggleProps())}
> >
<div>{column.render("Header")}</div> {renderColumnHeader(column)}
</th> </th>
))} ))}
</tr> </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; 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 { interface IHeaderCellProps {
value: string; value: string;
isSortedDesc?: boolean; isSortedDesc?: boolean;
disableSortBy?: boolean;
} }
const HeaderCell = ({ value, isSortedDesc }: IHeaderCellProps): JSX.Element => { const HeaderCell = ({
value,
isSortedDesc,
disableSortBy,
}: IHeaderCellProps): JSX.Element => {
let sortArrowClass = ""; let sortArrowClass = "";
if (isSortedDesc === undefined) { if (isSortedDesc === undefined) {
sortArrowClass = ""; sortArrowClass = "";
@ -18,10 +23,12 @@ const HeaderCell = ({ value, isSortedDesc }: IHeaderCellProps): JSX.Element => {
return ( return (
<div className={`header-cell ${sortArrowClass}`}> <div className={`header-cell ${sortArrowClass}`}>
<span>{value}</span> <span>{value}</span>
<div className="sort-arrows"> {!disableSortBy && (
<span className="ascending-arrow" /> <div className="sort-arrows">
<span className="descending-arrow" /> <span className="ascending-arrow" />
</div> <span className="descending-arrow" />
</div>
)}
</div> </div>
); );
}; };

View File

@ -4,4 +4,7 @@ export type IDataColumn = Column & {
title?: string; title?: string;
disableHidden?: boolean; disableHidden?: boolean;
disableSortBy?: 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 moment from "moment";
import classnames from "classnames"; import classnames from "classnames";
import FileSaver from "file-saver"; import FileSaver from "file-saver";
import { filter, get, keys, omit } from "lodash"; import { filter, get } from "lodash";
// @ts-ignore // @ts-ignore
import convertToCSV from "utilities/convert_to_csv"; // @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 { ICampaign, ICampaignQueryResult } from "interfaces/campaign";
import { ITarget } from "interfaces/target"; import { ITarget } from "interfaces/target";
import Button from "components/buttons/Button"; // @ts-ignore 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 Spinner from "components/Spinner";
import TableContainer from "components/TableContainer";
import TabsWrapper from "components/TabsWrapper"; import TabsWrapper from "components/TabsWrapper";
import DownloadIcon from "../../../../../../assets/images/icon-download-12x12@2x.png"; import DownloadIcon from "../../../../../../assets/images/icon-download-12x12@2x.png";
import resultsTableHeaders from "./QueryResultsTableConfig";
interface IQueryResultsProps { interface IQueryResultsProps {
campaign: ICampaign; campaign: ICampaign;
isQueryFinished: boolean; isQueryFinished: boolean;
@ -61,10 +61,7 @@ const QueryResults = ({
}`; }`;
const [pageTitle, setPageTitle] = useState<string>(PAGE_TITLES.RUNNING); 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); const [navTabIndex, setNavTabIndex] = useState(0);
useEffect(() => { useEffect(() => {
@ -75,17 +72,6 @@ const QueryResults = ({
} }
}, [isQueryFinished]); }, [isQueryFinished]);
const onFilterAttribute = (attribute: string) => {
return (value: string) => {
setResultsFilter({
...resultsFilter,
[attribute]: value,
});
return false;
};
};
const onExportQueryResults = (evt: React.MouseEvent<HTMLButtonElement>) => { const onExportQueryResults = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault(); evt.preventDefault();
@ -133,60 +119,36 @@ const QueryResults = ({
goToQueryEditor(); goToQueryEditor();
}; };
const renderTableHeaderColumn = (column: string, index: number) => { const renderNoResults = () => {
const filterable = column === "hostname" ? "host_hostname" : column;
const filterIconClassName = classnames(`${baseClass}__filter-icon`, {
[`${baseClass}__filter-icon--is-active`]: activeColumn === column,
});
return ( return (
<th key={`query-results-table-header-${index}`}> <p className="no-results-message">
Your live query returned no results.
<span> <span>
<FleetIcon className={filterIconClassName} name="filter" /> Expecting to see results? Check to see if the hosts you targeted
{column} reported &ldquo;Online&rdquo; or check out the &ldquo;Errors&rdquo;
table.
</span> </span>
<InputField </p>
name={column}
onChange={onFilterAttribute(filterable)}
onFocus={() => setActiveColumn(column)}
value={resultsFilter[filterable]}
/>
</th>
); );
}; };
const renderTableHeaderRow = (rows: ICampaignQueryResult[]) => { const renderTable = (tableData: ICampaignQueryResult[]) => {
if (!rows) {
return false;
}
const queryAttrs = omit(rows[0], ["host_hostname"]);
const queryResultColumns = keys(queryAttrs);
return ( return (
<tr> <TableContainer
{renderTableHeaderColumn("hostname", -1)} columns={resultsTableHeaders(tableData || [])}
{queryResultColumns.map((column, i) => { data={tableData || []}
return renderTableHeaderColumn(column, i); emptyComponent={renderNoResults}
})} isLoading={false}
</tr> disableCount
isClientSidePagination
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle={"hosts"}
/>
); );
}; };
const renderTableRows = (rows: ICampaignQueryResult[]) => { const renderResultsTable = () => {
const filteredRows = filterArrayByHash(rows, resultsFilter);
return filteredRows.map((row: ICampaignQueryResult) => {
return (
<QueryResultsRow
key={row.uuid || row.host_hostname}
queryResult={row}
/>
);
});
};
const renderTable = () => {
const emptyResults = !queryResults || !queryResults.length; const emptyResults = !queryResults || !queryResults.length;
const hasNoResultsYet = !isQueryFinished && emptyResults; const hasNoResultsYet = !isQueryFinished && emptyResults;
const finishedWithNoResults = const finishedWithNoResults =
@ -197,20 +159,11 @@ const QueryResults = ({
} }
if (finishedWithNoResults) { if (finishedWithNoResults) {
return ( return renderNoResults();
<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 ( return (
<div className={`${baseClass}__results-table-container`}> <div>
<Button <Button
className={`${baseClass}__export-btn`} className={`${baseClass}__export-btn`}
onClick={onExportQueryResults} onClick={onExportQueryResults}
@ -220,12 +173,7 @@ const QueryResults = ({
Export results <img alt="" src={DownloadIcon} /> Export results <img alt="" src={DownloadIcon} />
</> </>
</Button> </Button>
<div className={`${baseClass}__results-table-wrapper`}> {renderTable(queryResults)}
<table className={`${baseClass}__table`}>
<thead>{renderTableHeaderRow(queryResults)}</thead>
<tbody>{renderTableRows(queryResults)}</tbody>
</table>
</div>
</div> </div>
); );
}; };
@ -243,10 +191,7 @@ const QueryResults = ({
</> </>
</Button> </Button>
<div className={`${baseClass}__error-table-wrapper`}> <div className={`${baseClass}__error-table-wrapper`}>
<table className={`${baseClass}__table`}> {renderTable(errors)}
<thead>{renderTableHeaderRow(errors)}</thead>
<tbody>{renderTableRows(errors)}</tbody>
</table>
</div> </div>
</div> </div>
); );
@ -318,7 +263,7 @@ const QueryResults = ({
{NAV_TITLES.ERRORS} {NAV_TITLES.ERRORS}
</Tab> </Tab>
</TabList> </TabList>
<TabPanel>{renderTable()}</TabPanel> <TabPanel>{renderResultsTable()}</TabPanel>
<TabPanel>{renderErrorsTable()}</TabPanel> <TabPanel>{renderErrorsTable()}</TabPanel>
</Tabs> </Tabs>
</TabsWrapper> </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 { .query-results {
padding: $pad-xxxlarge $pad-xxlarge; padding: $pad-xxxlarge $pad-xxlarge;
&__text-wrapper { &__text-wrapper {
margin-top: 20px; margin-top: 20px;
display: flex; display: flex;
@ -64,6 +64,8 @@
} }
&__export-btn { &__export-btn {
padding-top: 28px;
img { img {
width: 13px; width: 13px;
height: 13px; height: 13px;
@ -73,81 +75,35 @@
} }
} }
&__results-table-container, .table-container {
&__error-table-container { padding-top: $pad-large;
margin-top: $pad-large;
&__header {
display: none;
}
} }
&__results-table-wrapper, .data-table__wrapper {
&__error-table-wrapper { overflow-x: scroll;
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;
} }
&__table { .data-table-container .data-table thead th {
background-color: $core-white; min-width: 140px;
width: 100%; padding-left: 0px;
box-sizing: border-box; padding-right: $pad-large;
font-size: $x-small; border-right: 0;
border-collapse: collapse;
color: $core-fleet-black; &:first-of-type {
padding-left: $pad-large;
&__filter-icon {
color: $ui-fleet-black-25;
&--is-active {
color: $core-vibrant-blue;
}
} }
}
thead {
background-color: $ui-off-white; .data-table-container .data-table tbody td {
color: $core-fleet-black; padding-left: 0px;
text-align: left; padding-right: $pad-large;
border-bottom: 1px solid $ui-fleet-blue-15;
&:first-of-type {
th { padding-left: $pad-large;
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;
}
}
} }
} }
} }