Workplace Search in Kibana MVP (#70979)

* Add Workplace Search plugin to app

- Adds telemetry for Workplace Search
- Adds routing for telemetry and overview
- Registers plugin

* Add breadcrumbs for Workplace Search

* Add Workplace Search index

* Add route paths, types and shared assets

* Add shared Workplace Search components

* Add setup guide to Workplace Search

* Add error state to Workplace Search

* Add Workplace Search overview

This is the functional MVP for Workplace Search

* Update telemetry per recent changes

- Remove saved objects indexing
- add schema definition
- remove no_ws_account
- minor cleanup

* Fix pluralization syntax

- Still not working but fixed the syntax nonetheless

* Change pluralization method

- Was unable to get the `FormattedMessage` to work using the syntax in the docs. Always added ‘more’, even when there were zero (or one for users). This commit uses an alternative approach that works

* Update readme

* Fix duplicate i18n label

* Fix failing test from previous commit

🤦

* Update link for image in Setup Guide

* Remove need for hash in routes

Because of a change in the Workplace Search rails code, we can now use non-hash routes that will be redirected by rails so that we don’t have users stuck on the overview page in Workplace Search when logging in

* Directly link to source details from activity feed

Previously the dashboard in legacy Workplace Search linked to the sources page and this was replicated in the Kibana MVP. This PR aligns with the legacy dashboard directly linking to the source details

https://github.com/elastic/ent-search/pull/1688

* Add warn logging to Workplace Search telemetry collector

* Change casing to camel to match App Search

* Misc security feedback for Workplace Search

* Update licence mocks to match App Search

* PR feedback from App Search PR

* REmove duplicate code from merge conflict

* Fix tests

* Move varible declaration inside map for TypeScript

There was no other way 🤦

* Refactor last commit

* Add punctuation

Smallest commit ever.

* Fix actionPath type errors

* Update rebase feedback

* Fix failing test

* Update telemetry test after AS PR feedback

* DRY out error state prompt copy

* DRY out telemetry endpoint into a single route + DRY out

DRY out endpoint
- Instead of /api/app_search/telemetry & /api/workplace_search/telemetry, just have a single /api/enterprise_search/telemetry endpoint that takes a product param
- Update public/send_telemetry accordingly (+ write tests for SendWorkplaceSearchTelemetry)

DRY out helpers
- Pull out certain reusable helper functions into a shared lib/ folder and have them take the repo id/name as a param
- Move tests over
- Remove misplaced comment block

+BONUS
- pull out content type header that's been giving us grief in Chrome into a constant

* Remove unused telemetry type

* Minor server cleanup - DRY out mockLogger

* Setup Guide cleanup

* Clean up Loading component

- use EUI vars per feedback
- remove unnecessary wrapper
- adjust vh for Kibana layout
- Actually apply loadingSpinner styles

* Misc i18n fixes

+ minor newline reduction, because prettier lets me

* Refactor Recent Activity component/styles

- Remove table markup/styles - not semantically correct or accessible in this case - replace w flex
- Fix link colors not inheriting
- Add EuiPanel, error colors looked odd against page background
- Fix prop/type definition
- CSS cleanup - EUI vars, correct BEM, don't target generic selectors

* [Opinionated] Refactor RecentActivity component

- Pull out iterated activity items into a child subcomponent
- Move constants/strings closer to where they're being used, instead of having to jump around the file
- Move IActivityFeed definition to this file, since that's primarily where it's used

@scottybollinger - if you're not a fan of this commit no worries, just let me know and we can discuss/roll back as needed

* Refactor ViewContentHeader

- remove unused CSS
- fallback cleanup
- refactor tests

* Refactor ContentSection

- Remove unused CSS classes
- Refactor tests to include all props/more specific assertions

* Refactor StatisticCard

- Prefer using EuiTextColor to spans / custom classes
- Prefer using EuiCard's native `href` behavior over using our own wrapping link/--isClickablec class
- Note that when we port the link/destination over to React Router, we should instead opt to use React Router history, which will involve creating a EuiCard helper
- Make test a bit more specific

* Minor OrganizationStats cleanup

- Use EuiFlexGrid

* Refactor OnboardingSteps

- i18n
    - Compact i18n newlines (nit)
    - Convert FormattedMessage to i18n.translate for easier test assertions
- Org Name CTA
    - Move to separate child subcomponent to make it easier to quickly skim the parent container
    - Remove unused CSS class
    - Fix/add responsive behavior

- Tests refactor
    - Use describe() blocks to break up tests by card/section
    - Make sure each card has tests for each state - zero, some/complete, and disabled/no access
    - Assert by plain text now that we're using i18n.translate()
    - Remove ContentSection/EuiPanel assertions - they're not terribly useful, and we have more specific elements to check
    - Add accounts={0} test to satisfy yellow branch line

* Clean up OnboardingCard

- Remove unused CSS class
- Remove unnecessary template literal

Tests
- Swap out check for EuiFlexItem - it's not really the content we're concerned about displaying, EuiEmptyPrompt is the primary component
- Remove need for mount() by dive()ing into EuiEmptyPrompt (this also removes the need to specify a[data-test-subj] instead of just [data-test-subj])
- Simplify empty button test - previous test has already checked for href/telemetry
- Cover uncovered actionPath branch line

* Minor Overview cleanup

- Remove unused telemetry type
- Remove unused CSS class
- finally
- Remove unused license context from tests

* Feedback: UI fixes

- Fix setup guide CSS class casing
- Remove border transparent (UX > UI)

* Fix Workplace Search not being hidden on feature control

- Whoops, totally missed this 🤦

* Add very basic functional Workplace Search test

- Has to be without_host_configured, since with host requires Enterprise Search
- Just checks for basic Setup Guide redirect for now
- TODO: Add more in-depth feature/privilege functional tests for both plugins at later date

* Pay down test render/loading tech debt

- Turns out you don't need render(), shallow() skips useEffect already 🤦
- Fix outdated comment import example

* DRY out repeated mountWithApiMock into mountWithAsyncContext

+ Minor engines_overview test refactors:
    - Prefer to define `const wrapper` at the start of each test rather than a `let wrapper` - this better for sandboxing / not leaking state between tests
    - Move Platinum license tests above pagination, so the contrast between the two tests are easier to grok

* Design feedback

- README copy tweak + linting
- Remove unused euiCard classes from onboarding card

Co-authored-by: Constance Chen <constance.chen.3@gmail.com>
This commit is contained in:
Scotty Bollinger 2020-07-13 13:10:35 -05:00 committed by GitHub
parent c82ccfedc6
commit 41c4f18b89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 2909 additions and 322 deletions

View file

@ -2,7 +2,10 @@
## Overview
This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness.
This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In it's current MVP state, the plugin provides the following with the goal of gathering user feedback and raising product awareness:
- **App Search:** A basic engines overview with links into the product.
- **Workplace Search:** A simple app overview with basic statistics, links to the sources, users (if standard auth), and product settings.
## Development

View file

@ -4,4 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const JSON_HEADER = { 'Content-Type': 'application/json' }; // This needs specific casing or Chrome throws a 415 error
export const ENGINES_PAGE_SIZE = 10;

View file

@ -7,7 +7,11 @@
export { mockHistory } from './react_router_history.mock';
export { mockKibanaContext } from './kibana_context.mock';
export { mockLicenseContext } from './license_context.mock';
export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock';
export {
mountWithContext,
mountWithKibanaContext,
mountWithAsyncContext,
} from './mount_with_context.mock';
export { shallowWithIntl } from './shallow_with_i18n.mock';
// Note: shallow_usecontext must be imported directly as a file

View file

@ -5,7 +5,8 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { mount, ReactWrapper } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
import { KibanaContext } from '../';
@ -47,3 +48,33 @@ export const mountWithKibanaContext = (children: React.ReactNode, context?: obje
</KibanaContext.Provider>
);
};
/**
* This helper is intended for components that have async effects
* (e.g. http fetches) on mount. It mostly adds act/update boilerplate
* that's needed for the wrapper to play nice with Enzyme/Jest
*
* Example usage:
*
* const wrapper = mountWithAsyncContext(<Component />, { http: { get: () => someData } });
*/
export const mountWithAsyncContext = async (
children: React.ReactNode,
context: object
): Promise<ReactWrapper> => {
let wrapper: ReactWrapper | undefined;
// We get a lot of act() warning/errors in the terminal without this.
// TBH, I don't fully understand why since Enzyme's mount is supposed to
// have act() baked in - could be because of the wrapping context provider?
await act(async () => {
wrapper = mountWithContext(children, context);
});
if (wrapper) {
wrapper.update(); // This seems to be required for the DOM to actually update
return wrapper;
} else {
throw new Error('Could not mount wrapper');
}
};

View file

