[Enterprise Search] Add Overview landing page/plugin (#76734)
* [public] Register Enterprise search plugin + move new Home strings to constants * [server] Register plugin access/visibility * Set up Enterprise Search Kibana Chrome - Add SetEnterpriseSearchChrome - Update Enterprise Search breadcrumbs to link to new overview plugin (+ update overview plugin URL per team discussion) - Add ability to break out of React Router basename by not using history.createhref - Update createHref mock to more closely match Kibana urls (adding /app prefix) - Minor documentation fix * Set up Enterprise Search plugin telemetry - client-side: SendEnterpriseSearchTelemetry - server-side: register saved objects, usage collector, etc. * Enterprise search overview views (#23) * Add formatTestSubj util This allows us to correctly format strings into our casing for data-test-subj attrs * Add images and stylesheet * Add product card component * Add index component * Remove unused styles * Fix inter-plugin links - by add shouldNotCreateHref prop to RR helpers - similiar to breadcrumb change * Fix/clean up CSS - Prefer EUI components over bespoke CSS (e.g. EuiCard) - Remove unused or unspecific CSS - Pull out product card CSS to its component - Fix kebab-cased CSS classes to camelCased * Clean up ProductCard props - Prefer passing in our plugin consts instead of separate props - Move productCardDescription to constants - Update tests * Add telemetry clicked actions to product buttons + revert data-test-subj strings to previous implementation + prune format_test_subj helper by using lodash util directly * [PR feedback] Add new plugin to applicationUsageSchema per telemetry team request * Fix failing functional navLinks test * Fix telemetry schema test * [Perf] Optimize assets size by switching from 300kb SVG to 25kb PNG * Only show product cards if the user has access to that product - adds access checks - fixes flex/CSS to show one card at a time Co-authored-by: Scotty Bollinger <scotty.bollinger@elastic.co>
This commit is contained in:
parent
87ca6ff70c
commit
d67a421e68
|
@ -66,6 +66,7 @@ export const applicationUsageSchema = {
|
|||
csm: commonSchema,
|
||||
canvas: commonSchema,
|
||||
dashboard_mode: commonSchema, // It's a forward app so we'll likely never report it
|
||||
enterpriseSearch: commonSchema,
|
||||
appSearch: commonSchema,
|
||||
workplaceSearch: commonSchema,
|
||||
graph: commonSchema,
|
||||
|
|
|
@ -414,6 +414,34 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"enterpriseSearch": {
|
||||
"properties": {
|
||||
"clicks_total": {
|
||||
"type": "long"
|
||||
},
|
||||
"clicks_7_days": {
|
||||
"type": "long"
|
||||
},
|
||||
"clicks_30_days": {
|
||||
"type": "long"
|
||||
},
|
||||
"clicks_90_days": {
|
||||
"type": "long"
|
||||
},
|
||||
"minutes_on_screen_total": {
|
||||
"type": "float"
|
||||
},
|
||||
"minutes_on_screen_7_days": {
|
||||
"type": "float"
|
||||
},
|
||||
"minutes_on_screen_30_days": {
|
||||
"type": "float"
|
||||
},
|
||||
"minutes_on_screen_90_days": {
|
||||
"type": "float"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSearch": {
|
||||
"properties": {
|
||||
"clicks_total": {
|
||||
|
|
|
@ -11,7 +11,24 @@ export const ENTERPRISE_SEARCH_PLUGIN = {
|
|||
NAME: i18n.translate('xpack.enterpriseSearch.productName', {
|
||||
defaultMessage: 'Enterprise Search',
|
||||
}),
|
||||
URL: '/app/enterprise_search',
|
||||
NAV_TITLE: i18n.translate('xpack.enterpriseSearch.navTitle', {
|
||||
defaultMessage: 'Overview',
|
||||
}),
|
||||
SUBTITLE: i18n.translate('xpack.enterpriseSearch.featureCatalogue.subtitle', {
|
||||
defaultMessage: 'Search everything',
|
||||
}),
|
||||
DESCRIPTIONS: [
|
||||
i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription1', {
|
||||
defaultMessage: 'Build a powerful search experience.',
|
||||
}),
|
||||
i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription2', {
|
||||
defaultMessage: 'Connect your users to relevant data.',
|
||||
}),
|
||||
i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription3', {
|
||||
defaultMessage: 'Unify your team content.',
|
||||
}),
|
||||
],
|
||||
URL: '/app/enterprise_search/overview',
|
||||
};
|
||||
|
||||
export const APP_SEARCH_PLUGIN = {
|
||||
|
@ -23,6 +40,10 @@ export const APP_SEARCH_PLUGIN = {
|
|||
defaultMessage:
|
||||
'Leverage dashboards, analytics, and APIs for advanced application search made simple.',
|
||||
}),
|
||||
CARD_DESCRIPTION: i18n.translate('xpack.enterpriseSearch.appSearch.productCardDescription', {
|
||||
defaultMessage:
|
||||
'Elastic App Search provides user-friendly tools to design and deploy a powerful search to your websites or web/mobile applications.',
|
||||
}),
|
||||
URL: '/app/enterprise_search/app_search',
|
||||
SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/app-search/',
|
||||
};
|
||||
|
@ -36,6 +57,13 @@ export const WORKPLACE_SEARCH_PLUGIN = {
|
|||
defaultMessage:
|
||||
'Search all documents, files, and sources available across your virtual workplace.',
|
||||
}),
|
||||
CARD_DESCRIPTION: i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.productCardDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
"Unify all your team's content in one place, with instant connectivity to popular productivity and collaboration tools.",
|
||||
}
|
||||
),
|
||||
URL: '/app/enterprise_search/workplace_search',
|
||||
SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/workplace-search/',
|
||||
};
|
||||
|
|
|
@ -18,6 +18,10 @@ export interface IInitialAppData {
|
|||
ilmEnabled?: boolean;
|
||||
isFederatedAuth?: boolean;
|
||||
configuredLimits?: IConfiguredLimits;
|
||||
access?: {
|
||||
hasAppSearchAccess: boolean;
|
||||
hasWorkplaceSearchAccess: boolean;
|
||||
};
|
||||
appSearch?: IAppSearchAccount;
|
||||
workplaceSearch?: IWorkplaceSearchInitialData;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
* Jest to accept its use within a jest.mock()
|
||||
*/
|
||||
export const mockHistory = {
|
||||
createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`),
|
||||
createHref: jest.fn(({ pathname }) => `/app/enterprise_search${pathname}`),
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
pathname: '/current-path',
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { ProductCard } from './product_card';
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
.productCard {
|
||||
margin: $euiSizeS;
|
||||
|
||||
&__imageContainer {
|
||||
max-height: 115px;
|
||||
overflow: hidden;
|
||||
background-color: #0076cc;
|
||||
|
||||
@include euiBreakpoint('s', 'm', 'l', 'xl') {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.euiCard__content {
|
||||
max-width: 350px;
|
||||
margin-top: $euiSizeL;
|
||||
|
||||
@include euiBreakpoint('s', 'm', 'l', 'xl') {
|
||||
margin-top: $euiSizeXL;
|
||||
}
|
||||
}
|
||||
|
||||
.euiCard__title {
|
||||
margin-bottom: $euiSizeM;
|
||||
font-weight: $euiFontWeightBold;
|
||||
|
||||
@include euiBreakpoint('s', 'm', 'l', 'xl') {
|
||||
margin-bottom: $euiSizeL;
|
||||
font-size: $euiSizeL;
|
||||
}
|
||||
}
|
||||
|
||||
.euiCard__description {
|
||||
font-weight: $euiFontWeightMedium;
|
||||
color: $euiColorMediumShade;
|
||||
margin-bottom: $euiSize;
|
||||
}
|
||||
|
||||
.euiCard__footer {
|
||||
margin-bottom: $euiSizeS;
|
||||
|
||||
@include euiBreakpoint('s', 'm', 'l', 'xl') {
|
||||
margin-bottom: $euiSizeM;
|
||||
font-size: $euiSizeL;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiCard } from '@elastic/eui';
|
||||
import { EuiButton } from '../../../shared/react_router_helpers';
|
||||
import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants';
|
||||
|
||||
jest.mock('../../../shared/telemetry', () => ({
|
||||
sendTelemetry: jest.fn(),
|
||||
}));
|
||||
import { sendTelemetry } from '../../../shared/telemetry';
|
||||
|
||||
import { ProductCard } from './';
|
||||
|
||||
describe('ProductCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders an App Search card', () => {
|
||||
const wrapper = shallow(<ProductCard product={APP_SEARCH_PLUGIN} image="as.jpg" />);
|
||||
const card = wrapper.find(EuiCard).dive().shallow();
|
||||
|
||||
expect(card.find('h2').text()).toEqual('Elastic App Search');
|
||||
expect(card.find('.productCard__image').prop('src')).toEqual('as.jpg');
|
||||
|
||||
const button = card.find(EuiButton);
|
||||
expect(button.prop('to')).toEqual('/app/enterprise_search/app_search');
|
||||
expect(button.prop('data-test-subj')).toEqual('LaunchAppSearchButton');
|
||||
|
||||
button.simulate('click');
|
||||
expect(sendTelemetry).toHaveBeenCalledWith(expect.objectContaining({ metric: 'app_search' }));
|
||||
});
|
||||
|
||||
it('renders a Workplace Search card', () => {
|
||||
const wrapper = shallow(<ProductCard product={WORKPLACE_SEARCH_PLUGIN} image="ws.jpg" />);
|
||||
const card = wrapper.find(EuiCard).dive().shallow();
|
||||
|
||||
expect(card.find('h2').text()).toEqual('Elastic Workplace Search');
|
||||
expect(card.find('.productCard__image').prop('src')).toEqual('ws.jpg');
|
||||
|
||||
const button = card.find(EuiButton);
|
||||
expect(button.prop('to')).toEqual('/app/enterprise_search/workplace_search');
|
||||
expect(button.prop('data-test-subj')).toEqual('LaunchWorkplaceSearchButton');
|
||||
|
||||
button.simulate('click');
|
||||
expect(sendTelemetry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metric: 'workplace_search' })
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import snakeCase from 'lodash/snakeCase';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiCard, EuiTextColor } from '@elastic/eui';
|
||||
|
||||
import { EuiButton } from '../../../shared/react_router_helpers';
|
||||
import { sendTelemetry } from '../../../shared/telemetry';
|
||||
import { KibanaContext, IKibanaContext } from '../../../index';
|
||||
|
||||
import './product_card.scss';
|
||||
|
||||
interface IProductCard {
|
||||
// Expects product plugin constants (@see common/constants.ts)
|
||||
product: {
|
||||
ID: string;
|
||||
NAME: string;
|
||||
CARD_DESCRIPTION: string;
|
||||
URL: string;
|
||||
};
|
||||
image: string;
|
||||
}
|
||||
|
||||
export const ProductCard: React.FC<IProductCard> = ({ product, image }) => {
|
||||
const { http } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
return (
|
||||
<EuiCard
|
||||
className="productCard"
|
||||
titleElement="h2"
|
||||
title={i18n.translate('xpack.enterpriseSearch.overview.productCard.heading', {
|
||||
defaultMessage: `Elastic {productName}`,
|
||||
values: { productName: product.NAME },
|
||||
})}
|
||||
image={
|
||||
<div className="productCard__imageContainer">
|
||||
<img src={image} className="productCard__image" alt="" role="presentation" />
|
||||
</div>
|
||||
}
|
||||
paddingSize="l"
|
||||
description={<EuiTextColor color="subdued">{product.CARD_DESCRIPTION}</EuiTextColor>}
|
||||
footer={
|
||||
<EuiButton
|
||||
fill
|
||||
to={product.URL}
|
||||
shouldNotCreateHref={true}
|
||||
onClick={() =>
|
||||
sendTelemetry({
|
||||
http,
|
||||
product: 'enterprise_search',
|
||||
action: 'clicked',
|
||||
metric: snakeCase(product.ID),
|
||||
})
|
||||
}
|
||||
data-test-subj={`Launch${upperFirst(product.ID)}Button`}
|
||||
>
|
||||
{i18n.translate('xpack.enterpriseSearch.overview.productCard.button', {
|
||||
defaultMessage: `Launch {productName}`,
|
||||
values: { productName: product.NAME },
|
||||
})}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
.enterpriseSearchOverview {
|
||||
padding-top: 78px;
|
||||
background-image: url('./assets/bg_enterprise_search.png');
|
||||
background-repeat: no-repeat;
|
||||
background-size: 670px;
|
||||
background-position: center -27px;
|
||||
|
||||
@include euiBreakpoint('m', 'l', 'xl') {
|
||||
padding-top: 158px;
|
||||
background-size: 1160px;
|
||||
background-position: center -48px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
@include euiBreakpoint('xs', 's') {
|
||||
font-size: $euiFontSizeXL;
|
||||
line-height: map-get(map-get($euiTitles, 'm'), 'line-height');
|
||||
}
|
||||
}
|
||||
|
||||
&__subheading {
|
||||
color: $euiColorMediumShade;
|
||||
font-size: $euiFontSize;
|
||||
|
||||
@include euiBreakpoint('m', 'l', 'xl') {
|
||||
font-size: $euiFontSizeL;
|
||||
margin-bottom: $euiSizeL;
|
||||
}
|
||||
}
|
||||
|
||||
// EUI override
|
||||
.euiTitle + .euiTitle {
|
||||
margin-top: 0;
|
||||
|
||||
@include euiBreakpoint('m', 'l', 'xl') {
|
||||
margin-top: $euiSizeS;
|
||||
}
|
||||
}
|
||||
|
||||
.enterpriseSearchOverview__card {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiPage } from '@elastic/eui';
|
||||
|
||||
import { EnterpriseSearch } from './';
|
||||
import { ProductCard } from './components/product_card';
|
||||
|
||||
describe('EnterpriseSearch', () => {
|
||||
it('renders the overview page and product cards', () => {
|
||||
const wrapper = shallow(
|
||||
<EnterpriseSearch access={{ hasAppSearchAccess: true, hasWorkplaceSearchAccess: true }} />
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiPage).hasClass('enterpriseSearchOverview')).toBe(true);
|
||||
expect(wrapper.find(ProductCard)).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('access checks', () => {
|
||||
it('does not render the App Search card if the user does not have access to AS', () => {
|
||||
const wrapper = shallow(
|
||||
<EnterpriseSearch access={{ hasAppSearchAccess: false, hasWorkplaceSearchAccess: true }} />
|
||||
);
|
||||
|
||||
expect(wrapper.find(ProductCard)).toHaveLength(1);
|
||||
expect(wrapper.find(ProductCard).prop('product').ID).toEqual('workplaceSearch');
|
||||
});
|
||||
|
||||
it('does not render the Workplace Search card if the user does not have access to WS', () => {
|
||||
const wrapper = shallow(
|
||||
<EnterpriseSearch access={{ hasAppSearchAccess: true, hasWorkplaceSearchAccess: false }} />
|
||||
);
|
||||
|
||||
expect(wrapper.find(ProductCard)).toHaveLength(1);
|
||||
expect(wrapper.find(ProductCard).prop('product').ID).toEqual('appSearch');
|
||||
});
|
||||
|
||||
it('does not render any cards if the user does not have access', () => {
|
||||
const wrapper = shallow(<EnterpriseSearch />);
|
||||
|
||||
expect(wrapper.find(ProductCard)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiPage,
|
||||
EuiPageBody,
|
||||
EuiPageHeader,
|
||||
EuiPageHeaderSection,
|
||||
EuiPageContentBody,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { IInitialAppData } from '../../../common/types';
|
||||
import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../common/constants';
|
||||
|
||||
import { SetEnterpriseSearchChrome as SetPageChrome } from '../shared/kibana_chrome';
|
||||
import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../shared/telemetry';
|
||||
|
||||
import { ProductCard } from './components/product_card';
|
||||
|
||||
import AppSearchImage from './assets/app_search.png';
|
||||
import WorkplaceSearchImage from './assets/workplace_search.png';
|
||||
import './index.scss';
|
||||
|
||||
export const EnterpriseSearch: React.FC<IInitialAppData> = ({ access = {} }) => {
|
||||
const { hasAppSearchAccess, hasWorkplaceSearchAccess } = access;
|
||||
|
||||
return (
|
||||
<EuiPage restrictWidth className="enterpriseSearchOverview">
|
||||
<SetPageChrome isRoot />
|
||||
<SendTelemetry action="viewed" metric="overview" />
|
||||
|
||||
<EuiPageBody>
|
||||
<EuiPageHeader>
|
||||
<EuiPageHeaderSection className="enterpriseSearchOverview__header">
|
||||
<EuiTitle size="l">
|
||||
<h1 className="enterpriseSearchOverview__heading">
|
||||
{i18n.translate('xpack.enterpriseSearch.overview.heading', {
|
||||
defaultMessage: 'Welcome to Elastic Enterprise Search',
|
||||
})}
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
<EuiTitle size="s">
|
||||
<p className="enterpriseSearchOverview__subheading">
|
||||
{i18n.translate('xpack.enterpriseSearch.overview.subheading', {
|
||||
defaultMessage: 'Select a product to get started',
|
||||
})}
|
||||
</p>
|
||||
</EuiTitle>
|
||||
</EuiPageHeaderSection>
|
||||
</EuiPageHeader>
|
||||
<EuiPageContentBody>
|
||||
<EuiFlexGroup justifyContent="center" gutterSize="xl">
|
||||
{hasAppSearchAccess && (
|
||||
<EuiFlexItem grow={false} className="enterpriseSearchOverview__card">
|
||||
<ProductCard product={APP_SEARCH_PLUGIN} image={AppSearchImage} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{hasWorkplaceSearchAccess && (
|
||||
<EuiFlexItem grow={false} className="enterpriseSearchOverview__card">
|
||||
<ProductCard product={WORKPLACE_SEARCH_PLUGIN} image={WorkplaceSearchImage} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
</EuiPageContentBody>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
};
|
|
@ -37,27 +37,37 @@ describe('useBreadcrumbs', () => {
|
|||
expect(breadcrumb).toEqual([
|
||||
{
|
||||
text: 'Hello',
|
||||
href: '/enterprise_search/hello',
|
||||
href: '/app/enterprise_search/hello',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'World',
|
||||
href: '/enterprise_search/world',
|
||||
href: '/app/enterprise_search/world',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prevents default navigation and uses React Router history on click', () => {
|
||||
const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any;
|
||||
const breadcrumb = useBreadcrumbs([{ text: '', path: '/test' }])[0] as any;
|
||||
const event = { preventDefault: jest.fn() };
|
||||
breadcrumb.onClick(event);
|
||||
|
||||
expect(mockKibanaContext.navigateToUrl).toHaveBeenCalled();
|
||||
expect(mockKibanaContext.navigateToUrl).toHaveBeenCalledWith('/app/enterprise_search/test');
|
||||
expect(mockHistory.createHref).toHaveBeenCalled();
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call createHref if shouldNotCreateHref is passed', () => {
|
||||
const breadcrumb = useBreadcrumbs([
|
||||
{ text: '', path: '/test', shouldNotCreateHref: true },
|
||||
])[0] as any;
|
||||
breadcrumb.onClick({ preventDefault: () => null });
|
||||
|
||||
expect(mockKibanaContext.navigateToUrl).toHaveBeenCalledWith('/test');
|
||||
expect(mockHistory.createHref).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not prevent default browser behavior on new tab/window clicks', () => {
|
||||
const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any;
|
||||
|
||||
|
@ -95,15 +105,17 @@ describe('useEnterpriseSearchBreadcrumbs', () => {
|
|||
expect(useEnterpriseSearchBreadcrumbs(breadcrumbs)).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
href: '/app/enterprise_search/overview',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Page 1',
|
||||
href: '/enterprise_search/page1',
|
||||
href: '/app/enterprise_search/page1',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Page 2',
|
||||
href: '/enterprise_search/page2',
|
||||
href: '/app/enterprise_search/page2',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
|
@ -113,6 +125,8 @@ describe('useEnterpriseSearchBreadcrumbs', () => {
|
|||
expect(useEnterpriseSearchBreadcrumbs()).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
href: '/app/enterprise_search/overview',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -122,7 +136,7 @@ describe('useAppSearchBreadcrumbs', () => {
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHistory.createHref.mockImplementation(
|
||||
({ pathname }: any) => `/enterprise_search/app_search${pathname}`
|
||||
({ pathname }: any) => `/app/enterprise_search/app_search${pathname}`
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -141,20 +155,22 @@ describe('useAppSearchBreadcrumbs', () => {
|
|||
expect(useAppSearchBreadcrumbs(breadcrumbs)).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
href: '/app/enterprise_search/overview',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'App Search',
|
||||
href: '/enterprise_search/app_search/',
|
||||
href: '/app/enterprise_search/app_search/',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Page 1',
|
||||
href: '/enterprise_search/app_search/page1',
|
||||
href: '/app/enterprise_search/app_search/page1',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Page 2',
|
||||
href: '/enterprise_search/app_search/page2',
|
||||
href: '/app/enterprise_search/app_search/page2',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
|
@ -164,10 +180,12 @@ describe('useAppSearchBreadcrumbs', () => {
|
|||
expect(useAppSearchBreadcrumbs()).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
href: '/app/enterprise_search/overview',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'App Search',
|
||||
href: '/enterprise_search/app_search/',
|
||||
href: '/app/enterprise_search/app_search/',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
|
@ -178,7 +196,7 @@ describe('useWorkplaceSearchBreadcrumbs', () => {
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHistory.createHref.mockImplementation(
|
||||
({ pathname }: any) => `/enterprise_search/workplace_search${pathname}`
|
||||
({ pathname }: any) => `/app/enterprise_search/workplace_search${pathname}`
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -197,20 +215,22 @@ describe('useWorkplaceSearchBreadcrumbs', () => {
|
|||
expect(useWorkplaceSearchBreadcrumbs(breadcrumbs)).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
href: '/app/enterprise_search/overview',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Workplace Search',
|
||||
href: '/enterprise_search/workplace_search/',
|
||||
href: '/app/enterprise_search/workplace_search/',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Page 1',
|
||||
href: '/enterprise_search/workplace_search/page1',
|
||||
href: '/app/enterprise_search/workplace_search/page1',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Page 2',
|
||||
href: '/enterprise_search/workplace_search/page2',
|
||||
href: '/app/enterprise_search/workplace_search/page2',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
|
@ -220,10 +240,12 @@ describe('useWorkplaceSearchBreadcrumbs', () => {
|
|||
expect(useWorkplaceSearchBreadcrumbs()).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
href: '/app/enterprise_search/overview',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Workplace Search',
|
||||
href: '/enterprise_search/workplace_search/',
|
||||
href: '/app/enterprise_search/workplace_search/',
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -26,6 +26,9 @@ import { letBrowserHandleEvent } from '../react_router_helpers';
|
|||
interface IBreadcrumb {
|
||||
text: string;
|
||||
path?: string;
|
||||
// Used to navigate outside of the React Router basename,
|
||||
// i.e. if we need to go from App Search to Enterprise Search
|
||||
shouldNotCreateHref?: boolean;
|
||||
}
|
||||
export type TBreadcrumbs = IBreadcrumb[];
|
||||
|
||||
|
@ -33,11 +36,11 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => {
|
|||
const history = useHistory();
|
||||
const { navigateToUrl } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
return breadcrumbs.map(({ text, path }) => {
|
||||
return breadcrumbs.map(({ text, path, shouldNotCreateHref }) => {
|
||||
const breadcrumb = { text } as EuiBreadcrumb;
|
||||
|
||||
if (path) {
|
||||
const href = history.createHref({ pathname: path }) as string;
|
||||
const href = shouldNotCreateHref ? path : (history.createHref({ pathname: path }) as string);
|
||||
|
||||
breadcrumb.href = href;
|
||||
breadcrumb.onClick = (event) => {
|
||||
|
@ -56,7 +59,14 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => {
|
|||
*/
|
||||
|
||||
export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) =>
|
||||
useBreadcrumbs([{ text: ENTERPRISE_SEARCH_PLUGIN.NAME }, ...breadcrumbs]);
|
||||
useBreadcrumbs([
|
||||
{
|
||||
text: ENTERPRISE_SEARCH_PLUGIN.NAME,
|
||||
path: ENTERPRISE_SEARCH_PLUGIN.URL,
|
||||
shouldNotCreateHref: true,
|
||||
},
|
||||
...breadcrumbs,
|
||||
]);
|
||||
|
||||
export const useAppSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) =>
|
||||
useEnterpriseSearchBreadcrumbs([{ text: APP_SEARCH_PLUGIN.NAME, path: '/' }, ...breadcrumbs]);
|
||||
|
|
|
@ -20,7 +20,7 @@ export type TTitle = string[];
|
|||
/**
|
||||
* Given an array of page titles, return a final formatted document title
|
||||
* @param pages - e.g., ['Curations', 'some Engine', 'App Search']
|
||||
* @returns - e.g., 'Curations | some Engine | App Search'
|
||||
* @returns - e.g., 'Curations - some Engine - App Search'
|
||||
*/
|
||||
export const generateTitle = (pages: TTitle) => pages.join(' - ');
|
||||
|
||||
|
|
|
@ -4,4 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { SetAppSearchChrome, SetWorkplaceSearchChrome } from './set_chrome';
|
||||
export {
|
||||
SetEnterpriseSearchChrome,
|
||||
SetAppSearchChrome,
|
||||
SetWorkplaceSearchChrome,
|
||||
} from './set_chrome';
|
||||
|
|
|
@ -12,18 +12,24 @@ import React from 'react';
|
|||
import { mockKibanaContext, mountWithKibanaContext } from '../../__mocks__';
|
||||
|
||||
jest.mock('./generate_breadcrumbs', () => ({
|
||||
useEnterpriseSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs),
|
||||
useAppSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs),
|
||||
useWorkplaceSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs),
|
||||
}));
|
||||
import { useAppSearchBreadcrumbs, useWorkplaceSearchBreadcrumbs } from './generate_breadcrumbs';
|
||||
import {
|
||||
useEnterpriseSearchBreadcrumbs,
|
||||
useAppSearchBreadcrumbs,
|
||||
useWorkplaceSearchBreadcrumbs,
|
||||
} from './generate_breadcrumbs';
|
||||
|
||||
jest.mock('./generate_title', () => ({
|
||||
enterpriseSearchTitle: jest.fn((title: any) => title),
|
||||
appSearchTitle: jest.fn((title: any) => title),
|
||||
workplaceSearchTitle: jest.fn((title: any) => title),
|
||||
}));
|
||||
import { appSearchTitle, workplaceSearchTitle } from './generate_title';
|
||||
import { enterpriseSearchTitle, appSearchTitle, workplaceSearchTitle } from './generate_title';
|
||||
|
||||
import { SetAppSearchChrome, SetWorkplaceSearchChrome } from './';
|
||||
import { SetEnterpriseSearchChrome, SetAppSearchChrome, SetWorkplaceSearchChrome } from './';
|
||||
|
||||
describe('Set Kibana Chrome helpers', () => {
|
||||
beforeEach(() => {
|
||||
|
@ -35,6 +41,27 @@ describe('Set Kibana Chrome helpers', () => {
|
|||
expect(mockKibanaContext.setDocTitle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('SetEnterpriseSearchChrome', () => {
|
||||
it('sets breadcrumbs and document title', () => {
|
||||
mountWithKibanaContext(<SetEnterpriseSearchChrome text="Hello World" />);
|
||||
|
||||
expect(enterpriseSearchTitle).toHaveBeenCalledWith(['Hello World']);
|
||||
expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([
|
||||
{
|
||||
text: 'Hello World',
|
||||
path: '/current-path',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets empty breadcrumbs and document title when isRoot is true', () => {
|
||||
mountWithKibanaContext(<SetEnterpriseSearchChrome isRoot />);
|
||||
|
||||
expect(enterpriseSearchTitle).toHaveBeenCalledWith([]);
|
||||
expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SetAppSearchChrome', () => {
|
||||
it('sets breadcrumbs and document title', () => {
|
||||
mountWithKibanaContext(<SetAppSearchChrome text="Engines" />);
|
||||
|
|
|
@ -10,11 +10,17 @@ import { EuiBreadcrumb } from '@elastic/eui';
|
|||
|
||||
import { KibanaContext, IKibanaContext } from '../../index';
|
||||
import {
|
||||
useEnterpriseSearchBreadcrumbs,
|
||||
useAppSearchBreadcrumbs,
|
||||
useWorkplaceSearchBreadcrumbs,
|
||||
TBreadcrumbs,
|
||||
} from './generate_breadcrumbs';
|
||||
import { appSearchTitle, workplaceSearchTitle, TTitle } from './generate_title';
|
||||
import {
|
||||
enterpriseSearchTitle,
|
||||
appSearchTitle,
|
||||
workplaceSearchTitle,
|
||||
TTitle,
|
||||
} from './generate_title';
|
||||
|
||||
/**
|
||||
* Helpers for setting Kibana chrome (breadcrumbs, doc titles) on React view mount
|
||||
|
@ -33,6 +39,24 @@ interface IRootBreadcrumbsProps {
|
|||
}
|
||||
type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps;
|
||||
|
||||
export const SetEnterpriseSearchChrome: React.FC<TBreadcrumbsProps> = ({ text, isRoot }) => {
|
||||
const history = useHistory();
|
||||
const { setBreadcrumbs, setDocTitle } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
const title = isRoot ? [] : [text];
|
||||
const docTitle = enterpriseSearchTitle(title as TTitle | []);
|
||||
|
||||
const crumb = isRoot ? [] : [{ text, path: history.location.pathname }];
|
||||
const breadcrumbs = useEnterpriseSearchBreadcrumbs(crumb as TBreadcrumbs | []);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs(breadcrumbs);
|
||||
setDocTitle(docTitle);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SetAppSearchChrome: React.FC<TBreadcrumbsProps> = ({ text, isRoot }) => {
|
||||
const history = useHistory();
|
||||
const { setBreadcrumbs, setDocTitle } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
|
|
@ -45,10 +45,18 @@ describe('EUI & React Router Component Helpers', () => {
|
|||
const link = wrapper.find(EuiLink);
|
||||
|
||||
expect(link.prop('onClick')).toBeInstanceOf(Function);
|
||||
expect(link.prop('href')).toEqual('/enterprise_search/foo/bar');
|
||||
expect(link.prop('href')).toEqual('/app/enterprise_search/foo/bar');
|
||||
expect(mockHistory.createHref).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders with the correct non-basenamed href when shouldNotCreateHref is passed', () => {
|
||||
const wrapper = mount(<EuiReactRouterLink to="/foo/bar" shouldNotCreateHref />);
|
||||
const link = wrapper.find(EuiLink);
|
||||
|
||||
expect(link.prop('href')).toEqual('/foo/bar');
|
||||
expect(mockHistory.createHref).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('onClick', () => {
|
||||
it('prevents default navigation and uses React Router history', () => {
|
||||
const wrapper = mount(<EuiReactRouterLink to="/bar/baz" />);
|
||||
|
|
|
@ -21,14 +21,22 @@ import { letBrowserHandleEvent } from './link_events';
|
|||
interface IEuiReactRouterProps {
|
||||
to: string;
|
||||
onClick?(): void;
|
||||
// Used to navigate outside of the React Router plugin basename but still within Kibana,
|
||||
// e.g. if we need to go from Enterprise Search to App Search
|
||||
shouldNotCreateHref?: boolean;
|
||||
}
|
||||
|
||||
export const EuiReactRouterHelper: React.FC<IEuiReactRouterProps> = ({ to, onClick, children }) => {
|
||||
export const EuiReactRouterHelper: React.FC<IEuiReactRouterProps> = ({
|
||||
to,
|
||||
onClick,
|
||||
shouldNotCreateHref,
|
||||
children,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const { navigateToUrl } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
// Generate the correct link href (with basename etc. accounted for)
|
||||
const href = history.createHref({ pathname: to });
|
||||
const href = shouldNotCreateHref ? to : history.createHref({ pathname: to });
|
||||
|
||||
const reactRouterLinkClick = (event: React.MouseEvent) => {
|
||||
if (onClick) onClick(); // Run any passed click events (e.g. telemetry)
|
||||
|
@ -51,9 +59,10 @@ type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps;
|
|||
export const EuiReactRouterLink: React.FC<TEuiReactRouterLinkProps> = ({
|
||||
to,
|
||||
onClick,
|
||||
shouldNotCreateHref,
|
||||
...rest
|
||||
}) => (
|
||||
<EuiReactRouterHelper to={to} onClick={onClick}>
|
||||
<EuiReactRouterHelper {...{ to, onClick, shouldNotCreateHref }}>
|
||||
<EuiLink {...rest} />
|
||||
</EuiReactRouterHelper>
|
||||
);
|
||||
|
@ -61,9 +70,10 @@ export const EuiReactRouterLink: React.FC<TEuiReactRouterLinkProps> = ({
|
|||
export const EuiReactRouterButton: React.FC<TEuiReactRouterButtonProps> = ({
|
||||
to,
|
||||
onClick,
|
||||
shouldNotCreateHref,
|
||||
...rest
|
||||
}) => (
|
||||
<EuiReactRouterHelper to={to} onClick={onClick}>
|
||||
<EuiReactRouterHelper {...{ to, onClick, shouldNotCreateHref }}>
|
||||
<EuiButton {...rest} />
|
||||
</EuiReactRouterHelper>
|
||||
);
|
||||
|
|
|
@ -5,5 +5,8 @@
|
|||
*/
|
||||
|
||||
export { sendTelemetry } from './send_telemetry';
|
||||
export { SendAppSearchTelemetry } from './send_telemetry';
|
||||
export { SendWorkplaceSearchTelemetry } from './send_telemetry';
|
||||
export {
|
||||
SendEnterpriseSearchTelemetry,
|
||||
SendAppSearchTelemetry,
|
||||
SendWorkplaceSearchTelemetry,
|
||||
} from './send_telemetry';
|
||||
|
|
|
@ -10,7 +10,12 @@ import { httpServiceMock } from 'src/core/public/mocks';
|
|||
import { JSON_HEADER as headers } from '../../../../common/constants';
|
||||
import { mountWithKibanaContext } from '../../__mocks__';
|
||||
|
||||
import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './';
|
||||
import {
|
||||
sendTelemetry,
|
||||
SendEnterpriseSearchTelemetry,
|
||||
SendAppSearchTelemetry,
|
||||
SendWorkplaceSearchTelemetry,
|
||||
} from './';
|
||||
|
||||
describe('Shared Telemetry Helpers', () => {
|
||||
const httpMock = httpServiceMock.createSetupContract();
|
||||
|
@ -44,6 +49,17 @@ describe('Shared Telemetry Helpers', () => {
|
|||
});
|
||||
|
||||
describe('React component helpers', () => {
|
||||
it('SendEnterpriseSearchTelemetry component', () => {
|
||||
mountWithKibanaContext(<SendEnterpriseSearchTelemetry action="viewed" metric="page" />, {
|
||||
http: httpMock,
|
||||
});
|
||||
|
||||
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
|
||||
headers,
|
||||
body: '{"product":"enterprise_search","action":"viewed","metric":"page"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('SendAppSearchTelemetry component', () => {
|
||||
mountWithKibanaContext(<SendAppSearchTelemetry action="clicked" metric="button" />, {
|
||||
http: httpMock,
|
||||
|
@ -56,13 +72,13 @@ describe('Shared Telemetry Helpers', () => {
|
|||
});
|
||||
|
||||
it('SendWorkplaceSearchTelemetry component', () => {
|
||||
mountWithKibanaContext(<SendWorkplaceSearchTelemetry action="viewed" metric="page" />, {
|
||||
mountWithKibanaContext(<SendWorkplaceSearchTelemetry action="error" metric="not_found" />, {
|
||||
http: httpMock,
|
||||
});
|
||||
|
||||
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
|
||||
headers,
|
||||
body: '{"product":"workplace_search","action":"viewed","metric":"page"}',
|
||||
body: '{"product":"workplace_search","action":"error","metric":"not_found"}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,9 +35,21 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele
|
|||
|
||||
/**
|
||||
* React component helpers - useful for on-page-load/views
|
||||
* TODO: SendEnterpriseSearchTelemetry
|
||||
*/
|
||||
|
||||
export const SendEnterpriseSearchTelemetry: React.FC<ISendTelemetryProps> = ({
|
||||
action,
|
||||
metric,
|
||||
}) => {
|
||||
const { http } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
useEffect(() => {
|
||||
sendTelemetry({ http, action, metric, product: 'enterprise_search' });
|
||||
}, [action, metric, http]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SendAppSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action, metric }) => {
|
||||
const { http } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
AppMountParameters,
|
||||
HttpSetup,
|
||||
} from 'src/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
FeatureCatalogueCategory,
|
||||
HomePublicPluginSetup,
|
||||
|
@ -52,6 +51,25 @@ export class EnterpriseSearchPlugin implements Plugin {
|
|||
}
|
||||
|
||||
public setup(core: CoreSetup, plugins: PluginsSetup) {
|
||||
core.application.register({
|
||||
id: ENTERPRISE_SEARCH_PLUGIN.ID,
|
||||
title: ENTERPRISE_SEARCH_PLUGIN.NAV_TITLE,
|
||||
appRoute: ENTERPRISE_SEARCH_PLUGIN.URL,
|
||||
category: DEFAULT_APP_CATEGORIES.enterpriseSearch,
|
||||
mount: async (params: AppMountParameters) => {
|
||||
const [coreStart] = await core.getStartServices();
|
||||
const { chrome } = coreStart;
|
||||
chrome.docTitle.change(ENTERPRISE_SEARCH_PLUGIN.NAME);
|
||||
|
||||
await this.getInitialData(coreStart.http);
|
||||
|
||||
const { renderApp } = await import('./applications');
|
||||
const { EnterpriseSearch } = await import('./applications/enterprise_search');
|
||||
|
||||
return renderApp(EnterpriseSearch, params, coreStart, plugins, this.config, this.data);
|
||||
},
|
||||
});
|
||||
|
||||
core.application.register({
|
||||
id: APP_SEARCH_PLUGIN.ID,
|
||||
title: APP_SEARCH_PLUGIN.NAME,
|
||||
|
@ -94,22 +112,10 @@ export class EnterpriseSearchPlugin implements Plugin {
|
|||
plugins.home.featureCatalogue.registerSolution({
|
||||
id: ENTERPRISE_SEARCH_PLUGIN.ID,
|
||||
title: ENTERPRISE_SEARCH_PLUGIN.NAME,
|
||||
subtitle: i18n.translate('xpack.enterpriseSearch.featureCatalogue.subtitle', {
|
||||
defaultMessage: 'Search everything',
|
||||
}),
|
||||
subtitle: ENTERPRISE_SEARCH_PLUGIN.SUBTITLE,
|
||||
icon: 'logoEnterpriseSearch',
|
||||
descriptions: [
|
||||
i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription1', {
|
||||
defaultMessage: 'Build a powerful search experience.',
|
||||
}),
|
||||
i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription2', {
|
||||
defaultMessage: 'Connect your users to relevant data.',
|
||||
}),
|
||||
i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription3', {
|
||||
defaultMessage: 'Unify your team content.',
|
||||
}),
|
||||
],
|
||||
path: APP_SEARCH_PLUGIN.URL, // TODO: Change this to enterprise search overview page once available
|
||||
descriptions: ENTERPRISE_SEARCH_PLUGIN.DESCRIPTIONS,
|
||||
path: ENTERPRISE_SEARCH_PLUGIN.URL,
|
||||
});
|
||||
|
||||
plugins.home.featureCatalogue.register({
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { mockLogger } from '../../__mocks__';
|
||||
|
||||
import { registerTelemetryUsageCollector } from './telemetry';
|
||||
|
||||
describe('Enterprise Search Telemetry Usage Collector', () => {
|
||||
const makeUsageCollectorStub = jest.fn();
|
||||
const registerStub = jest.fn();
|
||||
const usageCollectionMock = {
|
||||
makeUsageCollector: makeUsageCollectorStub,
|
||||
registerCollector: registerStub,
|
||||
} as any;
|
||||
|
||||
const savedObjectsRepoStub = {
|
||||
get: () => ({
|
||||
attributes: {
|
||||
'ui_viewed.overview': 10,
|
||||
'ui_clicked.app_search': 2,
|
||||
'ui_clicked.workplace_search': 3,
|
||||
},
|
||||
}),
|
||||
incrementCounter: jest.fn(),
|
||||
};
|
||||
const savedObjectsMock = {
|
||||
createInternalRepository: jest.fn(() => savedObjectsRepoStub),
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerTelemetryUsageCollector', () => {
|
||||
it('should make and register the usage collector', () => {
|
||||
registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
|
||||
|
||||
expect(registerStub).toHaveBeenCalledTimes(1);
|
||||
expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1);
|
||||
expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('enterprise_search');
|
||||
expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTelemetryMetrics', () => {
|
||||
it('should return existing saved objects data', async () => {
|
||||
registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
|
||||
const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
|
||||
|
||||
expect(savedObjectsCounts).toEqual({
|
||||
ui_viewed: {
|
||||
overview: 10,
|
||||
},
|
||||
ui_clicked: {
|
||||
app_search: 2,
|
||||
workplace_search: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a default telemetry object if no saved data exists', async () => {
|
||||
const emptySavedObjectsMock = {
|
||||
createInternalRepository: () => ({
|
||||
get: () => ({ attributes: null }),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger);
|
||||
const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
|
||||
|
||||
expect(savedObjectsCounts).toEqual({
|
||||
ui_viewed: {
|
||||
overview: 0,
|
||||
},
|
||||
ui_clicked: {
|
||||
app_search: 0,
|
||||
workplace_search: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { get } from 'lodash';
|
||||
import { SavedObjectsServiceStart, Logger } from 'src/core/server';
|
||||
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
||||
|
||||
import { getSavedObjectAttributesFromRepo } from '../lib/telemetry';
|
||||
|
||||
interface ITelemetry {
|
||||
ui_viewed: {
|
||||
overview: number;
|
||||
};
|
||||
ui_clicked: {
|
||||
app_search: number;
|
||||
workplace_search: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ES_TELEMETRY_NAME = 'enterprise_search_telemetry';
|
||||
|
||||
/**
|
||||
* Register the telemetry collector
|
||||
*/
|
||||
|
||||
export const registerTelemetryUsageCollector = (
|
||||
usageCollection: UsageCollectionSetup,
|
||||
savedObjects: SavedObjectsServiceStart,
|
||||
log: Logger
|
||||
) => {
|
||||
const telemetryUsageCollector = usageCollection.makeUsageCollector<ITelemetry>({
|
||||
type: 'enterprise_search',
|
||||
fetch: async () => fetchTelemetryMetrics(savedObjects, log),
|
||||
isReady: () => true,
|
||||
schema: {
|
||||
ui_viewed: {
|
||||
overview: { type: 'long' },
|
||||
},
|
||||
ui_clicked: {
|
||||
app_search: { type: 'long' },
|
||||
workplace_search: { type: 'long' },
|
||||
},
|
||||
},
|
||||
});
|
||||
usageCollection.registerCollector(telemetryUsageCollector);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the aggregated telemetry metrics from our saved objects
|
||||
*/
|
||||
|
||||
const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => {
|
||||
const savedObjectsRepository = savedObjects.createInternalRepository();
|
||||
const savedObjectAttributes = await getSavedObjectAttributesFromRepo(
|
||||
ES_TELEMETRY_NAME,
|
||||
savedObjectsRepository,
|
||||
log
|
||||
);
|
||||
|
||||
const defaultTelemetrySavedObject: ITelemetry = {
|
||||
ui_viewed: {
|
||||
overview: 0,
|
||||
},
|
||||
ui_clicked: {
|
||||
app_search: 0,
|
||||
workplace_search: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// If we don't have an existing/saved telemetry object, return the default
|
||||
if (!savedObjectAttributes) {
|
||||
return defaultTelemetrySavedObject;
|
||||
}
|
||||
|
||||
return {
|
||||
ui_viewed: {
|
||||
overview: get(savedObjectAttributes, 'ui_viewed.overview', 0),
|
||||
},
|
||||
ui_clicked: {
|
||||
app_search: get(savedObjectAttributes, 'ui_clicked.app_search', 0),
|
||||
workplace_search: get(savedObjectAttributes, 'ui_clicked.workplace_search', 0),
|
||||
},
|
||||
} as ITelemetry;
|
||||
};
|
|
@ -15,7 +15,7 @@ import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
|
|||
|
||||
import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry';
|
||||
|
||||
describe('App Search Telemetry Usage Collector', () => {
|
||||
describe('Telemetry helpers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
|
|
@ -31,8 +31,10 @@ import {
|
|||
IEnterpriseSearchRequestHandler,
|
||||
} from './lib/enterprise_search_request_handler';
|
||||
|
||||
import { registerConfigDataRoute } from './routes/enterprise_search/config_data';
|
||||
import { enterpriseSearchTelemetryType } from './saved_objects/enterprise_search/telemetry';
|
||||
import { registerTelemetryUsageCollector as registerESTelemetryUsageCollector } from './collectors/enterprise_search/telemetry';
|
||||
import { registerTelemetryRoute } from './routes/enterprise_search/telemetry';
|
||||
import { registerConfigDataRoute } from './routes/enterprise_search/config_data';
|
||||
|
||||
import { appSearchTelemetryType } from './saved_objects/app_search/telemetry';
|
||||
import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry';
|
||||
|
@ -81,8 +83,12 @@ export class EnterpriseSearchPlugin implements Plugin {
|
|||
name: ENTERPRISE_SEARCH_PLUGIN.NAME,
|
||||
order: 0,
|
||||
icon: 'logoEnterpriseSearch',
|
||||
navLinkId: APP_SEARCH_PLUGIN.ID, // TODO - remove this once functional tests no longer rely on navLinkId
|
||||
app: ['kibana', APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID],
|
||||
app: [
|
||||
'kibana',
|
||||
ENTERPRISE_SEARCH_PLUGIN.ID,
|
||||
APP_SEARCH_PLUGIN.ID,
|
||||
WORKPLACE_SEARCH_PLUGIN.ID,
|
||||
],
|
||||
catalogue: [ENTERPRISE_SEARCH_PLUGIN.ID, APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID],
|
||||
privileges: null,
|
||||
});
|
||||
|
@ -94,14 +100,16 @@ export class EnterpriseSearchPlugin implements Plugin {
|
|||
const dependencies = { config, security, request, log };
|
||||
|
||||
const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies);
|
||||
const showEnterpriseSearchOverview = hasAppSearchAccess || hasWorkplaceSearchAccess;
|
||||
|
||||
return {
|
||||
navLinks: {
|
||||
enterpriseSearch: showEnterpriseSearchOverview,
|
||||
appSearch: hasAppSearchAccess,
|
||||
workplaceSearch: hasWorkplaceSearchAccess,
|
||||
},
|
||||
catalogue: {
|
||||
enterpriseSearch: hasAppSearchAccess || hasWorkplaceSearchAccess,
|
||||
enterpriseSearch: showEnterpriseSearchOverview,
|
||||
appSearch: hasAppSearchAccess,
|
||||
workplaceSearch: hasWorkplaceSearchAccess,
|
||||
},
|
||||
|
@ -123,6 +131,7 @@ export class EnterpriseSearchPlugin implements Plugin {
|
|||
/**
|
||||
* Bootstrap the routes, saved objects, and collector for telemetry
|
||||
*/
|
||||
savedObjects.registerType(enterpriseSearchTelemetryType);
|
||||
savedObjects.registerType(appSearchTelemetryType);
|
||||
savedObjects.registerType(workplaceSearchTelemetryType);
|
||||
let savedObjectsStarted: SavedObjectsServiceStart;
|
||||
|
@ -131,6 +140,7 @@ export class EnterpriseSearchPlugin implements Plugin {
|
|||
savedObjectsStarted = coreStart.savedObjects;
|
||||
|
||||
if (usageCollection) {
|
||||
registerESTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
|
||||
registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
|
||||
registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
|
||||
}
|
||||
|
|
|
@ -9,12 +9,13 @@ import { schema } from '@kbn/config-schema';
|
|||
import { IRouteDependencies } from '../../plugin';
|
||||
import { incrementUICounter } from '../../collectors/lib/telemetry';
|
||||
|
||||
import { ES_TELEMETRY_NAME } from '../../collectors/enterprise_search/telemetry';
|
||||
import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry';
|
||||
import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry';
|
||||
const productToTelemetryMap = {
|
||||
enterprise_search: ES_TELEMETRY_NAME,
|
||||
app_search: AS_TELEMETRY_NAME,
|
||||
workplace_search: WS_TELEMETRY_NAME,
|
||||
enterprise_search: 'TODO',
|
||||
};
|
||||
|
||||
export function registerTelemetryRoute({
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { SavedObjectsType } from 'src/core/server';
|
||||
import { ES_TELEMETRY_NAME } from '../../collectors/enterprise_search/telemetry';
|
||||
|
||||
export const enterpriseSearchTelemetryType: SavedObjectsType = {
|
||||
name: ES_TELEMETRY_NAME,
|
||||
hidden: false,
|
||||
namespaceType: 'agnostic',
|
||||
mappings: {
|
||||
dynamic: false,
|
||||
properties: {},
|
||||
},
|
||||
};
|
|
@ -51,6 +51,27 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"enterprise_search": {
|
||||
"properties": {
|
||||
"ui_viewed": {
|
||||
"properties": {
|
||||
"overview": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui_clicked": {
|
||||
"properties": {
|
||||
"app_search": {
|
||||
"type": "long"
|
||||
},
|
||||
"workplace_search": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"workplace_search": {
|
||||
"properties": {
|
||||
"ui_viewed": {
|
||||
|
|
|
@ -49,7 +49,13 @@ export default function navLinksTests({ getService }: FtrProviderContext) {
|
|||
expect(uiCapabilities.success).to.be(true);
|
||||
expect(uiCapabilities.value).to.have.property('navLinks');
|
||||
expect(uiCapabilities.value!.navLinks).to.eql(
|
||||
navLinksBuilder.except('ml', 'monitoring', 'appSearch', 'workplaceSearch')
|
||||
navLinksBuilder.except(
|
||||
'ml',
|
||||
'monitoring',
|
||||
'enterpriseSearch',
|
||||
'appSearch',
|
||||
'workplaceSearch'
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'foo_all':
|
||||
|
|
Loading…
Reference in a new issue