From de0562a686feee270d4189d6e8bb0b4ed4f57791 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 11 Jun 2024 12:56:50 +0100 Subject: [PATCH] UI code cleanup and tests for self service feature (#19487) various code cleanup tasks for the self service UI. Also adds some tests for self service. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- frontend/__mocks__/deviceUserMock.ts | 41 +++++++++ .../DataTable/SoftwareNameCell/_styles.scss | 2 - .../TooltipWrapper/TooltipWrapper.tsx | 10 ++- .../components/TooltipWrapper/_styles.scss | 5 ++ frontend/interfaces/software.ts | 8 +- .../SoftwarePackageCard.tsx | 5 +- .../SoftwarePackageCard/_styles.scss | 3 +- .../SoftwareTable/SoftwareTable.tsx | 3 - .../SelfService/SelfService.tests.tsx | 83 +++++++++++++++++++ .../Software/SelfService/SelfService.tsx | 22 ++--- frontend/test/default-handlers.ts | 5 ++ frontend/test/handlers/device-handler.ts | 12 ++- frontend/test/test-utils.tsx | 20 +++++ frontend/utilities/endpoints.ts | 2 - 14 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx diff --git a/frontend/__mocks__/deviceUserMock.ts b/frontend/__mocks__/deviceUserMock.ts index a176b4ebc0..38017864fd 100644 --- a/frontend/__mocks__/deviceUserMock.ts +++ b/frontend/__mocks__/deviceUserMock.ts @@ -1,4 +1,6 @@ import { IDeviceUser } from "interfaces/host"; +import { IDeviceSoftware } from "interfaces/software"; +import { IGetDeviceSoftwareResponse } from "services/entities/device_user"; const DEFAULT_DEVICE_USER_MOCK: IDeviceUser = { email: "test@test.com", @@ -11,4 +13,43 @@ const createMockDeviceUser = ( return { ...DEFAULT_DEVICE_USER_MOCK, ...overrides }; }; +const DEFAULT_DEVICE_SOFTWARE_MOCK: IDeviceSoftware = { + id: 1, + name: "mock software 1.app", + self_service: false, + source: "apps", + bundle_identifier: "com.app.mock", + status: null, + last_install: null, + installed_versions: null, + package: { + name: "mock software 1", + version: "1.0.0", + }, +}; + +export const createMockDeviceSoftware = ( + overrides?: Partial +) => { + return { ...DEFAULT_DEVICE_SOFTWARE_MOCK, ...overrides }; +}; + +const DEFAULT_DEVICE_SOFTWARE_RESPONSE_MOCK = { + software: [createMockDeviceSoftware()], + count: 0, + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockDeviceSoftwareResponse = ( + overrides?: Partial +) => { + return { + ...DEFAULT_DEVICE_SOFTWARE_RESPONSE_MOCK, + ...overrides, + }; +}; + export default createMockDeviceUser; diff --git a/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss b/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss index 23a3f5a6a0..0b9ede8371 100644 --- a/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss +++ b/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss @@ -10,8 +10,6 @@ .software-icon { width: 24px; height: 24px; - border: 1px solid $ui-fleet-black-10; - border-radius: 8px; } &__install-icon { diff --git a/frontend/components/TooltipWrapper/TooltipWrapper.tsx b/frontend/components/TooltipWrapper/TooltipWrapper.tsx index 1fee1e01b1..bb989a0c5d 100644 --- a/frontend/components/TooltipWrapper/TooltipWrapper.tsx +++ b/frontend/components/TooltipWrapper/TooltipWrapper.tsx @@ -23,10 +23,14 @@ interface ITooltipWrapper { tipContent: React.ReactNode; /** If set to `true`, will not show the tooltip. This can be used to dynamically * disable the tooltip from the parent component. - * * @default false */ disableTooltip?: boolean; + /** If set to `true`, will show the arrow on the tooltip. + * This can be used to dynamically hide the arrow from the parent component. + * @default false + */ + showArrow?: boolean; } const baseClass = "component__tooltip-wrapper"; @@ -44,8 +48,10 @@ const TooltipWrapper = ({ tooltipClass, clickable = true, disableTooltip = false, + showArrow = false, }: ITooltipWrapper) => { const wrapperClassNames = classnames(baseClass, className, { + "show-arrow": showArrow, // [`${baseClass}__${wrapperCustomClass}`]: !!wrapperCustomClass, }); @@ -71,7 +77,7 @@ const TooltipWrapper = ({ id={tipId} delayShow={isDelayed ? 500 : undefined} delayHide={isDelayed ? 500 : undefined} - noArrow + noArrow={!showArrow} place={position} opacity={1} disableStyleInjection diff --git a/frontend/components/TooltipWrapper/_styles.scss b/frontend/components/TooltipWrapper/_styles.scss index 95a82a9abd..b9132eaabc 100644 --- a/frontend/components/TooltipWrapper/_styles.scss +++ b/frontend/components/TooltipWrapper/_styles.scss @@ -1,4 +1,9 @@ .component__tooltip-wrapper { + + &.show-arrow { + @include tooltip5-arrow-styles; + } + display: inline-flex; &__element { diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index bb626b1c2a..91e45130bc 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -232,10 +232,12 @@ export interface IHostSoftware { installed_versions: ISoftwareInstallVersion[] | null; } -export interface IDeviceSoftware extends IHostSoftware { - package_available_for_install: never; +export type IDeviceSoftware = Omit< + IHostSoftware, + "package_available_for_install" +> & { package: { name: string; version: string; }; -} +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 52bb8d0015..7336d16a67 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -35,8 +35,7 @@ import AdvancedOptionsModal from "../AdvancedOptionsModal"; const baseClass = "software-package-card"; /** TODO: pull this hook and SoftwareName component out. We could use this other places */ - -function useTruncatedElement(ref: any) { +function useTruncatedElement(ref: React.RefObject) { const [isTruncated, setIsTruncated] = useState(false); useLayoutEffect(() => { @@ -64,6 +63,7 @@ const SoftwareName = ({ name }: ISoftwareNameProps) => { position="top" underline={false} disableTooltip={!isTruncated} + showArrow >
{name} @@ -125,6 +125,7 @@ const PackageStatusCount = ({ position="top" tipContent={displayData.tooltip} underline={false} + showArrow >
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss index bc67f06ab7..6897dffe7b 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss @@ -24,7 +24,8 @@ &__title { font-size: $x-small; font-weight: $bold; - @include ellipse-text(290px); + @include ellipse-text; + max-width: 290px; } &__details { diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx index c67000d045..81e4850763 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx @@ -353,9 +353,6 @@ const SoftwareTable = ({ pageSize={perPage} showMarkAllPages={false} isAllPagesSelected={false} - disablePagination={ - !data?.meta.has_next_results && !data?.meta.has_previous_results - } disableNextPage={!data?.meta.has_next_results} searchable={searchable} inputPlaceHolder="Search by name or vulnerabilities (CVEs)" diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx new file mode 100644 index 0000000000..b1de958e24 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer, createMockRouter } from "test/test-utils"; +import mockServer from "test/mock-server"; +import { customDeviceSoftwareHandler } from "test/handlers/device-handler"; +import { createMockDeviceSoftware } from "__mocks__/deviceUserMock"; + +import SelfService from "./SelfService"; + +describe("SelfService", () => { + it("should render the self service items correctly", async () => { + mockServer.use( + customDeviceSoftwareHandler({ + software: [ + createMockDeviceSoftware({ name: "test1" }), + createMockDeviceSoftware({ name: "test2" }), + createMockDeviceSoftware({ name: "test3" }), + ], + count: 3, + }) + ); + + const render = createCustomRenderer({ withBackendMock: true }); + + render( + + ); + + // waiting for the device software data to render + await screen.findByText("test1"); + + expect(true).toBe(true); + expect(screen.getByText("test1")).toBeInTheDocument(); + expect(screen.getByText("test2")).toBeInTheDocument(); + expect(screen.getByText("test3")).toBeInTheDocument(); + expect(screen.getByText("3 items")).toBeInTheDocument(); + screen.debug(); + }); + + it("should render the contact link text if contact url is provided", () => { + mockServer.use(customDeviceSoftwareHandler()); + + const render = createCustomRenderer({ withBackendMock: true }); + + const expectedUrl = "http://example.com"; + + render( + + ); + + expect(screen.getByText("reach out to IT")).toBeInTheDocument(); + expect(screen.getByText("reach out to IT").getAttribute("href")).toBe( + expectedUrl + ); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx index f83f9d3b0d..c7d5f63098 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -32,6 +32,15 @@ const DEFAULT_SELF_SERVICE_QUERY_PARAMS = { self_service: true, } as const; +interface ISoftwareSelfServiceProps { + contactUrl: string; + deviceToken: string; + isSoftwareEnabled?: boolean; + pathname: string; + queryParams: ReturnType; + router: InjectedRouter; +} + const SoftwareSelfService = ({ contactUrl, deviceToken, @@ -39,15 +48,7 @@ const SoftwareSelfService = ({ pathname, queryParams, router, -}: { - contactUrl: string; // TODO: confirm this has been added to the device API response - deviceToken: string; - isSoftwareEnabled?: boolean; - pathname: string; - queryParams: ReturnType; - router: InjectedRouter; -}) => { - // TOOD: loading state for fetching? +}: ISoftwareSelfServiceProps) => { const { data, isLoading, isError, refetch } = useQuery< IGetDeviceSoftwareResponse, AxiosError, @@ -121,7 +122,8 @@ const SoftwareSelfService = ({
{data.software.map((s) => { - const key = `${s.id}${s.last_install?.install_uuid}`; // concatenating install_uuid so item updates with fresh data on refetch + // concatenating install_uuid so item updates with fresh data on refetch + const key = `${s.id}${s.last_install?.install_uuid}`; return ( { return `/api/latest/fleet${path}`; }; +// These are the default handlers that are used when testing the frontend. They +// are used to mock the responses from the Fleet API when running tests. +// These can be overridden in individual tests using the .use() method on the +// mock server within the desired test. +// More info on .use() here: https://mswjs.io/docs/api/setup-worker/use/ const handlers = [ defaultDeviceHandler, defaultDeviceMappingHandler, diff --git a/frontend/test/handlers/device-handler.ts b/frontend/test/handlers/device-handler.ts index 306c67a01d..8b92a57c78 100644 --- a/frontend/test/handlers/device-handler.ts +++ b/frontend/test/handlers/device-handler.ts @@ -1,11 +1,14 @@ import { rest } from "msw"; -import createMockDeviceUser from "__mocks__/deviceUserMock"; +import createMockDeviceUser, { + createMockDeviceSoftwareResponse, +} from "__mocks__/deviceUserMock"; import createMockHost from "__mocks__/hostMock"; import createMockLicense from "__mocks__/licenseMock"; import createMockMacAdmins from "__mocks__/macAdminsMock"; import { baseUrl } from "test/test-utils"; import { IDeviceUserResponse } from "interfaces/host"; +import { IGetDeviceSoftwareResponse } from "services/entities/device_user"; export const defaultDeviceHandler = rest.get( baseUrl("/device/:token"), @@ -64,3 +67,10 @@ export const defaultMacAdminsHandler = rest.get( ); } ); + +export const customDeviceSoftwareHandler = ( + overrides?: Partial +) => + rest.get(baseUrl("/device/:token/software"), (req, res, context) => { + return res(context.json(createMockDeviceSoftwareResponse(overrides))); + }); diff --git a/frontend/test/test-utils.tsx b/frontend/test/test-utils.tsx index 4111300afc..ce7bde4298 100644 --- a/frontend/test/test-utils.tsx +++ b/frontend/test/test-utils.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { InjectedRouter } from "react-router"; import { render, RenderOptions, RenderResult } from "@testing-library/react"; import type { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; import userEvent from "@testing-library/user-event"; @@ -151,3 +152,22 @@ export const renderWithSetup = (component: JSX.Element) => { ...render(component), }; }; + +const DEFAULT_MOCK_ROUTER: InjectedRouter = { + push: jest.fn(), + replace: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + go: jest.fn(), + setRouteLeaveHook: jest.fn(), + isActive: jest.fn(), + createHref: jest.fn(), + createPath: jest.fn(), +}; + +export const createMockRouter = (overrides?: Partial) => { + return { + ...DEFAULT_MOCK_ROUTER, + ...overrides, + }; +}; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index c1dc7057d9..d096fd4a0a 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -1,5 +1,3 @@ -import software from "interfaces/software"; - const API_VERSION = "latest"; export default {