@ -19,7 +19,7 @@ jest.mock('react', () => ({
/**
* Example usage within a component test using shallow():
*
* import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed
* import '../../../__mocks__/shallow_usecontext'; // Must come before React's import, adjust relative path as needed
*
* import React from 'react';
* import { shallow } from 'enzyme';

View file

@ -9,6 +9,7 @@ import '../../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui';
import { ErrorStatePrompt } from '../../../shared/error_state';
jest.mock('../../../shared/telemetry', () => ({
sendTelemetry: jest.fn(),
@ -22,7 +23,7 @@ describe('ErrorState', () => {
it('renders', () => {
const wrapper = shallow(<ErrorState />);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1);
});
});

View file

@ -4,21 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext } from 'react';
import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui';
import { EuiButton } from '../../../shared/react_router_helpers';
import { ErrorStatePrompt } from '../../../shared/error_state';
import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../index';
import { EngineOverviewHeader } from '../engine_overview_header';
import './empty_states.scss';
export const ErrorState: React.FC = () => {
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
return (
<EuiPage restrictWidth>
<SetBreadcrumbs isRoot />
@ -26,68 +22,8 @@ export const ErrorState: React.FC = () => {
<EuiPageBody>
<EngineOverviewHeader isButtonDisabled />
<EuiPageContent className="emptyState">
<EuiEmptyPrompt
className="emptyState__prompt"
iconType="alert"
iconColor="danger"
title={
<h2>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.errorConnectingState.title"
defaultMessage="Unable to connect"
/>
</h2>
}
titleSize="l"
body={
<>
<p>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description1"
defaultMessage="We cant establish a connection to App Search at the host URL: {enterpriseSearchUrl}"
values={{
enterpriseSearchUrl: <EuiCode>{enterpriseSearchUrl}</EuiCode>,
}}
/>
</p>
<ol className="eui-textLeft">
<li>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description2"
defaultMessage="Ensure the host URL is configured correctly in {configFile}."
values={{
configFile: <EuiCode>config/kibana.yml</EuiCode>,
}}
/>
</li>
<li>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description3"
defaultMessage="Confirm that the App Search server is responsive."
/>
</li>
<li>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description4"
defaultMessage="Review the Setup guide or check your server log for {pluginLog} log messages."
values={{
pluginLog: <EuiCode>[enterpriseSearch][plugins]</EuiCode>,
}}
/>
</li>
</ol>
</>
}
actions={
<EuiButton iconType="help" fill to="/setup_guide">
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.errorConnectingState.setupGuideCta"
defaultMessage="Review setup guide"
/>
</EuiButton>
}
/>
<EuiPageContent>
<ErrorStatePrompt />
</EuiPageContent>
</EuiPageBody>
</EuiPage>

View file

@ -8,51 +8,45 @@ import '../../../__mocks__/react_router_history.mock';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { render, ReactWrapper } from 'enzyme';
import { shallow, ReactWrapper } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
import { KibanaContext } from '../../../';
import { LicenseContext } from '../../../shared/licensing';
import { mountWithContext, mockKibanaContext } from '../../../__mocks__';
import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__';
import { EmptyState, ErrorState } from '../empty_states';
import { EngineTable, IEngineTablePagination } from './engine_table';
import { LoadingState, EmptyState, ErrorState } from '../empty_states';
import { EngineTable } from './engine_table';
import { EngineOverview } from './';
describe('EngineOverview', () => {
const mockHttp = mockKibanaContext.http;
describe('non-happy-path states', () => {
it('isLoading', () => {
// We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect)
// TODO: Consider pulling this out to a renderWithContext mock/helper
const wrapper: Cheerio = render(
<I18nProvider>
<KibanaContext.Provider value={{ http: {} }}>
<LicenseContext.Provider value={{ license: {} }}>
<EngineOverview />
</LicenseContext.Provider>
</KibanaContext.Provider>
</I18nProvider>
);
const wrapper = shallow(<EngineOverview />);
// render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly
expect(wrapper.find('.euiLoadingContent')).toHaveLength(2);
expect(wrapper.find(LoadingState)).toHaveLength(1);
});
it('isEmpty', async () => {
const wrapper = await mountWithApiMock({
get: () => ({
results: [],
meta: { page: { total_results: 0 } },
}),
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: {
...mockHttp,
get: () => ({
results: [],
meta: { page: { total_results: 0 } },
}),
},
});
expect(wrapper.find(EmptyState)).toHaveLength(1);
});
it('hasErrorConnecting', async () => {
const wrapper = await mountWithApiMock({
get: () => ({ invalidPayload: true }),
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: {
...mockHttp,
get: () => ({ invalidPayload: true }),
},
});
expect(wrapper.find(ErrorState)).toHaveLength(1);
});
@ -78,17 +72,17 @@ describe('EngineOverview', () => {
},
};
const mockApi = jest.fn(() => mockedApiResponse);
let wrapper: ReactWrapper;
beforeAll(async () => {
wrapper = await mountWithApiMock({ get: mockApi });
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', () => {
it('renders and calls the engines API', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
});
expect(wrapper.find(EngineTable)).toHaveLength(1);
});
it('calls the engines API', () => {
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', {
query: {
type: 'indexed',
@ -97,45 +91,14 @@ describe('EngineOverview', () => {
});
});
describe('pagination', () => {
const getTablePagination: () => IEngineTablePagination = () =>
wrapper.find(EngineTable).first().prop('pagination');
it('passes down page data from the API', () => {
const pagination = getTablePagination();
expect(pagination.totalEngines).toEqual(100);
expect(pagination.pageIndex).toEqual(0);
});
it('re-polls the API on page change', async () => {
await act(async () => getTablePagination().onPaginate(5));
wrapper.update();
expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', {
query: {
type: 'indexed',
pageIndex: 5,
},
});
expect(getTablePagination().pageIndex).toEqual(4);
});
});
describe('when on a platinum license', () => {
beforeAll(async () => {
mockApi.mockClear();
wrapper = await mountWithApiMock({
it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
license: { type: 'platinum', isActive: true },
get: mockApi,
});
});
it('renders a 2nd meta engines table', () => {
expect(wrapper.find(EngineTable)).toHaveLength(2);
});
it('makes a 2nd call to the engines API with type meta', () => {
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', {
query: {
type: 'meta',
@ -144,28 +107,36 @@ describe('EngineOverview', () => {
});
});
});
});
/**
* Test helpers
*/
describe('pagination', () => {
const getTablePagination = (wrapper: ReactWrapper) =>
wrapper.find(EngineTable).prop('pagination');
const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => {
let wrapper: ReactWrapper | undefined;
const httpMock = { ...mockKibanaContext.http, get };
it('passes down page data from the API', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
});
const pagination = getTablePagination(wrapper);
// We get a lot of act() warning/errors in the terminal without this.
// TBH, I don't fully understand why since Enzyme's mount is supposed to
// have act() baked in - could be because of the wrapping context provider?
await act(async () => {
wrapper = mountWithContext(<EngineOverview />, { http: httpMock, license });
expect(pagination.totalEngines).toEqual(100);
expect(pagination.pageIndex).toEqual(0);
});
it('re-polls the API on page change', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
});
await act(async () => getTablePagination(wrapper).onPaginate(5));
wrapper.update();
expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', {
query: {
type: 'indexed',
pageIndex: 5,
},
});
expect(getTablePagination(wrapper).pageIndex).toEqual(4);
});
});
if (wrapper) {
wrapper.update(); // This seems to be required for the DOM to actually update
return wrapper;
} else {
throw new Error('Could not mount wrapper');
}
};
});
});

View file

@ -6,14 +6,16 @@
import React from 'react';
import { AppMountParameters } from 'src/core/public';
import { coreMock } from 'src/core/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks';
import { renderApp } from './';
import { AppSearch } from './app_search';
import { WorkplaceSearch } from './workplace_search';
describe('renderApp', () => {
const params = coreMock.createAppMountParamters();
let params: AppMountParameters;
const core = coreMock.createStart();
const config = {};
const plugins = {
@ -22,6 +24,7 @@ describe('renderApp', () => {
beforeEach(() => {
jest.clearAllMocks();
params = coreMock.createAppMountParamters();
});
it('mounts and unmounts UI', () => {
@ -37,4 +40,9 @@ describe('renderApp', () => {
renderApp(AppSearch, core, params, config, plugins);
expect(params.element.querySelector('.setupGuide')).not.toBeNull();
});
it('renders WorkplaceSearch', () => {
renderApp(WorkplaceSearch, core, params, config, plugins);
expect(params.element.querySelector('.setupGuide')).not.toBeNull();
});
});

View file

@ -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 '../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt } from '@elastic/eui';
import { ErrorStatePrompt } from './';
describe('ErrorState', () => {
it('renders', () => {
const wrapper = shallow(<ErrorStatePrompt />);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
});

View file

@ -0,0 +1,79 @@
/*
* 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 { EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiButton } from '../react_router_helpers';
import { KibanaContext, IKibanaContext } from '../../index';
export const ErrorStatePrompt: React.FC = () => {
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
return (
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={
<h2>
<FormattedMessage
id="xpack.enterpriseSearch.errorConnectingState.title"
defaultMessage="Unable to connect"
/>
</h2>
}
titleSize="l"
body={
<>
<p>
<FormattedMessage
id="xpack.enterpriseSearch.errorConnectingState.description1"
defaultMessage="We cant establish a connection to Enterprise Search at the host URL: {enterpriseSearchUrl}"
values={{
enterpriseSearchUrl: <EuiCode>{enterpriseSearchUrl}</EuiCode>,
}}
/>
</p>
<ol className="eui-textLeft">
<li>
<FormattedMessage
id="xpack.enterpriseSearch.errorConnectingState.description2"
defaultMessage="Ensure the host URL is configured correctly in {configFile}."
values={{
configFile: <EuiCode>config/kibana.yml</EuiCode>,
}}
/>
</li>
<li>
<FormattedMessage
id="xpack.enterpriseSearch.errorConnectingState.description3"
defaultMessage="Confirm that the Enterprise Search server is responsive."
/>
</li>
<li>
<FormattedMessage
id="xpack.enterpriseSearch.errorConnectingState.description4"
defaultMessage="Review the Setup guide or check your server log for {pluginLog} log messages."
values={{
pluginLog: <EuiCode>[enterpriseSearch][plugins]</EuiCode>,
}}
/>
</li>
</ol>
</>
}
actions={
<EuiButton iconType="help" fill to="/setup_guide">
<FormattedMessage
id="xpack.enterpriseSearch.errorConnectingState.setupGuideCta"
defaultMessage="Review setup guide"
/>
</EuiButton>
}
/>
);
};

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 { ErrorStatePrompt } from './error_state_prompt';

View file

@ -5,7 +5,7 @@
*/
import { generateBreadcrumb } from './generate_breadcrumbs';
import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './';
import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs, workplaceSearchBreadcrumbs } from './';
import { mockHistory as mockHistoryUntyped } from '../../__mocks__';
const mockHistory = mockHistoryUntyped as any;
@ -204,3 +204,86 @@ describe('appSearchBreadcrumbs', () => {
});
});
});
describe('workplaceSearchBreadcrumbs', () => {
const breadCrumbs = [
{
text: 'Page 1',
path: '/page1',
},
{
text: 'Page 2',
path: '/page2',
},
];
beforeEach(() => {
jest.clearAllMocks();
mockHistory.createHref.mockImplementation(
({ pathname }: any) => `/enterprise_search/workplace_search${pathname}`
);
});
const subject = () => workplaceSearchBreadcrumbs(mockHistory)(breadCrumbs);
it('Builds a chain of breadcrumbs with Enterprise Search and Workplace Search at the root', () => {
expect(subject()).toEqual([
{
text: 'Enterprise Search',
},
{
href: '/enterprise_search/workplace_search/',
onClick: expect.any(Function),
text: 'Workplace Search',
},
{
href: '/enterprise_search/workplace_search/page1',
onClick: expect.any(Function),
text: 'Page 1',
},
{
href: '/enterprise_search/workplace_search/page2',
onClick: expect.any(Function),
text: 'Page 2',
},
]);
});
it('shows just the root if breadcrumbs is empty', () => {
expect(workplaceSearchBreadcrumbs(mockHistory)()).toEqual([
{
text: 'Enterprise Search',
},
{
href: '/enterprise_search/workplace_search/',
onClick: expect.any(Function),
text: 'Workplace Search',
},
]);
});
describe('links', () => {
const eventMock = {
preventDefault: jest.fn(),
} as any;
it('has Enterprise Search text first', () => {
expect(subject()[0].onClick).toBeUndefined();
});
it('has a link to Workplace Search second', () => {
(subject()[1] as any).onClick(eventMock);
expect(mockHistory.push).toHaveBeenCalledWith('/');
});
it('has a link to page 1 third', () => {
(subject()[2] as any).onClick(eventMock);
expect(mockHistory.push).toHaveBeenCalledWith('/page1');
});
it('has a link to page 2 last', () => {
(subject()[3] as any).onClick(eventMock);
expect(mockHistory.push).toHaveBeenCalledWith('/page2');
});
});
});

View file

@ -52,3 +52,6 @@ export const enterpriseSearchBreadcrumbs = (history: History) => (
export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) =>
enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]);
export const workplaceSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) =>
enterpriseSearchBreadcrumbs(history)([{ text: 'Workplace Search', path: '/' }, ...breadcrumbs]);

View file

@ -4,6 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs';
export { appSearchBreadcrumbs } from './generate_breadcrumbs';
export { SetAppSearchBreadcrumbs } from './set_breadcrumbs';
export {
enterpriseSearchBreadcrumbs,
appSearchBreadcrumbs,
workplaceSearchBreadcrumbs,
} from './generate_breadcrumbs';
export { SetAppSearchBreadcrumbs, SetWorkplaceSearchBreadcrumbs } from './set_breadcrumbs';

View file

