From 251c3d1096334857b0d0b6d98c9f46ddad9afe28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:40:12 +0100 Subject: [PATCH] [Inventory][ECO] filter by type on grid (#193875) closes https://github.com/elastic/kibana/issues/192669 https://github.com/user-attachments/assets/d8c51a63-6783-4a9c-a785-83004489cd5d (cherry picked from commit 475fd6c2021ffc79e91afae05fa6793f42a4bb6a) --- .../badge_filter_with_popover.test.tsx | 61 +++++++++++ .../badge_filter_with_popover/index.tsx | 103 ++++++++++++++++++ .../entities_grid/entities_grid.stories.tsx | 56 +++++++--- .../public/components/entities_grid/index.tsx | 18 ++- .../public/pages/inventory_page/index.tsx | 13 +++ 5 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx new file mode 100644 index 00000000000000..cda2f0bcb42d30 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { BadgeFilterWithPopover } from '.'; +import { EuiThemeProvider, copyToClipboard } from '@elastic/eui'; +import { ENTITY_TYPE } from '../../../common/es_fields/entities'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + copyToClipboard: jest.fn(), +})); + +describe('BadgeFilterWithPopover', () => { + const mockOnFilter = jest.fn(); + const field = ENTITY_TYPE; + const value = 'host'; + const label = 'Host'; + const popoverContentDataTestId = 'inventoryBadgeFilterWithPopoverContent'; + const popoverContentTitleTestId = 'inventoryBadgeFilterWithPopoverTitle'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the badge with the correct label', () => { + render( + , + { wrapper: EuiThemeProvider } + ); + expect(screen.queryByText(label)).toBeInTheDocument(); + expect(screen.getByText(label).textContent).toBe(label); + }); + + it('opens the popover when the badge is clicked', () => { + render(); + expect(screen.queryByTestId(popoverContentDataTestId)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(value)); + expect(screen.queryByTestId(popoverContentDataTestId)).toBeInTheDocument(); + expect(screen.queryByTestId(popoverContentTitleTestId)?.textContent).toBe(`${field}:${value}`); + }); + + it('calls onFilter when the "Filter for" button is clicked', () => { + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterForButton')); + expect(mockOnFilter).toHaveBeenCalled(); + }); + + it('copies value to clipboard when the "Copy value" button is clicked', () => { + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton')); + expect(copyToClipboard).toHaveBeenCalledWith(value); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx new file mode 100644 index 00000000000000..d1e952e189d6ee --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx @@ -0,0 +1,103 @@ +/* + * 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 { + EuiBadge, + EuiButtonEmpty, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverFooter, + copyToClipboard, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; + +interface Props { + field: string; + value: string; + label?: string; + onFilter: () => void; +} + +export function BadgeFilterWithPopover({ field, value, onFilter, label }: Props) { + const [isOpen, setIsOpen] = useState(false); + const theme = useEuiTheme(); + + return ( + setIsOpen((state) => !state)} + onClickAriaLabel={i18n.translate( + 'xpack.inventory.badgeFilterWithPopover.openPopoverBadgeLabel', + { defaultMessage: 'Open popover' } + )} + > + {label || value} + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + + + + + {field}: + + + + {value} + + + + + + + + {i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', { + defaultMessage: 'Filter for', + })} + + + + copyToClipboard(value)} + > + {i18n.translate('xpack.inventory.badgeFilterWithPopover.copyValueButtonEmptyLabel', { + defaultMessage: 'Copy value', + })} + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx index b0e6c2fcc5ee4f..996f0ec9515815 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import { EuiDataGridSorting } from '@elastic/eui'; +import { EuiDataGridSorting, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import { Meta, Story } from '@storybook/react'; -import React, { useMemo, useState } from 'react'; import { orderBy } from 'lodash'; +import React, { useMemo, useState } from 'react'; import { EntitiesGrid } from '.'; -import { ENTITY_LAST_SEEN } from '../../../common/es_fields/entities'; +import { EntityType } from '../../../common/entities'; +import { ENTITY_LAST_SEEN, ENTITY_TYPE } from '../../../common/es_fields/entities'; import { entitiesMock } from './mock/entities_mock'; const stories: Meta<{}> = { @@ -25,22 +26,44 @@ export const Example: Story<{}> = () => { id: ENTITY_LAST_SEEN, direction: 'desc', }); - - const sortedItems = useMemo( - () => orderBy(entitiesMock, sort.id, sort.direction), - [sort.direction, sort.id] + const [selectedEntityType, setSelectedEntityType] = useState(); + const filteredAndSortedItems = useMemo( + () => + orderBy( + selectedEntityType + ? entitiesMock.filter((mock) => mock[ENTITY_TYPE] === selectedEntityType) + : entitiesMock, + sort.id, + sort.direction + ), + [selectedEntityType, sort.direction, sort.id] ); return ( - + + + {`Entity filter: ${selectedEntityType || 'N/A'}`} + setSelectedEntityType(undefined)} + > + Clear filter + + + + + + ); }; @@ -60,6 +83,7 @@ export const EmptyGridExample: Story<{}> = () => { onChangePage={setPageIndex} onChangeSort={setSort} pageIndex={pageIndex} + onFilterByType={() => {}} /> ); }; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index d5ab911605109a..6e26548520a432 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -5,7 +5,6 @@ * 2.0. */ import { - EuiBadge, EuiButtonIcon, EuiDataGrid, EuiDataGridCellValueElementProps, @@ -20,6 +19,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react'; import { last } from 'lodash'; import React, { useCallback, useState } from 'react'; +import { EntityType } from '../../../common/entities'; import { ENTITY_DISPLAY_NAME, ENTITY_LAST_SEEN, @@ -27,7 +27,7 @@ import { } from '../../../common/es_fields/entities'; import { APIReturnType } from '../../api'; import { getEntityTypeLabel } from '../../utils/get_entity_type_label'; -import { EntityType } from '../../../common/entities'; +import { BadgeFilterWithPopover } from '../badge_filter_with_popover'; type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; @@ -106,6 +106,7 @@ interface Props { pageIndex: number; onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void; onChangePage: (nextPage: number) => void; + onFilterByType: (entityType: EntityType) => void; } const PAGE_SIZE = 20; @@ -118,6 +119,7 @@ export function EntitiesGrid({ pageIndex, onChangePage, onChangeSort, + onFilterByType, }: Props) { const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id)); @@ -141,10 +143,14 @@ export function EntitiesGrid({ const columnEntityTableId = columnId as EntityColumnIds; switch (columnEntityTableId) { case ENTITY_TYPE: + const entityType = entity[columnEntityTableId] as EntityType; return ( - - {getEntityTypeLabel(entity[columnEntityTableId] as EntityType)} - + onFilterByType(entityType)} + /> ); case ENTITY_LAST_SEEN: return ( @@ -183,7 +189,7 @@ export function EntitiesGrid({ return entity[columnId as EntityColumnIds] || ''; } }, - [entities] + [entities, onFilterByType] ); if (loading) { diff --git a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx index 2fcb147cbe61f4..7af9a9fc21acc7 100644 --- a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx @@ -7,6 +7,7 @@ import { EuiDataGridSorting } from '@elastic/eui'; import React from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; +import { EntityType } from '../../../common/entities'; import { EntitiesGrid } from '../../components/entities_grid'; import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; @@ -81,6 +82,17 @@ export function InventoryPage() { }); } + function handleTypeFilter(entityType: EntityType) { + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + // Override the current entity types + entityTypes: [entityType], + }, + }); + } + return ( ); }