[Infrastructure UI] Hosts view flyout metadata search (#154556)

Closes #154347 

## Summary

This PR adds search functionality to the metadata tab.
In order to optimize the search I changed the table to
`EuiInMemoryTable` and handled the search there. One benefit is that
table filtering is the responsibility of the table and the cases to
handle errors/no data found are much easier.
<img width="2435" alt="Screenshot 2023-04-06 at 15 36 35"
src="https://user-images.githubusercontent.com/14139027/230400195-b77b7783-9c4d-48b0-85e6-cb38180a29d3.png">

<img width="2434" alt="Screenshot 2023-04-06 at 15 58 22"
src="https://user-images.githubusercontent.com/14139027/230400337-1013626c-c802-4b45-88f1-bff67f0ec37e.png">

This also helped to get rid of some of the callouts condition and leave
the table component to decide what to render based on the items and
loading state. That way the loading looks much smoother rather than
replacing the table with a loading component - also when loading and
there are no results the loading indicator is inside the table.

![image](https://user-images.githubusercontent.com/14139027/230401218-04871ce9-ceba-4803-8259-7978c4152ee9.png)

## Testing
1. Open the flyout for a single host
2. On the metadata tab start searching
    a. Try to search for field name/value - should get a result
b. Do a typo with an invalid character (or just enter only ```, `+`,
etc) - an error message should be displayed (and ⚠️ icon in the search
bar)
c. Try to search for something that it's not a field name/value - should
display the `No metadata found.` message in the table.
3. Copy a URL after searching for something and paste it into a new
browser tab/window - it should persist the search term. (In case of a
search error the search filter is not persisted)



https://user-images.githubusercontent.com/14139027/230400149-6ba4dc32-efaa-4068-8abb-24b6ae43de76.mov
This commit is contained in:
jennypavlova 2023-04-12 13:49:56 +02:00 committed by GitHub
parent 57d6f413a1
commit 5a03c5d710
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 113 additions and 43 deletions

View file

@ -110,13 +110,23 @@ describe('Single Host Metadata (Hosts View)', () => {
mockUseMetadata({ metadata: [] });
const result = renderHostMetadata();
expect(result.queryByTestId('infraMetadataNoData')).toBeInTheDocument();
expect(result.queryByTestId('infraHostMetadataSearchBarInput')).toBeInTheDocument();
expect(result.queryByTestId('infraHostMetadataNoData')).toBeInTheDocument();
});
it('should return spinner if loading', async () => {
it('should show the metadata table if metadata is returned', async () => {
mockUseMetadata({ metadata: [{ name: 'host.os.name', value: 'Ubuntu' }] });
const result = renderHostMetadata();
expect(result.queryByTestId('infraHostMetadataSearchBarInput')).toBeInTheDocument();
expect(result.queryByTestId('infraMetadataTable')).toBeInTheDocument();
});
it('should return loading text if loading', async () => {
mockUseMetadata({ loading: true });
const result = renderHostMetadata();
expect(result.queryByTestId('infraHostMetadataSearchBarInput')).toBeInTheDocument();
expect(result.queryByTestId('infraHostMetadataLoading')).toBeInTheDocument();
});
});

View file

@ -7,7 +7,6 @@
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiLoadingChart } from '@elastic/eui';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useSourceContext } from '../../../../../../containers/metrics_source';
@ -31,17 +30,13 @@ export const Metadata = ({ node, currentTimeRange, nodeType }: TabProps) => {
const { sourceId } = useSourceContext();
const {
loading: metadataLoading,
error,
error: fetchMetadataError,
metadata,
} = useMetadata(nodeId, nodeType, inventoryModel.requiredMetrics, sourceId, currentTimeRange);
const fields = useMemo(() => getAllFields(metadata), [metadata]);
if (metadataLoading) {
return <LoadingPlaceholder />;
}
if (error) {
if (fetchMetadataError) {
return (
<EuiCallOut
title={i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.errorTitle', {
@ -71,33 +66,5 @@ export const Metadata = ({ node, currentTimeRange, nodeType }: TabProps) => {
);
}
return fields.length > 0 ? (
<Table rows={fields} />
) : (
<EuiCallOut
data-test-subj="infraMetadataNoData"
title={i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.noMetadataFound', {
defaultMessage: 'Sorry, there is no metadata related to this host.',
})}
size="m"
iconType="iInCircle"
/>
);
};
const LoadingPlaceholder = () => {
return (
<div
style={{
width: '100%',
height: '200px',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EuiLoadingChart data-test-subj="infraHostMetadataLoading" size="xl" />
</div>
);
return <Table rows={fields} loading={metadataLoading} />;
};

View file

@ -5,11 +5,21 @@
* 2.0.
*/
import { EuiText, EuiFlexGroup, EuiFlexItem, EuiLink, EuiBasicTable } from '@elastic/eui';
import {
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiInMemoryTable,
EuiSearchBarProps,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import useToggle from 'react-use/lib/useToggle';
import { debounce } from 'lodash';
import { Query } from '@elastic/eui';
import { useHostFlyoutOpen } from '../../../hooks/use_host_flyout_open_url_state';
interface Row {
name: string;
@ -18,6 +28,11 @@ interface Row {
interface Props {
rows: Row[];
loading: boolean;
}
interface SearchErrorType {
message: string;
}
/**
@ -31,8 +46,65 @@ const VALUE_LABEL = i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadat
defaultMessage: 'Value',
});
/**
* Component translations
*/
const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.infra.hostsViewPage.hostDetail.metadata.searchForMetadata',
{
defaultMessage: 'Search for metadata…',
}
);
const NO_METADATA_FOUND = i18n.translate(
'xpack.infra.hostsViewPage.hostDetail.metadata.noMetadataFound',
{
defaultMessage: 'No metadata found.',
}
);
const LOADING = i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.loading', {
defaultMessage: 'Loading...',
});
export const Table = (props: Props) => {
const { rows } = props;
const { rows, loading } = props;
const [searchError, setSearchError] = useState<SearchErrorType | null>(null);
const [hostFlyoutOpen, setHostFlyoutOpen] = useHostFlyoutOpen();
const debouncedSearchOnChange = useMemo(
() =>
debounce<(queryText: string) => void>((queryText) => {
setHostFlyoutOpen({ metadataSearch: String(queryText) ?? '' });
}, 500),
[setHostFlyoutOpen]
);
const searchBarOnChange = useCallback(
({ queryText, error }) => {
if (error) {
setSearchError(error);
} else {
setSearchError(null);
debouncedSearchOnChange(queryText);
}
},
[debouncedSearchOnChange]
);
const search: EuiSearchBarProps = {
onChange: searchBarOnChange,
box: {
'data-test-subj': 'infraHostMetadataSearchBarInput',
incremental: true,
schema: true,
placeholder: SEARCH_PLACEHOLDER,
},
query: hostFlyoutOpen.metadataSearch
? Query.parse(hostFlyoutOpen.metadataSearch)
: Query.MATCH_ALL,
};
const columns = useMemo(
() => [
{
@ -54,12 +126,22 @@ export const Table = (props: Props) => {
);
return (
<EuiBasicTable
<EuiInMemoryTable
data-test-subj="infraMetadataTable"
tableLayout={'fixed'}
responsive={false}
columns={columns}
items={rows}
search={search}
loading={loading}
error={searchError ? `${searchError.message}` : ''}
message={
loading ? (
<div data-test-subj="infraHostMetadataLoading">{LOADING}</div>
) : (
<div data-test-subj="infraHostMetadataNoData">{NO_METADATA_FOUND}</div>
)
}
/>
);
};

View file

@ -20,6 +20,7 @@ export const GET_DEFAULT_TABLE_PROPERTIES = {
clickedItemId: '',
selectedTabId: FlyoutTabIds.METADATA,
searchFilter: '',
metadataSearch: '',
};
const HOST_TABLE_PROPERTIES_URL_STATE_KEY = 'hostFlyoutOpen';
@ -55,12 +56,22 @@ const SetSearchFilterRT = rt.partial({
searchFilter: SearchFilterRT,
});
const ActionRT = rt.intersection([SetClickedItemIdRT, SetFlyoutTabId, SetSearchFilterRT]);
const SetMetadataSearchRT = rt.partial({
metadataSearch: SearchFilterRT,
});
const ActionRT = rt.intersection([
SetClickedItemIdRT,
SetFlyoutTabId,
SetSearchFilterRT,
SetMetadataSearchRT,
]);
const HostFlyoutOpenRT = rt.type({
clickedItemId: ClickedItemIdRT,
selectedTabId: FlyoutTabIdRT,
searchFilter: SearchFilterRT,
metadataSearch: SearchFilterRT,
});
type HostFlyoutOpen = rt.TypeOf<typeof HostFlyoutOpenRT>;