[Search] Introduce search navigation plugin (#200314)

This commit is contained in:
Rodney Norris 2024-11-22 08:35:49 -06:00 committed by GitHub
parent ae31ce1ea6
commit a84122c4ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 629 additions and 23 deletions

1
.github/CODEOWNERS vendored
View file

@ -961,6 +961,7 @@ x-pack/plugins/search_indices @elastic/search-kibana
x-pack/plugins/search_inference_endpoints @elastic/search-kibana
x-pack/plugins/search_notebooks @elastic/search-kibana
x-pack/plugins/search_playground @elastic/search-kibana
x-pack/plugins/search_solution/search_navigation @elastic/search-kibana
x-pack/plugins/searchprofiler @elastic/kibana-management
x-pack/plugins/security @elastic/kibana-security
x-pack/plugins/security_solution @elastic/security-solution

View file

@ -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.

View file

@ -802,6 +802,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",

View file

@ -143,6 +143,7 @@ pageLoadAssetSize:
searchHomepage: 19831
searchIndices: 20519
searchInferenceEndpoints: 20470
searchNavigation: 19233
searchNotebooks: 18942
searchPlayground: 19325
searchprofiler: 67080

View file

@ -1564,6 +1564,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"],

View file

@ -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",

View file

@ -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",

View file

@ -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,

View file

@ -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', () => {

View file

@ -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';

View file

@ -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;
}

View file

@ -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)) {

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# Search Navigation
The Search Navigation plugin is used to handle navigation for search solution plugins across both stack and serverless.

View file

@ -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';

View file

@ -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: ['<rootDir>/x-pack/plugins/search_solution/search_navigation'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/search_solution/search_navigation',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/search_solution/search_navigation/{public,server}/**/*.{ts,tsx}',
],
};

View file

@ -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": []
}
}

View file

@ -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<Partial<ChromeNavLink>> = [
{
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),
},
]);
});
});

View file

@ -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<string, ChromeNavLink | undefined>;
type SolutionNavItems = SolutionNavProps['items'];
export const classicNavigationFactory: ClassicNavigationFactoryFn = (
classicItems: ClassicNavItem[],
core: CoreStart,
history: ScopedHistory<unknown>
): 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<unknown> | undefined
): item is EuiSideNavItemTypeEnhanced<unknown> {
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,
};
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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';

View file

@ -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<SearchNavigationPluginSetup, SearchNavigationPluginStart>
{
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<void>> = [];
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<void>) {
this.onAppMountHandlers.push(handler);
}
private setGetBaseClassicNavItems(classicNavItemsFn: () => ClassicNavItem[]) {
this.baseClassicNavItemsFn = classicNavItemsFn;
}
private useClassicNavigation(history: ScopedHistory<unknown>) {
if (
this.baseClassicNavItemsFn === undefined ||
this.classicNavFactory === undefined ||
this.coreStart === undefined ||
this.currentChromeStyle !== 'classic'
)
return undefined;
return this.classicNavFactory(this.baseClassicNavItemsFn(), this.coreStart, history);
}
}

View file

@ -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>) => void;
handleOnAppMount: () => Promise<void>;
// 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<unknown>) => 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<unknown>
) => SolutionNavProps | undefined;

View file

@ -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;
};

View file

@ -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/**/*"]
}

View file

@ -6914,6 +6914,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 ""