From c85bf816b20a81dcc31c4dc1c3a75346c098a710 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Fri, 22 Nov 2024 08:35:49 -0600 Subject: [PATCH] [Search] Introduce search navigation plugin (#200314) (cherry picked from commit a84122c4cab5a61002d91631078056ee5d9cdd08) # Conflicts: # .github/CODEOWNERS --- docs/developer/plugin-list.asciidoc | 4 + package.json | 1 + packages/kbn-optimizer/limits.yml | 1 + tsconfig.base.json | 2 + x-pack/.i18nrc.json | 1 + x-pack/plugins/enterprise_search/kibana.jsonc | 5 +- .../applications/shared/layout/base_nav.tsx | 3 +- .../shared/layout/classic_nav_helpers.test.ts | 3 +- .../shared/layout/classic_nav_helpers.ts | 7 +- .../public/applications/shared/types.ts | 11 - .../enterprise_search/public/plugin.ts | 25 ++- .../plugins/enterprise_search/tsconfig.json | 3 +- .../search_navigation/README.mdx | 3 + .../search_navigation/common/index.ts | 9 + .../search_navigation/jest.config.js | 18 ++ .../search_navigation/kibana.jsonc | 21 ++ .../public/classic_navigation.test.ts | 201 ++++++++++++++++++ .../public/classic_navigation.ts | 124 +++++++++++ .../search_navigation/public/index.ts | 20 ++ .../search_navigation/public/plugin.ts | 92 ++++++++ .../search_navigation/public/types.ts | 51 +++++ .../search_navigation/public/utils.ts | 19 ++ .../search_navigation/tsconfig.json | 23 ++ yarn.lock | 4 + 24 files changed, 628 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/search_solution/search_navigation/README.mdx create mode 100644 x-pack/plugins/search_solution/search_navigation/common/index.ts create mode 100644 x-pack/plugins/search_solution/search_navigation/jest.config.js create mode 100644 x-pack/plugins/search_solution/search_navigation/kibana.jsonc create mode 100644 x-pack/plugins/search_solution/search_navigation/public/classic_navigation.test.ts create mode 100644 x-pack/plugins/search_solution/search_navigation/public/classic_navigation.ts create mode 100644 x-pack/plugins/search_solution/search_navigation/public/index.ts create mode 100644 x-pack/plugins/search_solution/search_navigation/public/plugin.ts create mode 100644 x-pack/plugins/search_solution/search_navigation/public/types.ts create mode 100644 x-pack/plugins/search_solution/search_navigation/public/utils.ts create mode 100644 x-pack/plugins/search_solution/search_navigation/tsconfig.json diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 6915662b1eec6..87f74ebdb7bb1 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -832,6 +832,10 @@ It uses Chromium and Puppeteer underneath to run the browser in headless mode. |The Inference Endpoints is a tool used to manage inference endpoints +|{kib-repo}blob/{branch}/x-pack/plugins/search_solution/search_navigation/README.mdx[searchNavigation] +|The Search Navigation plugin is used to handle navigation for search solution plugins across both stack and serverless. + + |{kib-repo}blob/{branch}/x-pack/plugins/search_notebooks/README.mdx[searchNotebooks] |This plugin contains endpoints and components for rendering search python notebooks in the persistent dev console. diff --git a/package.json b/package.json index 1554e71c387f1..b208a28cdb0d2 100644 --- a/package.json +++ b/package.json @@ -800,6 +800,7 @@ "@kbn/search-index-documents": "link:packages/kbn-search-index-documents", "@kbn/search-indices": "link:x-pack/plugins/search_indices", "@kbn/search-inference-endpoints": "link:x-pack/plugins/search_inference_endpoints", + "@kbn/search-navigation": "link:x-pack/plugins/search_solution/search_navigation", "@kbn/search-notebooks": "link:x-pack/plugins/search_notebooks", "@kbn/search-playground": "link:x-pack/plugins/search_playground", "@kbn/search-response-warnings": "link:packages/kbn-search-response-warnings", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 3c6d4c04a055b..533e632813960 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -143,6 +143,7 @@ pageLoadAssetSize: searchHomepage: 19831 searchIndices: 20519 searchInferenceEndpoints: 20470 + searchNavigation: 19233 searchNotebooks: 18942 searchPlayground: 19325 searchprofiler: 67080 diff --git a/tsconfig.base.json b/tsconfig.base.json index 8dcc96877bba5..3880224e8601c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1562,6 +1562,8 @@ "@kbn/search-indices/*": ["x-pack/plugins/search_indices/*"], "@kbn/search-inference-endpoints": ["x-pack/plugins/search_inference_endpoints"], "@kbn/search-inference-endpoints/*": ["x-pack/plugins/search_inference_endpoints/*"], + "@kbn/search-navigation": ["x-pack/plugins/search_solution/search_navigation"], + "@kbn/search-navigation/*": ["x-pack/plugins/search_solution/search_navigation/*"], "@kbn/search-notebooks": ["x-pack/plugins/search_notebooks"], "@kbn/search-notebooks/*": ["x-pack/plugins/search_notebooks/*"], "@kbn/search-playground": ["x-pack/plugins/search_playground"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index e1e8478aa0517..7c97dcf1b722f 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -130,6 +130,7 @@ "xpack.searchSharedUI": "packages/search/shared_ui", "xpack.searchHomepage": "plugins/search_homepage", "xpack.searchIndices": "plugins/search_indices", + "xpack.searchNavigation": "plugins/search_solution/search_navigation", "xpack.searchNotebooks": "plugins/search_notebooks", "xpack.searchPlayground": "plugins/search_playground", "xpack.searchInferenceEndpoints": "plugins/search_inference_endpoints", diff --git a/x-pack/plugins/enterprise_search/kibana.jsonc b/x-pack/plugins/enterprise_search/kibana.jsonc index e284ae1862144..65343904ba7fc 100644 --- a/x-pack/plugins/enterprise_search/kibana.jsonc +++ b/x-pack/plugins/enterprise_search/kibana.jsonc @@ -20,7 +20,7 @@ "logsShared", "logsDataAccess", "esUiShared", - "navigation" + "navigation", ], "optionalPlugins": [ "customIntegrations", @@ -34,8 +34,9 @@ "guidedOnboarding", "console", "searchConnectors", - "searchPlayground", "searchInferenceEndpoints", + "searchNavigation", + "searchPlayground", "embeddable", "discover", "charts", 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 index b971ab6deff53..a8fff53d8a9b2 100644 --- 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 @@ -17,10 +17,11 @@ import { SEARCH_AI_SEARCH, } from '@kbn/deeplinks-search'; import { i18n } from '@kbn/i18n'; +import type { ClassicNavItem } from '@kbn/search-navigation/public'; import { GETTING_STARTED_TITLE } from '../../../../common/constants'; -import { ClassicNavItem, BuildClassicNavParameters } from '../types'; +import { BuildClassicNavParameters } from '../types'; export const buildBaseClassicNavItems = ({ productAccess, 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 index 514072ba297aa..d43d14aba2235 100644 --- 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 @@ -8,6 +8,7 @@ import { mockKibanaValues } from '../../__mocks__/kea_logic'; import type { ChromeNavLink } from '@kbn/core-chrome-browser'; +import type { ClassicNavItem } from '@kbn/search-navigation/public'; import '../../__mocks__/react_router'; @@ -15,8 +16,6 @@ jest.mock('../react_router_helpers/link_events', () => ({ letBrowserHandleEvent: jest.fn(), })); -import { ClassicNavItem } from '../types'; - import { generateSideNavItems } from './classic_nav_helpers'; describe('generateSideNavItems', () => { 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 index 89f3c2ab5b59a..4609e01beb6f1 100644 --- 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 @@ -6,12 +6,9 @@ */ import { ChromeNavLink, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; +import type { ClassicNavItem } from '@kbn/search-navigation/public'; -import { - ClassicNavItem, - GenerateNavLinkFromDeepLinkParameters, - GenerateNavLinkParameters, -} from '../types'; +import type { GenerateNavLinkFromDeepLinkParameters, GenerateNavLinkParameters } from '../types'; import { generateNavLink } from './nav_link_helpers'; 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 095f1dddfcc4a..25fce6c62d05d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -5,8 +5,6 @@ * 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'; @@ -87,12 +85,3 @@ export interface GenerateNavLinkFromDeepLinkParameters { 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/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 4d2c66eee2e93..4d357956f6bb5 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -35,6 +35,7 @@ import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public' import { ELASTICSEARCH_URL_PLACEHOLDER } from '@kbn/search-api-panels/constants'; import { SearchConnectorsPluginStart } from '@kbn/search-connectors-plugin/public'; import { SearchInferenceEndpointsPluginStart } from '@kbn/search-inference-endpoints/public'; +import type { SearchNavigationPluginStart } from '@kbn/search-navigation/public'; import { SearchPlaygroundPluginStart } from '@kbn/search-playground/public'; import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; @@ -55,7 +56,7 @@ import { SEARCH_RELEVANCE_PLUGIN, } from '../common/constants'; import { registerLocators } from '../common/locators'; -import { ClientConfigType, InitialAppData } from '../common/types'; +import { ClientConfigType, InitialAppData, ProductAccess } from '../common/types'; import { hasEnterpriseLicense } from '../common/utils/licensing'; import { ENGINES_PATH } from './applications/app_search/routes'; @@ -99,6 +100,7 @@ export interface PluginsStart { navigation: NavigationPublicPluginStart; searchConnectors?: SearchConnectorsPluginStart; searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart; + searchNavigation?: SearchNavigationPluginStart; searchPlayground?: SearchPlaygroundPluginStart; security?: SecurityPluginStart; share?: SharePluginStart; @@ -618,6 +620,27 @@ export class EnterpriseSearchPlugin implements Plugin { }) ); }); + if (plugins.searchNavigation !== undefined) { + // while we have ent-search apps in the side nav, we need to provide access + // to the base set of classic side nav items to the search-navigation plugin. + import('./applications/shared/layout/base_nav').then(({ buildBaseClassicNavItems }) => { + plugins.searchNavigation?.setGetBaseClassicNavItems(() => { + const productAccess: ProductAccess = this.data?.access ?? { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }; + + return buildBaseClassicNavItems({ productAccess }); + }); + }); + + // This is needed so that we can fetch product access for plugins + // that need to share the classic nav. This can be removed when we + // remove product access and ent-search apps. + plugins.searchNavigation.registerOnAppMountHandler(async () => { + return this.getInitialData(core.http); + }); + } plugins.licensing?.license$.subscribe((license) => { if (hasEnterpriseLicense(license)) { diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json index 7b7556729a76c..de98a647e0a94 100644 --- a/x-pack/plugins/enterprise_search/tsconfig.json +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -83,6 +83,7 @@ "@kbn/security-plugin-types-common", "@kbn/core-security-server", "@kbn/core-security-server-mocks", - "@kbn/unsaved-changes-prompt" + "@kbn/unsaved-changes-prompt", + "@kbn/search-navigation", ] } diff --git a/x-pack/plugins/search_solution/search_navigation/README.mdx b/x-pack/plugins/search_solution/search_navigation/README.mdx new file mode 100644 index 0000000000000..13ece425b680f --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/README.mdx @@ -0,0 +1,3 @@ +# Search Navigation + +The Search Navigation plugin is used to handle navigation for search solution plugins across both stack and serverless. diff --git a/x-pack/plugins/search_solution/search_navigation/common/index.ts b/x-pack/plugins/search_solution/search_navigation/common/index.ts new file mode 100644 index 0000000000000..8a5bd50a5bc37 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/common/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PLUGIN_ID = 'searchNavigation'; +export const PLUGIN_NAME = 'searchNavigation'; diff --git a/x-pack/plugins/search_solution/search_navigation/jest.config.js b/x-pack/plugins/search_solution/search_navigation/jest.config.js new file mode 100644 index 0000000000000..e86a30c143245 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/jest.config.js @@ -0,0 +1,18 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/plugins/search_solution/search_navigation'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/plugins/search_solution/search_navigation', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/search_solution/search_navigation/{public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/x-pack/plugins/search_solution/search_navigation/kibana.jsonc b/x-pack/plugins/search_solution/search_navigation/kibana.jsonc new file mode 100644 index 0000000000000..4b10b5f3a5a78 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/kibana.jsonc @@ -0,0 +1,21 @@ +{ + "type": "plugin", + "id": "@kbn/search-navigation", + "owner": "@elastic/search-kibana", + "group": "search", + "visibility": "private", + "plugin": { + "id": "searchNavigation", + "server": false, + "browser": true, + "configPath": [ + "xpack", + "searchNavigation" + ], + "requiredPlugins": [], + "optionalPlugins": [ + "serverless" + ], + "requiredBundles": [] + } +} diff --git a/x-pack/plugins/search_solution/search_navigation/public/classic_navigation.test.ts b/x-pack/plugins/search_solution/search_navigation/public/classic_navigation.test.ts new file mode 100644 index 0000000000000..1b17296f54c73 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/public/classic_navigation.test.ts @@ -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 type { CoreStart, ScopedHistory } from '@kbn/core/public'; +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import { classicNavigationFactory } from './classic_navigation'; +import { ClassicNavItem } from './types'; + +describe('classicNavigationFactory', function () { + const mockedNavLinks: Array> = [ + { + id: 'enterpriseSearch', + url: '/app/enterprise_search/overview', + title: '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', + }, + ]; + const mockedCoreStart = { + chrome: { + navLinks: { + getAll: () => mockedNavLinks, + }, + }, + }; + const core = mockedCoreStart as unknown as CoreStart; + const mockHistory = { + location: { + pathname: '/', + }, + createHref: jest.fn(), + }; + const history = mockHistory as unknown as ScopedHistory; + + beforeEach(() => { + jest.clearAllMocks(); + mockHistory.location.pathname = '/'; + mockHistory.createHref.mockReturnValue('/'); + }); + + it('can render top-level items', () => { + const items: ClassicNavItem[] = [ + { + id: 'unit-test', + deepLink: { + link: 'enterpriseSearch', + }, + }, + ]; + expect(classicNavigationFactory(items, core, history)).toEqual({ + icon: 'logoEnterpriseSearch', + items: [ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Overview', + onClick: expect.any(Function), + }, + ], + name: 'Elasticsearch', + }); + }); + + it('will set isSelected', () => { + mockHistory.location.pathname = '/overview'; + mockHistory.createHref.mockReturnValue('/app/enterprise_search/overview'); + + const items: ClassicNavItem[] = [ + { + id: 'unit-test', + deepLink: { + link: 'enterpriseSearch', + }, + }, + ]; + + const solutionNav = classicNavigationFactory(items, core, history); + expect(solutionNav!.items).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: true, + name: 'Overview', + onClick: expect.any(Function), + }, + ]); + }); + it('can render items with children', () => { + const items: ClassicNavItem[] = [ + { + id: 'searchContent', + name: 'Content', + items: [ + { + id: 'searchIndices', + deepLink: { + link: 'enterpriseSearchContent:searchIndices', + }, + }, + { + id: 'searchConnectors', + deepLink: { + link: 'enterpriseSearchContent:connectors', + }, + }, + ], + }, + ]; + + const solutionNav = classicNavigationFactory(items, core, history); + expect(solutionNav!.items).toEqual([ + { + id: 'searchContent', + items: [ + { + href: '/app/enterprise_search/content/search_indices', + id: 'searchIndices', + isSelected: false, + name: 'Indices', + onClick: expect.any(Function), + }, + { + href: '/app/enterprise_search/content/connectors', + id: 'searchConnectors', + isSelected: false, + name: 'Connectors', + onClick: expect.any(Function), + }, + ], + name: 'Content', + }, + ]); + }); + it('returns name if provided over the deeplink title', () => { + const items: ClassicNavItem[] = [ + { + id: 'searchIndices', + deepLink: { + link: 'enterpriseSearchContent:searchIndices', + }, + name: 'Index Management', + }, + ]; + const solutionNav = classicNavigationFactory(items, core, history); + expect(solutionNav!.items).toEqual([ + { + href: '/app/enterprise_search/content/search_indices', + id: 'searchIndices', + isSelected: false, + name: 'Index Management', + onClick: expect.any(Function), + }, + ]); + }); + it('removes item if deeplink not defined', () => { + const items: ClassicNavItem[] = [ + { + id: 'unit-test', + deepLink: { + link: 'enterpriseSearch', + }, + }, + { + id: 'serverlessElasticsearch', + deepLink: { + link: 'serverlessElasticsearch', + }, + }, + ]; + + const solutionNav = classicNavigationFactory(items, core, history); + expect(solutionNav!.items).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Overview', + onClick: expect.any(Function), + }, + ]); + }); +}); diff --git a/x-pack/plugins/search_solution/search_navigation/public/classic_navigation.ts b/x-pack/plugins/search_solution/search_navigation/public/classic_navigation.ts new file mode 100644 index 0000000000000..99a6b33c4bcce --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/public/classic_navigation.ts @@ -0,0 +1,124 @@ +/* + * 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 { type MouseEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { CoreStart, ScopedHistory } from '@kbn/core/public'; +import type { ChromeNavLink, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; +import type { SolutionNavProps } from '@kbn/shared-ux-page-solution-nav'; + +import type { ClassicNavItem, ClassicNavItemDeepLink, ClassicNavigationFactoryFn } from './types'; +import { stripTrailingSlash } from './utils'; + +type DeepLinksMap = Record; +type SolutionNavItems = SolutionNavProps['items']; + +export const classicNavigationFactory: ClassicNavigationFactoryFn = ( + classicItems: ClassicNavItem[], + core: CoreStart, + history: ScopedHistory +): SolutionNavProps | undefined => { + const navLinks = core.chrome.navLinks.getAll(); + const deepLinks = navLinks.reduce((links: DeepLinksMap, link: ChromeNavLink) => { + links[link.id] = link; + return links; + }, {}); + + const currentPath = stripTrailingSlash(history.location.pathname); + const currentLocation = history.createHref({ pathname: currentPath }); + const items: SolutionNavItems = generateSideNavItems( + classicItems, + core, + deepLinks, + currentLocation + ); + + return { + items, + icon: 'logoEnterpriseSearch', + name: i18n.translate('xpack.searchNavigation.classicNav.name', { + defaultMessage: 'Elasticsearch', + }), + }; +}; + +function generateSideNavItems( + classicItems: ClassicNavItem[], + core: CoreStart, + deepLinks: DeepLinksMap, + currentLocation: string +): SolutionNavItems { + const result: SolutionNavItems = []; + + for (const navItem of classicItems) { + let children: SolutionNavItems | undefined; + + const { deepLink, items, ...rest } = navItem; + if (items) { + children = generateSideNavItems(items, core, deepLinks, currentLocation); + } + + let item: EuiSideNavItemTypeEnhanced<{}> | undefined; + if (deepLink) { + const sideNavProps = getSideNavItemLinkProps(deepLink, deepLinks, core, currentLocation); + if (sideNavProps) { + const { name, ...linkProps } = sideNavProps; + item = { + ...rest, + ...linkProps, + name: navItem?.name ?? name, + }; + } + } else { + item = { + ...rest, + items: children, + name: navItem.name, + }; + } + + if (isValidSideNavItem(item)) { + result.push(item); + } + } + + return result; +} + +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; +} + +function getSideNavItemLinkProps( + { link, shouldShowActiveForSubroutes }: ClassicNavItemDeepLink, + deepLinks: DeepLinksMap, + core: CoreStart, + currentLocation: string +) { + const deepLink = deepLinks[link]; + if (!deepLink || !deepLink.url) return undefined; + const isSelected = Boolean( + deepLink.url === currentLocation || + (shouldShowActiveForSubroutes && currentLocation.startsWith(deepLink.url)) + ); + + return { + onClick: (e: MouseEvent) => { + e.preventDefault(); + core.application.navigateToUrl(deepLink.url); + }, + href: deepLink.url, + name: deepLink.title, + isSelected, + }; +} diff --git a/x-pack/plugins/search_solution/search_navigation/public/index.ts b/x-pack/plugins/search_solution/search_navigation/public/index.ts new file mode 100644 index 0000000000000..f5fc03e088a2c --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/public/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginInitializerContext } from '@kbn/core-plugins-browser'; +import { SearchNavigationPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SearchNavigationPlugin(initializerContext); +} + +export type { + SearchNavigationPluginSetup, + SearchNavigationPluginStart, + ClassicNavItem, + ClassicNavItemDeepLink, +} from './types'; diff --git a/x-pack/plugins/search_solution/search_navigation/public/plugin.ts b/x-pack/plugins/search_solution/search_navigation/public/plugin.ts new file mode 100644 index 0000000000000..4b618a6245c40 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/public/plugin.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, + ScopedHistory, +} from '@kbn/core/public'; +import type { ChromeStyle } from '@kbn/core-chrome-browser'; +import type { Logger } from '@kbn/logging'; +import type { + SearchNavigationPluginSetup, + SearchNavigationPluginStart, + ClassicNavItem, + ClassicNavigationFactoryFn, +} from './types'; + +export class SearchNavigationPlugin + implements Plugin +{ + private readonly logger: Logger; + private currentChromeStyle: ChromeStyle | undefined = undefined; + private baseClassicNavItemsFn: (() => ClassicNavItem[]) | undefined = undefined; + private coreStart: CoreStart | undefined = undefined; + private classicNavFactory: ClassicNavigationFactoryFn | undefined = undefined; + private onAppMountHandlers: Array<() => Promise> = []; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = this.initializerContext.logger.get(); + } + + public setup(_core: CoreSetup): SearchNavigationPluginSetup { + return {}; + } + + public start(core: CoreStart): SearchNavigationPluginStart { + this.coreStart = core; + core.chrome.getChromeStyle$().subscribe((value) => { + this.currentChromeStyle = value; + }); + + import('./classic_navigation').then(({ classicNavigationFactory }) => { + this.classicNavFactory = classicNavigationFactory; + }); + + return { + handleOnAppMount: this.handleOnAppMount.bind(this), + registerOnAppMountHandler: this.registerOnAppMountHandler.bind(this), + setGetBaseClassicNavItems: this.setGetBaseClassicNavItems.bind(this), + useClassicNavigation: this.useClassicNavigation.bind(this), + }; + } + + public stop() {} + + private async handleOnAppMount() { + if (this.onAppMountHandlers.length === 0) return; + + try { + await Promise.all(this.onAppMountHandlers); + } catch (e) { + this.logger.warn('Error handling app mount functions for search navigation'); + this.logger.warn(e); + } + } + + private registerOnAppMountHandler(handler: () => Promise) { + this.onAppMountHandlers.push(handler); + } + + private setGetBaseClassicNavItems(classicNavItemsFn: () => ClassicNavItem[]) { + this.baseClassicNavItemsFn = classicNavItemsFn; + } + + private useClassicNavigation(history: ScopedHistory) { + if ( + this.baseClassicNavItemsFn === undefined || + this.classicNavFactory === undefined || + this.coreStart === undefined || + this.currentChromeStyle !== 'classic' + ) + return undefined; + + return this.classicNavFactory(this.baseClassicNavItemsFn(), this.coreStart, history); + } +} diff --git a/x-pack/plugins/search_solution/search_navigation/public/types.ts b/x-pack/plugins/search_solution/search_navigation/public/types.ts new file mode 100644 index 0000000000000..91e8cc73524e2 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/public/types.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ReactNode } from 'react'; +import type { AppDeepLinkId } from '@kbn/core-chrome-browser'; +import type { CoreStart, ScopedHistory } from '@kbn/core/public'; +import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public'; +import type { SolutionNavProps } from '@kbn/shared-ux-page-solution-nav'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SearchNavigationPluginSetup {} + +export interface SearchNavigationPluginStart { + registerOnAppMountHandler: (onAppMount: () => Promise) => void; + handleOnAppMount: () => Promise; + // This is temporary until we can migrate building the class nav item list out of `enterprise_search` plugin + setGetBaseClassicNavItems: (classicNavItemsFn: () => ClassicNavItem[]) => void; + useClassicNavigation: (history: ScopedHistory) => SolutionNavProps | undefined; +} + +export interface AppPluginSetupDependencies { + serverless?: ServerlessPluginSetup; +} + +export interface AppPluginStartDependencies { + serverless?: ServerlessPluginStart; +} + +export interface ClassicNavItemDeepLink { + link: AppDeepLinkId; + shouldShowActiveForSubroutes?: boolean; +} + +export interface ClassicNavItem { + 'data-test-subj'?: string; + deepLink?: ClassicNavItemDeepLink; + iconToString?: string; + id: string; + items?: ClassicNavItem[]; + name?: ReactNode; +} + +export type ClassicNavigationFactoryFn = ( + items: ClassicNavItem[], + core: CoreStart, + history: ScopedHistory +) => SolutionNavProps | undefined; diff --git a/x-pack/plugins/search_solution/search_navigation/public/utils.ts b/x-pack/plugins/search_solution/search_navigation/public/utils.ts new file mode 100644 index 0000000000000..fb80778977b16 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/public/utils.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Helpers for stripping trailing or leading slashes from URLs or paths + * (usually ones that come in from React Router or API endpoints) + */ + +export const stripTrailingSlash = (url: string): string => { + return url && url.endsWith('/') ? url.slice(0, -1) : url; +}; + +export const stripLeadingSlash = (path: string): string => { + return path && path.startsWith('/') ? path.substring(1) : path; +}; diff --git a/x-pack/plugins/search_solution/search_navigation/tsconfig.json b/x-pack/plugins/search_solution/search_navigation/tsconfig.json new file mode 100644 index 0000000000000..6d61fbb24ec89 --- /dev/null +++ b/x-pack/plugins/search_solution/search_navigation/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "__mocks__/**/*", + "common/**/*", + "public/**/*", + "server/**/*", + "../../../../typings/**/*" + ], + "kbn_references": [ + "@kbn/core", + "@kbn/i18n", + "@kbn/core-chrome-browser", + "@kbn/shared-ux-page-solution-nav", + "@kbn/logging", + "@kbn/serverless", + "@kbn/core-plugins-browser", + ], + "exclude": ["target/**/*"] +} diff --git a/yarn.lock b/yarn.lock index d4292cff99ae3..8795abae4cc38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6900,6 +6900,10 @@ version "0.0.0" uid "" +"@kbn/search-navigation@link:x-pack/plugins/search_solution/search_navigation": + version "0.0.0" + uid "" + "@kbn/search-notebooks@link:x-pack/plugins/search_notebooks": version "0.0.0" uid ""