[Cloud Posture] Add blank page graphic (#126750)

This commit is contained in:
Ari Aviran 2022-03-08 15:26:44 +02:00 committed by GitHub
parent a5f410ecf7
commit 79363812b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 229 additions and 104 deletions

View file

@ -4,10 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from 'react-query';
import type { CspClientPluginStartDeps } from '../../types';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
import { CspClientPluginStartDeps } from '../../types';
/**
* TODO: use perfected kibana data views

View file

@ -4,15 +4,37 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ComponentProps } from 'react';
import React, { type ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import Chance from 'chance';
import { coreMock } from '../../../../../src/core/public/mocks';
import { createStubDataView } from '../../../../../src/plugins/data_views/public/data_views/data_view.stub';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../common/constants';
import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view';
import { createNavigationItemFixture } from '../test/fixtures/navigation_item';
import { createReactQueryResponse } from '../test/fixtures/react_query';
import { TestProvider } from '../test/test_provider';
import { CspPageTemplate, getSideNavItems } from './page_template';
import {
LOADING,
NO_DATA_CONFIG_BUTTON,
NO_DATA_CONFIG_DESCRIPTION,
NO_DATA_CONFIG_TITLE,
} from './translations';
const chance = new Chance();
const BLANK_PAGE_GRAPHIC_TEXTS = [
NO_DATA_CONFIG_TITLE,
NO_DATA_CONFIG_DESCRIPTION,
NO_DATA_CONFIG_BUTTON,
];
// Synchronized to the error message in the formatted message in `page_template.tsx`
const ERROR_LOADING_DATA_DEFAULT_MESSAGE = "We couldn't fetch your cloud security posture data";
jest.mock('../common/api/use_kubebeat_data_view');
describe('getSideNavItems', () => {
it('maps navigation items to side navigation items', () => {
const navigationItem = createNavigationItemFixture();
@ -36,40 +58,101 @@ describe('getSideNavItems', () => {
});
describe('<CspPageTemplate />', () => {
const renderCspPageTemplate = (props: ComponentProps<typeof CspPageTemplate>) => {
beforeEach(() => {
jest.resetAllMocks();
});
const renderCspPageTemplate = (props: ComponentProps<typeof CspPageTemplate> = {}) => {
const mockCore = coreMock.createStart();
render(
<TestProvider>
<TestProvider
core={{
...mockCore,
application: {
...mockCore.application,
capabilities: {
...mockCore.application.capabilities,
// This is required so that the `noDataConfig` view will show the action button
navLinks: { integrations: true },
},
},
}}
>
<CspPageTemplate {...props} />
</TestProvider>
);
};
it('renders children when not loading', () => {
it('renders children when data view is found', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({
status: 'success',
data: createStubDataView({
spec: {
id: CSP_KUBEBEAT_INDEX_PATTERN,
},
}),
})
);
const children = chance.sentence();
renderCspPageTemplate({ isLoading: false, children });
renderCspPageTemplate({ children });
expect(screen.getByText(children)).toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) =>
expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument()
);
});
it('does not render loading text when not loading', () => {
it('renders loading text when data view is loading', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({ status: 'loading' })
);
const children = chance.sentence();
const loadingText = chance.sentence();
renderCspPageTemplate({ isLoading: false, loadingText, children });
renderCspPageTemplate({ children });
expect(screen.queryByText(loadingText)).not.toBeInTheDocument();
expect(screen.getByText(LOADING)).toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) =>
expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument()
);
});
it('renders loading text when loading is true', () => {
const loadingText = chance.sentence();
renderCspPageTemplate({ loadingText, isLoading: true });
it('renders an error view when data view fetching has an error', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({ status: 'error', error: new Error('') })
);
expect(screen.getByText(loadingText)).toBeInTheDocument();
});
it('does not render children when loading', () => {
const children = chance.sentence();
renderCspPageTemplate({ isLoading: true, children });
renderCspPageTemplate({ children });
expect(screen.getByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) =>
expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument()
);
});
it('renders the blank page graphic when data view is missing', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({
status: 'success',
data: undefined,
})
);
const children = chance.sentence();
renderCspPageTemplate({ children });
BLANK_PAGE_GRAPHIC_TEXTS.forEach((text) => expect(screen.getByText(text)).toBeInTheDocument());
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
});
});

View file

@ -4,20 +4,26 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiSpacer } from '@elastic/eui';
import React from 'react';
import { NavLink } from 'react-router-dom';
import { EuiErrorBoundary } from '@elastic/eui';
import { EuiEmptyPrompt, EuiErrorBoundary, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
KibanaPageTemplate,
KibanaPageTemplateProps,
type KibanaPageTemplateProps,
} from '../../../../../src/plugins/kibana_react/public';
import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view';
import { allNavigationItems } from '../common/navigation/constants';
import type { CspNavigationItem } from '../common/navigation/types';
import { CLOUD_SECURITY_POSTURE } from '../common/translations';
import { CspLoadingState } from './csp_loading_state';
import { LOADING } from './translations';
import {
LOADING,
NO_DATA_CONFIG_BUTTON,
NO_DATA_CONFIG_DESCRIPTION,
NO_DATA_CONFIG_SOLUTION_NAME,
NO_DATA_CONFIG_TITLE,
} from './translations';
const activeItemStyle = { fontWeight: 700 };
@ -36,37 +42,69 @@ export const getSideNavItems = (
),
}));
const defaultProps: KibanaPageTemplateProps = {
const DEFAULT_PROPS: KibanaPageTemplateProps = {
solutionNav: {
name: CLOUD_SECURITY_POSTURE,
items: getSideNavItems(allNavigationItems),
},
restrictWidth: false,
template: 'default',
};
interface CspPageTemplateProps extends KibanaPageTemplateProps {
isLoading?: boolean;
loadingText?: string;
}
const NO_DATA_CONFIG: KibanaPageTemplateProps['noDataConfig'] = {
pageTitle: NO_DATA_CONFIG_TITLE,
solution: NO_DATA_CONFIG_SOLUTION_NAME,
// TODO: Add real docs link once we have it
docsLink: 'https://www.elastic.co/guide/index.html',
logo: 'logoSecurity',
actions: {
elasticAgent: {
// TODO: Use `href` prop to link to our own integration once we have it
title: NO_DATA_CONFIG_BUTTON,
description: NO_DATA_CONFIG_DESCRIPTION,
},
},
};
export const CspPageTemplate: React.FC<KibanaPageTemplateProps> = ({ children, ...props }) => {
// TODO: Consider using more sophisticated logic to find out if our integration is installed
const kubeBeatQuery = useKubebeatDataView();
let noDataConfig: KibanaPageTemplateProps['noDataConfig'];
if (kubeBeatQuery.status === 'success' && !kubeBeatQuery.data) {
noDataConfig = NO_DATA_CONFIG;
}
let template: KibanaPageTemplateProps['template'] = 'default';
if (kubeBeatQuery.status === 'error' || kubeBeatQuery.status === 'loading') {
template = 'centeredContent';
}
export const CspPageTemplate: React.FC<CspPageTemplateProps> = ({
children,
isLoading,
loadingText = LOADING,
...props
}) => {
return (
<KibanaPageTemplate {...defaultProps} {...props}>
<KibanaPageTemplate
{...DEFAULT_PROPS}
{...props}
template={template}
noDataConfig={noDataConfig}
>
<EuiErrorBoundary>
{isLoading ? (
<>
<EuiSpacer size="xxl" />
<CspLoadingState>{loadingText}</CspLoadingState>
</>
) : (
children
{kubeBeatQuery.status === 'loading' && <CspLoadingState>{LOADING}</CspLoadingState>}
{kubeBeatQuery.status === 'error' && (
<EuiEmptyPrompt
color="danger"
iconType="alert"
title={
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.csp.pageTemplate.loadErrorMessage"
defaultMessage="We couldn't fetch your cloud security posture data"
/>
</h2>
</EuiTitle>
}
/>
)}
{kubeBeatQuery.status === 'success' && children}
</EuiErrorBoundary>
</KibanaPageTemplate>
);

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const CRITICAL = i18n.translate('xpack.csp.critical', {
@ -40,3 +39,29 @@ export const CSP_EVALUATION_BADGE_PASSED = i18n.translate(
defaultMessage: 'PASSED',
}
);
export const NO_DATA_CONFIG_TITLE = i18n.translate('xpack.csp.pageTemplate.noDataConfigTitle', {
defaultMessage: 'Understand your cloud security posture',
});
export const NO_DATA_CONFIG_SOLUTION_NAME = i18n.translate(
'xpack.csp.pageTemplate.noDataConfig.solutionNameLabel',
{
defaultMessage: 'Cloud Security Posture',
}
);
export const NO_DATA_CONFIG_DESCRIPTION = i18n.translate(
'xpack.csp.pageTemplate.noDataConfigDescription',
{
defaultMessage:
'Use our CIS Kubernetes Benchmark integration to measure your Kubernetes cluster setup against the CIS recommendations.',
}
);
export const NO_DATA_CONFIG_BUTTON = i18n.translate(
'xpack.csp.pageTemplate.noDataConfigButtonLabel',
{
defaultMessage: 'Add a CIS integration',
}
);

View file

@ -7,6 +7,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import type { UseQueryResult } from 'react-query/types/react/types';
import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view';
import { createCspBenchmarkIntegrationFixture } from '../../test/fixtures/csp_benchmark_integration';
import { createReactQueryResponse } from '../../test/fixtures/react_query';
import { TestProvider } from '../../test/test_provider';
@ -15,10 +18,22 @@ import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS, LOADING_BENCHMARKS } fro
import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations';
jest.mock('./use_csp_benchmark_integrations');
jest.mock('../../common/api/use_kubebeat_data_view');
describe('<Benchmarks />', () => {
beforeEach(() => {
jest.resetAllMocks();
// Required for the page template to render the benchmarks page
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({
status: 'success',
data: createStubDataView({
spec: {
id: CSP_KUBEBEAT_INDEX_PATTERN,
},
}),
})
);
});
const renderBenchmarks = (

View file

@ -4,10 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiPageHeaderProps, EuiButton } from '@elastic/eui';
import { EuiPageHeaderProps, EuiButton, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { allNavigationItems } from '../../common/navigation/constants';
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
import { CspLoadingState } from '../../components/csp_loading_state';
import { CspPageTemplate } from '../../components/page_template';
import { BenchmarksTable } from './benchmarks_table';
import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS, LOADING_BENCHMARKS } from './translations';
@ -35,11 +36,13 @@ export const Benchmarks = () => {
const query = useCspBenchmarkIntegrations();
return (
<CspPageTemplate
pageHeader={PAGE_HEADER}
loadingText={LOADING_BENCHMARKS}
isLoading={query.status === 'loading'}
>
<CspPageTemplate pageHeader={PAGE_HEADER}>
{query.status === 'loading' && (
<>
<EuiSpacer size="xxl" />
<CspLoadingState>{LOADING_BENCHMARKS}</CspLoadingState>
</>
)}
{query.status === 'error' && <BenchmarksErrorState />}
{query.status === 'success' && (
<BenchmarksTable benchmarks={query.data} data-test-subj={BENCHMARKS_TABLE_DATA_TEST_SUBJ} />

View file

@ -7,49 +7,31 @@
import React from 'react';
import type { UseQueryResult } from 'react-query';
import { render, screen } from '@testing-library/react';
import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view';
import { Findings } from './findings';
import { MISSING_KUBEBEAT } from './translations';
import { TestProvider } from '../../test/test_provider';
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import {
dataPluginMock,
type Start as DataPluginStart,
} from '../../../../../../src/plugins/data/public/mocks';
import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub';
import { useKubebeatDataView } from './utils';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
import * as TEST_SUBJECTS from './test_subjects';
import type { DataView } from '../../../../../../src/plugins/data/common';
jest.mock('./utils');
jest.mock('../../common/api/use_kubebeat_data_view');
beforeEach(() => {
jest.restoreAllMocks();
});
const Wrapper = ({ data = dataPluginMock.createStartContract() }) => (
const Wrapper = ({ data = dataPluginMock.createStartContract() }: { data: DataPluginStart }) => (
<TestProvider deps={{ data }}>
<Findings />
</TestProvider>
);
describe('<Findings />', () => {
it("renders the error state component when 'kubebeat' DataView doesn't exists", async () => {
(useKubebeatDataView as jest.Mock).mockReturnValue({
status: 'success',
} as UseQueryResult<DataView>);
render(<Wrapper />);
expect(await screen.findByText(MISSING_KUBEBEAT)).toBeInTheDocument();
});
it("renders the error state component when 'kubebeat' request status is 'error'", async () => {
(useKubebeatDataView as jest.Mock).mockReturnValue({
status: 'error',
} as UseQueryResult<DataView>);
render(<Wrapper />);
expect(await screen.findByText(MISSING_KUBEBEAT)).toBeInTheDocument();
});
it("renders the success state component when 'kubebeat' DataView exists and request status is 'success'", async () => {
const data = dataPluginMock.createStartContract();
const source = await data.search.searchSource.create();

View file

@ -5,15 +5,13 @@
* 2.0.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
import type { EuiPageHeaderProps } from '@elastic/eui';
import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view';
import { allNavigationItems } from '../../common/navigation/constants';
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
import { FindingsContainer } from './findings_container';
import { CspPageTemplate } from '../../components/page_template';
import { useKubebeatDataView } from './utils';
import * as TEST_SUBJECTS from './test_subjects';
import { FINDINGS, MISSING_KUBEBEAT } from './translations';
import { FINDINGS } from './translations';
const pageHeader: EuiPageHeaderProps = {
pageTitle: FINDINGS,
@ -24,27 +22,11 @@ export const Findings = () => {
useCspBreadcrumbs([allNavigationItems.findings]);
return (
// `CspPageTemplate` takes care of loading and error states for the kubebeat data view, no need to handle them here
<CspPageTemplate pageHeader={pageHeader}>
{dataView.status === 'loading' && <LoadingPrompt />}
{(dataView.status === 'error' || (dataView.status !== 'loading' && !dataView.data)) && (
<ErrorPrompt />
)}
{dataView.status === 'success' && dataView.data && (
<FindingsContainer dataView={dataView.data} />
)}
</CspPageTemplate>
);
};
const LoadingPrompt = () => <EuiEmptyPrompt icon={<EuiLoadingSpinner size="xl" />} />;
// TODO: follow https://elastic.github.io/eui/#/display/empty-prompt/guidelines
const ErrorPrompt = () => (
<EuiEmptyPrompt
data-test-subj={TEST_SUBJECTS.FINDINGS_MISSING_INDEX}
color="danger"
iconType="alert"
// TODO: account for when we have a dataview without an index
title={<h2>{MISSING_KUBEBEAT}</h2>}
/>
);

View file

@ -8,5 +8,4 @@
export const FINDINGS_SEARCH_BAR = 'findings_search_bar';
export const FINDINGS_TABLE = 'findings_table';
export const FINDINGS_CONTAINER = 'findings_container';
export const FINDINGS_MISSING_INDEX = 'findings_page_missing_dataview';
export const FINDINGS_TABLE_ZERO_STATE = 'findings_table_zero_state';

View file

@ -30,10 +30,6 @@ export const FINDINGS = i18n.translate('xpack.csp.findings', {
defaultMessage: 'Findings',
});
export const MISSING_KUBEBEAT = i18n.translate('xpack.csp.kubebeatDataViewIsMissing', {
defaultMessage: 'Kubebeat DataView is missing',
});
export const RESOURCE = i18n.translate('xpack.csp.resource', {
defaultMessage: 'Resource',
});

View file

@ -10,18 +10,19 @@ import { I18nProvider } from '@kbn/i18n-react';
import { Router, Switch, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { coreMock } from '../../../../../src/core/public/mocks';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import type { CspAppDeps } from '../application/app';
export const TestProvider: React.FC<Partial<CspAppDeps>> = ({
core = coreMock.createStart(),
deps = {},
deps = { data: dataPluginMock.createStartContract() },
params = coreMock.createAppMountParameters(),
children,
} = {}) => {
const queryClient = useMemo(() => new QueryClient(), []);
return (
<KibanaContextProvider services={{ ...deps, ...core }}>
<KibanaContextProvider services={{ ...core, ...deps }}>
<QueryClientProvider client={queryClient}>
<Router history={params.history}>
<I18nProvider>