@ -8,7 +8,11 @@ import React, { useContext, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { EuiBreadcrumb } from '@elastic/eui';
import { KibanaContext, IKibanaContext } from '../../index';
import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs';
import {
appSearchBreadcrumbs,
workplaceSearchBreadcrumbs,
TBreadcrumbs,
} from './generate_breadcrumbs';
/**
* Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view
@ -17,19 +21,17 @@ import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs';
export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void;
interface IBreadcrumbProps {
interface IBreadcrumbsProps {
text: string;
isRoot?: never;
}
interface IRootBreadcrumbProps {
interface IRootBreadcrumbsProps {
isRoot: true;
text?: never;
}
type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps;
export const SetAppSearchBreadcrumbs: React.FC<IBreadcrumbProps | IRootBreadcrumbProps> = ({
text,
isRoot,
}) => {
export const SetAppSearchBreadcrumbs: React.FC<TBreadcrumbsProps> = ({ text, isRoot }) => {
const history = useHistory();
const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext;
@ -41,3 +43,16 @@ export const SetAppSearchBreadcrumbs: React.FC<IBreadcrumbProps | IRootBreadcrum
return null;
};
export const SetWorkplaceSearchBreadcrumbs: React.FC<TBreadcrumbsProps> = ({ text, isRoot }) => {
const history = useHistory();
const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext;
const crumb = isRoot ? [] : [{ text, path: history.location.pathname }];
useEffect(() => {
setBreadcrumbs(workplaceSearchBreadcrumbs(history)(crumb as TBreadcrumbs | []));
}, []);
return null;
};

View file

@ -6,3 +6,4 @@
export { sendTelemetry } from './send_telemetry';
export { SendAppSearchTelemetry } from './send_telemetry';
export { SendWorkplaceSearchTelemetry } from './send_telemetry';

View file

@ -7,8 +7,10 @@
import React from 'react';
import { httpServiceMock } from 'src/core/public/mocks';
import { JSON_HEADER as headers } from '../../../../common/constants';
import { mountWithKibanaContext } from '../../__mocks__';
import { sendTelemetry, SendAppSearchTelemetry } from './';
import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './';
describe('Shared Telemetry Helpers', () => {
const httpMock = httpServiceMock.createSetupContract();
@ -27,8 +29,8 @@ describe('Shared Telemetry Helpers', () => {
});
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
headers: { 'Content-Type': 'application/json' },
body: '{"action":"viewed","metric":"setup_guide"}',
headers,
body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}',
});
});
@ -47,9 +49,20 @@ describe('Shared Telemetry Helpers', () => {
http: httpMock,
});
expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', {
headers: { 'Content-Type': 'application/json' },
body: '{"action":"clicked","metric":"button"}',
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
headers,
body: '{"product":"app_search","action":"clicked","metric":"button"}',
});
});
it('SendWorkplaceSearchTelemetry component', () => {
mountWithKibanaContext(<SendWorkplaceSearchTelemetry action="viewed" metric="page" />, {
http: httpMock,
});
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
headers,
body: '{"product":"workplace_search","action":"viewed","metric":"page"}',
});
});
});

View file

@ -7,6 +7,7 @@
import React, { useContext, useEffect } from 'react';
import { HttpSetup } from 'src/core/public';
import { JSON_HEADER as headers } from '../../../../common/constants';
import { KibanaContext, IKibanaContext } from '../../index';
interface ISendTelemetryProps {
@ -25,10 +26,8 @@ interface ISendTelemetry extends ISendTelemetryProps {
export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => {
try {
await http.put(`/api/${product}/telemetry`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, metric }),
});
const body = JSON.stringify({ product, action, metric });
await http.put('/api/enterprise_search/telemetry', { headers, body });
} catch (error) {
throw new Error('Unable to send telemetry');
}
@ -36,7 +35,7 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele
/**
* React component helpers - useful for on-page-load/views
* TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry
* TODO: SendEnterpriseSearchTelemetry
*/
export const SendAppSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action, metric }) => {
@ -48,3 +47,13 @@ export const SendAppSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action,
return null;
};
export const SendWorkplaceSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action, metric }) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
useEffect(() => {
sendTelemetry({ http, action, metric, product: 'workplace_search' });
}, [action, metric, http]);
return null;
};

View file

@ -0,0 +1,14 @@
/*
* 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 interface IFlashMessagesProps {
info?: string[];
warning?: string[];
error?: string[];
success?: string[];
isWrapped?: boolean;
children?: React.ReactNode;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

View file

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.1882 8.03747L18.8672 5.09605C18.9491 5.00964 19.0388 4.91586 19.1365 4.81452C19.6347 4.29902 20.4324 3.57073 21.3224 2.9792C22.242 2.368 23.1165 2 23.801 2H29V30H23.8C22.2799 30 20.5224 28.8398 19.2054 27.2777L18.9076 26.8678L21.1873 23.9854C21.1873 23.9854 21.1873 23.9854 21.1873 23.9853C24.9367 19.2461 24.9381 12.7767 21.1886 8.03797C21.1885 8.0378 21.1883 8.03763 21.1882 8.03747Z" stroke="#017D73" stroke-width="2"/>
<path d="M17.267 11.1395L17.2678 11.1406C19.5779 14.0598 19.5782 17.9609 17.2659 20.8824L17.2657 20.8827L11.4107 28.2856C11.4107 28.2856 11.4107 28.2857 11.4107 28.2857C10.5677 29.3513 9.22146 30 7.815 30H3V2H7.81C9.22095 2 10.5689 2.65129 11.412 3.71946C11.412 3.71947 11.412 3.71949 11.412 3.71951L17.267 11.1395Z" stroke="#343741" stroke-width="2"/>
<path d="M17.2105 21.1254L15.31 23.4123L12.8814 21.0168C10.3527 18.0627 10.3903 13.7569 12.884 11.0593C13.898 9.97667 14.6939 9.15785 15.2809 8.58921L17.2714 11.0032C19.6073 14.0114 19.5622 18.094 17.2105 21.1254Z" stroke="#343741" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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 '../../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { ErrorStatePrompt } from '../../../shared/error_state';
import { ErrorState } from './';
describe('ErrorState', () => {
it('renders', () => {
const wrapper = shallow(<ErrorState />);
expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1);
});
});

View file

@ -0,0 +1,34 @@
/*
* 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, EuiPageContent } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ErrorStatePrompt } from '../../../shared/error_state';
import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import { ViewContentHeader } from '../shared/view_content_header';
export const ErrorState: React.FC = () => {
return (
<EuiPage restrictWidth>
<SetBreadcrumbs isRoot />
<SendTelemetry action="error" metric="cannot_connect" />
<EuiPageBody>
<ViewContentHeader
title={i18n.translate('xpack.enterpriseSearch.workplaceSearch.productName', {
defaultMessage: 'Workplace Search',
})}
/>
<EuiPageContent>
<ErrorStatePrompt />
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
};

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 { ErrorState } from './error_state';

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 { Overview } from './overview';

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.
*/
import '../../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { OnboardingCard } from './onboarding_card';
jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
import { sendTelemetry } from '../../../shared/telemetry';
const cardProps = {
title: 'My card',
icon: 'icon',
description: 'this is a card',
actionTitle: 'action',
testSubj: 'actionButton',
};
describe('OnboardingCard', () => {
it('renders', () => {
const wrapper = shallow(<OnboardingCard {...cardProps} />);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
it('renders an action button', () => {
const wrapper = shallow(<OnboardingCard {...cardProps} actionPath="/some_path" />);
const prompt = wrapper.find(EuiEmptyPrompt).dive();
expect(prompt.find(EuiButton)).toHaveLength(1);
expect(prompt.find(EuiButtonEmpty)).toHaveLength(0);
const button = prompt.find('[data-test-subj="actionButton"]');
expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path');
button.simulate('click');
expect(sendTelemetry).toHaveBeenCalled();
});
it('renders an empty button when onboarding is completed', () => {
const wrapper = shallow(<OnboardingCard {...cardProps} complete />);
const prompt = wrapper.find(EuiEmptyPrompt).dive();
expect(prompt.find(EuiButton)).toHaveLength(0);
expect(prompt.find(EuiButtonEmpty)).toHaveLength(1);
});
});

View file

@ -0,0 +1,92 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiFlexItem,
EuiPanel,
EuiEmptyPrompt,
IconType,
EuiButtonProps,
EuiButtonEmptyProps,
EuiLinkProps,
} from '@elastic/eui';
import { useRoutes } from '../shared/use_routes';
import { sendTelemetry } from '../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../index';
interface IOnboardingCardProps {
title: React.ReactNode;
icon: React.ReactNode;
description: React.ReactNode;
actionTitle: React.ReactNode;
testSubj: string;
actionPath?: string;
complete?: boolean;
}
export const OnboardingCard: React.FC<IOnboardingCardProps> = ({
title,
icon,
description,
actionTitle,
testSubj,
actionPath,
complete,
}) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { getWSRoute } = useRoutes();
const onClick = () =>
sendTelemetry({
http,
product: 'workplace_search',
action: 'clicked',
metric: 'onboarding_card_button',
});
const buttonActionProps = actionPath
? {
onClick,
href: getWSRoute(actionPath),
target: '_blank',
'data-test-subj': testSubj,
}
: {
'data-test-subj': testSubj,
};
const emptyButtonProps = {
...buttonActionProps,
} as EuiButtonEmptyProps & EuiLinkProps;
const fillButtonProps = {
...buttonActionProps,
color: 'secondary',
fill: true,
} as EuiButtonProps & EuiLinkProps;
return (
<EuiFlexItem>
<EuiPanel>
<EuiEmptyPrompt
iconType={complete ? 'checkInCircleFilled' : (icon as IconType)}
iconColor={complete ? 'secondary' : 'subdued'}
title={<h3>{title}</h3>}
body={description}
actions={
complete ? (
<EuiButtonEmpty {...emptyButtonProps}>{actionTitle}</EuiButtonEmpty>
) : (
<EuiButton {...fillButtonProps}>{actionTitle}</EuiButton>
)
}
/>
</EuiPanel>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,136 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes';
jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
import { sendTelemetry } from '../../../shared/telemetry';
import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps';
import { OnboardingCard } from './onboarding_card';
import { defaultServerData } from './overview';
const account = {
id: '1',
isAdmin: true,
canCreatePersonalSources: true,
groups: [],
supportEligible: true,
isCurated: false,
};
describe('OnboardingSteps', () => {
describe('Shared Sources', () => {
it('renders 0 sources state', () => {
const wrapper = shallow(<OnboardingSteps {...defaultServerData} />);
expect(wrapper.find(OnboardingCard)).toHaveLength(1);
expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(ORG_SOURCES_PATH);
expect(wrapper.find(OnboardingCard).prop('description')).toBe(
'Add shared sources for your organization to start searching.'
);
});
it('renders completed sources state', () => {
const wrapper = shallow(
<OnboardingSteps {...defaultServerData} sourcesCount={2} hasOrgSources />
);
expect(wrapper.find(OnboardingCard).prop('description')).toEqual(
'You have added 2 shared sources. Happy searching.'
);
});
it('disables link when the user cannot create sources', () => {
const wrapper = shallow(
<OnboardingSteps {...defaultServerData} canCreateContentSources={false} />
);
expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined);
});
});
describe('Users & Invitations', () => {
it('renders 0 users when not on federated auth', () => {
const wrapper = shallow(
<OnboardingSteps
{...defaultServerData}
isFederatedAuth={false}
fpAccount={account}
accountsCount={0}
hasUsers={false}
/>
);
expect(wrapper.find(OnboardingCard)).toHaveLength(2);
expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH);
expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual(
'Invite your colleagues into this organization to search with you.'
);
});
it('renders completed users state', () => {
const wrapper = shallow(
<OnboardingSteps
{...defaultServerData}
isFederatedAuth={false}
fpAccount={account}
accountsCount={1}
hasUsers
/>
);
expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual(
'Nice, youve invited colleagues to search with you.'
);
});
it('disables link when the user cannot create invitations', () => {
const wrapper = shallow(
<OnboardingSteps
{...defaultServerData}
isFederatedAuth={false}
canCreateInvitations={false}
/>
);
expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined);
});
});
describe('Org Name', () => {
it('renders button to change name', () => {
const wrapper = shallow(<OnboardingSteps {...defaultServerData} />);
const button = wrapper
.find(OrgNameOnboarding)
.dive()
.find('[data-test-subj="orgNameChangeButton"]');
button.simulate('click');
expect(sendTelemetry).toHaveBeenCalled();
});
it('hides card when name has been changed', () => {
const wrapper = shallow(
<OnboardingSteps
{...defaultServerData}
organization={{
name: 'foo',
defaultOrgName: 'bar',
}}
/>
);
expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,179 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiSpacer,
EuiButtonEmpty,
EuiTitle,
EuiPanel,
EuiIcon,
EuiFlexGrid,
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmptyProps,
EuiLinkProps,
} from '@elastic/eui';
import sharedSourcesIcon from '../shared/assets/share_circle.svg';
import { useRoutes } from '../shared/use_routes';
import { sendTelemetry } from '../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../index';
import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes';
import { ContentSection } from '../shared/content_section';
import { IAppServerData } from './overview';
import { OnboardingCard } from './onboarding_card';
const SOURCES_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title',
{ defaultMessage: 'Shared sources' }
);
const USERS_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title',
{ defaultMessage: 'Users & invitations' }
);
const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description',
{ defaultMessage: 'Add shared sources for your organization to start searching.' }
);
const USERS_CARD_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title',
{ defaultMessage: 'Nice, youve invited colleagues to search with you.' }
);
const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description',
{ defaultMessage: 'Invite your colleagues into this organization to search with you.' }
);
export const OnboardingSteps: React.FC<IAppServerData> = ({
hasUsers,
hasOrgSources,
canCreateContentSources,
canCreateInvitations,
accountsCount,
sourcesCount,
fpAccount: { isCurated },
organization: { name, defaultOrgName },
isFederatedAuth,
}) => {
const accountsPath =
!isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined;
const sourcesPath = canCreateContentSources || isCurated ? ORG_SOURCES_PATH : undefined;
const SOURCES_CARD_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description',
{
defaultMessage:
'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.',
values: { sourcesCount },
}
);
return (
<ContentSection>
<EuiFlexGrid columns={isFederatedAuth ? 1 : 2}>
<OnboardingCard
title={SOURCES_TITLE}
testSubj="sharedSourcesButton"
icon={sharedSourcesIcon}
description={
hasOrgSources ? SOURCES_CARD_DESCRIPTION : ONBOARDING_SOURCES_CARD_DESCRIPTION
}
actionTitle={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.buttonLabel',
{
defaultMessage: 'Add {label} sources',
values: { label: sourcesCount > 0 ? 'more' : '' },
}
)}
actionPath={sourcesPath}
complete={hasOrgSources}
/>
{!isFederatedAuth && (
<OnboardingCard
title={USERS_TITLE}
testSubj="usersButton"
icon="user"
description={hasUsers ? USERS_CARD_DESCRIPTION : ONBOARDING_USERS_CARD_DESCRIPTION}
actionTitle={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.usersOnboardingCard.buttonLabel',
{
defaultMessage: 'Invite {label} users',
values: { label: accountsCount > 0 ? 'more' : '' },
}
)}
actionPath={accountsPath}
complete={hasUsers}
/>
)}
</EuiFlexGrid>
{name === defaultOrgName && (
<>
<EuiSpacer />
<OrgNameOnboarding />
</>
)}
</ContentSection>
);
};
export const OrgNameOnboarding: React.FC = () => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { getWSRoute } = useRoutes();
const onClick = () =>
sendTelemetry({
http,
product: 'workplace_search',
action: 'clicked',
metric: 'org_name_change_button',
});
const buttonProps = {
onClick,
target: '_blank',
color: 'primary',
href: getWSRoute(ORG_SETTINGS_PATH),
'data-test-subj': 'orgNameChangeButton',
} as EuiButtonEmptyProps & EuiLinkProps;
return (
<EuiPanel paddingSize="l">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}>
<EuiFlexItem className="eui-hideFor--xs eui-hideFor--s" grow={false}>
<EuiIcon type="training" color="subdued" size="xl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h4>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.orgNameOnboarding.description"
defaultMessage="Before inviting your colleagues, name your organization to improve recognition."
/>
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty {...buttonProps}>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.orgNameOnboarding.buttonLabel"
defaultMessage="Name your organization"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,31 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { EuiFlexGrid } from '@elastic/eui';
import { OrganizationStats } from './organization_stats';
import { StatisticCard } from './statistic_card';
import { defaultServerData } from './overview';
describe('OrganizationStats', () => {
it('renders', () => {
const wrapper = shallow(<OrganizationStats {...defaultServerData} />);
expect(wrapper.find(StatisticCard)).toHaveLength(2);
expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2);
});
it('renders additional cards for federated auth', () => {
const wrapper = shallow(<OrganizationStats {...defaultServerData} isFederatedAuth={false} />);
expect(wrapper.find(StatisticCard)).toHaveLength(4);
expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4);
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { EuiFlexGrid } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { ContentSection } from '../shared/content_section';
import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes';
import { IAppServerData } from './overview';
import { StatisticCard } from './statistic_card';
export const OrganizationStats: React.FC<IAppServerData> = ({
sourcesCount,
pendingInvitationsCount,
accountsCount,
personalSourcesCount,
isFederatedAuth,
}) => (
<ContentSection
title={
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.organizationStats.title"
defaultMessage="Usage statistics"
/>
}
headerSpacer="m"
>
<EuiFlexGrid columns={isFederatedAuth ? 2 : 4}>
<StatisticCard
title={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.organizationStats.sharedSources',
{ defaultMessage: 'Shared sources' }
)}
count={sourcesCount}
actionPath={ORG_SOURCES_PATH}
/>
{!isFederatedAuth && (
<>
<StatisticCard
title={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.organizationStats.invitations',
{ defaultMessage: 'Invitations' }
)}
count={pendingInvitationsCount}
actionPath={USERS_PATH}
/>
<StatisticCard
title={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.organizationStats.activeUsers',
{ defaultMessage: 'Active users' }
)}
count={accountsCount}
actionPath={USERS_PATH}
/>
</>
)}
<StatisticCard
title={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.organizationStats.privateSources',
{ defaultMessage: 'Private sources' }
)}
count={personalSourcesCount}
/>
</EuiFlexGrid>
</ContentSection>
);

