Extract reusable Include/Exclude TargetLabelSelector component (#47212)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #46583

Before implementing changes in the Save policy / Edit policy modal (see
[Figma](https://www.figma.com/design/0F1sw63SuYaKVWlcL7mnc6/-33441-Policies--Custom-targets-with-%22Include-any%22-and-%22Exclude-any%22?node-id=5303-5687&t=Fszpf83KhcZ7ViWh-0)),
I though of making the label selection a reusable component that we
could reuse both in Configuration Profiles and in Policies, so that we
don't repeat ourselves.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

I don't have MDM fully wired up but I checked the payloads were sent as
expected.



https://github.com/user-attachments/assets/b2c4898f-c69c-4c05-b76c-e89728842661



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a dropdown-based target selector and a tabbed Include/Exclude
label selector with Any/All mode.

* **Improvements**
* Unified target/label selection across packages, policies, queries, and
profiles.
* Better empty/loading/error states, selectable badges, help/subtitle
support, and cross-tab disabling of selected labels.

* **Tests**
* Added and updated test coverage for both dropdown and tabbed selector
behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Nico
2026-06-10 11:14:47 -03:00
committed by GitHub
parent 6fcd88ab2d
commit 8ba889f701
21 changed files with 1174 additions and 831 deletions
@@ -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(
<DropdownTargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
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 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(
<DropdownTargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customHelpText={<span>{HELP_TEXT}</span>}
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(
<DropdownTargetLabelSelector
selectedTargetType="All hosts"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
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 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(
<DropdownTargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{ "label 1": true, "label 2": false }}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
/>
);
// 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(
<DropdownTargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
title={TITLE}
/>
);
expect(screen.getByText(TITLE)).toBeVisible();
});
it("allows a custom title to be passed in", () => {
const TITLE = "Choose a target";
render(
<DropdownTargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
title={TITLE}
/>
);
expect(screen.getByText(TITLE)).toBeVisible();
});
it("suppresses the title when suppressTitle is true", () => {
render(
<DropdownTargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
suppressTitle
/>
);
expect(screen.queryByText("Target")).not.toBeInTheDocument();
});
it("allows a subtitle to be passed in", () => {
const SUBTITLE = "Select one of the following options";
render(
<DropdownTargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
subTitle={SUBTITLE}
/>
);
expect(screen.getByText(SUBTITLE)).toBeVisible();
});
});
@@ -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 (
<div className="form-field">
{title && <div className="form-field__label">{title}</div>}
{subTitle && <div className="form-field__subtitle">{subTitle}</div>}
<Radio
className={`${baseClass}__radio-input`}
label="All hosts"
id="all-hosts-target-radio-btn"
checked={selectedTarget === "All hosts"}
value="All hosts"
name="target-type"
onChange={onSelect}
disabled={disableOptions}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Custom"
id="custom-target-radio-btn"
checked={selectedTarget === "Custom"}
value="Custom"
name="target-type"
onChange={onSelect}
disabled={disableOptions}
/>
</div>
);
};
interface ILabelChooserProps {
isError: boolean;
isLoading: boolean;
labels: ILabelSummary[];
selectedLabels: Record<string, boolean>;
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 <Spinner centered={false} />;
}
if (isError) {
return <DataError />;
}
// Not using <EmptyState/> here as we want to include short string only
if (!labels.length) {
return (
<div className={`${baseClass}__no-labels`}>
<CustomLink url={PATHS.LABEL_NEW_DYNAMIC} text="Add label" /> to target
specific hosts.
</div>
);
}
return (
<div className={`${baseClass}__custom-label-chooser`}>
{!!customTargetOptions.length && (
<Dropdown
value={selectedCustomTarget}
options={customTargetOptions}
searchable={false}
onChange={onSelectCustomTarget}
disabled={disableOptions}
/>
)}
<div className={`${baseClass}__description`}>
{customTargetOptions.length
? getHelpText(selectedCustomTarget)
: customHelpText}
</div>
<div className={`${baseClass}__checkboxes`}>
{labels.map((label) => {
return (
<div className={`${baseClass}__label`} key={label.name}>
<Checkbox
className={`${baseClass}__checkbox`}
name={label.name}
value={!!selectedLabels[label.name]}
onChange={onSelectLabel}
parseTarget
disabled={disableOptions}
>
{label.name}
</Checkbox>
</div>
);
})}
</div>
</div>
);
};
interface IDropdownTargetLabelSelectorProps {
selectedTargetType: string;
selectedCustomTarget?: string;
customTargetOptions?: IDropdownOption[];
selectedLabels: Record<string, boolean>;
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 (
<div className={classNames}>
<TargetChooser
selectedTarget={selectedTargetType}
onSelect={onSelectTargetType}
disableOptions={disableOptions}
title={suppressTitle ? null : title}
subTitle={subTitle}
/>
{selectedTargetType === "Custom" && (
<LabelChooser
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={customTargetOptions}
isError={isErrorLabels}
isLoading={isLoadingLabels}
labels={labels || []}
selectedLabels={selectedLabels}
customHelpText={customHelpText}
dropdownHelpText={dropdownHelpText}
onSelectCustomTarget={onSelectCustomTarget}
onSelectLabel={onSelectLabel}
disableOptions={disableOptions}
/>
)}
</div>
);
};
export default DropdownTargetLabelSelector;
@@ -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(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
/>
);
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> = {}
): 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(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customHelpText={<span>{HELP_TEXT}</span>}
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<React.ComponentProps<typeof TargetLabelSelector>> = {}
) =>
render(
<TargetLabelSelector
selectedTargetType="Custom"
onSelectTargetType={noop}
labels={LABELS}
include={makeTab({ showModeToggle: true, mode: "any" })}
exclude={makeTab()}
emptyStateDescription="Add a label to target a group of hosts."
onAddLabel={noop}
{...props}
/>
);
// 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(
<TargetLabelSelector
selectedTargetType="All hosts"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
/>
);
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(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{ "label 1": true, "label 2": false }}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
/>
);
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(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
title={TITLE}
/>
);
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(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
title={TITLE}
/>
);
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(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
suppressTitle
/>
);
expect(screen.queryByText("Target")).not.toBeInTheDocument();
});
it("allows a subtitle to be passed in", () => {
const SUBTITLE = "Select one of the following options";
render(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
subTitle={SUBTITLE}
/>
);
expect(screen.getByText(SUBTITLE)).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Add label" }));
expect(onAddLabel).toHaveBeenCalledTimes(1);
});
});
@@ -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<string, boolean>) => {
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<string, boolean>
) => {
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<string, boolean>;
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 (
<div className="form-field">
{title && <div className="form-field__label">{title}</div>}
{subTitle && <div className="form-field__subtitle">{subTitle}</div>}
<Radio
className={`${baseClass}__radio-input`}
label="All hosts"
id="all-hosts-target-radio-btn"
checked={!disableOptions && selectedTarget === "All hosts"}
value="All hosts"
name="target-type"
onChange={onSelect}
disabled={disableOptions}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Custom"
id="custom-target-radio-btn"
checked={selectedTarget === "Custom"}
value="Custom"
name="target-type"
onChange={onSelect}
disabled={disableOptions}
/>
</div>
);
};
interface INoLabelsEmptyStateProps {
description: ReactNode;
onAddLabel: () => void;
}
interface ILabelChooserProps {
isError: boolean;
isLoading: boolean;
labels: ILabelSummary[];
const NoLabelsEmptyState = ({
description,
onAddLabel,
}: INoLabelsEmptyStateProps) => (
<div className={`${baseClass}__empty-state`}>
<span className={`${baseClass}__empty-state--title`}>No labels</span>
<span className={`${baseClass}__empty-state--description`}>
{description}
</span>
<Button onClick={onAddLabel}>Add label</Button>
</div>
);
interface ISelectedLabelBadgesProps {
selectedLabels: Record<string, boolean>;
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 <Spinner centered={false} />;
}: ISelectedLabelBadgesProps) => {
const selectedNames = listNamesFromSelectedLabels(selectedLabels);
if (!selectedNames.length) {
return null;
}
if (isError) {
return <DataError />;
}
// Not using <EmptyState/> here as we want to include short string only
if (!labels.length) {
return (
<div className={`${baseClass}__no-labels`}>
<CustomLink url={PATHS.LABEL_NEW_DYNAMIC} text="Add label" /> to target
specific hosts.
</div>
);
}
return (
<div className={`${baseClass}__custom-label-chooser`}>
{!!customTargetOptions.length && (
<Dropdown
value={selectedCustomTarget}
options={customTargetOptions}
searchable={false}
onChange={onSelectCustomTarget}
<div className={`${baseClass}__selected-badges`}>
{selectedNames.map((name) => (
<button
key={name}
type="button"
className={`${baseClass}__selected-badge`}
disabled={disableOptions}
/>
)}
<div className={`${baseClass}__description`}>
{customTargetOptions.length
? getHelpText(selectedCustomTarget)
: customHelpText}
</div>
<div className={`${baseClass}__checkboxes`}>
{labels.map((label) => {
return (
<div className={`${baseClass}__label`} key={label.name}>
<Checkbox
className={`${baseClass}__checkbox`}
name={label.name}
value={!!selectedLabels[label.name]}
onChange={onSelectLabel}
parseTarget
disabled={disableOptions}
>
{label.name}
</Checkbox>
</div>
);
})}
</div>
onClick={() => onSelectLabel({ name, value: false })}
>
<span>{name}</span>
<Icon name="close" size="small" color="ui-fleet-black-75" />
</button>
))}
</div>
);
};
interface ITargetLabelSelectorProps {
selectedTargetType: string;
selectedCustomTarget?: string;
customTargetOptions?: IDropdownOption[];
selectedLabels: Record<string, boolean>;
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<string, boolean>;
disabledLabels: Record<string, boolean>;
onSelectLabel: (arg: { name: string; value: boolean }) => void;
disableOptions: boolean;
}
const LabelCheckboxList = ({
labels,
selectedLabels,
disabledLabels,
onSelectLabel,
disableOptions,
}: ILabelCheckboxListProps) => (
<div className={`${baseClass}__checkboxes`}>
{labels.map((label) => (
<div className={`${baseClass}__label`} key={label.name}>
<Checkbox
className={`${baseClass}__checkbox`}
name={label.name}
value={!!selectedLabels[label.name]}
disabled={disableOptions || !!disabledLabels[label.name]}
onChange={onSelectLabel}
parseTarget
>
{label.name}
</Checkbox>
</div>
))}
</div>
);
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 (
<div className={`${baseClass}__mode-toggle`}>
<Radio
className={`${baseClass}__radio-input`}
label="Any"
id={`${modeName}-any-radio`}
checked={mode === "any"}
value="any"
name={modeName}
tooltip={anyTooltip}
disabled={disableOptions}
onChange={(val: string) => onSelectMode?.(val as LabelTargetMode)}
/>
<Radio
className={`${baseClass}__radio-input`}
label="All"
id={`${modeName}-all-radio`}
checked={mode === "all"}
value="all"
name={modeName}
tooltip={allTooltip}
disabled={disableOptions}
onChange={(val: string) => onSelectMode?.(val as LabelTargetMode)}
/>
</div>
);
};
interface ILabelTabContentProps {
tab: ILabelTabConfig;
filteredLabels: ILabelSummary[];
/** Labels selected in the other tab; disabled here to prevent overlap. */
disabledLabels: Record<string, boolean>;
onChangeSearch: (val: string) => void;
tabKey: LabelTabKey;
disableOptions: boolean;
}
const LabelTabContent = ({
tab,
filteredLabels,
disabledLabels,
onChangeSearch,
tabKey,
disableOptions,
}: ILabelTabContentProps) => (
<>
{tab.showModeToggle && (
<LabelModeToggle
mode={tab.mode ?? "any"}
onSelectMode={tab.onSelectMode}
anyTooltip={tab.anyTooltip}
allTooltip={tab.allTooltip}
tabKey={tabKey}
disableOptions={disableOptions}
/>
)}
<SearchField placeholder="Search labels" onChange={onChangeSearch} />
<SelectedLabelBadges
selectedLabels={tab.selectedLabels}
onSelectLabel={tab.onSelectLabel}
disableOptions={disableOptions}
/>
<LabelCheckboxList
labels={filteredLabels}
selectedLabels={tab.selectedLabels}
disabledLabels={disabledLabels}
onSelectLabel={tab.onSelectLabel}
disableOptions={disableOptions}
/>
</>
);
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 <Spinner centered={false} />;
}
if (isErrorLabels) {
return <DataError />;
}
const hasLabels = !!labels.length;
const filteredLabels = hasLabels
? labels.filter((l) =>
l.name.toLowerCase().includes(labelSearchQuery.toLowerCase())
)
: [];
const onSelectTab = (index: number) => {
setSelectedTabIndex(index);
setLabelSearchQuery("");
};
return (
<TabNav secondary>
<Tabs selectedIndex={selectedTabIndex} onSelect={onSelectTab}>
<TabList>
<Tab>
<TabText
showCheck={
listNamesFromSelectedLabels(include.selectedLabels).length > 0
}
>
Include
</TabText>
</Tab>
<Tab>
<TabText
showCheck={
listNamesFromSelectedLabels(exclude.selectedLabels).length > 0
}
>
Exclude
</TabText>
</Tab>
</TabList>
<TabPanel>
{hasLabels ? (
<LabelTabContent
tab={include}
filteredLabels={filteredLabels}
disabledLabels={exclude.selectedLabels}
onChangeSearch={setLabelSearchQuery}
tabKey="include"
disableOptions={disableOptions}
/>
) : (
<NoLabelsEmptyState
description={emptyStateDescription}
onAddLabel={onAddLabel}
/>
)}
</TabPanel>
<TabPanel>
{hasLabels ? (
<LabelTabContent
tab={exclude}
filteredLabels={filteredLabels}
disabledLabels={include.selectedLabels}
onChangeSearch={setLabelSearchQuery}
tabKey="exclude"
disableOptions={disableOptions}
/>
) : (
<NoLabelsEmptyState
description={emptyStateDescription}
onAddLabel={onAddLabel}
/>
)}
</TabPanel>
</Tabs>
</TabNav>
);
};
interface ITargetTypeChooserProps {
selectedTargetType: TargetType;
onSelectTargetType: (val: TargetType) => void;
disableOptions?: boolean;
}
const TargetTypeChooser = ({
selectedTargetType,
onSelectTargetType,
disableOptions = false,
}: ITargetTypeChooserProps) => (
<div className="form-field">
<Radio
className={`${baseClass}__radio-input`}
label="All hosts"
id="all-hosts-target-radio-btn"
checked={selectedTargetType === "All hosts"}
value="All hosts"
name="target-type"
onChange={(val: string) => onSelectTargetType(val as TargetType)}
disabled={disableOptions}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Custom"
id="custom-target-radio-btn"
checked={selectedTargetType === "Custom"}
value="Custom"
name="target-type"
onChange={(val: string) => onSelectTargetType(val as TargetType)}
disabled={disableOptions}
/>
</div>
);
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 (
<div className={classNames}>
<TargetChooser
selectedTarget={selectedTargetType}
onSelect={onSelectTargetType}
<TargetTypeChooser
selectedTargetType={selectedTargetType}
onSelectTargetType={onSelectTargetType}
disableOptions={disableOptions}
title={suppressTitle ? null : title}
subTitle={subTitle}
/>
{selectedTargetType === "Custom" && (
<LabelChooser
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={customTargetOptions}
isError={isErrorLabels}
isLoading={isLoadingLabels}
<CustomTargetTabs
labels={labels || []}
selectedLabels={selectedLabels}
customHelpText={customHelpText}
dropdownHelpText={dropdownHelpText}
onSelectCustomTarget={onSelectCustomTarget}
onSelectLabel={onSelectLabel}
include={include}
exclude={exclude}
emptyStateDescription={emptyStateDescription}
onAddLabel={onAddLabel}
isLoadingLabels={isLoadingLabels}
isErrorLabels={isErrorLabels}
disableOptions={disableOptions}
/>
)}
@@ -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;
}
}
}
@@ -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";
@@ -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<TargetType>(
"All hosts"
);
const [selectedLabelTabIndex, setSelectedLabelTabIndex] = useState(0);
const [selectedLabelIncludeMode, setSelectedLabelIncludeMode] = useState<
"any" | "all"
>("any");
const [
selectedLabelIncludeMode,
setSelectedLabelIncludeMode,
] = useState<LabelTargetMode>("any");
const [selectedIncludeLabels, setSelectedIncludeLabels] = useState<
Record<string, boolean>
>({});
const [selectedExcludeLabels, setSelectedExcludeLabels] = useState<
Record<string, boolean>
>({});
const [labelSearchQuery, setLabelSearchQuery] = useState("");
const fileRef = useRef<File | null>(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{" "}
<em>
<b>have any</b>
</em>{" "}
of these labels.
</>
),
allTooltip: (
<>
Profile will be applied to hosts that{" "}
<em>
<b>have all</b>
</em>{" "}
of these labels.
</>
),
};
const onSelectExcludeLabel = ({
name,
value,
}: {
name: string;
value: boolean;
}) => {
setSelectedExcludeLabels((prev) => ({ ...prev, [name]: value }));
};
const renderSelectedBadges = (
selected: Record<string, boolean>,
onChange: (arg: { name: string; value: boolean }) => void
) => {
const selectedNames = listNamesFromSelectedLabels(selected);
if (!selectedNames.length) return null;
return (
<div className={`${baseClass}__selected-badges`}>
{selectedNames.map((name) => (
<button
key={name}
className={`${baseClass}__selected-badge`}
onClick={() => onChange({ name, value: false })}
>
<span>{name}</span>
<Icon name="close" size="small" color="ui-fleet-black-75" />
</button>
))}
</div>
);
};
const renderLabelCheckboxes = (
filteredLabels: ILabelSummary[],
selected: Record<string, boolean>,
disabledLabels: Record<string, boolean>,
onChange: (arg: { name: string; value: boolean }) => void
) => (
<div className="target-label-selector__checkboxes">
{filteredLabels.map((label) => (
<div className="target-label-selector__label" key={label.name}>
<Checkbox
className="target-label-selector__checkbox"
name={label.name}
value={!!selected[label.name]}
disabled={!!disabledLabels[label.name]}
onChange={onChange}
parseTarget
>
{label.name}
</Checkbox>
</div>
))}
</div>
);
const renderCustomTarget = () => {
if (isFetchingLabels || isLoadingLabels) {
return <Spinner centered={false} />;
}
if (isErrorLabels) {
return <DataError />;
}
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 = () => (
<div className={`${baseClass}__no-labels`}>
<span className={`${baseClass}__no-labels--title`}>No labels</span>
<span className={`${baseClass}__no-labels--description`}>
Add a label to target your configuration profile.
</span>
<Button
onClick={() => {
window.location.href = PATHS.LABEL_NEW_DYNAMIC;
}}
>
Add label
</Button>
</div>
);
return (
<TabNav secondary>
<Tabs selectedIndex={selectedLabelTabIndex} onSelect={onSelectTab}>
<TabList>
<Tab>
<TabText
showCheck={
listNamesFromSelectedLabels(selectedIncludeLabels).length > 0
}
>
Include
</TabText>
</Tab>
<Tab>
<TabText
showCheck={
listNamesFromSelectedLabels(selectedExcludeLabels).length > 0
}
>
Exclude
</TabText>
</Tab>
</TabList>
<TabPanel>
{!hasLabels ? (
renderNoLabels()
) : (
<>
<Radio
className="target-label-selector__radio-input"
label="Any"
id="include-any-radio"
checked={selectedLabelIncludeMode === "any"}
value="any"
name="include-mode"
tooltip={
<>
Profile will be applied to hosts that{" "}
<em>
<b>have any</b>
</em>{" "}
of these labels.
</>
}
onChange={(val: string) =>
setSelectedLabelIncludeMode(val as "any" | "all")
}
/>
<Radio
className="target-label-selector__radio-input"
label="All"
id="include-all-radio"
checked={selectedLabelIncludeMode === "all"}
value="all"
name="include-mode"
tooltip={
<>
Profile will be applied to hosts that{" "}
<em>
<b>have all</b>
</em>{" "}
of these labels.
</>
}
onChange={(val: string) =>
setSelectedLabelIncludeMode(val as "any" | "all")
}
/>
<SearchField
placeholder="Search labels"
onChange={setLabelSearchQuery}
/>
{renderSelectedBadges(
selectedIncludeLabels,
onSelectIncludeLabel
)}
{renderLabelCheckboxes(
filteredLabels,
selectedIncludeLabels,
selectedExcludeLabels,
onSelectIncludeLabel
)}
</>
)}
</TabPanel>
<TabPanel>
{!hasLabels ? (
renderNoLabels()
) : (
<>
<SearchField
placeholder="Search labels"
onChange={setLabelSearchQuery}
/>
{renderSelectedBadges(
selectedExcludeLabels,
onSelectExcludeLabel
)}
{renderLabelCheckboxes(
filteredLabels,
selectedExcludeLabels,
selectedIncludeLabels,
onSelectExcludeLabel
)}
</>
)}
</TabPanel>
</Tabs>
</TabNav>
);
const excludeTab: ILabelTabConfig = {
selectedLabels: selectedExcludeLabels,
onSelectLabel: ({ name, value }) =>
setSelectedExcludeLabels((prev) => ({ ...prev, [name]: value })),
};
const hasSelectedLabels =
@@ -446,33 +261,21 @@ const AddProfileModal = ({
)}
</Card>
{isPremiumTier && (
<div className={`target-label-selector form ${baseClass}__target`}>
<div className="form-field">
<div className="form-field__label">Target</div>
<Radio
className="target-label-selector__radio-input"
label="All hosts"
id="all-hosts-target-radio-btn"
checked={selectedTargetType === "All hosts"}
value="All hosts"
name="target-type"
onChange={(val: string) =>
setSelectedTargetType(val as TargetType)
}
/>
<Radio
className="target-label-selector__radio-input"
label="Custom"
id="custom-target-radio-btn"
checked={selectedTargetType === "Custom"}
value="Custom"
name="target-type"
onChange={(val: string) =>
setSelectedTargetType(val as TargetType)
}
/>
</div>
{selectedTargetType === "Custom" && renderCustomTarget()}
<div className={`form-field ${baseClass}__target`}>
<div className="form-field__label">Target</div>
<TargetLabelSelector
selectedTargetType={selectedTargetType}
onSelectTargetType={setSelectedTargetType}
labels={labels || []}
include={includeTab}
exclude={excludeTab}
isLoadingLabels={isFetchingLabels}
isErrorLabels={isErrorLabels}
emptyStateDescription="Add a label to target your configuration profile."
onAddLabel={() => {
window.location.href = PATHS.LABEL_NEW_DYNAMIC;
}}
/>
</div>
)}
<div className={`${baseClass}__button-wrap`}>
@@ -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;
}
}
}
@@ -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({});
});
});
@@ -0,0 +1,35 @@
import { LabelTargetMode, TargetType } from "components/TargetLabelSelector";
import { listNamesFromSelectedLabels } from "services/entities/labels";
interface IGenerateCustomTargetLabelKeyArgs {
targetType: TargetType;
includeMode: LabelTargetMode;
includeLabels: Record<string, boolean>;
excludeLabels: Record<string, boolean>;
}
const generateCustomTargetLabelKey = ({
targetType,
includeMode,
includeLabels,
excludeLabels,
}: IGenerateCustomTargetLabelKeyArgs) => {
if (targetType !== "Custom") {
return {};
}
const result: Record<string, string[]> = {};
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;
@@ -1,32 +0,0 @@
export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
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<string, boolean>,
excludeLabels: Record<string, boolean>
) => {
if (target !== "Custom") {
return {};
}
const result: Record<string, string[]> = {};
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;
};
@@ -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<ILabelSummary[], Error>(
["custom_labels"],
() => labelsAPI.summary(teamId).then((res) => getCustomLabels(res.labels)),
@@ -276,7 +276,7 @@ const EditAutoUpdateConfigModal = ({
</div>
</Card>
<Card paddingSize="medium" borderRadiusSize="medium">
<TargetLabelSelector
<DropdownTargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
@@ -26,7 +26,7 @@ import {
getCustomTarget,
getTargetType,
} from "pages/SoftwarePage/helpers";
import TargetLabelSelector from "components/TargetLabelSelector";
import { DropdownTargetLabelSelector } from "components/TargetLabelSelector";
import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector";
import InfoBanner from "components/InfoBanner";
import CustomLink from "components/CustomLink";
@@ -428,7 +428,7 @@ const PackageForm = ({
);
const renderTargetLabelSelector = () => (
<TargetLabelSelector
<DropdownTargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
@@ -13,7 +13,7 @@ import Radio from "components/forms/fields/Radio";
import Button from "components/buttons/Button";
import FileDetails from "components/FileDetails";
import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector";
import TargetLabelSelector from "components/TargetLabelSelector";
import { DropdownTargetLabelSelector } from "components/TargetLabelSelector";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
@@ -275,7 +275,7 @@ const SoftwareVppForm = ({
}
teamId={teamId}
/>
<TargetLabelSelector
<DropdownTargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
@@ -49,7 +49,7 @@ import Icon from "components/Icon/Icon";
import InputField from "components/forms/fields/InputField";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import CustomLink from "components/CustomLink";
import TargetLabelSelector from "components/TargetLabelSelector";
import { DropdownTargetLabelSelector } from "components/TargetLabelSelector";
import labelsAPI, {
getCustomLabels,
@@ -759,7 +759,7 @@ const PolicyForm = ({
{renderResolution()}
{isEditMode && !isPatchPolicy && platformSelector.render()}
{isEditMode && isPremiumTier && !isPatchPolicy && (
<TargetLabelSelector
<DropdownTargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={customTargetOptions}
@@ -34,7 +34,7 @@ import Checkbox from "components/forms/fields/Checkbox";
import TooltipWrapper from "components/TooltipWrapper";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import TargetLabelSelector from "components/TargetLabelSelector";
import { DropdownTargetLabelSelector } from "components/TargetLabelSelector";
import Icon from "components/Icon";
import PolicyAutomationsFields, {
@@ -397,7 +397,7 @@ const SaveNewPolicyModal = ({
/>
{platformSelector.render()}
{isPremiumTier && (
<TargetLabelSelector
<DropdownTargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={customTargetOptions}
@@ -61,7 +61,7 @@ import Icon from "components/Icon/Icon";
import InputField from "components/forms/fields/InputField";
import LogDestinationIndicator from "components/LogDestinationIndicator";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import TargetLabelSelector from "components/TargetLabelSelector";
import { DropdownTargetLabelSelector } from "components/TargetLabelSelector";
import PageDescription from "components/PageDescription";
import {
@@ -736,7 +736,7 @@ const EditQueryForm = ({
</Checkbox>
{isExistingQuery && platformSelector.render()}
{isPremiumTier && (
<TargetLabelSelector
<DropdownTargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={customTargetOptions}
@@ -38,7 +38,7 @@ import Button from "components/buttons/Button";
import Modal from "components/Modal";
import RevealButton from "components/buttons/RevealButton";
import LogDestinationIndicator from "components/LogDestinationIndicator";
import TargetLabelSelector from "components/TargetLabelSelector";
import { DropdownTargetLabelSelector } from "components/TargetLabelSelector";
import labelsAPI, {
getCustomLabels,
ILabelsSummaryResponse,
@@ -314,7 +314,7 @@ const SaveNewQueryModal = ({
/>
{platformSelector.render()}
{isPremiumTier && (
<TargetLabelSelector
<DropdownTargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={customTargetOptions}
@@ -0,0 +1,17 @@
import { listNamesFromSelectedLabels } from "./labels";
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([]);
});
});
+9
View File
@@ -98,6 +98,15 @@ export const getCustomLabels = <T extends { label_type: string; name: string }>(
});
};
export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
return Object.entries(dict).reduce((acc, [labelName, isSelected]) => {
if (isSelected) {
acc.push(labelName);
}
return acc;
}, [] as string[]);
};
export default {
create: (formData: INewLabelFormData): Promise<ICreateLabelResponse> => {
const { LABELS } = endpoints;
+1 -1
View File
@@ -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";