Fleet UI: View, add, edit, delete custom categories (#46443)
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
+92
@@ -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;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
.add-category-modal {
|
||||
&__form {
|
||||
@include vertical-form-layout;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./AddCategoryModal";
|
||||
+71
@@ -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;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.delete-category-modal {
|
||||
&__body {
|
||||
margin: 0;
|
||||
color: $core-fleet-black;
|
||||
font-size: $x-small;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./DeleteCategoryModal";
|
||||
+93
@@ -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;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
.edit-category-modal {
|
||||
&__form {
|
||||
@include vertical-form-layout;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./EditCategoryModal";
|
||||
+500
@@ -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."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+279
@@ -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";
|
||||
+36
@@ -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: {
|
||||
|
||||
+21
-7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
);
|
||||
@@ -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`;
|
||||
},
|
||||
|
||||
Vendored
+1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user