enhance data table for two datasets and client derived data (#559)

* rework data table, got to point of calling network request correctly

* got to point of rendering table columns and data

* finish up functional changes to data table

* fix tests

* fix styles for table container and data table

* clean up some styles

* update to styles for no host configured

* cleanup and docs

* add missing method for host table text formatting

* disabling unused test. will add back in next PR

* clean up code
This commit is contained in:
Gabe Hernandez 2021-04-04 13:45:24 +01:00 committed by GitHub
parent be77b0de59
commit e33bbb8811
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 743 additions and 642 deletions

View File

@ -14,42 +14,42 @@ describe('Manage Users', () => {
cy.visit('/settings/users'); cy.visit('/settings/users');
cy.url().should('match', /\/settings\/users$/i); cy.url().should('match', /\/settings\/users$/i);
cy.wait('@getUsers'); // cy.wait('@getUsers');
//
cy.findByText('test@fleetdm.com') // cy.findByText('test@fleetdm.com')
.should('exist'); // .should('exist');
cy.findByText('test+1@fleetdm.com') // cy.findByText('test+1@fleetdm.com')
.should('exist'); // .should('exist');
cy.findByText('test+2@fleetdm.com') // cy.findByText('test+2@fleetdm.com')
.should('exist'); // .should('exist');
//
cy.findByPlaceholderText('Search') // cy.findByPlaceholderText('Search')
.type('test@fleetdm.com'); // .type('test@fleetdm.com');
//
cy.wait('@getUsers'); // cy.wait('@getUsers');
//
cy.findByText('test@fleetdm.com') // cy.findByText('test@fleetdm.com')
.should('exist'); // .should('exist');
cy.findByText('test+1@fleetdm.com') // cy.findByText('test+1@fleetdm.com')
.should('not.exist'); // .should('not.exist');
cy.findByText('test+2@fleetdm.com') // cy.findByText('test+2@fleetdm.com')
.should('not.exist'); // .should('not.exist');
}); });
it('Creating a user', () => { // it('Creating a user', () => {
cy.visit('/settings/users'); // cy.visit('/settings/users');
cy.url().should('match', /\/settings\/users$/i); // cy.url().should('match', /\/settings\/users$/i);
//
cy.contains('button:enabled', /create user/i) // cy.contains('button:enabled', /create user/i)
.click(); // .click();
//
cy.findByPlaceholderText('Full Name') // cy.findByPlaceholderText('Full Name')
.type('New User'); // .type('New User');
//
cy.findByPlaceholderText('Email') // cy.findByPlaceholderText('Email')
.type('new-user@fleetdm.com'); // .type('new-user@fleetdm.com');
//
cy.findByRole('checkbox', { name: 'Test Team' }) // cy.findByRole('checkbox', { name: 'Test Team' })
.click({ force: true }); // we use `force` as the checkbox button is not fully accessible yet. // .click({ force: true }); // we use `force` as the checkbox button is not fully accessible yet.
}); // });
}); });

View File

