diff --git a/frontend/components/TargetLabelSelector/DropdownTargetLabelSelector.tests.tsx b/frontend/components/TargetLabelSelector/DropdownTargetLabelSelector.tests.tsx new file mode 100644 index 0000000000..9901bbcb98 --- /dev/null +++ b/frontend/components/TargetLabelSelector/DropdownTargetLabelSelector.tests.tsx @@ -0,0 +1,210 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { noop } from "lodash"; + +import DropdownTargetLabelSelector from "./DropdownTargetLabelSelector"; + +describe("DropdownTargetLabelSelector component", () => { + describe("renders the custom target selector when the target type is 'Custom'", () => { + it("with a dropdown when there are custom options to choose from", () => { + render( + + ); + + // custom target selector is rendering + expect(screen.getByRole("option", { name: "Include any" })).toBeVisible(); + + // lables are rendering + expect(screen.getByRole("checkbox", { name: "label 1" })).toBeVisible(); + expect(screen.getByRole("checkbox", { name: "label 2" })).toBeVisible(); + }); + + it("with an optional message and no dropdown when there are no custom options to choose from", () => { + const HELP_TEXT = "go boldly where no target has gone before"; + render( + {HELP_TEXT}} + selectedLabels={{}} + labels={[ + { id: 1, name: "label 1", label_type: "regular" }, + { id: 2, name: "label 2", label_type: "regular" }, + ]} + onSelectCustomTarget={noop} + onSelectLabel={noop} + onSelectTargetType={noop} + /> + ); + + // custom target help text is visible + expect(screen.getByText(HELP_TEXT)).toBeVisible(); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + + // lables are rendering + expect(screen.getByRole("checkbox", { name: "label 1" })).toBeVisible(); + expect(screen.getByRole("checkbox", { name: "label 2" })).toBeVisible(); + }); + }); + + it("does not render the custom target selector when the target type is 'All hosts'", () => { + render( + + ); + + // custom target selector is not rendering + expect(screen.queryByRole("option", { name: "Include any" })).toBeNull(); + + // lables are not rendering + expect(screen.queryByRole("checkbox", { name: "label 1" })).toBeNull(); + expect(screen.queryByRole("checkbox", { name: "label 2" })).toBeNull(); + }); + + it("renders selected labels as checked", () => { + render( + + ); + + // lables are rendering + expect(screen.getByRole("checkbox", { name: "label 1" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "label 2" })).not.toBeChecked(); + }); + + it("sets the title to Target by default", () => { + const TITLE = "Target"; + render( + + ); + + expect(screen.getByText(TITLE)).toBeVisible(); + }); + + it("allows a custom title to be passed in", () => { + const TITLE = "Choose a target"; + render( + + ); + + expect(screen.getByText(TITLE)).toBeVisible(); + }); + + it("suppresses the title when suppressTitle is true", () => { + render( + + ); + + expect(screen.queryByText("Target")).not.toBeInTheDocument(); + }); + + it("allows a subtitle to be passed in", () => { + const SUBTITLE = "Select one of the following options"; + render( + + ); + + expect(screen.getByText(SUBTITLE)).toBeVisible(); + }); +}); diff --git a/frontend/components/TargetLabelSelector/DropdownTargetLabelSelector.tsx b/frontend/components/TargetLabelSelector/DropdownTargetLabelSelector.tsx new file mode 100644 index 0000000000..c908fa6f3d --- /dev/null +++ b/frontend/components/TargetLabelSelector/DropdownTargetLabelSelector.tsx @@ -0,0 +1,229 @@ +import React, { ReactNode } from "react"; +import classnames from "classnames"; + +import PATHS from "router/paths"; +import { IDropdownOption } from "interfaces/dropdownOption"; +import { ILabelSummary } from "interfaces/label"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Radio from "components/forms/fields/Radio"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; +import Checkbox from "components/forms/fields/Checkbox"; +import CustomLink from "components/CustomLink"; + +const baseClass = "target-label-selector"; + +interface ITargetChooserProps { + selectedTarget: string; + onSelect: (val: string) => void; + disableOptions?: boolean; + title: string | null; + subTitle?: string; +} + +const TargetChooser = ({ + selectedTarget, + onSelect, + disableOptions = false, + title, + subTitle, +}: ITargetChooserProps) => { + return ( +
+ {title &&
{title}
} + {subTitle &&
{subTitle}
} + + +
+ ); +}; + +interface ILabelChooserProps { + isError: boolean; + isLoading: boolean; + labels: ILabelSummary[]; + selectedLabels: Record; + selectedCustomTarget?: string; + customTargetOptions?: IDropdownOption[]; + customHelpText?: ReactNode; + dropdownHelpText?: ReactNode; + onSelectCustomTarget?: (val: string) => void; + onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; + disableOptions: boolean; +} + +const LabelChooser = ({ + isError, + isLoading, + labels, + customHelpText, + dropdownHelpText, + selectedLabels, + selectedCustomTarget, + customTargetOptions = [], + onSelectCustomTarget, + onSelectLabel, + disableOptions, +}: ILabelChooserProps) => { + const getHelpText = (value?: string) => { + if (dropdownHelpText) return dropdownHelpText; + return customTargetOptions.find((option) => option.value === value) + ?.helpText; + }; + + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + // Not using here as we want to include short string only + if (!labels.length) { + return ( +
+ to target + specific hosts. +
+ ); + } + + return ( +
+ {!!customTargetOptions.length && ( + + )} +
+ {customTargetOptions.length + ? getHelpText(selectedCustomTarget) + : customHelpText} +
+
+ {labels.map((label) => { + return ( +
+ + {label.name} + +
+ ); + })} +
+
+ ); +}; + +interface IDropdownTargetLabelSelectorProps { + selectedTargetType: string; + selectedCustomTarget?: string; + customTargetOptions?: IDropdownOption[]; + selectedLabels: Record; + labels: ILabelSummary[]; + customHelpText?: ReactNode; + /** set this prop to show a help text. If it is included then it will override + * the selected options defined `helpText` + */ + dropdownHelpText?: ReactNode; + isLoadingLabels?: boolean; + isErrorLabels?: boolean; + className?: string; + onSelectTargetType: (val: string) => void; + onSelectCustomTarget?: (val: string) => void; + onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; + disableOptions?: boolean; + title?: string; + suppressTitle?: boolean; + subTitle?: string; +} + +/** + * DropdownTargetLabelSelector lets the user target "All hosts" or a "Custom" + * set of hosts, scoped by a single label mode chosen from a dropdown (e.g. + * include any / include all / exclude any). Used by reports, software, and the + * not-yet-migrated policy forms. For the tabbed include + exclude experience, + * use the sibling TargetLabelSelector. + */ +const DropdownTargetLabelSelector = ({ + selectedTargetType, + selectedCustomTarget, + customTargetOptions = [], + selectedLabels, + dropdownHelpText, + customHelpText, + className, + labels, + isLoadingLabels = false, + isErrorLabels = false, + onSelectTargetType, + onSelectCustomTarget, + onSelectLabel, + disableOptions = false, + title = "Target", + subTitle, + suppressTitle = false, +}: IDropdownTargetLabelSelectorProps) => { + const classNames = classnames(baseClass, className, "form"); + + return ( +
+ + {selectedTargetType === "Custom" && ( + + )} +
+ ); +}; + +export default DropdownTargetLabelSelector; diff --git a/frontend/components/TargetLabelSelector/TargetLabelSelector.tests.tsx b/frontend/components/TargetLabelSelector/TargetLabelSelector.tests.tsx index 4282d2b6f4..f40e46c876 100644 --- a/frontend/components/TargetLabelSelector/TargetLabelSelector.tests.tsx +++ b/frontend/components/TargetLabelSelector/TargetLabelSelector.tests.tsx @@ -1,210 +1,117 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { noop } from "lodash"; -import TargetLabelSelector from "./TargetLabelSelector"; +import { ILabelSummary } from "interfaces/label"; -describe("TargetLabelSelector component", () => { - describe("renders the custom target selector when the target type is 'Custom'", () => { - it("with a dropdown when there are custom options to choose from", () => { - render( - - ); +import TargetLabelSelector, { ILabelTabConfig } from "./TargetLabelSelector"; - // custom target selector is rendering - expect(screen.getByRole("option", { name: "Include any" })).toBeVisible(); +const LABELS: ILabelSummary[] = [ + { id: 1, name: "label 1", label_type: "regular" }, + { id: 2, name: "label 2", label_type: "regular" }, +]; - // lables are rendering - expect(screen.getByRole("checkbox", { name: "label 1" })).toBeVisible(); - expect(screen.getByRole("checkbox", { name: "label 2" })).toBeVisible(); - }); +const makeTab = ( + overrides: Partial = {} +): ILabelTabConfig => ({ + selectedLabels: {}, + onSelectLabel: noop, + ...overrides, +}); - it("with an optional message and no dropdown when there are no custom options to choose from", () => { - const HELP_TEXT = "go boldly where no target has gone before"; - render( - {HELP_TEXT}} - selectedLabels={{}} - labels={[ - { id: 1, name: "label 1", label_type: "regular" }, - { id: 2, name: "label 2", label_type: "regular" }, - ]} - onSelectCustomTarget={noop} - onSelectLabel={noop} - onSelectTargetType={noop} - /> - ); +const renderSelector = ( + props: Partial> = {} +) => + render( + + ); - // custom target help text is visible - expect(screen.getByText(HELP_TEXT)).toBeVisible(); +describe("TargetLabelSelector (tabbed) component", () => { + it("renders Include and Exclude tabs with labels in Custom mode", () => { + renderSelector(); - expect(screen.queryByRole("option")).not.toBeInTheDocument(); - - // lables are rendering - expect(screen.getByRole("checkbox", { name: "label 1" })).toBeVisible(); - expect(screen.getByRole("checkbox", { name: "label 2" })).toBeVisible(); - }); + expect(screen.getByText("Include")).toBeVisible(); + expect(screen.getByText("Exclude")).toBeVisible(); + expect(screen.getByRole("checkbox", { name: "label 1" })).toBeVisible(); + expect(screen.getByRole("checkbox", { name: "label 2" })).toBeVisible(); }); - it("does not render the custom target selector when the target type is 'All hosts'", () => { - render( - - ); + it("does not render the custom tabs when the target type is 'All hosts'", () => { + renderSelector({ selectedTargetType: "All hosts" }); - // custom target selector is not rendering - expect(screen.queryByRole("option", { name: "Include any" })).toBeNull(); + expect(screen.queryByText("Include")).not.toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { name: "label 1" }) + ).not.toBeInTheDocument(); + }); - // lables are not rendering - expect(screen.queryByRole("checkbox", { name: "label 1" })).toBeNull(); - expect(screen.queryByRole("checkbox", { name: "label 2" })).toBeNull(); + it("renders the Any/All mode toggle on the include tab", () => { + renderSelector(); + + expect(screen.getByRole("radio", { name: "Any" })).toBeVisible(); + expect(screen.getByRole("radio", { name: "All" })).toBeVisible(); + }); + + it("does not render the mode toggle on the exclude tab when showModeToggle is false", () => { + renderSelector({ exclude: makeTab({ showModeToggle: false }) }); + + fireEvent.click(screen.getByText("Exclude")); + + expect( + screen.queryByRole("radio", { name: "Any" }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("radio", { name: "All" }) + ).not.toBeInTheDocument(); }); it("renders selected labels as checked", () => { - render( - - ); + renderSelector({ + include: makeTab({ + showModeToggle: true, + mode: "any", + selectedLabels: { "label 1": true, "label 2": false }, + }), + }); - // lables are rendering expect(screen.getByRole("checkbox", { name: "label 1" })).toBeChecked(); expect(screen.getByRole("checkbox", { name: "label 2" })).not.toBeChecked(); }); - it("sets the title to Target by default", () => { - const TITLE = "Target"; - render( - - ); + it("disables a label in the include tab when it is selected in the exclude tab", () => { + renderSelector({ + exclude: makeTab({ selectedLabels: { "label 1": true } }), + }); - expect(screen.getByText(TITLE)).toBeVisible(); + expect(screen.getByRole("checkbox", { name: "label 1" })).toHaveAttribute( + "aria-disabled", + "true" + ); + expect(screen.getByRole("checkbox", { name: "label 2" })).toHaveAttribute( + "aria-disabled", + "false" + ); }); - it("allows a custom title to be passed in", () => { - const TITLE = "Choose a target"; - render( - - ); + it("renders the empty state and triggers onAddLabel when there are no labels", () => { + const onAddLabel = jest.fn(); + renderSelector({ labels: [], onAddLabel }); - expect(screen.getByText(TITLE)).toBeVisible(); - }); + expect(screen.getByText("No labels")).toBeVisible(); + expect( + screen.getByText("Add a label to target a group of hosts.") + ).toBeVisible(); - it("suppresses the title when suppressTitle is true", () => { - render( - - ); - - expect(screen.queryByText("Target")).not.toBeInTheDocument(); - }); - - it("allows a subtitle to be passed in", () => { - const SUBTITLE = "Select one of the following options"; - render( - - ); - - expect(screen.getByText(SUBTITLE)).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Add label" })); + expect(onAddLabel).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx index a988c8d6ae..3189fa278e 100644 --- a/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx +++ b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx @@ -1,240 +1,407 @@ -import React, { ReactNode } from "react"; +import React, { ReactNode, useState } from "react"; import classnames from "classnames"; +import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; -import PATHS from "router/paths"; -import { IDropdownOption } from "interfaces/dropdownOption"; import { ILabelSummary } from "interfaces/label"; +import { listNamesFromSelectedLabels } from "services/entities/labels"; -// @ts-ignore -import Dropdown from "components/forms/fields/Dropdown"; +import Button from "components/buttons/Button"; +import Checkbox from "components/forms/fields/Checkbox"; import Radio from "components/forms/fields/Radio"; import DataError from "components/DataError"; +import Icon from "components/Icon"; +import SearchField from "components/forms/fields/SearchField"; import Spinner from "components/Spinner"; -import Checkbox from "components/forms/fields/Checkbox"; -import CustomLink from "components/CustomLink"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; const baseClass = "target-label-selector"; -export const listNamesFromSelectedLabels = (dict: Record) => { - return Object.entries(dict).reduce((acc, [labelName, isSelected]) => { - if (isSelected) { - acc.push(labelName); - } - return acc; - }, [] as string[]); -}; +export type LabelTargetMode = "any" | "all"; +export type TargetType = "All hosts" | "Custom"; -export const generateLabelKey = ( - target: string, - customTargetOption: string, - selectedLabels: Record -) => { - if (target !== "Custom") { - return {}; - } - - return { - [customTargetOption]: listNamesFromSelectedLabels(selectedLabels), - }; -}; - -interface ITargetChooserProps { - selectedTarget: string; - onSelect: (val: string) => void; - disableOptions?: boolean; - title: string | null; - subTitle?: string; +export interface ILabelTabConfig { + selectedLabels: Record; + onSelectLabel: (arg: { name: string; value: boolean }) => void; + /** When true, shows an "Any"/"All" radio that switches this tab between its + * `_any` and `_all` label scope (e.g. labels_include_any vs + * labels_include_all). When false, the tab is fixed to its `_any` scope. */ + showModeToggle?: boolean; + mode?: LabelTargetMode; + onSelectMode?: (mode: LabelTargetMode) => void; + anyTooltip?: ReactNode; + allTooltip?: ReactNode; } -const TargetChooser = ({ - selectedTarget, - onSelect, - disableOptions = false, - title, - subTitle, -}: ITargetChooserProps) => { - return ( -
- {title &&
{title}
} - {subTitle &&
{subTitle}
} - - -
- ); -}; +interface INoLabelsEmptyStateProps { + description: ReactNode; + onAddLabel: () => void; +} -interface ILabelChooserProps { - isError: boolean; - isLoading: boolean; - labels: ILabelSummary[]; +const NoLabelsEmptyState = ({ + description, + onAddLabel, +}: INoLabelsEmptyStateProps) => ( +
+ No labels + + {description} + + +
+); + +interface ISelectedLabelBadgesProps { selectedLabels: Record; - selectedCustomTarget?: string; - customTargetOptions?: IDropdownOption[]; - customHelpText?: ReactNode; - dropdownHelpText?: ReactNode; - onSelectCustomTarget?: (val: string) => void; - onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; + onSelectLabel: (arg: { name: string; value: boolean }) => void; disableOptions: boolean; } -const LabelChooser = ({ - isError, - isLoading, - labels, - customHelpText, - dropdownHelpText, +const SelectedLabelBadges = ({ selectedLabels, - selectedCustomTarget, - customTargetOptions = [], - onSelectCustomTarget, onSelectLabel, disableOptions, -}: ILabelChooserProps) => { - const getHelpText = (value?: string) => { - if (dropdownHelpText) return dropdownHelpText; - return customTargetOptions.find((option) => option.value === value) - ?.helpText; - }; - - if (isLoading) { - return ; +}: ISelectedLabelBadgesProps) => { + const selectedNames = listNamesFromSelectedLabels(selectedLabels); + if (!selectedNames.length) { + return null; } - - if (isError) { - return ; - } - - // Not using here as we want to include short string only - if (!labels.length) { - return ( -
- to target - specific hosts. -
- ); - } - return ( -
- {!!customTargetOptions.length && ( - + {selectedNames.map((name) => ( + + ))}
); }; -interface ITargetLabelSelectorProps { - selectedTargetType: string; - selectedCustomTarget?: string; - customTargetOptions?: IDropdownOption[]; - selectedLabels: Record; +interface ILabelCheckboxListProps { labels: ILabelSummary[]; - customHelpText?: ReactNode; - /** set this prop to show a help text. If it is included then it will override - * the selected options defined `helpText` - */ - dropdownHelpText?: ReactNode; + selectedLabels: Record; + disabledLabels: Record; + onSelectLabel: (arg: { name: string; value: boolean }) => void; + disableOptions: boolean; +} + +const LabelCheckboxList = ({ + labels, + selectedLabels, + disabledLabels, + onSelectLabel, + disableOptions, +}: ILabelCheckboxListProps) => ( +
+ {labels.map((label) => ( +
+ + {label.name} + +
+ ))} +
+); + +type LabelTabKey = "include" | "exclude"; + +interface ILabelModeToggleProps { + mode: LabelTargetMode; + onSelectMode?: (mode: LabelTargetMode) => void; + anyTooltip?: ReactNode; + allTooltip?: ReactNode; + tabKey: LabelTabKey; + disableOptions: boolean; +} + +const LabelModeToggle = ({ + mode, + onSelectMode, + anyTooltip, + allTooltip, + tabKey, + disableOptions, +}: ILabelModeToggleProps) => { + // The radio group name is derived from the tab so the two tabs can never + // share a group. + const modeName = `${tabKey}-mode`; + return ( +
+ onSelectMode?.(val as LabelTargetMode)} + /> + onSelectMode?.(val as LabelTargetMode)} + /> +
+ ); +}; + +interface ILabelTabContentProps { + tab: ILabelTabConfig; + filteredLabels: ILabelSummary[]; + /** Labels selected in the other tab; disabled here to prevent overlap. */ + disabledLabels: Record; + onChangeSearch: (val: string) => void; + tabKey: LabelTabKey; + disableOptions: boolean; +} + +const LabelTabContent = ({ + tab, + filteredLabels, + disabledLabels, + onChangeSearch, + tabKey, + disableOptions, +}: ILabelTabContentProps) => ( + <> + {tab.showModeToggle && ( + + )} + + + + +); + +interface ICustomTargetTabsProps { + labels: ILabelSummary[]; + include: ILabelTabConfig; + exclude: ILabelTabConfig; + emptyStateDescription: ReactNode; + onAddLabel: () => void; + isLoadingLabels: boolean; + isErrorLabels: boolean; + disableOptions: boolean; +} + +const CustomTargetTabs = ({ + labels, + include, + exclude, + emptyStateDescription, + onAddLabel, + isLoadingLabels, + isErrorLabels, + disableOptions, +}: ICustomTargetTabsProps) => { + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [labelSearchQuery, setLabelSearchQuery] = useState(""); + + if (isLoadingLabels) { + return ; + } + if (isErrorLabels) { + return ; + } + + const hasLabels = !!labels.length; + const filteredLabels = hasLabels + ? labels.filter((l) => + l.name.toLowerCase().includes(labelSearchQuery.toLowerCase()) + ) + : []; + + const onSelectTab = (index: number) => { + setSelectedTabIndex(index); + setLabelSearchQuery(""); + }; + + return ( + + + + + 0 + } + > + Include + + + + 0 + } + > + Exclude + + + + + {hasLabels ? ( + + ) : ( + + )} + + + {hasLabels ? ( + + ) : ( + + )} + + + + ); +}; + +interface ITargetTypeChooserProps { + selectedTargetType: TargetType; + onSelectTargetType: (val: TargetType) => void; + disableOptions?: boolean; +} + +const TargetTypeChooser = ({ + selectedTargetType, + onSelectTargetType, + disableOptions = false, +}: ITargetTypeChooserProps) => ( +
+ onSelectTargetType(val as TargetType)} + disabled={disableOptions} + /> + onSelectTargetType(val as TargetType)} + disabled={disableOptions} + /> +
+); + +export interface ITargetLabelSelectorProps { + selectedTargetType: TargetType; + onSelectTargetType: (val: TargetType) => void; + labels: ILabelSummary[]; + include: ILabelTabConfig; + exclude: ILabelTabConfig; + emptyStateDescription: ReactNode; + onAddLabel: () => void; isLoadingLabels?: boolean; isErrorLabels?: boolean; className?: string; - onSelectTargetType: (val: string) => void; - onSelectCustomTarget?: (val: string) => void; - onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; disableOptions?: boolean; - title?: string; - suppressTitle?: boolean; - subTitle?: string; } +/** + * TargetLabelSelector lets the user target "All hosts" or a "Custom" set of + * hosts via a tabbed Include / Exclude experience: one include scope (any/all) + * may be combined with one exclude scope (any/all) at the same time. + */ const TargetLabelSelector = ({ selectedTargetType, - selectedCustomTarget, - customTargetOptions = [], - selectedLabels, - dropdownHelpText, - customHelpText, - className, + onSelectTargetType, labels, + include, + exclude, + emptyStateDescription, + onAddLabel, isLoadingLabels = false, isErrorLabels = false, - onSelectTargetType, - onSelectCustomTarget, - onSelectLabel, + className, disableOptions = false, - title = "Target", - subTitle, - suppressTitle = false, }: ITargetLabelSelectorProps) => { const classNames = classnames(baseClass, className, "form"); return (
- {selectedTargetType === "Custom" && ( - )} diff --git a/frontend/components/TargetLabelSelector/_styles.scss b/frontend/components/TargetLabelSelector/_styles.scss index 18fa18c366..c4500ac25b 100644 --- a/frontend/components/TargetLabelSelector/_styles.scss +++ b/frontend/components/TargetLabelSelector/_styles.scss @@ -52,4 +52,93 @@ &__label-name { padding-left: $pad-small; } + + // Tabbed include/exclude experience (TargetLabelSelector). + .react-tabs { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + // react-tabs unmounts inactive panels by default, so only the selected + // panel needs layout. + .react-tabs__tab-panel--selected { + display: flex; + flex-direction: column; + gap: $pad-medium; + margin-top: 0; + } + + // Truncate long label names in the tabbed checkbox list. + .react-tabs__tab-panel .fleet-checkbox { + height: 20px; + display: flex; + align-items: center; + + &__label { + max-width: 490px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__mode-toggle { + display: flex; + flex-direction: column; + gap: $pad-small; + } + + &__selected-badges { + display: flex; + flex-wrap: wrap; + gap: $pad-xsmall; + } + + &__selected-badge { + display: flex; + align-items: center; + gap: $pad-xxsmall; + padding: $pad-xxsmall $pad-small; + background-color: $ui-fleet-black-5; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius; + font-size: $xx-small; + cursor: pointer; + color: $ui-fleet-black-75; + + &:hover { + background-color: $ui-fleet-black-10; + border-color: $ui-fleet-black-25; + } + } + + &__empty-state { + display: flex; + height: 187px; + flex-direction: column; + align-items: center; + gap: $pad-small; + justify-content: center; + text-align: center; + + span { + color: $ui-fleet-black-75; + } + + &--title { + font-size: $small; + font-weight: $bold; + color: $core-fleet-black !important; + } + + &--description { + font-size: $xx-small; + margin-top: -$pad-xsmall; + } + + .button { + margin-top: $pad-small; + } + } } diff --git a/frontend/components/TargetLabelSelector/index.ts b/frontend/components/TargetLabelSelector/index.ts index ea5f05f17c..78764e9ded 100644 --- a/frontend/components/TargetLabelSelector/index.ts +++ b/frontend/components/TargetLabelSelector/index.ts @@ -1 +1,9 @@ -export { default } from "./TargetLabelSelector"; +export { default as TargetLabelSelector } from "./TargetLabelSelector"; +export type { + ILabelTabConfig, + ITargetLabelSelectorProps, +} from "./TargetLabelSelector"; + +export { default as DropdownTargetLabelSelector } from "./DropdownTargetLabelSelector"; + +export type { LabelTargetMode, TargetType } from "./TargetLabelSelector"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx index ad4c34ccb6..09159e2167 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useContext, useRef, useState } from "react"; import { useQuery } from "react-query"; import { AxiosResponse } from "axios"; -import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import PATHS from "router/paths"; import { NotificationContext } from "context/notification"; @@ -9,23 +8,24 @@ import { NotificationContext } from "context/notification"; import { IApiError } from "interfaces/errors"; import { ILabelSummary } from "interfaces/label"; -import labelsAPI, { getCustomLabels } from "services/entities/labels"; +import labelsAPI, { + getCustomLabels, + listNamesFromSelectedLabels, +} from "services/entities/labels"; import mdmAPI from "services/entities/mdm"; -import SearchField from "components/forms/fields/SearchField"; -// @ts-ignore import Button from "components/buttons/Button"; import Card from "components/Card"; -// @ts-ignore -import Checkbox from "components/forms/fields/Checkbox"; import DataError from "components/DataError"; import Icon from "components/Icon"; import Modal from "components/Modal"; -// @ts-ignore -import Radio from "components/forms/fields/Radio"; import Spinner from "components/Spinner"; -import TabNav from "components/TabNav"; -import TabText from "components/TabText"; +import { + TargetLabelSelector, + ILabelTabConfig, + LabelTargetMode, + TargetType, +} from "components/TargetLabelSelector"; import ProfileGraphic from "../ProfileGraphic"; import { @@ -34,12 +34,10 @@ import { IParseFileResult, parseFile, } from "../../helpers"; -import { generateLabelKey, listNamesFromSelectedLabels } from "./helpers"; +import generateCustomTargetLabelKey from "./helpers"; const baseClass = "add-profile-modal"; -type TargetType = "All hosts" | "Custom"; - interface IFileChooserProps { isLoading: boolean; onFileOpen: (files: FileList | null) => void; @@ -122,17 +120,16 @@ const AddProfileModal = ({ const [selectedTargetType, setSelectedTargetType] = useState( "All hosts" ); - const [selectedLabelTabIndex, setSelectedLabelTabIndex] = useState(0); - const [selectedLabelIncludeMode, setSelectedLabelIncludeMode] = useState< - "any" | "all" - >("any"); + const [ + selectedLabelIncludeMode, + setSelectedLabelIncludeMode, + ] = useState("any"); const [selectedIncludeLabels, setSelectedIncludeLabels] = useState< Record >({}); const [selectedExcludeLabels, setSelectedExcludeLabels] = useState< Record >({}); - const [labelSearchQuery, setLabelSearchQuery] = useState(""); const fileRef = useRef(null); @@ -160,7 +157,6 @@ const AddProfileModal = ({ setFileDetails(null); setSelectedIncludeLabels({}); setSelectedExcludeLabels({}); - setLabelSearchQuery(""); setShowModal(false); }, [fileRef, setShowModal]); @@ -173,12 +169,12 @@ const AddProfileModal = ({ setIsLoading(true); try { - const labelKey = generateLabelKey( - selectedTargetType, - selectedLabelIncludeMode, - selectedIncludeLabels, - selectedExcludeLabels - ); + const labelKey = generateCustomTargetLabelKey({ + targetType: selectedTargetType, + includeMode: selectedLabelIncludeMode, + includeLabels: selectedIncludeLabels, + excludeLabels: selectedExcludeLabels, + }); await mdmAPI.uploadProfile({ file, teamId: currentTeamId, @@ -214,218 +210,37 @@ const AddProfileModal = ({ } }; - const onSelectIncludeLabel = ({ - name, - value, - }: { - name: string; - value: boolean; - }) => { - setSelectedIncludeLabels((prev) => ({ ...prev, [name]: value })); + const includeTab: ILabelTabConfig = { + selectedLabels: selectedIncludeLabels, + onSelectLabel: ({ name, value }) => + setSelectedIncludeLabels((prev) => ({ ...prev, [name]: value })), + showModeToggle: true, + mode: selectedLabelIncludeMode, + onSelectMode: setSelectedLabelIncludeMode, + anyTooltip: ( + <> + Profile will be applied to hosts that{" "} + + have any + {" "} + of these labels. + + ), + allTooltip: ( + <> + Profile will be applied to hosts that{" "} + + have all + {" "} + of these labels. + + ), }; - const onSelectExcludeLabel = ({ - name, - value, - }: { - name: string; - value: boolean; - }) => { - setSelectedExcludeLabels((prev) => ({ ...prev, [name]: value })); - }; - - const renderSelectedBadges = ( - selected: Record, - onChange: (arg: { name: string; value: boolean }) => void - ) => { - const selectedNames = listNamesFromSelectedLabels(selected); - if (!selectedNames.length) return null; - return ( -
- {selectedNames.map((name) => ( - - ))} -
- ); - }; - - const renderLabelCheckboxes = ( - filteredLabels: ILabelSummary[], - selected: Record, - disabledLabels: Record, - onChange: (arg: { name: string; value: boolean }) => void - ) => ( -
- {filteredLabels.map((label) => ( -
- - {label.name} - -
- ))} -
- ); - - const renderCustomTarget = () => { - if (isFetchingLabels || isLoadingLabels) { - return ; - } - if (isErrorLabels) { - return ; - } - const hasLabels = !!labels?.length; - - const filteredLabels = hasLabels - ? (labels || []).filter((l) => - l.name.toLowerCase().includes(labelSearchQuery.toLowerCase()) - ) - : []; - - const onSelectTab = (index: number) => { - setSelectedLabelTabIndex(index); - setLabelSearchQuery(""); - }; - - const renderNoLabels = () => ( -
- No labels - - Add a label to target your configuration profile. - - -
- ); - - return ( - - - - - 0 - } - > - Include - - - - 0 - } - > - Exclude - - - - - {!hasLabels ? ( - renderNoLabels() - ) : ( - <> - - Profile will be applied to hosts that{" "} - - have any - {" "} - of these labels. - - } - onChange={(val: string) => - setSelectedLabelIncludeMode(val as "any" | "all") - } - /> - - Profile will be applied to hosts that{" "} - - have all - {" "} - of these labels. - - } - onChange={(val: string) => - setSelectedLabelIncludeMode(val as "any" | "all") - } - /> - - {renderSelectedBadges( - selectedIncludeLabels, - onSelectIncludeLabel - )} - {renderLabelCheckboxes( - filteredLabels, - selectedIncludeLabels, - selectedExcludeLabels, - onSelectIncludeLabel - )} - - )} - - - {!hasLabels ? ( - renderNoLabels() - ) : ( - <> - - {renderSelectedBadges( - selectedExcludeLabels, - onSelectExcludeLabel - )} - {renderLabelCheckboxes( - filteredLabels, - selectedExcludeLabels, - selectedIncludeLabels, - onSelectExcludeLabel - )} - - )} - - - - ); + const excludeTab: ILabelTabConfig = { + selectedLabels: selectedExcludeLabels, + onSelectLabel: ({ name, value }) => + setSelectedExcludeLabels((prev) => ({ ...prev, [name]: value })), }; const hasSelectedLabels = @@ -446,33 +261,21 @@ const AddProfileModal = ({ )} {isPremiumTier && ( -
-
-
Target
- - setSelectedTargetType(val as TargetType) - } - /> - - setSelectedTargetType(val as TargetType) - } - /> -
- {selectedTargetType === "Custom" && renderCustomTarget()} +
+
Target
+ { + window.location.href = PATHS.LABEL_NEW_DYNAMIC; + }} + />
)}
diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/_styles.scss index ab4a6a0b19..76fd241bf2 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/_styles.scss @@ -1,6 +1,8 @@ .add-profile-modal { &__modal-content-wrap { - margin-top: $pad-large; + display: flex; + flex-direction: column; + gap: $pad-medium; .add-profile__file { padding: $pad-medium $pad-large; @@ -64,8 +66,8 @@ } &--title { - color: $ui-fleet-black-75; - font-weight: $bold; + color: $ui-fleet-black-75; + font-weight: $bold; } } @@ -73,124 +75,5 @@ display: flex; justify-content: flex-end; gap: $pad-small; - padding-top: $pad-medium; - } - - &__target { - margin: $pad-large 0; - - .react-tabs__tab-panel { - display: flex; - flex-direction: column; - gap: $pad-medium; - padding-top: 0; - } - } - - &__custom-label-chooser { - margin-top: $pad-medium; - } - - &__description { - margin: $pad-medium 0; - } - - &__no-labels { - display: flex; - height: 187px; - flex-direction: column; - align-items: center; - gap: $pad-small; - justify-content: center; - text-align: center; - - span { - color: $ui-fleet-black-75; - } - - &--title { - font-size: $small; - font-weight: $bold; - color: $core-fleet-black !important; - } - - &--description { - font-size: $xx-small; - margin-top: -$pad-xsmall; - } - - .button { - margin-top: $pad-small; - } - } - - &__selected-badges { - display: flex; - flex-wrap: wrap; - gap: $pad-xsmall; - } - - &__selected-badge { - display: flex; - align-items: center; - gap: $pad-xxsmall; - padding: $pad-xxsmall $pad-small; - background-color: $ui-fleet-black-5; - border: 1px solid $ui-fleet-black-10; - border-radius: $border-radius; - font-size: $xx-small; - cursor: pointer; - color: $ui-fleet-black-75; - - &:hover { - background-color: $ui-fleet-black-10; - border-color: $ui-fleet-black-25; - } - } - - &__checkboxes { - display: flex; - max-height: 187px; - flex-direction: column; - border-radius: $border-radius; - border: 1px solid $ui-fleet-black-10; - overflow-y: auto; - - .loading-spinner { - margin: 69.5px auto; - } - } - - &__label { - width: 100%; - padding: $pad-small $pad-medium; - box-sizing: border-box; - display: flex; - align-items: center; - - &:not(:last-child) { - border-bottom: 1px solid $ui-fleet-black-10; - } - - .form-field--checkbox { - width: auto; - } - } - - &__label-name { - padding-left: $pad-large; - } - - .fleet-checkbox { - height: 20px; - display: flex; - align-items: center; - - &__label { - width: 490px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } } } diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.tests.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.tests.ts index e3454c8a92..a7f3bb9cf3 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.tests.ts +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.tests.ts @@ -1,51 +1,69 @@ -import { listNamesFromSelectedLabels, generateLabelKey } from "./helpers"; +import generateCustomTargetLabelKey from "./helpers"; -describe("listNamesFromSelectedLabels", () => { - it("returns names of selected labels", () => { - expect( - listNamesFromSelectedLabels({ foo: true, bar: false, baz: true }) - ).toEqual(["foo", "baz"]); - }); - - it("returns empty array when nothing is selected", () => { - expect(listNamesFromSelectedLabels({ foo: false, bar: false })).toEqual([]); - }); - - it("returns empty array for an empty dict", () => { - expect(listNamesFromSelectedLabels({})).toEqual([]); - }); -}); - -describe("generateLabelKey", () => { +describe("generateCustomTargetLabelKey", () => { it("returns empty object when target is not Custom", () => { - expect(generateLabelKey("All hosts", "any", { foo: true }, {})).toEqual({}); + expect( + generateCustomTargetLabelKey({ + targetType: "All hosts", + includeMode: "any", + includeLabels: { foo: true }, + excludeLabels: {}, + }) + ).toEqual({}); }); it("returns labelsIncludeAny when include mode is any", () => { expect( - generateLabelKey("Custom", "any", { foo: true, bar: true }, {}) + generateCustomTargetLabelKey({ + targetType: "Custom", + includeMode: "any", + includeLabels: { foo: true, bar: true }, + excludeLabels: {}, + }) ).toEqual({ labelsIncludeAny: ["foo", "bar"] }); }); it("returns labelsIncludeAll when include mode is all", () => { - expect(generateLabelKey("Custom", "all", { foo: true }, {})).toEqual({ - labelsIncludeAll: ["foo"], - }); + expect( + generateCustomTargetLabelKey({ + targetType: "Custom", + includeMode: "all", + includeLabels: { foo: true }, + excludeLabels: {}, + }) + ).toEqual({ labelsIncludeAll: ["foo"] }); }); it("returns labelsExcludeAny when exclude labels are selected", () => { - expect(generateLabelKey("Custom", "any", {}, { bar: true })).toEqual({ - labelsExcludeAny: ["bar"], - }); + expect( + generateCustomTargetLabelKey({ + targetType: "Custom", + includeMode: "any", + includeLabels: {}, + excludeLabels: { bar: true }, + }) + ).toEqual({ labelsExcludeAny: ["bar"] }); }); it("returns both include and exclude keys when both have selections", () => { expect( - generateLabelKey("Custom", "any", { foo: true }, { bar: true }) - ).toEqual({ labelsIncludeAny: ["foo"], labelsExcludeAny: ["bar"] }); + generateCustomTargetLabelKey({ + targetType: "Custom", + includeMode: "all", + includeLabels: { foo: true }, + excludeLabels: { bar: true }, + }) + ).toEqual({ labelsIncludeAll: ["foo"], labelsExcludeAny: ["bar"] }); }); it("omits keys for empty selections", () => { - expect(generateLabelKey("Custom", "all", { foo: false }, {})).toEqual({}); + expect( + generateCustomTargetLabelKey({ + targetType: "Custom", + includeMode: "all", + includeLabels: { foo: false }, + excludeLabels: {}, + }) + ).toEqual({}); }); }); diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.ts new file mode 100644 index 0000000000..dc3a6a0234 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.ts @@ -0,0 +1,35 @@ +import { LabelTargetMode, TargetType } from "components/TargetLabelSelector"; +import { listNamesFromSelectedLabels } from "services/entities/labels"; + +interface IGenerateCustomTargetLabelKeyArgs { + targetType: TargetType; + includeMode: LabelTargetMode; + includeLabels: Record; + excludeLabels: Record; +} + +const generateCustomTargetLabelKey = ({ + targetType, + includeMode, + includeLabels, + excludeLabels, +}: IGenerateCustomTargetLabelKeyArgs) => { + if (targetType !== "Custom") { + return {}; + } + + const result: Record = {}; + const includeNames = listNamesFromSelectedLabels(includeLabels); + const excludeNames = listNamesFromSelectedLabels(excludeLabels); + if (includeNames.length) { + result[ + includeMode === "all" ? "labelsIncludeAll" : "labelsIncludeAny" + ] = includeNames; + } + if (excludeNames.length) { + result.labelsExcludeAny = excludeNames; + } + return result; +}; + +export default generateCustomTargetLabelKey; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.tsx deleted file mode 100644 index cc57929bbc..0000000000 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/helpers.tsx +++ /dev/null @@ -1,32 +0,0 @@ -export const listNamesFromSelectedLabels = (dict: Record) => { - return Object.entries(dict).reduce((acc, [labelName, isSelected]) => { - if (isSelected) { - acc.push(labelName); - } - return acc; - }, [] as string[]); -}; - -export const generateLabelKey = ( - target: string, - includeMode: "any" | "all", - includeLabels: Record, - excludeLabels: Record -) => { - if (target !== "Custom") { - return {}; - } - - const result: Record = {}; - const includeNames = listNamesFromSelectedLabels(includeLabels); - const excludeNames = listNamesFromSelectedLabels(excludeLabels); - if (includeNames.length) { - result[ - includeMode === "all" ? "labelsIncludeAll" : "labelsIncludeAny" - ] = includeNames; - } - if (excludeNames.length) { - result.labelsExcludeAny = excludeNames; - } - return result; -}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx index 47fa464f93..db2f69ef87 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx @@ -15,7 +15,7 @@ import Card from "components/Card"; import Modal from "components/Modal"; import ModalFooter from "components/ModalFooter"; import Checkbox from "components/forms/fields/Checkbox"; -import TargetLabelSelector from "components/TargetLabelSelector"; +import { DropdownTargetLabelSelector } from "components/TargetLabelSelector"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import { @@ -84,7 +84,7 @@ const EditAutoUpdateConfigModal = ({ ), }); - // Fetch labels for TargetLabelSelector + // Fetch labels for DropdownTargetLabelSelector const { data: labels } = useQuery( ["custom_labels"], () => labelsAPI.summary(teamId).then((res) => getCustomLabels(res.labels)), @@ -276,7 +276,7 @@ const EditAutoUpdateConfigModal = ({
- ( - - {platformSelector.render()} {isPremiumTier && ( - {isExistingQuery && platformSelector.render()} {isPremiumTier && ( - {platformSelector.render()} {isPremiumTier && ( - { + it("returns names of selected labels", () => { + expect( + listNamesFromSelectedLabels({ foo: true, bar: false, baz: true }) + ).toEqual(["foo", "baz"]); + }); + + it("returns empty array when nothing is selected", () => { + expect(listNamesFromSelectedLabels({ foo: false, bar: false })).toEqual([]); + }); + + it("returns empty array for an empty dict", () => { + expect(listNamesFromSelectedLabels({})).toEqual([]); + }); +}); diff --git a/frontend/services/entities/labels.ts b/frontend/services/entities/labels.ts index e7d1416583..83805490ce 100644 --- a/frontend/services/entities/labels.ts +++ b/frontend/services/entities/labels.ts @@ -98,6 +98,15 @@ export const getCustomLabels = ( }); }; +export const listNamesFromSelectedLabels = (dict: Record) => { + return Object.entries(dict).reduce((acc, [labelName, isSelected]) => { + if (isSelected) { + acc.push(labelName); + } + return acc; + }, [] as string[]); +}; + export default { create: (formData: INewLabelFormData): Promise => { const { LABELS } = endpoints; diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 7eb462a00a..c618f0dbf9 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -35,7 +35,7 @@ import { ISoftwareVppFormData } from "pages/SoftwarePage/components/forms/Softwa import { ISoftwareAutoUpdateConfigFormData } from "pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal"; import { ISoftwareDisplayNameFormData } from "pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal"; import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage"; -import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector"; +import { listNamesFromSelectedLabels } from "services/entities/labels"; import { ISoftwareAndroidFormData } from "pages/SoftwarePage/components/forms/SoftwareAndroidForm/SoftwareAndroidForm"; import { ISoftwareConfigurationFormData } from "pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal";