fleet/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx
Ian Littman d0040e7622
Add platform filtering to Software > OS table (#21797)
#20385

See notes on that issue for API limitations (which is why Windows and
macOS are the only platforms listed).

Will move out of draft after adding the changes file and tests.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
2024-10-16 10:02:06 -05:00

391 lines
12 KiB
TypeScript

/**
software/titles Software tab > Table
software/versions Software tab > Table (version toggle on)
*/
import React, { useCallback, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { Row } from "react-table";
import PATHS from "router/paths";
import { getNextLocationPath } from "utilities/helpers";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import {
buildQueryStringFromParams,
convertParamsToSnakeCase,
} from "utilities/url";
import {
ISoftwareApiParams,
ISoftwareTitlesResponse,
ISoftwareVersionsResponse,
} from "services/entities/software";
import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import TableContainer from "components/TableContainer";
import Slider from "components/forms/fields/Slider";
import CustomLink from "components/CustomLink";
import LastUpdatedText from "components/LastUpdatedText";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import TableCount from "components/TableContainer/TableCount";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import TooltipWrapper from "components/TooltipWrapper";
import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
import generateTitlesTableConfig from "./SoftwareTitlesTableConfig";
import generateVersionsTableConfig from "./SoftwareVersionsTableConfig";
import {
ISoftwareDropdownFilterVal,
ISoftwareVulnFiltersParams,
SOFTWARE_TITLES_DROPDOWN_OPTIONS,
buildSoftwareFilterQueryParams,
buildSoftwareVulnFiltersQueryParams,
getVulnFilterRenderDetails,
} from "./helpers";
interface IRowProps extends Row {
original: {
id?: number;
};
}
type ITableConfigGenerator = (router: InjectedRouter, teamId?: number) => void;
const isSoftwareTitles = (
data?: ISoftwareTitlesResponse | ISoftwareVersionsResponse
): data is ISoftwareTitlesResponse => {
if (!data) return false;
return (data as ISoftwareTitlesResponse).software_titles !== undefined;
};
interface ISoftwareTableProps {
router: InjectedRouter;
data?: ISoftwareTitlesResponse | ISoftwareVersionsResponse;
showVersions: boolean;
installableSoftwareExists: boolean;
isSoftwareEnabled: boolean;
query: string;
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
softwareFilter: ISoftwareDropdownFilterVal;
vulnFilters: ISoftwareVulnFiltersParams;
currentPage: number;
teamId?: number;
isLoading: boolean;
resetPageIndex: boolean;
onAddFiltersClick: () => void;
}
const baseClass = "software-table";
const SoftwareTable = ({
router,
data,
showVersions,
installableSoftwareExists,
isSoftwareEnabled,
query,
perPage,
orderDirection,
orderKey,
softwareFilter,
vulnFilters,
currentPage,
teamId,
isLoading,
resetPageIndex,
onAddFiltersClick,
}: ISoftwareTableProps) => {
const currentPath = showVersions
? PATHS.SOFTWARE_VERSIONS
: PATHS.SOFTWARE_TITLES;
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
switch (key) {
case "searchQuery":
return val !== query;
case "sortDirection":
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "pageIndex":
return val !== currentPage;
default:
return false;
}
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey, query]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData, changedParam: string) => {
const newQueryParam: Record<string, string | number | undefined> = {
query: newTableQuery.searchQuery,
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
...buildSoftwareVulnFiltersQueryParams(vulnFilters),
};
if (softwareFilter === "installableSoftware") {
newQueryParam.available_for_install = true.toString();
}
if (softwareFilter === "selfServiceSoftware") {
newQueryParam.self_service = true.toString();
}
return newQueryParam;
},
[softwareFilter, teamId, vulnFilters]
);
// NOTE: this is called once on initial render and every time the query changes
const onQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
// we want to determine which query param has changed in order to
// reset the page index to 0 if any other param has changed.
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render. Can also happen when
// the filter dropdown is changed. That is handled on the onChange handler
// for the dropdown.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
pathPrefix: currentPath,
routeTemplate: "",
queryParams: generateNewQueryParams(newTableQuery, changedParam),
});
router.replace(newRoute);
},
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
);
let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
let generateTableConfig: ITableConfigGenerator;
if (data === undefined) {
tableData;
generateTableConfig = () => [];
} else if (isSoftwareTitles(data)) {
tableData = data.software_titles;
generateTableConfig = generateTitlesTableConfig;
} else {
tableData = data.software;
generateTableConfig = generateVersionsTableConfig;
}
const softwareTableHeaders = useMemo(() => {
if (!data) return [];
return generateTableConfig(router, teamId);
}, [generateTableConfig, data, router, teamId]);
// Determines if a user should be able to filter or search in the table
const hasData = tableData && tableData.length > 0;
const hasQuery = query !== "";
const hasSoftwareFilter = softwareFilter !== "allSoftware";
const hasVersionFilter = showVersions;
const vulnFilterDetails = getVulnFilterRenderDetails(vulnFilters);
const hasVulnFilters = vulnFilterDetails.filterCount > 0;
const showFilterHeaders =
isSoftwareEnabled &&
(hasData ||
hasQuery ||
hasSoftwareFilter ||
hasVersionFilter ||
hasVulnFilters);
const handleShowVersionsToggle = () => {
const queryParams: Record<string, string | number | boolean | undefined> = {
query,
team_id: teamId,
order_direction: orderDirection,
order_key: orderKey,
page: 0, // resets page index
...buildSoftwareFilterQueryParams("allSoftware"), // Reset to all software
...buildSoftwareVulnFiltersQueryParams(vulnFilters),
};
router.replace(
getNextLocationPath({
pathPrefix: showVersions
? PATHS.SOFTWARE_TITLES
: PATHS.SOFTWARE_VERSIONS,
routeTemplate: "",
queryParams,
})
);
};
const handleCustomFilterDropdownChange = (
value: ISoftwareDropdownFilterVal
) => {
const queryParams: ISoftwareApiParams = {
query,
teamId,
orderDirection,
orderKey,
page: 0, // resets page index
...buildSoftwareVulnFiltersQueryParams(vulnFilters),
...buildSoftwareFilterQueryParams(value),
};
router.replace(
getNextLocationPath({
pathPrefix: currentPath,
routeTemplate: "",
queryParams: convertParamsToSnakeCase(queryParams),
})
);
};
const handleRowSelect = (row: IRowProps) => {
const queryParams = showVersions
? buildQueryStringFromParams({
software_version_id: row.original.id,
team_id: teamId,
})
: buildQueryStringFromParams({
software_title_id: row.original.id,
team_id: teamId,
});
const path = `${PATHS.MANAGE_HOSTS}?${queryParams}`;
router.push(path);
};
const renderSoftwareCount = () => {
if (!tableData || !data) return null;
return (
<>
<TableCount name="items" count={data?.count} />
{tableData && data?.counts_updated_at && (
<LastUpdatedText
lastUpdatedAt={data.counts_updated_at}
customTooltipText={
<>
The last time software data was <br />
updated, including vulnerabilities <br />
and host counts.
</>
}
/>
)}
</>
);
};
const renderCustomControls = () => {
return (
<div className={`${baseClass}__filter-controls`}>
{!showVersions && ( // Hidden when viewing versions table
<Dropdown
value={softwareFilter}
className={`${baseClass}__vuln_dropdown`}
options={SOFTWARE_TITLES_DROPDOWN_OPTIONS}
searchable={false}
onChange={handleCustomFilterDropdownChange}
iconName="filter"
/>
)}
<Slider
value={showVersions}
onChange={handleShowVersionsToggle}
inactiveText="Show versions"
activeText="Show versions"
/>
</div>
);
};
const renderCustomFiltersButton = () => {
return (
<TooltipWrapper
className={`${baseClass}__filters`}
position="left"
underline={false}
showArrow
tipOffset={12}
tipContent={vulnFilterDetails.tooltipText}
disableTooltip={!hasVulnFilters}
>
<Button variant="text-link" onClick={onAddFiltersClick}>
<Icon name="filter" color="core-fleet-blue" />
<span>{vulnFilterDetails.buttonText}</span>
</Button>
</TooltipWrapper>
);
};
const renderTableHelpText = () => (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
return (
<div className={baseClass}>
<TableContainer
columnConfigs={softwareTableHeaders}
data={tableData ?? []}
isLoading={isLoading}
resultsTitle="items"
emptyComponent={() => (
<EmptySoftwareTable
softwareFilter={softwareFilter}
vulnFilters={vulnFilters}
isSoftwareDisabled={!isSoftwareEnabled}
noSearchQuery={query === ""}
installableSoftwareExists={installableSoftwareExists}
/>
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}
defaultPageIndex={currentPage}
defaultSearchQuery={query}
manualSortBy
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!data?.meta.has_next_results}
searchable={showFilterHeaders}
inputPlaceHolder="Search by name or vulnerability (CVE)"
onQueryChange={onQueryChange}
// additionalQueries serves as a trigger for the useDeepEffect hook
// to fire onQueryChange for events happening outside of
// the TableContainer.
// additionalQueries={softwareFilter}
customControl={showFilterHeaders ? renderCustomControls : undefined}
customFiltersButton={
showFilterHeaders ? renderCustomFiltersButton : undefined
}
stackControls
renderCount={renderSoftwareCount}
renderTableHelpText={renderTableHelpText}
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
resetPageIndex={resetPageIndex}
/>
</div>
);
};
export default SoftwareTable;