<!-- 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 -->
230 lines
6.4 KiB
TypeScript
230 lines
6.4 KiB
TypeScript
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;
|