add UI to add the include any option for custom profile custom label target (#23390)

relates to #22575

Add the UI for allowing custom profiles to be uploaded to hosts that
have any of specified labels. I also spent some time cleaning up the
custom settings card and its components. I was able to remove a lot of
unused styles.


![image](https://github.com/user-attachments/assets/167a575c-4520-496b-8f73-ed390e079e44)


<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [ ] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez 2024-10-31 15:10:49 +00:00 committed by GitHub
parent 5331019735
commit ccbdf46119
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 158 additions and 250 deletions

View file

@ -0,0 +1,2 @@
- add UI for allowing users to install custom profiles on hosts that include any of the defined
labels

View file

@ -111,6 +111,7 @@ export interface IMdmProfile {
updated_at: string;
checksum: string | null; // null for windows profiles
labels_include_all?: IProfileLabel[];
labels_include_any?: IProfileLabel[];
labels_exclude_any?: IProfileLabel[];
}

View file

@ -20,7 +20,7 @@ import Pagination from "pages/ManageControlsPage/components/Pagination";
import UploadList from "../../../components/UploadList";
import AddProfileCard from "./components/ProfileUploader/components/AddProfileCard";
import AddProfileModal from "./components/ProfileUploader/components/AddProfileModal/AddProfileModal";
import AddProfileModal from "./components/ProfileUploader/components/AddProfileModal";
import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal";
import ProfileLabelsModal from "./components/ProfileLabelsModal/ProfileLabelsModal";
import ProfileListItem from "./components/ProfileListItem";
@ -136,7 +136,7 @@ const CustomSettings = ({
}
if (!profiles?.length) {
return null;
return <AddProfileCard setShowModal={setShowAddProfileModal} />;
}
return (
@ -144,11 +144,11 @@ const CustomSettings = ({
<UploadList
keyAttribute="profile_uuid"
listItems={profiles}
HeadingComponent={() =>
ProfileListHeading({
onClickAddProfile: () => setShowAddProfileModal(true),
})
}
HeadingComponent={() => (
<ProfileListHeading
onClickAddProfile={() => setShowAddProfileModal(true)}
/>
)}
ListItemComponent={({ listItem }) => (
<ProfileListItem
isPremium={!!isPremiumTier}
@ -184,13 +184,7 @@ const CustomSettings = ({
url="https://fleetdm.com/learn-more-about/custom-os-settings"
/>
</p>
{renderProfileList()}
{!isLoadingProfiles && !isErrorProfiles && !profiles?.length && (
<AddProfileCard
baseClass="add-profile"
setShowModal={setShowAddProfileModal}
/>
)}
<>{renderProfileList()}</>
{showAddProfileModal && (
<AddProfileModal
currentTeamId={currentTeamId}
@ -209,7 +203,6 @@ const CustomSettings = ({
)}
{isPremiumTier && hasLabels && (
<ProfileLabelsModal
baseClass={baseClass}
profile={profileLabelsModalData}
setModalData={setProfileLabelsModalData}
/>

View file

@ -1,69 +1,15 @@
.custom-settings {
.section-header {
margin: 0;
padding: 0 0 12px 0;
h2 {
padding-bottom: 0;
border-bottom: none;
margin: 0;
}
}
&__description {
font-size: $x-small;
margin: $pad-large 0;
}
&__profiles-header {
padding: $pad-medium $pad-large;
display: flex;
justify-content: space-between;
font-size: $x-small;
font-weight: $bold;
border-bottom: 1px solid $ui-fleet-black-10;
}
&__profile-list {
list-style: none;
padding: 0;
margin: 0;
}
&__pagination-controls {
display: flex;
justify-content: flex-end;
margin: $pad-large 0;
}
&__file-uploader {
margin-top: $pad-xxlarge;
}
&__labels-list {
border-radius: 6px;
border: 1px solid $ui-fleet-black-10;
&--label {
display: flex;
height: 41px;
padding: 0 $pad-large;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $ui-fleet-black-10;
.warning {
display: flex;
padding: 0;
gap: $pad-small;
}
&:last-of-type {
border-bottom: none;
}
}
}
.upload-list {
&__list {
.list-item__label-count {
@ -84,99 +30,4 @@
}
}
}
.add-profile {
&__card--content-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-medium;
padding: 28.5px 0;
}
&__profile-graphic {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-small;
&--message {
text-align: center;
line-height: 20px;
}
}
&__button-wrap {
display: flex;
justify-content: flex-end;
padding-top: $pad-medium;
}
&__target {
margin: $pad-large 0 $pad-small 0;
}
&__description {
margin: $pad-medium 0;
}
&__no-labels {
display: flex;
height: 187px;
flex-direction: column;
align-items: center;
gap: $pad-small;
justify-content: center;
span {
color: $ui-fleet-black-75;
}
}
&__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;
}
}
}
}

View file

@ -6,35 +6,7 @@ import InfoBanner from "components/InfoBanner";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
interface IModalDescriptionProps {
baseClass: string;
profileName: string;
targetType: "includeAll" | "excludeAny";
}
const ModalDescription = ({
baseClass,
profileName,
targetType,
}: IModalDescriptionProps) => {
const targetTypeText =
targetType === "includeAll" ? (
<>
have <b>all</b>
</>
) : (
<>
don&apos;t have <b>any</b>
</>
);
return (
<div className={`${baseClass}__description`}>
<b>{profileName}</b> profile only applies to hosts that {targetTypeText}{" "}
of these labels:
</div>
);
};
const baseClass = "profile-labels-modal";
const BrokenLabelWarning = () => (
<InfoBanner color="yellow">
@ -51,16 +23,10 @@ const BrokenLabelWarning = () => (
</InfoBanner>
);
const LabelsList = ({
baseClass,
labels,
}: {
baseClass: string;
labels: IProfileLabel[];
}) => (
<div className={`${baseClass}__labels-list`}>
const LabelsList = ({ labels }: { labels: IProfileLabel[] }) => (
<ul className={`${baseClass}__labels-list`}>
{labels.map((label) => (
<div key={label.name} className={`${baseClass}__labels-list--label`}>
<li key={label.name} className={`${baseClass}__labels-list--label`}>
{label.name}
{label.broken && (
<span className={`${baseClass}__labels-list--label warning`}>
@ -68,19 +34,17 @@ const LabelsList = ({
Label deleted
</span>
)}
</div>
</li>
))}
</div>
</ul>
);
interface IProfileLabelsModalProps {
baseClass: string;
profile: IMdmProfile | null;
setModalData: React.Dispatch<React.SetStateAction<IMdmProfile | null>>;
}
const ProfileLabelsModal = ({
baseClass,
profile,
setModalData,
}: IProfileLabelsModalProps) => {
@ -88,30 +52,53 @@ const ProfileLabelsModal = ({
return null;
}
const { name, labels_include_all, labels_exclude_any } = profile;
const labels = labels_include_all || labels_exclude_any;
const {
name,
labels_include_all,
labels_include_any,
labels_exclude_any,
} = profile;
const labels = labels_include_all || labels_include_any || labels_exclude_any;
if (!labels?.length) {
// caller ensures this never happens
return null;
}
const renderlabelDescription = () => {
let targetTypeText = <></>;
if (labels_include_all) {
targetTypeText = <b>have all</b>;
} else if (labels_include_any) {
targetTypeText = <b>have all</b>;
} else {
targetTypeText = <b>don&apos;t have any</b>;
}
return (
<p className={`${baseClass}__description`}>
<b>{name}</b> profile only applies to hosts that {targetTypeText} of
these labels:
</p>
);
};
return (
<Modal title="Custom target" onExit={() => setModalData(null)}>
<div className={`${baseClass}__modal-content-wrap`}>
<Modal
className={baseClass}
title="Custom target"
onExit={() => setModalData(null)}
>
<>
{labels.some((label) => label.broken) && <BrokenLabelWarning />}
<ModalDescription
baseClass={baseClass}
profileName={name}
targetType={labels_include_all ? "includeAll" : "excludeAny"}
/>
<LabelsList baseClass={baseClass} labels={labels} />
<>{renderlabelDescription()}</>
<LabelsList labels={labels} />
<div className="modal-cta-wrap">
<Button variant="brand" onClick={() => setModalData(null)}>
Done
</Button>
</div>
</div>
</>
</Modal>
);
};

View file

@ -0,0 +1,32 @@
.profile-labels-modal {
&__description {
font-size: $x-small;
margin: $pad-large 0 $pad-medium;
}
&__labels-list {
border-radius: 6px;
border: 1px solid $ui-fleet-black-10;
padding: 0;
margin: 0;
&--label {
display: flex;
height: 41px;
padding: 0 $pad-large;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $ui-fleet-black-10;
.warning {
display: flex;
padding: 0;
gap: $pad-small;
}
&:last-of-type {
border-bottom: none;
}
}
}
}

View file

@ -87,6 +87,7 @@ const ProfileListItem = ({
const {
created_at,
labels_include_all,
labels_include_any,
labels_exclude_any,
name,
platform,
@ -102,7 +103,7 @@ const ProfileListItem = ({
FileSaver.saveAs(file);
};
const labels = labels_include_all || labels_exclude_any;
const labels = labels_include_all || labels_include_any || labels_exclude_any;
const renderLabelInfo = () => {
if (!isPremium || labels === undefined || labels.length === 0) {

View file

@ -2,16 +2,16 @@ import React from "react";
import Card from "components/Card";
import Button from "components/buttons/Button";
import ProfileGraphic from "./AddProfileGraphic";
import ProfileGraphic from "../AddProfileGraphic";
const AddProfileCard = ({
baseClass,
setShowModal,
}: {
baseClass: string;
const baseClass = "add-profile-card";
interface IAddProfileCardProps {
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
}) => (
<Card color="gray" className={`${baseClass}__card`}>
}
const AddProfileCard = ({ setShowModal }: IAddProfileCardProps) => (
<Card color="gray" className={baseClass}>
<div className={`${baseClass}__card--content-wrap`}>
<ProfileGraphic baseClass={baseClass} showMessage />
<Button

View file

@ -0,0 +1,21 @@
.add-profile-card {
&__card--content-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-medium;
padding: 28.5px 0;
}
&__profile-graphic {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-small;
&--message {
text-align: center;
line-height: 20px;
}
}
}

View file

@ -32,6 +32,7 @@ import {
CUSTOM_TARGET_OPTIONS,
CustomTargetOption,
generateLabelKey,
getDescriptionText,
listNamesFromSelectedLabels,
} from "./helpers";
@ -152,18 +153,6 @@ const LabelChooser = ({
[setSelectedLabels]
);
const descriptionText =
customTargetOption === "labelsIncludeAll" ? (
<>
Profile will only be applied to hosts that have <b>all</b> these labels:
</>
) : (
<>
Profile will be applied to hosts that don&apos;t have <b>any</b> of
these labels:{" "}
</>
);
const renderLabels = () => {
if (isLoading) {
return <Spinner centered={false} />;
@ -206,7 +195,9 @@ const LabelChooser = ({
searchable={false}
onChange={onSelectCustomTargetOption}
/>
<div className={`${baseClass}__description`}>{descriptionText}</div>
<div className={`${baseClass}__description`}>
{getDescriptionText(customTargetOption)}
</div>
<div className={`${baseClass}__checkboxes`}>{renderLabels()}</div>
</div>
);

View file

@ -5,11 +5,22 @@ import { IDropdownOption } from "interfaces/dropdownOption";
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
{
value: "labelsIncludeAll",
label: "Include all ",
label: "Include all",
helpText: (
<>
Profile will only be applied to hosts that have <b>all</b> of these
labels{" "}
Profile will only be applied to hosts that <b>have all</b> of these
labels.
</>
),
disabled: false,
},
{
value: "labelsIncludeAny",
label: "Include any",
helpText: (
<>
Profile will only be applied to hosts that <b>have any</b> of these
labels.
</>
),
disabled: false,
@ -19,8 +30,8 @@ export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
label: "Exclude any",
helpText: (
<>
Profile will be applied to hosts that don&apos;t have <b>any</b> of
these labels{" "}
Profile will only be applied to hosts that <b>don&apos;t have any</b> of
these labels.
</>
),
disabled: false,
@ -36,7 +47,10 @@ export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
}, [] as string[]);
};
export type CustomTargetOption = "labelsIncludeAll" | "labelsExcludeAny";
export type CustomTargetOption =
| "labelsIncludeAll"
| "labelsIncldeAny"
| "labelsExcludeAny";
export const generateLabelKey = (
target: string,
@ -51,3 +65,8 @@ export const generateLabelKey = (
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
};
};
export const getDescriptionText = (value: string) => {
return CUSTOM_TARGET_OPTIONS.find((option) => option.value === value)
?.helpText;
};

View file

@ -49,6 +49,7 @@ export interface IUploadProfileApiParams {
file: File;
teamId?: number;
labelsIncludeAll?: string[];
labelsIncludeAny?: string[];
labelsExcludeAny?: string[];
}
@ -132,6 +133,7 @@ const mdmService = {
file,
teamId,
labelsIncludeAll,
labelsIncludeAny,
labelsExcludeAny,
}: IUploadProfileApiParams) => {
const { MDM_PROFILES } = endpoints;
@ -143,11 +145,18 @@ const mdmService = {
formData.append("team_id", teamId.toString());
}
if (labelsIncludeAll || labelsExcludeAny) {
const labels = labelsIncludeAll || labelsExcludeAny;
const labelKey = labelsIncludeAll
? "labels_include_all"
: "labels_exclude_any";
if (labelsIncludeAll || labelsIncludeAny || labelsExcludeAny) {
const labels = labelsIncludeAll || labelsIncludeAny || labelsExcludeAny;
let labelKey = "";
if (labelsIncludeAll) {
labelKey = "labels_include_all";
} else if (labelsIncludeAny) {
labelKey = "labels_include_any";
} else {
labelKey = "labels_exclude_any";
}
labels?.forEach((label) => {
formData.append(labelKey, label);
});