mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
UI: Labels by IdP (#30368)
This commit is contained in:
parent
1227900d7b
commit
48ea14abbd
22 changed files with 700 additions and 260 deletions
1
changes/23899-IdP-labels
Normal file
1
changes/23899-IdP-labels
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Add support for IdP-based labels
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { ILabel } from "interfaces/label";
|
||||
import { IGetLabelResonse } from "services/entities/labels";
|
||||
import { IGetLabelResponse } from "services/entities/labels";
|
||||
|
||||
const DEFAULT_LABEL_MOCK: ILabel = {
|
||||
created_at: "2024-04-12T13:32:00Z",
|
||||
|
|
@ -14,18 +14,22 @@ const DEFAULT_LABEL_MOCK: ILabel = {
|
|||
display_text: "test macsss",
|
||||
count: 0,
|
||||
host_ids: null,
|
||||
criteria: {
|
||||
vital: "end_user_idp_department",
|
||||
value: " IT admins",
|
||||
},
|
||||
};
|
||||
|
||||
export const createMockLabel = (overrides?: Partial<ILabel>): ILabel => {
|
||||
return { ...DEFAULT_LABEL_MOCK, ...overrides };
|
||||
};
|
||||
|
||||
const DEFAULT_GET_LABEL_RESPONSE_MOCK: IGetLabelResonse = {
|
||||
const DEFAULT_GET_LABEL_RESPONSE_MOCK: IGetLabelResponse = {
|
||||
label: createMockLabel(),
|
||||
};
|
||||
|
||||
export const createMockGetLabelResponse = (
|
||||
overrides?: Partial<IGetLabelResonse>
|
||||
): IGetLabelResonse => {
|
||||
overrides?: Partial<IGetLabelResponse>
|
||||
): IGetLabelResponse => {
|
||||
return { ...DEFAULT_GET_LABEL_RESPONSE_MOCK, ...overrides };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@ import { capitalize } from "lodash";
|
|||
import permissions from "utilities/permissions";
|
||||
|
||||
import PageError from "components/DataError";
|
||||
import TargetsInput from "components/LiveQuery/TargetsInput";
|
||||
import TargetsInput from "components/TargetsInput";
|
||||
import { generateTableHeaders } from "components/TargetsInput/TargetsInputHostsTableConfig";
|
||||
import Button from "components/buttons/Button";
|
||||
import Spinner from "components/Spinner";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import SearchField from "components/forms/fields/SearchField";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import TargetPillSelector from "./TargetChipSelector";
|
||||
import { generateTableHeaders } from "./TargetsInput/TargetsInputHostsTableConfig";
|
||||
|
||||
interface ISelectTargetsProps {
|
||||
baseClass: string;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,28 @@ export default PropTypes.shape({
|
|||
});
|
||||
|
||||
export type LabelType = "regular" | "builtin";
|
||||
export type LabelMembershipType = "dynamic" | "manual";
|
||||
export type LabelMembershipType = "dynamic" | "manual" | "host_vitals";
|
||||
export type LabelHostVitalsCriterion =
|
||||
| "end_user_idp_group"
|
||||
| "end_user_idp_department"; // for now, may expand to be configurable
|
||||
|
||||
export type LabelLeafCriterion = {
|
||||
vital: LabelHostVitalsCriterion;
|
||||
value: string; // from user input
|
||||
};
|
||||
|
||||
type LabelAndCriterion = {
|
||||
and: LabelHostVitalsCriteria[];
|
||||
};
|
||||
|
||||
type LabelOrCriterion = {
|
||||
or: LabelHostVitalsCriteria[];
|
||||
};
|
||||
|
||||
export type LabelHostVitalsCriteria =
|
||||
| LabelLeafCriterion
|
||||
| LabelAndCriterion
|
||||
| LabelOrCriterion;
|
||||
|
||||
export interface ILabelSummary {
|
||||
id: number;
|
||||
|
|
@ -43,17 +64,25 @@ export interface ILabel extends ILabelSummary {
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
uuid?: string;
|
||||
query: string;
|
||||
label_membership_type: LabelMembershipType;
|
||||
host_count?: number; // returned for built-in labels but not custom labels
|
||||
display_text: string;
|
||||
count: number; // seems to be a repeat of hosts_count issue #1618
|
||||
host_ids: number[] | null;
|
||||
type?: "custom" | "platform" | "status" | "all";
|
||||
slug?: string; // e.g., "labels/13" | "online"
|
||||
target_type?: string; // e.g., "labels"
|
||||
platform: string;
|
||||
author_id?: number;
|
||||
|
||||
label_membership_type: LabelMembershipType;
|
||||
|
||||
// dynamic-specific
|
||||
query: string; // does return '""' for other types
|
||||
platform: string; // does return '""' for other types
|
||||
|
||||
// host_vitals-specific
|
||||
criteria: LabelHostVitalsCriteria | null;
|
||||
|
||||
// manual-specific
|
||||
host_ids: number[] | null;
|
||||
}
|
||||
|
||||
// corresponding to fleet>server>fleet>labels.go>LabelSpec
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ const FailedEndUserInfoCard = ({
|
|||
const IdentityProviders = () => {
|
||||
const { isPremiumTier } = useContext(AppContext);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
const { data: scimIdPDetails, isLoading, isError } = useQuery(
|
||||
["scim_details"],
|
||||
() => idpAPI.getSCIMDetails(),
|
||||
{
|
||||
|
|
@ -137,19 +137,21 @@ const IdentityProviders = () => {
|
|||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
if (!scimIdPDetails) return null;
|
||||
|
||||
if (data.last_request === null) {
|
||||
if (scimIdPDetails.last_request === null) {
|
||||
return <AddEndUserInfoCard />;
|
||||
} else if (data.last_request.status === "success") {
|
||||
} else if (scimIdPDetails.last_request.status === "success") {
|
||||
return (
|
||||
<ReceivedEndUserInfoCard receivedAt={data.last_request.requested_at} />
|
||||
<ReceivedEndUserInfoCard
|
||||
receivedAt={scimIdPDetails.last_request.requested_at}
|
||||
/>
|
||||
);
|
||||
} else if (data.last_request.status === "error") {
|
||||
} else if (scimIdPDetails.last_request.status === "error") {
|
||||
return (
|
||||
<FailedEndUserInfoCard
|
||||
receivedAt={data.last_request.requested_at}
|
||||
details={data.last_request.details}
|
||||
receivedAt={scimIdPDetails.last_request.requested_at}
|
||||
details={scimIdPDetails.last_request.details}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,12 @@ const HostsFilterBlock = ({
|
|||
|
||||
const renderLabelFilterPill = () => {
|
||||
if (selectedLabel) {
|
||||
const { description, display_text, label_type } = selectedLabel;
|
||||
const {
|
||||
description,
|
||||
display_text,
|
||||
label_type,
|
||||
label_membership_type,
|
||||
} = selectedLabel;
|
||||
const pillLabel =
|
||||
(isPlatformLabelNameFromAPI(display_text) &&
|
||||
PLATFORM_LABEL_DISPLAY_NAMES[display_text]) ||
|
||||
|
|
@ -196,13 +201,18 @@ const HostsFilterBlock = ({
|
|||
!isOnlyObserver &&
|
||||
(isOnGlobalTeam || currentUser?.id === selectedLabel.author_id) && (
|
||||
<>
|
||||
<Button
|
||||
className={`${baseClass}__action-btn`}
|
||||
onClick={onClickEditLabel}
|
||||
variant="icon"
|
||||
>
|
||||
<Icon name="pencil" size="small" />
|
||||
</Button>
|
||||
{
|
||||
// TODO - remove condition if/when can edit host_vitals labels
|
||||
label_membership_type !== "host_vitals" && (
|
||||
<Button
|
||||
className={`${baseClass}__action-btn`}
|
||||
onClick={onClickEditLabel}
|
||||
variant="icon"
|
||||
>
|
||||
<Icon name="pencil" size="small" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
className={`${baseClass}__action-btn`}
|
||||
onClick={onClickDeleteLabel}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { AxiosError } from "axios";
|
|||
import PATHS from "router/paths";
|
||||
import labelsAPI, {
|
||||
IGetHostsInLabelResponse,
|
||||
IGetLabelResonse,
|
||||
IGetLabelResponse,
|
||||
} from "services/entities/labels";
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
import { ILabel } from "interfaces/label";
|
||||
|
|
@ -42,12 +42,18 @@ const EditLabelPage = ({ routeParams, router }: IEditLabelPageProps) => {
|
|||
data: label,
|
||||
isLoading: isLoadingLabel,
|
||||
isError: isErrorLabel,
|
||||
} = useQuery<IGetLabelResonse, AxiosError, ILabel>(
|
||||
} = useQuery<IGetLabelResponse, AxiosError, ILabel>(
|
||||
["label", labelId],
|
||||
() => labelsAPI.getLabel(labelId),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
select: (data) => data.label,
|
||||
onSuccess: (data) => {
|
||||
// can't edit host_vitals labels yet
|
||||
if (data.label_membership_type === "host_vitals") {
|
||||
router.replace(PATHS.MANAGE_HOSTS_LABEL(data.id));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import React, { useContext, useCallback } from "react";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import labelsAPI from "services/entities/labels";
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
|
||||
|
||||
import DynamicLabelForm from "pages/labels/components/DynamicLabelForm";
|
||||
import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm";
|
||||
import { DUPLICATE_ENTRY_ERROR } from "../ManualLabel/ManualLabel";
|
||||
|
||||
const baseClass = "dynamic-label";
|
||||
|
||||
const DEFAULT_QUERY = "SELECT 1 FROM os_version WHERE major >= 13;";
|
||||
|
||||
type IDynamicLabelProps = RouteComponentProps<never, never> & {
|
||||
showOpenSidebarButton: boolean;
|
||||
onOpenSidebar: () => void;
|
||||
onOsqueryTableSelect: (tableName: string) => void;
|
||||
};
|
||||
|
||||
const DynamicLabel = ({
|
||||
showOpenSidebarButton,
|
||||
router,
|
||||
onOpenSidebar,
|
||||
onOsqueryTableSelect,
|
||||
}: IDynamicLabelProps) => {
|
||||
const { currentTeam } = useContext(AppContext);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const onSaveNewLabel = useCallback(
|
||||
(formData: IDynamicLabelFormData) => {
|
||||
labelsAPI
|
||||
.create(formData)
|
||||
.then((res) => {
|
||||
router.push(
|
||||
getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(res.label.id), {
|
||||
team_id:
|
||||
currentTeam?.id === APP_CONTEXT_ALL_TEAMS_ID
|
||||
? API_ALL_TEAMS_ID
|
||||
: currentTeam?.id,
|
||||
})
|
||||
);
|
||||
renderFlash("success", "Label added successfully.");
|
||||
})
|
||||
.catch((error: { data: IApiError }) => {
|
||||
renderFlash(
|
||||
"error",
|
||||
error.data.errors[0].reason.includes("Duplicate entry")
|
||||
? DUPLICATE_ENTRY_ERROR
|
||||
: "Couldn't add label. Please try again."
|
||||
);
|
||||
});
|
||||
},
|
||||
[renderFlash, router]
|
||||
);
|
||||
|
||||
const onCancelLabel = () => {
|
||||
router.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<DynamicLabelForm
|
||||
defaultQuery={DEFAULT_QUERY}
|
||||
showOpenSidebarButton={showOpenSidebarButton}
|
||||
onOpenSidebar={onOpenSidebar}
|
||||
onOsqueryTableSelect={onOsqueryTableSelect}
|
||||
onSave={onSaveNewLabel}
|
||||
onCancel={onCancelLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicLabel;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./DynamicLabel";
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import React, { useCallback, useContext } from "react";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import labelsAPI from "services/entities/labels";
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
|
||||
|
||||
import ManualLabelForm from "pages/labels/components/ManualLabelForm";
|
||||
import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm";
|
||||
|
||||
const baseClass = "manual-label";
|
||||
|
||||
export const DUPLICATE_ENTRY_ERROR =
|
||||
"Couldn't add. A label with this name already exists.";
|
||||
|
||||
type IManualLabelProps = RouteComponentProps<never, never>;
|
||||
|
||||
const ManualLabel = ({ router }: IManualLabelProps) => {
|
||||
const { currentTeam } = useContext(AppContext);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const onSaveNewLabel = useCallback(
|
||||
(formData: IManualLabelFormData) => {
|
||||
labelsAPI
|
||||
.create(formData)
|
||||
.then((res) => {
|
||||
router.push(
|
||||
getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(res.label.id), {
|
||||
team_id:
|
||||
currentTeam?.id === APP_CONTEXT_ALL_TEAMS_ID
|
||||
? API_ALL_TEAMS_ID
|
||||
: currentTeam?.id,
|
||||
})
|
||||
);
|
||||
renderFlash("success", "Label added successfully.");
|
||||
})
|
||||
.catch((error: { data: IApiError }) => {
|
||||
renderFlash(
|
||||
"error",
|
||||
error.data.errors[0].reason.includes("Duplicate entry")
|
||||
? DUPLICATE_ENTRY_ERROR
|
||||
: "Couldn't add label. Please try again."
|
||||
);
|
||||
});
|
||||
},
|
||||
[renderFlash, router]
|
||||
);
|
||||
|
||||
const onCancelLabel = () => {
|
||||
router.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<ManualLabelForm onSave={onSaveNewLabel} onCancel={onCancelLabel} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualLabel;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./ManualLabel";
|
||||
|
|
@ -1,63 +1,131 @@
|
|||
import React, { useCallback, useContext, useState } from "react";
|
||||
import { Tab, TabList, Tabs } from "react-tabs";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
|
||||
import { useQuery } from "react-query";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { IAceEditor } from "react-ace/lib/types";
|
||||
import { Row } from "react-table";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
|
||||
import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets";
|
||||
import idpAPI from "services/entities/idp";
|
||||
import labelsAPI from "services/entities/labels";
|
||||
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
// TODO - move this table config near here once expanded this logic to encompass editing and
|
||||
// therefore not longer needed anywhere else
|
||||
import { generateTableHeaders } from "pages/labels/components/ManualLabelForm/LabelHostTargetTableConfig";
|
||||
|
||||
// @ts-ignore
|
||||
import validateQuery from "components/forms/validators/validate_query";
|
||||
|
||||
import { QueryContext } from "context/query";
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import useToggleSidePanel from "hooks/useToggleSidePanel";
|
||||
|
||||
import MainContent from "components/MainContent";
|
||||
import SidePanelContent from "components/SidePanelContent";
|
||||
import TabNav from "components/TabNav";
|
||||
import TabText from "components/TabText";
|
||||
import QuerySidePanel from "components/side_panels/QuerySidePanel";
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import {
|
||||
LabelHostVitalsCriterion,
|
||||
LabelMembershipType,
|
||||
} from "interfaces/label";
|
||||
import { IHost } from "interfaces/host";
|
||||
import { IFormField } from "interfaces/form_field";
|
||||
import SQLEditor from "components/SQLEditor";
|
||||
import Icon from "components/Icon";
|
||||
import TargetsInput from "components/TargetsInput";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
|
||||
interface ILabelSubNavItem {
|
||||
name: string;
|
||||
pathname: string;
|
||||
}
|
||||
import PlatformField from "../components/PlatformField";
|
||||
|
||||
const labelSubNav: ILabelSubNavItem[] = [
|
||||
{
|
||||
name: "Dynamic",
|
||||
pathname: PATHS.LABEL_NEW_DYNAMIC,
|
||||
},
|
||||
{
|
||||
name: "Manual",
|
||||
pathname: PATHS.LABEL_NEW_MANUAL,
|
||||
},
|
||||
const availableCriteria: {
|
||||
label: string;
|
||||
value: LabelHostVitalsCriterion;
|
||||
}[] = [
|
||||
{ label: "Identity provider (IdP) group", value: "end_user_idp_group" },
|
||||
{ label: "IdP department", value: "end_user_idp_department" },
|
||||
];
|
||||
|
||||
const getTabIndex = (path: string): number => {
|
||||
return labelSubNav.findIndex((navItem) => {
|
||||
// tab stays highlighted for paths that start with same pathname
|
||||
return path.startsWith(navItem.pathname);
|
||||
});
|
||||
};
|
||||
|
||||
const baseClass = "new-label-page";
|
||||
|
||||
interface INewLabelPageProps extends RouteComponentProps<never, never> {
|
||||
children: JSX.Element;
|
||||
export const LABEL_TARGET_HOSTS_INPUT_LABEL = "Select hosts";
|
||||
const LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER =
|
||||
"Search name, hostname, or serial number";
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
|
||||
interface ITargetsQueryKey {
|
||||
scope: string;
|
||||
query?: string | null;
|
||||
excludedHostIds?: number[];
|
||||
}
|
||||
|
||||
const NewLabelPage = ({ router, location, children }: INewLabelPageProps) => {
|
||||
export interface INewLabelFormData {
|
||||
name: string;
|
||||
description: string; // optional
|
||||
type: LabelMembershipType;
|
||||
// dynamic
|
||||
labelQuery: string;
|
||||
platform: string;
|
||||
|
||||
// host vitals
|
||||
vital: LabelHostVitalsCriterion; // TODO - make use of recursive `LabelHostVitalsCriteria` type in future iterations to support logical combinations of different criteria
|
||||
vitalValue: string;
|
||||
|
||||
// manual
|
||||
targetedHosts: IHost[];
|
||||
}
|
||||
|
||||
interface INewLabelFormErrors {
|
||||
name?: string | null;
|
||||
labelQuery?: string | null;
|
||||
criteria?: string | null;
|
||||
}
|
||||
|
||||
const validate = (newData: INewLabelFormData) => {
|
||||
const errors: INewLabelFormErrors = {};
|
||||
const { name, type, labelQuery, vitalValue } = newData;
|
||||
if (!name) {
|
||||
errors.name = "Label name must be present";
|
||||
}
|
||||
if (type === "dynamic") {
|
||||
if (!labelQuery) {
|
||||
errors.labelQuery = "Query text must be present";
|
||||
}
|
||||
} else if (type === "host_vitals") {
|
||||
if (!vitalValue) {
|
||||
errors.criteria = "Label criteria must be completed";
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
const DEFAULT_DYNAMIC_QUERY = "SELECT 1 FROM os_version WHERE major >= 13;";
|
||||
|
||||
const NewLabelPage = ({
|
||||
router,
|
||||
location,
|
||||
}: RouteComponentProps<never, never>) => {
|
||||
// page-level state
|
||||
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
|
||||
QueryContext
|
||||
);
|
||||
const { isPremiumTier } = useContext(AppContext);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const { isSidePanelOpen, setSidePanelOpen } = useToggleSidePanel(true);
|
||||
const [showOpenSidebarButton, setShowOpenSidebarButton] = useState(false);
|
||||
|
||||
const isDynamicLabel = location.pathname.includes("dynamic");
|
||||
|
||||
const navigateToNav = useCallback(
|
||||
(i: number): void => {
|
||||
router.replace(labelSubNav[i].pathname);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// page-level handlers
|
||||
const onCloseSidebar = () => {
|
||||
setSidePanelOpen(false);
|
||||
setShowOpenSidebarButton(true);
|
||||
|
|
@ -72,37 +140,430 @@ const NewLabelPage = ({ router, location, children }: INewLabelPageProps) => {
|
|||
setSelectedOsqueryTable(tableName);
|
||||
};
|
||||
|
||||
// form state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [formData, setFormData] = useState<INewLabelFormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
type: "dynamic", // default type
|
||||
// dynamic-specific
|
||||
labelQuery: DEFAULT_DYNAMIC_QUERY,
|
||||
platform: "",
|
||||
// host_vitals-specific
|
||||
vital: "end_user_idp_group",
|
||||
vitalValue: "",
|
||||
// manual-specific
|
||||
targetedHosts: [],
|
||||
});
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
labelQuery,
|
||||
platform,
|
||||
vital,
|
||||
vitalValue,
|
||||
targetedHosts,
|
||||
} = formData;
|
||||
|
||||
const [formErrors, setFormErrors] = useState<INewLabelFormErrors>({});
|
||||
|
||||
const [targetsSearchQuery, setTargetsSearchQuery] = useState("");
|
||||
const [
|
||||
debouncedTargetsSearchQuery,
|
||||
setDebouncedTargetsSearchQuery,
|
||||
] = useState("");
|
||||
const [isDebouncingTargetsSearch, setIsDebouncingTargetsSearch] = useState(
|
||||
false
|
||||
);
|
||||
|
||||
// "manual" label target search logic
|
||||
const debounceSearch = useDebouncedCallback(
|
||||
(search: string) => {
|
||||
setDebouncedTargetsSearchQuery(search);
|
||||
setIsDebouncingTargetsSearch(false);
|
||||
},
|
||||
DEBOUNCE_DELAY,
|
||||
{ trailing: true }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDebouncingTargetsSearch(true);
|
||||
debounceSearch(targetsSearchQuery);
|
||||
}, [debounceSearch, targetsSearchQuery]);
|
||||
|
||||
const {
|
||||
data: targetsSearchResults,
|
||||
isLoading: isLoadingTargetsSearchResults,
|
||||
isError: isErrorTargetsSearchResults,
|
||||
} = useQuery<ITargetsSearchResponse, Error, IHost[], ITargetsQueryKey[]>(
|
||||
[
|
||||
{
|
||||
scope: "labels-targets-search",
|
||||
query: debouncedTargetsSearchQuery,
|
||||
excludedHostIds: targetedHosts.map((host) => host.id),
|
||||
},
|
||||
],
|
||||
({ queryKey }) => {
|
||||
const { query, excludedHostIds } = queryKey[0];
|
||||
return targetsAPI.search({
|
||||
query: query ?? "",
|
||||
excluded_host_ids: excludedHostIds ?? null,
|
||||
});
|
||||
},
|
||||
{
|
||||
select: (data) => data.hosts,
|
||||
enabled: type === "manual" && !!targetsSearchQuery,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: scimIdPDetails } = useQuery(
|
||||
["scim_details"],
|
||||
() => idpAPI.getSCIMDetails(),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
enabled: isPremiumTier,
|
||||
}
|
||||
);
|
||||
const idpConfigured = !!scimIdPDetails?.last_request?.requested_at;
|
||||
|
||||
let hostVitalsTooltipContent: React.ReactNode;
|
||||
if (!isPremiumTier) {
|
||||
hostVitalsTooltipContent = (
|
||||
<>
|
||||
Currently, host vitals labels are based on
|
||||
<br />
|
||||
identity provider (IdP) groups or departments.
|
||||
<br />
|
||||
IdP integration available in Fleet Premium.
|
||||
</>
|
||||
);
|
||||
} else if (!idpConfigured) {
|
||||
hostVitalsTooltipContent = (
|
||||
<>
|
||||
Currently, host vitals labels are based on
|
||||
<br />
|
||||
identity provider (IdP) groups or departments.
|
||||
<br />
|
||||
IdP has not been configured in integration settings.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname.includes("dynamic")) {
|
||||
router.replace(PATHS.NEW_LABEL);
|
||||
}
|
||||
if (location.pathname.includes("manual")) {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
type: "manual",
|
||||
}));
|
||||
|
||||
router.replace(PATHS.NEW_LABEL);
|
||||
}
|
||||
}, [location.pathname, router]);
|
||||
|
||||
// form handlers
|
||||
|
||||
const onInputChange = ({ name: fieldName, value }: IFormField) => {
|
||||
const newFormData = { ...formData, [fieldName]: value };
|
||||
setFormData(newFormData);
|
||||
const newErrs = validate(newFormData);
|
||||
// only set errors that are updates of existing errors
|
||||
// new errors are only set onBlur or submit
|
||||
const errsToSet: Record<string, string> = {};
|
||||
Object.keys(formErrors).forEach((k) => {
|
||||
// @ts-ignore
|
||||
if (newErrs[k]) {
|
||||
// @ts-ignore
|
||||
errsToSet[k] = newErrs[k];
|
||||
}
|
||||
});
|
||||
setFormErrors(errsToSet);
|
||||
};
|
||||
|
||||
const onTypeChange = (value: string): void => {
|
||||
const newFormData = {
|
||||
...formData,
|
||||
type: value as LabelMembershipType, // reconcile type differences between form data and radio component handler
|
||||
};
|
||||
setFormData(newFormData);
|
||||
|
||||
const newErrs = validate(newFormData);
|
||||
const errsToSet: Record<string, string> = {};
|
||||
Object.keys(formErrors).forEach((k) => {
|
||||
// @ts-ignore
|
||||
if (newErrs[k]) {
|
||||
// @ts-ignore
|
||||
errsToSet[k] = newErrs[k];
|
||||
}
|
||||
});
|
||||
setFormErrors(errsToSet);
|
||||
};
|
||||
|
||||
const onInputBlur = () => {
|
||||
setFormErrors(validate(formData));
|
||||
};
|
||||
|
||||
const onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
|
||||
const errs = validate(formData);
|
||||
if (Object.keys(errs).length > 0) {
|
||||
setFormErrors(errs);
|
||||
return;
|
||||
}
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const res = await labelsAPI.create(formData);
|
||||
router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id));
|
||||
renderFlash("success", "Label added successfully.");
|
||||
} catch {
|
||||
renderFlash("error", "Couldn't add label. Please try again.");
|
||||
}
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
const debounceValidateSQL = useDebouncedCallback((queryString: string) => {
|
||||
const { error } = validateQuery(queryString);
|
||||
return error || null;
|
||||
}, 500);
|
||||
|
||||
const onQueryChange = (newQuery: string) => {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
labelQuery: newQuery,
|
||||
}));
|
||||
debounceValidateSQL(newQuery);
|
||||
};
|
||||
|
||||
// form rendering helpers
|
||||
const onLoadSQLEditor = (editor: IAceEditor) => {
|
||||
editor.setOptions({
|
||||
enableLinking: true,
|
||||
enableMultiselect: false, // Disables command + click creating multiple cursors
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
// the string "linkClick" is not officially in the lib but we need it
|
||||
editor.on("linkClick", (data) => {
|
||||
const { type: type_, value } = data.token;
|
||||
|
||||
if (type_ === "osquery-token" && onOsqueryTableSelect) {
|
||||
return onOsqueryTableSelect(value);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeSearchQuery = (value: string) => {
|
||||
setTargetsSearchQuery(value);
|
||||
};
|
||||
const onHostSelect = (row: Row<IHost>) => {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
targetedHosts: targetedHosts.concat(row.original),
|
||||
}));
|
||||
setTargetsSearchQuery("");
|
||||
};
|
||||
|
||||
const onHostRemove = (row: Row<IHost>) => {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
targetedHosts: targetedHosts.filter((h) => h.id !== row.original.id),
|
||||
}));
|
||||
};
|
||||
const resultsTableConfig = generateTableHeaders();
|
||||
const selectedHostsTableConfig = generateTableHeaders(onHostRemove);
|
||||
|
||||
const renderVariableFields = () => {
|
||||
switch (type) {
|
||||
case "dynamic":
|
||||
return (
|
||||
<>
|
||||
<SQLEditor
|
||||
error={formErrors.labelQuery}
|
||||
name="query"
|
||||
onChange={onQueryChange}
|
||||
onBlur={onInputBlur}
|
||||
value={labelQuery}
|
||||
label="Query"
|
||||
labelActionComponent={
|
||||
showOpenSidebarButton ? (
|
||||
<Button variant="text-icon" onClick={onOpenSidebar}>
|
||||
Schema
|
||||
<Icon name="info" size="small" />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
// readOnly={isEditing} TODO when extending to handle edits
|
||||
onLoad={onLoadSQLEditor}
|
||||
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
|
||||
// helpText={isEditing ? IMMUTABLE_QUERY_HELP_TEXT : ""} TODO when extending to handle edits
|
||||
wrapEnabled
|
||||
/>
|
||||
<PlatformField
|
||||
platform={platform}
|
||||
// isEditing={isEditing} TODO when extending to handle edits
|
||||
|
||||
// onChange={onInputChange} TODO - once this form covers edits, can use the commmon
|
||||
// `onInputChange` along with updating PlatformField's Dropdown to `parseTarget`
|
||||
onChange={(newPlatform) => {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
platform: newPlatform,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
case "host_vitals":
|
||||
return (
|
||||
<div className={`${baseClass}__host_vitals-fields`}>
|
||||
<Dropdown
|
||||
label="Label criteria"
|
||||
name="vital"
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
value={vital}
|
||||
error={formErrors.criteria}
|
||||
options={availableCriteria}
|
||||
classname={`${baseClass}__criteria-dropdown`}
|
||||
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--criteria`}
|
||||
helpText="Currently, label criteria can be IdP group or department."
|
||||
/>
|
||||
<p>is equal to</p>
|
||||
<InputField
|
||||
error={formErrors.criteria}
|
||||
name="vitalValue"
|
||||
onChange={onInputChange}
|
||||
onBlur={onInputBlur}
|
||||
value={vitalValue}
|
||||
inputClassName={`${baseClass}__vital-value`}
|
||||
placeholder={
|
||||
vital === "end_user_idp_group" ? "IT admins" : "Engineering"
|
||||
}
|
||||
parseTarget
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "manual":
|
||||
return (
|
||||
<TargetsInput
|
||||
label={LABEL_TARGET_HOSTS_INPUT_LABEL}
|
||||
placeholder={LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER}
|
||||
searchText={targetsSearchQuery}
|
||||
searchResultsTableConfig={resultsTableConfig}
|
||||
selectedHostsTableConifg={selectedHostsTableConfig}
|
||||
isTargetsLoading={
|
||||
isLoadingTargetsSearchResults || isDebouncingTargetsSearch
|
||||
}
|
||||
hasFetchError={isErrorTargetsSearchResults}
|
||||
searchResults={targetsSearchResults ?? []}
|
||||
targetedHosts={targetedHosts}
|
||||
setSearchText={onChangeSearchQuery}
|
||||
handleRowSelect={onHostSelect}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderLabelForm = () => (
|
||||
<form className={`${baseClass}__label-form`} onSubmit={onSubmit}>
|
||||
<InputField
|
||||
error={formErrors.name}
|
||||
name="name"
|
||||
onChange={onInputChange}
|
||||
onBlur={onInputBlur}
|
||||
value={name}
|
||||
inputClassName={`${baseClass}__label-name`}
|
||||
label="Name"
|
||||
placeholder="Label name"
|
||||
parseTarget
|
||||
/>
|
||||
<InputField
|
||||
name="description"
|
||||
onChange={onInputChange}
|
||||
onBlur={onInputBlur}
|
||||
value={description}
|
||||
inputClassName={`${baseClass}__label-description`}
|
||||
label="Description"
|
||||
type="textarea"
|
||||
placeholder="Label description (optional)"
|
||||
parseTarget
|
||||
/>
|
||||
<div className="form-field type-field">
|
||||
<div className="form-field__label">Type</div>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Dynamic"
|
||||
id="dynamic"
|
||||
checked={type === "dynamic"}
|
||||
value="dynamic"
|
||||
name="label-type"
|
||||
onChange={onTypeChange}
|
||||
/>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Host vitals"
|
||||
id="host_vitals"
|
||||
checked={type === "host_vitals"}
|
||||
value="host_vitals"
|
||||
name="label-type"
|
||||
onChange={onTypeChange}
|
||||
tooltip={hostVitalsTooltipContent}
|
||||
disabled={!!hostVitalsTooltipContent}
|
||||
/>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Manual"
|
||||
id="manual"
|
||||
checked={type === "manual"}
|
||||
value="manual"
|
||||
name="label-type"
|
||||
onChange={onTypeChange}
|
||||
/>
|
||||
</div>
|
||||
{renderVariableFields()}
|
||||
<div className="button-wrap">
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.goBack();
|
||||
}}
|
||||
variant="inverse"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isUpdating}
|
||||
disabled={isUpdating || !!Object.entries(formErrors).length}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainContent className={baseClass}>
|
||||
<h1>Add label</h1>
|
||||
<p className={`${baseClass}__page-description`}>
|
||||
Dynamic (smart) labels are assigned to hosts if the query returns
|
||||
results. Manual labels are assigned to selected hosts.
|
||||
</p>
|
||||
<TabNav className={`${baseClass}__new-label-tab-nav`}>
|
||||
<Tabs
|
||||
selectedIndex={getTabIndex(location?.pathname || "")}
|
||||
onSelect={navigateToNav}
|
||||
>
|
||||
<TabList>
|
||||
{labelSubNav.map((navItem) => {
|
||||
return (
|
||||
<Tab key={navItem.name} data-text={navItem.name}>
|
||||
<TabText>{navItem.name}</TabText>
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</TabNav>
|
||||
{React.cloneElement(children, {
|
||||
showOpenSidebarButton,
|
||||
onOpenSidebar,
|
||||
onOsqueryTableSelect,
|
||||
})}
|
||||
<div className={`${baseClass}__header`}>
|
||||
<h1>New label</h1>
|
||||
<p className={`${baseClass}__page-description`}>
|
||||
Create a new label for targeting and filtering hosts.
|
||||
</p>
|
||||
</div>
|
||||
{renderLabelForm()}
|
||||
</MainContent>
|
||||
{isDynamicLabel && isSidePanelOpen && (
|
||||
{type === "dynamic" && isSidePanelOpen && (
|
||||
<SidePanelContent>
|
||||
<QuerySidePanel
|
||||
key="query-side-panel"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,62 @@
|
|||
.new-label-page {
|
||||
h1 {
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-smedium;
|
||||
margin-bottom: $pad-large;
|
||||
}
|
||||
|
||||
&__page-description {
|
||||
margin: 0;
|
||||
color: $ui-fleet-black-75;
|
||||
font-size: $xx-small;
|
||||
font-style: italic;
|
||||
}
|
||||
&__label-name,
|
||||
&__label-description {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__new-label-tab-nav {
|
||||
margin-bottom: $pad-xxlarge;
|
||||
&__label-platform {
|
||||
font-size: $x-small;
|
||||
font-weight: $regular;
|
||||
color: $core-fleet-black;
|
||||
padding-bottom: $pad-large;
|
||||
|
||||
p {
|
||||
padding-bottom: $pad-small;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: $core-fleet-blue;
|
||||
padding-top: $pad-small;
|
||||
}
|
||||
}
|
||||
&__host_vitals-fields {
|
||||
width: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-medium;
|
||||
.form-field--dropdown {
|
||||
margin-right: -14px;
|
||||
.form-field__help-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.Select {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
white-space: nowrap;
|
||||
}
|
||||
dropdown__select--error.Select {
|
||||
border: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useDebouncedCallback } from "use-debounce";
|
|||
import { IHost } from "interfaces/host";
|
||||
import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets";
|
||||
|
||||
import TargetsInput from "components/LiveQuery/TargetsInput";
|
||||
import TargetsInput from "components/TargetsInput";
|
||||
|
||||
import LabelForm from "../LabelForm";
|
||||
import { ILabelFormData } from "../LabelForm/LabelForm";
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ import ForgotPasswordPage from "pages/ForgotPasswordPage";
|
|||
import GatedLayout from "layouts/GatedLayout";
|
||||
import HostDetailsPage from "pages/hosts/details/HostDetailsPage";
|
||||
import NewLabelPage from "pages/labels/NewLabelPage";
|
||||
import DynamicLabel from "pages/labels/NewLabelPage/DynamicLabel";
|
||||
import ManualLabel from "pages/labels/NewLabelPage/ManualLabel";
|
||||
import EditLabelPage from "pages/labels/EditLabelPage";
|
||||
import LoginPage, { LoginPreviewPage } from "pages/LoginPage";
|
||||
import LogoutPage from "pages/LogoutPage";
|
||||
|
|
@ -227,11 +225,11 @@ const routes = (
|
|||
<Redirect from="teams/:team_id/options" to="teams" />
|
||||
</Route>
|
||||
<Route path="labels">
|
||||
<IndexRedirect to="new/dynamic" />
|
||||
<IndexRedirect to="new" />
|
||||
<Route path="new" component={NewLabelPage}>
|
||||
<IndexRedirect to="dynamic" />
|
||||
<Route path="dynamic" component={DynamicLabel} />
|
||||
<Route path="manual" component={ManualLabel} />
|
||||
{/* maintaining previous 2 sub-routes for backward-compatibility of URL routes. NewLabelPage now sets the corresponding label type */}
|
||||
<Route path="dynamic" component={NewLabelPage} />
|
||||
<Route path="manual" component={NewLabelPage} />
|
||||
</Route>
|
||||
<Route path=":label_id" component={EditLabelPage} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -90,8 +90,12 @@ export default {
|
|||
SOFTWARE_ADD_APP_STORE: `${URL_PREFIX}/software/add/app-store`,
|
||||
|
||||
// Label pages
|
||||
NEW_LABEL: `${URL_PREFIX}/labels/new`,
|
||||
// deprecated - now handled by `/new` route
|
||||
LABEL_NEW_DYNAMIC: `${URL_PREFIX}/labels/new/dynamic`,
|
||||
// deprecated - now handled by `/new` route
|
||||
LABEL_NEW_MANUAL: `${URL_PREFIX}/labels/new/manual`,
|
||||
|
||||
LABEL_EDIT: (labelId: number) => `${URL_PREFIX}/labels/${labelId}`,
|
||||
|
||||
EDIT_PACK: (packId: number): string => {
|
||||
|
|
@ -185,7 +189,6 @@ export default {
|
|||
MANAGE_QUERIES: `${URL_PREFIX}/queries/manage`,
|
||||
MANAGE_SCHEDULE: `${URL_PREFIX}/schedule/manage`,
|
||||
MANAGE_POLICIES: `${URL_PREFIX}/policies/manage`,
|
||||
NEW_LABEL: `${URL_PREFIX}/labels/new`,
|
||||
NEW_POLICY: `${URL_PREFIX}/policies/new`,
|
||||
NEW_QUERY: `${URL_PREFIX}/queries/new`,
|
||||
RESET_PASSWORD: `${URL_PREFIX}/login/reset`,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ILabel, ILabelSummary } from "interfaces/label";
|
|||
import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm";
|
||||
import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm";
|
||||
import { IHost } from "interfaces/host";
|
||||
import { INewLabelFormData } from "pages/labels/NewLabelPage/NewLabelPage";
|
||||
|
||||
export interface ILabelsResponse {
|
||||
labels: ILabel[];
|
||||
|
|
@ -19,7 +20,7 @@ export interface ICreateLabelResponse {
|
|||
label: ILabel;
|
||||
}
|
||||
export type IUpdateLabelResponse = ICreateLabelResponse;
|
||||
export type IGetLabelResonse = ICreateLabelResponse;
|
||||
export type IGetLabelResponse = ICreateLabelResponse;
|
||||
|
||||
export interface IGetHostsInLabelResponse {
|
||||
hosts: IHost[];
|
||||
|
|
@ -31,7 +32,7 @@ const isManualLabelFormData = (
|
|||
return "targetedHosts" in formData;
|
||||
};
|
||||
|
||||
const generateCreateLabelBody = (
|
||||
const generateUpdateLabelBody = (
|
||||
formData: IDynamicLabelFormData | IManualLabelFormData
|
||||
) => {
|
||||
// we need to prepare the post body for only manual labels.
|
||||
|
|
@ -45,7 +46,34 @@ const generateCreateLabelBody = (
|
|||
return formData;
|
||||
};
|
||||
|
||||
const generateUpdateLabelBody = generateCreateLabelBody;
|
||||
const generateCreateLabelBody = (formData: INewLabelFormData) => {
|
||||
switch (formData.type) {
|
||||
case "manual":
|
||||
return {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
host_ids: formData.targetedHosts.map((host) => host.id),
|
||||
};
|
||||
case "dynamic":
|
||||
return {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
query: formData.labelQuery,
|
||||
platform: formData.platform,
|
||||
};
|
||||
case "host_vitals":
|
||||
return {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
criteria: {
|
||||
vital: formData.vital,
|
||||
value: formData.vitalValue,
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown label type: ${formData.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
/** gets the custom label and returns them in case-insensitive alphabetical
|
||||
* ascending order by label name. (e.g. [A, B, C, a, b, c] => [A, a, B, b, C, c])
|
||||
|
|
@ -70,12 +98,9 @@ export const getCustomLabels = <T extends { label_type: string; name: string }>(
|
|||
};
|
||||
|
||||
export default {
|
||||
create: (
|
||||
formData: IDynamicLabelFormData | IManualLabelFormData
|
||||
): Promise<ICreateLabelResponse> => {
|
||||
create: (formData: INewLabelFormData): Promise<ICreateLabelResponse> => {
|
||||
const { LABELS } = endpoints;
|
||||
const postBody = generateCreateLabelBody(formData);
|
||||
return sendRequest("POST", LABELS, postBody);
|
||||
return sendRequest("POST", LABELS, generateCreateLabelBody(formData));
|
||||
},
|
||||
|
||||
destroy: (label: ILabel) => {
|
||||
|
|
@ -117,7 +142,7 @@ export default {
|
|||
return sendRequest("GET", path);
|
||||
},
|
||||
|
||||
getLabel: (labelId: number): Promise<IGetLabelResonse> => {
|
||||
getLabel: (labelId: number): Promise<IGetLabelResponse> => {
|
||||
const { LABEL } = endpoints;
|
||||
return sendRequest("GET", LABEL(labelId));
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue