mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
* 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 * Update URL casing to match Kibana best practices - URL casing appears to be snake_casing, but kibana.json casing appears to be camelCase * Register App Search plugin in Home Feature Catalogue * 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! * Fix appSearch.host config setting to be optional - instead of crashing folks on load * 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 * Set up React Router URL structure * 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) * 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 * Added server unit tests (#2) * Added unit test for server * PR Feedback * 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 * 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 <constancecchen@users.noreply.github.com> Co-authored-by: Constance <constancecchen@users.noreply.github.com> * 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 * 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 * 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) * 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 * Update Setup Guide installation instructions (#9) Co-authored-by: Chris Cressman <chris@chriscressman.com> * [Refactor] DRY out route test helper * [Refactor] Rename public/test_utils to public/__mocks__ - to better follow/use jest setups and for .mock.ts suffixes * 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 * 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 `<Router history={params.history}>` from `<BrowserRouter basename={params.appBasePath}>` 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) * Add create_first_engine_button telemetry tracking to EmptyState * Switch plugin URLs back to /app/enterprise_search/app_search Now that https://github.com/elastic/kibana/pull/66455 has been merged in 🎉 * 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 * Update EngineOverviewHeader to disable button on prop * 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 * 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 <constancecchen@users.noreply.github.com> * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx Co-authored-by: Constance <constancecchen@users.noreply.github.com> * PR feedback - updated README * Additional lint fixes Co-authored-by: Constance <constancecchen@users.noreply.github.com> * Add README and CODEOWNERS (#15) * Add plugin README and CODEOWNERS * 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 * ❗ 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 * Fixed TS warnings in E2E tests (#17) * Feedback: Convert static CSS values to EUI variables where possible * 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 * 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 * Update copy per feedback from copy team * 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 * 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 * 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 * Fix failing feature control tests - Split up scenario cases as needed - Add plugin as an exception alongside ML & Monitoring * Address PR feedback - version: kibana - copy edits - Sass vars - code cleanup * 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 * Misc security feedback - remove set - remove unnecessary capabilities registration - telemetry namespace agnostic * 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) * Address feedback from Pierre - Remove unnecessary ServerConfigType - Remove unnecessary uiCapabilities - Move registerTelemetryRoute / SavedObjectsServiceStart workaround - Remove unnecessary license optional chaining * PR feedback Address type/typos * 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 * Fix failing tests * 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 * 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 Co-authored-by: Jason Stoltzfus <jastoltz24@gmail.com> Co-authored-by: Chris Cressman <chris@chriscressman.com> Co-authored-by: scottybollinger <scotty.bollinger@elastic.co> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> # Conflicts: # .github/CODEOWNERS # x-pack/scripts/functional_tests.js
This commit is contained in:
parent
c72fff66bf
commit
aa176aff38
111 changed files with 4966 additions and 20 deletions
12
.eslintrc.js
12
.eslintrc.js
|
@ -910,6 +910,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
|
||||
*/
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -566,6 +566,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;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"],
|
||||
|
|
25
x-pack/plugins/enterprise_search/README.md
Normal file
25
x-pack/plugins/enterprise_search/README.md
Normal file
|
@ -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](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
|
||||
|
||||
### 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).
|
7
x-pack/plugins/enterprise_search/common/constants.ts
Normal file
7
x-pack/plugins/enterprise_search/common/constants.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export const ENGINES_PAGE_SIZE = 10;
|
10
x-pack/plugins/enterprise_search/kibana.json
Normal file
10
x-pack/plugins/enterprise_search/kibana.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "enterpriseSearch",
|
||||
"version": "kibana",
|
||||
"kibanaVersion": "kibana",
|
||||
"requiredPlugins": ["home", "features", "licensing"],
|
||||
"configPath": ["enterpriseSearch"],
|
||||
"optionalPlugins": ["usageCollection", "security"],
|
||||
"server": true,
|
||||
"ui": true
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
||||
export { mockHistory } from './react_router_history.mock';
|
||||
export { mockKibanaContext } from './kibana_context.mock';
|
||||
export { mockLicenseContext } from './license_context.mock';
|
||||
export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock';
|
||||
export { shallowWithIntl } from './shallow_with_i18n.mock';
|
||||
|
||||
// Note: shallow_usecontext must be imported directly as a file
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { 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',
|
||||
};
|
|
@ -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(),
|
||||
};
|
|
@ -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 { 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 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 = mountWithContext(<Component />, { enterpriseSearchUrl: 'someOverride', license: {} });
|
||||
*/
|
||||
export const mountWithContext = (children: React.ReactNode, context?: object) => {
|
||||
return mount(
|
||||
<I18nProvider>
|
||||
<KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
|
||||
<LicenseContext.Provider value={{ ...mockLicenseContext, ...context }}>
|
||||
{children}
|
||||
</LicenseContext.Provider>
|
||||
</KibanaContext.Provider>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: React.ReactNode, context?: object) => {
|
||||
return mount(
|
||||
<KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
|
||||
{children}
|
||||
</KibanaContext.Provider>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
*/
|
|
@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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') as object),
|
||||
useContext: jest.fn(() => ({ ...mockKibanaContext, ...mockLicenseContext })),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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' }));
|
||||
* });
|
||||
*/
|
|
@ -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 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 <I18nProvider> which
|
||||
* fixes "Could not find required `intl` object" console errors when running tests
|
||||
*
|
||||
* Example usage (should be the same as shallow()):
|
||||
*
|
||||
* const wrapper = shallowWithIntl(<Component />);
|
||||
*/
|
||||
export const shallowWithIntl = (children: React.ReactNode) => {
|
||||
const context = { context: { intl } };
|
||||
|
||||
return shallow(<I18nProvider>{children}</I18nProvider>, context)
|
||||
.childAt(0)
|
||||
.dive(context)
|
||||
.shallow();
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.776 1.389a7.66 7.66 0 00-.725-.04v.001H9a7.65 7.65 0 00-.051 15.301v-.001H9a7.65 7.65 0 00.776-15.261zm-1.52 1.254a6.401 6.401 0 00.02 12.716l2.333-3.791a.875.875 0 00-.354-1.242l-3.07-1.534a2.125 2.125 0 01-.859-3.015l1.93-3.134zm1.489 12.714l1.929-3.134a2.125 2.125 0 00-.86-3.015l-3.07-1.534a.875.875 0 01-.353-1.242L9.724 2.64a6.401 6.401 0 01.02 12.717z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 531 B |
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5 11.915L27 5.277 19.5.937a7.002 7.002 0 00-7 0l-8 4.62A7 7 0 001 11.62v9.237a7 7 0 003.5 6.062l7.5 4.33V17.979a7 7 0 013.5-6.063zM10 27.785v-9.808a9 9 0 014.5-7.793l8.503-4.91L18.5 2.67a5.003 5.003 0 00-5 0l-8 4.619a5 5 0 00-2.5 4.33v9.238a5 5 0 002.5 4.33l4.5 2.598z" fill="#343741" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.409 13.55a7.09 7.09 0 011.035 1.711A6.93 6.93 0 0120 17.978v13.27l7.5-4.33a7 7 0 003.5-6.061v-9.238a6.992 6.992 0 00-1.587-4.422L18.409 13.55zm2.777.705A8.933 8.933 0 0122 17.978v9.807l4.5-2.598a5 5 0 002.5-4.33v-9.238c0-.588-.106-1.161-.303-1.7l-7.51 4.336z" fill="#017D73" />
|
||||
</svg>
|
After Width: | Height: | Size: 779 B |
|
@ -0,0 +1,4 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 9a5.002 5.002 0 005 5 5 5 0 10-5-5zm5.506 1.653L8.37 12.697a3.751 3.751 0 01-.003-7.394L7.402 7.04a1.625 1.625 0 00.519 2.142l1.465.976a.375.375 0 01.12.495zm1.092.607l-.777 1.4a3.751 3.751 0 00-.04-7.329L8.494 7.647a.375.375 0 00.12.495l1.465.976c.705.47.93 1.402.52 2.142z" fill="#000"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 1.375A5.125 5.125 0 001.375 6.5v5A5.125 5.125 0 006.5 16.625h5a5.125 5.125 0 005.125-5.125v-5A5.125 5.125 0 0011.5 1.375h-5zM2.625 6.5A3.875 3.875 0 016.5 2.625h5A3.875 3.875 0 0115.375 6.5v5a3.875 3.875 0 01-3.875 3.875h-5A3.875 3.875 0 012.625 11.5v-5z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 775 B |
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { 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';
|
||||
import { KibanaContext, IKibanaContext } from '../../../index';
|
||||
|
||||
import { EngineOverviewHeader } from '../engine_overview_header';
|
||||
|
||||
import './empty_states.scss';
|
||||
|
||||
export const EmptyState: React.FC = () => {
|
||||
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 (
|
||||
<EuiPage restrictWidth>
|
||||
<SetBreadcrumbs isRoot />
|
||||
|
||||
<EuiPageBody>
|
||||
<EngineOverviewHeader />
|
||||
<EuiPageContent className="emptyState">
|
||||
<EuiEmptyPrompt
|
||||
className="emptyState__prompt"
|
||||
iconType="eyeClosed"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.emptyState.title"
|
||||
defaultMessage="Create your first engine"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
titleSize="l"
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.emptyState.description1"
|
||||
defaultMessage="An App Search engine stores the documents for your search experience."
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<EuiButton iconType="popout" fill {...buttonProps}>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta"
|
||||
defaultMessage="Create an engine"
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Empty/Error UI states
|
||||
*/
|
||||
.emptyState {
|
||||
min-height: $euiSizeXXL * 11.25;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
&__prompt > .euiIcon {
|
||||
margin-bottom: $euiSizeS;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import '../../../__mocks__/shallow_usecontext.mock';
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui';
|
||||
|
||||
jest.mock('../../../shared/telemetry', () => ({
|
||||
sendTelemetry: jest.fn(),
|
||||
SendAppSearchTelemetry: jest.fn(),
|
||||
}));
|
||||
import { sendTelemetry } from '../../../shared/telemetry';
|
||||
|
||||
import { ErrorState, EmptyState, LoadingState } from './';
|
||||
|
||||
describe('ErrorState', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<ErrorState />);
|
||||
|
||||
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<EmptyState />);
|
||||
|
||||
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sends telemetry on create first engine click', () => {
|
||||
const wrapper = shallow(<EmptyState />);
|
||||
const prompt = wrapper.find(EuiEmptyPrompt).dive();
|
||||
const button = prompt.find(EuiButton);
|
||||
|
||||
button.simulate('click');
|
||||
expect(sendTelemetry).toHaveBeenCalled();
|
||||
(sendTelemetry as jest.Mock).mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadingState', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<LoadingState />);
|
||||
|
||||
expect(wrapper.find(EuiLoadingContent)).toHaveLength(2);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { EuiPage, EuiPageBody, 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';
|
||||
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
|
||||
import { KibanaContext, IKibanaContext } from '../../../index';
|
||||
import { EngineOverviewHeader } from '../engine_overview_header';
|
||||
|
||||
import './empty_states.scss';
|
||||
|
||||
export const ErrorState: React.FC = () => {
|
||||
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
return (
|
||||
<EuiPage restrictWidth>
|
||||
<SetBreadcrumbs isRoot />
|
||||
<SendTelemetry action="error" metric="cannot_connect" />
|
||||
|
||||
<EuiPageBody>
|
||||
<EngineOverviewHeader isButtonDisabled />
|
||||
<EuiPageContent className="emptyState">
|
||||
<EuiEmptyPrompt
|
||||
className="emptyState__prompt"
|
||||
iconType="alert"
|
||||
iconColor="danger"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.errorConnectingState.title"
|
||||
defaultMessage="Unable to connect"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
titleSize="l"
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description1"
|
||||
defaultMessage="We can’t establish a connection to App Search at the host URL: {enterpriseSearchUrl}"
|
||||
values={{
|
||||
enterpriseSearchUrl: <EuiCode>{enterpriseSearchUrl}</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<ol className="eui-textLeft">
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description2"
|
||||
defaultMessage="Ensure the host URL is configured correctly in {configFile}."
|
||||
values={{
|
||||
configFile: <EuiCode>config/kibana.yml</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description3"
|
||||
defaultMessage="Confirm that the App Search server is responsive."
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.errorConnectingState.description4"
|
||||
defaultMessage="Review the Setup guide or check your server log for {pluginLog} log messages."
|
||||
values={{
|
||||
pluginLog: <EuiCode>[enterpriseSearch][plugins]</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<EuiButton iconType="help" fill to="/setup_guide">
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.errorConnectingState.setupGuideCta"
|
||||
defaultMessage="Review setup guide"
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
};
|
|
@ -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 { LoadingState } from './loading_state';
|
||||
export { EmptyState } from './empty_state';
|
||||
export { ErrorState } from './error_state';
|
|
@ -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 React from 'react';
|
||||
import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui';
|
||||
|
||||
import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
|
||||
import { EngineOverviewHeader } from '../engine_overview_header';
|
||||
|
||||
import './empty_states.scss';
|
||||
|
||||
export const LoadingState: React.FC = () => {
|
||||
return (
|
||||
<EuiPage restrictWidth>
|
||||
<SetBreadcrumbs isRoot />
|
||||
|
||||
<EuiPageBody>
|
||||
<EngineOverviewHeader />
|
||||
<EuiPageContent className="emptyState">
|
||||
<EuiLoadingContent lines={5} />
|
||||
<EuiSpacer size="xxl" />
|
||||
<EuiLoadingContent lines={4} />
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
*/
|
||||
.engineOverview {
|
||||
width: 100%;
|
||||
|
||||
&__body {
|
||||
padding: $euiSize;
|
||||
|
||||
@include euiBreakpoint('m', 'l', 'xl') {
|
||||
padding: $euiSizeXL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.engineIcon {
|
||||
display: inline-block;
|
||||
width: $euiSize;
|
||||
height: $euiSize;
|
||||
margin-right: $euiSizeXS;
|
||||
}
|
|
@ -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 '../../../__mocks__/react_router_history.mock';
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { render, ReactWrapper } from 'enzyme';
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { KibanaContext } from '../../../';
|
||||
import { LicenseContext } from '../../../shared/licensing';
|
||||
import { mountWithContext, mockKibanaContext } from '../../../__mocks__';
|
||||
|
||||
import { EmptyState, ErrorState } from '../empty_states';
|
||||
import { EngineTable, IEngineTablePagination } 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)
|
||||
// TODO: Consider pulling this out to a renderWithContext mock/helper
|
||||
const wrapper: Cheerio = render(
|
||||
<I18nProvider>
|
||||
<KibanaContext.Provider value={{ http: {} }}>
|
||||
<LicenseContext.Provider value={{ license: {} }}>
|
||||
<EngineOverview />
|
||||
</LicenseContext.Provider>
|
||||
</KibanaContext.Provider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('happy-path states', () => {
|
||||
const mockedApiResponse = {
|
||||
results: [
|
||||
{
|
||||
name: 'hello-world',
|
||||
created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
|
||||
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: ReactWrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await mountWithApiMock({ get: mockApi });
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(EngineTable)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calls the engines API', () => {
|
||||
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', {
|
||||
query: {
|
||||
type: 'indexed',
|
||||
pageIndex: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
const getTablePagination: () => IEngineTablePagination = () =>
|
||||
wrapper.find(EngineTable).first().prop('pagination');
|
||||
|
||||
it('passes down page data from the API', () => {
|
||||
const pagination = getTablePagination();
|
||||
|
||||
expect(pagination.totalEngines).toEqual(100);
|
||||
expect(pagination.pageIndex).toEqual(0);
|
||||
});
|
||||
|
||||
it('re-polls the API on page change', async () => {
|
||||
await act(async () => getTablePagination().onPaginate(5));
|
||||
wrapper.update();
|
||||
|
||||
expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', {
|
||||
query: {
|
||||
type: 'indexed',
|
||||
pageIndex: 5,
|
||||
},
|
||||
});
|
||||
expect(getTablePagination().pageIndex).toEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when on a platinum license', () => {
|
||||
beforeAll(async () => {
|
||||
mockApi.mockClear();
|
||||
wrapper = await mountWithApiMock({
|
||||
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, license }: { get(): any; license?: object }) => {
|
||||
let wrapper: ReactWrapper | undefined;
|
||||
const httpMock = { ...mockKibanaContext.http, get };
|
||||
|
||||
// We get a lot of act() warning/errors in the terminal without this.
|
||||
// TBH, I don't fully understand why since Enzyme's mount is supposed to
|
||||
// have act() baked in - could be because of the wrapping context provider?
|
||||
await act(async () => {
|
||||
wrapper = mountWithContext(<EngineOverview />, { http: httpMock, license });
|
||||
});
|
||||
if (wrapper) {
|
||||
wrapper.update(); // This seems to be required for the DOM to actually update
|
||||
|
||||
return wrapper;
|
||||
} else {
|
||||
throw new Error('Could not mount wrapper');
|
||||
}
|
||||
};
|
||||
});
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
EuiPage,
|
||||
EuiPageBody,
|
||||
EuiPageContent,
|
||||
EuiPageContentHeader,
|
||||
EuiPageContentBody,
|
||||
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';
|
||||
import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing';
|
||||
import { KibanaContext, IKibanaContext } from '../../../index';
|
||||
|
||||
import EnginesIcon from '../../assets/engine.svg';
|
||||
import MetaEnginesIcon from '../../assets/meta_engine.svg';
|
||||
|
||||
import { LoadingState, EmptyState, ErrorState } from '../empty_states';
|
||||
import { EngineOverviewHeader } from '../engine_overview_header';
|
||||
import { EngineTable } from './engine_table';
|
||||
|
||||
import './engine_overview.scss';
|
||||
|
||||
interface IGetEnginesParams {
|
||||
type: string;
|
||||
pageIndex: number;
|
||||
}
|
||||
interface ISetEnginesCallbacks {
|
||||
setResults: React.Dispatch<React.SetStateAction<never[]>>;
|
||||
setResultsTotal: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export const EngineOverview: React.FC = () => {
|
||||
const { http } = useContext(KibanaContext) as IKibanaContext;
|
||||
const { license } = useContext(LicenseContext) as ILicenseContext;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 = async ({ type, pageIndex }: IGetEnginesParams) => {
|
||||
return await http.get('/api/app_search/engines', {
|
||||
query: { type, pageIndex },
|
||||
});
|
||||
};
|
||||
const setEnginesData = async (params: IGetEnginesParams, callbacks: ISetEnginesCallbacks) => {
|
||||
try {
|
||||
const response = await getEnginesData(params);
|
||||
|
||||
callbacks.setResults(response.results);
|
||||
callbacks.setResultsTotal(response.meta.page.total_results);
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setHasErrorConnecting(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const params = { type: 'indexed', pageIndex: enginesPage };
|
||||
const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal };
|
||||
|
||||
setEnginesData(params, callbacks);
|
||||
}, [enginesPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPlatinumLicense(license)) {
|
||||
const params = { type: 'meta', pageIndex: metaEnginesPage };
|
||||
const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal };
|
||||
|
||||
setEnginesData(params, callbacks);
|
||||
}
|
||||
}, [license, metaEnginesPage]);
|
||||
|
||||
if (hasErrorConnecting) return <ErrorState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (!engines.length) return <EmptyState />;
|
||||
|
||||
return (
|
||||
<EuiPage restrictWidth className="engineOverview">
|
||||
<SetBreadcrumbs isRoot />
|
||||
<SendTelemetry action="viewed" metric="engines_overview" />
|
||||
|
||||
<EuiPageBody>
|
||||
<EngineOverviewHeader />
|
||||
|
||||
<EuiPageContent panelPaddingSize="s" className="engineOverview__body">
|
||||
<EuiPageContentHeader>
|
||||
<EuiTitle size="s">
|
||||
<h2>
|
||||
<img src={EnginesIcon} alt="" className="engineIcon" />
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.enginesOverview.engines"
|
||||
defaultMessage="Engines"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiPageContentHeader>
|
||||
<EuiPageContentBody data-test-subj="appSearchEngines">
|
||||
<EngineTable
|
||||
data={engines}
|
||||
pagination={{
|
||||
totalEngines: enginesTotal,
|
||||
pageIndex: enginesPage - 1,
|
||||
onPaginate: setEnginesPage,
|
||||
}}
|
||||
/>
|
||||
</EuiPageContentBody>
|
||||
|
||||
{metaEngines.length > 0 && (
|
||||
<>
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiPageContentHeader>
|
||||
<EuiTitle size="s">
|
||||
<h2>
|
||||
<img src={MetaEnginesIcon} alt="" className="engineIcon" />
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines"
|
||||
defaultMessage="Meta Engines"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiPageContentHeader>
|
||||
<EuiPageContentBody data-test-subj="appSearchMetaEngines">
|
||||
<EngineTable
|
||||
data={metaEngines}
|
||||
pagination={{
|
||||
totalEngines: metaEnginesTotal,
|
||||
pageIndex: metaEnginesPage - 1,
|
||||
onPaginate: setMetaEnginesPage,
|
||||
}}
|
||||
/>
|
||||
</EuiPageContentBody>
|
||||
</>
|
||||
)}
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
};
|
|
@ -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 { mountWithContext } from '../../../__mocks__';
|
||||
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 = mountWithContext(
|
||||
<EngineTable
|
||||
data={[
|
||||
{
|
||||
name: 'test-engine',
|
||||
created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
|
||||
document_count: 99999,
|
||||
field_count: 10,
|
||||
},
|
||||
]}
|
||||
pagination={{
|
||||
totalEngines: 50,
|
||||
pageIndex: 0,
|
||||
onPaginate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
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 = mountWithContext(
|
||||
<EngineTable data={[]} pagination={{ totalEngines: 0, pageIndex: 0, onPaginate: () => {} }} />
|
||||
);
|
||||
const emptyTable = emptyWrapper.find(EuiBasicTable);
|
||||
expect(emptyTable.prop('pagination').pageIndex).toEqual(0);
|
||||
});
|
||||
});
|
|
@ -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 React, { useContext } from 'react';
|
||||
import { EuiBasicTable, EuiBasicTableColumn, 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';
|
||||
|
||||
import { ENGINES_PAGE_SIZE } from '../../../../../common/constants';
|
||||
|
||||
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;
|
||||
}
|
||||
export interface IOnChange {
|
||||
page: {
|
||||
index: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const EngineTable: React.FC<IEngineTableProps> = ({
|
||||
data,
|
||||
pagination: { totalEngines, pageIndex, onPaginate },
|
||||
}) => {
|
||||
const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext;
|
||||
const engineLinkProps = (name: string) => ({
|
||||
href: `${enterpriseSearchUrl}/as/engines/${name}`,
|
||||
target: '_blank',
|
||||
onClick: () =>
|
||||
sendTelemetry({
|
||||
http,
|
||||
product: 'app_search',
|
||||
action: 'clicked',
|
||||
metric: 'engine_table_link',
|
||||
}),
|
||||
});
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<IEngineTableData>> = [
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', {
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
render: (name: string) => (
|
||||
<EuiLink data-test-subj="engineNameLink" {...engineLinkProps(name)}>
|
||||
{name}
|
||||
</EuiLink>
|
||||
),
|
||||
width: '30%',
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt',
|
||||
{
|
||||
defaultMessage: 'Created At',
|
||||
}
|
||||
),
|
||||
dataType: 'string',
|
||||
render: (dateString: string) => (
|
||||
// e.g., January 1, 1970
|
||||
<FormattedDate value={new Date(dateString)} year="numeric" month="long" day="numeric" />
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'document_count',
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount',
|
||||
{
|
||||
defaultMessage: 'Document Count',
|
||||
}
|
||||
),
|
||||
dataType: 'number',
|
||||
render: (number: number) => <FormattedNumber value={number} />,
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'field_count',
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount',
|
||||
{
|
||||
defaultMessage: 'Field Count',
|
||||
}
|
||||
),
|
||||
dataType: 'number',
|
||||
render: (number: number) => <FormattedNumber value={number} />,
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions',
|
||||
{
|
||||
defaultMessage: 'Actions',
|
||||
}
|
||||
),
|
||||
dataType: 'string',
|
||||
render: (name: string) => (
|
||||
<EuiLink {...engineLinkProps(name)}>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.enginesOverview.table.action.manage"
|
||||
defaultMessage="Manage"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiBasicTable
|
||||
items={data}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize: ENGINES_PAGE_SIZE,
|
||||
totalItemCount: totalEngines,
|
||||
hidePerPageOptions: true,
|
||||
}}
|
||||
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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import '../../../__mocks__/shallow_usecontext.mock';
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
|
||||
import { sendTelemetry } from '../../../shared/telemetry';
|
||||
|
||||
import { EngineOverviewHeader } from '../engine_overview_header';
|
||||
|
||||
describe('EngineOverviewHeader', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<EngineOverviewHeader />);
|
||||
expect(wrapper.find('h1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a launch app search button that sends telemetry on click', () => {
|
||||
const wrapper = shallow(<EngineOverviewHeader />);
|
||||
const button = wrapper.find('[data-test-subj="launchButton"]');
|
||||
|
||||
expect(button.prop('href')).toBe('http://localhost:3002/as');
|
||||
expect(button.prop('isDisabled')).toBeFalsy();
|
||||
|
||||
button.simulate('click');
|
||||
expect(sendTelemetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a disabled button when isButtonDisabled is true', () => {
|
||||
const wrapper = shallow(<EngineOverviewHeader isButtonDisabled />);
|
||||
const button = wrapper.find('[data-test-subj="launchButton"]');
|
||||
|
||||
expect(button.prop('isDisabled')).toBe(true);
|
||||
expect(button.prop('href')).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiPageHeader,
|
||||
EuiPageHeaderSection,
|
||||
EuiTitle,
|
||||
EuiButton,
|
||||
EuiButtonProps,
|
||||
EuiLinkProps,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { sendTelemetry } from '../../../shared/telemetry';
|
||||
import { KibanaContext, IKibanaContext } from '../../../index';
|
||||
|
||||
interface IEngineOverviewHeaderProps {
|
||||
isButtonDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const EngineOverviewHeader: React.FC<IEngineOverviewHeaderProps> = ({
|
||||
isButtonDisabled,
|
||||
}) => {
|
||||
const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
const buttonProps = {
|
||||
fill: true,
|
||||
iconType: 'popout',
|
||||
'data-test-subj': 'launchButton',
|
||||
} as EuiButtonProps & EuiLinkProps;
|
||||
|
||||
if (isButtonDisabled) {
|
||||
buttonProps.isDisabled = true;
|
||||
} else {
|
||||
buttonProps.href = `${enterpriseSearchUrl}/as`;
|
||||
buttonProps.target = '_blank';
|
||||
buttonProps.onClick = () =>
|
||||
sendTelemetry({
|
||||
http,
|
||||
product: 'app_search',
|
||||
action: 'clicked',
|
||||
metric: 'header_launch_button',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPageHeader>
|
||||
<EuiPageHeaderSection>
|
||||
<EuiTitle size="l">
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.enginesOverview.title"
|
||||
defaultMessage="Engine Overview"
|
||||
/>
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
</EuiPageHeaderSection>
|
||||
<EuiPageHeaderSection>
|
||||
<EuiButton {...buttonProps}>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.productCta"
|
||||
defaultMessage="Launch App Search"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiPageHeaderSection>
|
||||
</EuiPageHeader>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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';
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { 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(<SetupGuide />);
|
||||
|
||||
expect(wrapper.find(SetupGuideLayout)).toHaveLength(1);
|
||||
expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { 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';
|
||||
|
||||
export const SetupGuide: React.FC = () => (
|
||||
<SetupGuideLayout
|
||||
productName={i18n.translate('xpack.enterpriseSearch.appSearch.productName', {
|
||||
defaultMessage: 'App Search',
|
||||
})}
|
||||
productEuiIcon="logoAppSearch"
|
||||
standardAuthLink="https://swiftype.com/documentation/app-search/self-managed/security#standard"
|
||||
elasticsearchNativeAuthLink="https://swiftype.com/documentation/app-search/self-managed/security#elasticsearch-native-realm"
|
||||
>
|
||||
<SetBreadcrumbs text="Setup Guide" />
|
||||
<SendTelemetry action="viewed" metric="setup_guide" />
|
||||
|
||||
<a
|
||||
href="https://www.elastic.co/webinars/getting-started-with-elastic-app-search"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
className="setupGuide__thumbnail"
|
||||
src={GettingStarted}
|
||||
alt={i18n.translate('xpack.enterpriseSearch.appSearch.setupGuide.videoAlt', {
|
||||
defaultMessage:
|
||||
"Getting started with App Search - in this short video we'll guide you through how to get App Search up and running",
|
||||
})}
|
||||
width="1280"
|
||||
height-="720"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<EuiTitle size="s">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.setupGuide.description"
|
||||
defaultMessage="Elastic App Search provides tools to design and deploy a powerful search to your websites and mobile applications."
|
||||
/>
|
||||
</p>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.appSearch.setupGuide.notConfigured"
|
||||
defaultMessage="App Search is not configured in your Kibana instance yet."
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</SetupGuideLayout>
|
||||
);
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import '../__mocks__/shallow_usecontext.mock';
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { SetupGuide } from './components/setup_guide';
|
||||
import { EngineOverview } from './components/engine_overview';
|
||||
|
||||
import { AppSearch } from './';
|
||||
|
||||
describe('App Search Routes', () => {
|
||||
describe('/', () => {
|
||||
it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => {
|
||||
(useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' }));
|
||||
const wrapper = shallow(<AppSearch />);
|
||||
|
||||
expect(wrapper.find(Redirect)).toHaveLength(1);
|
||||
expect(wrapper.find(EngineOverview)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders Engine Overview when enterpriseSearchUrl is set', () => {
|
||||
(useContext as jest.Mock).mockImplementationOnce(() => ({
|
||||
enterpriseSearchUrl: 'https://foo.bar',
|
||||
}));
|
||||
const wrapper = shallow(<AppSearch />);
|
||||
|
||||
expect(wrapper.find(EngineOverview)).toHaveLength(1);
|
||||
expect(wrapper.find(Redirect)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/setup_guide', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<AppSearch />);
|
||||
|
||||
expect(wrapper.find(SetupGuide)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { Route, Redirect } from 'react-router-dom';
|
||||
|
||||
import { KibanaContext, IKibanaContext } from '../index';
|
||||
|
||||
import { SetupGuide } from './components/setup_guide';
|
||||
import { EngineOverview } from './components/engine_overview';
|
||||
|
||||
export const AppSearch: React.FC = () => {
|
||||
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Route exact path="/">
|
||||
{!enterpriseSearchUrl ? <Redirect to="/setup_guide" /> : <EngineOverview />}
|
||||
</Route>
|
||||
<Route path="/setup_guide">
|
||||
<SetupGuide />
|
||||
</Route>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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(),
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('mounts and unmounts UI', () => {
|
||||
const MockApp = () => <div className="hello-world">Hello world!</div>;
|
||||
|
||||
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('.setupGuide')).not.toBeNull();
|
||||
});
|
||||
});
|
|
@ -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 ReactDOM from 'react-dom';
|
||||
import { Router } from 'react-router-dom';
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { CoreStart, AppMountParameters, HttpSetup, ChromeBreadcrumb } from 'src/core/public';
|
||||
import { ClientConfigType, PluginsSetup } from '../plugin';
|
||||
import { LicenseProvider } from './shared/licensing';
|
||||
|
||||
export interface IKibanaContext {
|
||||
enterpriseSearchUrl?: string;
|
||||
http: HttpSetup;
|
||||
setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void;
|
||||
}
|
||||
|
||||
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.FC,
|
||||
core: CoreStart,
|
||||
params: AppMountParameters,
|
||||
config: ClientConfigType,
|
||||
plugins: PluginsSetup
|
||||
) => {
|
||||
ReactDOM.render(
|
||||
<I18nProvider>
|
||||
<KibanaContext.Provider
|
||||
value={{
|
||||
http: core.http,
|
||||
enterpriseSearchUrl: config.host,
|
||||
setBreadcrumbs: core.chrome.setBreadcrumbs,
|
||||
}}
|
||||
>
|
||||
<LicenseProvider license$={plugins.licensing.license$}>
|
||||
<Router history={params.history}>
|
||||
<App />
|
||||
</Router>
|
||||
</LicenseProvider>
|
||||
</KibanaContext.Provider>
|
||||
</I18nProvider>,
|
||||
params.element
|
||||
);
|
||||
return () => ReactDOM.unmountComponentAtNode(params.element);
|
||||
};
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -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<string> => {
|
||||
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;
|
||||
};
|
|
@ -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';
|
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
* 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 { generateBreadcrumb } from './generate_breadcrumbs';
|
||||
import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './';
|
||||
|
||||
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';
|
||||
|
||||
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 }) as any;
|
||||
const event = { preventDefault: jest.fn() };
|
||||
breadcrumb.onClick(event);
|
||||
|
||||
expect(mockHistory.push).toHaveBeenCalled();
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
breadcrumb.onClick();
|
||||
|
||||
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('enterpriseSearchBreadcrumbs', () => {
|
||||
const breadCrumbs = [
|
||||
{
|
||||
text: 'Page 1',
|
||||
path: '/page1',
|
||||
},
|
||||
{
|
||||
text: 'Page 2',
|
||||
path: '/page2',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs);
|
||||
|
||||
it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => {
|
||||
expect(subject()).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
},
|
||||
{
|
||||
href: '/enterprise_search/page1',
|
||||
onClick: expect.any(Function),
|
||||
text: 'Page 1',
|
||||
},
|
||||
{
|
||||
href: '/enterprise_search/page2',
|
||||
onClick: expect.any(Function),
|
||||
text: 'Page 2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows just the root if breadcrumbs is empty', () => {
|
||||
expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('links', () => {
|
||||
const eventMock = {
|
||||
preventDefault: jest.fn(),
|
||||
} as any;
|
||||
|
||||
it('has Enterprise Search text first', () => {
|
||||
expect(subject()[0].onClick).toBeUndefined();
|
||||
});
|
||||
|
||||
it('has a link to page 1 second', () => {
|
||||
(subject()[1] as any).onClick(eventMock);
|
||||
expect(mockHistory.push).toHaveBeenCalledWith('/page1');
|
||||
});
|
||||
|
||||
it('has a link to page 2 last', () => {
|
||||
(subject()[2] as any).onClick(eventMock);
|
||||
expect(mockHistory.push).toHaveBeenCalledWith('/page2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('appSearchBreadcrumbs', () => {
|
||||
const breadCrumbs = [
|
||||
{
|
||||
text: 'Page 1',
|
||||
path: '/page1',
|
||||
},
|
||||
{
|
||||
text: 'Page 2',
|
||||
path: '/page2',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHistory.createHref.mockImplementation(
|
||||
({ pathname }: any) => `/enterprise_search/app_search${pathname}`
|
||||
);
|
||||
});
|
||||
|
||||
const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs);
|
||||
|
||||
it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => {
|
||||
expect(subject()).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
},
|
||||
{
|
||||
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/app_search/page2',
|
||||
onClick: expect.any(Function),
|
||||
text: 'Page 2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows just the root if breadcrumbs is empty', () => {
|
||||
expect(appSearchBreadcrumbs(mockHistory)()).toEqual([
|
||||
{
|
||||
text: 'Enterprise Search',
|
||||
},
|
||||
{
|
||||
href: '/enterprise_search/app_search/',
|
||||
onClick: expect.any(Function),
|
||||
text: 'App Search',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('links', () => {
|
||||
const eventMock = {
|
||||
preventDefault: jest.fn(),
|
||||
} as any;
|
||||
|
||||
it('has Enterprise Search text first', () => {
|
||||
expect(subject()[0].onClick).toBeUndefined();
|
||||
});
|
||||
|
||||
it('has a link to App Search second', () => {
|
||||
(subject()[1] as any).onClick(eventMock);
|
||||
expect(mockHistory.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('has a link to page 1 third', () => {
|
||||
(subject()[2] as any).onClick(eventMock);
|
||||
expect(mockHistory.push).toHaveBeenCalledWith('/page1');
|
||||
});
|
||||
|
||||
it('has a link to page 2 last', () => {
|
||||
(subject()[3] as any).onClick(eventMock);
|
||||
expect(mockHistory.push).toHaveBeenCalledWith('/page2');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { 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) => {
|
||||
const breadcrumb = { text } as EuiBreadcrumb;
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
export type TBreadcrumbs = IGenerateBreadcrumbProps[];
|
||||
|
||||
export const enterpriseSearchBreadcrumbs = (history: History) => (
|
||||
breadcrumbs: TBreadcrumbs = []
|
||||
) => [
|
||||
generateBreadcrumb({ text: 'Enterprise Search' }),
|
||||
...breadcrumbs.map(({ text, path }: IGenerateBreadcrumbProps) =>
|
||||
generateBreadcrumb({ text, path, history })
|
||||
),
|
||||
];
|
||||
|
||||
export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) =>
|
||||
enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]);
|
|
@ -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 { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs';
|
||||
export { appSearchBreadcrumbs } from './generate_breadcrumbs';
|
||||
export { SetAppSearchBreadcrumbs } from './set_breadcrumbs';
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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 '../../__mocks__/react_router_history.mock';
|
||||
import { mountWithKibanaContext } from '../../__mocks__';
|
||||
|
||||
jest.mock('./generate_breadcrumbs', () => ({ appSearchBreadcrumbs: jest.fn() }));
|
||||
import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './';
|
||||
|
||||
describe('SetAppSearchBreadcrumbs', () => {
|
||||
const setBreadcrumbs = jest.fn();
|
||||
const builtBreadcrumbs = [] as any;
|
||||
const appSearchBreadCrumbsInnerCall = jest.fn().mockReturnValue(builtBreadcrumbs);
|
||||
const appSearchBreadCrumbsOuterCall = jest.fn().mockReturnValue(appSearchBreadCrumbsInnerCall);
|
||||
(appSearchBreadcrumbs as jest.Mock).mockImplementation(appSearchBreadCrumbsOuterCall);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mountSetAppSearchBreadcrumbs = (props: any) => {
|
||||
return mountWithKibanaContext(<SetAppSearchBreadcrumbs {...props} />, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { useHistory } from 'react-router-dom';
|
||||
import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui';
|
||||
import { KibanaContext, IKibanaContext } from '../../index';
|
||||
import { appSearchBreadcrumbs, TBreadcrumbs } 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 IBreadcrumbProps {
|
||||
text: string;
|
||||
isRoot?: never;
|
||||
}
|
||||
interface IRootBreadcrumbProps {
|
||||
isRoot: true;
|
||||
text?: never;
|
||||
}
|
||||
|
||||
export const SetAppSearchBreadcrumbs: React.FC<IBreadcrumbProps | IRootBreadcrumbProps> = ({
|
||||
text,
|
||||
isRoot,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
const crumb = isRoot ? [] : [{ text, path: history.location.pathname }];
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs(appSearchBreadcrumbs(history)(crumb as TBreadcrumbs | []));
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
|
@ -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';
|
|
@ -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' } as any)).toEqual(true);
|
||||
});
|
||||
|
||||
it('is true for enterprise licenses', () => {
|
||||
expect(hasPlatinumLicense({ isActive: true, type: 'enterprise' } as any)).toEqual(true);
|
||||
});
|
||||
|
||||
it('is true for trial licenses', () => {
|
||||
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' } 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' } as any)).toEqual(false);
|
||||
expect(hasPlatinumLicense({ isActive: false, type: 'standard' } as any)).toEqual(false);
|
||||
expect(hasPlatinumLicense({ isActive: true, type: 'gold' } as any)).toEqual(false);
|
||||
});
|
||||
});
|
|
@ -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?: ILicense) => {
|
||||
return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type as string);
|
||||
};
|
|
@ -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, { useContext } from 'react';
|
||||
|
||||
import { mountWithContext } from '../../__mocks__';
|
||||
import { LicenseContext, ILicenseContext } from './';
|
||||
|
||||
describe('LicenseProvider', () => {
|
||||
const MockComponent: React.FC = () => {
|
||||
const { license } = useContext(LicenseContext) as ILicenseContext;
|
||||
return <div className="license-test">{license?.type}</div>;
|
||||
};
|
||||
|
||||
it('renders children', () => {
|
||||
const wrapper = mountWithContext(<MockComponent />, { license: { type: 'basic' } });
|
||||
|
||||
expect(wrapper.find('.license-test')).toHaveLength(1);
|
||||
expect(wrapper.text()).toEqual('basic');
|
||||
});
|
||||
});
|
|
@ -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<ILicense>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const LicenseContext = React.createContext({});
|
||||
|
||||
export const LicenseProvider: React.FC<ILicenseContextProps> = ({ license$, children }) => {
|
||||
// Listen for changes to license subscription
|
||||
const license = useObservable(license$);
|
||||
|
||||
// Render rest of application and pass down license via context
|
||||
return <LicenseContext.Provider value={{ license }} children={children} />;
|
||||
};
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { EuiLink, EuiButton } from '@elastic/eui';
|
||||
|
||||
import '../../__mocks__/react_router_history.mock';
|
||||
import { mockHistory } from '../../__mocks__';
|
||||
|
||||
import { EuiReactRouterLink, EuiReactRouterButton } from './eui_link';
|
||||
|
||||
describe('EUI & React Router Component Helpers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<EuiReactRouterLink to="/" />);
|
||||
|
||||
expect(wrapper.find(EuiLink)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders an EuiButton', () => {
|
||||
const wrapper = shallow(<EuiReactRouterButton to="/" />);
|
||||
|
||||
expect(wrapper.find(EuiButton)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes down all ...rest props', () => {
|
||||
const wrapper = shallow(<EuiReactRouterLink to="/" data-test-subj="foo" external={true} />);
|
||||
const link = wrapper.find(EuiLink);
|
||||
|
||||
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 = mount(<EuiReactRouterLink to="/foo/bar" />);
|
||||
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 = mount(<EuiReactRouterLink to="/bar/baz" />);
|
||||
|
||||
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 = mount(<EuiReactRouterLink to="/bar/baz" />);
|
||||
|
||||
const simulatedEvent = {
|
||||
shiftKey: true,
|
||||
target: { getAttribute: () => '_blank' },
|
||||
};
|
||||
wrapper.find(EuiLink).simulate('click', simulatedEvent);
|
||||
|
||||
expect(mockHistory.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { EuiLink, EuiButton, EuiButtonProps, EuiLinkAnchorProps } 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;
|
||||
}
|
||||
|
||||
export const EuiReactRouterHelper: React.FC<IEuiReactRouterProps> = ({ to, children }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const onClick = (event: React.MouseEvent) => {
|
||||
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 reactRouterProps = { href, onClick };
|
||||
return React.cloneElement(children as React.ReactElement, reactRouterProps);
|
||||
};
|
||||
|
||||
type TEuiReactRouterLinkProps = EuiLinkAnchorProps & IEuiReactRouterProps;
|
||||
type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps;
|
||||
|
||||
export const EuiReactRouterLink: React.FC<TEuiReactRouterLinkProps> = ({ to, ...rest }) => (
|
||||
<EuiReactRouterHelper to={to}>
|
||||
<EuiLink {...rest} />
|
||||
</EuiReactRouterHelper>
|
||||
);
|
||||
|
||||
export const EuiReactRouterButton: React.FC<TEuiReactRouterButtonProps> = ({ to, ...rest }) => (
|
||||
<EuiReactRouterHelper to={to}>
|
||||
<EuiButton {...rest} />
|
||||
</EuiReactRouterHelper>
|
||||
);
|
|
@ -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';
|
|
@ -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',
|
||||
},
|
||||
} as any;
|
||||
|
||||
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: string | null) => {
|
||||
return {
|
||||
getAttribute: () => value,
|
||||
};
|
||||
};
|
|
@ -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 { MouseEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Helper functions for determining which events we should
|
||||
* let browsers handle natively, e.g. new tabs/windows
|
||||
*/
|
||||
|
||||
type THandleEvent = (event: MouseEvent) => 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 element = event.target as HTMLElement;
|
||||
const target = element.getAttribute('target');
|
||||
return !!target && target !== '_self';
|
||||
};
|
|
@ -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';
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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
|
||||
*/
|
||||
.setupGuide {
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
|
||||
&__sidebar {
|
||||
flex-basis: $euiSizeXXL * 7.5;
|
||||
flex-shrink: 0;
|
||||
padding: $euiSizeL;
|
||||
margin-right: 0;
|
||||
|
||||
background-color: $euiColorLightestShade;
|
||||
border-color: $euiBorderColor;
|
||||
border-style: solid;
|
||||
border-width: 0 0 $euiBorderWidthThin 0; // bottom - mobile view
|
||||
|
||||
@include euiBreakpoint('m', 'l', 'xl') {
|
||||
border-width: 0 $euiBorderWidthThin 0 0; // right - desktop view
|
||||
}
|
||||
@include euiBreakpoint('m', 'l') {
|
||||
flex-basis: $euiSizeXXL * 10;
|
||||
}
|
||||
@include euiBreakpoint('xl') {
|
||||
flex-basis: $euiSizeXXL * 12.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
align-self: start;
|
||||
padding: $euiSizeL;
|
||||
|
||||
@include euiBreakpoint('l') {
|
||||
padding: $euiSizeXXL ($euiSizeXXL * 1.25);
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: $euiSizeL auto;
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
<SetupGuide productName="Enterprise Search" productEuiIcon="logoEnterpriseSearch">
|
||||
<p data-test-subj="test">Wow!</p>
|
||||
</SetupGuide>
|
||||
);
|
||||
|
||||
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(
|
||||
<SetupGuide
|
||||
productName="Foo"
|
||||
productEuiIcon="logoAppSearch"
|
||||
standardAuthLink="http://foo.com"
|
||||
elasticsearchNativeAuthLink="http://bar.com"
|
||||
>
|
||||
Baz
|
||||
</SetupGuide>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com');
|
||||
expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com');
|
||||
});
|
||||
});
|
|
@ -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<ISetupGuideProps> = ({
|
||||
children,
|
||||
productName,
|
||||
productEuiIcon,
|
||||
standardAuthLink,
|
||||
elasticsearchNativeAuthLink,
|
||||
}) => (
|
||||
<EuiPage className="setupGuide">
|
||||
<EuiPageSideBar className="setupGuide__sidebar">
|
||||
<EuiText color="subdued" size="s">
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.setupGuide.title"
|
||||
defaultMessage="Setup Guide"
|
||||
/>
|
||||
</strong>
|
||||
</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={productEuiIcon} size="l" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="m">
|
||||
<h1>{productName}</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
{children}
|
||||
</EuiPageSideBar>
|
||||
|
||||
<EuiPageBody className="setupGuide__body">
|
||||
<EuiPageContent>
|
||||
<EuiSteps
|
||||
headingElement="h2"
|
||||
steps={[
|
||||
{
|
||||
title: i18n.translate('xpack.enterpriseSearch.setupGuide.step1.title', {
|
||||
defaultMessage: 'Add your {productName} host URL to your Kibana configuration',
|
||||
values: { productName },
|
||||
}),
|
||||
children: (
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.setupGuide.step1.instruction1"
|
||||
defaultMessage="In your {configFile} file, set {configSetting} to the URL of your {productName} instance. For example:"
|
||||
values={{
|
||||
productName,
|
||||
configFile: <EuiCode>config/kibana.yml</EuiCode>,
|
||||
configSetting: <EuiCode>enterpriseSearch.host</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<EuiCodeBlock language="yml">
|
||||
enterpriseSearch.host: 'http://localhost:3002'
|
||||
</EuiCodeBlock>
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', {
|
||||
defaultMessage: 'Reload your Kibana instance',
|
||||
}),
|
||||
children: (
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.setupGuide.step2.instruction1"
|
||||
defaultMessage="Restart Kibana to pick up the configuration changes from the previous step."
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.setupGuide.step2.instruction2"
|
||||
defaultMessage="If you’re using {elasticsearchNativeAuthLink} in {productName}, you’re all set. Your users can now access {productName} in Kibana with their current {productName} access and permissions."
|
||||
values={{
|
||||
productName,
|
||||
elasticsearchNativeAuthLink: elasticsearchNativeAuthLink ? (
|
||||
<EuiLink href={elasticsearchNativeAuthLink} target="_blank">
|
||||
Elasticsearch Native Auth
|
||||
</EuiLink>
|
||||
) : (
|
||||
'Elasticsearch Native Auth'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', {
|
||||
defaultMessage: 'Troubleshooting issues',
|
||||
}),
|
||||
children: (
|
||||
<>
|
||||
<EuiAccordion
|
||||
buttonContent={i18n.translate(
|
||||
'xpack.enterpriseSearch.troubleshooting.differentEsClusters.title',
|
||||
{
|
||||
defaultMessage:
|
||||
'{productName} and Kibana are on different Elasticsearch clusters',
|
||||
values: { productName },
|
||||
}
|
||||
)}
|
||||
id="differentEsClusters"
|
||||
paddingSize="s"
|
||||
>
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.troubleshooting.differentEsClusters.description"
|
||||
defaultMessage="This plugin does not currently support {productName} and Kibana running on different clusters."
|
||||
values={{ productName }}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiAccordion>
|
||||
<EuiSpacer />
|
||||
<EuiAccordion
|
||||
buttonContent={i18n.translate(
|
||||
'xpack.enterpriseSearch.troubleshooting.differentAuth.title',
|
||||
{
|
||||
defaultMessage:
|
||||
'{productName} and Kibana are on different authentication methods',
|
||||
values: { productName },
|
||||
}
|
||||
)}
|
||||
id="differentAuth"
|
||||
paddingSize="s"
|
||||
>
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.troubleshooting.differentAuth.description"
|
||||
defaultMessage="This plugin does not currently support {productName} and Kibana operating on different authentication methods, for example, {productName} using a different SAML provider than Kibana."
|
||||
values={{ productName }}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiAccordion>
|
||||
<EuiSpacer />
|
||||
<EuiAccordion
|
||||
buttonContent={i18n.translate(
|
||||
'xpack.enterpriseSearch.troubleshooting.standardAuth.title',
|
||||
{
|
||||
defaultMessage: '{productName} on Standard authentication is not supported',
|
||||
values: { productName },
|
||||
}
|
||||
)}
|
||||
id="standardAuth"
|
||||
paddingSize="s"
|
||||
>
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.troubleshooting.standardAuth.description"
|
||||
defaultMessage="This plugin does not fully support {productName} on {standardAuthLink}. Users created in {productName} must have Kibana access. Users created in Kibana will not see {productName} in the navigation menu."
|
||||
values={{
|
||||
productName,
|
||||
standardAuthLink: standardAuthLink ? (
|
||||
<EuiLink href={standardAuthLink} target="_blank">
|
||||
Standard Auth
|
||||
</EuiLink>
|
||||
) : (
|
||||
'Standard Auth'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiAccordion>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
|
@ -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';
|
|
@ -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 { httpServiceMock } from 'src/core/public/mocks';
|
||||
import { mountWithKibanaContext } from '../../__mocks__';
|
||||
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' },
|
||||
body: '{"action":"viewed","metric":"setup_guide"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if the telemetry endpoint fails', () => {
|
||||
const httpRejectMock = sendTelemetry({
|
||||
http: { put: () => Promise.reject() },
|
||||
} as any);
|
||||
|
||||
expect(httpRejectMock).rejects.toThrow('Unable to send telemetry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('React component helpers', () => {
|
||||
it('SendAppSearchTelemetry component', () => {
|
||||
mountWithKibanaContext(<SendAppSearchTelemetry action="clicked" metric="button" />, {
|
||||
http: httpMock,
|
||||
});
|
||||
|
||||
expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"action":"clicked","metric":"button"}',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { HttpSetup } 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: HttpSetup;
|
||||
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' },
|
||||
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<ISendTelemetryProps> = ({ action, metric }) => {
|
||||
const { http } = useContext(KibanaContext) as IKibanaContext;
|
||||
|
||||
useEffect(() => {
|
||||
sendTelemetry({ http, action, metric, product: 'app_search' });
|
||||
}, [action, metric, http]);
|
||||
|
||||
return null;
|
||||
};
|
12
x-pack/plugins/enterprise_search/public/index.ts
Normal file
12
x-pack/plugins/enterprise_search/public/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PluginInitializerContext } from 'src/core/public';
|
||||
import { EnterpriseSearchPlugin } from './plugin';
|
||||
|
||||
export const plugin = (initializerContext: PluginInitializerContext) => {
|
||||
return new EnterpriseSearchPlugin(initializerContext);
|
||||
};
|
88
x-pack/plugins/enterprise_search/public/plugin.ts
Normal file
88
x-pack/plugins/enterprise_search/public/plugin.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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,
|
||||
HttpSetup,
|
||||
} from 'src/core/public';
|
||||
|
||||
import {
|
||||
FeatureCatalogueCategory,
|
||||
HomePublicPluginSetup,
|
||||
} from '../../../../src/plugins/home/public';
|
||||
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 {
|
||||
host?: string;
|
||||
}
|
||||
export interface PluginsSetup {
|
||||
home: HomePublicPluginSetup;
|
||||
licensing: LicensingPluginSetup;
|
||||
}
|
||||
|
||||
export class EnterpriseSearchPlugin implements Plugin {
|
||||
private config: ClientConfigType;
|
||||
private hasCheckedPublicUrl: boolean = false;
|
||||
|
||||
constructor(initializerContext: PluginInitializerContext) {
|
||||
this.config = initializerContext.config.get<ClientConfigType>();
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup, plugins: PluginsSetup) {
|
||||
const config = { host: this.config.host };
|
||||
|
||||
core.application.register({
|
||||
id: 'appSearch',
|
||||
title: 'App Search',
|
||||
appRoute: '/app/enterprise_search/app_search',
|
||||
category: DEFAULT_APP_CATEGORIES.enterpriseSearch,
|
||||
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');
|
||||
|
||||
return renderApp(AppSearch, coreStart, params, config, plugins);
|
||||
},
|
||||
});
|
||||
// TODO: Workplace Search will need to register its own plugin.
|
||||
|
||||
plugins.home.featureCatalogue.register({
|
||||
id: 'appSearch',
|
||||
title: 'App Search',
|
||||
icon: AppSearchLogo,
|
||||
description:
|
||||
'Leverage dashboards, analytics, and APIs for advanced application search made simple.',
|
||||
path: '/app/enterprise_search/app_search',
|
||||
category: FeatureCatalogueCategory.DATA,
|
||||
showOnHomePage: true,
|
||||
});
|
||||
// TODO: Workplace Search will need to register its own feature catalogue section/card.
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* 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';
|
||||
|
||||
jest.mock('../../../../../../src/core/server', () => ({
|
||||
SavedObjectsErrorHelpers: {
|
||||
isNotFoundError: jest.fn(),
|
||||
},
|
||||
}));
|
||||
import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
|
||||
|
||||
import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry';
|
||||
|
||||
describe('App Search Telemetry Usage Collector', () => {
|
||||
const mockLogger = loggingSystemMock.create().get();
|
||||
|
||||
const makeUsageCollectorStub = jest.fn();
|
||||
const registerStub = jest.fn();
|
||||
const usageCollectionMock = {
|
||||
makeUsageCollector: makeUsageCollectorStub,
|
||||
registerCollector: registerStub,
|
||||
} as any;
|
||||
|
||||
const savedObjectsRepoStub = {
|
||||
get: () => ({
|
||||
attributes: {
|
||||
'ui_viewed.setup_guide': 10,
|
||||
'ui_viewed.engines_overview': 20,
|
||||
'ui_error.cannot_connect': 3,
|
||||
'ui_clicked.create_first_engine_button': 40,
|
||||
'ui_clicked.header_launch_button': 50,
|
||||
'ui_clicked.engine_table_link': 60,
|
||||
},
|
||||
}),
|
||||
incrementCounter: jest.fn(),
|
||||
};
|
||||
const savedObjectsMock = {
|
||||
createInternalRepository: jest.fn(() => savedObjectsRepoStub),
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerTelemetryUsageCollector', () => {
|
||||
it('should make and register the usage collector', () => {
|
||||
registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
|
||||
|
||||
expect(registerStub).toHaveBeenCalledTimes(1);
|
||||
expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1);
|
||||
expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search');
|
||||
expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTelemetryMetrics', () => {
|
||||
it('should return existing saved objects data', async () => {
|
||||
registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
|
||||
const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
|
||||
|
||||
expect(savedObjectsCounts).toEqual({
|
||||
ui_viewed: {
|
||||
setup_guide: 10,
|
||||
engines_overview: 20,
|
||||
},
|
||||
ui_error: {
|
||||
cannot_connect: 3,
|
||||
},
|
||||
ui_clicked: {
|
||||
create_first_engine_button: 40,
|
||||
header_launch_button: 50,
|
||||
engine_table_link: 60,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a default telemetry object if no saved data exists', async () => {
|
||||
const emptySavedObjectsMock = {
|
||||
createInternalRepository: () => ({
|
||||
get: () => ({ attributes: null }),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger);
|
||||
const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
|
||||
|
||||
expect(savedObjectsCounts).toEqual({
|
||||
ui_viewed: {
|
||||
setup_guide: 0,
|
||||
engines_overview: 0,
|
||||
},
|
||||
ui_error: {
|
||||
cannot_connect: 0,
|
||||
},
|
||||
ui_clicked: {
|
||||
create_first_engine_button: 0,
|
||||
header_launch_button: 0,
|
||||
engine_table_link: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw but log a warning if saved objects errors', async () => {
|
||||
const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any;
|
||||
registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger);
|
||||
|
||||
// Without log warning (not found)
|
||||
(SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true);
|
||||
await makeUsageCollectorStub.mock.calls[0][0].fetch();
|
||||
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
|
||||
// With log warning
|
||||
(SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false);
|
||||
await makeUsageCollectorStub.mock.calls[0][0].fetch();
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementUICounter', () => {
|
||||
it('should increment the saved objects internal repository', async () => {
|
||||
const response = await incrementUICounter({
|
||||
savedObjects: savedObjectsMock,
|
||||
uiAction: 'ui_clicked',
|
||||
metric: 'button',
|
||||
});
|
||||
|
||||
expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith(
|
||||
'app_search_telemetry',
|
||||
'app_search_telemetry',
|
||||
'ui_clicked.button'
|
||||
);
|
||||
expect(response).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { get } from 'lodash';
|
||||
import {
|
||||
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;
|
||||
engines_overview: number;
|
||||
};
|
||||
ui_error: {
|
||||
cannot_connect: 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
|
||||
*/
|
||||
|
||||
export const registerTelemetryUsageCollector = (
|
||||
usageCollection: UsageCollectionSetup,
|
||||
savedObjects: SavedObjectsServiceStart,
|
||||
log: Logger
|
||||
) => {
|
||||
const telemetryUsageCollector = usageCollection.makeUsageCollector<ITelemetry>({
|
||||
type: 'app_search',
|
||||
fetch: async () => fetchTelemetryMetrics(savedObjects, log),
|
||||
isReady: () => true,
|
||||
schema: {
|
||||
ui_viewed: {
|
||||
setup_guide: { type: 'long' },
|
||||
engines_overview: { type: 'long' },
|
||||
},
|
||||
ui_error: {
|
||||
cannot_connect: { type: 'long' },
|
||||
},
|
||||
ui_clicked: {
|
||||
create_first_engine_button: { type: 'long' },
|
||||
header_launch_button: { type: 'long' },
|
||||
engine_table_link: { type: 'long' },
|
||||
},
|
||||
},
|
||||
});
|
||||
usageCollection.registerCollector(telemetryUsageCollector);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the aggregated telemetry metrics from our saved objects
|
||||
*/
|
||||
|
||||
const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => {
|
||||
const savedObjectsRepository = savedObjects.createInternalRepository();
|
||||
const savedObjectAttributes = (await getSavedObjectAttributesFromRepo(
|
||||
savedObjectsRepository,
|
||||
log
|
||||
)) as SavedObjectAttributes;
|
||||
|
||||
const defaultTelemetrySavedObject: ITelemetry = {
|
||||
ui_viewed: {
|
||||
setup_guide: 0,
|
||||
engines_overview: 0,
|
||||
},
|
||||
ui_error: {
|
||||
cannot_connect: 0,
|
||||
},
|
||||
ui_clicked: {
|
||||
create_first_engine_button: 0,
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function - fetches saved objects attributes
|
||||
*/
|
||||
|
||||
const getSavedObjectAttributesFromRepo = async (
|
||||
savedObjectsRepository: ISavedObjectsRepository,
|
||||
log: Logger
|
||||
) => {
|
||||
try {
|
||||
return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes;
|
||||
} catch (e) {
|
||||
if (!SavedObjectsErrorHelpers.isNotFoundError(e)) {
|
||||
log.warn(`Failed to retrieve App Search telemetry data: ${e}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set saved objection attributes - used by telemetry route
|
||||
*/
|
||||
|
||||
interface IIncrementUICounter {
|
||||
savedObjects: SavedObjectsServiceStart;
|
||||
uiAction: string;
|
||||
metric: string;
|
||||
}
|
||||
|
||||
export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) {
|
||||
const internalRepository = savedObjects.createInternalRepository();
|
||||
|
||||
await internalRepository.incrementCounter(
|
||||
AS_TELEMETRY_NAME,
|
||||
AS_TELEMETRY_NAME,
|
||||
`${uiAction}.${metric}` // e.g., ui_viewed.setup_guide
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
29
x-pack/plugins/enterprise_search/server/index.ts
Normal file
29
x-pack/plugins/enterprise_search/server/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { EnterpriseSearchPlugin } from './plugin';
|
||||
|
||||
export const plugin = (initializerContext: PluginInitializerContext) => {
|
||||
return new EnterpriseSearchPlugin(initializerContext);
|
||||
};
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
export type ConfigType = TypeOf<typeof configSchema>;
|
||||
|
||||
export const config: PluginConfigDescriptor<ConfigType> = {
|
||||
schema: configSchema,
|
||||
exposeToBrowser: {
|
||||
host: true,
|
||||
},
|
||||
};
|
128
x-pack/plugins/enterprise_search/server/lib/check_access.test.ts
Normal file
128
x-pack/plugins/enterprise_search/server/lib/check_access.test.ts
Normal file
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
76
x-pack/plugins/enterprise_search/server/lib/check_access.ts
Normal file
76
x-pack/plugins/enterprise_search/server/lib/check_access.ts
Normal file
|
@ -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 { ConfigType } from '../';
|
||||
|
||||
import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api';
|
||||
|
||||
interface ICheckAccess {
|
||||
request: KibanaRequest;
|
||||
security?: SecurityPluginSetup;
|
||||
config: ConfigType;
|
||||
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<IAccess> => {
|
||||
// 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<boolean> => {
|
||||
try {
|
||||
const { hasAllRequested } = await security.authz
|
||||
.checkPrivilegesWithRequest(request)
|
||||
.globally(security.authz.actions.ui.get('enterpriseSearch', '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;
|
||||
};
|
|
@ -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."
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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 { ConfigType } from '../';
|
||||
import { IAccess } from './check_access';
|
||||
|
||||
interface IParams {
|
||||
request: KibanaRequest;
|
||||
config: ConfigType;
|
||||
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<IReturn> => {
|
||||
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);
|
||||
}
|
||||
};
|
121
x-pack/plugins/enterprise_search/server/plugin.ts
Normal file
121
x-pack/plugins/enterprise_search/server/plugin.ts
Normal file
|
@ -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 { Observable } from 'rxjs';
|
||||
import { first } from 'rxjs/operators';
|
||||
import {
|
||||
Plugin,
|
||||
PluginInitializerContext,
|
||||
CoreSetup,
|
||||
Logger,
|
||||
SavedObjectsServiceStart,
|
||||
IRouter,
|
||||
KibanaRequest,
|
||||
} from 'src/core/server';
|
||||
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
||||
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';
|
||||
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;
|
||||
security?: SecurityPluginSetup;
|
||||
features: FeaturesPluginSetup;
|
||||
}
|
||||
|
||||
export interface IRouteDependencies {
|
||||
router: IRouter;
|
||||
config: ConfigType;
|
||||
log: Logger;
|
||||
getSavedObjectsService?(): SavedObjectsServiceStart;
|
||||
}
|
||||
|
||||
export class EnterpriseSearchPlugin implements Plugin {
|
||||
private config: Observable<ConfigType>;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(initializerContext: PluginInitializerContext) {
|
||||
this.config = initializerContext.config.create<ConfigType>();
|
||||
this.logger = initializerContext.logger.get();
|
||||
}
|
||||
|
||||
public async setup(
|
||||
{ capabilities, http, savedObjects, getStartServices }: CoreSetup,
|
||||
{ usageCollection, security, features }: PluginsSetup
|
||||
) {
|
||||
const config = await this.config.pipe(first()).toPromise();
|
||||
|
||||
/**
|
||||
* Register space/feature control
|
||||
*/
|
||||
features.registerFeature({
|
||||
id: 'enterpriseSearch',
|
||||
name: 'Enterprise Search',
|
||||
order: 0,
|
||||
icon: 'logoEnterpriseSearch',
|
||||
navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId
|
||||
app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
|
||||
catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
|
||||
privileges: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Register user access to the Enterprise Search plugins
|
||||
*/
|
||||
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
|
||||
*/
|
||||
const router = http.createRouter();
|
||||
const dependencies = { router, config, log: this.logger };
|
||||
|
||||
registerPublicUrlRoute(dependencies);
|
||||
registerEnginesRoute(dependencies);
|
||||
|
||||
/**
|
||||
* Bootstrap the routes, saved objects, and collector for telemetry
|
||||
*/
|
||||
savedObjects.registerType(appSearchTelemetryType);
|
||||
let savedObjectsStarted: SavedObjectsServiceStart;
|
||||
|
||||
getStartServices().then(([coreStart]) => {
|
||||
savedObjectsStarted = coreStart.savedObjects;
|
||||
if (usageCollection) {
|
||||
registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
|
||||
}
|
||||
});
|
||||
registerTelemetryRoute({
|
||||
...dependencies,
|
||||
getSavedObjectsService: () => savedObjectsStarted,
|
||||
});
|
||||
}
|
||||
|
||||
public start() {}
|
||||
|
||||
public stop() {}
|
||||
}
|
|
@ -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 { MockRouter } from './router.mock';
|
||||
export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock';
|
|
@ -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 { httpServiceMock, httpServerMock } from 'src/core/server/mocks';
|
||||
import {
|
||||
IRouter,
|
||||
KibanaRequest,
|
||||
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';
|
||||
|
||||
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<IRouter>;
|
||||
public method: methodType;
|
||||
public payload?: payloadType;
|
||||
public response = httpServerMock.createResponseFactory();
|
||||
|
||||
constructor({ method, payload }: IMockRouterProps) {
|
||||
this.createRouter();
|
||||
this.method = method;
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public createRouter = () => {
|
||||
this.router = httpServiceMock.createRouter();
|
||||
};
|
||||
|
||||
public callRoute = async (request: TMockRouterRequest) => {
|
||||
const [, handler] = this.router[this.method].mock.calls[0];
|
||||
|
||||
const context = {} as jest.Mocked<RequestHandlerContext>;
|
||||
await handler(context, httpServerMock.createKibanaRequest(request as any), this.response);
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema validation helpers
|
||||
*/
|
||||
|
||||
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<{}, {}, {}>;
|
||||
|
||||
const payloadValidation = validate[this.payload] as { validate(request: KibanaRequest): void };
|
||||
const payloadRequest = request[this.payload] as KibanaRequest;
|
||||
|
||||
payloadValidation.validate(payloadRequest);
|
||||
};
|
||||
|
||||
public shouldValidate = (request: TMockRouterRequest) => {
|
||||
expect(() => this.validateRoute(request)).not.toThrow();
|
||||
};
|
||||
|
||||
public shouldThrow = (request: TMockRouterRequest) => {
|
||||
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);
|
||||
// });
|
|
@ -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,
|
||||
};
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { MockRouter, mockConfig, mockLogger } from '../__mocks__';
|
||||
|
||||
import { registerEnginesRoute } from './engines';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
const fetch = jest.requireActual('node-fetch');
|
||||
const { Response } = fetch;
|
||||
const fetchMock = require('node-fetch') as jest.Mocked<typeof fetch>;
|
||||
|
||||
describe('engine routes', () => {
|
||||
describe('GET /api/app_search/engines', () => {
|
||||
const AUTH_HEADER = 'Basic 123';
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
authorization: AUTH_HEADER,
|
||||
},
|
||||
query: {
|
||||
type: 'indexed',
|
||||
pageIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
let mockRouter: MockRouter;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRouter = new MockRouter({ method: 'get', payload: 'query' });
|
||||
|
||||
registerEnginesRoute({
|
||||
router: mockRouter.router,
|
||||
log: mockLogger,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the underlying App Search API returns a 200', () => {
|
||||
beforeEach(() => {
|
||||
AppSearchAPI.shouldBeCalledWith(
|
||||
`http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`,
|
||||
{ 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 mockRouter.callRoute(mockRequest);
|
||||
|
||||
expect(mockRouter.response.ok).toHaveBeenCalledWith({
|
||||
body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the App Search URL is invalid', () => {
|
||||
beforeEach(() => {
|
||||
AppSearchAPI.shouldBeCalledWith(
|
||||
`http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`,
|
||||
{ headers: { Authorization: AUTH_HEADER } }
|
||||
).andReturnError();
|
||||
});
|
||||
|
||||
it('should return 404 with a message', async () => {
|
||||
await mockRouter.callRoute(mockRequest);
|
||||
|
||||
expect(mockRouter.response.notFound).toHaveBeenCalledWith({
|
||||
body: 'cannot-connect',
|
||||
});
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to 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%5Bcurrent%5D=1&page%5Bsize%5D=10`,
|
||||
{ headers: { Authorization: AUTH_HEADER } }
|
||||
).andReturnInvalidData();
|
||||
});
|
||||
|
||||
it('should return 404 with a message', async () => {
|
||||
await mockRouter.callRoute(mockRequest);
|
||||
|
||||
expect(mockRouter.response.notFound).toHaveBeenCalledWith({
|
||||
body: 'cannot-connect',
|
||||
});
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Cannot connect to App Search: Error: Invalid data received from App Search: {"foo":"bar"}'
|
||||
);
|
||||
expect(mockLogger.debug).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validates', () => {
|
||||
it('correctly', () => {
|
||||
const request = { query: { type: 'meta', pageIndex: 5 } };
|
||||
mockRouter.shouldValidate(request);
|
||||
});
|
||||
|
||||
it('wrong pageIndex type', () => {
|
||||
const request = { query: { type: 'indexed', pageIndex: 'indexed' } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
|
||||
it('wrong type string', () => {
|
||||
const request = { query: { type: 'invalid', pageIndex: 1 } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
|
||||
it('missing pageIndex', () => {
|
||||
const request = { query: { type: 'indexed' } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
|
||||
it('missing type', () => {
|
||||
const request = { query: { pageIndex: 1 } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
});
|
||||
|
||||
const AppSearchAPI = {
|
||||
shouldBeCalledWith(expectedUrl: string, expectedParams: object) {
|
||||
return {
|
||||
andReturn(response: object) {
|
||||
fetchMock.mockImplementation((url: string, params: object) => {
|
||||
expect(url).toEqual(expectedUrl);
|
||||
expect(params).toEqual(expectedParams);
|
||||
|
||||
return Promise.resolve(new Response(JSON.stringify(response)));
|
||||
});
|
||||
},
|
||||
andReturnInvalidData() {
|
||||
fetchMock.mockImplementation((url: string, params: object) => {
|
||||
expect(url).toEqual(expectedUrl);
|
||||
expect(params).toEqual(expectedParams);
|
||||
|
||||
return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' })));
|
||||
});
|
||||
},
|
||||
andReturnError() {
|
||||
fetchMock.mockImplementation((url: string, params: object) => {
|
||||
expect(url).toEqual(expectedUrl);
|
||||
expect(params).toEqual(expectedParams);
|
||||
|
||||
return Promise.reject('Failed');
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 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 }: IRouteDependencies) {
|
||||
router.get(
|
||||
{
|
||||
path: '/api/app_search/engines',
|
||||
validate: {
|
||||
query: schema.object({
|
||||
type: schema.oneOf([schema.literal('indexed'), schema.literal('meta')]),
|
||||
pageIndex: schema.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
try {
|
||||
const enterpriseSearchUrl = config.host as string;
|
||||
const { type, pageIndex } = request.query;
|
||||
|
||||
const params = querystring.stringify({
|
||||
type,
|
||||
'page[current]': pageIndex,
|
||||
'page[size]': ENGINES_PAGE_SIZE,
|
||||
});
|
||||
const url = `${encodeURI(enterpriseSearchUrl)}/as/engines/collection?${params}`;
|
||||
|
||||
const enginesResponse = await fetch(url, {
|
||||
headers: { Authorization: request.headers.authorization as string },
|
||||
});
|
||||
|
||||
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 });
|
||||
} 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 as string);
|
||||
|
||||
return response.notFound({ body: 'cannot-connect' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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, savedObjectsServiceMock } from 'src/core/server/mocks';
|
||||
import { MockRouter, mockConfig, mockLogger } from '../__mocks__';
|
||||
|
||||
import { registerTelemetryRoute } from './telemetry';
|
||||
|
||||
jest.mock('../../collectors/app_search/telemetry', () => ({
|
||||
incrementUICounter: jest.fn(),
|
||||
}));
|
||||
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', () => {
|
||||
let mockRouter: MockRouter;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRouter = new MockRouter({ method: 'put', payload: 'body' });
|
||||
|
||||
registerTelemetryRoute({
|
||||
router: mockRouter.router,
|
||||
getSavedObjectsService: () => savedObjectsServiceMock.createStartContract(),
|
||||
log: mockLogger,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/app_search/telemetry', () => {
|
||||
it('increments the saved objects counter', async () => {
|
||||
const successResponse = { success: true };
|
||||
(incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse));
|
||||
|
||||
await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } });
|
||||
|
||||
expect(incrementUICounter).toHaveBeenCalledWith({
|
||||
savedObjects: expect.any(Object),
|
||||
uiAction: 'ui_viewed',
|
||||
metric: 'setup_guide',
|
||||
});
|
||||
expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse });
|
||||
});
|
||||
|
||||
it('throws an error when incrementing fails', async () => {
|
||||
(incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed')));
|
||||
|
||||
await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } });
|
||||
|
||||
expect(incrementUICounter).toHaveBeenCalled();
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
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();
|
||||
expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual(
|
||||
expect.stringContaining(
|
||||
'App Search UI telemetry error: Error: Could not find Saved Objects service'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
describe('validates', () => {
|
||||
it('correctly', () => {
|
||||
const request = { body: { action: 'viewed', metric: 'setup_guide' } };
|
||||
mockRouter.shouldValidate(request);
|
||||
});
|
||||
|
||||
it('wrong action string', () => {
|
||||
const request = { body: { action: 'invalid', metric: 'setup_guide' } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
|
||||
it('wrong metric type', () => {
|
||||
const request = { body: { action: 'clicked', metric: true } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
|
||||
it('action is missing', () => {
|
||||
const request = { body: { metric: 'engines_overview' } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
|
||||
it('metric is missing', () => {
|
||||
const request = { body: { action: 'error' } };
|
||||
mockRouter.shouldThrow(request);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { schema } from '@kbn/config-schema';
|
||||
|
||||
import { IRouteDependencies } from '../../plugin';
|
||||
import { incrementUICounter } from '../../collectors/app_search/telemetry';
|
||||
|
||||
export function registerTelemetryRoute({
|
||||
router,
|
||||
getSavedObjectsService,
|
||||
log,
|
||||
}: IRouteDependencies) {
|
||||
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 {
|
||||
if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service');
|
||||
|
||||
return response.ok({
|
||||
body: await incrementUICounter({
|
||||
savedObjects: getSavedObjectsService(),
|
||||
uiAction: `ui_${action}`,
|
||||
metric,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`);
|
||||
return response.internalError({ body: 'App Search UI telemetry failed' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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, mockDependencies } from '../__mocks__';
|
||||
|
||||
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', () => {
|
||||
let mockRouter: MockRouter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouter = new MockRouter({ method: 'get' });
|
||||
|
||||
registerPublicUrlRoute({
|
||||
...mockDependencies,
|
||||
router: mockRouter.router,
|
||||
});
|
||||
});
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { SavedObjectsType } from 'src/core/server';
|
||||
import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry';
|
||||
|
||||
export const appSearchTelemetryType: SavedObjectsType = {
|
||||
name: AS_TELEMETRY_NAME,
|
||||
hidden: false,
|
||||
namespaceType: 'agnostic',
|
||||
mappings: {
|
||||
dynamic: false,
|
||||
properties: {},
|
||||
},
|
||||
};
|
|
@ -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('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'),
|
||||
|
@ -450,6 +453,7 @@ describe('features', () => {
|
|||
actions.ui.get('management', 'kibana', 'spaces'),
|
||||
]
|
||||
: []),
|
||||
...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', '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('enterpriseSearch', '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('enterpriseSearch', '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('enterpriseSearch', '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('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'),
|
||||
|
@ -1189,6 +1197,7 @@ describe('subFeatures', () => {
|
|||
actions.space.manage,
|
||||
actions.ui.get('spaces', 'manage'),
|
||||
actions.ui.get('management', 'kibana', 'spaces'),
|
||||
actions.ui.get('enterpriseSearch', '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('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'),
|
||||
|
@ -1477,6 +1487,7 @@ describe('subFeatures', () => {
|
|||
actions.space.manage,
|
||||
actions.ui.get('spaces', 'manage'),
|
||||
actions.ui.get('management', 'kibana', 'spaces'),
|
||||
actions.ui.get('enterpriseSearch', '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('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'),
|
||||
|
|
|
@ -101,6 +101,7 @@ export function privilegesFactory(
|
|||
actions.space.manage,
|
||||
actions.ui.get('spaces', 'manage'),
|
||||
actions.ui.get('management', 'kibana', 'spaces'),
|
||||
actions.ui.get('enterpriseSearch', 'all'),
|
||||
...allActions,
|
||||
],
|
||||
read: [actions.login, actions.version, ...readActions],
|
||||
|
|
|
@ -7,6 +7,40 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"app_search": {
|
||||
"properties": {
|
||||
"ui_viewed": {
|
||||
"properties": {
|
||||
"setup_guide": {
|
||||
"type": "long"
|
||||
},
|
||||
"engines_overview": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui_error": {
|
||||
"properties": {
|
||||
"cannot_connect": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui_clicked": {
|
||||
"properties": {
|
||||
"create_first_engine_button": {
|
||||
"type": "long"
|
||||
},
|
||||
"header_launch_button": {
|
||||
"type": "long"
|
||||
},
|
||||
"engine_table_link": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileUploadTelemetry": {
|
||||
"properties": {
|
||||
"filesUploadedTotalCount": {
|
||||
|
|
|
@ -53,4 +53,5 @@ require('@kbn/test').runTestsCli([
|
|||
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'),
|
||||
]);
|
||||
|
|
|
@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'visualize',
|
||||
'dashboard',
|
||||
'dev_tools',
|
||||
'enterpriseSearch',
|
||||
'advancedSettings',
|
||||
'indexPatterns',
|
||||
'timelion',
|
||||
|
|
41
x-pack/test/functional_enterprise_search/README.md
Normal file
41
x-pack/test/functional_enterprise_search/README.md
Normal file
|
@ -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 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
|
||||
```
|
||||
|
||||
## Enterprise Search Requirement
|
||||
|
||||
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.
|
||||
|
||||
```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.
|
|
@ -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/common';
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Enterprise Search', function () {
|
||||
this.tags('ciGroup10');
|
||||
|
||||
loadTestFile(require.resolve('./app_search/setup_guide'));
|
||||
});
|
||||
}
|
20
x-pack/test/functional_enterprise_search/base_config.ts
Normal file
20
x-pack/test/functional_enterprise_search/base_config.ts
Normal file
|
@ -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,
|
||||
};
|
||||
}
|
12
x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts
vendored
Normal file
12
x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { GenericFtrProviderContext } from '@kbn/test/types/ftr';
|
||||
|
||||
import { pageObjects } from './page_objects';
|
||||
import { services } from './services';
|
||||
|
||||
export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
|
|
@ -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 { FtrProviderContext } from '../ftr_provider_context';
|
||||
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(): Promise<void> {
|
||||
return await PageObjects.common.navigateToApp('enterprise_search/app_search');
|
||||
},
|
||||
|
||||
async getEngineLinks(): Promise<WebElementWrapper[]> {
|
||||
const engines = await testSubjects.find('appSearchEngines');
|
||||
return await testSubjects.findAllDescendant('engineNameLink', engines);
|
||||
},
|
||||
|
||||
async getMetaEngineLinks(): Promise<WebElementWrapper[]> {
|
||||
const metaEngines = await testSubjects.find('appSearchMetaEngines');
|
||||
return await testSubjects.findAllDescendant('engineNameLink', metaEngines);
|
||||
},
|
||||
};
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue