From c107ccb937fd67299dca097a8960cbb1df4e0284 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 3 Mar 2020 16:43:31 -0800 Subject: [PATCH 01/48] Initial App Search in Kibana plugin work - Initializes a new platform plugin that ships out of the box w/ x-pack - Contains a very basic front-end that shows AS engines, error states, or a Setup Guide - Contains a very basic server that remotely calls the AS internal engines API and returns results --- x-pack/plugins/app_search/kibana.json | 8 + .../app_search/public/applications/app.tsx | 17 ++ .../components/empty_states/empty_state.tsx | 46 +++++ .../components/empty_states/empty_states.scss | 21 +++ .../components/empty_states/error_state.tsx | 55 ++++++ .../components/empty_states/index.ts | 10 + .../components/empty_states/loading_state.tsx | 28 +++ .../components/empty_states/no_user_state.tsx | 49 +++++ .../components/empty_states/types.ts | 10 + .../engine_overview/engine_overview.scss | 27 +++ .../engine_overview/engine_overview.tsx | 151 +++++++++++++++ .../engine_overview/engine_table.tsx | 105 +++++++++++ .../components/engine_overview/index.ts | 7 + .../engine_overview_header.tsx | 38 ++++ .../engine_overview_header/index.ts | 7 + .../applications/components/main/index.ts | 7 + .../applications/components/main/main.tsx | 24 +++ .../components/setup_guide/index.ts | 7 + .../components/setup_guide/setup_guide.scss | 53 ++++++ .../components/setup_guide/setup_guide.tsx | 175 ++++++++++++++++++ .../public/applications/utils/get_username.ts | 20 ++ .../app_search/public/assets/engine.svg | 3 + .../public/assets/getting_started.png | Bin 0 -> 92044 bytes .../app_search/public/assets/meta_engine.svg | 4 + x-pack/plugins/app_search/public/index.ts | 12 ++ x-pack/plugins/app_search/public/plugin.ts | 49 +++++ x-pack/plugins/app_search/server/index.ts | 26 +++ x-pack/plugins/app_search/server/plugin.ts | 35 ++++ .../app_search/server/routes/engines.ts | 45 +++++ 29 files changed, 1039 insertions(+) create mode 100644 x-pack/plugins/app_search/kibana.json create mode 100644 x-pack/plugins/app_search/public/applications/app.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/empty_states/empty_state.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/empty_states/empty_states.scss create mode 100644 x-pack/plugins/app_search/public/applications/components/empty_states/error_state.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/empty_states/index.ts create mode 100644 x-pack/plugins/app_search/public/applications/components/empty_states/loading_state.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/empty_states/no_user_state.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/empty_states/types.ts create mode 100644 x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.scss create mode 100644 x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/engine_overview/engine_table.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/engine_overview/index.ts create mode 100644 x-pack/plugins/app_search/public/applications/components/engine_overview_header/engine_overview_header.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/engine_overview_header/index.ts create mode 100644 x-pack/plugins/app_search/public/applications/components/main/index.ts create mode 100644 x-pack/plugins/app_search/public/applications/components/main/main.tsx create mode 100644 x-pack/plugins/app_search/public/applications/components/setup_guide/index.ts create mode 100644 x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.scss create mode 100644 x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/app_search/public/applications/utils/get_username.ts create mode 100644 x-pack/plugins/app_search/public/assets/engine.svg create mode 100644 x-pack/plugins/app_search/public/assets/getting_started.png create mode 100644 x-pack/plugins/app_search/public/assets/meta_engine.svg create mode 100644 x-pack/plugins/app_search/public/index.ts create mode 100644 x-pack/plugins/app_search/public/plugin.ts create mode 100644 x-pack/plugins/app_search/server/index.ts create mode 100644 x-pack/plugins/app_search/server/plugin.ts create mode 100644 x-pack/plugins/app_search/server/routes/engines.ts diff --git a/x-pack/plugins/app_search/kibana.json b/x-pack/plugins/app_search/kibana.json new file mode 100644 index 0000000000000..6dd452aca8d8d --- /dev/null +++ b/x-pack/plugins/app_search/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "appSearch", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["appSearch"], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/app_search/public/applications/app.tsx b/x-pack/plugins/app_search/public/applications/app.tsx new file mode 100644 index 0000000000000..d6fbc582ab957 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/app.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 ReactDOM from 'react-dom'; +import { CoreStart, AppMountParams } from 'src/core/public'; +import { ClientConfigType } from '../plugin'; + +import { Main } from './components/main'; + +export const renderApp = (core: CoreStart, params: AppMountParams, config: ClientConfigType) => { + ReactDOM.render(
, params.element); + return () => ReactDOM.unmountComponentAtNode(params.element); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/empty_state.tsx b/x-pack/plugins/app_search/public/applications/components/empty_states/empty_state.tsx new file mode 100644 index 0000000000000..c120166a197b4 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/empty_states/empty_state.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 { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EngineOverviewHeader } from '../engine_overview_header'; +import { IEmptyStatesProps } from './types'; + +import './empty_states.scss'; + +export const EmptyState: React.FC = ({ appSearchUrl }) => { + return ( + + + + + There’s nothing here yet} + titleSize="l" + body={ +

+ Looks like you don’t have any App Search engines. +
Let’s create your first one now. +

+ } + actions={ + + Create your first Engine + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/empty_states.scss b/x-pack/plugins/app_search/public/applications/components/empty_states/empty_states.scss new file mode 100644 index 0000000000000..0c1170b8ac99f --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/empty_states/empty_states.scss @@ -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. + */ + +/** + * Empty/Error UI states + */ +.empty-state { + .euiPageContent { + min-height: 450px; + display: flex; + flex-direction: column; + justify-content: center; + } + + .euiEmptyPrompt > .euiIcon { + margin-bottom: 8px; + } +} diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/error_state.tsx b/x-pack/plugins/app_search/public/applications/components/empty_states/error_state.tsx new file mode 100644 index 0000000000000..f2d6d99d37822 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/empty_states/error_state.tsx @@ -0,0 +1,55 @@ +/* + * 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, + EuiEmptyPrompt, + EuiCode, + EuiButton, +} from '@elastic/eui'; + +import { EngineOverviewHeader } from '../engine_overview_header'; +import { IEmptyStatesProps } from './types'; + +import './empty_states.scss'; + +export const ErrorState: ReactFC = ({ appSearchUrl, showSetupGuideFlag }) => { + return ( + + + + + Cannot connect to App Search} + titleSize="l" + body={ + <> +

+ We cannot connect to the App Search instance at the configured host URL:{' '} + {appSearchUrl} +

+

+ Please ensure your App Search host URL is configured correctly within{' '} + config/kibana.yml. +

+ + } + actions={ + showSetupGuideFlag(true)}> + Review the setup guide + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/index.ts b/x-pack/plugins/app_search/public/applications/components/empty_states/index.ts new file mode 100644 index 0000000000000..d1b65a4729a87 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/empty_states/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { LoadingState } from './loading_state'; +export { EmptyState } from './empty_state'; +export { NoUserState } from './no_user_state'; +export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/loading_state.tsx b/x-pack/plugins/app_search/public/applications/components/empty_states/loading_state.tsx new file mode 100644 index 0000000000000..44d9e5d7ea64c --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/empty_states/loading_state.tsx @@ -0,0 +1,28 @@ +/* + * 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, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; + +import { EngineOverviewHeader } from '../engine_overview_header'; +import { IEmptyStatesProps } from './types'; + +import './empty_states.scss'; + +export const LoadingState: React.FC = ({ appSearchUrl }) => { + return ( + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/no_user_state.tsx b/x-pack/plugins/app_search/public/applications/components/empty_states/no_user_state.tsx new file mode 100644 index 0000000000000..0e41586d95aff --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/empty_states/no_user_state.tsx @@ -0,0 +1,49 @@ +/* + * 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, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; + +import { IEmptyStatesProps } from './types'; +import { EngineOverviewHeader } from '../engine_overview_header'; +import { getUserName } from '../../utils/get_username'; + +import './empty_states.scss'; + +export const NoUserState: React.FC = ({ appSearchUrl }) => { + const username = getUserName(); + + return ( + + + + + Cannot find App Search account} + titleSize="l" + body={ + <> +

+ We cannot find an App Search account matching your username + {username && ( + <> + : {username} + + )} + . +

+

+ Please contact your App Search administrator to request an invite for that user. +

+ + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/types.ts b/x-pack/plugins/app_search/public/applications/components/empty_states/types.ts new file mode 100644 index 0000000000000..eb9edb877b2c6 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/empty_states/types.ts @@ -0,0 +1,10 @@ +/* + * 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 IEmptyStatesProps { + appSearchUrl: string; + showSetupGuideFlag?(flag: boolean); +} diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.scss b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.scss new file mode 100644 index 0000000000000..a304139532d57 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.scss @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/** + * Engine Overview + */ +.engine-overview { + width: 100%; + + .euiPanel { + padding: 35px; + } + + .euiPageContent .euiPageContentHeader { + margin-bottom: 10px; + } + + .engine-icon { + display: inline-block; + width: 15px; + height: 15px; + margin-right: 5px; + } +} diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx new file mode 100644 index 0000000000000..977e44d24fd24 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_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, { useEffect, useState } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentBody, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; + +import EnginesIcon from '../../../assets/engine.svg'; +import MetaEnginesIcon from '../../../assets/meta_engine.svg'; + +import { LoadingState, EmptyState, NoUserState, ErrorState } from '../empty_states'; +import { EngineOverviewHeader } from '../engine_overview_header'; +import { EngineTable } from './engine_table'; + +import './engine_overview.scss'; + +interface IEngineOverviewProps { + appSearchUrl: string; + showSetupGuideFlag(); + http(); +} + +export const EngineOverview: ReactFC = ({ http, ...props }) => { + const [isLoading, setIsLoading] = useState(true); + const [hasNoAccount, setHasNoAccount] = useState(false); + const [hasErrorConnecting, setHasErrorConnecting] = useState(false); + + const [engines, setEngines] = useState([]); + const [enginesPage, setEnginesPage] = useState(1); + const [enginesTotal, setEnginesTotal] = useState(0); + const [metaEngines, setMetaEngines] = useState([]); + const [metaEnginesPage, setMetaEnginesPage] = useState(1); + const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); + + const getEnginesData = ({ type, pageIndex }) => { + return http.get('../api/appsearch/engines', { + query: { type, pageIndex }, + }); + }; + const hasValidData = response => { + return response && response.results && response.meta; + }; + const hasNoAccountError = response => { + return response && response.message === 'no-as-account'; + }; + const setEnginesData = (params, callbacks) => { + getEnginesData(params) + .then(response => { + if (!hasValidData(response)) { + if (hasNoAccountError(response)) { + return setHasNoAccount(true); + } + throw new Error('App Search engines response is missing valid data'); + } + + callbacks.setResults(response.results); + callbacks.setResultsTotal(response.meta.page.total_results); + setIsLoading(false); + }) + .catch(error => { + // TODO - should we be logging errors to telemetry or elsewhere for debugging? + setHasErrorConnecting(true); + }); + }; + + useEffect(() => { + const params = { type: 'indexed', pageIndex: enginesPage }; + const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal }; + + setEnginesData(params, callbacks); + }, [enginesPage]); // eslint-disable-line + // TODO: https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies + + useEffect(() => { + const params = { type: 'meta', pageIndex: metaEnginesPage }; + const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal }; + + setEnginesData(params, callbacks); + }, [metaEnginesPage]); // eslint-disable-line + // TODO: https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies + + if (hasErrorConnecting) return ; + if (hasNoAccount) return ; + if (isLoading) return ; + if (!engines.length) return ; + + return ( + + + + + + + +

+ + Engines +

+
+
+ + + + + {metaEngines.length > 0 && ( + <> + + + +

+ + Meta Engines +

+
+
+ + + + + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_table.tsx b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_table.tsx new file mode 100644 index 0000000000000..85a51c2f3fc5d --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_table.tsx @@ -0,0 +1,105 @@ +/* + * 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 { EuiBasicTable, EuiLink } from '@elastic/eui'; + +interface IEngineTableProps { + data: Array<{ + name: string; + created_at: string; + document_count: number; + field_count: number; + }>; + pagination: { + totalEngines: number; + pageIndex: number; + onPaginate(pageIndex: number); + }; + appSearchUrl: string; +} + +export const EngineTable: ReactFC = ({ + data, + pagination: { totalEngines, pageIndex = 0, onPaginate }, + appSearchUrl, +}) => { + const columns = [ + { + field: 'name', + name: 'Name', + render: name => ( + + {name} + + ), + width: '30%', + truncateText: true, + mobileOptions: { + header: true, + enlarge: true, + fullWidth: true, + truncateText: false, + }, + }, + { + field: 'created_at', + name: 'Created At', + dataType: 'string', + render: dateString => { + // e.g., January 1, 1970 + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }, + }, + { + field: 'document_count', + name: 'Document Count', + dataType: 'number', + render: number => number.toLocaleString(), // Display with comma thousand separators + truncateText: true, + }, + { + field: 'field_count', + name: 'Field Count', + dataType: 'number', + render: number => number.toLocaleString(), // Display with comma thousand separators + truncateText: true, + }, + { + field: 'name', + name: 'Actions', + dataType: 'string', + render: name => ( + + Manage + + ), + align: 'right', + width: '100px', + }, + ]; + + return ( + { + const { index } = page; + onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0 + }} + /> + ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/index.ts b/x-pack/plugins/app_search/public/applications/components/engine_overview/index.ts new file mode 100644 index 0000000000000..48b7645dc39e8 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/engine_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 { EngineOverview } from './engine_overview'; diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/app_search/public/applications/components/engine_overview_header/engine_overview_header.tsx new file mode 100644 index 0000000000000..e99b286c577c9 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/engine_overview_header/engine_overview_header.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 React from 'react'; +import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiButton } from '@elastic/eui'; + +interface IEngineOverviewHeader { + appSearchUrl?: string; +} + +export const EngineOverviewHeader: React.FC = ({ appSearchUrl }) => { + const buttonProps = { + fill: true, + iconType: 'popout', + }; + if (appSearchUrl) { + buttonProps.href = `${appSearchUrl}/as`; + buttonProps.target = '_blank'; + } else { + buttonProps.isDisabled = true; + } + + return ( + + + +

Engine Overview

+
+
+ + Launch App Search + +
+ ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview_header/index.ts b/x-pack/plugins/app_search/public/applications/components/engine_overview_header/index.ts new file mode 100644 index 0000000000000..2d37f037e21e5 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/engine_overview_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 { EngineOverviewHeader } from './engine_overview_header'; diff --git a/x-pack/plugins/app_search/public/applications/components/main/index.ts b/x-pack/plugins/app_search/public/applications/components/main/index.ts new file mode 100644 index 0000000000000..509a15c0c71a5 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/main/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 { Main } from './main'; diff --git a/x-pack/plugins/app_search/public/applications/components/main/main.tsx b/x-pack/plugins/app_search/public/applications/components/main/main.tsx new file mode 100644 index 0000000000000..6e338dd230505 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/main/main.tsx @@ -0,0 +1,24 @@ +/* + * 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, { useState } from 'react'; + +import { SetupGuide } from '../setup_guide'; +import { EngineOverview } from '../engine_overview'; + +interface IMainProps { + appSearchUrl?: string; +} + +export const Main: React.FC = props => { + const [showSetupGuide, showSetupGuideFlag] = useState(!props.appSearchUrl); + + return showSetupGuide ? ( + + ) : ( + + ); +}; diff --git a/x-pack/plugins/app_search/public/applications/components/setup_guide/index.ts b/x-pack/plugins/app_search/public/applications/components/setup_guide/index.ts new file mode 100644 index 0000000000000..c367424d375f9 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/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/app_search/public/applications/components/setup_guide/setup_guide.scss b/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.scss new file mode 100644 index 0000000000000..77a62961128e1 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.scss @@ -0,0 +1,53 @@ +/* + * 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. + */ + +/** + * Setup Guide + */ +.setup-guide { + &.euiPage { + padding: 0; + min-height: 100vh; + } + + .euiPageSideBar { + flex-basis: 300px; + flex-shrink: 0; + padding: 24px; + margin-right: 0; + + background-color: #f5f7fa; // $euiColorLightestShade + border-color: #d3dae6; // $euiColorLightShade + border-style: solid; + border-width: 0 0 1px 0; // bottom - mobile view + + @media (min-width: 766px) { + border-width: 0 1px 0 0; // right - desktop view + } + @media (min-width: 1000px) { + flex-basis: 400px; + } + @media (min-width: 1200px) { + flex-basis: 500px; + } + } + + .euiPageBody { + align-self: start; + padding: 24px; + + @media (min-width: 1000px) { + padding: 40px 50px; + } + } + + &__thumbnail { + display: block; + max-width: 100%; + height: auto; + margin: 24px auto; + } +} diff --git a/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.tsx b/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.tsx new file mode 100644 index 0000000000000..1608f67f31587 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.tsx @@ -0,0 +1,175 @@ +/* + * 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, + EuiPageSideBar, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiImage, + EuiIcon, + EuiTabbedContent, + EuiSteps, + EuiCode, + EuiCodeBlock, +} from '@elastic/eui'; + +import GettingStarted from '../../../assets/getting_started.png'; +import './setup_guide.scss'; + +export const SetupGuide: React.FC<> = () => { + return ( + + + + Setup Guide + + + + + + + + + +

App Search

+
+
+
+ + + Getting started with App Search - in this short video we'll guide you through how to get App Search up and running + + + +

+ Set up App Search to leverage dashboards, analytics, and APIs for advanced application + search made simple. +

+
+ + +

+ App Search has not been configured in your Kibana instance yet. To get started, follow + the instructions on this page. +

+
+
+ + + + + + +

Run this code snippet to install things.

+ npm install + + ), + }, + { + title: 'Reload your Kibana instance', + children: ( + +

Run this code snippet to install things.

+ npm install +
+ ), + }, + ]} + /> + + ), + }, + { + id: 'smas', + name: 'Self-Managed', + content: ( + <> + + +

+ Within your config/kibana.yml file, add the + following the host URL of your App Search instace as{' '} + app_search.host. +

+ + app_search.host: 'http://localhost:3002' + + + ), + }, + { + title: 'Reload your Kibana instance', + children: ( + +

+ After updating your Kibana config file, restart Kibana to pick up + your changes. +

+
+ ), + }, + { + title: + 'Ensure that your Kibana users have corresponding App Search accounts', + children: ( + +

If you’re using Elasticsearch Native auth - you’re all set.

+

+ (If you’re using standard auth) Log into App Search and invite your + Kibana users into App Search! Be sure to use their corresponding + Kibana usernames. +

+

If you’re on SAML auth - ??????

+
+ ), + }, + ]} + /> + + ), + }, + ]} + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/app_search/public/applications/utils/get_username.ts b/x-pack/plugins/app_search/public/applications/utils/get_username.ts new file mode 100644 index 0000000000000..16320af0f3757 --- /dev/null +++ b/x-pack/plugins/app_search/public/applications/utils/get_username.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Attempt to get the current Kibana user's username + * by querying the DOM + */ +export const getUserName: () => undefined | string = () => { + const userMenu = document.getElementById('headerUserMenu'); + if (!userMenu) return; + + const avatar = userMenu.querySelector('.euiAvatar'); + if (!avatar) return; + + const username = avatar.getAttribute('aria-label'); + return username; +}; diff --git a/x-pack/plugins/app_search/public/assets/engine.svg b/x-pack/plugins/app_search/public/assets/engine.svg new file mode 100644 index 0000000000000..ceab918e92e70 --- /dev/null +++ b/x-pack/plugins/app_search/public/assets/engine.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/app_search/public/assets/getting_started.png b/x-pack/plugins/app_search/public/assets/getting_started.png new file mode 100644 index 0000000000000000000000000000000000000000..4d988d14f0483c31045783b714eedde9baac92b9 GIT binary patch literal 92044 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 b{wDtaU9#Ss{&OGS00000NkvXXu0mjf=D5g< literal 0 HcmV?d00001 diff --git a/x-pack/plugins/app_search/public/assets/meta_engine.svg b/x-pack/plugins/app_search/public/assets/meta_engine.svg new file mode 100644 index 0000000000000..4e01e9a0b34fb --- /dev/null +++ b/x-pack/plugins/app_search/public/assets/meta_engine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/app_search/public/index.ts b/x-pack/plugins/app_search/public/index.ts new file mode 100644 index 0000000000000..c66365a735e95 --- /dev/null +++ b/x-pack/plugins/app_search/public/index.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. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { AppSearchPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => { + return new AppSearchPlugin(initializerContext); +}; diff --git a/x-pack/plugins/app_search/public/plugin.ts b/x-pack/plugins/app_search/public/plugin.ts new file mode 100644 index 0000000000000..0ca6f04ff2428 --- /dev/null +++ b/x-pack/plugins/app_search/public/plugin.ts @@ -0,0 +1,49 @@ +/* + * 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 { + Plugin, + PluginInitializerContext, + CoreSetup, + CoreStart, + AppMountParameters, +} from 'src/core/public'; + +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; + +export interface ClientConfigType { + host: string; +} + +export class AppSearchPlugin implements Plugin { + private config: ClientConfigType; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + } + + public setup(core: CoreSetup) { + const config = this.config; + + core.application.register({ + id: 'appsearch', + title: 'App Search', + euiIconType: 'logoAppSearch', // TODO: Temporary - App Search will likely no longer need an icon once the nav structure changes. + category: DEFAULT_APP_CATEGORIES.management, // TODO - This is likely not final/correct + order: 10, // TODO - This will also likely not be needed once new nav structure changes land + async mount(params: AppMountParameters) { + const [coreStart] = await core.getStartServices(); + const { renderApp } = await import('./applications/app'); + + return renderApp(coreStart, params, config); + }, + }); + } + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/x-pack/plugins/app_search/server/index.ts b/x-pack/plugins/app_search/server/index.ts new file mode 100644 index 0000000000000..7f7571d3fbc11 --- /dev/null +++ b/x-pack/plugins/app_search/server/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { AppSearchPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => { + return new AppSearchPlugin(initializerContext); +}; + +export const configSchema = schema.object({ + host: schema.string(), +}); + +type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + host: true, + }, +}; diff --git a/x-pack/plugins/app_search/server/plugin.ts b/x-pack/plugins/app_search/server/plugin.ts new file mode 100644 index 0000000000000..a8772967fe914 --- /dev/null +++ b/x-pack/plugins/app_search/server/plugin.ts @@ -0,0 +1,35 @@ +/* + * 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 { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; + +import { registerEnginesRoute } from './routes/engines'; + +export interface ServerConfigType { + host: string; +} + +export class AppSearchPlugin implements Plugin { + private config: Observable; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.create(); + } + + public async setup({ http }: CoreSetup) { + const router = http.createRouter(); + const config = await this.config.pipe(first()).toPromise(); + const dependencies = { router, config }; + + registerEnginesRoute(dependencies); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/app_search/server/routes/engines.ts b/x-pack/plugins/app_search/server/routes/engines.ts new file mode 100644 index 0000000000000..179951be450f8 --- /dev/null +++ b/x-pack/plugins/app_search/server/routes/engines.ts @@ -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 fetch from 'node-fetch'; +import { schema } from '@kbn/config-schema'; + +export function registerEnginesRoute({ router, config }) { + router.get( + { + path: '/api/appsearch/engines', + validate: { + query: schema.object({ + type: schema.string(), + pageIndex: schema.number(), + }), + }, + }, + async (context, request, response) => { + const appSearchUrl = config.host; + const { type, pageIndex } = request.query; + + const url = `${appSearchUrl}/as/engines/collection?type=${type}&page[current]=${pageIndex}&page[size]=10`; + const enginesResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization }, + }); + + if (enginesResponse.url.endsWith('/login')) { + return response.custom({ + statusCode: 200, + body: { message: 'no-as-account' }, + headers: { 'content-type': 'application/json' }, + }); + } + + const engines = await enginesResponse.json(); + return response.ok({ + body: engines, + headers: { 'content-type': 'application/json' }, + }); + } + ); +} From 7f47c8a5488ea6d942da489107445f0be69cf98c Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 10 Mar 2020 09:23:20 -0700 Subject: [PATCH 02/48] Update URL casing to match Kibana best practices - URL casing appears to be snake_casing, but kibana.json casing appears to be camelCase --- .../applications/components/engine_overview/engine_overview.tsx | 2 +- x-pack/plugins/app_search/public/plugin.ts | 2 +- x-pack/plugins/app_search/server/routes/engines.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx index 977e44d24fd24..dd5589b0513bc 100644 --- a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx @@ -43,7 +43,7 @@ export const EngineOverview: ReactFC = ({ http, ...props } const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); const getEnginesData = ({ type, pageIndex }) => { - return http.get('../api/appsearch/engines', { + return http.get('../api/app_search/engines', { query: { type, pageIndex }, }); }; diff --git a/x-pack/plugins/app_search/public/plugin.ts b/x-pack/plugins/app_search/public/plugin.ts index 0ca6f04ff2428..a3d58132da173 100644 --- a/x-pack/plugins/app_search/public/plugin.ts +++ b/x-pack/plugins/app_search/public/plugin.ts @@ -29,7 +29,7 @@ export class AppSearchPlugin implements Plugin { const config = this.config; core.application.register({ - id: 'appsearch', + id: 'app_search', title: 'App Search', euiIconType: 'logoAppSearch', // TODO: Temporary - App Search will likely no longer need an icon once the nav structure changes. category: DEFAULT_APP_CATEGORIES.management, // TODO - This is likely not final/correct diff --git a/x-pack/plugins/app_search/server/routes/engines.ts b/x-pack/plugins/app_search/server/routes/engines.ts index 179951be450f8..da75815628a88 100644 --- a/x-pack/plugins/app_search/server/routes/engines.ts +++ b/x-pack/plugins/app_search/server/routes/engines.ts @@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema'; export function registerEnginesRoute({ router, config }) { router.get( { - path: '/api/appsearch/engines', + path: '/api/app_search/engines', validate: { query: schema.object({ type: schema.string(), From ad1f754b89d3fbce66806bcbf9e2ed973649d76e Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 11 Mar 2020 09:29:48 -0700 Subject: [PATCH 03/48] Register App Search plugin in Home Feature Catalogue --- x-pack/plugins/app_search/kibana.json | 1 + x-pack/plugins/app_search/public/plugin.ts | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/app_search/kibana.json b/x-pack/plugins/app_search/kibana.json index 6dd452aca8d8d..9940eb595c560 100644 --- a/x-pack/plugins/app_search/kibana.json +++ b/x-pack/plugins/app_search/kibana.json @@ -2,6 +2,7 @@ "id": "appSearch", "version": "1.0.0", "kibanaVersion": "kibana", + "requiredPlugins": ["home"], "configPath": ["appSearch"], "server": true, "ui": true diff --git a/x-pack/plugins/app_search/public/plugin.ts b/x-pack/plugins/app_search/public/plugin.ts index a3d58132da173..38e54a1311b22 100644 --- a/x-pack/plugins/app_search/public/plugin.ts +++ b/x-pack/plugins/app_search/public/plugin.ts @@ -12,11 +12,18 @@ import { AppMountParameters, } from 'src/core/public'; +import { + FeatureCatalogueCategory, + HomePublicPluginSetup, +} from '../../../../src/plugins/home/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; export interface ClientConfigType { host: string; } +export interface PluginsSetup { + home?: HomePublicPluginSetup; +} export class AppSearchPlugin implements Plugin { private config: ClientConfigType; @@ -25,7 +32,7 @@ export class AppSearchPlugin implements Plugin { this.config = initializerContext.config.get(); } - public setup(core: CoreSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { const config = this.config; core.application.register({ @@ -41,6 +48,17 @@ export class AppSearchPlugin implements Plugin { return renderApp(coreStart, params, config); }, }); + + plugins.home.featureCatalogue.register({ + id: 'app_search', + title: 'App Search', + icon: 'logoAppSearch', // TODO + description: + 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', + path: '/app/app_search', + category: FeatureCatalogueCategory.DATA, + showOnHomePage: true, + }); } public start(core: CoreStart) {} From 852b9e6ed705fb9250471182a0cd553c16d495f9 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 11 Mar 2020 10:10:17 -0700 Subject: [PATCH 04/48] Add custom App Search in Kibana logo - I haven't had much success in surfacing a SVG file via a server-side endpoint/URL, but then I realized EuiIcon supports passing in a ReactElement directly. Woo! --- x-pack/plugins/app_search/public/assets/logo.svg | 4 ++++ x-pack/plugins/app_search/public/plugin.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/app_search/public/assets/logo.svg diff --git a/x-pack/plugins/app_search/public/assets/logo.svg b/x-pack/plugins/app_search/public/assets/logo.svg new file mode 100644 index 0000000000000..2284a425b5add --- /dev/null +++ b/x-pack/plugins/app_search/public/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/app_search/public/plugin.ts b/x-pack/plugins/app_search/public/plugin.ts index 38e54a1311b22..827822d6530ed 100644 --- a/x-pack/plugins/app_search/public/plugin.ts +++ b/x-pack/plugins/app_search/public/plugin.ts @@ -18,6 +18,8 @@ import { } from '../../../../src/plugins/home/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import AppSearchLogo from './assets/logo.svg'; + export interface ClientConfigType { host: string; } @@ -38,7 +40,7 @@ export class AppSearchPlugin implements Plugin { core.application.register({ id: 'app_search', title: 'App Search', - euiIconType: 'logoAppSearch', // TODO: Temporary - App Search will likely no longer need an icon once the nav structure changes. + euiIconType: AppSearchLogo, // TODO: Temporary - App Search will likely no longer need an icon once the nav structure changes. category: DEFAULT_APP_CATEGORIES.management, // TODO - This is likely not final/correct order: 10, // TODO - This will also likely not be needed once new nav structure changes land async mount(params: AppMountParameters) { @@ -52,7 +54,7 @@ export class AppSearchPlugin implements Plugin { plugins.home.featureCatalogue.register({ id: 'app_search', title: 'App Search', - icon: 'logoAppSearch', // TODO + icon: AppSearchLogo, description: 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', path: '/app/app_search', From 68a4b10829ae874767ed2480ad65d429dcf4e308 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 11 Mar 2020 11:04:05 -0700 Subject: [PATCH 05/48] Fix appSearch.host config setting to be optional - instead of crashing folks on load --- x-pack/plugins/app_search/public/plugin.ts | 2 +- x-pack/plugins/app_search/server/index.ts | 2 +- x-pack/plugins/app_search/server/plugin.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/app_search/public/plugin.ts b/x-pack/plugins/app_search/public/plugin.ts index 827822d6530ed..279da31ac2895 100644 --- a/x-pack/plugins/app_search/public/plugin.ts +++ b/x-pack/plugins/app_search/public/plugin.ts @@ -21,7 +21,7 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; import AppSearchLogo from './assets/logo.svg'; export interface ClientConfigType { - host: string; + host?: string; } export interface PluginsSetup { home?: HomePublicPluginSetup; diff --git a/x-pack/plugins/app_search/server/index.ts b/x-pack/plugins/app_search/server/index.ts index 7f7571d3fbc11..214ce55fff868 100644 --- a/x-pack/plugins/app_search/server/index.ts +++ b/x-pack/plugins/app_search/server/index.ts @@ -13,7 +13,7 @@ export const plugin = (initializerContext: PluginInitializerContext) => { }; export const configSchema = schema.object({ - host: schema.string(), + host: schema.maybe(schema.string()), }); type ConfigType = TypeOf; diff --git a/x-pack/plugins/app_search/server/plugin.ts b/x-pack/plugins/app_search/server/plugin.ts index a8772967fe914..1ca65447a88cd 100644 --- a/x-pack/plugins/app_search/server/plugin.ts +++ b/x-pack/plugins/app_search/server/plugin.ts @@ -11,7 +11,7 @@ import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; import { registerEnginesRoute } from './routes/engines'; export interface ServerConfigType { - host: string; + host?: string; } export class AppSearchPlugin implements Plugin { From fe6eac146325e38e2cc17c9fbaad3dc810deebe9 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 15 Apr 2020 15:47:18 -0700 Subject: [PATCH 06/48] Rename plugin to Enterprise Search - per product decision, URL should be enterprise_search/app_search and Workplace Search should also eventually live here - reorganize folder structure in anticipation for another workplace_search plugin/codebase living alongside app_search - rename app.tsx/main.tsx to a standard top-level index.tsx (which will contain top-level routes/state) - rename AS->ES files/vars where applicable - TODO: React Router --- .../public/applications/components/main/index.ts | 7 ------- .../kibana.json | 4 ++-- .../applications/app_search}/assets/engine.svg | 0 .../app_search}/assets/getting_started.png | Bin .../applications/app_search}/assets/logo.svg | 0 .../app_search}/assets/meta_engine.svg | 0 .../components/empty_states/empty_state.tsx | 0 .../components/empty_states/empty_states.scss | 0 .../components/empty_states/error_state.tsx | 0 .../app_search}/components/empty_states/index.ts | 0 .../components/empty_states/loading_state.tsx | 0 .../components/empty_states/no_user_state.tsx | 0 .../app_search}/components/empty_states/types.ts | 0 .../engine_overview/engine_overview.scss | 0 .../engine_overview/engine_overview.tsx | 4 ++-- .../components/engine_overview/engine_table.tsx | 0 .../components/engine_overview/index.ts | 0 .../engine_overview_header.tsx | 0 .../components/engine_overview_header/index.ts | 0 .../app_search}/components/setup_guide/index.ts | 0 .../components/setup_guide/setup_guide.scss | 0 .../components/setup_guide/setup_guide.tsx | 2 +- .../public/applications/app_search/index.tsx} | 4 ++-- .../app_search}/utils/get_username.ts | 0 .../public/applications/index.tsx} | 2 +- .../public/index.ts | 4 ++-- .../public/plugin.ts | 15 ++++++++------- .../server/index.ts | 4 ++-- .../server/plugin.ts | 4 ++-- .../server/routes/app_search}/engines.ts | 0 30 files changed, 22 insertions(+), 28 deletions(-) delete mode 100644 x-pack/plugins/app_search/public/applications/components/main/index.ts rename x-pack/plugins/{app_search => enterprise_search}/kibana.json (63%) rename x-pack/plugins/{app_search/public => enterprise_search/public/applications/app_search}/assets/engine.svg (100%) rename x-pack/plugins/{app_search/public => enterprise_search/public/applications/app_search}/assets/getting_started.png (100%) rename x-pack/plugins/{app_search/public => enterprise_search/public/applications/app_search}/assets/logo.svg (100%) rename x-pack/plugins/{app_search/public => enterprise_search/public/applications/app_search}/assets/meta_engine.svg (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/empty_states/empty_state.tsx (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/empty_states/empty_states.scss (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/empty_states/error_state.tsx (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/empty_states/index.ts (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/empty_states/loading_state.tsx (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/empty_states/no_user_state.tsx (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/empty_states/types.ts (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/engine_overview/engine_overview.scss (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/engine_overview/engine_overview.tsx (97%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/engine_overview/engine_table.tsx (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/engine_overview/index.ts (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/engine_overview_header/engine_overview_header.tsx (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/engine_overview_header/index.ts (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/setup_guide/index.ts (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/setup_guide/setup_guide.scss (100%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/components/setup_guide/setup_guide.tsx (98%) rename x-pack/plugins/{app_search/public/applications/components/main/main.tsx => enterprise_search/public/applications/app_search/index.tsx} (83%) rename x-pack/plugins/{app_search/public/applications => enterprise_search/public/applications/app_search}/utils/get_username.ts (100%) rename x-pack/plugins/{app_search/public/applications/app.tsx => enterprise_search/public/applications/index.tsx} (94%) rename x-pack/plugins/{app_search => enterprise_search}/public/index.ts (77%) rename x-pack/plugins/{app_search => enterprise_search}/public/plugin.ts (76%) rename x-pack/plugins/{app_search => enterprise_search}/server/index.ts (87%) rename x-pack/plugins/{app_search => enterprise_search}/server/plugin.ts (88%) rename x-pack/plugins/{app_search/server/routes => enterprise_search/server/routes/app_search}/engines.ts (100%) diff --git a/x-pack/plugins/app_search/public/applications/components/main/index.ts b/x-pack/plugins/app_search/public/applications/components/main/index.ts deleted file mode 100644 index 509a15c0c71a5..0000000000000 --- a/x-pack/plugins/app_search/public/applications/components/main/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * 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 { Main } from './main'; diff --git a/x-pack/plugins/app_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json similarity index 63% rename from x-pack/plugins/app_search/kibana.json rename to x-pack/plugins/enterprise_search/kibana.json index 9940eb595c560..0a049f80a82fe 100644 --- a/x-pack/plugins/app_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -1,9 +1,9 @@ { - "id": "appSearch", + "id": "enterpriseSearch", "version": "1.0.0", "kibanaVersion": "kibana", "requiredPlugins": ["home"], - "configPath": ["appSearch"], + "configPath": ["enterpriseSearch"], "server": true, "ui": true } diff --git a/x-pack/plugins/app_search/public/assets/engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg similarity index 100% rename from x-pack/plugins/app_search/public/assets/engine.svg rename to x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg diff --git a/x-pack/plugins/app_search/public/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png similarity index 100% rename from x-pack/plugins/app_search/public/assets/getting_started.png rename to x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png diff --git a/x-pack/plugins/app_search/public/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg similarity index 100% rename from x-pack/plugins/app_search/public/assets/logo.svg rename to x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg diff --git a/x-pack/plugins/app_search/public/assets/meta_engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg similarity index 100% rename from x-pack/plugins/app_search/public/assets/meta_engine.svg rename to x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/empty_states/empty_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/empty_states/empty_states.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/empty_states/error_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/empty_states/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/empty_states/loading_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/empty_states/no_user_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx diff --git a/x-pack/plugins/app_search/public/applications/components/empty_states/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/empty_states/types.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx similarity index 97% rename from x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index dd5589b0513bc..13051e66b9959 100644 --- a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -15,8 +15,8 @@ import { EuiSpacer, } from '@elastic/eui'; -import EnginesIcon from '../../../assets/engine.svg'; -import MetaEnginesIcon from '../../../assets/meta_engine.svg'; +import EnginesIcon from '../../assets/engine.svg'; +import MetaEnginesIcon from '../../assets/meta_engine.svg'; import { LoadingState, EmptyState, NoUserState, ErrorState } from '../empty_states'; import { EngineOverviewHeader } from '../engine_overview_header'; diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/engine_overview/engine_table.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/engine_overview/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/engine_overview_header/engine_overview_header.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx diff --git a/x-pack/plugins/app_search/public/applications/components/engine_overview_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/engine_overview_header/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts diff --git a/x-pack/plugins/app_search/public/applications/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/setup_guide/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts diff --git a/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.scss similarity index 100% rename from x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.scss diff --git a/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx similarity index 98% rename from x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index 1608f67f31587..107107d6e52ef 100644 --- a/x-pack/plugins/app_search/public/applications/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -23,7 +23,7 @@ import { EuiCodeBlock, } from '@elastic/eui'; -import GettingStarted from '../../../assets/getting_started.png'; +import GettingStarted from '../../assets/getting_started.png'; import './setup_guide.scss'; export const SetupGuide: React.FC<> = () => { diff --git a/x-pack/plugins/app_search/public/applications/components/main/main.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx similarity index 83% rename from x-pack/plugins/app_search/public/applications/components/main/main.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 6e338dd230505..d1758bf4f582e 100644 --- a/x-pack/plugins/app_search/public/applications/components/main/main.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -6,8 +6,8 @@ import React, { useState } from 'react'; -import { SetupGuide } from '../setup_guide'; -import { EngineOverview } from '../engine_overview'; +import { SetupGuide } from './components/setup_guide'; +import { EngineOverview } from './components/engine_overview'; interface IMainProps { appSearchUrl?: string; diff --git a/x-pack/plugins/app_search/public/applications/utils/get_username.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts similarity index 100% rename from x-pack/plugins/app_search/public/applications/utils/get_username.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts diff --git a/x-pack/plugins/app_search/public/applications/app.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx similarity index 94% rename from x-pack/plugins/app_search/public/applications/app.tsx rename to x-pack/plugins/enterprise_search/public/applications/index.tsx index d6fbc582ab957..966587f88d6c9 100644 --- a/x-pack/plugins/app_search/public/applications/app.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -9,7 +9,7 @@ import ReactDOM from 'react-dom'; import { CoreStart, AppMountParams } from 'src/core/public'; import { ClientConfigType } from '../plugin'; -import { Main } from './components/main'; +import { Main } from './app_search'; export const renderApp = (core: CoreStart, params: AppMountParams, config: ClientConfigType) => { ReactDOM.render(
, params.element); diff --git a/x-pack/plugins/app_search/public/index.ts b/x-pack/plugins/enterprise_search/public/index.ts similarity index 77% rename from x-pack/plugins/app_search/public/index.ts rename to x-pack/plugins/enterprise_search/public/index.ts index c66365a735e95..06272641b1929 100644 --- a/x-pack/plugins/app_search/public/index.ts +++ b/x-pack/plugins/enterprise_search/public/index.ts @@ -5,8 +5,8 @@ */ import { PluginInitializerContext } from 'src/core/public'; -import { AppSearchPlugin } from './plugin'; +import { EnterpriseSearchPlugin } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => { - return new AppSearchPlugin(initializerContext); + return new EnterpriseSearchPlugin(initializerContext); }; diff --git a/x-pack/plugins/app_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts similarity index 76% rename from x-pack/plugins/app_search/public/plugin.ts rename to x-pack/plugins/enterprise_search/public/plugin.ts index 279da31ac2895..5331eb1e8f51f 100644 --- a/x-pack/plugins/app_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -16,9 +16,9 @@ import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; -import AppSearchLogo from './assets/logo.svg'; +import AppSearchLogo from './applications/app_search/assets/logo.svg'; export interface ClientConfigType { host?: string; @@ -27,7 +27,7 @@ export interface PluginsSetup { home?: HomePublicPluginSetup; } -export class AppSearchPlugin implements Plugin { +export class EnterpriseSearchPlugin implements Plugin { private config: ClientConfigType; constructor(private readonly initializerContext: PluginInitializerContext) { @@ -38,14 +38,14 @@ export class AppSearchPlugin implements Plugin { const config = this.config; core.application.register({ - id: 'app_search', - title: 'App Search', + id: 'enterprise_search', + title: 'App Search', // TODO: This will eventually be 'Enterprise Search' once there's more than just App Search in here euiIconType: AppSearchLogo, // TODO: Temporary - App Search will likely no longer need an icon once the nav structure changes. category: DEFAULT_APP_CATEGORIES.management, // TODO - This is likely not final/correct order: 10, // TODO - This will also likely not be needed once new nav structure changes land async mount(params: AppMountParameters) { const [coreStart] = await core.getStartServices(); - const { renderApp } = await import('./applications/app'); + const { renderApp } = await import('./applications'); return renderApp(coreStart, params, config); }, @@ -57,10 +57,11 @@ export class AppSearchPlugin implements Plugin { icon: AppSearchLogo, description: 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', - path: '/app/app_search', + path: '/app/enterprise_search/app_search', category: FeatureCatalogueCategory.DATA, showOnHomePage: true, }); + // TODO: Workplace Search will likely also register its own feature catalogue section/card. } public start(core: CoreStart) {} diff --git a/x-pack/plugins/app_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts similarity index 87% rename from x-pack/plugins/app_search/server/index.ts rename to x-pack/plugins/enterprise_search/server/index.ts index 214ce55fff868..faf8f61bd2b9e 100644 --- a/x-pack/plugins/app_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -6,10 +6,10 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; import { schema, TypeOf } from '@kbn/config-schema'; -import { AppSearchPlugin } from './plugin'; +import { EnterpriseSearchPlugin } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => { - return new AppSearchPlugin(initializerContext); + return new EnterpriseSearchPlugin(initializerContext); }; export const configSchema = schema.object({ diff --git a/x-pack/plugins/app_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts similarity index 88% rename from x-pack/plugins/app_search/server/plugin.ts rename to x-pack/plugins/enterprise_search/server/plugin.ts index 1ca65447a88cd..1ebe58ab28c8e 100644 --- a/x-pack/plugins/app_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -8,13 +8,13 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; -import { registerEnginesRoute } from './routes/engines'; +import { registerEnginesRoute } from './routes/app_search/engines'; export interface ServerConfigType { host?: string; } -export class AppSearchPlugin implements Plugin { +export class EnterpriseSearchPlugin implements Plugin { private config: Observable; constructor(initializerContext: PluginInitializerContext) { diff --git a/x-pack/plugins/app_search/server/routes/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts similarity index 100% rename from x-pack/plugins/app_search/server/routes/engines.ts rename to x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts From c79af1a4fbb9df77c143fefbcc5379e8389eccf7 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Thu, 16 Apr 2020 11:21:41 -0700 Subject: [PATCH 07/48] Set up React Router URL structure --- .../engine_overview/engine_overview.tsx | 2 +- .../public/applications/app_search/index.tsx | 22 ++++++++++++++----- .../public/applications/index.tsx | 18 +++++++++++++-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 13051e66b9959..a291453dcb0db 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -43,7 +43,7 @@ export const EngineOverview: ReactFC = ({ http, ...props } const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); const getEnginesData = ({ type, pageIndex }) => { - return http.get('../api/app_search/engines', { + return http.get('/api/app_search/engines', { query: { type, pageIndex }, }); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index d1758bf4f582e..fd04d5787a7b9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -5,20 +5,30 @@ */ import React, { useState } from 'react'; +import { Route, Redirect } from 'react-router-dom'; import { SetupGuide } from './components/setup_guide'; import { EngineOverview } from './components/engine_overview'; -interface IMainProps { +interface IAppSearchProps { appSearchUrl?: string; } -export const Main: React.FC = props => { +export const AppSearch: React.FC = props => { const [showSetupGuide, showSetupGuideFlag] = useState(!props.appSearchUrl); - return showSetupGuide ? ( - - ) : ( - + return ( + <> + + {showSetupGuide ? ( + + ) : ( + + )} + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 966587f88d6c9..73ad2c10745c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -6,12 +6,26 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { BrowserRouter, Route, Redirect } from 'react-router-dom'; + import { CoreStart, AppMountParams } from 'src/core/public'; import { ClientConfigType } from '../plugin'; -import { Main } from './app_search'; +import { AppSearch } from './app_search'; export const renderApp = (core: CoreStart, params: AppMountParams, config: ClientConfigType) => { - ReactDOM.render(
, params.element); + ReactDOM.render( + + + {/* This will eventually contain an Enterprise Search landing page, + and we'll also actually have a /workplace_search route */} + + + + + + , + params.element + ); return () => ReactDOM.unmountComponentAtNode(params.element); }; From cbae5635111e18ef1d85f07d5b0942b02d34d2fe Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Thu, 16 Apr 2020 13:40:10 -0700 Subject: [PATCH 08/48] Convert showSetupGuide action/flag to a React Router link - remove showSetupGuide flag - add a new shared helper component for combining EuiButton/EuiLink with React Router behavior (https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51) --- .../components/empty_states/error_state.tsx | 14 ++---- .../components/empty_states/types.ts | 1 - .../engine_overview/engine_overview.tsx | 1 - .../public/applications/app_search/index.tsx | 34 ++++++-------- .../shared/react_router_helpers/eui_link.tsx | 47 +++++++++++++++++++ .../shared/react_router_helpers/index.ts | 9 ++++ .../react_router_helpers/link_events.tsx | 30 ++++++++++++ 7 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.tsx 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 f2d6d99d37822..b2ff79b47c7a6 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 @@ -5,21 +5,15 @@ */ import React from 'react'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiEmptyPrompt, - EuiCode, - EuiButton, -} from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { EuiButton } from '../../../shared/react_router_helpers'; import { EngineOverviewHeader } from '../engine_overview_header'; import { IEmptyStatesProps } from './types'; import './empty_states.scss'; -export const ErrorState: ReactFC = ({ appSearchUrl, showSetupGuideFlag }) => { +export const ErrorState: ReactFC = ({ appSearchUrl }) => { return ( @@ -43,7 +37,7 @@ export const ErrorState: ReactFC = ({ appSearchUrl, showSetup } actions={ - showSetupGuideFlag(true)}> + Review the setup guide } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts index eb9edb877b2c6..e0a51a80069c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts @@ -6,5 +6,4 @@ export interface IEmptyStatesProps { appSearchUrl: string; - showSetupGuideFlag?(flag: boolean); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index a291453dcb0db..c149b61f67d27 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -26,7 +26,6 @@ import './engine_overview.scss'; interface IEngineOverviewProps { appSearchUrl: string; - showSetupGuideFlag(); http(); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index fd04d5787a7b9..70cf40e6b9db9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React from 'react'; import { Route, Redirect } from 'react-router-dom'; import { SetupGuide } from './components/setup_guide'; @@ -14,21 +14,17 @@ interface IAppSearchProps { appSearchUrl?: string; } -export const AppSearch: React.FC = props => { - const [showSetupGuide, showSetupGuideFlag] = useState(!props.appSearchUrl); - - return ( - <> - - {showSetupGuide ? ( - - ) : ( - - )} - - - - - - ); -}; +export const AppSearch: React.FC = props => ( + <> + + {!props.appSearchUrl ? ( + + ) : ( + + )} + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx new file mode 100644 index 0000000000000..b56535d984e5c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useHistory } from 'react-router-dom'; +import { EuiLink, EuiButton } from '@elastic/eui'; + +import { letBrowserHandleEvent } from './link_events'; + +/** + * Generates either an EuiLink or EuiButton with a React-Router-ified link + * + * Based off of EUI's recommendations for handling React Router: + * https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51 + */ + +interface IEuiReactRouterProps { + to: string; + isButton?: boolean; +} + +export const EuiReactRouterLink: React.FC = ({ to, isButton, ...rest }) => { + const history = useHistory(); + + const onClick = event => { + if (letBrowserHandleEvent(event)) return; + + // Prevent regular link behavior, which causes a browser refresh. + event.preventDefault(); + + // Push the route to the history. + history.push(to); + }; + + // Generate the correct link href (with basename etc. accounted for) + const href = history.createHref({ pathname: to }); + + const props = { ...rest, href, onClick }; + return isButton ? : ; +}; + +export const EuiReactRouterButton: React.FC = props => ( + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts new file mode 100644 index 0000000000000..46dc328633153 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { letBrowserHandleEvent } from './link_events'; +export { EuiReactRouterLink as EuiLink } from './eui_link'; +export { EuiReactRouterButton as EuiButton } from './eui_link'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.tsx new file mode 100644 index 0000000000000..dba5d576faa7d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.tsx @@ -0,0 +1,30 @@ +/* + * 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 { SyntheticEvent } from 'react'; + +/** + * Helper functions for determining which events we should + * let browsers handle natively, e.g. new tabs/windows + */ + +type THandleEvent = (event: SyntheticEvent) => boolean; + +export const letBrowserHandleEvent: THandleEvent = event => + event.defaultPrevented || + isModifiedEvent(event) || + !isLeftClickEvent(event) || + isTargetBlank(event); + +const isModifiedEvent: THandleEvent = event => + !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +const isLeftClickEvent: THandleEvent = event => event.button === 0; + +const isTargetBlank: THandleEvent = event => { + const target = event.target.getAttribute('target'); + return target && target !== '_self'; +}; From 0e7fd5092ea4b39c4ac160ca1783da483c3bba63 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Thu, 16 Apr 2020 23:54:51 -0700 Subject: [PATCH 09/48] Implement Kibana Chrome breadcrumbs - create shared helper (WS will presumably also want this) for generating EUI breadcrumb objects with React Router links+click behavior - create React component that calls chrome.setBreadcrumbs on page mount - clean up type definitions - move app-wide props to IAppSearchProps and update most pages/views to simply import it instead of calling their own definitions --- .../components/empty_states/empty_state.tsx | 8 ++- .../components/empty_states/error_state.tsx | 8 ++- .../components/empty_states/loading_state.tsx | 8 ++- .../components/empty_states/no_user_state.tsx | 8 ++- .../engine_overview/engine_overview.tsx | 18 ++++--- .../components/setup_guide/setup_guide.tsx | 5 +- .../public/applications/app_search/index.tsx | 7 ++- .../public/applications/index.tsx | 6 ++- .../generate_breadcrumbs.ts | 50 +++++++++++++++++++ .../kibana_breadcrumbs/index.ts} | 6 +-- .../kibana_breadcrumbs/set_breadcrumbs.tsx | 38 ++++++++++++++ 11 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts rename x-pack/plugins/enterprise_search/public/applications/{app_search/components/empty_states/types.ts => shared/kibana_breadcrumbs/index.ts} (55%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index c120166a197b4..c2672ec3be085 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -7,14 +7,18 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { IAppSearchProps } from '../../index'; + import { EngineOverviewHeader } from '../engine_overview_header'; -import { IEmptyStatesProps } from './types'; import './empty_states.scss'; -export const EmptyState: React.FC = ({ appSearchUrl }) => { +export const EmptyState: React.FC = ({ appSearchUrl, setBreadcrumbs }) => { return ( + + 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 b2ff79b47c7a6..3eec392749c2f 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 @@ -6,16 +6,20 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; + import { EuiButton } from '../../../shared/react_router_helpers'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { IAppSearchProps } from '../../index'; import { EngineOverviewHeader } from '../engine_overview_header'; -import { IEmptyStatesProps } from './types'; import './empty_states.scss'; -export const ErrorState: ReactFC = ({ appSearchUrl }) => { +export const ErrorState: ReactFC = ({ appSearchUrl, setBreadcrumbs }) => { return ( + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx index 44d9e5d7ea64c..426716030adc4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx @@ -7,14 +7,18 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { IAppSearchProps } from '../../index'; + import { EngineOverviewHeader } from '../engine_overview_header'; -import { IEmptyStatesProps } from './types'; import './empty_states.scss'; -export const LoadingState: React.FC = ({ appSearchUrl }) => { +export const LoadingState: React.FC = ({ appSearchUrl, setBreadcrumbs }) => { return ( + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index 0e41586d95aff..cc3fc130a7b01 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -7,17 +7,21 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { IEmptyStatesProps } from './types'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { IAppSearchProps } from '../../index'; + import { EngineOverviewHeader } from '../engine_overview_header'; import { getUserName } from '../../utils/get_username'; import './empty_states.scss'; -export const NoUserState: React.FC = ({ appSearchUrl }) => { +export const NoUserState: React.FC = ({ appSearchUrl, setBreadcrumbs }) => { const username = getUserName(); return ( + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index c149b61f67d27..c7c06bfb05e72 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -15,6 +15,9 @@ import { EuiSpacer, } from '@elastic/eui'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { IAppSearchProps } from '../../index'; + import EnginesIcon from '../../assets/engine.svg'; import MetaEnginesIcon from '../../assets/meta_engine.svg'; @@ -24,12 +27,9 @@ import { EngineTable } from './engine_table'; import './engine_overview.scss'; -interface IEngineOverviewProps { - appSearchUrl: string; - http(); -} +export const EngineOverview: ReactFC = props => { + const { http, appSearchUrl } = props; -export const EngineOverview: ReactFC = ({ http, ...props }) => { const [isLoading, setIsLoading] = useState(true); const [hasNoAccount, setHasNoAccount] = useState(false); const [hasErrorConnecting, setHasErrorConnecting] = useState(false); @@ -95,8 +95,10 @@ export const EngineOverview: ReactFC = ({ http, ...props } return ( + + - + @@ -115,7 +117,7 @@ export const EngineOverview: ReactFC = ({ http, ...props } pageIndex: enginesPage - 1, onPaginate: setEnginesPage, }} - appSearchUrl={props.appSearchUrl} + appSearchUrl={appSearchUrl} /> @@ -138,7 +140,7 @@ export const EngineOverview: ReactFC = ({ http, ...props } pageIndex: metaEnginesPage - 1, onPaginate: setMetaEnginesPage, }} - appSearchUrl={props.appSearchUrl} + appSearchUrl={appSearchUrl} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index 107107d6e52ef..b91fbdf1a19f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -23,12 +23,15 @@ import { EuiCodeBlock, } from '@elastic/eui'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; + import GettingStarted from '../../assets/getting_started.png'; import './setup_guide.scss'; -export const SetupGuide: React.FC<> = () => { +export const SetupGuide: React.FC<> = props => { return ( + Setup Guide diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 70cf40e6b9db9..b7cd6e27696d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -7,11 +7,16 @@ import React from 'react'; import { Route, Redirect } from 'react-router-dom'; +import { HttpHandler } from 'src/core/public'; +import { TSetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; + import { SetupGuide } from './components/setup_guide'; import { EngineOverview } from './components/engine_overview'; -interface IAppSearchProps { +export interface IAppSearchProps { appSearchUrl?: string; + http(): HttpHandler; + setBreadCrumbs(): TSetBreadcrumbs; } export const AppSearch: React.FC = props => ( diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 73ad2c10745c7..3a7f5b37a2861 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -22,7 +22,11 @@ export const renderApp = (core: CoreStart, params: AppMountParams, config: Clien - + , params.element 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 new file mode 100644 index 0000000000000..9aab47e51433a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -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 { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; +import { History } from 'history'; + +import { letBrowserHandleEvent } from '../react_router_helpers'; + +/** + * Generate React-Router-friendly EUI breadcrumb objects + * https://elastic.github.io/eui/#/navigation/breadcrumbs + */ + +interface IGenerateBreadcrumbProps { + text: string; + path: string; + history: History; +} + +export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbProps) => ({ + text, + href: history.createHref({ pathname: path }), + onClick: event => { + if (letBrowserHandleEvent(event)) return; + event.preventDefault(); + history.push(path); + }, +}); + +/** + * Product-specific breadcrumb helpers + */ + +type TBreadcrumbs = EuiBreadcrumb[] | []; + +export const enterpriseSearchBreadcrumbs = (history: History) => ( + breadcrumbs: TBreadcrumbs = [] +) => [ + generateBreadcrumb({ text: 'Enterprise Search', path: '/', history }), + ...breadcrumbs.map(({ text, path }) => generateBreadcrumb({ text, path, history })), +]; + +export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => + enterpriseSearchBreadcrumbs(history)([ + { text: 'App Search', path: '/app_search' }, + ...breadcrumbs, + ]); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts similarity index 55% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts index e0a51a80069c1..cf8bbbc593f2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface IEmptyStatesProps { - appSearchUrl: string; -} +export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs'; +export { appSearchBreadcrumbs } from './generate_breadcrumbs'; +export { SetAppSearchBreadcrumbs } 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 new file mode 100644 index 0000000000000..7ce1ba1268f98 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.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 React, { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; +import { appSearchBreadcrumbs } from './generate_breadcrumbs'; + +/** + * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view + * @see https://github.com/elastic/kibana/blob/master/src/core/public/chrome/chrome_service.tsx + */ + +export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; + +interface ISetBreadcrumbsProps { + text: string; + setBreadcrumbs(): setBreadcrumbs; + isRoot?: boolean; +} + +export const SetAppSearchBreadcrumbs: React.FC = ({ + text, + setBreadcrumbs, + isRoot, +}) => { + const history = useHistory(); + const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; + + useEffect(() => { + setBreadcrumbs(appSearchBreadcrumbs(history)(crumb)); + }, []); // eslint-disable-line + + return null; +}; From b3c564e85967650d2a74ef91e0de97fac9b9d120 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 21 Apr 2020 17:10:02 -0400 Subject: [PATCH 10/48] Added server unit tests (#2) * Added unit test for server * PR Feedback --- .../server/routes/app_search/engines.test.ts | 171 ++++++++++++++++++ .../server/routes/app_search/engines.ts | 3 +- 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts new file mode 100644 index 0000000000000..608d79f0cdbcf --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -0,0 +1,171 @@ +/* + * 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 { RequestHandlerContext } from 'kibana/server'; +import { mockRouter, RouterMock } from 'src/core/server/http/router/router.mock'; +import { httpServerMock } from 'src/core/server/http/http_server.mocks'; +import { RouteValidatorConfig } from 'src/core/server/http/router/validator'; + +import { registerEnginesRoute } from './engines'; +import { ObjectType } from '@kbn/config-schema'; + +jest.mock('node-fetch'); +const fetch = jest.requireActual('node-fetch'); +const { Response } = fetch; +const fetchMock = require('node-fetch') as jest.Mocked; + +describe('engine routes', () => { + describe('GET /api/app_search/engines', () => { + const AUTH_HEADER = 'Basic 123'; + let router: RouterMock; + const mockResponseFactory = httpServerMock.createResponseFactory(); + + beforeEach(() => { + jest.resetAllMocks(); + router = mockRouter.create(); + registerEnginesRoute({ + router, + config: { + host: 'http://localhost:3002', + }, + }); + }); + + describe('when the underlying App Search API returns a 200', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + { + headers: { Authorization: AUTH_HEADER }, + } + ).andReturn({ name: 'engine1' }); + }); + + it('should return 200 with a list of engines from the App Search API', async () => { + await callThisRoute(); + + expectResponseToBe200With({ + body: { name: 'engine1' }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); + + describe('when the underlying App Search API redirects to /login', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + { + headers: { Authorization: AUTH_HEADER }, + } + ).andReturnRedirect(); + }); + + it('should return 200 with a message', async () => { + await callThisRoute(); + + expectResponseToBe200With({ + body: { message: 'no-as-account' }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); + + describe('validation', () => { + function itShouldValidate(request: { query: object }) { + it('should be validated', async () => { + expect(() => executeRouteValidation(request)).not.toThrow(); + }); + } + + function itShouldThrow(request: { query: object }) { + it('should throw', async () => { + expect(() => executeRouteValidation(request)).toThrow(); + }); + } + + describe('when query is valid', () => { + const request = { query: { type: 'indexed', pageIndex: 1 } }; + itShouldValidate(request); + }); + + describe('pageIndex is wrong type', () => { + const request = { query: { type: 'indexed', pageIndex: 'indexed' } }; + itShouldThrow(request); + }); + + describe('type is wrong type', () => { + const request = { query: { type: 1, pageIndex: 1 } }; + itShouldThrow(request); + }); + + describe('pageIndex is missing', () => { + const request = { query: { type: 'indexed' } }; + itShouldThrow(request); + }); + + describe('type is missing', () => { + const request = { query: { pageIndex: 1 } }; + itShouldThrow(request); + }); + }); + + const AppSearchAPI = { + shouldBeCalledWith(expectedUrl: string, expectedParams: object) { + return { + andReturnRedirect() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve( + new Response('{}', { + url: '/login', + }) + ); + }); + }, + 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))); + }); + }, + }; + }, + }; + + const expectResponseToBe200With = (response: object) => { + expect(mockResponseFactory.ok).toHaveBeenCalledWith(response); + }; + + const callThisRoute = async ( + request = { + headers: { + authorization: AUTH_HEADER, + }, + query: { + type: 'indexed', + pageIndex: 1, + }, + } + ) => { + const [_, handler] = router.get.mock.calls[0]; + + const context = {} as jest.Mocked; + await handler(context, httpServerMock.createKibanaRequest(request), mockResponseFactory); + }; + + const executeRouteValidation = (data: { query: object }) => { + const [config] = router.get.mock.calls[0]; + const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + const query = validate.query as ObjectType; + query.validate(data.query); + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index da75815628a88..8455b01aa4354 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -28,8 +28,7 @@ export function registerEnginesRoute({ router, config }) { }); if (enginesResponse.url.endsWith('/login')) { - return response.custom({ - statusCode: 200, + return response.ok({ body: { message: 'no-as-account' }, headers: { 'content-type': 'application/json' }, }); From 0c083e14bc739bb01271a8d41ba52ebb1c71b6e9 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 22 Apr 2020 18:41:51 -0700 Subject: [PATCH 11/48] Refactor top-level Kibana props to a global context state - rather them passing them around verbosely as props, the components that need them should be able to call the useContext hook + Remove IAppSearchProps in favor of IKibanaContext + Also rename `appSearchUrl` to `enterpriseSearchUrl`, since this context will contained shared/Kibana-wide values/actions useful to both AS and WS --- .../components/empty_states/empty_state.tsx | 14 +++--- .../components/empty_states/error_state.tsx | 12 +++--- .../components/empty_states/loading_state.tsx | 8 ++-- .../components/empty_states/no_user_state.tsx | 8 ++-- .../engine_overview/engine_overview.tsx | 22 +++++----- .../engine_overview/engine_table.tsx | 12 +++--- .../engine_overview_header.tsx | 14 +++--- .../components/setup_guide/setup_guide.tsx | 4 +- .../public/applications/app_search/index.tsx | 37 +++++++--------- .../public/applications/index.tsx | 43 ++++++++++++------- .../kibana_breadcrumbs/set_breadcrumbs.tsx | 12 +++--- 11 files changed, 95 insertions(+), 91 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index c2672ec3be085..2055b0b7b54bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -4,23 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; -import { IAppSearchProps } from '../../index'; +import { KibanaContext, IKibanaContext } from '../../../index'; import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; -export const EmptyState: React.FC = ({ appSearchUrl, setBreadcrumbs }) => { +export const EmptyState: React.FC<> = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + return ( - + - + = ({ appSearchUrl, setBreadcr Create your first Engine 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 3eec392749c2f..b33a4ea17f756 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,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { EuiButton } from '../../../shared/react_router_helpers'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; -import { IAppSearchProps } from '../../index'; +import { KibanaContext, IKibanaContext } from '../../../index'; import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; -export const ErrorState: ReactFC = ({ appSearchUrl, setBreadcrumbs }) => { +export const ErrorState: ReactFC<> = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + return ( - + @@ -32,7 +34,7 @@ export const ErrorState: ReactFC = ({ appSearchUrl, setBreadcru <>

We cannot connect to the App Search instance at the configured host URL:{' '} - {appSearchUrl} + {enterpriseSearchUrl}

Please ensure your App Search host URL is configured correctly within{' '} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx index 426716030adc4..5c1d0c744f743 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx @@ -8,19 +8,17 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; -import { IAppSearchProps } from '../../index'; - import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; -export const LoadingState: React.FC = ({ appSearchUrl, setBreadcrumbs }) => { +export const LoadingState: React.FC<> = () => { return ( - + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index cc3fc130a7b01..f1ebaed71d95b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -8,22 +8,20 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; -import { IAppSearchProps } from '../../index'; - import { EngineOverviewHeader } from '../engine_overview_header'; import { getUserName } from '../../utils/get_username'; import './empty_states.scss'; -export const NoUserState: React.FC = ({ appSearchUrl, setBreadcrumbs }) => { +export const NoUserState: React.FC<> = () => { const username = getUserName(); return ( - + - + = props => { - const { http, appSearchUrl } = props; +export const EngineOverview: ReactFC<> = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; const [isLoading, setIsLoading] = useState(true); const [hasNoAccount, setHasNoAccount] = useState(false); @@ -88,17 +88,17 @@ export const EngineOverview: ReactFC = props => { }, [metaEnginesPage]); // eslint-disable-line // TODO: https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies - if (hasErrorConnecting) return ; - if (hasNoAccount) return ; - if (isLoading) return ; - if (!engines.length) return ; + if (hasErrorConnecting) return ; + if (hasNoAccount) return ; + if (isLoading) return ; + if (!engines.length) return ; return ( - + - + @@ -117,7 +117,6 @@ export const EngineOverview: ReactFC = props => { pageIndex: enginesPage - 1, onPaginate: setEnginesPage, }} - appSearchUrl={appSearchUrl} /> @@ -140,7 +139,6 @@ export const EngineOverview: ReactFC = props => { pageIndex: metaEnginesPage - 1, onPaginate: setMetaEnginesPage, }} - appSearchUrl={appSearchUrl} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index 85a51c2f3fc5d..c4ad289bfda6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { EuiBasicTable, EuiLink } from '@elastic/eui'; +import { KibanaContext, IKibanaContext } from '../../../index'; + interface IEngineTableProps { data: Array<{ name: string; @@ -19,20 +21,20 @@ interface IEngineTableProps { pageIndex: number; onPaginate(pageIndex: number); }; - appSearchUrl: string; } export const EngineTable: ReactFC = ({ data, pagination: { totalEngines, pageIndex = 0, onPaginate }, - appSearchUrl, }) => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const columns = [ { field: 'name', name: 'Name', render: name => ( - + {name} ), @@ -77,7 +79,7 @@ export const EngineTable: ReactFC = ({ name: 'Actions', dataType: 'string', render: name => ( - + Manage ), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index e99b286c577c9..14b2d00668c0d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -4,20 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiButton } from '@elastic/eui'; -interface IEngineOverviewHeader { - appSearchUrl?: string; -} +import { KibanaContext, IKibanaContext } from '../../../index'; + +export const EngineOverviewHeader: React.FC<> = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; -export const EngineOverviewHeader: React.FC = ({ appSearchUrl }) => { const buttonProps = { fill: true, iconType: 'popout', }; - if (appSearchUrl) { - buttonProps.href = `${appSearchUrl}/as`; + if (enterpriseSearchUrl) { + buttonProps.href = `${enterpriseSearchUrl}/as`; buttonProps.target = '_blank'; } else { buttonProps.isDisabled = true; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index b91fbdf1a19f3..c1bb71bada723 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -28,10 +28,10 @@ import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kiban import GettingStarted from '../../assets/getting_started.png'; import './setup_guide.scss'; -export const SetupGuide: React.FC<> = props => { +export const SetupGuide: React.FC<> = () => { return ( - + Setup Guide diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index b7cd6e27696d6..4c1a85358ea14 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -4,32 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { Route, Redirect } from 'react-router-dom'; -import { HttpHandler } from 'src/core/public'; -import { TSetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { KibanaContext, IKibanaContext } from '../index'; import { SetupGuide } from './components/setup_guide'; import { EngineOverview } from './components/engine_overview'; -export interface IAppSearchProps { - appSearchUrl?: string; - http(): HttpHandler; - setBreadCrumbs(): TSetBreadcrumbs; -} +export const AppSearch: React.FC<> = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; -export const AppSearch: React.FC = props => ( - <> - - {!props.appSearchUrl ? ( - - ) : ( - - )} - - - - - -); + return ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 3a7f5b37a2861..473d395c1e604 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -8,27 +8,40 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter, Route, Redirect } from 'react-router-dom'; -import { CoreStart, AppMountParams } from 'src/core/public'; +import { CoreStart, AppMountParams, HttpHandler } from 'src/core/public'; import { ClientConfigType } from '../plugin'; +import { TSetBreadcrumbs } from './shared/kibana_breadcrumbs'; import { AppSearch } from './app_search'; +export interface IKibanaContext { + enterpriseSearchUrl?: string; + http(): HttpHandler; + setBreadCrumbs(): TSetBreadcrumbs; +} + +export const KibanaContext = React.createContext(); + export const renderApp = (core: CoreStart, params: AppMountParams, config: ClientConfigType) => { ReactDOM.render( - - - {/* This will eventually contain an Enterprise Search landing page, - and we'll also actually have a /workplace_search route */} - - - - - - , + + + + {/* This will eventually contain an Enterprise Search landing page, + and we'll also actually have a /workplace_search route */} + + + + + + + , params.element ); return () => ReactDOM.unmountComponentAtNode(params.element); 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 7ce1ba1268f98..19ba890e0af0d 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 @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; +import { KibanaContext, IKibanaContext } from '../../index'; import { appSearchBreadcrumbs } from './generate_breadcrumbs'; /** @@ -18,16 +19,13 @@ export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; interface ISetBreadcrumbsProps { text: string; - setBreadcrumbs(): setBreadcrumbs; isRoot?: boolean; } -export const SetAppSearchBreadcrumbs: React.FC = ({ - text, - setBreadcrumbs, - isRoot, -}) => { +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(() => { From 1719a9d9562b2698e1c0edb10173e5191a1eb9b1 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 28 Apr 2020 12:07:05 -0400 Subject: [PATCH 12/48] Added unit tests for public (#4) * application.test.ts * Added Unit Test for EngineOverviewHeader * Added Unit Test for generate_breadcrumbs * Added Unit Test for set_breadcrumb.tsx * Added a unit test for link_events - Also changed link_events.tsx to link_events.ts since it's just TS, no React - Modified letBrowserHandleEvent so it will still return a false boolean when target is blank * Betterize these tests Co-Authored-By: Constance Co-authored-by: Constance --- .../engine_overview_header.test.tsx | 56 +++++++ .../engine_overview_header.tsx | 1 + .../public/applications/index.test.ts | 20 +++ .../generate_breadcrumbs.test.ts | 151 ++++++++++++++++++ .../set_breadcrumbs.test.tsx | 75 +++++++++ .../react_router_helpers/link_events.test.ts | 102 ++++++++++++ .../{link_events.tsx => link_events.ts} | 2 +- .../applications/test_utils/helpers.tsx | 25 +++ 8 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts rename x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/{link_events.tsx => link_events.ts} (95%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx new file mode 100644 index 0000000000000..19ea683eb878c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 { EngineOverviewHeader } from '../engine_overview_header'; +import { mountWithKibanaContext } from '../../../test_utils/helpers'; + +describe('EngineOverviewHeader', () => { + describe('when enterpriseSearchUrl is set', () => { + let wrapper; + + beforeEach(() => { + wrapper = mountWithKibanaContext(, { + enterpriseSearchUrl: 'http://localhost:3002', + }); + }); + + describe('the Launch App Search button', () => { + const subject = () => wrapper.find('EuiButton[data-test-subj="launchButton"]'); + + it('should not be disabled', () => { + expect(subject().props().isDisabled).toBeFalsy(); + }); + + it('should use the enterpriseSearchUrl as the base path for its href', () => { + expect(subject().props().href).toBe('http://localhost:3002/as'); + }); + }); + }); + + describe('when enterpriseSearchUrl is not set', () => { + let wrapper; + + beforeEach(() => { + wrapper = mountWithKibanaContext(, { + enterpriseSearchUrl: undefined, + }); + }); + + describe('the Launch App Search button', () => { + const subject = () => wrapper.find('EuiButton[data-test-subj="launchButton"]'); + + it('should be disabled', () => { + expect(subject().props().isDisabled).toBe(true); + }); + + it('should not have an href', () => { + expect(subject().props().href).toBeUndefined(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index 14b2d00668c0d..69bfb9ad124eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -15,6 +15,7 @@ export const EngineOverviewHeader: React.FC<> = () => { const buttonProps = { fill: true, iconType: 'popout', + ['data-test-subj']: 'launchButton', }; if (enterpriseSearchUrl) { buttonProps.href = `${enterpriseSearchUrl}/as`; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.ts b/x-pack/plugins/enterprise_search/public/applications/index.test.ts new file mode 100644 index 0000000000000..7ece7e153c154 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.ts @@ -0,0 +1,20 @@ +/* + * 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 { coreMock } from 'src/core/public/mocks'; +import { renderApp } from '../applications'; + +describe('renderApp', () => { + it('mounts and unmounts UI', () => { + const params = coreMock.createAppMountParamters(); + const core = coreMock.createStart(); + + const unmount = renderApp(core, params, {}); + expect(params.element.querySelector('.setup-guide')).not.toBeNull(); + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); +}); 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 new file mode 100644 index 0000000000000..aa2b584d98425 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -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 { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from '../kibana_breadcrumbs'; + +jest.mock('../react_router_helpers', () => ({ + letBrowserHandleEvent: () => false, +})); + +describe('appSearchBreadcrumbs', () => { + const historyMock = { + createHref: jest.fn().mockImplementation(path => path.pathname), + push: jest.fn(), + }; + + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + const subject = () => appSearchBreadcrumbs(historyMock)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => { + expect(subject()).toEqual([ + { + href: '/', + onClick: expect.any(Function), + text: 'Enterprise Search', + }, + { + href: '/app_search', + onClick: expect.any(Function), + text: 'App Search', + }, + { + href: '/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + }; + + it('has a link to Enterprise Search Home page first', () => { + subject()[0].onClick(eventMock); + expect(historyMock.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to App Search second', () => { + subject()[1].onClick(eventMock); + expect(historyMock.push).toHaveBeenCalledWith('/app_search'); + }); + + it('has a link to page 1 third', () => { + subject()[2].onClick(eventMock); + expect(historyMock.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + subject()[3].onClick(eventMock); + expect(historyMock.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); + +describe('enterpriseSearchBreadcrumbs', () => { + const historyMock = { + createHref: jest.fn(), + push: jest.fn(), + }; + + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + const subject = () => enterpriseSearchBreadcrumbs(historyMock)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => { + expect(subject()).toEqual([ + { + href: undefined, + onClick: expect.any(Function), + text: 'Enterprise Search', + }, + { + href: undefined, + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: undefined, + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + }; + + it('has a link to Enterprise Search Home page first', () => { + subject()[0].onClick(eventMock); + expect(historyMock.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to page 1 second', () => { + subject()[1].onClick(eventMock); + expect(historyMock.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + subject()[2].onClick(eventMock); + expect(historyMock.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx new file mode 100644 index 0000000000000..788800d86ec84 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 { SetAppSearchBreadcrumbs } from '../kibana_breadcrumbs'; +import { mountWithKibanaContext } from '../../test_utils/helpers'; + +jest.mock('./generate_breadcrumbs', () => ({ + appSearchBreadcrumbs: jest.fn(), +})); +import { appSearchBreadcrumbs } from './generate_breadcrumbs'; + +jest.mock('react-router-dom', () => ({ + useHistory: () => ({ + createHref: jest.fn(), + push: jest.fn(), + location: { + pathname: '/current-path', + }, + }), +})); + +describe('SetAppSearchBreadcrumbs', () => { + const setBreadcrumbs = jest.fn(); + const builtBreadcrumbs = []; + const appSearchBreadCrumbsInnerCall = jest.fn().mockReturnValue(builtBreadcrumbs); + const appSearchBreadCrumbsOuterCall = jest.fn().mockReturnValue(appSearchBreadCrumbsInnerCall); + appSearchBreadcrumbs.mockImplementation(appSearchBreadCrumbsOuterCall); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const mountSetAppSearchBreadcrumbs = props => { + return mountWithKibanaContext(, { + http: {}, + enterpriseSearchUrl: 'http://localhost:3002', + setBreadcrumbs, + }); + }; + + describe('when isRoot is false', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: false }); + + it('calls appSearchBreadcrumbs to build breadcrumbs, then registers them with Kibana', () => { + subject(); + + // calls appSearchBreadcrumbs to build breadcrumbs with the target page and current location + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([ + { text: 'Page 1', path: '/current-path' }, + ]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); + + describe('when isRoot is true', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: true }); + + it('calls appSearchBreadcrumbs to build breadcrumbs with an empty breadcrumb, then registers them with Kibana', () => { + subject(); + + // uses an empty bredcrumb + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts new file mode 100644 index 0000000000000..49ab5ed920e36 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('letBrowserHandleEvent', () => { + const event = { + defaultPrevented: false, + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + button: 0, + target: { + getAttribute: () => '_self', + }, + }; + + describe('the browser should handle the link when', () => { + it('default is prevented', () => { + expect(letBrowserHandleEvent({ ...event, defaultPrevented: true })).toBe(true); + }); + + it('is modified with metaKey', () => { + expect(letBrowserHandleEvent({ ...event, metaKey: true })).toBe(true); + }); + + it('is modified with altKey', () => { + expect(letBrowserHandleEvent({ ...event, altKey: true })).toBe(true); + }); + + it('is modified with ctrlKey', () => { + expect(letBrowserHandleEvent({ ...event, ctrlKey: true })).toBe(true); + }); + + it('is modified with shiftKey', () => { + expect(letBrowserHandleEvent({ ...event, shiftKey: true })).toBe(true); + }); + + it('it is not a left click event', () => { + expect(letBrowserHandleEvent({ ...event, button: 2 })).toBe(true); + }); + + it('the target is anything value other than _self', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue('_blank'), + }) + ).toBe(true); + }); + }); + + describe('the browser should NOT handle the link when', () => { + it('default is not prevented', () => { + expect(letBrowserHandleEvent({ ...event, defaultPrevented: false })).toBe(false); + }); + + it('is not modified', () => { + expect( + letBrowserHandleEvent({ + ...event, + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + }) + ).toBe(false); + }); + + it('it is a left click event', () => { + expect(letBrowserHandleEvent({ ...event, button: 0 })).toBe(false); + }); + + it('the target is a value of _self', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue('_self'), + }) + ).toBe(false); + }); + + it('the target has no value', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue(null), + }) + ).toBe(false); + }); + }); +}); + +const targetValue = value => { + return { + getAttribute: () => value, + }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts similarity index 95% rename from x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.tsx rename to x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts index dba5d576faa7d..bb87ecaf6877b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts @@ -26,5 +26,5 @@ const isLeftClickEvent: THandleEvent = event => event.button === 0; const isTargetBlank: THandleEvent = event => { const target = event.target.getAttribute('target'); - return target && target !== '_self'; + return !!target && target !== '_self'; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx b/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx new file mode 100644 index 0000000000000..9343e927e82ac --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx @@ -0,0 +1,25 @@ +/* + * 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 { mount } from 'enzyme'; + +import { KibanaContext } from '..'; + +export const mountWithKibanaContext = (node, contextProps) => { + return mount( + + {node} + + ); +}; From c6393ed22a388f2a01695cd05fdf9fac501fe7a6 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 28 Apr 2020 13:01:28 -0700 Subject: [PATCH 13/48] Add UI telemetry tracking to AS in Kibana (#5) * Set up Telemetry usageCollection, savedObjects, route, & shared helper - The Kibana UsageCollection plugin handles collecting our telemetry UI data (views, clicks, errors, etc.) and pushing it to elastic's telemetry servers - That data is stored in incremented in Kibana's savedObjects lib/plugin (as well as mapped) - When an end-user hits a certain view or action, the shared helper will ping the app search telemetry route which increments the savedObject store * Update client-side views/links to new shared telemetry helper * Write tests for new telemetry files --- x-pack/plugins/enterprise_search/kibana.json | 1 + .../components/empty_states/error_state.tsx | 3 +- .../components/empty_states/no_user_state.tsx | 2 + .../engine_overview/engine_overview.tsx | 2 + .../engine_overview/engine_table.tsx | 26 ++-- .../engine_overview_header.tsx | 10 +- .../components/setup_guide/setup_guide.tsx | 3 + .../applications/shared/telemetry/index.ts | 8 ++ .../shared/telemetry/send_telemetry.test.tsx | 56 +++++++++ .../shared/telemetry/send_telemetry.tsx | 50 ++++++++ .../collectors/app_search/telemetry.test.ts | 118 ++++++++++++++++++ .../server/collectors/app_search/telemetry.ts | 109 ++++++++++++++++ .../enterprise_search/server/plugin.ts | 45 ++++++- .../routes/app_search/telemetry.test.ts | 111 ++++++++++++++++ .../server/routes/app_search/telemetry.ts | 42 +++++++ .../saved_objects/app_search/telemetry.ts | 70 +++++++++++ 16 files changed, 640 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index 0a049f80a82fe..d0c4c9733da2a 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -4,6 +4,7 @@ "kibanaVersion": "kibana", "requiredPlugins": ["home"], "configPath": ["enterpriseSearch"], + "optionalPlugins": ["usageCollection"], "server": true, "ui": true } 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 b33a4ea17f756..7459800d4a893 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 @@ -9,8 +9,8 @@ import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@ import { EuiButton } from '../../../shared/react_router_helpers'; 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'; @@ -21,6 +21,7 @@ export const ErrorState: ReactFC<> = () => { return ( + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index f1ebaed71d95b..41ffe88f57fcc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { EngineOverviewHeader } from '../engine_overview_header'; import { getUserName } from '../../utils/get_username'; @@ -19,6 +20,7 @@ export const NoUserState: React.FC<> = () => { return ( + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 67f2a512ca1b3..c55f1f46c0e50 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import EnginesIcon from '../../assets/engine.svg'; @@ -96,6 +97,7 @@ export const EngineOverview: ReactFC<> = () => { return ( + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index c4ad289bfda6f..ed51c40671b4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -7,6 +7,7 @@ import React, { useContext } from 'react'; import { EuiBasicTable, EuiLink } from '@elastic/eui'; +import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; interface IEngineTableProps { @@ -27,17 +28,24 @@ export const EngineTable: ReactFC = ({ data, pagination: { totalEngines, pageIndex = 0, onPaginate }, }) => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const engineLinkProps = { + href: `${enterpriseSearchUrl}/as/engines/${name}`, + target: '_blank', + onClick: () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }), + }; const columns = [ { field: 'name', name: 'Name', - render: name => ( - - {name} - - ), + render: name => {name}, width: '30%', truncateText: true, mobileOptions: { @@ -78,11 +86,7 @@ export const EngineTable: ReactFC = ({ field: 'name', name: 'Actions', dataType: 'string', - render: name => ( - - Manage - - ), + render: name => Manage, align: 'right', width: '100px', }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index 69bfb9ad124eb..df3238fde56d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -7,10 +7,11 @@ import React, { useContext } from 'react'; import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiButton } from '@elastic/eui'; +import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; export const EngineOverviewHeader: React.FC<> = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { fill: true, @@ -20,6 +21,13 @@ export const EngineOverviewHeader: React.FC<> = () => { if (enterpriseSearchUrl) { buttonProps.href = `${enterpriseSearchUrl}/as`; buttonProps.target = '_blank'; + buttonProps.onClick = () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'header_launch_button', + }); } else { buttonProps.isDisabled = true; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index c1bb71bada723..3931f1dc0073e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -24,6 +24,7 @@ import { } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import GettingStarted from '../../assets/getting_started.png'; import './setup_guide.scss'; @@ -32,6 +33,8 @@ export const SetupGuide: React.FC<> = () => { return ( + + Setup Guide 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 new file mode 100644 index 0000000000000..f871f48b17154 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { sendTelemetry } from './send_telemetry'; +export { SendAppSearchTelemetry } 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 new file mode 100644 index 0000000000000..a46e2124d95c5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 { mount } from 'enzyme'; + +import { httpServiceMock } from 'src/core/public/mocks'; +import { mountWithKibanaContext } from '../../test_utils/helpers'; +import { sendTelemetry, SendAppSearchTelemetry } from './'; + +describe('Shared Telemetry Helpers', () => { + const httpMock = httpServiceMock.createSetupContract(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('sendTelemetry', () => { + it('successfully calls the server-side telemetry endpoint', () => { + sendTelemetry({ + http: httpMock, + product: 'enterprise_search', + action: 'viewed', + metric: 'setup_guide', + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: '{"action":"viewed","metric":"setup_guide"}', + }); + }); + + it('throws an error if the telemetry endpoint fails', () => { + const httpRejectMock = { put: () => Promise.reject() }; + + expect(sendTelemetry({ http: httpRejectMock })).rejects.toThrow('Unable to send telemetry'); + }); + }); + + describe('React component helpers', () => { + it('SendAppSearchTelemetry component', () => { + const wrapper = mountWithKibanaContext( + , + { http: httpMock } + ); + + expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: '{"action":"clicked","metric":"button"}', + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..00c521303d269 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.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 React, { useContext, useEffect } from 'react'; + +import { HttpHandler } from 'src/core/public'; +import { KibanaContext, IKibanaContext } from '../../index'; + +interface ISendTelemetryProps { + action: 'viewed' | 'error' | 'clicked'; + metric: string; // e.g., 'setup_guide' +} + +interface ISendTelemetry extends ISendTelemetryProps { + http(): HttpHandler; + product: 'app_search' | 'workplace_search' | 'enterprise_search'; +} + +/** + * Base function - useful for non-component actions, e.g. clicks + */ + +export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { + try { + await http.put(`/api/${product}/telemetry`, { + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ action, metric }), + }); + } catch (error) { + throw new Error('Unable to send telemetry'); + } +}; + +/** + * React component helpers - useful for on-page-load/views + * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry + */ + +export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'app_search' }); + }, [action, metric, http]); + + return null; +}; 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 new file mode 100644 index 0000000000000..f6028284f3d00 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -0,0 +1,118 @@ +/* + * 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 { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; + +/** + * Since these route callbacks are so thin, these serve simply as integration tests + * to ensure they're wired up to the lib functions correctly. Business logic is tested + * more thoroughly in the lib/telemetry tests. + */ +describe('App Search Telemetry Usage Collector', () => { + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.setup_guide': 10, + 'ui_viewed.engines_overview': 20, + 'ui_error.cannot_connect': 3, + 'ui_error.no_as_account': 4, + 'ui_clicked.header_launch_button': 50, + 'ui_clicked.engine_table_link': 60, + }, + }), + incrementCounter: jest.fn(), + }; + const dependencies = { + usageCollection: { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + }, + savedObjects: { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(dependencies); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search_kibana_telemetry'); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(dependencies); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 10, + engines_overview: 20, + }, + ui_error: { + cannot_connect: 3, + no_as_account: 4, + }, + ui_clicked: { + header_launch_button: 50, + engine_table_link: 60, + }, + }); + }); + + it('should not error & should return a default telemetry object if no saved data exists', async () => { + registerTelemetryUsageCollector({ + ...dependencies, + savedObjects: { createInternalRepository: () => ({}) }, + }); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + no_as_account: 0, + }, + ui_clicked: { + header_launch_button: 0, + engine_table_link: 0, + }, + }); + }); + }); + + describe('incrementUICounter', () => { + it('should increment the saved objects internal repository', async () => { + const { savedObjects } = dependencies; + + const response = await incrementUICounter({ + savedObjects, + uiAction: 'ui_clicked', + metric: 'button', + }); + + expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( + 'app_search_kibana_telemetry', + 'app_search_kibana_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 new file mode 100644 index 0000000000000..302e9843488e1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -0,0 +1,109 @@ +/* + * 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 { set } from 'lodash'; +import { ISavedObjectsRepository, SavedObjectsServiceStart } from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +import { AS_TELEMETRY_NAME, ITelemetrySavedObject } from '../../saved_objects/app_search/telemetry'; + +/** + * Register the telemetry collector + */ + +interface Dependencies { + savedObjects: SavedObjectsServiceStart; + usageCollection: UsageCollectionSetup; +} + +export const registerTelemetryUsageCollector = ({ + usageCollection, + savedObjects, +}: Dependencies) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: AS_TELEMETRY_NAME, + fetch: async () => fetchTelemetryMetrics(savedObjects), + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = await getSavedObjectAttributesFromRepo(savedObjectsRepository); + + const defaultTelemetrySavedObject: ITelemetrySavedObject = { + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + no_as_account: 0, + }, + ui_clicked: { + header_launch_button: 0, + engine_table_link: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + // Iterate through each attribute key and set its saved values + const attributeKeys = Object.keys(savedObjectAttributes); + const telemetryObj = defaultTelemetrySavedObject; + attributeKeys.forEach((key: string) => { + set(telemetryObj, key, savedObjectAttributes[key]); + }); + + return telemetryObj as ITelemetrySavedObject; +}; + +/** + * Helper function - fetches saved objects attributes + */ + +interface ISavedObjectAttributes { + [key: string]: any; +} + +const getSavedObjectAttributesFromRepo = async ( + savedObjectsRepository: ISavedObjectsRepository +) => { + try { + return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes; + } catch (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/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 1ebe58ab28c8e..d9e5ce79537e6 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -6,9 +6,23 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { + Plugin, + PluginInitializerContext, + CoreSetup, + CoreStart, + SavedObjectsServiceStart, +} from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { registerEnginesRoute } from './routes/app_search/engines'; +import { registerTelemetryRoute } from './routes/app_search/telemetry'; +import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; + +export interface PluginsSetup { + usageCollection?: UsageCollectionSetup; +} export interface ServerConfigType { host?: string; @@ -16,20 +30,45 @@ export interface ServerConfigType { export class EnterpriseSearchPlugin implements Plugin { private config: Observable; + private savedObjects?: SavedObjectsServiceStart; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create(); } - public async setup({ http }: CoreSetup) { + public async setup( + { http, savedObjects, getStartServices }: CoreSetup, + { usageCollection }: PluginsSetup + ) { const router = http.createRouter(); const config = await this.config.pipe(first()).toPromise(); const dependencies = { router, config }; registerEnginesRoute(dependencies); + + /** + * Bootstrap the routes, saved objects, and collector for telemetry + */ + registerTelemetryRoute({ + ...dependencies, + getSavedObjectsService: () => { + if (!this.savedObjectsServiceStart) { + throw new Error('Saved Objects Start service not available'); + } + return this.savedObjectsServiceStart; + }, + }); + savedObjects.registerType(appSearchTelemetryType); + if (usageCollection) { + getStartServices().then(([{ savedObjects: savedObjectsStarted }]) => { + registerTelemetryUsageCollector({ usageCollection, savedObjects: savedObjectsStarted }); + }); + } } - public start() {} + public start({ savedObjects }: CoreStart) { + this.savedObjectsServiceStart = savedObjects; + } public stop() {} } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts new file mode 100644 index 0000000000000..02fc3f63f402a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -0,0 +1,111 @@ +/* + * 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, RouterMock } from 'src/core/server/http/router/router.mock'; +import { savedObjectsServiceMock } from 'src/core/server/saved_objects/saved_objects_service.mock'; +import { httpServerMock } from 'src/core/server/http/http_server.mocks'; + +import { registerTelemetryRoute } from './telemetry'; + +jest.mock('../../collectors/app_search/telemetry', () => ({ + incrementUICounter: jest.fn(), +})); +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +describe('App Search Telemetry API', () => { + let router: RouterMock; + const mockResponseFactory = httpServerMock.createResponseFactory(); + + beforeEach(() => { + jest.resetAllMocks(); + router = mockRouter.create(); + registerTelemetryRoute({ + router, + getSavedObjectsService: () => savedObjectsServiceMock.create(), + }); + }); + + describe('PUT /api/app_search/telemetry', () => { + it('increments the saved objects counter', async () => { + const successResponse = { success: true }; + incrementUICounter.mockImplementation(jest.fn(() => successResponse)); + + await callThisRoute('put', { body: { action: 'viewed', metric: 'setup_guide' } }); + + expect(incrementUICounter).toHaveBeenCalledWith({ + savedObjects: expect.any(Object), + uiAction: 'ui_viewed', + metric: 'setup_guide', + }); + expect(mockResponseFactory.ok).toHaveBeenCalledWith({ body: successResponse }); + }); + + it('throws an error when incrementing fails', async () => { + incrementUICounter.mockImplementation(jest.fn(() => Promise.reject())); + + await callThisRoute('put', { body: { action: 'error', metric: 'error' } }); + + expect(incrementUICounter).toHaveBeenCalled(); + expect(mockResponseFactory.internalError).toHaveBeenCalled(); + }); + + describe('validates', () => { + const itShouldValidate = request => { + expect(() => executeRouteValidation(request)).not.toThrow(); + }; + + const itShouldThrow = request => { + expect(() => executeRouteValidation(request)).toThrow(); + }; + + it('correctly', () => { + const request = { body: { action: 'viewed', metric: 'setup_guide' } }; + itShouldValidate(request); + }); + + it('wrong action enum', () => { + const request = { body: { action: 'invalid', metric: 'setup_guide' } }; + itShouldThrow(request); + }); + + describe('wrong metric type', () => { + const request = { body: { action: 'clicked', metric: true } }; + itShouldThrow(request); + }); + + describe('action is missing', () => { + const request = { body: { metric: 'engines_overview' } }; + itShouldThrow(request); + }); + + describe('metric is missing', () => { + const request = { body: { action: 'error' } }; + itShouldThrow(request); + }); + }); + }); + + /** + * Test helpers + */ + + const callThisRoute = async (method, request) => { + const [_, handler] = router[method].mock.calls[0]; + + const context = {}; + await handler(context, httpServerMock.createKibanaRequest(request), mockResponseFactory); + }; + + const executeRouteValidation = request => { + const method = 'put'; + + const [config] = router[method].mock.calls[0]; + const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + + const payload = 'body'; + validate[payload].validate(request[payload]); + }; +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts new file mode 100644 index 0000000000000..3eabe1f19c5ff --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts @@ -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 { schema } from '@kbn/config-schema'; + +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +export function registerTelemetryRoute({ router, getSavedObjectsService }) { + router.put( + { + path: '/api/app_search/telemetry', + validate: { + body: schema.object({ + action: schema.oneOf([ + schema.literal('viewed'), + schema.literal('clicked'), + schema.literal('error'), + ]), + metric: schema.string(), + }), + }, + }, + async (ctx, request, response) => { + const { action, metric } = request.body; + + try { + return response.ok({ + body: await incrementUICounter({ + savedObjects: getSavedObjectsService(), + uiAction: `ui_${action}`, + metric, + }), + }); + } catch (e) { + return response.internalError({ body: 'App Search UI telemetry failed' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts new file mode 100644 index 0000000000000..614492bcf6510 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -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 { SavedObjectsType } from 'src/core/server'; + +export const AS_TELEMETRY_NAME = 'app_search_kibana_telemetry'; + +export interface ITelemetrySavedObject { + ui_viewed: { + setup_guide: number; + engines_overview: number; + }; + ui_error: { + cannot_connect: number; + no_as_account: number; + }; + ui_clicked: { + header_launch_button: number; + engine_table_link: number; + }; +} + +export const appSearchTelemetryType: SavedObjectsType = { + name: AS_TELEMETRY_NAME, + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + ui_viewed: { + properties: { + setup_guide: { + type: 'long', + null_value: 0, + }, + engines_overview: { + type: 'long', + null_value: 0, + }, + }, + }, + ui_error: { + properties: { + cannot_connect: { + type: 'long', + null_value: 0, + }, + no_as_account: { + type: 'long', + null_value: 0, + }, + }, + }, + ui_clicked: { + properties: { + header_launch_button: { + type: 'long', + null_value: 0, + }, + engine_table_link: { + type: 'long', + null_value: 0, + }, + }, + }, + }, + }, +}; From c8e3d994f26fe8e5b717eaf86a6f238ba598743b Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 4 May 2020 08:35:31 -0700 Subject: [PATCH 14/48] Implement remaining unit tests (#7) * Write tests for React Router+EUI helper components * Update generate_breadcrumbs test - add test suite for generateBreadcrumb() itself (in order to cover a missing branch) - minor lint fixes - remove unnecessary import from set_breadcrumbs test * Write test for get_username util + update test to return a more consistent falsey value (null) * Add test for SetupGuide * [Refactor] Pull out various Kibana context mocks into separate files - I'm creating a reusable useContext mock for shallow()ed enzyme components + add more documentation comments + examples * Write tests for empty state components + test new usecontext shallow mock * Empty state components: Add extra getUserName branch test * Write test for app search index/routes * Write tests for engine overview table + fix bonus bug * Write Engine Overview tests + Update EngineOverview logic to account for issues found during tests :) - Move http to async/await syntax instead of promise syntax (works better with existing HttpServiceMock jest.fn()s) - hasValidData wasn't strict enough in type checking/object nest checking and was causing the app itself to crash (no bueno) * Refactor EngineOverviewHeader test to use shallow + to full coverage - missed adding this test during telemetry work - switching to shallow and beforeAll reduces the test time from 5s to 4s! * [Refactor] Pull out React Router history mocks into a test util helper + minor refactors/updates * Add small tests to increase branch coverage - mostly testing fallbacks or removing fallbacks in favor of strict type interface - these are slightly obsessive so I'd also be fine ditching them if they aren't terribly valuable --- .../empty_states/empty_states.test.tsx | 62 +++++++ .../engine_overview/engine_overview.test.tsx | 153 ++++++++++++++++++ .../engine_overview/engine_overview.tsx | 45 +++--- .../engine_overview/engine_table.test.tsx | 80 +++++++++ .../engine_overview/engine_table.tsx | 15 +- .../engine_overview_header.test.tsx | 46 +++--- .../setup_guide/setup_guide.test.tsx | 20 +++ .../applications/app_search/index.test.tsx | 44 +++++ .../app_search/utils/get_username.test.ts | 29 ++++ .../app_search/utils/get_username.ts | 6 +- .../generate_breadcrumbs.test.ts | 118 ++++++++++---- .../set_breadcrumbs.test.tsx | 20 +-- .../react_router_helpers/eui_link.test.tsx | 79 +++++++++ .../shared/telemetry/send_telemetry.test.tsx | 3 +- .../applications/test_utils/helpers.tsx | 25 --- .../public/applications/test_utils/index.ts | 11 ++ .../test_utils/mock_kibana_context.ts | 17 ++ .../test_utils/mock_rr_usehistory.ts | 25 +++ .../test_utils/mock_shallow_usecontext.ts | 39 +++++ .../test_utils/mount_with_context.tsx | 27 ++++ 20 files changed, 741 insertions(+), 123 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx 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 new file mode 100644 index 0000000000000..e75970404dc5e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -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 '../../../test_utils/mock_shallow_usecontext'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiEmptyPrompt, EuiCode, EuiLoadingContent } from '@elastic/eui'; + +jest.mock('../../utils/get_username', () => ({ getUserName: jest.fn() })); +import { getUserName } from '../../utils/get_username'; + +import { ErrorState, NoUserState, EmptyState, LoadingState } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt); + + expect(prompt).toHaveLength(1); + expect(prompt.prop('title')).toEqual(

Cannot connect to App Search

); + }); +}); + +describe('NoUserState', () => { + it('renders', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt); + + expect(prompt).toHaveLength(1); + expect(prompt.prop('title')).toEqual(

Cannot find App Search account

); + }); + + it('renders with username', () => { + getUserName.mockImplementationOnce(() => 'dolores-abernathy'); + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiCode).prop('children')).toContain('dolores-abernathy'); + }); +}); + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt); + + expect(prompt).toHaveLength(1); + expect(prompt.prop('title')).toEqual(

There’s nothing here yet

); + }); +}); + +describe('LoadingState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); + }); +}); 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 new file mode 100644 index 0000000000000..dd3effce21957 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -0,0 +1,153 @@ +/* + * 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 '../../../test_utils/mock_rr_usehistory'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { render } from 'enzyme'; + +import { KibanaContext } from '../../../'; +import { mountWithKibanaContext, mockKibanaContext } from '../../../test_utils'; + +import { EmptyState, ErrorState, NoUserState } from '../empty_states'; +import { EngineTable } from './engine_table'; + +import { EngineOverview } from './'; + +describe('EngineOverview', () => { + describe('non-happy-path states', () => { + it('isLoading', () => { + // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) + const wrapper = render( + + + + ); + + // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly + expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + }); + + it('isEmpty', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ + results: [], + meta: { page: { total_results: 0 } }, + }), + }); + + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ invalidPayload: true }), + }); + expect(wrapper.find(ErrorState)).toHaveLength(1); + }); + + it('hasNoAccount', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ message: 'no-as-account' }), + }); + expect(wrapper.find(NoUserState)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + const mockedApiResponse = { + results: [ + { + name: 'hello-world', + created_at: 'somedate', + document_count: 50, + field_count: 10, + }, + ], + meta: { + page: { + current: 1, + total_pages: 10, + total_results: 100, + size: 10, + }, + }, + }; + const mockApi = jest.fn(() => mockedApiResponse); + let wrapper; + + beforeAll(async () => { + wrapper = await mountWithApiMock({ get: mockApi }); + }); + + it('renders', () => { + expect(wrapper.find(EngineTable)).toHaveLength(2); + }); + + it('calls the engines API', () => { + expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 1, + }, + }); + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + + describe('pagination', () => { + const getTablePagination = () => + wrapper + .find(EngineTable) + .first() + .prop('pagination'); + + it('passes down page data from the API', () => { + const pagination = getTablePagination(); + + expect(pagination.totalEngines).toEqual(100); + expect(pagination.pageIndex).toEqual(0); + }); + + it('re-polls the API on page change', async () => { + await act(async () => getTablePagination().onPaginate(5)); + wrapper.update(); + + expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 5, + }, + }); + expect(getTablePagination().pageIndex).toEqual(4); + }); + }); + }); + + /** + * Test helpers + */ + + const mountWithApiMock = async ({ get }) => { + let wrapper; + 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 = mountWithKibanaContext(, { http: httpMock }); + }); + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + }; +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index c55f1f46c0e50..8c3c6d61c89d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -42,35 +42,40 @@ export const EngineOverview: ReactFC<> = () => { const [metaEnginesPage, setMetaEnginesPage] = useState(1); const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); - const getEnginesData = ({ type, pageIndex }) => { - return http.get('/api/app_search/engines', { + const getEnginesData = async ({ type, pageIndex }) => { + return await http.get('/api/app_search/engines', { query: { type, pageIndex }, }); }; const hasValidData = response => { - return response && response.results && response.meta; + return ( + response && + Array.isArray(response.results) && + response.meta && + response.meta.page && + typeof response.meta.page.total_results === 'number' + ); // TODO: Move to optional chaining once Prettier has been updated to support it }; const hasNoAccountError = response => { return response && response.message === 'no-as-account'; }; - const setEnginesData = (params, callbacks) => { - getEnginesData(params) - .then(response => { - if (!hasValidData(response)) { - if (hasNoAccountError(response)) { - return setHasNoAccount(true); - } - throw new Error('App Search engines response is missing valid data'); + const setEnginesData = async (params, callbacks) => { + try { + const response = await getEnginesData(params); + if (!hasValidData(response)) { + if (hasNoAccountError(response)) { + return setHasNoAccount(true); } - - callbacks.setResults(response.results); - callbacks.setResultsTotal(response.meta.page.total_results); - setIsLoading(false); - }) - .catch(error => { - // TODO - should we be logging errors to telemetry or elsewhere for debugging? - setHasErrorConnecting(true); - }); + throw new Error('App Search engines response is missing valid data'); + } + + callbacks.setResults(response.results); + callbacks.setResultsTotal(response.meta.page.total_results); + setIsLoading(false); + } catch (error) { + // TODO - should we be logging errors to telemetry or elsewhere for debugging? + setHasErrorConnecting(true); + } }; useEffect(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx new file mode 100644 index 0000000000000..0c05131e80835 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; + +import { mountWithKibanaContext } from '../../../test_utils'; +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { EngineTable } from './engine_table'; + +describe('EngineTable', () => { + const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream + + const wrapper = mountWithKibanaContext( + + ); + const table = wrapper.find(EuiBasicTable); + + it('renders', () => { + expect(table).toHaveLength(1); + expect(table.prop('pagination').totalItemCount).toEqual(50); + + const tableContent = table.text(); + expect(tableContent).toContain('test-engine'); + expect(tableContent).toContain('January 1, 1970'); + expect(tableContent).toContain('99,999'); + expect(tableContent).toContain('10'); + + expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page + }); + + it('contains engine links which send telemetry', () => { + const engineLinks = wrapper.find(EuiLink); + + engineLinks.forEach(link => { + expect(link.prop('href')).toEqual('http://localhost:3002/as/engines/test-engine'); + link.simulate('click'); + + expect(sendTelemetry).toHaveBeenCalledWith({ + http: expect.any(Object), + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }); + }); + }); + + it('triggers onPaginate', () => { + table.prop('onChange')({ page: { index: 4 } }); + + expect(onPaginate).toHaveBeenCalledWith(5); + }); + + it('handles empty data', () => { + const emptyWrapper = mountWithKibanaContext( + + ); + const emptyTable = wrapper.find(EuiBasicTable); + expect(emptyTable.prop('pagination').pageIndex).toEqual(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index ed51c40671b4a..8db8538e82788 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -23,13 +23,18 @@ interface IEngineTableProps { onPaginate(pageIndex: number); }; } +interface IOnChange { + page: { + index: number; + }; +} export const EngineTable: ReactFC = ({ data, pagination: { totalEngines, pageIndex = 0, onPaginate }, }) => { const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; - const engineLinkProps = { + const engineLinkProps = name => ({ href: `${enterpriseSearchUrl}/as/engines/${name}`, target: '_blank', onClick: () => @@ -39,13 +44,13 @@ export const EngineTable: ReactFC = ({ action: 'clicked', metric: 'engine_table_link', }), - }; + }); const columns = [ { field: 'name', name: 'Name', - render: name => {name}, + render: name => {name}, width: '30%', truncateText: true, mobileOptions: { @@ -86,7 +91,7 @@ export const EngineTable: ReactFC = ({ field: 'name', name: 'Actions', dataType: 'string', - render: name => Manage, + render: name => Manage, align: 'right', width: '100px', }, @@ -102,7 +107,7 @@ export const EngineTable: ReactFC = ({ totalItemCount: totalEngines, hidePerPageOptions: true, }} - onChange={({ page = {} }) => { + onChange={({ page }): IOnChange => { const { index } = page; onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0 }} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx index 19ea683eb878c..03801e2b9f82d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -4,52 +4,58 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import '../../../test_utils/mock_shallow_usecontext'; + +import React, { useContext } from 'react'; +import { shallow } from 'enzyme'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; import { EngineOverviewHeader } from '../engine_overview_header'; -import { mountWithKibanaContext } from '../../../test_utils/helpers'; describe('EngineOverviewHeader', () => { describe('when enterpriseSearchUrl is set', () => { - let wrapper; + let button; - beforeEach(() => { - wrapper = mountWithKibanaContext(, { - enterpriseSearchUrl: 'http://localhost:3002', - }); + beforeAll(() => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'http://localhost:3002' })); + const wrapper = shallow(); + button = wrapper.find('[data-test-subj="launchButton"]'); }); describe('the Launch App Search button', () => { - const subject = () => wrapper.find('EuiButton[data-test-subj="launchButton"]'); - it('should not be disabled', () => { - expect(subject().props().isDisabled).toBeFalsy(); + expect(button.props().isDisabled).toBeFalsy(); }); it('should use the enterpriseSearchUrl as the base path for its href', () => { - expect(subject().props().href).toBe('http://localhost:3002/as'); + expect(button.props().href).toBe('http://localhost:3002/as'); + }); + + it('should send telemetry when clicked', () => { + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); }); }); }); describe('when enterpriseSearchUrl is not set', () => { - let wrapper; + let button; - beforeEach(() => { - wrapper = mountWithKibanaContext(, { - enterpriseSearchUrl: undefined, - }); + beforeAll(() => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: undefined })); + const wrapper = shallow(); + button = wrapper.find('[data-test-subj="launchButton"]'); }); describe('the Launch App Search button', () => { - const subject = () => wrapper.find('EuiButton[data-test-subj="launchButton"]'); - it('should be disabled', () => { - expect(subject().props().isDisabled).toBe(true); + expect(button.props().isDisabled).toBe(true); }); it('should not have an href', () => { - expect(subject().props().href).toBeUndefined(); + expect(button.props().href).toBeUndefined(); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..0307d8a1555ec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -0,0 +1,20 @@ +/* + * 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 { EuiPageSideBar, EuiTabbedContent } from '@elastic/eui'; + +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTabbedContent)).toHaveLength(1); + expect(wrapper.find(EuiPageSideBar)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx new file mode 100644 index 0000000000000..45d094f3c255a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 '../test_utils/mock_shallow_usecontext'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { EngineOverview } from './components/engine_overview'; + +import { AppSearch } from './'; + +describe('App Search Routes', () => { + describe('/app_search', () => { + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(EngineOverview)).toHaveLength(0); + }); + + it('renders Engine Overview when enterpriseSearchUrl is set', () => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'https://foo.bar' })); + const wrapper = shallow(); + + expect(wrapper.find(EngineOverview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/app_search/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts new file mode 100644 index 0000000000000..c0a9ee5a90ea5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts @@ -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 { getUserName } from './get_username'; + +describe('getUserName', () => { + it('fetches the current username from the DOM', () => { + document.body.innerHTML = + '
' + + ' ' + + '
'; + + expect(getUserName()).toEqual('foo_bar_baz'); + }); + + it('returns null if the expected DOM does not exist', () => { + document.body.innerHTML = '
' + '' + '
'; + expect(getUserName()).toEqual(null); + + document.body.innerHTML = '
'; + expect(getUserName()).toEqual(null); + + document.body.innerHTML = '
'; + expect(getUserName()).toEqual(null); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts index 16320af0f3757..3010da50f913e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts @@ -8,12 +8,12 @@ * Attempt to get the current Kibana user's username * by querying the DOM */ -export const getUserName: () => undefined | string = () => { +export const getUserName: () => null | string = () => { const userMenu = document.getElementById('headerUserMenu'); - if (!userMenu) return; + if (!userMenu) return null; const avatar = userMenu.querySelector('.euiAvatar'); - if (!avatar) return; + if (!avatar) return null; const username = avatar.getAttribute('aria-label'); return username; 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 aa2b584d98425..a76170fdf795e 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 @@ -4,18 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from '../kibana_breadcrumbs'; +import { generateBreadcrumb } from './generate_breadcrumbs'; +import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; -jest.mock('../react_router_helpers', () => ({ - letBrowserHandleEvent: () => false, -})); +import { mockHistory } from '../../test_utils'; -describe('appSearchBreadcrumbs', () => { - const historyMock = { - createHref: jest.fn().mockImplementation(path => path.pathname), - push: jest.fn(), - }; +jest.mock('../react_router_helpers', () => ({ letBrowserHandleEvent: jest.fn(() => false) })); +import { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('generateBreadcrumb', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("creates a breadcrumb object matching EUI's breadcrumb type", () => { + const breadcrumb = generateBreadcrumb({ + text: 'Hello World', + path: '/hello_world', + history: mockHistory, + }); + expect(breadcrumb).toEqual({ + text: 'Hello World', + href: '/enterprise_search/hello_world', + onClick: expect.any(Function), + }); + }); + + it('prevents default navigation and uses React Router history on click', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }); + const event = { preventDefault: jest.fn() }; + breadcrumb.onClick(event); + expect(mockHistory.push).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('does not prevents default browser behavior on new tab/window clicks', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }); + + letBrowserHandleEvent.mockImplementationOnce(() => true); + breadcrumb.onClick(); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); +}); + +describe('appSearchBreadcrumbs', () => { const breadCrumbs = [ { text: 'Page 1', @@ -27,37 +61,52 @@ describe('appSearchBreadcrumbs', () => { }, ]; - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); - const subject = () => appSearchBreadcrumbs(historyMock)(breadCrumbs); + const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs); it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => { expect(subject()).toEqual([ { - href: '/', + href: '/enterprise_search/', onClick: expect.any(Function), text: 'Enterprise Search', }, { - href: '/app_search', + href: '/enterprise_search/app_search', onClick: expect.any(Function), text: 'App Search', }, { - href: '/page1', + href: '/enterprise_search/page1', onClick: expect.any(Function), text: 'Page 1', }, { - href: '/page2', + href: '/enterprise_search/page2', onClick: expect.any(Function), text: 'Page 2', }, ]); }); + it('shows just the root if breadcrumbs is empty', () => { + expect(appSearchBreadcrumbs(mockHistory)()).toEqual([ + { + href: '/enterprise_search/', + onClick: expect.any(Function), + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/app_search', + onClick: expect.any(Function), + text: 'App Search', + }, + ]); + }); + describe('links', () => { const eventMock = { preventDefault: jest.fn(), @@ -65,32 +114,27 @@ describe('appSearchBreadcrumbs', () => { it('has a link to Enterprise Search Home page first', () => { subject()[0].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/'); + expect(mockHistory.push).toHaveBeenCalledWith('/'); }); it('has a link to App Search second', () => { subject()[1].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/app_search'); + expect(mockHistory.push).toHaveBeenCalledWith('/app_search'); }); it('has a link to page 1 third', () => { subject()[2].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page1'); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { subject()[3].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page2'); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); }); }); }); describe('enterpriseSearchBreadcrumbs', () => { - const historyMock = { - createHref: jest.fn(), - push: jest.fn(), - }; - const breadCrumbs = [ { text: 'Page 1', @@ -102,32 +146,42 @@ describe('enterpriseSearchBreadcrumbs', () => { }, ]; - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); - const subject = () => enterpriseSearchBreadcrumbs(historyMock)(breadCrumbs); + const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs); it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => { expect(subject()).toEqual([ { - href: undefined, + href: '/enterprise_search/', onClick: expect.any(Function), text: 'Enterprise Search', }, { - href: undefined, + href: '/enterprise_search/page1', onClick: expect.any(Function), text: 'Page 1', }, { - href: undefined, + href: '/enterprise_search/page2', onClick: expect.any(Function), text: 'Page 2', }, ]); }); + it('shows just the root if breadcrumbs is empty', () => { + expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([ + { + href: '/enterprise_search/', + onClick: expect.any(Function), + text: 'Enterprise Search', + }, + ]); + }); + describe('links', () => { const eventMock = { preventDefault: jest.fn(), @@ -135,17 +189,17 @@ describe('enterpriseSearchBreadcrumbs', () => { it('has a link to Enterprise Search Home page first', () => { subject()[0].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/'); + expect(mockHistory.push).toHaveBeenCalledWith('/'); }); it('has a link to page 1 second', () => { subject()[1].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page1'); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { subject()[2].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page2'); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx index 788800d86ec84..5da0effd15ba5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -6,23 +6,11 @@ import React from 'react'; -import { SetAppSearchBreadcrumbs } from '../kibana_breadcrumbs'; -import { mountWithKibanaContext } from '../../test_utils/helpers'; +import '../../test_utils/mock_rr_usehistory'; +import { mountWithKibanaContext } from '../../test_utils'; -jest.mock('./generate_breadcrumbs', () => ({ - appSearchBreadcrumbs: jest.fn(), -})); -import { appSearchBreadcrumbs } from './generate_breadcrumbs'; - -jest.mock('react-router-dom', () => ({ - useHistory: () => ({ - createHref: jest.fn(), - push: jest.fn(), - location: { - pathname: '/current-path', - }, - }), -})); +jest.mock('./generate_breadcrumbs', () => ({ appSearchBreadcrumbs: jest.fn() })); +import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './'; describe('SetAppSearchBreadcrumbs', () => { const setBreadcrumbs = jest.fn(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx new file mode 100644 index 0000000000000..0ae97383c93bb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.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 from 'react'; +import { shallow } from 'enzyme'; +import { EuiLink, EuiButton } from '@elastic/eui'; + +import '../../test_utils/mock_rr_usehistory'; +import { mockHistory } from '../../test_utils'; + +import { EuiReactRouterLink, EuiReactRouterButton } from './eui_link'; + +describe('EUI & React Router Component Helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + }); + + it('renders an EuiButton', () => { + const wrapper = shallow() + .find(EuiReactRouterLink) + .dive(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + + it('passes down all ...rest props', () => { + const wrapper = shallow(); + const link = wrapper.find(EuiLink); + + expect(link.prop('disabled')).toEqual(true); + expect(link.prop('data-test-subj')).toEqual('foo'); + }); + + it('renders with the correct href and onClick props', () => { + const wrapper = shallow(); + const link = wrapper.find(EuiLink); + + expect(link.prop('onClick')).toBeInstanceOf(Function); + expect(link.prop('href')).toEqual('/enterprise_search/foo/bar'); + expect(mockHistory.createHref).toHaveBeenCalled(); + }); + + describe('onClick', () => { + it('prevents default navigation and uses React Router history', () => { + const wrapper = shallow(); + + const simulatedEvent = { + button: 0, + target: { getAttribute: () => '_self' }, + preventDefault: jest.fn(), + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(simulatedEvent.preventDefault).toHaveBeenCalled(); + expect(mockHistory.push).toHaveBeenCalled(); + }); + + it('does not prevent default browser behavior on new tab/window clicks', () => { + const wrapper = shallow(); + + const simulatedEvent = { + shiftKey: true, + target: { getAttribute: () => '_blank' }, + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + }); +}); 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 a46e2124d95c5..6d92a32a502b1 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 @@ -5,10 +5,9 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { httpServiceMock } from 'src/core/public/mocks'; -import { mountWithKibanaContext } from '../../test_utils/helpers'; +import { mountWithKibanaContext } from '../../test_utils'; import { sendTelemetry, SendAppSearchTelemetry } from './'; describe('Shared Telemetry Helpers', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx b/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx deleted file mode 100644 index 9343e927e82ac..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 { mount } from 'enzyme'; - -import { KibanaContext } from '..'; - -export const mountWithKibanaContext = (node, contextProps) => { - return mount( - - {node} - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts new file mode 100644 index 0000000000000..11627df8d15ba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { mockHistory } from './mock_rr_usehistory'; +export { mockKibanaContext } from './mock_kibana_context'; +export { mountWithKibanaContext } from './mount_with_context'; + +// Note: mock_shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts new file mode 100644 index 0000000000000..fcfa1b0a21f13 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.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 { httpServiceMock } from 'src/core/public/mocks'; + +/** + * A set of default Kibana context values to use across component tests. + * @see enterprise_search/public/index.tsx for the KibanaContext definition/import + */ +export const mockKibanaContext = { + http: httpServiceMock.createSetupContract(), + setBreadcrumbs: jest.fn(), + enterpriseSearchUrl: 'http://localhost:3002', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts new file mode 100644 index 0000000000000..fd422465d87f1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * NOTE: This variable name MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +export const mockHistory = { + createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`), + push: jest.fn(), + location: { + pathname: '/current-path', + }, +}; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn(() => mockHistory), +})); + +/** + * For example usage, @see public/applications/shared/react_router_helpers/eui_link.test.tsx + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts new file mode 100644 index 0000000000000..eca7a7ab6e354 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts @@ -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. + */ + +/** + * NOTE: This variable name MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +import { mockKibanaContext } from './mock_kibana_context'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(() => mockKibanaContext), +})); + +/** + * 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 React from 'react'; + * import { shallow } from 'enzyme'; + * + * // ... etc. + */ + +/** + * If you need to override the default mock context values, you can do so via jest.mockImplementation: + * + * import React, { useContext } from 'react'; + * + * // ... etc. + * + * it('some test', () => { + * useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'someOverride' })); + * }); + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx b/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx new file mode 100644 index 0000000000000..856f3faa7332b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx @@ -0,0 +1,27 @@ +/* + * 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 { mount } from 'enzyme'; + +import { KibanaContext } from '../'; +import { mockKibanaContext } from './mock_kibana_context'; + +/** + * This helper mounts a component with a set of default KibanaContext, + * while also allowing custom context to be passed in via a second arg + * + * Example usage: + * + * const wrapper = mountWithKibanaContext(, { enterpriseSearchUrl: 'someOverride' }); + */ +export const mountWithKibanaContext = (node, contextProps) => { + return mount( + + {node} + + ); +}; From a45d65e667a277820106b940e55f9bba279d43af Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 5 May 2020 08:07:29 -0700 Subject: [PATCH 15/48] Address larger tech debt/TODOs (#8) * Fix optional chaining TODO - turns out my local Prettier wasn't up to date, completely my bad * Fix constants TODO - adds a common folder/architecture for others to use in the future * Remove TODO for eslint-disable-line and specify lint rule being skipped - hopefully that's OK for review, I can't think of any other way to sanely do this without re-architecting the entire file or DDoSing our API * Add server-side logging to route dependencies + add basic example of error catching/logging to Telemetry route + [extra] refactor mockResponseFactory name to something slightly easier to read * Move more Engines Overview API logic/logging to server-side - handle data validation in the server-side - wrap server-side API in a try/catch to account for fetch issues - more correctly return 2xx/4xx statuses and more correctly deal with those responses in the front-end - Add server info/error/debug logs (addresses TODO) - Update tests + minor refactors/cleanup - remove expectResponseToBe200With helper (since we're now returning multiple response types) and instead make mockResponse var name more readable - one-line header auth - update tests with example error logs - update schema validation for `type` to be an enum of `indexed`/`meta` (more accurately reflecting API) --- .../enterprise_search/common/constants.ts | 7 ++ .../engine_overview/engine_overview.test.tsx | 2 +- .../engine_overview/engine_overview.tsx | 32 ++---- .../engine_overview/engine_table.tsx | 4 +- .../kibana_breadcrumbs/set_breadcrumbs.tsx | 2 +- .../enterprise_search/server/plugin.ts | 5 +- .../server/routes/app_search/engines.test.ts | 98 ++++++++++++++----- .../server/routes/app_search/engines.ts | 56 +++++++---- .../routes/app_search/telemetry.test.ts | 14 ++- .../server/routes/app_search/telemetry.ts | 3 +- 10 files changed, 147 insertions(+), 76 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/common/constants.ts diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts new file mode 100644 index 0000000000000..c134131caba75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/constants.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 const ENGINES_PAGE_SIZE = 10; 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 dd3effce21957..8f707fe57bde7 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 @@ -52,7 +52,7 @@ describe('EngineOverview', () => { it('hasNoAccount', async () => { const wrapper = await mountWithApiMock({ - get: () => ({ message: 'no-as-account' }), + get: () => Promise.reject({ body: { message: 'no-as-account' } }), }); expect(wrapper.find(NoUserState)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 8c3c6d61c89d8..d87c36cd9b9d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -47,34 +47,20 @@ export const EngineOverview: ReactFC<> = () => { query: { type, pageIndex }, }); }; - const hasValidData = response => { - return ( - response && - Array.isArray(response.results) && - response.meta && - response.meta.page && - typeof response.meta.page.total_results === 'number' - ); // TODO: Move to optional chaining once Prettier has been updated to support it - }; - const hasNoAccountError = response => { - return response && response.message === 'no-as-account'; - }; const setEnginesData = async (params, callbacks) => { try { const response = await getEnginesData(params); - if (!hasValidData(response)) { - if (hasNoAccountError(response)) { - return setHasNoAccount(true); - } - throw new Error('App Search engines response is missing valid data'); - } callbacks.setResults(response.results); callbacks.setResultsTotal(response.meta.page.total_results); + setIsLoading(false); } catch (error) { - // TODO - should we be logging errors to telemetry or elsewhere for debugging? - setHasErrorConnecting(true); + if (error?.body?.message === 'no-as-account') { + setHasNoAccount(true); + } else { + setHasErrorConnecting(true); + } } }; @@ -83,16 +69,14 @@ export const EngineOverview: ReactFC<> = () => { const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal }; setEnginesData(params, callbacks); - }, [enginesPage]); // eslint-disable-line - // TODO: https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies + }, [enginesPage]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const params = { type: 'meta', pageIndex: metaEnginesPage }; const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal }; setEnginesData(params, callbacks); - }, [metaEnginesPage]); // eslint-disable-line - // TODO: https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies + }, [metaEnginesPage]); // eslint-disable-line react-hooks/exhaustive-deps if (hasErrorConnecting) return ; if (hasNoAccount) return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index 8db8538e82788..e138bade11c15 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -10,6 +10,8 @@ import { EuiBasicTable, EuiLink } from '@elastic/eui'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; +import { ENGINES_PAGE_SIZE } from '../../../../../common/constants'; + interface IEngineTableProps { data: Array<{ name: string; @@ -103,7 +105,7 @@ export const EngineTable: ReactFC = ({ columns={columns} pagination={{ pageIndex, - pageSize: 10, // TODO: pull this out to a constant? + pageSize: ENGINES_PAGE_SIZE, totalItemCount: totalEngines, hidePerPageOptions: true, }} 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 19ba890e0af0d..aaa54febcc20b 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 @@ -30,7 +30,7 @@ export const SetAppSearchBreadcrumbs: React.FC = ({ text, useEffect(() => { setBreadcrumbs(appSearchBreadcrumbs(history)(crumb)); - }, []); // eslint-disable-line + }, []); // eslint-disable-line react-hooks/exhaustive-deps return null; }; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index d9e5ce79537e6..f93fab18ab90e 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -11,6 +11,7 @@ import { PluginInitializerContext, CoreSetup, CoreStart, + Logger, SavedObjectsServiceStart, } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -30,10 +31,12 @@ export interface ServerConfigType { export class EnterpriseSearchPlugin implements Plugin { private config: Observable; + private logger: Logger; private savedObjects?: SavedObjectsServiceStart; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create(); + this.logger = initializerContext.logger.get(); } public async setup( @@ -42,7 +45,7 @@ export class EnterpriseSearchPlugin implements Plugin { ) { const router = http.createRouter(); const config = await this.config.pipe(first()).toPromise(); - const dependencies = { router, config }; + const dependencies = { router, config, log: this.logger }; registerEnginesRoute(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 608d79f0cdbcf..02408544a8315 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -6,6 +6,7 @@ import { RequestHandlerContext } from 'kibana/server'; import { mockRouter, RouterMock } from 'src/core/server/http/router/router.mock'; +import { loggingServiceMock } from 'src/core/server/mocks'; import { httpServerMock } from 'src/core/server/http/http_server.mocks'; import { RouteValidatorConfig } from 'src/core/server/http/router/validator'; @@ -21,13 +22,15 @@ describe('engine routes', () => { describe('GET /api/app_search/engines', () => { const AUTH_HEADER = 'Basic 123'; let router: RouterMock; - const mockResponseFactory = httpServerMock.createResponseFactory(); + const mockResponse = httpServerMock.createResponseFactory(); + const mockLogger = loggingServiceMock.create().get(); beforeEach(() => { jest.resetAllMocks(); router = mockRouter.create(); registerEnginesRoute({ router, + log: mockLogger, config: { host: 'http://localhost:3002', }, @@ -38,17 +41,18 @@ describe('engine routes', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, - { - headers: { Authorization: AUTH_HEADER }, - } - ).andReturn({ name: 'engine1' }); + { headers: { Authorization: AUTH_HEADER } } + ).andReturn({ + results: [{ name: 'engine1' }], + meta: { page: { total_results: 1 } }, + }); }); it('should return 200 with a list of engines from the App Search API', async () => { await callThisRoute(); - expectResponseToBe200With({ - body: { name: 'engine1' }, + expect(mockResponse.ok).toHaveBeenCalledWith({ + body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } }, headers: { 'content-type': 'application/json' }, }); }); @@ -58,19 +62,57 @@ describe('engine routes', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, - { - headers: { Authorization: AUTH_HEADER }, - } + { headers: { Authorization: AUTH_HEADER } } ).andReturnRedirect(); }); - it('should return 200 with a message', async () => { + it('should return 403 with a message', async () => { await callThisRoute(); - expectResponseToBe200With({ - body: { message: 'no-as-account' }, - headers: { 'content-type': 'application/json' }, + expect(mockResponse.forbidden).toHaveBeenCalledWith({ + body: 'no-as-account', + }); + expect(mockLogger.info).toHaveBeenCalledWith('No corresponding App Search account found'); + }); + }); + + describe('when the App Search URL is invalid', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturnError(); + }); + + it('should return 404 with a message', async () => { + await callThisRoute(); + + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('when the App Search API returns invalid data', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturnInvalidData(); + }); + + it('should return 404 with a message', async () => { + await callThisRoute(); + + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot connect to App Search: Error: Invalid data received from App Search: {"foo":"bar"}' + ); + expect(mockLogger.debug).toHaveBeenCalled(); }); }); @@ -88,7 +130,7 @@ describe('engine routes', () => { } describe('when query is valid', () => { - const request = { query: { type: 'indexed', pageIndex: 1 } }; + const request = { query: { type: 'meta', pageIndex: 5 } }; itShouldValidate(request); }); @@ -97,8 +139,8 @@ describe('engine routes', () => { itShouldThrow(request); }); - describe('type is wrong type', () => { - const request = { query: { type: 1, pageIndex: 1 } }; + describe('type is wrong string', () => { + const request = { query: { type: 'invalid', pageIndex: 1 } }; itShouldThrow(request); }); @@ -136,14 +178,26 @@ describe('engine routes', () => { return Promise.resolve(new Response(JSON.stringify(response))); }); }, + andReturnInvalidData(response: object) { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); + }); + }, + andReturnError(response: object) { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.reject('Failed'); + }); + }, }; }, }; - const expectResponseToBe200With = (response: object) => { - expect(mockResponseFactory.ok).toHaveBeenCalledWith(response); - }; - const callThisRoute = async ( request = { headers: { @@ -158,7 +212,7 @@ describe('engine routes', () => { const [_, handler] = router.get.mock.calls[0]; const context = {} as jest.Mocked; - await handler(context, httpServerMock.createKibanaRequest(request), mockResponseFactory); + await handler(context, httpServerMock.createKibanaRequest(request), mockResponse); }; const executeRouteValidation = (data: { query: object }) => { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 8455b01aa4354..3a474dc58e4dd 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -7,38 +7,54 @@ import fetch from 'node-fetch'; import { schema } from '@kbn/config-schema'; -export function registerEnginesRoute({ router, config }) { +import { ENGINES_PAGE_SIZE } from '../../../common/constants'; + +export function registerEnginesRoute({ router, config, log }) { router.get( { path: '/api/app_search/engines', validate: { query: schema.object({ - type: schema.string(), + type: schema.oneOf([schema.literal('indexed'), schema.literal('meta')]), pageIndex: schema.number(), }), }, }, async (context, request, response) => { - const appSearchUrl = config.host; - const { type, pageIndex } = request.query; - - const url = `${appSearchUrl}/as/engines/collection?type=${type}&page[current]=${pageIndex}&page[size]=10`; - const enginesResponse = await fetch(url, { - headers: { Authorization: request.headers.authorization }, - }); - - if (enginesResponse.url.endsWith('/login')) { - return response.ok({ - body: { message: 'no-as-account' }, - headers: { 'content-type': 'application/json' }, + try { + const appSearchUrl = config.host; + const { type, pageIndex } = request.query; + + const url = `${appSearchUrl}/as/engines/collection?type=${type}&page[current]=${pageIndex}&page[size]=${ENGINES_PAGE_SIZE}`; + const enginesResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization }, }); - } - const engines = await enginesResponse.json(); - return response.ok({ - body: engines, - headers: { 'content-type': 'application/json' }, - }); + if (enginesResponse.url.endsWith('/login')) { + log.info('No corresponding App Search account found'); + // Note: Can't use response.unauthorized, Kibana will auto-log out the user + return response.forbidden({ body: 'no-as-account' }); + } + + const engines = await enginesResponse.json(); + const hasValidData = + Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number'; + + if (hasValidData) { + return response.ok({ + body: engines, + headers: { 'content-type': 'application/json' }, + }); + } else { + // Either a completely incorrect Enterprise Search host URL was configured, or App Search is returning bad data + throw new Error(`Invalid data received from App Search: ${JSON.stringify(engines)}`); + } + } catch (e) { + log.error(`Cannot connect to App Search: ${e.toString()}`); + if (e instanceof Error) log.debug(e.stack); + + return response.notFound({ body: 'cannot-connect' }); + } } ); } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts index 02fc3f63f402a..db98b95d500ab 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -6,6 +6,7 @@ import { mockRouter, RouterMock } from 'src/core/server/http/router/router.mock'; import { savedObjectsServiceMock } from 'src/core/server/saved_objects/saved_objects_service.mock'; +import { loggingServiceMock } from 'src/core/server/mocks'; import { httpServerMock } from 'src/core/server/http/http_server.mocks'; import { registerTelemetryRoute } from './telemetry'; @@ -17,7 +18,8 @@ import { incrementUICounter } from '../../collectors/app_search/telemetry'; describe('App Search Telemetry API', () => { let router: RouterMock; - const mockResponseFactory = httpServerMock.createResponseFactory(); + const mockResponse = httpServerMock.createResponseFactory(); + const mockLogger = loggingServiceMock.create().get(); beforeEach(() => { jest.resetAllMocks(); @@ -25,6 +27,7 @@ describe('App Search Telemetry API', () => { registerTelemetryRoute({ router, getSavedObjectsService: () => savedObjectsServiceMock.create(), + log: mockLogger, }); }); @@ -40,16 +43,17 @@ describe('App Search Telemetry API', () => { uiAction: 'ui_viewed', metric: 'setup_guide', }); - expect(mockResponseFactory.ok).toHaveBeenCalledWith({ body: successResponse }); + expect(mockResponse.ok).toHaveBeenCalledWith({ body: successResponse }); }); it('throws an error when incrementing fails', async () => { - incrementUICounter.mockImplementation(jest.fn(() => Promise.reject())); + incrementUICounter.mockImplementation(jest.fn(() => Promise.reject('Failed'))); await callThisRoute('put', { body: { action: 'error', metric: 'error' } }); expect(incrementUICounter).toHaveBeenCalled(); - expect(mockResponseFactory.internalError).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockResponse.internalError).toHaveBeenCalled(); }); describe('validates', () => { @@ -96,7 +100,7 @@ describe('App Search Telemetry API', () => { const [_, handler] = router[method].mock.calls[0]; const context = {}; - await handler(context, httpServerMock.createKibanaRequest(request), mockResponseFactory); + await handler(context, httpServerMock.createKibanaRequest(request), mockResponse); }; const executeRouteValidation = request => { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts index 3eabe1f19c5ff..6b7657a384e9f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import { incrementUICounter } from '../../collectors/app_search/telemetry'; -export function registerTelemetryRoute({ router, getSavedObjectsService }) { +export function registerTelemetryRoute({ router, getSavedObjectsService, log }) { router.put( { path: '/api/app_search/telemetry', @@ -35,6 +35,7 @@ export function registerTelemetryRoute({ router, getSavedObjectsService }) { }), }); } 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' }); } } From 8eee7fd42293ea982708690d9afdd75d36016c27 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 5 May 2020 13:28:40 -0700 Subject: [PATCH 16/48] Per telemetry team feedback, rename usageCollection telemetry mapping name to simpler 'app_search' - since their mapping already nests under 'kibana.plugins' - note: I left the savedObjects name with the '_telemetry' suffix, as there very well may be a use case for top-level generic 'app_search' saved objects --- .../server/collectors/app_search/telemetry.test.ts | 6 +++--- .../server/collectors/app_search/telemetry.ts | 2 +- .../server/saved_objects/app_search/telemetry.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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 f6028284f3d00..144f22236ec4e 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 @@ -48,7 +48,7 @@ describe('App Search Telemetry Usage Collector', () => { expect(registerStub).toHaveBeenCalledTimes(1); expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); - expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search_kibana_telemetry'); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search'); }); }); @@ -108,8 +108,8 @@ describe('App Search Telemetry Usage Collector', () => { }); expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( - 'app_search_kibana_telemetry', - 'app_search_kibana_telemetry', + '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 302e9843488e1..c95fc641144e1 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 @@ -24,7 +24,7 @@ export const registerTelemetryUsageCollector = ({ savedObjects, }: Dependencies) => { const telemetryUsageCollector = usageCollection.makeUsageCollector({ - type: AS_TELEMETRY_NAME, + type: 'app_search', fetch: async () => fetchTelemetryMetrics(savedObjects), }); usageCollection.registerCollector(telemetryUsageCollector); diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts index 614492bcf6510..28f7d2b45b9f6 100644 --- a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -6,7 +6,7 @@ import { SavedObjectsType } from 'src/core/server'; -export const AS_TELEMETRY_NAME = 'app_search_kibana_telemetry'; +export const AS_TELEMETRY_NAME = 'app_search_telemetry'; export interface ITelemetrySavedObject { ui_viewed: { From 75cfd89d94df6e16372864e0bd63e737dd707215 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 5 May 2020 14:06:46 -0700 Subject: [PATCH 17/48] Update Setup Guide installation instructions (#9) Co-authored-by: Chris Cressman --- .../setup_guide/setup_guide.test.tsx | 4 +- .../components/setup_guide/setup_guide.tsx | 172 +++++++++--------- 2 files changed, 91 insertions(+), 85 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx index 0307d8a1555ec..4a07e950041e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageSideBar, EuiTabbedContent } from '@elastic/eui'; +import { EuiPageSideBar, EuiSteps } from '@elastic/eui'; import { SetupGuide } from './'; @@ -14,7 +14,7 @@ describe('SetupGuide', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiTabbedContent)).toHaveLength(1); + expect(wrapper.find(EuiSteps)).toHaveLength(1); expect(wrapper.find(EuiPageSideBar)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index 3931f1dc0073e..575a604069fdb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -17,10 +17,11 @@ import { EuiText, EuiImage, EuiIcon, - EuiTabbedContent, EuiSteps, EuiCode, EuiCodeBlock, + EuiAccordion, + EuiLink, } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; @@ -68,8 +69,8 @@ export const SetupGuide: React.FC<> = () => {

- Set up App Search to leverage dashboards, analytics, and APIs for advanced application - search made simple. + Elastic App Search provides user-friendly tools to design and deploy a powerful search + to your websites or web/mobile applications.

@@ -83,92 +84,97 @@ export const SetupGuide: React.FC<> = () => { - - - -

Run this code snippet to install things.

- npm install - - ), - }, - { - title: 'Reload your Kibana instance', - children: ( - -

Run this code snippet to install things.

- npm install -
- ), - }, - ]} - /> - + title: 'Add your App Search host URL to your Kibana configuration', + children: ( + +

+ Within your config/kibana.yml file, set{' '} + enterpriseSearch.host to the URL of your App Search + instance. For example: +

+ + enterpriseSearch.host: 'http://localhost:3002' + +
), }, { - id: 'smas', - name: 'Self-Managed', - content: ( + title: 'Reload your Kibana instance', + children: ( + +

+ Restart Kibana to pick up the configuration changes from the previous step. +

+

+ If you’re using{' '} + + Elasticsearch Native + {' '} + auth within App Search - you’re all set! All users should be able to use App + Search in Kibana automatically, inheriting the existing access and permissions + they have within App Search. +

+
+ ), + }, + { + title: 'Troubleshooting issues', + children: ( <> + + +

+ This plugin does not currently support App Search and Kibana running on + different clusters. +

+
+
+ + + +

+ This plugin does not currently support App Search and Kibana operating on + different authentication methods (for example, App Search using a + different SAML provider than Kibana). +

+
+
- -

- Within your config/kibana.yml file, add the - following the host URL of your App Search instace as{' '} - app_search.host. -

- - app_search.host: 'http://localhost:3002' - - - ), - }, - { - title: 'Reload your Kibana instance', - children: ( - -

- After updating your Kibana config file, restart Kibana to pick up - your changes. -

-
- ), - }, - { - title: - 'Ensure that your Kibana users have corresponding App Search accounts', - children: ( - -

If you’re using Elasticsearch Native auth - you’re all set.

-

- (If you’re using standard auth) Log into App Search and invite your - Kibana users into App Search! Be sure to use their corresponding - Kibana usernames. -

-

If you’re on SAML auth - ??????

-
- ), - }, - ]} - /> + + +

+ App Search operating on{' '} + + Standard Auth + {' '} + is currently not fully supported by this plugin. Users created in App + Search must be granted Kibana access. Users created in Kibana will see + "Cannot find App Search account" error messages. +

+
+
), }, From 90e8aae826c1de012edb2f9c897b3e3361f6665b Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 6 May 2020 12:43:57 -0700 Subject: [PATCH 18/48] [Refactor] DRY out route test helper --- .../server/routes/__mocks__/router.mock.ts | 81 ++++++++++++++ .../server/routes/app_search/engines.test.ts | 100 ++++++------------ .../routes/app_search/telemetry.test.ts | 65 +++--------- 3 files changed, 133 insertions(+), 113 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts new file mode 100644 index 0000000000000..3f1c310daac02 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts @@ -0,0 +1,81 @@ +/* + * 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 { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { IRouter, RequestHandlerContext, RouteValidatorConfig } from 'src/core/server'; + +/** + * Test helper that mocks Kibana's router and DRYs out various helper (callRoute, schema validation) + */ + +type methodType = 'get' | 'post' | 'put' | 'patch' | 'delete'; +type payloadType = 'params' | 'query' | 'body'; + +export class MockRouter { + public router: jest.Mocked; + public method: methodType; + public payload: payloadType; + public response = httpServerMock.createResponseFactory(); + + private constructor({ method, payload }) { + this.createRouter(); + this.method = method; + this.payload = payload; + } + + public createRouter = () => { + this.router = httpServiceMock.createRouter(); + }; + + public callRoute = async request => { + const [_, handler] = this.router[this.method].mock.calls[0]; + + const context = {} as jest.Mocked; + await handler(context, httpServerMock.createKibanaRequest(request), this.response); + }; + + /** + * Schema validation helpers + */ + + public validateRoute = request => { + const [config] = this.router[this.method].mock.calls[0]; + const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + + validate[this.payload].validate(request[this.payload]); + }; + + public shouldValidate = request => { + expect(() => this.validateRoute(request)).not.toThrow(); + }; + + public shouldThrow = request => { + expect(() => this.validateRoute(request)).toThrow(); + }; +} + +/** + * Example usage: + */ +// const mockRouter = new MockRouter({ method: 'get', payload: 'body' }); +// +// beforeEach(() => { +// jest.clearAllMocks(); +// mockRouter.createRouter(); +// +// registerExampleRoute({ router: mockRouter.router, ...dependencies }); // Whatever other dependencies the route needs +// }); + +// it('hits the endpoint successfully', async () => { +// await mockRouter.callRoute({ body: { foo: 'bar' } }); +// +// expect(mockRouter.response.ok).toHaveBeenCalled(); +// }); + +// it('validates', () => { +// const request = { body: { foo: 'bar' } }; +// mockRouter.shouldValidate(request); +// }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 02408544a8315..e78d389ca8818 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -4,14 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandlerContext } from 'kibana/server'; -import { mockRouter, RouterMock } from 'src/core/server/http/router/router.mock'; import { loggingServiceMock } from 'src/core/server/mocks'; -import { httpServerMock } from 'src/core/server/http/http_server.mocks'; -import { RouteValidatorConfig } from 'src/core/server/http/router/validator'; +import { MockRouter } from '../__mocks__/router.mock'; import { registerEnginesRoute } from './engines'; -import { ObjectType } from '@kbn/config-schema'; jest.mock('node-fetch'); const fetch = jest.requireActual('node-fetch'); @@ -21,15 +17,25 @@ const fetchMock = require('node-fetch') as jest.Mocked; describe('engine routes', () => { describe('GET /api/app_search/engines', () => { const AUTH_HEADER = 'Basic 123'; - let router: RouterMock; - const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = { + headers: { + authorization: AUTH_HEADER, + }, + query: { + type: 'indexed', + pageIndex: 1, + }, + }; + + const mockRouter = new MockRouter({ method: 'get', payload: 'query' }); const mockLogger = loggingServiceMock.create().get(); beforeEach(() => { - jest.resetAllMocks(); - router = mockRouter.create(); + jest.clearAllMocks(); + mockRouter.createRouter(); + registerEnginesRoute({ - router, + router: mockRouter.router, log: mockLogger, config: { host: 'http://localhost:3002', @@ -49,9 +55,9 @@ describe('engine routes', () => { }); it('should return 200 with a list of engines from the App Search API', async () => { - await callThisRoute(); + await mockRouter.callRoute(mockRequest); - expect(mockResponse.ok).toHaveBeenCalledWith({ + expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } }, headers: { 'content-type': 'application/json' }, }); @@ -67,9 +73,9 @@ describe('engine routes', () => { }); it('should return 403 with a message', async () => { - await callThisRoute(); + await mockRouter.callRoute(mockRequest); - expect(mockResponse.forbidden).toHaveBeenCalledWith({ + expect(mockRouter.response.forbidden).toHaveBeenCalledWith({ body: 'no-as-account', }); expect(mockLogger.info).toHaveBeenCalledWith('No corresponding App Search account found'); @@ -85,9 +91,9 @@ describe('engine routes', () => { }); it('should return 404 with a message', async () => { - await callThisRoute(); + await mockRouter.callRoute(mockRequest); - expect(mockResponse.notFound).toHaveBeenCalledWith({ + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ body: 'cannot-connect', }); expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed'); @@ -104,9 +110,9 @@ describe('engine routes', () => { }); it('should return 404 with a message', async () => { - await callThisRoute(); + await mockRouter.callRoute(mockRequest); - expect(mockResponse.notFound).toHaveBeenCalledWith({ + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ body: 'cannot-connect', }); expect(mockLogger.error).toHaveBeenCalledWith( @@ -116,42 +122,30 @@ describe('engine routes', () => { }); }); - describe('validation', () => { - function itShouldValidate(request: { query: object }) { - it('should be validated', async () => { - expect(() => executeRouteValidation(request)).not.toThrow(); - }); - } - - function itShouldThrow(request: { query: object }) { - it('should throw', async () => { - expect(() => executeRouteValidation(request)).toThrow(); - }); - } - - describe('when query is valid', () => { + describe('validates', () => { + it('correctly', () => { const request = { query: { type: 'meta', pageIndex: 5 } }; - itShouldValidate(request); + mockRouter.shouldValidate(request); }); - describe('pageIndex is wrong type', () => { + it('wrong pageIndex type', () => { const request = { query: { type: 'indexed', pageIndex: 'indexed' } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); - describe('type is wrong string', () => { + it('wrong type string', () => { const request = { query: { type: 'invalid', pageIndex: 1 } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); - describe('pageIndex is missing', () => { + it('missing pageIndex', () => { const request = { query: { type: 'indexed' } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); - describe('type is missing', () => { + it('missing type', () => { const request = { query: { pageIndex: 1 } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); }); @@ -197,29 +191,5 @@ describe('engine routes', () => { }; }, }; - - const callThisRoute = async ( - request = { - headers: { - authorization: AUTH_HEADER, - }, - query: { - type: 'indexed', - pageIndex: 1, - }, - } - ) => { - const [_, handler] = router.get.mock.calls[0]; - - const context = {} as jest.Mocked; - await handler(context, httpServerMock.createKibanaRequest(request), mockResponse); - }; - - const executeRouteValidation = (data: { query: object }) => { - const [config] = router.get.mock.calls[0]; - const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; - const query = validate.query as ObjectType; - query.validate(data.query); - }; }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts index db98b95d500ab..7644f3019de80 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -4,10 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mockRouter, RouterMock } from 'src/core/server/http/router/router.mock'; -import { savedObjectsServiceMock } from 'src/core/server/saved_objects/saved_objects_service.mock'; -import { loggingServiceMock } from 'src/core/server/mocks'; -import { httpServerMock } from 'src/core/server/http/http_server.mocks'; +import { loggingServiceMock, savedObjectsServiceMock } from 'src/core/server/mocks'; +import { MockRouter } from '../__mocks__/router.mock'; import { registerTelemetryRoute } from './telemetry'; @@ -17,15 +15,15 @@ jest.mock('../../collectors/app_search/telemetry', () => ({ import { incrementUICounter } from '../../collectors/app_search/telemetry'; describe('App Search Telemetry API', () => { - let router: RouterMock; - const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = new MockRouter({ method: 'put', payload: 'body' }); const mockLogger = loggingServiceMock.create().get(); beforeEach(() => { - jest.resetAllMocks(); - router = mockRouter.create(); + jest.clearAllMocks(); + mockRouter.createRouter(); + registerTelemetryRoute({ - router, + router: mockRouter.router, getSavedObjectsService: () => savedObjectsServiceMock.create(), log: mockLogger, }); @@ -36,80 +34,51 @@ describe('App Search Telemetry API', () => { const successResponse = { success: true }; incrementUICounter.mockImplementation(jest.fn(() => successResponse)); - await callThisRoute('put', { body: { action: 'viewed', metric: 'setup_guide' } }); + await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } }); expect(incrementUICounter).toHaveBeenCalledWith({ savedObjects: expect.any(Object), uiAction: 'ui_viewed', metric: 'setup_guide', }); - expect(mockResponse.ok).toHaveBeenCalledWith({ body: successResponse }); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); }); it('throws an error when incrementing fails', async () => { incrementUICounter.mockImplementation(jest.fn(() => Promise.reject('Failed'))); - await callThisRoute('put', { body: { action: 'error', metric: 'error' } }); + await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } }); expect(incrementUICounter).toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled(); - expect(mockResponse.internalError).toHaveBeenCalled(); + expect(mockRouter.response.internalError).toHaveBeenCalled(); }); describe('validates', () => { - const itShouldValidate = request => { - expect(() => executeRouteValidation(request)).not.toThrow(); - }; - - const itShouldThrow = request => { - expect(() => executeRouteValidation(request)).toThrow(); - }; - it('correctly', () => { const request = { body: { action: 'viewed', metric: 'setup_guide' } }; - itShouldValidate(request); + mockRouter.shouldValidate(request); }); - it('wrong action enum', () => { + it('wrong action string', () => { const request = { body: { action: 'invalid', metric: 'setup_guide' } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); describe('wrong metric type', () => { const request = { body: { action: 'clicked', metric: true } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); describe('action is missing', () => { const request = { body: { metric: 'engines_overview' } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); describe('metric is missing', () => { const request = { body: { action: 'error' } }; - itShouldThrow(request); + mockRouter.shouldThrow(request); }); }); }); - - /** - * Test helpers - */ - - const callThisRoute = async (method, request) => { - const [_, handler] = router[method].mock.calls[0]; - - const context = {}; - await handler(context, httpServerMock.createKibanaRequest(request), mockResponse); - }; - - const executeRouteValidation = request => { - const method = 'put'; - - const [config] = router[method].mock.calls[0]; - const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; - - const payload = 'body'; - validate[payload].validate(request[payload]); - }; }); From f987781ce7290c9f49cc057207ae4e16c62f14a0 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 6 May 2020 14:52:14 -0700 Subject: [PATCH 19/48] [Refactor] Rename public/test_utils to public/__mocks__ - to better follow/use jest setups and for .mock.ts suffixes --- .../public/applications/__mocks__/index.ts | 11 +++++++++++ .../kibana_context.mock.ts} | 0 .../mount_with_context.mock.tsx} | 2 +- .../react_router_history.mock.ts} | 0 .../shallow_usecontext.mock.ts} | 2 +- .../components/empty_states/empty_states.test.tsx | 2 +- .../engine_overview/engine_overview.test.tsx | 4 ++-- .../components/engine_overview/engine_table.test.tsx | 2 +- .../engine_overview_header.test.tsx | 2 +- .../public/applications/app_search/index.test.tsx | 2 +- .../kibana_breadcrumbs/generate_breadcrumbs.test.ts | 2 +- .../kibana_breadcrumbs/set_breadcrumbs.test.tsx | 4 ++-- .../shared/react_router_helpers/eui_link.test.tsx | 4 ++-- .../shared/telemetry/send_telemetry.test.tsx | 2 +- .../public/applications/test_utils/index.ts | 11 ----------- 15 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts rename x-pack/plugins/enterprise_search/public/applications/{test_utils/mock_kibana_context.ts => __mocks__/kibana_context.mock.ts} (100%) rename x-pack/plugins/enterprise_search/public/applications/{test_utils/mount_with_context.tsx => __mocks__/mount_with_context.mock.tsx} (93%) rename x-pack/plugins/enterprise_search/public/applications/{test_utils/mock_rr_usehistory.ts => __mocks__/react_router_history.mock.ts} (100%) rename x-pack/plugins/enterprise_search/public/applications/{test_utils/mock_shallow_usecontext.ts => __mocks__/shallow_usecontext.mock.ts} (94%) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts new file mode 100644 index 0000000000000..cfe5a1e4c4ee2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { mockHistory } from './react_router_history.mock'; +export { mockKibanaContext } from './kibana_context.mock'; +export { mountWithKibanaContext } from './mount_with_context.mock'; + +// Note: shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts rename to x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx similarity index 93% rename from x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx rename to x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index 856f3faa7332b..dcb5810d9fccc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { KibanaContext } from '../'; -import { mockKibanaContext } from './mock_kibana_context'; +import { mockKibanaContext } from './kibana_context.mock'; /** * This helper mounts a component with a set of default KibanaContext, diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts rename to x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts similarity index 94% rename from x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts rename to x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts index eca7a7ab6e354..5193a0cd299f8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -8,7 +8,7 @@ * NOTE: This variable name MUST start with 'mock*' in order for * Jest to accept its use within a jest.mock() */ -import { mockKibanaContext } from './mock_kibana_context'; +import { mockKibanaContext } from './kibana_context.mock'; jest.mock('react', () => ({ ...jest.requireActual('react'), 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 e75970404dc5e..61b740f8ca888 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../test_utils/mock_shallow_usecontext'; +import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; 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 8f707fe57bde7..2b712721a7fa5 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 @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../test_utils/mock_rr_usehistory'; +import '../../../__mocks__/react_router_history.mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { render } from 'enzyme'; import { KibanaContext } from '../../../'; -import { mountWithKibanaContext, mockKibanaContext } from '../../../test_utils'; +import { mountWithKibanaContext, mockKibanaContext } from '../../../__mocks__'; import { EmptyState, ErrorState, NoUserState } from '../empty_states'; import { EngineTable } from './engine_table'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx index 0c05131e80835..62305d7acb451 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; -import { mountWithKibanaContext } from '../../../test_utils'; +import { mountWithKibanaContext } from '../../../__mocks__'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); import { sendTelemetry } from '../../../shared/telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx index 03801e2b9f82d..b1ff1d9d25631 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../test_utils/mock_shallow_usecontext'; +import '../../../__mocks__/shallow_usecontext.mock'; import React, { useContext } from 'react'; import { shallow } from 'enzyme'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 45d094f3c255a..57cd70389e807 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../test_utils/mock_shallow_usecontext'; +import '../__mocks__/shallow_usecontext.mock'; import React, { useContext } from 'react'; import { Redirect } from 'react-router-dom'; 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 a76170fdf795e..cc6353fd3d3d0 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 @@ -7,7 +7,7 @@ import { generateBreadcrumb } from './generate_breadcrumbs'; import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; -import { mockHistory } from '../../test_utils'; +import { mockHistory } from '../../__mocks__'; jest.mock('../react_router_helpers', () => ({ letBrowserHandleEvent: jest.fn(() => false) })); import { letBrowserHandleEvent } from '../react_router_helpers'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx index 5da0effd15ba5..38aae0e499c92 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -6,8 +6,8 @@ import React from 'react'; -import '../../test_utils/mock_rr_usehistory'; -import { mountWithKibanaContext } from '../../test_utils'; +import '../../__mocks__/react_router_history.mock'; +import { mountWithKibanaContext } from '../../__mocks__'; jest.mock('./generate_breadcrumbs', () => ({ appSearchBreadcrumbs: jest.fn() })); import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx index 0ae97383c93bb..eb9b9f3e35e06 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { shallow } from 'enzyme'; import { EuiLink, EuiButton } from '@elastic/eui'; -import '../../test_utils/mock_rr_usehistory'; -import { mockHistory } from '../../test_utils'; +import '../../__mocks__/react_router_history.mock'; +import { mockHistory } from '../../__mocks__'; import { EuiReactRouterLink, EuiReactRouterButton } from './eui_link'; 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 6d92a32a502b1..da8fd25b9194b 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,7 +7,7 @@ import React from 'react'; import { httpServiceMock } from 'src/core/public/mocks'; -import { mountWithKibanaContext } from '../../test_utils'; +import { mountWithKibanaContext } from '../../__mocks__'; import { sendTelemetry, SendAppSearchTelemetry } from './'; describe('Shared Telemetry Helpers', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts deleted file mode 100644 index 11627df8d15ba..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * 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 { mockHistory } from './mock_rr_usehistory'; -export { mockKibanaContext } from './mock_kibana_context'; -export { mountWithKibanaContext } from './mount_with_context'; - -// Note: mock_shallow_usecontext must be imported directly as a file From 7883a2334a91727dcc8f5640d1ad97717f85293b Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 7 May 2020 09:12:00 -0700 Subject: [PATCH 20/48] Add platinum licensing check to Meta Engines table/call (#11) * Licensing plugin setup * Add LicensingContext setup * Update EngineOverview to not hit meta engines API on platinum license * Add Jest test helpers for future shallow/context use --- x-pack/plugins/enterprise_search/kibana.json | 2 +- .../public/applications/__mocks__/index.ts | 1 + .../__mocks__/license_context.mock.ts | 11 +++++ .../__mocks__/shallow_usecontext.mock.ts | 5 ++- .../engine_overview/engine_overview.test.tsx | 45 ++++++++++++++----- .../engine_overview/engine_overview.tsx | 12 +++-- .../public/applications/index.test.ts | 10 ++++- .../public/applications/index.tsx | 32 ++++++++----- .../applications/shared/licensing/index.ts | 8 ++++ .../shared/licensing/license_checks.test.ts | 33 ++++++++++++++ .../shared/licensing/license_checks.ts | 11 +++++ .../shared/licensing/license_context.test.tsx | 28 ++++++++++++ .../shared/licensing/license_context.tsx | 29 ++++++++++++ .../enterprise_search/public/plugin.ts | 9 ++-- 14 files changed, 202 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index d0c4c9733da2a..3121d6bd470b0 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -2,7 +2,7 @@ "id": "enterpriseSearch", "version": "1.0.0", "kibanaVersion": "kibana", - "requiredPlugins": ["home"], + "requiredPlugins": ["home", "licensing"], "configPath": ["enterpriseSearch"], "optionalPlugins": ["usageCollection"], "server": true, 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 cfe5a1e4c4ee2..5b19055115fde 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -6,6 +6,7 @@ export { mockHistory } from './react_router_history.mock'; export { mockKibanaContext } from './kibana_context.mock'; +export { mockLicenseContext } from './license_context.mock'; export { mountWithKibanaContext } from './mount_with_context.mock'; // Note: shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts new file mode 100644 index 0000000000000..7c37ecc7cde1b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts @@ -0,0 +1,11 @@ +/* + * 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 { licensingMock } from '../../../../licensing/public/mocks'; + +export const mockLicenseContext = { + license: licensingMock.createLicense(), +}; 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 5193a0cd299f8..20add45e16b58 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 @@ -5,14 +5,15 @@ */ /** - * NOTE: This variable name MUST start with 'mock*' in order for + * NOTE: These variable names MUST start with 'mock*' in order for * Jest to accept its use within a jest.mock() */ import { mockKibanaContext } from './kibana_context.mock'; +import { mockLicenseContext } from './license_context.mock'; jest.mock('react', () => ({ ...jest.requireActual('react'), - useContext: jest.fn(() => mockKibanaContext), + useContext: jest.fn(() => ({ ...mockKibanaContext, ...mockLicenseContext })), })); /** 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 2b712721a7fa5..5d029d6c4ba8a 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 @@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils'; import { render } from 'enzyme'; import { KibanaContext } from '../../../'; +import { LicenseContext } from '../../../shared/licensing'; import { mountWithKibanaContext, mockKibanaContext } from '../../../__mocks__'; import { EmptyState, ErrorState, NoUserState } from '../empty_states'; @@ -24,7 +25,9 @@ describe('EngineOverview', () => { // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) const wrapper = render( - + + + ); @@ -85,7 +88,7 @@ describe('EngineOverview', () => { }); it('renders', () => { - expect(wrapper.find(EngineTable)).toHaveLength(2); + expect(wrapper.find(EngineTable)).toHaveLength(1); }); it('calls the engines API', () => { @@ -95,12 +98,6 @@ describe('EngineOverview', () => { pageIndex: 1, }, }); - expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { - query: { - type: 'meta', - pageIndex: 1, - }, - }); }); describe('pagination', () => { @@ -130,13 +127,36 @@ describe('EngineOverview', () => { 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, + }, + }); + }); + }); }); /** * Test helpers */ - const mountWithApiMock = async ({ get }) => { + const mountWithApiMock = async ({ get, license }) => { let wrapper; const httpMock = { ...mockKibanaContext.http, get }; @@ -144,7 +164,12 @@ describe('EngineOverview', () => { // 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 = mountWithKibanaContext(, { http: httpMock }); + wrapper = mountWithKibanaContext( + + + , + { http: httpMock } + ); }); wrapper.update(); // This seems to be required for the DOM to actually update diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index d87c36cd9b9d6..1e1a583b5bcdb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -17,6 +17,7 @@ import { import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing'; import { KibanaContext, IKibanaContext } from '../../../index'; import EnginesIcon from '../../assets/engine.svg'; @@ -30,6 +31,7 @@ import './engine_overview.scss'; export const EngineOverview: ReactFC<> = () => { const { http } = useContext(KibanaContext) as IKibanaContext; + const { license } = useContext(LicenseContext) as ILicenseContext; const [isLoading, setIsLoading] = useState(true); const [hasNoAccount, setHasNoAccount] = useState(false); @@ -72,11 +74,13 @@ export const EngineOverview: ReactFC<> = () => { }, [enginesPage]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - const params = { type: 'meta', pageIndex: metaEnginesPage }; - const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal }; + if (hasPlatinumLicense(license)) { + const params = { type: 'meta', pageIndex: metaEnginesPage }; + const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal }; - setEnginesData(params, callbacks); - }, [metaEnginesPage]); // eslint-disable-line react-hooks/exhaustive-deps + setEnginesData(params, callbacks); + } + }, [license, metaEnginesPage]); // eslint-disable-line react-hooks/exhaustive-deps if (hasErrorConnecting) return ; if (hasNoAccount) return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.ts b/x-pack/plugins/enterprise_search/public/applications/index.test.ts index 7ece7e153c154..7ea5b97feac6c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.ts @@ -5,14 +5,20 @@ */ import { coreMock } from 'src/core/public/mocks'; -import { renderApp } from '../applications'; +import { licensingMock } from '../../../licensing/public/mocks'; + +import { renderApp } from './'; describe('renderApp', () => { it('mounts and unmounts UI', () => { const params = coreMock.createAppMountParamters(); const core = coreMock.createStart(); + const config = {}; + const plugins = { + licensing: licensingMock.createSetup(), + }; - const unmount = renderApp(core, params, {}); + const unmount = renderApp(core, params, config, plugins); expect(params.element.querySelector('.setup-guide')).not.toBeNull(); unmount(); expect(params.element.innerHTML).toEqual(''); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 473d395c1e604..2fd5f960391b6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -9,8 +9,9 @@ import ReactDOM from 'react-dom'; import { BrowserRouter, Route, Redirect } from 'react-router-dom'; import { CoreStart, AppMountParams, HttpHandler } from 'src/core/public'; -import { ClientConfigType } from '../plugin'; +import { ClientConfigType, PluginsSetup } from '../plugin'; import { TSetBreadcrumbs } from './shared/kibana_breadcrumbs'; +import { LicenseProvider } from './shared/licensing'; import { AppSearch } from './app_search'; @@ -22,7 +23,12 @@ export interface IKibanaContext { export const KibanaContext = React.createContext(); -export const renderApp = (core: CoreStart, params: AppMountParams, config: ClientConfigType) => { +export const renderApp = ( + core: CoreStart, + params: AppMountParams, + config: ClientConfigType, + plugins: PluginsSetup +) => { ReactDOM.render( - - - {/* This will eventually contain an Enterprise Search landing page, - and we'll also actually have a /workplace_search route */} - - - - - - + + + + {/* This will eventually contain an Enterprise Search landing page, + and we'll also actually have a /workplace_search route */} + + + + + + + , params.element ); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts new file mode 100644 index 0000000000000..9c8c1417d48db --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { LicenseContext, LicenseProvider, ILicenseContext } from './license_context'; +export { hasPlatinumLicense } from './license_checks'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts new file mode 100644 index 0000000000000..e21bf004b39a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { hasPlatinumLicense } from './license_checks'; + +describe('hasPlatinumLicense', () => { + it('is true for platinum licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' })).toEqual(true); + }); + + it('is true for enterprise licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'enterprise' })).toEqual(true); + }); + + it('is true for trial licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' })).toEqual(true); + }); + + it('is false if the current license is expired', () => { + expect(hasPlatinumLicense({ isActive: false, type: 'platinum' })).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'enterprise' })).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'trial' })).toEqual(false); + }); + + it('is false for licenses below platinum', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'basic' })).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'standard' })).toEqual(false); + expect(hasPlatinumLicense({ isActive: true, type: 'gold' })).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts new file mode 100644 index 0000000000000..7d0de8a093b31 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts @@ -0,0 +1,11 @@ +/* + * 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 { ILicense } from '../../../../../../licensing/public'; + +export const hasPlatinumLicense = (license: ILicenseContext) => { + return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx new file mode 100644 index 0000000000000..3385f79d3d075 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx @@ -0,0 +1,28 @@ +/* + * 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 { mountWithKibanaContext } from '../../__mocks__'; +import { LicenseContext, ILicenseContext } from './'; + +describe('LicenseProvider', () => { + const MockComponent: React.FC<> = () => { + const { license } = useContext(LicenseContext) as ILicenseContext; + return
{license.type}
; + }; + + it('renders children', () => { + const wrapper = mountWithKibanaContext( + + + + ); + + expect(wrapper.find('.license-test')).toHaveLength(1); + expect(wrapper.text()).toEqual('basic'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx new file mode 100644 index 0000000000000..03787031bc075 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.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 from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; + +import { ILicense } from '../../../../licensing/public'; + +export interface ILicenseContext { + license?: ILicense; +} +interface ILicenseContextProps { + license$: Observable; + children: React.ReactNode; +} + +export const LicenseContext = React.createContext(); + +export const LicenseProvider: React.FC = ({ license$, children }) => { + // Listen for changes to license subscription + const license = useObservable(license$); + + // Render rest of application and pass down license via context + return ; +}; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 5331eb1e8f51f..cf495b6a6f9de 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -17,6 +17,7 @@ import { HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; +import { LicensingPluginSetup } from '../../licensing/public'; import AppSearchLogo from './applications/app_search/assets/logo.svg'; @@ -24,7 +25,8 @@ export interface ClientConfigType { host?: string; } export interface PluginsSetup { - home?: HomePublicPluginSetup; + home: HomePublicPluginSetup; + licensing: LicensingPluginSetup; } export class EnterpriseSearchPlugin implements Plugin { @@ -43,11 +45,12 @@ export class EnterpriseSearchPlugin implements Plugin { euiIconType: AppSearchLogo, // TODO: Temporary - App Search will likely no longer need an icon once the nav structure changes. category: DEFAULT_APP_CATEGORIES.management, // TODO - This is likely not final/correct order: 10, // TODO - This will also likely not be needed once new nav structure changes land - async mount(params: AppMountParameters) { + mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); + const { renderApp } = await import('./applications'); - return renderApp(coreStart, params, config); + return renderApp(coreStart, params, config, plugins); }, }); From 4f83c5c84f002f47645718491f7d8a7352f24dd8 Mon Sep 17 00:00:00 2001 From: Constance Date: Fri, 8 May 2020 08:48:56 -0700 Subject: [PATCH 21/48] Update plugin to use new Kibana nav + URL update (#12) * Update new nav categories to add Enterprise Search + update plugin to use new category - per @johnbarrierwilson and Matt Riley, Enterprise Search should be under Kibana and above Observability - Run `node scripts/check_published_api_changes.js --accept` since this new category affects public API * [URL UPDATE] Change '/app/enterprise_search/app_search' to '/app/app_search' - This needs to be done because App Search and Workplace search *have* to be registered as separate plugins to have 2 distinct nav links - Currently Kibana doesn't support nested app names (see: https://github.com/elastic/kibana/issues/59190) but potentially will in the future - To support this change, we need to update applications/index.tsx to NOT handle '/app/enterprise_search' level routing, but instead accept an async imported app component (e.g. AppSearch, WorkplaceSearch). - AppSearch should now treat its router as root '/' instead of '/app_search' - (Addl) Per Josh Dover's recommendation, switch to `` from `` since they're deprecating appBasePath * Update breadcrumbs helper to account for new URLs - Remove path for Enterprise Search breadcrumb, since '/app/enterprise_search' will not link anywhere meaningful for the foreseeable future, so the Enterprise Search root should not go anywhere - Update App Search helper to go to root path, per new React Router setup Test changes: - Mock custom basepath for App Search tests - Swap enterpriseSearchBreadcrumbs and appSearchBreadcrumbs test order (since the latter overrides the default mock) --- .../collapsible_nav.test.tsx.snap | 6 +- src/core/public/public.api.md | 6 ++ src/core/server/server.api.md | 6 ++ src/core/utils/default_app_categories.ts | 12 ++- .../components/empty_states/error_state.tsx | 2 +- .../applications/app_search/index.test.tsx | 4 +- .../public/applications/app_search/index.tsx | 6 +- .../public/applications/index.test.ts | 26 ------ .../public/applications/index.test.tsx | 40 +++++++++ .../public/applications/index.tsx | 24 +++--- .../generate_breadcrumbs.test.ts | 86 +++++++++---------- .../generate_breadcrumbs.ts | 34 ++++---- .../enterprise_search/public/plugin.ts | 17 ++-- 13 files changed, 152 insertions(+), 117 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/index.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.test.tsx diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 9fee7b50f371b..1cfded4dc7b8f 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -149,7 +149,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoSecurity", "id": "security", "label": "Security", - "order": 3000, + "order": 4000, }, "data-test-subj": "siem", "href": "siem", @@ -164,7 +164,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "metrics", "href": "metrics", @@ -233,7 +233,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "logs", "href": "logs", diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 86e281a49b744..40fc3f977006f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -582,6 +582,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ euiIconType: string; order: number; }; + enterpriseSearch: { + id: string; + label: string; + order: number; + euiIconType: string; + }; observability: { id: string; label: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index ea95329bf8fa4..05aec3e845b17 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -561,6 +561,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ euiIconType: string; order: number; }; + enterpriseSearch: { + id: string; + label: string; + order: number; + euiIconType: string; + }; observability: { id: string; label: string; diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 5708bcfeac31a..cc9bfb1db04d5 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -29,20 +29,28 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({ euiIconType: 'logoKibana', order: 1000, }, + enterpriseSearch: { + id: 'enterpriseSearch', + label: i18n.translate('core.ui.enterpriseSearchNavList.label', { + defaultMessage: 'Enterprise Search', + }), + order: 2000, + euiIconType: 'logoEnterpriseSearch', + }, observability: { id: 'observability', label: i18n.translate('core.ui.observabilityNavList.label', { defaultMessage: 'Observability', }), euiIconType: 'logoObservability', - order: 2000, + order: 3000, }, security: { id: 'security', label: i18n.translate('core.ui.securityNavList.label', { defaultMessage: 'Security', }), - order: 3000, + order: 4000, euiIconType: 'logoSecurity', }, management: { 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 7459800d4a893..9067f8dfc9c81 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 @@ -44,7 +44,7 @@ export const ErrorState: ReactFC<> = () => { } actions={ - + Review the setup guide } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 57cd70389e807..d11c47475089d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -16,7 +16,7 @@ import { EngineOverview } from './components/engine_overview'; import { AppSearch } from './'; describe('App Search Routes', () => { - describe('/app_search', () => { + describe('/', () => { it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); const wrapper = shallow(); @@ -34,7 +34,7 @@ describe('App Search Routes', () => { }); }); - describe('/app_search/setup_guide', () => { + describe('/setup_guide', () => { it('renders', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 4c1a85358ea14..9afc3c9fd9761 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -17,10 +17,10 @@ export const AppSearch: React.FC<> = () => { return ( <> - - {!enterpriseSearchUrl ? : } + + {!enterpriseSearchUrl ? : } - + diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.ts b/x-pack/plugins/enterprise_search/public/applications/index.test.ts deleted file mode 100644 index 7ea5b97feac6c..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { coreMock } from 'src/core/public/mocks'; -import { licensingMock } from '../../../licensing/public/mocks'; - -import { renderApp } from './'; - -describe('renderApp', () => { - it('mounts and unmounts UI', () => { - const params = coreMock.createAppMountParamters(); - const core = coreMock.createStart(); - const config = {}; - const plugins = { - licensing: licensingMock.createSetup(), - }; - - const unmount = renderApp(core, params, config, plugins); - expect(params.element.querySelector('.setup-guide')).not.toBeNull(); - unmount(); - expect(params.element.innerHTML).toEqual(''); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx new file mode 100644 index 0000000000000..fd88fc32ff4ae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 { coreMock } from 'src/core/public/mocks'; +import { licensingMock } from '../../../licensing/public/mocks'; + +import { renderApp } from './'; +import { AppSearch } from './app_search'; + +describe('renderApp', () => { + const params = coreMock.createAppMountParamters(); + const core = coreMock.createStart(); + const config = {}; + const plugins = { + licensing: licensingMock.createSetup(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('mounts and unmounts UI', () => { + const MockApp: React.FC = () =>
Hello world!
; + + const unmount = renderApp(MockApp, core, params, config, plugins); + expect(params.element.querySelector('.hello-world')).not.toBeNull(); + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); + + it('renders AppSearch', () => { + renderApp(AppSearch, core, params, config, plugins); + expect(params.element.querySelector('.setup-guide')).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 2fd5f960391b6..1c18ad41cb590 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -6,15 +6,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { BrowserRouter, Route, Redirect } from 'react-router-dom'; +import { Router } from 'react-router-dom'; import { CoreStart, AppMountParams, HttpHandler } from 'src/core/public'; import { ClientConfigType, PluginsSetup } from '../plugin'; import { TSetBreadcrumbs } from './shared/kibana_breadcrumbs'; import { LicenseProvider } from './shared/licensing'; -import { AppSearch } from './app_search'; - export interface IKibanaContext { enterpriseSearchUrl?: string; http(): HttpHandler; @@ -23,7 +21,14 @@ export interface IKibanaContext { export const KibanaContext = React.createContext(); +/** + * This file serves as a reusable wrapper to share Kibana-level context and other helpers + * between various Enterprise Search plugins (e.g. AppSearch, WorkplaceSearch, ES landing page) + * which should be imported and passed in as the first param in plugin.ts. + */ + export const renderApp = ( + App: React.Element, core: CoreStart, params: AppMountParams, config: ClientConfigType, @@ -38,16 +43,9 @@ export const renderApp = ( }} > - - - {/* This will eventually contain an Enterprise Search landing page, - and we'll also actually have a /workplace_search route */} - - - - - - + + + , params.element 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 cc6353fd3d3d0..b07aacf443abb 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 @@ -47,9 +47,16 @@ describe('generateBreadcrumb', () => { expect(mockHistory.push).not.toHaveBeenCalled(); }); + + it('does not generate link behavior if path is excluded', () => { + const breadcrumb = generateBreadcrumb({ text: 'Unclickable breadcrumb' }); + + expect(breadcrumb.href).toBeUndefined(); + expect(breadcrumb.onClick).toBeUndefined(); + }); }); -describe('appSearchBreadcrumbs', () => { +describe('enterpriseSearchBreadcrumbs', () => { const breadCrumbs = [ { text: 'Page 1', @@ -65,20 +72,13 @@ describe('appSearchBreadcrumbs', () => { jest.clearAllMocks(); }); - const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs); + const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs); - it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => { + it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => { expect(subject()).toEqual([ { - href: '/enterprise_search/', - onClick: expect.any(Function), text: 'Enterprise Search', }, - { - href: '/enterprise_search/app_search', - onClick: expect.any(Function), - text: 'App Search', - }, { href: '/enterprise_search/page1', onClick: expect.any(Function), @@ -93,17 +93,10 @@ describe('appSearchBreadcrumbs', () => { }); it('shows just the root if breadcrumbs is empty', () => { - expect(appSearchBreadcrumbs(mockHistory)()).toEqual([ + expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([ { - href: '/enterprise_search/', - onClick: expect.any(Function), text: 'Enterprise Search', }, - { - href: '/enterprise_search/app_search', - onClick: expect.any(Function), - text: 'App Search', - }, ]); }); @@ -112,29 +105,23 @@ describe('appSearchBreadcrumbs', () => { preventDefault: jest.fn(), }; - it('has a link to Enterprise Search Home page first', () => { - subject()[0].onClick(eventMock); - expect(mockHistory.push).toHaveBeenCalledWith('/'); + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); }); - it('has a link to App Search second', () => { + it('has a link to page 1 second', () => { subject()[1].onClick(eventMock); - expect(mockHistory.push).toHaveBeenCalledWith('/app_search'); - }); - - it('has a link to page 1 third', () => { - subject()[2].onClick(eventMock); expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { - subject()[3].onClick(eventMock); + subject()[2].onClick(eventMock); expect(mockHistory.push).toHaveBeenCalledWith('/page2'); }); }); }); -describe('enterpriseSearchBreadcrumbs', () => { +describe('appSearchBreadcrumbs', () => { const breadCrumbs = [ { text: 'Page 1', @@ -148,24 +135,30 @@ describe('enterpriseSearchBreadcrumbs', () => { beforeEach(() => { jest.clearAllMocks(); + mockHistory.createHref.mockImplementation( + ({ pathname }) => `/enterprise_search/app_search${pathname}` + ); }); - const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs); + const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs); - it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => { + it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => { expect(subject()).toEqual([ { - href: '/enterprise_search/', - onClick: expect.any(Function), text: 'Enterprise Search', }, { - href: '/enterprise_search/page1', + href: '/enterprise_search/app_search/', + onClick: expect.any(Function), + text: 'App Search', + }, + { + href: '/enterprise_search/app_search/page1', onClick: expect.any(Function), text: 'Page 1', }, { - href: '/enterprise_search/page2', + href: '/enterprise_search/app_search/page2', onClick: expect.any(Function), text: 'Page 2', }, @@ -173,12 +166,15 @@ describe('enterpriseSearchBreadcrumbs', () => { }); it('shows just the root if breadcrumbs is empty', () => { - expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([ + expect(appSearchBreadcrumbs(mockHistory)()).toEqual([ { - href: '/enterprise_search/', - onClick: expect.any(Function), text: 'Enterprise Search', }, + { + href: '/enterprise_search/app_search/', + onClick: expect.any(Function), + text: 'App Search', + }, ]); }); @@ -187,18 +183,22 @@ describe('enterpriseSearchBreadcrumbs', () => { preventDefault: jest.fn(), }; - it('has a link to Enterprise Search Home page first', () => { - subject()[0].onClick(eventMock); - expect(mockHistory.push).toHaveBeenCalledWith('/'); + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); }); - it('has a link to page 1 second', () => { + it('has a link to App Search second', () => { subject()[1].onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to page 1 third', () => { + subject()[2].onClick(eventMock); expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { - subject()[2].onClick(eventMock); + subject()[3].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 9aab47e51433a..fb59af54ca84c 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 @@ -16,19 +16,24 @@ import { letBrowserHandleEvent } from '../react_router_helpers'; interface IGenerateBreadcrumbProps { text: string; - path: string; - history: History; + path?: string; + history?: History; } -export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbProps) => ({ - text, - href: history.createHref({ pathname: path }), - onClick: event => { - if (letBrowserHandleEvent(event)) return; - event.preventDefault(); - history.push(path); - }, -}); +export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbProps) => { + const breadcrumb = { text }; + + if (path && history) { + breadcrumb.href = history.createHref({ pathname: path }); + breadcrumb.onClick = event => { + if (letBrowserHandleEvent(event)) return; + event.preventDefault(); + history.push(path); + }; + } + + return breadcrumb; +}; /** * Product-specific breadcrumb helpers @@ -39,12 +44,9 @@ type TBreadcrumbs = EuiBreadcrumb[] | []; export const enterpriseSearchBreadcrumbs = (history: History) => ( breadcrumbs: TBreadcrumbs = [] ) => [ - generateBreadcrumb({ text: 'Enterprise Search', path: '/', history }), + generateBreadcrumb({ text: 'Enterprise Search' }), ...breadcrumbs.map(({ text, path }) => generateBreadcrumb({ text, path, history })), ]; export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => - enterpriseSearchBreadcrumbs(history)([ - { text: 'App Search', path: '/app_search' }, - ...breadcrumbs, - ]); + enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]); diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index cf495b6a6f9de..3f6493a81272f 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -40,19 +40,20 @@ export class EnterpriseSearchPlugin implements Plugin { const config = this.config; core.application.register({ - id: 'enterprise_search', - title: 'App Search', // TODO: This will eventually be 'Enterprise Search' once there's more than just App Search in here - euiIconType: AppSearchLogo, // TODO: Temporary - App Search will likely no longer need an icon once the nav structure changes. - category: DEFAULT_APP_CATEGORIES.management, // TODO - This is likely not final/correct - order: 10, // TODO - This will also likely not be needed once new nav structure changes land + id: 'app_search', + title: 'App Search', + // appRoute: '/app/enterprise_search/app_search', // TODO: Switch to this once https://github.com/elastic/kibana/issues/59190 is in + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); const { renderApp } = await import('./applications'); + const { AppSearch } = await import('./applications/app_search'); - return renderApp(coreStart, params, config, plugins); + return renderApp(AppSearch, coreStart, params, config, plugins); }, }); + // TODO: Workplace Search will need to register its own plugin. plugins.home.featureCatalogue.register({ id: 'app_search', @@ -60,11 +61,11 @@ export class EnterpriseSearchPlugin implements Plugin { icon: AppSearchLogo, description: 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', - path: '/app/enterprise_search/app_search', + path: '/app/app_search', // TODO: Switch to '/app/enterprise_search/app_search' once https://github.com/elastic/kibana/issues/59190 is in category: FeatureCatalogueCategory.DATA, showOnHomePage: true, }); - // TODO: Workplace Search will likely also register its own feature catalogue section/card. + // TODO: Workplace Search will need to register its own feature catalogue section/card. } public start(core: CoreStart) {} From 6d9c1c4628386e9d678a8913916b4c308a4f8c09 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Fri, 8 May 2020 13:03:08 -0700 Subject: [PATCH 22/48] Add create_first_engine_button telemetry tracking to EmptyState --- .../components/empty_states/empty_state.tsx | 22 +++++++++++++------ .../empty_states/empty_states.test.tsx | 18 ++++++++++++++- .../collectors/app_search/telemetry.test.ts | 3 +++ .../server/collectors/app_search/telemetry.ts | 1 + .../saved_objects/app_search/telemetry.ts | 5 +++++ 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index 2055b0b7b54bd..2c394d310401f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -7,6 +7,7 @@ import React, { useContext } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { sendTelemetry } from '../../../shared/telemetry'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { KibanaContext, IKibanaContext } from '../../../index'; @@ -15,7 +16,19 @@ import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; export const EmptyState: React.FC<> = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + href: `${enterpriseSearchUrl}/as/engines/new`, + target: '_blank', + onClick: () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'create_first_engine_button', + }), + }; return ( @@ -35,12 +48,7 @@ export const EmptyState: React.FC<> = () => {

} actions={ - + Create your first Engine } 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 61b740f8ca888..35baf68e09ca0 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 @@ -8,11 +8,17 @@ import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiCode, EuiLoadingContent } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiButton, EuiCode, EuiLoadingContent } from '@elastic/eui'; jest.mock('../../utils/get_username', () => ({ getUserName: jest.fn() })); import { getUserName } from '../../utils/get_username'; +jest.mock('../../../shared/telemetry', () => ({ + sendTelemetry: jest.fn(), + SendAppSearchTelemetry: jest.fn(), +})); +import { sendTelemetry } from '../../../shared/telemetry'; + import { ErrorState, NoUserState, EmptyState, LoadingState } from './'; describe('ErrorState', () => { @@ -51,6 +57,16 @@ describe('EmptyState', () => { expect(prompt).toHaveLength(1); expect(prompt.prop('title')).toEqual(

There’s nothing here yet

); }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + const button = prompt.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + sendTelemetry.mockClear(); + }); }); describe('LoadingState', () => { 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 144f22236ec4e..b4922a822ae23 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 @@ -22,6 +22,7 @@ describe('App Search Telemetry Usage Collector', () => { 'ui_viewed.engines_overview': 20, 'ui_error.cannot_connect': 3, 'ui_error.no_as_account': 4, + 'ui_clicked.create_first_engine_button': 40, 'ui_clicked.header_launch_button': 50, 'ui_clicked.engine_table_link': 60, }, @@ -67,6 +68,7 @@ describe('App Search Telemetry Usage Collector', () => { no_as_account: 4, }, ui_clicked: { + create_first_engine_button: 40, header_launch_button: 50, engine_table_link: 60, }, @@ -90,6 +92,7 @@ describe('App Search Telemetry Usage Collector', () => { no_as_account: 0, }, ui_clicked: { + create_first_engine_button: 0, header_launch_button: 0, engine_table_link: 0, }, 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 c95fc641144e1..72f6fc2201be8 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 @@ -48,6 +48,7 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => no_as_account: 0, }, ui_clicked: { + create_first_engine_button: 0, header_launch_button: 0, engine_table_link: 0, }, diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts index 28f7d2b45b9f6..20c03b6aece8a 100644 --- a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -18,6 +18,7 @@ export interface ITelemetrySavedObject { no_as_account: number; }; ui_clicked: { + create_first_engine_button: number; header_launch_button: number; engine_table_link: number; }; @@ -55,6 +56,10 @@ export const appSearchTelemetryType: SavedObjectsType = { }, ui_clicked: { properties: { + create_first_engine_button: { + type: 'long', + null_value: 0, + }, header_launch_button: { type: 'long', null_value: 0, From af1593653346ad35d9f1557008fc43485b7f76db Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 13 May 2020 15:22:48 -0700 Subject: [PATCH 23/48] Switch plugin URLs back to /app/enterprise_search/app_search Now that https://github.com/elastic/kibana/pull/66455 has been merged in :tada: --- x-pack/plugins/enterprise_search/public/plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 3f6493a81272f..5863df5ccba25 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -42,7 +42,7 @@ export class EnterpriseSearchPlugin implements Plugin { core.application.register({ id: 'app_search', title: 'App Search', - // appRoute: '/app/enterprise_search/app_search', // TODO: Switch to this once https://github.com/elastic/kibana/issues/59190 is in + appRoute: '/app/enterprise_search/app_search', category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); @@ -61,7 +61,7 @@ export class EnterpriseSearchPlugin implements Plugin { icon: AppSearchLogo, description: 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', - path: '/app/app_search', // TODO: Switch to '/app/enterprise_search/app_search' once https://github.com/elastic/kibana/issues/59190 is in + path: '/app/enterprise_search/app_search', category: FeatureCatalogueCategory.DATA, showOnHomePage: true, }); From 9f3645d92f0b7a51f7931f820e38ee6b09136b4f Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 18 May 2020 11:34:11 -0700 Subject: [PATCH 24/48] Add i18n formatted messages / translations (#13) * Add i18n provider and formatted/i18n translated messages * Update tests to account for new I18nProvider context + FormattedMessage components - Add new mountWithContext helper that provides all contexts+providers used in top-level app - Add new shallowWithIntl helper for shallow() components that dive into FormattedMessage * Format i18n dates and numbers + update some mock tests to not throw react-intl invalid date messages --- x-pack/.i18nrc.json | 1 + .../public/applications/__mocks__/index.ts | 3 +- .../__mocks__/mount_with_context.mock.tsx | 34 +++- .../__mocks__/shallow_with_i18n.mock.tsx | 31 ++++ .../components/empty_states/empty_state.tsx | 26 ++- .../empty_states/empty_states.test.tsx | 22 +-- .../components/empty_states/error_state.tsx | 33 +++- .../components/empty_states/no_user_state.tsx | 29 ++-- .../engine_overview/engine_overview.test.tsx | 25 ++- .../engine_overview/engine_overview.tsx | 11 +- .../engine_overview/engine_table.test.tsx | 6 +- .../engine_overview/engine_table.tsx | 57 +++++-- .../engine_overview_header.tsx | 15 +- .../components/setup_guide/setup_guide.tsx | 151 ++++++++++++------ .../public/applications/index.tsx | 29 ++-- .../shared/licensing/license_context.test.tsx | 8 +- 16 files changed, 345 insertions(+), 136 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 596ba17d343c0..d0055008eb9bf 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -16,6 +16,7 @@ "xpack.data": "plugins/data_enhanced", "xpack.embeddableEnhanced": "plugins/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", + "xpack.enterpriseSearch": "plugins/enterprise_search", "xpack.features": "plugins/features", "xpack.fileUpload": "plugins/file_upload", "xpack.globalSearch": ["plugins/global_search"], 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 5b19055115fde..14fde357a980a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -7,6 +7,7 @@ export { mockHistory } from './react_router_history.mock'; export { mockKibanaContext } from './kibana_context.mock'; export { mockLicenseContext } from './license_context.mock'; -export { mountWithKibanaContext } from './mount_with_context.mock'; +export { mountWithContext, mountWithKibanaContext } 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 dcb5810d9fccc..7d0716ce0cdd0 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 @@ -7,21 +7,43 @@ import React from 'react'; import { mount } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; import { KibanaContext } from '../'; import { mockKibanaContext } from './kibana_context.mock'; +import { LicenseContext } from '../shared/licensing'; +import { mockLicenseContext } from './license_context.mock'; /** - * This helper mounts a component with a set of default KibanaContext, - * while also allowing custom context to be passed in via a second arg + * This helper mounts a component with all the contexts/providers used + * by the production app, while allowing custom context to be + * passed in via a second arg * * Example usage: * - * const wrapper = mountWithKibanaContext(, { enterpriseSearchUrl: 'someOverride' }); + * const wrapper = mountWithContext(, { enterpriseSearchUrl: 'someOverride', license: {} }); */ -export const mountWithKibanaContext = (node, contextProps) => { +export const mountWithContext = (children, context) => { return mount( - - {node} + + + + {children} + + + + ); +}; + +/** + * This helper mounts a component with just the default KibanaContext - + * useful for isolated / helper components that only need this context + * + * Same usage/override functionality as mountWithContext + */ +export const mountWithKibanaContext = (children, context) => { + return mount( + + {children} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx new file mode 100644 index 0000000000000..f37d02d251c92 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.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 React from 'react'; +import { shallow } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; +import { IntlProvider } from 'react-intl'; + +const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {}); +const { intl } = intlProvider.getChildContext(); + +/** + * This helper shallow wraps a component with react-intl's which + * fixes "Could not find required `intl` object" console errors when running tests + * + * Example usage (should be the same as shallow()): + * + * const wrapper = shallowWithIntl(); + */ +export const shallowWithIntl = children => { + return shallow({children}, { + context: { intl }, + childContextTypes: { intl }, + }) + .childAt(0) + .dive() + .shallow(); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index 2c394d310401f..91b1a4319cbd7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -6,6 +6,7 @@ import React, { useContext } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../shared/telemetry'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; @@ -39,17 +40,34 @@ export const EmptyState: React.FC<> = () => { There’s nothing here yet} + title={ +

+ +

+ } titleSize="l" body={

- Looks like you don’t have any App Search engines. -
Let’s create your first one now. + +
+

} actions={ - Create your first Engine + } /> 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 35baf68e09ca0..5419029a37b16 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,8 @@ import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiButton, EuiCode, EuiLoadingContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { shallowWithIntl } from '../../../__mocks__'; jest.mock('../../utils/get_username', () => ({ getUserName: jest.fn() })); import { getUserName } from '../../utils/get_username'; @@ -24,38 +26,36 @@ import { ErrorState, NoUserState, EmptyState, LoadingState } from './'; describe('ErrorState', () => { it('renders', () => { const wrapper = shallow(); - const prompt = wrapper.find(EuiEmptyPrompt); - expect(prompt).toHaveLength(1); - expect(prompt.prop('title')).toEqual(

Cannot connect to App Search

); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); }); describe('NoUserState', () => { it('renders', () => { const wrapper = shallow(); - const prompt = wrapper.find(EuiEmptyPrompt); - expect(prompt).toHaveLength(1); - expect(prompt.prop('title')).toEqual(

Cannot find App Search account

); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); it('renders with username', () => { getUserName.mockImplementationOnce(() => 'dolores-abernathy'); - const wrapper = shallow(); + const wrapper = shallowWithIntl(); const prompt = wrapper.find(EuiEmptyPrompt).dive(); + const description1 = prompt + .find(FormattedMessage) + .at(1) + .dive(); - expect(prompt.find(EuiCode).prop('children')).toContain('dolores-abernathy'); + expect(description1.find(EuiCode).prop('children')).toContain('dolores-abernathy'); }); }); describe('EmptyState', () => { it('renders', () => { const wrapper = shallow(); - const prompt = wrapper.find(EuiEmptyPrompt); - expect(prompt).toHaveLength(1); - expect(prompt.prop('title')).toEqual(

There’s nothing here yet

); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); it('sends telemetry on create first engine click', () => { 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 9067f8dfc9c81..0725d3650054e 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 @@ -6,6 +6,7 @@ import React, { useContext } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton } from '../../../shared/react_router_helpers'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; @@ -29,23 +30,43 @@ export const ErrorState: ReactFC<> = () => { Cannot connect to App Search} + title={ +

+ +

+ } titleSize="l" body={ <>

- We cannot connect to the App Search instance at the configured host URL:{' '} - {enterpriseSearchUrl} + {enterpriseSearchUrl}, + }} + />

- Please ensure your App Search host URL is configured correctly within{' '} - config/kibana.yml. + config/kibana.yml, + }} + />

} actions={ - Review the setup guide + } /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index 41ffe88f57fcc..c1d6c2bcffe41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; @@ -27,21 +28,31 @@ export const NoUserState: React.FC<> = () => { Cannot find App Search account} + title={ +

+ +

+ } titleSize="l" body={ <>

- We cannot find an App Search account matching your username - {username && ( - <> - : {username} - - )} - . + {username} : '', + }} + />

- Please contact your App Search administrator to request an invite for that user. +

} 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 5d029d6c4ba8a..e7223b1c6b002 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 @@ -10,9 +10,10 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { render } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; import { KibanaContext } from '../../../'; import { LicenseContext } from '../../../shared/licensing'; -import { mountWithKibanaContext, mockKibanaContext } from '../../../__mocks__'; +import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; import { EmptyState, ErrorState, NoUserState } from '../empty_states'; import { EngineTable } from './engine_table'; @@ -23,12 +24,15 @@ describe('EngineOverview', () => { 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 = render( - - - - - + + + + + + + ); // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly @@ -66,7 +70,7 @@ describe('EngineOverview', () => { results: [ { name: 'hello-world', - created_at: 'somedate', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', document_count: 50, field_count: 10, }, @@ -164,12 +168,7 @@ describe('EngineOverview', () => { // 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 = mountWithKibanaContext( - - - , - { http: httpMock } - ); + wrapper = mountWithContext(, { http: httpMock, license }); }); wrapper.update(); // This seems to be required for the DOM to actually update diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 1e1a583b5bcdb..d640122282618 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -14,6 +14,7 @@ import { EuiTitle, EuiSpacer, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; @@ -100,7 +101,10 @@ export const EngineOverview: ReactFC<> = () => {

- Engines +

@@ -122,7 +126,10 @@ export const EngineOverview: ReactFC<> = () => {

- Meta Engines +

diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx index 62305d7acb451..f7591fb6d1dee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; -import { mountWithKibanaContext } from '../../../__mocks__'; +import { mountWithContext } from '../../../__mocks__'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); import { sendTelemetry } from '../../../shared/telemetry'; @@ -16,7 +16,7 @@ import { EngineTable } from './engine_table'; describe('EngineTable', () => { const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream - const wrapper = mountWithKibanaContext( + const wrapper = mountWithContext( { }); it('handles empty data', () => { - const emptyWrapper = mountWithKibanaContext( + const emptyWrapper = mountWithContext( ); const emptyTable = wrapper.find(EuiBasicTable); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index e138bade11c15..81c7888730ab8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -6,6 +6,8 @@ import React, { useContext } from 'react'; import { EuiBasicTable, EuiLink } from '@elastic/eui'; +import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; @@ -51,7 +53,9 @@ export const EngineTable: ReactFC = ({ const columns = [ { field: 'name', - name: 'Name', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { + defaultMessage: 'Name', + }), render: name => {name}, width: '30%', truncateText: true, @@ -64,36 +68,59 @@ export const EngineTable: ReactFC = ({ }, { field: 'created_at', - name: 'Created At', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt', + { + defaultMessage: 'Created At', + } + ), dataType: 'string', - render: dateString => { + render: dateString => ( // e.g., January 1, 1970 - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }, + + ), }, { field: 'document_count', - name: 'Document Count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', + { + defaultMessage: 'Document Count', + } + ), dataType: 'number', - render: number => number.toLocaleString(), // Display with comma thousand separators + render: number => , truncateText: true, }, { field: 'field_count', - name: 'Field Count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', + { + defaultMessage: 'Field Count', + } + ), dataType: 'number', - render: number => number.toLocaleString(), // Display with comma thousand separators + render: number => , truncateText: true, }, { field: 'name', - name: 'Actions', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', + { + defaultMessage: 'Actions', + } + ), dataType: 'string', - render: name => Manage, + render: name => ( + + + + ), align: 'right', width: '100px', }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index df3238fde56d8..20ad3ce5ad272 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -6,6 +6,7 @@ import React, { useContext } from 'react'; import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; @@ -36,11 +37,21 @@ export const EngineOverviewHeader: React.FC<> = () => { -

Engine Overview

+

+ +

- Launch App Search + + +
); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index 575a604069fdb..855449b0c0bcd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -23,6 +23,8 @@ import { EuiAccordion, EuiLink, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; @@ -38,7 +40,12 @@ export const SetupGuide: React.FC<> = () => { - Setup Guide + + + @@ -48,7 +55,12 @@ export const SetupGuide: React.FC<> = () => { -

App Search

+

+ +

@@ -61,7 +73,10 @@ export const SetupGuide: React.FC<> = () => { Getting started with App Search - in this short video we'll guide you through how to get App Search up and running @@ -69,15 +84,19 @@ export const SetupGuide: React.FC<> = () => {

- Elastic App Search provides user-friendly tools to design and deploy a powerful search - to your websites or web/mobile applications. +

- App Search has not been configured in your Kibana instance yet. To get started, follow - the instructions on this page. +

@@ -88,13 +107,20 @@ export const SetupGuide: React.FC<> = () => { headingElement="h2" steps={[ { - title: 'Add your App Search host URL to your Kibana configuration', + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step1.title', { + defaultMessage: 'Add your App Search host URL to your Kibana configuration', + }), children: (

- Within your config/kibana.yml file, set{' '} - enterpriseSearch.host to the URL of your App Search - instance. For example: + config/kibana.yml, + configSetting: enterpriseSearch.host, + }} + />

enterpriseSearch.host: 'http://localhost:3002' @@ -103,75 +129,110 @@ export const SetupGuide: React.FC<> = () => { ), }, { - title: 'Reload your Kibana instance', + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { + defaultMessage: 'Reload your Kibana instance', + }), children: (

- Restart Kibana to pick up the configuration changes from the previous step. +

- If you’re using{' '} - - Elasticsearch Native - {' '} - auth within App Search - you’re all set! All users should be able to use App - Search in Kibana automatically, inheriting the existing access and permissions - they have within App Search. + + Elasticsearch Native Auth + + ), + }} + />

), }, { - title: 'Troubleshooting issues', + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { + defaultMessage: 'Troubleshooting issues', + }), children: ( <>

- This plugin does not currently support App Search and Kibana running on - different clusters. +

- This plugin does not currently support App Search and Kibana operating on - different authentication methods (for example, App Search using a - different SAML provider than Kibana). +

- App Search operating on{' '} - - Standard Auth - {' '} - is currently not fully supported by this plugin. Users created in App - Search must be granted Kibana access. Users created in Kibana will see - "Cannot find App Search account" error messages. + + Standard Auth + + ), + }} + />

diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 1c18ad41cb590..ae7079befb8c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Router } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n/react'; import { CoreStart, AppMountParams, HttpHandler } from 'src/core/public'; import { ClientConfigType, PluginsSetup } from '../plugin'; import { TSetBreadcrumbs } from './shared/kibana_breadcrumbs'; @@ -35,19 +36,21 @@ export const renderApp = ( plugins: PluginsSetup ) => { ReactDOM.render( - - - - - - - , + + + + + + + + + , params.element ); return () => ReactDOM.unmountComponentAtNode(params.element); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx index 3385f79d3d075..01d976bf49c19 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx @@ -6,7 +6,7 @@ import React, { useContext } from 'react'; -import { mountWithKibanaContext } from '../../__mocks__'; +import { mountWithContext } from '../../__mocks__'; import { LicenseContext, ILicenseContext } from './'; describe('LicenseProvider', () => { @@ -16,11 +16,7 @@ describe('LicenseProvider', () => { }; it('renders children', () => { - const wrapper = mountWithKibanaContext( - - - - ); + const wrapper = mountWithContext(, { license: { type: 'basic' } }); expect(wrapper.find('.license-test')).toHaveLength(1); expect(wrapper.text()).toEqual('basic'); From 7e886e09047332795b98bf93eef26b46b998ab70 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Mon, 18 May 2020 11:59:55 -0700 Subject: [PATCH 25/48] Update EngineOverviewHeader to disable button on prop --- .../components/empty_states/error_state.tsx | 2 +- .../engine_overview_header.test.tsx | 57 ++++++------------- .../engine_overview_header.tsx | 16 ++++-- 3 files changed, 30 insertions(+), 45 deletions(-) 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 0725d3650054e..039e645a27126 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 @@ -25,7 +25,7 @@ export const ErrorState: ReactFC<> = () => { - + ({ sendTelemetry: jest.fn() })); @@ -15,48 +15,27 @@ import { sendTelemetry } from '../../../shared/telemetry'; import { EngineOverviewHeader } from '../engine_overview_header'; describe('EngineOverviewHeader', () => { - describe('when enterpriseSearchUrl is set', () => { - let button; - - beforeAll(() => { - useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'http://localhost:3002' })); - const wrapper = shallow(); - button = wrapper.find('[data-test-subj="launchButton"]'); - }); - - describe('the Launch App Search button', () => { - it('should not be disabled', () => { - expect(button.props().isDisabled).toBeFalsy(); - }); - - it('should use the enterpriseSearchUrl as the base path for its href', () => { - expect(button.props().href).toBe('http://localhost:3002/as'); - }); - - it('should send telemetry when clicked', () => { - button.simulate('click'); - expect(sendTelemetry).toHaveBeenCalled(); - }); - }); + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('h1')).toHaveLength(1); }); - describe('when enterpriseSearchUrl is not set', () => { - let button; + it('renders a launch app search button that sends telemetry on click', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="launchButton"]'); - beforeAll(() => { - useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: undefined })); - const wrapper = shallow(); - button = wrapper.find('[data-test-subj="launchButton"]'); - }); + expect(button.props().href).toBe('http://localhost:3002/as'); + expect(button.props().isDisabled).toBeFalsy(); - describe('the Launch App Search button', () => { - it('should be disabled', () => { - expect(button.props().isDisabled).toBe(true); - }); + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders a disabled button when isButtonDisabled is true', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="launchButton"]'); - it('should not have an href', () => { - expect(button.props().href).toBeUndefined(); - }); - }); + expect(button.props().isDisabled).toBe(true); + expect(button.props().href).toBeUndefined(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index 20ad3ce5ad272..650a864f5e615 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -11,15 +11,23 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; -export const EngineOverviewHeader: React.FC<> = () => { +interface IEngineOverviewHeaderProps { + isButtonDisabled?: boolean; +} + +export const EngineOverviewHeader: React.FC = ({ + isButtonDisabled, +}) => { const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { fill: true, iconType: 'popout', - ['data-test-subj']: 'launchButton', + 'data-test-subj': 'launchButton', }; - if (enterpriseSearchUrl) { + if (isButtonDisabled) { + buttonProps.isDisabled = true; + } else { buttonProps.href = `${enterpriseSearchUrl}/as`; buttonProps.target = '_blank'; buttonProps.onClick = () => @@ -29,8 +37,6 @@ export const EngineOverviewHeader: React.FC<> = () => { action: 'clicked', metric: 'header_launch_button', }); - } else { - buttonProps.isDisabled = true; } return ( From 3454568e7e42d417c651757c7936d4fab8c67224 Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 1 Jun 2020 12:48:45 -0700 Subject: [PATCH 26/48] Address review feedback (#14) * Fix Prettier linting issues * Escape App Search API endpoint URLs - per PR feedback - querystring should automatically encodeURIComponent / escape query param strings * Update server plugin.ts to use getStartServices() rather than storing local references from start() - Per feedback: https://github.com/elastic/kibana/blob/master/src/core/CONVENTIONS.md#applications - Note: savedObjects.registerType needs to be outside of getStartServices, or an error is thrown - Side update to registerTelemetryUsageCollector to simplify args - Update/fix tests to account for changes --- .../__mocks__/shallow_with_i18n.mock.tsx | 2 +- .../empty_states/empty_states.test.tsx | 5 +--- .../engine_overview/engine_overview.test.tsx | 6 +--- .../engine_overview/engine_table.test.tsx | 2 +- .../engine_overview/engine_table.tsx | 12 ++++---- .../generate_breadcrumbs.ts | 2 +- .../set_breadcrumbs.test.tsx | 2 +- .../shared/react_router_helpers/eui_link.tsx | 4 +-- .../react_router_helpers/link_events.test.ts | 2 +- .../react_router_helpers/link_events.ts | 8 ++--- .../collectors/app_search/telemetry.test.ts | 29 ++++++++----------- .../server/collectors/app_search/telemetry.ts | 13 +++------ .../enterprise_search/server/plugin.ts | 29 ++++++++----------- .../server/routes/__mocks__/router.mock.ts | 8 ++--- .../server/routes/app_search/engines.test.ts | 8 ++--- .../server/routes/app_search/engines.ts | 9 +++++- 16 files changed, 63 insertions(+), 78 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx index f37d02d251c92..7815bb71fa50e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx @@ -20,7 +20,7 @@ const { intl } = intlProvider.getChildContext(); * * const wrapper = shallowWithIntl(); */ -export const shallowWithIntl = children => { +export const shallowWithIntl = (children) => { return shallow({children}, { context: { intl }, childContextTypes: { intl }, 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 5419029a37b16..bb37229998ed2 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 @@ -42,10 +42,7 @@ describe('NoUserState', () => { getUserName.mockImplementationOnce(() => 'dolores-abernathy'); const wrapper = shallowWithIntl(); const prompt = wrapper.find(EuiEmptyPrompt).dive(); - const description1 = prompt - .find(FormattedMessage) - .at(1) - .dive(); + const description1 = prompt.find(FormattedMessage).at(1).dive(); expect(description1.find(EuiCode).prop('children')).toContain('dolores-abernathy'); }); 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 e7223b1c6b002..a9670163e76b8 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 @@ -105,11 +105,7 @@ describe('EngineOverview', () => { }); describe('pagination', () => { - const getTablePagination = () => - wrapper - .find(EngineTable) - .first() - .prop('pagination'); + const getTablePagination = () => wrapper.find(EngineTable).first().prop('pagination'); it('passes down page data from the API', () => { const pagination = getTablePagination(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx index f7591fb6d1dee..1665726251bd6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -51,7 +51,7 @@ describe('EngineTable', () => { it('contains engine links which send telemetry', () => { const engineLinks = wrapper.find(EuiLink); - engineLinks.forEach(link => { + engineLinks.forEach((link) => { expect(link.prop('href')).toEqual('http://localhost:3002/as/engines/test-engine'); link.simulate('click'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index 81c7888730ab8..df82b54fbf9f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -38,7 +38,7 @@ export const EngineTable: ReactFC = ({ pagination: { totalEngines, pageIndex = 0, onPaginate }, }) => { const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; - const engineLinkProps = name => ({ + const engineLinkProps = (name) => ({ href: `${enterpriseSearchUrl}/as/engines/${name}`, target: '_blank', onClick: () => @@ -56,7 +56,7 @@ export const EngineTable: ReactFC = ({ name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { defaultMessage: 'Name', }), - render: name => {name}, + render: (name) => {name}, width: '30%', truncateText: true, mobileOptions: { @@ -75,7 +75,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'string', - render: dateString => ( + render: (dateString) => ( // e.g., January 1, 1970 ), @@ -89,7 +89,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'number', - render: number => , + render: (number) => , truncateText: true, }, { @@ -101,7 +101,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'number', - render: number => , + render: (number) => , truncateText: true, }, { @@ -113,7 +113,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'string', - render: name => ( + render: (name) => ( { + breadcrumb.onClick = (event) => { if (letBrowserHandleEvent(event)) return; event.preventDefault(); history.push(path); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx index 38aae0e499c92..aeaa38a5ad38f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -23,7 +23,7 @@ describe('SetAppSearchBreadcrumbs', () => { jest.clearAllMocks(); }); - const mountSetAppSearchBreadcrumbs = props => { + const mountSetAppSearchBreadcrumbs = (props) => { return mountWithKibanaContext(, { http: {}, enterpriseSearchUrl: 'http://localhost:3002', diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx index b56535d984e5c..3c410584cc49d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -25,7 +25,7 @@ interface IEuiReactRouterProps { export const EuiReactRouterLink: React.FC = ({ to, isButton, ...rest }) => { const history = useHistory(); - const onClick = event => { + const onClick = (event) => { if (letBrowserHandleEvent(event)) return; // Prevent regular link behavior, which causes a browser refresh. @@ -42,6 +42,6 @@ export const EuiReactRouterLink: React.FC = ({ to, isButto return isButton ? : ; }; -export const EuiReactRouterButton: React.FC = props => ( +export const EuiReactRouterButton: React.FC = (props) => ( ); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts index 49ab5ed920e36..0845e5562776b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts @@ -95,7 +95,7 @@ describe('letBrowserHandleEvent', () => { }); }); -const targetValue = value => { +const targetValue = (value) => { return { getAttribute: () => value, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts index bb87ecaf6877b..67e987623c2c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts @@ -13,18 +13,18 @@ import { SyntheticEvent } from 'react'; type THandleEvent = (event: SyntheticEvent) => boolean; -export const letBrowserHandleEvent: THandleEvent = event => +export const letBrowserHandleEvent: THandleEvent = (event) => event.defaultPrevented || isModifiedEvent(event) || !isLeftClickEvent(event) || isTargetBlank(event); -const isModifiedEvent: THandleEvent = event => +const isModifiedEvent: THandleEvent = (event) => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); -const isLeftClickEvent: THandleEvent = event => event.button === 0; +const isLeftClickEvent: THandleEvent = (event) => event.button === 0; -const isTargetBlank: THandleEvent = event => { +const isTargetBlank: THandleEvent = (event) => { const target = event.target.getAttribute('target'); return !!target && target !== '_self'; }; 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 b4922a822ae23..4be2c220024bc 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 @@ -14,6 +14,10 @@ import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry describe('App Search Telemetry Usage Collector', () => { const makeUsageCollectorStub = jest.fn(); const registerStub = jest.fn(); + const usageCollectionMock = { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + }; const savedObjectsRepoStub = { get: () => ({ @@ -29,14 +33,8 @@ describe('App Search Telemetry Usage Collector', () => { }), incrementCounter: jest.fn(), }; - const dependencies = { - usageCollection: { - makeUsageCollector: makeUsageCollectorStub, - registerCollector: registerStub, - }, - savedObjects: { - createInternalRepository: jest.fn(() => savedObjectsRepoStub), - }, + const savedObjectsMock = { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), }; beforeEach(() => { @@ -45,7 +43,7 @@ describe('App Search Telemetry Usage Collector', () => { describe('registerTelemetryUsageCollector', () => { it('should make and register the usage collector', () => { - registerTelemetryUsageCollector(dependencies); + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock); expect(registerStub).toHaveBeenCalledTimes(1); expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); @@ -55,7 +53,7 @@ describe('App Search Telemetry Usage Collector', () => { describe('fetchTelemetryMetrics', () => { it('should return existing saved objects data', async () => { - registerTelemetryUsageCollector(dependencies); + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock); const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); expect(savedObjectsCounts).toEqual({ @@ -76,10 +74,9 @@ describe('App Search Telemetry Usage Collector', () => { }); it('should not error & should return a default telemetry object if no saved data exists', async () => { - registerTelemetryUsageCollector({ - ...dependencies, - savedObjects: { createInternalRepository: () => ({}) }, - }); + const emptySavedObjectsMock = { createInternalRepository: () => ({}) }; + + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock); const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); expect(savedObjectsCounts).toEqual({ @@ -102,10 +99,8 @@ describe('App Search Telemetry Usage Collector', () => { describe('incrementUICounter', () => { it('should increment the saved objects internal repository', async () => { - const { savedObjects } = dependencies; - const response = await incrementUICounter({ - savedObjects, + savedObjects: savedObjectsMock, uiAction: 'ui_clicked', metric: 'button', }); 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 72f6fc2201be8..12b5a165bf1ac 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 @@ -14,15 +14,10 @@ import { AS_TELEMETRY_NAME, ITelemetrySavedObject } from '../../saved_objects/ap * Register the telemetry collector */ -interface Dependencies { - savedObjects: SavedObjectsServiceStart; - usageCollection: UsageCollectionSetup; -} - -export const registerTelemetryUsageCollector = ({ - usageCollection, - savedObjects, -}: Dependencies) => { +export const registerTelemetryUsageCollector = ( + usageCollection: UsageCollectionSetup, + savedObjects: SavedObjectsServiceStart +) => { const telemetryUsageCollector = usageCollection.makeUsageCollector({ type: 'app_search', fetch: async () => fetchTelemetryMetrics(savedObjects), diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index f93fab18ab90e..077d900a8d8cf 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -10,7 +10,6 @@ import { Plugin, PluginInitializerContext, CoreSetup, - CoreStart, Logger, SavedObjectsServiceStart, } from 'src/core/server'; @@ -52,26 +51,22 @@ export class EnterpriseSearchPlugin implements Plugin { /** * Bootstrap the routes, saved objects, and collector for telemetry */ - registerTelemetryRoute({ - ...dependencies, - getSavedObjectsService: () => { - if (!this.savedObjectsServiceStart) { - throw new Error('Saved Objects Start service not available'); - } - return this.savedObjectsServiceStart; - }, - }); savedObjects.registerType(appSearchTelemetryType); - if (usageCollection) { - getStartServices().then(([{ savedObjects: savedObjectsStarted }]) => { - registerTelemetryUsageCollector({ usageCollection, savedObjects: savedObjectsStarted }); + + getStartServices().then(([coreStart]) => { + const savedObjectsStarted = coreStart.savedObjects as SavedObjectsServiceStart; + + registerTelemetryRoute({ + ...dependencies, + getSavedObjectsService: () => savedObjectsStarted, }); - } + if (usageCollection) { + registerTelemetryUsageCollector(usageCollection, savedObjectsStarted); + } + }); } - public start({ savedObjects }: CoreStart) { - this.savedObjectsServiceStart = savedObjects; - } + public start() {} public stop() {} } diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts index 3f1c310daac02..1cec5da055140 100644 --- a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts @@ -30,7 +30,7 @@ export class MockRouter { this.router = httpServiceMock.createRouter(); }; - public callRoute = async request => { + public callRoute = async (request) => { const [_, handler] = this.router[this.method].mock.calls[0]; const context = {} as jest.Mocked; @@ -41,18 +41,18 @@ export class MockRouter { * Schema validation helpers */ - public validateRoute = request => { + public validateRoute = (request) => { const [config] = this.router[this.method].mock.calls[0]; const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; validate[this.payload].validate(request[this.payload]); }; - public shouldValidate = request => { + public shouldValidate = (request) => { expect(() => this.validateRoute(request)).not.toThrow(); }; - public shouldThrow = request => { + public shouldThrow = (request) => { expect(() => this.validateRoute(request)).toThrow(); }; } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index e78d389ca8818..722ad0d9269d3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -46,7 +46,7 @@ describe('engine routes', () => { describe('when the underlying App Search API returns a 200', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, { headers: { Authorization: AUTH_HEADER } } ).andReturn({ results: [{ name: 'engine1' }], @@ -67,7 +67,7 @@ describe('engine routes', () => { describe('when the underlying App Search API redirects to /login', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, { headers: { Authorization: AUTH_HEADER } } ).andReturnRedirect(); }); @@ -85,7 +85,7 @@ describe('engine routes', () => { describe('when the App Search URL is invalid', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, { headers: { Authorization: AUTH_HEADER } } ).andReturnError(); }); @@ -104,7 +104,7 @@ describe('engine routes', () => { describe('when the App Search API returns invalid data', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`, + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, { headers: { Authorization: AUTH_HEADER } } ).andReturnInvalidData(); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 3a474dc58e4dd..ebe2252b24eef 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -5,6 +5,7 @@ */ import fetch from 'node-fetch'; +import querystring from 'querystring'; import { schema } from '@kbn/config-schema'; import { ENGINES_PAGE_SIZE } from '../../../common/constants'; @@ -25,7 +26,13 @@ export function registerEnginesRoute({ router, config, log }) { const appSearchUrl = config.host; const { type, pageIndex } = request.query; - const url = `${appSearchUrl}/as/engines/collection?type=${type}&page[current]=${pageIndex}&page[size]=${ENGINES_PAGE_SIZE}`; + const params = querystring.stringify({ + type, + 'page[current]': pageIndex, + 'page[size]': ENGINES_PAGE_SIZE, + }); + const url = `${encodeURI(appSearchUrl)}/as/engines/collection?${params}`; + const enginesResponse = await fetch(url, { headers: { Authorization: request.headers.authorization }, }); From fc194041a7dea266bad01ace6c5500eecf6826db Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 2 Jun 2020 13:59:58 -0400 Subject: [PATCH 27/48] E2E testing (#6) * Wired up basics for E2E testing * Added version with App Search * Updated naming * Switched configuration around * Added concept of 'fixtures' * Figured out how to log in as the enterprise_search user * Refactored to use an App Search service * Added some real tests * Added a README * Cleanup * More cleanup * Error handling + README updatre * Removed unnecessary files * Apply suggestions from code review Co-authored-by: Constance * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx Co-authored-by: Constance * PR feedback - updated README * Additional lint fixes Co-authored-by: Constance --- .../engine_overview/engine_overview.tsx | 4 +- .../engine_overview/engine_table.tsx | 6 +- .../functional_enterprise_search/README.md | 41 ++++++ .../app_search/engines.ts | 75 +++++++++++ .../with_host_configured/index.ts | 13 ++ .../app_search/setup_guide.ts | 36 ++++++ .../without_host_configured/index.ts | 13 ++ .../base_config.ts | 20 +++ .../page_objects/app_search.ts | 29 +++++ .../page_objects/index.ts | 13 ++ .../services/app_search_client.ts | 121 ++++++++++++++++++ .../services/app_search_service.ts | 83 ++++++++++++ .../services/index.ts | 13 ++ .../with_host_configured.config.ts | 31 +++++ .../without_host_configured.config.ts | 23 ++++ 15 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/functional_enterprise_search/README.md create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/base_config.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/app_search.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/index.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_client.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_service.ts create mode 100644 x-pack/test/functional_enterprise_search/services/index.ts create mode 100644 x-pack/test/functional_enterprise_search/with_host_configured.config.ts create mode 100644 x-pack/test/functional_enterprise_search/without_host_configured.config.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index d640122282618..a1e4a11dc2daf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -108,7 +108,7 @@ export const EngineOverview: ReactFC<> = () => { - + = () => { - + = ({ name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { defaultMessage: 'Name', }), - render: (name) => {name}, + render: (name) => ( + + {name} + + ), width: '30%', truncateText: true, mobileOptions: { diff --git a/x-pack/test/functional_enterprise_search/README.md b/x-pack/test/functional_enterprise_search/README.md new file mode 100644 index 0000000000000..e5d9009fc393b --- /dev/null +++ b/x-pack/test/functional_enterprise_search/README.md @@ -0,0 +1,41 @@ +# Enterprise Search Functional E2E Tests + +## Running these tests + +Follow the [Functional Test Runner instructions](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html#_running_functional_tests). + +There are two suites available to run, a suite that requires a Kibana instance without an `enterpriseSearch.host` +configured, and one that does. The later also [requires a running Enterprise Search instance](#enterprise-search-requirement), and a Private API key +from that instance set in an Environment variable. + +Ex. + +```sh +# Run specs from the x-pack directory +cd x-pack + +# Run tests that require enterpriseSearch.host variable +APP_SEARCH_API_KEY=[use private key from local App Search instance here] node scripts/functional_tests --config test/functional_enterprise_search/with_host_configured.config.ts + +# Run tests that do not require enterpriseSearch.host variable +APP_SEARCH_API_KEY=[use private key from local App Search instance here] node scripts/functional_tests --config test/functional_enterprise_search/without_host_configured.config.ts +``` + +## Enterprise Search Requirement + +These tests will not currently start an instance of App Search automatically. As such, they are not run as part of CI and are most useful for local regression testing. + +The easiest way to start Enterprise Search for these tests is to check out the `ent-search` project +and use the following script. + +```sh +cd script/stack_scripts +/start-with-license-and-expiration.sh platinum 500000 +``` + +Requirements for Enterprise Search: + +- Running on port 3002 against a separate Elasticsearch cluster. +- Elasticsearch must have a platinum or greater level license (or trial). +- Must have Standard or Native Auth configured with an `enterprise_search` user with password `changeme`. +- There should be NO existing Engines or Meta Engines. diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts new file mode 100644 index 0000000000000..38bdb429b8e09 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts @@ -0,0 +1,75 @@ +/* + * 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 { EsArchiver } from 'src/es_archiver'; +import { AppSearchService, IEngine } from '../../../../services/app_search_service'; +import { Browser } from '../../../../../../../test/functional/services/browser'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function enterpriseSearchSetupEnginesTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver') as EsArchiver; + const browser = getService('browser') as Browser; + const retry = getService('retry'); + const appSearch = getService('appSearch') as AppSearchService; + + const PageObjects = getPageObjects(['appSearch', 'security']); + + describe('Engines Overview', function () { + let engine1: IEngine; + let engine2: IEngine; + let metaEngine: IEngine; + + before(async () => { + await esArchiver.load('empty_kibana'); + engine1 = await appSearch.createEngine(); + engine2 = await appSearch.createEngine(); + metaEngine = await appSearch.createMetaEngine([engine1.name, engine2.name]); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + appSearch.destroyEngine(engine1.name); + appSearch.destroyEngine(engine2.name); + appSearch.destroyEngine(metaEngine.name); + }); + + describe('when an enterpriseSearch.host is configured', () => { + it('navigating to the enterprise_search plugin will redirect a user to the App Search Engines Overview page', async () => { + await PageObjects.security.forceLogout(); + const { user, password } = appSearch.getEnterpriseSearchUser(); + await PageObjects.security.login(user, password, { + expectSpaceSelector: false, + }); + + await PageObjects.appSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/app_search'); + }); + }); + + it('lists engines', async () => { + const engineLinks = await PageObjects.appSearch.getEngineLinks(); + const engineLinksText = await Promise.all(engineLinks.map((l) => l.getVisibleText())); + + expect(engineLinksText.includes(engine1.name)).to.equal(true); + expect(engineLinksText.includes(engine2.name)).to.equal(true); + }); + + it('lists meta engines', async () => { + const metaEngineLinks = await PageObjects.appSearch.getMetaEngineLinks(); + const metaEngineLinksText = await Promise.all( + metaEngineLinks.map((l) => l.getVisibleText()) + ); + expect(metaEngineLinksText.includes(metaEngine.name)).to.equal(true); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts new file mode 100644 index 0000000000000..d239d538290fa --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts @@ -0,0 +1,13 @@ +/* + * 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 default function ({ loadTestFile }: FtrProviderContext) { + describe('Enterprise Search', function () { + loadTestFile(require.resolve('./app_search/engines')); + }); +} 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 new file mode 100644 index 0000000000000..c328d0b202647 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_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(['appSearch']); + + 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 enterprise_search plugin will redirect a user to the setup guide', async () => { + await PageObjects.appSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/app_search/setup_guide'); + }); + }); + }); + }); +} 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 new file mode 100644 index 0000000000000..8408af99b117f --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts @@ -0,0 +1,13 @@ +/* + * 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 default function ({ loadTestFile }: FtrProviderContext) { + describe('Enterprise Search', function () { + loadTestFile(require.resolve('./app_search/setup_guide')); + }); +} diff --git a/x-pack/test/functional_enterprise_search/base_config.ts b/x-pack/test/functional_enterprise_search/base_config.ts new file mode 100644 index 0000000000000..f737b6cd4b5f4 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/base_config.ts @@ -0,0 +1,20 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackFunctionalConfig = await readConfigFile(require.resolve('../functional/config')); + + return { + // default to the xpack functional config + ...xPackFunctionalConfig.getAll(), + services, + pageObjects, + }; +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts new file mode 100644 index 0000000000000..a8b40b7774f78 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts @@ -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 { FtrProviderContext } from '../ftr_provider_context'; +import { TestSubjects } from '../../../../../test/functional/services/test_subjects'; + +export function AppSearchPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['common']); + const testSubjects = getService('testSubjects') as TestSubjects; + + return { + async navigateToPage() { + return await PageObjects.common.navigateToApp('app_search'); + }, + + async getEngineLinks() { + const engines = await testSubjects.find('appSearchEngines'); + return await testSubjects.findAllDescendant('engineNameLink', engines); + }, + + async getMetaEngineLinks() { + const metaEngines = await testSubjects.find('appSearchMetaEngines'); + return await testSubjects.findAllDescendant('engineNameLink', metaEngines); + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts new file mode 100644 index 0000000000000..009fb26482419 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { pageObjects as basePageObjects } from '../../functional/page_objects'; +import { AppSearchPageProvider } from './app_search'; + +export const pageObjects = { + ...basePageObjects, + appSearch: AppSearchPageProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts new file mode 100644 index 0000000000000..11c383eb779d6 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts @@ -0,0 +1,121 @@ +/* + * 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 http from 'http'; + +/** + * A simple request client for making API calls to the App Search API + */ +const makeRequest = (method: string, path: string, body?: object): Promise => { + return new Promise(function (resolve, reject) { + const APP_SEARCH_API_KEY = process.env.APP_SEARCH_API_KEY; + + if (!APP_SEARCH_API_KEY) { + throw new Error('Please provide a valid APP_SEARCH_API_KEY. See README for more details.'); + } + + let postData; + + if (body) { + postData = JSON.stringify(body); + } + + const req = http.request( + { + method, + hostname: 'localhost', + port: 3002, + path, + agent: false, // Create a new agent just for this one request + headers: { + Authorization: `Bearer ${APP_SEARCH_API_KEY}`, + 'Content-Type': 'application/json', + ...(!!postData && { 'Content-Length': Buffer.byteLength(postData) }), + }, + }, + (res) => { + const bodyChunks: Uint8Array[] = []; + res.on('data', function (chunk) { + bodyChunks.push(chunk); + }); + + res.on('end', function () { + let responseBody; + try { + responseBody = JSON.parse(Buffer.concat(bodyChunks).toString()); + } catch (e) { + reject(e); + } + + if (res.statusCode > 299) { + reject('Error calling App Search API: ' + JSON.stringify(responseBody)); + } + + resolve(responseBody); + }); + } + ); + + req.on('error', (e) => { + reject(e); + }); + + if (postData) { + req.write(postData); + } + req.end(); + }); +}; + +export interface IEngine { + name: string; +} + +export const createEngine = async (engineName: string): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { name: engineName }); +}; + +export const destroyEngine = async (engineName: string): Promise => { + return await makeRequest('DELETE', `/api/as/v1/engines/${engineName}`); +}; + +export const createMetaEngine = async ( + engineName: string, + sourceEngines: string[] +): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { + name: engineName, + type: 'meta', + source_engines: sourceEngines, + }); +}; + +export interface ISearchResponse { + results: object[]; +} + +const search = async (engineName: string): Promise => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/search`, { query: '' }); +}; + +// Since the App Search API does not issue document receipts, the only way to tell whether or not documents +// are fully indexed is to poll the search endpoint. +export const waitForIndexedDocs = (engineName: string) => { + return new Promise(async function (resolve) { + let isReady = false; + while (!isReady) { + const response = await search(engineName); + if (response.results && response.results.length > 0) { + isReady = true; + resolve(); + } + } + }); +}; + +export const indexData = async (engineName: string, docs: object[]) => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/documents`, docs); +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_service.ts b/x-pack/test/functional_enterprise_search/services/app_search_service.ts new file mode 100644 index 0000000000000..c04988a26d5f9 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_service.ts @@ -0,0 +1,83 @@ +/* + * 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'; + +const ENTERPRISE_SEARCH_USER = 'enterprise_search'; +const ENTERPRISE_SEARCH_PASSWORD = 'changeme'; +import { + createEngine, + createMetaEngine, + indexData, + waitForIndexedDocs, + destroyEngine, + IEngine, +} from './app_search_client'; + +export interface IUser { + user: string; + password: string; +} +export { IEngine }; + +export class AppSearchService { + getEnterpriseSearchUser(): IUser { + return { + user: ENTERPRISE_SEARCH_USER, + password: ENTERPRISE_SEARCH_PASSWORD, + }; + } + + createEngine(): Promise { + const engineName = `test-engine-${new Date().getTime()}`; + return createEngine(engineName); + } + + async createEngineWithDocs(): Promise { + const engine = await this.createEngine(); + const docs = [ + { id: 1, name: 'doc1' }, + { id: 2, name: 'doc2' }, + { id: 3, name: 'doc2' }, + ]; + await indexData(engine.name, docs); + await waitForIndexedDocs(engine.name); + return engine; + } + + createMetaEngine(sourceEngines: string[]): Promise { + const engineName = `test-meta-engine-${new Date().getTime()}`; + return createMetaEngine(engineName, sourceEngines); + } + + destroyEngine(engineName: string) { + return destroyEngine(engineName); + } +} + +export async function AppSearchServiceProvider({ getService }: FtrProviderContext) { + const lifecycle = getService('lifecycle'); + const security = getService('security'); + + lifecycle.beforeTests.add(async () => { + const APP_SEARCH_API_KEY = process.env.APP_SEARCH_API_KEY; + + if (!APP_SEARCH_API_KEY) { + throw new Error('Please provide a valid APP_SEARCH_API_KEY. See README for more details.'); + } + + // The App Search plugin passes through the current user name and password + // through on the API call to App Search. Therefore, we need to be signed + // in as the enterprise_search user in order for this plugin to work. + await security.user.create(ENTERPRISE_SEARCH_USER, { + password: ENTERPRISE_SEARCH_PASSWORD, + roles: ['kibana_admin'], + full_name: ENTERPRISE_SEARCH_USER, + }); + }); + + return new AppSearchService(); +} diff --git a/x-pack/test/functional_enterprise_search/services/index.ts b/x-pack/test/functional_enterprise_search/services/index.ts new file mode 100644 index 0000000000000..1715c98677ac6 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { services as functionalServices } from '../../functional/services'; +import { AppSearchServiceProvider } from './app_search_service'; + +export const services = { + ...functionalServices, + appSearch: AppSearchServiceProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/with_host_configured.config.ts b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts new file mode 100644 index 0000000000000..f425f806f4bcd --- /dev/null +++ b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts @@ -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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/with_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests with Host Configured', + }, + + kbnTestServer: { + ...baseConfig.get('kbnTestServer'), + serverArgs: [ + ...baseConfig.get('kbnTestServer.serverArgs'), + '--enterpriseSearch.host=http://localhost:3002', + ], + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/without_host_configured.config.ts b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts new file mode 100644 index 0000000000000..0f2afd214abed --- /dev/null +++ b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts @@ -0,0 +1,23 @@ +/* + * 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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/without_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests without Host Configured', + }, + }; +} From 0cd9668128883f62125a5bc12c799691caac530d Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 2 Jun 2020 11:07:03 -0700 Subject: [PATCH 28/48] Add README and CODEOWNERS (#15) * Add plugin README and CODEOWNERS --- .github/CODEOWNERS | 5 +++++ x-pack/plugins/enterprise_search/README.md | 25 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 x-pack/plugins/enterprise_search/README.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4aab9943022d4..f053c6da9c29b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -201,6 +201,11 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Design **/*.scss @elastic/kibana-design +# Enterprise Search +/x-pack/plugins/enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend +/x-pack/test/functional_enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend +/x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design + # Elasticsearch UI /src/plugins/dev_tools/ @elastic/es-ui /src/plugins/console/ @elastic/es-ui diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md new file mode 100644 index 0000000000000..b62138df44166 --- /dev/null +++ b/x-pack/plugins/enterprise_search/README.md @@ -0,0 +1,25 @@ +# Enterprise Search + +## 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. + +## Development + +1. When developing locally, Enterprise Search should be running locally alongside Kibana on `localhost:3002`. +2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'` +3. For faster QA/development, run Enterprise Search on `elasticsearch-native` auth and log in as the `elastic` superuser on Kibana. + +## Testing + +### Unit tests + +From `kibana-root-folder/x-pack`, run: + +```bash +yarn test:jest plugins/enterprise_search +``` + +### E2E tests + +See [our functional test runner README](../../test/functional_enterprise_search). From e366a4a7ffbb56b98e190c17a15c5887bcbd8502 Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 4 Jun 2020 12:01:29 -0700 Subject: [PATCH 29/48] Fix Typescript errors (#16) * Fix public mocks * Fix empty states types * Fix engine table component errors * Fix engine overview component errors * Fix setup guide component errors - SetBreadcrumbs will be fixed in a separate commit * Fix App Search index errors * Fix engine overview header component errors * Fix applications context index errors * Fix kibana breadcrumb helper errors * Fix license helper errors * :exclamation: Refactor React Router EUI link/button helpers - in order to fix typescript errors - this changes the component logic significantly to a react render prop, so that the Link and Button components can have different types - however, end behavior should still remain the same * Fix telemetry helper errors * Minor unused var cleanup in plugin files * Fix telemetry collector/savedobjects errors * Fix MockRouter type errors and add IRouteDependencies export - routes will use IRouteDependencies in the next few commits * Fix engines route errors * Fix telemetry route errors * Remove any type from source code - thanks to Scotty for the inspiration * Add eslint rules for Enterprise Search plugin - Add checks for type any, but only on non-test files - Disable react-hooks/exhaustive-deps, since we're already disabling it in a few files and other plugins also have it turned off * Cover uncovered lines in engines_table and telemetry tests --- .eslintrc.js | 12 +++++ .../__mocks__/mount_with_context.mock.tsx | 4 +- .../__mocks__/shallow_usecontext.mock.ts | 2 +- .../__mocks__/shallow_with_i18n.mock.tsx | 11 ++-- .../components/empty_states/empty_state.tsx | 2 +- .../empty_states/empty_states.test.tsx | 5 +- .../components/empty_states/error_state.tsx | 2 +- .../components/empty_states/loading_state.tsx | 2 +- .../components/empty_states/no_user_state.tsx | 2 +- .../engine_overview/engine_overview.test.tsx | 23 ++++---- .../engine_overview/engine_overview.tsx | 23 ++++++-- .../engine_overview/engine_table.test.tsx | 4 +- .../engine_overview/engine_table.tsx | 52 ++++++++++--------- .../engine_overview_header.test.tsx | 8 +-- .../engine_overview_header.tsx | 12 ++++- .../components/setup_guide/setup_guide.tsx | 3 +- .../applications/app_search/index.test.tsx | 6 ++- .../public/applications/app_search/index.tsx | 2 +- .../public/applications/index.test.tsx | 4 +- .../public/applications/index.tsx | 13 +++-- .../generate_breadcrumbs.test.ts | 25 ++++----- .../generate_breadcrumbs.ts | 8 +-- .../set_breadcrumbs.test.tsx | 6 +-- .../kibana_breadcrumbs/set_breadcrumbs.tsx | 19 ++++--- .../shared/licensing/license_checks.test.ts | 18 +++---- .../shared/licensing/license_checks.ts | 6 +-- .../shared/licensing/license_context.test.tsx | 4 +- .../shared/licensing/license_context.tsx | 6 +-- .../react_router_helpers/eui_link.test.tsx | 16 +++--- .../shared/react_router_helpers/eui_link.tsx | 26 +++++++--- .../react_router_helpers/link_events.test.ts | 4 +- .../react_router_helpers/link_events.ts | 7 +-- .../shared/telemetry/send_telemetry.test.tsx | 13 ++--- .../shared/telemetry/send_telemetry.tsx | 4 +- .../enterprise_search/public/plugin.ts | 2 +- .../collectors/app_search/telemetry.test.ts | 7 +-- .../server/collectors/app_search/telemetry.ts | 15 +++--- .../enterprise_search/server/plugin.ts | 9 +++- .../server/routes/__mocks__/router.mock.ts | 39 ++++++++++---- .../server/routes/app_search/engines.test.ts | 8 +-- .../server/routes/app_search/engines.ts | 9 ++-- .../routes/app_search/telemetry.test.ts | 24 +++++++-- .../server/routes/app_search/telemetry.ts | 9 +++- .../saved_objects/app_search/telemetry.ts | 2 +- 44 files changed, 296 insertions(+), 182 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 8d979dc0f8645..4425ad3a12659 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -906,6 +906,18 @@ module.exports = { }, }, + /** + * Enterprise Search overrides + */ + { + files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], + excludedFiles: ['x-pack/plugins/enterprise_search/**/*.{test,mock}.{ts,tsx}'], + rules: { + 'react-hooks/exhaustive-deps': 'off', + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + /** * disable jsx-a11y for kbn-ui-framework */ 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 7d0716ce0cdd0..dfcda544459d4 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 @@ -22,7 +22,7 @@ import { mockLicenseContext } from './license_context.mock'; * * const wrapper = mountWithContext(, { enterpriseSearchUrl: 'someOverride', license: {} }); */ -export const mountWithContext = (children, context) => { +export const mountWithContext = (children: React.ReactNode, context?: object) => { return mount( @@ -40,7 +40,7 @@ export const mountWithContext = (children, context) => { * * Same usage/override functionality as mountWithContext */ -export const mountWithKibanaContext = (children, context) => { +export const mountWithKibanaContext = (children: React.ReactNode, context?: object) => { return mount( {children} 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 20add45e16b58..767a52a75d1fb 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 @@ -12,7 +12,7 @@ import { mockKibanaContext } from './kibana_context.mock'; import { mockLicenseContext } from './license_context.mock'; jest.mock('react', () => ({ - ...jest.requireActual('react'), + ...(jest.requireActual('react') as object), useContext: jest.fn(() => ({ ...mockKibanaContext, ...mockLicenseContext })), })); diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx index 7815bb71fa50e..ae7d0b09f9872 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx @@ -20,12 +20,11 @@ const { intl } = intlProvider.getChildContext(); * * const wrapper = shallowWithIntl(); */ -export const shallowWithIntl = (children) => { - return shallow({children}, { - context: { intl }, - childContextTypes: { intl }, - }) +export const shallowWithIntl = (children: React.ReactNode) => { + const context = { context: { intl } }; + + return shallow({children}, context) .childAt(0) - .dive() + .dive(context) .shallow(); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index 91b1a4319cbd7..26ed01cc614dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -16,7 +16,7 @@ import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; -export const EmptyState: React.FC<> = () => { +export const EmptyState: React.FC = () => { const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { 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 bb37229998ed2..2d2f92c2f7b1f 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 @@ -39,7 +39,8 @@ describe('NoUserState', () => { }); it('renders with username', () => { - getUserName.mockImplementationOnce(() => 'dolores-abernathy'); + (getUserName as jest.Mock).mockImplementationOnce(() => 'dolores-abernathy'); + const wrapper = shallowWithIntl(); const prompt = wrapper.find(EuiEmptyPrompt).dive(); const description1 = prompt.find(FormattedMessage).at(1).dive(); @@ -62,7 +63,7 @@ describe('EmptyState', () => { button.simulate('click'); expect(sendTelemetry).toHaveBeenCalled(); - sendTelemetry.mockClear(); + (sendTelemetry as jest.Mock).mockClear(); }); }); 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 039e645a27126..5891c89c3a022 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 @@ -16,7 +16,7 @@ import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; -export const ErrorState: ReactFC<> = () => { +export const ErrorState: React.FC = () => { const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx index 5c1d0c744f743..3d69fe6126273 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx @@ -12,7 +12,7 @@ import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; -export const LoadingState: React.FC<> = () => { +export const LoadingState: React.FC = () => { return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index c1d6c2bcffe41..bf728bd43ead0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -15,7 +15,7 @@ import { getUserName } from '../../utils/get_username'; import './empty_states.scss'; -export const NoUserState: React.FC<> = () => { +export const NoUserState: React.FC = () => { const username = getUserName(); return ( 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 a9670163e76b8..18cf3dade2056 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,7 +8,7 @@ import '../../../__mocks__/react_router_history.mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import { render } from 'enzyme'; +import { render, ReactWrapper } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { KibanaContext } from '../../../'; @@ -16,7 +16,7 @@ import { LicenseContext } from '../../../shared/licensing'; import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; import { EmptyState, ErrorState, NoUserState } from '../empty_states'; -import { EngineTable } from './engine_table'; +import { EngineTable, IEngineTablePagination } from './engine_table'; import { EngineOverview } from './'; @@ -25,7 +25,7 @@ describe('EngineOverview', () => { 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 = render( + const wrapper: Cheerio = render( @@ -85,7 +85,7 @@ describe('EngineOverview', () => { }, }; const mockApi = jest.fn(() => mockedApiResponse); - let wrapper; + let wrapper: ReactWrapper; beforeAll(async () => { wrapper = await mountWithApiMock({ get: mockApi }); @@ -105,7 +105,8 @@ describe('EngineOverview', () => { }); describe('pagination', () => { - const getTablePagination = () => wrapper.find(EngineTable).first().prop('pagination'); + const getTablePagination: () => IEngineTablePagination = () => + wrapper.find(EngineTable).first().prop('pagination'); it('passes down page data from the API', () => { const pagination = getTablePagination(); @@ -156,8 +157,8 @@ describe('EngineOverview', () => { * Test helpers */ - const mountWithApiMock = async ({ get, license }) => { - let wrapper; + 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. @@ -166,8 +167,12 @@ describe('EngineOverview', () => { await act(async () => { wrapper = mountWithContext(, { http: httpMock, license }); }); - wrapper.update(); // This seems to be required for the DOM to actually update + if (wrapper) { + wrapper.update(); // This seems to be required for the DOM to actually update - return wrapper; + return wrapper; + } else { + throw new Error('Could not mount wrapper'); + } }; }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index a1e4a11dc2daf..479dfe8e61513 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -30,7 +30,7 @@ import { EngineTable } from './engine_table'; import './engine_overview.scss'; -export const EngineOverview: ReactFC<> = () => { +export const EngineOverview: React.FC = () => { const { http } = useContext(KibanaContext) as IKibanaContext; const { license } = useContext(LicenseContext) as ILicenseContext; @@ -45,12 +45,12 @@ export const EngineOverview: ReactFC<> = () => { const [metaEnginesPage, setMetaEnginesPage] = useState(1); const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); - const getEnginesData = async ({ type, pageIndex }) => { + const getEnginesData = async ({ type, pageIndex }: IGetEnginesParams) => { return await http.get('/api/app_search/engines', { query: { type, pageIndex }, }); }; - const setEnginesData = async (params, callbacks) => { + const setEnginesData = async (params: IGetEnginesParams, callbacks: ISetEnginesCallbacks) => { try { const response = await getEnginesData(params); @@ -72,7 +72,7 @@ export const EngineOverview: ReactFC<> = () => { const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal }; setEnginesData(params, callbacks); - }, [enginesPage]); // eslint-disable-line react-hooks/exhaustive-deps + }, [enginesPage]); useEffect(() => { if (hasPlatinumLicense(license)) { @@ -81,7 +81,7 @@ export const EngineOverview: ReactFC<> = () => { setEnginesData(params, callbacks); } - }, [license, metaEnginesPage]); // eslint-disable-line react-hooks/exhaustive-deps + }, [license, metaEnginesPage]); if (hasErrorConnecting) return ; if (hasNoAccount) return ; @@ -150,3 +150,16 @@ export const EngineOverview: ReactFC<> = () => { ); }; + +/** + * Type definitions + */ + +interface IGetEnginesParams { + type: string; + pageIndex: number; +} +interface ISetEnginesCallbacks { + setResults: React.Dispatch>; + setResultsTotal: React.Dispatch>; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx index 1665726251bd6..46b6e61e352de 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -72,9 +72,9 @@ describe('EngineTable', () => { it('handles empty data', () => { const emptyWrapper = mountWithContext( - + {} }} /> ); - const emptyTable = wrapper.find(EuiBasicTable); + const emptyTable = emptyWrapper.find(EuiBasicTable); expect(emptyTable.prop('pagination').pageIndex).toEqual(0); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index d565856f9675d..1e58d820dc83b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -5,7 +5,7 @@ */ import React, { useContext } from 'react'; -import { EuiBasicTable, EuiLink } from '@elastic/eui'; +import { EuiBasicTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -14,31 +14,33 @@ import { KibanaContext, IKibanaContext } from '../../../index'; import { ENGINES_PAGE_SIZE } from '../../../../../common/constants'; -interface IEngineTableProps { - data: Array<{ - name: string; - created_at: string; - document_count: number; - field_count: number; - }>; - pagination: { - totalEngines: number; - pageIndex: number; - onPaginate(pageIndex: number); - }; +export interface IEngineTableData { + name: string; + created_at: string; + document_count: number; + field_count: number; +} +export interface IEngineTablePagination { + totalEngines: number; + pageIndex: number; + onPaginate(pageIndex: number): void; +} +export interface IEngineTableProps { + data: IEngineTableData[]; + pagination: IEngineTablePagination; } -interface IOnChange { +export interface IOnChange { page: { index: number; }; } -export const EngineTable: ReactFC = ({ +export const EngineTable: React.FC = ({ data, - pagination: { totalEngines, pageIndex = 0, onPaginate }, + pagination: { totalEngines, pageIndex, onPaginate }, }) => { const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; - const engineLinkProps = (name) => ({ + const engineLinkProps = (name: string) => ({ href: `${enterpriseSearchUrl}/as/engines/${name}`, target: '_blank', onClick: () => @@ -50,13 +52,13 @@ export const EngineTable: ReactFC = ({ }), }); - const columns = [ + const columns: Array> = [ { field: 'name', name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { defaultMessage: 'Name', }), - render: (name) => ( + render: (name: string) => ( {name} @@ -65,6 +67,8 @@ export const EngineTable: ReactFC = ({ truncateText: true, mobileOptions: { header: true, + // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error + // @ts-ignore enlarge: true, fullWidth: true, truncateText: false, @@ -79,7 +83,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'string', - render: (dateString) => ( + render: (dateString: string) => ( // e.g., January 1, 1970 ), @@ -93,7 +97,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'number', - render: (number) => , + render: (number: number) => , truncateText: true, }, { @@ -105,7 +109,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'number', - render: (number) => , + render: (number: number) => , truncateText: true, }, { @@ -117,7 +121,7 @@ export const EngineTable: ReactFC = ({ } ), dataType: 'string', - render: (name) => ( + render: (name: string) => ( = ({ totalItemCount: totalEngines, hidePerPageOptions: true, }} - onChange={({ page }): IOnChange => { + onChange={({ page }: IOnChange) => { const { index } = page; onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0 }} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx index 9663eb4ef61af..2e49540270ef0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -24,8 +24,8 @@ describe('EngineOverviewHeader', () => { const wrapper = shallow(); const button = wrapper.find('[data-test-subj="launchButton"]'); - expect(button.props().href).toBe('http://localhost:3002/as'); - expect(button.props().isDisabled).toBeFalsy(); + expect(button.prop('href')).toBe('http://localhost:3002/as'); + expect(button.prop('isDisabled')).toBeFalsy(); button.simulate('click'); expect(sendTelemetry).toHaveBeenCalled(); @@ -35,7 +35,7 @@ describe('EngineOverviewHeader', () => { const wrapper = shallow(); const button = wrapper.find('[data-test-subj="launchButton"]'); - expect(button.props().isDisabled).toBe(true); - expect(button.props().href).toBeUndefined(); + expect(button.prop('isDisabled')).toBe(true); + expect(button.prop('href')).toBeUndefined(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index 650a864f5e615..9aafa8ec0380c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -5,7 +5,14 @@ */ import React, { useContext } from 'react'; -import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiButton } from '@elastic/eui'; +import { + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiButton, + EuiButtonProps, + EuiLinkProps, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../shared/telemetry'; @@ -24,7 +31,8 @@ export const EngineOverviewHeader: React.FC = ({ fill: true, iconType: 'popout', 'data-test-subj': 'launchButton', - }; + } as EuiButtonProps & EuiLinkProps; + if (isButtonDisabled) { buttonProps.isDisabled = true; } else { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index 855449b0c0bcd..3e290a7777f1c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -15,7 +15,6 @@ import { EuiFlexItem, EuiTitle, EuiText, - EuiImage, EuiIcon, EuiSteps, EuiCode, @@ -32,7 +31,7 @@ import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemet import GettingStarted from '../../assets/getting_started.png'; import './setup_guide.scss'; -export const SetupGuide: React.FC<> = () => { +export const SetupGuide: React.FC = () => { return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index d11c47475089d..45e318ca0f9d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -18,7 +18,7 @@ import { AppSearch } from './'; describe('App Search Routes', () => { describe('/', () => { it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { - useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); const wrapper = shallow(); expect(wrapper.find(Redirect)).toHaveLength(1); @@ -26,7 +26,9 @@ describe('App Search Routes', () => { }); it('renders Engine Overview when enterpriseSearchUrl is set', () => { - useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'https://foo.bar' })); + (useContext as jest.Mock).mockImplementationOnce(() => ({ + enterpriseSearchUrl: 'https://foo.bar', + })); const wrapper = shallow(); expect(wrapper.find(EngineOverview)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 9afc3c9fd9761..8f7142f1631a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -12,7 +12,7 @@ import { KibanaContext, IKibanaContext } from '../index'; import { SetupGuide } from './components/setup_guide'; import { EngineOverview } from './components/engine_overview'; -export const AppSearch: React.FC<> = () => { +export const AppSearch: React.FC = () => { const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; return ( 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 fd88fc32ff4ae..ef69ba7e40cf3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -18,14 +18,14 @@ describe('renderApp', () => { const config = {}; const plugins = { licensing: licensingMock.createSetup(), - }; + } as any; beforeEach(() => { jest.clearAllMocks(); }); it('mounts and unmounts UI', () => { - const MockApp: React.FC = () =>
Hello world!
; + const MockApp = () =>
Hello world!
; const unmount = renderApp(MockApp, core, params, config, plugins); expect(params.element.querySelector('.hello-world')).not.toBeNull(); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index ae7079befb8c9..4ef7aca8260a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -9,18 +9,17 @@ import ReactDOM from 'react-dom'; import { Router } from 'react-router-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { CoreStart, AppMountParams, HttpHandler } from 'src/core/public'; +import { CoreStart, AppMountParameters, HttpSetup, ChromeBreadcrumb } from 'src/core/public'; import { ClientConfigType, PluginsSetup } from '../plugin'; -import { TSetBreadcrumbs } from './shared/kibana_breadcrumbs'; import { LicenseProvider } from './shared/licensing'; export interface IKibanaContext { enterpriseSearchUrl?: string; - http(): HttpHandler; - setBreadCrumbs(): TSetBreadcrumbs; + http: HttpSetup; + setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; } -export const KibanaContext = React.createContext(); +export const KibanaContext = React.createContext({}); /** * This file serves as a reusable wrapper to share Kibana-level context and other helpers @@ -29,9 +28,9 @@ export const KibanaContext = React.createContext(); */ export const renderApp = ( - App: React.Element, + App: React.FC, core: CoreStart, - params: AppMountParams, + params: AppMountParameters, config: ClientConfigType, plugins: PluginsSetup ) => { 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 b07aacf443abb..5a5cce6ec23b6 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 @@ -7,7 +7,8 @@ import { generateBreadcrumb } from './generate_breadcrumbs'; import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; -import { mockHistory } from '../../__mocks__'; +import { mockHistory as mockHistoryUntyped } from '../../__mocks__'; +const mockHistory = mockHistoryUntyped as any; jest.mock('../react_router_helpers', () => ({ letBrowserHandleEvent: jest.fn(() => false) })); import { letBrowserHandleEvent } from '../react_router_helpers'; @@ -31,7 +32,7 @@ describe('generateBreadcrumb', () => { }); it('prevents default navigation and uses React Router history on click', () => { - const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }); + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any; const event = { preventDefault: jest.fn() }; breadcrumb.onClick(event); @@ -40,9 +41,9 @@ describe('generateBreadcrumb', () => { }); it('does not prevents default browser behavior on new tab/window clicks', () => { - const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }); + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any; - letBrowserHandleEvent.mockImplementationOnce(() => true); + (letBrowserHandleEvent as jest.Mock).mockImplementationOnce(() => true); breadcrumb.onClick(); expect(mockHistory.push).not.toHaveBeenCalled(); @@ -103,19 +104,19 @@ describe('enterpriseSearchBreadcrumbs', () => { 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 page 1 second', () => { - subject()[1].onClick(eventMock); + (subject()[1] as any).onClick(eventMock); expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { - subject()[2].onClick(eventMock); + (subject()[2] as any).onClick(eventMock); expect(mockHistory.push).toHaveBeenCalledWith('/page2'); }); }); @@ -136,7 +137,7 @@ describe('appSearchBreadcrumbs', () => { beforeEach(() => { jest.clearAllMocks(); mockHistory.createHref.mockImplementation( - ({ pathname }) => `/enterprise_search/app_search${pathname}` + ({ pathname }: any) => `/enterprise_search/app_search${pathname}` ); }); @@ -181,24 +182,24 @@ describe('appSearchBreadcrumbs', () => { 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 App Search second', () => { - subject()[1].onClick(eventMock); + (subject()[1] as any).onClick(eventMock); expect(mockHistory.push).toHaveBeenCalledWith('/'); }); it('has a link to page 1 third', () => { - subject()[2].onClick(eventMock); + (subject()[2] as any).onClick(eventMock); expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { - subject()[3].onClick(eventMock); + (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 659a113dc31de..0e1bb796cbf2e 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 @@ -21,7 +21,7 @@ interface IGenerateBreadcrumbProps { } export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbProps) => { - const breadcrumb = { text }; + const breadcrumb = { text } as EuiBreadcrumb; if (path && history) { breadcrumb.href = history.createHref({ pathname: path }); @@ -39,13 +39,15 @@ export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbP * Product-specific breadcrumb helpers */ -type TBreadcrumbs = EuiBreadcrumb[] | []; +export type TBreadcrumbs = IGenerateBreadcrumbProps[]; export const enterpriseSearchBreadcrumbs = (history: History) => ( breadcrumbs: TBreadcrumbs = [] ) => [ generateBreadcrumb({ text: 'Enterprise Search' }), - ...breadcrumbs.map(({ text, path }) => generateBreadcrumb({ text, path, history })), + ...breadcrumbs.map(({ text, path }: IGenerateBreadcrumbProps) => + generateBreadcrumb({ text, path, history }) + ), ]; export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx index aeaa38a5ad38f..974ca54277c51 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -14,16 +14,16 @@ import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './'; describe('SetAppSearchBreadcrumbs', () => { const setBreadcrumbs = jest.fn(); - const builtBreadcrumbs = []; + const builtBreadcrumbs = [] as any; const appSearchBreadCrumbsInnerCall = jest.fn().mockReturnValue(builtBreadcrumbs); const appSearchBreadCrumbsOuterCall = jest.fn().mockReturnValue(appSearchBreadCrumbsInnerCall); - appSearchBreadcrumbs.mockImplementation(appSearchBreadCrumbsOuterCall); + (appSearchBreadcrumbs as jest.Mock).mockImplementation(appSearchBreadCrumbsOuterCall); afterEach(() => { jest.clearAllMocks(); }); - const mountSetAppSearchBreadcrumbs = (props) => { + const mountSetAppSearchBreadcrumbs = (props: any) => { return mountWithKibanaContext(, { http: {}, enterpriseSearchUrl: 'http://localhost:3002', 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 aaa54febcc20b..ad3cd65c09516 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,7 @@ import React, { useContext, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; import { KibanaContext, IKibanaContext } from '../../index'; -import { appSearchBreadcrumbs } from './generate_breadcrumbs'; +import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; /** * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view @@ -17,20 +17,27 @@ import { appSearchBreadcrumbs } from './generate_breadcrumbs'; export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; -interface ISetBreadcrumbsProps { +interface IBreadcrumbProps { text: string; - isRoot?: boolean; + isRoot?: never; +} +interface IRootBreadcrumbProps { + isRoot: true; + text?: never; } -export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { +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(appSearchBreadcrumbs(history)(crumb)); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + setBreadcrumbs(appSearchBreadcrumbs(history)(crumb as TBreadcrumbs | [])); + }, []); return null; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts index e21bf004b39a2..ad134e7d36b10 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts @@ -8,26 +8,26 @@ import { hasPlatinumLicense } from './license_checks'; describe('hasPlatinumLicense', () => { it('is true for platinum licenses', () => { - expect(hasPlatinumLicense({ isActive: true, type: 'platinum' })).toEqual(true); + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true); }); it('is true for enterprise licenses', () => { - expect(hasPlatinumLicense({ isActive: true, type: 'enterprise' })).toEqual(true); + expect(hasPlatinumLicense({ isActive: true, type: 'enterprise' } as any)).toEqual(true); }); it('is true for trial licenses', () => { - expect(hasPlatinumLicense({ isActive: true, type: 'platinum' })).toEqual(true); + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true); }); it('is false if the current license is expired', () => { - expect(hasPlatinumLicense({ isActive: false, type: 'platinum' })).toEqual(false); - expect(hasPlatinumLicense({ isActive: false, type: 'enterprise' })).toEqual(false); - expect(hasPlatinumLicense({ isActive: false, type: 'trial' })).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'platinum' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'enterprise' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'trial' } as any)).toEqual(false); }); it('is false for licenses below platinum', () => { - expect(hasPlatinumLicense({ isActive: true, type: 'basic' })).toEqual(false); - expect(hasPlatinumLicense({ isActive: false, type: 'standard' })).toEqual(false); - expect(hasPlatinumLicense({ isActive: true, type: 'gold' })).toEqual(false); + expect(hasPlatinumLicense({ isActive: true, type: 'basic' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'standard' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: true, type: 'gold' } as any)).toEqual(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts index 7d0de8a093b31..363ae39ab0da4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILicense } from '../../../../../../licensing/public'; +import { ILicense } from '../../../../../licensing/public'; -export const hasPlatinumLicense = (license: ILicenseContext) => { - return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type); +export const hasPlatinumLicense = (license: ILicense) => { + return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type as string); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx index 01d976bf49c19..c65474ec1f590 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx @@ -10,9 +10,9 @@ import { mountWithContext } from '../../__mocks__'; import { LicenseContext, ILicenseContext } from './'; describe('LicenseProvider', () => { - const MockComponent: React.FC<> = () => { + const MockComponent: React.FC = () => { const { license } = useContext(LicenseContext) as ILicenseContext; - return
{license.type}
; + return
{license?.type}
; }; it('renders children', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx index 03787031bc075..9b47959ff7544 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx @@ -8,17 +8,17 @@ import React from 'react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; -import { ILicense } from '../../../../licensing/public'; +import { ILicense } from '../../../../../licensing/public'; export interface ILicenseContext { - license?: ILicense; + license: ILicense; } interface ILicenseContextProps { license$: Observable; children: React.ReactNode; } -export const LicenseContext = React.createContext(); +export const LicenseContext = React.createContext({}); export const LicenseProvider: React.FC = ({ license$, children }) => { // Listen for changes to license subscription diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx index eb9b9f3e35e06..7d4c068b21155 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { EuiLink, EuiButton } from '@elastic/eui'; import '../../__mocks__/react_router_history.mock'; @@ -25,23 +25,21 @@ describe('EUI & React Router Component Helpers', () => { }); it('renders an EuiButton', () => { - const wrapper = shallow() - .find(EuiReactRouterLink) - .dive(); + const wrapper = shallow(); expect(wrapper.find(EuiButton)).toHaveLength(1); }); it('passes down all ...rest props', () => { - const wrapper = shallow(); + const wrapper = shallow(); const link = wrapper.find(EuiLink); - expect(link.prop('disabled')).toEqual(true); + expect(link.prop('external')).toEqual(true); expect(link.prop('data-test-subj')).toEqual('foo'); }); it('renders with the correct href and onClick props', () => { - const wrapper = shallow(); + const wrapper = mount(); const link = wrapper.find(EuiLink); expect(link.prop('onClick')).toBeInstanceOf(Function); @@ -51,7 +49,7 @@ describe('EUI & React Router Component Helpers', () => { describe('onClick', () => { it('prevents default navigation and uses React Router history', () => { - const wrapper = shallow(); + const wrapper = mount(); const simulatedEvent = { button: 0, @@ -65,7 +63,7 @@ describe('EUI & React Router Component Helpers', () => { }); it('does not prevent default browser behavior on new tab/window clicks', () => { - const wrapper = shallow(); + const wrapper = mount(); const simulatedEvent = { shiftKey: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx index 3c410584cc49d..f486e432bae76 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; -import { EuiLink, EuiButton } from '@elastic/eui'; +import { EuiLink, EuiButton, EuiButtonProps, EuiLinkAnchorProps } from '@elastic/eui'; import { letBrowserHandleEvent } from './link_events'; @@ -19,13 +19,12 @@ import { letBrowserHandleEvent } from './link_events'; interface IEuiReactRouterProps { to: string; - isButton?: boolean; } -export const EuiReactRouterLink: React.FC = ({ to, isButton, ...rest }) => { +export const EuiReactRouterHelper: React.FC = ({ to, children }) => { const history = useHistory(); - const onClick = (event) => { + const onClick = (event: React.MouseEvent) => { if (letBrowserHandleEvent(event)) return; // Prevent regular link behavior, which causes a browser refresh. @@ -38,10 +37,21 @@ export const EuiReactRouterLink: React.FC = ({ to, isButto // Generate the correct link href (with basename etc. accounted for) const href = history.createHref({ pathname: to }); - const props = { ...rest, href, onClick }; - return isButton ? : ; + const reactRouterProps = { href, onClick }; + return React.cloneElement(children as React.ReactElement, reactRouterProps); }; -export const EuiReactRouterButton: React.FC = (props) => ( - +type TEuiReactRouterLinkProps = EuiLinkAnchorProps & IEuiReactRouterProps; +type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps; + +export const EuiReactRouterLink: React.FC = ({ to, ...rest }) => ( + + + +); + +export const EuiReactRouterButton: React.FC = ({ to, ...rest }) => ( + + + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts index 0845e5562776b..3682946b63a13 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts @@ -17,7 +17,7 @@ describe('letBrowserHandleEvent', () => { target: { getAttribute: () => '_self', }, - }; + } as any; describe('the browser should handle the link when', () => { it('default is prevented', () => { @@ -95,7 +95,7 @@ describe('letBrowserHandleEvent', () => { }); }); -const targetValue = (value) => { +const targetValue = (value: string | null) => { return { getAttribute: () => value, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts index 67e987623c2c1..93da2ab71d952 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SyntheticEvent } from 'react'; +import { MouseEvent } from 'react'; /** * Helper functions for determining which events we should * let browsers handle natively, e.g. new tabs/windows */ -type THandleEvent = (event: SyntheticEvent) => boolean; +type THandleEvent = (event: MouseEvent) => boolean; export const letBrowserHandleEvent: THandleEvent = (event) => event.defaultPrevented || @@ -25,6 +25,7 @@ const isModifiedEvent: THandleEvent = (event) => const isLeftClickEvent: THandleEvent = (event) => event.button === 0; const isTargetBlank: THandleEvent = (event) => { - const target = event.target.getAttribute('target'); + const element = event.target as HTMLElement; + const target = element.getAttribute('target'); return !!target && target !== '_self'; }; 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 da8fd25b9194b..e08fe6c06b0f1 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 @@ -33,18 +33,19 @@ describe('Shared Telemetry Helpers', () => { }); it('throws an error if the telemetry endpoint fails', () => { - const httpRejectMock = { put: () => Promise.reject() }; + const httpRejectMock = sendTelemetry({ + http: { put: () => Promise.reject() }, + } as any); - expect(sendTelemetry({ http: httpRejectMock })).rejects.toThrow('Unable to send telemetry'); + expect(httpRejectMock).rejects.toThrow('Unable to send telemetry'); }); }); describe('React component helpers', () => { it('SendAppSearchTelemetry component', () => { - const wrapper = mountWithKibanaContext( - , - { http: httpMock } - ); + mountWithKibanaContext(, { + http: httpMock, + }); expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { headers: { 'content-type': 'application/json; charset=utf-8' }, 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 00c521303d269..0be26b2bf0459 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 @@ -6,7 +6,7 @@ import React, { useContext, useEffect } from 'react'; -import { HttpHandler } from 'src/core/public'; +import { HttpSetup } from 'src/core/public'; import { KibanaContext, IKibanaContext } from '../../index'; interface ISendTelemetryProps { @@ -15,7 +15,7 @@ interface ISendTelemetryProps { } interface ISendTelemetry extends ISendTelemetryProps { - http(): HttpHandler; + http: HttpSetup; product: 'app_search' | 'workplace_search' | 'enterprise_search'; } diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 5863df5ccba25..1ebfdd779a791 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -32,7 +32,7 @@ export interface PluginsSetup { export class EnterpriseSearchPlugin implements Plugin { private config: ClientConfigType; - constructor(private readonly initializerContext: PluginInitializerContext) { + constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); } 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 4be2c220024bc..9e82a7f8da9ee 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 @@ -17,7 +17,7 @@ describe('App Search Telemetry Usage Collector', () => { const usageCollectionMock = { makeUsageCollector: makeUsageCollectorStub, registerCollector: registerStub, - }; + } as any; const savedObjectsRepoStub = { get: () => ({ @@ -35,7 +35,7 @@ describe('App Search Telemetry Usage Collector', () => { }; const savedObjectsMock = { createInternalRepository: jest.fn(() => savedObjectsRepoStub), - }; + } as any; beforeEach(() => { jest.clearAllMocks(); @@ -48,6 +48,7 @@ describe('App Search Telemetry Usage Collector', () => { expect(registerStub).toHaveBeenCalledTimes(1); expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search'); + expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true); }); }); @@ -74,7 +75,7 @@ describe('App Search Telemetry Usage Collector', () => { }); it('should not error & should return a default telemetry object if no saved data exists', async () => { - const emptySavedObjectsMock = { createInternalRepository: () => ({}) }; + const emptySavedObjectsMock = { createInternalRepository: () => ({}) } as any; registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock); const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); 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 12b5a165bf1ac..2a396ead2f718 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,7 +5,11 @@ */ import { set } from 'lodash'; -import { ISavedObjectsRepository, SavedObjectsServiceStart } from 'src/core/server'; +import { + ISavedObjectsRepository, + SavedObjectsServiceStart, + SavedObjectAttributes, +} from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { AS_TELEMETRY_NAME, ITelemetrySavedObject } from '../../saved_objects/app_search/telemetry'; @@ -21,6 +25,7 @@ export const registerTelemetryUsageCollector = ( const telemetryUsageCollector = usageCollection.makeUsageCollector({ type: 'app_search', fetch: async () => fetchTelemetryMetrics(savedObjects), + isReady: () => true, }); usageCollection.registerCollector(telemetryUsageCollector); }; @@ -31,7 +36,9 @@ export const registerTelemetryUsageCollector = ( const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => { const savedObjectsRepository = savedObjects.createInternalRepository(); - const savedObjectAttributes = await getSavedObjectAttributesFromRepo(savedObjectsRepository); + const savedObjectAttributes = (await getSavedObjectAttributesFromRepo( + savedObjectsRepository + )) as SavedObjectAttributes; const defaultTelemetrySavedObject: ITelemetrySavedObject = { ui_viewed: { @@ -68,10 +75,6 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => * Helper function - fetches saved objects attributes */ -interface ISavedObjectAttributes { - [key: string]: any; -} - const getSavedObjectAttributesFromRepo = async ( savedObjectsRepository: ISavedObjectsRepository ) => { diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 077d900a8d8cf..a8430ad8f56af 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -12,6 +12,7 @@ import { CoreSetup, Logger, SavedObjectsServiceStart, + IRouter, } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -28,10 +29,16 @@ export interface ServerConfigType { host?: string; } +export interface IRouteDependencies { + router: IRouter; + config: ServerConfigType; + log: Logger; + getSavedObjectsService?(): SavedObjectsServiceStart; +} + export class EnterpriseSearchPlugin implements Plugin { private config: Observable; private logger: Logger; - private savedObjects?: SavedObjectsServiceStart; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create(); diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts index 1cec5da055140..332d1ad1062f2 100644 --- a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts @@ -5,7 +5,12 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, RequestHandlerContext, RouteValidatorConfig } from 'src/core/server'; +import { + IRouter, + KibanaRequest, + RequestHandlerContext, + RouteValidatorConfig, +} from 'src/core/server'; /** * Test helper that mocks Kibana's router and DRYs out various helper (callRoute, schema validation) @@ -14,13 +19,24 @@ import { IRouter, RequestHandlerContext, RouteValidatorConfig } from 'src/core/s type methodType = 'get' | 'post' | 'put' | 'patch' | 'delete'; type payloadType = 'params' | 'query' | 'body'; +interface IMockRouterProps { + method: methodType; + payload: payloadType; +} +interface IMockRouterRequest { + body?: object; + query?: object; + params?: object; +} +type TMockRouterRequest = KibanaRequest | IMockRouterRequest; + export class MockRouter { - public router: jest.Mocked; + public router!: jest.Mocked; public method: methodType; public payload: payloadType; public response = httpServerMock.createResponseFactory(); - private constructor({ method, payload }) { + constructor({ method, payload }: IMockRouterProps) { this.createRouter(); this.method = method; this.payload = payload; @@ -30,29 +46,32 @@ export class MockRouter { this.router = httpServiceMock.createRouter(); }; - public callRoute = async (request) => { - const [_, handler] = this.router[this.method].mock.calls[0]; + public callRoute = async (request: TMockRouterRequest) => { + const [, handler] = this.router[this.method].mock.calls[0]; const context = {} as jest.Mocked; - await handler(context, httpServerMock.createKibanaRequest(request), this.response); + await handler(context, httpServerMock.createKibanaRequest(request as any), this.response); }; /** * Schema validation helpers */ - public validateRoute = (request) => { + public validateRoute = (request: TMockRouterRequest) => { const [config] = this.router[this.method].mock.calls[0]; const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; - validate[this.payload].validate(request[this.payload]); + const payloadValidation = validate[this.payload] as { validate(request: KibanaRequest): void }; + const payloadRequest = request[this.payload] as KibanaRequest; + + payloadValidation.validate(payloadRequest); }; - public shouldValidate = (request) => { + public shouldValidate = (request: TMockRouterRequest) => { expect(() => this.validateRoute(request)).not.toThrow(); }; - public shouldThrow = (request) => { + public shouldThrow = (request: TMockRouterRequest) => { expect(() => this.validateRoute(request)).toThrow(); }; } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 722ad0d9269d3..c45514ae537fe 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { MockRouter } from '../__mocks__/router.mock'; import { registerEnginesRoute } from './engines'; @@ -28,7 +28,7 @@ describe('engine routes', () => { }; const mockRouter = new MockRouter({ method: 'get', payload: 'query' }); - const mockLogger = loggingServiceMock.create().get(); + const mockLogger = loggingSystemMock.create().get(); beforeEach(() => { jest.clearAllMocks(); @@ -172,7 +172,7 @@ describe('engine routes', () => { return Promise.resolve(new Response(JSON.stringify(response))); }); }, - andReturnInvalidData(response: object) { + andReturnInvalidData() { fetchMock.mockImplementation((url: string, params: object) => { expect(url).toEqual(expectedUrl); expect(params).toEqual(expectedParams); @@ -180,7 +180,7 @@ describe('engine routes', () => { return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); }); }, - andReturnError(response: object) { + andReturnError() { fetchMock.mockImplementation((url: string, params: object) => { expect(url).toEqual(expectedUrl); expect(params).toEqual(expectedParams); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index ebe2252b24eef..ffc7a0228454f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -8,9 +8,10 @@ import fetch from 'node-fetch'; import querystring from 'querystring'; import { schema } from '@kbn/config-schema'; +import { IRouteDependencies } from '../../plugin'; import { ENGINES_PAGE_SIZE } from '../../../common/constants'; -export function registerEnginesRoute({ router, config, log }) { +export function registerEnginesRoute({ router, config, log }: IRouteDependencies) { router.get( { path: '/api/app_search/engines', @@ -23,7 +24,7 @@ export function registerEnginesRoute({ router, config, log }) { }, async (context, request, response) => { try { - const appSearchUrl = config.host; + const appSearchUrl = config.host as string; const { type, pageIndex } = request.query; const params = querystring.stringify({ @@ -34,7 +35,7 @@ export function registerEnginesRoute({ router, config, log }) { const url = `${encodeURI(appSearchUrl)}/as/engines/collection?${params}`; const enginesResponse = await fetch(url, { - headers: { Authorization: request.headers.authorization }, + headers: { Authorization: request.headers.authorization as string }, }); if (enginesResponse.url.endsWith('/login')) { @@ -58,7 +59,7 @@ export function registerEnginesRoute({ router, config, log }) { } } catch (e) { log.error(`Cannot connect to App Search: ${e.toString()}`); - if (e instanceof Error) log.debug(e.stack); + 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/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts index 7644f3019de80..9e4ca2459ebd5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingServiceMock, savedObjectsServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; import { MockRouter } from '../__mocks__/router.mock'; import { registerTelemetryRoute } from './telemetry'; @@ -16,7 +16,7 @@ import { incrementUICounter } from '../../collectors/app_search/telemetry'; describe('App Search Telemetry API', () => { const mockRouter = new MockRouter({ method: 'put', payload: 'body' }); - const mockLogger = loggingServiceMock.create().get(); + const mockLogger = loggingSystemMock.create().get(); beforeEach(() => { jest.clearAllMocks(); @@ -26,13 +26,13 @@ describe('App Search Telemetry API', () => { router: mockRouter.router, getSavedObjectsService: () => savedObjectsServiceMock.create(), log: mockLogger, - }); + } as any); }); describe('PUT /api/app_search/telemetry', () => { it('increments the saved objects counter', async () => { const successResponse = { success: true }; - incrementUICounter.mockImplementation(jest.fn(() => successResponse)); + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } }); @@ -45,7 +45,7 @@ describe('App Search Telemetry API', () => { }); it('throws an error when incrementing fails', async () => { - incrementUICounter.mockImplementation(jest.fn(() => Promise.reject('Failed'))); + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed'))); await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } }); @@ -54,6 +54,20 @@ describe('App Search Telemetry API', () => { expect(mockRouter.response.internalError).toHaveBeenCalled(); }); + it('throws an error if the Saved Objects service is unavailable', async () => { + jest.clearAllMocks(); + registerTelemetryRoute({ + router: mockRouter.router, + getSavedObjectsService: null, + log: mockLogger, + } as any); + await mockRouter.callRoute({}); + + expect(incrementUICounter).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockRouter.response.internalError).toHaveBeenCalled(); + }); + describe('validates', () => { it('correctly', () => { const request = { body: { action: 'viewed', metric: 'setup_guide' } }; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts index 6b7657a384e9f..4cc9b64adc092 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts @@ -6,9 +6,14 @@ import { schema } from '@kbn/config-schema'; +import { IRouteDependencies } from '../../plugin'; import { incrementUICounter } from '../../collectors/app_search/telemetry'; -export function registerTelemetryRoute({ router, getSavedObjectsService, log }) { +export function registerTelemetryRoute({ + router, + getSavedObjectsService, + log, +}: IRouteDependencies) { router.put( { path: '/api/app_search/telemetry', @@ -27,6 +32,8 @@ export function registerTelemetryRoute({ router, getSavedObjectsService, log }) const { action, metric } = request.body; try { + if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service'); + return response.ok({ body: await incrementUICounter({ savedObjects: getSavedObjectsService(), diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts index 20c03b6aece8a..02bfe450ce7eb 100644 --- a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -27,7 +27,7 @@ export interface ITelemetrySavedObject { export const appSearchTelemetryType: SavedObjectsType = { name: AS_TELEMETRY_NAME, hidden: false, - namespaceAgnostic: true, + namespaceType: 'single', mappings: { properties: { ui_viewed: { From fbdcc6194ae05051d75cc8505738b5701f9eab0f Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 9 Jun 2020 15:09:10 -0400 Subject: [PATCH 30/48] Fixed TS warnings in E2E tests (#17) --- .../with_host_configured/app_search/engines.ts | 4 ++-- .../enterprise_search/with_host_configured/index.ts | 2 +- .../app_search/setup_guide.ts | 2 +- .../without_host_configured/index.ts | 2 +- .../ftr_provider_context.d.ts | 12 ++++++++++++ .../page_objects/app_search.ts | 9 +++++---- .../services/app_search_client.ts | 2 +- 7 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts index 38bdb429b8e09..e4ebd61c0692a 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts @@ -7,8 +7,8 @@ import expect from '@kbn/expect'; import { EsArchiver } from 'src/es_archiver'; import { AppSearchService, IEngine } from '../../../../services/app_search_service'; -import { Browser } from '../../../../../../../test/functional/services/browser'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { Browser } from '../../../../../../../test/functional/services/common'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function enterpriseSearchSetupEnginesTests({ getService, diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts index d239d538290fa..ac4984e0db019 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Enterprise Search', function () { 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 c328d0b202647..1d478c6baf29c 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 @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function enterpriseSearchSetupGuideTests({ getService, 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 8408af99b117f..b366879bde0d9 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Enterprise Search', function () { diff --git a/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..bb257cdcbfe1b --- /dev/null +++ b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.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. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts index a8b40b7774f78..2c38542d904f3 100644 --- a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts +++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts @@ -5,23 +5,24 @@ */ import { FtrProviderContext } from '../ftr_provider_context'; -import { TestSubjects } from '../../../../../test/functional/services/test_subjects'; +import { TestSubjects } from '../../../../test/functional/services/common'; +import { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; export function AppSearchPageProvider({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common']); const testSubjects = getService('testSubjects') as TestSubjects; return { - async navigateToPage() { + async navigateToPage(): Promise { return await PageObjects.common.navigateToApp('app_search'); }, - async getEngineLinks() { + async getEngineLinks(): Promise { const engines = await testSubjects.find('appSearchEngines'); return await testSubjects.findAllDescendant('engineNameLink', engines); }, - async getMetaEngineLinks() { + async getMetaEngineLinks(): Promise { const metaEngines = await testSubjects.find('appSearchMetaEngines'); return await testSubjects.findAllDescendant('engineNameLink', metaEngines); }, diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts index 11c383eb779d6..fbd15b83f97ea 100644 --- a/x-pack/test/functional_enterprise_search/services/app_search_client.ts +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts @@ -50,7 +50,7 @@ const makeRequest = (method: string, path: string, body?: object): Promise reject(e); } - if (res.statusCode > 299) { + if (res.statusCode && res.statusCode > 299) { reject('Error calling App Search API: ' + JSON.stringify(responseBody)); } From 931ed8d6b321a4757ae470c99cd1cc731b6c0891 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 16 Jun 2020 13:46:00 -0700 Subject: [PATCH 31/48] Feedback: Convert static CSS values to EUI variables where possible --- .../components/empty_states/empty_states.scss | 2 +- .../engine_overview/engine_overview.scss | 10 ++++---- .../components/setup_guide/setup_guide.scss | 24 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss index 0c1170b8ac99f..9b785c9b96abe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss @@ -16,6 +16,6 @@ } .euiEmptyPrompt > .euiIcon { - margin-bottom: 8px; + margin-bottom: $euiSizeS; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss index a304139532d57..1f4b711f56ef2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss @@ -11,17 +11,17 @@ width: 100%; .euiPanel { - padding: 35px; + padding: $euiSizeXL; } .euiPageContent .euiPageContentHeader { - margin-bottom: 10px; + margin-bottom: $euiSizeM; } .engine-icon { display: inline-block; - width: 15px; - height: 15px; - margin-right: 5px; + width: $euiSize; + height: $euiSize; + margin-right: $euiSizeXS; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.scss index 77a62961128e1..f055b15d6644d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.scss @@ -16,31 +16,31 @@ .euiPageSideBar { flex-basis: 300px; flex-shrink: 0; - padding: 24px; + padding: $euiSizeL; margin-right: 0; - background-color: #f5f7fa; // $euiColorLightestShade - border-color: #d3dae6; // $euiColorLightShade + background-color: $euiColorLightestShade; + border-color: $euiBorderColor; border-style: solid; - border-width: 0 0 1px 0; // bottom - mobile view + border-width: 0 0 $euiBorderWidthThin 0; // bottom - mobile view - @media (min-width: 766px) { - border-width: 0 1px 0 0; // right - desktop view + @include euiBreakpoint('m', 'l', 'xl') { + border-width: 0 $euiBorderWidthThin 0 0; // right - desktop view } - @media (min-width: 1000px) { + @include euiBreakpoint('m', 'l') { flex-basis: 400px; } - @media (min-width: 1200px) { + @include euiBreakpoint('xl') { flex-basis: 500px; } } .euiPageBody { align-self: start; - padding: 24px; + padding: $euiSizeL; - @media (min-width: 1000px) { - padding: 40px 50px; + @include euiBreakpoint('l') { + padding: $euiSizeXXL ($euiSizeXXL * 1.25); } } @@ -48,6 +48,6 @@ display: block; max-width: 100%; height: auto; - margin: 24px auto; + margin: $euiSizeL auto; } } From 520408bb5becb1b4ada681874d479500871f4a18 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 16 Jun 2020 14:40:17 -0700 Subject: [PATCH 32/48] Feedback: Flatten nested CSS where possible - Prefer setting CSS class overrides on individual EUI components, not on a top-level page + Change CSS class casing from kebab-case to camelCase to better match EUI/Kibana + Remove unnecessary .euiPageContentHeader margin-bottom override by changing the panelPaddingSize of euiPageContent + Decrease engine overview table padding on mobile --- .../components/empty_states/empty_state.tsx | 5 ++-- .../components/empty_states/empty_states.scss | 14 +++++------ .../components/empty_states/error_state.tsx | 5 ++-- .../components/empty_states/loading_state.tsx | 4 ++-- .../components/empty_states/no_user_state.tsx | 5 ++-- .../engine_overview/engine_overview.scss | 24 +++++++++---------- .../engine_overview/engine_overview.tsx | 8 +++---- .../components/setup_guide/setup_guide.scss | 12 ++++------ .../components/setup_guide/setup_guide.tsx | 8 +++---- .../public/applications/index.test.tsx | 2 +- 10 files changed, 43 insertions(+), 44 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index 26ed01cc614dc..cdb61d565ef9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -32,13 +32,14 @@ export const EmptyState: React.FC = () => { }; return ( - + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss index 9b785c9b96abe..32d1b198c5ced 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss @@ -7,15 +7,13 @@ /** * Empty/Error UI states */ -.empty-state { - .euiPageContent { - min-height: 450px; - display: flex; - flex-direction: column; - justify-content: center; - } +.emptyState { + min-height: 450px; + display: flex; + flex-direction: column; + justify-content: center; - .euiEmptyPrompt > .euiIcon { + &__prompt > .euiIcon { margin-bottom: $euiSizeS; } } 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 5891c89c3a022..15822c5953a91 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 @@ -20,14 +20,15 @@ export const ErrorState: React.FC = () => { const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; return ( - + - + { return ( - + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index bf728bd43ead0..8ee038f33971a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -19,14 +19,15 @@ export const NoUserState: React.FC = () => { const username = getUserName(); return ( - + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss index 1f4b711f56ef2..2c7f7de6458e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss @@ -7,21 +7,21 @@ /** * Engine Overview */ -.engine-overview { +.engineOverview { width: 100%; - .euiPanel { - padding: $euiSizeXL; - } + &__body { + padding: $euiSize; - .euiPageContent .euiPageContentHeader { - margin-bottom: $euiSizeM; + @include euiBreakpoint('m', 'l', 'xl') { + padding: $euiSizeXL; + } } +} - .engine-icon { - display: inline-block; - width: $euiSize; - height: $euiSize; - margin-right: $euiSizeXS; - } +.engineIcon { + display: inline-block; + width: $euiSize; + height: $euiSize; + margin-right: $euiSizeXS; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 479dfe8e61513..7f7c271d2e68b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -89,18 +89,18 @@ export const EngineOverview: React.FC = () => { if (!engines.length) return ; return ( - + - +

- + {

- + { return ( - + - + { rel="noopener noreferrer" > {i18n.translate('xpack.enterpriseSearch.setupGuide.videoAlt', { - + { it('renders AppSearch', () => { renderApp(AppSearch, core, params, config, plugins); - expect(params.element.querySelector('.setup-guide')).not.toBeNull(); + expect(params.element.querySelector('.setupGuide')).not.toBeNull(); }); }); From bef0ef599abb915059597ead7c0bc026c9d6adb3 Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 17 Jun 2020 09:54:52 -0700 Subject: [PATCH 33/48] Refactor out components shared with Workplace Search (#18) * Move getUserName helper to shared - in preparation for Workplace Search plugin also using this helper * Move Setup Guide layout to a shared component * Setup Guide: add extra props for standard/native auth links Note: It's possible this commit may be unnecessary if we can publish shared Enterprise Search security mode docs --- .../empty_states/empty_states.test.tsx | 4 +- .../components/empty_states/no_user_state.tsx | 2 +- .../setup_guide/setup_guide.test.tsx | 7 +- .../components/setup_guide/setup_guide.tsx | 279 +++--------------- .../get_username}/get_username.test.ts | 0 .../get_username}/get_username.ts | 0 .../applications/shared/get_username/index.ts | 7 + .../applications/shared/setup_guide/index.ts | 7 + .../setup_guide/setup_guide.scss | 0 .../shared/setup_guide/setup_guide.test.tsx | 44 +++ .../shared/setup_guide/setup_guide.tsx | 226 ++++++++++++++ 11 files changed, 339 insertions(+), 237 deletions(-) rename x-pack/plugins/enterprise_search/public/applications/{app_search/utils => shared/get_username}/get_username.test.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/{app_search/utils => shared/get_username}/get_username.ts (100%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts rename x-pack/plugins/enterprise_search/public/applications/{app_search/components => shared}/setup_guide/setup_guide.scss (100%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx 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 2d2f92c2f7b1f..b76cc73a996b4 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 @@ -12,8 +12,8 @@ import { EuiEmptyPrompt, EuiButton, EuiCode, EuiLoadingContent } from '@elastic/ import { FormattedMessage } from '@kbn/i18n/react'; import { shallowWithIntl } from '../../../__mocks__'; -jest.mock('../../utils/get_username', () => ({ getUserName: jest.fn() })); -import { getUserName } from '../../utils/get_username'; +jest.mock('../../../shared/get_username', () => ({ getUserName: jest.fn() })); +import { getUserName } from '../../../shared/get_username'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn(), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index 8ee038f33971a..b86b3caceefca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -10,8 +10,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { getUserName } from '../../../shared/get_username'; import { EngineOverviewHeader } from '../engine_overview_header'; -import { getUserName } from '../../utils/get_username'; import './empty_states.scss'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx index 4a07e950041e7..82cc344d49632 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -6,15 +6,16 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageSideBar, EuiSteps } from '@elastic/eui'; +import { SetAppSearchBreadcrumbs 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(EuiSteps)).toHaveLength(1); - expect(wrapper.find(EuiPageSideBar)).toHaveLength(1); + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index be39cd6908ee1..dba445e2961bc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -5,243 +5,60 @@ */ import React from 'react'; -import { - EuiPage, - EuiPageSideBar, - EuiPageBody, - EuiPageContent, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiText, - EuiIcon, - EuiSteps, - EuiCode, - EuiCodeBlock, - EuiAccordion, - EuiLink, -} from '@elastic/eui'; +import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; - import GettingStarted from '../../assets/getting_started.png'; -import './setup_guide.scss'; - -export const SetupGuide: React.FC = () => { - return ( - - - - - - - - - - - - - - - - - - -

- -

-
-
-
- - {i18n.translate('xpack.enterpriseSearch.setupGuide.videoAlt', - +export const SetupGuide: React.FC = () => ( + + + - -

- -

-
- - -

- -

-
-
+ + {i18n.translate('xpack.enterpriseSearch.appSearch.setupGuide.videoAlt', + - - - -

- config/kibana.yml, - configSetting: enterpriseSearch.host, - }} - /> -

- - enterpriseSearch.host: 'http://localhost:3002' - - - ), - }, - { - title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { - defaultMessage: 'Reload your Kibana instance', - }), - children: ( - -

- -

-

- - Elasticsearch Native Auth - - ), - }} - /> -

-
- ), - }, - { - title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { - defaultMessage: 'Troubleshooting issues', - }), - children: ( - <> - - -

- -

-
-
- - - -

- -

-
-
- - - -

- - Standard Auth - - ), - }} - /> -

-
-
- - ), - }, - ]} - /> -
-
-
- ); -}; + +

+ +

+
+ + +

+ +

+
+ +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts new file mode 100644 index 0000000000000..efc58065784fb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/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 { getUserName } from './get_username'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts new file mode 100644 index 0000000000000..c367424d375f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/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/app_search/components/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.scss rename to x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..0423ae61779af --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { EuiSteps, EuiIcon, EuiLink } from '@elastic/eui'; + +import { mountWithContext } from '../../__mocks__'; + +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow( + +

Wow!

+
+ ); + + expect(wrapper.find('h1').text()).toEqual('Enterprise Search'); + expect(wrapper.find(EuiIcon).prop('type')).toEqual('logoEnterpriseSearch'); + expect(wrapper.find('[data-test-subj="test"]').text()).toEqual('Wow!'); + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('renders with optional auth links', () => { + const wrapper = mountWithContext( + + Baz + + ); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com'); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx new file mode 100644 index 0000000000000..4ebffea10aa12 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx @@ -0,0 +1,226 @@ +/* + * 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, + EuiPageSideBar, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiIcon, + EuiSteps, + EuiCode, + EuiCodeBlock, + EuiAccordion, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import './setup_guide.scss'; + +/** + * Shared Setup Guide component. Sidebar content and product name/links are + * customizable, but the basic layout and instruction steps are DRYed out + */ + +interface ISetupGuideProps { + children: React.ReactNode; + productName: string; + productEuiIcon: 'logoAppSearch' | 'logoWorkplaceSearch' | 'logoEnterpriseSearch'; + standardAuthLink?: string; + elasticsearchNativeAuthLink?: string; +} + +export const SetupGuide: React.FC = ({ + children, + productName, + productEuiIcon, + standardAuthLink, + elasticsearchNativeAuthLink, +}) => ( + + + + + + + + + + + + + + + +

{productName}

+
+
+
+ + {children} +
+ + + + +

+ config/kibana.yml, + configSetting: enterpriseSearch.host, + }} + /> +

+ + enterpriseSearch.host: 'http://localhost:3002' + + + ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { + defaultMessage: 'Reload your Kibana instance', + }), + children: ( + +

+ +

+

+ + Elasticsearch Native Auth + + ) : ( + 'Elasticsearch Native Auth' + ), + }} + /> +

+
+ ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { + defaultMessage: 'Troubleshooting issues', + }), + children: ( + <> + + +

+ +

+
+
+ + + +

+ +

+
+
+ + + +

+ + Standard Auth + + ) : ( + 'Standard Auth' + ), + }} + /> +

+
+
+ + ), + }, + ]} + /> +
+
+
+); From 176313e2b88d3a4bdd75d807f704c8026a6048ae Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 24 Jun 2020 08:50:15 -0700 Subject: [PATCH 34/48] Update copy per feedback from copy team --- .../components/empty_states/empty_state.tsx | 11 ++--- .../components/empty_states/error_state.tsx | 41 +++++++++++++------ .../components/setup_guide/setup_guide.tsx | 4 +- .../shared/setup_guide/setup_guide.tsx | 10 ++--- 4 files changed, 39 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index cdb61d565ef9f..9bb5cd3bffdf5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -45,7 +45,7 @@ export const EmptyState: React.FC = () => {

} @@ -54,12 +54,7 @@ export const EmptyState: React.FC = () => {

-
-

} @@ -67,7 +62,7 @@ export const EmptyState: React.FC = () => { } 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 15822c5953a91..d8eeff2aba1c6 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 @@ -35,7 +35,7 @@ export const ErrorState: React.FC = () => {

} @@ -45,28 +45,45 @@ export const ErrorState: React.FC = () => {

{enterpriseSearchUrl}, }} />

-

- config/kibana.yml, - }} - /> -

+
    +
  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/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index dba445e2961bc..df278bf938a69 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -47,7 +47,7 @@ export const SetupGuide: React.FC = () => (

@@ -56,7 +56,7 @@ export const SetupGuide: React.FC = () => (

diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx index 4ebffea10aa12..31ff0089dbd7c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx @@ -88,7 +88,7 @@ export const SetupGuide: React.FC = ({

config/kibana.yml, @@ -117,7 +117,7 @@ export const SetupGuide: React.FC = ({

= ({

@@ -189,7 +189,7 @@ export const SetupGuide: React.FC = ({ buttonContent={i18n.translate( 'xpack.enterpriseSearch.troubleshooting.standardAuth.title', { - defaultMessage: '{productName} on Standard authentication', + defaultMessage: '{productName} on Standard authentication is not supported', values: { productName }, } )} @@ -200,7 +200,7 @@ export const SetupGuide: React.FC = ({

Date: Thu, 2 Jul 2020 08:54:33 -0700 Subject: [PATCH 35/48] Address various telemetry issues - saved objects: removing indexing per #43673 - add schema and generate json per #64942 - move definitions over to collectors since saved objects is mostly empty at this point, and schema throws an error when it imports an obj instead of being defined inline - istanbul ignore saved_objects file since it doesn't have anything meaningful to test but was affecting code coverage --- .../server/collectors/app_search/telemetry.ts | 39 +++++++++-- .../saved_objects/app_search/telemetry.ts | 64 ++----------------- .../schema/xpack_plugins.json | 37 +++++++++++ 3 files changed, 76 insertions(+), 64 deletions(-) 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 2a396ead2f718..f9376f65f79a7 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 @@ -12,7 +12,23 @@ import { } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { AS_TELEMETRY_NAME, ITelemetrySavedObject } from '../../saved_objects/app_search/telemetry'; +interface ITelemetry { + ui_viewed: { + setup_guide: number; + engines_overview: number; + }; + ui_error: { + cannot_connect: number; + no_as_account: number; + }; + ui_clicked: { + create_first_engine_button: number; + header_launch_button: number; + engine_table_link: number; + }; +} + +export const AS_TELEMETRY_NAME = 'app_search_telemetry'; /** * Register the telemetry collector @@ -22,10 +38,25 @@ export const registerTelemetryUsageCollector = ( usageCollection: UsageCollectionSetup, savedObjects: SavedObjectsServiceStart ) => { - const telemetryUsageCollector = usageCollection.makeUsageCollector({ + const telemetryUsageCollector = usageCollection.makeUsageCollector({ type: 'app_search', fetch: async () => fetchTelemetryMetrics(savedObjects), isReady: () => true, + schema: { + ui_viewed: { + setup_guide: { type: 'long' }, + engines_overview: { type: 'long' }, + }, + ui_error: { + cannot_connect: { type: 'long' }, + no_as_account: { type: 'long' }, + }, + ui_clicked: { + create_first_engine_button: { type: 'long' }, + header_launch_button: { type: 'long' }, + engine_table_link: { type: 'long' }, + }, + }, }); usageCollection.registerCollector(telemetryUsageCollector); }; @@ -40,7 +71,7 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => savedObjectsRepository )) as SavedObjectAttributes; - const defaultTelemetrySavedObject: ITelemetrySavedObject = { + const defaultTelemetrySavedObject: ITelemetry = { ui_viewed: { setup_guide: 0, engines_overview: 0, @@ -68,7 +99,7 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => set(telemetryObj, key, savedObjectAttributes[key]); }); - return telemetryObj as ITelemetrySavedObject; + return telemetryObj as ITelemetry; }; /** diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts index 02bfe450ce7eb..e581b4a69790f 100644 --- a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -3,73 +3,17 @@ * 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'; - -export const AS_TELEMETRY_NAME = 'app_search_telemetry'; - -export interface ITelemetrySavedObject { - ui_viewed: { - setup_guide: number; - engines_overview: number; - }; - ui_error: { - cannot_connect: number; - no_as_account: number; - }; - ui_clicked: { - create_first_engine_button: number; - header_launch_button: number; - engine_table_link: number; - }; -} +import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; export const appSearchTelemetryType: SavedObjectsType = { name: AS_TELEMETRY_NAME, hidden: false, namespaceType: 'single', mappings: { - properties: { - ui_viewed: { - properties: { - setup_guide: { - type: 'long', - null_value: 0, - }, - engines_overview: { - type: 'long', - null_value: 0, - }, - }, - }, - ui_error: { - properties: { - cannot_connect: { - type: 'long', - null_value: 0, - }, - no_as_account: { - type: 'long', - null_value: 0, - }, - }, - }, - ui_clicked: { - properties: { - create_first_engine_button: { - type: 'long', - null_value: 0, - }, - header_launch_button: { - type: 'long', - null_value: 0, - }, - engine_table_link: { - type: 'long', - null_value: 0, - }, - }, - }, - }, + 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 13d7c62316040..7b5bd3fd578d5 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -7,6 +7,43 @@ } } }, + "app_search": { + "properties": { + "ui_viewed": { + "properties": { + "setup_guide": { + "type": "long" + }, + "engines_overview": { + "type": "long" + } + } + }, + "ui_error": { + "properties": { + "cannot_connect": { + "type": "long" + }, + "no_as_account": { + "type": "long" + } + } + }, + "ui_clicked": { + "properties": { + "create_first_engine_button": { + "type": "long" + }, + "header_launch_button": { + "type": "long" + }, + "engine_table_link": { + "type": "long" + } + } + } + } + }, "fileUploadTelemetry": { "properties": { "filesUploadedTotalCount": { From 2a14b5d80b24e0fae05da5fbda8393fd261ac61b Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 6 Jul 2020 10:37:22 -0700 Subject: [PATCH 36/48] Disable plugin access if a normal user does not have access to App Search (#19) * Set up new server security dependency and configs * Set up access capabilities * Set up checkAccess helper/caller * Remove NoUserState component from the public UI - Since this is now being handled by checkAccess / normal users should never see the plugin at all if they don't have an account/access, the component is no longer needed * Update server routes to account for new changes - Remove login redirect catch from routes, since the access helper should now handle that for most users by disabling the plugin (superusers will see a generic cannot connect/error screen) - Refactor out new config values to a shared mock * Refactor Enterprise Search http call to hit/return new internal API endpoint + pull out the http call to a separate library for upcoming public URL work (so that other files can call it directly as well) * [Discussion] Increase timeout but add another warning timeout for slow servers - per recommendation/convo with Brandon * Register feature control * Remove no_as_account from UI telemetry - since we're no longer tracking that in the UI * Address PR feedback - isSuperUser check --- x-pack/plugins/enterprise_search/kibana.json | 4 +- .../empty_states/empty_states.test.tsx | 27 +--- .../components/empty_states/index.ts | 1 - .../components/empty_states/no_user_state.tsx | 65 --------- .../engine_overview/engine_overview.test.tsx | 9 +- .../engine_overview/engine_overview.tsx | 10 +- .../shared/get_username/get_username.test.ts | 29 ---- .../shared/get_username/get_username.ts | 20 --- .../collectors/app_search/telemetry.test.ts | 3 - .../server/collectors/app_search/telemetry.ts | 3 - .../plugins/enterprise_search/server/index.ts | 3 + .../server/lib/check_access.test.ts | 128 ++++++++++++++++++ .../server/lib/check_access.ts | 76 +++++++++++ .../lib/enterprise_search_config_api.test.ts | 111 +++++++++++++++ .../lib/enterprise_search_config_api.ts | 78 +++++++++++ .../enterprise_search/server/plugin.ts | 66 ++++++++- .../server/routes/__mocks__/config.mock.ts | 12 ++ .../routes/__mocks__}/index.ts | 3 +- .../server/routes/app_search/engines.test.ts | 36 +---- .../server/routes/app_search/engines.ts | 6 - .../privileges/privileges.test.ts | 14 +- .../authorization/privileges/privileges.ts | 1 + .../schema/xpack_plugins.json | 3 - .../apis/features/features/features.ts | 1 + 24 files changed, 497 insertions(+), 212 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts rename x-pack/plugins/enterprise_search/{public/applications/shared/get_username => server/routes/__mocks__}/index.ts (73%) diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index 3121d6bd470b0..bfd36b1736d68 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -2,9 +2,9 @@ "id": "enterpriseSearch", "version": "1.0.0", "kibanaVersion": "kibana", - "requiredPlugins": ["home", "licensing"], + "requiredPlugins": ["home", "features", "licensing"], "configPath": ["enterpriseSearch"], - "optionalPlugins": ["usageCollection"], + "optionalPlugins": ["usageCollection", "security"], "server": true, "ui": true } 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 b76cc73a996b4..12bf003564103 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 @@ -8,12 +8,7 @@ import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiButton, EuiCode, EuiLoadingContent } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { shallowWithIntl } from '../../../__mocks__'; - -jest.mock('../../../shared/get_username', () => ({ getUserName: jest.fn() })); -import { getUserName } from '../../../shared/get_username'; +import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn(), @@ -21,7 +16,7 @@ jest.mock('../../../shared/telemetry', () => ({ })); import { sendTelemetry } from '../../../shared/telemetry'; -import { ErrorState, NoUserState, EmptyState, LoadingState } from './'; +import { ErrorState, EmptyState, LoadingState } from './'; describe('ErrorState', () => { it('renders', () => { @@ -31,24 +26,6 @@ describe('ErrorState', () => { }); }); -describe('NoUserState', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); - }); - - it('renders with username', () => { - (getUserName as jest.Mock).mockImplementationOnce(() => 'dolores-abernathy'); - - const wrapper = shallowWithIntl(); - const prompt = wrapper.find(EuiEmptyPrompt).dive(); - const description1 = prompt.find(FormattedMessage).at(1).dive(); - - expect(description1.find(EuiCode).prop('children')).toContain('dolores-abernathy'); - }); -}); - describe('EmptyState', () => { it('renders', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts index d1b65a4729a87..e92bf214c4cc7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts @@ -6,5 +6,4 @@ export { LoadingState } from './loading_state'; export { EmptyState } from './empty_state'; -export { NoUserState } from './no_user_state'; export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx deleted file mode 100644 index b86b3caceefca..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; -import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { getUserName } from '../../../shared/get_username'; -import { EngineOverviewHeader } from '../engine_overview_header'; - -import './empty_states.scss'; - -export const NoUserState: React.FC = () => { - const username = getUserName(); - - return ( - - - - - - - - - -

- } - titleSize="l" - body={ - <> -

- {username} : '', - }} - /> -

-

- -

- - } - /> - - - - ); -}; 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 18cf3dade2056..4d2a2ea1df9aa 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 @@ -15,7 +15,7 @@ import { KibanaContext } from '../../../'; import { LicenseContext } from '../../../shared/licensing'; import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; -import { EmptyState, ErrorState, NoUserState } from '../empty_states'; +import { EmptyState, ErrorState } from '../empty_states'; import { EngineTable, IEngineTablePagination } from './engine_table'; import { EngineOverview } from './'; @@ -56,13 +56,6 @@ describe('EngineOverview', () => { }); expect(wrapper.find(ErrorState)).toHaveLength(1); }); - - it('hasNoAccount', async () => { - const wrapper = await mountWithApiMock({ - get: () => Promise.reject({ body: { message: 'no-as-account' } }), - }); - expect(wrapper.find(NoUserState)).toHaveLength(1); - }); }); describe('happy-path states', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 7f7c271d2e68b..c4cebf30ab45e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -24,7 +24,7 @@ import { KibanaContext, IKibanaContext } from '../../../index'; import EnginesIcon from '../../assets/engine.svg'; import MetaEnginesIcon from '../../assets/meta_engine.svg'; -import { LoadingState, EmptyState, NoUserState, ErrorState } from '../empty_states'; +import { LoadingState, EmptyState, ErrorState } from '../empty_states'; import { EngineOverviewHeader } from '../engine_overview_header'; import { EngineTable } from './engine_table'; @@ -35,7 +35,6 @@ export const EngineOverview: React.FC = () => { const { license } = useContext(LicenseContext) as ILicenseContext; const [isLoading, setIsLoading] = useState(true); - const [hasNoAccount, setHasNoAccount] = useState(false); const [hasErrorConnecting, setHasErrorConnecting] = useState(false); const [engines, setEngines] = useState([]); @@ -59,11 +58,7 @@ export const EngineOverview: React.FC = () => { setIsLoading(false); } catch (error) { - if (error?.body?.message === 'no-as-account') { - setHasNoAccount(true); - } else { - setHasErrorConnecting(true); - } + setHasErrorConnecting(true); } }; @@ -84,7 +79,6 @@ export const EngineOverview: React.FC = () => { }, [license, metaEnginesPage]); if (hasErrorConnecting) return ; - if (hasNoAccount) return ; if (isLoading) return ; if (!engines.length) return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts deleted file mode 100644 index c0a9ee5a90ea5..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 { getUserName } from './get_username'; - -describe('getUserName', () => { - it('fetches the current username from the DOM', () => { - document.body.innerHTML = - '
' + - ' ' + - '
'; - - expect(getUserName()).toEqual('foo_bar_baz'); - }); - - it('returns null if the expected DOM does not exist', () => { - document.body.innerHTML = '
' + '' + '
'; - expect(getUserName()).toEqual(null); - - document.body.innerHTML = '
'; - expect(getUserName()).toEqual(null); - - document.body.innerHTML = '
'; - expect(getUserName()).toEqual(null); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts deleted file mode 100644 index 3010da50f913e..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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. - */ - -/** - * Attempt to get the current Kibana user's username - * by querying the DOM - */ -export const getUserName: () => null | string = () => { - const userMenu = document.getElementById('headerUserMenu'); - if (!userMenu) return null; - - const avatar = userMenu.querySelector('.euiAvatar'); - if (!avatar) return null; - - const username = avatar.getAttribute('aria-label'); - return username; -}; 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 9e82a7f8da9ee..56722c85afbd0 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 @@ -25,7 +25,6 @@ describe('App Search Telemetry Usage Collector', () => { 'ui_viewed.setup_guide': 10, 'ui_viewed.engines_overview': 20, 'ui_error.cannot_connect': 3, - 'ui_error.no_as_account': 4, 'ui_clicked.create_first_engine_button': 40, 'ui_clicked.header_launch_button': 50, 'ui_clicked.engine_table_link': 60, @@ -64,7 +63,6 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_error: { cannot_connect: 3, - no_as_account: 4, }, ui_clicked: { create_first_engine_button: 40, @@ -87,7 +85,6 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_error: { cannot_connect: 0, - no_as_account: 0, }, ui_clicked: { create_first_engine_button: 0, 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 f9376f65f79a7..91c88c82f5614 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 @@ -19,7 +19,6 @@ interface ITelemetry { }; ui_error: { cannot_connect: number; - no_as_account: number; }; ui_clicked: { create_first_engine_button: number; @@ -49,7 +48,6 @@ export const registerTelemetryUsageCollector = ( }, ui_error: { cannot_connect: { type: 'long' }, - no_as_account: { type: 'long' }, }, ui_clicked: { create_first_engine_button: { type: 'long' }, @@ -78,7 +76,6 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => }, ui_error: { cannot_connect: 0, - no_as_account: 0, }, ui_clicked: { create_first_engine_button: 0, diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index faf8f61bd2b9e..88fc48e81701f 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -14,6 +14,9 @@ export const plugin = (initializerContext: PluginInitializerContext) => { export const configSchema = schema.object({ host: schema.maybe(schema.string()), + enabled: schema.boolean({ defaultValue: true }), + accessCheckTimeout: schema.number({ defaultValue: 5000 }), + accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), }); type ConfigType = TypeOf; diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts new file mode 100644 index 0000000000000..11d4a387b533f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -0,0 +1,128 @@ +/* + * 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. + */ + +jest.mock('./enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +import { checkAccess } from './check_access'; + +describe('checkAccess', () => { + const mockSecurity = { + authz: { + mode: { + useRbacForRequest: () => true, + }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: false, + }), + }), + actions: { + ui: { + get: () => null, + }, + }, + }, + }; + const mockDependencies = { + request: {}, + config: { host: 'http://localhost:3002' }, + security: mockSecurity, + } as any; + + describe('when security is disabled', () => { + it('should allow all access', async () => { + const security = undefined; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + }); + + describe('when the user is a superuser', () => { + it('should allow all access', async () => { + const security = { + ...mockSecurity, + authz: { + mode: { useRbacForRequest: () => true }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: true, + }), + }), + actions: { ui: { get: () => {} } }, + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to assuming a non-superuser role if auth credentials are missing', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: () => ({ + globally: () => Promise.reject({ statusCode: 403 }), + }), + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + + it('throws other authz errors', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: undefined, + }, + }; + await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow(); + }); + }); + + describe('when the user is a non-superuser', () => { + describe('when enterpriseSearch.host is not set in kibana.yml', () => { + it('should deny all access', async () => { + const config = { host: undefined }; + expect(await checkAccess({ ...mockDependencies, config })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + + describe('when enterpriseSearch.host is set in kibana.yml', () => { + it('should make a http call and return the access response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({ + access: { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }, + })); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to no access if no http response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({})); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts new file mode 100644 index 0000000000000..1855dc94631bd --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -0,0 +1,76 @@ +/* + * 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 { KibanaRequest, Logger } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { ServerConfigType } from '../plugin'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +interface ICheckAccess { + request: KibanaRequest; + security?: SecurityPluginSetup; + config: ServerConfigType; + log: Logger; +} +export interface IAccess { + hasAppSearchAccess: boolean; + hasWorkplaceSearchAccess: boolean; +} + +const ALLOW_ALL_PLUGINS = { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, +}; +const DENY_ALL_PLUGINS = { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, +}; + +/** + * Determines whether the user has access to our Enterprise Search products + * via HTTP call. If not, we hide the corresponding plugin links from the + * nav and catalogue in `plugin.ts`, which disables plugin access + */ +export const checkAccess = async ({ + config, + security, + request, + log, +}: ICheckAccess): Promise => { + // If security has been disabled, always show the plugin + if (!security?.authz?.mode.useRbacForRequest(request)) { + return ALLOW_ALL_PLUGINS; + } + + // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin + const isSuperUser = async (): Promise => { + try { + const { hasAllRequested } = await security.authz + .checkPrivilegesWithRequest(request) + .globally(security.authz.actions.ui.get('enterprise_search', 'all')); + return hasAllRequested; + } catch (err) { + if (err.statusCode === 401 || err.statusCode === 403) { + return false; + } + throw err; + } + }; + if (await isSuperUser()) { + return ALLOW_ALL_PLUGINS; + } + + // Hide the plugin when enterpriseSearch.host is not defined in kibana.yml + if (!config.host) { + return DENY_ALL_PLUGINS; + } + + // When enterpriseSearch.host is defined in kibana.yml, + // make a HTTP call which returns product access + const { access } = (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + return access || DENY_ALL_PLUGINS; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts new file mode 100644 index 0000000000000..cf35a458b4825 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -0,0 +1,111 @@ +/* + * 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. + */ + +jest.mock('node-fetch'); +const fetchMock = require('node-fetch') as jest.Mock; +const { Response } = jest.requireActual('node-fetch'); + +import { loggingSystemMock } from 'src/core/server/mocks'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +describe('callEnterpriseSearchConfigAPI', () => { + const mockConfig = { + host: 'http://localhost:3002', + accessCheckTimeout: 200, + accessCheckTimeoutWarning: 100, + }; + const mockRequest = { + url: { path: '/app/kibana' }, + headers: { authorization: '==someAuth' }, + }; + const mockDependencies = { + config: mockConfig, + request: mockRequest, + log: loggingSystemMock.create().get(), + } as any; + + const mockResponse = { + version: { + number: '1.0.0', + }, + settings: { + external_url: 'http://some.vanity.url/', + }, + access: { + user: 'someuser', + products: { + app_search: true, + workplace_search: false, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the config API endpoint', async () => { + fetchMock.mockImplementationOnce((url: string) => { + expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config'); + return Promise.resolve(new Response(JSON.stringify(mockResponse))); + }); + + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({ + publicUrl: 'http://some.vanity.url/', + access: { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: false, + }, + }); + }); + + it('returns early if config.host is not set', async () => { + const config = { host: '' }; + + expect(await callEnterpriseSearchConfigAPI({ ...mockDependencies, config })).toEqual({}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('handles server errors', async () => { + fetchMock.mockImplementationOnce(() => { + return Promise.reject('500'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: 500' + ); + + fetchMock.mockImplementationOnce(() => { + return Promise.resolve('Bad Data'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: TypeError: response.json is not a function' + ); + }); + + it('handles timeouts', async () => { + jest.useFakeTimers(); + + // Warning + callEnterpriseSearchConfigAPI(mockDependencies); + jest.advanceTimersByTime(150); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + 'Enterprise Search access check took over 100ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.' + ); + + // Timeout + fetchMock.mockImplementationOnce(async () => { + jest.advanceTimersByTime(250); + return Promise.reject({ name: 'AbortError' }); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + "Exceeded 200ms timeout while checking http://localhost:3002. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses." + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts new file mode 100644 index 0000000000000..a8eb5a4ec3611 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import AbortController from 'abort-controller'; +import fetch from 'node-fetch'; + +import { KibanaRequest, Logger } from 'src/core/server'; +import { ServerConfigType } from '../plugin'; +import { IAccess } from './check_access'; + +interface IParams { + request: KibanaRequest; + config: ServerConfigType; + log: Logger; +} +interface IReturn { + publicUrl?: string; + access?: IAccess; +} + +/** + * Calls an internal Enterprise Search API endpoint which returns + * useful various settings (e.g. product access, external URL) + * needed by the Kibana plugin at the setup stage + */ +const ENDPOINT = '/api/ent/v1/internal/client_config'; + +export const callEnterpriseSearchConfigAPI = async ({ + config, + log, + request, +}: IParams): Promise => { + if (!config.host) return {}; + + const TIMEOUT_WARNING = `Enterprise Search access check took over ${config.accessCheckTimeoutWarning}ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.`; + const TIMEOUT_MESSAGE = `Exceeded ${config.accessCheckTimeout}ms timeout while checking ${config.host}. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses.`; + const CONNECTION_ERROR = 'Could not perform access check to Enterprise Search'; + + const warningTimeout = setTimeout(() => { + log.warn(TIMEOUT_WARNING); + }, config.accessCheckTimeoutWarning); + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, config.accessCheckTimeout); + + try { + const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); + const response = await fetch(enterpriseSearchUrl, { + headers: { Authorization: request.headers.authorization as string }, + signal: controller.signal, + }); + const data = await response.json(); + + return { + publicUrl: data?.settings?.external_url, + access: { + hasAppSearchAccess: !!data?.access?.products?.app_search, + hasWorkplaceSearchAccess: !!data?.access?.products?.workplace_search, + }, + }; + } catch (err) { + if (err.name === 'AbortError') { + log.warn(TIMEOUT_MESSAGE); + } else { + log.error(`${CONNECTION_ERROR}: ${err.toString()}`); + if (err instanceof Error) log.debug(err.stack as string); + } + return {}; + } finally { + clearTimeout(warningTimeout); + clearTimeout(timeout); + } +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index a8430ad8f56af..62c448bc83760 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -13,9 +13,14 @@ import { Logger, SavedObjectsServiceStart, IRouter, + KibanaRequest, } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UICapabilities } from 'ui/capabilities'; +import { SecurityPluginSetup } from '../../security/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { checkAccess } from './lib/check_access'; import { registerEnginesRoute } from './routes/app_search/engines'; import { registerTelemetryRoute } from './routes/app_search/telemetry'; import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; @@ -23,10 +28,15 @@ import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; + security?: SecurityPluginSetup; + features: FeaturesPluginSetup; } export interface ServerConfigType { host?: string; + enabled: boolean; + accessCheckTimeout: number; + accessCheckTimeoutWarning: number; } export interface IRouteDependencies { @@ -46,11 +56,61 @@ export class EnterpriseSearchPlugin implements Plugin { } public async setup( - { http, savedObjects, getStartServices }: CoreSetup, - { usageCollection }: PluginsSetup + { capabilities, http, savedObjects, getStartServices }: CoreSetup, + { usageCollection, security, features }: PluginsSetup ) { - const router = http.createRouter(); const config = await this.config.pipe(first()).toPromise(); + + /** + * Register space/feature control + */ + features.registerFeature({ + id: 'enterprise_search', + name: 'Enterprise Search', + order: 0, + icon: 'logoEnterpriseSearch', + app: ['enterprise_search', 'app_search', 'workplace_search'], + catalogue: ['enterprise_search', 'app_search', 'workplace_search'], + privileges: null, + }); + + /** + * Register user access to the Enterprise Search plugins + */ + capabilities.registerProvider(() => ({ + navLinks: { + app_search: true, + }, + catalogue: { + app_search: true, + }, + })); + + capabilities.registerSwitcher( + async (request: KibanaRequest, uiCapabilities: UICapabilities) => { + const dependencies = { config, security, request, log: this.logger }; + + const { hasAppSearchAccess } = await checkAccess(dependencies); + // TODO: hasWorkplaceSearchAccess + + return { + ...uiCapabilities, + navLinks: { + ...uiCapabilities.navLinks, + app_search: hasAppSearchAccess, + }, + catalogue: { + ...uiCapabilities.catalogue, + app_search: hasAppSearchAccess, + }, + }; + } + ); + + /** + * Register routes + */ + const router = http.createRouter(); const dependencies = { router, config, log: this.logger }; registerEnginesRoute(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts new file mode 100644 index 0000000000000..c468b140c948d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.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 mockConfig = { + enabled: true, + host: 'http://localhost:3002', + accessCheckTimeout: 5000, + accessCheckTimeoutWarning: 300, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts similarity index 73% rename from x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts rename to x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts index efc58065784fb..8545d65b6f78d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getUserName } from './get_username'; +export { MockRouter } from './router.mock'; +export { mockConfig } from './config.mock'; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index c45514ae537fe..77289810049f5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -5,7 +5,7 @@ */ import { loggingSystemMock } from 'src/core/server/mocks'; -import { MockRouter } from '../__mocks__/router.mock'; +import { MockRouter, mockConfig } from '../__mocks__'; import { registerEnginesRoute } from './engines'; @@ -37,9 +37,7 @@ describe('engine routes', () => { registerEnginesRoute({ router: mockRouter.router, log: mockLogger, - config: { - host: 'http://localhost:3002', - }, + config: mockConfig, }); }); @@ -64,24 +62,6 @@ describe('engine routes', () => { }); }); - describe('when the underlying App Search API redirects to /login', () => { - beforeEach(() => { - AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, - { headers: { Authorization: AUTH_HEADER } } - ).andReturnRedirect(); - }); - - it('should return 403 with a message', async () => { - await mockRouter.callRoute(mockRequest); - - expect(mockRouter.response.forbidden).toHaveBeenCalledWith({ - body: 'no-as-account', - }); - expect(mockLogger.info).toHaveBeenCalledWith('No corresponding App Search account found'); - }); - }); - describe('when the App Search URL is invalid', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( @@ -152,18 +132,6 @@ describe('engine routes', () => { const AppSearchAPI = { shouldBeCalledWith(expectedUrl: string, expectedParams: object) { return { - andReturnRedirect() { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.resolve( - new Response('{}', { - url: '/login', - }) - ); - }); - }, andReturn(response: object) { fetchMock.mockImplementation((url: string, params: object) => { expect(url).toEqual(expectedUrl); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index ffc7a0228454f..b86555ca54a16 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -38,12 +38,6 @@ export function registerEnginesRoute({ router, config, log }: IRouteDependencies headers: { Authorization: request.headers.authorization as string }, }); - if (enginesResponse.url.endsWith('/login')) { - log.info('No corresponding App Search account found'); - // Note: Can't use response.unauthorized, Kibana will auto-log out the user - return response.forbidden({ body: 'no-as-account' }); - } - const engines = await enginesResponse.json(); const hasValidData = Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number'; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 06f064a379fe6..83c6417e25ad0 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -189,13 +189,15 @@ describe('features', () => { group: 'global', expectManageSpaces: true, expectGetFeatures: true, + expectEnterpriseSearch: true, }, { group: 'space', expectManageSpaces: false, expectGetFeatures: false, + expectEnterpriseSearch: false, }, -].forEach(({ group, expectManageSpaces, expectGetFeatures }) => { +].forEach(({ group, expectManageSpaces, expectGetFeatures, expectEnterpriseSearch }) => { describe(`${group}`, () => { test('actions defined in any feature privilege are included in `all`', () => { const features: Feature[] = [ @@ -256,6 +258,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), actions.ui.get('catalogue', 'all-catalogue-1'), actions.ui.get('catalogue', 'all-catalogue-2'), actions.ui.get('management', 'all-management', 'all-management-1'), @@ -450,6 +453,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -514,6 +518,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -579,6 +584,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -840,6 +846,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'all'), actions.ui.get('foo', 'foo'), ]); expect(actual).toHaveProperty('global.read', [ @@ -991,6 +998,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1189,6 +1197,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1315,6 +1324,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1477,6 +1487,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1592,6 +1603,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 5a15290a7f1a2..9df70dfd76f74 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -101,6 +101,7 @@ export function privilegesFactory( actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'all'), ...allActions, ], read: [actions.login, actions.version, ...readActions], 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 7b5bd3fd578d5..1ea16a2a9940c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -23,9 +23,6 @@ "properties": { "cannot_connect": { "type": "long" - }, - "no_as_account": { - "type": "long" } } }, diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 11fb9b2de7199..c783ae01176db 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { 'visualize', 'dashboard', 'dev_tools', + 'enterprise_search', 'advancedSettings', 'indexPatterns', 'timelion', From b1bf08a779731512b661fa90a90d628b37c4cf94 Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 6 Jul 2020 12:22:17 -0700 Subject: [PATCH 37/48] Public URL support for Elastic Cloud (#21) * Add server-side public URL route - Per feedback from Kibana platform team, it's not possible to pass info from server/ to public/ without a HTTP call :[ * Update MockRouter for routes without any payload/params * Add client-side helper for calling the new public URL API + API seems to return a URL a trailing slash, which we need to omit * Update public/plugin.ts to check and set a public URL - relies on this.hasCheckedPublicUrl to only make the call once per page load instead of on every page nav --- .../get_enterprise_search_url.test.ts | 30 +++++++++++ .../get_enterprise_search_url.ts | 27 ++++++++++ .../shared/enterprise_search_url/index.ts | 7 +++ .../enterprise_search/public/plugin.ts | 16 +++++- .../enterprise_search/server/plugin.ts | 2 + .../server/routes/__mocks__/router.mock.ts | 6 ++- .../enterprise_search/public_url.test.ts | 54 +++++++++++++++++++ .../routes/enterprise_search/public_url.ts | 26 +++++++++ 8 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts new file mode 100644 index 0000000000000..42f308c554268 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { getPublicUrl } from './'; + +describe('Enterprise Search URL helper', () => { + const httpMock = { get: jest.fn() } as any; + + it('calls and returns the public URL API endpoint', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://some.vanity.url' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://some.vanity.url'); + }); + + it('strips trailing slashes', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://trailing.slash/' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://trailing.slash'); + }); + + // For the most part, error logging/handling is done on the server side. + // On the front-end, we should simply gracefully fall back to config.host + // if we can't fetch a public URL + it('falls back to an empty string', async () => { + expect(await getPublicUrl(httpMock)).toEqual(''); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts new file mode 100644 index 0000000000000..419c187a0048a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts @@ -0,0 +1,27 @@ +/* + * 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 { HttpSetup } from 'src/core/public'; + +/** + * On Elastic Cloud, the host URL set in kibana.yml is not necessarily the same + * URL we want to send users to in the front-end (e.g. if a vanity URL is set). + * + * This helper checks a Kibana API endpoint (which has checks an Enterprise + * Search internal API endpoint) for the correct public-facing URL to use. + */ +export const getPublicUrl = async (http: HttpSetup): Promise => { + try { + const { publicUrl } = await http.get('/api/enterprise_search/public_url'); + return stripTrailingSlash(publicUrl); + } catch { + return ''; + } +}; + +const stripTrailingSlash = (url: string): string => { + return url.endsWith('/') ? url.slice(0, -1) : url; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts new file mode 100644 index 0000000000000..bbbb688b8ea7b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/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 { getPublicUrl } from './get_enterprise_search_url'; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 1ebfdd779a791..23df7044031ad 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -10,6 +10,7 @@ import { CoreSetup, CoreStart, AppMountParameters, + HttpSetup, } from 'src/core/public'; import { @@ -19,6 +20,7 @@ import { import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { LicensingPluginSetup } from '../../licensing/public'; +import { getPublicUrl } from './applications/shared/enterprise_search_url'; import AppSearchLogo from './applications/app_search/assets/logo.svg'; export interface ClientConfigType { @@ -31,13 +33,14 @@ export interface PluginsSetup { export class EnterpriseSearchPlugin implements Plugin { private config: ClientConfigType; + private hasCheckedPublicUrl: boolean = false; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); } public setup(core: CoreSetup, plugins: PluginsSetup) { - const config = this.config; + const config = { host: this.config.host }; core.application.register({ id: 'app_search', @@ -47,6 +50,8 @@ export class EnterpriseSearchPlugin implements Plugin { mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); + await this.setPublicUrl(config, coreStart.http); + const { renderApp } = await import('./applications'); const { AppSearch } = await import('./applications/app_search'); @@ -71,4 +76,13 @@ export class EnterpriseSearchPlugin implements Plugin { public start(core: CoreStart) {} public stop() {} + + private async setPublicUrl(config: ClientConfigType, http: HttpSetup) { + if (!config.host) return; // No API to check + if (this.hasCheckedPublicUrl) return; // We've already performed the check + + const publicUrl = await getPublicUrl(http); + if (publicUrl) config.host = publicUrl; + this.hasCheckedPublicUrl = true; + } } diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 62c448bc83760..17da3460820e7 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -21,6 +21,7 @@ import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; 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'; @@ -113,6 +114,7 @@ export class EnterpriseSearchPlugin implements Plugin { const router = http.createRouter(); const dependencies = { router, config, log: this.logger }; + registerPublicUrlRoute(dependencies); registerEnginesRoute(dependencies); /** diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts index 332d1ad1062f2..1ca7755979f99 100644 --- a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts @@ -21,7 +21,7 @@ type payloadType = 'params' | 'query' | 'body'; interface IMockRouterProps { method: methodType; - payload: payloadType; + payload?: payloadType; } interface IMockRouterRequest { body?: object; @@ -33,7 +33,7 @@ type TMockRouterRequest = KibanaRequest | IMockRouterRequest; export class MockRouter { public router!: jest.Mocked; public method: methodType; - public payload: payloadType; + public payload?: payloadType; public response = httpServerMock.createResponseFactory(); constructor({ method, payload }: IMockRouterProps) { @@ -58,6 +58,8 @@ export class MockRouter { */ public validateRoute = (request: TMockRouterRequest) => { + if (!this.payload) throw new Error('Cannot validate wihout a payload type specified.'); + const [config] = this.router[this.method].mock.calls[0]; const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts new file mode 100644 index 0000000000000..6fbe4d99e9d46 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts @@ -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 { MockRouter } from '../__mocks__/router.mock'; + +jest.mock('../../lib/enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api'; + +import { registerPublicUrlRoute } from './public_url'; + +describe('Enterprise Search Public URL API', () => { + const mockRouter = new MockRouter({ method: 'get' }); + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter.createRouter(); + + registerPublicUrlRoute({ + router: mockRouter.router, + config: {}, + log: {}, + } as any); + }); + + describe('GET /api/enterprise_search/public_url', () => { + it('returns a publicUrl', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve({ publicUrl: 'http://some.vanity.url' }); + }); + + await mockRouter.callRoute({}); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { publicUrl: 'http://some.vanity.url' }, + headers: { 'content-type': 'application/json' }, + }); + }); + + // For the most part, all error logging is handled by callEnterpriseSearchConfigAPI. + // This endpoint should mostly just fall back gracefully to an empty string + it('falls back to an empty string', async () => { + await mockRouter.callRoute({}); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { publicUrl: '' }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts new file mode 100644 index 0000000000000..a9edd4eb10da0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts @@ -0,0 +1,26 @@ +/* + * 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 { IRouteDependencies } from '../../plugin'; +import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api'; + +export function registerPublicUrlRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/enterprise_search/public_url', + validate: false, + }, + async (context, request, response) => { + const { publicUrl = '' } = + (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + + return response.ok({ + body: { publicUrl }, + headers: { 'content-type': 'application/json' }, + }); + } + ); +} From 0d0ca9c1ecf3a5f20d971a77e39879a0625682a7 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Mon, 6 Jul 2020 17:11:11 -0700 Subject: [PATCH 38/48] Fix failing feature control tests - Split up scenario cases as needed - Add plugin as an exception alongside ML & Monitoring --- .../plugins/enterprise_search/server/plugin.ts | 5 +++-- .../ui_capabilities/common/nav_links_builder.ts | 4 ++++ .../security_and_spaces/tests/catalogue.ts | 16 +++++++++++++--- .../security_and_spaces/tests/nav_links.ts | 12 +++++++++--- .../security_only/tests/catalogue.ts | 16 +++++++++++++--- .../security_only/tests/nav_links.ts | 10 ++++++++-- 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 17da3460820e7..69551fbcdbdc3 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -70,8 +70,9 @@ export class EnterpriseSearchPlugin implements Plugin { name: 'Enterprise Search', order: 0, icon: 'logoEnterpriseSearch', - app: ['enterprise_search', 'app_search', 'workplace_search'], - catalogue: ['enterprise_search', 'app_search', 'workplace_search'], + navLinkId: 'app_search', // TODO - remove this once functional tests no longer rely on navLinkId + app: ['kibana', 'app_search'], // TODO: 'enterprise_search', 'workplace_search' + catalogue: ['app_search'], // TODO: 'enterprise_search', 'workplace_search' privileges: null, }); diff --git a/x-pack/test/ui_capabilities/common/nav_links_builder.ts b/x-pack/test/ui_capabilities/common/nav_links_builder.ts index 405ef4dbdc5b1..ed94c98508518 100644 --- a/x-pack/test/ui_capabilities/common/nav_links_builder.ts +++ b/x-pack/test/ui_capabilities/common/nav_links_builder.ts @@ -15,6 +15,10 @@ export class NavLinksBuilder { management: { navLinkId: 'kibana:stack_management', }, + // TODO: Temp until navLinkIds fix is merged in + app_search: { + navLinkId: 'app_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 f8f3f2be2b2ec..6dc7b2a9b95ff 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 @@ -32,17 +32,27 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'global_all at everything_space': - case 'dual_privileges_all at everything_space': + case 'dual_privileges_all at everything_space': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'everything_space_all at everything_space': case 'global_read at everything_space': case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring is enabled + // everything except ml and monitoring and enterprise search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + (enabled, catalogueId) => !['ml', 'monitoring', 'app_search'].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 10ecf5d25d346..c84248f9a793e 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 @@ -38,14 +38,20 @@ export default function navLinksTests({ getService }: FtrProviderContext) { break; case 'global_all at everything_space': case 'dual_privileges_all at everything_space': - case 'dual_privileges_read at everything_space': - case 'global_read at everything_space': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring') + ); + break; case 'everything_space_all at everything_space': + case 'global_read at everything_space': + case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring') + navLinksBuilder.except('ml', 'monitoring', 'enterprise_search', 'app_search') ); 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 52a1f30147b4f..05dd93261c8bc 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -32,15 +32,25 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'all': + case 'dual_privileges_all': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring and enterprise_search is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'read': - case 'dual_privileges_all': case 'dual_privileges_read': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring is enabled + // everything except ml and monitoring and enterprise_search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + (enabled, catalogueId) => !['ml', 'monitoring', 'app_search'].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 fe9ffa9286de8..6a455e4511bcb 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 @@ -37,15 +37,21 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.navLinks).to.eql(navLinksBuilder.all()); break; case 'all': - case 'read': case 'dual_privileges_all': - case 'dual_privileges_read': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( navLinksBuilder.except('ml', 'monitoring') ); break; + case 'read': + case 'dual_privileges_read': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring', 'app_search') + ); + break; case 'foo_all': case 'foo_read': expect(uiCapabilities.success).to.be(true); From a74b5e62d02ffefc0597e39cd0f1d212d6b13060 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 7 Jul 2020 11:56:41 -0700 Subject: [PATCH 39/48] Address PR feedback - version: kibana - copy edits - Sass vars - code cleanup --- x-pack/plugins/enterprise_search/README.md | 2 +- x-pack/plugins/enterprise_search/kibana.json | 2 +- .../components/empty_states/empty_states.scss | 2 +- .../engine_overview/engine_overview.tsx | 22 ++++++++----------- .../shared/setup_guide/setup_guide.scss | 6 ++--- .../server/routes/app_search/engines.ts | 4 ++-- 6 files changed, 17 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index b62138df44166..8c316c848184b 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -8,7 +8,7 @@ This plugin's goal is to provide a Kibana user interface to the Enterprise Searc 1. When developing locally, Enterprise Search should be running locally alongside Kibana on `localhost:3002`. 2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'` -3. For faster QA/development, run Enterprise Search on `elasticsearch-native` auth and log in as the `elastic` superuser on Kibana. +3. For faster QA/development, run Enterprise Search on [elasticsearch-native auth](https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm) and log in as the `elastic` superuser on Kibana. ## Testing diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index bfd36b1736d68..9a2daefcd8c6e 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -1,6 +1,6 @@ { "id": "enterpriseSearch", - "version": "1.0.0", + "version": "kibana", "kibanaVersion": "kibana", "requiredPlugins": ["home", "features", "licensing"], "configPath": ["enterpriseSearch"], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss index 32d1b198c5ced..01b0903add559 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss @@ -8,7 +8,7 @@ * Empty/Error UI states */ .emptyState { - min-height: 450px; + min-height: $euiSizeXXL * 11.25; display: flex; flex-direction: column; justify-content: center; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index c4cebf30ab45e..13d092a657d11 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -30,6 +30,15 @@ import { EngineTable } from './engine_table'; import './engine_overview.scss'; +interface IGetEnginesParams { + type: string; + pageIndex: number; +} +interface ISetEnginesCallbacks { + setResults: React.Dispatch>; + setResultsTotal: React.Dispatch>; +} + export const EngineOverview: React.FC = () => { const { http } = useContext(KibanaContext) as IKibanaContext; const { license } = useContext(LicenseContext) as ILicenseContext; @@ -144,16 +153,3 @@ export const EngineOverview: React.FC = () => { ); }; - -/** - * Type definitions - */ - -interface IGetEnginesParams { - type: string; - pageIndex: number; -} -interface ISetEnginesCallbacks { - setResults: React.Dispatch>; - setResultsTotal: React.Dispatch>; -} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss index f90d60ee528ef..ecfa13cc828f0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss @@ -12,7 +12,7 @@ min-height: 100vh; &__sidebar { - flex-basis: 300px; + flex-basis: $euiSizeXXL * 7.5; flex-shrink: 0; padding: $euiSizeL; margin-right: 0; @@ -26,10 +26,10 @@ border-width: 0 $euiBorderWidthThin 0 0; // right - desktop view } @include euiBreakpoint('m', 'l') { - flex-basis: 400px; + flex-basis: $euiSizeXXL * 10; } @include euiBreakpoint('xl') { - flex-basis: 500px; + flex-basis: $euiSizeXXL * 12.5; } } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index b86555ca54a16..c32cbc0e74d71 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -24,7 +24,7 @@ export function registerEnginesRoute({ router, config, log }: IRouteDependencies }, async (context, request, response) => { try { - const appSearchUrl = config.host as string; + const enterpriseSearchUrl = config.host as string; const { type, pageIndex } = request.query; const params = querystring.stringify({ @@ -32,7 +32,7 @@ export function registerEnginesRoute({ router, config, log }: IRouteDependencies 'page[current]': pageIndex, 'page[size]': ENGINES_PAGE_SIZE, }); - const url = `${encodeURI(appSearchUrl)}/as/engines/collection?${params}`; + const url = `${encodeURI(enterpriseSearchUrl)}/as/engines/collection?${params}`; const enginesResponse = await fetch(url, { headers: { Authorization: request.headers.authorization as string }, From 972adcf87a50e9050166cd4c44208cc8bd1ec47a Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 7 Jul 2020 19:03:33 -0700 Subject: [PATCH 40/48] Casing feedback: change all plugin registration IDs from snake_case to camelCase - note: current remainng snake_case exceptions are telemetry keys - file names and api endpoints are snake_case per conventions --- .../enterprise_search/public/plugin.ts | 4 ++-- .../server/lib/check_access.ts | 2 +- .../enterprise_search/server/plugin.ts | 12 +++++------ .../privileges/privileges.test.ts | 20 +++++++++---------- .../authorization/privileges/privileges.ts | 2 +- .../apis/features/features/features.ts | 2 +- .../page_objects/app_search.ts | 2 +- .../common/nav_links_builder.ts | 4 ++-- .../security_and_spaces/tests/catalogue.ts | 2 +- .../security_and_spaces/tests/nav_links.ts | 2 +- .../security_only/tests/catalogue.ts | 2 +- .../security_only/tests/nav_links.ts | 2 +- 12 files changed, 28 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 23df7044031ad..fbfcc303de47a 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -43,7 +43,7 @@ export class EnterpriseSearchPlugin implements Plugin { const config = { host: this.config.host }; core.application.register({ - id: 'app_search', + id: 'appSearch', title: 'App Search', appRoute: '/app/enterprise_search/app_search', category: DEFAULT_APP_CATEGORIES.enterpriseSearch, @@ -61,7 +61,7 @@ export class EnterpriseSearchPlugin implements Plugin { // TODO: Workplace Search will need to register its own plugin. plugins.home.featureCatalogue.register({ - id: 'app_search', + id: 'appSearch', title: 'App Search', icon: AppSearchLogo, description: diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts index 1855dc94631bd..031dad234a143 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -51,7 +51,7 @@ export const checkAccess = async ({ try { const { hasAllRequested } = await security.authz .checkPrivilegesWithRequest(request) - .globally(security.authz.actions.ui.get('enterprise_search', 'all')); + .globally(security.authz.actions.ui.get('enterpriseSearch', 'all')); return hasAllRequested; } catch (err) { if (err.statusCode === 401 || err.statusCode === 403) { diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 69551fbcdbdc3..ee879d606dcbf 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -66,13 +66,13 @@ export class EnterpriseSearchPlugin implements Plugin { * Register space/feature control */ features.registerFeature({ - id: 'enterprise_search', + id: 'enterpriseSearch', name: 'Enterprise Search', order: 0, icon: 'logoEnterpriseSearch', - navLinkId: 'app_search', // TODO - remove this once functional tests no longer rely on navLinkId - app: ['kibana', 'app_search'], // TODO: 'enterprise_search', 'workplace_search' - catalogue: ['app_search'], // TODO: 'enterprise_search', 'workplace_search' + navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId + app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' privileges: null, }); @@ -99,11 +99,11 @@ export class EnterpriseSearchPlugin implements Plugin { ...uiCapabilities, navLinks: { ...uiCapabilities.navLinks, - app_search: hasAppSearchAccess, + appSearch: hasAppSearchAccess, }, catalogue: { ...uiCapabilities.catalogue, - app_search: hasAppSearchAccess, + appSearch: hasAppSearchAccess, }, }; } diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 83c6417e25ad0..8a499a3eba8fa 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -258,7 +258,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), actions.ui.get('catalogue', 'all-catalogue-1'), actions.ui.get('catalogue', 'all-catalogue-2'), actions.ui.get('management', 'all-management', 'all-management-1'), @@ -453,7 +453,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -518,7 +518,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -584,7 +584,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - ...(expectEnterpriseSearch ? [actions.ui.get('enterprise_search', 'all')] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -846,7 +846,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), - actions.ui.get('enterprise_search', 'all'), + actions.ui.get('enterpriseSearch', 'all'), actions.ui.get('foo', 'foo'), ]); expect(actual).toHaveProperty('global.read', [ @@ -998,7 +998,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), - actions.ui.get('enterprise_search', 'all'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1197,7 +1197,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), - actions.ui.get('enterprise_search', 'all'), + actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1324,7 +1324,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), - actions.ui.get('enterprise_search', 'all'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1487,7 +1487,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), - actions.ui.get('enterprise_search', 'all'), + actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1603,7 +1603,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), - actions.ui.get('enterprise_search', 'all'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 9df70dfd76f74..f9ee5fc750127 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -101,7 +101,7 @@ export function privilegesFactory( actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), - actions.ui.get('enterprise_search', 'all'), + actions.ui.get('enterpriseSearch', 'all'), ...allActions, ], read: [actions.login, actions.version, ...readActions], diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index c783ae01176db..df6eca795f801 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -97,7 +97,7 @@ export default function ({ getService }: FtrProviderContext) { 'visualize', 'dashboard', 'dev_tools', - 'enterprise_search', + 'enterpriseSearch', 'advancedSettings', 'indexPatterns', 'timelion', diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts index 2c38542d904f3..1e093066f6432 100644 --- a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts +++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts @@ -14,7 +14,7 @@ export function AppSearchPageProvider({ getService, getPageObjects }: FtrProvide return { async navigateToPage(): Promise { - return await PageObjects.common.navigateToApp('app_search'); + return await PageObjects.common.navigateToApp('appSearch'); }, async getEngineLinks(): Promise { diff --git a/x-pack/test/ui_capabilities/common/nav_links_builder.ts b/x-pack/test/ui_capabilities/common/nav_links_builder.ts index ed94c98508518..b20a499ba7e20 100644 --- a/x-pack/test/ui_capabilities/common/nav_links_builder.ts +++ b/x-pack/test/ui_capabilities/common/nav_links_builder.ts @@ -16,8 +16,8 @@ export class NavLinksBuilder { navLinkId: 'kibana:stack_management', }, // TODO: Temp until navLinkIds fix is merged in - app_search: { - navLinkId: 'app_search', + appSearch: { + navLinkId: 'appSearch', }, }; } 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 6dc7b2a9b95ff..0e0d46c6ce2cd 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 @@ -52,7 +52,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { // everything except ml and monitoring and enterprise search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => !['ml', 'monitoring', 'app_search'].includes(catalogueId) + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].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 c84248f9a793e..08a7d789153e7 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,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', 'enterprise_search', 'app_search') + navLinksBuilder.except('ml', 'monitoring', 'enterpriseSearch', 'appSearch') ); 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 05dd93261c8bc..703451de55cfd 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -50,7 +50,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { // everything except ml and monitoring and enterprise_search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => !['ml', 'monitoring', 'app_search'].includes(catalogueId) + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].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 6a455e4511bcb..d3bd2e1afd357 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', 'app_search') + navLinksBuilder.except('ml', 'monitoring', 'appSearch') ); break; case 'foo_all': From 9e526804145b358cf4030b095e16b579b522e911 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 7 Jul 2020 19:58:50 -0700 Subject: [PATCH 41/48] Misc security feedback - remove set - remove unnecessary capabilities registration - telemetry namespace agnostic --- .../server/collectors/app_search/telemetry.ts | 28 +++++++++++++------ .../enterprise_search/server/plugin.ts | 9 ------ .../saved_objects/app_search/telemetry.ts | 2 +- 3 files changed, 20 insertions(+), 19 deletions(-) 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 91c88c82f5614..918dca85232c6 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { set } from 'lodash'; +import { get } from 'lodash'; import { ISavedObjectsRepository, SavedObjectsServiceStart, @@ -89,14 +89,24 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => return defaultTelemetrySavedObject; } - // Iterate through each attribute key and set its saved values - const attributeKeys = Object.keys(savedObjectAttributes); - const telemetryObj = defaultTelemetrySavedObject; - attributeKeys.forEach((key: string) => { - set(telemetryObj, key, savedObjectAttributes[key]); - }); - - return telemetryObj as ITelemetry; + return { + ui_viewed: { + setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0), + engines_overview: get(savedObjectAttributes, 'ui_viewed.engines_overview', 0), + }, + ui_error: { + cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0), + }, + ui_clicked: { + create_first_engine_button: get( + savedObjectAttributes, + 'ui_clicked.create_first_engine_button', + 0 + ), + header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), + engine_table_link: get(savedObjectAttributes, 'ui_clicked.engine_table_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 ee879d606dcbf..c338b0f04a939 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -79,15 +79,6 @@ export class EnterpriseSearchPlugin implements Plugin { /** * Register user access to the Enterprise Search plugins */ - capabilities.registerProvider(() => ({ - navLinks: { - app_search: true, - }, - catalogue: { - app_search: true, - }, - })); - capabilities.registerSwitcher( async (request: KibanaRequest, uiCapabilities: UICapabilities) => { const dependencies = { config, security, request, log: this.logger }; diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts index e581b4a69790f..32322d494b5e2 100644 --- a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -11,7 +11,7 @@ import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; export const appSearchTelemetryType: SavedObjectsType = { name: AS_TELEMETRY_NAME, hidden: false, - namespaceType: 'single', + namespaceType: 'agnostic', mappings: { dynamic: false, properties: {}, From 15a8b15222b0af95beeb7ee6bdfca187ea163c6b Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Tue, 7 Jul 2020 20:01:13 -0700 Subject: [PATCH 42/48] Security feedback: add warn logging to telemetry collector see https://github.com/elastic/kibana/pull/66922#discussion_r451215760 - add if statement - pass log dependency around (this is kinda medium, should maybe refactor) - update tests - move test file comment to the right file (was meant for telemetry route file) --- .../collectors/app_search/telemetry.test.ts | 49 +++++++++++++++---- .../server/collectors/app_search/telemetry.ts | 20 ++++++-- .../enterprise_search/server/plugin.ts | 2 +- .../routes/app_search/telemetry.test.ts | 5 ++ 4 files changed, 60 insertions(+), 16 deletions(-) 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 56722c85afbd0..e95056b871324 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,14 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { loggingSystemMock } from 'src/core/server/mocks'; + +jest.mock('../../../../../../src/core/server', () => ({ + SavedObjectsErrorHelpers: { + isNotFoundError: jest.fn(), + }, +})); +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; -/** - * Since these route callbacks are so thin, these serve simply as integration tests - * to ensure they're wired up to the lib functions correctly. Business logic is tested - * more thoroughly in the lib/telemetry tests. - */ describe('App Search Telemetry Usage Collector', () => { + const mockLogger = loggingSystemMock.create().get(); + const makeUsageCollectorStub = jest.fn(); const registerStub = jest.fn(); const usageCollectionMock = { @@ -42,7 +48,7 @@ describe('App Search Telemetry Usage Collector', () => { describe('registerTelemetryUsageCollector', () => { it('should make and register the usage collector', () => { - registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock); + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); expect(registerStub).toHaveBeenCalledTimes(1); expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); @@ -53,7 +59,7 @@ describe('App Search Telemetry Usage Collector', () => { describe('fetchTelemetryMetrics', () => { it('should return existing saved objects data', async () => { - registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock); + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); expect(savedObjectsCounts).toEqual({ @@ -72,10 +78,14 @@ describe('App Search Telemetry Usage Collector', () => { }); }); - it('should not error & should return a default telemetry object if no saved data exists', async () => { - const emptySavedObjectsMock = { createInternalRepository: () => ({}) } as any; + it('should return a default telemetry object if no saved data exists', async () => { + const emptySavedObjectsMock = { + createInternalRepository: () => ({ + get: () => ({ attributes: null }), + }), + } as any; - registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock); + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger); const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); expect(savedObjectsCounts).toEqual({ @@ -93,6 +103,25 @@ 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', () => { 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 918dca85232c6..a10f96907ad28 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 @@ -9,9 +9,13 @@ import { ISavedObjectsRepository, SavedObjectsServiceStart, SavedObjectAttributes, + 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'; + interface ITelemetry { ui_viewed: { setup_guide: number; @@ -35,11 +39,12 @@ export const AS_TELEMETRY_NAME = 'app_search_telemetry'; export const registerTelemetryUsageCollector = ( usageCollection: UsageCollectionSetup, - savedObjects: SavedObjectsServiceStart + savedObjects: SavedObjectsServiceStart, + log: Logger ) => { const telemetryUsageCollector = usageCollection.makeUsageCollector({ type: 'app_search', - fetch: async () => fetchTelemetryMetrics(savedObjects), + fetch: async () => fetchTelemetryMetrics(savedObjects, log), isReady: () => true, schema: { ui_viewed: { @@ -63,10 +68,11 @@ export const registerTelemetryUsageCollector = ( * Fetch the aggregated telemetry metrics from our saved objects */ -const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => { +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { const savedObjectsRepository = savedObjects.createInternalRepository(); const savedObjectAttributes = (await getSavedObjectAttributesFromRepo( - savedObjectsRepository + savedObjectsRepository, + log )) as SavedObjectAttributes; const defaultTelemetrySavedObject: ITelemetry = { @@ -114,11 +120,15 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => */ const getSavedObjectAttributesFromRepo = async ( - savedObjectsRepository: ISavedObjectsRepository + 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; } }; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index c338b0f04a939..88431ac158a01 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -122,7 +122,7 @@ export class EnterpriseSearchPlugin implements Plugin { getSavedObjectsService: () => savedObjectsStarted, }); if (usageCollection) { - registerTelemetryUsageCollector(usageCollection, savedObjectsStarted); + registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); } }); } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts index 9e4ca2459ebd5..bdf1e681f8848 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -14,6 +14,11 @@ jest.mock('../../collectors/app_search/telemetry', () => ({ })); import { incrementUICounter } from '../../collectors/app_search/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', () => { const mockRouter = new MockRouter({ method: 'put', payload: 'body' }); const mockLogger = loggingSystemMock.create().get(); From 9c6acdd7ce0ccea1b1904b906bec3b52133a8b35 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 8 Jul 2020 08:48:17 -0700 Subject: [PATCH 43/48] Address feedback from Pierre - Remove unnecessary ServerConfigType - Remove unnecessary uiCapabilities - Move registerTelemetryRoute / SavedObjectsServiceStart workaround - Remove unnecessary license optional chaining --- .../plugins/enterprise_search/server/index.ts | 2 +- .../server/lib/check_access.ts | 4 +- .../lib/enterprise_search_config_api.ts | 4 +- .../enterprise_search/server/plugin.ts | 62 ++++++++----------- 4 files changed, 30 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index 88fc48e81701f..1e4159124ed94 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -19,7 +19,7 @@ export const configSchema = schema.object({ accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), }); -type ConfigType = TypeOf; +export type ConfigType = TypeOf; export const config: PluginConfigDescriptor = { schema: configSchema, diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts index 031dad234a143..1ce70c5170968 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -6,14 +6,14 @@ import { KibanaRequest, Logger } from 'src/core/server'; import { SecurityPluginSetup } from '../../../security/server'; -import { ServerConfigType } from '../plugin'; +import { ConfigType } from '../'; import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; interface ICheckAccess { request: KibanaRequest; security?: SecurityPluginSetup; - config: ServerConfigType; + config: ConfigType; log: Logger; } export interface IAccess { diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index a8eb5a4ec3611..7a6d1eac1b454 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -8,12 +8,12 @@ import AbortController from 'abort-controller'; import fetch from 'node-fetch'; import { KibanaRequest, Logger } from 'src/core/server'; -import { ServerConfigType } from '../plugin'; +import { ConfigType } from '../'; import { IAccess } from './check_access'; interface IParams { request: KibanaRequest; - config: ServerConfigType; + config: ConfigType; log: Logger; } interface IReturn { diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 88431ac158a01..70be8600862e9 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -16,10 +16,10 @@ import { KibanaRequest, } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { UICapabilities } from 'ui/capabilities'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { ConfigType } from './'; import { checkAccess } from './lib/check_access'; import { registerPublicUrlRoute } from './routes/enterprise_search/public_url'; import { registerEnginesRoute } from './routes/app_search/engines'; @@ -33,26 +33,19 @@ export interface PluginsSetup { features: FeaturesPluginSetup; } -export interface ServerConfigType { - host?: string; - enabled: boolean; - accessCheckTimeout: number; - accessCheckTimeoutWarning: number; -} - export interface IRouteDependencies { router: IRouter; - config: ServerConfigType; + config: ConfigType; log: Logger; getSavedObjectsService?(): SavedObjectsServiceStart; } export class EnterpriseSearchPlugin implements Plugin { - private config: Observable; + private config: Observable; private logger: Logger; constructor(initializerContext: PluginInitializerContext) { - this.config = initializerContext.config.create(); + this.config = initializerContext.config.create(); this.logger = initializerContext.logger.get(); } @@ -79,26 +72,21 @@ export class EnterpriseSearchPlugin implements Plugin { /** * Register user access to the Enterprise Search plugins */ - capabilities.registerSwitcher( - async (request: KibanaRequest, uiCapabilities: UICapabilities) => { - const dependencies = { config, security, request, log: this.logger }; - - const { hasAppSearchAccess } = await checkAccess(dependencies); - // TODO: hasWorkplaceSearchAccess - - return { - ...uiCapabilities, - navLinks: { - ...uiCapabilities.navLinks, - appSearch: hasAppSearchAccess, - }, - catalogue: { - ...uiCapabilities.catalogue, - appSearch: hasAppSearchAccess, - }, - }; - } - ); + capabilities.registerSwitcher(async (request: KibanaRequest) => { + const dependencies = { config, security, request, log: this.logger }; + + const { hasAppSearchAccess } = await checkAccess(dependencies); + // TODO: hasWorkplaceSearchAccess + + return { + navLinks: { + appSearch: hasAppSearchAccess, + }, + catalogue: { + appSearch: hasAppSearchAccess, + }, + }; + }); /** * Register routes @@ -113,18 +101,18 @@ export class EnterpriseSearchPlugin implements Plugin { * Bootstrap the routes, saved objects, and collector for telemetry */ savedObjects.registerType(appSearchTelemetryType); + let savedObjectsStarted: SavedObjectsServiceStart; getStartServices().then(([coreStart]) => { - const savedObjectsStarted = coreStart.savedObjects as SavedObjectsServiceStart; - - registerTelemetryRoute({ - ...dependencies, - getSavedObjectsService: () => savedObjectsStarted, - }); + savedObjectsStarted = coreStart.savedObjects; if (usageCollection) { registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); } }); + registerTelemetryRoute({ + ...dependencies, + getSavedObjectsService: () => savedObjectsStarted, + }); } public start() {} From 3b715b52e109c42f59b4296c34f3e319d14fd846 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 8 Jul 2020 10:35:27 -0700 Subject: [PATCH 44/48] PR feedback Address type/typos --- .../shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts | 2 +- .../public/applications/shared/licensing/license_checks.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 5a5cce6ec23b6..7ea73577c4de6 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 @@ -40,7 +40,7 @@ describe('generateBreadcrumb', () => { expect(event.preventDefault).toHaveBeenCalled(); }); - it('does not prevents default browser behavior on new tab/window clicks', () => { + it('does not prevent default browser behavior on new tab/window clicks', () => { const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any; (letBrowserHandleEvent as jest.Mock).mockImplementationOnce(() => true); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts index 363ae39ab0da4..de4a17ce2bd3c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts @@ -6,6 +6,6 @@ import { ILicense } from '../../../../../licensing/public'; -export const hasPlatinumLicense = (license: ILicense) => { +export const hasPlatinumLicense = (license?: ILicense) => { return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type as string); }; From d72e9888033285efabcf133d54cbe49a2712892b Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 8 Jul 2020 10:46:09 -0700 Subject: [PATCH 45/48] Fix telemetry API call returning 415 on Chrome - I can't even?? I swear charset=utf-8 fixed the same error a few weeks ago --- .../public/applications/shared/telemetry/send_telemetry.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0be26b2bf0459..300cb18272717 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 @@ -26,7 +26,7 @@ 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; charset=utf-8' }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, metric }), }); } catch (error) { From d6740f2e9ba524f5a960d256708934cbea75d5dd Mon Sep 17 00:00:00 2001 From: scottybollinger Date: Wed, 8 Jul 2020 12:59:03 -0500 Subject: [PATCH 46/48] Fix failing tests --- .../applications/shared/telemetry/send_telemetry.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 e08fe6c06b0f1..9825c0d8ab889 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 @@ -27,7 +27,7 @@ describe('Shared Telemetry Helpers', () => { }); expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { - headers: { 'content-type': 'application/json; charset=utf-8' }, + headers: { 'Content-Type': 'application/json' }, body: '{"action":"viewed","metric":"setup_guide"}', }); }); @@ -48,7 +48,7 @@ describe('Shared Telemetry Helpers', () => { }); expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { - headers: { 'content-type': 'application/json; charset=utf-8' }, + headers: { 'Content-Type': 'application/json' }, body: '{"action":"clicked","metric":"button"}', }); }); From f305f1146bf488aa7b74418d117c4ed05750e9d0 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 8 Jul 2020 12:45:29 -0700 Subject: [PATCH 47/48] Update Enterprise Search functional tests (without host) to run on CI - Fix incorrect navigateToApp slug (hadn't realized this was a URL, not an ID) - Update without_host_configured tests to run without API key - Update README --- x-pack/scripts/functional_tests.js | 1 + x-pack/test/functional_enterprise_search/README.md | 8 ++++---- .../enterprise_search/without_host_configured/index.ts | 2 ++ .../page_objects/app_search.ts | 2 +- .../services/app_search_service.ts | 6 ------ 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 29be6d826c1bc..ee8af9e040401 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -53,6 +53,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting_api_integration/config.js'), require.resolve('../test/functional_embedded/config.ts'), require.resolve('../test/ingest_manager_api_integration/config.ts'), + require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'), ]; require('@kbn/plugin-helpers').babelRegister(); diff --git a/x-pack/test/functional_enterprise_search/README.md b/x-pack/test/functional_enterprise_search/README.md index e5d9009fc393b..63d13cbac7020 100644 --- a/x-pack/test/functional_enterprise_search/README.md +++ b/x-pack/test/functional_enterprise_search/README.md @@ -14,16 +14,16 @@ Ex. # Run specs from the x-pack directory cd x-pack +# Run tests that do not require enterpriseSearch.host variable +node scripts/functional_tests --config test/functional_enterprise_search/without_host_configured.config.ts + # Run tests that require enterpriseSearch.host variable APP_SEARCH_API_KEY=[use private key from local App Search instance here] node scripts/functional_tests --config test/functional_enterprise_search/with_host_configured.config.ts - -# Run tests that do not require enterpriseSearch.host variable -APP_SEARCH_API_KEY=[use private key from local App Search instance here] node scripts/functional_tests --config test/functional_enterprise_search/without_host_configured.config.ts ``` ## Enterprise Search Requirement -These tests will not currently start an instance of App Search automatically. As such, they are not run as part of CI and are most useful for local regression testing. +The `with_host_configured` tests will not currently start an instance of App Search automatically. As such, they are not run as part of CI and are most useful for local regression testing. The easiest way to start Enterprise Search for these tests is to check out the `ent-search` project and use the following script. 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 b366879bde0d9..31a92e752fcf4 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 @@ -8,6 +8,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Enterprise Search', function () { + this.tags('ciGroup10'); + loadTestFile(require.resolve('./app_search/setup_guide')); }); } diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts index 1e093066f6432..d845a1935a149 100644 --- a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts +++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts @@ -14,7 +14,7 @@ export function AppSearchPageProvider({ getService, getPageObjects }: FtrProvide return { async navigateToPage(): Promise { - return await PageObjects.common.navigateToApp('appSearch'); + return await PageObjects.common.navigateToApp('enterprise_search/app_search'); }, async getEngineLinks(): Promise { diff --git a/x-pack/test/functional_enterprise_search/services/app_search_service.ts b/x-pack/test/functional_enterprise_search/services/app_search_service.ts index c04988a26d5f9..9a43783402f4b 100644 --- a/x-pack/test/functional_enterprise_search/services/app_search_service.ts +++ b/x-pack/test/functional_enterprise_search/services/app_search_service.ts @@ -63,12 +63,6 @@ export async function AppSearchServiceProvider({ getService }: FtrProviderContex const security = getService('security'); lifecycle.beforeTests.add(async () => { - const APP_SEARCH_API_KEY = process.env.APP_SEARCH_API_KEY; - - if (!APP_SEARCH_API_KEY) { - throw new Error('Please provide a valid APP_SEARCH_API_KEY. See README for more details.'); - } - // The App Search plugin passes through the current user name and password // through on the API call to App Search. Therefore, we need to be signed // in as the enterprise_search user in order for this plugin to work. From 8ce448669c331f1a293282f97e746042e83a8f60 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Thu, 9 Jul 2020 11:22:57 -0700 Subject: [PATCH 48/48] Address PR feedback from Pierre - remove unnecessary authz? - remove unnecessary content-type json headers - add loggingSystemMock.collect(mockLogger).error assertion - reconstrcut new MockRouter on beforeEach for better sandboxing - fix incorrect describe()s -should be it() - pull out reusable mockDependencies helper (renamed/extended from mockConfig) for tests that don't particularly use config/log but still want to pass type definitions - Fix comment copy --- .../server/lib/check_access.ts | 2 +- .../server/routes/__mocks__/config.mock.ts | 12 --------- .../server/routes/__mocks__/index.ts | 2 +- .../__mocks__/routerDependencies.mock.ts | 27 +++++++++++++++++++ .../server/routes/app_search/engines.test.ts | 9 +++---- .../server/routes/app_search/engines.ts | 5 +--- .../routes/app_search/telemetry.test.ts | 23 +++++++++------- .../enterprise_search/public_url.test.ts | 12 ++++----- .../security_only/tests/catalogue.ts | 4 +-- 9 files changed, 54 insertions(+), 42 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts index 1ce70c5170968..0239cb6422d03 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -42,7 +42,7 @@ export const checkAccess = async ({ log, }: ICheckAccess): Promise => { // If security has been disabled, always show the plugin - if (!security?.authz?.mode.useRbacForRequest(request)) { + if (!security?.authz.mode.useRbacForRequest(request)) { return ALLOW_ALL_PLUGINS; } diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts deleted file mode 100644 index c468b140c948d..0000000000000 --- a/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * 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 mockConfig = { - enabled: true, - host: 'http://localhost:3002', - accessCheckTimeout: 5000, - accessCheckTimeoutWarning: 300, -}; diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts index 8545d65b6f78d..3cca5e21ce9c3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts @@ -5,4 +5,4 @@ */ export { MockRouter } from './router.mock'; -export { mockConfig } from './config.mock'; +export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock'; diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts new file mode 100644 index 0000000000000..9b6fa30271d61 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts @@ -0,0 +1,27 @@ +/* + * 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 { loggingSystemMock } from 'src/core/server/mocks'; +import { ConfigType } from '../../'; + +export const mockLogger = loggingSystemMock.createLogger().get(); + +export const mockConfig = { + enabled: true, + host: 'http://localhost:3002', + accessCheckTimeout: 5000, + accessCheckTimeoutWarning: 300, +} as ConfigType; + +/** + * This is useful for tests that don't use either config or log, + * but should still pass them in to pass Typescript definitions + */ +export const mockDependencies = { + // Mock router should be handled on a per-test basis + config: mockConfig, + log: mockLogger, +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 77289810049f5..d5b1bc5003456 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingSystemMock } from 'src/core/server/mocks'; -import { MockRouter, mockConfig } from '../__mocks__'; +import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; import { registerEnginesRoute } from './engines'; @@ -27,12 +26,11 @@ describe('engine routes', () => { }, }; - const mockRouter = new MockRouter({ method: 'get', payload: 'query' }); - const mockLogger = loggingSystemMock.create().get(); + let mockRouter: MockRouter; beforeEach(() => { jest.clearAllMocks(); - mockRouter.createRouter(); + mockRouter = new MockRouter({ method: 'get', payload: 'query' }); registerEnginesRoute({ router: mockRouter.router, @@ -57,7 +55,6 @@ describe('engine routes', () => { expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } }, - headers: { 'content-type': 'application/json' }, }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index c32cbc0e74d71..ca83c0e187ddb 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -43,10 +43,7 @@ export function registerEnginesRoute({ router, config, log }: IRouteDependencies Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number'; if (hasValidData) { - return response.ok({ - body: engines, - headers: { 'content-type': 'application/json' }, - }); + return response.ok({ body: engines }); } else { // Either a completely incorrect Enterprise Search host URL was configured, or App Search is returning bad data throw new Error(`Invalid data received from App Search: ${JSON.stringify(engines)}`); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts index bdf1e681f8848..e2d5fbcec3705 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -5,7 +5,7 @@ */ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; -import { MockRouter } from '../__mocks__/router.mock'; +import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; import { registerTelemetryRoute } from './telemetry'; @@ -20,18 +20,18 @@ import { incrementUICounter } from '../../collectors/app_search/telemetry'; * is tested more thoroughly in the collectors/telemetry tests. */ describe('App Search Telemetry API', () => { - const mockRouter = new MockRouter({ method: 'put', payload: 'body' }); - const mockLogger = loggingSystemMock.create().get(); + let mockRouter: MockRouter; beforeEach(() => { jest.clearAllMocks(); - mockRouter.createRouter(); + mockRouter = new MockRouter({ method: 'put', payload: 'body' }); registerTelemetryRoute({ router: mockRouter.router, - getSavedObjectsService: () => savedObjectsServiceMock.create(), + getSavedObjectsService: () => savedObjectsServiceMock.createStartContract(), log: mockLogger, - } as any); + config: mockConfig, + }); }); describe('PUT /api/app_search/telemetry', () => { @@ -71,6 +71,11 @@ describe('App Search Telemetry API', () => { expect(incrementUICounter).not.toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled(); 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' + ) + ); }); describe('validates', () => { @@ -84,17 +89,17 @@ describe('App Search Telemetry API', () => { mockRouter.shouldThrow(request); }); - describe('wrong metric type', () => { + it('wrong metric type', () => { const request = { body: { action: 'clicked', metric: true } }; mockRouter.shouldThrow(request); }); - describe('action is missing', () => { + it('action is missing', () => { const request = { body: { metric: 'engines_overview' } }; mockRouter.shouldThrow(request); }); - describe('metric is missing', () => { + it('metric is missing', () => { const request = { body: { action: 'error' } }; mockRouter.shouldThrow(request); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts index 6fbe4d99e9d46..846aae3fce56f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MockRouter } from '../__mocks__/router.mock'; +import { MockRouter, mockDependencies } from '../__mocks__'; jest.mock('../../lib/enterprise_search_config_api', () => ({ callEnterpriseSearchConfigAPI: jest.fn(), @@ -14,17 +14,15 @@ import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_confi import { registerPublicUrlRoute } from './public_url'; describe('Enterprise Search Public URL API', () => { - const mockRouter = new MockRouter({ method: 'get' }); + let mockRouter: MockRouter; beforeEach(() => { - jest.clearAllMocks(); - mockRouter.createRouter(); + mockRouter = new MockRouter({ method: 'get' }); registerPublicUrlRoute({ + ...mockDependencies, router: mockRouter.router, - config: {}, - log: {}, - } as any); + }); }); describe('GET /api/enterprise_search/public_url', () => { 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 703451de55cfd..99f91407dc1d2 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -35,7 +35,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { case 'dual_privileges_all': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring and enterprise_search is enabled + // everything except ml and monitoring is enabled const expected = mapValues( uiCapabilities.value!.catalogue, (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' @@ -47,7 +47,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { case 'dual_privileges_read': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring and enterprise_search is enabled + // everything except ml and monitoring and enterprise search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId)