From c4301d080b9fd595b6cf2313d2053256b0fae89d Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 30 Oct 2024 09:39:42 -0500 Subject: [PATCH] [Search] Refactor: abstracting classic nav items (#196579) ## Summary Moved the base set of sidenav items from being statically defined in useEnterpriseSearchNav to using a function that can be shared with the plugin. Additionally wrapped this generation in a `useMemo` to improve performance. This will support the ability to share the classic navigation items for Search to other plugins so that they can render their own UIs without sharing components with enterprise_search just to have access to the side nav defined by enterprise_search. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed --------- Co-authored-by: Elastic Machine --- packages/deeplinks/search/constants.ts | 4 + packages/deeplinks/search/deep_links.ts | 14 +- packages/deeplinks/search/index.ts | 4 + .../enterprise_search/common/constants.ts | 23 +- .../__mocks__/kea_logic/kibana_logic.mock.ts | 1 + .../components/playground/page_template.tsx | 71 +++++ .../components/playground/playground.tsx | 8 +- .../elasticsearch_guide.tsx | 2 +- .../components/layout/page_template.tsx | 3 +- .../public/applications/index.tsx | 1 + .../shared/kibana/kibana_logic.ts | 3 + .../kibana_chrome/generate_breadcrumbs.ts | 28 +- .../shared/kibana_chrome/generate_title.ts | 17 +- .../shared/kibana_chrome/set_chrome.tsx | 25 +- .../applications/shared/layout/base_nav.tsx | 201 ++++++++++++++ .../shared/layout/classic_nav_helpers.test.ts | 189 +++++++++++++ .../shared/layout/classic_nav_helpers.ts | 102 +++++++ .../applications/shared/layout/nav.test.tsx | 190 ++++++++++--- .../public/applications/shared/layout/nav.tsx | 256 ++---------------- .../shared/layout/nav_link_helpers.test.ts | 1 + .../shared/layout/nav_link_helpers.ts | 22 +- .../react_router_helpers/create_href.ts | 11 +- .../react_router_helpers/eui_components.tsx | 4 +- .../generate_react_router_props.test.ts | 2 + .../generate_react_router_props.ts | 14 +- .../shared/react_router_helpers/index.ts | 1 - .../public/applications/shared/types.ts | 39 +++ .../test_helpers/test_utils.test_helper.tsx | 1 + .../translations/translations/fr-FR.json | 12 - .../translations/translations/ja-JP.json | 12 - .../translations/translations/zh-CN.json | 12 - x-pack/test/functional/config.base.js | 3 + x-pack/test/functional/page_objects/index.ts | 2 + .../page_objects/search_classic_navigation.ts | 118 ++++++++ x-pack/test/functional_search/config.ts | 10 + x-pack/test/functional_search/index.ts | 1 + .../tests/classic_navigation.ts | 131 +++++++++ 37 files changed, 1180 insertions(+), 358 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/applications/components/playground/page_template.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/base_nav.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.ts create mode 100644 x-pack/test/functional/page_objects/search_classic_navigation.ts create mode 100644 x-pack/test/functional_search/tests/classic_navigation.ts diff --git a/packages/deeplinks/search/constants.ts b/packages/deeplinks/search/constants.ts index a2a17b20efba8..9848bb0c3d42e 100644 --- a/packages/deeplinks/search/constants.ts +++ b/packages/deeplinks/search/constants.ts @@ -21,3 +21,7 @@ export const SERVERLESS_ES_SEARCH_INFERENCE_ENDPOINTS_ID = 'searchInferenceEndpo export const SEARCH_HOMEPAGE = 'searchHomepage'; export const SEARCH_INDICES_START = 'elasticsearchStart'; export const SEARCH_INDICES = 'elasticsearchIndices'; +export const SEARCH_ELASTICSEARCH = 'enterpriseSearchElasticsearch'; +export const SEARCH_VECTOR_SEARCH = 'enterpriseSearchVectorSearch'; +export const SEARCH_SEMANTIC_SEARCH = 'enterpriseSearchSemanticSearch'; +export const SEARCH_AI_SEARCH = 'enterpriseSearchAISearch'; diff --git a/packages/deeplinks/search/deep_links.ts b/packages/deeplinks/search/deep_links.ts index 98703f18ac3fb..22dfb91bdff33 100644 --- a/packages/deeplinks/search/deep_links.ts +++ b/packages/deeplinks/search/deep_links.ts @@ -22,6 +22,10 @@ import { SEARCH_HOMEPAGE, SEARCH_INDICES_START, SEARCH_INDICES, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, } from './constants'; export type EnterpriseSearchApp = typeof ENTERPRISE_SEARCH_APP_ID; @@ -38,6 +42,10 @@ export type SearchInferenceEndpointsId = typeof SERVERLESS_ES_SEARCH_INFERENCE_E export type SearchHomepage = typeof SEARCH_HOMEPAGE; export type SearchStart = typeof SEARCH_INDICES_START; export type SearchIndices = typeof SEARCH_INDICES; +export type SearchElasticsearch = typeof SEARCH_ELASTICSEARCH; +export type SearchVectorSearch = typeof SEARCH_VECTOR_SEARCH; +export type SearchSemanticSearch = typeof SEARCH_SEMANTIC_SEARCH; +export type SearchAISearch = typeof SEARCH_AI_SEARCH; export type ContentLinkId = 'searchIndices' | 'connectors' | 'webCrawlers'; @@ -65,4 +73,8 @@ export type DeepLinkId = | `${EnterpriseSearchAppsearchApp}:${AppsearchLinkId}` | `${EnterpriseSearchRelevanceApp}:${RelevanceLinkId}` | SearchStart - | SearchIndices; + | SearchIndices + | SearchElasticsearch + | SearchVectorSearch + | SearchSemanticSearch + | SearchAISearch; diff --git a/packages/deeplinks/search/index.ts b/packages/deeplinks/search/index.ts index 250dfeed299e6..7c78d64081133 100644 --- a/packages/deeplinks/search/index.ts +++ b/packages/deeplinks/search/index.ts @@ -17,6 +17,10 @@ export { ENTERPRISE_SEARCH_WORKPLACESEARCH_APP_ID, SERVERLESS_ES_APP_ID, SERVERLESS_ES_CONNECTORS_ID, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, } from './constants'; export type { diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 67dfa03dc3705..797f94fa29e51 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -15,6 +15,10 @@ import { ENTERPRISE_SEARCH_ANALYTICS_APP_ID, ENTERPRISE_SEARCH_APPSEARCH_APP_ID, ENTERPRISE_SEARCH_WORKPLACESEARCH_APP_ID, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, } from '@kbn/deeplinks-search'; import { i18n } from '@kbn/i18n'; @@ -58,7 +62,7 @@ export const ENTERPRISE_SEARCH_CONTENT_PLUGIN = { }; export const AI_SEARCH_PLUGIN = { - ID: 'enterpriseSearchAISearch', + ID: SEARCH_AI_SEARCH, NAME: i18n.translate('xpack.enterpriseSearch.aiSearch.productName', { defaultMessage: 'AI Search', }), @@ -91,7 +95,7 @@ export const ANALYTICS_PLUGIN = { }; export const ELASTICSEARCH_PLUGIN = { - ID: 'enterpriseSearchElasticsearch', + ID: SEARCH_ELASTICSEARCH, NAME: i18n.translate('xpack.enterpriseSearch.elasticsearch.productName', { defaultMessage: 'Elasticsearch', }), @@ -167,7 +171,7 @@ export const VECTOR_SEARCH_PLUGIN = { defaultMessage: 'Elasticsearch can be used as a vector database, which enables vector search and semantic search use cases.', }), - ID: 'enterpriseSearchVectorSearch', + ID: SEARCH_VECTOR_SEARCH, LOGO: 'logoEnterpriseSearch', NAME: i18n.translate('xpack.enterpriseSearch.vectorSearch.productName', { defaultMessage: 'Vector Search', @@ -184,7 +188,7 @@ export const SEMANTIC_SEARCH_PLUGIN = { defaultMessage: 'Easily add semantic search to Elasticsearch with inference endpoints and the semantic_text field type, to boost search relevance.', }), - ID: 'enterpriseSearchSemanticSearch', + ID: SEARCH_SEMANTIC_SEARCH, LOGO: 'logoEnterpriseSearch', NAME: i18n.translate('xpack.enterpriseSearch.SemanticSearch.productName', { defaultMessage: 'Semantic Search', @@ -297,3 +301,14 @@ export const CRAWLER = { // TODO remove this once the connector service types are no longer in "example" state export const EXAMPLE_CONNECTOR_SERVICE_TYPES = ['opentext_documentum']; + +export const GETTING_STARTED_TITLE = i18n.translate('xpack.enterpriseSearch.gettingStarted.title', { + defaultMessage: 'Getting started', +}); + +export const SEARCH_APPS_BREADCRUMB = i18n.translate( + 'xpack.enterpriseSearch.searchApplications.breadcrumb', + { + defaultMessage: 'Search Applications', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts index cca5523ded681..9b37c661d923a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts @@ -41,6 +41,7 @@ export const mockKibanaValues = { data: dataPluginMock.createStartContract(), esConfig: { elasticsearch_host: 'https://your_deployment_url' }, getChromeStyle$: jest.fn().mockReturnValue(of('classic')), + getNavLinks: jest.fn().mockReturnValue([]), guidedOnboarding: {}, history: mockHistory, indexMappingComponent: null, diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/page_template.tsx new file mode 100644 index 0000000000000..40698b273730b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/page_template.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useLayoutEffect } from 'react'; + +import { useValues } from 'kea'; + +import useObservable from 'react-use/lib/useObservable'; + +import { SEARCH_PRODUCT_NAME } from '../../../../../common/constants'; +import { KibanaLogic } from '../../../shared/kibana'; +import { SetSearchPlaygroundChrome } from '../../../shared/kibana_chrome/set_chrome'; +import { EnterpriseSearchPageTemplateWrapper, PageTemplateProps } from '../../../shared/layout'; +import { useEnterpriseSearchNav } from '../../../shared/layout'; +import { SendEnterpriseSearchTelemetry } from '../../../shared/telemetry'; + +import { PlaygroundHeaderDocsAction } from './header_docs_action'; + +export type SearchPlaygroundPageTemplateProps = Omit< + PageTemplateProps, + 'useEndpointHeaderActions' +> & { + hasSchemaConflicts?: boolean; + restrictWidth?: boolean; + searchApplicationName?: string; +}; + +export const SearchPlaygroundPageTemplate: React.FC = ({ + children, + pageChrome, + pageViewTelemetry, + searchApplicationName, + hasSchemaConflicts, + restrictWidth = true, + ...pageTemplateProps +}) => { + const navItems = useEnterpriseSearchNav(); + + const { renderHeaderActions, getChromeStyle$ } = useValues(KibanaLogic); + const chromeStyle = useObservable(getChromeStyle$(), 'classic'); + + useLayoutEffect(() => { + renderHeaderActions(PlaygroundHeaderDocsAction); + + return () => { + renderHeaderActions(); + }; + }, []); + + return ( + } + useEndpointHeaderActions={false} + > + {pageViewTelemetry && ( + + )} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx index b117518d3a6e0..e8e72e5dfb37a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx @@ -12,7 +12,8 @@ import { useValues } from 'kea'; import { i18n } from '@kbn/i18n'; import { KibanaLogic } from '../../../shared/kibana'; -import { EnterpriseSearchApplicationsPageTemplate } from '../layout/page_template'; + +import { SearchPlaygroundPageTemplate } from './page_template'; export const Playground: React.FC = () => { const { searchPlayground } = useValues(KibanaLogic); @@ -22,7 +23,7 @@ export const Playground: React.FC = () => { } return ( - { panelled={false} customPageSections bottomBorder="extended" - docLink="playground" > - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx index 80a8de9acdc21..be470577cd519 100644 --- a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx @@ -40,7 +40,7 @@ export const ElasticsearchGuide = () => { }, []); return ( - + {isFlyoutOpen && setIsFlyoutOpen(false)} />}

diff --git a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx index 7f2eded8a6565..c5c777cb74773 100644 --- a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx @@ -19,13 +19,14 @@ export const EnterpriseSearchElasticsearchPageTemplate: React.FC { + const navItems = useEnterpriseSearchNav(); return ( } > diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index eafa8827869d8..717379d433dd1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -114,6 +114,7 @@ export const renderApp = ( data: plugins.data, esConfig, getChromeStyle$: chrome.getChromeStyle$, + getNavLinks: chrome.navLinks.getAll, guidedOnboarding, history, indexMappingComponent, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index f74345a1c75c1..6cd6e5410ef11 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -55,6 +55,7 @@ export interface KibanaLogicProps { data?: DataPublicPluginStart; esConfig: ESConfig; getChromeStyle$: ChromeStart['getChromeStyle$']; + getNavLinks: ChromeStart['navLinks']['getAll']; guidedOnboarding?: GuidedOnboardingPluginStart; history: ScopedHistory; indexMappingComponent?: React.FC; @@ -87,6 +88,7 @@ export interface KibanaValues { data: DataPublicPluginStart | null; esConfig: ESConfig; getChromeStyle$: ChromeStart['getChromeStyle$']; + getNavLinks: ChromeStart['navLinks']['getAll']; guidedOnboarding: GuidedOnboardingPluginStart | null; history: ScopedHistory; indexMappingComponent: React.FC | null; @@ -126,6 +128,7 @@ export const KibanaLogic = kea>({ data: [props.data || null, {}], esConfig: [props.esConfig || { elasticsearch_host: ELASTICSEARCH_URL_PLACEHOLDER }, {}], getChromeStyle$: [props.getChromeStyle$, {}], + getNavLinks: [props.getNavLinks, {}], guidedOnboarding: [props.guidedOnboarding || null, {}], history: [props.history, {}], indexMappingComponent: [props.indexMappingComponent || null, {}], diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts index ea6bda26be450..189ca53e362e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts @@ -22,6 +22,8 @@ import { VECTOR_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, SEMANTIC_SEARCH_PLUGIN, + APPLICATIONS_PLUGIN, + GETTING_STARTED_TITLE, } from '../../../../common/constants'; import { stripLeadingSlash } from '../../../../common/strip_slashes'; @@ -126,7 +128,11 @@ export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => ]); export const useAnalyticsBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: ANALYTICS_PLUGIN.NAME, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: APPLICATIONS_PLUGIN.NAV_TITLE }, + { text: ANALYTICS_PLUGIN.NAME, path: '/' }, + ...breadcrumbs, + ]); export const useElasticsearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => useSearchBreadcrumbs([ @@ -161,13 +167,25 @@ export const useSearchExperiencesBreadcrumbs = (breadcrumbs: Breadcrumbs = []) = useSearchBreadcrumbs([{ text: SEARCH_EXPERIENCES_PLUGIN.NAV_TITLE, path: '/' }, ...breadcrumbs]); export const useEnterpriseSearchApplicationsBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs(breadcrumbs); + useSearchBreadcrumbs([{ text: APPLICATIONS_PLUGIN.NAV_TITLE }, ...breadcrumbs]); export const useAiSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: AI_SEARCH_PLUGIN.NAME, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: GETTING_STARTED_TITLE }, + { text: AI_SEARCH_PLUGIN.NAME, path: '/' }, + ...breadcrumbs, + ]); export const useVectorSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: VECTOR_SEARCH_PLUGIN.NAV_TITLE, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: GETTING_STARTED_TITLE }, + { text: VECTOR_SEARCH_PLUGIN.NAV_TITLE, path: '/' }, + ...breadcrumbs, + ]); export const useSemanticSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: SEMANTIC_SEARCH_PLUGIN.NAME, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: GETTING_STARTED_TITLE }, + { text: SEMANTIC_SEARCH_PLUGIN.NAME, path: '/' }, + ...breadcrumbs, + ]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts index eaeb30f1540d0..df7d16cddc4d4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; + import { AI_SEARCH_PLUGIN, ANALYTICS_PLUGIN, @@ -40,7 +42,12 @@ export const searchTitle = (page: Title = []) => generateTitle([...page, SEARCH_ export const analyticsTitle = (page: Title = []) => generateTitle([...page, ANALYTICS_PLUGIN.NAME]); export const elasticsearchTitle = (page: Title = []) => - generateTitle([...page, 'Getting started with Elasticsearch']); + generateTitle([ + ...page, + i18n.translate('xpack.enterpriseSearch.titles.elasticsearch', { + defaultMessage: 'Getting started with Elasticsearch', + }), + ]); export const appSearchTitle = (page: Title = []) => generateTitle([...page, APP_SEARCH_PLUGIN.NAME]); @@ -61,3 +68,11 @@ export const semanticSearchTitle = (page: Title = []) => export const enterpriseSearchContentTitle = (page: Title = []) => generateTitle([...page, ENTERPRISE_SEARCH_CONTENT_PLUGIN.NAME]); + +export const searchApplicationsTitle = (page: Title = []) => + generateTitle([ + ...page, + i18n.translate('xpack.enterpriseSearch.titles.searchApplications', { + defaultMessage: 'Search Applications', + }), + ]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx index 8f7c71d1309c0..0c05cb0c02ca0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx @@ -9,8 +9,7 @@ import React, { useEffect } from 'react'; import { useValues } from 'kea'; -import { APPLICATIONS_PLUGIN } from '../../../../common/constants'; - +import { SEARCH_APPS_BREADCRUMB } from '../../../../common/constants'; import { KibanaLogic } from '../kibana'; import { @@ -35,6 +34,8 @@ import { appSearchTitle, elasticsearchTitle, enterpriseSearchContentTitle, + generateTitle, + searchApplicationsTitle, searchExperiencesTitle, searchTitle, semanticSearchTitle, @@ -210,14 +211,30 @@ export const SetSearchExperiencesChrome: React.FC = ({ trail = [ return null; }; +export const SetSearchPlaygroundChrome: React.FC = ({ trail = [] }) => { + const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); + + const title = reverseArray(trail); + const docTitle = generateTitle(title); + + const breadcrumbs = useEnterpriseSearchApplicationsBreadcrumbs(useGenerateBreadcrumbs(trail)); + + useEffect(() => { + setBreadcrumbs(breadcrumbs); + setDocTitle(docTitle); + }, [trail]); + + return null; +}; + export const SetEnterpriseSearchApplicationsChrome: React.FC = ({ trail = [] }) => { const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); const title = reverseArray(trail); - const docTitle = appSearchTitle(title); + const docTitle = searchApplicationsTitle(title); const breadcrumbs = useEnterpriseSearchApplicationsBreadcrumbs( - useGenerateBreadcrumbs([APPLICATIONS_PLUGIN.NAV_TITLE, ...trail]) + useGenerateBreadcrumbs([SEARCH_APPS_BREADCRUMB, ...trail]) ); useEffect(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/base_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/base_nav.tsx new file mode 100644 index 0000000000000..b971ab6deff53 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/base_nav.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText } from '@elastic/eui'; +import { + ENTERPRISE_SEARCH_APP_ID, + ENTERPRISE_SEARCH_ANALYTICS_APP_ID, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, +} from '@kbn/deeplinks-search'; +import { i18n } from '@kbn/i18n'; + +import { GETTING_STARTED_TITLE } from '../../../../common/constants'; + +import { ClassicNavItem, BuildClassicNavParameters } from '../types'; + +export const buildBaseClassicNavItems = ({ + productAccess, +}: BuildClassicNavParameters): ClassicNavItem[] => { + const navItems: ClassicNavItem[] = []; + + // Home + navItems.push({ + 'data-test-subj': 'searchSideNav-Home', + deepLink: { + link: ENTERPRISE_SEARCH_APP_ID, + shouldShowActiveForSubroutes: true, + }, + id: 'home', + name: ( + + {i18n.translate('xpack.enterpriseSearch.nav.homeTitle', { + defaultMessage: 'Home', + })} + + ), + }); + + // Content + navItems.push({ + 'data-test-subj': 'searchSideNav-Content', + id: 'content', + items: [ + { + 'data-test-subj': 'searchSideNav-Indices', + deepLink: { + link: 'enterpriseSearchContent:searchIndices', + shouldShowActiveForSubroutes: true, + }, + id: 'search_indices', + }, + { + 'data-test-subj': 'searchSideNav-Connectors', + deepLink: { + link: 'enterpriseSearchContent:connectors', + shouldShowActiveForSubroutes: true, + }, + id: 'connectors', + }, + { + 'data-test-subj': 'searchSideNav-Crawlers', + deepLink: { + link: 'enterpriseSearchContent:webCrawlers', + shouldShowActiveForSubroutes: true, + }, + id: 'crawlers', + }, + ], + name: i18n.translate('xpack.enterpriseSearch.nav.contentTitle', { + defaultMessage: 'Content', + }), + }); + + // Build + navItems.push({ + 'data-test-subj': 'searchSideNav-Build', + id: 'build', + items: [ + { + 'data-test-subj': 'searchSideNav-Playground', + deepLink: { + link: 'enterpriseSearchApplications:playground', + shouldShowActiveForSubroutes: true, + }, + id: 'playground', + }, + { + 'data-test-subj': 'searchSideNav-SearchApplications', + deepLink: { + link: 'enterpriseSearchApplications:searchApplications', + }, + id: 'searchApplications', + }, + { + 'data-test-subj': 'searchSideNav-BehavioralAnalytics', + deepLink: { + link: ENTERPRISE_SEARCH_ANALYTICS_APP_ID, + }, + id: 'analyticsCollections', + }, + ], + name: i18n.translate('xpack.enterpriseSearch.nav.applicationsTitle', { + defaultMessage: 'Build', + }), + }); + + navItems.push({ + 'data-test-subj': 'searchSideNav-Relevance', + id: 'relevance', + items: [ + { + 'data-test-subj': 'searchSideNav-InferenceEndpoints', + deepLink: { + link: 'searchInferenceEndpoints:inferenceEndpoints', + shouldShowActiveForSubroutes: true, + }, + id: 'inference_endpoints', + }, + ], + name: i18n.translate('xpack.enterpriseSearch.nav.relevanceTitle', { + defaultMessage: 'Relevance', + }), + }); + + // Getting Started + navItems.push({ + 'data-test-subj': 'searchSideNav-GettingStarted', + id: 'es_getting_started', + items: [ + { + 'data-test-subj': 'searchSideNav-Elasticsearch', + deepLink: { + link: SEARCH_ELASTICSEARCH, + }, + id: 'elasticsearch', + }, + { + 'data-test-subj': 'searchSideNav-VectorSearch', + deepLink: { + link: SEARCH_VECTOR_SEARCH, + }, + id: 'vectorSearch', + }, + { + 'data-test-subj': 'searchSideNav-SemanticSearch', + deepLink: { + link: SEARCH_SEMANTIC_SEARCH, + }, + id: 'semanticSearch', + }, + { + 'data-test-subj': 'searchSideNav-AISearch', + deepLink: { + link: SEARCH_AI_SEARCH, + }, + id: 'aiSearch', + }, + ], + name: GETTING_STARTED_TITLE, + }); + + if (productAccess.hasAppSearchAccess || productAccess.hasWorkplaceSearchAccess) { + const entSearchItems: ClassicNavItem[] = []; + if (productAccess.hasAppSearchAccess) { + entSearchItems.push({ + 'data-test-subj': 'searchSideNav-AppSearch', + deepLink: { + link: 'appSearch:engines', + }, + id: 'app_search', + }); + } + if (productAccess.hasWorkplaceSearchAccess) { + entSearchItems.push({ + 'data-test-subj': 'searchSideNav-WorkplaceSearch', + deepLink: { + link: 'workplaceSearch', + }, + id: 'workplace_search', + }); + } + navItems.push({ + 'data-test-subj': 'searchSideNav-EnterpriseSearch', + id: 'enterpriseSearch', + items: entSearchItems, + name: i18n.translate('xpack.enterpriseSearch.nav.title', { + defaultMessage: 'Enterprise Search', + }), + }); + } + + return navItems; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.test.ts new file mode 100644 index 0000000000000..514072ba297aa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockKibanaValues } from '../../__mocks__/kea_logic'; + +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import '../../__mocks__/react_router'; + +jest.mock('../react_router_helpers/link_events', () => ({ + letBrowserHandleEvent: jest.fn(), +})); + +import { ClassicNavItem } from '../types'; + +import { generateSideNavItems } from './classic_nav_helpers'; + +describe('generateSideNavItems', () => { + const deepLinksMap = { + enterpriseSearch: { + id: 'enterpriseSearch', + url: '/app/enterprise_search/overview', + title: 'Overview', + }, + 'enterpriseSearchContent:searchIndices': { + id: 'enterpriseSearchContent:searchIndices', + title: 'Indices', + url: '/app/enterprise_search/content/search_indices', + }, + 'enterpriseSearchContent:connectors': { + id: 'enterpriseSearchContent:connectors', + title: 'Connectors', + url: '/app/enterprise_search/content/connectors', + }, + 'enterpriseSearchContent:webCrawlers': { + id: 'enterpriseSearchContent:webCrawlers', + title: 'Web crawlers', + url: '/app/enterprise_search/content/crawlers', + }, + } as unknown as Record; + beforeEach(() => { + jest.clearAllMocks(); + mockKibanaValues.history.location.pathname = '/'; + }); + + it('renders top-level items', () => { + const classicNavItems: ClassicNavItem[] = [ + { + id: 'unit-test', + deepLink: { + link: 'enterpriseSearch', + }, + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Overview', + onClick: expect.any(Function), + }, + ]); + }); + + it('renders items with children', () => { + const classicNavItems: ClassicNavItem[] = [ + { + id: 'parent', + name: 'Parent', + items: [ + { + id: 'unit-test', + deepLink: { + link: 'enterpriseSearch', + }, + }, + ], + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + id: 'parent', + items: [ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Overview', + onClick: expect.any(Function), + }, + ], + name: 'Parent', + }, + ]); + }); + + it('renders classic nav name over deep link title if provided', () => { + const classicNavItems: ClassicNavItem[] = [ + { + deepLink: { + link: 'enterpriseSearch', + }, + id: 'unit-test', + name: 'Home', + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Home', + onClick: expect.any(Function), + }, + ]); + }); + + it('removes item if deep link is not defined', () => { + const classicNavItems: ClassicNavItem[] = [ + { + deepLink: { + link: 'enterpriseSearch', + }, + id: 'unit-test', + name: 'Home', + }, + { + deepLink: { + link: 'enterpriseSearchApplications:playground', + }, + id: 'unit-test-missing', + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Home', + onClick: expect.any(Function), + }, + ]); + }); + + it('adds pre-rendered child items provided', () => { + const classicNavItems: ClassicNavItem[] = [ + { + id: 'unit-test', + name: 'Indices', + }, + ]; + const subItems = { + 'unit-test': [ + { + href: '/app/unit-test', + id: 'child', + isSelected: true, + name: 'Index', + onClick: jest.fn(), + }, + ], + }; + + expect(generateSideNavItems(classicNavItems, deepLinksMap, subItems)).toEqual([ + { + id: 'unit-test', + items: [ + { + href: '/app/unit-test', + id: 'child', + isSelected: true, + name: 'Index', + onClick: expect.any(Function), + }, + ], + name: 'Indices', + }, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.ts new file mode 100644 index 0000000000000..89f3c2ab5b59a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ChromeNavLink, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; + +import { + ClassicNavItem, + GenerateNavLinkFromDeepLinkParameters, + GenerateNavLinkParameters, +} from '../types'; + +import { generateNavLink } from './nav_link_helpers'; + +export const generateSideNavItems = ( + navItems: ClassicNavItem[], + deepLinks: Record, + subItemsMap: Record> | undefined> = {} +): Array> => { + const sideNavItems: Array> = []; + + for (const navItem of navItems) { + let sideNavChildItems: Array> | undefined; + + const { deepLink, items, ...rest } = navItem; + const subItems = subItemsMap?.[navItem.id]; + + if (items || subItems) { + sideNavChildItems = []; + if (items) { + sideNavChildItems.push(...generateSideNavItems(items, deepLinks, subItemsMap)); + } + if (subItems) { + sideNavChildItems.push(...subItems); + } + } + + let sideNavItem: EuiSideNavItemTypeEnhanced | undefined; + if (deepLink) { + const navLinkParams = getNavLinkParameters(deepLink, deepLinks); + if (navLinkParams !== undefined) { + const name = navItem.name ?? getDeepLinkTitle(deepLink.link, deepLinks); + sideNavItem = { + ...rest, + name, + ...generateNavLink({ + ...navLinkParams, + items: sideNavChildItems, + }), + }; + } + } else { + sideNavItem = { + ...rest, + items: sideNavChildItems, + name: navItem.name, + }; + } + + if (isValidSideNavItem(sideNavItem)) { + sideNavItems.push(sideNavItem); + } + } + + return sideNavItems; +}; + +const getNavLinkParameters = ( + navLink: GenerateNavLinkFromDeepLinkParameters, + deepLinks: Record +): GenerateNavLinkParameters | undefined => { + const { link, ...navLinkProps } = navLink; + const deepLink = deepLinks[link]; + if (!deepLink || !deepLink.url) return undefined; + return { + ...navLinkProps, + shouldNotCreateHref: true, + shouldNotPrepend: true, + to: deepLink.url, + }; +}; +const getDeepLinkTitle = ( + link: string, + deepLinks: Record +): string | undefined => { + const deepLink = deepLinks[link]; + if (!deepLink || !deepLink.url) return undefined; + return deepLink.title; +}; + +function isValidSideNavItem( + item: EuiSideNavItemTypeEnhanced | undefined +): item is EuiSideNavItemTypeEnhanced { + if (item === undefined) return false; + if (item.href || item.onClick) return true; + if (item?.items?.length ?? 0 > 0) return true; + + return false; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx index b2c31ff4868bc..3305e92dd8d9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx @@ -15,6 +15,8 @@ jest.mock('../../enterprise_search_content/components/search_index/indices/indic import { setMockValues, mockKibanaValues } from '../../__mocks__/kea_logic'; +import { renderHook } from '@testing-library/react-hooks'; + import { EuiSideNavItemType } from '@elastic/eui'; import { DEFAULT_PRODUCT_FEATURES } from '../../../../common/constants'; @@ -32,26 +34,31 @@ const DEFAULT_PRODUCT_ACCESS: ProductAccess = { }; const baseNavItems = [ expect.objectContaining({ + 'data-test-subj': 'searchSideNav-Home', href: '/app/enterprise_search/overview', id: 'home', items: undefined, }), { + 'data-test-subj': 'searchSideNav-Content', id: 'content', items: [ { + 'data-test-subj': 'searchSideNav-Indices', href: '/app/enterprise_search/content/search_indices', id: 'search_indices', items: [], name: 'Indices', }, { + 'data-test-subj': 'searchSideNav-Connectors', href: '/app/enterprise_search/content/connectors', id: 'connectors', items: undefined, name: 'Connectors', }, { + 'data-test-subj': 'searchSideNav-Crawlers', href: '/app/enterprise_search/content/crawlers', id: 'crawlers', items: undefined, @@ -61,21 +68,25 @@ const baseNavItems = [ name: 'Content', }, { + 'data-test-subj': 'searchSideNav-Build', id: 'build', items: [ { + 'data-test-subj': 'searchSideNav-Playground', href: '/app/enterprise_search/applications/playground', id: 'playground', items: undefined, name: 'Playground', }, { + 'data-test-subj': 'searchSideNav-SearchApplications', href: '/app/enterprise_search/applications/search_applications', id: 'searchApplications', items: undefined, name: 'Search Applications', }, { + 'data-test-subj': 'searchSideNav-BehavioralAnalytics', href: '/app/enterprise_search/analytics', id: 'analyticsCollections', items: undefined, @@ -85,9 +96,11 @@ const baseNavItems = [ name: 'Build', }, { + 'data-test-subj': 'searchSideNav-Relevance', id: 'relevance', items: [ { + 'data-test-subj': 'searchSideNav-InferenceEndpoints', href: '/app/enterprise_search/relevance/inference_endpoints', id: 'inference_endpoints', items: undefined, @@ -97,27 +110,32 @@ const baseNavItems = [ name: 'Relevance', }, { + 'data-test-subj': 'searchSideNav-GettingStarted', id: 'es_getting_started', items: [ { + 'data-test-subj': 'searchSideNav-Elasticsearch', href: '/app/enterprise_search/elasticsearch', id: 'elasticsearch', items: undefined, name: 'Elasticsearch', }, { + 'data-test-subj': 'searchSideNav-VectorSearch', href: '/app/enterprise_search/vector_search', id: 'vectorSearch', items: undefined, name: 'Vector Search', }, { + 'data-test-subj': 'searchSideNav-SemanticSearch', href: '/app/enterprise_search/semantic_search', id: 'semanticSearch', items: undefined, name: 'Semantic Search', }, { + 'data-test-subj': 'searchSideNav-AISearch', href: '/app/enterprise_search/ai_search', id: 'aiSearch', items: undefined, @@ -127,15 +145,18 @@ const baseNavItems = [ name: 'Getting started', }, { + 'data-test-subj': 'searchSideNav-EnterpriseSearch', id: 'enterpriseSearch', items: [ { + 'data-test-subj': 'searchSideNav-AppSearch', href: '/app/enterprise_search/app_search', id: 'app_search', items: undefined, name: 'App Search', }, { + 'data-test-subj': 'searchSideNav-WorkplaceSearch', href: '/app/enterprise_search/workplace_search', id: 'workplace_search', items: undefined, @@ -146,21 +167,102 @@ const baseNavItems = [ }, ]; +const mockNavLinks = [ + { + id: 'enterpriseSearch', + url: '/app/enterprise_search/overview', + }, + { + id: 'enterpriseSearchContent:searchIndices', + title: 'Indices', + url: '/app/enterprise_search/content/search_indices', + }, + { + id: 'enterpriseSearchContent:connectors', + title: 'Connectors', + url: '/app/enterprise_search/content/connectors', + }, + { + id: 'enterpriseSearchContent:webCrawlers', + title: 'Web crawlers', + url: '/app/enterprise_search/content/crawlers', + }, + { + id: 'enterpriseSearchApplications:playground', + title: 'Playground', + url: '/app/enterprise_search/applications/playground', + }, + { + id: 'enterpriseSearchApplications:searchApplications', + title: 'Search Applications', + url: '/app/enterprise_search/applications/search_applications', + }, + { + id: 'enterpriseSearchAnalytics', + title: 'Behavioral Analytics', + url: '/app/enterprise_search/analytics', + }, + { + id: 'searchInferenceEndpoints:inferenceEndpoints', + title: 'Inference Endpoints', + url: '/app/enterprise_search/relevance/inference_endpoints', + }, + { + id: 'appSearch:engines', + title: 'App Search', + url: '/app/enterprise_search/app_search', + }, + { + id: 'workplaceSearch', + title: 'Workplace Search', + url: '/app/enterprise_search/workplace_search', + }, + { + id: 'enterpriseSearchElasticsearch', + title: 'Elasticsearch', + url: '/app/enterprise_search/elasticsearch', + }, + { + id: 'enterpriseSearchVectorSearch', + title: 'Vector Search', + url: '/app/enterprise_search/vector_search', + }, + { + id: 'enterpriseSearchSemanticSearch', + title: 'Semantic Search', + url: '/app/enterprise_search/semantic_search', + }, + { + id: 'enterpriseSearchAISearch', + title: 'AI Search', + url: '/app/enterprise_search/ai_search', + }, +]; + +const defaultMockValues = { + hasEnterpriseLicense: true, + isSidebarEnabled: true, + productAccess: DEFAULT_PRODUCT_ACCESS, + productFeatures: DEFAULT_PRODUCT_FEATURES, +}; + describe('useEnterpriseSearchContentNav', () => { beforeEach(() => { jest.clearAllMocks(); mockKibanaValues.uiSettings.get.mockReturnValue(false); + mockKibanaValues.getNavLinks.mockReturnValue(mockNavLinks); }); it('returns an array of top-level Enterprise Search nav items', () => { const fullProductAccess: ProductAccess = DEFAULT_PRODUCT_ACCESS; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: fullProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); - expect(useEnterpriseSearchNav()).toEqual(baseNavItems); + const { result } = renderHook(() => useEnterpriseSearchNav()); + + expect(result.current).toEqual(baseNavItems); }); it('excludes legacy products when the user has no access to them', () => { @@ -171,13 +273,13 @@ describe('useEnterpriseSearchContentNav', () => { }; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: noProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); mockKibanaValues.uiSettings.get.mockReturnValue(false); - const esNav = useEnterpriseSearchNav(); + const { result } = renderHook(() => useEnterpriseSearchNav()); + const esNav = result.current; const legacyESNav = esNav?.find((item) => item.id === 'enterpriseSearch'); expect(legacyESNav).toBeUndefined(); }); @@ -190,18 +292,20 @@ describe('useEnterpriseSearchContentNav', () => { }; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: workplaceSearchProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); - const esNav = useEnterpriseSearchNav(); + const { result } = renderHook(() => useEnterpriseSearchNav()); + const esNav = result.current; const legacyESNav = esNav?.find((item) => item.id === 'enterpriseSearch'); expect(legacyESNav).not.toBeUndefined(); expect(legacyESNav).toEqual({ + 'data-test-subj': 'searchSideNav-EnterpriseSearch', id: 'enterpriseSearch', items: [ { + 'data-test-subj': 'searchSideNav-WorkplaceSearch', href: '/app/enterprise_search/workplace_search', id: 'workplace_search', name: 'Workplace Search', @@ -218,18 +322,20 @@ describe('useEnterpriseSearchContentNav', () => { }; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: appSearchProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); - const esNav = useEnterpriseSearchNav(); + const { result } = renderHook(() => useEnterpriseSearchNav()); + const esNav = result.current; const legacyESNav = esNav?.find((item) => item.id === 'enterpriseSearch'); expect(legacyESNav).not.toBeUndefined(); expect(legacyESNav).toEqual({ + 'data-test-subj': 'searchSideNav-EnterpriseSearch', id: 'enterpriseSearch', items: [ { + 'data-test-subj': 'searchSideNav-AppSearch', href: '/app/enterprise_search/app_search', id: 'app_search', name: 'App Search', @@ -243,21 +349,21 @@ describe('useEnterpriseSearchContentNav', () => { describe('useEnterpriseSearchApplicationNav', () => { beforeEach(() => { jest.clearAllMocks(); + mockKibanaValues.getNavLinks.mockReturnValue(mockNavLinks); mockKibanaValues.uiSettings.get.mockReturnValue(true); - setMockValues({ - isSidebarEnabled: true, - productAccess: DEFAULT_PRODUCT_ACCESS, - productFeatures: DEFAULT_PRODUCT_FEATURES, - }); + setMockValues(defaultMockValues); }); it('returns an array of top-level Enterprise Search nav items', () => { - expect(useEnterpriseSearchApplicationNav()).toEqual(baseNavItems); + const { result } = renderHook(() => useEnterpriseSearchApplicationNav()); + expect(result.current).toEqual(baseNavItems); }); it('returns selected engine sub nav items', () => { const engineName = 'my-test-engine'; - const navItems = useEnterpriseSearchApplicationNav(engineName); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchApplicationNav(engineName)); expect(navItems![0].id).toEqual('home'); expect(navItems?.slice(1).map((ni) => ni.name)).toEqual([ 'Content', @@ -317,7 +423,9 @@ describe('useEnterpriseSearchApplicationNav', () => { it('returns selected engine without tabs when isEmpty', () => { const engineName = 'my-test-engine'; - const navItems = useEnterpriseSearchApplicationNav(engineName, true); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchApplicationNav(engineName, true)); expect(navItems![0].id).toEqual('home'); expect(navItems?.slice(1).map((ni) => ni.name)).toEqual([ 'Content', @@ -348,7 +456,9 @@ describe('useEnterpriseSearchApplicationNav', () => { it('returns selected engine with conflict warning when hasSchemaConflicts', () => { const engineName = 'my-test-engine'; - const navItems = useEnterpriseSearchApplicationNav(engineName, false, true); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchApplicationNav(engineName, false, true)); // @ts-ignore const engineItem = navItems @@ -383,27 +493,20 @@ describe('useEnterpriseSearchApplicationNav', () => { describe('useEnterpriseSearchAnalyticsNav', () => { beforeEach(() => { jest.clearAllMocks(); - setMockValues({ - isSidebarEnabled: true, - }); + setMockValues(defaultMockValues); + mockKibanaValues.getNavLinks.mockReturnValue(mockNavLinks); }); it('returns basic nav all params are empty', () => { - const navItems = useEnterpriseSearchAnalyticsNav(); - expect(navItems).toEqual( - baseNavItems.map((item) => - item.id === 'content' - ? { - ...item, - items: item.items, - } - : item - ) - ); + const { result } = renderHook(() => useEnterpriseSearchAnalyticsNav()); + + expect(result.current).toEqual(baseNavItems); }); it('returns basic nav if only name provided', () => { - const navItems = useEnterpriseSearchAnalyticsNav('my-test-collection'); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchAnalyticsNav('my-test-collection')); expect(navItems).toEqual( baseNavItems.map((item) => item.id === 'content' @@ -417,16 +520,21 @@ describe('useEnterpriseSearchAnalyticsNav', () => { }); it('returns nav with sub items when name and paths provided', () => { - const navItems = useEnterpriseSearchAnalyticsNav('my-test-collection', { - explorer: '/explorer-path', - integration: '/integration-path', - overview: '/overview-path', - }); + const { + result: { current: navItems }, + } = renderHook(() => + useEnterpriseSearchAnalyticsNav('my-test-collection', { + explorer: '/explorer-path', + integration: '/integration-path', + overview: '/overview-path', + }) + ); const applicationsNav = navItems?.find((item) => item.id === 'build'); expect(applicationsNav).not.toBeUndefined(); const analyticsNav = applicationsNav?.items?.[2]; expect(analyticsNav).not.toBeUndefined(); expect(analyticsNav).toEqual({ + 'data-test-subj': 'searchSideNav-BehavioralAnalytics', href: '/app/enterprise_search/analytics', id: 'analyticsCollections', items: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx index 3b3960a7a92ba..8f83b6c73402e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx @@ -5,44 +5,22 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { useValues } from 'kea'; -import { EuiFlexGroup, EuiIcon, EuiText } from '@elastic/eui'; -import type { EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; +import { EuiFlexGroup, EuiIcon } from '@elastic/eui'; +import type { ChromeNavLink, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; import { i18n } from '@kbn/i18n'; -import { - ANALYTICS_PLUGIN, - APPLICATIONS_PLUGIN, - APP_SEARCH_PLUGIN, - ELASTICSEARCH_PLUGIN, - ENTERPRISE_SEARCH_CONTENT_PLUGIN, - ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, - AI_SEARCH_PLUGIN, - VECTOR_SEARCH_PLUGIN, - WORKPLACE_SEARCH_PLUGIN, - SEARCH_RELEVANCE_PLUGIN, - SEMANTIC_SEARCH_PLUGIN, -} from '../../../../common/constants'; -import { - SEARCH_APPLICATIONS_PATH, - SearchApplicationViewTabs, - PLAYGROUND_PATH, -} from '../../applications/routes'; +import { ANALYTICS_PLUGIN, APPLICATIONS_PLUGIN } from '../../../../common/constants'; +import { SEARCH_APPLICATIONS_PATH, SearchApplicationViewTabs } from '../../applications/routes'; import { useIndicesNav } from '../../enterprise_search_content/components/search_index/indices/indices_nav'; -import { - CONNECTORS_PATH, - CRAWLERS_PATH, - SEARCH_INDICES_PATH, -} from '../../enterprise_search_content/routes'; -import { INFERENCE_ENDPOINTS_PATH } from '../../enterprise_search_relevance/routes'; import { KibanaLogic } from '../kibana'; -import { LicensingLogic } from '../licensing'; - +import { buildBaseClassicNavItems } from './base_nav'; +import { generateSideNavItems } from './classic_nav_helpers'; import { generateNavLink } from './nav_link_helpers'; /** @@ -52,219 +30,21 @@ import { generateNavLink } from './nav_link_helpers'; * @returns The Enterprise Search navigation items */ export const useEnterpriseSearchNav = (alwaysReturn = false) => { - const { isSidebarEnabled, productAccess } = useValues(KibanaLogic); - - const { hasEnterpriseLicense } = useValues(LicensingLogic); + const { isSidebarEnabled, productAccess, getNavLinks } = useValues(KibanaLogic); const indicesNavItems = useIndicesNav(); - if (!isSidebarEnabled && !alwaysReturn) return undefined; + const navItems: Array> = useMemo(() => { + const baseNavItems = buildBaseClassicNavItems({ productAccess }); + const deepLinks = getNavLinks().reduce((links, link) => { + links[link.id] = link; + return links; + }, {} as Record); - const navItems: Array> = [ - { - id: 'home', - name: ( - - {i18n.translate('xpack.enterpriseSearch.nav.homeTitle', { - defaultMessage: 'Home', - })} - - ), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.URL, - }), - }, - { - id: 'content', - items: [ - { - id: 'search_indices', - name: i18n.translate('xpack.enterpriseSearch.nav.searchIndicesTitle', { - defaultMessage: 'Indices', - }), - ...generateNavLink({ - items: indicesNavItems, - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + SEARCH_INDICES_PATH, - }), - }, - { - id: 'connectors', - name: i18n.translate('xpack.enterpriseSearch.nav.connectorsTitle', { - defaultMessage: 'Connectors', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + CONNECTORS_PATH, - }), - }, - { - id: 'crawlers', - name: i18n.translate('xpack.enterpriseSearch.nav.crawlersTitle', { - defaultMessage: 'Web crawlers', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + CRAWLERS_PATH, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.contentTitle', { - defaultMessage: 'Content', - }), - }, - { - id: 'build', - items: [ - { - id: 'playground', - name: i18n.translate('xpack.enterpriseSearch.nav.PlaygroundTitle', { - defaultMessage: 'Playground', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: APPLICATIONS_PLUGIN.URL + PLAYGROUND_PATH, - }), - }, - { - id: 'searchApplications', - name: i18n.translate('xpack.enterpriseSearch.nav.searchApplicationsTitle', { - defaultMessage: 'Search Applications', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: APPLICATIONS_PLUGIN.URL + SEARCH_APPLICATIONS_PATH, - }), - }, - { - id: 'analyticsCollections', - name: i18n.translate('xpack.enterpriseSearch.nav.analyticsTitle', { - defaultMessage: 'Behavioral Analytics', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: ANALYTICS_PLUGIN.URL, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.applicationsTitle', { - defaultMessage: 'Build', - }), - }, - ...(hasEnterpriseLicense - ? [ - { - id: 'relevance', - items: [ - { - id: 'inference_endpoints', - name: i18n.translate('xpack.enterpriseSearch.nav.inferenceEndpointsTitle', { - defaultMessage: 'Inference Endpoints', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: SEARCH_RELEVANCE_PLUGIN.URL + INFERENCE_ENDPOINTS_PATH, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.relevanceTitle', { - defaultMessage: 'Relevance', - }), - }, - ] - : []), - { - id: 'es_getting_started', - items: [ - { - id: 'elasticsearch', - name: i18n.translate('xpack.enterpriseSearch.nav.elasticsearchTitle', { - defaultMessage: 'Elasticsearch', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: ELASTICSEARCH_PLUGIN.URL, - }), - }, - { - id: 'vectorSearch', - name: VECTOR_SEARCH_PLUGIN.NAME, - ...generateNavLink({ - shouldNotCreateHref: true, - to: VECTOR_SEARCH_PLUGIN.URL, - }), - }, - { - id: 'semanticSearch', - name: SEMANTIC_SEARCH_PLUGIN.NAME, - ...generateNavLink({ - shouldNotCreateHref: true, - to: SEMANTIC_SEARCH_PLUGIN.URL, - }), - }, - { - id: 'aiSearch', - name: i18n.translate('xpack.enterpriseSearch.nav.aiSearchTitle', { - defaultMessage: 'AI Search', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: AI_SEARCH_PLUGIN.URL, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle', { - defaultMessage: 'Getting started', - }), - }, - ...(productAccess.hasAppSearchAccess || productAccess.hasWorkplaceSearchAccess - ? [ - { - id: 'enterpriseSearch', - items: [ - ...(productAccess.hasAppSearchAccess - ? [ - { - id: 'app_search', - name: i18n.translate('xpack.enterpriseSearch.nav.appSearchTitle', { - defaultMessage: 'App Search', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: APP_SEARCH_PLUGIN.URL, - }), - }, - ] - : []), - ...(productAccess.hasWorkplaceSearchAccess - ? [ - { - id: 'workplace_search', - name: i18n.translate('xpack.enterpriseSearch.nav.workplaceSearchTitle', { - defaultMessage: 'Workplace Search', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: WORKPLACE_SEARCH_PLUGIN.URL, - }), - }, - ] - : []), - ], - name: i18n.translate('xpack.enterpriseSearch.nav.title', { - defaultMessage: 'Enterprise Search', - }), - }, - ] - : []), - ]; + return generateSideNavItems(baseNavItems, deepLinks, { search_indices: indicesNavItems }); + }, [productAccess, indicesNavItems]); + + if (!isSidebarEnabled && !alwaysReturn) return undefined; return navItems; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts index fff28345bb1bb..50c85a268e366 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts @@ -36,6 +36,7 @@ describe('generateNavLink', () => { navItem.onClick({ preventDefault: jest.fn() } as any); expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/test', { shouldNotCreateHref: false, + shouldNotPrepend: false, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts index f086433c9fc0e..36000307adcc3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts @@ -5,27 +5,32 @@ * 2.0. */ -import { EuiSideNavItemType } from '@elastic/eui'; +import { EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; import { stripTrailingSlash } from '../../../../common/strip_slashes'; import { KibanaLogic } from '../kibana'; -import { generateReactRouterProps, ReactRouterProps } from '../react_router_helpers'; -import { GeneratedReactRouterProps } from '../react_router_helpers/generate_react_router_props'; +import { + type GeneratedReactRouterProps, + generateReactRouterProps, +} from '../react_router_helpers/generate_react_router_props'; +import { ReactRouterProps } from '../types'; interface Params { - items?: Array>; // Primarily passed if using `items` to determine isSelected - if not, you can just set `items` outside of this helper + items?: Array>; // Primarily passed if using `items` to determine isSelected - if not, you can just set `items` outside of this helper shouldShowActiveForSubroutes?: boolean; to: string; } type NavLinkProps = GeneratedReactRouterProps & - Pick, 'isSelected' | 'items'>; + Pick, 'isSelected' | 'items'>; + +export type GenerateNavLinkParameters = Params & ReactRouterProps; export const generateNavLink = ({ items, ...rest -}: Params & ReactRouterProps): NavLinkProps => { +}: GenerateNavLinkParameters): NavLinkProps => { const linkProps = { ...generateReactRouterProps({ ...rest }), isSelected: getNavLinkActive({ items, ...rest }), @@ -38,14 +43,15 @@ export const getNavLinkActive = ({ shouldShowActiveForSubroutes = false, items = [], shouldNotCreateHref = false, -}: Params & ReactRouterProps): boolean => { + shouldNotPrepend = false, +}: GenerateNavLinkParameters): boolean => { const { pathname } = KibanaLogic.values.history.location; const currentPath = stripTrailingSlash(pathname); const { href: currentPathHref } = generateReactRouterProps({ shouldNotCreateHref: false, to: currentPath, }); - const { href: toHref } = generateReactRouterProps({ shouldNotCreateHref, to }); + const { href: toHref } = generateReactRouterProps({ shouldNotCreateHref, shouldNotPrepend, to }); if (currentPathHref === toHref) return true; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts index a399d632140b6..cf02c3ed74f71 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts @@ -30,12 +30,19 @@ interface CreateHrefDeps { } export interface CreateHrefOptions { shouldNotCreateHref?: boolean; + shouldNotPrepend?: boolean; } export const createHref = ( path: string, { history, http }: CreateHrefDeps, - { shouldNotCreateHref }: CreateHrefOptions = {} + { shouldNotCreateHref, shouldNotPrepend }: CreateHrefOptions = {} ): string => { - return shouldNotCreateHref ? http.basePath.prepend(path) : history.createHref({ pathname: path }); + if (shouldNotCreateHref) { + if (shouldNotPrepend) { + return path; + } + return http.basePath.prepend(path); + } + return history.createHref({ pathname: path }); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx index 8271f49f9f39a..708cc597e582d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx @@ -26,7 +26,9 @@ import { } from '@elastic/eui'; import { EuiPanelProps } from '@elastic/eui/src/components/panel/panel'; -import { generateReactRouterProps, ReactRouterProps } from '.'; +import { ReactRouterProps } from '../types'; + +import { generateReactRouterProps } from '.'; /** * Correctly typed component helpers with React-Router-friendly `href` and `onClick` props diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts index 309f94fcf55b4..de2a80ee5eaf4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts @@ -44,6 +44,7 @@ describe('generateReactRouterProps', () => { expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/test', { shouldNotCreateHref: false, + shouldNotPrepend: false, }); }); @@ -63,6 +64,7 @@ describe('generateReactRouterProps', () => { expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/app/enterprise_search/test', { shouldNotCreateHref: true, + shouldNotPrepend: false, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts index 2ef7f556eb2d1..89219362e5be4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts @@ -11,6 +11,7 @@ import { EuiSideNavItemType } from '@elastic/eui'; import { HttpLogic } from '../http'; import { KibanaLogic } from '../kibana'; +import { ReactRouterProps } from '../types'; import { letBrowserHandleEvent, createHref } from '.'; @@ -23,14 +24,6 @@ import { letBrowserHandleEvent, createHref } from '.'; * but separated out from EuiLink portion as we use this for multiple EUI components */ -export interface ReactRouterProps { - to: string; - onClick?(): void; - // Used to navigate outside of the React Router plugin basename but still within Kibana, - // e.g. if we need to go from Enterprise Search to App Search - shouldNotCreateHref?: boolean; -} - export type GeneratedReactRouterProps = Required< Pick, 'href' | 'onClick'> >; @@ -39,12 +32,13 @@ export const generateReactRouterProps = ({ to, onClick, shouldNotCreateHref = false, + shouldNotPrepend = false, }: ReactRouterProps): GeneratedReactRouterProps => { const { navigateToUrl, history } = KibanaLogic.values; const { http } = HttpLogic.values; // Generate the correct link href (with basename etc. accounted for) - const href = createHref(to, { history, http }, { shouldNotCreateHref }); + const href = createHref(to, { history, http }, { shouldNotCreateHref, shouldNotPrepend }); const reactRouterLinkClick = (event: React.MouseEvent) => { if (onClick) onClick(); // Run any passed click events (e.g. telemetry) @@ -54,7 +48,7 @@ export const generateReactRouterProps = ({ event.preventDefault(); // Perform SPA navigation. - navigateToUrl(to, { shouldNotCreateHref }); + navigateToUrl(to, { shouldNotCreateHref, shouldNotPrepend }); }; return { href, onClick: reactRouterLinkClick }; 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 index ded9310fe361a..237e0d342ed1f 100644 --- 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 @@ -8,7 +8,6 @@ export { letBrowserHandleEvent } from './link_events'; export type { CreateHrefOptions } from './create_href'; export { createHref } from './create_href'; -export type { ReactRouterProps } from './generate_react_router_props'; export { generateReactRouterProps } from './generate_react_router_props'; export { EuiLinkTo, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index 51a83cb15cca5..095f1dddfcc4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -5,7 +5,12 @@ * 2.0. */ +import type { ReactNode } from 'react'; + +import type { AppDeepLinkId, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; + import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../common/constants'; +import type { ProductAccess } from '../../../common/types'; import { ADD, UPDATE } from './constants/operations'; @@ -57,3 +62,37 @@ export interface SingleUserRoleMapping { roleMapping: T; hasEnterpriseSearchRole?: boolean; } + +export interface ReactRouterProps { + to: string; + onClick?(): void; + // Used to navigate outside of the React Router plugin basename but still within Kibana, + // e.g. if we need to go from Enterprise Search to App Search + shouldNotCreateHref?: boolean; + // Used if to is already a fully qualified URL that doesn't need basePath prepended + shouldNotPrepend?: boolean; +} + +export type GenerateNavLinkParameters = { + items?: Array>; // Primarily passed if using `items` to determine isSelected - if not, you can just set `items` outside of this helper + shouldShowActiveForSubroutes?: boolean; + to: string; +} & ReactRouterProps; + +export interface GenerateNavLinkFromDeepLinkParameters { + link: AppDeepLinkId; + shouldShowActiveForSubroutes?: boolean; +} + +export interface BuildClassicNavParameters { + productAccess: ProductAccess; +} + +export interface ClassicNavItem { + 'data-test-subj'?: string; + deepLink?: GenerateNavLinkFromDeepLinkParameters; + iconToString?: string; + id: string; + items?: ClassicNavItem[]; + name?: ReactNode; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx b/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx index d1729a50909ed..da30e6e93fadb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx @@ -58,6 +58,7 @@ export const mockKibanaProps: KibanaLogicProps = { elasticsearch_host: 'https://your_deployment_url', }, getChromeStyle$: jest.fn().mockReturnValue(of('classic')), + getNavLinks: jest.fn().mockReturnValue([]), guidedOnboarding: {}, history: mockHistory, indexMappingComponent: () => { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index e2d34ad004872..b408251126660 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -17359,27 +17359,16 @@ "xpack.enterpriseSearch.modelCard.elserPlaceholder.description": "ELSER est le modèle NLP d'Elastic pour la recherche sémantique en anglais, utilisant des vecteurs creux. Il donne la priorité à l'intention et à la signification contextuelle plutôt qu'à la correspondance littérale des termes. Il est optimisé spécifiquement pour les documents et les recherches en anglais sur la plateforme Elastic.", "xpack.enterpriseSearch.nameLabel": "Nom", "xpack.enterpriseSearch.nativeLabel": "Natif", - "xpack.enterpriseSearch.nav.aiSearchTitle": "Recherche propulsée par l'intelligence artificielle", "xpack.enterpriseSearch.nav.analyticsCollections.explorerTitle": "Explorer", "xpack.enterpriseSearch.nav.analyticsCollections.integrationTitle": "Intégration", "xpack.enterpriseSearch.nav.analyticsCollections.overviewTitle": "Aperçu", - "xpack.enterpriseSearch.nav.analyticsTitle": "Behavioral Analytics", "xpack.enterpriseSearch.nav.applications.searchApplications.connectTitle": "Connecter", "xpack.enterpriseSearch.nav.applicationsTitle": "Développer", - "xpack.enterpriseSearch.nav.appSearchTitle": "App Search", - "xpack.enterpriseSearch.nav.connectorsTitle": "Connecteurs", "xpack.enterpriseSearch.nav.contentTitle": "Contenu", - "xpack.enterpriseSearch.nav.crawlersTitle": "Robots d'indexation", - "xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch", - "xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "Premiers pas", "xpack.enterpriseSearch.nav.homeTitle": "Accueil", - "xpack.enterpriseSearch.nav.inferenceEndpointsTitle": "Points de terminaison d'inférence", - "xpack.enterpriseSearch.nav.PlaygroundTitle": "Playground", "xpack.enterpriseSearch.nav.relevanceTitle": "Pertinence", "xpack.enterpriseSearch.nav.searchApplication.contentTitle": "Contenu", "xpack.enterpriseSearch.nav.searchApplication.docsExplorerTitle": "Explorateur de documents", - "xpack.enterpriseSearch.nav.searchApplicationsTitle": "Applications de recherche", - "xpack.enterpriseSearch.nav.searchIndicesTitle": "Index", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.connectorsConfigurationLabel": "Configuration", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.crawlerConfigurationLabel": "Configuration", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.crawlerSchedulingLabel": "Planification", @@ -17391,7 +17380,6 @@ "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.schedulingTitle": "Planification", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.syncRulesLabel": "Règles de synchronisation", "xpack.enterpriseSearch.nav.title": "Enterprise Search", - "xpack.enterpriseSearch.nav.workplaceSearchTitle": "Workplace Search", "xpack.enterpriseSearch.navigation.applicationsSearchApplicationsLinkLabel": "Applications de recherche", "xpack.enterpriseSearch.navigation.appSearchEnginesLinkLabel": "Moteurs", "xpack.enterpriseSearch.navigation.contentConnectorsLinkLabel": "Connecteurs", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 21b2266f451c0..9831cfa07e6d3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17105,27 +17105,16 @@ "xpack.enterpriseSearch.modelCard.elserPlaceholder.description": "ELSERは、疎ベクトルを利用した英語のセマンティック検索のためのElasticのNLPモデルです。Elasticプラットフォームの英語ドキュメントやクエリー向けに特別に最適化されており、文字通りの用語一致よりも意図や文脈上の意味を優先します。", "xpack.enterpriseSearch.nameLabel": "名前", "xpack.enterpriseSearch.nativeLabel": "ネイティブ", - "xpack.enterpriseSearch.nav.aiSearchTitle": "AI検索", "xpack.enterpriseSearch.nav.analyticsCollections.explorerTitle": "エクスプローラー", "xpack.enterpriseSearch.nav.analyticsCollections.integrationTitle": "統合", "xpack.enterpriseSearch.nav.analyticsCollections.overviewTitle": "概要", - "xpack.enterpriseSearch.nav.analyticsTitle": "Behavioral Analytics", "xpack.enterpriseSearch.nav.applications.searchApplications.connectTitle": "接続", "xpack.enterpriseSearch.nav.applicationsTitle": "ビルド", - "xpack.enterpriseSearch.nav.appSearchTitle": "App Search", - "xpack.enterpriseSearch.nav.connectorsTitle": "コネクター", "xpack.enterpriseSearch.nav.contentTitle": "コンテンツ", - "xpack.enterpriseSearch.nav.crawlersTitle": "Webクローラー", - "xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch", - "xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "はじめて使う", "xpack.enterpriseSearch.nav.homeTitle": "ホーム", - "xpack.enterpriseSearch.nav.inferenceEndpointsTitle": "推論エンドポイント", - "xpack.enterpriseSearch.nav.PlaygroundTitle": "Playground", "xpack.enterpriseSearch.nav.relevanceTitle": "関連性", "xpack.enterpriseSearch.nav.searchApplication.contentTitle": "コンテンツ", "xpack.enterpriseSearch.nav.searchApplication.docsExplorerTitle": "ドキュメントエクスプローラー", - "xpack.enterpriseSearch.nav.searchApplicationsTitle": "検索アプリケーション", - "xpack.enterpriseSearch.nav.searchIndicesTitle": "インデックス", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.connectorsConfigurationLabel": "構成", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.crawlerConfigurationLabel": "構成", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.crawlerSchedulingLabel": "スケジュール", @@ -17137,7 +17126,6 @@ "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.schedulingTitle": "スケジュール", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.syncRulesLabel": "同期ルール", "xpack.enterpriseSearch.nav.title": "エンタープライズ サーチ", - "xpack.enterpriseSearch.nav.workplaceSearchTitle": "Workplace Search", "xpack.enterpriseSearch.navigation.applicationsSearchApplicationsLinkLabel": "検索アプリケーション", "xpack.enterpriseSearch.navigation.appSearchEnginesLinkLabel": "エンジン", "xpack.enterpriseSearch.navigation.contentConnectorsLinkLabel": "コネクター", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0bfb50d722051..a7ae5366d9762 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17134,27 +17134,16 @@ "xpack.enterpriseSearch.modelCard.elserPlaceholder.description": "ELSER 是 Elastic 的利用稀疏向量执行英语语义搜索的 NLP 模型。与字面值匹配相比,它优先处理意图和上下文含义,对 Elastic 平台上的英语文档和查询专门进行了优化。", "xpack.enterpriseSearch.nameLabel": "名称", "xpack.enterpriseSearch.nativeLabel": "原生", - "xpack.enterpriseSearch.nav.aiSearchTitle": "AI 搜索", "xpack.enterpriseSearch.nav.analyticsCollections.explorerTitle": "浏览器", "xpack.enterpriseSearch.nav.analyticsCollections.integrationTitle": "集成", "xpack.enterpriseSearch.nav.analyticsCollections.overviewTitle": "概览", - "xpack.enterpriseSearch.nav.analyticsTitle": "行为分析", "xpack.enterpriseSearch.nav.applications.searchApplications.connectTitle": "连接", "xpack.enterpriseSearch.nav.applicationsTitle": "构建", - "xpack.enterpriseSearch.nav.appSearchTitle": "App Search", - "xpack.enterpriseSearch.nav.connectorsTitle": "连接器", "xpack.enterpriseSearch.nav.contentTitle": "内容", - "xpack.enterpriseSearch.nav.crawlersTitle": "网络爬虫", - "xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch", - "xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "入门", "xpack.enterpriseSearch.nav.homeTitle": "主页", - "xpack.enterpriseSearch.nav.inferenceEndpointsTitle": "推理终端", - "xpack.enterpriseSearch.nav.PlaygroundTitle": "Playground", "xpack.enterpriseSearch.nav.relevanceTitle": "相关性", "xpack.enterpriseSearch.nav.searchApplication.contentTitle": "内容", "xpack.enterpriseSearch.nav.searchApplication.docsExplorerTitle": "文档浏览器", - "xpack.enterpriseSearch.nav.searchApplicationsTitle": "搜索应用程序", - "xpack.enterpriseSearch.nav.searchIndicesTitle": "索引", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.connectorsConfigurationLabel": "配置", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.crawlerConfigurationLabel": "配置", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.crawlerSchedulingLabel": "正在计划", @@ -17166,7 +17155,6 @@ "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.schedulingTitle": "正在计划", "xpack.enterpriseSearch.nav.searchIndicesTitle.nav.syncRulesLabel": "同步规则", "xpack.enterpriseSearch.nav.title": "Enterprise Search", - "xpack.enterpriseSearch.nav.workplaceSearchTitle": "Workplace Search", "xpack.enterpriseSearch.navigation.applicationsSearchApplicationsLinkLabel": "搜索应用程序", "xpack.enterpriseSearch.navigation.appSearchEnginesLinkLabel": "引擎", "xpack.enterpriseSearch.navigation.contentConnectorsLinkLabel": "连接器", diff --git a/x-pack/test/functional/config.base.js b/x-pack/test/functional/config.base.js index b35d1f6b6673c..d5c6d77785b85 100644 --- a/x-pack/test/functional/config.base.js +++ b/x-pack/test/functional/config.base.js @@ -196,6 +196,9 @@ export default async function ({ readConfigFile }) { obsAIAssistantManagement: { pathname: '/app/management/kibana/observabilityAiAssistantManagement', }, + enterpriseSearch: { + pathname: '/app/enterprise_search/overview', + }, }, suiteTags: { diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index e0d12bdd7459e..0d270661a05df 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -54,6 +54,7 @@ import { UserProfilePageProvider } from './user_profile_page'; import { WatcherPageObject } from './watcher_page'; import { SearchProfilerPageProvider } from './search_profiler_page'; import { SearchPlaygroundPageProvider } from './search_playground_page'; +import { SearchClassicNavigationProvider } from './search_classic_navigation'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -93,6 +94,7 @@ export const pageObjects = { reporting: ReportingPageObject, roleMappings: RoleMappingsPageProvider, rollup: RollupPageObject, + searchClassicNavigation: SearchClassicNavigationProvider, searchProfiler: SearchProfilerPageProvider, searchPlayground: SearchPlaygroundPageProvider, searchSessionsManagement: SearchSessionsPageProvider, diff --git a/x-pack/test/functional/page_objects/search_classic_navigation.ts b/x-pack/test/functional/page_objects/search_classic_navigation.ts new file mode 100644 index 0000000000000..90ec1c6c46007 --- /dev/null +++ b/x-pack/test/functional/page_objects/search_classic_navigation.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; + +import { FtrProviderContext } from '../ftr_provider_context'; + +const TIMEOUT_CHECK = 3000; + +export function SearchClassicNavigationProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const retry = getService('retry'); + + async function getByVisibleText( + selector: string | (() => Promise), + text: string + ) { + const subjects = + typeof selector === 'string' ? await testSubjects.findAll(selector) : await selector(); + let found: WebElementWrapper | null = null; + for (const subject of subjects) { + const visibleText = await subject.getVisibleText(); + if (visibleText === text) { + found = subject; + break; + } + } + return found; + } + const sideNavTestSubj = (id: string) => `searchSideNav-${id}`; + + return { + async expectAllNavItems(items: Array<{ id: string; label: string }>) { + for (const navItem of items) { + await testSubjects.existOrFail(sideNavTestSubj(navItem.id)); + const itemElement = await testSubjects.find(sideNavTestSubj(navItem.id)); + const itemLabel = await itemElement.getVisibleText(); + expect(itemLabel).to.equal(navItem.label); + } + const allSideNavItems = await testSubjects.findAll('*searchSideNav-'); + expect(allSideNavItems.length).to.equal(items.length); + }, + + async expectNavItemExists(id: string) { + await testSubjects.existOrFail(sideNavTestSubj(id)); + }, + + async expectNavItemMissing(id: string) { + await testSubjects.missingOrFail(sideNavTestSubj(id)); + }, + + async clickNavItem(id: string) { + await testSubjects.existOrFail(sideNavTestSubj(id)); + await testSubjects.click(sideNavTestSubj(id)); + }, + + async expectNavItemActive(id: string) { + await testSubjects.existOrFail(sideNavTestSubj(id)); + const item = await testSubjects.find(sideNavTestSubj(id)); + expect(await item.elementHasClass('euiSideNavItemButton-isSelected')).to.be(true); + }, + + breadcrumbs: { + async expectExists() { + await testSubjects.existOrFail('breadcrumbs', { timeout: TIMEOUT_CHECK }); + }, + async clickBreadcrumb(text: string) { + await (await getByVisibleText('~breadcrumb', text))?.click(); + }, + async getBreadcrumb(text: string) { + return getByVisibleText('~breadcrumb', text); + }, + async expectBreadcrumbExists(text: string) { + await retry.try(async () => { + expect(await getByVisibleText('~breadcrumb', text)).not.be(null); + }); + }, + async expectBreadcrumbMissing(text: string) { + await retry.try(async () => { + expect(await getByVisibleText('~breadcrumb', text)).be(null); + }); + }, + }, + + // helper to assert that the page did not reload + async createNoPageReloadCheck() { + const trackReloadTs = Date.now(); + await browser.execute( + ({ ts }) => { + // @ts-ignore + window.__testTrackReload__ = ts; + }, + { + ts: trackReloadTs, + } + ); + + return async () => { + const noReload = await browser.execute( + ({ ts }) => { + // @ts-ignore + return window.__testTrackReload__ && window.__testTrackReload__ === ts; + }, + { + ts: trackReloadTs, + } + ); + expect(noReload).to.be(true); + }; + }, + }; +} diff --git a/x-pack/test/functional_search/config.ts b/x-pack/test/functional_search/config.ts index f997aaea7c5e2..c7708363766b0 100644 --- a/x-pack/test/functional_search/config.ts +++ b/x-pack/test/functional_search/config.ts @@ -17,7 +17,17 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), + junit: { + reportName: 'Search Solution UI Functional Tests', + }, testFiles: [require.resolve('.')], + esTestCluster: { + ...functionalConfig.get('esTestCluster'), + serverArgs: [ + ...functionalConfig.get('esTestCluster.serverArgs'), + 'xpack.security.enabled=true', + ], + }, kbnTestServer: { ...functionalConfig.get('kbnTestServer'), serverArgs: [ diff --git a/x-pack/test/functional_search/index.ts b/x-pack/test/functional_search/index.ts index 149b3dbcf7eca..d48bd1d695d16 100644 --- a/x-pack/test/functional_search/index.ts +++ b/x-pack/test/functional_search/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from './ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext): void => { describe('Search solution tests', function () { + loadTestFile(require.resolve('./tests/classic_navigation')); loadTestFile(require.resolve('./tests/solution_navigation')); }); }; diff --git a/x-pack/test/functional_search/tests/classic_navigation.ts b/x-pack/test/functional_search/tests/classic_navigation.ts new file mode 100644 index 0000000000000..7ec78394dae74 --- /dev/null +++ b/x-pack/test/functional_search/tests/classic_navigation.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function searchSolutionNavigation({ + getPageObjects, + getService, +}: FtrProviderContext) { + const { common, searchClassicNavigation } = getPageObjects(['common', 'searchClassicNavigation']); + const spaces = getService('spaces'); + const browser = getService('browser'); + + describe('Search Classic Navigation', () => { + let cleanUp: () => Promise; + let spaceCreated: { id: string } = { id: '' }; + + before(async () => { + // Navigate to the spaces management page which will log us in Kibana + await common.navigateToUrl('management', 'kibana/spaces', { + shouldUseHashForSubUrl: false, + }); + + // Create a space with the search solution and navigate to its home page + ({ cleanUp, space: spaceCreated } = await spaces.create({ solution: 'classic' })); + await browser.navigateTo(spaces.getRootUrl(spaceCreated.id)); + await common.navigateToApp('enterpriseSearch'); + }); + + after(async () => { + // Clean up space created + await cleanUp(); + }); + + it('renders expected navigation items', async () => { + await searchClassicNavigation.expectAllNavItems([ + { id: 'Home', label: 'Home' }, + { id: 'Content', label: 'Content' }, + { id: 'Indices', label: 'Indices' }, + { id: 'Connectors', label: 'Connectors' }, + { id: 'Crawlers', label: 'Web crawlers' }, + { id: 'Build', label: 'Build' }, + { id: 'Playground', label: 'Playground' }, + { id: 'SearchApplications', label: 'Search Applications' }, + { id: 'BehavioralAnalytics', label: 'Behavioral Analytics' }, + { id: 'Relevance', label: 'Relevance' }, + { id: 'InferenceEndpoints', label: 'Inference Endpoints' }, + { id: 'GettingStarted', label: 'Getting started' }, + { id: 'Elasticsearch', label: 'Elasticsearch' }, + { id: 'VectorSearch', label: 'Vector Search' }, + { id: 'SemanticSearch', label: 'Semantic Search' }, + { id: 'AISearch', label: 'AI Search' }, + ]); + }); + it('has expected navigation', async () => { + const expectNoPageReload = await searchClassicNavigation.createNoPageReloadCheck(); + + await searchClassicNavigation.expectNavItemExists('Home'); + + // Check Content + // > Indices + await searchClassicNavigation.clickNavItem('Indices'); + await searchClassicNavigation.expectNavItemActive('Indices'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Content'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Elasticsearch indices'); + // > Connectors + await searchClassicNavigation.clickNavItem('Connectors'); + await searchClassicNavigation.expectNavItemActive('Connectors'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Content'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Connectors'); + // > Crawlers + await searchClassicNavigation.clickNavItem('Crawlers'); + await searchClassicNavigation.expectNavItemActive('Crawlers'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Content'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Web crawlers'); + + // Check Build + // > Playground + await searchClassicNavigation.clickNavItem('Playground'); + await searchClassicNavigation.expectNavItemActive('Playground'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Build'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Playground'); + // > SearchApplications + await searchClassicNavigation.clickNavItem('SearchApplications'); + await searchClassicNavigation.expectNavItemActive('SearchApplications'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Build'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Search Applications'); + // > BehavioralAnalytics + await searchClassicNavigation.clickNavItem('BehavioralAnalytics'); + await searchClassicNavigation.expectNavItemActive('BehavioralAnalytics'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Build'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Behavioral Analytics'); + + // Check Relevance + // > InferenceEndpoints + await searchClassicNavigation.clickNavItem('InferenceEndpoints'); + await searchClassicNavigation.expectNavItemActive('InferenceEndpoints'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Relevance'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Inference Endpoints'); + + // Check Getting started + // > Elasticsearch + await searchClassicNavigation.clickNavItem('Elasticsearch'); + await searchClassicNavigation.expectNavItemActive('Elasticsearch'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists( + 'Getting started with Elasticsearch' + ); + // > VectorSearch + await searchClassicNavigation.clickNavItem('VectorSearch'); + await searchClassicNavigation.expectNavItemActive('VectorSearch'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Getting started'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Vector Search'); + // > SemanticSearch + await searchClassicNavigation.clickNavItem('SemanticSearch'); + await searchClassicNavigation.expectNavItemActive('SemanticSearch'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Getting started'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Semantic Search'); + // > AISearch + await searchClassicNavigation.clickNavItem('AISearch'); + await searchClassicNavigation.expectNavItemActive('AISearch'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('Getting started'); + await searchClassicNavigation.breadcrumbs.expectBreadcrumbExists('AI Search'); + + await expectNoPageReload(); + }); + }); +}