Add "recently accessed" to Kibana Home (#16556) (#17046)

* kbn history

* put Add Data section in panel and move to seperate component

* RecentlyAccessed component

* complete circle with recently accessed dashboards

* record visualizations in recent accessed history

* render recently accessed

* do not show recently accessed panel when no recently accessed history

* add test cases for home feature panels

* render dropdown when more than 5 items

* only add saved search when id is provided

* remove border around add data cards, move set up index patterns to under cards

* add dot icon to seperate recently accessed items

* fix white space issues

* add timelion sheet to recently accessed

* fix spelling errors, better name space styles, enhance dropdown label

* avoid cutting off bottom of letters, do not display separators with small screen

* wrap separator (EuiIcon) in EuiText component so it is even link text

* track history by object id to avoid duplicate entries when saved object is renamed

* align link dropdown on right side

* shift popover placement for small screens

* update recently_accessed tests to look for nodes insted of using snapshots

* move id to variable

* change 'Recently accessed' to 'Recently viewed'

* change more dropdown label

* add max-width to flex item

* include /app in link path, use arrow functions to remove bind in react props

* add to recently accessed when saved object is saved

* address cjcenizal's comments on test assertion order and react imports
This commit is contained in:
Nathan Reese 2018-03-08 13:53:09 -07:00 committed by GitHub
parent ed17359c41
commit 5eea130137
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1416 additions and 187 deletions

View file

@ -234,7 +234,7 @@
"classnames": "2.2.5",
"enzyme": "3.2.0",
"enzyme-adapter-react-16": "^1.1.1",
"enzyme-to-json": "3.1.4",
"enzyme-to-json": "3.3.0",
"eslint": "4.14.0",
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-babel": "4.1.2",

View file

@ -13,6 +13,7 @@ import { DashboardConstants, createDashboardEditUrl } from './dashboard_constant
import { SavedObjectNotFound } from 'ui/errors';
import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory } from 'ui/registry/feature_catalogue';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
import { recentlyAccessed } from 'ui/persisted_log';
uiRoutes
.defaults(/dashboard/, {
@ -64,7 +65,12 @@ uiRoutes
resolve: {
dash: function (savedDashboards, Notifier, $route, $location, courier, kbnUrl, AppState) {
const id = $route.current.params.id;
return savedDashboards.get(id)
.then((savedDashboard) => {
recentlyAccessed.add(savedDashboard.getFullPath(), savedDashboard.title, id);
return savedDashboard;
})
.catch((error) => {
// Preserve BWC of v5.3.0 links for new, unsaved dashboards.
// See https://github.com/elastic/kibana/issues/10951 for more context.

View file

@ -1,6 +1,7 @@
import angular from 'angular';
import _ from 'lodash';
import { uiModules } from 'ui/modules';
import { createDashboardEditUrl } from 'plugins/kibana/dashboard/dashboard_constants';
const module = uiModules.get('app/dashboard');
// Used only by the savedDashboards service, usually no reason to change this
@ -41,6 +42,9 @@ module.factory('SavedDashboard', function (courier, config) {
// object, clear it. It was a mistake
clearSavedIndexPattern: true
});
this.showInRecenltyAccessed = true;
}
// save these objects with the 'dashboard' type
@ -77,5 +81,9 @@ module.factory('SavedDashboard', function (courier, config) {
SavedDashboard.searchsource = true;
SavedDashboard.prototype.getFullPath = function () {
return `/app/kibana#${createDashboardEditUrl(this.id)}`;
};
return SavedDashboard;
});

View file

@ -30,6 +30,7 @@ import { StateProvider } from 'ui/state_management/state';
import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
import { FilterManagerProvider } from 'ui/filter_manager';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
import { recentlyAccessed } from 'ui/persisted_log';
const app = uiModules.get('apps/discover', [
'kibana/notify',
@ -80,7 +81,17 @@ uiRoutes
});
},
savedSearch: function (courier, savedSearches, $route) {
return savedSearches.get($route.current.params.id)
const savedSearchId = $route.current.params.id;
return savedSearches.get(savedSearchId)
.then((savedSearch) => {
if (savedSearchId) {
recentlyAccessed.add(
savedSearch.getFullPath(),
savedSearch.title,
savedSearchId);
}
return savedSearch;
})
.catch(courier.redirectWhenMissing({
'search': '/discover',
'index-pattern': '/management/kibana/objects/savedSearches/' + $route.current.params.id

View file

@ -26,6 +26,8 @@ module.factory('SavedSearch', function (courier) {
version: 1
}
});
this.showInRecenltyAccessed = true;
}
SavedSearch.type = 'search';
@ -44,5 +46,9 @@ module.factory('SavedSearch', function (courier) {
SavedSearch.searchSource = true;
SavedSearch.prototype.getFullPath = function () {
return `/app/kibana#/discover/${this.id}`;
};
return SavedSearch;
});

View file

@ -0,0 +1,568 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`directories should not render directory entry when showOnHomePage is false 1`] = `
<EuiPage
className="home"
>
<AddData
addBasePath={[Function]}
isCloudEnabled={true}
/>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Visualize and Explore Data
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Manage and Administer the Elastic Stack
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="center"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText>
<p>
Didnt find what you were looking for?
</p>
</EuiText>
<EuiSpacer
size="s"
/>
<EuiButton
color="primary"
fill={false}
href="#/home/feature_directory"
iconSide="left"
type="button"
>
View full directory of Kibana plugins
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPage>
`;
exports[`directories should render ADMIN directory entry in "Manage" panel 1`] = `
<EuiPage
className="home"
>
<AddData
addBasePath={[Function]}
isCloudEnabled={true}
/>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Visualize and Explore Data
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Manage and Administer the Elastic Stack
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
>
<EuiFlexItem
component="div"
grow={true}
key="index_patterns"
style={
Object {
"minHeight": 64,
}
}
>
<Synopsis
description="Manage the index patterns that help retrieve your data from Elasticsearch."
iconUrl="base_path//plugins/kibana/assets/app_index_pattern.svg"
title="Index Patterns"
url="base_path/index_management_landing_page"
/>
</EuiFlexItem>
</EuiFlexGrid>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="center"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText>
<p>
Didnt find what you were looking for?
</p>
</EuiText>
<EuiSpacer
size="s"
/>
<EuiButton
color="primary"
fill={false}
href="#/home/feature_directory"
iconSide="left"
type="button"
>
View full directory of Kibana plugins
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPage>
`;
exports[`directories should render DATA directory entry in "Explore Data" panel 1`] = `
<EuiPage
className="home"
>
<AddData
addBasePath={[Function]}
isCloudEnabled={true}
/>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Visualize and Explore Data
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
>
<EuiFlexItem
component="div"
grow={true}
key="dashboard"
style={
Object {
"minHeight": 64,
}
}
>
<Synopsis
description="Display and share a collection of visualizations and saved searches."
iconUrl="base_path//plugins/kibana/assets/app_dashboard.svg"
title="Dashboard"
url="base_path/dashboard_landing_page"
/>
</EuiFlexItem>
</EuiFlexGrid>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Manage and Administer the Elastic Stack
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="center"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText>
<p>
Didnt find what you were looking for?
</p>
</EuiText>
<EuiSpacer
size="s"
/>
<EuiButton
color="primary"
fill={false}
href="#/home/feature_directory"
iconSide="left"
type="button"
>
View full directory of Kibana plugins
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPage>
`;
exports[`should not contain RecentlyAccessed panel when there is no recentlyAccessed history 1`] = `
<EuiPage
className="home"
>
<AddData
addBasePath={[Function]}
isCloudEnabled={true}
/>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Visualize and Explore Data
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Manage and Administer the Elastic Stack
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="center"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText>
<p>
Didnt find what you were looking for?
</p>
</EuiText>
<EuiSpacer
size="s"
/>
<EuiButton
color="primary"
fill={false}
href="#/home/feature_directory"
iconSide="left"
type="button"
>
View full directory of Kibana plugins
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPage>
`;
exports[`should render home component 1`] = `
<EuiPage
className="home"
>
<React.Fragment>
<RecentlyAccessed
recentlyAccessed={
Array [
Object {
"id": "1",
"label": "my vis",
"link": "link_to_my_vis",
},
]
}
/>
<EuiSpacer
size="l"
/>
</React.Fragment>
<AddData
addBasePath={[Function]}
isCloudEnabled={true}
/>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Visualize and Explore Data
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiTitle>
<h3>
Manage and Administer the Elastic Stack
</h3>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFlexGrid
columns={2}
gutterSize="l"
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="l"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="center"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText>
<p>
Didnt find what you were looking for?
</p>
</EuiText>
<EuiSpacer
size="s"
/>
<EuiButton
color="primary"
fill={false}
href="#/home/feature_directory"
iconSide="left"
type="button"
>
View full directory of Kibana plugins
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPage>
`;

View file

@ -0,0 +1,109 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`render 1`] = `
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiText>
<p>
<EuiTextColor
color="subdued"
>
Recently viewed
</EuiTextColor>
</p>
</EuiText>
<EuiSpacer
size="s"
/>
<EuiFlexGroup
alignItems="flexEnd"
component="div"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<EuiFlexItem
className="recentlyAccessedFlexItem"
component="div"
grow={false}
>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<React.Fragment
key="0"
>
<EuiFlexItem
className="recentlyAccessedItem"
component="div"
grow={false}
style={
Object {
"minWidth": "3.9000000000000004em",
}
}
>
<EuiLink
className="recentlyAccessedLongLink"
color="primary"
href="link0"
type="button"
>
label0
</EuiLink>
</EuiFlexItem>
</React.Fragment>
<React.Fragment
key="1"
>
<EuiFlexItem
className="recentlyAccessedSeparator"
component="div"
grow={false}
>
<EuiText>
<EuiIcon
color="subdued"
size="m"
type="dot"
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem
className="recentlyAccessedItem"
component="div"
grow={false}
style={
Object {
"minWidth": "3.9000000000000004em",
}
}
>
<EuiLink
className="recentlyAccessedLongLink"
color="primary"
href="link1"
type="button"
>
label1
</EuiLink>
</EuiFlexItem>
</React.Fragment>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
/>
</EuiFlexGroup>
</EuiPanel>
`;

View file

@ -0,0 +1,188 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
KuiCardGroup,
KuiCard,
KuiCardDescription,
KuiCardDescriptionTitle,
KuiCardDescriptionText,
KuiCardFooter,
} from 'ui_framework/components';
import {
EuiButton,
EuiLink,
EuiPanel,
EuiTitle,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiText,
} from '@elastic/eui';
export function AddData({ addBasePath, isCloudEnabled }) {
const renderCards = () => {
const cardStyle = {
width: '250px',
'minWidth': '200px',
'border': 'none'
};
let apmCard;
if (!isCloudEnabled) {
apmCard = (
<KuiCard style={cardStyle}>
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_apm.svg')}
/>
<p>
APM
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
APM automatically collects in-depth performance metrics and errors from inside your applications.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<EuiButton
href="#/home/tutorial/apm"
>
Add APM
</EuiButton>
</KuiCardFooter>
</KuiCard>
);
}
return (
<div className="kuiVerticalRhythm">
<KuiCardGroup>
{apmCard}
<KuiCard style={cardStyle}>
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_logging.svg')}
/>
<p>
Logging
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
Ingest logs from popular data sources and easily visualize in preconfigured dashboards.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<EuiButton
href="#/home/tutorial_directory/logging"
>
Add log data
</EuiButton>
</KuiCardFooter>
</KuiCard>
<KuiCard style={cardStyle}>
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_monitoring.svg')}
/>
<p>
Metrics
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
Collect metrics from the operating system and services running on your servers.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<EuiButton
href="#/home/tutorial_directory/metrics"
>
Add metric data
</EuiButton>
</KuiCardFooter>
</KuiCard>
<KuiCard style={cardStyle}>
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_security.svg')}
/>
<p>
Security analytics
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
Centralize security events for interactive investigation in ready-to-go visualizations.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<EuiButton
href="#/home/tutorial_directory/security"
>
Add security events
</EuiButton>
</KuiCardFooter>
</KuiCard>
</KuiCardGroup>
</div>
);
};
return (
<EuiPanel paddingSize="l">
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle>
<h3>Add Data to Kibana</h3>
</EuiTitle>
<EuiText>
<p>
Use these solutions to quickly turn your data into pre-built dashboards and monitoring systems.
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{renderCards()}
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiText>
<span style={{ height: 38 }}>
Data already in Elasticsearch?
</span>
<EuiLink
style={{ marginLeft: 8 }}
href="#/management/kibana/index"
>
Set up index patterns
</EuiLink>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
AddData.propTypes = {
addBasePath: PropTypes.func.isRequired,
isCloudEnabled: PropTypes.bool.isRequired,
};

View file

@ -1,17 +1,11 @@
import React from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Synopsis } from './synopsis';
import {
KuiLinkButton,
KuiCardGroup,
KuiCard,
KuiCardDescription,
KuiCardDescriptionTitle,
KuiCardDescriptionText,
KuiCardFooter,
} from 'ui_framework/components';
import { AddData } from './add_data';
import { RecentlyAccessed, recentlyAccessedShape } from './recently_accessed';
import {
EuiButton,
EuiPage,
EuiPanel,
EuiTitle,
@ -20,15 +14,11 @@ import {
EuiFlexItem,
EuiFlexGrid,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import { FeatureCatalogueCategory } from 'ui/registry/feature_catalogue';
import chrome from 'ui/chrome';
const kbnBaseUrl = chrome.getInjected('kbnBaseUrl');
export function Home({ addBasePath, directories, isCloudEnabled }) {
export function Home({ addBasePath, directories, isCloudEnabled, recentlyAccessed }) {
const renderDirectories = (category) => {
return directories
@ -49,173 +39,31 @@ export function Home({ addBasePath, directories, isCloudEnabled }) {
});
};
const renderPromo = () => {
const cardStyle = {
width: '250px',
'minWidth': '200px'
};
let apmCard;
if (!isCloudEnabled) {
apmCard = (
<KuiCard style={cardStyle} className="euiPanel">
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_apm.svg')}
/>
<p>
APM
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
APM automatically collects in-depth performance metrics and errors from inside your applications.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<KuiLinkButton
buttonType="secondary"
href={addBasePath(`${kbnBaseUrl}#/home/tutorial/apm`)}
>
Add APM
</KuiLinkButton>
</KuiCardFooter>
</KuiCard>
);
}
return (
<div className="kuiVerticalRhythm">
<KuiCardGroup>
{apmCard}
<KuiCard style={cardStyle} className="euiPanel">
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_logging.svg')}
/>
<p>
Logging
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
Ingest logs from popular data sources and easily visualize in preconfigured dashboards.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<KuiLinkButton
buttonType="secondary"
href={addBasePath(`${kbnBaseUrl}#/home/tutorial_directory/logging`)}
>
Add log data
</KuiLinkButton>
</KuiCardFooter>
</KuiCard>
<KuiCard style={cardStyle} className="euiPanel">
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_monitoring.svg')}
/>
<p>
Metrics
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
Collect metrics from the operating system and services running on your servers.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<KuiLinkButton
buttonType="secondary"
href={addBasePath(`${kbnBaseUrl}#/home/tutorial_directory/metrics`)}
>
Add metric data
</KuiLinkButton>
</KuiCardFooter>
</KuiCard>
<KuiCard style={cardStyle} className="euiPanel">
<KuiCardDescription>
<KuiCardDescriptionTitle>
<img
src={addBasePath('/plugins/kibana/assets/app_security.svg')}
/>
<p>
Security analytics
</p>
</KuiCardDescriptionTitle>
<KuiCardDescriptionText>
Centralize security events for interactive investigation in ready-to-go visualizations.
</KuiCardDescriptionText>
</KuiCardDescription>
<KuiCardFooter>
<KuiLinkButton
buttonType="secondary"
href={addBasePath(`${kbnBaseUrl}#/home/tutorial_directory/security`)}
>
Add security events
</KuiLinkButton>
</KuiCardFooter>
</KuiCard>
</KuiCardGroup>
</div>
let recentlyAccessedPanel;
if (recentlyAccessed.length > 0) {
recentlyAccessedPanel = (
<Fragment>
<RecentlyAccessed
recentlyAccessed={recentlyAccessed}
/>
<EuiSpacer size="l" />
</Fragment>
);
};
}
return (
<EuiPage className="home">
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="flexEnd"
>
<EuiFlexItem>
<EuiTitle size="l">
<h1>Add Data to Kibana</h1>
</EuiTitle>
<EuiText>
<p>
Use these solutions to quickly turn your data into pre-built dashboards and monitoring systems.
</p>
</EuiText>
</EuiFlexItem>
{recentlyAccessedPanel}
<EuiFlexItem grow={false}>
<EuiTextColor color="subdued">
<EuiText>
<p>
Data already in Elasticsearch?
</p>
</EuiText>
</EuiTextColor>
<EuiSpacer size="s" />
<a href="#/management/kibana/index" className="euiButton euiButton--primary euiButton--small">
<span className="euiButton__content">
Set up index patterns
</span>
</a>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{ renderPromo() }
<AddData
addBasePath={addBasePath}
isCloudEnabled={isCloudEnabled}
/>
<EuiSpacer size="l" />
<EuiFlexGroup className="kuiVerticalRhythm">
<EuiFlexGroup>
<EuiFlexItem>
<EuiPanel paddingSize="l">
<EuiTitle>
@ -254,12 +102,11 @@ export function Home({ addBasePath, directories, isCloudEnabled }) {
</p>
</EuiText>
<EuiSpacer size="s" />
<KuiLinkButton
buttonType="secondary"
<EuiButton
href="#/home/feature_directory"
>
View full directory of Kibana plugins
</KuiLinkButton>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
@ -279,4 +126,5 @@ Home.propTypes = {
category: PropTypes.string.isRequired
})),
isCloudEnabled: PropTypes.bool.isRequired,
recentlyAccessed: PropTypes.arrayOf(recentlyAccessedShape).isRequired,
};

View file

@ -0,0 +1,95 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Home } from './home';
import { FeatureCatalogueCategory } from 'ui/registry/feature_catalogue';
const addBasePath = (url) => { return `base_path/${url}`; };
test('should render home component', () => {
const recentlyAccessed = [
{
label: 'my vis',
link: 'link_to_my_vis',
id: '1'
}
];
const component = shallow(<Home
addBasePath={addBasePath}
directories={[]}
isCloudEnabled={true}
recentlyAccessed={recentlyAccessed}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('should not contain RecentlyAccessed panel when there is no recentlyAccessed history', () => {
const component = shallow(<Home
addBasePath={addBasePath}
directories={[]}
isCloudEnabled={true}
recentlyAccessed={[]}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
describe('directories', () => {
test('should render DATA directory entry in "Explore Data" panel', () => {
const directoryEntry = {
id: 'dashboard',
title: 'Dashboard',
description: 'Display and share a collection of visualizations and saved searches.',
icon: '/plugins/kibana/assets/app_dashboard.svg',
path: 'dashboard_landing_page',
showOnHomePage: true,
category: FeatureCatalogueCategory.DATA
};
const component = shallow(<Home
addBasePath={addBasePath}
directories={[directoryEntry]}
isCloudEnabled={true}
recentlyAccessed={[]}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('should render ADMIN directory entry in "Manage" panel', () => {
const directoryEntry = {
id: 'index_patterns',
title: 'Index Patterns',
description: 'Manage the index patterns that help retrieve your data from Elasticsearch.',
icon: '/plugins/kibana/assets/app_index_pattern.svg',
path: 'index_management_landing_page',
showOnHomePage: true,
category: FeatureCatalogueCategory.ADMIN
};
const component = shallow(<Home
addBasePath={addBasePath}
directories={[directoryEntry]}
isCloudEnabled={true}
recentlyAccessed={[]}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('should not render directory entry when showOnHomePage is false', () => {
const directoryEntry = {
id: 'management',
title: 'Management',
description: 'Your center console for managing the Elastic Stack.',
icon: '/plugins/kibana/assets/app_management.svg',
path: 'management_landing_page',
showOnHomePage: false,
category: FeatureCatalogueCategory.ADMIN
};
const component = shallow(<Home
addBasePath={addBasePath}
directories={[directoryEntry]}
isCloudEnabled={true}
recentlyAccessed={[]}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
});

View file

@ -12,8 +12,9 @@ import {
import { getTutorial } from '../load_tutorials';
import { replaceTemplateStrings } from './tutorial/replace_template_strings';
import chrome from 'ui/chrome';
import { recentlyAccessedShape } from './recently_accessed';
export function HomeApp({ addBasePath, directories }) {
export function HomeApp({ addBasePath, directories, recentlyAccessed }) {
const isCloudEnabled = chrome.getInjected('isCloudEnabled', false);
@ -65,6 +66,7 @@ export function HomeApp({ addBasePath, directories }) {
addBasePath={addBasePath}
directories={directories}
isCloudEnabled={isCloudEnabled}
recentlyAccessed={recentlyAccessed}
/>
</Route>
</Switch>
@ -82,5 +84,6 @@ HomeApp.propTypes = {
path: PropTypes.string.isRequired,
showOnHomePage: PropTypes.bool.isRequired,
category: PropTypes.string.isRequired
}))
})),
recentlyAccessed: PropTypes.arrayOf(recentlyAccessedShape).isRequired,
};

View file

@ -0,0 +1,206 @@
import './recently_accessed.less';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiPanel,
EuiLink,
EuiText,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiIcon,
EuiSpacer,
} from '@elastic/eui';
export const NUM_LONG_LINKS = 5;
export class RecentlyAccessed extends Component {
constructor(props) {
super(props);
this.state = {
isPopoverOpen: false,
};
}
onButtonClick = () => {
this.setState({
isPopoverOpen: !this.state.isPopoverOpen,
});
}
closePopover = () => {
this.setState({
isPopoverOpen: false,
});
}
renderDropdown = () => {
if (this.props.recentlyAccessed.length <= NUM_LONG_LINKS) {
return;
}
const dropdownLinks = [];
for (let i = NUM_LONG_LINKS; i < this.props.recentlyAccessed.length; i++) {
dropdownLinks.push(
(
<li
style={{ marginBottom: 8 }}
key={this.props.recentlyAccessed[i].id}
data-test-subj={`moreRecentlyAccessedItem${this.props.recentlyAccessed[i].id}`}
>
<EuiLink
className="recentlyAccessedDropwdownLink"
href={this.props.recentlyAccessed[i].link}
>
{this.props.recentlyAccessed[i].label}
</EuiLink>
</li>
)
);
}
const openPopoverComponent = (
<EuiLink
onClick={this.onButtonClick}
data-test-subj="openMoreRecentlyAccessedPopover"
>
<EuiTextColor
className="recentlyAccessedDropdownLabel"
color="subdued"
>
{`${dropdownLinks.length} more`}
</EuiTextColor>
<EuiIcon
type="arrowDown"
color="subdued"
/>
</EuiLink>
);
let anchorPosition = 'downRight';
if (window.innerWidth <= 768) {
anchorPosition = 'downLeft';
}
return (
<EuiPopover
id="popover"
ownFocus
button={openPopoverComponent}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
anchorPosition={anchorPosition}
>
<ul>
{dropdownLinks}
</ul>
</EuiPopover>
);
}
renderLongLink = (recentlyAccessedItem, includeSeparator = false) => {
let separator;
if (includeSeparator) {
separator = (
<EuiFlexItem grow={false} className="recentlyAccessedSeparator">
<EuiText>
<EuiIcon
type="dot"
color="subdued"
/>
</EuiText>
</EuiFlexItem>
);
}
// Want to avoid a bunch of white space around items with short labels (happens when min width is too large).
// Also want to avoid truncating really short names (happens when there is no min width)
// Dynamically setting the min width based on label lengh meets both of these goals.
const EM_RATIO = 0.65; // 'em' ratio that avoids too much horizontal white space and too much truncation
const minWidth = (recentlyAccessedItem.label.length < 8 ? recentlyAccessedItem.label.length : 8) * EM_RATIO;
const style = { minWidth: `${minWidth}em` };
return (
<React.Fragment key={recentlyAccessedItem.id}>
{separator}
<EuiFlexItem
className="recentlyAccessedItem"
style={style}
grow={false}
>
<EuiLink
className="recentlyAccessedLongLink"
href={recentlyAccessedItem.link}
>
{recentlyAccessedItem.label}
</EuiLink>
</EuiFlexItem>
</React.Fragment>
);
}
renderRecentlyAccessed = () => {
if (this.props.recentlyAccessed.length <= NUM_LONG_LINKS) {
return this.props.recentlyAccessed.map((item, index) => {
let includeSeparator = true;
if (index === 0) {
includeSeparator = false;
}
return this.renderLongLink(item, includeSeparator);
});
}
const links = [];
for (let i = 0; i < NUM_LONG_LINKS; i++) {
let includeSeparator = true;
if (i === 0) {
includeSeparator = false;
}
links.push(this.renderLongLink(
this.props.recentlyAccessed[i],
includeSeparator));
}
return links;
};
render() {
return (
<EuiPanel paddingSize="l">
<EuiText>
<p>
<EuiTextColor color="subdued">
Recently viewed
</EuiTextColor>
</p>
</EuiText>
<EuiSpacer size="s"/>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd">
<EuiFlexItem grow={false} className="recentlyAccessedFlexItem">
<EuiFlexGroup>
{this.renderRecentlyAccessed()}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{this.renderDropdown()}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
}
export const recentlyAccessedShape = PropTypes.shape({
label: PropTypes.string.isRequired,
link: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
});
RecentlyAccessed.propTypes = {
recentlyAccessed: PropTypes.arrayOf(recentlyAccessedShape).isRequired
};

View file

@ -0,0 +1,30 @@
@media only screen and (max-width: 768px) {
.recentlyAccessedSeparator {
display: none;
}
}
.recentlyAccessedItem {
overflow: hidden;
max-width: 300px;
}
.recentlyAccessedLongLink {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: normal;
}
.recentlyAccessedFlexItem {
max-width: 1000px;
}
.recentlyAccessedDropwdownLink {
white-space: nowrap;
height: 18px;
}
.recentlyAccessedDropdownLabel {
white-space: nowrap;
}

View file

@ -0,0 +1,77 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import { RecentlyAccessed, NUM_LONG_LINKS } from './recently_accessed';
import { findTestSubject } from '@elastic/eui/lib/test';
const createRecentlyAccessed = (length) => {
const recentlyAccessed = [];
let i = 0;
while(recentlyAccessed.length < length) {
recentlyAccessed.push({
label: `label${recentlyAccessed.length}`,
link: `link${recentlyAccessed.length}`,
id: `${i++}`
});
}
return recentlyAccessed;
};
test('render', () => {
const component = shallow(<RecentlyAccessed
recentlyAccessed={createRecentlyAccessed(2)}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
describe('more popover', () => {
test('should not be rendered when recently accessed list size is less than NUM_LONG_LINKS', () => {
const component = mount(<RecentlyAccessed
recentlyAccessed={createRecentlyAccessed(NUM_LONG_LINKS - 1)}
/>);
const moreRecentlyAccessed = findTestSubject(component, 'openMoreRecentlyAccessedPopover');
expect(moreRecentlyAccessed.length).toBe(0);
});
test('should not be rendered when recently accessed list size is NUM_LONG_LINKS', () => {
const component = mount(<RecentlyAccessed
recentlyAccessed={createRecentlyAccessed(NUM_LONG_LINKS)}
/>);
const moreRecentlyAccessed = findTestSubject(component, 'openMoreRecentlyAccessedPopover');
expect(moreRecentlyAccessed.length).toBe(0);
});
describe('recently accessed list size exceeds NUM_LONG_LINKS', () => {
test('should be rendered', () => {
const component = mount(<RecentlyAccessed
recentlyAccessed={createRecentlyAccessed(NUM_LONG_LINKS + 1)}
/>);
const moreRecentlyAccessed = findTestSubject(component, 'openMoreRecentlyAccessedPopover');
expect(moreRecentlyAccessed.length).toBe(1);
});
test('should only contain overflow recently accessed items when opened', () => {
const numberOfRecentlyAccessed = NUM_LONG_LINKS + 2;
const component = mount(<RecentlyAccessed
recentlyAccessed={createRecentlyAccessed(numberOfRecentlyAccessed)}
/>);
const moreRecentlyAccessed = findTestSubject(component, 'openMoreRecentlyAccessedPopover');
moreRecentlyAccessed.simulate('click');
let i = 0;
while (i < numberOfRecentlyAccessed) {
const item = findTestSubject(component, `moreRecentlyAccessedItem${i}`);
if (i < NUM_LONG_LINKS) {
expect(item.length).toBe(0);
} else {
expect(item.length).toBe(1);
}
i++;
}
});
});
});

View file

@ -1,4 +1,5 @@
<home-app
add-base-path="addBasePath"
directories="directories"
recently-accessed="recentlyAccessed"
/>

View file

@ -7,6 +7,7 @@ import { uiModules } from 'ui/modules';
import {
HomeApp
} from './components/home_app';
import { recentlyAccessed } from 'ui/persisted_log';
const app = uiModules.get('apps/home', []);
app.directive('homeApp', function (reactDirective) {
@ -19,6 +20,10 @@ function getRoute() {
controller($scope, Private) {
$scope.addBasePath = chrome.addBasePath;
$scope.directories = Private(FeatureCatalogueRegistryProvider).inTitleOrder;
$scope.recentlyAccessed = recentlyAccessed.get().map(item => {
item.link = chrome.addBasePath(item.link);
return item;
});
}
};
}

View file

@ -21,6 +21,7 @@ import { VisualizeConstants } from '../visualize_constants';
import { KibanaParsedUrl } from 'ui/url/kibana_parsed_url';
import { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url';
import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
import { recentlyAccessed } from 'ui/persisted_log';
uiRoutes
.when(VisualizeConstants.CREATE_PATH, {
@ -47,6 +48,13 @@ uiRoutes
resolve: {
savedVis: function (savedVisualizations, courier, $route) {
return savedVisualizations.get($route.current.params.id)
.then((savedVis) => {
recentlyAccessed.add(
savedVis.getFullPath(),
savedVis.title,
savedVis.id);
return savedVis;
})
.catch(courier.redirectWhenMissing({
'visualization': '/visualize',
'search': '/management/kibana/objects/savedVisualizations/' + $route.current.params.id,

View file

@ -10,6 +10,7 @@ import _ from 'lodash';
import { VisProvider } from 'ui/vis';
import { uiModules } from 'ui/modules';
import { updateOldState } from 'ui/vis/vis_update_state';
import { VisualizeConstants } from 'plugins/kibana/visualize/visualize_constants';
uiModules
.get('app/visualize')
@ -45,6 +46,8 @@ uiModules
afterESResp: this._afterEsResp
});
this.showInRecenltyAccessed = true;
}
SavedVis.type = 'visualization';
@ -63,6 +66,10 @@ uiModules
SavedVis.searchSource = true;
SavedVis.prototype.getFullPath = function () {
return `/app/kibana#${VisualizeConstants.EDIT_PATH}/${this.id}`;
};
SavedVis.prototype._afterEsResp = function () {
const self = this;

View file

@ -5,6 +5,7 @@ import { DocTitleProvider } from 'ui/doc_title';
import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry';
import { notify, fatalError, toastNotifications } from 'ui/notify';
import { timezoneProvider } from 'ui/vis/lib/timezone';
import { recentlyAccessed } from 'ui/persisted_log';
require('ui/autoload/all');
require('plugins/timelion/directives/cells/cells');
@ -18,8 +19,6 @@ require('plugins/timelion/app.less');
document.title = 'Timelion - Kibana';
require('ui/chrome');
const app = require('ui/modules').get('apps/timelion', []);
require('plugins/timelion/services/saved_sheets');
@ -40,6 +39,15 @@ require('ui/routes')
resolve: {
savedSheet: function (courier, savedSheets, $route) {
return savedSheets.get($route.current.params.id)
.then((savedSheet) => {
if ($route.current.params.id) {
recentlyAccessed.add(
savedSheet.getFullPath(),
savedSheet.title,
savedSheet.id);
}
return savedSheet;
})
.catch(courier.redirectWhenMissing({
'search': '/'
}));

View file

@ -30,6 +30,8 @@ module.factory('SavedSheet', function (courier, config) {
version: 1,
}
});
this.showInRecenltyAccessed = true;
}
// save these objects with the 'sheet' type
@ -52,5 +54,9 @@ module.factory('SavedSheet', function (courier, config) {
// Order these fields to the top, the rest are alphabetical
SavedSheet.fieldOrder = ['title', 'description'];
SavedSheet.prototype.getFullPath = function () {
return `/app/timelion#/${this.id}`;
};
return SavedSheet;
});

View file

@ -18,6 +18,7 @@ import MappingSetupProvider from 'ui/utils/mapping_setup';
import { SearchSourceProvider } from '../data_source/search_source';
import { SavedObjectsClientProvider, findObjectByTitle } from 'ui/saved_objects';
import { migrateLegacyQuery } from '../../utils/migrateLegacyQuery.js';
import { recentlyAccessed } from 'ui/persisted_log';
/**
* An error message to be used when the user rejects a confirm overwrite.
@ -344,6 +345,9 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
this.id = resp.id;
})
.then(() => {
if (this.showInRecenltyAccessed && this.getFullPath) {
recentlyAccessed.add(this.getFullPath(), this.title, this.id);
}
this.isSaving = false;
this.lastSavedTitle = this.title;
return this.id;

View file

@ -1,3 +1,4 @@
import './persisted_log';
export { PersistedLog } from './persisted_log';
export { recentlyAccessed } from './recently_accessed';

View file

@ -4,11 +4,16 @@ import { Storage } from 'ui/storage';
const localStorage = new Storage(window.localStorage);
const defaultIsDuplicate = (oldItem, newItem) => {
return _.isEqual(oldItem, newItem);
};
export class PersistedLog {
constructor(name, options = {}, storage = localStorage) {
this.name = name;
this.maxLength = parseInt(options.maxLength, 10);
this.filterDuplicates = options.filterDuplicates || false;
this.isDuplicate = options.isDuplicate || defaultIsDuplicate;
this.storage = storage;
this.items = this.storage.get(this.name) || [];
if (!isNaN(this.maxLength)) this.items = _.take(this.items, this.maxLength);
@ -21,8 +26,8 @@ export class PersistedLog {
// remove any matching items from the stack if option is set
if (this.filterDuplicates) {
_.remove(this.items, function (item) {
return _.isEqual(item, val);
_.remove(this.items, (item) => {
return this.isDuplicate(item, val);
});
}

View file

@ -0,0 +1,29 @@
import { PersistedLog } from 'ui/persisted_log';
class RecentlyAccessed {
constructor() {
const historyOptions = {
maxLength: 20,
filterDuplicates: true,
isDuplicate: (oldItem, newItem) => {
return oldItem.id === newItem.id;
}
};
this.history = new PersistedLog('kibana.history.recentlyAccessed', historyOptions);
}
add(link, label, id) {
const historyItem = {
link: link,
label: label,
id: id
};
this.history.add(historyItem);
}
get() {
return this.history.get();
}
}
export const recentlyAccessed = new RecentlyAccessed();

View file

@ -3684,9 +3684,9 @@ enzyme-adapter-utils@^1.3.0:
object.assign "^4.0.4"
prop-types "^15.6.0"
enzyme-to-json@3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/enzyme-to-json/-/enzyme-to-json-3.1.4.tgz#a4a85a8f7b561cb8c9c0d728ad1b619a3fed7df2"
enzyme-to-json@3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/enzyme-to-json/-/enzyme-to-json-3.3.0.tgz#553e23a09ffb4b0cf09287e2edf9c6539fddaa84"
dependencies:
lodash "^4.17.4"