diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 8c316c848184b9..31ee304fe22477 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -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 diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index c134131caba75c..fc9a47717871b2 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -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; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts index 14fde357a980a0..6f82946c0ea145 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -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 diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index dfcda544459d44..1e0df1326c1772 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -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 ); }; + +/** + * 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(, { http: { get: () => someData } }); + */ +export const mountWithAsyncContext = async ( + children: React.ReactNode, + context: object +): Promise => { + 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'); + } +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts index 767a52a75d1fbb..2bcdd42c380554 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx index 12bf0035641039..25a9fa7430c40c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -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(); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx index d8eeff2aba1c69..7ac02082ee75c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -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 ( @@ -26,68 +22,8 @@ export const ErrorState: React.FC = () => { - - - - - } - titleSize="l" - body={ - <> -

- {enterpriseSearchUrl}, - }} - /> -

-
    -
  1. - config/kibana.yml, - }} - /> -
  2. -
  3. - -
  4. -
  5. - [enterpriseSearch][plugins], - }} - /> -
  6. -
- - } - actions={ - - - - } - /> + +
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index 4d2a2ea1df9aa9..45ab5dc5b9ab10 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -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( - - - - - - - - ); - - // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly - expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + const wrapper = shallow(); + + expect(wrapper.find(LoadingState)).toHaveLength(1); }); it('isEmpty', async () => { - const wrapper = await mountWithApiMock({ - get: () => ({ - results: [], - meta: { page: { total_results: 0 } }, - }), + const wrapper = await mountWithAsyncContext(, { + 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(, { + 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', () => { - expect(wrapper.find(EngineTable)).toHaveLength(1); - }); + it('renders and calls the engines API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); - it('calls the engines API', () => { + expect(wrapper.find(EngineTable)).toHaveLength(1); expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { query: { type: 'indexed', @@ -97,19 +91,42 @@ describe('EngineOverview', () => { }); }); + describe('when on a platinum license', () => { + it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + license: { type: 'platinum', isActive: true }, + }); + + expect(wrapper.find(EngineTable)).toHaveLength(2); + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + }); + describe('pagination', () => { - const getTablePagination: () => IEngineTablePagination = () => - wrapper.find(EngineTable).first().prop('pagination'); + const getTablePagination = (wrapper: ReactWrapper) => + wrapper.find(EngineTable).prop('pagination'); - it('passes down page data from the API', () => { - const pagination = getTablePagination(); + it('passes down page data from the API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + const pagination = getTablePagination(wrapper); 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)); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + await act(async () => getTablePagination(wrapper).onPaginate(5)); wrapper.update(); expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { @@ -118,54 +135,8 @@ describe('EngineOverview', () => { pageIndex: 5, }, }); - expect(getTablePagination().pageIndex).toEqual(4); - }); - }); - - describe('when on a platinum license', () => { - beforeAll(async () => { - mockApi.mockClear(); - wrapper = await mountWithApiMock({ - 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', - pageIndex: 1, - }, - }); + expect(getTablePagination(wrapper).pageIndex).toEqual(4); }); }); }); - - /** - * Test helpers - */ - - const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => { - let wrapper: ReactWrapper | undefined; - const httpMock = { ...mockKibanaContext.http, get }; - - // 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(, { http: httpMock, license }); - }); - 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'); - } - }; }); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx index 1aead8468ca3b0..70e16e61846b46 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -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(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx new file mode 100644 index 00000000000000..29b773b80158af --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx @@ -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(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx new file mode 100644 index 00000000000000..81455cea0b497a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx @@ -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 ( + + + + } + titleSize="l" + body={ + <> +

+ {enterpriseSearchUrl}, + }} + /> +

+
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + [enterpriseSearch][plugins], + }} + /> +
  6. +
