mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
Host Details Page: Paginate user and software tables (#2464)
This commit is contained in:
parent
fb6d83ea05
commit
57150fde6c
1
changes/issue-2098-paginate-host-details
Normal file
1
changes/issue-2098-paginate-host-details
Normal file
@ -0,0 +1 @@
|
||||
* Improve host details page UX with pagination for over 20 users and software items
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
54
frontend/pages/hosts/HostDetailsPage/EmptyUsers/_styles.scss
Normal file
54
frontend/pages/hosts/HostDetailsPage/EmptyUsers/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
1
frontend/pages/hosts/HostDetailsPage/EmptyUsers/index.ts
Normal file
1
frontend/pages/hosts/HostDetailsPage/EmptyUsers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./EmptyUsers";
|
@ -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>
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from "./HostUsersListRow";
|
@ -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;
|
7
frontend/utilities/scroll_by.js
Normal file
7
frontend/utilities/scroll_by.js
Normal file
@ -0,0 +1,7 @@
|
||||
export const scrollBy = (lines, pixelsPerLine) => {
|
||||
const { window } = global;
|
||||
|
||||
window.scrollBy(0, -lines * pixelsPerLine);
|
||||
};
|
||||
|
||||
export default scrollBy;
|
Loading…
Reference in New Issue
Block a user