[KibanaPageTemplate] Adding a noDataConfig for templated add data screens (#108293)

This commit is contained in:
Caroline Horn 2021-08-12 20:01:54 -04:00 committed by GitHub
parent 4d7aa45e14
commit ba14713623
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1253 additions and 10 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

View file

@ -1,7 +1,7 @@
---
id: kibDevDocsKPTTutorial
slug: /kibana-dev-docs/tutorials/kibana-page-template
title: KibanaPageTemplate component
title: Kibana Page Template
summary: Learn how to create pages in Kibana
date: 2021-03-20
tags: ['kibana', 'dev', 'ui', 'tutorials']
@ -117,3 +117,54 @@ When using `EuiSideNav`, root level items should not be linked but provide secti
![Screenshot of Stack Management empty state with a provided solution navigation shown on the left, outlined in pink.](../assets/kibana_template_solution_nav.png)
![Screenshots of Stack Management page in mobile view. Menu closed on the left, menu open on the right.](../assets/kibana_template_solution_nav_mobile.png)
## `noDataConfig`
Increases the consistency in messaging across all the solutions during the getting started process when no data exists. Each solution/template instance decides when is the most appropriate time to show this configuration, but is messaged specifically towards having no indices or index patterns at all or that match the particular solution.
This is a built-in configuration that displays a very specific UI and requires very specific keys. It will also ignore all other configurations of the template including `pageHeader` and `children`, with the exception of continuing to show `solutionNav`.
The `noDataConfig` is of type [`NoDataPagProps`](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx):
1. `solution: string`: Single name for the current solution, used to auto-generate the title, logo, description, and button label *(required)*
2. `docsLink: string`: Required to set the docs link for the whole solution *(required)*
3. `logo?: string`: Optionally replace the auto-generated logo
4. `pageTitle?: string`: Optionally replace the auto-generated page title (h1)
5. `actions: NoDataPageActionsProps`: An object of `NoDataPageActions` configurations with unique primary keys *(required)*
### `NoDataPageActions`
There are two main actions for adding data that we promote throughout Kibana, Elastic Agent and Beats. They are added to the cards that are displayed by using the keys `elasticAgent` and `beats` respectively. For consistent messaging, these two cards are pre-configured but require specific `href`s and/or `onClick` handlers for directing the user to the right location for that solution.
It also accepts a `recommended` prop as a boolean to promote one or more of the cards through visuals added to the UI. It will also place the `recommended` ones first in the list. By default, the configuration will recommend `elasticAgent`. Optionally you can also replace the `button` label by passing a string, or the whole component by passing a `ReactNode`.
```tsx
// Perform your own check
const hasData = checkForData();
// No data configuration
const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = {
solution: 'Observability',
docsLink: '#',
actions: {
elasticAgent: {
href: '#',
},
beats: {
href: '#',
},
},
};
<KibanaPageTemplate
solutionNav={/* Solution navigation still show if it exists and use the right template type */}
pageHeader={/* Page header will be ignored */}
noDataConfig={hasData ? undefined : noDataConfig}
>
{/* Children will be ignored */}
</KibanaPageTemplate>
```
![Screenshot of and example in Observability using the no data configuration and using the corresponding list numbers to point out the UI elements that they adjust.](../assets/kibana_template_no_data_config.png)

View file

@ -42,7 +42,7 @@ pageLoadAssetSize:
inspector: 148711
kibanaLegacy: 107711
kibanaOverview: 56279
kibanaReact: 161921
kibanaReact: 188705
kibanaUtils: 198829
lens: 96624
licenseManagement: 41817

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -2,6 +2,7 @@
exports[`KibanaPageTemplate render basic template 1`] = `
<EuiPageTemplate
className="kbnPageTemplate"
pageHeader={
Object {
"description": "test",
@ -24,6 +25,7 @@ exports[`KibanaPageTemplate render basic template 1`] = `
exports[`KibanaPageTemplate render custom empty prompt only 1`] = `
<EuiPageTemplate
className="kbnPageTemplate kbnPageTemplate--centeredBody"
pageSideBarProps={
Object {
"className": "kbnPageTemplate__pageSideBar",
@ -45,6 +47,7 @@ exports[`KibanaPageTemplate render custom empty prompt only 1`] = `
exports[`KibanaPageTemplate render custom empty prompt with page header 1`] = `
<EuiPageTemplate
className="kbnPageTemplate kbnPageTemplate--centeredContent"
pageHeader={
Object {
"description": "test",
@ -76,6 +79,7 @@ exports[`KibanaPageTemplate render custom empty prompt with page header 1`] = `
exports[`KibanaPageTemplate render default empty prompt 1`] = `
<EuiPageTemplate
className="kbnPageTemplate kbnPageTemplate--centeredBody"
pageSideBarProps={
Object {
"className": "kbnPageTemplate__pageSideBar",
@ -102,8 +106,89 @@ exports[`KibanaPageTemplate render default empty prompt 1`] = `
</EuiPageTemplate>
`;
exports[`KibanaPageTemplate render noDataContent 1`] = `
<EuiPageTemplate
className="kbnPageTemplate kbnPageTemplate--centeredBody"
pageContentProps={
Object {
"color": "transparent",
"hasShadow": false,
}
}
pageSideBar={
<KibanaPageTemplateSolutionNav
icon="solution"
isOpenOnDesktop={true}
items={
Array [
Object {
"id": "1",
"items": Array [
Object {
"id": "1.1",
"name": "Ingest Node Pipelines",
},
Object {
"id": "1.2",
"name": "Logstash Pipelines",
},
Object {
"id": "1.3",
"name": "Beats Central Management",
},
],
"name": "Ingest",
},
Object {
"id": "2",
"items": Array [
Object {
"id": "2.1",
"name": "Index Management",
},
Object {
"id": "2.2",
"name": "Index Lifecycle Policies",
},
Object {
"id": "2.3",
"name": "Snapshot and Restore",
},
],
"name": "Data",
},
]
}
name="Solution"
onCollapse={[Function]}
/>
}
pageSideBarProps={
Object {
"className": "kbnPageTemplate__pageSideBar",
"paddingSize": "none",
}
}
restrictWidth={950}
template="centeredBody"
>
<NoDataPage
actions={
Object {
"beats": Object {},
"custom": Object {},
"elasticAgent": Object {},
}
}
docsLink="test"
solution="Elastic"
/>
</EuiPageTemplate>
`;
exports[`KibanaPageTemplate render solutionNav 1`] = `
<EuiPageTemplate
className="kbnPageTemplate"
pageHeader={
Object {
"description": "test",

View file

@ -7,3 +7,5 @@
*/
export { KibanaPageTemplate, KibanaPageTemplateProps } from './page_template';
export { KibanaPageTemplateSolutionNavAvatar } from './solution_nav';
export * from './no_data_page';

View file

@ -0,0 +1,117 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NoDataPage render 1`] = `
<div
className="kbnNoDataPageContents"
>
<EuiText
textAlign="center"
>
<KibanaPageTemplateSolutionNavAvatar
iconType="logoElastic"
name="Elastic"
size="xxl"
/>
<EuiSpacer />
<h1>
<FormattedMessage
defaultMessage="Welcome to Elastic {solution}!"
id="kibana-react.noDataPage.welcomeTitle"
values={
Object {
"solution": "Elastic",
}
}
/>
</h1>
<EuiTextColor
color="subdued"
>
<p>
<FormattedMessage
defaultMessage="Add your data to get started, or {link} about {solution}."
id="kibana-react.noDataPage.intro"
values={
Object {
"link": <EuiLink
href="test"
>
<FormattedMessage
defaultMessage="learn more"
id="kibana-react.noDataPage.intro.link"
values={Object {}}
/>
</EuiLink>,
"solution": "Elastic",
}
}
/>
</p>
</EuiTextColor>
</EuiText>
<EuiSpacer
size="xxl"
/>
<EuiSpacer
size="l"
/>
<EuiFlexGrid
columns={2}
style={
Object {
"justifyContent": "space-around",
}
}
>
<EuiFlexItem
className="kbnNoDataPageContents__item"
key="empty-page-agent-action"
>
<ElasticAgentCard
solution="Elastic"
/>
</EuiFlexItem>
<EuiFlexItem
className="kbnNoDataPageContents__item"
key="empty-page-beats-action"
>
<ElasticBeatsCard
solution="Elastic"
/>
</EuiFlexItem>
<EuiFlexItem
className="kbnNoDataPageContents__item"
key="empty-page-custom-action"
>
<NoDataCard />
</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer
size="xxl"
/>
<EuiText
color="subdued"
textAlign="center"
>
<p>
<FormattedMessage
defaultMessage="Confused on which to use? {link}"
id="kibana-react.noDataPage.cantDecide"
values={
Object {
"link": <EuiLink
href="https://www.elastic.co/guide/en/fleet/current/beats-agent-comparison.html"
>
<FormattedMessage
defaultMessage="Check our docs for more information."
id="kibana-react.noDataPage.cantDecide.link"
values={Object {}}
/>
</EuiLink>,
}
}
/>
</p>
</EuiText>
</div>
`;

View file

@ -0,0 +1,10 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './no_data_page';
export * from './no_data_card';

View file

@ -0,0 +1,81 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ElasticAgentCard props button 1`] = `
<EuiCard
betaBadgeLabel="Recommended"
description="The Elastic Agent provides a simple, unified way to
collect data from your machines."
footer={
<EuiButton
fill={true}
href="app/integrations/browse"
>
Button
</EuiButton>
}
href="app/integrations/browse"
image="/plugins/kibanaReact/assets/elastic_agent_card.svg"
paddingSize="l"
title="Add a Solution integration"
/>
`;
exports[`ElasticAgentCard props href 1`] = `
<EuiCard
betaBadgeLabel="Recommended"
description="The Elastic Agent provides a simple, unified way to
collect data from your machines."
footer={
<EuiButton
fill={true}
href="#"
>
Button
</EuiButton>
}
href="#"
image="/plugins/kibanaReact/assets/elastic_agent_card.svg"
paddingSize="l"
title="Add a Solution integration"
/>
`;
exports[`ElasticAgentCard props recommended 1`] = `
<EuiCard
betaBadgeLabel="Recommended"
description="The Elastic Agent provides a simple, unified way to
collect data from your machines."
footer={
<EuiButton
fill={true}
href="app/integrations/browse"
>
Find an integration for Solution
</EuiButton>
}
href="app/integrations/browse"
image="/plugins/kibanaReact/assets/elastic_agent_card.svg"
paddingSize="l"
title="Add a Solution integration"
/>
`;
exports[`ElasticAgentCard renders 1`] = `
<EuiCard
betaBadgeLabel="Recommended"
description="The Elastic Agent provides a simple, unified way to
collect data from your machines."
footer={
<EuiButton
fill={true}
href="app/integrations/browse"
>
Find an integration for Solution
</EuiButton>
}
href="app/integrations/browse"
image="/plugins/kibanaReact/assets/elastic_agent_card.svg"
paddingSize="l"
title="Add a Solution integration"
/>
`;

View file

@ -0,0 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ElasticBeatsCard props button 1`] = `
<EuiCard
description="
Beats send data from hundreds or thousands of machines and systems to Logstash or Elasticsearch."
footer={
<EuiButton
fill={true}
href="app/home#/tutorial"
>
Button
</EuiButton>
}
href="app/home#/tutorial"
image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg"
paddingSize="l"
title="Add data with Beats"
/>
`;
exports[`ElasticBeatsCard props href 1`] = `
<EuiCard
description="
Beats send data from hundreds or thousands of machines and systems to Logstash or Elasticsearch."
footer={
<EuiButton
fill={true}
href="#"
>
Button
</EuiButton>
}
href="#"
image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg"
paddingSize="l"
title="Add data with Beats"
/>
`;
exports[`ElasticBeatsCard props recommended 1`] = `
<EuiCard
betaBadgeLabel="Recommended"
description="
Beats send data from hundreds or thousands of machines and systems to Logstash or Elasticsearch."
footer={
<EuiButton
fill={true}
href="app/home#/tutorial"
>
Install Beats for Solution
</EuiButton>
}
href="app/home#/tutorial"
image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg"
paddingSize="l"
title="Add data with Beats"
/>
`;
exports[`ElasticBeatsCard renders 1`] = `
<EuiCard
description="
Beats send data from hundreds or thousands of machines and systems to Logstash or Elasticsearch."
footer={
<EuiButton
fill={true}
href="app/home#/tutorial"
>
Install Beats for Solution
</EuiButton>
}
href="app/home#/tutorial"
image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg"
paddingSize="l"
title="Add data with Beats"
/>
`;

View file

@ -0,0 +1,156 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NoDataCard props button 1`] = `
<div
class="euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiCard euiCard--centerAligned"
>
<div
class="euiCard__content"
>
<span
class="euiTitle euiTitle--small euiCard__title"
id="generated-idTitle"
>
Card title
</span>
<div
class="euiText euiText--small euiCard__description"
id="generated-idDescription"
>
<p>
Description
</p>
</div>
</div>
<div
class="euiCard__footer"
>
<button
class="euiButton euiButton--primary euiButton--fill"
type="button"
>
<span
class="euiButtonContent euiButton__content"
>
<span
class="euiButton__text"
>
Button
</span>
</span>
</button>
</div>
</div>
`;
exports[`NoDataCard props href 1`] = `
<div
class="euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPanel--isClickable euiCard euiCard--centerAligned euiCard--isClickable"
>
<div
class="euiCard__content"
>
<span
class="euiTitle euiTitle--small euiCard__title"
id="generated-idTitle"
>
<a
aria-describedby="generated-idDescription"
class="euiCard__titleAnchor"
href="#"
rel="noreferrer"
>
Card title
</a>
</span>
<div
class="euiText euiText--small euiCard__description"
id="generated-idDescription"
>
<p>
Description
</p>
</div>
</div>
<div
class="euiCard__footer"
>
<a
class="euiButton euiButton--primary euiButton--fill"
href="#"
rel="noreferrer"
>
<span
class="euiButtonContent euiButton__content"
>
<span
class="euiButton__text"
>
Button
</span>
</span>
</a>
</div>
</div>
`;
exports[`NoDataCard props recommended 1`] = `
<div
class="euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiCard euiCard--centerAligned euiCard--hasBetaBadge"
>
<div
class="euiCard__content"
>
<span
class="euiTitle euiTitle--small euiCard__title"
id="generated-idTitle"
>
Card title
</span>
<div
class="euiText euiText--small euiCard__description"
id="generated-idDescription"
>
<p>
Description
</p>
</div>
</div>
<span
class="euiCard__betaBadgeWrapper"
>
<span
class="euiBetaBadge euiBetaBadge--hollow euiCard__betaBadge"
id="generated-idBetaBadge"
title="Recommended"
>
Recommended
</span>
</span>
</div>
`;
exports[`NoDataCard renders 1`] = `
<div
class="euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiCard euiCard--centerAligned"
>
<div
class="euiCard__content"
>
<span
class="euiTitle euiTitle--small euiCard__title"
id="generated-idTitle"
>
Card title
</span>
<div
class="euiText euiText--small euiCard__description"
id="generated-idDescription"
>
<p>
Description
</p>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,45 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { ElasticAgentCard } from './elastic_agent_card';
jest.mock('../../../context', () => ({
...jest.requireActual('../../../context'),
useKibana: jest.fn().mockReturnValue({
services: {
http: { basePath: { prepend: jest.fn((path: string) => (path ? path : 'path')) } },
uiSettings: { get: jest.fn() },
},
}),
}));
describe('ElasticAgentCard', () => {
test('renders', () => {
const component = shallow(<ElasticAgentCard solution="Solution" />);
expect(component).toMatchSnapshot();
});
describe('props', () => {
test('recommended', () => {
const component = shallow(<ElasticAgentCard recommended solution="Solution" />);
expect(component).toMatchSnapshot();
});
test('button', () => {
const component = shallow(<ElasticAgentCard button="Button" solution="Solution" />);
expect(component).toMatchSnapshot();
});
test('href', () => {
const component = shallow(<ElasticAgentCard href="#" button="Button" solution="Solution" />);
expect(component).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,69 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/* eslint-disable @elastic/eui/href-or-on-click */
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
import { CoreStart } from 'kibana/public';
import { EuiButton, EuiCard } from '@elastic/eui';
import { useKibana } from '../../../context';
import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page';
export type ElasticAgentCardProps = NoDataPageActions & {
solution: string;
};
/**
* Applies extra styling to a typical EuiAvatar
*/
export const ElasticAgentCard: FunctionComponent<ElasticAgentCardProps> = ({
solution,
recommended = true,
href = 'app/integrations/browse',
button,
...cardRest
}) => {
const {
services: { http },
} = useKibana<CoreStart>();
const addBasePath = http.basePath.prepend;
const basePathUrl = '/plugins/kibanaReact/assets/';
const footer =
typeof button !== 'string' && typeof button !== 'undefined' ? (
button
) : (
<EuiButton href={href} onClick={cardRest?.onClick} target={cardRest?.target} fill>
{button ||
i18n.translate('kibana-react.noDataPage.elasticAgentCard.buttonLabel', {
defaultMessage: 'Find an integration for {solution}',
values: { solution },
})}
</EuiButton>
);
return (
<EuiCard
paddingSize="l"
href={href}
title={i18n.translate('kibana-react.noDataPage.elasticAgentCard.title', {
defaultMessage: 'Add a {solution} integration',
values: { solution },
})}
description={i18n.translate('kibana-react.noDataPage.elasticAgentCard.description', {
defaultMessage: `The Elastic Agent provides a simple, unified way to
collect data from your machines.`,
})}
image={addBasePath(`${basePathUrl}elastic_agent_card.svg`)}
betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined}
footer={footer}
{...(cardRest as any)}
/>
);
};

View file

@ -0,0 +1,45 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { ElasticBeatsCard } from './elastic_beats_card';
jest.mock('../../../context', () => ({
...jest.requireActual('../../../context'),
useKibana: jest.fn().mockReturnValue({
services: {
http: { basePath: { prepend: jest.fn((path: string) => (path ? path : 'path')) } },
uiSettings: { get: jest.fn() },
},
}),
}));
describe('ElasticBeatsCard', () => {
test('renders', () => {
const component = shallow(<ElasticBeatsCard solution="Solution" />);
expect(component).toMatchSnapshot();
});
describe('props', () => {
test('recommended', () => {
const component = shallow(<ElasticBeatsCard recommended solution="Solution" />);
expect(component).toMatchSnapshot();
});
test('button', () => {
const component = shallow(<ElasticBeatsCard button="Button" solution="Solution" />);
expect(component).toMatchSnapshot();
});
test('href', () => {
const component = shallow(<ElasticBeatsCard href="#" button="Button" solution="Solution" />);
expect(component).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/* eslint-disable @elastic/eui/href-or-on-click */
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
import { CoreStart } from 'kibana/public';
import { EuiButton, EuiCard } from '@elastic/eui';
import { useKibana } from '../../../context';
import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page';
export type ElasticBeatsCardProps = NoDataPageActions & {
solution: string;
};
export const ElasticBeatsCard: FunctionComponent<ElasticBeatsCardProps> = ({
recommended,
href = 'app/home#/tutorial',
button,
solution,
...cardRest
}) => {
const {
services: { http, uiSettings },
} = useKibana<CoreStart>();
const addBasePath = http.basePath.prepend;
const basePathUrl = '/plugins/kibanaReact/assets/';
const IS_DARK_THEME = uiSettings.get('theme:darkMode');
const footer =
typeof button !== 'string' && typeof button !== 'undefined' ? (
button
) : (
<EuiButton href={href} onClick={cardRest?.onClick} target={cardRest?.target} fill>
{button ||
i18n.translate('kibana-react.noDataPage.elasticBeatsCard.buttonLabel', {
defaultMessage: 'Install Beats for {solution}',
values: { solution },
})}
</EuiButton>
);
return (
<EuiCard
paddingSize="l"
href={href}
title={i18n.translate('kibana-react.noDataPage.elasticBeatsCard.title', {
defaultMessage: 'Add data with Beats',
})}
description={i18n.translate('kibana-react.noDataPage.elasticBeatsCard.description', {
defaultMessage: `
Beats send data from hundreds or thousands of machines and systems to Logstash or Elasticsearch.`,
})}
image={addBasePath(
`${basePathUrl}elastic_beats_card_${IS_DARK_THEME ? 'dark' : 'light'}.svg`
)}
betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined}
footer={footer}
{...(cardRest as any)}
/>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './elastic_agent_card';
export * from './elastic_beats_card';
export * from './no_data_card';

View file

@ -0,0 +1,41 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { render } from 'enzyme';
import React from 'react';
import { NoDataCard } from './no_data_card';
describe('NoDataCard', () => {
test('renders', () => {
const component = render(<NoDataCard title="Card title" description="Description" />);
expect(component).toMatchSnapshot();
});
describe('props', () => {
test('recommended', () => {
const component = render(
<NoDataCard recommended title="Card title" description="Description" />
);
expect(component).toMatchSnapshot();
});
test('button', () => {
const component = render(
<NoDataCard button="Button" title="Card title" description="Description" />
);
expect(component).toMatchSnapshot();
});
test('href', () => {
const component = render(
<NoDataCard href="#" button="Button" title="Card title" description="Description" />
);
expect(component).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/* eslint-disable @elastic/eui/href-or-on-click */
import React, { FunctionComponent } from 'react';
import { EuiButton, EuiCard, EuiCardProps } from '@elastic/eui';
import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page';
// Custom cards require all the props the EuiCard does
type NoDataCard = EuiCardProps & NoDataPageActions;
export const NoDataCard: FunctionComponent<NoDataPageActions> = ({
recommended,
button,
...cardRest
}) => {
const footer =
typeof button !== 'string' ? (
button
) : (
<EuiButton href={cardRest?.href} onClick={cardRest?.onClick} target={cardRest?.target} fill>
{button}
</EuiButton>
);
return (
<EuiCard
paddingSize="l"
betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined}
footer={footer}
{...(cardRest as any)}
/>
);
};

View file

@ -0,0 +1,7 @@
.kbnNoDataPageContents__item:only-child {
min-width: 400px;
@include euiBreakpoint('xs', 's') {
min-width: auto;
}
}

View file

@ -0,0 +1,28 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { NoDataPage } from './no_data_page';
import { shallowWithIntl } from '@kbn/test/jest';
describe('NoDataPage', () => {
test('render', () => {
const component = shallowWithIntl(
<NoDataPage
solution="Elastic"
actions={{
elasticAgent: {},
beats: {},
custom: {},
}}
docsLink="test"
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,203 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import './no_data_page.scss';
import React, { ReactNode, useMemo, FunctionComponent, MouseEventHandler } from 'react';
import {
EuiFlexItem,
EuiCardProps,
EuiFlexGrid,
EuiSpacer,
EuiText,
EuiTextColor,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { KibanaPageTemplateProps } from '../page_template';
import { ElasticAgentCard, ElasticBeatsCard, NoDataCard } from './no_data_card';
import { KibanaPageTemplateSolutionNavAvatar } from '../solution_nav';
export const NO_DATA_PAGE_MAX_WIDTH = 950;
export const NO_DATA_PAGE_TEMPLATE_PROPS: KibanaPageTemplateProps = {
restrictWidth: NO_DATA_PAGE_MAX_WIDTH,
template: 'centeredBody',
pageContentProps: {
hasShadow: false,
color: 'transparent',
},
};
export const NO_DATA_RECOMMENDED = i18n.translate(
'kibana-react.noDataPage.noDataPage.recommended',
{
defaultMessage: 'Recommended',
}
);
export type NoDataPageActions = Partial<EuiCardProps> & {
/**
* Applies the `Recommended` beta badge and makes the button `fill`
*/
recommended?: boolean;
/**
* Provide just a string for the button's label, or a whole component
*/
button?: string | ReactNode;
/**
* Remapping `onClick` to any element
*/
onClick?: MouseEventHandler<HTMLElement>;
};
export type NoDataPageActionsProps = Record<string, NoDataPageActions>;
export interface NoDataPageProps {
/**
* Single name for the current solution, used to auto-generate the title, logo, description, and button label
*/
solution: string;
/**
* Optionally replace the auto-generated logo
*/
logo?: string;
/**
* Required to set the docs link for the whole solution
*/
docsLink: string;
/**
* Optionally replace the auto-generated page title (h1)
*/
pageTitle?: string;
/**
* An object of `NoDataPageActions` configurations with unique primary keys.
* Use `elasticAgent` or `beats` as the primary key for pre-configured cards of this type.
* Otherwise use a custom key that contains `EuiCard` props.
*/
actions: NoDataPageActionsProps;
}
export const NoDataPage: FunctionComponent<NoDataPageProps> = ({
solution,
logo,
actions,
docsLink,
pageTitle,
}) => {
// Convert obj data into an iterable array
const entries = Object.entries(actions);
// This sort fn may look nonsensical, but it's some Good Ol' Javascript (TM)
// Sort functions want either a 1, 0, or -1 returned to determine order,
// and it turns out in JS you CAN minus booleans from each other to get a 1, 0, or -1 - e.g., (true - false == 1) :whoa:
const sortedEntries = entries.sort(([, firstObj], [, secondObj]) => {
// The `??` fallbacks are because the recommended key can be missing or undefined
return Number(secondObj.recommended ?? false) - Number(firstObj.recommended ?? false);
});
// Convert the iterated [[key, value]] array format back into an object
const sortedData = Object.fromEntries(sortedEntries);
const actionsKeys = Object.keys(sortedData);
const renderActions = useMemo(() => {
return Object.values(sortedData).map((action, i) => {
if (actionsKeys[i] === 'elasticAgent') {
return (
<EuiFlexItem key={`empty-page-agent-action`} className="kbnNoDataPageContents__item">
<ElasticAgentCard solution={solution} {...action} />
</EuiFlexItem>
);
} else if (actionsKeys[i] === 'beats') {
return (
<EuiFlexItem key={`empty-page-beats-action`} className="kbnNoDataPageContents__item">
<ElasticBeatsCard solution={solution} {...action} />
</EuiFlexItem>
);
} else {
return (
<EuiFlexItem
key={`empty-page-${actionsKeys[i]}-action`}
className="kbnNoDataPageContents__item"
>
<NoDataCard {...action} />
</EuiFlexItem>
);
}
});
}, [actions, sortedData, actionsKeys]);
return (
<div className="kbnNoDataPageContents">
<EuiText textAlign="center">
<KibanaPageTemplateSolutionNavAvatar
name={solution}
iconType={logo || `logo${solution}`}
size="xxl"
/>
<EuiSpacer />
<h1>
{pageTitle || (
<FormattedMessage
id="kibana-react.noDataPage.welcomeTitle"
defaultMessage="Welcome to Elastic {solution}!"
values={{ solution }}
/>
)}
</h1>
<EuiTextColor color="subdued">
<p>
<FormattedMessage
id="kibana-react.noDataPage.intro"
defaultMessage="Add your data to get started, or {link} about {solution}."
values={{
solution,
link: (
<EuiLink href={docsLink}>
<FormattedMessage
id="kibana-react.noDataPage.intro.link"
defaultMessage="learn more"
/>
</EuiLink>
),
}}
/>
</p>
</EuiTextColor>
</EuiText>
<EuiSpacer size="xxl" />
<EuiSpacer size="l" />
<EuiFlexGrid columns={2} style={{ justifyContent: 'space-around' }}>
{renderActions}
</EuiFlexGrid>
{actionsKeys.length > 1 ? (
<>
<EuiSpacer size="xxl" />
<EuiText textAlign="center" color="subdued">
<p>
<FormattedMessage
id="kibana-react.noDataPage.cantDecide"
defaultMessage="Confused on which to use? {link}"
values={{
link: (
<EuiLink href="https://www.elastic.co/guide/en/fleet/current/beats-agent-comparison.html">
<FormattedMessage
id="kibana-react.noDataPage.cantDecide.link"
defaultMessage="Check our docs for more information."
/>
</EuiLink>
),
}}
/>
</p>
</EuiText>
</>
) : undefined}
</div>
);
};

View file

@ -10,4 +10,8 @@
&.kbnPageTemplate__pageSideBar--shrink {
min-width: $euiSizeXXL;
}
.kbnPageTemplate--centeredBody & {
border-right: $euiBorderThin;
}
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { KibanaPageTemplate } from './page_template';
import { KibanaPageTemplate, KibanaPageTemplateProps } from './page_template';
import { EuiEmptyPrompt } from '@elastic/eui';
import { KibanaPageTemplateSolutionNavProps } from './solution_nav';
@ -51,6 +51,16 @@ const navItems: KibanaPageTemplateSolutionNavProps['items'] = [
},
];
const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = {
solution: 'Elastic',
actions: {
elasticAgent: {},
beats: {},
custom: {},
},
docsLink: 'test',
};
describe('KibanaPageTemplate', () => {
test('render default empty prompt', () => {
const component = shallow(
@ -126,6 +136,26 @@ describe('KibanaPageTemplate', () => {
expect(component).toMatchSnapshot();
});
test('render noDataContent', () => {
const component = shallow(
<KibanaPageTemplate
pageHeader={{
iconType: 'test',
title: 'test',
description: 'test',
rightSideItems: ['test'],
}}
solutionNav={{
name: 'Solution',
icon: 'solution',
items: navItems,
}}
noDataConfig={noDataConfig}
/>
);
expect(component).toMatchSnapshot();
});
test('render sidebar classes', () => {
const component = shallow(
<KibanaPageTemplate

View file

@ -24,6 +24,8 @@ import {
KibanaPageTemplateSolutionNavProps,
} from './solution_nav/solution_nav';
import { NoDataPage, NoDataPageProps, NO_DATA_PAGE_TEMPLATE_PROPS } from './no_data_page';
/**
* A thin wrapper around EuiPageTemplate with a few Kibana specific additions
*/
@ -39,10 +41,16 @@ export type KibanaPageTemplateProps = EuiPageTemplateProps & {
* Quick creation of EuiSideNav. Hooks up mobile instance too
*/
solutionNav?: KibanaPageTemplateSolutionNavProps;
/**
* Accepts a configuration object, that when provided, ignores pageHeader and children and instead
* displays Agent, Beats, and custom cards to direct users to the right ingest location
*/
noDataConfig?: NoDataPageProps;
};
export const KibanaPageTemplate: FunctionComponent<KibanaPageTemplateProps> = ({
template,
className,
pageHeader,
children,
isEmptyState,
@ -50,6 +58,7 @@ export const KibanaPageTemplate: FunctionComponent<KibanaPageTemplateProps> = ({
pageSideBar,
pageSideBarProps,
solutionNav,
noDataConfig,
...rest
}) => {
/**
@ -86,11 +95,10 @@ export const KibanaPageTemplate: FunctionComponent<KibanaPageTemplateProps> = ({
);
}
const emptyStateDefaultTemplate = pageSideBar ? 'centeredContent' : 'centeredBody';
/**
* An easy way to create the right content for empty pages
*/
const emptyStateDefaultTemplate = pageSideBar ? 'centeredContent' : 'centeredBody';
if (isEmptyState && pageHeader && !children) {
template = template ?? emptyStateDefaultTemplate;
const { iconType, pageTitle, description, rightSideItems } = pageHeader;
@ -110,9 +118,40 @@ export const KibanaPageTemplate: FunctionComponent<KibanaPageTemplateProps> = ({
template = template ?? emptyStateDefaultTemplate;
}
// Set the template before the classes
template = noDataConfig ? NO_DATA_PAGE_TEMPLATE_PROPS.template : template;
const classes = classNames(
'kbnPageTemplate',
{ [`kbnPageTemplate--${template}`]: template },
className
);
/**
* If passing the custom template of `noDataConfig`
*/
if (noDataConfig) {
return (
<EuiPageTemplate
template={template}
className={classes}
pageSideBar={pageSideBar}
pageSideBarProps={{
paddingSize: solutionNav ? 'none' : 'l',
...pageSideBarProps,
className: classNames(sideBarClasses, pageSideBarProps?.className),
}}
{...NO_DATA_PAGE_TEMPLATE_PROPS}
>
<NoDataPage {...noDataConfig} />
</EuiPageTemplate>
);
}
return (
<EuiPageTemplate
template={template}
className={classes}
restrictWidth={restrictWidth}
pageHeader={pageHeader}
pageSideBar={pageSideBar}

View file

@ -14,6 +14,10 @@ $euiSideNavEmphasizedBackgroundColor: transparentize($euiColorLightShade, .7);
width: 248px;
padding: $euiSizeL;
}
.kbnPageTemplateSolutionNavAvatar {
margin-right: $euiSize;
}
}
.kbnPageTemplateSolutionNav--hidden {

View file

@ -1,4 +1,14 @@
.kbnPageTemplateSolutionNavAvatar {
@include euiBottomShadowSmall;
margin-right: $euiSize;
&--xxl {
@include euiBottomShadowMedium;
@include size(100px);
line-height: 100px;
border-radius: 100px;
display: inline-block;
background: $euiColorEmptyShade url('../../assets/texture.svg') no-repeat;
background-size: cover, 125%;
text-align: center;
}
}

View file

@ -10,21 +10,36 @@ import './solution_nav_avatar.scss';
import React, { FunctionComponent } from 'react';
import classNames from 'classnames';
import { EuiAvatar, EuiAvatarProps } from '@elastic/eui';
import { DistributiveOmit, EuiAvatar, EuiAvatarProps } from '@elastic/eui';
export type KibanaPageTemplateSolutionNavAvatarProps = EuiAvatarProps;
export type KibanaPageTemplateSolutionNavAvatarProps = DistributiveOmit<EuiAvatarProps, 'size'> & {
/**
* Any EuiAvatar size available, of `xxl` for custom large, brand-focused version
*/
size?: EuiAvatarProps['size'] | 'xxl';
};
/**
* Applies extra styling to a typical EuiAvatar
*/
export const KibanaPageTemplateSolutionNavAvatar: FunctionComponent<KibanaPageTemplateSolutionNavAvatarProps> = ({
className,
size,
...rest
}) => {
return (
// @ts-ignore Complains about ExclusiveUnion between `iconSize` and `iconType`, but works fine
<EuiAvatar
className={classNames('kbnPageTemplateSolutionNavAvatar', className)}
className={classNames(
'kbnPageTemplateSolutionNavAvatar',
{
[`kbnPageTemplateSolutionNavAvatar--${size}`]: size,
},
className
)}
color="plain"
size={size === 'xxl' ? 'xl' : size}
iconSize={size}
{...rest}
/>
);

View file

@ -2,7 +2,7 @@
exports[`Storyshots Home Home Page 1`] = `
<div
className="euiPage euiPage--grow euiPageTemplate"
className="euiPage euiPage--grow euiPageTemplate kbnPageTemplate"
style={
Object {
"minHeight": 460,