Add new feature: filter hosts by team (#1592)

Add new dropdown on ManageHostsPage to filter hosts by team
This commit is contained in:
gillespi314 2021-08-10 14:24:13 -05:00 committed by GitHub
parent 267b7343e1
commit 0dccfad032
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 326 additions and 27 deletions

View file

@ -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 ? (
<div>
<Dropdown
value={selectedTeamId}
placeholder={"All teams"}
className={`${baseClass}__team-dropdown`}
options={teamOptions}
searchable={false}
onChange={(newSelectedValue) =>
handleChangeSelectedTeamFilter(newSelectedValue)
}
/>
</div>
) : (
<h1>Hosts</h1>
);
};
renderEditColumnsModal = () => {
const { config, currentUser } = this.props;
const { showEditColumnsModal, hiddenColumns } = this.state;
@ -608,22 +798,16 @@ export class ManageHostsPage extends PureComponent {
};
renderHeader = () => {
const { renderHeaderLabelBlock } = this;
const { renderHeaderLabelBlock, renderTeamsFilterDropdown } = this;
const { isAddLabel, selectedLabel } = this.props;
if (!selectedLabel || isAddLabel) {
return false;
}
const { type } = selectedLabel;
const type = selectedLabel?.type;
return (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<h1 className={`${baseClass}__title`}>
<span>Hosts</span>
</h1>
{renderTeamsFilterDropdown()}
{type !== "all" &&
type !== "status" &&
selectedLabel &&
renderHeaderLabelBlock(selectedLabel)}
</div>
</div>
@ -833,7 +1017,12 @@ export class ManageHostsPage extends PureComponent {
}
}
const mapStateToProps = (state, { location, params }) => {
const mapStateToProps = (state, ownProps) => {
const { location, params, route, routeParams } = ownProps;
const locationPath = location.path;
const queryParams = location.query;
const routeTemplate = route && route.path ? route.path : "";
const { active_label: activeLabel, label_id: labelID } = params;
const selectedFilters = [];
@ -866,6 +1055,12 @@ const mapStateToProps = (state, { location, params }) => {
const { loading: loadingHosts } = state.entities.hosts;
const { loading: loadingTeams } = state.entities.teams;
const teams = memoizedGetEntity(state.entities.teams.data);
// If there is no team_id, set selectedTeam to 0 so dropdown defaults to "All teams"
const selectedTeam = location.query?.team_id || 0;
const currentUser = state.auth.user;
const canAddNewHosts =
permissionUtils.isGlobalAdmin(currentUser) ||
@ -875,11 +1070,15 @@ const mapStateToProps = (state, { location, params }) => {
permissionUtils.isGlobalAdmin(currentUser) ||
permissionUtils.isGlobalMaintainer(currentUser);
const isGlobalAdmin = permissionUtils.isGlobalAdmin(currentUser);
const isOnGlobalTeam = permissionUtils.isOnGlobalTeam(currentUser);
const isBasicTier = permissionUtils.isBasicTier(config);
const teams = memoizedGetEntity(state.entities.teams.data);
return {
selectedFilters,
locationPath,
queryParams,
routeParams,
routeTemplate,
isAddLabel,
isEditLabel,
labelErrors,
@ -895,8 +1094,11 @@ const mapStateToProps = (state, { location, params }) => {
canAddNewHosts,
canAddNewLabels,
isGlobalAdmin,
isOnGlobalTeam,
isBasicTier,
teams,
loadingTeams,
selectedTeam,
};
};

View file

@ -13,6 +13,10 @@
&__header {
display: flex;
align-items: center;
.form-field {
margin-bottom: 0;
}
}
&__text {
@ -111,6 +115,7 @@
.form-field--dropdown {
margin: 0;
}
&__status_dropdown {
width: 159px;
@ -123,4 +128,90 @@
}
}
}
&__team-dropdown {
border: 0 !important;
position: relative;
:hover {
cursor: pointer !important;
}
&.is-focused {
.Select-control {
border: 0 !important;
height: 32px;
}
}
.Select-menu-outer {
position: absolute;
width: 330px;
min-width: 125px;
left: -12px;
border-radius: 6px;
}
}
&__header {
.Select-control {
background-color: #fff;
border: 0 !important;
border-radius: none;
position: none;
width: max-content; // move select arrow
height: 20px;
&:hover {
box-shadow: none;
}
&:hover .Select-value-label {
color: $core-vibrant-blue !important;
}
.Select-arrow-zone {
padding-left: $pad-small;
.Select-arrow {
top: 1px !important;
margin-top: 0 !important;
}
}
.Select-multi-value-wrapper {
width: max-content; // move select arrow
height: 20px;
margin-bottom: $pad-xsmall;
.Select-input {
display: none !important;
}
.Select-value {
position: relative; // move select arrow
display: inline-block; // move select arrow
line-height: 28px;
padding: 0;
border: 0 !important;
background-color: #fff !important;
right: 0;
left: 0;
bottom: 0;
top: 0;
&.is-focused {
border: 0 !important;
}
:hover {
border: 0 !important;
}
.Select-value-label {
font-size: $large !important;
}
}
}
}
}
}

View file

@ -13,6 +13,7 @@ interface IHostLoadOptions {
selectedLabels: string[];
globalFilter: string;
sortBy: ISortOption[];
teamId: number;
}
export default {
@ -41,6 +42,7 @@ export default {
const selectedLabels = options?.selectedLabels || [];
const globalFilter = options?.globalFilter || "";
const sortBy = options?.sortBy || [];
const teamId = options?.teamId || null;
// TODO: add this query param logic to client class
const pagination = `page=${page}&per_page=${perPage}`;
@ -86,6 +88,10 @@ export default {
path = `${HOSTS}?${pagination}${searchQuery}${orderKeyParam}${orderDirection}`;
}
if (teamId) {
path += `&team_id=${teamId}`;
}
return sendRequest("GET", path);
},
transferToTeam: (teamId: number | null, hostIds: number[]) => {