Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
*/

import '../../../__mocks__/kea.mock';
import '../../../__mocks__/shallow_usecontext.mock';

import React from 'react';
import React, { useContext } from 'react';
import { shallow } from 'enzyme';

import { EuiCard } from '@elastic/eui';
Expand All @@ -26,6 +27,7 @@ describe('ProductCard', () => {
});

it('renders an App Search card', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: 'localhost' } }));
const wrapper = shallow(<ProductCard product={APP_SEARCH_PLUGIN} image="as.jpg" />);
const card = wrapper.find(EuiCard).dive().shallow();

Expand All @@ -35,12 +37,14 @@ describe('ProductCard', () => {
const button = card.find(EuiButton);
expect(button.prop('to')).toEqual('/app/enterprise_search/app_search');
expect(button.prop('data-test-subj')).toEqual('LaunchAppSearchButton');
expect(button.prop('children')).toEqual('Launch App Search');

button.simulate('click');
expect(sendTelemetry).toHaveBeenCalledWith(expect.objectContaining({ metric: 'app_search' }));
});

it('renders a Workplace Search card', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: 'localhost' } }));
const wrapper = shallow(<ProductCard product={WORKPLACE_SEARCH_PLUGIN} image="ws.jpg" />);
const card = wrapper.find(EuiCard).dive().shallow();

Expand All @@ -50,10 +54,22 @@ describe('ProductCard', () => {
const button = card.find(EuiButton);
expect(button.prop('to')).toEqual('/app/enterprise_search/workplace_search');
expect(button.prop('data-test-subj')).toEqual('LaunchWorkplaceSearchButton');
expect(button.prop('children')).toEqual('Launch Workplace Search');

button.simulate('click');
expect(sendTelemetry).toHaveBeenCalledWith(
expect.objectContaining({ metric: 'workplace_search' })
);
});

it('renders correct button text when host not present', () => {
(useContext as jest.Mock).mockImplementation(() => ({ config: { host: '' } }));

const wrapper = shallow(<ProductCard product={WORKPLACE_SEARCH_PLUGIN} image="ws.jpg" />);
const card = wrapper.find(EuiCard).dive().shallow();
const button = card.find(EuiButton);

expect(button.prop('data-test-subj')).toEqual('SetupWorkplaceSearchButton');
expect(button.prop('children')).toEqual('Setup Workplace Search');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import React, { useContext } from 'react';
import { useValues } from 'kea';
import upperFirst from 'lodash/upperFirst';
import snakeCase from 'lodash/snakeCase';
import { i18n } from '@kbn/i18n';
import { EuiCard, EuiTextColor } from '@elastic/eui';

import { KibanaContext, IKibanaContext } from '../../../index';

import { EuiButton } from '../../../shared/react_router_helpers';
import { sendTelemetry } from '../../../shared/telemetry';
import { HttpLogic } from '../../../shared/http';
Expand All @@ -30,6 +32,25 @@ interface IProductCard {

export const ProductCard: React.FC<IProductCard> = ({ product, image }) => {
const { http } = useValues(HttpLogic);
const {
config: { host },
} = useContext(KibanaContext) as IKibanaContext;

const LAUNCH_BUTTON_TEXT = i18n.translate(
'xpack.enterpriseSearch.overview.productCard.launchButton',
{
defaultMessage: 'Launch {productName}',
values: { productName: product.NAME },
}
);

const SETUP_BUTTON_TEXT = i18n.translate(
'xpack.enterpriseSearch.overview.productCard.setupButton',
{
defaultMessage: 'Setup {productName}',
values: { productName: product.NAME },
}
);

return (
<EuiCard
Expand Down Expand Up @@ -59,12 +80,8 @@ export const ProductCard: React.FC<IProductCard> = ({ product, image }) => {
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 },
})}
{host ? LAUNCH_BUTTON_TEXT : SETUP_BUTTON_TEXT}
</EuiButton>
}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { ProductSelector } from './product_selector';
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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 '../../../__mocks__/shallow_usecontext.mock';

import React, { useContext } from 'react';
import { shallow } from 'enzyme';
import { EuiPage } from '@elastic/eui';

import '../../../__mocks__/kea.mock';
import { useValues } from 'kea';

import { ProductSelector } from './';
import { ProductCard } from '../product_card';

describe('ProductSelector', () => {
beforeEach(() => {
(useValues as jest.Mock).mockReturnValue({ errorConnecting: false });
});

it('renders the overview page and product cards with no host set', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: '' } }));
const wrapper = shallow(<ProductSelector access={{}} />);

expect(wrapper.find(EuiPage).hasClass('enterpriseSearchOverview')).toBe(true);
expect(wrapper.find(ProductCard)).toHaveLength(2);
});

