mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
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:
parent
51cd0ff148
commit
39b7c7d9f9
BIN
assets/images/icon-filter-grey-12x12@2x.png
Normal file
BIN
assets/images/icon-filter-grey-12x12@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 348 B |
1
changes/issue-2714-live-query-table
Normal file
1
changes/issue-2714-live-query-table
Normal file
@ -0,0 +1 @@
|
||||
* Add client-side pagination and multi-column filter to live query results table
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./DefaultColumnFilter";
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -4,4 +4,7 @@ export type IDataColumn = Column & {
|
||||
title?: string;
|
||||
disableHidden?: boolean;
|
||||
disableSortBy?: boolean;
|
||||
filterValue?: any;
|
||||
preFilteredRows?: any;
|
||||
setFilter?: any;
|
||||
};
|
||||
|
@ -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 “Online” or check out the “Errors”
|
||||
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 “Online” or check out the “Errors”
|
||||
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>
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user