<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #46583 Figma: https://www.figma.com/design/0F1sw63SuYaKVWlcL7mnc6/-33441-Policies--Custom-targets-with-%22Include-any%22-and-%22Exclude-any%22?node-id=5319-2300&t=Fszpf83KhcZ7ViWh-0 ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually Note: selecting `Exclude All` will fail since BE doesn't support it yet. Will be tackled as part of https://github.com/fleetdm/fleet/issues/46582. https://github.com/user-attachments/assets/12316264-b5b0-4588-b714-8f0aea11c604 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for excluding labels when targeting policy hosts, enabling more granular control over which hosts receive specific policy configurations. * **Refactor** * Streamlined label-targeting state management and UI components for improved performance and maintainability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
413 lines
11 KiB
TypeScript
413 lines
11 KiB
TypeScript
import React, { ReactNode, useState } from "react";
|
|
import classnames from "classnames";
|
|
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
|
|
|
|
import { ILabelSummary } from "interfaces/label";
|
|
import { listNamesFromSelectedLabels } from "services/entities/labels";
|
|
|
|
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 TabNav from "components/TabNav";
|
|
import TabText from "components/TabText";
|
|
|
|
const baseClass = "target-label-selector";
|
|
|
|
export type LabelTargetMode = "any" | "all";
|
|
export type TargetType = "All hosts" | "Custom";
|
|
|
|
export interface ILabelConfig {
|
|
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;
|
|
}
|
|
|
|
interface INoLabelsEmptyStateProps {
|
|
description: ReactNode;
|
|
onAddLabel: () => void;
|
|
}
|
|
|
|
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>;
|
|
onSelectLabel: (arg: { name: string; value: boolean }) => void;
|
|
disableOptions: boolean;
|
|
}
|
|
|
|
const SelectedLabelBadges = ({
|
|
selectedLabels,
|
|
onSelectLabel,
|
|
disableOptions,
|
|
}: ISelectedLabelBadgesProps) => {
|
|
const selectedNames = listNamesFromSelectedLabels(selectedLabels);
|
|
if (!selectedNames.length) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className={`${baseClass}__selected-badges`}>
|
|
{selectedNames.map((name) => (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
className={`${baseClass}__selected-badge`}
|
|
disabled={disableOptions}
|
|
onClick={() => onSelectLabel({ name, value: false })}
|
|
>
|
|
<span>{name}</span>
|
|
<Icon name="close" size="small" color="ui-fleet-black-75" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ILabelCheckboxListProps {
|
|
labels: ILabelSummary[];
|
|
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: ILabelConfig;
|
|
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: ILabelConfig;
|
|
exclude: ILabelConfig;
|
|
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[];
|
|
includeConfig: ILabelConfig;
|
|
excludeConfig: ILabelConfig;
|
|
emptyStateDescription: ReactNode;
|
|
onAddLabel: () => void;
|
|
isLoadingLabels?: boolean;
|
|
isErrorLabels?: boolean;
|
|
className?: string;
|
|
disableOptions?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
onSelectTargetType,
|
|
labels,
|
|
includeConfig,
|
|
excludeConfig,
|
|
emptyStateDescription,
|
|
onAddLabel,
|
|
isLoadingLabels = false,
|
|
isErrorLabels = false,
|
|
className,
|
|
disableOptions = false,
|
|
}: ITargetLabelSelectorProps) => {
|
|
const classNames = classnames(baseClass, className, "form");
|
|
|
|
return (
|
|
<div className={classNames}>
|
|
<TargetTypeChooser
|
|
selectedTargetType={selectedTargetType}
|
|
onSelectTargetType={onSelectTargetType}
|
|
disableOptions={disableOptions}
|
|
/>
|
|
{selectedTargetType === "Custom" && (
|
|
<CustomTargetTabs
|
|
labels={labels || []}
|
|
include={includeConfig}
|
|
exclude={excludeConfig}
|
|
emptyStateDescription={emptyStateDescription}
|
|
onAddLabel={onAddLabel}
|
|
isLoadingLabels={isLoadingLabels}
|
|
isErrorLabels={isErrorLabels}
|
|
disableOptions={disableOptions}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TargetLabelSelector;
|