diff --git a/cmd/ui/src/views/GroupManagement/GroupManagement.test.tsx b/cmd/ui/src/views/GroupManagement/GroupManagement.test.tsx index 5bf84960e3..3d298fe780 100644 --- a/cmd/ui/src/views/GroupManagement/GroupManagement.test.tsx +++ b/cmd/ui/src/views/GroupManagement/GroupManagement.test.tsx @@ -19,12 +19,13 @@ import { act, render, waitFor } from '../../test-utils'; import GroupManagement from './GroupManagement'; import { rest } from 'msw'; import { createMockDomain } from 'src/mocks/factories'; -import { createMockAssetGroup, createMockAssetGroupMembers } from 'bh-shared-ui'; +import { createMockAssetGroup, createMockAssetGroupMembers, createMockMemberCounts } from 'bh-shared-ui'; import userEvent from '@testing-library/user-event'; const domain = createMockDomain(); const assetGroup = createMockAssetGroup(); const assetGroupMembers = createMockAssetGroupMembers(); +const memberCounts = createMockMemberCounts(); const server = setupServer( rest.get('/api/v2/available-domains', (req, res, ctx) => { @@ -43,6 +44,9 @@ const server = setupServer( }) ); }), + rest.get('/api/v2/asset-groups/1/members/counts', (req, res, ctx) => { + return res(ctx.json(memberCounts)); + }), rest.get('*', (req, res, ctx) => res(ctx.json({ data: [] }))) ); diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.test.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.test.tsx index b8fae10c5b..d3b8e296e6 100644 --- a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.test.tsx +++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.test.tsx @@ -15,7 +15,7 @@ // SPDX-License-Identifier: Apache-2.0 import { setupServer } from 'msw/node'; -import { createMockAssetGroup, createMockAssetGroupMembers, createMockSearchResults } from '../../mocks/factories'; +import { createMockAssetGroup, createMockMemberCounts, createMockSearchResults } from '../../mocks/factories'; import { act, render, waitFor } from '../../test-utils'; import { AUTOCOMPLETE_PLACEHOLDER } from './AssetGroupAutocomplete'; import AssetGroupEdit from './AssetGroupEdit'; @@ -23,20 +23,10 @@ import { rest } from 'msw'; import userEvent from '@testing-library/user-event'; const assetGroup = createMockAssetGroup(); -const assetGroupMembers = createMockAssetGroupMembers(); const searchResults = createMockSearchResults(); +const memberCounts = createMockMemberCounts(); const server = setupServer( - rest.get('/api/v2/asset-groups/1/members', (req, res, ctx) => { - return res( - ctx.json({ - count: assetGroupMembers.members.length, - limit: 100, - skip: 0, - data: assetGroupMembers, - }) - ); - }), rest.get('/api/v2/search', (req, res, ctx) => { return res( ctx.json({ @@ -54,7 +44,7 @@ describe('AssetGroupEdit', () => { const setup = async () => { const user = userEvent.setup(); const screen = await act(async () => { - return render( ({})} />); + return render(); }); return { user, screen }; }; @@ -68,7 +58,7 @@ describe('AssetGroupEdit', () => { it('should display a total count of asset group members', async () => { const { screen } = await setup(); const count = screen.getByText('Total Count').nextSibling.textContent; - expect(count).toBe(assetGroupMembers.members.length.toString()); + expect(count).toBe(memberCounts.total_count.toString()); }); it('should display search results when the user enters text', async () => { diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.tsx index 30d7ca4a0f..c585fcce32 100644 --- a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.tsx +++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.tsx @@ -15,27 +15,26 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, Paper } from '@mui/material'; -import { AssetGroup, AssetGroupMemberParams, UpdateAssetGroupSelectorRequest } from 'js-client-library'; +import { + AssetGroup, + AssetGroupMemberParams, + AssetGroupMemberCounts, + UpdateAssetGroupSelectorRequest, +} from 'js-client-library'; import { FC, useEffect, useState } from 'react'; import { AssetGroupChangelog, AssetGroupChangelogEntry, ChangelogAction } from './types'; import AssetGroupAutocomplete from './AssetGroupAutocomplete'; import { SubHeader } from '../../views/Explore'; -import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useMutation, useQueryClient } from 'react-query'; import { apiClient } from '../../utils'; import AssetGroupChangelogTable from './AssetGroupChangelogTable'; -import { - ActiveDirectoryNodeKind, - ActiveDirectoryNodeKindToDisplay, - AzureNodeKind, - AzureNodeKindToDisplay, -} from '../../graphSchema'; import { useNotifications } from '../../providers'; const AssetGroupEdit: FC<{ assetGroup: AssetGroup; filter: AssetGroupMemberParams; - makeNodeFilterable: (node: ActiveDirectoryNodeKind | AzureNodeKind) => void; -}> = ({ assetGroup, filter, makeNodeFilterable }) => { + memberCounts: AssetGroupMemberCounts | undefined; +}> = ({ assetGroup, filter, memberCounts }) => { const [changelog, setChangelog] = useState([]); const addRows = changelog.filter((entry) => entry.action === ChangelogAction.ADD); const removeRows = changelog.filter((entry) => entry.action === ChangelogAction.REMOVE); @@ -93,11 +92,7 @@ const AssetGroupEdit: FC<{ return ( - + mutation.mutate()} /> )} - {Object.values(ActiveDirectoryNodeKind).map((kind) => { - const { environment_id, environment_kind } = filter; - const narrowedFilter = { primary_kind: `eq:${kind}`, environment_id, environment_kind }; - const label = ActiveDirectoryNodeKindToDisplay(kind) || ''; - - return ( - makeNodeFilterable(kind)} - /> - ); - })} - {Object.values(AzureNodeKind).map((kind) => { - const { environment_id, environment_kind } = filter; - const narrowedFilter = { primary_kind: `eq:${kind}`, environment_id, environment_kind }; - const label = AzureNodeKindToDisplay(kind) || ''; - - return ( - makeNodeFilterable(kind)} - /> - ); + {Object.entries(memberCounts?.counts ?? {}).map(([kind, count]) => { + return ; })} ); }; -const FilteredMemberCountDisplay: FC<{ - assetGroupId: number; - label: string; - filter: AssetGroupMemberParams; - makeNodeKindFilterable?: () => void; -}> = ({ assetGroupId, label, filter, makeNodeKindFilterable }) => { - const { - data: count, - isError, - isLoading, - } = useQuery(['countAssetGroupMembers', assetGroupId, filter], ({ signal }) => - apiClient.listAssetGroupMembers(assetGroupId.toString(), filter, { signal }).then((res) => res.data.count) - ); - - const hasValidCount = !isLoading && !isError && count && count > 0; - - useEffect(() => { - if (hasValidCount) { - makeNodeKindFilterable?.(); - } - }, [hasValidCount, makeNodeKindFilterable]); - - if (hasValidCount) { - return ; - } else { - return null; - } -}; - export default AssetGroupEdit; diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.test.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.test.tsx index d733ea15e7..098a7a7eb6 100644 --- a/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.test.tsx +++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.test.tsx @@ -14,21 +14,21 @@ // // SPDX-License-Identifier: Apache-2.0 -import { createMockAssetGroupMemberParams, createMockAvailableNodeKinds } from '../../mocks/factories'; +import { createMockAssetGroupMemberParams, createMockMemberCounts } from '../../mocks/factories'; import { act, render } from '../../test-utils'; import AssetGroupFilters, { FILTERABLE_PARAMS } from './AssetGroupFilters'; import userEvent from '@testing-library/user-event'; import { Screen, waitFor } from '@testing-library/react'; -import { AssetGroupMemberParams } from 'js-client-library'; -import { ActiveDirectoryNodeKind, AzureNodeKind } from '../..'; +import { AssetGroupMemberParams, AssetGroupMemberCountsResponse } from 'js-client-library'; +import { ActiveDirectoryNodeKind } from '../../graphSchema'; const filterParams = createMockAssetGroupMemberParams(); -const availableNodeKinds = createMockAvailableNodeKinds(); +const memberCounts = createMockMemberCounts(); describe('AssetGroupEdit', () => { const setup = async (options?: { filterParams?: AssetGroupMemberParams; - availableNodeKinds?: Array; + memberCounts?: AssetGroupMemberCountsResponse['data']; }) => { const user = userEvent.setup(); const handleFilterChange = vi.fn(); @@ -37,7 +37,7 @@ describe('AssetGroupEdit', () => { ); }); @@ -45,7 +45,7 @@ describe('AssetGroupEdit', () => { }; it('renders a button that expands the filter section', async () => { - const { screen, user } = await setup({ filterParams, availableNodeKinds }); + const { screen, user } = await setup({ filterParams, memberCounts }); const filtersButton = screen.getByTestId('display-filters-button'); const collapsedSection = screen.getByTestId('asset-group-filter-collapsible-section'); @@ -60,7 +60,7 @@ describe('AssetGroupEdit', () => { }); it('indicates that filters are active', async () => { - const { screen } = await setup({ filterParams, availableNodeKinds }); + const { screen } = await setup({ filterParams, memberCounts }); const filtersContainer = screen.getByTestId('asset-group-filters-container'); @@ -77,7 +77,7 @@ describe('AssetGroupEdit', () => { describe('Node Type dropdown filter', () => { it('displays the value from filterParams.node_type', async () => { - const { screen } = await setup({ filterParams, availableNodeKinds }); + const { screen } = await setup({ filterParams, memberCounts }); const nodeTypeFilter = screen.getByTestId('asset-groups-node-type-filter'); const nodeTypeFilterValue = nodeTypeFilter.firstChild?.nextSibling; @@ -86,42 +86,44 @@ describe('AssetGroupEdit', () => { }); it('lists all available node kinds as options to filter by', async () => { - const { screen, user } = await setup({ availableNodeKinds }); + const { screen, user } = await setup({ memberCounts }); await user.click(screen.getByTestId('display-filters-button')); await user.click(screen.getByLabelText('Node Type')); const nodeKindList = await screen.findAllByRole('option'); - expect(nodeKindList).toHaveLength(availableNodeKinds.length + 1); // +1 for the default empty value + expect(nodeKindList).toHaveLength(memberCounts.total_count + 1); // +1 for the default empty value - for (const nodeKind of availableNodeKinds) { + for (const nodeKind in memberCounts.counts) { expect(screen.getByText(nodeKind)).toBeInTheDocument(); } }); it('calls handleFilterChange when a node type is selected', async () => { - const { screen, user, handleFilterChange } = await setup({ availableNodeKinds }); + const { screen, user, handleFilterChange } = await setup({ memberCounts }); + + const expectedNodeKind = ActiveDirectoryNodeKind.Domain; await user.click(screen.getByTestId('display-filters-button')); await user.click(screen.getByLabelText('Node Type')); - await user.click(screen.getByText(availableNodeKinds[0])); + await user.click(screen.getByText(expectedNodeKind)); expect(handleFilterChange).toBeCalledTimes(1); - expect(handleFilterChange).toHaveBeenCalledWith('primary_kind', `eq:${availableNodeKinds[0]}`); + expect(handleFilterChange).toHaveBeenCalledWith('primary_kind', `eq:${expectedNodeKind}`); }); }); describe('Custom Member checkbox filter', () => { it("displays the checkbox as checked if the filter params value is 'true'", async () => { - const { screen } = await setup({ filterParams: { custom_member: 'eq:true' }, availableNodeKinds }); + const { screen } = await setup({ filterParams: { custom_member: 'eq:true' }, memberCounts }); const checkbox = screen.getByTestId('asset-groups-custom-member-filter'); expect((checkbox.firstChild as HTMLInputElement)?.checked).toBe(true); }); it('invokes handleFilterChange with eq:false when clicked and custom_member filter is on', async () => { - const { screen, user, handleFilterChange } = await setup({ filterParams, availableNodeKinds }); + const { screen, user, handleFilterChange } = await setup({ filterParams, memberCounts }); const checkbox = screen.getByTestId('asset-groups-custom-member-filter'); await user.click(checkbox); @@ -143,14 +145,14 @@ describe('AssetGroupEdit', () => { describe('Clear Filters button', () => { it('has a button with text Clear Filters', async () => { - const { screen } = await setup({ filterParams, availableNodeKinds }); + const { screen } = await setup({ filterParams, memberCounts }); const clearFilersButton = screen.getByText('Clear Filters'); expect(clearFilersButton).toBeInTheDocument(); }); it('calls handleFilterChange with all filter types and empty strings when clicked while filters are active', async () => { - const { screen, user, handleFilterChange } = await setup({ filterParams, availableNodeKinds }); + const { screen, user, handleFilterChange } = await setup({ filterParams, memberCounts }); const clearFilersButton = screen.getByText('Clear Filters'); await user.click(clearFilersButton); diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.tsx index d7966b0c64..fcecba7489 100644 --- a/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.tsx +++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupFilters/AssetGroupFilters.tsx @@ -16,7 +16,6 @@ import { AssetGroupMemberParams } from 'js-client-library/dist/types'; import { FC, useState } from 'react'; -import { AzureNodeKind, ActiveDirectoryNodeKind, NodeIcon } from '../..'; import { Box, Button, @@ -30,46 +29,45 @@ import { Paper, Select, } from '@mui/material'; -import createStyles from '@mui/styles/createStyles'; import makeStyles from '@mui/styles/makeStyles'; import { Theme } from '@mui/material/styles'; +import NodeIcon from '../NodeIcon'; +import { AssetGroupMemberCounts } from 'js-client-library'; export const FILTERABLE_PARAMS: Array> = [ 'primary_kind', 'custom_member', ]; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - formControl: { - display: 'block', - }, - activeFilters: { - '& button.expand-filters': { - fontWeight: 'bolder', - '& span': { - visibility: 'visible', - }, +const useStyles = makeStyles((theme: Theme) => ({ + formControl: { + display: 'block', + }, + activeFilters: { + '& button.expand-filters': { + fontWeight: 'bolder', + '& span': { + visibility: 'visible', }, }, - activeFiltersDot: { - width: '6px', - height: '6px', - borderRadius: '100%', - backgroundColor: theme.palette.primary.main, - alignSelf: 'baseline', - visibility: 'hidden', - }, - }) -); + }, + activeFiltersDot: { + width: '6px', + height: '6px', + borderRadius: '100%', + backgroundColor: theme.palette.primary.main, + alignSelf: 'baseline', + visibility: 'hidden', + }, +})); interface Props { filterParams: AssetGroupMemberParams; handleFilterChange: (key: (typeof FILTERABLE_PARAMS)[number], value: string) => void; - availableNodeKinds: Array; + memberCounts: AssetGroupMemberCounts | undefined; } -const AssetGroupFilters: FC = ({ filterParams, handleFilterChange, availableNodeKinds }) => { +const AssetGroupFilters: FC = ({ filterParams, handleFilterChange, memberCounts = { counts: {} } }) => { const [displayFilters, setDisplayFilters] = useState(false); const classes = useStyles(); @@ -115,7 +113,7 @@ const AssetGroupFilters: FC = ({ filterParams, handleFilterChange, availa None - {availableNodeKinds.map((value) => { + {Object.keys(memberCounts.counts).map((value) => { return ( diff --git a/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/GroupManagementContent.tsx b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/GroupManagementContent.tsx index 54366dac3f..72b81eee45 100644 --- a/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/GroupManagementContent.tsx +++ b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/GroupManagementContent.tsx @@ -27,7 +27,6 @@ import AssetGroupMemberList from '../AssetGroupMemberList'; import { SelectedDomain } from './types'; import DataSelector from '../../views/DataQuality/DataSelector'; import AssetGroupFilters from '../AssetGroupFilters'; -import { ActiveDirectoryNodeKind, AzureNodeKind } from '../..'; import { FILTERABLE_PARAMS } from '../AssetGroupFilters/AssetGroupFilters'; // Top level layout and shared logic for the Group Management page @@ -57,7 +56,6 @@ const GroupManagementContent: FC<{ const [selectedDomain, setSelectedDomain] = useState(null); const [selectedAssetGroupId, setSelectedAssetGroupId] = useState(null); const [filterParams, setFilterParams] = useState({}); - const [availableNodeKinds, setAvailableNodeKinds] = useState>([]); const setInitialGroup = (data: AssetGroup[]) => { if (!selectedAssetGroupId && data?.length) { @@ -74,6 +72,24 @@ const GroupManagementContent: FC<{ const selectedAssetGroup = listAssetGroups.data?.find((group) => group.id === selectedAssetGroupId) || null; + const { data: memberCounts } = useQuery({ + queryKey: [ + 'getAssetGroupMembersCount', + filterParams.environment_id, + filterParams.environment_kind, + selectedAssetGroup, + ], + enabled: !!selectedAssetGroupId, + queryFn: ({ signal }) => + apiClient + .getAssetGroupMembersCount( + selectedAssetGroupId?.toString() ?? '', // This query will only execute if selectedAssetGroup is truethy. + { environment_id: filterParams.environment_id, environment_kind: filterParams.environment_kind }, + { signal } + ) + .then((res) => res.data.data), + }); + const handleAssetGroupSelectorChange = (selectedAssetGroup: DropdownOption) => { const selected = listAssetGroups.data?.find((assetGroup) => assetGroup.id === selectedAssetGroup.key); if (selected) setSelectedAssetGroupId(selected.id); @@ -98,11 +114,6 @@ const GroupManagementContent: FC<{ setFilterParams((prev) => ({ ...prev, [key]: value.toString() })); }; - const makeNodeFilterable = (node: ActiveDirectoryNodeKind | AzureNodeKind) => { - if (availableNodeKinds.includes(node)) return; - setAvailableNodeKinds((prev) => [...prev, node]); - }; - // Start building a filter query for members that gets passed down to AssetGroupMemberList to make the request useEffect(() => { const filterDomain = selectedDomain || globalDomain; @@ -114,7 +125,6 @@ const GroupManagementContent: FC<{ } else { filter.environment_id = `eq:${filterDomain?.id}`; } - setAvailableNodeKinds([]); setFilterParams(filter); }, [selectedDomain, globalDomain, selectedAssetGroupId]); @@ -153,13 +163,13 @@ const GroupManagementContent: FC<{ {selectedAssetGroup && ( )} @@ -168,7 +178,7 @@ const GroupManagementContent: FC<{ assetGroup={selectedAssetGroup} filter={filterParams} onSelectMember={onClickMember} - canFilterToEmpty={!!availableNodeKinds.length} + canFilterToEmpty={(memberCounts?.total_count ?? 0) > 0} /> diff --git a/packages/javascript/bh-shared-ui/src/mocks/factories.ts b/packages/javascript/bh-shared-ui/src/mocks/factories.ts index 13b101a098..57e267dd09 100644 --- a/packages/javascript/bh-shared-ui/src/mocks/factories.ts +++ b/packages/javascript/bh-shared-ui/src/mocks/factories.ts @@ -14,9 +14,9 @@ // // SPDX-License-Identifier: Apache-2.0 -import { AssetGroup, AssetGroupMember, AssetGroupMemberParams } from 'js-client-library'; +import { AssetGroup, AssetGroupMember, AssetGroupMemberParams, AssetGroupMemberCounts } from 'js-client-library'; import { SearchResults } from '../hooks'; -import { ActiveDirectoryNodeKind, AzureNodeKind } from '..'; +import { ActiveDirectoryNodeKind } from '../graphSchema'; export const createMockAssetGroupMembers = (): { members: AssetGroupMember[] } => { return { @@ -92,10 +92,9 @@ export const createMockAssetGroupMemberParams = (): AssetGroupMemberParams => { } } -export const createMockAvailableNodeKinds = (): Array => { - return [ - ActiveDirectoryNodeKind.User, - ActiveDirectoryNodeKind.Computer, - ActiveDirectoryNodeKind.Domain, - ] +export const createMockMemberCounts = (): AssetGroupMemberCounts => { + return { + total_count: 3, + counts: { [ActiveDirectoryNodeKind.User]: 1, [ActiveDirectoryNodeKind.Computer]: 23, [ActiveDirectoryNodeKind.Domain]: 123 } + } } \ No newline at end of file diff --git a/packages/javascript/js-client-library/src/client.ts b/packages/javascript/js-client-library/src/client.ts index bfe3199564..c19622b12c 100644 --- a/packages/javascript/js-client-library/src/client.ts +++ b/packages/javascript/js-client-library/src/client.ts @@ -25,6 +25,7 @@ import { PaginatedResponse, PostureResponse, SavedQuery, + AssetGroupMemberCountsResponse, } from './responses'; class BHEAPIClient { @@ -131,6 +132,16 @@ class BHEAPIClient { Object.assign({ params }, options) ); + getAssetGroupMembersCount = ( + assetGroupId: string, + params?: Pick, + options?: types.RequestOptions + ) => + this.baseClient.get( + `/api/v2/asset-groups/${assetGroupId}/members/counts`, + Object.assign({ params }, options) + ); + listAssetGroups = (options?: types.RequestOptions) => this.baseClient.get('/api/v2/asset-groups', options); diff --git a/packages/javascript/js-client-library/src/responses.ts b/packages/javascript/js-client-library/src/responses.ts index 893f03a794..16b0bcd50b 100644 --- a/packages/javascript/js-client-library/src/responses.ts +++ b/packages/javascript/js-client-library/src/responses.ts @@ -93,10 +93,17 @@ export type AssetGroupMember = { primary_kind: string; }; +export type AssetGroupMemberCounts = { + total_count: number; + counts: Record; +}; + export type AssetGroupResponse = BasicResponse<{ asset_groups: AssetGroup[] }>; export type AssetGroupMembersResponse = PaginatedResponse<{ members: AssetGroupMember[] }>; +export type AssetGroupMemberCountsResponse = BasicResponse + export type SavedQuery = { id: number; name: string;