[Asset Inventory] Split URL State Management for Page Filters and Grouping fixes (#220455)

## Summary

It closes #219580 and #219654

This PR adds separate URL state management to filters in the asset
inventory page, so it doesn't conflict with the filter used in the
Searchbar.

It also fixes an issue with the null grouping not filtering for the
missing group field.

### Screenshot


https://github.com/user-attachments/assets/0c022d41-f68f-46df-bbe9-2c5918e0af44

**Null group fix**

<img width="1481" alt="image"
src="https://github.com/user-attachments/assets/60010d40-f314-4823-bddc-17f828f175c0"
/>

**Asset Criticality Grouping**

<img width="1493" alt="image"
src="https://github.com/user-attachments/assets/7a47742f-0d14-4643-91ac-ce3dd847b576"
/>
This commit is contained in:
Paulo Silva 2025-05-09 10:22:41 -07:00 committed by GitHub
parent 4f2a06d358
commit e528a30fa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 168 additions and 16 deletions

View file

@ -219,7 +219,7 @@ const DataTableWithLocalPagination = ({
...currentGroupFilters,
...(parentGroupFilters ? JSON.parse(parentGroupFilters) : []),
]
.map(({ query }) => (query.match_phrase ? query : null))
.map(({ query }) => (query?.match_phrase || query?.bool?.should ? query : null))
.filter(Boolean);
const newState: AssetInventoryURLStateResult = {

View file

@ -64,8 +64,8 @@ export const AssetInventoryFilters = ({ setQuery }: AssetInventoryFiltersProps)
return (
<FilterGroup
dataViewId={dataView.id || null}
onFiltersChange={(filters: Filter[]) => {
setQuery({ filters });
onFiltersChange={(pageFilters: Filter[]) => {
setQuery({ pageFilters });
}}
ruleTypeIds={ASSET_INVENTORY_RULE_TYPE_IDS}
Storage={Storage}

View file

@ -56,11 +56,13 @@ const defaultGroupingOptions: GroupOption[] = [
export const getDefaultQuery = ({
query,
filters,
pageFilters,
}: AssetsBaseURLQuery): AssetsBaseURLQuery & {
sort: string[][];
} => ({
query,
filters,
pageFilters: [],
sort: [[]],
});

View file

@ -24,6 +24,8 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { getAbbreviatedNumber } from '@kbn/cloud-security-posture-common';
import { CloudProviderIcon, type CloudProvider } from '@kbn/custom-icons';
import type { CriticalityLevelWithUnassigned } from '../../../../../common/entity_analytics/asset_criticality/types';
import { AssetCriticalityBadge } from '../../../../entity_analytics/components/asset_criticality';
import { ASSET_GROUPING_OPTIONS, TEST_SUBJ_GROUPING_COUNTER } from '../../../constants';
import { firstNonNullValue } from './first_non_null_value';
import { NullGroup } from './null_group';
@ -68,19 +70,27 @@ export const groupPanelRenderer: GroupPanelRenderer<AssetsGroupingAggregation> =
switch (selectedGroup) {
case ASSET_GROUPING_OPTIONS.ASSET_CRITICALITY:
return nullGroupMessage ? (
renderNullGroup(NULL_GROUPING_MESSAGES.ASSET_CRITICALITY)
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<AssetCriticalityBadge criticalityLevel="unassigned" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiText size="s"> {getGroupPanelTitle()}</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs" color="subdued">
{firstNonNullValue(bucket.assetCriticality?.buckets?.[0]?.key)}{' '}
{firstNonNullValue(bucket.assetCriticality?.buckets?.[0]?.key)}
</EuiText>
<AssetCriticalityBadge
criticalityLevel={
firstNonNullValue(
bucket.assetCriticality?.buckets?.[0]?.key
) as CriticalityLevelWithUnassigned
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -17,13 +17,14 @@ import { usePersistedQuery } from './use_persisted_query';
export interface AssetsBaseURLQuery {
query: Query;
filters: Filter[];
pageFilters?: Filter[];
/**
* Grouping component selection
*/
groupBy?: string[];
}
export type AssetsURLQuery = Pick<AssetsBaseURLQuery, 'query' | 'filters'>;
export type AssetsURLQuery = Pick<AssetsBaseURLQuery, 'query' | 'filters' | 'pageFilters'>;
export type URLQuery = AssetsBaseURLQuery & Record<string, unknown>;
@ -33,6 +34,7 @@ export interface AssetInventoryURLStateResult {
setUrlQuery: (query: Record<string, unknown>) => void;
sort: SortOrder[];
filters: Filter[];
pageFilters: Filter[];
query: { bool: BoolQuery };
queryError?: Error;
pageIndex: number;
@ -52,6 +54,7 @@ export interface AssetInventoryURLStateResult {
const getDefaultQuery = ({ query, filters }: AssetsBaseURLQuery) => ({
query,
filters,
pageFilters: [],
sort: { field: '@timestamp', direction: 'desc' },
pageIndex: 0,
});
@ -128,6 +131,7 @@ export const useAssetInventoryURLState = ({
*/
const baseEsQuery = useBaseEsQuery({
filters: urlQuery.filters,
pageFilters: urlQuery.pageFilters,
query: urlQuery.query,
});
@ -151,6 +155,7 @@ export const useAssetInventoryURLState = ({
setUrlQuery,
sort: urlQuery.sort as SortOrder[],
filters: urlQuery.filters || [],
pageFilters: urlQuery.pageFilters || [],
query: baseEsQuery.query
? baseEsQuery.query
: {

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react';
import { useBaseEsQuery } from './use_base_es_query';
import { coreMock } from '@kbn/core/public/mocks';
import { useKibana } from '../../../common/lib/kibana';
import { useDataViewContext } from '../data_view_context';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { Filter } from '@kbn/es-query';
jest.mock('../../../common/lib/kibana');
jest.mock('../data_view_context');
const mockDataView = {
id: 'test-data-view',
title: 'test-*',
getIndexPattern: () => 'test-*',
fields: [
{
name: 'entity.name',
type: 'string',
esTypes: ['keyword'],
aggregatable: true,
searchable: true,
},
{
name: 'entity.type',
type: 'string',
esTypes: ['keyword'],
aggregatable: true,
searchable: true,
},
],
} as unknown as DataView;
const uiSettings = coreMock.createStart().uiSettings;
const notifications = coreMock.createStart().notifications;
const filterManager = {
setAppFilters: jest.fn(),
};
const queryString = {
setQuery: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
(useKibana as jest.Mock).mockReturnValue({
services: {
notifications,
uiSettings,
data: {
query: {
filterManager,
queryString,
},
},
},
});
(useDataViewContext as jest.Mock).mockReturnValue({
dataView: mockDataView,
});
uiSettings.get = jest.fn().mockReturnValue(true);
});
describe('useBaseEsQuery', () => {
it('should build and return a valid ES query', () => {
const query = { query: 'entity.sub_type: "Compute"', language: 'kuery' };
const filters = [
{
meta: { disabled: false, key: 'entity.type', negate: false },
query: {
match: { 'entity.type': 'host' },
},
},
];
const pageFilters: Filter[] = [
{
meta: { disabled: false, key: 'entity.name', negate: false },
query: {
match: { 'entity.name': 'test-host' },
},
},
];
const { result } = renderHook(() => useBaseEsQuery({ query, filters, pageFilters }));
expect(result.current.query).toBeDefined();
expect(result.current.query).toEqual({
bool: {
filter: [
{
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'entity.sub_type': 'Compute',
},
},
],
},
},
{
match: { 'entity.type': 'host' },
},
{
match: { 'entity.name': 'test-host' },
},
],
must: [],
must_not: [],
should: [],
},
});
expect(filterManager.setAppFilters).toHaveBeenCalledWith(filters);
expect(filterManager.setAppFilters).not.toHaveBeenCalledWith(pageFilters);
expect(queryString.setQuery).toHaveBeenCalledWith(query);
expect(notifications.toasts.addError).not.toHaveBeenCalled();
});
});

View file

@ -21,14 +21,17 @@ const getBaseQuery = ({
dataView,
query,
filters,
pageFilters,
config,
}: AssetsBaseURLQuery &
AssetsBaseESQueryConfig & {
dataView: DataView | undefined;
}) => {
try {
const mergedFilters = [...filters, ...(pageFilters ?? [])];
return {
query: buildEsQuery(dataView, query, filters, config), // will throw for malformed query
query: buildEsQuery(dataView, query, mergedFilters, config), // will throw for malformed query
};
} catch (error) {
return {
@ -38,7 +41,7 @@ const getBaseQuery = ({
}
};
export const useBaseEsQuery = ({ filters = [], query }: AssetsBaseURLQuery) => {
export const useBaseEsQuery = ({ filters = [], query, pageFilters = [] }: AssetsBaseURLQuery) => {
const {
notifications: { toasts },
data: {
@ -54,10 +57,11 @@ export const useBaseEsQuery = ({ filters = [], query }: AssetsBaseURLQuery) => {
getBaseQuery({
dataView,
filters,
pageFilters,
query,
config,
}),
[dataView, filters, query, config]
[dataView, filters, pageFilters, query, config]
);
/**

View file

@ -31,9 +31,10 @@ import {
} from '../constants';
import { OnboardingSuccessCallout } from '../components/onboarding/onboarding_success_callout';
const getDefaultQuery = ({ query, filters }: AssetsBaseURLQuery): URLQuery => ({
const getDefaultQuery = ({ query, filters, pageFilters }: AssetsBaseURLQuery): URLQuery => ({
query,
filters,
pageFilters,
sort: [['@timestamp', 'desc']],
});