@ -1,210 +0,0 @@
import React, { useMemo, useEffect, useRef, useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useTable, useGlobalFilter, useSortBy, useAsyncDebounce } from 'react-table';
import { useSelector, useDispatch } from 'react-redux';
import Spinner from 'components/loaders/Spinner';
import Pagination from 'components/Pagination';
import scrollToTop from 'utilities/scroll_to_top';
const DEFAULT_PAGE_INDEX = 0;
const DEBOUNCE_QUERY_DELAY = 300;
const DEFAULT_RESULTS_NAME = 'results';
const baseClass = 'data-table-container';
const generateResultsCountText = (name = DEFAULT_RESULTS_NAME, pageIndex, itemsPerPage, resultsCount) => {
if (itemsPerPage === resultsCount) return `${itemsPerPage}+ ${name}`;
if (pageIndex !== 0 && (resultsCount <= itemsPerPage)) return `${itemsPerPage}+ ${name}`;
return `${resultsCount} ${name}`;
};
// 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 = (props) => {
const {
// selectedFilter is passed from parent, as it ultimately comes from the router and this
// component cannot access the router state.
selectedFilter,
searchQuery,
hiddenColumns,
tableColumns,
pageSize,
defaultSortHeader,
resultsName,
fetchDataAction,
entity,
emptyComponent,
} = props;
const [pageIndex, setPageIndex] = useState(DEFAULT_PAGE_INDEX);
const dispatch = useDispatch();
const loadingEntity = useSelector(state => state.entities[entity].loading);
const entityData = useSelector(state => state.entities[entity].data);
const apiOrder = useSelector(state => state.entities[entity].originalOrder);
// This variable is used to keep the react-table state persistent across server calls for new data.
// You can read more about this here technique here:
// https://react-table.tanstack.com/docs/faq#how-do-i-stop-my-table-state-from-automatically-resetting-when-my-data-changes
const skipPageResetRef = useRef();
const pageIndexChangeRef = useRef();
const columns = useMemo(() => {
// console.log('Column calc');
return tableColumns;
}, [tableColumns]);
// The table data needs to be ordered by the order we received from the API.
const data = useMemo(() => {
// console.log('Data calc');
return apiOrder.map((id) => {
return entityData[id];
});
}, [entityData, apiOrder]);
const {
headerGroups,
rows,
prepareRow,
setGlobalFilter,
setHiddenColumns,
state: tableState,
} = useTable(
{
columns,
data,
initialState: {
sortBy: [{ id: defaultSortHeader, desc: true }],
hiddenColumns,
},
autoResetHiddenColumns: false,
disableMultiSort: true,
manualGlobalFilter: true,
manualSortBy: true,
autoResetSortBy: !skipPageResetRef.current,
autoResetGlobalFilter: !skipPageResetRef.current,
},
useGlobalFilter,
useSortBy,
);
const { globalFilter, sortBy } = tableState;
const debouncedGlobalFilter = useAsyncDebounce((value) => {
skipPageResetRef.current = true;
setGlobalFilter(value || undefined);
}, DEBOUNCE_QUERY_DELAY);
const onPaginationChange = useCallback((newPage) => {
if (newPage > pageIndex) {
setPageIndex(pageIndex + 1);
} else {
setPageIndex(pageIndex - 1);
}
pageIndexChangeRef.current = true;
scrollToTop();
}, [pageIndex, setPageIndex]);
// Since searchQuery is passed in from the parent, we want to debounce the globalFilter change
// when we see it change.
useEffect(() => {
debouncedGlobalFilter(searchQuery);
}, [debouncedGlobalFilter, searchQuery]);
// Track hidden columns changing and update the table accordingly.
useEffect(() => {
setHiddenColumns(hiddenColumns);
}, [setHiddenColumns, hiddenColumns]);
// Any changes to these relevant table search params will fire off an action to get the new
// entity data.
useEffect(() => {
if (pageIndexChangeRef.current) { // the pageIndex has changed
dispatch(fetchDataAction(pageIndex, pageSize, selectedFilter, globalFilter, sortBy));
} else { // something besides pageIndex changed. we want to get results starting at the first page
// NOTE: currently this causes the request to fire twice if the user is not on the first page
// of results. Need to come back to this and figure out how to get it to
// only fire once.
setPageIndex(0);
dispatch(fetchDataAction(0, pageSize, selectedFilter, globalFilter, sortBy));
}
skipPageResetRef.current = false;
pageIndexChangeRef.current = false;
}, [fetchDataAction, dispatch, pageIndex, pageSize, selectedFilter, globalFilter, sortBy]);
// No entities for this result.
if (!loadingEntity && Object.values(entityData).length === 0) {
const NoResultsComponent = emptyComponent;
return <NoResultsComponent />;
}
return (
<div className={baseClass}>
<div className={`${baseClass}__topper`}>
<p className={`${baseClass}__results-count`}>{generateResultsCountText(resultsName, pageIndex, pageSize, rows.length)}</p>
</div>
<div className={'data-table data-table__wrapper'}>
{loadingEntity &&
<div className={'loading-overlay'}>
<Spinner />
</div>
}
<table className={'data-table__table'}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
<th {...column.getHeaderProps(column.getSortByToggleProps())}>
{column.render('Header')}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map((row) => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map((cell) => {
return (
<td {...cell.getCellProps()}>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
})
}
</tbody>
</table>
</div>
<Pagination
resultsOnCurrentPage={rows.length}
currentPage={pageIndex}
resultsPerPage={pageSize}
onPaginationChange={onPaginationChange}
/>
</div>
);
};
DataTable.propTypes = {
selectedFilter: PropTypes.string,
searchQuery: PropTypes.string,
tableColumns: PropTypes.arrayOf(PropTypes.object), // TODO: create proper interface for this
hiddenColumns: PropTypes.arrayOf(PropTypes.string),
pageSize: PropTypes.number,
defaultSortHeader: PropTypes.string,
resultsName: PropTypes.string,
fetchDataAction: PropTypes.func,
entity: PropTypes.string,
emptyComponent: PropTypes.func,
};
export default DataTable;

View File

@ -0,0 +1,118 @@
import React, { useMemo, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTable, useSortBy } from 'react-table';
import Spinner from 'components/loaders/Spinner';
const baseClass = 'data-table-container';
// 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 = (props) => {
const {
columns: tableColumns,
data: tableData,
isLoading,
sortHeader,
sortDirection,
onSort,
} = props;
const columns = useMemo(() => {
return tableColumns;
}, [tableColumns]);
// The table data needs to be ordered by the order we received from the API.
const data = useMemo(() => {
return tableData;
}, [tableData]);
const {
headerGroups,
rows,
prepareRow,
state: tableState,
} = useTable(
{
columns,
data,
initialState: {
sortBy: useMemo(() => {
return [{ id: sortHeader, desc: sortDirection === 'desc' }];
}, [sortHeader, sortDirection]),
},
disableMultiSort: true,
manualSortBy: true,
},
useSortBy,
);
const { sortBy } = tableState;
useEffect(() => {
const column = sortBy[0];
if (column !== undefined) {
if (column.id !== sortHeader || column.desc !== (sortDirection === 'desc')) {
onSort(column.id, column.desc);
}
} else {
onSort(undefined);
}
}, [sortBy, sortHeader, sortDirection]);
return (
<div className={baseClass}>
<div className={'data-table data-table__wrapper'}>
{isLoading &&
<div className={'loading-overlay'}>
<Spinner />
</div>
}
{/* TODO: can this be memoized? seems performance heavy */}
<table className={'data-table__table'}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
<th {...column.getHeaderProps(column.getSortByToggleProps())}>
{column.render('Header')}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map((row) => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map((cell) => {
return (
<td {...cell.getCellProps()}>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
})
}
</tbody>
</table>
</div>
</div>
);
};
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,
fetchDataAction: PropTypes.func,
};
export default DataTable;

View File

