From 0dccfad03203c330742857ff65fe770e3e728795 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Tue, 10 Aug 2021 14:24:13 -0500 Subject: [PATCH] Add new feature: filter hosts by team (#1592) Add new dropdown on ManageHostsPage to filter hosts by team --- .../hosts/ManageHostsPage/ManageHostsPage.jsx | 256 ++++++++++++++++-- .../pages/hosts/ManageHostsPage/_styles.scss | 91 +++++++ frontend/services/entities/hosts.ts | 6 + 3 files changed, 326 insertions(+), 27 deletions(-) diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx index 3299cd88a0..4ffee58d2c 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx @@ -2,8 +2,10 @@ import React, { PureComponent } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; import { push } from "react-router-redux"; +import { find, isEmpty, reduce, trim, union } from "lodash"; import Button from "components/buttons/Button"; +import Dropdown from "components/forms/fields/Dropdown"; import configInterface from "interfaces/config"; import HostSidePanel from "components/side_panels/HostSidePanel"; import LabelForm from "components/forms/LabelForm"; @@ -25,12 +27,10 @@ import entityGetter, { memoizedGetEntity } from "redux/utilities/entityGetter"; import { getLabels } from "redux/nodes/components/ManageHostsPage/actions"; import PATHS from "router/paths"; import deepDifference from "utilities/deep_difference"; -import { find } from "lodash"; import hostClient from "services/entities/hosts"; import permissionUtils from "utilities/permissions"; -import Dropdown from "components/forms/fields/Dropdown"; import { defaultHiddenColumns, generateVisibleTableColumns, @@ -45,10 +45,11 @@ import EditColumnsIcon from "../../../../assets/images/icon-edit-columns-16x12@2 import PencilIcon from "../../../../assets/images/icon-pencil-14x14@2x.png"; import TrashIcon from "../../../../assets/images/icon-trash-14x14@2x.png"; +const baseClass = "manage-hosts"; + const NEW_LABEL_HASH = "#new_label"; const EDIT_LABEL_HASH = "#edit_label"; const ALL_HOSTS_LABEL = "all-hosts"; -const baseClass = "manage-hosts"; const LABEL_SLUG_PREFIX = "labels/"; const HOST_SELECT_STATUSES = [ @@ -95,9 +96,17 @@ export class ManageHostsPage extends PureComponent { }), labels: PropTypes.arrayOf(labelInterface), loadingLabels: PropTypes.bool.isRequired, + queryParams: PropTypes.objectOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + routeTemplate: PropTypes.string, + routeParams: PropTypes.objectOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), enrollSecret: enrollSecretInterface, selectedFilters: PropTypes.arrayOf(PropTypes.string), selectedLabel: labelInterface, + selectedTeam: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), selectedOsqueryTable: osqueryTableInterface, statusLabels: statusLabelsInterface, loadingHosts: PropTypes.bool, @@ -105,6 +114,7 @@ export class ManageHostsPage extends PureComponent { canAddNewLabels: PropTypes.bool, teams: PropTypes.arrayOf(teamInterface), isGlobalAdmin: PropTypes.bool, + isOnGlobalTeam: PropTypes.bool, isBasicTier: PropTypes.bool, currentUser: userInterface, }; @@ -138,21 +148,21 @@ export class ManageHostsPage extends PureComponent { searchQuery: "", hosts: [], isHostsLoading: false, + isTeamsLoading: true, }; } componentDidMount() { - const { dispatch } = this.props; + const { dispatch, isBasicTier } = this.props; dispatch(getLabels()); + if (isBasicTier) { + dispatch(teamActions.loadAll({})); + } } componentDidUpdate(prevProps) { - const { dispatch, isBasicTier, canAddNewHosts } = this.props; - if ( - isBasicTier !== prevProps.isBasicTier && - isBasicTier && - canAddNewHosts - ) { + const { dispatch, isBasicTier } = this.props; + if (isBasicTier !== prevProps.isBasicTier && isBasicTier) { dispatch(teamActions.loadAll({})); } } @@ -212,6 +222,7 @@ export class ManageHostsPage extends PureComponent { toggleAddHostModal(); }; + // The onChange method below is for the dropdown used in modals onChangeTeam = (team) => { const { dispatch } = this.props; dispatch(teamActions.getEnrollSecrets(team)); @@ -227,7 +238,7 @@ export class ManageHostsPage extends PureComponent { sortDirection, }) => { const { retrieveHosts } = this; - const { selectedFilters } = this.props; + const { selectedFilters, selectedTeam } = this.props; let sortBy = []; if (sortHeader !== "") { @@ -243,6 +254,7 @@ export class ManageHostsPage extends PureComponent { selectedLabels: selectedFilters, globalFilter: searchQuery, sortBy, + teamId: selectedTeam, }); }; @@ -371,6 +383,56 @@ export class ManageHostsPage extends PureComponent { this.setState({ isAllMatchingHostsSelected: false }); }; + getNextLocationUrl = ( + pathPrefix = "", + newRouteTemplate = "", + newRouteParams = {}, + newQueryParams = {} + ) => { + const routeTemplate = newRouteTemplate || this.props.routeTemplate || ""; + const urlRouteParams = Object.assign( + {}, + this.props.routeParams, + newRouteParams + ); + const urlQueryParams = Object.assign( + {}, + this.props.queryParams, + newQueryParams + ); + + let routeString = ""; + + if (!isEmpty(urlRouteParams)) { + routeString = reduce( + urlRouteParams, + (string, value, key) => { + return string.replace(`:${key}`, encodeURIComponent(value)); + }, + routeTemplate + ); + } + + let queryString = ""; + if (!isEmpty(urlQueryParams)) { + queryString = reduce( + urlQueryParams, + (arr, value, key) => { + key && arr.push(`${key}=${encodeURIComponent(value)}`); + return arr; + }, + [] + ).join("&"); + } + + const nextLocation = union( + trim(pathPrefix, "/").split("/"), + routeString.split("/") + ).join("/"); + + return queryString ? `/${nextLocation}?${queryString}` : `/${nextLocation}`; + }; + getLabelSelected = () => { const { selectedFilters } = this.props; return selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX)); @@ -381,6 +443,48 @@ export class ManageHostsPage extends PureComponent { return selectedFilters.find((f) => !f.includes(LABEL_SLUG_PREFIX)); }; + generateTeamFilterDropdownOptions = (teams) => { + const { currentUser, isOnGlobalTeam } = this.props; + + let currentUserTeams = []; + if (isOnGlobalTeam) { + currentUserTeams = teams; + } else if (currentUser && currentUser.teams) { + currentUserTeams = currentUser.teams; + } + + const allTeamsOption = [ + { + disabled: false, + label: "All teams", + value: 0, + }, + ]; + + const sortedCurrentUserTeamOptions = currentUserTeams + .map((team) => { + return { + disabled: false, + label: team.name, + value: team.id, + }; + }) + .sort((a, b) => { + const labelA = a.label.toUpperCase(); + const labelB = b.label.toUpperCase(); + if (labelA < labelB) { + return -1; + } + if (labelA > labelB) { + return 1; + } + + return 0; // values are equal + }); + + return allTeamsOption.concat(sortedCurrentUserTeamOptions); + }; + retrieveHosts = async (options) => { const { dispatch } = this.props; this.setState({ isHostsLoading: true }); @@ -407,12 +511,29 @@ export class ManageHostsPage extends PureComponent { ); }; - clearHostUpdates() { + isValidSelectedTeamId = (teamId) => { + const { currentUser, isOnGlobalTeam, teams } = this.props; + + let currentUserTeams = []; + if (isOnGlobalTeam) { + currentUserTeams = teams; + } else if (currentUser && currentUser.teams) { + currentUserTeams = currentUser.teams; + } + + const currentUserTeamIds = currentUserTeams.map((t) => t.id); + + teamId = parseInt(teamId, 10); + + return !isNaN(teamId) && teamId > 0 && currentUserTeamIds.includes(teamId); + }; + + clearHostUpdates = () => { if (this.timeout) { global.window.clearTimeout(this.timeout); this.timeout = null; } - } + }; toggleAddHostModal = () => { const { showAddHostModal } = this.state; @@ -441,12 +562,46 @@ export class ManageHostsPage extends PureComponent { } }; - handleLabelChange = ({ slug, type }) => { + // The handleChange method below is for the filter-by-team dropdown rather than the dropdown used in modals + handleChangeSelectedTeamFilter = (selectedTeam) => { const { dispatch, selectedFilters } = this.props; + const { searchQuery } = this.state; + const { getNextLocationUrl, isValidSelectedTeamId, retrieveHosts } = this; + const { MANAGE_HOSTS } = PATHS; + + let selectedTeamId = parseInt(selectedTeam, 10); + selectedTeamId = isValidSelectedTeamId(selectedTeamId) ? selectedTeamId : 0; + + let nextLocation = getNextLocationUrl( + MANAGE_HOSTS, + "", + {}, + { team_id: selectedTeamId } + ); + + if (!selectedTeamId) { + nextLocation = nextLocation.replace(`team_id=${selectedTeamId}`, ""); + } + + // TODO confirm that sort order, pagination work as expected + retrieveHosts({ + teamId: selectedTeam, + selectedLabels: selectedFilters, + globalFilter: searchQuery, + }); + dispatch(push(nextLocation)); + }; + + handleLabelChange = ({ slug, type }) => { + const { dispatch, selectedFilters, selectedTeam } = this.props; + const { isValidSelectedTeamId } = this; const { MANAGE_HOSTS } = PATHS; const isAllHosts = slug === ALL_HOSTS_LABEL; const newFilters = [...selectedFilters]; + let selectedTeamId = parseInt(selectedTeam, 10); + selectedTeamId = isValidSelectedTeamId(selectedTeamId) ? selectedTeamId : 0; + if (!isAllHosts) { // always remove "all-hosts" from the filters first because we don't want // something like ["label/8", "all-hosts"] @@ -468,9 +623,13 @@ export class ManageHostsPage extends PureComponent { } } - const nextLocation = isAllHosts + let nextLocation = isAllHosts ? MANAGE_HOSTS : `${MANAGE_HOSTS}/${newFilters.join("/")}`; + + if (selectedTeamId) { + nextLocation += `?team_id=${selectedTeamId}`; + } dispatch(push(nextLocation)); }; @@ -486,6 +645,37 @@ export class ManageHostsPage extends PureComponent { handleLabelChange(selected); }; + // TODO revisit UX for server errors for invalid team_id (e.g., team_id=0, team_id=null, team_id=foo, etc.) + renderTeamsFilterDropdown = () => { + const { isBasicTier, selectedTeam, teams } = this.props; + const { + generateTeamFilterDropdownOptions, + isValidSelectedTeamId, + handleChangeSelectedTeamFilter, + } = this; + const teamOptions = generateTeamFilterDropdownOptions(teams); + + let selectedTeamId = parseInt(selectedTeam, 10); + selectedTeamId = isValidSelectedTeamId(selectedTeamId) ? selectedTeamId : 0; + + return isBasicTier ? ( +