Fleet UI: View, add, edit, delete custom categories (#46443)

This commit is contained in:
RachelElysia
2026-06-01 15:46:03 -04:00
committed by GitHub
parent 57d78c54d1
commit 9680221695
27 changed files with 1417 additions and 10 deletions
@@ -0,0 +1,2 @@
- Added Self-service categories page (Premium) under Software > Library for managing custom categories per fleet, including add, edit, and delete flows.
- Added Categories button to the Software > Library page that navigates to the new categories page.
+6 -1
View File
@@ -27,6 +27,8 @@ interface IDataErrorProps {
useNew?: boolean;
/** Overrides something gone wrong line with description text to condense error onto one line */
singleCustomLine?: boolean;
/** Centers the component within its parent */
selfCenter?: boolean;
}
const DEFAULT_DESCRIPTION = "Refresh the page or log in again.";
@@ -39,8 +41,11 @@ const DataError = ({
className,
useNew = false,
singleCustomLine = false,
selfCenter = false,
}: IDataErrorProps): JSX.Element => {
const classes = classnames(baseClass, className);
const classes = classnames(baseClass, className, {
[`${baseClass}--self-center`]: selfCenter,
});
if (singleCustomLine) {
return (
@@ -3,6 +3,10 @@
flex-direction: column;
align-items: center;
&--self-center {
align-self: center;
}
&__vertical-pad-small {
padding: $pad-small 0;
}
@@ -0,0 +1,16 @@
export interface ISelfServiceCategory {
id: number;
name: string;
fleet_id: number;
created_at: string;
updated_at: string;
}
export interface ICreateSelfServiceCategoryFormData {
fleet_id: number;
name: string;
}
export interface IEditSelfServiceCategoryFormData {
name: string;
}
@@ -0,0 +1,92 @@
import React, { useState } from "react";
import selfServiceCategoriesAPI from "services/entities/self_service_categories";
import { hasStatusKey } from "interfaces/errors";
import Button from "components/buttons/Button";
import InputField from "components/forms/fields/InputField";
import Modal from "components/Modal";
const baseClass = "add-category-modal";
const NAME_MAX_LENGTH = 255;
interface IAddCategoryModalProps {
fleetId: number;
onExit: () => void;
onSuccess: () => void;
}
const AddCategoryModal = ({
fleetId,
onExit,
onSuccess,
}: IAddCategoryModalProps) => {
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const trimmedName = name.trim();
const isInvalid =
trimmedName.length === 0 || trimmedName.length > NAME_MAX_LENGTH;
const isDisabled = isInvalid || isSubmitting;
const onNameChange = (value: string) => {
setName(value);
if (error) setError(null);
};
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isDisabled) return;
setIsSubmitting(true);
try {
await selfServiceCategoriesAPI.addCategory({
fleet_id: fleetId,
name: trimmedName,
});
onSuccess();
} catch (e) {
if (hasStatusKey(e) && e.status === 409) {
setError(
"A self-service category with this name already exists in this fleet."
);
} else {
setError("Couldn't add self-service category.");
}
setIsSubmitting(false);
}
};
return (
<Modal
title="Add category"
onExit={onExit}
className={baseClass}
isContentDisabled={isSubmitting}
>
<form className={`${baseClass}__form`} onSubmit={onSubmit}>
<InputField
label="Name"
name="name"
value={name}
onChange={onNameChange}
error={error}
autofocus
ignore1password
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
/>
<div className="modal-cta-wrap">
<Button type="submit" disabled={isDisabled} isLoading={isSubmitting}>
Add
</Button>
<Button variant="inverse" onClick={onExit} disabled={isSubmitting}>
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default AddCategoryModal;
@@ -0,0 +1,5 @@
.add-category-modal {
&__form {
@include vertical-form-layout;
}
}
@@ -0,0 +1 @@
export { default } from "./AddCategoryModal";
@@ -0,0 +1,71 @@
import React, { useContext, useState } from "react";
import selfServiceCategoriesAPI from "services/entities/self_service_categories";
import { NotificationContext } from "context/notification";
import { ISelfServiceCategory } from "interfaces/self_service_category";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
const baseClass = "delete-category-modal";
interface IDeleteCategoryModalProps {
category: ISelfServiceCategory;
onExit: () => void;
onSuccess: () => void;
}
const DeleteCategoryModal = ({
category,
onExit,
onSuccess,
}: IDeleteCategoryModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {
if (isDeleting) return;
setIsDeleting(true);
try {
await selfServiceCategoriesAPI.deleteCategory(category.id);
onSuccess();
} catch (e) {
renderFlash("error", "Couldn't delete self-service category.");
setIsDeleting(false);
}
};
return (
<Modal
title="Delete category"
onExit={onExit}
className={baseClass}
isContentDisabled={isDeleting}
>
<>
<p className={`${baseClass}__body`}>
The category will be removed from all associated software.
</p>
<div className="modal-cta-wrap">
<Button
variant="alert"
disabled={isDeleting}
isLoading={isDeleting}
onClick={onDelete}
>
Delete
</Button>
<Button
variant="inverse-alert"
onClick={onExit}
disabled={isDeleting}
>
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default DeleteCategoryModal;
@@ -0,0 +1,7 @@
.delete-category-modal {
&__body {
margin: 0;
color: $core-fleet-black;
font-size: $x-small;
}
}
@@ -0,0 +1 @@
export { default } from "./DeleteCategoryModal";
@@ -0,0 +1,93 @@
import React, { useState } from "react";
import selfServiceCategoriesAPI from "services/entities/self_service_categories";
import { hasStatusKey } from "interfaces/errors";
import { ISelfServiceCategory } from "interfaces/self_service_category";
import Button from "components/buttons/Button";
import InputField from "components/forms/fields/InputField";
import Modal from "components/Modal";
const baseClass = "edit-category-modal";
const NAME_MAX_LENGTH = 255;
interface IEditCategoryModalProps {
category: ISelfServiceCategory;
onExit: () => void;
onSuccess: () => void;
}
const EditCategoryModal = ({
category,
onExit,
onSuccess,
}: IEditCategoryModalProps) => {
const [name, setName] = useState(category.name);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const trimmedName = name.trim();
const isInvalid =
trimmedName.length === 0 || trimmedName.length > NAME_MAX_LENGTH;
const isUnchanged = trimmedName === category.name;
const isDisabled = isInvalid || isUnchanged || isSubmitting;
const onNameChange = (value: string) => {
setName(value);
if (error) setError(null);
};
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isDisabled) return;
setIsSubmitting(true);
try {
await selfServiceCategoriesAPI.updateCategory(category.id, {
name: trimmedName,
});
onSuccess();
} catch (e) {
if (hasStatusKey(e) && e.status === 409) {
setError(
"A self-service category with this name already exists in this fleet."
);
} else {
setError("Couldn't update self-service category.");
}
setIsSubmitting(false);
}
};
return (
<Modal
title="Edit category"
onExit={onExit}
className={baseClass}
isContentDisabled={isSubmitting}
>
<form className={`${baseClass}__form`} onSubmit={onSubmit}>
<InputField
label="Name"
name="name"
value={name}
onChange={onNameChange}
error={error}
autofocus
ignore1password
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
/>
<div className="modal-cta-wrap">
<Button type="submit" disabled={isDisabled} isLoading={isSubmitting}>
Save
</Button>
<Button variant="inverse" onClick={onExit} disabled={isSubmitting}>
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default EditCategoryModal;
@@ -0,0 +1,5 @@
.edit-category-modal {
&__form {
@include vertical-form-layout;
}
}
@@ -0,0 +1 @@
export { default } from "./EditCategoryModal";
@@ -0,0 +1,500 @@
import React from "react";
import { screen, waitFor, within } from "@testing-library/react";
import { createCustomRenderer, createMockRouter } from "test/test-utils";
import mockServer from "test/mock-server";
import createMockUser from "__mocks__/userMock";
import { createMockTeamSummary } from "__mocks__/teamMock";
import {
addSelfServiceCategoryConflictHandler,
addSelfServiceCategoryErrorHandler,
addSelfServiceCategoryHandler,
deleteSelfServiceCategoryErrorHandler,
deleteSelfServiceCategoryHandler,
editSelfServiceCategoryConflictHandler,
editSelfServiceCategoryErrorHandler,
editSelfServiceCategoryHandler,
emptySelfServiceCategoriesHandler,
listSelfServiceCategoriesHandler,
} from "test/handlers/self-service-categories-handlers";
import SelfServiceCategoriesPage from "./SelfServiceCategoriesPage";
const baseProps = {
router: createMockRouter(),
location: {
pathname: "/software/library/categories",
search: "?fleet_id=1",
query: { fleet_id: "1" },
hash: "",
},
};
const renderFlash = jest.fn();
const mockTeam = createMockTeamSummary({ id: 1, name: "Workstations" });
const premiumAdminContext = {
app: {
isPremiumTier: true,
isGlobalAdmin: true,
currentUser: createMockUser({ global_role: "admin" }),
availableTeams: [mockTeam],
setCurrentTeam: jest.fn(),
},
notification: { renderFlash, hideFlash: jest.fn() },
};
// Returns the currently open modal element scoped for `within(...)` queries.
// Modal renders its title as a span (not a heading) so role-based queries
// can't locate it; falling back to the modal-container class since only one
// modal is open at a time in these tests.
const MODAL_SELECTOR = ".modal__modal_container";
const getOpenModal = async () => {
await waitFor(() => {
if (!document.querySelector(MODAL_SELECTOR)) {
throw new Error("Modal not yet rendered");
}
});
return document.querySelector(MODAL_SELECTOR) as HTMLElement;
};
describe("SelfServiceCategoriesPage", () => {
beforeEach(() => {
renderFlash.mockClear();
});
it("renders the premium gate on Fleet Free", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: false,
isGlobalAdmin: true,
currentUser: createMockUser({ global_role: "admin" }),
},
notification: { renderFlash, hideFlash: jest.fn() },
},
});
render(<SelfServiceCategoriesPage {...baseProps} />);
expect(
screen.getByText("This feature is included in Fleet Premium.")
).toBeInTheDocument();
});
it("renders the empty state with Add button when canManage", async () => {
mockServer.use(emptySelfServiceCategoriesHandler);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
render(<SelfServiceCategoriesPage {...baseProps} />);
expect(
await screen.findByText("No self-service categories")
).toBeInTheDocument();
expect(
screen.getByText(
"Add category to group your software and scripts in self-service."
)
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add category" })
).toBeInTheDocument();
});
it("renders the empty state without Add button for non-managers", async () => {
mockServer.use(emptySelfServiceCategoriesHandler);
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
isGlobalAdmin: false,
isGlobalMaintainer: false,
isTeamAdmin: false,
isTeamMaintainer: false,
currentUser: createMockUser({ global_role: "observer" }),
availableTeams: [mockTeam],
setCurrentTeam: jest.fn(),
},
notification: { renderFlash, hideFlash: jest.fn() },
},
});
render(<SelfServiceCategoriesPage {...baseProps} />);
expect(
await screen.findByText("No self-service categories")
).toBeInTheDocument();
expect(
screen.getByText("No self-service categories are available.")
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Add category" })
).not.toBeInTheDocument();
});
it("renders the populated list with category names", async () => {
mockServer.use(listSelfServiceCategoriesHandler());
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
render(<SelfServiceCategoriesPage {...baseProps} />);
expect(await screen.findByText("🌎 Browsers")).toBeInTheDocument();
expect(screen.getByText("👬 Communication")).toBeInTheDocument();
expect(screen.getByText("🧰 Developer tools")).toBeInTheDocument();
});
it("hides edit/delete actions for observers", async () => {
mockServer.use(listSelfServiceCategoriesHandler());
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
isGlobalAdmin: false,
isGlobalMaintainer: false,
isTeamAdmin: false,
isTeamMaintainer: false,
currentUser: createMockUser({ global_role: "observer" }),
availableTeams: [mockTeam],
setCurrentTeam: jest.fn(),
},
notification: { renderFlash, hideFlash: jest.fn() },
},
});
render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🌎 Browsers");
expect(
screen.queryByRole("button", { name: /^Edit / })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /^Delete / })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Add category/ })
).not.toBeInTheDocument();
});
it("creates a category on Add submit", async () => {
mockServer.use(
emptySelfServiceCategoriesHandler,
addSelfServiceCategoryHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await user.click(
await screen.findByRole("button", { name: "Add category" })
);
const modal = await getOpenModal();
await user.type(within(modal).getByLabelText("Name"), "🌎 Browsers");
await user.click(within(modal).getByRole("button", { name: /^Add$/ }));
await waitFor(() => {
expect(renderFlash).toHaveBeenCalledWith(
"success",
"Successfully added self-service category."
);
});
});
it("shows inline 409 error on duplicate name", async () => {
mockServer.use(
emptySelfServiceCategoriesHandler,
addSelfServiceCategoryConflictHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await user.click(
await screen.findByRole("button", { name: "Add category" })
);
const modal = await getOpenModal();
await user.type(within(modal).getByLabelText("Name"), "🌎 Browsers");
await user.click(within(modal).getByRole("button", { name: /^Add$/ }));
expect(
await within(modal).findByText(
"A self-service category with this name already exists in this fleet."
)
).toBeInTheDocument();
expect(renderFlash).not.toHaveBeenCalled();
});
it("shows inline generic error when add fails", async () => {
mockServer.use(
emptySelfServiceCategoriesHandler,
addSelfServiceCategoryErrorHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await user.click(
await screen.findByRole("button", { name: "Add category" })
);
const modal = await getOpenModal();
await user.type(within(modal).getByLabelText("Name"), "🌎 Browsers");
await user.click(within(modal).getByRole("button", { name: /^Add$/ }));
expect(
await within(modal).findByText("Couldn't add self-service category.")
).toBeInTheDocument();
expect(renderFlash).not.toHaveBeenCalled();
});
it("shows inline 409 error on duplicate name when editing", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🌎 Browsers" }]),
editSelfServiceCategoryConflictHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🌎 Browsers");
await user.click(screen.getByRole("button", { name: "Edit 🌎 Browsers" }));
const modal = await getOpenModal();
const input = within(modal).getByLabelText("Name");
await user.clear(input);
await user.type(input, "👬 Communication");
await user.click(within(modal).getByRole("button", { name: /Save/ }));
expect(
await within(modal).findByText(
"A self-service category with this name already exists in this fleet."
)
).toBeInTheDocument();
expect(renderFlash).not.toHaveBeenCalled();
});
it("shows inline generic error when edit fails", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🌎 Browsers" }]),
editSelfServiceCategoryErrorHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🌎 Browsers");
await user.click(screen.getByRole("button", { name: "Edit 🌎 Browsers" }));
const modal = await getOpenModal();
const input = within(modal).getByLabelText("Name");
await user.clear(input);
await user.type(input, "🌍 Browsers (EU)");
await user.click(within(modal).getByRole("button", { name: /Save/ }));
expect(
await within(modal).findByText("Couldn't update self-service category.")
).toBeInTheDocument();
expect(renderFlash).not.toHaveBeenCalled();
});
it("flashes an error and re-enables the Delete button when delete fails", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🛟 Support" }]),
deleteSelfServiceCategoryErrorHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🛟 Support");
await user.click(screen.getByRole("button", { name: "Delete 🛟 Support" }));
const modal = await getOpenModal();
const deleteBtn = within(modal).getByRole("button", { name: /^Delete$/ });
await user.click(deleteBtn);
await waitFor(() => {
expect(renderFlash).toHaveBeenCalledWith(
"error",
"Couldn't delete self-service category."
);
});
expect(deleteBtn).not.toBeDisabled();
});
it("closes the Add modal when Cancel is clicked", async () => {
mockServer.use(emptySelfServiceCategoriesHandler);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await user.click(
await screen.findByRole("button", { name: "Add category" })
);
const modal = await getOpenModal();
await user.click(within(modal).getByRole("button", { name: /Cancel/ }));
await waitFor(() => {
expect(document.querySelector(".modal__modal_container")).toBeNull();
});
});
it("closes the Edit modal when Cancel is clicked", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🌎 Browsers" }])
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🌎 Browsers");
await user.click(screen.getByRole("button", { name: "Edit 🌎 Browsers" }));
const modal = await getOpenModal();
await user.click(within(modal).getByRole("button", { name: /Cancel/ }));
await waitFor(() => {
expect(document.querySelector(".modal__modal_container")).toBeNull();
});
});
it("closes the Delete modal when Cancel is clicked", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🛟 Support" }])
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🛟 Support");
await user.click(screen.getByRole("button", { name: "Delete 🛟 Support" }));
const modal = await getOpenModal();
await user.click(within(modal).getByRole("button", { name: /Cancel/ }));
await waitFor(() => {
expect(document.querySelector(".modal__modal_container")).toBeNull();
});
});
it("disables Save when the edited name is unchanged", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🌎 Browsers" }])
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🌎 Browsers");
await user.click(screen.getByRole("button", { name: "Edit 🌎 Browsers" }));
const modal = await getOpenModal();
const saveBtn = within(modal).getByRole("button", { name: /Save/ });
expect(saveBtn).toBeDisabled();
// Editing then reverting also leaves Save disabled (trim-aware).
const input = within(modal).getByLabelText("Name");
await user.type(input, " ");
expect(saveBtn).toBeDisabled();
});
it("edits a category on Save", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🌎 Browsers" }]),
editSelfServiceCategoryHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🌎 Browsers");
await user.click(screen.getByRole("button", { name: "Edit 🌎 Browsers" }));
const modal = await getOpenModal();
const input = within(modal).getByLabelText("Name");
await user.clear(input);
await user.type(input, "🌍 Browsers (EU)");
await user.click(within(modal).getByRole("button", { name: /Save/ }));
await waitFor(() => {
expect(renderFlash).toHaveBeenCalledWith(
"success",
"Successfully updated self-service category."
);
});
});
it("deletes a category on confirm", async () => {
mockServer.use(
listSelfServiceCategoriesHandler([{ id: 1, name: "🛟 Support" }]),
deleteSelfServiceCategoryHandler
);
const render = createCustomRenderer({
withBackendMock: true,
context: premiumAdminContext,
});
const { user } = render(<SelfServiceCategoriesPage {...baseProps} />);
await screen.findByText("🛟 Support");
await user.click(screen.getByRole("button", { name: "Delete 🛟 Support" }));
const modal = await getOpenModal();
expect(
within(modal).getByText(
"The category will be removed from all associated software."
)
).toBeInTheDocument();
await user.click(within(modal).getByRole("button", { name: /^Delete$/ }));
await waitFor(() => {
expect(renderFlash).toHaveBeenCalledWith(
"success",
"Successfully deleted self-service category."
);
});
});
});
@@ -0,0 +1,279 @@
import React, { useContext, useState } from "react";
import { useQuery, useQueryClient } from "react-query";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import { getPathWithQueryParams } from "utilities/url";
import selfServiceCategoriesAPI, {
ISelfServiceCategoriesResponse,
} from "services/entities/self_service_categories";
import { ISelfServiceCategory } from "interfaces/self_service_category";
import BackButton from "components/BackButton";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import DataError from "components/DataError";
import EmptyState from "components/EmptyState";
import Icon from "components/Icon";
import MainContent from "components/MainContent";
import PageDescription from "components/PageDescription";
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import Spinner from "components/Spinner";
import TeamsDropdown from "components/TeamsDropdown";
import UploadList from "components/UploadList";
import AddCategoryModal from "./AddCategoryModal";
import EditCategoryModal from "./EditCategoryModal";
import DeleteCategoryModal from "./DeleteCategoryModal";
const baseClass = "self-service-categories-page";
interface ISelfServiceCategoriesPageProps {
router: InjectedRouter;
location: {
pathname: string;
search: string;
query: { fleet_id?: string; team_id?: string };
hash?: string;
};
}
const SelfServiceCategoriesPage = ({
router,
location,
}: ISelfServiceCategoriesPageProps) => {
const {
config,
isPremiumTier,
isGlobalAdmin,
isGlobalMaintainer,
} = useContext(AppContext);
const isPrimoMode = config?.partnerships?.enable_primo || false;
const { renderFlash } = useContext(NotificationContext);
const queryClient = useQueryClient();
const {
currentTeamId,
teamIdForApi,
userTeams,
handleTeamChange,
isRouteOk,
isTeamAdmin,
isTeamMaintainer,
} = useTeamIdParam({
location,
router,
includeAllTeams: false,
includeNoTeam: true,
});
const fleetId = teamIdForApi ?? 0;
const backToLibraryPath = getPathWithQueryParams(PATHS.SOFTWARE_LIBRARY, {
fleet_id: teamIdForApi,
});
const canManage =
!!isGlobalAdmin ||
!!isGlobalMaintainer ||
!!isTeamAdmin ||
!!isTeamMaintainer;
const [showAddModal, setShowAddModal] = useState(false);
const [
categoryToEdit,
setCategoryToEdit,
] = useState<ISelfServiceCategory | null>(null);
const [
categoryToDelete,
setCategoryToDelete,
] = useState<ISelfServiceCategory | null>(null);
const { data: categoriesData, isLoading, isError } = useQuery<
ISelfServiceCategoriesResponse,
Error
>(
["selfServiceCategories", teamIdForApi],
() => selfServiceCategoriesAPI.getCategories(teamIdForApi as number),
{
enabled: !!isPremiumTier && isRouteOk && teamIdForApi !== undefined,
refetchOnWindowFocus: false,
}
);
const invalidateList = () => {
queryClient.invalidateQueries(["selfServiceCategories", teamIdForApi]);
};
const onAddSuccess = () => {
invalidateList();
setShowAddModal(false);
renderFlash("success", "Successfully added self-service category.");
};
const onEditSuccess = () => {
invalidateList();
setCategoryToEdit(null);
renderFlash("success", "Successfully updated self-service category.");
};
const onDeleteSuccess = () => {
invalidateList();
setCategoryToDelete(null);
renderFlash("success", "Successfully deleted self-service category.");
};
const renderHeader = () => (
<>
<BackButton text="Back to software library" path={backToLibraryPath} />
{!isPrimoMode && (
<div className={`${baseClass}__fleet-row`}>
<TeamsDropdown
currentUserTeams={userTeams ?? []}
selectedTeamId={currentTeamId}
onChange={handleTeamChange}
includeAllTeams={false}
includeNoTeams
/>
</div>
)}
<PageDescription
content={
<>
Manage self-service categories.{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/self-service-software-categories"
text="Learn more"
newTab
/>
</>
}
/>
</>
);
const renderBody = () => {
if (!isPremiumTier) {
return (
<div className={`${baseClass}__premium-card`}>
<PremiumFeatureMessage />
</div>
);
}
if (!isRouteOk || isLoading) {
return <Spinner />;
}
if (isError) {
return <DataError verticalPaddingSize="pad-xxxlarge" selfCenter />;
}
const categories = categoriesData?.self_service_categories ?? [];
const hasCategories = categories.length > 0;
if (!hasCategories) {
return (
<EmptyState
variant="header-list"
header="No self-service categories"
info={
canManage
? "Add category to group your software and scripts in self-service."
: "No self-service categories are available."
}
primaryButton={
canManage ? (
<Button onClick={() => setShowAddModal(true)}>
Add category
</Button>
) : undefined
}
/>
);
}
return (
<UploadList<ISelfServiceCategory>
className={`${baseClass}__list`}
keyAttribute="id"
listItems={categories}
HeadingComponent={() => (
<div className={`${baseClass}__list-header`}>
<span className={`${baseClass}__list-title`}>
Self-service categories
</span>
{canManage && (
<Button variant="text-icon" onClick={() => setShowAddModal(true)}>
<Icon name="plus" />
Add category
</Button>
)}
</div>
)}
ListItemComponent={({ listItem }) => (
<div className={`${baseClass}__row`}>
<span className={`${baseClass}__row-name`}>{listItem.name}</span>
{canManage && (
<div className={`${baseClass}__row-actions`}>
<Button
variant="icon"
onClick={() => setCategoryToEdit(listItem)}
ariaLabel={`Edit ${listItem.name}`}
title="Edit"
>
<Icon name="pencil" />
</Button>
<Button
variant="icon"
onClick={() => setCategoryToDelete(listItem)}
ariaLabel={`Delete ${listItem.name}`}
title="Delete"
>
<Icon name="trash" />
</Button>
</div>
)}
</div>
)}
/>
);
};
return (
<MainContent className={baseClass}>
<>
{renderHeader()}
{renderBody()}
{showAddModal && (
<AddCategoryModal
fleetId={fleetId}
onExit={() => setShowAddModal(false)}
onSuccess={onAddSuccess}
/>
)}
{categoryToEdit && (
<EditCategoryModal
category={categoryToEdit}
onExit={() => setCategoryToEdit(null)}
onSuccess={onEditSuccess}
/>
)}
{categoryToDelete && (
<DeleteCategoryModal
category={categoryToDelete}
onExit={() => setCategoryToDelete(null)}
onSuccess={onDeleteSuccess}
/>
)}
</>
</MainContent>
);
};
export default SelfServiceCategoriesPage;
@@ -0,0 +1,71 @@
.self-service-categories-page {
@include vertical-page-layout;
align-items: flex-start;
&__fleet-row {
align-self: stretch;
.team-dropdown-wrapper {
@include normalize-team-header;
}
}
&__premium-card {
background-color: $ui-off-white;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius;
padding: $pad-xxlarge;
display: flex;
justify-content: center;
}
&__list {
align-self: stretch;
font-size: $x-small;
.upload-list__list-item {
padding: 0 $pad-large;
height: 56px;
box-sizing: border-box;
}
}
&__list-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: $x-small;
}
&__list-title {
font-size: $x-small;
font-weight: $bold;
color: $core-fleet-black;
}
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $pad-medium;
height: 100%;
}
&__row-name {
font-size: $x-small;
color: $core-fleet-black;
}
&__row-actions {
display: flex;
align-items: center;
gap: $pad-small;
opacity: 0;
transition: opacity 0.1s ease-in-out;
}
.upload-list__list-item:hover &__row-actions,
.upload-list__list-item:focus-within &__row-actions {
opacity: 1;
}
}
@@ -0,0 +1 @@
export { default } from "./SelfServiceCategoriesPage";
@@ -119,6 +119,42 @@ describe("Software library table", () => {
expect(screen.getByText("Self-service only")).toBeInTheDocument();
});
it("Navigates to the categories page when the Categories button is clicked", async () => {
const router = createMockRouter();
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<SoftwareLibraryTable
router={router}
isSoftwareEnabled
data={createMockSoftwareTitlesResponse({
counts_updated_at: null,
software_titles: [],
})}
query=""
perPage={50}
orderDirection="asc"
orderKey="hosts_count"
selfServiceOnly={false}
currentPage={0}
teamId={4}
isLoading={false}
/>
);
await user.click(screen.getByRole("button", { name: /Categories/ }));
expect(router.push).toHaveBeenCalledWith(
"/software/library/categories?fleet_id=4"
);
});
it("Renders the empty state without Add software button for observers", () => {
const render = createCustomRenderer({
context: {
@@ -22,6 +22,7 @@ import LastUpdatedText from "components/LastUpdatedText";
import Slider from "components/forms/fields/Slider";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import TableCount from "components/TableContainer/TableCount";
import Icon from "components/Icon";
import EmptySoftwareTable from "pages/SoftwarePage/components/tables/EmptySoftwareTable";
@@ -194,15 +195,28 @@ const SoftwareLibraryTable = ({
);
};
const onClickCategories = () => {
router.push(
getPathWithQueryParams(PATHS.SOFTWARE_LIBRARY_CATEGORIES, {
fleet_id: teamId,
})
);
};
const renderCustomControls = () => {
return (
<Slider
value={selfServiceOnly}
onChange={handleSelfServiceToggle}
inactiveText="Self-service only"
activeText="Self-service only"
disabled={controlsDisabled}
/>
<div className={`${baseClass}__controls`}>
<Button variant="inverse" onClick={onClickCategories}>
<Icon name="settings" /> Categories
</Button>
<Slider
value={selfServiceOnly}
onChange={handleSelfServiceToggle}
inactiveText="Self-service only"
activeText="Self-service only"
disabled={controlsDisabled}
/>
</div>
);
};
@@ -1,4 +1,6 @@
.software-library-table {
margin-top: $gap-page-component; // Required as these Tabs don't use TabPanel
// Override the grid container to a simple flex layout
// to avoid an empty grid column gap.
.container {
@@ -9,6 +11,12 @@
gap: $pad-medium;
}
&__controls {
display: flex;
align-items: center;
gap: $pad-medium;
}
.top-shift-header {
align-items: center;
}
@@ -1,4 +1,3 @@
.software-library {
@include vertical-page-tab-panel-layout;
margin-top: $gap-page-component; // Required as these Tabs don't use TabPanel
@include vertical-page-layout;
}
+5
View File
@@ -80,6 +80,7 @@ import SoftwareInventory from "pages/SoftwarePage/SoftwareInventory";
import SoftwareOS from "pages/SoftwarePage/SoftwareOS";
import SoftwareVulnerabilities from "pages/SoftwarePage/SoftwareVulnerabilities";
import SoftwareLibrary from "pages/SoftwarePage/SoftwareLibrary";
import SelfServiceCategoriesPage from "pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage";
import SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPage";
import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage";
import TeamSettings from "pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamSettings";
@@ -376,6 +377,10 @@ const routes = (
{/* Legacy redirect: keeps old /software/:id URLs working */}
<Redirect from=":id" to="versions/:id" />
</Route>
<Route
path="library/categories"
component={SelfServiceCategoriesPage}
/>
<Route path="titles/:id" component={SoftwareTitleDetailsPage} />
<Route path="versions/:id" component={SoftwareVersionDetailsPage} />
<Route path="os/:id" component={SoftwareOSDetailsPage} />
+1
View File
@@ -89,6 +89,7 @@ export default {
SOFTWARE_OS: `${URL_PREFIX}/software/os`,
SOFTWARE_VERSIONS: `${URL_PREFIX}/software/versions`,
SOFTWARE_LIBRARY: `${URL_PREFIX}/software/library`,
SOFTWARE_LIBRARY_CATEGORIES: `${URL_PREFIX}/software/library/categories`,
SOFTWARE_TITLE_DETAILS: (id: string): string => {
return `${URL_PREFIX}/software/titles/${id}`;
},
@@ -0,0 +1,44 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
import {
ICreateSelfServiceCategoryFormData,
IEditSelfServiceCategoryFormData,
ISelfServiceCategory,
} from "interfaces/self_service_category";
export interface ISelfServiceCategoriesResponse {
self_service_categories: ISelfServiceCategory[];
}
export interface ISelfServiceCategoryResponse {
self_service_category: ISelfServiceCategory;
}
export default {
getCategories: (fleetId: number): Promise<ISelfServiceCategoriesResponse> => {
const { SELF_SERVICE_CATEGORIES } = endpoints;
const queryString = buildQueryStringFromParams({ fleet_id: fleetId });
return sendRequest("GET", `${SELF_SERVICE_CATEGORIES}?${queryString}`);
},
addCategory: (
formData: ICreateSelfServiceCategoryFormData
): Promise<ISelfServiceCategoryResponse> => {
const { SELF_SERVICE_CATEGORIES } = endpoints;
return sendRequest("POST", SELF_SERVICE_CATEGORIES, formData);
},
updateCategory: (
id: number,
formData: IEditSelfServiceCategoryFormData
): Promise<ISelfServiceCategoryResponse> => {
const { SELF_SERVICE_CATEGORY } = endpoints;
return sendRequest("PATCH", SELF_SERVICE_CATEGORY(id), formData);
},
deleteCategory: (id: number) => {
const { SELF_SERVICE_CATEGORY } = endpoints;
return sendRequest("DELETE", SELF_SERVICE_CATEGORY(id));
},
};
@@ -0,0 +1,140 @@
import { http, HttpResponse } from "msw";
import { baseUrl } from "test/test-utils";
import {
ICreateSelfServiceCategoryFormData,
IEditSelfServiceCategoryFormData,
ISelfServiceCategory,
} from "interfaces/self_service_category";
const DEFAULT_TIMESTAMP = "2026-05-28T00:00:00Z";
const createMockSelfServiceCategory = (
overrides?: Partial<ISelfServiceCategory>
): ISelfServiceCategory => ({
id: 1,
name: "🌎 Browsers",
fleet_id: 0,
created_at: DEFAULT_TIMESTAMP,
updated_at: DEFAULT_TIMESTAMP,
...overrides,
});
const categoriesUrl = baseUrl("/software/self_service_categories");
const categoryByIdUrl = baseUrl("/software/self_service_categories/:id");
// GET /software/self_service_categories?fleet_id=:id
export const listSelfServiceCategoriesHandler = (
categories: Partial<ISelfServiceCategory>[] = [
{ id: 1, name: "🌎 Browsers" },
{ id: 2, name: "👬 Communication" },
{ id: 3, name: "🧰 Developer tools" },
{ id: 4, name: "💻 Productivity" },
{ id: 5, name: "🔐 Security" },
]
) =>
http.get(categoriesUrl, () =>
HttpResponse.json({
self_service_categories: categories.map((c) =>
createMockSelfServiceCategory(c)
),
})
);
export const emptySelfServiceCategoriesHandler = http.get(categoriesUrl, () =>
HttpResponse.json({ self_service_categories: [] })
);
// POST /software/self_service_categories
export const addSelfServiceCategoryHandler = http.post(
categoriesUrl,
async ({ request }) => {
const body = (await request.json()) as ICreateSelfServiceCategoryFormData;
return HttpResponse.json({
self_service_category: createMockSelfServiceCategory({
id: 99,
name: body.name,
fleet_id: body.fleet_id,
}),
});
}
);
export const addSelfServiceCategoryConflictHandler = http.post(
categoriesUrl,
() =>
HttpResponse.json(
{
errors: [
{
name: "name",
reason:
"A self-service category with this name already exists in this fleet.",
},
],
},
{ status: 409 }
)
);
export const addSelfServiceCategoryErrorHandler = http.post(categoriesUrl, () =>
HttpResponse.json(
{ errors: [{ name: "base", reason: "Internal Server Error" }] },
{ status: 500 }
)
);
// PATCH /software/self_service_categories/:id
export const editSelfServiceCategoryHandler = http.patch(
categoryByIdUrl,
async ({ request, params }) => {
const body = (await request.json()) as IEditSelfServiceCategoryFormData;
return HttpResponse.json({
self_service_category: createMockSelfServiceCategory({
id: Number(params.id),
name: body.name,
}),
});
}
);
export const editSelfServiceCategoryConflictHandler = http.patch(
categoryByIdUrl,
() =>
HttpResponse.json(
{
errors: [
{
name: "name",
reason:
"A self-service category with this name already exists in this fleet.",
},
],
},
{ status: 409 }
)
);
export const editSelfServiceCategoryErrorHandler = http.patch(
categoryByIdUrl,
() =>
HttpResponse.json(
{ errors: [{ name: "base", reason: "Internal Server Error" }] },
{ status: 500 }
)
);
// DELETE /software/self_service_categories/:id
export const deleteSelfServiceCategoryHandler = http.delete(
categoryByIdUrl,
() => new HttpResponse(null, { status: 204 })
);
export const deleteSelfServiceCategoryErrorHandler = http.delete(
categoryByIdUrl,
() =>
HttpResponse.json(
{ errors: [{ name: "base", reason: "Internal Server Error" }] },
{ status: 500 }
)
);
+5
View File
@@ -117,6 +117,11 @@ export default {
LABEL: (id: number) => `/${API_VERSION}/fleet/labels/${id}`,
LABELS: `/${API_VERSION}/fleet/labels`,
LABELS_SUMMARY: `/${API_VERSION}/fleet/labels/summary`,
// self-service categories
SELF_SERVICE_CATEGORIES: `/${API_VERSION}/fleet/software/self_service_categories`,
SELF_SERVICE_CATEGORY: (id: number) =>
`/${API_VERSION}/fleet/software/self_service_categories/${id}`,
LABEL_HOSTS: (id: number): string => {
return `/${API_VERSION}/fleet/labels/${id}/hosts`;
},
+1
View File
@@ -1315,6 +1315,7 @@ module.exports.routes = {
'GET /learn-more-about/ndes-scep-configuration-profile': '/guides/connect-end-user-to-wifi-with-certificate#step-2-add-scep-configuration-profile-to-fleet',
'GET /learn-more-about/macos-distribution-packages': 'https://scriptingosx.com/2017/09/on-distribution-packages/',
'GET /learn-more-about/self-service-software': '/guides/software-self-service',
'GET /learn-more-about/self-service-software-categories': '/guides/software-self-service#manage-self-service-categories',
'GET /learn-more-about/request-hydrant-certificate': '/docs/rest-api#request-certificate',
'GET /learn-more-about/yaml-software-setup-experience': '/docs/configuration/yaml-files#self-service-labels-categories-and-setup-experience',
'GET /learn-more-about/microsoft-compliance-partner': '/guides/entra-conditional-access-integration',