Add edit column modal feature (#351)

This adds the column toggling to the feature to the host data table.

Co-authored-by: Noah Talerman <noahtal@umich.edu>
This commit is contained in:
Gabe Hernandez
2021-02-25 09:13:13 -08:00
committed by GitHub
co-authored by Noah Talerman
parent a6c8987200
commit 4de0090bd3
9 changed files with 375 additions and 75 deletions
+1
View File
@@ -35,3 +35,4 @@ helm-temp
#editors
.vscode
.idea
Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import Checkbox from '../../../../../components/forms/fields/Checkbox';
import Button from '../../../../../components/buttons/Button';
const useCheckboxListStateManagement = (allColumns, hiddenColumns) => {
const [columnItems, setColumnItems] = useState(() => {
return allColumns.map((column) => {
return {
name: column.title,
accessor: column.accessor,
isChecked: !hiddenColumns.includes(column.accessor),
};
});
});
const updateColumnItems = (columnAccessor) => {
setColumnItems((prevState) => {
const selectedColumn = columnItems.find(column => column.accessor === columnAccessor);
const updatedColumn = {
...selectedColumn,
isChecked: !selectedColumn.isChecked,
};
// this is replacing the column object with the updatedColumn we just created.
const newState = prevState.map((currentColumn) => {
return currentColumn.accessor === columnAccessor ? updatedColumn : currentColumn;
});
return newState;
});
};
return [columnItems, updateColumnItems];
};
const getHiddenColumns = (columns) => {
return columns.filter(column => !column.isChecked)
.map(column => column.accessor);
};
const EditColumnsModal = (props) => {
const { columns, hiddenColumns, onSaveColumns, onCancelColumns } = props;
const [columnItems, updateColumnItems] = useCheckboxListStateManagement(columns, hiddenColumns);
return (
<div className={'edit-column-modal'}>
<p>Choose which columns you see</p>
<div className={'modal-items'}>
{columnItems.map((column) => {
return (
<div key={column.accessor}>
<Checkbox
name={column.name}
value={column.isChecked}
onChange={() => updateColumnItems(column.accessor)}
>
<span>{column.name}</span>
</Checkbox>
</div>
);
})}
</div>
<div className={'button-actions'}>
<Button
onClick={onCancelColumns}
variant={'inverse'}
>
Cancel
</Button>
<Button
className={'save-button'}
onClick={() => onSaveColumns(getHiddenColumns(columnItems))}
variant={'default'}
>
Save
</Button>
</div>
</div>
);
};
EditColumnsModal.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object), // TODO: create proper interface for this
hiddenColumns: PropTypes.arrayOf(PropTypes.string),
onSaveColumns: PropTypes.func,
onCancelColumns: PropTypes.func,
};
export default EditColumnsModal;
@@ -0,0 +1,11 @@
.edit-column-modal {
.button-actions {
display: flex;
justify-content: flex-end;
.save-button {
margin-left: $pad-half;
}
}
}
@@ -1,12 +1,18 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ReactTooltip from 'react-tooltip';
import labelInterface from 'interfaces/label';
import Button from 'components/buttons/Button';
import InputField from 'components/forms/fields/InputField';
import KolideIcon from 'components/icons/KolideIcon';
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 HostsDataTable from '../HostsDataTable/HostsDataTable';
import EditColumnsModal from '../EditColumnsModal/EditColumnsModal';
const baseClass = 'host-container';
@@ -20,13 +26,18 @@ class HostContainer extends Component {
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,
};
}
@@ -36,14 +47,55 @@ class HostContainer extends Component {
});
}
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 } = this;
const { onSearchQueryChange, renderEditColumnsModal } = this;
const { selectedFilter, selectedLabel } = this.props;
const { searchQuery } = this.state;
const { searchQuery, hiddenColumns } = this.state;
if (selectedFilter === 'all-hosts' && selectedLabel.count === 0) {
return (
<div className={`${baseClass} ${baseClass}--no-hosts`}>
<div className={`${baseClass} ${baseClass}--no-hosts`}>
<div className={`${baseClass}--no-hosts__inner`}>
<img src={RoboDogImage} alt="No Hosts" />
<div>
@@ -62,20 +114,33 @@ class HostContainer extends Component {
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}__search-input`}>
<InputField
placeholder="Search hosts by hostname"
name=""
onChange={onSearchQueryChange}
value={searchQuery}
inputWrapperClass={`${baseClass}__input-wrapper`}
/>
<KolideIcon name="search" />
{/* 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 data-tip data-for="search" 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>
<ReactTooltip place="bottom" type="dark" effect="solid" id="search" backgroundColor="#3e4771">
<span className={`${baseClass}__tooltip-text`}>Search by hostname, UUID, serial number, or IPv4</span>
</ReactTooltip>
</div>
<HostsDataTable
selectedFilter={selectedFilter}
searchQuery={searchQuery}
tableColumns={hostDataHeaders}
hiddenColumns={hiddenColumns}
/>
{renderEditColumnsModal()}
</div>
);
}
@@ -0,0 +1,104 @@
import React from 'react';
import HeaderCell from '../HeaderCell/HeaderCell';
import LinkCell from '../LinkCell/LinkCell';
import StatusCell from '../StatusCell/StatusCell';
import TextCell from '../TextCell/TextCell';
import { humanHostMemory, humanHostUptime, humanHostLastSeen } from '../../../../../kolide/helpers';
const hostDataHeaders = [
{
title: 'Hostname',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'hostname',
Cell: cellProps => <LinkCell value={cellProps.cell.value} host={cellProps.row.original} />,
canHide: false,
},
{
title: 'Status',
Header: 'Status',
disableSortBy: true,
accessor: 'status',
Cell: cellProps => <StatusCell value={cellProps.cell.value} />,
},
{
title: 'OS',
Header: cellProps => <HeaderCell all={cellProps.column} value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'os_version',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
{
title: 'Osquery',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'osquery_version',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
{
title: 'IPv4',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'primary_ip',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
{
title: 'Last Seen',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'seen_time',
Cell: cellProps => <TextCell value={cellProps.cell.value} formatter={humanHostLastSeen} />,
},
{
title: 'UUID',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'uuid',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
{
title: 'Uptime',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'uptime',
Cell: cellProps => <TextCell value={cellProps.cell.value} formatter={humanHostUptime} />,
},
{
title: 'CPU',
Header: 'CPU',
disableSortBy: true,
accessor: 'host_cpu',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
{
title: 'Memory',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'memory',
Cell: cellProps => <TextCell value={cellProps.cell.value} formatter={humanHostMemory} />,
},
{
title: 'MAC Address',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'primary_mac',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
{
title: 'Serial Number',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'hardware_serial',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
{
title: 'Hardware Model',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'hardware_model',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
];
const defaultHiddenColumns = [
'primary_mac',
'host_cpu',
'memory',
'uptime',
'uuid',
'seen_time',
'hardware_model',
'hardware_serial',
];
export { hostDataHeaders, defaultHiddenColumns };
@@ -3,15 +3,8 @@
&--no-hosts {
display: flex;
flex-direction: column;
justify-content: center;
padding-top: 35px;
font-size: 15px;
font-weight: $regular;
line-height: 2;
letter-spacing: normal;
color: rgba(32, 37, 50, 0.66);
border-top: 1px solid $ui-borders;
margin-top: $pad-half;
align-items: center;
margin-top: 80px;
h1 {
font-size: $large;
@@ -47,8 +40,13 @@
&__inner {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
h1 {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-medium;
}
img {
width: 176px;
@@ -61,6 +59,12 @@
font-size: $x-small;
margin: 0;
}
.no-filter-results {
display: flex;
flex-direction: column;
width: 350px;
}
}
.host-pagination__pager-wrap {
@@ -96,11 +100,35 @@
}
}
&__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: auto;
margin-left: $pad-medium;
.host-container__input-wrapper {
margin-bottom: 0;
@@ -3,18 +3,11 @@ import PropTypes from 'prop-types';
import { useTable, useGlobalFilter, useSortBy, useAsyncDebounce } from 'react-table';
import { useSelector, useDispatch } from 'react-redux';
// TODO: move this file closer to HostsDataTable
import { humanHostMemory, humanHostUptime } from 'kolide/helpers';
import { getHostTableData } from 'redux/nodes/components/ManageHostsPage/actions';
import Spinner from 'components/loaders/Spinner';
import HostPagination from 'components/hosts/HostPagination';
import HeaderCell from '../HeaderCell/HeaderCell';
import TextCell from '../TextCell/TextCell';
import StatusCell from '../StatusCell/StatusCell';
import LinkCell from '../LinkCell/LinkCell';
import scrollToTop from '../../../../../utilities/scroll_to_top';
// TODO: pass in as props
@@ -42,6 +35,8 @@ const HostsDataTable = (props) => {
// component cannot access the router state.
selectedFilter,
searchQuery,
hiddenColumns,
tableColumns,
} = props;
const [pageSize] = useState(DEFAULT_PAGE_SIZE);
@@ -59,20 +54,9 @@ const HostsDataTable = (props) => {
const pageIndexChangeRef = useRef();
// TODO: maybe pass as props?
const columns = useMemo(() => {
return [
{ Header: cellProps => <HeaderCell value={'Hostname'} isSortedDesc={cellProps.column.isSortedDesc} />, accessor: 'hostname', Cell: cellProps => <LinkCell value={cellProps.cell.value} host={cellProps.row.original} /> },
{ Header: 'Status', disableSortBy: true, accessor: 'status', Cell: cellProps => <StatusCell value={cellProps.cell.value} /> },
{ Header: cellProps => <HeaderCell all={cellProps.column} value={'OS'} isSortedDesc={cellProps.column.isSortedDesc} />, accessor: 'os_version', Cell: cellProps => <TextCell value={cellProps.cell.value} /> },
{ Header: cellProps => <HeaderCell value={'Osquery'} isSortedDesc={cellProps.column.isSortedDesc} />, accessor: 'osquery_version', Cell: cellProps => <TextCell value={cellProps.cell.value} /> },
{ Header: cellProps => <HeaderCell value={'IPv4'} isSortedDesc={cellProps.column.isSortedDesc} />, accessor: 'primary_ip', Cell: cellProps => <TextCell value={cellProps.cell.value} /> },
{ Header: cellProps => <HeaderCell value={'Physical Address'} isSortedDesc={cellProps.column.isSortedDesc} />, accessor: 'primary_mac', Cell: cellProps => <TextCell value={cellProps.cell.value} /> },
{ Header: 'CPU', disableSortBy: true, accessor: 'host_cpu', Cell: cellProps => <TextCell value={cellProps.cell.value} /> },
{ Header: cellProps => <HeaderCell value={'Memory'} isSortedDesc={cellProps.column.isSortedDesc} />, accessor: 'memory', Cell: cellProps => <TextCell value={cellProps.cell.value} formatter={humanHostMemory} /> },
{ Header: cellProps => <HeaderCell value={'Uptime'} isSortedDesc={cellProps.column.isSortedDesc} />, accessor: 'uptime', Cell: cellProps => <TextCell value={cellProps.cell.value} formatter={humanHostUptime} /> },
];
}, []);
return tableColumns;
}, [tableColumns]);
const data = useMemo(() => {
return hostAPIOrder.map((id) => {
@@ -85,13 +69,16 @@ const HostsDataTable = (props) => {
rows,
prepareRow,
setGlobalFilter,
setHiddenColumns,
state: tableState,
} = useTable(
{ columns,
data,
initialState: {
sortBy: [{ id: DEFAULT_SORT_KEY, desc: true }],
hiddenColumns,
},
autoResetHiddenColumns: false,
disableMultiSort: true,
manualGlobalFilter: true,
manualSortBy: true,
@@ -110,28 +97,34 @@ const HostsDataTable = (props) => {
const onPaginationChange = useCallback((newPage) => {
if (newPage > pageIndex) {
// pageIndexChangeRef.current = pageIndex;
setPageIndex(pageIndex + 1);
} else {
// pageIndpageIndexChangeRefexRef.current = pageIndex;
setPageIndex(pageIndex - 1);
}
pageIndexChangeRef.current = true;
scrollToTop();
}, [pageIndex, setPageIndex]);
// Since searchQuery is feed in from the parent, we want to debounce the globalfilter change
// 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]);
// Any changes to these relevent table search params will fire off an action to get the new
// 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
// hosts data.
useEffect(() => {
if (pageIndexChangeRef.current) { // the pageIndex has changed
dispatch(getHostTableData(pageIndex, pageSize, selectedFilter, globalFilter, sortBy));
} else {
} 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(getHostTableData(0, pageSize, selectedFilter, globalFilter, sortBy));
}
@@ -144,18 +137,11 @@ const HostsDataTable = (props) => {
return (
<div className={`${containerClass} ${containerClass}--no-hosts`}>
<div className={`${containerClass}--no-hosts__inner`}>
<div>
<h1>No hosts match the current search criteria</h1>
<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>
<HostPagination
hostOnCurrentPage={100}
currentPage={pageIndex}
hostsPerPage={pageSize}
onPaginationChange={onPaginationChange}
/>
</div>
);
}
@@ -166,6 +152,11 @@ const HostsDataTable = (props) => {
<p className={'manage-hosts__host-count'}>{generateHostCountText(pageIndex, pageSize, rows.length)}</p>
</div>
<div className={'hosts-table hosts-table__wrapper'}>
{loadingHosts &&
<div className={'loading-overlay'}>
<Spinner />
</div>
}
<table className={'hosts-table__table'}>
<thead>
{headerGroups.map(headerGroup => (
@@ -179,22 +170,20 @@ const HostsDataTable = (props) => {
))}
</thead>
<tbody>
{loadingHosts
? <tr><td><Spinner /></td></tr>
: rows.map((row) => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map((cell) => {
return (
<td {...cell.getCellProps()}>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
})
{rows.map((row) => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map((cell) => {
return (
<td {...cell.getCellProps()}>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
})
}
</tbody>
</table>
@@ -213,6 +202,8 @@ const HostsDataTable = (props) => {
HostsDataTable.propTypes = {
selectedFilter: PropTypes.string,
searchQuery: PropTypes.string,
tableColumns: PropTypes.arrayOf(PropTypes.object), // TODO: create proper interface for this
hiddenColumns: PropTypes.arrayOf(PropTypes.string),
};
export default HostsDataTable;
@@ -86,5 +86,15 @@
}
}
}
.loading-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(255, 255, 255, 0.8);
z-index: 1;
}
}
}