Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fleet UI: Allow select target search for labels and teams #24798

Merged
merged 10 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/22448-searchable-query-targets
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fleet UI: Add searchable query targets and cleaner UI I for uses with many teams or labels
177 changes: 161 additions & 16 deletions frontend/components/LiveQuery/SelectTargets.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from "react";
import React, { useContext, useEffect, useState, useRef } from "react";
import { Row } from "react-table";
import { useQuery } from "react-query";
import { useDebouncedCallback } from "use-debounce";
Expand All @@ -23,6 +23,7 @@ import targetsAPI, {
} from "services/entities/targets";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import { formatSelectedTargetsForApi } from "utilities/helpers";
import { capitalize } from "lodash";
import permissions from "utilities/permissions";
import {
LABEL_DISPLAY_MAP,
Expand All @@ -35,6 +36,8 @@ import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
import SearchField from "components/forms/fields/SearchField";
import RevealButton from "components/buttons/RevealButton";
import { generateTableHeaders } from "./TargetsInput/TargetsInputHostsTableConfig";

interface ITargetPillSelectorProps {
Expand Down Expand Up @@ -80,6 +83,7 @@ interface ITargetsQueryKey {

const DEBOUNCE_DELAY = 500;
const STALE_TIME = 60000;
const SECTION_CHARACTER_LIMIT = 600;

const isLabel = (entity: ISelectTargetsEntity) => "label_type" in entity;
const isBuiltInLabel = (
Expand Down Expand Up @@ -107,6 +111,20 @@ const parseLabels = (list?: ILabelSummary[]) => {
return { allHosts, platforms, other };
};

/** Returns the index at which the sum of the names in the list exceed the maximum character length */
const getTruncatedEntityCount = (
list: ISelectLabel[] | ISelectTeam[],
maxLength: number
): number => {
let totalLength = 0;
let index = 0;
while (index < list.length && totalLength < maxLength) {
totalLength += list[index].name.length;
index += 1;
}
return index;
};

const TargetPillSelector = ({
entity,
isSelected,
Expand Down Expand Up @@ -152,10 +170,15 @@ const SelectTargets = ({
isLivePolicy,
isObserverCanRunQuery,
}: ISelectTargetsProps): JSX.Element => {
const isMountedRef = useRef(false);
const { isPremiumTier, isOnGlobalTeam, currentUser } = useContext(AppContext);

const [labels, setLabels] = useState<ILabelsByType | null>(null);
const [searchText, setSearchText] = useState("");
const [searchTextHosts, setSearchTextHosts] = useState("");
const [searchTextTeams, setSearchTextTeams] = useState("");
const [searchTextLabels, setSearchTextLabels] = useState("");
const [isTeamListExpanded, setIsTeamListExpanded] = useState(false);
const [isLabelsListExpanded, setIsLabelsListExpanded] = useState(false);
const [debouncedSearchText, setDebouncedSearchText] = useState("");
const [isDebouncing, setIsDebouncing] = useState(false);

Expand Down Expand Up @@ -253,6 +276,51 @@ const SelectTargets = ({
}
);

// Ensure that the team or label list is expanded on the first load only if a hidden entity is already selected
const shouldExpandList = (
RachelElysia marked this conversation as resolved.
Show resolved Hide resolved
targetedList: ISelectLabel[] | ISelectTeam[],
truncatedList: ISelectLabel[] | ISelectTeam[]
) => {
// Set used to improve lookup time
const truncatedIds = new Set(truncatedList.map((entity) => entity.id));

// Check if any entity targeted is not in truncated list shown
return targetedList.some((entity) => !truncatedIds.has(entity.id));
};

const expandListsOnInitialLoad = () => {
if (!isMountedRef.current && teams && labels) {
RachelElysia marked this conversation as resolved.
Show resolved Hide resolved
const truncatedLabels =
labels?.other?.slice(
0,
getTruncatedEntityCount(labels?.other, SECTION_CHARACTER_LIMIT)
) || [];
const truncatedTeams =
teams?.slice(
0,
getTruncatedEntityCount(teams, SECTION_CHARACTER_LIMIT)
) || [];

if (shouldExpandList(targetedLabels, truncatedLabels)) {
setIsLabelsListExpanded(true);
}

if (shouldExpandList(targetedTeams, truncatedTeams)) {
setIsTeamListExpanded(true);
}

isMountedRef.current = true;
}
};

useEffect(expandListsOnInitialLoad, [
targetedTeams,
targetedLabels,
labels,
teams,
isMountedRef,
]);

RachelElysia marked this conversation as resolved.
Show resolved Hide resolved
useEffect(() => {
const selected = [...targetedHosts, ...targetedLabels, ...targetedTeams];
setSelectedTargets(selected);
Expand All @@ -264,8 +332,8 @@ const SelectTargets = ({

useEffect(() => {
setIsDebouncing(true);
debounceSearch(searchText);
}, [searchText]);
debounceSearch(searchTextHosts);
}, [searchTextHosts]);

const handleClickCancel = () => {
goToQueryEditor();
Expand Down Expand Up @@ -313,7 +381,7 @@ const SelectTargets = ({

const handleRowSelect = (row: Row<IHost>) => {
setTargetedHosts((prevHosts) => prevHosts.concat(row.original));
setSearchText("");
setSearchTextHosts("");

// If "all hosts" is already selected when using host target picker, deselect "all hosts"
if (targetedLabels.some((t) => isAllHosts(t))) {
Expand All @@ -333,15 +401,77 @@ const SelectTargets = ({
goToRunQuery();
};

const renderTargetEntityList = (
header: string,
const renderTargetEntitySection = (
entityType: string,
entityList: ISelectLabel[] | ISelectTeam[]
): JSX.Element => {
const isSearchEnabled = entityType === "teams" || entityType === "labels";
const searchTerm = (
(entityType === "teams" ? searchTextTeams : searchTextLabels) || ""
).toLowerCase();
const arrFixed = entityList as Array<typeof entityList[number]>;
const filteredEntities = isSearchEnabled
? arrFixed.filter((entity: ISelectLabel | ISelectTeam) => {
if (isSearchEnabled) {
return searchTerm
? entity.name.toLowerCase().includes(searchTerm)
: true;
}
return true;
})
: arrFixed;

const isListExpanded =
entityType === "teams" ? isTeamListExpanded : isLabelsListExpanded;
const truncatedEntities = filteredEntities.slice(
0,
getTruncatedEntityCount(filteredEntities, SECTION_CHARACTER_LIMIT)
);
const hiddenEntityCount =
filteredEntities.length - truncatedEntities.length;

const toggleExpansion = () => {
entityType === "teams"
? setIsTeamListExpanded(!isTeamListExpanded)
: setIsLabelsListExpanded(!isLabelsListExpanded);
};

const entitiesToDisplay = isListExpanded
? filteredEntities
: truncatedEntities;

const emptySearchString = `No matching ${entityType}.`;

const renderEmptySearchString = () => {
if (entitiesToDisplay.length === 0 && searchTerm !== "") {
return (
<div className={`${baseClass}__empty-entity-search`}>
{emptySearchString}
</div>
);
}
return undefined;
};

return (
<>
{header && <h3>{header}</h3>}
{entityType && <h3>{capitalize(entityType)}</h3>}
{isSearchEnabled && (
<>
<SearchField
placeholder={`Search ${entityType}`}
onChange={(searchString) => {
entityType === "teams"
? setSearchTextTeams(searchString)
: setSearchTextLabels(searchString);
}}
clearButton
/>
{renderEmptySearchString()}
</>
)}
<div className="selector-block">
{entityList?.map((entity: ISelectLabel | ISelectTeam) => {
{entitiesToDisplay?.map((entity: ISelectLabel | ISelectTeam) => {
const targetList = isLabel(entity) ? targetedLabels : targetedTeams;
return (
<TargetPillSelector
Expand All @@ -353,6 +483,17 @@ const SelectTargets = ({
);
})}
</div>
{hiddenEntityCount > 0 && (
<div className="expand-button-wrap">
<RevealButton
onClick={toggleExpansion}
caretPosition="after"
showText="Show more"
hideText="Show less"
isShowing={isListExpanded}
/>
</div>
)}
</>
);
};
Expand Down Expand Up @@ -456,34 +597,38 @@ const SelectTargets = ({
);
};

if (isLoadingLabels || isLoadingTeams) {
return <Spinner />;
}

return (
<div className={`${baseClass}__wrapper`}>
<h1>Select targets</h1>
<div className={`${baseClass}__target-selectors`}>
{!!labels?.allHosts.length &&
renderTargetEntityList("", labels.allHosts)}
renderTargetEntitySection("", labels.allHosts)}
{!!labels?.platforms?.length &&
renderTargetEntityList("Platforms", labels.platforms)}
renderTargetEntitySection("Platforms", labels.platforms)}
{!!teams?.length &&
(isOnGlobalTeam
? renderTargetEntityList("Teams", [
? renderTargetEntitySection("teams", [
{ id: 0, name: "No team" },
...teams,
])
: renderTargetEntityList("Teams", filterTeamObserverTeams()))}
: renderTargetEntitySection("teams", filterTeamObserverTeams()))}
{!!labels?.other?.length &&
renderTargetEntityList("Labels", labels.other)}
renderTargetEntitySection("labels", labels.other)}
</div>
<TargetsInput
autofocus
searchResultsTableConfig={resultsTableConfig}
selectedHostsTableConifg={selectedHostsTableConfig}
searchText={searchText}
searchText={searchTextHosts}
searchResults={searchResults || []}
isTargetsLoading={isFetchingSearchResults || isDebouncing}
targetedHosts={targetedHosts}
hasFetchError={!!errorSearchResults}
setSearchText={setSearchText}
setSearchText={setSearchTextHosts}
handleRowSelect={handleRowSelect}
disablePagination
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const TargetsInput = ({
emptyComponent={() => (
<div className="empty-search">
<div className="empty-search__inner">
<h4>No hosts match the current search criteria.</h4>
<h4>No matching hosts.</h4>
<p>
Expecting to see hosts? Try again in a few seconds as the
system catches up.
Expand Down
6 changes: 6 additions & 0 deletions frontend/components/LiveQuery/TargetsInput/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
box-shadow: 0px 4px 10px rgba(52, 59, 96, 0.15);
box-sizing: border-box;

&__inner {
display: flex;
flex-direction: column;
align-items: center;
}

h4 {
margin: 0;
margin-bottom: 16px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import classnames from "classnames";
import Icon from "components/Icon/Icon";
import FleetIcon from "components/icons/FleetIcon";
import TooltipWrapper from "components/TooltipWrapper";
import Button from "components/buttons/Button";
import InputField from "../InputField";

const baseClass = "input-icon-field";
Expand All @@ -20,6 +21,7 @@ class InputFieldWithIcon extends InputField {
name: PropTypes.string,
onChange: PropTypes.func,
onClick: PropTypes.func,
clearButton: PropTypes.func,
placeholder: PropTypes.string,
tabIndex: PropTypes.number,
type: PropTypes.string,
Expand Down Expand Up @@ -86,6 +88,8 @@ class InputFieldWithIcon extends InputField {
inputOptions,
ignore1Password,
onClick,
onChange,
clearButton,
} = this.props;
const { onInputChange, renderHelpText } = this;

Expand All @@ -111,6 +115,10 @@ class InputFieldWithIcon extends InputField {
{ [`${baseClass}__icon--active`]: value }
);

const handleClear = () => {
onChange("");
};

return (
<div className={wrapperClasses}>
{this.props.label && this.renderHeading()}
Expand All @@ -134,6 +142,15 @@ class InputFieldWithIcon extends InputField {
/>
{iconSvg && <Icon name={iconSvg} className={iconClasses} />}
{iconName && <FleetIcon name={iconName} className={iconClasses} />}
{clearButton && !!value && (
<Button
onClick={() => handleClear()}
variant="icon"
className={`${baseClass}__clear-button`}
>
<Icon name="close-filled" color="core-fleet-black" />
</Button>
)}
</div>
{renderHelpText()}
</div>
Expand Down
Loading
Loading