+ + } + actions={ + + + + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts new file mode 100644 index 00000000000000..1012fdf4126a2e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts index 7ea73577c4de6f..70aa723d626018 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -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'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts index 8f72875a32bd4d..b57fdfdbb75caf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -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]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts index cf8bbbc593f2f7..c4ef68704b7e0e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx index 530117e1976160..e54f1a12b73cb9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx @@ -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 = ({ - text, - isRoot, -}) => { +export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { const history = useHistory(); const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; @@ -41,3 +43,16 @@ export const SetAppSearchBreadcrumbs: React.FC = ({ 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; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts index f871f48b171548..eadf7fa8055906 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -6,3 +6,4 @@ export { sendTelemetry } from './send_telemetry'; export { SendAppSearchTelemetry } from './send_telemetry'; +export { SendWorkplaceSearchTelemetry } from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index 9825c0d8ab889d..3c873dbc25e377 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -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(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"workplace_search","action":"viewed","metric":"page"}', }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx index 300cb182727174..715d61b31512c2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -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 = ({ action, metric }) => { @@ -48,3 +47,13 @@ export const SendAppSearchTelemetry: React.FC = ({ action, return null; }; + +export const SendWorkplaceSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'workplace_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts new file mode 100644 index 00000000000000..3f28710d922959 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -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; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png new file mode 100644 index 00000000000000..b6267b6e2c48e6 Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png differ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg new file mode 100644 index 00000000000000..e6b987c3982686 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx new file mode 100644 index 00000000000000..ab5cd7f0de90fb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx @@ -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(); + + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx new file mode 100644 index 00000000000000..9fa508d599425e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx @@ -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 ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts new file mode 100644 index 00000000000000..b4d58bab58ff1c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts new file mode 100644 index 00000000000000..9ee1b444ee8172 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx new file mode 100644 index 00000000000000..1d7c565935e970 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx @@ -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(); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('renders an action button', () => { + const wrapper = shallow(); + 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(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx new file mode 100644 index 00000000000000..288c0be84fa9aa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx @@ -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 = ({ + 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 ( + + + {title}} + body={description} + actions={ + complete ? ( + {actionTitle} + ) : ( + {actionTitle} + ) + } + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx new file mode 100644 index 00000000000000..6174dc1c795eb1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx @@ -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(); + + 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( + + ); + + 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( + + ); + + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined); + }); + }); + + describe('Users & Invitations', () => { + it('renders 0 users when not on federated auth', () => { + const wrapper = shallow( + + ); + + 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( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Nice, you’ve invited colleagues to search with you.' + ); + }); + + it('disables link when the user cannot create invitations', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined); + }); + }); + + describe('Org Name', () => { + it('renders button to change name', () => { + const wrapper = shallow(); + + 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( + + ); + + expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx new file mode 100644 index 00000000000000..1b003474373382 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx @@ -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, you’ve 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 = ({ + 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 ( + + + 0 ? 'more' : '' }, + } + )} + actionPath={sourcesPath} + complete={hasOrgSources} + /> + {!isFederatedAuth && ( + 0 ? 'more' : '' }, + } + )} + actionPath={accountsPath} + complete={hasUsers} + /> + )} + + {name === defaultOrgName && ( + <> + + + + )} + + ); +}; + +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 ( + + + + + + + +

+ +

+
+
+ + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx new file mode 100644 index 00000000000000..112e9a910667ae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx @@ -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(); + + expect(wrapper.find(StatisticCard)).toHaveLength(2); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2); + }); + + it('renders additional cards for federated auth', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(4); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx new file mode 100644 index 00000000000000..aa9be81f32baed --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx @@ -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 = ({ + sourcesCount, + pendingInvitationsCount, + accountsCount, + personalSourcesCount, + isFederatedAuth, +}) => ( + + } + headerSpacer="m" + > + + + {!isFederatedAuth && ( + <> + + + + )} + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx new file mode 100644 index 00000000000000..e5e5235c523686 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx @@ -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(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithAsyncContext(, { + 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(, { + 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(, { + http: { ...mockHttp, get: mockApi }, + }); + + expect(wrapper.find(OnboardingSteps)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx new file mode 100644 index 00000000000000..bacd65a2be75f4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx @@ -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 ; + if (isLoading) return ; + + 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 ( + + + + + + } + /> + {!hideOnboarding && } + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss new file mode 100644 index 00000000000000..2d1e474c03faaa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss @@ -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; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx new file mode 100644 index 00000000000000..e9bdedb199dada --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx @@ -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(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + + // Branch coverage - renders without error for custom org name + shallow(); + }); + + it('renders an activity feed with links', () => { + const wrapper = shallow(); + 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(); + + expect(wrapper.find('.activity--error')).toHaveLength(1); + expect(wrapper.find('.activity--error__label')).toHaveLength(1); + expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx new file mode 100644 index 00000000000000..8d69582c936842 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx @@ -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 = ({ + organization: { name, defaultOrgName }, + activityFeed, +}) => { + return ( + + } + headerSpacer="m" + > + + {activityFeed.length > 0 ? ( + <> + {activityFeed.map((props: IFeedActivity, index) => ( + + ))} + + ) : ( + <> + + + {name === defaultOrgName ? ( + + ) : ( + + )} + + } + /> + + + )} + + + ); +}; + +export const RecentActivityItem: React.FC = ({ + 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 ( +
+
+ + {id} {message} + {status === 'error' && ( + + {' '} + + + )} + +
+
{moment.utc(timestamp).fromNow()}
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx new file mode 100644 index 00000000000000..edf266231b39ef --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx @@ -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(); + + expect(wrapper.find(EuiCard)).toHaveLength(1); + }); + + it('renders clickable card', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx new file mode 100644 index 00000000000000..9bc8f4f768073f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx @@ -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 = ({ 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 ( + + + {count} + + } + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts new file mode 100644 index 00000000000000..c367424d375f9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 00000000000000..b87c35d5a5942d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx @@ -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(); + + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx new file mode 100644 index 00000000000000..5b5d067d23eb8f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx @@ -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 ( + + + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.setupGuide.imageAlt', + + + +

+ +

+
+ + + Get started with Workplace Search + + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg new file mode 100644 index 00000000000000..f8d2ea1e634f60 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx new file mode 100644 index 00000000000000..f406fb136f13fd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx @@ -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:
, + testSubj: 'contentSection', + className: 'test', +}; + +describe('ContentSection', () => { + it('renders', () => { + const wrapper = shallow(); + + 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(); + + expect(wrapper.find(EuiTitle)).toHaveLength(1); + expect(wrapper.find('p').text()).toEqual('bar'); + }); + + it('displays header content', () => { + const wrapper = shallow( + } + /> + ); + + expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s'); + expect(wrapper.find('.header')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx new file mode 100644 index 00000000000000..b2a9eebc72e857 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -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 = ({ + children, + className = '', + title, + description, + headerChildren, + headerSpacer, + testSubj, +}) => ( +
+ {title && ( + <> + +

{title}

+
+ {description &&

{description}

} + {headerChildren} + {headerSpacer && } + + )} + {children} +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts new file mode 100644 index 00000000000000..7dcb1b13ad1dc7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts new file mode 100644 index 00000000000000..745639955dcbaa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss new file mode 100644 index 00000000000000..008a8066f807b3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss @@ -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; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx new file mode 100644 index 00000000000000..8d168b436cc3ba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx @@ -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(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx new file mode 100644 index 00000000000000..399abedf55e874 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx @@ -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 = () => ( +
+ +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts new file mode 100644 index 00000000000000..c41e27bacb8920 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx new file mode 100644 index 00000000000000..429a2c509813db --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx @@ -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(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(FormattedMessage)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + (sendTelemetry as jest.Mock).mockClear(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx new file mode 100644 index 00000000000000..5b86e14132e0fe --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx @@ -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 ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts new file mode 100644 index 00000000000000..cb9684408c4596 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx new file mode 100644 index 00000000000000..48b8695f82b43b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx @@ -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 }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts new file mode 100644 index 00000000000000..774b3d85c8c859 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx new file mode 100644 index 00000000000000..4680f15771caab --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx @@ -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(); + + expect(wrapper.find('h2').text()).toEqual('Header'); + expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart'); + }); + + it('shows description, when present', () => { + const wrapper = shallow(); + + expect(wrapper.find('p').text()).toEqual('Hello World'); + }); + + it('shows action, when present', () => { + const wrapper = shallow(} />); + + expect(wrapper.find('.action')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx new file mode 100644 index 00000000000000..0408517fd4ec5d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx @@ -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 = ({ + title, + description, + action, + alignItems = 'center', +}) => ( + <> + + + +

{title}

+
+ {description && ( + +

{description}

+
+ )} +
+ {action && {action}} +
+ + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx new file mode 100644 index 00000000000000..743080d965c36c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -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(); + + 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(); + + expect(wrapper.find(Overview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx new file mode 100644 index 00000000000000..36b1a56ecba262 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -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 ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts new file mode 100644 index 00000000000000..d9798d1f30cfcc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -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}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts new file mode 100644 index 00000000000000..b448c59c52f3e3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index fbfcc303de47a2..fc95828a3f4a4f 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -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) {} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index e95056b8713248..53c6dee61cd1dc 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -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 }); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts index a10f96907ad28a..f700088cb67a03 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -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 }; -} diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts new file mode 100644 index 00000000000000..3ab3b03dd77252 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts @@ -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 }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts new file mode 100644 index 00000000000000..f5f4fa368555fb --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts @@ -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 => { + 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 }; +} diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts new file mode 100644 index 00000000000000..496b2f254f9a6e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts @@ -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, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts new file mode 100644 index 00000000000000..892de5cfee35e0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts @@ -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({ + 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; +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 70be8600862e9c..a7bd68f92f78b9 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -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() {} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts similarity index 56% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index e2d5fbcec37056..ebd84d3e0e79ab 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -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); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts similarity index 55% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts index 4cc9b64adc0927..7ed1d7b17753c3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts @@ -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' }); } } ); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts new file mode 100644 index 00000000000000..b1b55397953575 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -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; + +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'); + }); + }, + }; + }, + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts new file mode 100644 index 00000000000000..d1e2f4f5f180d8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts @@ -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' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts new file mode 100644 index 00000000000000..86315a9d617e41 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts @@ -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: {}, + }, +}; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index fbef75b9aa9cce..899ece7bce3125 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -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": { diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts index 1d478c6baf29cb..76a47cc4a7e105 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts @@ -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(); diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts index 31a92e752fcf4e..ebfdca780c1279 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts @@ -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')); }); } diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts new file mode 100644 index 00000000000000..20145306b21c8e --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts @@ -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'); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts index 009fb264824195..87de26b6feda0d 100644 --- a/x-pack/test/functional_enterprise_search/page_objects/index.ts +++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts @@ -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, }; diff --git a/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts b/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts new file mode 100644 index 00000000000000..f97ad2af581119 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts @@ -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 { + return await PageObjects.common.navigateToApp('enterprise_search/workplace_search'); + }, + }; +} diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index 0e0d46c6ce2cd2..0d5c553a786fa5 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -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; diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index 08a7d789153e77..0133a2fafb129f 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -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': diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts index 99f91407dc1d2b..9ed1c890bf57f4 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -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; diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts index d3bd2e1afd357c..18838e536cf96d 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts @@ -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':