diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx index 1c65d84494..a212fe692f 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -74,17 +74,6 @@ describe("SelfService", () => { expect(screen.getAllByText("test3")).toHaveLength(2); }); - it("should render the contact link text for self-service section if contact url is provided", () => { - mockServer.use(customDeviceSoftwareHandler()); - - const render = createCustomRenderer({ withBackendMock: true }); - render(); - - const link = screen.getByRole("link", { name: /reach out to IT/i }); - - expect(link).toHaveAttribute("href", "http://example.com"); - }); - it("renders installed status and 'Reinstall' action button and 'More' dropdown with 'installed' status and installed_versions", async () => { mockServer.use( customDeviceSoftwareHandler({ diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx index 2b50dea901..68d2fdfde9 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -25,31 +25,16 @@ import deviceApi, { } from "services/entities/device_user"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; -import { getPathWithQueryParams } from "utilities/url"; import { getExtensionFromFileName } from "utilities/file/fileUtils"; -import { SingleValue } from "react-select-5"; -import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper"; -import TableContainer from "components/TableContainer"; -import EmptySoftwareTable from "pages/SoftwarePage/components/tables/EmptySoftwareTable"; - -import Card from "components/Card"; -import CardHeader from "components/CardHeader"; -import CustomLink from "components/CustomLink"; -import DeviceUserError from "components/DeviceUserError"; -import EmptyTable from "components/EmptyTable"; -import Spinner from "components/Spinner"; -import SearchField from "components/forms/fields/SearchField"; -import DropdownWrapper from "components/forms/fields/DropdownWrapper"; - import SoftwareUninstallDetailsModal, { ISWUninstallDetailsParentState, } from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal"; import SoftwareInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal"; import { VppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/VppInstallDetailsModal/VppInstallDetailsModal"; -import { ITableQueryData } from "components/TableContainer/TableContainer"; import UpdatesCard from "./UpdatesCard/UpdatesCard"; +import SelfServiceCard from "./SelfServiceCard/SelfServiceCard"; import SoftwareUpdateModal from "../SoftwareUpdateModal"; import UninstallSoftwareModal from "./UninstallSoftwareModal"; import SoftwareInstructionsModal from "./OpenSoftwareModal"; @@ -57,12 +42,6 @@ import SoftwareInstructionsModal from "./OpenSoftwareModal"; import { generateSoftwareTableHeaders } from "./SelfServiceTableConfig"; import { getLastInstall } from "../../HostSoftwareLibrary/helpers"; -import { - CATEGORIES_NAV_ITEMS, - filterSoftwareByCategory, - ICategory, -} from "./helpers"; -import CategoriesMenu from "./CategoriesMenu"; import { getUiStatus } from "../helpers"; const baseClass = "software-self-service"; @@ -150,10 +129,6 @@ const SoftwareSelfService = ({ }: ISoftwareSelfServiceProps) => { const { renderFlash, renderMultiFlash } = useContext(NotificationContext); - const initialSortHeader = queryParams.order_key || "name"; - const initialSortDirection = queryParams.order_direction || "asc"; - const initialSortPage = queryParams.page || 0; - const [selfServiceData, setSelfServiceData] = useState< IGetDeviceSoftwareResponse | undefined >(undefined); @@ -505,48 +480,6 @@ const SoftwareSelfService = ({ [setSelectedHostSWUninstallDetails] ); - 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 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) => (