Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.x] [Inventory][ECO] filter by type on grid (#193875) #193985

Merged
merged 1 commit into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} label={label} />,
{ wrapper: EuiThemeProvider }
);
expect(screen.queryByText(label)).toBeInTheDocument();
expect(screen.getByText(label).textContent).toBe(label);
});

it('opens the popover when the badge is clicked', () => {
render(<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} />);
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(<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} />);
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(<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} />);
fireEvent.click(screen.getByText(value));
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton'));
expect(copyToClipboard).toHaveBeenCalledWith(value);
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<EuiPopover
button={
<EuiBadge
data-test-subj="inventoryBadgeFilterWithPopoverButton"
color="hollow"
onClick={() => setIsOpen((state) => !state)}
onClickAriaLabel={i18n.translate(
'xpack.inventory.badgeFilterWithPopover.openPopoverBadgeLabel',
{ defaultMessage: 'Open popover' }
)}
>
{label || value}
</EuiBadge>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
>
<span data-test-subj="inventoryBadgeFilterWithPopoverTitle">
<EuiFlexGroup
data-test-subj="inventoryBadgeFilterWithPopoverContent"
responsive={false}
gutterSize="xs"
css={css`
font-family: ${theme.euiTheme.font.familyCode};
`}
>
<EuiFlexItem grow={false}>
<span
css={css`
font-weight: bold;
`}
>
{field}:
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className="eui-textBreakWord">{value}</span>
</EuiFlexItem>
</EuiFlexGroup>
</span>
<EuiPopoverFooter>
<EuiFlexGrid responsive={false} columns={2}>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
iconType="plusInCircle"
onClick={onFilter}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {
defaultMessage: 'Filter for',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverCopyValueButton"
iconType="copyClipboard"
onClick={() => copyToClipboard(value)}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.copyValueButtonEmptyLabel', {
defaultMessage: 'Copy value',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGrid>
</EuiPopoverFooter>
</EuiPopover>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<{}> = {
Expand All @@ -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<EntityType | undefined>();
const filteredAndSortedItems = useMemo(
() =>
orderBy(
selectedEntityType
? entitiesMock.filter((mock) => mock[ENTITY_TYPE] === selectedEntityType)
: entitiesMock,
sort.id,
sort.direction
),
[selectedEntityType, sort.direction, sort.id]
);

return (
<EntitiesGrid
entities={sortedItems}
loading={false}
sortDirection={sort.direction}
sortField={sort.id}
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
/>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
{`Entity filter: ${selectedEntityType || 'N/A'}`}
<EuiLink
disabled={!selectedEntityType}
data-test-subj="inventoryExampleClearFilterButton"
onClick={() => setSelectedEntityType(undefined)}
>
Clear filter
</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EntitiesGrid
entities={filteredAndSortedItems}
loading={false}
sortDirection={sort.direction}
sortField={sort.id}
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
onFilterByType={setSelectedEntityType}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

Expand All @@ -60,6 +83,7 @@ export const EmptyGridExample: Story<{}> = () => {
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
onFilterByType={() => {}}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* 2.0.
*/
import {
EuiBadge,
EuiButtonIcon,
EuiDataGrid,
EuiDataGridCellValueElementProps,
Expand All @@ -20,14 +19,15 @@ 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,
ENTITY_TYPE,
} 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'>;

Expand Down Expand Up @@ -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;
Expand All @@ -118,6 +119,7 @@ export function EntitiesGrid({
pageIndex,
onChangePage,
onChangeSort,
onFilterByType,
}: Props) {
const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id));

Expand All @@ -141,10 +143,14 @@ export function EntitiesGrid({
const columnEntityTableId = columnId as EntityColumnIds;
switch (columnEntityTableId) {
case ENTITY_TYPE:
const entityType = entity[columnEntityTableId] as EntityType;
return (
<EuiBadge color="hollow">
{getEntityTypeLabel(entity[columnEntityTableId] as EntityType)}
</EuiBadge>
<BadgeFilterWithPopover
field={ENTITY_TYPE}
value={entityType}
label={getEntityTypeLabel(entityType)}
onFilter={() => onFilterByType(entityType)}
/>
);
case ENTITY_LAST_SEEN:
return (
Expand Down Expand Up @@ -183,7 +189,7 @@ export function EntitiesGrid({
return entity[columnId as EntityColumnIds] || '';
}
},
[entities]
[entities, onFilterByType]
);

if (loading) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,6 +82,17 @@ export function InventoryPage() {
});
}

function handleTypeFilter(entityType: EntityType) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
// Override the current entity types
entityTypes: [entityType],
},
});
}

return (
<EntitiesGrid
entities={value.entities}
Expand All @@ -90,6 +102,7 @@ export function InventoryPage() {
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
onFilterByType={handleTypeFilter}
/>
);
}