- ) => {
- router.push(
- getPathWithQueryParams(pathname, {
- category_id: option?.value !== "undefined" ? option?.value : undefined,
- query: queryParams.query,
- order_key: initialSortHeader,
- order_direction: initialSortDirection,
- page: 0, // Always reset to page 0 when searching
- })
- );
- };
-
const onClickFailedUpdateStatus = (hostSoftware: IHostSoftware) => {
const lastInstall = getLastInstall(hostSoftware);
@@ -582,28 +515,6 @@ const SoftwareSelfService = ({
onInstallOrUninstall();
};
- const onClientSidePaginationChange = useCallback(
- (page: number) => {
- router.push(
- getPathWithQueryParams(pathname, {
- query: queryParams.query,
- category_id: queryParams.category_id,
- order_key: initialSortHeader,
- order_direction: initialSortDirection,
- page,
- })
- );
- },
- [
- pathname,
- queryParams.query,
- queryParams.category_id,
- initialSortDirection,
- initialSortHeader,
- router,
- ]
- );
-
// TODO: handle empty state better, this is just a placeholder for now
// TODO: what should happen if query params are invalid (e.g., page is negative or exceeds the
// available results)?
@@ -636,109 +547,6 @@ const SoftwareSelfService = ({
onClickOpenInstructionsAction,
]);
- const renderSelfServiceCard = () => {
- const renderHeaderFilters = () => (
-
-
- ({
- ...category,
- value: String(category.id), // DropdownWrapper only accepts string
- }))}
- value={String(queryParams.category_id || 0)}
- onChange={onCategoriesDropdownChange}
- name="categories-dropdown"
- className={`${baseClass}__categories-dropdown`}
- />
-
- );
-
- const renderCategoriesMenu = () => (
-
- );
-
- if (isLoading) {
- return ;
- }
-
- if (isError) {
- return ; // Only shown on DeviceUserPage not HostDetailsPage
- }
-
- // No self-service software available hides categories menu and header filters
- if ((isEmpty || !selfServiceData) && !isFetching) {
- return (
- <>
-
- >
- );
- }
-
- return (
- <>
- {renderHeaderFilters()}
-
- {renderCategoriesMenu()}
-
{
- return isEmptySearch ? (
-
- Not finding what you're looking for?{" "}
-
- >
- }
- />
- ) : (
-
- );
- }}
- showMarkAllPages={false}
- isAllPagesSelected={false}
- disableTableHeader
- disableCount
- />
-
- >
- );
- };
-
return (
-
-
- {SELF_SERVICE_SUBHEADER}{" "}
- {contactUrl && (
-
- If you need help,{" "}
-
-
- )}
- >
- }
- />
- {renderSelfServiceCard()}
-
+
{showUninstallSoftwareModal && selectedSoftwareForUninstall.current && (
;
+ queryParams: SelfServiceQueryParams;
className?: string;
}
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/CategoriesMenu/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/CategoriesMenu/_styles.scss
similarity index 100%
rename from frontend/pages/hosts/details/cards/Software/SelfService/CategoriesMenu/_styles.scss
rename to frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/CategoriesMenu/_styles.scss
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/CategoriesMenu/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/CategoriesMenu/index.ts
similarity index 100%
rename from frontend/pages/hosts/details/cards/Software/SelfService/CategoriesMenu/index.ts
rename to frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/CategoriesMenu/index.ts
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx
new file mode 100644
index 0000000000..f54059f277
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx
@@ -0,0 +1,211 @@
+// State is passed in through tableConfig which is tested in the parent component's tests (SelfService.tests.tsx)
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { createCustomRenderer, createMockRouter } from "test/test-utils";
+import { createMockDeviceSoftware } from "__mocks__/deviceUserMock";
+
+import SelfServiceCard, {
+ SelfServiceQueryParams,
+ ISelfServiceCardProps,
+} from "./SelfServiceCard";
+
+const createMockTableConfig = () => [
+ {
+ title: "Name",
+ accessor: "name",
+ disableHidden: false,
+ },
+ {
+ title: "Status",
+ accessor: "status",
+ disableHidden: false,
+ },
+ {
+ title: "Actions",
+ accessor: "actions",
+ disableHidden: false,
+ },
+];
+
+const DEFAULT_QUERY_PARAMS: SelfServiceQueryParams = {
+ page: 0,
+ query: "",
+ order_key: "name",
+ order_direction: "asc",
+ per_page: 20,
+ category_id: undefined,
+};
+
+const createTestProps = (
+ overrides: Partial = {}
+): ISelfServiceCardProps => ({
+ contactUrl: "http://example.com/contact",
+ queryParams: DEFAULT_QUERY_PARAMS,
+ enhancedSoftware: [
+ { ...createMockDeviceSoftware({ name: "test1" }), ui_status: "installed" },
+ { ...createMockDeviceSoftware({ name: "test2" }), ui_status: "installed" },
+ { ...createMockDeviceSoftware({ name: "test3" }), ui_status: "installed" },
+ ],
+ selfServiceData: {
+ count: 3,
+ software: [
+ createMockDeviceSoftware({ name: "test1" }),
+ createMockDeviceSoftware({ name: "test2" }),
+ createMockDeviceSoftware({ name: "test3" }),
+ ],
+ meta: {
+ has_previous_results: false,
+ has_next_results: false,
+ },
+ },
+ tableConfig: createMockTableConfig(),
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ isEmpty: false,
+ isEmptySearch: false,
+ router: createMockRouter(),
+ pathname: "/device/software",
+ ...overrides,
+});
+
+describe("SelfServiceCard", () => {
+ it("renders loading spinner when isLoading is true", () => {
+ const props = createTestProps({ isLoading: true });
+ const render = createCustomRenderer();
+
+ render();
+
+ expect(screen.getByTestId("spinner")).toBeInTheDocument();
+ });
+
+ it("renders error state when isError is true", () => {
+ const props = createTestProps({ isError: true });
+ const render = createCustomRenderer();
+
+ render();
+
+ expect(screen.getByText("Error loading software.")).toBeInTheDocument();
+ });
+
+ it("renders empty state when isEmpty is true", () => {
+ const props = createTestProps({
+ isEmpty: true,
+ enhancedSoftware: [],
+ selfServiceData: undefined,
+ isFetching: false,
+ });
+ const render = createCustomRenderer();
+
+ render();
+
+ expect(
+ screen.getByText("No self-service software available yet")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Your organization didn’t add any self-service software./i
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("renders self-service card with header and subheader", () => {
+ const props = createTestProps();
+ const render = createCustomRenderer();
+
+ render();
+
+ expect(screen.getByText("Self-service")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Install organization-approved apps provided by your IT department/
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("renders contact link when contactUrl is provided", () => {
+ const props = createTestProps({ contactUrl: "http://example.com/help" });
+ const render = createCustomRenderer();
+
+ render();
+
+ const link = screen.getByRole("link", { name: /reach out to IT/i });
+ expect(link).toHaveAttribute("href", "http://example.com/help");
+ expect(link).toHaveAttribute("target", "_blank");
+ });
+
+ it("does not render contact link when contactUrl is empty", () => {
+ const props = createTestProps({ contactUrl: "" });
+ const render = createCustomRenderer();
+
+ render();
+
+ expect(
+ screen.queryByRole("link", { name: /reach out to IT/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders search field with correct placeholder and default value", () => {
+ const props = createTestProps({
+ queryParams: { ...DEFAULT_QUERY_PARAMS, query: "test search" },
+ });
+ const render = createCustomRenderer();
+
+ render();
+
+ const searchField = screen.getByPlaceholderText("Search by name");
+ expect(searchField).toBeInTheDocument();
+ expect(searchField).toHaveValue("test search");
+ });
+
+ it("calls router.push when category dropdown changes", async () => {
+ const mockRouter = createMockRouter();
+ const props = createTestProps({ router: mockRouter });
+ const render = createCustomRenderer();
+ const user = userEvent.setup();
+
+ render();
+
+ const dropdown = screen.getByRole("combobox");
+ await user.click(dropdown);
+
+ // Note: This test might need adjustment based on your dropdown implementation
+ expect(mockRouter.push).toHaveBeenCalled();
+ });
+
+ it("renders empty search state when isEmptySearch is true", () => {
+ const props = createTestProps({
+ isEmptySearch: true,
+ enhancedSoftware: [],
+ queryParams: { ...DEFAULT_QUERY_PARAMS, query: "nonexistent" },
+ });
+ const render = createCustomRenderer();
+
+ render();
+
+ expect(screen.getByText("No items match your search")).toBeInTheDocument();
+ expect(
+ screen.getByText(/Not finding what you're looking for/)
+ ).toBeInTheDocument();
+
+ const contactLink = screen.getAllByRole("link", {
+ name: /Reach out to IT/i,
+ });
+ expect(contactLink[0]).toHaveAttribute("href", props.contactUrl);
+ });
+
+ it("renders categories menu component", () => {
+ const props = createTestProps();
+ const render = createCustomRenderer();
+
+ render();
+
+ expect(screen.getAllByText(/Browsers/i).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/Communication/i).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/Productivity/i).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/Developer tools/i).length).toBeGreaterThan(0);
+ });
+});
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx
new file mode 100644
index 0000000000..65948f6faa
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx
@@ -0,0 +1,248 @@
+import React, { useCallback } from "react";
+import { InjectedRouter } from "react-router";
+
+import { getPathWithQueryParams } from "utilities/url";
+import { SingleValue } from "react-select-5";
+import { IDeviceSoftwareWithUiStatus } from "interfaces/software";
+import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
+
+import Card from "components/Card";
+import CardHeader from "components/CardHeader";
+import Spinner from "components/Spinner";
+import EmptyTable from "components/EmptyTable";
+import EmptySoftwareTable from "pages/SoftwarePage/components/tables/EmptySoftwareTable";
+import TableContainer from "components/TableContainer";
+import { ITableQueryData } from "components/TableContainer/TableContainer";
+
+import SearchField from "components/forms/fields/SearchField";
+import DropdownWrapper, {
+ CustomOptionType,
+} from "components/forms/fields/DropdownWrapper/DropdownWrapper";
+import CustomLink from "components/CustomLink";
+
+import CategoriesMenu from "./CategoriesMenu/CategoriesMenu";
+import { filterSoftwareByCategory, CATEGORIES_NAV_ITEMS } from "./../helpers";
+
+const baseClass = "software-self-service";
+
+export interface SelfServiceQueryParams {
+ query: string;
+ category_id?: number;
+ order_key: string;
+ order_direction: "asc" | "desc";
+ page: number;
+ per_page: number;
+}
+
+export interface ISelfServiceCardProps {
+ contactUrl: string;
+ queryParams: SelfServiceQueryParams;
+ enhancedSoftware: IDeviceSoftwareWithUiStatus[];
+ selfServiceData?: IGetDeviceSoftwareResponse;
+ tableConfig: any;
+ isLoading: boolean;
+ isError: boolean;
+ isFetching: boolean;
+ isEmpty: boolean;
+ isEmptySearch: boolean;
+ router: InjectedRouter;
+ pathname: string;
+}
+
+const SelfServiceCard = ({
+ contactUrl,
+ queryParams,
+ enhancedSoftware,
+ selfServiceData,
+ tableConfig,
+ isLoading,
+ isError,
+ isFetching,
+ isEmpty,
+ isEmptySearch,
+ router,
+ pathname,
+}: ISelfServiceCardProps) => {
+ const initialSortHeader = queryParams.order_key || "name";
+ const initialSortDirection = queryParams.order_direction || "asc";
+ const initialSortPage = queryParams.page || 0;
+
+ const onClientSidePaginationChange = useCallback(
+ (page: number) => {
+ router.push(
+ getPathWithQueryParams(pathname, {
+ query: queryParams.query,
+ category_id: queryParams.category_id,
+ order_key: initialSortHeader,
+ order_direction: initialSortDirection,
+ page,
+ })
+ );
+ },
+ [
+ pathname,
+ queryParams.query,
+ queryParams.category_id,
+ initialSortDirection,
+ initialSortHeader,
+ router,
+ ]
+ );
+
+ const onSearchQueryChange = (value: string) => {
+ router.push(
+ getPathWithQueryParams(pathname, {
+ query: value,
+ category_id: queryParams.category_id,
+ order_key: initialSortHeader,
+ order_direction: initialSortDirection,
+ page: 0, // Always reset to page 0 when searching
+ })
+ );
+ };
+
+ const onSortChange = ({ sortHeader, sortDirection }: ITableQueryData) => {
+ router.push(
+ getPathWithQueryParams(pathname, {
+ ...queryParams,
+ order_key: sortHeader,
+ order_direction: sortDirection,
+ query: queryParams.query !== undefined ? queryParams.query : undefined,
+ category_id:
+ queryParams.category_id !== undefined
+ ? queryParams.category_id
+ : undefined,
+ page: 0, // Always reset to page 0 when sorting
+ })
+ );
+ };
+
+ const onCategoriesDropdownChange = (
+ option: SingleValue
+ ) => {
+ router.push(
+ getPathWithQueryParams(pathname, {
+ category_id: option?.value !== "undefined" ? option?.value : undefined,
+ query: queryParams.query,
+ order_key: initialSortHeader,
+ order_direction: initialSortDirection,
+ page: 0, // Always reset to page 0 when searching
+ })
+ );
+ };
+
+ const renderHeaderFilters = () => (
+
+
+ ({
+ ...category,
+ value: String(category.id),
+ }))}
+ value={String(queryParams.category_id || 0)}
+ onChange={onCategoriesDropdownChange}
+ name="categories-dropdown"
+ className={`${baseClass}__categories-dropdown`}
+ />
+
+ );
+
+ const renderCategoriesMenu = () => (
+
+ );
+
+ if (isLoading) return ;
+ if (isError) return ;
+
+ // Empty state
+ if ((isEmpty || !selfServiceData) && !isFetching) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Install organization-approved apps provided by your IT department.{" "}
+ {contactUrl && (
+
+ If you need help,{" "}
+
+
+ )}
+ >
+ }
+ />
+ {renderHeaderFilters()}
+
+ {renderCategoriesMenu()}
+
+ isEmptySearch ? (
+
+ Not finding what you're looking for?{" "}
+
+ >
+ }
+ />
+ ) : (
+
+ )
+ }
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
+ disableTableHeader
+ disableCount
+ />
+
+
+ );
+};
+
+export default SelfServiceCard;
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx
index 19c06c3106..1cff7f9861 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx
@@ -18,7 +18,7 @@ import InstallStatusCell from "../InstallStatusCell/InstallStatusCell";
import { installStatusSortType } from "../helpers";
import HostInstallerActionCell from "../../HostSoftwareLibrary/HostInstallerActionCell/HostInstallerActionCell";
-type ISoftwareTableConfig = Column;
+type ISelfServiceTableConfig = Column;
type ITableHeaderProps = IHeaderProps;
type ITableStringCellProps = IStringCellProps;
type IStatusCellProps = CellProps<
@@ -60,8 +60,8 @@ export const generateSoftwareTableHeaders = ({
onClickInstallAction,
onClickUninstallAction,
onClickOpenInstructionsAction,
-}: ISelfServiceTableHeaders): ISoftwareTableConfig[] => {
- const tableHeaders: ISoftwareTableConfig[] = [
+}: ISelfServiceTableHeaders): ISelfServiceTableConfig[] => {
+ const tableHeaders: ISelfServiceTableConfig[] = [
{
Header: (cellProps: ITableHeaderProps) => (