View file

@ -0,0 +1,77 @@
/*
* 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__/react_router_history.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__';
import { ErrorState } from '../error_state';
import { Loading } from '../shared/loading';
import { ViewContentHeader } from '../shared/view_content_header';
import { OnboardingSteps } from './onboarding_steps';
import { OrganizationStats } from './organization_stats';
import { RecentActivity } from './recent_activity';
import { Overview, defaultServerData } from './overview';
describe('Overview', () => {
const mockHttp = mockKibanaContext.http;
describe('non-happy-path states', () => {
it('isLoading', () => {
const wrapper = shallow(<Overview />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
it('hasErrorConnecting', async () => {
const wrapper = await mountWithAsyncContext(<Overview />, {
http: {
...mockHttp,
get: () => Promise.reject({ invalidPayload: true }),
},
});
expect(wrapper.find(ErrorState)).toHaveLength(1);
});
});
describe('happy-path states', () => {
it('renders onboarding state', async () => {
const mockApi = jest.fn(() => defaultServerData);
const wrapper = await mountWithAsyncContext(<Overview />, {
http: { ...mockHttp, get: mockApi },
});
expect(wrapper.find(ViewContentHeader)).toHaveLength(1);
expect(wrapper.find(OnboardingSteps)).toHaveLength(1);
expect(wrapper.find(OrganizationStats)).toHaveLength(1);
expect(wrapper.find(RecentActivity)).toHaveLength(1);
});
it('renders when onboarding complete', async () => {
const obCompleteData = {
...defaultServerData,
hasUsers: true,
hasOrgSources: true,
isOldAccount: true,
organization: {
name: 'foo',
defaultOrgName: 'bar',
},
};
const mockApi = jest.fn(() => obCompleteData);
const wrapper = await mountWithAsyncContext(<Overview />, {
http: { ...mockHttp, get: mockApi },
});
expect(wrapper.find(OnboardingSteps)).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,151 @@
/*
* 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, useEffect, useState } from 'react';
import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../index';
import { IAccount } from '../../types';
import { ErrorState } from '../error_state';
import { Loading } from '../shared/loading';
import { ProductButton } from '../shared/product_button';
import { ViewContentHeader } from '../shared/view_content_header';
import { OnboardingSteps } from './onboarding_steps';
import { OrganizationStats } from './organization_stats';
import { RecentActivity, IFeedActivity } from './recent_activity';
export interface IAppServerData {
hasUsers: boolean;
hasOrgSources: boolean;
canCreateContentSources: boolean;
canCreateInvitations: boolean;
isOldAccount: boolean;
sourcesCount: number;
pendingInvitationsCount: number;
accountsCount: number;
personalSourcesCount: number;
activityFeed: IFeedActivity[];
organization: {
name: string;
defaultOrgName: string;
};
isFederatedAuth: boolean;
currentUser: {
firstName: string;
email: string;
name: string;
color: string;
};
fpAccount: IAccount;
}
export const defaultServerData = {
accountsCount: 1,
activityFeed: [],
canCreateContentSources: true,
canCreateInvitations: true,
currentUser: {
firstName: '',
email: '',
name: '',
color: '',
},
fpAccount: {} as IAccount,
hasOrgSources: false,
hasUsers: false,
isFederatedAuth: true,
isOldAccount: false,
organization: {
name: '',
defaultOrgName: '',
},
pendingInvitationsCount: 0,
personalSourcesCount: 0,
sourcesCount: 0,
} as IAppServerData;
const ONBOARDING_HEADER_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title',
{ defaultMessage: 'Get started with Workplace Search' }
);
const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', {
defaultMessage: 'Organization overview',
});
const ONBOARDING_HEADER_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description',
{ defaultMessage: 'Complete the following to set up your organization.' }
);
const HEADER_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description',
{ defaultMessage: "Your organizations's statistics and activity" }
);
export const Overview: React.FC = () => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const [isLoading, setIsLoading] = useState(true);
const [hasErrorConnecting, setHasErrorConnecting] = useState(false);
const [appData, setAppData] = useState(defaultServerData);
const getAppData = async () => {
try {
const response = await http.get('/api/workplace_search/overview');
setAppData(response);
} catch (error) {
setHasErrorConnecting(true);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
getAppData();
}, []);
if (hasErrorConnecting) return <ErrorState />;
if (isLoading) return <Loading />;
const {
hasUsers,
hasOrgSources,
isOldAccount,
organization: { name: orgName, defaultOrgName },
} = appData as IAppServerData;
const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName;
const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE;
const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION;
return (
<EuiPage restrictWidth>
<SetBreadcrumbs isRoot />
<SendTelemetry action="viewed" metric="overview" />
<EuiPageBody>
<ViewContentHeader
title={headerTitle}
description={headerDescription}
action={<ProductButton />}
/>
{!hideOnboarding && <OnboardingSteps {...appData} />}
<EuiSpacer size="xl" />
<OrganizationStats {...appData} />
<EuiSpacer size="xl" />
<RecentActivity {...appData} />
</EuiPageBody>
</EuiPage>
);
};

View file

@ -0,0 +1,37 @@
/*
* 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.
*/
.activity {
display: flex;
justify-content: space-between;
padding: $euiSizeM;
font-size: $euiFontSizeS;
&--error {
font-weight: $euiFontWeightSemiBold;
color: $euiColorDanger;
background: rgba($euiColorDanger, 0.1);
&__label {
margin-left: $euiSizeS * 1.75;
font-weight: $euiFontWeightRegular;
text-decoration: underline;
opacity: 0.7;
}
}
&__message {
flex-grow: 1;
}
&__date {
flex-grow: 0;
}
& + & {
border-top: $euiBorderThin;
}
}

View file

@ -0,0 +1,61 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import { RecentActivity, RecentActivityItem } from './recent_activity';
import { defaultServerData } from './overview';
jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
import { sendTelemetry } from '../../../shared/telemetry';
const org = { name: 'foo', defaultOrgName: 'bar' };
const feed = [
{
id: 'demo',
sourceId: 'd2d2d23d',
message: 'was successfully connected',
target: 'http://localhost:3002/ws/org/sources',
timestamp: '2020-06-24 16:34:16',
},
];
describe('RecentActivity', () => {
it('renders with no feed data', () => {
const wrapper = shallow(<RecentActivity {...defaultServerData} activityFeed={[]} />);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
// Branch coverage - renders without error for custom org name
shallow(<RecentActivity {...defaultServerData} organization={org} activityFeed={[]} />);
});
it('renders an activity feed with links', () => {
const wrapper = shallow(<RecentActivity {...defaultServerData} activityFeed={feed} />);
const activity = wrapper.find(RecentActivityItem).dive();
expect(activity).toHaveLength(1);
const link = activity.find('[data-test-subj="viewSourceDetailsLink"]');
link.simulate('click');
expect(sendTelemetry).toHaveBeenCalled();
});
it('renders activity item error state', () => {
const props = { ...feed[0], status: 'error' };
const wrapper = shallow(<RecentActivityItem {...props} />);
expect(wrapper.find('.activity--error')).toHaveLength(1);
expect(wrapper.find('.activity--error__label')).toHaveLength(1);
expect(wrapper.find(EuiLink).prop('color')).toEqual('danger');
});
});

