diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index e598a19cfa..8926de0224 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -2,7 +2,7 @@ import React, { useState, useContext, useEffect, useCallback } from "react"; import { useQuery } from "react-query"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { RouteProps } from "react-router/lib/Route"; -import { find, isEmpty, isEqual, omit, invert } from "lodash"; +import { find, isEmpty, isEqual, omit } from "lodash"; import { format } from "date-fns"; import FileSaver from "file-saver"; @@ -35,11 +35,8 @@ import { import { IHost } from "interfaces/host"; import { ILabel } from "interfaces/label"; import { IMunkiIssuesAggregate } from "interfaces/macadmins"; -import { IMdmSolution, MDM_ENROLLMENT_STATUS } from "interfaces/mdm"; -import { - formatOperatingSystemDisplayName, - IOperatingSystemVersion, -} from "interfaces/operating_system"; +import { IMdmSolution } from "interfaces/mdm"; +import { IOperatingSystemVersion } from "interfaces/operating_system"; import { IPolicy, IStoredPolicyResponse } from "interfaces/policy"; import { ISoftware } from "interfaces/software"; import { ITeam } from "interfaces/team"; @@ -49,7 +46,6 @@ import sortUtils from "utilities/sort"; import { HOSTS_SEARCH_BOX_PLACEHOLDER, HOSTS_SEARCH_BOX_TOOLTIP, - PLATFORM_LABEL_DISPLAY_NAMES, PolicyResponse, } from "utilities/constants"; @@ -74,27 +70,22 @@ import { DEFAULT_SORT_DIRECTION, DEFAULT_PAGE_SIZE, HOST_SELECT_STATUSES, - MAC_SETTINGS_FILTER_OPTIONS, } from "./constants"; import { isAcceptableStatus, getNextLocationPath } from "./helpers"; import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal"; import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal"; import AddHostsModal from "../../../components/AddHostsModal"; import EnrollSecretModal from "../../../components/EnrollSecrets/EnrollSecretModal"; -import PoliciesFilter from "./components/PoliciesFilter"; // @ts-ignore import EditColumnsModal from "./components/EditColumnsModal/EditColumnsModal"; import TransferHostModal from "../components/TransferHostModal"; import DeleteHostModal from "../components/DeleteHostModal"; import DeleteLabelModal from "./components/DeleteLabelModal"; import EditColumnsIcon from "../../../../assets/images/icon-edit-columns-16x16@2x.png"; -import PencilIcon from "../../../../assets/images/icon-pencil-14x14@2x.png"; -import TrashIcon from "../../../../assets/images/icon-trash-14x14@2x.png"; import CloseIconBlack from "../../../../assets/images/icon-close-fleet-black-16x16@2x.png"; -import PolicyIcon from "../../../../assets/images/icon-policy-fleet-black-12x12@2x.png"; import DownloadIcon from "../../../../assets/images/icon-download-12x12@2x.png"; import LabelFilterSelect from "./components/LabelFilterSelect"; -import FilterPill from "./components/FilterPill"; +import HostsFilterBlock from "./components/HostsFilterBlock"; interface IManageHostsProps { route: RouteProps; @@ -280,18 +271,13 @@ const ManageHostsPage = ({ const canEnrollGlobalHosts = isGlobalAdmin || isGlobalMaintainer; const canAddNewLabels = (isGlobalAdmin || isGlobalMaintainer) ?? false; - const { - isLoading: isLoadingLabels, - data: labels, - error: labelsError, - refetch: refetchLabels, - } = useQuery( - ["labels"], - () => labelsAPI.loadAll(), - { - select: (data: ILabelsResponse) => data.labels, - } - ); + const { data: labels, refetch: refetchLabels } = useQuery< + ILabelsResponse, + Error, + ILabel[] + >(["labels"], () => labelsAPI.loadAll(), { + select: (data: ILabelsResponse) => data.labels, + }); const { isLoading: isGlobalSecretsLoading, @@ -623,38 +609,6 @@ const ManageHostsPage = ({ ); }; - const handleClearPoliciesFilter = () => { - handleClearFilter(["policy_id", "policy_response"]); - }; - - const handleClearOSFilter = () => { - handleClearFilter(["os_id", "os_name", "os_version"]); - }; - - const handleClearMacSettingsStatusFilter = () => { - handleClearFilter(["macos_settings"]); - }; - - const handleClearSoftwareFilter = () => { - handleClearFilter(["software_id"]); - }; - - const handleClearMDMSolutionFilter = () => { - handleClearFilter(["mdm_id"]); - }; - - const handleClearMDMEnrollmentFilter = () => { - handleClearFilter(["mdm_enrollment_status"]); - }; - - const handleClearMunkiIssueFilter = () => { - handleClearFilter(["munki_issue_id"]); - }; - - const handleClearLowDiskSpaceFilter = () => { - handleClearFilter(["low_disk_space"]); - }; - const handleTeamSelect = (teamId: number) => { const { MANAGE_HOSTS } = PATHS; @@ -1105,247 +1059,6 @@ const ManageHostsPage = ({ /> ); - const renderLabelFilterPill = () => { - if (selectedLabel) { - const { description, display_text, label_type } = selectedLabel; - const pillLabel = - PLATFORM_LABEL_DISPLAY_NAMES[display_text] ?? display_text; - - return ( - <> - - {label_type !== "builtin" && !isOnlyObserver && ( - <> - - - - )} - - ); - } - - return null; - }; - - const renderOSFilterBlock = () => { - if (!osId && !(osName && osVersion)) return null; - - let os: IOperatingSystemVersion | undefined; - if (osId) { - os = osVersions?.find((v) => v.os_id === osId); - } else if (osName && osVersion) { - const name: string = osName; - const vers: string = osVersion; - - os = osVersions?.find( - ({ name_only, version }) => - name_only.toLowerCase() === name.toLowerCase() && - version.toLowerCase() === vers.toLowerCase() - ); - } - if (!os) return null; - - const { name, name_only, version } = os; - const label = formatOperatingSystemDisplayName( - name_only || version - ? `${name_only || ""} ${version || ""}` - : `${name || ""}` - ); - const TooltipDescription = ( - - Hosts with {formatOperatingSystemDisplayName(name_only || name)}, -
- {version && `${version} installed`} -
- ); - - return ( - - ); - }; - - const renderPoliciesFilterBlock = () => ( - <> - - - - ); - - const renderMacSettingsStatusFilterBlock = () => { - const label = "macOS settings"; - return ( - <> - - - - ); - }; - - const renderSoftwareFilterBlock = () => { - if (!softwareDetails) return null; - - const { name, version } = softwareDetails; - const label = `${name || "Unknown software"} ${version || ""}`; - - const TooltipDescription = ( - - Hosts with {name || "Unknown software"}, -
- {version || "version unknown"} installed -
- ); - - return ( - - ); - }; - - const renderMDMSolutionFilterBlock = () => { - if (!mdmSolutionDetails) return null; - - const { name, server_url } = mdmSolutionDetails; - const label = name ? `${name} ${server_url}` : `${server_url}`; - - const TooltipDescription = ( - - Host enrolled - {name !== "Unknown" && ` to ${name}`} -
at {server_url} -
- ); - - return ( - - ); - }; - - const renderMDMEnrollmentFilterBlock = () => { - if (!mdmEnrollmentStatus) return null; - - const label = `MDM status: ${ - invert(MDM_ENROLLMENT_STATUS)[mdmEnrollmentStatus] - }`; - - // More narrow tooltip than other MDM tooltip - const MDM_STATUS_PILL_TOOLTIP: Record = { - automatic: ( - - MDM was turned on
- automatically using Apple
- Automated Device
- Enrollment (DEP) or
- Windows Autopilot.
- Administrators can block
- device users from turning -
MDM off. -
- ), - manual: ( - - MDM was turned on
- manually. Device users
- can turn MDM off. -
- ), - unenrolled: ( - - Hosts with MDM off
- don't receive macOS
- settings and macOS
- update encouragement. -
- ), - pending: ( - - Hosts ordered using Apple
- Business Manager (ABM).
- They will automatically enroll
- to Fleet and turn on MDM
- when they're unboxed. -
- ), - }; - - return ( - - ); - }; - - const renderMunkiIssueFilterBlock = () => { - if (munkiIssueDetails) { - return ( - - Hosts that reported this Munki issue
- the last time Munki ran on each host. - - } - onClear={handleClearMunkiIssueFilter} - /> - ); - } - return null; - }; - - const renderLowDiskSpaceFilterBlock = () => { - const TooltipDescription = ( - - Hosts that have {lowDiskSpaceHosts} GB or less
- disk space available. -
- ); - - return ( - - ); - }; - const renderEditColumnsModal = () => { if (!config || !currentUser) { return null; @@ -1570,76 +1283,6 @@ const ManageHostsPage = ({ ); }, [isHostCountLoading, filteredHostCount]); - const renderActiveFilterBlock = () => { - const showSelectedLabel = selectedLabel && selectedLabel.type !== "all"; - - if ( - showSelectedLabel || - policyId || - macSettingsStatus || - softwareId || - showSelectedLabel || - mdmId || - mdmEnrollmentStatus || - lowDiskSpaceHosts || - osId || - (osName && osVersion) || - munkiIssueId - ) { - const renderFilterPill = () => { - switch (true) { - // backend allows for pill combos (label + low disk space) OR - // (label + mdm solution) OR (label + mdm enrollment status) - case showSelectedLabel && !!lowDiskSpaceHosts: - return ( - <> - {renderLabelFilterPill()} {renderLowDiskSpaceFilterBlock()} - - ); - case showSelectedLabel && !!mdmId: - return ( - <> - {renderLabelFilterPill()} {renderMDMSolutionFilterBlock()} - - ); - - case showSelectedLabel && !!mdmEnrollmentStatus: - return ( - <> - {renderLabelFilterPill()} {renderMDMEnrollmentFilterBlock()} - - ); - case showSelectedLabel: - return renderLabelFilterPill(); - case !!policyId: - return renderPoliciesFilterBlock(); - case !!macSettingsStatus: - return renderMacSettingsStatusFilterBlock(); - case !!softwareId: - return renderSoftwareFilterBlock(); - case !!mdmId: - return renderMDMSolutionFilterBlock(); - case !!mdmEnrollmentStatus: - return renderMDMEnrollmentFilterBlock(); - case !!osId || (!!osName && !!osVersion): - return renderOSFilterBlock(); - case !!munkiIssueId: - return renderMunkiIssueFilterBlock(); - case !!lowDiskSpaceHosts: - return renderLowDiskSpaceFilterBlock(); - default: - return null; - } - }; - - return ( -
- {renderFilterPill()} -
- ); - } - }; - const renderCustomControls = () => { // we filter out the status labels as we dont want to display them in the label // filter select dropdown. @@ -1901,7 +1544,36 @@ const ManageHostsPage = ({ )} - {renderActiveFilterBlock()} + {/* TODO: look at improving the props API for this component. Im thinking + some of the props can be defined inside HostsFilterBlock */} + {renderNoEnrollSecretBanner()} {renderTable()} diff --git a/frontend/pages/hosts/ManageHostsPage/_styles.scss b/frontend/pages/hosts/ManageHostsPage/_styles.scss index 1f1e6b8b8c..7335c03bba 100644 --- a/frontend/pages/hosts/ManageHostsPage/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/_styles.scss @@ -248,34 +248,6 @@ margin-left: $pad-small; } - &__macsettings-dropdown { - width: 137px; - - .Select-value { - display: flex; - align-items: center; - - &::before { - position: relative; - content: url(../assets/images/icon-filter-v2-black-16x16@2x.png); - transform: scale(0.5); - left: -8px; - top: 4px; - } - } - } - - &__labels-active-filter-wrap { - display: flex; - align-items: center; - margin-bottom: $pad-medium; - gap: $pad-small; // between multiple filter pills - } - - &__policies-filter-pill { - margin-left: $pad-medium; - } - &__enroll-hosts { padding: $pad-small; margin-right: $pad-small; diff --git a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tests.tsx b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tests.tsx new file mode 100644 index 0000000000..ad390871f4 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tests.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { noop } from "lodash"; +import { render, screen } from "@testing-library/react"; + +import { renderWithSetup } from "test/test-utils"; + +import FilterPill from "./FilterPill"; + +import PolicyIcon from "../../../../../../assets/images/icon-policy-fleet-black-12x12@2x.png"; + +describe("Filter Pill Component", () => { + it("renders the pill text", () => { + render(); + + expect(screen.getByText("Test Pill")).toBeInTheDocument(); + }); + + it("renders an passed in icon properly", () => { + render(); + + expect(screen.getByTestId("filter-pill__icon")).toBeInTheDocument(); + }); + + it("renders a passed in string tooltip", () => { + render( + + ); + + expect(screen.getByText("Test Tooltip")).toBeInTheDocument(); + }); + + it("renders a passed in ReactNode tooltip", () => { + render( + This is a ReactNode

} + onClear={noop} + /> + ); + + expect(screen.getByText("This is a ReactNode")).toBeInTheDocument(); + }); + + it("calls the onCancel callback when a user clicks on the remove button", async () => { + const spy = jest.fn(); + + const { user } = renderWithSetup( + + ); + + await user.click(screen.getByRole("button", { name: "Remove filter" })); + + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx index 62d7964360..3251236ab2 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx @@ -40,7 +40,9 @@ const FilterPill = ({ data-for={`filter-pill-tooltip-${label}`} >
- {icon && } + {icon && ( + + )} {label} + + + )} + + ); + } + + return null; + }; + + const renderOSFilterBlock = () => { + if (!osId && !(osName && osVersion)) return null; + + let os: IOperatingSystemVersion | undefined; + if (osId) { + os = osVersions?.find((v) => v.os_id === osId); + } else if (osName && osVersion) { + const name: string = osName; + const vers: string = osVersion; + + os = osVersions?.find( + ({ name_only, version }) => + name_only.toLowerCase() === name.toLowerCase() && + version.toLowerCase() === vers.toLowerCase() + ); + } + if (!os) return null; + + const { name, name_only, version } = os; + // TODO: Move formatOperatingSystemDisplayName into utils file + const label = formatOperatingSystemDisplayName( + name_only || version + ? `${name_only || ""} ${version || ""}` + : `${name || ""}` + ); + const TooltipDescription = ( + + Hosts with {formatOperatingSystemDisplayName(name_only || name)}, +
+ {version && `${version} installed`} +
+ ); + + return ( + handleClearFilter(["os_id", "os_name", "os_version"])} + /> + ); + }; + + // NOTE: good example of filter dropdown with pill + const renderPoliciesFilterBlock = () => ( + <> + + handleClearFilter(["policy_id", "policy_response"])} + className={`${baseClass}__policies-filter-pill`} + /> + + ); + + const renderMacSettingsStatusFilterBlock = () => { + const label = "macOS settings"; + return ( + <> + + handleClearFilter(["macos_settings"])} + /> + + ); + }; + + const renderSoftwareFilterBlock = () => { + if (!softwareDetails) return null; + + const { name, version } = softwareDetails; + const label = `${name || "Unknown software"} ${version || ""}`; + + const TooltipDescription = ( + + Hosts with {name || "Unknown software"}, +
+ {version || "version unknown"} installed +
+ ); + + return ( + handleClearFilter(["software_id"])} + tooltipDescription={TooltipDescription} + /> + ); + }; + + const renderMDMSolutionFilterBlock = () => { + if (!mdmSolutionDetails) return null; + + const { name, server_url } = mdmSolutionDetails; + const label = name ? `${name} ${server_url}` : `${server_url}`; + + const TooltipDescription = ( + + Host enrolled + {name !== "Unknown" && ` to ${name}`} +
at {server_url} +
+ ); + + return ( + handleClearFilter(["mdm_id"])} + /> + ); + }; + + const renderMDMEnrollmentFilterBlock = () => { + if (!mdmEnrollmentStatus) return null; + + const label = `MDM status: ${ + // TODO: move MDM_ENROLLMENT_STATUS to util file + invert(MDM_ENROLLMENT_STATUS)[mdmEnrollmentStatus] + }`; + + // More narrow tooltip than other MDM tooltip + const MDM_STATUS_PILL_TOOLTIP: Record = { + automatic: ( + + MDM was turned on
+ automatically using Apple
+ Automated Device
+ Enrollment (DEP) or
+ Windows Autopilot.
+ Administrators can block
+ device users from turning +
MDM off. +
+ ), + manual: ( + + MDM was turned on
+ manually. Device users
+ can turn MDM off. +
+ ), + unenrolled: ( + + Hosts with MDM off
+ don't receive macOS
+ settings and macOS
+ update encouragement. +
+ ), + pending: ( + + Hosts ordered using Apple
+ Business Manager (ABM).
+ They will automatically enroll
+ to Fleet and turn on MDM
+ when they're unboxed. +
+ ), + }; + + return ( + handleClearFilter(["mdm_enrollment_status"])} + /> + ); + }; + + const renderMunkiIssueFilterBlock = () => { + if (munkiIssueDetails) { + return ( + + Hosts that reported this Munki issue
+ the last time Munki ran on each host. + + } + onClear={() => handleClearFilter(["munki_issue_id"])} + /> + ); + } + return null; + }; + + const renderLowDiskSpaceFilterBlock = () => { + const TooltipDescription = ( + + Hosts that have {lowDiskSpaceHosts} GB or less
+ disk space available. +
+ ); + + return ( + handleClearFilter(["low_disk_space"])} + /> + ); + }; + + const showSelectedLabel = selectedLabel && selectedLabel.type !== "all"; + + if ( + showSelectedLabel || + policyId || + macSettingsStatus || + softwareId || + mdmId || + mdmEnrollmentStatus || + lowDiskSpaceHosts || + osId || + (osName && osVersion) || + munkiIssueId + ) { + const renderFilterPill = () => { + switch (true) { + // backend allows for pill combos (label + low disk space) OR + // (label + mdm solution) OR (label + mdm enrollment status) + case showSelectedLabel && !!lowDiskSpaceHosts: + return ( + <> + {renderLabelFilterPill()} {renderLowDiskSpaceFilterBlock()} + + ); + case showSelectedLabel && !!mdmId: + return ( + <> + {renderLabelFilterPill()} {renderMDMSolutionFilterBlock()} + + ); + + case showSelectedLabel && !!mdmEnrollmentStatus: + return ( + <> + {renderLabelFilterPill()} {renderMDMEnrollmentFilterBlock()} + + ); + case showSelectedLabel: + return renderLabelFilterPill(); + case !!policyId: + return renderPoliciesFilterBlock(); + case !!macSettingsStatus: + return renderMacSettingsStatusFilterBlock(); + case !!softwareId: + return renderSoftwareFilterBlock(); + case !!mdmId: + return renderMDMSolutionFilterBlock(); + case !!mdmEnrollmentStatus: + return renderMDMEnrollmentFilterBlock(); + case !!osId || (!!osName && !!osVersion): + return renderOSFilterBlock(); + case !!munkiIssueId: + return renderMunkiIssueFilterBlock(); + case !!lowDiskSpaceHosts: + return renderLowDiskSpaceFilterBlock(); + default: + return null; + } + }; + + return ( +
+ {renderFilterPill()} +
+ ); + } + + return null; +}; + +export default HostsFilterBlock; diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/_styles.scss new file mode 100644 index 0000000000..2adf8fbd34 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/_styles.scss @@ -0,0 +1,32 @@ +.hosts-filter-block { + &__labels-active-filter-wrap { + display: flex; + align-items: center; + margin-bottom: $pad-medium; + gap: $pad-small; // between multiple filter pills + } + + // NOTE: can probably be removed and placed anytime we have a dropdown. Need to + // go through other filter dropdowns and see if thats the case + &__policies-filter-pill { + margin-left: $pad-medium; + } + + // NOTE: Look more into this styling + &__macsettings-dropdown { + width: 137px; + + .Select-value { + display: flex; + align-items: center; + + &::before { + position: relative; + content: url(../assets/images/icon-filter-v2-black-16x16@2x.png); + transform: scale(0.5); + left: -8px; + top: 4px; + } + } + } +} diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/index.ts b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/index.ts new file mode 100644 index 0000000000..35f83a7b75 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/index.ts @@ -0,0 +1 @@ +export { default } from "./HostsFilterBlock"; diff --git a/frontend/test/test-utils.tsx b/frontend/test/test-utils.tsx index 7bddc50f19..3409523a3f 100644 --- a/frontend/test/test-utils.tsx +++ b/frontend/test/test-utils.tsx @@ -143,6 +143,10 @@ export const createCustomRenderer = (renderOptions?: ICustomRenderOptions) => { }; }; +/** + * This is a convenince method that calls the render method from `@testing-library/react` and also + * sets up the also `user-events`library and adds the user object to the returned object. + */ // eslint-disable-next-line import/prefer-default-export export const renderWithSetup = (component: JSX.Element) => { return {