[8.4] [Kubernetes Security] - Tree Navigation Empty State (#137133)

This commit is contained in:
Paulo Henrique 2022-07-26 09:34:55 -07:00 committed by GitHub
parent 5124d6c94d
commit e0280ea2f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 297 additions and 85 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -0,0 +1,37 @@
/*
* 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, { createContext, useContext } from 'react';
import { useTreeView, UseTreeViewProps } from './hooks';
type TreeViewContextType = ReturnType<typeof useTreeView>;
const TreeViewContext = createContext<TreeViewContextType | null>(null);
export const useTreeViewContext = () => {
const context = useContext(TreeViewContext);
if (!context) {
throw new Error('useTreeViewContext must be called within an TreeViewContextProvider');
}
return context;
};
type TreeViewContextProviderProps = {
children: JSX.Element;
};
export const TreeViewContextProvider = ({
children,
...useTreeViewProps
}: TreeViewContextProviderProps & UseTreeViewProps) => {
return (
<TreeViewContext.Provider value={useTreeView(useTreeViewProps)}>
{children}
</TreeViewContext.Provider>
);
};

View file

@ -10,6 +10,7 @@ import { waitFor } from '@testing-library/react';
import { AppContextTestRender, createAppRootMockRenderer } from '../../../test';
import { DynamicTreeView } from '.';
import { clusterResponseMock, nodeResponseMock } from '../mocks';
import { TreeViewContextProvider } from '../contexts';
describe('DynamicTreeView component', () => {
let render: (props?: any) => ReturnType<AppContextTestRender['render']>;
@ -19,37 +20,48 @@ describe('DynamicTreeView component', () => {
const waitForApiCall = () => waitFor(() => expect(mockedApi).toHaveBeenCalled());
const defaultProps = {
globalFilter: {
startDate: Date.now().toString(),
endDate: (Date.now() + 1).toString(),
},
indexPattern: {
title: '*-logs',
},
} as any;
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
mockedApi = mockedContext.coreStart.http.get;
mockedApi.mockResolvedValue(clusterResponseMock);
render = (props) =>
(renderResult = mockedContext.render(
<DynamicTreeView
query={{
bool: {
filter: [],
must: [],
must_not: [],
should: [],
},
}}
indexPattern={'*-logs'}
tree={[
{
key: 'cluster',
name: 'cluster',
namePlural: 'clusters',
type: 'cluster',
iconProps: {
type: 'cluster',
<TreeViewContextProvider {...defaultProps}>
<DynamicTreeView
query={{
bool: {
filter: [],
must: [],
must_not: [],
should: [],
},
},
]}
aria-label="Logical Tree View"
onSelect={(selectionDepth, key, type) => {}}
{...props}
/>
}}
tree={[
{
key: 'cluster',
name: 'cluster',
namePlural: 'clusters',
type: 'cluster',
iconProps: {
type: 'cluster',
},
},
]}
aria-label="Logical Tree View"
onSelect={(selectionDepth, key, type) => {}}
{...props}
/>
</TreeViewContextProvider>
));
});

View file

@ -22,6 +22,7 @@ import {
import { useFetchDynamicTreeView } from './hooks';
import { useStyles } from './styles';
import { disableEventDefaults, focusNextElement } from './helpers';
import { useTreeViewContext } from '../contexts';
import type { DynamicTreeViewProps, DynamicTreeViewItemProps } from './types';
const BUTTON_TEST_ID = 'kubernetesSecurity:dynamicTreeViewButton';
@ -55,15 +56,15 @@ export const DynamicTreeView = ({
depth = 0,
selectionDepth = {},
query,
indexPattern = '',
onSelect,
hasSelection,
selected = '',
expanded = true,
...props
}: DynamicTreeViewProps) => {
const styles = useStyles(depth);
const { indexPattern, hasSelection, setNoResults } = useTreeViewContext();
const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } =
useFetchDynamicTreeView(query, tree[depth].key, indexPattern, expanded);
@ -86,6 +87,12 @@ export const DynamicTreeView = ({
}
};
useEffect(() => {
if (depth === 0 && data && data.pages?.[0].buckets.length === 0) {
setNoResults(true);
}
}, [data, depth, setNoResults]);
useEffect(() => {
if (expanded) {
fetchNextPage();
@ -158,7 +165,6 @@ export const DynamicTreeView = ({
aria-label={ariaLabel}
depth={depth}
expanded={expanded}
indexPattern={indexPattern}
isExpanded={isExpanded}
onSelect={onSelect}
onToggleExpand={onToggleExpand}
@ -209,7 +215,6 @@ const DynamicTreeViewItem = ({
selected,
expanded,
query,
indexPattern,
...props
}: DynamicTreeViewItemProps) => {
const isLastNode = depth === tree.length - 1;
@ -319,7 +324,6 @@ const DynamicTreeViewItem = ({
[tree[depth].type]: aggData.key,
}}
tree={tree}
indexPattern={indexPattern}
onSelect={onSelect}
selected={selected}
aria-label={`${aggData.key} child of ${props['aria-label']}`}

View file

@ -12,7 +12,6 @@ export type DynamicTreeViewProps = {
depth?: number;
selectionDepth?: TreeNavSelection;
query: QueryDslQueryContainerBool;
indexPattern?: string;
onSelect: (selectionDepth: TreeNavSelection, key: string | number, type: string) => void;
hasSelection?: boolean;
'aria-label': string;

View file

@ -0,0 +1,58 @@
/*
* 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 { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiImage, EuiText, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { CSSObject } from '@emotion/serialize';
import icon from './assets/illustration_product_no_results_magnifying_glass.svg';
export const TREE_EMPTY_STATE = 'kubernetesSecurity:treeEmptyState';
const panelStyle: CSSObject = {
maxWidth: 500,
};
const wrapperStyle: CSSObject = {
height: 262,
};
export const EmptyState: React.FC = () => {
return (
<EuiPanel color="subdued" data-test-subj={TREE_EMPTY_STATE}>
<EuiFlexGroup css={wrapperStyle} alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={true} css={panelStyle}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.kubernetesSecurity.treeView.empty.title"
defaultMessage="No results match your search criteria"
/>
</h3>
</EuiTitle>
<p>
<FormattedMessage
id="xpack.kubernetesSecurity.treeView.empty.description"
defaultMessage="Try searching over a longer period of time or modifying your search"
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiImage size="200" alt="" url={icon} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,63 @@
/*
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { KubernetesCollection, TreeNavSelection } from '../../types';
import { addTimerangeAndDefaultFilterToQuery } from '../../utils/add_timerange_and_default_filter_to_query';
import { addTreeNavSelectionToFilterQuery } from './helpers';
import { IndexPattern, GlobalFilter } from '../../types';
export type UseTreeViewProps = {
globalFilter: GlobalFilter;
indexPattern?: IndexPattern;
};
export const useTreeView = ({ globalFilter, indexPattern }: UseTreeViewProps) => {
const [noResults, setNoResults] = useState(false);
const [treeNavSelection, setTreeNavSelection] = useState<TreeNavSelection>({});
const filterQueryWithTimeRange = useMemo(() => {
return JSON.parse(
addTimerangeAndDefaultFilterToQuery(
globalFilter.filterQuery,
globalFilter.startDate,
globalFilter.endDate
)
);
}, [globalFilter.filterQuery, globalFilter.startDate, globalFilter.endDate]);
const onTreeNavSelect = useCallback((selection: TreeNavSelection) => {
setTreeNavSelection(selection);
}, []);
const hasSelection = useMemo(
() => !!treeNavSelection[KubernetesCollection.cluster],
[treeNavSelection]
);
const sessionViewFilter = useMemo(
() => addTreeNavSelectionToFilterQuery(globalFilter.filterQuery, treeNavSelection),
[globalFilter.filterQuery, treeNavSelection]
);
// Resetting defaults whenever filter changes
useEffect(() => {
setNoResults(false);
setTreeNavSelection({});
}, [filterQueryWithTimeRange]);
return {
noResults,
setNoResults,
filterQueryWithTimeRange,
indexPattern: indexPattern?.title || '',
onTreeNavSelect,
hasSelection,
treeNavSelection,
sessionViewFilter,
};
};

View file

@ -0,0 +1,43 @@
/*
* 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 { TreeViewContainer } from '.';
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
import * as context from './contexts';
describe('TreeNav component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
const spy = jest.spyOn(context, 'useTreeViewContext');
const defaultProps = {
globalFilter: {
startDate: Date.now().toString(),
endDate: (Date.now() + 1).toString(),
},
renderSessionsView: <div>Session View</div>,
} as any;
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
});
afterEach(() => {
spy.mockRestore();
});
it('shows empty message when there is no results', async () => {
spy.mockImplementation(() => ({
...jest.requireActual('./contexts').useTreeViewContext,
noResults: true,
}));
renderResult = mockedContext.render(<TreeViewContainer {...defaultProps} />);
expect(await renderResult.getByText(/no results/i)).toBeInTheDocument();
});
});

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiSplitPanel } from '@elastic/eui';
import React from 'react';
import { EuiSplitPanel, EuiText } from '@elastic/eui';
import { useStyles } from './styles';
import { IndexPattern, GlobalFilter, TreeNavSelection, KubernetesCollection } from '../../types';
import { IndexPattern, GlobalFilter } from '../../types';
import { TreeNav } from './tree_nav';
import { addTreeNavSelectionToFilterQuery } from './helpers';
import { Breadcrumb } from './breadcrumb';
import { TreeViewContextProvider, useTreeViewContext } from './contexts';
import { EmptyState } from './empty_state';
export interface TreeViewContainerDeps {
globalFilter: GlobalFilter;
@ -24,35 +25,38 @@ export const TreeViewContainer = ({
renderSessionsView,
indexPattern,
}: TreeViewContainerDeps) => {
const styles = useStyles();
const [treeNavSelection, setTreeNavSelection] = useState<TreeNavSelection>({});
const onTreeNavSelect = useCallback((selection: TreeNavSelection) => {
setTreeNavSelection(selection);
}, []);
const hasSelection = useMemo(
() => !!treeNavSelection[KubernetesCollection.cluster],
[treeNavSelection]
return (
<TreeViewContextProvider indexPattern={indexPattern} globalFilter={globalFilter}>
<TreeViewContainerComponent renderSessionsView={renderSessionsView} />
</TreeViewContextProvider>
);
};
const TreeViewContainerComponent = ({
renderSessionsView,
}: Pick<TreeViewContainerDeps, 'renderSessionsView'>) => {
const styles = useStyles();
const { hasSelection, treeNavSelection, sessionViewFilter, onTreeNavSelect, noResults } =
useTreeViewContext();
return (
<EuiSplitPanel.Outer direction="row" hasBorder borderRadius="m" css={styles.outerPanel}>
<EuiSplitPanel.Inner color="subdued" grow={false} css={styles.navPanel}>
<TreeNav
indexPattern={indexPattern}
globalFilter={globalFilter}
onSelect={onTreeNavSelect}
hasSelection={hasSelection}
/>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner css={styles.sessionsPanel}>
<Breadcrumb treeNavSelection={treeNavSelection} onSelect={onTreeNavSelect} />
{hasSelection &&
renderSessionsView(
addTreeNavSelectionToFilterQuery(globalFilter.filterQuery, treeNavSelection)
)}
</EuiSplitPanel.Inner>
{noResults ? (
<EmptyState />
) : (
<>
<EuiSplitPanel.Inner color="subdued" grow={false} css={styles.navPanel}>
<EuiText>
<TreeNav />
</EuiText>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner css={styles.sessionsPanel}>
<Breadcrumb treeNavSelection={treeNavSelection} onSelect={onTreeNavSelect} />
{hasSelection && renderSessionsView(sessionViewFilter)}
</EuiSplitPanel.Inner>
</>
)}
</EuiSplitPanel.Outer>
);
};

View file

@ -16,7 +16,7 @@ export const useStyles = () => {
const { border } = euiTheme;
const outerPanel: CSSObject = {
minHeight: '500px',
minHeight: '262px',
};
const navPanel: CSSObject = {

View file

@ -9,6 +9,7 @@ import React from 'react';
import { AppContextTestRender, createAppRootMockRenderer } from '../../../test';
import { clusterResponseMock } from '../mocks';
import { TreeNav } from '.';
import { TreeViewContextProvider } from '../contexts';
describe('TreeNav component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
@ -25,6 +26,12 @@ describe('TreeNav component', () => {
hasSelection: false,
};
const TreeNavContainer = () => (
<TreeViewContextProvider {...defaultProps}>
<TreeNav />
</TreeViewContextProvider>
);
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
mockedApi = mockedContext.coreStart.http.get;
@ -32,13 +39,13 @@ describe('TreeNav component', () => {
});
it('mount with Logical View selected by default', async () => {
renderResult = mockedContext.render(<TreeNav {...defaultProps} />);
renderResult = mockedContext.render(<TreeNavContainer />);
const elemLabel = await renderResult.getByDisplayValue(/logical/i);
expect(elemLabel).toBeChecked();
});
it('shows the tree path according with the selected view type', async () => {
renderResult = mockedContext.render(<TreeNav {...defaultProps} />);
renderResult = mockedContext.render(<TreeNavContainer />);
const logicalViewPath = 'cluster / namespace / pod / container image';
const logicViewRadio = await renderResult.getByDisplayValue(/logical/i);
@ -55,7 +62,7 @@ describe('TreeNav component', () => {
});
it('collapses / expands the tree nav when clicking on collapse button', async () => {
renderResult = mockedContext.render(<TreeNav {...defaultProps} />);
renderResult = mockedContext.render(<TreeNavContainer />);
expect(renderResult.getByText(/cluster/i)).toBeVisible();

View file

@ -23,20 +23,12 @@ import {
TREE_NAVIGATION_EXPAND,
} from '../../../../common/translations';
import { useStyles } from './styles';
import { IndexPattern, GlobalFilter, TreeNavSelection } from '../../../types';
import { DynamicTreeView } from '../dynamic_tree_view';
import { addTimerangeAndDefaultFilterToQuery } from '../../../utils/add_timerange_and_default_filter_to_query';
import { INFRASTRUCTURE, LOGICAL, TREE_VIEW } from './constants';
import { TreeViewKind, TreeViewOptionsGroup } from './types';
import { useTreeViewContext } from '../contexts';
interface TreeNavProps {
indexPattern?: IndexPattern;
globalFilter: GlobalFilter;
onSelect: (selection: TreeNavSelection) => void;
hasSelection: boolean;
}
export const TreeNav = ({ indexPattern, globalFilter, onSelect, hasSelection }: TreeNavProps) => {
export const TreeNav = () => {
const styles = useStyles();
const [tree, setTree] = useState(TREE_VIEW.logical);
const [selected, setSelected] = useState('');
@ -47,18 +39,12 @@ export const TreeNav = ({ indexPattern, globalFilter, onSelect, hasSelection }:
const logicalTreeViewPrefix = `${treeNavTypePrefix}${LOGICAL}`;
const [toggleIdSelected, setToggleIdSelected] = useState(logicalTreeViewPrefix);
const { filterQueryWithTimeRange, onTreeNavSelect } = useTreeViewContext();
const handleToggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
const filterQueryWithTimeRange = useMemo(() => {
return addTimerangeAndDefaultFilterToQuery(
globalFilter.filterQuery,
globalFilter.startDate,
globalFilter.endDate
);
}, [globalFilter.filterQuery, globalFilter.startDate, globalFilter.endDate]);
const options: TreeViewOptionsGroup[] = useMemo(
() => [
{
@ -127,8 +113,7 @@ export const TreeNav = ({ indexPattern, globalFilter, onSelect, hasSelection }:
<EuiSpacer size="s" />
<div css={styles.treeViewContainer} className="eui-scrollBar">
<DynamicTreeView
query={JSON.parse(filterQueryWithTimeRange)}
indexPattern={indexPattern?.title}
query={filterQueryWithTimeRange}
tree={tree}
aria-label={selectedLabel}
selected={selected}
@ -142,9 +127,8 @@ export const TreeNav = ({ indexPattern, globalFilter, onSelect, hasSelection }:
.map(([k, v]) => `${k}.${v}`)
.join()
);
onSelect(newSelectionDepth);
onTreeNavSelect(newSelectionDepth);
}}
hasSelection={hasSelection}
/>
</div>
</div>