fleet/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx
Scott Gress 04685db892
Auto software update frontend (#37677)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #35459

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [ ] Added/updated automated tests
working on these
- [X] QA'd all new/changed functionality manually

## Screenshots

| Option does not appear for FMA apps |
| --- |
| <img width="723" height="419" alt="image"
src="https://github.com/user-attachments/assets/f9f1328e-e38c-452c-b06e-337a69c13e71"
/> |

| Option does not appear for custom packages |
| --- |
| <img width="731" height="416" alt="image"
src="https://github.com/user-attachments/assets/3de78f15-d7ce-45c7-875f-a250fc00a160"
/> |

| Option does not appear for macOS VPP apps |
| --- |
| <img width="725" height="454" alt="image"
src="https://github.com/user-attachments/assets/07dcb074-f57d-4cc4-a746-20b80c821fb6"
/> |

| Option appears iOS VPP apps |
| --- |
| <img width="727" height="420" alt="image"
src="https://github.com/user-attachments/assets/ec4ce503-0300-437c-b3f2-248928fcfe7b"
/> |

| Option appears iPadOS VPP apps |
| --- |
| <img width="727" height="422" alt="image"
src="https://github.com/user-attachments/assets/0030c6cc-3d93-480c-af93-740fca4d5b57"
/> |

| Form with auto-updates disabled |
| --- |
| <img width="668" height="517" alt="image"
src="https://github.com/user-attachments/assets/d59a7ba4-dc83-4a80-ba94-0befc7635f05"
/> |

| Start / end time validation |
| --- |
| <img width="668" height="679" alt="image"
src="https://github.com/user-attachments/assets/939fd09a-76f6-42de-9c71-fe4982f3f84b"
/> |

| Maintenance window length validation |
| --- |
| <img width="664" height="681" alt="image"
src="https://github.com/user-attachments/assets/a2eab676-5166-42a9-9043-2565014e33cb"
/> |

| Badge and banner appears after saving |
| --- |
| <img width="766" height="529" alt="image"
src="https://github.com/user-attachments/assets/48d89e1d-4430-4dd7-b8e6-d5b04ebad47f"
/> |

---------

Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com>
Co-authored-by: Nico <32375741+nulmete@users.noreply.github.com>
2026-01-05 10:43:26 -06:00

240 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";
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,
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;
}
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 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,
}: 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 />;
}
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}__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
/>
<div className={`${baseClass}__label-name`}>{label.name}</div>
</div>
);
})}
</div>
</div>
);
};
interface ITargetLabelSelectorProps {
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;
}
const TargetLabelSelector = ({
selectedTargetType,
selectedCustomTarget,
customTargetOptions = [],
selectedLabels,
dropdownHelpText,
customHelpText,
className,
labels,
isLoadingLabels = false,
isErrorLabels = false,
onSelectTargetType,
onSelectCustomTarget,
onSelectLabel,
disableOptions = false,
title = "Target",
subTitle,
suppressTitle = false,
}: ITargetLabelSelectorProps) => {
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 TargetLabelSelector;