diff --git a/changes/22448-searchable-query-targets b/changes/22448-searchable-query-targets new file mode 100644 index 000000000000..5cbb33f42d34 --- /dev/null +++ b/changes/22448-searchable-query-targets @@ -0,0 +1 @@ +- Fleet UI: Add searchable query targets and cleaner UI I for uses with many teams or labels diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index 566a8e35da18..0e7b68053ddc 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -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"; @@ -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, @@ -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 { @@ -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 = ( @@ -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, @@ -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(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); @@ -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 = ( + 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) { + 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, + ]); + useEffect(() => { const selected = [...targetedHosts, ...targetedLabels, ...targetedTeams]; setSelectedTargets(selected); @@ -264,8 +332,8 @@ const SelectTargets = ({ useEffect(() => { setIsDebouncing(true); - debounceSearch(searchText); - }, [searchText]); + debounceSearch(searchTextHosts); + }, [searchTextHosts]); const handleClickCancel = () => { goToQueryEditor(); @@ -313,7 +381,7 @@ const SelectTargets = ({ const handleRowSelect = (row: Row) => { 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))) { @@ -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; + 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 ( +
+ {emptySearchString} +
+ ); + } + return undefined; + }; + return ( <> - {header &&

{header}

} + {entityType &&

{capitalize(entityType)}

} + {isSearchEnabled && ( + <> + { + entityType === "teams" + ? setSearchTextTeams(searchString) + : setSearchTextLabels(searchString); + }} + clearButton + /> + {renderEmptySearchString()} + + )}
- {entityList?.map((entity: ISelectLabel | ISelectTeam) => { + {entitiesToDisplay?.map((entity: ISelectLabel | ISelectTeam) => { const targetList = isLabel(entity) ? targetedLabels : targetedTeams; return ( + {hiddenEntityCount > 0 && ( +
+ +
+ )} ); }; @@ -456,34 +597,38 @@ const SelectTargets = ({ ); }; + if (isLoadingLabels || isLoadingTeams) { + return ; + } + return (

Select targets

{!!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)}
diff --git a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx index 65b21efcbe63..974031b139a7 100644 --- a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx +++ b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx @@ -108,7 +108,7 @@ const TargetsInput = ({ emptyComponent={() => (
-

No hosts match the current search criteria.

+

No matching hosts.

Expecting to see hosts? Try again in a few seconds as the system catches up. diff --git a/frontend/components/LiveQuery/TargetsInput/_styles.scss b/frontend/components/LiveQuery/TargetsInput/_styles.scss index 782ab932bec2..2124f1ce8f00 100644 --- a/frontend/components/LiveQuery/TargetsInput/_styles.scss +++ b/frontend/components/LiveQuery/TargetsInput/_styles.scss @@ -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; diff --git a/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx index 3b416ee3fda8..3d25f2239979 100644 --- a/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx +++ b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx @@ -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"; @@ -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, @@ -86,6 +88,8 @@ class InputFieldWithIcon extends InputField { inputOptions, ignore1Password, onClick, + onChange, + clearButton, } = this.props; const { onInputChange, renderHelpText } = this; @@ -111,6 +115,10 @@ class InputFieldWithIcon extends InputField { { [`${baseClass}__icon--active`]: value } ); + const handleClear = () => { + onChange(""); + }; + return (

{this.props.label && this.renderHeading()} @@ -134,6 +142,15 @@ class InputFieldWithIcon extends InputField { /> {iconSvg && } {iconName && } + {clearButton && !!value && ( + + )}
{renderHelpText()}
diff --git a/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.tests.tsx b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.tests.tsx new file mode 100644 index 000000000000..3a57042745a7 --- /dev/null +++ b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.tests.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +// @ts-ignore +import InputFieldWithIcon from "./InputFieldWithIcon"; + +describe("InputFieldWithIcon Component", () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders with label and placeholder", () => { + render( + + ); + + expect(screen.getByText(/test input/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/enter text/i)).toBeInTheDocument(); + }); + + test("calls onChange when input value changes", async () => { + render( + + ); + + // Change the input value + await userEvent.type( + screen.getByPlaceholderText(/enter text/i), + "New Value" + ); + + expect(mockOnChange).toHaveBeenCalledTimes(9); // 'New Value' has 9 characters + }); + + test("renders help text when provided", () => { + render( + + ); + + expect(screen.getByText(/this is a help text/i)).toBeInTheDocument(); + }); + + test("renders error message when provided", () => { + render( + + ); + + expect(screen.getByText(/this is an error message/i)).toBeInTheDocument(); + }); + + test("renders clear button when clearButton is true and input has value", () => { + render( + + ); + + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + test("clears input value when clear button is clicked", async () => { + render( + + ); + + // Click the clear button + await userEvent.click(screen.getByRole("button")); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith(""); + }); + + test("renders tooltip when provided", async () => { + render( + + ); + + await fireEvent.mouseEnter(screen.getByText(/test input/i)); + const tooltip = screen.getByText("This is a tooltip."); + expect(tooltip).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss b/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss index 13ff7bbb2a23..4ee9826bc662 100644 --- a/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss +++ b/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss @@ -150,4 +150,15 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } + + &__clear-button { + position: absolute; + right: 12px; + top: 0; + height: 40px; + width: 16px; + flex-wrap: wrap; + align-content: center; + z-index: 1; + } } diff --git a/frontend/components/forms/fields/SearchField/SearchField.tsx b/frontend/components/forms/fields/SearchField/SearchField.tsx index d487d8932412..13461a064395 100644 --- a/frontend/components/forms/fields/SearchField/SearchField.tsx +++ b/frontend/components/forms/fields/SearchField/SearchField.tsx @@ -9,6 +9,7 @@ export interface ISearchFieldProps { defaultValue?: string; onChange: (value: string) => void; onClick?: (e: React.MouseEvent) => void; + clearButton?: boolean; icon?: IconNames; } @@ -16,6 +17,7 @@ const SearchField = ({ placeholder, defaultValue = "", onChange, + clearButton, onClick, icon = "search", }: ISearchFieldProps): JSX.Element => { @@ -37,6 +39,7 @@ const SearchField = ({ value={searchQueryInput} onChange={onInputChange} onClick={onClick} + clearButton={clearButton} iconPosition="start" iconSvg={icon} /> diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx index d3780901c695..1786c571a296 100644 --- a/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx +++ b/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx @@ -4,9 +4,7 @@ import AceEditor from "react-ace"; import classnames from "classnames"; import { humanHostMemory } from "utilities/helpers"; -// @ts-ignore import FleetIcon from "components/icons/FleetIcon"; -// @ts-ignore import PlatformIcon from "components/icons/PlatformIcon"; import { ISelectHost, ISelectLabel, ISelectTeam } from "interfaces/target"; @@ -25,27 +23,6 @@ const TargetDetails = ({ className = "", handleBackToResults = noop, }: ITargetDetailsProps): JSX.Element => { - const onlineHosts = ( - labelBaseClass: string, - count: number, - online: number - ) => { - const offline = count - online; - const percentCount = ((count - offline) / count) * 100; - const percentOnline = parseFloat(percentCount.toFixed(2)); - - if (online > 0) { - return ( - - {" "} - ({percentOnline}% ONLINE) - - ); - } - - return false; - }; - const renderHost = (hostTarget: ISelectHost) => { const { display_text: displayText, @@ -142,12 +119,10 @@ const TargetDetails = ({ count, description, display_text: displayText, - label_type: labelType, - // online, query, } = labelTarget; + const labelBaseClass = "label-target"; - console.log("ERROR 1: labelTarget", labelTarget); return (