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:
@@ -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";
|
||||
|
||||
+65
-262
@@ -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`}>
|
||||
|
||||
+5
-122
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-29
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
+35
@@ -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;
|
||||
-32
@@ -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;
|
||||
};
|
||||
+3
-3
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user