@ -1,18 +1,6 @@
.data-table-container { .data-table-container {
position: relative; position: relative;
&__topper {
position: absolute;
top: -38px;
}
&__results-count {
font-size: $x-small;
font-weight: $bold;
color: $core-black;
margin: 0;
}
.data-table { .data-table {
&__wrapper { &__wrapper {
border: solid 1px $ui-borders; border: solid 1px $ui-borders;

View File

@ -0,0 +1,199 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import classnames from 'classnames';
import { useAsyncDebounce } from 'react-table';
import Button from 'components/buttons/Button';
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import InputField from 'components/forms/fields/InputField';
// @ts-ignore
import KolideIcon from 'components/icons/KolideIcon';
// @ts-ignore
import Pagination from 'components/Pagination';
// @ts-ignore
import scrollToTop from 'utilities/scroll_to_top';
// @ts-ignore
import DataTable from './DataTable/DataTable';
import TableContainerUtils from './TableContainerUtils';
interface ITableQueryData {
searchQuery: string;
sortHeader: string;
sortDirection: string;
pageSize: number;
pageIndex: number;
}
interface ITableContainerProps<T, U> {
columns: T[];
data: U[];
isLoading: boolean;
defaultSortHeader: string;
defaultSortDirection: string;
includesTableAction: boolean;
onTableActionClick: () => void;
onQueryChange: (queryData: ITableQueryData) => void;
inputPlaceHolder: string;
resultsTitle?: string;
additionalQueries?: string;
emptyComponent: React.ElementType;
className?: string;
}
const baseClass = 'table-container';
const DEFAULT_PAGE_SIZE = 100;
const DEFAULT_PAGE_INDEX = 0;
const DEBOUNCE_QUERY_DELAY = 300;
const TableContainer = <T, U>(props: ITableContainerProps<T, U>): JSX.Element => {
const {
columns,
data,
isLoading,
defaultSortHeader,
defaultSortDirection,
onTableActionClick,
inputPlaceHolder,
additionalQueries,
onQueryChange,
resultsTitle,
emptyComponent,
className,
} = props;
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 wrapperClasses = classnames(baseClass, className);
const EmptyComponent = emptyComponent;
const onSortChange = useCallback((id?:string, isDesc?: boolean) => {
if (id === undefined) {
setSortHeader('');
setSortDirection('');
} else {
setSortHeader(id);
const direction = isDesc ? 'desc' : 'asc';
setSortDirection(direction);
}
}, [setSortHeader, setSortDirection]);
const onSearchQueryChange = (value: string) => {
setSearchQuery(value);
};
const hasPageIndexChangedRef = useRef(false);
const onPaginationChange = (newPage: number) => {
setPageIndex(newPage);
hasPageIndexChangedRef.current = true;
scrollToTop();
};
// We use useRef to keep track of the previous searchQuery value. This allows us
// to later compare this the the current value and debounce a change handler.
const prevSearchQueryRef = useRef(searchQuery);
const prevSearchQuery = prevSearchQueryRef.current;
const debounceOnQueryChange = useAsyncDebounce((queryData: ITableQueryData) => {
onQueryChange(queryData);
}, DEBOUNCE_QUERY_DELAY);
// When any of our query params change, or if any additionalQueries change, we want to fire off
// the parent components handler function with this updated query data. There is logic in here to check
// different types of query updates, as we handle some of them differently than others.
useEffect(() => {
const queryData = {
searchQuery,
sortHeader,
sortDirection,
pageSize,
pageIndex,
};
// Something besides the pageIndex has changed; we want to set it back to 0.
if (!hasPageIndexChangedRef.current) {
const updateQueryData = {
...queryData,
pageIndex: 0,
};
// searchQuery has changed; we want to debounce calling the handler so the
// user can finish typing.
if (searchQuery !== prevSearchQuery) {
debounceOnQueryChange(updateQueryData);
} else {
onQueryChange(updateQueryData);
}
setPageIndex(0);
} else {
onQueryChange(queryData);
}
hasPageIndexChangedRef.current = false;
}, [searchQuery, sortHeader, sortDirection, pageSize, pageIndex, additionalQueries, onQueryChange]);
return (
<div className={wrapperClasses}>
{/* TODO: find a way to move these controls into the table component */}
<div className={`${baseClass}__header`}>
{ data && data.length ?
<p className={`${baseClass}__results-count`}>
{TableContainerUtils.generateResultsCountText(resultsTitle, pageIndex, pageSize, data.length)}
</p> :
null
}
<div className={`${baseClass}__table-controls`}>
<Button
onClick={onTableActionClick}
variant="unstyled"
className={`${baseClass}__table-action-button`}
>
Edit columns
</Button>
<div className={`${baseClass}__search-input`}>
<InputField
placeholder={inputPlaceHolder}
name="searchQuery"
onChange={onSearchQueryChange}
value={searchQuery}
inputWrapperClass={`${baseClass}__input-wrapper`}
/>
<KolideIcon name="search" />
</div>
</div>
</div>
<div className={`${baseClass}__data-table-container`}>
{/* No entities for this result. */}
{!isLoading && data.length === 0 ?
<EmptyComponent /> :
<>
<DataTable
isLoading={isLoading}
columns={columns}
data={data}
sortHeader={sortHeader}
sortDirection={sortDirection}
onSort={onSortChange}
resultsName={'hosts'}
/>
<Pagination
resultsOnCurrentPage={data.length}
currentPage={pageIndex}
resultsPerPage={pageSize}
onPaginationChange={onPaginationChange}
/>
</>
}
</div>
</div>
);
};
export default TableContainer;

View File

@ -0,0 +1,9 @@
const DEFAULT_RESULTS_NAME = 'results';
const generateResultsCountText = (name: string = DEFAULT_RESULTS_NAME, pageIndex: number, pageSize: number, resultsCount: number) => {
if (pageSize === resultsCount) return `${pageSize}+ ${name}`;
if (pageIndex !== 0 && (resultsCount <= pageSize)) return `${pageSize}+ ${name}`;
return `${resultsCount} ${name}`;
};
export default { generateResultsCountText };

View File

@ -0,0 +1,63 @@
.table-container {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__results-count {
font-size: $x-small;
font-weight: $bold;
color: $core-black;
margin: 0;
}
&__table-controls {
display: flex;
justify-content: flex-end;
align-items: center;
}
&__table-action-button {
display: flex;
align-items: center;
font-size: $x-small;
color: $core-blue;
img {
width: 20px;
margin-right: $pad-half;
}
}
&__edit-columns-button:hover {
cursor: pointer;
text-decoration: underline;
color: $core-blue-over;
}
&__search-input {
position: relative;
color: $core-dark-blue-grey;
width: 344px;
margin-left: $pad-medium;
.table-container__input-wrapper {
margin-bottom: 0;
}
.input-field {
padding-left: 42px;
width: 100%;
}
.kolidecon {
position: absolute;
top: 10px;
left: 12px;
font-size: 20px;
color: $core-medium-blue-grey;
}
}
}

View File

@ -0,0 +1 @@
export { default } from './TableContainer';

View File

@ -27,7 +27,7 @@ export default (client) => {
if (sortBy.length !== 0) { if (sortBy.length !== 0) {
const sortItem = sortBy[0]; const sortItem = sortBy[0];
orderKeyParam += `&order_key=${sortItem.id}`; orderKeyParam += `&order_key=${sortItem.id}`;
orderDirection = sortItem.desc ? '&order_direction=desc' : '&order_direction=asc'; orderDirection = `&order_direction=${sortItem.direction}`;
} }
let searchQuery = ''; let searchQuery = '';

View File

@ -44,7 +44,7 @@ describe('Kolide - API client (hosts)', () => {
const perPage = 100; const perPage = 100;
const selectedFilter = 'new'; const selectedFilter = 'new';
const query = 'testQuery'; const query = 'testQuery';
const sortBy = [{ id: 'hostname', desc: true }]; const sortBy = [{ id: 'hostname', direction: 'desc' }];
Kolide.setBearerToken(bearerToken); Kolide.setBearerToken(bearerToken);
return Kolide.hosts.loadAll(page, perPage, selectedFilter, query, sortBy) return Kolide.hosts.loadAll(page, perPage, selectedFilter, query, sortBy)

View File

@ -17,7 +17,6 @@ import WarningBanner from 'components/WarningBanner';
import { updateUser } from 'redux/nodes/auth/actions'; import { updateUser } from 'redux/nodes/auth/actions';
import userActions from 'redux/nodes/entities/users/actions'; import userActions from 'redux/nodes/entities/users/actions';
import userInterface from 'interfaces/user'; import userInterface from 'interfaces/user';
import DataTable from 'components/DataTable/DataTable';
import CreateUserForm from './components/CreateUserForm'; import CreateUserForm from './components/CreateUserForm';
import usersTableHeaders from './UsersTableConfig'; import usersTableHeaders from './UsersTableConfig';
@ -270,17 +269,6 @@ export class UserManagementPage extends Component {
<KolideIcon name="search" /> <KolideIcon name="search" />
</div> </div>
</div> </div>
<DataTable
searchQuery={searchQuery}
tableColumns={usersTableHeaders}
hiddenColumns={[]}
pageSize={100}
defaultSortHeader={'name'}
resultsName={'rows'}
fetchDataAction={userActions.loadAll}
entity={'users'}
emptyComponent={() => { return null; }}
/>
{renderModal()} {renderModal()}
</div> </div>
); );

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import HeaderCell from 'components/DataTable/HeaderCell/HeaderCell'; import HeaderCell from 'components/TableContainer/DataTable/HeaderCell/HeaderCell';
// import StatusCell from 'components/DataTable/StatusCell/StatusCell'; // import StatusCell from 'components/DataTable/StatusCell/StatusCell';
import TextCell from 'components/DataTable/TextCell/TextCell'; import TextCell from 'components/TableContainer/DataTable/TextCell/TextCell';
import { IUser } from 'interfaces/user'; import { IUser } from 'interfaces/user';
interface IHeaderProps { interface IHeaderProps {

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { IHost } from 'interfaces/host'; import { IHost } from 'interfaces/host';
import HeaderCell from 'components/DataTable/HeaderCell/HeaderCell'; import HeaderCell from 'components/TableContainer/DataTable/HeaderCell/HeaderCell';
import LinkCell from 'components/DataTable/LinkCell/LinkCell'; import LinkCell from 'components/TableContainer/DataTable/LinkCell/LinkCell';
import StatusCell from 'components/DataTable/StatusCell/StatusCell'; import StatusCell from 'components/TableContainer/DataTable/StatusCell/StatusCell';
import TextCell from 'components/DataTable/TextCell/TextCell'; import TextCell from 'components/TableContainer/DataTable/TextCell/TextCell';
import { humanHostMemory, humanHostUptime, humanHostLastSeen, humanHostDetailUpdated } from 'kolide/helpers'; import { humanHostMemory, humanHostUptime, humanHostLastSeen, humanHostDetailUpdated } from 'kolide/helpers';
interface IHeaderProps { interface IHeaderProps {
@ -32,7 +32,7 @@ interface IHostDataColumn {
disableSortBy?: boolean; disableSortBy?: boolean;
} }
const hostDataHeaders: IHostDataColumn[] = [ const hostTableHeaders: IHostDataColumn[] = [
{ {
title: 'Hostname', title: 'Hostname',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />, Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
@ -133,4 +133,10 @@ const defaultHiddenColumns = [
'hardware_serial', 'hardware_serial',
]; ];
export { hostDataHeaders, defaultHiddenColumns }; const generateVisibleHostColumns = (hiddenColumns: string[]) => {
return hostTableHeaders.filter((column) => {
return !hiddenColumns.includes(column.accessor);
});
};
export { hostTableHeaders, defaultHiddenColumns, generateVisibleHostColumns };

View File

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import AceEditor from 'react-ace'; import AceEditor from 'react-ace';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { push } from 'react-router-redux'; import { push } from 'react-router-redux';
import { sortBy } from 'lodash';
import AddHostModal from 'components/hosts/AddHostModal'; import AddHostModal from 'components/hosts/AddHostModal';
import Button from 'components/buttons/Button'; import Button from 'components/buttons/Button';
@ -12,17 +11,23 @@ import HostSidePanel from 'components/side_panels/HostSidePanel';
import LabelForm from 'components/forms/LabelForm'; import LabelForm from 'components/forms/LabelForm';
import Modal from 'components/modals/Modal'; import Modal from 'components/modals/Modal';
import QuerySidePanel from 'components/side_panels/QuerySidePanel'; import QuerySidePanel from 'components/side_panels/QuerySidePanel';
import TableContainer from 'components/TableContainer';
import labelInterface from 'interfaces/label'; import labelInterface from 'interfaces/label';
import hostInterface from 'interfaces/host';
import osqueryTableInterface from 'interfaces/osquery_table'; import osqueryTableInterface from 'interfaces/osquery_table';
import statusLabelsInterface from 'interfaces/status_labels'; import statusLabelsInterface from 'interfaces/status_labels';
import enrollSecretInterface from 'interfaces/enroll_secret'; import enrollSecretInterface from 'interfaces/enroll_secret';
import { selectOsqueryTable } from 'redux/nodes/components/QueryPages/actions'; import { selectOsqueryTable } from 'redux/nodes/components/QueryPages/actions';
import labelActions from 'redux/nodes/entities/labels/actions'; import labelActions from 'redux/nodes/entities/labels/actions';
import entityGetter from 'redux/utilities/entityGetter'; import entityGetter from 'redux/utilities/entityGetter';
import { getLabels } from 'redux/nodes/components/ManageHostsPage/actions'; import { getLabels, getHosts } from 'redux/nodes/components/ManageHostsPage/actions';
import PATHS from 'router/paths'; import PATHS from 'router/paths';
import deepDifference from 'utilities/deep_difference'; import deepDifference from 'utilities/deep_difference';
import HostContainer from './components/HostContainer';
import { defaultHiddenColumns, hostTableHeaders, generateVisibleHostColumns } from './HostTableConfig';
import NoHosts from './components/NoHosts';
import EmptyHosts from './components/EmptyHosts';
import EditColumnsModal from './components/EditColumnsModal/EditColumnsModal';
const NEW_LABEL_HASH = '#new_label'; const NEW_LABEL_HASH = '#new_label';
const baseClass = 'manage-hosts'; const baseClass = 'manage-hosts';
@ -42,23 +47,29 @@ export class ManageHostsPage extends PureComponent {
selectedLabel: labelInterface, selectedLabel: labelInterface,
selectedOsqueryTable: osqueryTableInterface, selectedOsqueryTable: osqueryTableInterface,
statusLabels: statusLabelsInterface, statusLabels: statusLabelsInterface,
hosts: PropTypes.arrayOf(hostInterface),
loadingHosts: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
loadingLabels: false, loadingLabels: false,
hosts: [],
}; };
constructor (props) { constructor (props) {
super(props); super(props);
// For now we persist using localstorage. May do server side persistence later.
const storedHiddenColumns = JSON.parse(localStorage.getItem('hostHiddenColumns'));
this.state = { this.state = {
isEditLabel: false, isEditLabel: false,
labelQueryText: '', labelQueryText: '',
pagedHosts: [],
showAddHostModal: false, showAddHostModal: false,
selectedHost: null, selectedHost: null,
showDeleteLabelModal: false, showDeleteLabelModal: false,
showHostContainerSpinner: false, showEditColumnsModal: false,
hiddenColumns: storedHiddenColumns !== null ? storedHiddenColumns : defaultHiddenColumns,
}; };
} }
@ -83,6 +94,32 @@ export class ManageHostsPage extends PureComponent {
return false; return false;
} }
onSearchQueryChange = (newQuery) => {
this.setState({
searchQuery: newQuery,
});
}
onEditColumnsClick = () => {
this.setState({
showEditColumnsModal: true,
});
}
onCancelColumns = () => {
this.setState({
showEditColumnsModal: false,
});
}
onSaveColumns = (newHiddenColumns) => {
localStorage.setItem('hostHiddenColumns', JSON.stringify(newHiddenColumns));
this.setState({
hiddenColumns: newHiddenColumns,
showEditColumnsModal: false,
});
}
onCancelAddLabel = () => { onCancelAddLabel = () => {
const { dispatch } = this.props; const { dispatch } = this.props;
@ -100,6 +137,18 @@ export class ManageHostsPage extends PureComponent {
return false; return false;
} }
// NOTE: this is called once on the initial rendering. The initial render of
// the TableContainer child component.
onTableQueryChange = (queryData) => {
const { selectedFilter, dispatch } = this.props;
const { pageIndex, pageSize, searchQuery, sortHeader, sortDirection } = queryData;
let sortBy = [];
if (sortHeader !== '') {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
dispatch(getHosts(pageIndex, pageSize, selectedFilter, searchQuery, sortBy));
}
onEditLabel = (formData) => { onEditLabel = (formData) => {
const { dispatch, selectedLabel } = this.props; const { dispatch, selectedLabel } = this.props;
const updateAttrs = deepDifference(formData, selectedLabel); const updateAttrs = deepDifference(formData, selectedLabel);
@ -163,10 +212,6 @@ export class ManageHostsPage extends PureComponent {
} }
} }
sortHosts = (hosts) => {
return sortBy(hosts, (h) => { return h.hostname; });
}
toggleAddHostModal = () => { toggleAddHostModal = () => {
const { showAddHostModal } = this.state; const { showAddHostModal } = this.state;
this.setState({ showAddHostModal: !showAddHostModal }); this.setState({ showAddHostModal: !showAddHostModal });
@ -188,6 +233,27 @@ export class ManageHostsPage extends PureComponent {
return false; return false;
} }
renderEditColumnsModal = () => {
const { showEditColumnsModal, hiddenColumns } = this.state;
if (!showEditColumnsModal) return null;
return (
<Modal
title="Edit Columns"
onExit={() => this.setState({ showEditColumnsModal: false })}
className={`${baseClass}__invite-modal`}
>
<EditColumnsModal
columns={hostTableHeaders}
hiddenColumns={hiddenColumns}
onSaveColumns={this.onSaveColumns}
onCancelColumns={this.onCancelColumns}
/>
</Modal>
);
}
renderAddHostModal = () => { renderAddHostModal = () => {
const { toggleAddHostModal } = this; const { toggleAddHostModal } = this;
const { showAddHostModal } = this.state; const { showAddHostModal } = this.state;
@ -388,6 +454,36 @@ export class ManageHostsPage extends PureComponent {
return SidePanel; return SidePanel;
} }
renderTable = () => {
const { selectedFilter, selectedLabel, hosts, loadingHosts } = this.props;
const { hiddenColumns } = this.state;
const { toggleEditColumnsModal, onTableQueryChange, onEditColumnsClick } = this;
// The data has not been fetched yet.
if (selectedFilter === undefined || selectedLabel === undefined) return null;
// Hosts have not been set up for this instance yet.
if (selectedFilter === 'all-hosts' && selectedLabel.count === 0) {
return <NoHosts />;
}
return (
<TableContainer
columns={generateVisibleHostColumns(hiddenColumns)}
data={hosts}
isLoading={loadingHosts}
defaultSortHeader={'hostname'}
defaultSortDirection={'desc'}
additionalQueries={JSON.stringify([selectedFilter])}
inputPlaceHolder={'Search hostname, UUID, serial number, or IPv4'}
onTableActionClick={onEditColumnsClick}
onQueryChange={onTableQueryChange}
resultsTitle={'hosts'}
emptyComponent={EmptyHosts}
/>
);
}
render () { render () {
const { const {
renderForm, renderForm,
@ -396,17 +492,20 @@ export class ManageHostsPage extends PureComponent {
renderAddHostModal, renderAddHostModal,
renderDeleteLabelModal, renderDeleteLabelModal,
renderQuery, renderQuery,
renderTable,
renderEditColumnsModal,
onAddHostClick,
} = this; } = this;
const { const {
isAddLabel, isAddLabel,
loadingLabels, loadingLabels,
selectedLabel, selectedLabel,
selectedFilter, selectedFilter,
hosts,
loadingHosts,
} = this.props; } = this.props;
const { isEditLabel } = this.state; const { isEditLabel } = this.state;
const { onAddHostClick } = this;
return ( return (
<div className="has-sidebar"> <div className="has-sidebar">
{renderForm()} {renderForm()}
@ -420,17 +519,12 @@ export class ManageHostsPage extends PureComponent {
</Button> </Button>
</div> </div>
{selectedLabel && renderQuery()} {selectedLabel && renderQuery()}
<div className={`${baseClass}__list`}> {renderTable()}
<HostContainer
selectedFilter={selectedFilter}
selectedLabel={selectedLabel}
/>
</div>
</div> </div>
} }
{!loadingLabels && renderSidePanel()} {!loadingLabels && renderSidePanel()}
{renderAddHostModal()} {renderAddHostModal()}
{renderEditColumnsModal()}
{renderDeleteLabelModal()} {renderDeleteLabelModal()}
</div> </div>
); );
@ -455,6 +549,12 @@ const mapStateToProps = (state, { location, params }) => {
const enrollSecret = state.app.enrollSecret; const enrollSecret = state.app.enrollSecret;
const config = state.app.config; const config = state.app.config;
// NOTE: good opportunity for performance optimisation here later. This currently
// always generates a new array of hosts, when it could memoized version of the list.
const { entities: hosts } = entityGetter(state).get('hosts');
const { loading: loadingHosts } = state.entities.hosts;
return { return {
selectedFilter, selectedFilter,
isAddLabel, isAddLabel,
@ -466,6 +566,8 @@ const mapStateToProps = (state, { location, params }) => {
selectedOsqueryTable, selectedOsqueryTable,
statusLabels, statusLabels,
config, config,
hosts,
loadingHosts,
}; };
}; };

View File

@ -93,7 +93,7 @@ describe('ManageHostsPage - component', () => {
const ownProps = { location: { hash: '' }, params: {} }; const ownProps = { location: { hash: '' }, params: {} };
const component = connectedComponent(ConnectedManageHostsPage, { props: ownProps, mockStore }); const component = connectedComponent(ConnectedManageHostsPage, { props: ownProps, mockStore });
const page = mount(component); const page = mount(component);
expect(page.find('HostContainer').find('tbody tr').length).toEqual(2); expect(page.find('TableContainer').find('tbody tr').length).toEqual(2);
}); });
}); });

View File

@ -0,0 +1,21 @@
/**
* Component when there is no host results found in a search
*/
import React from 'react';
const baseClass = 'empty-hosts';
const EmptyHosts = (): JSX.Element => {
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}__inner`}>
<div className={`${baseClass}__empty-filter-results`}>
<h1>No hosts match the current criteria</h1>
<p>Expecting to see new hosts? Try again in a few seconds as the system catches up</p>
</div>
</div>
</div>
);
};
export default EmptyHosts;

View File

@ -0,0 +1,35 @@
.empty-hosts {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 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-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
}
}
&__empty-filter-results {
display: flex;
flex-direction: column;
width: 350px;
}
}

View File

@ -0,0 +1 @@
export { default } from './EmptyHosts';

View File

@ -1,164 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import labelInterface from 'interfaces/label';
import { getHostTableData } from 'redux/nodes/components/ManageHostsPage/actions';
import Button from 'components/buttons/Button';
import InputField from 'components/forms/fields/InputField';
import KolideIcon from 'components/icons/KolideIcon';
import DataTable from 'components/DataTable/DataTable';
import Modal from 'components/modals/Modal';
import RoboDogImage from '../../../../../../assets/images/robo-dog-176x144@2x.png';
import EditColumnsIcon from '../../../../../../assets/images/icon-edit-columns-20x20@2x.png';
import { hostDataHeaders, defaultHiddenColumns } from './HostTableConfig';
import EditColumnsModal from '../EditColumnsModal/EditColumnsModal';
const baseClass = 'host-container';
const EmptyHosts = () => {
return (
<div className={`${baseClass} ${baseClass}--no-hosts`}>
<div className={`${baseClass}--no-hosts__inner`}>
<div className={'no-filter-results'}>
<h1>No hosts match the current criteria</h1>
<p>Expecting to see new hosts? Try again in a few seconds as the system catches up</p>
</div>
</div>
</div>
);
};
class HostContainer extends Component {
static propTypes = {
selectedFilter: PropTypes.string,
selectedLabel: labelInterface,
};
static defaultProps = {
selectedLabel: { count: undefined },
}
constructor (props) {
super(props);
// For now we persist using localstorage. May do server side persistence later.
const storedHiddenColumns = JSON.parse(localStorage.getItem('hostHiddenColumns'));
this.state = {
searchQuery: '',
showEditColumnsModal: false,
hiddenColumns: storedHiddenColumns !== null ? storedHiddenColumns : defaultHiddenColumns,
};
}
onSearchQueryChange = (newQuery) => {
this.setState({
searchQuery: newQuery,
});
}
onEditColumnsClick = () => {
this.setState({
showEditColumnsModal: true,
});
}
onCancelColumns = () => {
this.setState({
showEditColumnsModal: false,
});
}
onSaveColumns = (newHiddenColumns) => {
localStorage.setItem('hostHiddenColumns', JSON.stringify(newHiddenColumns));
this.setState({
hiddenColumns: newHiddenColumns,
showEditColumnsModal: false,
});
}
renderEditColumnsModal = () => {
const { showEditColumnsModal, hiddenColumns } = this.state;
if (!showEditColumnsModal) return null;
return (
<Modal
title="Edit Columns"
onExit={() => this.setState({ showEditColumnsModal: false })}
className={`${baseClass}__invite-modal`}
>
<EditColumnsModal
columns={hostDataHeaders}
hiddenColumns={hiddenColumns}
onSaveColumns={this.onSaveColumns}
onCancelColumns={this.onCancelColumns}
/>
</Modal>
);
}
render () {
const { onSearchQueryChange, renderEditColumnsModal } = this;
const { selectedFilter, selectedLabel } = this.props;
const { searchQuery, hiddenColumns } = this.state;
if (selectedFilter === 'all-hosts' && selectedLabel.count === 0) {
return (
<div className={`${baseClass} ${baseClass}--no-hosts`}>
<div className={`${baseClass}--no-hosts__inner`}>
<img src={RoboDogImage} alt="No Hosts" />
<div>
<h1>It&#39;s kinda empty in here...</h1>
<h2>Get started adding hosts to Fleet.</h2>
<p>Add your laptops and servers to securely monitor them.</p>
<div className={`${baseClass}__no-hosts-contact`}>
<p>Still having trouble?</p>
<a href="https://github.com/fleetdm/fleet/issues">File a GitHub issue</a>
</div>
</div>
</div>
</div>
);
}
return (
<div className={`${baseClass}`}>
{/* TODO: find a way to move these controls into the table component */}
<div className={`${baseClass}__table-controls`}>
<Button onClick={this.onEditColumnsClick} variant="unstyled" className={`${baseClass}__edit-columns-button`}>
<img src={EditColumnsIcon} alt="edit columns icon" />
Edit columns
</Button>
<div className={`${baseClass}__search-input`}>
<InputField
placeholder="Search hostname, UUID, serial number, or IPv4"
name=""
onChange={onSearchQueryChange}
value={searchQuery}
inputWrapperClass={`${baseClass}__input-wrapper`}
/>
<KolideIcon name="search" />
</div>
</div>
<DataTable
selectedFilter={selectedFilter}
searchQuery={searchQuery}
tableColumns={hostDataHeaders}
hiddenColumns={hiddenColumns}
pageSize={100}
defaultSortHeader={hostDataHeaders[0].accessor}
resultsName={'hosts'}
fetchDataAction={getHostTableData}
entity={'hosts'}
emptyComponent={EmptyHosts}
/>
{renderEditColumnsModal()}
</div>
);
}
}
export default HostContainer;

View File

@ -1,24 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import HostContainer from './HostContainer';
const allHostsLabel = { id: 1, display_text: 'All Hosts', slug: 'all-hosts', type: 'all', count: 0 };
describe('HostsContainer - component', () => {
const props = {
selectedLabel: allHostsLabel,
};
it('displays getting started text if no hosts available', () => {
const page = shallow(<HostContainer {...props} selectedFilter={'all-hosts'} selectedLabel={allHostsLabel} />);
expect(page.find('h2').text()).toEqual('Get started adding hosts to Fleet.');
});
it('renders the DataTable if there are hosts', () => {
const page = shallow(<HostContainer {...props} />);
expect(page.find('DataTable').length).toEqual(1);
});
});

View File

@ -1,151 +0,0 @@
.host-container {
&--no-hosts {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 80px;
h1 {
font-size: $large;
font-weight: $regular;
line-height: normal;
letter-spacing: normal;
color: $core-black;
}
h2 {
font-size: $x-small;
font-weight: $bold;
margin: 0 0 24px;
line-height: 20px;
color: $core-black;
}
ul {
margin: 0;
padding: 0;
color: $core-black;
list-style: none;
li {
&::before {
content: '';
color: $core-blue;
margin-right: 16px;
}
}
}
&__inner {
display: flex;
flex-direction: row;
align-items: flex-start;
h1 {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-medium;
}
img {
width: 176px;
margin-right: 34px;
}
p {
color: $core-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
}
.no-filter-results {
display: flex;
flex-direction: column;
width: 350px;
}
}
.host-pagination__pager-wrap {
margin-top: $pad-base;
}
}
&__no-hosts-contact {
text-align: left;
margin-top: 24px;
p {
color: $core-black;
font-weight: $bold;
font-size: $x-small;
margin: 0;
}
a {
color: $core-blue;
font-weight: $regular;
font-size: $x-small;
text-decoration: none;
}
}
&__filter-labels {
margin-bottom: 16px;
.input-field {
padding-left: 42px;
width: 100%;
}
}
&__table-controls {
display: flex;
justify-content: flex-end;
align-items: center;
}
&__edit-columns-button {
display: flex;
align-items: center;
font-size: $x-small;
color: $core-blue;
img {
width: 20px;
margin-right: $pad-half;
}
}
&__edit-columns-button:hover {
cursor: pointer;
text-decoration: underline;
color: $core-blue-over;
}
&__search-input {
position: relative;
color: $core-dark-blue-grey;
width: 344px;
margin-left: $pad-medium;
.host-container__input-wrapper {
margin-bottom: 0;
}
.input-field {
padding-left: 42px;
width: 100%;
}
.kolidecon {
position: absolute;
top: 10px;
left: 12px;
font-size: 20px;
color: $core-medium-blue-grey;
}
}
}

View File

@ -1 +0,0 @@
export { default } from './HostContainer';

View File

@ -0,0 +1,29 @@
/**
* Component when there is no hosts set up in fleet
*/
import React from 'react';
import RoboDogImage from '../../../../../../assets/images/robo-dog-176x144@2x.png';
const baseClass = 'no-hosts';
const NoHosts = (): JSX.Element => {
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}--no-hosts__inner`}>
<img src={RoboDogImage} alt="No Hosts" />
<div>
<h1>It&#39;s kinda empty in here...</h1>
<h2>Get started adding hosts to Fleet.</h2>
<p>Add your laptops and servers to securely monitor them.</p>
<div className={`${baseClass}__no-hosts-contact`}>
<p>Still having trouble?</p>
<a href="https://github.com/fleetdm/fleet/issues">File a GitHub issue</a>
</div>
</div>
</div>
</div>
);
};
export default NoHosts;

View File

@ -0,0 +1,90 @@
.no-hosts {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 80px;
h1 {
font-size: $large;
font-weight: $regular;
line-height: normal;
letter-spacing: normal;
color: $core-black;
}
h2 {
font-size: $x-small;
font-weight: $bold;
margin: 0 0 24px;
line-height: 20px;
color: $core-black;
}
ul {
margin: 0;
padding: 0;
color: $core-black;
list-style: none;
li {
&::before {
content: '';
color: $core-blue;
margin-right: 16px;
}
}
}
&__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-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
}
.no-filter-results {
display: flex;
flex-direction: column;
width: 350px;
}
}
.host-pagination__pager-wrap {
margin-top: $pad-base;
}
&__no-hosts-contact {
text-align: left;
margin-top: 24px;
p {
color: $core-black;
font-weight: $bold;
font-size: $x-small;
margin: 0;
}
a {
color: $core-blue;
font-weight: $regular;
font-size: $x-small;
text-decoration: none;
}
}
}

View File

@ -0,0 +1 @@
export { default } from './NoHosts';

View File

@ -62,9 +62,8 @@ export const getLabels = () => (dispatch) => {
dispatch(silentGetStatusLabelCounts); dispatch(silentGetStatusLabelCounts);
}; };
export const getHostTableData = (page, perPage, selectedLabel, globalFilter, sortBy) => (dispatch) => { export const getHosts = (page, perPage, selectedLabel, globalFilter, sortBy) => (dispatch) => {
dispatch(hostActions.loadAll(page, perPage, selectedLabel, globalFilter, sortBy)); dispatch(hostActions.loadAll(page, perPage, selectedLabel, globalFilter, sortBy));
}; };
export default { getStatusLabelCounts, silentGetStatusLabelCounts, getHostTableData: getHosts };
export default { getStatusLabelCounts, silentGetStatusLabelCounts, getHostTableData };

View File

@ -2,6 +2,7 @@
"extends": "@tsconfig/recommended/tsconfig.json", "extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": "./frontend", "baseUrl": "./frontend",
"target": "ES2016",
"sourceMap": true, "sourceMap": true,
"jsx": "react", "jsx": "react",
}, },