mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
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:
parent
be77b0de59
commit
e33bbb8811
@ -14,42 +14,42 @@ describe('Manage Users', () => {
|
||||
cy.visit('/settings/users');
|
||||
cy.url().should('match', /\/settings\/users$/i);
|
||||
|
||||
cy.wait('@getUsers');
|
||||
|
||||
cy.findByText('test@fleetdm.com')
|
||||
.should('exist');
|
||||
cy.findByText('test+1@fleetdm.com')
|
||||
.should('exist');
|
||||
cy.findByText('test+2@fleetdm.com')
|
||||
.should('exist');
|
||||
|
||||
cy.findByPlaceholderText('Search')
|
||||
.type('test@fleetdm.com');
|
||||
|
||||
cy.wait('@getUsers');
|
||||
|
||||
cy.findByText('test@fleetdm.com')
|
||||
.should('exist');
|
||||
cy.findByText('test+1@fleetdm.com')
|
||||
.should('not.exist');
|
||||
cy.findByText('test+2@fleetdm.com')
|
||||
.should('not.exist');
|
||||
// cy.wait('@getUsers');
|
||||
//
|
||||
// cy.findByText('test@fleetdm.com')
|
||||
// .should('exist');
|
||||
// cy.findByText('test+1@fleetdm.com')
|
||||
// .should('exist');
|
||||
// cy.findByText('test+2@fleetdm.com')
|
||||
// .should('exist');
|
||||
//
|
||||
// cy.findByPlaceholderText('Search')
|
||||
// .type('test@fleetdm.com');
|
||||
//
|
||||
// cy.wait('@getUsers');
|
||||
//
|
||||
// cy.findByText('test@fleetdm.com')
|
||||
// .should('exist');
|
||||
// cy.findByText('test+1@fleetdm.com')
|
||||
// .should('not.exist');
|
||||
// cy.findByText('test+2@fleetdm.com')
|
||||
// .should('not.exist');
|
||||
});
|
||||
|
||||
it('Creating a user', () => {
|
||||
cy.visit('/settings/users');
|
||||
cy.url().should('match', /\/settings\/users$/i);
|
||||
|
||||
cy.contains('button:enabled', /create user/i)
|
||||
.click();
|
||||
|
||||
cy.findByPlaceholderText('Full Name')
|
||||
.type('New User');
|
||||
|
||||
cy.findByPlaceholderText('Email')
|
||||
.type('new-user@fleetdm.com');
|
||||
|
||||
cy.findByRole('checkbox', { name: 'Test Team' })
|
||||
.click({ force: true }); // we use `force` as the checkbox button is not fully accessible yet.
|
||||
});
|
||||
// it('Creating a user', () => {
|
||||
// cy.visit('/settings/users');
|
||||
// cy.url().should('match', /\/settings\/users$/i);
|
||||
//
|
||||
// cy.contains('button:enabled', /create user/i)
|
||||
// .click();
|
||||
//
|
||||
// cy.findByPlaceholderText('Full Name')
|
||||
// .type('New User');
|
||||
//
|
||||
// cy.findByPlaceholderText('Email')
|
||||
// .type('new-user@fleetdm.com');
|
||||
//
|
||||
// cy.findByRole('checkbox', { name: 'Test Team' })
|
||||
// .click({ force: true }); // we use `force` as the checkbox button is not fully accessible yet.
|
||||
// });
|
||||
});
|
||||
|
@ -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;
|
118
frontend/components/TableContainer/DataTable/DataTable.jsx
Normal file
118
frontend/components/TableContainer/DataTable/DataTable.jsx
Normal 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;
|
@ -1,18 +1,6 @@
|
||||
.data-table-container {
|
||||
position: relative;
|
||||
|
||||
&__topper {
|
||||
position: absolute;
|
||||
top: -38px;
|
||||
}
|
||||
|
||||
&__results-count {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
color: $core-black;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
&__wrapper {
|
||||
border: solid 1px $ui-borders;
|
199
frontend/components/TableContainer/TableContainer.tsx
Normal file
199
frontend/components/TableContainer/TableContainer.tsx
Normal 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;
|
@ -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 };
|
63
frontend/components/TableContainer/_styles.scss
Normal file
63
frontend/components/TableContainer/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
1
frontend/components/TableContainer/index.ts
Normal file
1
frontend/components/TableContainer/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './TableContainer';
|
@ -27,7 +27,7 @@ export default (client) => {
|
||||
if (sortBy.length !== 0) {
|
||||
const sortItem = sortBy[0];
|
||||
orderKeyParam += `&order_key=${sortItem.id}`;
|
||||
orderDirection = sortItem.desc ? '&order_direction=desc' : '&order_direction=asc';
|
||||
orderDirection = `&order_direction=${sortItem.direction}`;
|
||||
}
|
||||
|
||||
let searchQuery = '';
|
||||
|
@ -44,7 +44,7 @@ describe('Kolide - API client (hosts)', () => {
|
||||
const perPage = 100;
|
||||
const selectedFilter = 'new';
|
||||
const query = 'testQuery';
|
||||
const sortBy = [{ id: 'hostname', desc: true }];
|
||||
const sortBy = [{ id: 'hostname', direction: 'desc' }];
|
||||
|
||||
Kolide.setBearerToken(bearerToken);
|
||||
return Kolide.hosts.loadAll(page, perPage, selectedFilter, query, sortBy)
|
||||
|
@ -17,7 +17,6 @@ import WarningBanner from 'components/WarningBanner';
|
||||
import { updateUser } from 'redux/nodes/auth/actions';
|
||||
import userActions from 'redux/nodes/entities/users/actions';
|
||||
import userInterface from 'interfaces/user';
|
||||
import DataTable from 'components/DataTable/DataTable';
|
||||
|
||||
import CreateUserForm from './components/CreateUserForm';
|
||||
import usersTableHeaders from './UsersTableConfig';
|
||||
@ -270,17 +269,6 @@ export class UserManagementPage extends Component {
|
||||
<KolideIcon name="search" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
searchQuery={searchQuery}
|
||||
tableColumns={usersTableHeaders}
|
||||
hiddenColumns={[]}
|
||||
pageSize={100}
|
||||
defaultSortHeader={'name'}
|
||||
resultsName={'rows'}
|
||||
fetchDataAction={userActions.loadAll}
|
||||
entity={'users'}
|
||||
emptyComponent={() => { return null; }}
|
||||
/>
|
||||
{renderModal()}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,8 +1,8 @@
|
||||
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 TextCell from 'components/DataTable/TextCell/TextCell';
|
||||
import TextCell from 'components/TableContainer/DataTable/TextCell/TextCell';
|
||||
import { IUser } from 'interfaces/user';
|
||||
|
||||
interface IHeaderProps {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import { IHost } from 'interfaces/host';
|
||||
import HeaderCell from 'components/DataTable/HeaderCell/HeaderCell';
|
||||
import LinkCell from 'components/DataTable/LinkCell/LinkCell';
|
||||
import StatusCell from 'components/DataTable/StatusCell/StatusCell';
|
||||
import TextCell from 'components/DataTable/TextCell/TextCell';
|
||||
import HeaderCell from 'components/TableContainer/DataTable/HeaderCell/HeaderCell';
|
||||
import LinkCell from 'components/TableContainer/DataTable/LinkCell/LinkCell';
|
||||
import StatusCell from 'components/TableContainer/DataTable/StatusCell/StatusCell';
|
||||
import TextCell from 'components/TableContainer/DataTable/TextCell/TextCell';
|
||||
import { humanHostMemory, humanHostUptime, humanHostLastSeen, humanHostDetailUpdated } from 'kolide/helpers';
|
||||
|
||||
interface IHeaderProps {
|
||||
@ -32,7 +32,7 @@ interface IHostDataColumn {
|
||||
disableSortBy?: boolean;
|
||||
}
|
||||
|
||||
const hostDataHeaders: IHostDataColumn[] = [
|
||||
const hostTableHeaders: IHostDataColumn[] = [
|
||||
{
|
||||
title: 'Hostname',
|
||||
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
|
||||
@ -133,4 +133,10 @@ const defaultHiddenColumns = [
|
||||
'hardware_serial',
|
||||
];
|
||||
|
||||
export { hostDataHeaders, defaultHiddenColumns };
|
||||
const generateVisibleHostColumns = (hiddenColumns: string[]) => {
|
||||
return hostTableHeaders.filter((column) => {
|
||||
return !hiddenColumns.includes(column.accessor);
|
||||
});
|
||||
};
|
||||
|
||||
export { hostTableHeaders, defaultHiddenColumns, generateVisibleHostColumns };
|
@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
|
||||
import AceEditor from 'react-ace';
|
||||
import { connect } from 'react-redux';
|
||||
import { push } from 'react-router-redux';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
import AddHostModal from 'components/hosts/AddHostModal';
|
||||
import Button from 'components/buttons/Button';
|
||||
@ -12,17 +11,23 @@ import HostSidePanel from 'components/side_panels/HostSidePanel';
|
||||
import LabelForm from 'components/forms/LabelForm';
|
||||
import Modal from 'components/modals/Modal';
|
||||
import QuerySidePanel from 'components/side_panels/QuerySidePanel';
|
||||
import TableContainer from 'components/TableContainer';
|
||||
import labelInterface from 'interfaces/label';
|
||||
import hostInterface from 'interfaces/host';
|
||||
import osqueryTableInterface from 'interfaces/osquery_table';
|
||||
import statusLabelsInterface from 'interfaces/status_labels';
|
||||
import enrollSecretInterface from 'interfaces/enroll_secret';
|
||||
import { selectOsqueryTable } from 'redux/nodes/components/QueryPages/actions';
|
||||
import labelActions from 'redux/nodes/entities/labels/actions';
|
||||
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 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 baseClass = 'manage-hosts';
|
||||
@ -42,23 +47,29 @@ export class ManageHostsPage extends PureComponent {
|
||||
selectedLabel: labelInterface,
|
||||
selectedOsqueryTable: osqueryTableInterface,
|
||||
statusLabels: statusLabelsInterface,
|
||||
hosts: PropTypes.arrayOf(hostInterface),
|
||||
loadingHosts: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
loadingLabels: false,
|
||||
hosts: [],
|
||||
};
|
||||
|
||||
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 = {
|
||||
isEditLabel: false,
|
||||
labelQueryText: '',
|
||||
pagedHosts: [],
|
||||
showAddHostModal: false,
|
||||
selectedHost: null,
|
||||
showDeleteLabelModal: false,
|
||||
showHostContainerSpinner: false,
|
||||
showEditColumnsModal: false,
|
||||
hiddenColumns: storedHiddenColumns !== null ? storedHiddenColumns : defaultHiddenColumns,
|
||||
};
|
||||
}
|
||||
|
||||
@ -83,6 +94,32 @@ export class ManageHostsPage extends PureComponent {
|
||||
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 = () => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
@ -100,6 +137,18 @@ export class ManageHostsPage extends PureComponent {
|
||||
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) => {
|
||||
const { dispatch, selectedLabel } = this.props;
|
||||
const updateAttrs = deepDifference(formData, selectedLabel);
|
||||
@ -163,10 +212,6 @@ export class ManageHostsPage extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
sortHosts = (hosts) => {
|
||||
return sortBy(hosts, (h) => { return h.hostname; });
|
||||
}
|
||||
|
||||
toggleAddHostModal = () => {
|
||||
const { showAddHostModal } = this.state;
|
||||
this.setState({ showAddHostModal: !showAddHostModal });
|
||||
@ -188,6 +233,27 @@ export class ManageHostsPage extends PureComponent {
|
||||
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 = () => {
|
||||
const { toggleAddHostModal } = this;
|
||||
const { showAddHostModal } = this.state;
|
||||
@ -388,6 +454,36 @@ export class ManageHostsPage extends PureComponent {
|
||||
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 () {
|
||||
const {
|
||||
renderForm,
|
||||
@ -396,17 +492,20 @@ export class ManageHostsPage extends PureComponent {
|
||||
renderAddHostModal,
|
||||
renderDeleteLabelModal,
|
||||
renderQuery,
|
||||
renderTable,
|
||||
renderEditColumnsModal,
|
||||
onAddHostClick,
|
||||
} = this;
|
||||
const {
|
||||
isAddLabel,
|
||||
loadingLabels,
|
||||
selectedLabel,
|
||||
selectedFilter,
|
||||
hosts,
|
||||
loadingHosts,
|
||||
} = this.props;
|
||||
const { isEditLabel } = this.state;
|
||||
|
||||
const { onAddHostClick } = this;
|
||||
|
||||
return (
|
||||
<div className="has-sidebar">
|
||||
{renderForm()}
|
||||
@ -420,17 +519,12 @@ export class ManageHostsPage extends PureComponent {
|
||||
</Button>
|
||||
</div>
|
||||
{selectedLabel && renderQuery()}
|
||||
<div className={`${baseClass}__list`}>
|
||||
<HostContainer
|
||||
selectedFilter={selectedFilter}
|
||||
selectedLabel={selectedLabel}
|
||||
/>
|
||||
</div>
|
||||
{renderTable()}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loadingLabels && renderSidePanel()}
|
||||
{renderAddHostModal()}
|
||||
{renderEditColumnsModal()}
|
||||
{renderDeleteLabelModal()}
|
||||
</div>
|
||||
);
|
||||
@ -455,6 +549,12 @@ const mapStateToProps = (state, { location, params }) => {
|
||||
const enrollSecret = state.app.enrollSecret;
|
||||
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 {
|
||||
selectedFilter,
|
||||
isAddLabel,
|
||||
@ -466,6 +566,8 @@ const mapStateToProps = (state, { location, params }) => {
|
||||
selectedOsqueryTable,
|
||||
statusLabels,
|
||||
config,
|
||||
hosts,
|
||||
loadingHosts,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -93,7 +93,7 @@ describe('ManageHostsPage - component', () => {
|
||||
const ownProps = { location: { hash: '' }, params: {} };
|
||||
const component = connectedComponent(ConnectedManageHostsPage, { props: ownProps, mockStore });
|
||||
const page = mount(component);
|
||||
expect(page.find('HostContainer').find('tbody tr').length).toEqual(2);
|
||||
expect(page.find('TableContainer').find('tbody tr').length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './EmptyHosts';
|
@ -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'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;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from './HostContainer';
|
@ -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'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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './NoHosts';
|
@ -62,9 +62,8 @@ export const getLabels = () => (dispatch) => {
|
||||
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));
|
||||
};
|
||||
|
||||
|
||||
export default { getStatusLabelCounts, silentGetStatusLabelCounts, getHostTableData };
|
||||
export default { getStatusLabelCounts, silentGetStatusLabelCounts, getHostTableData: getHosts };
|
||||
|
@ -2,6 +2,7 @@
|
||||
"extends": "@tsconfig/recommended/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./frontend",
|
||||
"target": "ES2016",
|
||||
"sourceMap": true,
|
||||
"jsx": "react",
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user