diff --git a/frontend/components/TableContainer/DataTable/DataTable.tests.tsx b/frontend/components/TableContainer/DataTable/DataTable.tests.tsx index 3a925721a8..f4a916d68c 100644 --- a/frontend/components/TableContainer/DataTable/DataTable.tests.tsx +++ b/frontend/components/TableContainer/DataTable/DataTable.tests.tsx @@ -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} /> ); diff --git a/frontend/components/TableContainer/DataTable/DataTable.jsx b/frontend/components/TableContainer/DataTable/DataTable.tsx similarity index 58% rename from frontend/components/TableContainer/DataTable/DataTable.jsx rename to frontend/components/TableContainer/DataTable/DataTable.tsx index 8852904daa..0328840e40 100644 --- a/frontend/components/TableContainer/DataTable/DataTable.jsx +++ b/frontend/components/TableContainer/DataTable/DataTable.tsx @@ -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

All matching {resultsTitle} are selected

; + } + + if (isAllRowsSelected) { + return

All {resultsTitle} on this page are selected

; + } + + return ( +

+ {selectedFlatRows.length} selected +

+ ); + }; + + const shouldRenderToggleAllPages = + Object.keys(selectedRowIds).length >= defaultPageSize && + showMarkAllPages && + !isAllPagesSelected; return (
@@ -99,18 +159,29 @@ const DataTable = (props) => {
-

- {selectedFlatRows.length} selected -

- - +
+ {renderSelectedText()} + {shouldRenderToggleAllPages && ( + + )} + +
+
+ +
diff --git a/frontend/components/TableContainer/DataTable/_styles.scss b/frontend/components/TableContainer/DataTable/_styles.scss index a1549b0297..8b75724e70 100644 --- a/frontend/components/TableContainer/DataTable/_styles.scss +++ b/frontend/components/TableContainer/DataTable/_styles.scss @@ -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 { diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index 15d69a8760..105a82e8df 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -22,9 +22,9 @@ interface ITableQueryData { pageIndex: number; } -interface ITableContainerProps { - 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 { 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 = ( - props: ITableContainerProps -): 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 = ( sortDirection={sortDirection} onSort={onSortChange} onSelectActionClick={onSelectActionClick} + showMarkAllPages={showMarkAllPages} + isAllPagesSelected={isAllPagesSelected} + toggleAllPagesSelected={toggleAllPagesSelected} + resultsTitle={resultsTitle} + defaultPageSize={DEFAULT_PAGE_SIZE} /> { diff --git a/frontend/fleet/entities/hosts.ts b/frontend/fleet/entities/hosts.ts index cb0d0bc0ee..8c16653964 100644 --- a/frontend/fleet/entities/hosts.ts +++ b/frontend/fleet/entities/hosts.ts @@ -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, + }, + }) + ); + }, }; }; diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx index b7e16ab64e..dec140148b 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx @@ -248,6 +248,8 @@ const MembersPage = (props: IMembersPageProps): JSX.Element => { onQueryChange={onQueryChange} inputPlaceHolder={"Search"} emptyComponent={EmptyMembers} + showMarkAllPages={false} + isAllPagesSelected={false} /> {showAddMemberModal ? ( { onQueryChange={onQueryChange} resultsTitle={"teams"} emptyComponent={EmptyTeams} + showMarkAllPages={false} + isAllPagesSelected={false} /> {showCreateTeamModal ? ( {renderCreateUserModal()} diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx index caa5bae210..835ef57320 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx @@ -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} /> ); }; diff --git a/frontend/redux/nodes/entities/hosts/actions.ts b/frontend/redux/nodes/entities/hosts/actions.ts index f7839ea6c0..eb4426d8ac 100644 --- a/frontend/redux/nodes/entities/hosts/actions.ts +++ b/frontend/redux/nodes/entities/hosts/actions.ts @@ -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, diff --git a/frontend/typings/react-table-config.d.ts b/frontend/typings/react-table-config.d.ts new file mode 100644 index 0000000000..cb7d717323 --- /dev/null +++ b/frontend/typings/react-table-config.d.ts @@ -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 + > extends UseExpandedOptions, + UseFiltersOptions, + UseGlobalFiltersOptions, + UseGroupByOptions, + UsePaginationOptions, + UseResizeColumnsOptions, + UseRowSelectOptions, + UseRowStateOptions, + UseSortByOptions, + // 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 {} + + export interface Hooks< + D extends Record = Record + > extends UseExpandedHooks, + UseGroupByHooks, + UseRowSelectHooks, + UseSortByHooks {} + + export interface TableInstance< + D extends Record = Record + > extends UseColumnOrderInstanceProps, + UseExpandedInstanceProps, + UseFiltersInstanceProps, + UseGlobalFiltersInstanceProps, + UseGroupByInstanceProps, + UsePaginationInstanceProps, + UseRowSelectInstanceProps, + UseRowStateInstanceProps, + UseSortByInstanceProps {} + + export interface TableState< + D extends Record = Record + > extends UseColumnOrderState, + UseExpandedState, + UseFiltersState, + UseGlobalFiltersState, + UseGroupByState, + UsePaginationState, + UseResizeColumnsState, + UseRowSelectState, + UseRowStateState, + UseSortByState {} + + export interface ColumnInterface< + D extends Record = Record + > extends UseFiltersColumnOptions, + UseGlobalFiltersColumnOptions, + UseGroupByColumnOptions, + UseResizeColumnsColumnOptions, + UseSortByColumnOptions {} + + export interface ColumnInstance< + D extends Record = Record + > extends UseFiltersColumnProps, + UseGroupByColumnProps, + UseResizeColumnsColumnProps, + UseSortByColumnProps {} + + export interface Cell< + D extends Record = Record, + V = any + > extends UseGroupByCellProps, + UseRowStateCellProps {} + + export interface Row< + D extends Record = Record + > extends UseExpandedRowProps, + UseGroupByRowProps, + UseRowSelectRowProps, + UseRowStateRowProps {} +} diff --git a/frontend/utilities/hooks/useDeepEffect.ts b/frontend/utilities/hooks/useDeepEffect.ts new file mode 100644 index 0000000000..2e28b388cd --- /dev/null +++ b/frontend/utilities/hooks/useDeepEffect.ts @@ -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) => { + 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;