Skip to content

Commit

Permalink
[Cloud Posture] Add blank page graphic (#126750)
Browse files Browse the repository at this point in the history
  • Loading branch information
ari-aviran authored Mar 8, 2022
1 parent a5f410e commit 7936381
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 102 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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('') })
);

const children = chance.sentence();
renderCspPageTemplate({ children });

expect(screen.getByText(loadingText)).toBeInTheDocument();
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('does not render children when loading', () => {
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({ isLoading: true, children });
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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} />
Expand Down
Loading

0 comments on commit 7936381

Please sign in to comment.