diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 5183653883..d460901de9 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -22,6 +22,10 @@ export interface IStoredPolicyResponse { policy: IPolicy; } +export interface IPoliciesCountResponse { + count: number; +} + export interface IPolicy { id: number; name: string; diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 72bd71254e..d4e0d4b228 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -307,7 +307,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { teamId: teamIdForApi, }, ], - ({ queryKey }) => softwareAPI.count(queryKey[0]), + ({ queryKey }) => softwareAPI.getCount(queryKey[0]), { enabled: isRouteOk && !software?.software, keepPreviousData: true, diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index f0ba95e3c6..1db14c14df 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import { useQuery } from "react-query"; import { InjectedRouter } from "react-router/lib/Router"; import PATHS from "router/paths"; -import { noop, isEmpty } from "lodash"; +import { noop, isEqual } from "lodash"; import { getNextLocationPath } from "utilities/helpers"; @@ -17,12 +17,19 @@ import { IPolicyStats, ILoadAllPoliciesResponse, ILoadTeamPoliciesResponse, + IPoliciesCountResponse, } from "interfaces/policy"; import { ITeamConfig } from "interfaces/team"; import configAPI from "services/entities/config"; -import globalPoliciesAPI from "services/entities/global_policies"; -import teamPoliciesAPI from "services/entities/team_policies"; +import globalPoliciesAPI, { + IPoliciesCountQueryKey, + IPoliciesQueryKey, +} from "services/entities/global_policies"; +import teamPoliciesAPI, { + ITeamPoliciesCountQueryKey, + ITeamPoliciesQueryKey, +} from "services/entities/team_policies"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import { ITableQueryData } from "components/TableContainer/TableContainer"; @@ -60,6 +67,10 @@ interface IManagePoliciesPageProps { }; } +const DEFAULT_SORT_DIRECTION = "asc"; +const DEFAULT_PAGE_SIZE = 20; +const DEFAULT_SORT_COLUMN = "name"; + const baseClass = "manage-policies-page"; const ManagePolicyPage = ({ @@ -128,31 +139,41 @@ const ManagePolicyPage = ({ // Functions to avoid race conditions const initialSearchQuery = (() => queryParams.query ?? "")(); const initialSortHeader = (() => - (queryParams?.order_key as "name" | "failing_host_count") ?? "name")(); + (queryParams?.order_key as "name" | "failing_host_count") ?? + DEFAULT_SORT_COLUMN)(); const initialSortDirection = (() => - (queryParams?.order_direction as "asc" | "desc") ?? "asc")(); + (queryParams?.order_direction as "asc" | "desc") ?? + DEFAULT_SORT_DIRECTION)(); const initialPage = (() => queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)(); const initialShowInheritedTable = (() => queryParams && queryParams.inherited_table === "true")(); const initialInheritedSortHeader = (() => (queryParams?.inherited_order_key as "name" | "failing_host_count") ?? - "name")(); + DEFAULT_SORT_COLUMN)(); const initialInheritedSortDirection = (() => - (queryParams?.inherited_order_direction as "asc" | "desc") ?? "asc")(); + (queryParams?.inherited_order_direction as "asc" | "desc") ?? + DEFAULT_SORT_DIRECTION)(); const initialInheritedPage = (() => queryParams && queryParams.inherited_page ? parseInt(queryParams?.inherited_page, 10) : 0)(); - const page = initialPage; const showInheritedTable = initialShowInheritedTable; - const inheritedPage = initialInheritedPage; - const searchQuery = initialSearchQuery; // Needs update on location change or table state might not match URL + const [searchQuery, setSearchQuery] = useState(initialSearchQuery); + const [page, setPage] = useState(initialPage); + const [inheritedPage, setInheritedPage] = useState(initialInheritedPage); + const [tableQueryData, setTableQueryData] = useState(); + const [ + inheritedTableQueryData, + setInheritedTableQueryData, + ] = useState(); const [sortHeader, setSortHeader] = useState(initialSortHeader); - const [sortDirection, setSortDirection] = useState(initialSortDirection); + const [sortDirection, setSortDirection] = useState< + "asc" | "desc" | undefined + >(initialSortDirection); const [inheritedSortDirection, setInheritedSortDirection] = useState( initialInheritedSortDirection ); @@ -168,8 +189,11 @@ const ManagePolicyPage = ({ if (!isRouteOk) { return; } + setPage(initialPage); + setSearchQuery(initialSearchQuery); setSortHeader(initialSortHeader); setSortDirection(initialSortDirection); + setInheritedPage(initialInheritedPage); setInheritedSortHeader(initialInheritedSortHeader); setInheritedSortDirection(initialInheritedSortDirection); }, [location, isRouteOk]); @@ -195,10 +219,24 @@ const ManagePolicyPage = ({ error: globalPoliciesError, isFetching: isFetchingGlobalPolicies, refetch: refetchGlobalPolicies, - } = useQuery( - ["globalPolicies", teamIdForApi], - () => { - return globalPoliciesAPI.loadAll(); + } = useQuery< + ILoadAllPoliciesResponse, + Error, + IPolicyStats[], + IPoliciesQueryKey[] + >( + [ + { + scope: "globalPolicies", + page: tableQueryData?.pageIndex, + perPage: DEFAULT_PAGE_SIZE, + query: searchQuery, + orderDirection: sortDirection, + orderKey: sortHeader, + }, + ], + ({ queryKey }) => { + return globalPoliciesAPI.loadAllNew(queryKey[0]); }, { enabled: isRouteOk, @@ -207,13 +245,55 @@ const ManagePolicyPage = ({ } ); + const { + data: globalPoliciesCount, + + isFetching: isFetchingGlobalCount, + } = useQuery( + [ + { + scope: "policiesCount", + query: isAnyTeamSelected ? "" : searchQuery, // Search query not used for inherited count + }, + ], + ({ queryKey }) => globalPoliciesAPI.getCount(queryKey[0]), + { + enabled: isRouteOk, + keepPreviousData: true, + refetchOnWindowFocus: false, + retry: 1, + select: (data) => data.count, + } + ); + const { error: teamPoliciesError, isFetching: isFetchingTeamPolicies, refetch: refetchTeamPolicies, - } = useQuery( - ["teamPolicies", teamIdForApi], - () => teamPoliciesAPI.loadAll(teamIdForApi), + } = useQuery< + ILoadTeamPoliciesResponse, + Error, + ILoadTeamPoliciesResponse, + ITeamPoliciesQueryKey[] + >( + [ + { + scope: "teamPolicies", + page: tableQueryData?.pageIndex, + perPage: DEFAULT_PAGE_SIZE, + query: searchQuery, + orderDirection: sortDirection, + orderKey: sortHeader, + inheritedPage: inheritedTableQueryData?.pageIndex, + inheritedPerPage: DEFAULT_PAGE_SIZE, + inheritedOrderDirection: inheritedSortDirection, + inheritedOrderKey: inheritedSortHeader, + teamId: teamIdForApi || 0, + }, + ], + ({ queryKey }) => { + return teamPoliciesAPI.loadAllNew(queryKey[0]); + }, { enabled: isRouteOk && isPremiumTier && !!teamIdForApi, onSuccess: (data) => { @@ -223,9 +303,32 @@ const ManagePolicyPage = ({ } ); - const canAddOrDeletePolicy = + const { data: teamPoliciesCount, isFetching: isFetchingTeamCount } = useQuery< + IPoliciesCountResponse, + Error, + number, + ITeamPoliciesCountQueryKey[] + >( + [ + { + scope: "teamPoliciesCount", + query: searchQuery, + teamId: teamIdForApi || 0, // TODO: Fix number/undefined type + }, + ], + ({ queryKey }) => teamPoliciesAPI.getCount(queryKey[0]), + { + enabled: isRouteOk && !!teamIdForApi, + keepPreviousData: true, + refetchOnWindowFocus: false, + retry: 1, + select: (data) => data.count, + } + ); + + const canAddOrDeletePolicy: boolean = isGlobalAdmin || isGlobalMaintainer || isTeamMaintainer || isTeamAdmin; - const canManageAutomations = isGlobalAdmin || isTeamAdmin; + const canManageAutomations: boolean = isGlobalAdmin || isTeamAdmin; const { data: config, @@ -281,6 +384,14 @@ const ManagePolicyPage = ({ // Inherited table uses the same onQueryChange function but routes to different URL params const onQueryChange = useCallback( async (newTableQuery: ITableQueryData) => { + if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) { + return; + } + + newTableQuery.editingInheritedTable + ? setInheritedTableQueryData({ ...newTableQuery }) + : setTableQueryData({ ...newTableQuery }); + const { pageIndex: newPageIndex, searchQuery: newSearchQuery, @@ -359,48 +470,6 @@ const ManagePolicyPage = ({ ] // Other dependencies can cause infinite re-renders as URL is source of truth ); - const onClientSidePaginationChange = useCallback( - (pageIndex: number) => { - const locationPath = getNextLocationPath({ - pathPrefix: PATHS.MANAGE_POLICIES, - queryParams: { - ...queryParams, - page: pageIndex, - query: searchQuery, - order_direction: sortDirection, - order_key: sortHeader, - inherited_order_direction: inheritedSortDirection, - inherited_order_key: inheritedSortHeader, - inherited_page: inheritedPage, - }, - }); - - router?.replace(locationPath); - }, - [searchQuery, queryParams, sortHeader, sortDirection] // Dependencies required for correct variable state - ); - - const onClientSideInheritedPaginationChange = useCallback( - (pageIndex: number) => { - const locationPath = getNextLocationPath({ - pathPrefix: PATHS.MANAGE_POLICIES, - queryParams: { - ...queryParams, - inherited_table: "true", - inherited_page: pageIndex, - query: searchQuery, - page, - order_direction: sortDirection, - order_key: sortHeader, - inherited_order_direction: inheritedSortDirection, - inherited_order_key: inheritedSortHeader, - }, - }); - router?.replace(locationPath); - }, - [queryParams, inheritedSortHeader, inheritedSortDirection] // Dependencies required for correct variable state - ); - const toggleManageAutomationsModal = () => setShowManageAutomationsModal(!showManageAutomationsModal); @@ -541,6 +610,16 @@ const ManagePolicyPage = ({ } } + const renderPoliciesCount = (count?: number) => { + return ( +
+ {count !== undefined && ( + {`${count} polic${count === 1 ? "y" : "ies"}`} + )} +
+ ); + }; + return !isRouteOk || (isPremiumTier && !userTeams) ? ( ) : ( @@ -628,6 +707,9 @@ const ManagePolicyPage = ({ canAddOrDeletePolicy={canAddOrDeletePolicy} currentTeam={currentTeamSummary} currentAutomatedPolicies={currentAutomatedPolicies} + renderPoliciesCount={() => + !isFetchingTeamCount && renderPoliciesCount(teamPoliciesCount) + } isPremiumTier={isPremiumTier} isSandboxMode={isSandboxMode} searchQuery={searchQuery} @@ -653,7 +735,11 @@ const ManagePolicyPage = ({ currentAutomatedPolicies={currentAutomatedPolicies} isPremiumTier={isPremiumTier} isSandboxMode={isSandboxMode} - onClientSidePaginationChange={onClientSidePaginationChange} + // onClientSidePaginationChange={onClientSidePaginationChange} + renderPoliciesCount={() => + !isFetchingGlobalCount && + renderPoliciesCount(globalPoliciesCount) + } searchQuery={searchQuery} sortHeader={sortHeader} sortDirection={sortDirection} @@ -662,25 +748,27 @@ const ManagePolicyPage = ({ /> ))} - {showInheritedPoliciesButton && globalPolicies && ( - for this team’s hosts.' - } - onClick={toggleShowInheritedPolicies} - /> - )} + {showInheritedPoliciesButton && + globalPolicies && + globalPoliciesCount && ( + for this team’s hosts.' + } + onClick={toggleShowInheritedPolicies} + /> + )} {showInheritedPoliciesButton && showInheritedTable && (
{globalPoliciesError && } @@ -696,8 +784,11 @@ const ManagePolicyPage = ({ tableType="inheritedPolicies" currentTeam={currentTeamSummary} searchQuery="" - onClientSidePaginationChange={ - onClientSideInheritedPaginationChange + // onClientSidePaginationChange={ + // onClientSideInheritedPaginationChange + // } + renderPoliciesCount={() => + renderPoliciesCount(teamPoliciesCount) } sortHeader={inheritedSortHeader} sortDirection={inheritedSortDirection} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx index 6619d69a2c..426de14942 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx @@ -21,6 +21,7 @@ describe("Policies table", () => { searchQuery="" page={0} onQueryChange={noop} + renderPoliciesCount={noop} /> ); @@ -47,6 +48,7 @@ describe("Policies table", () => { searchQuery="" page={0} onQueryChange={noop} + renderPoliciesCount={noop} /> ); diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx index 171281c340..f6e850a577 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx @@ -35,7 +35,8 @@ interface IPoliciesTableProps { currentAutomatedPolicies?: number[]; isPremiumTier?: boolean; isSandboxMode?: boolean; - onClientSidePaginationChange?: (pageIndex: number) => void; + // onClientSidePaginationChange?: (pageIndex: number) => void; + renderPoliciesCount: any; // TODO: typing onQueryChange: (newTableQuery: ITableQueryData) => void; searchQuery: string; sortHeader?: "name" | "failing_host_count"; @@ -55,7 +56,8 @@ const PoliciesTable = ({ isPremiumTier, isSandboxMode, onQueryChange, - onClientSidePaginationChange, + // onClientSidePaginationChange, + renderPoliciesCount, searchQuery, sortHeader, sortDirection, @@ -154,7 +156,7 @@ const PoliciesTable = ({ currentAutomatedPolicies, config?.update_interval.osquery_policy )} - filters={{ global: searchQuery }} + // filters={{ global: searchQuery }} isLoading={isLoading} defaultSortHeader={sortHeader || DEFAULT_SORT_HEADER} defaultSortDirection={sortDirection || DEFAULT_SORT_DIRECTION} @@ -179,10 +181,11 @@ const PoliciesTable = ({ }) } disableCount={tableType === "inheritedPolicies"} - isClientSidePagination - onClientSidePaginationChange={onClientSidePaginationChange} - isClientSideFilter - searchQueryColumn="name" + renderCount={renderPoliciesCount} + // isClientSidePagination + // onClientSidePaginationChange={onClientSidePaginationChange} + // isClientSideFilter + // searchQueryColumn="name" onQueryChange={onTableQueryChange} inputPlaceHolder="Search by name" searchable={searchable} diff --git a/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx b/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx index 179d9ed4fd..9cbe20a177 100644 --- a/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx +++ b/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx @@ -327,7 +327,7 @@ const ManageSoftwarePage = ({ teamId: teamIdForApi, }, ], - ({ queryKey }) => softwareAPI.count(queryKey[0]), + ({ queryKey }) => softwareAPI.getCount(queryKey[0]), { enabled: isRouteOk && isSoftwareConfigLoaded, keepPreviousData: true, diff --git a/frontend/services/entities/global_policies.ts b/frontend/services/entities/global_policies.ts index 68d96856c3..340f5b5e4b 100644 --- a/frontend/services/entities/global_policies.ts +++ b/frontend/services/entities/global_policies.ts @@ -1,7 +1,45 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { snakeCase, reduce } from "lodash"; + import sendRequest from "services"; import endpoints from "utilities/endpoints"; -import { IPolicyFormData, ILoadAllPoliciesResponse } from "interfaces/policy"; +import { + IPolicyFormData, + ILoadAllPoliciesResponse, + IPoliciesCountResponse, +} from "interfaces/policy"; +import { buildQueryStringFromParams, QueryParams } from "utilities/url"; + +interface IPoliciesApiParams { + page?: number; + perPage?: number; + orderKey?: string; + orderDirection?: "asc" | "desc"; + query?: string; +} + +export interface IPoliciesQueryKey extends IPoliciesApiParams { + scope: "globalPolicies"; +} + +export interface IPoliciesCountQueryKey + extends Pick { + scope: "policiesCount"; +} + +const ORDER_KEY = "name"; +const ORDER_DIRECTION = "asc"; + +const convertParamsToSnakeCase = (params: IPoliciesApiParams) => { + return reduce( + params, + (result, val, key) => { + result[snakeCase(key)] = val; + return result; + }, + {} + ); +}; export default { // TODO: How does the frontend need to support legacy policies? @@ -33,4 +71,40 @@ export default { return sendRequest("GET", GLOBAL_POLICIES); }, + loadAllNew: async ({ + page, + perPage, + orderKey = ORDER_KEY, + orderDirection: orderDir = ORDER_DIRECTION, + query, + }: IPoliciesApiParams): Promise => { + const { GLOBAL_POLICIES } = endpoints; + + const queryParams = { + page, + perPage, + orderKey, + orderDirection: orderDir, + query, + }; + + const snakeCaseParams = convertParamsToSnakeCase(queryParams); + const queryString = buildQueryStringFromParams(snakeCaseParams); + const path = `${GLOBAL_POLICIES}?${queryString}`; + + return sendRequest("GET", path); + }, + getCount: async ({ + query, + }: Pick): Promise => { + const { GLOBAL_POLICIES } = endpoints; + const path = `${GLOBAL_POLICIES}/count`; + const queryParams = { + query, + }; + const snakeCaseParams = convertParamsToSnakeCase(queryParams); + const queryString = buildQueryStringFromParams(snakeCaseParams); + + return sendRequest("GET", path.concat(`?${queryString}`)); + }, }; diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 5d0ded929a..54f829171d 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -74,7 +74,7 @@ export default { } }, - count: async ({ + getCount: async ({ query, teamId, vulnerable, diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 2e90d5df65..d7a03f1f1b 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -1,8 +1,59 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { snakeCase, reduce } from "lodash"; + import sendRequest from "services"; import endpoints from "utilities/endpoints"; -import { ILoadTeamPoliciesResponse, IPolicyFormData } from "interfaces/policy"; +import { + ILoadTeamPoliciesResponse, + IPolicyFormData, + IPoliciesCountResponse, +} from "interfaces/policy"; import { API_NO_TEAM_ID } from "interfaces/team"; +import { buildQueryStringFromParams, QueryParams } from "utilities/url"; + +interface IPoliciesApiQueryParams { + page?: number; + perPage?: number; + orderKey?: string; + orderDirection?: "asc" | "desc"; + query?: string; + inheritedPage?: number; + inheritedPerPage?: number; + inheritedOrderKey?: string; + inheritedOrderDirection?: "asc" | "desc"; +} + +export interface IPoliciesApiParams extends IPoliciesApiQueryParams { + teamId: number; +} + +export interface ITeamPoliciesQueryKey extends IPoliciesApiParams { + scope: "teamPolicies"; +} + +export interface ITeamPoliciesCountQueryKey + extends Pick { + scope: "teamPoliciesCount"; +} + +interface IPoliciesCountApiParams { + teamId: number; + query?: string; +} + +const ORDER_KEY = "name"; +const ORDER_DIRECTION = "asc"; + +const convertParamsToSnakeCase = (params: IPoliciesApiQueryParams) => { + return reduce( + params, + (result, val, key) => { + result[snakeCase(key)] = val; + return result; + }, + {} + ); +}; export default { create: (data: IPolicyFormData) => { @@ -77,4 +128,56 @@ export default { return sendRequest("GET", path); }, + loadAllNew: async ({ + teamId, + page, + perPage, + orderKey = ORDER_KEY, + orderDirection: orderDir = ORDER_DIRECTION, + query, + inheritedPage, + inheritedPerPage, + inheritedOrderKey = ORDER_KEY, + inheritedOrderDirection: inheritedOrderDir = ORDER_DIRECTION, + }: IPoliciesApiParams): Promise => { + const { TEAMS } = endpoints; + + const queryParams = { + page, + perPage, + orderKey, + orderDirection: orderDir, + query, + inheritedPage, + inheritedPerPage, + inheritedOrderKey, + inheritedOrderDirection: inheritedOrderDir, + }; + + const snakeCaseParams = convertParamsToSnakeCase(queryParams); + const queryString = buildQueryStringFromParams(snakeCaseParams); + const path = `${TEAMS}/${teamId}/policies?${queryString}`; + if (!teamId) { + throw new Error("Invalid team id"); + } + + return sendRequest("GET", path); + }, + getCount: async ({ + query, + teamId, + }: Pick< + IPoliciesCountApiParams, + "query" | "teamId" + >): Promise => { + const { TEAM_POLICIES } = endpoints; + const path = `${TEAM_POLICIES(teamId)}/count`; + const queryParams = { + query, + }; + const snakeCaseParams = convertParamsToSnakeCase(queryParams); + const queryString = buildQueryStringFromParams(snakeCaseParams); + + return sendRequest("GET", path.concat(`?${queryString}`)); + }, };