View file

@ -0,0 +1,131 @@
/*
* 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 moment from 'moment';
import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { ContentSection } from '../shared/content_section';
import { useRoutes } from '../shared/use_routes';
import { sendTelemetry } from '../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../index';
import { getSourcePath } from '../../routes';
import { IAppServerData } from './overview';
import './recent_activity.scss';
export interface IFeedActivity {
status?: string;
id: string;
message: string;
timestamp: string;
sourceId: string;
}
export const RecentActivity: React.FC<IAppServerData> = ({
organization: { name, defaultOrgName },
activityFeed,
}) => {
return (
<ContentSection
title={
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.recentActivity.title"
defaultMessage="Recent activity"
/>
}
headerSpacer="m"
>
<EuiPanel>
{activityFeed.length > 0 ? (
<>
{activityFeed.map((props: IFeedActivity, index) => (
<RecentActivityItem {...props} key={index} />
))}
</>
) : (
<>
<EuiSpacer size="xl" />
<EuiEmptyPrompt
iconType="clock"
iconColor="subdued"
titleSize="s"
title={
<h3>
{name === defaultOrgName ? (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.activityFeedEmptyDefault.title"
defaultMessage="Your organization has no recent activity"
/>
) : (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.activityFeedNamedDefault.title"
defaultMessage="{name} has no recent activity"
values={{ name }}
/>
)}
</h3>
}
/>
<EuiSpacer size="xl" />
</>
)}
</EuiPanel>
</ContentSection>
);
};
export const RecentActivityItem: React.FC<IFeedActivity> = ({
id,
status,
message,
timestamp,
sourceId,
}) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { getWSRoute } = useRoutes();
const onClick = () =>
sendTelemetry({
http,
product: 'workplace_search',
action: 'clicked',
metric: 'recent_activity_source_details_link',
});
const linkProps = {
onClick,
target: '_blank',
href: getWSRoute(getSourcePath(sourceId)),
external: true,
color: status === 'error' ? 'danger' : 'primary',
'data-test-subj': 'viewSourceDetailsLink',
} as EuiLinkProps;
return (
<div className={`activity ${status ? `activity--${status}` : ''}`}>
<div className="activity__message">
<EuiLink {...linkProps}>
{id} {message}
{status === 'error' && (
<span className="activity--error__label">
{' '}
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.recentActivitySourceLink.linkLabel"
defaultMessage="View Source"
/>
</span>
)}
</EuiLink>
</div>
<div className="activity__date">{moment.utc(timestamp).fromNow()}</div>
</div>
);
};

View file

@ -0,0 +1,32 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { EuiCard } from '@elastic/eui';
import { StatisticCard } from './statistic_card';
const props = {
title: 'foo',
};
describe('StatisticCard', () => {
it('renders', () => {
const wrapper = shallow(<StatisticCard {...props} />);
expect(wrapper.find(EuiCard)).toHaveLength(1);
});
it('renders clickable card', () => {
const wrapper = shallow(<StatisticCard {...props} actionPath="/foo" />);
expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo');
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui';
import { useRoutes } from '../shared/use_routes';
interface IStatisticCardProps {
title: string;
count?: number;
actionPath?: string;
}
export const StatisticCard: React.FC<IStatisticCardProps> = ({ title, count = 0, actionPath }) => {
const { getWSRoute } = useRoutes();
const linkProps = actionPath
? {
href: getWSRoute(actionPath),
target: '_blank',
rel: 'noopener',
}
: {};
// TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx)
return (
<EuiFlexItem>
<EuiCard
{...linkProps}
layout="horizontal"
title={title}
titleSize="xs"
description={
<EuiTitle size="l">
<EuiTextColor color={actionPath ? 'default' : 'subdued'}>{count}</EuiTextColor>
</EuiTitle>
}
/>
</EuiFlexItem>
);
};

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 { SetupGuide } from './setup_guide';

View file

@ -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 { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
import { SetupGuide } from './';
describe('SetupGuide', () => {
it('renders', () => {
const wrapper = shallow(<SetupGuide />);
expect(wrapper.find(SetupGuideLayout)).toHaveLength(1);
expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1);
});
});

View file

@ -0,0 +1,70 @@
/*
* 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, EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import GettingStarted from '../../assets/getting_started.png';
const GETTING_STARTED_LINK_URL =
'https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html';
export const SetupGuide: React.FC = () => {
return (
<SetupGuideLayout
productName={i18n.translate('xpack.enterpriseSearch.workplaceSearch.productName', {
defaultMessage: 'Workplace Search',
})}
productEuiIcon="logoWorkplaceSearch"
standardAuthLink="https://www.elastic.co/guide/en/workplace-search/current/workplace-search-security.html#standard"
elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/workplace-search/current/workplace-search-security.html#elasticsearch-native-realm"
>
<SetBreadcrumbs text="Setup Guide" />
<SendTelemetry action="viewed" metric="setup_guide" />
<a href={GETTING_STARTED_LINK_URL} target="_blank" rel="noopener noreferrer">
<img
className="setupGuide__thumbnail"
src={GettingStarted}
alt={i18n.translate('xpack.enterpriseSearch.workplaceSearch.setupGuide.imageAlt', {
defaultMessage:
'Getting started with Workplace Search - a guide to show you how to get Workplace Search up and running',
})}
width="1280"
height-="720"
/>
</a>
<EuiTitle size="s">
<p>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.setupGuide.description"
defaultMessage="Elastic Workplace Search unifies your content platforms (Google Drive, Salesforce) into a personalized search experience."
/>
</p>
</EuiTitle>
<EuiSpacer size="l" />
<EuiButton target="_blank" fill href={GETTING_STARTED_LINK_URL} iconType="popout">
Get started with Workplace Search
</EuiButton>
<EuiSpacer size="l" />
<EuiText>
<p>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.setupGuide.notConfigured"
defaultMessage="Workplace Search isn't configured in Kibana. Follow the instructions on this page."
/>
</p>
</EuiText>
</SetupGuideLayout>
);
};

View file

@ -0,0 +1,3 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 479 474">
<path fill-rule="evenodd" clip-rule="evenodd" d="M220.262 1.41682C195.691 6.84541 174.739 24.4644 165.405 47.6073 162.739 53.9882 159.881 66.9405 159.786 72.0834 159.786 75.3215 159.501 75.5119 147.596 81.6072 94.9292 108.179 57.4054 153.036 41.3102 208.464 38.0721 219.798 33.8816 244.274 33.8816 251.893 33.8816 255.448 33.8816 256.863 34.5886 257.55 35.0557 258.005 35.8314 258.142 37.1197 258.369 38.834 258.655 46.7387 258.94 54.5482 259.131L68.8339 259.417V254.845C68.8339 252.464 69.6911 245.036 70.8339 238.369 76.7387 202.274 91.4054 172.845 116.643 146.655 128.739 134.179 144.929 121.893 158.072 115.321L166.739 110.94 171.12 117.702C182.072 134.56 200.358 147.131 220.358 151.512 253.215 158.655 288.738 141.988 304.167 112.083 311.786 97.3215 314.262 84.2739 312.643 67.8929 311.596 57.4167 309.881 51.9882 303.977 40.3691 296.358 25.4168 281.31 11.893 265.119 5.3216 252.167.17866 233.5-1.44036 220.262 1.41682zM253.024 39.1311C260.834 42.6549 265.119 46.0834 269.5 52.1787 284.834 73.6073 277.31 103.036 253.691 113.893 232.453 123.702 209.12 115.321 199.12 94.1787 195.691 87.0357 195.405 85.7025 195.405 77.4167 195.31 58.9405 204.167 45.512 221.405 38.3691 226.643 36.0834 228.453 35.893 237.31 36.1787 245.596 36.3691 248.358 36.9406 253.024 39.1311zM340.834 108.56L331.5 123.226 333.881 124.845C349.881 135.226 368.929 154.179 379.786 170.369 398.643 198.464 408.834 231.226 408.834 263.417 408.834 270.655 408.548 278.083 408.167 279.797L408.162 279.822C407.595 282.846 407.578 282.94 400.929 282.94 381.786 282.94 362.548 291.131 348.262 305.321 328.453 325.036 321.5 349.416 327.405 378.274 331.5 398.369 347.881 419.131 367.31 428.94 379.977 435.226 389.691 437.226 404.834 436.654 418.834 436.083 424.834 434.464 438.262 427.702 449.31 422.083 463.5 407.893 469.5 396.369 479.691 376.94 481.405 355.607 474.357 334.845 469.786 321.417 459.691 307.607 448.262 298.94L442.643 294.655 443.31 288.083C444.548 276.94 444.072 250.94 442.357 238.75 437.5 202.559 422.643 166.655 401.31 139.798 393.31 129.702 377.786 113.798 369.691 107.321 361.977 101.131 351.786 93.8929 350.834 93.8929 350.453 93.8929 345.881 100.464 340.834 108.56zM418.929 322.75C427.5 326.75 434.834 334.083 439.024 342.655 442.167 349.036 442.357 349.988 442.357 360.369 442.357 370.845 442.167 371.797 439.024 378.083 428.834 398.845 404.643 407.035 383.215 396.94 367.596 389.702 358.167 371.512 360.834 354.083 363.31 338.178 373.405 326.369 389.31 320.655 397.31 317.797 410.548 318.655 418.929 322.75zM7.21495 327.417C18.3578 303.797 38.453 288.845 65.8816 283.512 71.4054 282.464 89.5006 283.797 96.6435 285.797 121.691 293.036 142.548 313.226 149.691 337.416 156.358 359.607 152.739 384.94 140.643 402.178 139.501 403.702 138.739 405.131 138.929 405.321 141.691 407.797 157.596 417.131 165.31 420.845 193.31 434.273 226.739 440.178 254.834 436.845 276.834 434.273 290.358 430.845 311.024 422.369 312.453 421.797 314.072 424.369 320.262 436.75 324.453 445.131 327.881 452.178 327.881 452.654 327.881 454.845 300.548 464.559 283.596 468.273 266.548 472.083 256.929 473.131 239.31 473.131 194.643 473.035 155.215 460.464 119.12 434.654 113.367 430.574 111.655 429.36 110.089 429.526 109.426 429.597 108.789 429.915 107.882 430.369 95.6911 436.654 73.6911 438.75 58.8339 435.035 36.2626 429.416 17.8816 414.75 7.88161 394.464 2.64354 383.797.834004 376.559.167348 363.893-.594554 350.178 1.40545 339.607 7.21495 327.417zM117.12 354.178C114.643 339.988 106.072 328.75 93.1197 322.655 76.8339 315.036 55.7863 319.702 44.6435 333.416 30.6435 350.464 31.7864 373.988 47.2149 389.416 62.5482 404.75 86.5482 405.321 103.882 390.845 113.691 382.655 119.31 367.035 117.12 354.178z" fill="#647487"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

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 '../../../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiTitle, EuiSpacer } from '@elastic/eui';
import { ContentSection } from './';
const props = {
children: <div className="children" />,
testSubj: 'contentSection',
className: 'test',
};
describe('ContentSection', () => {
it('renders', () => {
const wrapper = shallow(<ContentSection {...props} />);
expect(wrapper.prop('data-test-subj')).toEqual('contentSection');
expect(wrapper.prop('className')).toEqual('test');
expect(wrapper.find('.children')).toHaveLength(1);
});
it('displays title and description', () => {
const wrapper = shallow(<ContentSection {...props} title="foo" description="bar" />);
expect(wrapper.find(EuiTitle)).toHaveLength(1);
expect(wrapper.find('p').text()).toEqual('bar');
});
it('displays header content', () => {
const wrapper = shallow(
<ContentSection
{...props}
title="h"
headerSpacer="s"
headerChildren={<div className="header" />}
/>
);
expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s');
expect(wrapper.find('.header')).toHaveLength(1);
});
});

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { TSpacerSize } from '../../../types';
interface IContentSectionProps {
children: React.ReactNode;
className?: string;
title?: React.ReactNode;
description?: React.ReactNode;
headerChildren?: React.ReactNode;
headerSpacer?: TSpacerSize;
testSubj?: string;
}
export const ContentSection: React.FC<IContentSectionProps> = ({
children,
className = '',
title,
description,
headerChildren,
headerSpacer,
testSubj,
}) => (
<div className={className} data-test-subj={testSubj}>
{title && (
<>
<EuiTitle size="s">
<h3>{title}</h3>
</EuiTitle>
{description && <p>{description}</p>}
{headerChildren}
{headerSpacer && <EuiSpacer size={headerSpacer} />}
</>
)}
{children}
</div>
);

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 { ContentSection } from './content_section';

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 { Loading } from './loading';

View file

@ -0,0 +1,14 @@
.loadingSpinnerWrapper {
width: 100%;
height: 90vh;
margin: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.loadingSpinner {
width: $euiSizeXXL * 1.25;
height: $euiSizeXXL * 1.25;
}

View file

@ -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 '../../../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiLoadingSpinner } from '@elastic/eui';
import { Loading } from './';
describe('Loading', () => {
it('renders', () => {
const wrapper = shallow(<Loading />);
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1);
});
});

View file

@ -0,0 +1,17 @@
/*
* 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 { EuiLoadingSpinner } from '@elastic/eui';
import './loading.scss';
export const Loading: React.FC = () => (
<div className="loadingSpinnerWrapper">
<EuiLoadingSpinner size="xl" className="loadingSpinner" />
</div>
);

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 { ProductButton } from './product_button';

View file

@ -0,0 +1,38 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { ProductButton } from './';
jest.mock('../../../../shared/telemetry', () => ({
sendTelemetry: jest.fn(),
SendAppSearchTelemetry: jest.fn(),
}));
import { sendTelemetry } from '../../../../shared/telemetry';
describe('ProductButton', () => {
it('renders', () => {
const wrapper = shallow(<ProductButton />);
expect(wrapper.find(EuiButton)).toHaveLength(1);
expect(wrapper.find(FormattedMessage)).toHaveLength(1);
});
it('sends telemetry on create first engine click', () => {
const wrapper = shallow(<ProductButton />);
const button = wrapper.find(EuiButton);
button.simulate('click');
expect(sendTelemetry).toHaveBeenCalled();
(sendTelemetry as jest.Mock).mockClear();
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext } from 'react';
import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { sendTelemetry } from '../../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../../index';
export const ProductButton: React.FC = () => {
const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext;
const buttonProps = {
fill: true,
iconType: 'popout',
'data-test-subj': 'launchButton',
} as EuiButtonProps & EuiLinkProps;
buttonProps.href = `${enterpriseSearchUrl}/ws`;
buttonProps.target = '_blank';
buttonProps.onClick = () =>
sendTelemetry({
http,
product: 'workplace_search',
action: 'clicked',
metric: 'header_launch_button',
});
return (
<EuiButton {...buttonProps}>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.productCta"
defaultMessage="Launch Workplace Search"
/>
</EuiButton>
);
};

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 { useRoutes } from './use_routes';

View file

@ -0,0 +1,15 @@
/*
* 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 { useContext } from 'react';
import { KibanaContext, IKibanaContext } from '../../../../index';
export const useRoutes = () => {
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
const getWSRoute = (path: string): string => `${enterpriseSearchUrl}/ws${path}`;
return { getWSRoute };
};

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 { ViewContentHeader } from './view_content_header';

View file

@ -0,0 +1,39 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { EuiFlexGroup } from '@elastic/eui';
import { ViewContentHeader } from './';
const props = {
title: 'Header',
alignItems: 'flexStart' as any,
};
describe('ViewContentHeader', () => {
it('renders with title and alignItems', () => {
const wrapper = shallow(<ViewContentHeader {...props} />);
expect(wrapper.find('h2').text()).toEqual('Header');
expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart');
});
it('shows description, when present', () => {
const wrapper = shallow(<ViewContentHeader {...props} description="Hello World" />);
expect(wrapper.find('p').text()).toEqual('Hello World');
});
it('shows action, when present', () => {
const wrapper = shallow(<ViewContentHeader {...props} action={<div className="action" />} />);
expect(wrapper.find('.action')).toHaveLength(1);
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui';
import { FlexGroupAlignItems } from '@elastic/eui/src/components/flex/flex_group';
interface IViewContentHeaderProps {
title: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
alignItems?: FlexGroupAlignItems;
}
export const ViewContentHeader: React.FC<IViewContentHeaderProps> = ({
title,
description,
action,
alignItems = 'center',
}) => (
<>
<EuiFlexGroup alignItems={alignItems} justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="m">
<h2>{title}</h2>
</EuiTitle>
{description && (
<EuiText grow={false}>
<p>{description}</p>
</EuiText>
)}
</EuiFlexItem>
{action && <EuiFlexItem grow={false}>{action}</EuiFlexItem>}
</EuiFlexGroup>
<EuiSpacer />
</>
);

View file

@ -0,0 +1,46 @@
/*
* 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 { Redirect } from 'react-router-dom';
import { shallow } from 'enzyme';
import { SetupGuide } from './components/setup_guide';
import { Overview } from './components/overview';
import { WorkplaceSearch } from './';
describe('Workplace Search Routes', () => {
describe('/', () => {
it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' }));
const wrapper = shallow(<WorkplaceSearch />);
expect(wrapper.find(Redirect)).toHaveLength(1);
expect(wrapper.find(Overview)).toHaveLength(0);
});
it('renders Engine Overview when enterpriseSearchUrl is set', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({
enterpriseSearchUrl: 'https://foo.bar',
}));
const wrapper = shallow(<WorkplaceSearch />);
expect(wrapper.find(Overview)).toHaveLength(1);
expect(wrapper.find(Redirect)).toHaveLength(0);
});
});
describe('/setup_guide', () => {
it('renders', () => {
const wrapper = shallow(<WorkplaceSearch />);
expect(wrapper.find(SetupGuide)).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { Route, Redirect } from 'react-router-dom';
import { KibanaContext, IKibanaContext } from '../index';
import { SETUP_GUIDE_PATH } from './routes';
import { SetupGuide } from './components/setup_guide';
import { Overview } from './components/overview';
export const WorkplaceSearch: React.FC = () => {
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
return (
<>
<Route exact path="/">
{!enterpriseSearchUrl ? <Redirect to={SETUP_GUIDE_PATH} /> : <Overview />}
</Route>
<Route path={SETUP_GUIDE_PATH}>
<SetupGuide />
</Route>
</>
);
};

View file

@ -0,0 +1,12 @@
/*
* 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 const ORG_SOURCES_PATH = '/org/sources';
export const USERS_PATH = '/org/users';
export const ORG_SETTINGS_PATH = '/org/settings';
export const SETUP_GUIDE_PATH = '/setup_guide';
export const getSourcePath = (sourceId: string): string => `${ORG_SOURCES_PATH}/${sourceId}`;

View file

@ -0,0 +1,16 @@
/*
* 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 interface IAccount {
id: string;
isCurated?: boolean;
isAdmin: boolean;
canCreatePersonalSources: boolean;
groups: string[];
supportEligible: boolean;
}
export type TSpacerSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl';

View file

@ -22,6 +22,7 @@ import { LicensingPluginSetup } from '../../licensing/public';
import { getPublicUrl } from './applications/shared/enterprise_search_url';
import AppSearchLogo from './applications/app_search/assets/logo.svg';
import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg';
export interface ClientConfigType {
host?: string;
@ -58,7 +59,21 @@ export class EnterpriseSearchPlugin implements Plugin {
return renderApp(AppSearch, coreStart, params, config, plugins);
},
});
// TODO: Workplace Search will need to register its own plugin.
core.application.register({
id: 'workplaceSearch',
title: 'Workplace Search',
appRoute: '/app/enterprise_search/workplace_search',
category: DEFAULT_APP_CATEGORIES.enterpriseSearch,
mount: async (params: AppMountParameters) => {
const [coreStart] = await core.getStartServices();
const { renderApp } = await import('./applications');
const { WorkplaceSearch } = await import('./applications/workplace_search');
return renderApp(WorkplaceSearch, coreStart, params, config, plugins);
},
});
plugins.home.featureCatalogue.register({
id: 'appSearch',
@ -70,7 +85,17 @@ export class EnterpriseSearchPlugin implements Plugin {
category: FeatureCatalogueCategory.DATA,
showOnHomePage: true,
});
// TODO: Workplace Search will need to register its own feature catalogue section/card.
plugins.home.featureCatalogue.register({
id: 'workplaceSearch',
title: 'Workplace Search',
icon: WorkplaceSearchLogo,
description:
'Search all documents, files, and sources available across your virtual workplace.',
path: '/app/enterprise_search/workplace_search',
category: FeatureCatalogueCategory.DATA,
showOnHomePage: true,
});
}
public start(core: CoreStart) {}

View file

@ -4,20 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { loggingSystemMock } from 'src/core/server/mocks';
import { mockLogger } from '../../routes/__mocks__';
jest.mock('../../../../../../src/core/server', () => ({
SavedObjectsErrorHelpers: {
isNotFoundError: jest.fn(),
},
}));
import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry';
import { registerTelemetryUsageCollector } from './telemetry';
describe('App Search Telemetry Usage Collector', () => {
const mockLogger = loggingSystemMock.create().get();
const makeUsageCollectorStub = jest.fn();
const registerStub = jest.fn();
const usageCollectionMock = {
@ -103,41 +94,5 @@ describe('App Search Telemetry Usage Collector', () => {
},
});
});
it('should not throw but log a warning if saved objects errors', async () => {
const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any;
registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger);
// Without log warning (not found)
(SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true);
await makeUsageCollectorStub.mock.calls[0][0].fetch();
expect(mockLogger.warn).not.toHaveBeenCalled();
// With log warning
(SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false);
await makeUsageCollectorStub.mock.calls[0][0].fetch();
expect(mockLogger.warn).toHaveBeenCalledWith(
'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function'
);
});
});
describe('incrementUICounter', () => {
it('should increment the saved objects internal repository', async () => {
const response = await incrementUICounter({
savedObjects: savedObjectsMock,
uiAction: 'ui_clicked',
metric: 'button',
});
expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith(
'app_search_telemetry',
'app_search_telemetry',
'ui_clicked.button'
);
expect(response).toEqual({ success: true });
});
});
});

View file

@ -5,16 +5,10 @@
*/
import { get } from 'lodash';
import {
ISavedObjectsRepository,
SavedObjectsServiceStart,
SavedObjectAttributes,
Logger,
} from 'src/core/server';
import { SavedObjectsServiceStart, Logger } from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯
import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
import { getSavedObjectAttributesFromRepo } from '../lib/telemetry';
interface ITelemetry {
ui_viewed: {
@ -70,10 +64,11 @@ export const registerTelemetryUsageCollector = (
const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => {
const savedObjectsRepository = savedObjects.createInternalRepository();
const savedObjectAttributes = (await getSavedObjectAttributesFromRepo(
const savedObjectAttributes = await getSavedObjectAttributesFromRepo(
AS_TELEMETRY_NAME,
savedObjectsRepository,
log
)) as SavedObjectAttributes;
);
const defaultTelemetrySavedObject: ITelemetry = {
ui_viewed: {
@ -114,43 +109,3 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log
},
} as ITelemetry;
};
/**
* Helper function - fetches saved objects attributes
*/
const getSavedObjectAttributesFromRepo = async (
savedObjectsRepository: ISavedObjectsRepository,
log: Logger
) => {
try {
return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes;
} catch (e) {
if (!SavedObjectsErrorHelpers.isNotFoundError(e)) {
log.warn(`Failed to retrieve App Search telemetry data: ${e}`);
}
return null;
}
};
/**
* Set saved objection attributes - used by telemetry route
*/
interface IIncrementUICounter {
savedObjects: SavedObjectsServiceStart;
uiAction: string;
metric: string;
}
export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) {
const internalRepository = savedObjects.createInternalRepository();
await internalRepository.incrementCounter(
AS_TELEMETRY_NAME,
AS_TELEMETRY_NAME,
`${uiAction}.${metric}` // e.g., ui_viewed.setup_guide
);
return { success: true };
}

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockLogger } from '../../routes/__mocks__';
jest.mock('../../../../../../src/core/server', () => ({
SavedObjectsErrorHelpers: {
isNotFoundError: jest.fn(),
},
}));
import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry';
describe('App Search Telemetry Usage Collector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getSavedObjectAttributesFromRepo', () => {
// Note: savedObjectsRepository.get() is best tested as a whole from
// individual fetchTelemetryMetrics tests. This mostly just tests error handling
it('should not throw but log a warning if saved objects errors', async () => {
const errorSavedObjectsMock = {} as any;
// Without log warning (not found)
(SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true);
await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger);
expect(mockLogger.warn).not.toHaveBeenCalled();
// With log warning
(SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false);
await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger);
expect(mockLogger.warn).toHaveBeenCalledWith(
'Failed to retrieve some_id telemetry data: TypeError: savedObjectsRepository.get is not a function'
);
});
});
describe('incrementUICounter', () => {
const incrementCounterMock = jest.fn();
const savedObjectsMock = {
createInternalRepository: jest.fn(() => ({
incrementCounter: incrementCounterMock,
})),
} as any;
it('should increment the saved objects internal repository', async () => {
const response = await incrementUICounter({
id: 'app_search_telemetry',
savedObjects: savedObjectsMock,
uiAction: 'ui_clicked',
metric: 'button',
});
expect(incrementCounterMock).toHaveBeenCalledWith(
'app_search_telemetry',
'app_search_telemetry',
'ui_clicked.button'
);
expect(response).toEqual({ success: true });
});
});
});