describe('access checks when host is set', () => {
beforeEach(() => {
(useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: 'localhost' } }));
});
it('does not render the App Search card if the user does not have access to AS', () => {
const wrapper = shallow(
<ProductSelector 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(
<ProductSelector 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(<ProductSelector access={{}} />);

expect(wrapper.find(ProductCard)).toHaveLength(0);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* 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.
*/
/*
* 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 {
EuiPage,
EuiPageBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiPageContentBody,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';

import { KibanaContext, IKibanaContext } from '../../../index';

import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants';

import { SetEnterpriseSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we alias this? I feel like it confuses things a bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying Constance's pattern from other components. If we want to change IMO that is beyond the scope of this PR and we should discuss as a team and change all in a separate PR

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey! This pattern's on me (also one we use in multiple places, e.g., with telemetry, so def open to discussing and refactoring separately from this PR).

Most of these components Set<Product>Chrome, Set<Product>Telemetry are a very very light wrapper around what's essentially a generic function with a product key passed in. Examples:

I thought it would feel and read more nicely from a dev experience perspective - for example, if you're an App Search dev you know you're already working in the App Search plugin, so no need to be overly specific when calling the component - just rename it to a more generic <SetChrome /> component, or <SendTelemetry />, etc. without having to repeat App Search or pass the app search ID again and again.

Definitely happy to discuss the overall pattern and if you have any thoughts on refactoring, but would very likely be in a separate PR if that sounds cool!

import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';

import { ProductCard } from '../product_card';

import AppSearchImage from '../../assets/app_search.png';
import WorkplaceSearchImage from '../../assets/workplace_search.png';

interface IProductSelectorProps {
access: {
hasAppSearchAccess?: boolean;
hasWorkplaceSearchAccess?: boolean;
};
}

export const ProductSelector: React.FC<IProductSelectorProps> = ({ access }) => {
const { hasAppSearchAccess, hasWorkplaceSearchAccess } = access;
const {
config: { host },
} = useContext(KibanaContext) as IKibanaContext;

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">
{(!host || hasAppSearchAccess) && (
<EuiFlexItem grow={false} className="enterpriseSearchOverview__card">
<ProductCard product={APP_SEARCH_PLUGIN} image={AppSearchImage} />
</EuiFlexItem>
)}
{(!host || hasWorkplaceSearchAccess) && (
<EuiFlexItem grow={false} className="enterpriseSearchOverview__card">
<ProductCard product={WORKPLACE_SEARCH_PLUGIN} image={WorkplaceSearchImage} />
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer />
</EuiPageContentBody>
</EuiPageBody>
</EuiPage>
);
};
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 { SetupGuide } from './setup_guide';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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 { SetEnterpriseSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel we should just rename shared/setup_guide to shared/setup_guide_layout.tsx and export SetupGuideLayout. It's confusing having a file named one way but used another way everywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying Constance's pattern from other components. If we want to change IMO that is beyond the scope of this PR and we should discuss as a team and change all in a separate PR

Copy link
Contributor

@cee-chen cee-chen Sep 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one's on me again, and almost definitely an alias we don't need 🤔 Historical context for this shenanigans:

  1. The (very early) MVP only had a single <SetupGuide /> component
  2. When WS was added, I refactored out the instructions part of the Setup Guide to a reusable component, and left it named as SetupGuide and just alias'ed it
  3. Here we are today and I look like a fool

EDIT: Went back and actually re-looked at what I was doing, I'm +1 for just renaming this SetupGuideLayout or similar. Thanks @JasonStoltz!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ Sorry, to clarify - in a separate PR of course

import { SetupGuide } from './';

describe('SetupGuide', () => {
it('renders', () => {
const wrapper = shallow(<SetupGuide />);

expect(wrapper.find(SetupGuideLayout)).toHaveLength(1);
expect(wrapper.find(SetPageChrome)).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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 { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';

import { ENTERPRISE_SEARCH_PLUGIN } from '../../../../../common/constants';
import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
import { SetEnterpriseSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import GettingStarted from './assets/getting_started.png';

export const SetupGuide: React.FC = () => (
<SetupGuideLayout
productName={ENTERPRISE_SEARCH_PLUGIN.NAME}
productEuiIcon="logoEnterpriseSearch"
standardAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-standard"
elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm"
>
<SetPageChrome
text={i18n.translate('xpack.enterpriseSearch.setupGuide.title', {
defaultMessage: 'Setup Guide',
})}
/>
<SendTelemetry action="viewed" metric="setup_guide" />

<a href="https://www.elastic.co/enterprise-search" target="_blank" rel="noopener noreferrer">
<img
className="setupGuide__thumbnail"
src={GettingStarted}
alt={i18n.translate('xpack.enterpriseSearch.enterpriseSearch.setupGuide.videoAlt', {
defaultMessage: 'Getting started with Enterprise Search',
})}
width="1280"
height-="720"
/>
</a>

<EuiTitle size="s">
<p>
<FormattedMessage
id="xpack.enterpriseSearch.enterpriseSearch.setupGuide.description"
defaultMessage="Search everything, anywhere. Easily implement powerful, modern search experiences for your busy team. Quickly add pre-tuned search to your website, app, or workplace. Search it all, simply."
/>
</p>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText>
<p>
<FormattedMessage
id="xpack.enterpriseSearch.enterpriseSearch.setupGuide.notConfigured"
defaultMessage="Enterprise Search is not configured in your Kibana instance yet."
/>
</p>
</EuiText>
</SetupGuideLayout>
);
Loading