From aa176aff38c84a24d1a4cdc67fe037a50dafa9f7 Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 9 Jul 2020 15:12:57 -0700 Subject: [PATCH] New Enterprise Search Kibana plugin (#66922) (#71314) * 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 Co-authored-by: Constance * 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 * [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 `` from `` since they're deprecating appBasePath * Update breadcrumbs helper to account for new URLs - Remove path for Enterprise Search breadcrumb, since '/app/enterprise_search' will not link anywhere meaningful for the foreseeable future, so the Enterprise Search root should not go anywhere - Update App Search helper to go to root path, per new React Router setup Test changes: - Mock custom basepath for App Search tests - Swap enterpriseSearchBreadcrumbs and appSearchBreadcrumbs test order (since the latter overrides the default mock) * 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 :tada: * 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 * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx Co-authored-by: Constance * PR feedback - updated README * Additional lint fixes Co-authored-by: Constance * 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 * :exclamation: Refactor React Router EUI link/button helpers - in order to fix typescript errors - this changes the component logic significantly to a react render prop, so that the Link and Button components can have different types - however, end behavior should still remain the same * Fix telemetry helper errors * Minor unused var cleanup in plugin files * Fix telemetry collector/savedobjects errors * Fix MockRouter type errors and add IRouteDependencies export - routes will use IRouteDependencies in the next few commits * Fix engines route errors * Fix telemetry route errors * Remove any type from source code - thanks to Scotty for the inspiration * Add eslint rules for Enterprise Search plugin - Add checks for type any, but only on non-test files - Disable react-hooks/exhaustive-deps, since we're already disabling it in a few files and other plugins also have it turned off * Cover uncovered lines in engines_table and telemetry tests * 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 Co-authored-by: Chris Cressman Co-authored-by: scottybollinger Co-authored-by: Elastic Machine # Conflicts: # .github/CODEOWNERS # x-pack/scripts/functional_tests.js --- .eslintrc.js | 12 + .../collapsible_nav.test.tsx.snap | 6 +- src/core/public/public.api.md | 6 + src/core/server/server.api.md | 6 + src/core/utils/default_app_categories.ts | 12 +- x-pack/.i18nrc.json | 1 + x-pack/plugins/enterprise_search/README.md | 25 ++ .../enterprise_search/common/constants.ts | 7 + x-pack/plugins/enterprise_search/kibana.json | 10 + .../public/applications/__mocks__/index.ts | 13 + .../__mocks__/kibana_context.mock.ts | 17 ++ .../__mocks__/license_context.mock.ts | 11 + .../__mocks__/mount_with_context.mock.tsx | 49 ++++ .../__mocks__/react_router_history.mock.ts | 25 ++ .../__mocks__/shallow_usecontext.mock.ts | 40 ++++ .../__mocks__/shallow_with_i18n.mock.tsx | 30 +++ .../applications/app_search/assets/engine.svg | 3 + .../app_search/assets/getting_started.png | Bin 0 -> 92044 bytes .../applications/app_search/assets/logo.svg | 4 + .../app_search/assets/meta_engine.svg | 4 + .../components/empty_states/empty_state.tsx | 74 ++++++ .../components/empty_states/empty_states.scss | 19 ++ .../empty_states/empty_states.test.tsx | 53 ++++ .../components/empty_states/error_state.tsx | 95 ++++++++ .../components/empty_states/index.ts | 9 + .../components/empty_states/loading_state.tsx | 30 +++ .../engine_overview/engine_overview.scss | 27 +++ .../engine_overview/engine_overview.test.tsx | 171 +++++++++++++ .../engine_overview/engine_overview.tsx | 155 ++++++++++++ .../engine_overview/engine_table.test.tsx | 80 +++++++ .../engine_overview/engine_table.tsx | 153 ++++++++++++ .../components/engine_overview/index.ts | 7 + .../engine_overview_header.test.tsx | 41 ++++ .../engine_overview_header.tsx | 72 ++++++ .../engine_overview_header/index.ts | 7 + .../components/setup_guide/index.ts | 7 + .../setup_guide/setup_guide.test.tsx | 21 ++ .../components/setup_guide/setup_guide.tsx | 64 +++++ .../applications/app_search/index.test.tsx | 46 ++++ .../public/applications/app_search/index.tsx | 28 +++ .../public/applications/index.test.tsx | 40 ++++ .../public/applications/index.tsx | 56 +++++ .../get_enterprise_search_url.test.ts | 30 +++ .../get_enterprise_search_url.ts | 27 +++ .../shared/enterprise_search_url/index.ts | 7 + .../generate_breadcrumbs.test.ts | 206 ++++++++++++++++ .../generate_breadcrumbs.ts | 54 +++++ .../shared/kibana_breadcrumbs/index.ts | 9 + .../set_breadcrumbs.test.tsx | 63 +++++ .../kibana_breadcrumbs/set_breadcrumbs.tsx | 43 ++++ .../applications/shared/licensing/index.ts | 8 + .../shared/licensing/license_checks.test.ts | 33 +++ .../shared/licensing/license_checks.ts | 11 + .../shared/licensing/license_context.test.tsx | 24 ++ .../shared/licensing/license_context.tsx | 29 +++ .../react_router_helpers/eui_link.test.tsx | 77 ++++++ .../shared/react_router_helpers/eui_link.tsx | 57 +++++ .../shared/react_router_helpers/index.ts | 9 + .../react_router_helpers/link_events.test.ts | 102 ++++++++ .../react_router_helpers/link_events.ts | 31 +++ .../applications/shared/setup_guide/index.ts | 7 + .../shared/setup_guide/setup_guide.scss | 51 ++++ .../shared/setup_guide/setup_guide.test.tsx | 44 ++++ .../shared/setup_guide/setup_guide.tsx | 226 ++++++++++++++++++ .../applications/shared/telemetry/index.ts | 8 + .../shared/telemetry/send_telemetry.test.tsx | 56 +++++ .../shared/telemetry/send_telemetry.tsx | 50 ++++ .../plugins/enterprise_search/public/index.ts | 12 + .../enterprise_search/public/plugin.ts | 88 +++++++ .../collectors/app_search/telemetry.test.ts | 143 +++++++++++ .../server/collectors/app_search/telemetry.ts | 156 ++++++++++++ .../plugins/enterprise_search/server/index.ts | 29 +++ .../server/lib/check_access.test.ts | 128 ++++++++++ .../server/lib/check_access.ts | 76 ++++++ .../lib/enterprise_search_config_api.test.ts | 111 +++++++++ .../lib/enterprise_search_config_api.ts | 78 ++++++ .../enterprise_search/server/plugin.ts | 121 ++++++++++ .../server/routes/__mocks__/index.ts | 8 + .../server/routes/__mocks__/router.mock.ts | 102 ++++++++ .../__mocks__/routerDependencies.mock.ts | 27 +++ .../server/routes/app_search/engines.test.ts | 160 +++++++++++++ .../server/routes/app_search/engines.ts | 59 +++++ .../routes/app_search/telemetry.test.ts | 108 +++++++++ .../server/routes/app_search/telemetry.ts | 50 ++++ .../enterprise_search/public_url.test.ts | 52 ++++ .../routes/enterprise_search/public_url.ts | 26 ++ .../saved_objects/app_search/telemetry.ts | 19 ++ .../privileges/privileges.test.ts | 14 +- .../authorization/privileges/privileges.ts | 1 + .../schema/xpack_plugins.json | 34 +++ x-pack/scripts/functional_tests.js | 1 + .../apis/features/features/features.ts | 1 + .../functional_enterprise_search/README.md | 41 ++++ .../app_search/engines.ts | 75 ++++++ .../with_host_configured/index.ts | 13 + .../app_search/setup_guide.ts | 36 +++ .../without_host_configured/index.ts | 15 ++ .../base_config.ts | 20 ++ .../ftr_provider_context.d.ts | 12 + .../page_objects/app_search.ts | 30 +++ .../page_objects/index.ts | 13 + .../services/app_search_client.ts | 121 ++++++++++ .../services/app_search_service.ts | 77 ++++++ .../services/index.ts | 13 + .../with_host_configured.config.ts | 31 +++ .../without_host_configured.config.ts | 23 ++ .../common/nav_links_builder.ts | 4 + .../security_and_spaces/tests/catalogue.ts | 16 +- .../security_and_spaces/tests/nav_links.ts | 12 +- .../security_only/tests/catalogue.ts | 16 +- .../security_only/tests/nav_links.ts | 10 +- 111 files changed, 4963 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/README.md create mode 100644 x-pack/plugins/enterprise_search/common/constants.ts create mode 100644 x-pack/plugins/enterprise_search/kibana.json create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx create mode 100644 x-pack/plugins/enterprise_search/public/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/plugin.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts create mode 100644 x-pack/plugins/enterprise_search/server/plugin.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts create mode 100644 x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts create mode 100644 x-pack/test/functional_enterprise_search/README.md create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/base_config.ts create mode 100644 x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/app_search.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/index.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_client.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_service.ts create mode 100644 x-pack/test/functional_enterprise_search/services/index.ts create mode 100644 x-pack/test/functional_enterprise_search/with_host_configured.config.ts create mode 100644 x-pack/test/functional_enterprise_search/without_host_configured.config.ts diff --git a/.eslintrc.js b/.eslintrc.js index 7c5396af839c2..a352b67e8d5da 100644 --- a/.eslintrc.js +++ b/.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 */ diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 9fee7b50f371b..1cfded4dc7b8f 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -149,7 +149,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoSecurity", "id": "security", "label": "Security", - "order": 3000, + "order": 4000, }, "data-test-subj": "siem", "href": "siem", @@ -164,7 +164,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "metrics", "href": "metrics", @@ -233,7 +233,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "logs", "href": "logs", diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 86e281a49b744..40fc3f977006f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -582,6 +582,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ euiIconType: string; order: number; }; + enterpriseSearch: { + id: string; + label: string; + order: number; + euiIconType: string; + }; observability: { id: string; label: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index efeafc9e68d35..95912c3af63e5 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -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; diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 5708bcfeac31a..cc9bfb1db04d5 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -29,20 +29,28 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({ euiIconType: 'logoKibana', order: 1000, }, + enterpriseSearch: { + id: 'enterpriseSearch', + label: i18n.translate('core.ui.enterpriseSearchNavList.label', { + defaultMessage: 'Enterprise Search', + }), + order: 2000, + euiIconType: 'logoEnterpriseSearch', + }, observability: { id: 'observability', label: i18n.translate('core.ui.observabilityNavList.label', { defaultMessage: 'Observability', }), euiIconType: 'logoObservability', - order: 2000, + order: 3000, }, security: { id: 'security', label: i18n.translate('core.ui.securityNavList.label', { defaultMessage: 'Security', }), - order: 3000, + order: 4000, euiIconType: 'logoSecurity', }, management: { diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 596ba17d343c0..d0055008eb9bf 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -16,6 +16,7 @@ "xpack.data": "plugins/data_enhanced", "xpack.embeddableEnhanced": "plugins/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", + "xpack.enterpriseSearch": "plugins/enterprise_search", "xpack.features": "plugins/features", "xpack.fileUpload": "plugins/file_upload", "xpack.globalSearch": ["plugins/global_search"], diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md new file mode 100644 index 0000000000000..8c316c848184b --- /dev/null +++ b/x-pack/plugins/enterprise_search/README.md @@ -0,0 +1,25 @@ +# Enterprise Search + +## Overview + +This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness. + +## Development + +1. When developing locally, Enterprise Search should be running locally alongside Kibana on `localhost:3002`. +2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'` +3. For faster QA/development, run Enterprise Search on [elasticsearch-native auth](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). diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts new file mode 100644 index 0000000000000..c134131caba75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ENGINES_PAGE_SIZE = 10; diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json new file mode 100644 index 0000000000000..9a2daefcd8c6e --- /dev/null +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "enterpriseSearch", + "version": "kibana", + "kibanaVersion": "kibana", + "requiredPlugins": ["home", "features", "licensing"], + "configPath": ["enterpriseSearch"], + "optionalPlugins": ["usageCollection", "security"], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts new file mode 100644 index 0000000000000..14fde357a980a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +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 diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts new file mode 100644 index 0000000000000..fcfa1b0a21f13 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from 'src/core/public/mocks'; + +/** + * A set of default Kibana context values to use across component tests. + * @see enterprise_search/public/index.tsx for the KibanaContext definition/import + */ +export const mockKibanaContext = { + http: httpServiceMock.createSetupContract(), + setBreadcrumbs: jest.fn(), + enterpriseSearchUrl: 'http://localhost:3002', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts new file mode 100644 index 0000000000000..7c37ecc7cde1b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { licensingMock } from '../../../../licensing/public/mocks'; + +export const mockLicenseContext = { + license: licensingMock.createLicense(), +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx new file mode 100644 index 0000000000000..dfcda544459d4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { 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(, { enterpriseSearchUrl: 'someOverride', license: {} }); + */ +export const mountWithContext = (children: React.ReactNode, context?: object) => { + return mount( + + + + {children} + + + + ); +}; + +/** + * This helper mounts a component with just the default KibanaContext - + * useful for isolated / helper components that only need this context + * + * Same usage/override functionality as mountWithContext + */ +export const mountWithKibanaContext = (children: React.ReactNode, context?: object) => { + return mount( + + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts new file mode 100644 index 0000000000000..fd422465d87f1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: This variable name MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +export const mockHistory = { + createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`), + push: jest.fn(), + location: { + pathname: '/current-path', + }, +}; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn(() => mockHistory), +})); + +/** + * For example usage, @see public/applications/shared/react_router_helpers/eui_link.test.tsx + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts new file mode 100644 index 0000000000000..767a52a75d1fb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -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' })); + * }); + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx new file mode 100644 index 0000000000000..ae7d0b09f9872 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; +import { IntlProvider } from 'react-intl'; + +const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {}); +const { intl } = intlProvider.getChildContext(); + +/** + * This helper shallow wraps a component with react-intl's which + * fixes "Could not find required `intl` object" console errors when running tests + * + * Example usage (should be the same as shallow()): + * + * const wrapper = shallowWithIntl(); + */ +export const shallowWithIntl = (children: React.ReactNode) => { + const context = { context: { intl } }; + + return shallow({children}, context) + .childAt(0) + .dive(context) + .shallow(); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg new file mode 100644 index 0000000000000..ceab918e92e70 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png new file mode 100644 index 0000000000000000000000000000000000000000..4d988d14f0483c31045783b714eedde9baac92b9 GIT binary patch literal 92044 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 b{wDtaU9#Ss{&OGS00000NkvXXu0mjf=D5g< literal 0 HcmV?d00001 diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg new file mode 100644 index 0000000000000..2284a425b5add --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg new file mode 100644 index 0000000000000..4e01e9a0b34fb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx new file mode 100644 index 0000000000000..9bb5cd3bffdf5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { 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 ( + + + + + + + + + + } + titleSize="l" + body={ +

+ +

+ } + actions={ + + + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss new file mode 100644 index 0000000000000..01b0903add559 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss @@ -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; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx new file mode 100644 index 0000000000000..12bf003564103 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -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(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + const button = prompt.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + (sendTelemetry as jest.Mock).mockClear(); + }); +}); + +describe('LoadingState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx new file mode 100644 index 0000000000000..d8eeff2aba1c6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -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 ( + + + + + + + + + + + } + titleSize="l" + body={ + <> +

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

+
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + [enterpriseSearch][plugins], + }} + /> +
  6. +
