Host Details Page: Paginate user and software tables (#2464)

This commit is contained in:
RachelElysia 2021-10-18 15:14:24 -04:00 committed by GitHub
parent fb6d83ea05
commit 57150fde6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 327 additions and 115 deletions

View File

@ -0,0 +1 @@
* Improve host details page UX with pagination for over 20 users and software items

View File

@ -2,15 +2,23 @@ import React, { useMemo, useEffect, useCallback, useContext } from "react";
import { TableContext } from "context/table";
import PropTypes from "prop-types";
import classnames from "classnames";
import { useTable, useSortBy, useRowSelect, Row } from "react-table";
import {
useTable,
useSortBy,
useRowSelect,
Row,
usePagination,
} from "react-table";
import { isString, kebabCase, noop } from "lodash";
import { useDeepEffect } from "utilities/hooks";
import sort from "utilities/sort";
import Button from "components/buttons/Button";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import Spinner from "components/loaders/Spinner";
import { ButtonVariant } from "components/buttons/Button/Button";
import Button from "../../buttons/Button";
import ActionButton, { IActionButtonProps } from "./ActionButton";
const baseClass = "data-table-container";
@ -35,8 +43,11 @@ interface IDataTableProps {
onPrimarySelectActionClick: any; // figure out type
secondarySelectActions?: IActionButtonProps[];
onSelectSingleRow?: (value: Row) => void;
clientSidePagination?: boolean;
}
const CLIENT_SIDE_DEFAULT_PAGE_SIZE = 20;
// This data table uses react-table for implementation. The relevant documentation of the library
// can be found here https://react-table.tanstack.com/docs/api/useTable
const DataTable = ({
@ -59,6 +70,7 @@ const DataTable = ({
primarySelectActionButtonText,
secondarySelectActions,
onSelectSingleRow,
clientSidePagination,
}: IDataTableProps): JSX.Element => {
const { resetSelectedRows } = useContext(TableContext);
@ -79,6 +91,18 @@ const DataTable = ({
toggleAllRowsSelected,
isAllRowsSelected,
state: tableState,
page, // Instead of using 'rows', we'll use page,
// which has only the rows for the active page
// The rest of these things are super handy, too ;)
canPreviousPage,
canNextPage,
pageOptions,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
} = useTable(
{
columns,
@ -117,10 +141,11 @@ const DataTable = ({
),
},
useSortBy,
usePagination,
useRowSelect
);
const { sortBy, selectedRowIds } = tableState;
const { sortBy, selectedRowIds, pageIndex, pageSize } = tableState;
// This is used to listen for changes to sort. If there is a change
// Then the sortHandler change is fired.
@ -263,6 +288,22 @@ const DataTable = ({
showMarkAllPages &&
!isAllPagesSelected;
const pageOrRows = clientSidePagination ? page : rows;
useEffect(() => {
setPageSize(CLIENT_SIDE_DEFAULT_PAGE_SIZE);
}, []);
const previousButton = (
<>
<FleetIcon name="chevronleft" /> Previous
</>
);
const nextButton = (
<>
Next <FleetIcon name="chevronright" />
</>
);
return (
<div className={baseClass}>
{isLoading && (
@ -328,7 +369,7 @@ const DataTable = ({
))}
</thead>
<tbody>
{rows.map((row: any) => {
{pageOrRows.map((row: any) => {
prepareRow(row);
const rowStyles = classnames({
@ -355,6 +396,24 @@ const DataTable = ({
</tbody>
</table>
</div>
{clientSidePagination && (
<div className={`${baseClass}__pagination`}>
<Button
variant="unstyled"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
{previousButton}
</Button>
<Button
variant="unstyled"
onClick={() => nextPage()}
disabled={!canNextPage}
>
{nextButton}
</Button>
</div>
)}
</div>
);
};

View File

@ -214,4 +214,52 @@
background-color: rgba(255, 255, 255, 0.8);
z-index: 1;
}
&__pagination {
display: flex;
justify-content: flex-end;
margin-top: $pad-small;
margin-bottom: $pad-small;
margin-left: auto;
text-align: right;
button {
color: $core-vibrant-blue;
padding: 6px;
.fleeticon-chevronleft {
margin-right: $pad-small;
&:before {
font-size: 0.6rem;
font-weight: $bold;
position: relative;
top: -2px;
}
}
.fleeticon-chevronright {
margin-left: $pad-small;
&:before {
font-size: 0.6rem;
font-weight: $bold;
position: relative;
top: -2px;
}
}
}
button:hover {
background-color: $ui-vibrant-blue-10;
}
.button--disabled:hover {
background-color: transparent;
}
button:last-child {
margin-left: $pad-large;
}
}
}

View File

@ -8,7 +8,6 @@ import InputField from "components/forms/fields/InputField"; // @ts-ignore
import Pagination from "components/Pagination";
import Button from "components/buttons/Button";
import { ButtonVariant } from "components/buttons/Button/Button"; // @ts-ignore
import scrollToTop from "utilities/scroll_to_top";
import { useDeepEffect } from "utilities/hooks";
import ReactTooltip from "react-tooltip";
@ -62,6 +61,7 @@ interface ITableContainerProps {
onSelectSingleRow?: (value: Row) => void;
filteredCount?: number;
searchToolTipText?: string;
clientSidePagination?: boolean;
}
const baseClass = "table-container";
@ -107,14 +107,15 @@ const TableContainer = ({
onSelectSingleRow,
filteredCount,
searchToolTipText,
clientSidePagination,
}: ITableContainerProps): JSX.Element => {
const [searchQuery, setSearchQuery] = useState("");
const [sortHeader, setSortHeader] = useState(defaultSortHeader || "");
const [sortDirection, setSortDirection] = useState(
defaultSortDirection || ""
);
const [pageSize] = useState(DEFAULT_PAGE_SIZE);
const [pageIndex, setPageIndex] = useState(DEFAULT_PAGE_INDEX);
const [pageSize, setPageSize] = useState<number>(DEFAULT_PAGE_SIZE);
const [pageIndex, setPageIndex] = useState<number>(DEFAULT_PAGE_INDEX);
const wrapperClasses = classnames(baseClass, className);
@ -142,7 +143,6 @@ const TableContainer = ({
const onPaginationChange = (newPage: number) => {
setPageIndex(newPage);
hasPageIndexChangedRef.current = true;
scrollToTop();
};
// We use useRef to keep track of the previous searchQuery value. This allows us
@ -200,6 +200,7 @@ const TableContainer = ({
]);
const displayCount = filteredCount || data.length;
return (
<div className={wrapperClasses}>
{wideSearch && searchable && (
@ -311,7 +312,7 @@ const TableContainer = ({
isAllPagesSelected={isAllPagesSelected}
toggleAllPagesSelected={toggleAllPagesSelected}
resultsTitle={resultsTitle}
defaultPageSize={DEFAULT_PAGE_SIZE}
defaultPageSize={pageSize}
primarySelectActionButtonVariant={
primarySelectActionButtonVariant
}
@ -320,8 +321,9 @@ const TableContainer = ({
onPrimarySelectActionClick={onPrimarySelectActionClick}
secondarySelectActions={secondarySelectActions}
onSelectSingleRow={onSelectSingleRow}
clientSidePagination={clientSidePagination}
/>
{!disablePagination && (
{!disablePagination && !clientSidePagination && (
<Pagination
resultsOnCurrentPage={data.length}
currentPage={pageIndex}

View File

@ -0,0 +1,18 @@
import React from "react";
const baseClass = "empty-users";
const EmptyUsers = (): JSX.Element => {
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}__inner`}>
<div className={`${baseClass}__empty-filter-results`}>
<h1>No users matched your search criteria.</h1>
<p>Try a different search.</p>
</div>
</div>
</div>
);
};
export default EmptyUsers;

View File

@ -0,0 +1,54 @@
.empty-users {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 80px;
margin-bottom: 80px;
&__inner {
display: flex;
flex-direction: row;
h1 {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-medium;
}
img {
width: 176px;
margin-right: 34px;
}
p {
color: $core-fleet-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
}
p.learn-more {
margin-top: $pad-medium;
}
a {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
margin-left: $pad-xsmall;
}
img {
width: 12px;
height: 12px;
margin-left: 6px;
}
}
&__empty-filter-results {
display: flex;
flex-direction: column;
width: 350px;
}
}

View File

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

View File

@ -1,4 +1,4 @@
import React, { useCallback, useContext, useState } from "react";
import React, { useContext, useState, useCallback, useEffect } from "react";
import { useDispatch } from "react-redux";
import { Link } from "react-router";
import { Params } from "react-router/lib/Router";
@ -16,17 +16,14 @@ import { ISoftware } from "interfaces/software";
import { IHostPolicy } from "interfaces/host_policy";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import { IQuery } from "interfaces/query";
import { ITableSearchData } from "components/TableContainer/TableContainer"; // @ts-ignore
import { IQuery } from "interfaces/query"; // @ts-ignore
import { renderFlash } from "redux/nodes/notifications/actions"; // @ts-ignore
import simpleSearch from "utilities/simple_search";
import ReactTooltip from "react-tooltip";
import Spinner from "components/loaders/Spinner";
import Button from "components/buttons/Button";
import Modal from "components/modals/Modal"; // @ts-ignore
import SoftwareVulnerabilities from "pages/hosts/HostDetailsPage/SoftwareVulnCount"; // @ts-ignore
import HostUsersListRow from "pages/hosts/HostDetailsPage/HostUsersListRow";
import TableContainer from "components/TableContainer";
import InfoBanner from "components/InfoBanner";
@ -57,11 +54,13 @@ import {
generateSoftwareTableHeaders,
generateSoftwareDataSet,
} from "./SoftwareTable/SoftwareTableConfig";
import generateUsersTableHeaders from "./UsersTable/UsersTableConfig";
import {
generatePackTableHeaders,
generatePackDataSet,
} from "./PackTable/PackTableConfig";
import EmptySoftware from "./EmptySoftware";
import EmptyUsers from "./EmptyUsers";
import PolicyFailingCount from "./HostPoliciesTable/PolicyFailingCount";
import { isValidPolicyResponse } from "../ManageHostsPage/helpers";
@ -122,6 +121,9 @@ const HostDetailsPage = ({
setShowRefetchLoadingSpinner,
] = useState<boolean>(false);
const [softwareState, setSoftwareState] = useState<ISoftware[]>([]);
const [softwareSearchString, setSoftwareSearchString] = useState<string>("");
const [usersState, setUsersState] = useState<{ username: string }[]>([]);
const [usersSearchString, setUsersSearchString] = useState<string>("");
const { data: fleetQueries, error: fleetQueriesError } = useQuery<
IFleetQueriesResponse,
@ -163,6 +165,37 @@ const HostDetailsPage = ({
}
);
useEffect(() => {
if (host) {
setUsersState(host.users);
setSoftwareState(host.software);
}
}, [host]);
useEffect(() => {
if (host) {
setUsersState(() => {
return host.users.filter((user) => {
return user.username
.toLowerCase()
.includes(usersSearchString.toLowerCase());
});
});
}
}, [usersSearchString]);
useEffect(() => {
if (host) {
setSoftwareState(() => {
return host.software.filter((softwareItem) => {
return softwareItem.name
.toLowerCase()
.includes(softwareSearchString.toLowerCase());
});
});
}
}, [softwareSearchString]);
// returns a mixture of props from host
const normalizeEmptyValues = (hostData: any): { [key: string]: any } => {
return reduce(
@ -179,7 +212,7 @@ const HostDetailsPage = ({
);
};
const wrapKolideHelper = (
const wrapFleetHelper = (
helperFn: (value: any) => string,
value: string
): any => {
@ -284,24 +317,15 @@ const HostDetailsPage = ({
}
};
// Search functionality
const onTableSearchChange = ({
searchQuery,
sortHeader,
sortDirection,
}: ITableSearchData) => {
let sortBy = [];
if (sortHeader !== "") {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
const onSoftwareTableSearchChange = useCallback((queryData: any) => {
const { searchQuery } = queryData;
setSoftwareSearchString(searchQuery);
}, []);
if (!searchQuery && host) {
setSoftwareState(host.software);
return;
}
setSoftwareState(simpleSearch(searchQuery, host?.software));
};
const onUsersTableSearchChange = useCallback((queryData: any) => {
const { searchQuery } = queryData;
setUsersSearchString(searchQuery);
}, []);
const renderDeleteHostModal = () => (
<Modal
@ -535,7 +559,8 @@ const HostDetailsPage = ({
const renderUsers = () => {
const { users } = host || {};
const wrapperClassName = `${baseClass}__table`;
const tableHeaders = generateUsersTableHeaders();
if (users) {
return (
@ -546,25 +571,23 @@ const HostDetailsPage = ({
No users were detected on this host.
</p>
) : (
<div className={`${baseClass}__wrapper`}>
<table className={wrapperClassName}>
<thead>
<tr>
<th>Username</th>
</tr>
</thead>
<tbody>
{users.map((hostUser) => {
return (
<HostUsersListRow
key={`host-users-row-${hostUser.username}`}
hostUser={hostUser}
/>
);
})}
</tbody>
</table>
</div>
<TableContainer
columns={tableHeaders}
data={usersState}
isLoading={isLoadingHost}
defaultSortHeader={"username"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Search users by username"}
onQueryChange={onUsersTableSearchChange}
resultsTitle={"users"}
emptyComponent={EmptyUsers}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable
wideSearch
filteredCount={usersState.length}
clientSidePagination
/>
)}
</div>
);
@ -601,14 +624,15 @@ const HostDetailsPage = ({
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Filter software"}
onQueryChange={onTableSearchChange}
onQueryChange={onSoftwareTableSearchChange}
resultsTitle={"software items"}
emptyComponent={EmptySoftware}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable
wideSearch
disablePagination
filteredCount={softwareState.length}
clientSidePagination
/>
)}
</>
@ -805,7 +829,7 @@ const HostDetailsPage = ({
<div className="info-flex__item info-flex__item--title">
<span className="info-flex__header">RAM</span>
<span className="info-flex__data">
{wrapKolideHelper(humanHostMemory, titleData.memory)}
{wrapFleetHelper(humanHostMemory, titleData.memory)}
</span>
</div>
<div className="info-flex__item info-flex__item--title">
@ -825,19 +849,19 @@ const HostDetailsPage = ({
<div className="info-grid__block">
<span className="info-grid__header">Created at</span>
<span className="info-grid__data">
{wrapKolideHelper(humanHostEnrolled, aboutData.last_enrolled_at)}
{wrapFleetHelper(humanHostEnrolled, aboutData.last_enrolled_at)}
</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">Updated at</span>
<span className="info-grid__data">
{wrapKolideHelper(humanHostLastSeen, titleData.detail_updated_at)}
{wrapFleetHelper(humanHostLastSeen, titleData.detail_updated_at)}
</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">Uptime</span>
<span className="info-grid__data">
{wrapKolideHelper(humanHostUptime, aboutData.uptime)}
{wrapFleetHelper(humanHostUptime, aboutData.uptime)}
</span>
</div>
<div className="info-grid__block">
@ -863,19 +887,19 @@ const HostDetailsPage = ({
<div className="info-grid__block">
<span className="info-grid__header">Config TLS refresh</span>
<span className="info-grid__data">
{wrapKolideHelper(secondsToHms, osqueryData.config_tls_refresh)}
{wrapFleetHelper(secondsToHms, osqueryData.config_tls_refresh)}
</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">Logger TLS period</span>
<span className="info-grid__data">
{wrapKolideHelper(secondsToHms, osqueryData.logger_tls_period)}
{wrapFleetHelper(secondsToHms, osqueryData.logger_tls_period)}
</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">Distributed interval</span>
<span className="info-grid__data">
{wrapKolideHelper(secondsToHms, osqueryData.distributed_interval)}
{wrapFleetHelper(secondsToHms, osqueryData.distributed_interval)}
</span>
</div>
</div>

View File

@ -1,23 +0,0 @@
import React, { Component } from "react";
import hostUserInterface from "interfaces/host_users";
const baseClass = "hosts-user-list-row";
class HostUsersListRow extends Component {
static propTypes = {
hostUser: hostUserInterface.isRequired,
};
render() {
const { hostUser } = this.props;
const { username } = hostUser;
return (
<tr>
<td className={`${baseClass}__username`}>{username}</td>
</tr>
);
}
}
export default HostUsersListRow;

View File

@ -1,29 +0,0 @@
.host-users-list-row {
line-height: 38px;
border-bottom: 1px solid $ui-fleet-blue-15;
&__username {
max-width: 280px;
}
&__groupname {
max-width: 280px;
}
&:last-child {
border-bottom: 0;
}
td {
font-size: $x-small;
.form-field {
margin: 0;
}
}
&__name {
@include ellipsis(120px);
display: table-cell;
}
}

View File

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

View File

@ -0,0 +1,51 @@
import React from "react";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
interface ICellProps {
cell: {
value: any;
};
row: {
original: { user: string };
};
}
interface IDataColumn {
title: string;
Header: ((props: IHeaderProps) => JSX.Element) | string;
accessor: string;
Cell: (props: ICellProps) => JSX.Element;
disableHidden?: boolean;
disableSortBy?: boolean;
sortType?: string;
}
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateUsersTableHeaders = (): IDataColumn[] => {
return [
{
title: "Username",
Header: (cellProps) => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
sortType: "caseInsensitive",
accessor: "username",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
];
};
export default generateUsersTableHeaders;

View File

@ -0,0 +1,7 @@
export const scrollBy = (lines, pixelsPerLine) => {
const { window } = global;
window.scrollBy(0, -lines * pixelsPerLine);
};
export default scrollBy;