View file

@ -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 {
ISavedObjectsRepository,
SavedObjectsServiceStart,
SavedObjectAttributes,
Logger,
} from 'src/core/server';
// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯
import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
/**
* Fetches saved objects attributes - used by collectors
*/
export const getSavedObjectAttributesFromRepo = async (
id: string, // Telemetry name
savedObjectsRepository: ISavedObjectsRepository,
log: Logger
): Promise<SavedObjectAttributes | null> => {
try {
return (await savedObjectsRepository.get(id, id)).attributes as SavedObjectAttributes;
} catch (e) {
if (!SavedObjectsErrorHelpers.isNotFoundError(e)) {
log.warn(`Failed to retrieve ${id} telemetry data: ${e}`);
}
return null;
}
};
/**
* Set saved objection attributes - used by telemetry route
*/
interface IIncrementUICounter {
id: string; // Telemetry name
savedObjects: SavedObjectsServiceStart;
uiAction: string;
metric: string;
}
export async function incrementUICounter({
id,
savedObjects,
uiAction,
metric,
}: IIncrementUICounter) {
const internalRepository = savedObjects.createInternalRepository();
await internalRepository.incrementCounter(
id,
id,
`${uiAction}.${metric}` // e.g., ui_viewed.setup_guide
);
return { success: true };
}

