[Infrastructure UI] Asset Details: Add pins to the metadata table (#161074)

Closes #155190

## Summary

This PR adds the possibility to pin different rows inside the metadata
table in asset details embeddable. The pins are persisted in the local
storage and should be available after refreshing/reopening the host
flyout. The order and sorting are explained in [this
comment](https://github.com/elastic/kibana/issues/155190#issuecomment-1523335704),
so basically we keep the original sorting order of the table (`host`,
`cloud`, `agent`) also for the pins.

## Testing 
- Go to hosts view and open a single host flyout (metadata tab)
- Try to add / remove pins
- Check if the pins are persisted after a page refresh



62873e7e-b5f0-444c-94ff-5e19f2f46f58
This commit is contained in:
jennypavlova 2023-07-06 17:10:38 +02:00 committed by GitHub
parent 8afb9b086c
commit b641a22438
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 166 additions and 14 deletions

View file

@ -0,0 +1,75 @@
/*
* 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, { Dispatch } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import type { Field } from './utils';
interface AddMetadataPinToRowProps {
fieldName: Field['name'];
pinnedItems: Array<Field['name']>;
onPinned: Dispatch<React.SetStateAction<Array<Field['name']> | undefined>>;
}
const PIN_FIELD = i18n.translate('xpack.infra.metadataEmbeddable.pinField', {
defaultMessage: 'Pin Field',
});
export const AddMetadataPinToRow = ({
fieldName,
pinnedItems,
onPinned,
}: AddMetadataPinToRowProps) => {
const handleAddPin = () => {
onPinned([...pinnedItems, fieldName]);
};
const handleRemovePin = () => {
if (pinnedItems && pinnedItems.includes(fieldName)) {
onPinned((pinnedItems ?? []).filter((pinName: string) => fieldName !== pinName));
}
};
if (pinnedItems?.includes(fieldName)) {
return (
<span>
<EuiToolTip
content={i18n.translate('xpack.infra.metadataEmbeddable.unpinField', {
defaultMessage: 'Unpin field',
})}
>
<EuiButtonIcon
size="s"
color="primary"
iconType="pinFilled"
data-test-subj="infraMetadataEmbeddableRemovePin"
aria-label={i18n.translate('xpack.infra.metadataEmbeddable.pinAriaLabel', {
defaultMessage: 'Pinned field',
})}
onClick={handleRemovePin}
/>
</EuiToolTip>
</span>
);
}
return (
<span className="euiTableCellContent__hoverItem expandedItemActions__completelyHide">
<EuiToolTip content={PIN_FIELD}>
<EuiButtonIcon
color="primary"
size="s"
iconType="pin"
data-test-subj="infraMetadataEmbeddableAddPin"
aria-label={PIN_FIELD}
onClick={handleAddPin}
/>
</EuiToolTip>
</span>
);
};

View file

@ -10,6 +10,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiIcon,
EuiInMemoryTable,
EuiSearchBarProps,
type HorizontalAlignment,
@ -20,15 +21,13 @@ import { FormattedMessage } from '@kbn/i18n-react';
import useToggle from 'react-use/lib/useToggle';
import { debounce } from 'lodash';
import { Query } from '@elastic/eui';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { AddMetadataFilterButton } from './add_metadata_filter_button';
interface Row {
name: string;
value: string | string[] | undefined;
}
import { type Field, getRowsWithPins } from './utils';
import { AddMetadataPinToRow } from './add_pin_to_row';
export interface Props {
rows: Row[];
rows: Field[];
loading: boolean;
showActionsColumn?: boolean;
search?: string;
@ -65,12 +64,43 @@ const LOADING = i18n.translate('xpack.infra.metadataEmbeddable.loading', {
defaultMessage: 'Loading...',
});
const LOCAL_STORAGE_PINNED_METADATA_ROWS = 'hostsView:pinnedMetadataRows';
export const Table = ({ loading, rows, onSearchChange, search, showActionsColumn }: Props) => {
const [searchError, setSearchError] = useState<SearchErrorType | null>(null);
const [metadataSearch, setMetadataSearch] = useState(search);
const [fieldsWithPins, setFieldsWithPins] = useState(rows);
const [pinnedItems, setPinnedItems] = useLocalStorage<Array<Field['name']>>(
LOCAL_STORAGE_PINNED_METADATA_ROWS,
[]
);
useMemo(() => {
if (pinnedItems) {
setFieldsWithPins(getRowsWithPins(rows, pinnedItems) ?? rows);
}
}, [rows, pinnedItems]);
const defaultColumns = useMemo(
() => [
{
field: 'value',
name: <EuiIcon type="pin" />,
align: 'center' as HorizontalAlignment,
width: '5%',
sortable: false,
showOnHover: true,
render: (_name: string, item: Field) => {
return (
<AddMetadataPinToRow
fieldName={item.name}
pinnedItems={pinnedItems ?? []}
onPinned={setPinnedItems}
/>
);
},
},
{
field: 'name',
name: FIELD_LABEL,
@ -81,12 +111,12 @@ export const Table = ({ loading, rows, onSearchChange, search, showActionsColumn
{
field: 'value',
name: VALUE_LABEL,
width: '55%',
width: '50%',
sortable: false,
render: (_name: string, item: Row) => <ExpandableContent values={item.value} />,
render: (_name: string, item: Field) => <ExpandableContent values={item.value} />,
},
],
[]
[pinnedItems, setPinnedItems]
);
const debouncedSearchOnChange = useMemo(
@ -134,7 +164,7 @@ export const Table = ({ loading, rows, onSearchChange, search, showActionsColumn
sortable: false,
showOnHover: true,
align: 'center' as HorizontalAlignment,
render: (_name: string, item: Row) => {
render: (_name: string, item: Field) => {
return <AddMetadataFilterButton item={item} />;
},
},
@ -149,7 +179,7 @@ export const Table = ({ loading, rows, onSearchChange, search, showActionsColumn
tableLayout={'fixed'}
responsive={false}
columns={columns}
items={rows}
items={fieldsWithPins}
rowProps={{ className: 'euiTableRow-hasActions' }}
search={searchBar}
loading={loading}

View file

@ -7,6 +7,11 @@
import type { InfraMetadata } from '../../../../../common/http_api';
export interface Field {
name: string;
value: string | string[] | undefined;
}
export const getAllFields = (metadata: InfraMetadata | null) => {
if (!metadata?.info) return [];
return prune([
@ -105,5 +110,17 @@ export const getAllFields = (metadata: InfraMetadata | null) => {
]);
};
const prune = (fields: Array<{ name: string; value: string | string[] | undefined }>) =>
fields.filter((f) => !!f.value);
const prune = (fields: Field[]) => fields.filter((f) => !!f.value);
export const getRowsWithPins = (rows: Field[], pinnedItems: Array<Field['name']>) => {
if (pinnedItems.length > 0) {
const { pinned, other } = rows.reduce(
(acc, row) => {
(pinnedItems.includes(row.name) ? acc.pinned : acc.other).push(row);
return acc;
},
{ pinned: [] as Field[], other: [] as Field[] }
);
return [...pinned, ...other];
}
};

View file

@ -267,10 +267,23 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
describe('Metadata Tab', () => {
it('should render metadata tab, add and remove filter', async () => {
it('should render metadata tab, pin/unpin row, add and remove filter', async () => {
const metadataTab = await pageObjects.infraHostsView.getMetadataTabName();
expect(metadataTab).to.contain('Metadata');
// Add Pin
await pageObjects.infraHostsView.clickAddMetadataPin();
expect(await pageObjects.infraHostsView.getRemovePinExist()).to.be(true);
// Persist pin after refresh
await browser.refresh();
await pageObjects.infraHome.waitForLoading();
expect(await pageObjects.infraHostsView.getRemovePinExist()).to.be(true);
// Remove Pin
await pageObjects.infraHostsView.clickRemoveMetadataPin();
expect(await pageObjects.infraHostsView.getRemovePinExist()).to.be(false);
await pageObjects.infraHostsView.clickAddMetadataFilter();
await pageObjects.header.waitUntilLoadingHasFinished();
@ -289,6 +302,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
});
it('should render metadata tab, pin and unpin table row', async () => {
const metadataTab = await pageObjects.infraHostsView.getMetadataTabName();
expect(metadataTab).to.contain('Metadata');
});
describe('Processes Tab', () => {
it('should render processes tab and with Total Value summary', async () => {
await pageObjects.infraHostsView.clickProcessesFlyoutTab();

View file

@ -60,6 +60,14 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) {
return testSubjects.click('hostsView-flyout-apm-services-link');
},
async clickAddMetadataPin() {
return testSubjects.click('infraMetadataEmbeddableAddPin');
},
async clickRemoveMetadataPin() {
return testSubjects.click('infraMetadataEmbeddableRemovePin');
},
async clickAddMetadataFilter() {
return testSubjects.click('hostsView-flyout-metadata-add-filter');
},
@ -190,6 +198,10 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) {
return tabTitle.getVisibleText();
},
async getRemovePinExist() {
return testSubjects.exists('infraMetadataEmbeddableRemovePin');
},
async getAppliedFilter() {
const filter = await testSubjects.find(
"filter-badge-'host.architecture: arm64' filter filter-enabled filter-key-host.architecture filter-value-arm64 filter-unpinned filter-id-0"