From 9680221695fe1549abc9557757dcd596f3051548 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:46:03 -0400 Subject: [PATCH] Fleet UI: View, add, edit, delete custom categories (#46443) --- changes/46370-self-service-categories-page | 2 + frontend/components/DataError/DataError.tsx | 7 +- frontend/components/DataError/_styles.scss | 4 + frontend/interfaces/self_service_category.ts | 16 + .../AddCategoryModal/AddCategoryModal.tsx | 92 ++++ .../AddCategoryModal/_styles.scss | 5 + .../AddCategoryModal/index.ts | 1 + .../DeleteCategoryModal.tsx | 71 +++ .../DeleteCategoryModal/_styles.scss | 7 + .../DeleteCategoryModal/index.ts | 1 + .../EditCategoryModal/EditCategoryModal.tsx | 93 ++++ .../EditCategoryModal/_styles.scss | 5 + .../EditCategoryModal/index.ts | 1 + .../SelfServiceCategoriesPage.tests.tsx | 500 ++++++++++++++++++ .../SelfServiceCategoriesPage.tsx | 279 ++++++++++ .../SelfServiceCategoriesPage/_styles.scss | 71 +++ .../SelfServiceCategoriesPage/index.ts | 1 + .../SoftwareLibraryTable.tests.tsx | 36 ++ .../SoftwareLibraryTable.tsx | 28 +- .../SoftwareLibraryTable/_styles.scss | 8 + .../SoftwarePage/SoftwareLibrary/_styles.scss | 3 +- frontend/router/index.tsx | 5 + frontend/router/paths.ts | 1 + .../entities/self_service_categories.ts | 44 ++ .../self-service-categories-handlers.ts | 140 +++++ frontend/utilities/endpoints.ts | 5 + website/config/routes.js | 1 + 27 files changed, 1417 insertions(+), 10 deletions(-) create mode 100644 changes/46370-self-service-categories-page create mode 100644 frontend/interfaces/self_service_category.ts create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/_styles.scss create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/index.ts create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/_styles.scss create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/index.ts create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/EditCategoryModal.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/_styles.scss create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/index.ts create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/_styles.scss create mode 100644 frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/index.ts create mode 100644 frontend/services/entities/self_service_categories.ts create mode 100644 frontend/test/handlers/self-service-categories-handlers.ts diff --git a/changes/46370-self-service-categories-page b/changes/46370-self-service-categories-page new file mode 100644 index 0000000000..13feae6435 --- /dev/null +++ b/changes/46370-self-service-categories-page @@ -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. diff --git a/frontend/components/DataError/DataError.tsx b/frontend/components/DataError/DataError.tsx index e68c187668..0e0c0ff510 100644 --- a/frontend/components/DataError/DataError.tsx +++ b/frontend/components/DataError/DataError.tsx @@ -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 ( diff --git a/frontend/components/DataError/_styles.scss b/frontend/components/DataError/_styles.scss index 3c924a89a7..40ec2eadf7 100644 --- a/frontend/components/DataError/_styles.scss +++ b/frontend/components/DataError/_styles.scss @@ -3,6 +3,10 @@ flex-direction: column; align-items: center; + &--self-center { + align-self: center; + } + &__vertical-pad-small { padding: $pad-small 0; } diff --git a/frontend/interfaces/self_service_category.ts b/frontend/interfaces/self_service_category.ts new file mode 100644 index 0000000000..077f6d5fc2 --- /dev/null +++ b/frontend/interfaces/self_service_category.ts @@ -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; +} diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx new file mode 100644 index 0000000000..f771beeef4 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx @@ -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(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 ( + +
+ +
+ + +
+ +
+ ); +}; + +export default AddCategoryModal; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/_styles.scss new file mode 100644 index 0000000000..a996b14674 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/_styles.scss @@ -0,0 +1,5 @@ +.add-category-modal { + &__form { + @include vertical-form-layout; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/index.ts b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/index.ts new file mode 100644 index 0000000000..0595cced5d --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AddCategoryModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx new file mode 100644 index 0000000000..a05a7e7e6d --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx @@ -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 ( + + <> +

+ The category will be removed from all associated software. +

+
+ + +
+ +
+ ); +}; + +export default DeleteCategoryModal; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/_styles.scss new file mode 100644 index 0000000000..1985adeaed --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/_styles.scss @@ -0,0 +1,7 @@ +.delete-category-modal { + &__body { + margin: 0; + color: $core-fleet-black; + font-size: $x-small; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/index.ts b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/index.ts new file mode 100644 index 0000000000..003a6f74d8 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteCategoryModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/EditCategoryModal.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/EditCategoryModal.tsx new file mode 100644 index 0000000000..bd23d6383a --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/EditCategoryModal.tsx @@ -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(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 ( + +
+ +
+ + +
+ +
+ ); +}; + +export default EditCategoryModal; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/_styles.scss new file mode 100644 index 0000000000..d509419043 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/_styles.scss @@ -0,0 +1,5 @@ +.edit-category-modal { + &__form { + @include vertical-form-layout; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/index.ts b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/index.ts new file mode 100644 index 0000000000..eb441cbed5 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/EditCategoryModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EditCategoryModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx new file mode 100644 index 0000000000..3ff474efe6 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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." + ); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx new file mode 100644 index 0000000000..950a704db3 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx @@ -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(null); + const [ + categoryToDelete, + setCategoryToDelete, + ] = useState(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 = () => ( + <> + + {!isPrimoMode && ( +
+ +
+ )} + + Manage self-service categories.{" "} + + + } + /> + + ); + + const renderBody = () => { + if (!isPremiumTier) { + return ( +
+ +
+ ); + } + + if (!isRouteOk || isLoading) { + return ; + } + + if (isError) { + return ; + } + + const categories = categoriesData?.self_service_categories ?? []; + const hasCategories = categories.length > 0; + + if (!hasCategories) { + return ( + setShowAddModal(true)}> + Add category + + ) : undefined + } + /> + ); + } + + return ( + + className={`${baseClass}__list`} + keyAttribute="id" + listItems={categories} + HeadingComponent={() => ( +
+ + Self-service categories + + {canManage && ( + + )} +
+ )} + ListItemComponent={({ listItem }) => ( +
+ {listItem.name} + {canManage && ( +
+ + +
+ )} +
+ )} + /> + ); + }; + + return ( + + <> + {renderHeader()} + {renderBody()} + + {showAddModal && ( + setShowAddModal(false)} + onSuccess={onAddSuccess} + /> + )} + + {categoryToEdit && ( + setCategoryToEdit(null)} + onSuccess={onEditSuccess} + /> + )} + + {categoryToDelete && ( + setCategoryToDelete(null)} + onSuccess={onDeleteSuccess} + /> + )} + + + ); +}; + +export default SelfServiceCategoriesPage; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/_styles.scss new file mode 100644 index 0000000000..a733d1f1fb --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/_styles.scss @@ -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; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/index.ts b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/index.ts new file mode 100644 index 0000000000..3205cecfea --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/index.ts @@ -0,0 +1 @@ +export { default } from "./SelfServiceCategoriesPage"; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tests.tsx index 35b7bbf191..3eb7972a6d 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tests.tsx @@ -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( + + ); + + 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: { diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tsx index 6d52159153..c0f0f320b3 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tsx @@ -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 ( - +
+ + +
); }; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/_styles.scss index 84d50bc541..373328a6f5 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/_styles.scss @@ -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; } diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/_styles.scss index 69556fb595..b3d9a694e7 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/_styles.scss @@ -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; } diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index d0800ce778..b78f4e1c5f 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -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 */} + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index cb5d5cde55..57b69682a9 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -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}`; }, diff --git a/frontend/services/entities/self_service_categories.ts b/frontend/services/entities/self_service_categories.ts new file mode 100644 index 0000000000..658d14b381 --- /dev/null +++ b/frontend/services/entities/self_service_categories.ts @@ -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 => { + const { SELF_SERVICE_CATEGORIES } = endpoints; + const queryString = buildQueryStringFromParams({ fleet_id: fleetId }); + return sendRequest("GET", `${SELF_SERVICE_CATEGORIES}?${queryString}`); + }, + + addCategory: ( + formData: ICreateSelfServiceCategoryFormData + ): Promise => { + const { SELF_SERVICE_CATEGORIES } = endpoints; + return sendRequest("POST", SELF_SERVICE_CATEGORIES, formData); + }, + + updateCategory: ( + id: number, + formData: IEditSelfServiceCategoryFormData + ): Promise => { + 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)); + }, +}; diff --git a/frontend/test/handlers/self-service-categories-handlers.ts b/frontend/test/handlers/self-service-categories-handlers.ts new file mode 100644 index 0000000000..41609d3cbb --- /dev/null +++ b/frontend/test/handlers/self-service-categories-handlers.ts @@ -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 => ({ + 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[] = [ + { 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 } + ) +); diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 6cf2abd3e5..1ddd4be7c0 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -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`; }, diff --git a/website/config/routes.js b/website/config/routes.js index 1569da478b..0d83286e84 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -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',