View file

@ -0,0 +1,101 @@
/*
* 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 '../../routes/__mocks__';
import { registerTelemetryUsageCollector } from './telemetry';
describe('Workplace 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.setup_guide': 10,
'ui_viewed.overview': 20,
'ui_error.cannot_connect': 3,
'ui_clicked.header_launch_button': 30,
'ui_clicked.org_name_change_button': 40,
'ui_clicked.onboarding_card_button': 50,
'ui_clicked.recent_activity_source_details_link': 60,
},
}),
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('workplace_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: {
setup_guide: 10,
overview: 20,
},
ui_error: {
cannot_connect: 3,
},
ui_clicked: {
header_launch_button: 30,
org_name_change_button: 40,
onboarding_card_button: 50,
recent_activity_source_details_link: 60,
},
});
});
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: {
setup_guide: 0,
overview: 0,
},
ui_error: {
cannot_connect: 0,
},
ui_clicked: {
header_launch_button: 0,
org_name_change_button: 0,
onboarding_card_button: 0,
recent_activity_source_details_link: 0,
},
});
});
});
});

View file

@ -0,0 +1,115 @@
/*
* 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: {
setup_guide: number;
overview: number;
};
ui_error: {
cannot_connect: number;
};
ui_clicked: {
header_launch_button: number;
org_name_change_button: number;
onboarding_card_button: number;
recent_activity_source_details_link: number;
};
}
export const WS_TELEMETRY_NAME = 'workplace_search_telemetry';
/**
* Register the telemetry collector
*/
export const registerTelemetryUsageCollector = (
usageCollection: UsageCollectionSetup,
savedObjects: SavedObjectsServiceStart,
log: Logger
) => {
const telemetryUsageCollector = usageCollection.makeUsageCollector<ITelemetry>({
type: 'workplace_search',
fetch: async () => fetchTelemetryMetrics(savedObjects, log),
isReady: () => true,
schema: {
ui_viewed: {
setup_guide: { type: 'long' },
overview: { type: 'long' },
},
ui_error: {
cannot_connect: { type: 'long' },
},
ui_clicked: {
header_launch_button: { type: 'long' },
org_name_change_button: { type: 'long' },
onboarding_card_button: { type: 'long' },
recent_activity_source_details_link: { 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(
WS_TELEMETRY_NAME,
savedObjectsRepository,
log
);
const defaultTelemetrySavedObject: ITelemetry = {
ui_viewed: {
setup_guide: 0,
overview: 0,
},
ui_error: {
cannot_connect: 0,
},
ui_clicked: {
header_launch_button: 0,
org_name_change_button: 0,
onboarding_card_button: 0,
recent_activity_source_details_link: 0,
},
};
// If we don't have an existing/saved telemetry object, return the default
if (!savedObjectAttributes) {
return defaultTelemetrySavedObject;
}
return {
ui_viewed: {
setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0),
overview: get(savedObjectAttributes, 'ui_viewed.overview', 0),
},
ui_error: {
cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0),
},
ui_clicked: {
header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0),
org_name_change_button: get(savedObjectAttributes, 'ui_clicked.org_name_change_button', 0),
onboarding_card_button: get(savedObjectAttributes, 'ui_clicked.onboarding_card_button', 0),
recent_activity_source_details_link: get(
savedObjectAttributes,
'ui_clicked.recent_activity_source_details_link',
0
),
},
} as ITelemetry;
};

View file

@ -22,10 +22,15 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve
import { ConfigType } from './';
import { checkAccess } from './lib/check_access';
import { registerPublicUrlRoute } from './routes/enterprise_search/public_url';
import { registerEnginesRoute } from './routes/app_search/engines';
import { registerTelemetryRoute } from './routes/app_search/telemetry';
import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry';
import { registerTelemetryRoute } from './routes/enterprise_search/telemetry';
import { appSearchTelemetryType } from './saved_objects/app_search/telemetry';
import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry';
import { registerEnginesRoute } from './routes/app_search/engines';
import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry';
import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry';
import { registerWSOverviewRoute } from './routes/workplace_search/overview';
export interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
@ -64,8 +69,8 @@ export class EnterpriseSearchPlugin implements Plugin {
order: 0,
icon: 'logoEnterpriseSearch',
navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId
app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
app: ['kibana', 'appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch'
catalogue: ['appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch'
privileges: null,
});
@ -75,15 +80,16 @@ export class EnterpriseSearchPlugin implements Plugin {
capabilities.registerSwitcher(async (request: KibanaRequest) => {
const dependencies = { config, security, request, log: this.logger };
const { hasAppSearchAccess } = await checkAccess(dependencies);
// TODO: hasWorkplaceSearchAccess
const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies);
return {
navLinks: {
appSearch: hasAppSearchAccess,
workplaceSearch: hasWorkplaceSearchAccess,
},
catalogue: {
appSearch: hasAppSearchAccess,
workplaceSearch: hasWorkplaceSearchAccess,
},
};
});
@ -96,23 +102,24 @@ export class EnterpriseSearchPlugin implements Plugin {
registerPublicUrlRoute(dependencies);
registerEnginesRoute(dependencies);
registerWSOverviewRoute(dependencies);
/**
* Bootstrap the routes, saved objects, and collector for telemetry
*/
savedObjects.registerType(appSearchTelemetryType);
savedObjects.registerType(workplaceSearchTelemetryType);
let savedObjectsStarted: SavedObjectsServiceStart;
getStartServices().then(([coreStart]) => {
savedObjectsStarted = coreStart.savedObjects;
if (usageCollection) {
registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
}
});
registerTelemetryRoute({
...dependencies,
getSavedObjectsService: () => savedObjectsStarted,
});
registerTelemetryRoute({ ...dependencies, getSavedObjectsService: () => savedObjectsStarted });
}
public start() {}

View file

@ -7,20 +7,21 @@
import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks';
import { MockRouter, mockConfig, mockLogger } from '../__mocks__';
import { registerTelemetryRoute } from './telemetry';
jest.mock('../../collectors/app_search/telemetry', () => ({
jest.mock('../../collectors/lib/telemetry', () => ({
incrementUICounter: jest.fn(),
}));
import { incrementUICounter } from '../../collectors/app_search/telemetry';
import { incrementUICounter } from '../../collectors/lib/telemetry';
import { registerTelemetryRoute } from './telemetry';
/**
* Since these route callbacks are so thin, these serve simply as integration tests
* to ensure they're wired up to the collector functions correctly. Business logic
* is tested more thoroughly in the collectors/telemetry tests.
*/
describe('App Search Telemetry API', () => {
describe('Enterprise Search Telemetry API', () => {
let mockRouter: MockRouter;
const successResponse = { success: true };
beforeEach(() => {
jest.clearAllMocks();
@ -34,14 +35,20 @@ describe('App Search Telemetry API', () => {
});
});
describe('PUT /api/app_search/telemetry', () => {
it('increments the saved objects counter', async () => {
const successResponse = { success: true };
describe('PUT /api/enterprise_search/telemetry', () => {
it('increments the saved objects counter for App Search', async () => {
(incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse));
await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } });
await mockRouter.callRoute({
body: {
product: 'app_search',
action: 'viewed',
metric: 'setup_guide',
},
});
expect(incrementUICounter).toHaveBeenCalledWith({
id: 'app_search_telemetry',
savedObjects: expect.any(Object),
uiAction: 'ui_viewed',
metric: 'setup_guide',
@ -49,10 +56,36 @@ describe('App Search Telemetry API', () => {
expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse });
});
it('increments the saved objects counter for Workplace Search', async () => {
(incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse));
await mockRouter.callRoute({
body: {
product: 'workplace_search',
action: 'clicked',
metric: 'onboarding_card_button',
},
});
expect(incrementUICounter).toHaveBeenCalledWith({
id: 'workplace_search_telemetry',
savedObjects: expect.any(Object),
uiAction: 'ui_clicked',
metric: 'onboarding_card_button',
});
expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse });
});
it('throws an error when incrementing fails', async () => {
(incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed')));
await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } });
await mockRouter.callRoute({
body: {
product: 'enterprise_search',
action: 'error',
metric: 'error',
},
});
expect(incrementUICounter).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalled();
@ -73,34 +106,50 @@ describe('App Search Telemetry API', () => {
expect(mockRouter.response.internalError).toHaveBeenCalled();
expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual(
expect.stringContaining(
'App Search UI telemetry error: Error: Could not find Saved Objects service'
'Enterprise Search UI telemetry error: Error: Could not find Saved Objects service'
)
);
});
describe('validates', () => {
it('correctly', () => {
const request = { body: { action: 'viewed', metric: 'setup_guide' } };
const request = {
body: { product: 'workplace_search', action: 'viewed', metric: 'setup_guide' },
};
mockRouter.shouldValidate(request);
});
it('wrong product string', () => {
const request = {
body: { product: 'workspace_space_search', action: 'viewed', metric: 'setup_guide' },
};
mockRouter.shouldThrow(request);
});
it('wrong action string', () => {
const request = { body: { action: 'invalid', metric: 'setup_guide' } };
const request = {
body: { product: 'app_search', action: 'invalid', metric: 'setup_guide' },
};
mockRouter.shouldThrow(request);
});
it('wrong metric type', () => {
const request = { body: { action: 'clicked', metric: true } };
const request = { body: { product: 'enterprise_search', action: 'clicked', metric: true } };
mockRouter.shouldThrow(request);
});
it('product is missing string', () => {
const request = { body: { action: 'viewed', metric: 'setup_guide' } };
mockRouter.shouldThrow(request);
});
it('action is missing', () => {
const request = { body: { metric: 'engines_overview' } };
const request = { body: { product: 'app_search', metric: 'engines_overview' } };
mockRouter.shouldThrow(request);
});
it('metric is missing', () => {
const request = { body: { action: 'error' } };
const request = { body: { product: 'app_search', action: 'error' } };
mockRouter.shouldThrow(request);
});
});

View file

@ -7,7 +7,15 @@
import { schema } from '@kbn/config-schema';
import { IRouteDependencies } from '../../plugin';
import { incrementUICounter } from '../../collectors/app_search/telemetry';
import { incrementUICounter } from '../../collectors/lib/telemetry';
import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry';
import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry';
const productToTelemetryMap = {
app_search: AS_TELEMETRY_NAME,
workplace_search: WS_TELEMETRY_NAME,
enterprise_search: 'TODO',
};
export function registerTelemetryRoute({
router,
@ -16,9 +24,14 @@ export function registerTelemetryRoute({
}: IRouteDependencies) {
router.put(
{
path: '/api/app_search/telemetry',
path: '/api/enterprise_search/telemetry',
validate: {
body: schema.object({
product: schema.oneOf([
schema.literal('app_search'),
schema.literal('workplace_search'),
schema.literal('enterprise_search'),
]),
action: schema.oneOf([
schema.literal('viewed'),
schema.literal('clicked'),
@ -29,21 +42,24 @@ export function registerTelemetryRoute({
},
},
async (ctx, request, response) => {
const { action, metric } = request.body;
const { product, action, metric } = request.body;
try {
if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service');
return response.ok({
body: await incrementUICounter({
id: productToTelemetryMap[product],
savedObjects: getSavedObjectsService(),
uiAction: `ui_${action}`,
metric,
}),
});
} catch (e) {
log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`);
return response.internalError({ body: 'App Search UI telemetry failed' });
log.error(
`Enterprise Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`
);
return response.internalError({ body: 'Enterprise Search UI telemetry failed' });
}
}
);

View file

@ -0,0 +1,127 @@
/*
* 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 { MockRouter, mockConfig, mockLogger } from '../__mocks__';
import { registerWSOverviewRoute } from './overview';
jest.mock('node-fetch');
const fetch = jest.requireActual('node-fetch');
const { Response } = fetch;
const fetchMock = require('node-fetch') as jest.Mocked<typeof fetch>;
const ORG_ROUTE = 'http://localhost:3002/ws/org';
describe('engine routes', () => {
describe('GET /api/workplace_search/overview', () => {
const AUTH_HEADER = 'Basic 123';
const mockRequest = {
headers: {
authorization: AUTH_HEADER,
},
query: {},
};
const mockRouter = new MockRouter({ method: 'get', payload: 'query' });
beforeEach(() => {
jest.clearAllMocks();
mockRouter.createRouter();
registerWSOverviewRoute({
router: mockRouter.router,
log: mockLogger,
config: mockConfig,
});
});
describe('when the underlying Workplace Search API returns a 200', () => {
beforeEach(() => {
WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, {
headers: { Authorization: AUTH_HEADER },
}).andReturn({ accountsCount: 1 });
});
it('should return 200 with a list of overview from the Workplace Search API', async () => {
await mockRouter.callRoute(mockRequest);
expect(mockRouter.response.ok).toHaveBeenCalledWith({
body: { accountsCount: 1 },
headers: { 'content-type': 'application/json' },
});
});
});
describe('when the Workplace Search URL is invalid', () => {
beforeEach(() => {
WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, {
headers: { Authorization: AUTH_HEADER },
}).andReturnError();
});
it('should return 404 with a message', async () => {
await mockRouter.callRoute(mockRequest);
expect(mockRouter.response.notFound).toHaveBeenCalledWith({
body: 'cannot-connect',
});
expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to Workplace Search: Failed');
expect(mockLogger.debug).not.toHaveBeenCalled();
});
});
describe('when the Workplace Search API returns invalid data', () => {
beforeEach(() => {
WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, {
headers: { Authorization: AUTH_HEADER },
}).andReturnInvalidData();
});
it('should return 404 with a message', async () => {
await mockRouter.callRoute(mockRequest);
expect(mockRouter.response.notFound).toHaveBeenCalledWith({
body: 'cannot-connect',
});
expect(mockLogger.error).toHaveBeenCalledWith(
'Cannot connect to Workplace Search: Error: Invalid data received from Workplace Search: {"foo":"bar"}'
);
expect(mockLogger.debug).toHaveBeenCalled();
});
});
const WorkplaceSearchAPI = {
shouldBeCalledWith(expectedUrl: string, expectedParams: object) {
return {
andReturn(response: object) {
fetchMock.mockImplementation((url: string, params: object) => {
expect(url).toEqual(expectedUrl);
expect(params).toEqual(expectedParams);
return Promise.resolve(new Response(JSON.stringify(response)));
});
},
andReturnInvalidData() {
fetchMock.mockImplementation((url: string, params: object) => {
expect(url).toEqual(expectedUrl);
expect(params).toEqual(expectedParams);
return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' })));
});
},
andReturnError() {
fetchMock.mockImplementation((url: string, params: object) => {
expect(url).toEqual(expectedUrl);
expect(params).toEqual(expectedParams);
return Promise.reject('Failed');
});
},
};
},
};
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 fetch from 'node-fetch';
import { IRouteDependencies } from '../../plugin';
export function registerWSOverviewRoute({ router, config, log }: IRouteDependencies) {
router.get(
{
path: '/api/workplace_search/overview',
validate: false,
},
async (context, request, response) => {
try {
const entSearchUrl = config.host as string;
const url = `${encodeURI(entSearchUrl)}/ws/org`;
const overviewResponse = await fetch(url, {
headers: { Authorization: request.headers.authorization as string },
});
const body = await overviewResponse.json();
const hasValidData = typeof body?.accountsCount === 'number';
if (hasValidData) {
return response.ok({
body,
headers: { 'content-type': 'application/json' },
});
} else {
// Either a completely incorrect Enterprise Search host URL was configured, or Workplace Search is returning bad data
throw new Error(`Invalid data received from Workplace Search: ${JSON.stringify(body)}`);
}
} catch (e) {
log.error(`Cannot connect to Workplace Search: ${e.toString()}`);
if (e instanceof Error) log.debug(e.stack as string);
return response.notFound({ body: 'cannot-connect' });
}
}
);
}

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 { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry';
export const workplaceSearchTelemetryType: SavedObjectsType = {
name: WS_TELEMETRY_NAME,
hidden: false,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {},
},
};

View file

@ -41,6 +41,43 @@
}
}
},
"workplace_search": {
"properties": {
"ui_viewed": {
"properties": {
"setup_guide": {
"type": "long"
},
"overview": {
"type": "long"
}
}
},
"ui_error": {
"properties": {
"cannot_connect": {
"type": "long"
}
}
},
"ui_clicked": {
"properties": {
"header_launch_button": {
"type": "long"
},
"org_name_change_button": {
"type": "long"
},
"onboarding_card_button": {
"type": "long"
},
"recent_activity_source_details_link": {
"type": "long"
}
}
}
}
},
"fileUploadTelemetry": {
"properties": {
"filesUploadedTotalCount": {

View file

@ -24,7 +24,7 @@ export default function enterpriseSearchSetupGuideTests({
});
describe('when no enterpriseSearch.host is configured', () => {
it('navigating to the enterprise_search plugin will redirect a user to the setup guide', async () => {
it('navigating to the plugin will redirect a user to the setup guide', async () => {
await PageObjects.appSearch.navigateToPage();
await retry.try(async function () {
const currentUrl = await browser.getCurrentUrl();

View file

@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
this.tags('ciGroup10');
loadTestFile(require.resolve('./app_search/setup_guide'));
loadTestFile(require.resolve('./workplace_search/setup_guide'));
});
}

View file

@ -0,0 +1,36 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function enterpriseSearchSetupGuideTests({
getService,
getPageObjects,
}: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const browser = getService('browser');
const retry = getService('retry');
const PageObjects = getPageObjects(['workplaceSearch']);
describe('Setup Guide', function () {
before(async () => await esArchiver.load('empty_kibana'));
after(async () => {
await esArchiver.unload('empty_kibana');
});
describe('when no enterpriseSearch.host is configured', () => {
it('navigating to the plugin will redirect a user to the setup guide', async () => {
await PageObjects.workplaceSearch.navigateToPage();
await retry.try(async function () {
const currentUrl = await browser.getCurrentUrl();
expect(currentUrl).to.contain('/workplace_search/setup_guide');
});
});
});
});
}

View file

@ -6,8 +6,10 @@
import { pageObjects as basePageObjects } from '../../functional/page_objects';
import { AppSearchPageProvider } from './app_search';
import { WorkplaceSearchPageProvider } from './workplace_search';
export const pageObjects = {
...basePageObjects,
appSearch: AppSearchPageProvider,
workplaceSearch: WorkplaceSearchPageProvider,
};

View file

@ -0,0 +1,17 @@
/*
* 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 { FtrProviderContext } from '../ftr_provider_context';
export function WorkplaceSearchPageProvider({ getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common']);
return {
async navigateToPage(): Promise<void> {
return await PageObjects.common.navigateToApp('enterprise_search/workplace_search');
},
};
}

View file

@ -50,9 +50,10 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
expect(uiCapabilities.success).to.be(true);
expect(uiCapabilities.value).to.have.property('catalogue');
// everything except ml and monitoring and enterprise search is enabled
const exceptions = ['ml', 'monitoring', 'appSearch', 'workplaceSearch'];
const expected = mapValues(
uiCapabilities.value!.catalogue,
(enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId)
(enabled, catalogueId) => !exceptions.includes(catalogueId)
);
expect(uiCapabilities.value!.catalogue).to.eql(expected);
break;

View file

@ -51,7 +51,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', 'enterpriseSearch', 'appSearch')
navLinksBuilder.except(
'ml',
'monitoring',
'enterpriseSearch',
'appSearch',
'workplaceSearch'
)
);
break;
case 'superuser at nothing_space':

View file

@ -48,9 +48,10 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
expect(uiCapabilities.success).to.be(true);
expect(uiCapabilities.value).to.have.property('catalogue');
// everything except ml and monitoring and enterprise search is enabled
const exceptions = ['ml', 'monitoring', 'appSearch', 'workplaceSearch'];
const expected = mapValues(
uiCapabilities.value!.catalogue,
(enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId)
(enabled, catalogueId) => !exceptions.includes(catalogueId)
);
expect(uiCapabilities.value!.catalogue).to.eql(expected);
break;

View file

@ -49,7 +49,7 @@ 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')
navLinksBuilder.except('ml', 'monitoring', 'appSearch', 'workplaceSearch')
);
break;
case 'foo_all':