[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:
Constance 2020-09-09 12:53:51 -07:00 committed by GitHub
parent 87ca6ff70c
commit d67a421e68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 868 additions and 61 deletions

View file

@ -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,

View file

@ -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": {

View file

@ -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/',
};

View file

@ -18,6 +18,10 @@ export interface IInitialAppData {
ilmEnabled?: boolean;
isFederatedAuth?: boolean;
configuredLimits?: IConfiguredLimits;
access?: {
hasAppSearchAccess: boolean;
hasWorkplaceSearchAccess: boolean;
};
appSearch?: IAppSearchAccount;
workplaceSearch?: IWorkplaceSearchInitialData;
}

View file

@ -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

View file

@ -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';

View file

@ -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;
}
}
}

View file

@ -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' })
);
});
});

View file

@ -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>
}
/>
);
};

View file

@ -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%;
}
}

View file

@ -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);
});
});
});

View file

@ -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>
);
};

View file

@ -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),
},
]);

View file

@ -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]);

View file

@ -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(' - ');

View file

@ -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';

View file

@ -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" />);

View file

@ -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;

View file

@ -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" />);

View file

@ -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>
);

View file

@ -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';

View file

@ -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"}',
});
});
});

View file

@ -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;

View file

@ -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({

View file

@ -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,
},
});
});
});
});

View file

@ -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;
};

View file

@ -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();
});

View file

@ -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);
}

View file

@ -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({

View file

@ -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: {},
},
};

View file

@ -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": {

View file

@ -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':