+ + } + actions={ + + + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts new file mode 100644 index 0000000000000..e92bf214c4cc7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LoadingState } from './loading_state'; +export { EmptyState } from './empty_state'; +export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx new file mode 100644 index 0000000000000..2be917c8df096 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 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 ( + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss new file mode 100644 index 0000000000000..2c7f7de6458e2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Engine Overview + */ +.engineOverview { + width: 100%; + + &__body { + padding: $euiSize; + + @include euiBreakpoint('m', 'l', 'xl') { + padding: $euiSizeXL; + } + } +} + +.engineIcon { + display: inline-block; + width: $euiSize; + height: $euiSize; + margin-right: $euiSizeXS; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx new file mode 100644 index 0000000000000..4d2a2ea1df9aa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -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( + + + + + + + + ); + + // 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(, { http: httpMock, license }); + }); + if (wrapper) { + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + } else { + throw new Error('Could not mount wrapper'); + } + }; +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx new file mode 100644 index 0000000000000..13d092a657d11 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -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>; + setResultsTotal: React.Dispatch>; +} + +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 ; + if (isLoading) return ; + if (!engines.length) return ; + + return ( + + + + + + + + + + +

+ + +

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

+ + +

+
+
+ + + + + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx new file mode 100644 index 0000000000000..46b6e61e352de --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; + +import { 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( + + ); + 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( + {} }} /> + ); + const emptyTable = emptyWrapper.find(EuiBasicTable); + expect(emptyTable.prop('pagination').pageIndex).toEqual(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx new file mode 100644 index 0000000000000..1e58d820dc83b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 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 = ({ + 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> = [ + { + field: 'name', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { + defaultMessage: 'Name', + }), + render: (name: string) => ( + + {name} + + ), + 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 + + ), + }, + { + field: 'document_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', + { + defaultMessage: 'Document Count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, + }, + { + field: 'field_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', + { + defaultMessage: 'Field Count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, + }, + { + field: 'name', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', + { + defaultMessage: 'Actions', + } + ), + dataType: 'string', + render: (name: string) => ( + + + + ), + align: 'right', + width: '100px', + }, + ]; + + return ( + { + const { index } = page; + onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0 + }} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts new file mode 100644 index 0000000000000..48b7645dc39e8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EngineOverview } from './engine_overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx new file mode 100644 index 0000000000000..2e49540270ef0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__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(); + expect(wrapper.find('h1')).toHaveLength(1); + }); + + it('renders a launch app search button that sends telemetry on click', () => { + const wrapper = shallow(); + 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(); + const button = wrapper.find('[data-test-subj="launchButton"]'); + + expect(button.prop('isDisabled')).toBe(true); + expect(button.prop('href')).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx new file mode 100644 index 0000000000000..9aafa8ec0380c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -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 = ({ + 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 ( + + + +

+ +

+
+
+ + + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts new file mode 100644 index 0000000000000..2d37f037e21e5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EngineOverviewHeader } from './engine_overview_header'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts new file mode 100644 index 0000000000000..c367424d375f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..82cc344d49632 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx new file mode 100644 index 0000000000000..df278bf938a69 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -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 = () => ( + + + + +
+ {i18n.translate('xpack.enterpriseSearch.appSearch.setupGuide.videoAlt', + + + +

+ +

+
+ + +

+ +

+
+ +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx new file mode 100644 index 0000000000000..45e318ca0f9d9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../__mocks__/shallow_usecontext.mock'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { 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(); + + 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(); + + expect(wrapper.find(EngineOverview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx new file mode 100644 index 0000000000000..8f7142f1631a9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { 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 ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx new file mode 100644 index 0000000000000..1aead8468ca3b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { coreMock } from 'src/core/public/mocks'; +import { licensingMock } from '../../../licensing/public/mocks'; + +import { renderApp } from './'; +import { AppSearch } from './app_search'; + +describe('renderApp', () => { + const params = coreMock.createAppMountParamters(); + const core = coreMock.createStart(); + const config = {}; + const plugins = { + licensing: licensingMock.createSetup(), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('mounts and unmounts UI', () => { + const MockApp = () =>
Hello world!
; + + const unmount = renderApp(MockApp, core, params, config, plugins); + expect(params.element.querySelector('.hello-world')).not.toBeNull(); + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); + + it('renders AppSearch', () => { + renderApp(AppSearch, core, params, config, plugins); + expect(params.element.querySelector('.setupGuide')).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx new file mode 100644 index 0000000000000..4ef7aca8260a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import 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( + + + + + + + + + , + params.element + ); + return () => ReactDOM.unmountComponentAtNode(params.element); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts new file mode 100644 index 0000000000000..42f308c554268 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getPublicUrl } from './'; + +describe('Enterprise Search URL helper', () => { + const httpMock = { get: jest.fn() } as any; + + it('calls and returns the public URL API endpoint', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://some.vanity.url' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://some.vanity.url'); + }); + + it('strips trailing slashes', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://trailing.slash/' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://trailing.slash'); + }); + + // For the most part, error logging/handling is done on the server side. + // On the front-end, we should simply gracefully fall back to config.host + // if we can't fetch a public URL + it('falls back to an empty string', async () => { + expect(await getPublicUrl(httpMock)).toEqual(''); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts new file mode 100644 index 0000000000000..419c187a0048a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'src/core/public'; + +/** + * On Elastic Cloud, the host URL set in kibana.yml is not necessarily the same + * URL we want to send users to in the front-end (e.g. if a vanity URL is set). + * + * This helper checks a Kibana API endpoint (which has checks an Enterprise + * Search internal API endpoint) for the correct public-facing URL to use. + */ +export const getPublicUrl = async (http: HttpSetup): Promise => { + try { + const { publicUrl } = await http.get('/api/enterprise_search/public_url'); + return stripTrailingSlash(publicUrl); + } catch { + return ''; + } +}; + +const stripTrailingSlash = (url: string): string => { + return url.endsWith('/') ? url.slice(0, -1) : url; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts new file mode 100644 index 0000000000000..bbbb688b8ea7b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getPublicUrl } from './get_enterprise_search_url'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts new file mode 100644 index 0000000000000..7ea73577c4de6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -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'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts new file mode 100644 index 0000000000000..0e1bb796cbf2e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts new file mode 100644 index 0000000000000..cf8bbbc593f2f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs'; +export { appSearchBreadcrumbs } from './generate_breadcrumbs'; +export { SetAppSearchBreadcrumbs } from './set_breadcrumbs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx new file mode 100644 index 0000000000000..974ca54277c51 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -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(, { + http: {}, + enterpriseSearchUrl: 'http://localhost:3002', + setBreadcrumbs, + }); + }; + + describe('when isRoot is false', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: false }); + + it('calls appSearchBreadcrumbs to build breadcrumbs, then registers them with Kibana', () => { + subject(); + + // calls appSearchBreadcrumbs to build breadcrumbs with the target page and current location + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([ + { text: 'Page 1', path: '/current-path' }, + ]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); + + describe('when isRoot is true', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: true }); + + it('calls appSearchBreadcrumbs to build breadcrumbs with an empty breadcrumb, then registers them with Kibana', () => { + subject(); + + // uses an empty bredcrumb + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx new file mode 100644 index 0000000000000..ad3cd65c09516 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx @@ -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 = ({ + 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; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts new file mode 100644 index 0000000000000..9c8c1417d48db --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LicenseContext, LicenseProvider, ILicenseContext } from './license_context'; +export { hasPlatinumLicense } from './license_checks'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts new file mode 100644 index 0000000000000..ad134e7d36b10 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { hasPlatinumLicense } from './license_checks'; + +describe('hasPlatinumLicense', () => { + it('is true for platinum licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } 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); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts new file mode 100644 index 0000000000000..de4a17ce2bd3c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILicense } from '../../../../../licensing/public'; + +export const hasPlatinumLicense = (license?: ILicense) => { + return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type as string); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx new file mode 100644 index 0000000000000..c65474ec1f590 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import { mountWithContext } from '../../__mocks__'; +import { LicenseContext, ILicenseContext } from './'; + +describe('LicenseProvider', () => { + const MockComponent: React.FC = () => { + const { license } = useContext(LicenseContext) as ILicenseContext; + return
{license?.type}
; + }; + + it('renders children', () => { + const wrapper = mountWithContext(, { license: { type: 'basic' } }); + + expect(wrapper.find('.license-test')).toHaveLength(1); + expect(wrapper.text()).toEqual('basic'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx new file mode 100644 index 0000000000000..9b47959ff7544 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; + +import { ILicense } from '../../../../../licensing/public'; + +export interface ILicenseContext { + license: ILicense; +} +interface ILicenseContextProps { + license$: Observable; + children: React.ReactNode; +} + +export const LicenseContext = React.createContext({}); + +export const LicenseProvider: React.FC = ({ license$, children }) => { + // Listen for changes to license subscription + const license = useObservable(license$); + + // Render rest of application and pass down license via context + return ; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx new file mode 100644 index 0000000000000..7d4c068b21155 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 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(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + }); + + it('renders an EuiButton', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + + it('passes down all ...rest props', () => { + const wrapper = shallow(); + 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(); + 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(); + + 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(); + + const simulatedEvent = { + shiftKey: true, + target: { getAttribute: () => '_blank' }, + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx new file mode 100644 index 0000000000000..f486e432bae76 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -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 = ({ 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 = ({ to, ...rest }) => ( + + + +); + +export const EuiReactRouterButton: React.FC = ({ to, ...rest }) => ( + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts new file mode 100644 index 0000000000000..46dc328633153 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { letBrowserHandleEvent } from './link_events'; +export { EuiReactRouterLink as EuiLink } from './eui_link'; +export { EuiReactRouterButton as EuiButton } from './eui_link'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts new file mode 100644 index 0000000000000..3682946b63a13 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('letBrowserHandleEvent', () => { + const event = { + defaultPrevented: false, + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + button: 0, + target: { + getAttribute: () => '_self', + }, + } 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, + }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts new file mode 100644 index 0000000000000..93da2ab71d952 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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'; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts new file mode 100644 index 0000000000000..c367424d375f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss new file mode 100644 index 0000000000000..ecfa13cc828f0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss @@ -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; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..0423ae61779af --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSteps, EuiIcon, EuiLink } from '@elastic/eui'; + +import { mountWithContext } from '../../__mocks__'; + +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow( + +

Wow!

+
+ ); + + expect(wrapper.find('h1').text()).toEqual('Enterprise Search'); + expect(wrapper.find(EuiIcon).prop('type')).toEqual('logoEnterpriseSearch'); + expect(wrapper.find('[data-test-subj="test"]').text()).toEqual('Wow!'); + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('renders with optional auth links', () => { + const wrapper = mountWithContext( + + Baz + + ); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com'); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx new file mode 100644 index 0000000000000..31ff0089dbd7c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiPage, + EuiPageSideBar, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiIcon, + EuiSteps, + EuiCode, + EuiCodeBlock, + EuiAccordion, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import './setup_guide.scss'; + +/** + * Shared Setup Guide component. Sidebar content and product name/links are + * customizable, but the basic layout and instruction steps are DRYed out + */ + +interface ISetupGuideProps { + children: React.ReactNode; + productName: string; + productEuiIcon: 'logoAppSearch' | 'logoWorkplaceSearch' | 'logoEnterpriseSearch'; + standardAuthLink?: string; + elasticsearchNativeAuthLink?: string; +} + +export const SetupGuide: React.FC = ({ + children, + productName, + productEuiIcon, + standardAuthLink, + elasticsearchNativeAuthLink, +}) => ( + + + + + + + + + + + + + + + +

{productName}

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

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

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

+ +

+

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

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

+ +

+
+
+ + + +

+ +

+
+
+ + + +

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

+
+
+ + ), + }, + ]} + /> +
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts new file mode 100644 index 0000000000000..f871f48b17154 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { sendTelemetry } from './send_telemetry'; +export { SendAppSearchTelemetry } from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx new file mode 100644 index 0000000000000..9825c0d8ab889 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { 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(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { + headers: { 'Content-Type': 'application/json' }, + body: '{"action":"clicked","metric":"button"}', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx new file mode 100644 index 0000000000000..300cb18272717 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect } from 'react'; + +import { 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 = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'app_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/index.ts b/x-pack/plugins/enterprise_search/public/index.ts new file mode 100644 index 0000000000000..06272641b1929 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { EnterpriseSearchPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => { + return new EnterpriseSearchPlugin(initializerContext); +}; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts new file mode 100644 index 0000000000000..fbfcc303de47a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -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(); + } + + 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; + } +} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts new file mode 100644 index 0000000000000..e95056b871324 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -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 }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts new file mode 100644 index 0000000000000..a10f96907ad28 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -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({ + 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 }; +} diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts new file mode 100644 index 0000000000000..1e4159124ed94 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + host: true, + }, +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts new file mode 100644 index 0000000000000..11d4a387b533f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +import { checkAccess } from './check_access'; + +describe('checkAccess', () => { + const mockSecurity = { + authz: { + mode: { + useRbacForRequest: () => true, + }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: false, + }), + }), + actions: { + ui: { + get: () => null, + }, + }, + }, + }; + const mockDependencies = { + request: {}, + config: { host: 'http://localhost:3002' }, + security: mockSecurity, + } as any; + + describe('when security is disabled', () => { + it('should allow all access', async () => { + const security = undefined; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + }); + + describe('when the user is a superuser', () => { + it('should allow all access', async () => { + const security = { + ...mockSecurity, + authz: { + mode: { useRbacForRequest: () => true }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: true, + }), + }), + actions: { ui: { get: () => {} } }, + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to assuming a non-superuser role if auth credentials are missing', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: () => ({ + globally: () => Promise.reject({ statusCode: 403 }), + }), + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + + it('throws other authz errors', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: undefined, + }, + }; + await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow(); + }); + }); + + describe('when the user is a non-superuser', () => { + describe('when enterpriseSearch.host is not set in kibana.yml', () => { + it('should deny all access', async () => { + const config = { host: undefined }; + expect(await checkAccess({ ...mockDependencies, config })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + + describe('when enterpriseSearch.host is set in kibana.yml', () => { + it('should make a http call and return the access response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({ + access: { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }, + })); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to no access if no http response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({})); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts new file mode 100644 index 0000000000000..0239cb6422d03 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest, Logger } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { 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 => { + // If security has been disabled, always show the plugin + if (!security?.authz.mode.useRbacForRequest(request)) { + return ALLOW_ALL_PLUGINS; + } + + // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin + const isSuperUser = async (): Promise => { + try { + const { hasAllRequested } = await security.authz + .checkPrivilegesWithRequest(request) + .globally(security.authz.actions.ui.get('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; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts new file mode 100644 index 0000000000000..cf35a458b4825 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('node-fetch'); +const fetchMock = require('node-fetch') as jest.Mock; +const { Response } = jest.requireActual('node-fetch'); + +import { loggingSystemMock } from 'src/core/server/mocks'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +describe('callEnterpriseSearchConfigAPI', () => { + const mockConfig = { + host: 'http://localhost:3002', + accessCheckTimeout: 200, + accessCheckTimeoutWarning: 100, + }; + const mockRequest = { + url: { path: '/app/kibana' }, + headers: { authorization: '==someAuth' }, + }; + const mockDependencies = { + config: mockConfig, + request: mockRequest, + log: loggingSystemMock.create().get(), + } as any; + + const mockResponse = { + version: { + number: '1.0.0', + }, + settings: { + external_url: 'http://some.vanity.url/', + }, + access: { + user: 'someuser', + products: { + app_search: true, + workplace_search: false, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the config API endpoint', async () => { + fetchMock.mockImplementationOnce((url: string) => { + expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config'); + return Promise.resolve(new Response(JSON.stringify(mockResponse))); + }); + + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({ + publicUrl: 'http://some.vanity.url/', + access: { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: false, + }, + }); + }); + + it('returns early if config.host is not set', async () => { + const config = { host: '' }; + + expect(await callEnterpriseSearchConfigAPI({ ...mockDependencies, config })).toEqual({}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('handles server errors', async () => { + fetchMock.mockImplementationOnce(() => { + return Promise.reject('500'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: 500' + ); + + fetchMock.mockImplementationOnce(() => { + return Promise.resolve('Bad Data'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: TypeError: response.json is not a function' + ); + }); + + it('handles timeouts', async () => { + jest.useFakeTimers(); + + // Warning + callEnterpriseSearchConfigAPI(mockDependencies); + jest.advanceTimersByTime(150); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + 'Enterprise Search access check took over 100ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.' + ); + + // Timeout + fetchMock.mockImplementationOnce(async () => { + jest.advanceTimersByTime(250); + return Promise.reject({ name: 'AbortError' }); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + "Exceeded 200ms timeout while checking http://localhost:3002. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses." + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts new file mode 100644 index 0000000000000..7a6d1eac1b454 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import AbortController from 'abort-controller'; +import fetch from 'node-fetch'; + +import { KibanaRequest, Logger } from 'src/core/server'; +import { 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 => { + if (!config.host) return {}; + + const TIMEOUT_WARNING = `Enterprise Search access check took over ${config.accessCheckTimeoutWarning}ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.`; + const TIMEOUT_MESSAGE = `Exceeded ${config.accessCheckTimeout}ms timeout while checking ${config.host}. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses.`; + const CONNECTION_ERROR = 'Could not perform access check to Enterprise Search'; + + const warningTimeout = setTimeout(() => { + log.warn(TIMEOUT_WARNING); + }, config.accessCheckTimeoutWarning); + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, config.accessCheckTimeout); + + try { + const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); + const response = await fetch(enterpriseSearchUrl, { + headers: { Authorization: request.headers.authorization as string }, + signal: controller.signal, + }); + const data = await response.json(); + + return { + publicUrl: data?.settings?.external_url, + access: { + hasAppSearchAccess: !!data?.access?.products?.app_search, + hasWorkplaceSearchAccess: !!data?.access?.products?.workplace_search, + }, + }; + } catch (err) { + if (err.name === 'AbortError') { + log.warn(TIMEOUT_MESSAGE); + } else { + log.error(`${CONNECTION_ERROR}: ${err.toString()}`); + if (err instanceof Error) log.debug(err.stack as string); + } + return {}; + } finally { + clearTimeout(warningTimeout); + clearTimeout(timeout); + } +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts new file mode 100644 index 0000000000000..70be8600862e9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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; + private logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.create(); + 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() {} +} diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts new file mode 100644 index 0000000000000..3cca5e21ce9c3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { MockRouter } from './router.mock'; +export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock'; diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts new file mode 100644 index 0000000000000..1ca7755979f99 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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; + 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; + 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); +// }); diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts new file mode 100644 index 0000000000000..9b6fa30271d61 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggingSystemMock } from 'src/core/server/mocks'; +import { ConfigType } from '../../'; + +export const mockLogger = loggingSystemMock.createLogger().get(); + +export const mockConfig = { + enabled: true, + host: 'http://localhost:3002', + accessCheckTimeout: 5000, + accessCheckTimeoutWarning: 300, +} as ConfigType; + +/** + * This is useful for tests that don't use either config or log, + * but should still pass them in to pass Typescript definitions + */ +export const mockDependencies = { + // Mock router should be handled on a per-test basis + config: mockConfig, + log: mockLogger, +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts new file mode 100644 index 0000000000000..d5b1bc5003456 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -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; + +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'); + }); + }, + }; + }, + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts new file mode 100644 index 0000000000000..ca83c0e187ddb --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -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' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts new file mode 100644 index 0000000000000..e2d5fbcec3705 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -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); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts new file mode 100644 index 0000000000000..4cc9b64adc092 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts new file mode 100644 index 0000000000000..846aae3fce56f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts @@ -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' }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts new file mode 100644 index 0000000000000..a9edd4eb10da0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouteDependencies } from '../../plugin'; +import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api'; + +export function registerPublicUrlRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/enterprise_search/public_url', + validate: false, + }, + async (context, request, response) => { + const { publicUrl = '' } = + (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + + return response.ok({ + body: { publicUrl }, + headers: { 'content-type': 'application/json' }, + }); + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts new file mode 100644 index 0000000000000..32322d494b5e2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* istanbul ignore file */ + +import { SavedObjectsType } from 'src/core/server'; +import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; + +export const appSearchTelemetryType: SavedObjectsType = { + name: AS_TELEMETRY_NAME, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, +}; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 06f064a379fe6..8a499a3eba8fa 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -189,13 +189,15 @@ describe('features', () => { group: 'global', expectManageSpaces: true, expectGetFeatures: true, + expectEnterpriseSearch: true, }, { group: 'space', expectManageSpaces: false, expectGetFeatures: false, + expectEnterpriseSearch: false, }, -].forEach(({ group, expectManageSpaces, expectGetFeatures }) => { +].forEach(({ group, expectManageSpaces, expectGetFeatures, expectEnterpriseSearch }) => { describe(`${group}`, () => { test('actions defined in any feature privilege are included in `all`', () => { const features: Feature[] = [ @@ -256,6 +258,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('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'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 5a15290a7f1a2..f9ee5fc750127 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -101,6 +101,7 @@ export function privilegesFactory( actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), ...allActions, ], read: [actions.login, actions.version, ...readActions], diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 13d7c62316040..1ea16a2a9940c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -7,6 +7,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": { diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 5372563942332..3d75e9c5bd950 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -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'), ]); diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 11fb9b2de7199..df6eca795f801 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { 'visualize', 'dashboard', 'dev_tools', + 'enterpriseSearch', 'advancedSettings', 'indexPatterns', 'timelion', diff --git a/x-pack/test/functional_enterprise_search/README.md b/x-pack/test/functional_enterprise_search/README.md new file mode 100644 index 0000000000000..63d13cbac7020 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/README.md @@ -0,0 +1,41 @@ +# Enterprise Search Functional E2E Tests + +## Running these tests + +Follow the [Functional Test Runner instructions](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html#_running_functional_tests). + +There are two suites available to run, a suite that requires a Kibana instance without an `enterpriseSearch.host` +configured, and one that does. The later also [requires a running Enterprise Search instance](#enterprise-search-requirement), and a Private API key +from that instance set in an Environment variable. + +Ex. + +```sh +# Run specs from the x-pack directory +cd x-pack + +# Run tests that 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. diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts new file mode 100644 index 0000000000000..e4ebd61c0692a --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { EsArchiver } from 'src/es_archiver'; +import { AppSearchService, IEngine } from '../../../../services/app_search_service'; +import { Browser } from '../../../../../../../test/functional/services/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); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts new file mode 100644 index 0000000000000..ac4984e0db019 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Enterprise Search', function () { + loadTestFile(require.resolve('./app_search/engines')); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts new file mode 100644 index 0000000000000..1d478c6baf29c --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function enterpriseSearchSetupGuideTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const retry = getService('retry'); + + const PageObjects = getPageObjects(['appSearch']); + + describe('Setup Guide', function () { + before(async () => await esArchiver.load('empty_kibana')); + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('when no enterpriseSearch.host is configured', () => { + it('navigating to the enterprise_search plugin will redirect a user to the setup guide', async () => { + await PageObjects.appSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/app_search/setup_guide'); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts new file mode 100644 index 0000000000000..31a92e752fcf4 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts @@ -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')); + }); +} diff --git a/x-pack/test/functional_enterprise_search/base_config.ts b/x-pack/test/functional_enterprise_search/base_config.ts new file mode 100644 index 0000000000000..f737b6cd4b5f4 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/base_config.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackFunctionalConfig = await readConfigFile(require.resolve('../functional/config')); + + return { + // default to the xpack functional config + ...xPackFunctionalConfig.getAll(), + services, + pageObjects, + }; +} diff --git a/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..bb257cdcbfe1b --- /dev/null +++ b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts new file mode 100644 index 0000000000000..d845a1935a149 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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 { + return await PageObjects.common.navigateToApp('enterprise_search/app_search'); + }, + + async getEngineLinks(): Promise { + const engines = await testSubjects.find('appSearchEngines'); + return await testSubjects.findAllDescendant('engineNameLink', engines); + }, + + async getMetaEngineLinks(): Promise { + const metaEngines = await testSubjects.find('appSearchMetaEngines'); + return await testSubjects.findAllDescendant('engineNameLink', metaEngines); + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts new file mode 100644 index 0000000000000..009fb26482419 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pageObjects as basePageObjects } from '../../functional/page_objects'; +import { AppSearchPageProvider } from './app_search'; + +export const pageObjects = { + ...basePageObjects, + appSearch: AppSearchPageProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts new file mode 100644 index 0000000000000..fbd15b83f97ea --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import http from 'http'; + +/** + * A simple request client for making API calls to the App Search API + */ +const makeRequest = (method: string, path: string, body?: object): Promise => { + return new Promise(function (resolve, reject) { + const APP_SEARCH_API_KEY = process.env.APP_SEARCH_API_KEY; + + if (!APP_SEARCH_API_KEY) { + throw new Error('Please provide a valid APP_SEARCH_API_KEY. See README for more details.'); + } + + let postData; + + if (body) { + postData = JSON.stringify(body); + } + + const req = http.request( + { + method, + hostname: 'localhost', + port: 3002, + path, + agent: false, // Create a new agent just for this one request + headers: { + Authorization: `Bearer ${APP_SEARCH_API_KEY}`, + 'Content-Type': 'application/json', + ...(!!postData && { 'Content-Length': Buffer.byteLength(postData) }), + }, + }, + (res) => { + const bodyChunks: Uint8Array[] = []; + res.on('data', function (chunk) { + bodyChunks.push(chunk); + }); + + res.on('end', function () { + let responseBody; + try { + responseBody = JSON.parse(Buffer.concat(bodyChunks).toString()); + } catch (e) { + reject(e); + } + + if (res.statusCode && res.statusCode > 299) { + reject('Error calling App Search API: ' + JSON.stringify(responseBody)); + } + + resolve(responseBody); + }); + } + ); + + req.on('error', (e) => { + reject(e); + }); + + if (postData) { + req.write(postData); + } + req.end(); + }); +}; + +export interface IEngine { + name: string; +} + +export const createEngine = async (engineName: string): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { name: engineName }); +}; + +export const destroyEngine = async (engineName: string): Promise => { + return await makeRequest('DELETE', `/api/as/v1/engines/${engineName}`); +}; + +export const createMetaEngine = async ( + engineName: string, + sourceEngines: string[] +): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { + name: engineName, + type: 'meta', + source_engines: sourceEngines, + }); +}; + +export interface ISearchResponse { + results: object[]; +} + +const search = async (engineName: string): Promise => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/search`, { query: '' }); +}; + +// Since the App Search API does not issue document receipts, the only way to tell whether or not documents +// are fully indexed is to poll the search endpoint. +export const waitForIndexedDocs = (engineName: string) => { + return new Promise(async function (resolve) { + let isReady = false; + while (!isReady) { + const response = await search(engineName); + if (response.results && response.results.length > 0) { + isReady = true; + resolve(); + } + } + }); +}; + +export const indexData = async (engineName: string, docs: object[]) => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/documents`, docs); +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_service.ts b/x-pack/test/functional_enterprise_search/services/app_search_service.ts new file mode 100644 index 0000000000000..9a43783402f4b --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_service.ts @@ -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 { FtrProviderContext } from '../ftr_provider_context'; + +const ENTERPRISE_SEARCH_USER = 'enterprise_search'; +const ENTERPRISE_SEARCH_PASSWORD = 'changeme'; +import { + createEngine, + createMetaEngine, + indexData, + waitForIndexedDocs, + destroyEngine, + IEngine, +} from './app_search_client'; + +export interface IUser { + user: string; + password: string; +} +export { IEngine }; + +export class AppSearchService { + getEnterpriseSearchUser(): IUser { + return { + user: ENTERPRISE_SEARCH_USER, + password: ENTERPRISE_SEARCH_PASSWORD, + }; + } + + createEngine(): Promise { + const engineName = `test-engine-${new Date().getTime()}`; + return createEngine(engineName); + } + + async createEngineWithDocs(): Promise { + const engine = await this.createEngine(); + const docs = [ + { id: 1, name: 'doc1' }, + { id: 2, name: 'doc2' }, + { id: 3, name: 'doc2' }, + ]; + await indexData(engine.name, docs); + await waitForIndexedDocs(engine.name); + return engine; + } + + createMetaEngine(sourceEngines: string[]): Promise { + const engineName = `test-meta-engine-${new Date().getTime()}`; + return createMetaEngine(engineName, sourceEngines); + } + + destroyEngine(engineName: string) { + return destroyEngine(engineName); + } +} + +export async function AppSearchServiceProvider({ getService }: FtrProviderContext) { + const lifecycle = getService('lifecycle'); + const security = getService('security'); + + lifecycle.beforeTests.add(async () => { + // The App Search plugin passes through the current user name and password + // through on the API call to App Search. Therefore, we need to be signed + // in as the enterprise_search user in order for this plugin to work. + await security.user.create(ENTERPRISE_SEARCH_USER, { + password: ENTERPRISE_SEARCH_PASSWORD, + roles: ['kibana_admin'], + full_name: ENTERPRISE_SEARCH_USER, + }); + }); + + return new AppSearchService(); +} diff --git a/x-pack/test/functional_enterprise_search/services/index.ts b/x-pack/test/functional_enterprise_search/services/index.ts new file mode 100644 index 0000000000000..1715c98677ac6 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as functionalServices } from '../../functional/services'; +import { AppSearchServiceProvider } from './app_search_service'; + +export const services = { + ...functionalServices, + appSearch: AppSearchServiceProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/with_host_configured.config.ts b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts new file mode 100644 index 0000000000000..f425f806f4bcd --- /dev/null +++ b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/with_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests with Host Configured', + }, + + kbnTestServer: { + ...baseConfig.get('kbnTestServer'), + serverArgs: [ + ...baseConfig.get('kbnTestServer.serverArgs'), + '--enterpriseSearch.host=http://localhost:3002', + ], + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/without_host_configured.config.ts b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts new file mode 100644 index 0000000000000..0f2afd214abed --- /dev/null +++ b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/without_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests without Host Configured', + }, + }; +} diff --git a/x-pack/test/ui_capabilities/common/nav_links_builder.ts b/x-pack/test/ui_capabilities/common/nav_links_builder.ts index 405ef4dbdc5b1..b20a499ba7e20 100644 --- a/x-pack/test/ui_capabilities/common/nav_links_builder.ts +++ b/x-pack/test/ui_capabilities/common/nav_links_builder.ts @@ -15,6 +15,10 @@ export class NavLinksBuilder { management: { navLinkId: 'kibana:stack_management', }, + // TODO: Temp until navLinkIds fix is merged in + appSearch: { + navLinkId: 'appSearch', + }, }; } diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index f8f3f2be2b2ec..0e0d46c6ce2cd 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -32,17 +32,27 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'global_all at everything_space': - case 'dual_privileges_all at everything_space': + case 'dual_privileges_all at everything_space': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'everything_space_all at everything_space': case 'global_read at everything_space': case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring is enabled + // everything except ml and monitoring and enterprise search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index 10ecf5d25d346..08a7d789153e7 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -38,14 +38,20 @@ export default function navLinksTests({ getService }: FtrProviderContext) { break; case 'global_all at everything_space': case 'dual_privileges_all at everything_space': - case 'dual_privileges_read at everything_space': - case 'global_read at everything_space': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring') + ); + break; case 'everything_space_all at everything_space': + case 'global_read at everything_space': + case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring') + navLinksBuilder.except('ml', 'monitoring', 'enterpriseSearch', 'appSearch') ); break; case 'superuser at nothing_space': diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts index 52a1f30147b4f..99f91407dc1d2 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -32,9 +32,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'all': - case 'read': - case 'dual_privileges_all': - case 'dual_privileges_read': { + case 'dual_privileges_all': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything except ml and monitoring is enabled @@ -45,6 +43,18 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.catalogue).to.eql(expected); break; } + case 'read': + case 'dual_privileges_read': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring and enterprise search is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'foo_all': case 'foo_read': { expect(uiCapabilities.success).to.be(true); diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts index fe9ffa9286de8..d3bd2e1afd357 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts @@ -37,15 +37,21 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.navLinks).to.eql(navLinksBuilder.all()); break; case 'all': - case 'read': case 'dual_privileges_all': - case 'dual_privileges_read': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( navLinksBuilder.except('ml', 'monitoring') ); break; + case 'read': + case 'dual_privileges_read': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring', 'appSearch') + ); + break; case 'foo_all': case 'foo_read': expect(uiCapabilities.success).to.be(true);