Select all matching hosts (#1226)

* #940 passing matching props to table; typescript

* #940 fixed use of results title

* #940 logic to swap states of selected and not

* #940 optimized rendering selected text

* #940 began styling to match flexbox spacing

* #940 added logic to API

* #940 clean up

* #940 fixed action

* #940 fixed conflict and coment

* #940 hide select matching if under 100 hosts

* #940 fixed issue with status filter
This commit is contained in:
Martavis Parker
2021-07-10 10:29:27 -07:00
committed by GitHub
parent 2d16ad1608
commit ec23d4ee8e
14 changed files with 434 additions and 57 deletions
@@ -5,6 +5,8 @@ import { noop } from "lodash";
// @ts-ignore
import DataTable from "./DataTable";
const DEFAULT_PAGE_SIZE = 100;
describe("DataTable - component", () => {
it("renders a data table based on the columns and data passed in", () => {
const columns = [
@@ -24,6 +26,10 @@ describe("DataTable - component", () => {
data={data}
isLoading={false}
onSort={noop}
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -61,6 +67,10 @@ describe("DataTable - component", () => {
sortDirection={"desc"}
isLoading={false}
onSort={noop}
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -82,6 +92,10 @@ describe("DataTable - component", () => {
sortDirection={"desc"}
isLoading={false}
onSort={noop}
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -111,6 +125,10 @@ describe("DataTable - component", () => {
sortDirection={"desc"}
isLoading={false}
onSort={noop}
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -129,6 +147,10 @@ describe("DataTable - component", () => {
sortDirection={"asc"}
isLoading={false}
onSort={noop}
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle="users"
defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -1,25 +1,44 @@
import React, { useMemo, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import { useTable, useSortBy, useRowSelect } from "react-table";
import useDeepEffect from "utilities/hooks/useDeepEffect";
import Spinner from "components/loaders/Spinner";
import Button from "../../buttons/Button";
const baseClass = "data-table-container";
interface IDataTableProps {
columns: any;
data: any;
isLoading: boolean;
sortHeader: any;
sortDirection: any;
onSort: any; // TODO: an event type
onSelectActionClick: any; // TODO: an event type
showMarkAllPages: boolean;
isAllPagesSelected: boolean; // TODO: make dependent on showMarkAllPages
toggleAllPagesSelected?: any; // TODO: an event type and make it dependent on showMarkAllPages
resultsTitle: string;
defaultPageSize: number;
}
// 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,
onSelectActionClick,
} = props;
const DataTable = ({
columns: tableColumns,
data: tableData,
isLoading,
sortHeader,
sortDirection,
onSort,
onSelectActionClick,
showMarkAllPages,
isAllPagesSelected,
toggleAllPagesSelected,
resultsTitle,
defaultPageSize,
}: IDataTableProps) => {
const columns = useMemo(() => {
return tableColumns;
}, [tableColumns]);
@@ -35,6 +54,7 @@ const DataTable = (props) => {
prepareRow,
selectedFlatRows,
toggleAllRowsSelected,
isAllRowsSelected,
state: tableState,
} = useTable(
{
@@ -69,15 +89,55 @@ const DataTable = (props) => {
}
}, [sortBy, sortHeader, onSort, sortDirection]);
useEffect(() => {
if (isAllPagesSelected) {
toggleAllRowsSelected(true);
}
}, [isAllPagesSelected]);
useDeepEffect(() => {
if (
Object.keys(selectedRowIds).length < rows.length &&
toggleAllPagesSelected
) {
toggleAllPagesSelected(false);
}
}, [tableState.selectedRowIds, toggleAllPagesSelected]);
const onSelectActionButtonClick = useCallback(() => {
const entityIds = selectedFlatRows.map((row) => row.original.id);
const entityIds = selectedFlatRows.map((row: any) => row.original.id);
onSelectActionClick(entityIds);
}, [onSelectActionClick, selectedFlatRows]);
const onToggleAllPagesClick = useCallback(() => {
toggleAllPagesSelected();
}, [toggleAllPagesSelected]);
const onClearSelectionClick = useCallback(() => {
toggleAllRowsSelected(false);
toggleAllPagesSelected(false);
}, [toggleAllRowsSelected]);
const renderSelectedText = (): JSX.Element => {
if (isAllPagesSelected) {
return <p>All matching {resultsTitle} are selected</p>;
}
if (isAllRowsSelected) {
return <p>All {resultsTitle} on this page are selected</p>;
}
return (
<p>
<span>{selectedFlatRows.length}</span> selected
</p>
);
};
const shouldRenderToggleAllPages =
Object.keys(selectedRowIds).length >= defaultPageSize &&
showMarkAllPages &&
!isAllPagesSelected;
return (
<div className={baseClass}>
<div className={"data-table data-table__wrapper"}>
@@ -99,18 +159,29 @@ const DataTable = (props) => {
</th>
<th className={"active-selection__container"}>
<div className={"active-selection__inner"}>
<p>
<span>{selectedFlatRows.length}</span> selected
</p>
<Button
onClick={onClearSelectionClick}
variant={"text-link"}
>
Clear selection
</Button>
<Button onClick={onSelectActionButtonClick}>
Transfer to team
</Button>
<div className={"active-selection__inner-left"}>
{renderSelectedText()}
{shouldRenderToggleAllPages && (
<Button
onClick={onToggleAllPagesClick}
variant={"text-link"}
className={"light-text"}
>
<>Select all matching {resultsTitle}</>
</Button>
)}
<Button
onClick={onClearSelectionClick}
variant={"text-link"}
>
Clear selection
</Button>
</div>
<div className={"active-selection__inner-right"}>
<Button onClick={onSelectActionButtonClick}>
Transfer to team
</Button>
</div>
</div>
</th>
</tr>
@@ -56,6 +56,7 @@
position: absolute;
top: -1px;
width: 100%;
max-width: calc( 100vw - 2rem - 2rem - 0.125rem - 300px); // same as wrapper page for flex layout
border: 0;
&__container {
@@ -64,6 +65,7 @@
&__inner {
display: flex;
justify-content: space-between;
align-items: center;
p {
@@ -79,6 +81,12 @@
margin-right: $pad-medium;
}
}
&__inner-left,
&__inner-right {
display: flex;
align-items: center;
}
}
tbody {
@@ -22,9 +22,9 @@ interface ITableQueryData {
pageIndex: number;
}
interface ITableContainerProps<T, U> {
columns: T[];
data: U[];
interface ITableContainerProps {
columns: any; // TODO: Figure out type
data: any; // TODO: Figure out type
isLoading: boolean;
defaultSortHeader: string;
defaultSortDirection: string;
@@ -36,10 +36,13 @@ interface ITableContainerProps<T, U> {
onSelectActionClick?: (selectedItemIds: number[]) => void;
inputPlaceHolder: string;
disableActionButton?: boolean;
resultsTitle?: string;
resultsTitle: string;
additionalQueries?: string;
emptyComponent: React.ElementType;
className?: string;
showMarkAllPages: boolean;
isAllPagesSelected: boolean; // TODO: make dependent on showMarkAllPages
toggleAllPagesSelected?: any; // TODO: an event type and make it dependent on showMarkAllPages
}
const baseClass = "table-container";
@@ -48,29 +51,28 @@ 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,
onActionButtonClick,
inputPlaceHolder,
additionalQueries,
onQueryChange,
resultsTitle,
emptyComponent,
className,
disableActionButton,
actionButtonText,
actionButtonIcon,
actionButtonVariant,
onSelectActionClick,
} = props;
const TableContainer = ({
columns,
data,
isLoading,
defaultSortHeader,
defaultSortDirection,
onActionButtonClick,
inputPlaceHolder,
additionalQueries,
onQueryChange,
resultsTitle,
emptyComponent,
className,
disableActionButton,
actionButtonText,
actionButtonIcon,
actionButtonVariant,
onSelectActionClick,
showMarkAllPages,
isAllPagesSelected,
toggleAllPagesSelected,
}: ITableContainerProps): JSX.Element => {
const [searchQuery, setSearchQuery] = useState("");
const [sortHeader, setSortHeader] = useState(defaultSortHeader || "");
const [sortDirection, setSortDirection] = useState(
@@ -215,6 +217,11 @@ const TableContainer = <T, U>(
sortDirection={sortDirection}
onSort={onSortChange}
onSelectActionClick={onSelectActionClick}
showMarkAllPages={showMarkAllPages}
isAllPagesSelected={isAllPagesSelected}
toggleAllPagesSelected={toggleAllPagesSelected}
resultsTitle={resultsTitle}
defaultPageSize={DEFAULT_PAGE_SIZE}
/>
<Pagination
resultsOnCurrentPage={data.length}
@@ -140,6 +140,10 @@ $base-class: "button";
box-shadow: none;
text-decoration: underline;
}
&.light-text {
font-weight: $regular;
}
}
&--text-icon {
+1
View File
@@ -14,6 +14,7 @@ export default {
FORGOT_PASSWORD: "/v1/fleet/forgot_password",
HOSTS: "/v1/fleet/hosts",
HOSTS_TRANSFER: "/v1/fleet/hosts/transfer",
HOSTS_TRANSFER_BY_FILTER: "/v1/fleet/hosts/transfer/filter",
INVITES: "/v1/fleet/invites",
LABELS: "/v1/fleet/labels",
LABEL_HOSTS: (id: number): string => {
+20
View File
@@ -92,5 +92,25 @@ export default (client: any) => {
})
);
},
transferToTeamByFilter: (
teamId: number | null,
query: string,
status: string,
labelId: number | null
) => {
const { HOSTS_TRANSFER_BY_FILTER } = endpoints;
const endpoint = client._endpoint(HOSTS_TRANSFER_BY_FILTER);
return client.authenticatedPost(
endpoint,
JSON.stringify({
team_id: teamId,
filters: {
query,
status,
label_id: labelId,
},
})
);
},
};
};
@@ -248,6 +248,8 @@ const MembersPage = (props: IMembersPageProps): JSX.Element => {
onQueryChange={onQueryChange}
inputPlaceHolder={"Search"}
emptyComponent={EmptyMembers}
showMarkAllPages={false}
isAllPagesSelected={false}
/>
{showAddMemberModal ? (
<AddMemberModal
@@ -186,6 +186,8 @@ const TeamManagementPage = (): JSX.Element => {
onQueryChange={onQueryChange}
resultsTitle={"teams"}
emptyComponent={EmptyTeams}
showMarkAllPages={false}
isAllPagesSelected={false}
/>
{showCreateTeamModal ? (
<CreateTeamModal
@@ -592,7 +592,7 @@ export class UserManagementPage extends Component {
actionButtonText={"Create User"}
onActionButtonClick={toggleCreateUserModal}
onQueryChange={onTableQueryChange}
resultsTitle={"rows"}
resultsTitle={"users"}
emptyComponent={EmptyUsers}
/>
{renderCreateUserModal()}
@@ -100,6 +100,8 @@ export class ManageHostsPage extends PureComponent {
? storedHiddenColumns
: defaultHiddenColumns,
selectedHostIds: [],
isAllMatchingHostsSelected: false,
searchQuery: "",
};
}
@@ -191,6 +193,10 @@ export class ManageHostsPage extends PureComponent {
if (sortHeader !== "") {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
// keep track as a local state to be used later
this.setState({ searchQuery });
dispatch(
getHosts(pageIndex, pageSize, selectedFilter, searchQuery, sortBy)
);
@@ -268,11 +274,35 @@ export class ManageHostsPage extends PureComponent {
};
onTransferHostSubmit = (team) => {
const { toggleTransferHostModal } = this;
const { dispatch } = this.props;
const { selectedHostIds } = this.state;
const { toggleTransferHostModal, isAcceptableStatus } = this;
const { dispatch, selectedFilter, selectedLabel } = this.props;
const {
selectedHostIds,
isAllMatchingHostsSelected,
searchQuery,
} = this.state;
const teamId = team.id === "no-team" ? null : team.id;
dispatch(hostActions.transferToTeam(teamId, selectedHostIds))
let action = hostActions.transferToTeam(teamId, selectedHostIds);
if (isAllMatchingHostsSelected) {
let status = "";
let labelId = null;
if (isAcceptableStatus(selectedFilter)) {
status = selectedFilter;
} else {
labelId = selectedLabel.id;
}
action = hostActions.transferToTeamByFilter(
teamId,
searchQuery,
status,
labelId
);
}
dispatch(action)
.then(() => {
const successMessage =
teamId === null
@@ -286,8 +316,19 @@ export class ManageHostsPage extends PureComponent {
renderFlash("error", "Could not transfer hosts. Please try again.")
);
});
toggleTransferHostModal();
this.setState({ selectedHostIds: [] });
this.setState({ isAllMatchingHostsSelected: false });
};
isAcceptableStatus = (filter) => {
return (
filter === "new" ||
filter === "online" ||
filter === "offline" ||
filter === "mia"
);
};
clearHostUpdates() {
@@ -312,6 +353,19 @@ export class ManageHostsPage extends PureComponent {
this.setState({ showTransferHostModal: !showTransferHostModal });
};
toggleAllMatchingHosts = (shouldSelect = undefined) => {
// shouldSelect?: boolean
const { isAllMatchingHostsSelected } = this.state;
if (shouldSelect !== undefined) {
this.setState({ isAllMatchingHostsSelected: shouldSelect });
} else {
this.setState({
isAllMatchingHostsSelected: !isAllMatchingHostsSelected,
});
}
};
renderEditColumnsModal = () => {
const { config, currentUser } = this.props;
const { showEditColumnsModal, hiddenColumns } = this.state;
@@ -578,11 +632,12 @@ export class ManageHostsPage extends PureComponent {
hosts,
loadingHosts,
} = this.props;
const { hiddenColumns } = this.state;
const { hiddenColumns, isAllMatchingHostsSelected } = this.state;
const {
onTableQueryChange,
onEditColumnsClick,
onTransferToTeamClick,
toggleAllMatchingHosts,
} = this;
// The data has not been fetched yet.
@@ -615,6 +670,9 @@ export class ManageHostsPage extends PureComponent {
onQueryChange={onTableQueryChange}
resultsTitle={"hosts"}
emptyComponent={EmptyHosts}
showMarkAllPages
isAllPagesSelected={isAllMatchingHostsSelected}
toggleAllPagesSelected={toggleAllMatchingHosts}
/>
);
};
@@ -41,6 +41,27 @@ const transferToTeam = (teamId: number | null, hostIds: number[]): any => {
};
};
const transferToTeamByFilter = (
teamId: number | null,
query: string,
status: string,
labelId: number | null
): any => {
return (dispatch: any) => {
dispatch(loadRequest());
return Fleet.hosts
.transferToTeamByFilter(teamId, query, status, labelId)
.then(() => {
dispatch(transferHostsSuccess());
})
.catch((res: IApiError) => {
const errorsObject = formatErrorResponse(res);
dispatch(transferHostsFailure(errorsObject));
throw errorsObject;
});
};
};
export const LOAD_PAGINATED = "LOAD_PAGINATED";
export const loadPaginated = (): any => {
return (dispatch: any) => {
@@ -80,6 +101,7 @@ export const refetchHostStart = (host: IHost): any => {
export default {
...actions,
transferToTeam,
transferToTeamByFilter,
refetchHostSuccess,
refetchHostFailure,
refetchHostStart,
+129
View File
@@ -0,0 +1,129 @@
import {
UseColumnOrderInstanceProps,
UseColumnOrderState,
UseExpandedHooks,
UseExpandedInstanceProps,
UseExpandedOptions,
UseExpandedRowProps,
UseExpandedState,
UseFiltersColumnOptions,
UseFiltersColumnProps,
UseFiltersInstanceProps,
UseFiltersOptions,
UseFiltersState,
UseGlobalFiltersColumnOptions,
UseGlobalFiltersInstanceProps,
UseGlobalFiltersOptions,
UseGlobalFiltersState,
UseGroupByCellProps,
UseGroupByColumnOptions,
UseGroupByColumnProps,
UseGroupByHooks,
UseGroupByInstanceProps,
UseGroupByOptions,
UseGroupByRowProps,
UseGroupByState,
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseResizeColumnsColumnOptions,
UseResizeColumnsColumnProps,
UseResizeColumnsOptions,
UseResizeColumnsState,
UseRowSelectHooks,
UseRowSelectInstanceProps,
UseRowSelectOptions,
UseRowSelectRowProps,
UseRowSelectState,
UseRowStateCellProps,
UseRowStateInstanceProps,
UseRowStateOptions,
UseRowStateRowProps,
UseRowStateState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByHooks,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
} from "react-table";
declare module "react-table" {
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
export interface TableOptions<
D extends Record<string, unknown>
> extends UseExpandedOptions<D>,
UseFiltersOptions<D>,
UseGlobalFiltersOptions<D>,
UseGroupByOptions<D>,
UsePaginationOptions<D>,
UseResizeColumnsOptions<D>,
UseRowSelectOptions<D>,
UseRowStateOptions<D>,
UseSortByOptions<D>,
// note that having Record here allows you to add anything to the options, this matches the spirit of the
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
// feature set, this is a safe default.
Record<string, any> {}
export interface Hooks<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseExpandedHooks<D>,
UseGroupByHooks<D>,
UseRowSelectHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseColumnOrderInstanceProps<D>,
UseExpandedInstanceProps<D>,
UseFiltersInstanceProps<D>,
UseGlobalFiltersInstanceProps<D>,
UseGroupByInstanceProps<D>,
UsePaginationInstanceProps<D>,
UseRowSelectInstanceProps<D>,
UseRowStateInstanceProps<D>,
UseSortByInstanceProps<D> {}
export interface TableState<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseColumnOrderState<D>,
UseExpandedState<D>,
UseFiltersState<D>,
UseGlobalFiltersState<D>,
UseGroupByState<D>,
UsePaginationState<D>,
UseResizeColumnsState<D>,
UseRowSelectState<D>,
UseRowStateState<D>,
UseSortByState<D> {}
export interface ColumnInterface<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseFiltersColumnOptions<D>,
UseGlobalFiltersColumnOptions<D>,
UseGroupByColumnOptions<D>,
UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {}
export interface ColumnInstance<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseFiltersColumnProps<D>,
UseGroupByColumnProps<D>,
UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {}
export interface Cell<
D extends Record<string, unknown> = Record<string, unknown>,
V = any
> extends UseGroupByCellProps<D>,
UseRowStateCellProps<D> {}
export interface Row<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D>,
UseRowStateRowProps<D> {}
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useRef } from "react";
import { isEqual } from "lodash";
/**
*
* @param fn Anonymous function passing into the hook
* @param deps What dependencies to watch for changes
*
* Adapted from https://betterprogramming.pub/how-to-use-the-react-hook-usedeepeffect-815818c0ad9d,
* this hook does a deeper check for changes within objects and arrays
*/
const useDeepEffect = (fn: () => void, deps: Array<any>) => {
const isFirst = useRef(true);
const prevDeps = useRef(deps);
useEffect(() => {
const isSame = prevDeps.current.every((obj, index) =>
isEqual(obj, deps[index])
);
if (isFirst.current || !isSame) {
fn();
}
isFirst.current = false;
prevDeps.current = deps;
}, [deps, fn]);
};
export default useDeepEffect;