diff --git a/config/serverless.yml b/config/serverless.yml index 0e616a97177c6..8167ee5a42f4f 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -10,9 +10,6 @@ xpack.fleet.internal.activeAgentsSoftLimit: 25000 # Cloud links xpack.cloud.base_url: "https://cloud.elastic.co" -xpack.cloud.profile_url: "/user/settings" -xpack.cloud.billing_url: "/billing" -xpack.cloud.organization_url: "/account" # Enable ZDT migration algorithm migrations.algorithm: zdt diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts index 3d484b6d2fb0d..cbbca4d2c5828 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts @@ -140,7 +140,8 @@ export class ProjectNavigationService { const activeNodes = findActiveNodes( currentPathname, this.projectNavigationNavTreeFlattened, - location + location, + this.http?.basePath.prepend ); // Each time we call findActiveNodes() we create a new array of activeNodes. As this array is used diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.test.ts index 940a350f47c06..93abfd5d5a1f7 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.test.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.test.ts @@ -254,6 +254,29 @@ describe('findActiveNodes', () => { ]); }); + test('should find active node at the root', () => { + const flattendNavTree: Record = { + '[0]': { + id: 'root', + title: 'Root', + deepLink: getDeepLink('root', `root`), + path: ['root'], + }, + }; + + expect(findActiveNodes(`/foo/root`, flattendNavTree)).toEqual([ + [ + { + id: 'root', + title: 'Root', + isActive: true, + deepLink: getDeepLink('root', `root`), + path: ['root'], + }, + ], + ]); + }); + test('should match the longest matching node', () => { const flattendNavTree: Record = { '[0]': { @@ -345,7 +368,7 @@ describe('findActiveNodes', () => { id: 'item1', title: 'Item 1', path: ['root', 'item1'], - getIsActive: (loc) => loc.pathname.startsWith('/foo'), // Should match + getIsActive: ({ location }) => location.pathname.startsWith('/foo'), // Should match }, '[0][2]': { id: 'item2', diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts index e8a3750ade156..48158025414cb 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts @@ -73,9 +73,12 @@ function serializeDeeplinkUrl(url?: string) { * @param key The key to extract parent paths from * @returns An array of parent paths */ -function extractParentPaths(key: string) { +function extractParentPaths(key: string, navTree: Record) { // Split the string on every '][' to get an array of values without the brackets. const arr = key.split(']['); + if (arr.length === 1) { + return arr; + } // Add the brackets back in for the first and last elements, and all elements in between. arr[0] = `${arr[0]}]`; arr[arr.length - 1] = `[${arr[arr.length - 1]}`; @@ -83,10 +86,12 @@ function extractParentPaths(key: string) { arr[i] = `[${arr[i]}]`; } - return arr.reduce((acc, currentValue, currentIndex) => { - acc.push(arr.slice(0, currentIndex + 1).join('')); - return acc; - }, []); + return arr + .reduce((acc, currentValue, currentIndex) => { + acc.push(arr.slice(0, currentIndex + 1).join('')); + return acc; + }, []) + .filter((k) => Boolean(navTree[k])); } /** @@ -101,7 +106,8 @@ function extractParentPaths(key: string) { export const findActiveNodes = ( currentPathname: string, navTree: Record, - location?: Location + location?: Location, + prepend: (path: string) => string = (path) => path ): ChromeProjectNavigationNode[][] => { const activeNodes: ChromeProjectNavigationNode[][] = []; const matches: string[][] = []; @@ -113,9 +119,9 @@ export const findActiveNodes = ( Object.entries(navTree).forEach(([key, node]) => { if (node.getIsActive && location) { - const isActive = node.getIsActive(location); + const isActive = node.getIsActive({ pathNameSerialized: currentPathname, location, prepend }); if (isActive) { - const keysWithParents = extractParentPaths(key); + const keysWithParents = extractParentPaths(key, navTree); activeNodes.push(keysWithParents.map(activeNodeFromKey)); } return; @@ -139,7 +145,7 @@ export const findActiveNodes = ( if (matches.length > 0) { const longestMatch = matches[matches.length - 1]; longestMatch.forEach((key) => { - const keysWithParents = extractParentPaths(key); + const keysWithParents = extractParentPaths(key, navTree); activeNodes.push(keysWithParents.map(activeNodeFromKey)); }); } diff --git a/packages/core/chrome/core-chrome-browser/index.ts b/packages/core/chrome/core-chrome-browser/index.ts index d42d859cc6f3b..2e587cbcda497 100644 --- a/packages/core/chrome/core-chrome-browser/index.ts +++ b/packages/core/chrome/core-chrome-browser/index.ts @@ -34,6 +34,7 @@ export type { ChromeUserBanner, ChromeProjectNavigation, ChromeProjectNavigationNode, + CloudLinkId, SideNavCompProps, SideNavComponent, ChromeProjectBreadcrumb, diff --git a/packages/core/chrome/core-chrome-browser/src/index.ts b/packages/core/chrome/core-chrome-browser/src/index.ts index 7a414fc87164e..b931f05ecd327 100644 --- a/packages/core/chrome/core-chrome-browser/src/index.ts +++ b/packages/core/chrome/core-chrome-browser/src/index.ts @@ -33,6 +33,7 @@ export type { ChromeProjectNavigationNode, AppDeepLinkId, AppId, + CloudLinkId, SideNavCompProps, SideNavComponent, ChromeSetProjectBreadcrumbsParams, diff --git a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts index 5be1d505f2ff9..38317cfc1e0f2 100644 --- a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts +++ b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts @@ -44,6 +44,18 @@ export type AppDeepLinkId = | SearchLink | ObservabilityLink; +/** @public */ +export type CloudLinkId = 'userAndRoles' | 'performance' | 'billingAndSub'; + +export type GetIsActiveFn = (params: { + /** The current path name including the basePath + hash value but **without** any query params */ + pathNameSerialized: string; + /** The history Location */ + location: Location; + /** Utiliy function to prepend a path with the basePath */ + prepend: (path: string) => string; +}) => boolean; + /** @public */ export interface ChromeProjectNavigationNode { /** Optional id, if not passed a "link" must be provided. */ @@ -69,7 +81,7 @@ export interface ChromeProjectNavigationNode { /** * Optional function to get the active state. This function is called whenever the location changes. */ - getIsActive?: (location: Location) => boolean; + getIsActive?: GetIsActiveFn; /** * Optional flag to indicate if the breadcrumb should be hidden when this node is active. @@ -123,6 +135,8 @@ export interface NodeDefinition< title?: string; /** App id or deeplink id */ link?: LinkId; + /** Cloud link id */ + cloudLink?: CloudLinkId; /** Optional icon for the navigation node. Note: not all navigation depth will render the icon */ icon?: string; /** Optional children of the navigation node */ @@ -134,7 +148,7 @@ export interface NodeDefinition< /** * Optional function to get the active state. This function is called whenever the location changes. */ - getIsActive?: (location: Location) => boolean; + getIsActive?: GetIsActiveFn; /** * Optional flag to indicate if the breadcrumb should be hidden when this node is active. diff --git a/packages/shared-ux/chrome/navigation/mocks/src/jest.ts b/packages/shared-ux/chrome/navigation/mocks/src/jest.ts index 4fd298811b5d8..d2e7f9b77ca22 100644 --- a/packages/shared-ux/chrome/navigation/mocks/src/jest.ts +++ b/packages/shared-ux/chrome/navigation/mocks/src/jest.ts @@ -29,5 +29,19 @@ export const getServicesMock = ({ navigateToUrl, onProjectNavigationChange: jest.fn(), activeNodes$: of(activeNodes), + cloudLinks: { + billingAndSub: { + title: 'Mock Billing & Subscriptions', + href: 'https://cloud.elastic.co/account/billing', + }, + performance: { + title: 'Mock Performance', + href: 'https://cloud.elastic.co/deployments/123456789/performance', + }, + userAndRoles: { + title: 'Mock Users & Roles', + href: 'https://cloud.elastic.co/deployments/123456789/security/users', + }, + }, }; }; diff --git a/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts b/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts index a002f072770a8..2a243080b9e46 100644 --- a/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts +++ b/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts @@ -28,6 +28,7 @@ const allNavLinks: AppDeepLinkId[] = [ 'discover', 'fleet', 'integrations', + 'management', 'management:api_keys', 'management:cases', 'management:cross_cluster_replication', diff --git a/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts b/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts index 4514ffe30fcd4..28184ee086cc7 100644 --- a/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts +++ b/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts @@ -44,6 +44,20 @@ export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices> navLinks$: params.navLinks$ ?? new BehaviorSubject([]), onProjectNavigationChange: params.onProjectNavigationChange ?? (() => undefined), activeNodes$: new BehaviorSubject([]), + cloudLinks: { + billingAndSub: { + title: 'Billing & Subscriptions', + href: 'https://cloud.elastic.co/account/billing', + }, + performance: { + title: 'Performance', + href: 'https://cloud.elastic.co/deployments/123456789/performance', + }, + userAndRoles: { + title: 'Users & Roles', + href: 'https://cloud.elastic.co/deployments/123456789/security/users', + }, + }, }; } diff --git a/packages/shared-ux/chrome/navigation/src/cloud_links.tsx b/packages/shared-ux/chrome/navigation/src/cloud_links.tsx new file mode 100644 index 0000000000000..4efd73b8e3a97 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/cloud_links.tsx @@ -0,0 +1,58 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import type { CloudLinkId } from '@kbn/core-chrome-browser'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; + +export type CloudLinks = { + [id in CloudLinkId]?: { + title: string; + href: string; + }; +}; + +export const getCloudLinks = (cloud: CloudStart): CloudLinks => { + const { billingUrl, performanceUrl, usersAndRolesUrl } = cloud; + + const links: CloudLinks = {}; + + if (usersAndRolesUrl) { + links.userAndRoles = { + title: i18n.translate( + 'sharedUXPackages.chrome.sideNavigation.cloudLinks.usersAndRolesLinkText', + { + defaultMessage: 'Users and roles', + } + ), + href: usersAndRolesUrl, + }; + } + + if (performanceUrl) { + links.performance = { + title: i18n.translate( + 'sharedUXPackages.chrome.sideNavigation.cloudLinks.performanceLinkText', + { + defaultMessage: 'Performance', + } + ), + href: performanceUrl, + }; + } + + if (billingUrl) { + links.billingAndSub = { + title: i18n.translate('sharedUXPackages.chrome.sideNavigation.cloudLinks.billingLinkText', { + defaultMessage: 'Billing and subscription', + }), + href: billingUrl, + }; + } + + return links; +}; diff --git a/packages/shared-ux/chrome/navigation/src/services.tsx b/packages/shared-ux/chrome/navigation/src/services.tsx index f59365b21ead3..ebe7846b33378 100644 --- a/packages/shared-ux/chrome/navigation/src/services.tsx +++ b/packages/shared-ux/chrome/navigation/src/services.tsx @@ -6,8 +6,9 @@ * Side Public License, v 1. */ -import React, { FC, useContext } from 'react'; +import React, { FC, useContext, useMemo } from 'react'; import { NavigationKibanaDependencies, NavigationServices } from '../types'; +import { CloudLinks, getCloudLinks } from './cloud_links'; const Context = React.createContext(null); @@ -25,11 +26,13 @@ export const NavigationKibanaProvider: FC = ({ children, ...dependencies }) => { - const { core, serverless } = dependencies; + const { core, serverless, cloud } = dependencies; const { chrome, http } = core; const { basePath } = http; const { navigateToUrl } = core.application; + const cloudLinks: CloudLinks = useMemo(() => (cloud ? getCloudLinks(cloud) : {}), [cloud]); + const value: NavigationServices = { basePath, recentlyAccessed$: chrome.recentlyAccessed.get$(), @@ -38,6 +41,7 @@ export const NavigationKibanaProvider: FC = ({ navIsOpen: true, onProjectNavigationChange: serverless.setNavigation, activeNodes$: serverless.getActiveNavigationNodes$(), + cloudLinks, }; return {children}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap b/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap new file mode 100644 index 0000000000000..232d6b7dc610e --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap @@ -0,0 +1,678 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` builds the full navigation tree when only custom project is provided reading the title from config or deeplink 1`] = ` +Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": undefined, + "id": "item1", + "isActive": false, + "path": Array [ + "group1", + "item1", + ], + "renderItem": undefined, + "title": "Item 1", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "", + "href": "", + "id": "item2", + "title": "Title from deeplink!", + "url": "", + }, + "href": undefined, + "id": "item2", + "isActive": false, + "path": Array [ + "group1", + "item2", + ], + "renderItem": undefined, + "title": "Title from deeplink!", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "", + "href": "", + "id": "item2", + "title": "Title from deeplink!", + "url": "", + }, + "href": undefined, + "id": "item3", + "isActive": false, + "path": Array [ + "group1", + "item3", + ], + "renderItem": undefined, + "title": "Deeplink title overriden", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1", + "isActive": false, + "path": Array [ + "group1", + ], + "title": "Group 1", + "type": "navGroup", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/discover", + "id": "discover", + "title": "Deeplink discover", + "url": "/mocked/discover", + }, + "href": undefined, + "id": "discover", + "isActive": false, + "path": Array [ + "rootNav:analytics", + "root", + "discover", + ], + "renderItem": undefined, + "title": "Deeplink discover", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/dashboards", + "id": "dashboards", + "title": "Deeplink dashboards", + "url": "/mocked/dashboards", + }, + "href": undefined, + "id": "dashboards", + "isActive": false, + "path": Array [ + "rootNav:analytics", + "root", + "dashboards", + ], + "renderItem": undefined, + "title": "Deeplink dashboards", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/visualize", + "id": "visualize", + "title": "Deeplink visualize", + "url": "/mocked/visualize", + }, + "href": undefined, + "id": "visualize", + "isActive": false, + "path": Array [ + "rootNav:analytics", + "root", + "visualize", + ], + "renderItem": undefined, + "title": "Deeplink visualize", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "root", + "isActive": false, + "path": Array [ + "rootNav:analytics", + "root", + ], + "title": "", + }, + ], + "deepLink": undefined, + "href": undefined, + "icon": "stats", + "id": "rootNav:analytics", + "isActive": false, + "path": Array [ + "rootNav:analytics", + ], + "title": "Data exploration", + "type": "navGroup", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:overview", + "id": "ml:overview", + "title": "Deeplink ml:overview", + "url": "/mocked/ml:overview", + }, + "href": undefined, + "id": "ml:overview", + "isActive": false, + "path": Array [ + "rootNav:ml", + "root", + "ml:overview", + ], + "renderItem": undefined, + "title": "Deeplink ml:overview", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:notifications", + "id": "ml:notifications", + "title": "Deeplink ml:notifications", + "url": "/mocked/ml:notifications", + }, + "href": undefined, + "id": "ml:notifications", + "isActive": false, + "path": Array [ + "rootNav:ml", + "root", + "ml:notifications", + ], + "renderItem": undefined, + "title": "Deeplink ml:notifications", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "root", + "isActive": false, + "path": Array [ + "rootNav:ml", + "root", + ], + "title": "", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:anomalyDetection", + "id": "ml:anomalyDetection", + "title": "Deeplink ml:anomalyDetection", + "url": "/mocked/ml:anomalyDetection", + }, + "href": undefined, + "id": "ml:anomalyDetection", + "isActive": false, + "path": Array [ + "rootNav:ml", + "anomaly_detection", + "ml:anomalyDetection", + ], + "renderItem": undefined, + "title": "Jobs", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:anomalyExplorer", + "id": "ml:anomalyExplorer", + "title": "Deeplink ml:anomalyExplorer", + "url": "/mocked/ml:anomalyExplorer", + }, + "href": undefined, + "id": "ml:anomalyExplorer", + "isActive": false, + "path": Array [ + "rootNav:ml", + "anomaly_detection", + "ml:anomalyExplorer", + ], + "renderItem": undefined, + "title": "Deeplink ml:anomalyExplorer", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:singleMetricViewer", + "id": "ml:singleMetricViewer", + "title": "Deeplink ml:singleMetricViewer", + "url": "/mocked/ml:singleMetricViewer", + }, + "href": undefined, + "id": "ml:singleMetricViewer", + "isActive": false, + "path": Array [ + "rootNav:ml", + "anomaly_detection", + "ml:singleMetricViewer", + ], + "renderItem": undefined, + "title": "Deeplink ml:singleMetricViewer", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:settings", + "id": "ml:settings", + "title": "Deeplink ml:settings", + "url": "/mocked/ml:settings", + }, + "href": undefined, + "id": "ml:settings", + "isActive": false, + "path": Array [ + "rootNav:ml", + "anomaly_detection", + "ml:settings", + ], + "renderItem": undefined, + "title": "Deeplink ml:settings", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "anomaly_detection", + "isActive": false, + "path": Array [ + "rootNav:ml", + "anomaly_detection", + ], + "title": "Anomaly Detection", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:dataFrameAnalytics", + "id": "ml:dataFrameAnalytics", + "title": "Deeplink ml:dataFrameAnalytics", + "url": "/mocked/ml:dataFrameAnalytics", + }, + "href": undefined, + "id": "ml:dataFrameAnalytics", + "isActive": false, + "path": Array [ + "rootNav:ml", + "data_frame_analytics", + "ml:dataFrameAnalytics", + ], + "renderItem": undefined, + "title": "Jobs", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:resultExplorer", + "id": "ml:resultExplorer", + "title": "Deeplink ml:resultExplorer", + "url": "/mocked/ml:resultExplorer", + }, + "href": undefined, + "id": "ml:resultExplorer", + "isActive": false, + "path": Array [ + "rootNav:ml", + "data_frame_analytics", + "ml:resultExplorer", + ], + "renderItem": undefined, + "title": "Deeplink ml:resultExplorer", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:analyticsMap", + "id": "ml:analyticsMap", + "title": "Deeplink ml:analyticsMap", + "url": "/mocked/ml:analyticsMap", + }, + "href": undefined, + "id": "ml:analyticsMap", + "isActive": false, + "path": Array [ + "rootNav:ml", + "data_frame_analytics", + "ml:analyticsMap", + ], + "renderItem": undefined, + "title": "Deeplink ml:analyticsMap", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "data_frame_analytics", + "isActive": false, + "path": Array [ + "rootNav:ml", + "data_frame_analytics", + ], + "title": "Data Frame Analytics", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:nodesOverview", + "id": "ml:nodesOverview", + "title": "Deeplink ml:nodesOverview", + "url": "/mocked/ml:nodesOverview", + }, + "href": undefined, + "id": "ml:nodesOverview", + "isActive": false, + "path": Array [ + "rootNav:ml", + "model_management", + "ml:nodesOverview", + ], + "renderItem": undefined, + "title": "Deeplink ml:nodesOverview", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:nodes", + "id": "ml:nodes", + "title": "Deeplink ml:nodes", + "url": "/mocked/ml:nodes", + }, + "href": undefined, + "id": "ml:nodes", + "isActive": false, + "path": Array [ + "rootNav:ml", + "model_management", + "ml:nodes", + ], + "renderItem": undefined, + "title": "Deeplink ml:nodes", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "model_management", + "isActive": false, + "path": Array [ + "rootNav:ml", + "model_management", + ], + "title": "Model Management", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:fileUpload", + "id": "ml:fileUpload", + "title": "Deeplink ml:fileUpload", + "url": "/mocked/ml:fileUpload", + }, + "href": undefined, + "id": "ml:fileUpload", + "isActive": false, + "path": Array [ + "rootNav:ml", + "data_visualizer", + "ml:fileUpload", + ], + "renderItem": undefined, + "title": "File", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:indexDataVisualizer", + "id": "ml:indexDataVisualizer", + "title": "Deeplink ml:indexDataVisualizer", + "url": "/mocked/ml:indexDataVisualizer", + }, + "href": undefined, + "id": "ml:indexDataVisualizer", + "isActive": false, + "path": Array [ + "rootNav:ml", + "data_visualizer", + "ml:indexDataVisualizer", + ], + "renderItem": undefined, + "title": "Data view", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "data_visualizer", + "isActive": false, + "path": Array [ + "rootNav:ml", + "data_visualizer", + ], + "title": "Data Visualizer", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:explainLogRateSpikes", + "id": "ml:explainLogRateSpikes", + "title": "Deeplink ml:explainLogRateSpikes", + "url": "/mocked/ml:explainLogRateSpikes", + }, + "href": undefined, + "id": "ml:explainLogRateSpikes", + "isActive": false, + "path": Array [ + "rootNav:ml", + "aiops_labs", + "ml:explainLogRateSpikes", + ], + "renderItem": undefined, + "title": "Deeplink ml:explainLogRateSpikes", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:logPatternAnalysis", + "id": "ml:logPatternAnalysis", + "title": "Deeplink ml:logPatternAnalysis", + "url": "/mocked/ml:logPatternAnalysis", + }, + "href": undefined, + "id": "ml:logPatternAnalysis", + "isActive": false, + "path": Array [ + "rootNav:ml", + "aiops_labs", + "ml:logPatternAnalysis", + ], + "renderItem": undefined, + "title": "Deeplink ml:logPatternAnalysis", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:changePointDetections", + "id": "ml:changePointDetections", + "title": "Deeplink ml:changePointDetections", + "url": "/mocked/ml:changePointDetections", + }, + "href": undefined, + "id": "ml:changePointDetections", + "isActive": false, + "path": Array [ + "rootNav:ml", + "aiops_labs", + "ml:changePointDetections", + ], + "renderItem": undefined, + "title": "Deeplink ml:changePointDetections", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "aiops_labs", + "isActive": false, + "path": Array [ + "rootNav:ml", + "aiops_labs", + ], + "title": "AIOps labs", + }, + ], + "deepLink": undefined, + "href": undefined, + "icon": "machineLearningApp", + "id": "rootNav:ml", + "isActive": false, + "path": Array [ + "rootNav:ml", + ], + "title": "Machine Learning", + "type": "navGroup", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/dev_tools", + "id": "dev_tools", + "title": "Deeplink dev_tools", + "url": "/mocked/dev_tools", + }, + "href": undefined, + "icon": "editorCodeBlock", + "id": "devTools", + "isActive": false, + "path": Array [ + "devTools", + ], + "title": "Developer tools", + "type": "navGroup", + }, + Object { + "breadcrumbStatus": "hidden", + "children": Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/management", + "id": "management", + "title": "Deeplink management", + "url": "/mocked/management", + }, + "href": undefined, + "id": "management", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + "settings", + "management", + ], + "renderItem": undefined, + "title": "Management", + }, + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://cloud.elastic.co/deployments/123456789/security/users", + "id": "cloudLinkUserAndRoles", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + "settings", + "cloudLinkUserAndRoles", + ], + "renderItem": undefined, + "title": "Mock Users & Roles", + }, + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://cloud.elastic.co/deployments/123456789/performance", + "id": "cloudLinkPerformance", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + "settings", + "cloudLinkPerformance", + ], + "renderItem": undefined, + "title": "Mock Performance", + }, + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://cloud.elastic.co/account/billing", + "id": "cloudLinkBilling", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + "settings", + "cloudLinkBilling", + ], + "renderItem": undefined, + "title": "Mock Billing & Subscriptions", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "settings", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + "settings", + ], + "title": "", + }, + ], + "deepLink": undefined, + "href": undefined, + "icon": "gear", + "id": "project_settings_project_nav", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + ], + "title": "Project settings", + "type": "navGroup", + }, +] +`; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/group_as_link.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/group_as_link.tsx new file mode 100644 index 0000000000000..092d243722cd4 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/group_as_link.tsx @@ -0,0 +1,61 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import type { NavigateToUrlFn } from '../../../types/internal'; + +interface Props { + title: string; + href: string; + navigateToUrl: NavigateToUrlFn; + iconType?: string; +} + +export const GroupAsLink = ({ title, href, navigateToUrl, iconType }: Props) => { + const groupID = useGeneratedHtmlId(); + const titleID = `${groupID}__title`; + const TitleElement = 'h3'; + + return ( + + {iconType && ( + + + + )} + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + { + e.preventDefault(); + e.stopPropagation(); + navigateToUrl(href); + }} + href={href} + > + + + {title} + + + + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx index 1c9655eaf27ce..fce8f41734014 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx @@ -735,4 +735,36 @@ describe('', () => { ); }); }); + + describe('cloud links', () => { + test('render the cloud link', async () => { + const onProjectNavigationChange = jest.fn(); + + const { findByTestId } = render( + + + + + + + + + + ); + + expect(await findByTestId('nav-item-group1.cloudLink1')).toBeVisible(); + expect(await findByTestId('nav-item-group1.cloudLink2')).toBeVisible(); + expect(await findByTestId('nav-item-group1.cloudLink3')).toBeVisible(); + + expect(await (await findByTestId('nav-item-group1.cloudLink1')).textContent).toBe( + 'Mock Users & RolesExternal link' + ); + expect(await (await findByTestId('nav-item-group1.cloudLink2')).textContent).toBe( + 'Mock PerformanceExternal link' + ); + expect(await (await findByTestId('nav-item-group1.cloudLink3')).textContent).toBe( + 'Mock Billing & SubscriptionsExternal link' + ); + }); + }); }); diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx index eda309b3635b2..76180b799991a 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx @@ -7,8 +7,9 @@ */ import React, { createContext, useCallback, useMemo, useContext } from 'react'; - import type { AppDeepLinkId } from '@kbn/core-chrome-browser'; + +import { useNavigation as useNavigationServices } from '../../services'; import { useInitNavNode } from '../hooks'; import type { NodeProps, RegisterFunction } from '../types'; import { NavigationSectionUI } from './navigation_section_ui'; @@ -45,6 +46,7 @@ function NavigationGroupInternalComp< Id extends string = string, ChildrenId extends string = Id >(props: Props) { + const { cloudLinks } = useNavigationServices(); const navigationContext = useNavigation(); const { children, node } = useMemo(() => { @@ -58,7 +60,7 @@ function NavigationGroupInternalComp< }; }, [props]); - const { navNode, registerChildNode, path, childrenNodes } = useInitNavNode(node); + const { navNode, registerChildNode, path, childrenNodes } = useInitNavNode(node, { cloudLinks }); const unstyled = props.unstyled ?? navigationContext.unstyled; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx index 4333db7c9844c..ca9844db7b470 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx @@ -9,6 +9,7 @@ import React, { Fragment, ReactElement, ReactNode, useEffect, useMemo } from 'react'; import type { AppDeepLinkId } from '@kbn/core-chrome-browser'; +import { useNavigation as useNavigationServices } from '../../services'; import type { ChromeProjectNavigationNodeEnhanced, NodeProps } from '../types'; import { useInitNavNode } from '../hooks'; import { useNavigation } from './navigation'; @@ -31,6 +32,7 @@ function NavigationItemComp< Id extends string = string, ChildrenId extends string = Id >(props: Props) { + const { cloudLinks } = useNavigationServices(); const navigationContext = useNavigation(); const navNodeRef = React.useRef(null); @@ -51,7 +53,7 @@ function NavigationItemComp< typeof children === 'function' ? () => children(navNodeRef.current) : () => children; } - const { navNode } = useInitNavNode({ ...node, children, renderItem }); + const { navNode } = useInitNavNode({ ...node, children, renderItem }, { cloudLinks }); useEffect(() => { navNodeRef.current = navNode; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx index 06be447ba2a65..6aba392c626f9 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx @@ -20,6 +20,7 @@ import { navigationStyles as styles } from '../../styles'; import { useNavigation as useServices } from '../../services'; import { ChromeProjectNavigationNodeEnhanced } from '../types'; import { isAbsoluteLink } from '../../utils'; +import { GroupAsLink } from './group_as_link'; type RenderItem = EuiSideNavItemType['renderItem']; @@ -39,7 +40,7 @@ const navigationNodeToEuiItem = ( return () => (
- + {item.title}
@@ -108,6 +109,10 @@ export const NavigationSectionUI: FC = ({ navNode, items = [] }) => { }); const groupHasLink = Boolean(navNode.deepLink) || Boolean(navNode.href); + const groupHasChildren = filteredItems.some(itemHasLinkOrChildren); + // Group with a link and no children will be rendered as a link and not an EUI accordion + const groupIsLink = groupHasLink && !groupHasChildren; + const groupHref = navNode.deepLink?.url ?? navNode.href!; useEffect(() => { if (doCollapseFromActiveState) { @@ -115,17 +120,35 @@ export const NavigationSectionUI: FC = ({ navNode, items = [] }) => { } }, [isActive, doCollapseFromActiveState]); - if (!groupHasLink && !filteredItems.some(itemHasLinkOrChildren)) { + if (!groupHasLink && !groupHasChildren) { return null; } + const propsForGroupAsLink = groupIsLink + ? { + buttonElement: 'div' as const, + // If we don't force the state there is a little UI animation as if the + // accordion was openin/closing. We don't want any animation when it is a link. + forceState: 'closed' as const, + buttonContent: ( + + ), + arrowProps: { style: { display: 'none' } }, + } + : {}; + return ( { setIsCollapsed(!isOpen); @@ -133,6 +156,7 @@ export const NavigationSectionUI: FC = ({ navNode, items = [] }) => { }} forceState={isCollapsed ? 'closed' : 'open'} data-test-subj={`nav-bucket-${id}`} + {...propsForGroupAsLink} > ', () => { onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; const [navTreeGenerated] = lastCall; - expect(navTreeGenerated.navigationTree).toEqual([ - { - id: 'group1', - path: ['group1'], - title: '', - isActive: false, - children: [ - { - id: 'item1', - title: 'Item 1', - href: 'http://foo', - isActive: false, - path: ['group1', 'item1'], - }, - { - id: 'item2', - title: 'Item 2', - href: 'http://foo', - isActive: false, - path: ['group1', 'item2'], - }, - { - id: 'group1A', - title: 'Group1A', - isActive: false, - path: ['group1', 'group1A'], - children: [ - { - id: 'item1', - title: 'Group 1A Item 1', - href: 'http://foo', - isActive: false, - path: ['group1', 'group1A', 'item1'], - }, - { - id: 'group1A_1', - title: 'Group1A_1', - isActive: false, - path: ['group1', 'group1A', 'group1A_1'], - children: [ - { - id: 'item1', - title: 'Group 1A_1 Item 1', - href: 'http://foo', - isActive: false, - path: ['group1', 'group1A', 'group1A_1', 'item1'], - }, - ], - }, - ], - }, - ], - }, - ]); + expect(navTreeGenerated.navigationTree).toMatchInlineSnapshot(` + Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "http://foo", + "id": "item1", + "isActive": false, + "path": Array [ + "group1", + "item1", + ], + "renderItem": undefined, + "title": "Item 1", + }, + Object { + "children": undefined, + "deepLink": undefined, + "href": "http://foo", + "id": "item2", + "isActive": false, + "path": Array [ + "group1", + "item2", + ], + "renderItem": undefined, + "title": "Item 2", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "http://foo", + "id": "item1", + "isActive": false, + "path": Array [ + "group1", + "group1A", + "item1", + ], + "renderItem": undefined, + "title": "Group 1A Item 1", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "http://foo", + "id": "item1", + "isActive": false, + "path": Array [ + "group1", + "group1A", + "group1A_1", + "item1", + ], + "renderItem": undefined, + "title": "Group 1A_1 Item 1", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1A_1", + "isActive": false, + "path": Array [ + "group1", + "group1A", + "group1A_1", + ], + "title": "Group1A_1", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1A", + "isActive": false, + "path": Array [ + "group1", + "group1A", + ], + "title": "Group1A", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1", + "isActive": false, + "path": Array [ + "group1", + ], + "title": "", + "type": "navGroup", + }, + ] + `); }); test('should read the title from deeplink', async () => { @@ -223,50 +268,76 @@ describe('', () => { onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; const [navTreeGenerated] = lastCall; - expect(navTreeGenerated.navigationTree).toEqual([ - { - id: 'root', - path: ['root'], - title: '', - isActive: false, - children: [ - { - id: 'group1', - path: ['root', 'group1'], - title: '', - isActive: false, - children: [ - { - id: 'item1', - path: ['root', 'group1', 'item1'], - title: 'Title from deeplink', - isActive: false, - deepLink: { - id: 'item1', - title: 'Title from deeplink', - baseUrl: '', - url: '', - href: '', + expect(navTreeGenerated.navigationTree).toMatchInlineSnapshot(` + Array [ + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "", + "href": "", + "id": "item1", + "title": "Title from deeplink", + "url": "", + }, + "href": undefined, + "id": "item1", + "isActive": false, + "path": Array [ + "root", + "group1", + "item1", + ], + "renderItem": undefined, + "title": "Title from deeplink", }, - }, - { - id: 'item2', - title: 'Overwrite deeplink title', - path: ['root', 'group1', 'item2'], - isActive: false, - deepLink: { - id: 'item1', - title: 'Title from deeplink', - baseUrl: '', - url: '', - href: '', + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "", + "href": "", + "id": "item1", + "title": "Title from deeplink", + "url": "", + }, + "href": undefined, + "id": "item2", + "isActive": false, + "path": Array [ + "root", + "group1", + "item2", + ], + "renderItem": undefined, + "title": "Overwrite deeplink title", }, - }, - ], - }, - ], - }, - ]); + ], + "deepLink": undefined, + "href": undefined, + "id": "group1", + "isActive": false, + "path": Array [ + "root", + "group1", + ], + "title": "", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "root", + "isActive": false, + "path": Array [ + "root", + ], + "title": "", + "type": "navGroup", + }, + ] + `); }); test('should allow href for absolute links', async () => { @@ -306,31 +377,50 @@ describe('', () => { onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; const [navTreeGenerated] = lastCall; - expect(navTreeGenerated.navigationTree).toEqual([ - { - id: 'root', - path: ['root'], - title: '', - isActive: false, - children: [ - { - id: 'group1', - path: ['root', 'group1'], - title: '', - isActive: false, - children: [ - { - id: 'item1', - path: ['root', 'group1', 'item1'], - title: 'Absolute link', - href: 'https://example.com', - isActive: false, - }, - ], - }, - ], - }, - ]); + expect(navTreeGenerated.navigationTree).toMatchInlineSnapshot(` + Array [ + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://example.com", + "id": "item1", + "isActive": false, + "path": Array [ + "root", + "group1", + "item1", + ], + "renderItem": undefined, + "title": "Absolute link", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1", + "isActive": false, + "path": Array [ + "root", + "group1", + ], + "title": "", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "root", + "isActive": false, + "path": Array [ + "root", + ], + "title": "", + "type": "navGroup", + }, + ] + `); }); test('should throw if href is not an absolute links', async () => { @@ -601,45 +691,38 @@ describe('', () => { }); // The project navigation tree passed - expect(navTreeGenerated.navigationTree[0]).toEqual({ - id: 'group1', - title: 'Group 1', - path: ['group1'], - isActive: false, - children: [ - { - id: 'item1', - title: 'Item 1', - isActive: false, - path: ['group1', 'item1'], - }, - { - id: 'item2', - path: ['group1', 'item2'], - title: 'Title from deeplink!', - isActive: false, - deepLink: { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - }, - { - id: 'item3', - title: 'Deeplink title overriden', - path: ['group1', 'item3'], - isActive: false, - deepLink: { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - }, - ], + expect(navTreeGenerated.navigationTree).toMatchSnapshot(); + }); + + describe('cloud links', () => { + test('render the cloud link', async () => { + const { findByTestId } = render( + + + + ); + + expect( + await ( + await findByTestId( + 'nav-item-project_settings_project_nav.settings.cloudLinkUserAndRoles' + ) + ).textContent + ).toBe('Mock Users & RolesExternal link'); + + expect( + await ( + await findByTestId( + 'nav-item-project_settings_project_nav.settings.cloudLinkPerformance' + ) + ).textContent + ).toBe('Mock PerformanceExternal link'); + + expect( + await ( + await findByTestId('nav-item-project_settings_project_nav.settings.cloudLinkBilling') + ).textContent + ).toBe('Mock Billing & SubscriptionsExternal link'); }); }); }); diff --git a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx index 75a7d983f1cdb..679d0d997eb3c 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx @@ -7,6 +7,7 @@ */ import React, { FC, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; import type { AppDeepLinkId, NodeDefinition } from '@kbn/core-chrome-browser'; import { Navigation } from './components'; @@ -54,11 +55,46 @@ const getDefaultNavigationTree = ( footer: [ { type: 'navGroup', - ...getPresets('devtools'), + id: 'devTools', + title: i18n.translate('sharedUXPackages.chrome.sideNavigation.devTools', { + defaultMessage: 'Developer tools', + }), + link: 'dev_tools', + icon: 'editorCodeBlock', }, { type: 'navGroup', - ...getPresets('management'), + id: 'project_settings_project_nav', + title: i18n.translate('sharedUXPackages.chrome.sideNavigation.projectSettings', { + defaultMessage: 'Project settings', + }), + icon: 'gear', + breadcrumbStatus: 'hidden', + children: [ + { + id: 'settings', + children: [ + { + link: 'management', + title: i18n.translate('sharedUXPackages.chrome.sideNavigation.mngt', { + defaultMessage: 'Management', + }), + }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkPerformance', + cloudLink: 'performance', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', + }, + ], + }, + ], }, ], }; @@ -102,15 +138,12 @@ export const DefaultNavigation: FC - {renderItems(copy.children, [...path, id])} + return item.children || (item as GroupDefinition).type === 'navGroup' ? ( + + {renderItems(item.children, [...path, id])} ) : ( - + ); }); }, diff --git a/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts index 8f805c4b5d8ed..f569115250e11 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts @@ -10,9 +10,11 @@ import { AppDeepLinkId, ChromeNavLink, ChromeProjectNavigationNode, + CloudLinkId, } from '@kbn/core-chrome-browser'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; +import { CloudLinks } from '../../cloud_links'; import { useNavigation as useNavigationServices } from '../../services'; import { isAbsoluteLink } from '../../utils'; @@ -40,12 +42,40 @@ function getIdFromNavigationNode< return id; } -function isNodeVisible({ link, deepLink }: { link?: string; deepLink?: ChromeNavLink }) { +/** + * We don't have currently a way to know if a user has access to a Cloud section. + * TODO: This function will have to be revisited once we have an API from Cloud to know the user + * permissions. + */ +function hasUserAccessToCloudLink(): boolean { + return true; +} + +function isNodeVisible( + { + link, + deepLink, + cloudLink, + }: { + link?: string; + deepLink?: ChromeNavLink; + cloudLink?: CloudLinkId; + }, + { cloudLinks }: { cloudLinks: CloudLinks } +) { if (link && !deepLink) { // If a link is provided, but no deepLink is found, don't render anything return false; } + if (cloudLink) { + if (!cloudLinks[cloudLink]) { + // Invalid cloudLinkId or link url has not been set in kibana.yml + return false; + } + return hasUserAccessToCloudLink(); + } + if (deepLink) { return !deepLink.hidden; } @@ -53,6 +83,47 @@ function isNodeVisible({ link, deepLink }: { link?: string; deepLink?: ChromeNav return true; } +function getTitleForNode< + LinkId extends AppDeepLinkId = AppDeepLinkId, + Id extends string = string, + ChildrenId extends string = Id +>( + navNode: NodePropsEnhanced, + { deepLink, cloudLinks }: { deepLink?: ChromeNavLink; cloudLinks: CloudLinks } +): string { + const { children } = navNode; + if (navNode.title) { + return navNode.title; + } + + if (typeof children === 'string') { + return children; + } + + if (deepLink?.title) { + return deepLink.title; + } + + if (navNode.cloudLink) { + return cloudLinks[navNode.cloudLink]?.title ?? ''; + } + + return ''; +} + +function validateNodeProps< + LinkId extends AppDeepLinkId = AppDeepLinkId, + Id extends string = string, + ChildrenId extends string = Id +>({ link, href, cloudLink }: NodePropsEnhanced) { + if (link && cloudLink) { + throw new Error(`Only one of "link" or "cloudLink" can be provided.`); + } + if (href && cloudLink) { + throw new Error(`Only one of "href" or "cloudLink" can be provided.`); + } +} + function createInternalNavNode< LinkId extends AppDeepLinkId = AppDeepLinkId, Id extends string = string, @@ -62,14 +133,16 @@ function createInternalNavNode< _navNode: NodePropsEnhanced, deepLinks: Readonly, path: string[] | null, - isActive: boolean + isActive: boolean, + { cloudLinks }: { cloudLinks: CloudLinks } ): ChromeProjectNavigationNodeEnhanced | null { - const { children, link, href, ...navNode } = _navNode; - const deepLink = deepLinks.find((dl) => dl.id === link); - const isVisible = isNodeVisible({ link, deepLink }); + validateNodeProps(_navNode); - const titleFromDeepLinkOrChildren = typeof children === 'string' ? children : deepLink?.title; - const title = navNode.title ?? titleFromDeepLinkOrChildren; + const { children, link, cloudLink, ...navNode } = _navNode; + const deepLink = deepLinks.find((dl) => dl.id === link); + const isVisible = isNodeVisible({ link, deepLink, cloudLink }, { cloudLinks }); + const title = getTitleForNode(_navNode, { deepLink, cloudLinks }); + const href = cloudLink ? cloudLinks[cloudLink]?.href : _navNode.href; if (href && !isAbsoluteLink(href)) { throw new Error(`href must be an absolute URL. Node id [${id}].`); @@ -104,7 +177,8 @@ export const useInitNavNode = < Id extends string = string, ChildrenId extends string = Id >( - node: NodePropsEnhanced + node: NodePropsEnhanced, + { cloudLinks }: { cloudLinks: CloudLinks } ) => { const { isActive: isActiveControlled } = node; @@ -150,8 +224,8 @@ export const useInitNavNode = < const id = getIdFromNavigationNode(node); const internalNavNode = useMemo( - () => createInternalNavNode(id, node, deepLinks, nodePath, isActive), - [node, id, deepLinks, nodePath, isActive] + () => createInternalNavNode(id, node, deepLinks, nodePath, isActive, { cloudLinks }), + [node, id, deepLinks, nodePath, isActive, cloudLinks] ); // Register the node on the parent whenever its properties change or whenever diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx index 241e53cb8242f..eeff6afd945e9 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx @@ -363,8 +363,20 @@ export const WithUIComponents = (args: NavigationServices) => { - - + + + + + + + + +
diff --git a/packages/shared-ux/chrome/navigation/src/ui/types.ts b/packages/shared-ux/chrome/navigation/src/ui/types.ts index f4414ae2e003f..5175425b6ada4 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/types.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/types.ts @@ -94,11 +94,11 @@ export interface GroupDefinition< /** * Flag to indicate if the group is initially collapsed or not. * + * `undefined`: (Recommended) the group will be opened if any of its children nodes matches the current URL. + * * `false`: the group will be opened event if none of its children nodes matches the current URL. * * `true`: the group will be collapsed event if any of its children nodes matches the current URL. - * - * `undefined`: the group will be opened if any of its children nodes matches the current URL. */ defaultIsCollapsed?: boolean; preset?: NavigationGroupPreset; diff --git a/packages/shared-ux/chrome/navigation/tsconfig.json b/packages/shared-ux/chrome/navigation/tsconfig.json index 45e8325bc9d3e..b3b9b636ff0a7 100644 --- a/packages/shared-ux/chrome/navigation/tsconfig.json +++ b/packages/shared-ux/chrome/navigation/tsconfig.json @@ -26,6 +26,7 @@ "@kbn/default-nav-devtools", "@kbn/shared-ux-storybook-mock", "@kbn/core-http-browser", + "@kbn/cloud-plugin", ], "exclude": [ "target/**/*" diff --git a/packages/shared-ux/chrome/navigation/types/index.ts b/packages/shared-ux/chrome/navigation/types/index.ts index dc6014dcdd28b..00d8e69edf0e3 100644 --- a/packages/shared-ux/chrome/navigation/types/index.ts +++ b/packages/shared-ux/chrome/navigation/types/index.ts @@ -7,6 +7,7 @@ */ import type { Observable } from 'rxjs'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { ChromeNavLink, @@ -14,6 +15,7 @@ import type { ChromeProjectNavigationNode, } from '@kbn/core-chrome-browser'; import type { BasePathService, NavigateToUrlFn, RecentItem } from './internal'; +import type { CloudLinks } from '../src/cloud_links'; /** * A list of services that are consumed by this component. @@ -27,6 +29,7 @@ export interface NavigationServices { navigateToUrl: NavigateToUrlFn; onProjectNavigationChange: (chromeProjectNavigation: ChromeProjectNavigation) => void; activeNodes$: Observable; + cloudLinks: CloudLinks; } /** @@ -55,4 +58,5 @@ export interface NavigationKibanaDependencies { ) => void; getActiveNavigationNodes$: () => Observable; }; + cloud: CloudStart; } diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 9f57286daf9e2..2fa5c5767ce60 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -221,6 +221,8 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.cloud.organization_url (string)', 'xpack.cloud.billing_url (string)', 'xpack.cloud.profile_url (string)', + 'xpack.cloud.performance_url (string)', + 'xpack.cloud.users_and_roles_url (string)', // can't be used to infer urls or customer id from the outside 'xpack.cloud.serverless.project_id (string)', 'xpack.discoverEnhanced.actions.exploreDataInChart.enabled (boolean)', diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 11052f48de7a3..a12d090b0c9c4 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -24,6 +24,8 @@ export interface CloudConfigType { deployment_url?: string; billing_url?: string; organization_url?: string; + users_and_roles_url?: string; + performance_url?: string; trial_end_date?: string; is_elastic_staff_owned?: boolean; serverless?: { @@ -37,6 +39,8 @@ interface CloudUrls { billingUrl?: string; organizationUrl?: string; snapshotsUrl?: string; + performanceUrl?: string; + usersAndRolesUrl?: string; } export class CloudPlugin implements Plugin { @@ -110,7 +114,14 @@ export class CloudPlugin implements Plugin { ); }; - const { deploymentUrl, profileUrl, billingUrl, organizationUrl } = this.getCloudUrls(); + const { + deploymentUrl, + profileUrl, + billingUrl, + organizationUrl, + performanceUrl, + usersAndRolesUrl, + } = this.getCloudUrls(); let decodedId: DecodedCloudId | undefined; if (this.config.id) { @@ -131,6 +142,8 @@ export class CloudPlugin implements Plugin { serverless: { projectId: this.config.serverless?.project_id, }, + performanceUrl, + usersAndRolesUrl, }; } @@ -143,12 +156,16 @@ export class CloudPlugin implements Plugin { organization_url: organizationUrl, deployment_url: deploymentUrl, base_url: baseUrl, + performance_url: performanceUrl, + users_and_roles_url: usersAndRolesUrl, } = this.config; const fullCloudDeploymentUrl = getFullCloudUrl(baseUrl, deploymentUrl); const fullCloudProfileUrl = getFullCloudUrl(baseUrl, profileUrl); const fullCloudBillingUrl = getFullCloudUrl(baseUrl, billingUrl); const fullCloudOrganizationUrl = getFullCloudUrl(baseUrl, organizationUrl); + const fullCloudPerformanceUrl = getFullCloudUrl(baseUrl, performanceUrl); + const fullCloudUsersAndRolesUrl = getFullCloudUrl(baseUrl, usersAndRolesUrl); const fullCloudSnapshotsUrl = `${fullCloudDeploymentUrl}/${CLOUD_SNAPSHOTS_PATH}`; return { @@ -157,6 +174,8 @@ export class CloudPlugin implements Plugin { billingUrl: fullCloudBillingUrl, organizationUrl: fullCloudOrganizationUrl, snapshotsUrl: fullCloudSnapshotsUrl, + performanceUrl: fullCloudPerformanceUrl, + usersAndRolesUrl: fullCloudUsersAndRolesUrl, }; } } diff --git a/x-pack/plugins/cloud/public/types.ts b/x-pack/plugins/cloud/public/types.ts index 0224e0620c983..df4512f185ea1 100644 --- a/x-pack/plugins/cloud/public/types.ts +++ b/x-pack/plugins/cloud/public/types.ts @@ -36,6 +36,14 @@ export interface CloudStart { * The full URL to the organization management page on Elastic Cloud. Undefined if not running on Cloud. */ organizationUrl?: string; + /** + * The full URL to the performance page on Elastic Cloud. Undefined if not running on Cloud. + */ + performanceUrl?: string; + /** + * The full URL to the users and roles page on Elastic Cloud. Undefined if not running on Cloud. + */ + usersAndRolesUrl?: string; /** * The full URL to the elasticsearch cluster. */ diff --git a/x-pack/plugins/cloud/server/config.ts b/x-pack/plugins/cloud/server/config.ts index 66cd1f3a47411..c13c0b255e7f2 100644 --- a/x-pack/plugins/cloud/server/config.ts +++ b/x-pack/plugins/cloud/server/config.ts @@ -25,6 +25,8 @@ const configSchema = schema.object({ deployment_url: schema.maybe(schema.string()), id: schema.maybe(schema.string()), billing_url: schema.maybe(schema.string()), + performance_url: schema.maybe(schema.string()), + users_and_roles_url: schema.maybe(schema.string()), organization_url: schema.maybe(schema.string()), profile_url: schema.maybe(schema.string()), trial_end_date: schema.maybe(schema.string()), @@ -45,6 +47,8 @@ export const config: PluginConfigDescriptor = { deployment_url: true, id: true, billing_url: true, + users_and_roles_url: true, + performance_url: true, organization_url: true, profile_url: true, trial_end_date: true, diff --git a/x-pack/plugins/serverless_observability/kibana.jsonc b/x-pack/plugins/serverless_observability/kibana.jsonc index 0f85a1033c2ea..d7a8de7298c41 100644 --- a/x-pack/plugins/serverless_observability/kibana.jsonc +++ b/x-pack/plugins/serverless_observability/kibana.jsonc @@ -8,7 +8,7 @@ "server": true, "browser": true, "configPath": ["xpack", "serverless", "observability"], - "requiredPlugins": ["serverless", "observabilityShared", "kibanaReact", "management", "ml"], + "requiredPlugins": ["serverless", "observabilityShared", "kibanaReact", "management", "ml", "cloud"], "optionalPlugins": [], "requiredBundles": [] } diff --git a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx index 70ba2b98d5707..33394041c5561 100644 --- a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx +++ b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import { CoreStart } from '@kbn/core/public'; +import type { CoreStart } from '@kbn/core/public'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; import { ServerlessPluginStart } from '@kbn/serverless/public'; import { DefaultNavigation, @@ -37,6 +38,9 @@ const navigationTree: NavigationTreeDefinition = { defaultMessage: 'Dashboards', }), link: 'dashboards', + getIsActive: ({ pathNameSerialized, prepend }) => { + return pathNameSerialized.startsWith(prepend('/app/dashboards')); + }, }, { link: 'observability-overview:alerts', @@ -75,13 +79,14 @@ const navigationTree: NavigationTreeDefinition = { }, ], }, - { id: 'applications', children: [ { id: 'apm', - title: 'Applications', + title: i18n.translate('xpack.serverlessObservability.nav.applications', { + defaultMessage: 'Applications', + }), children: [ { link: 'apm:services', @@ -107,6 +112,13 @@ const navigationTree: NavigationTreeDefinition = { defaultMessage: 'Visualizations', }), link: 'visualize', + getIsActive: ({ pathNameSerialized, prepend }) => { + return ( + pathNameSerialized.startsWith(prepend('/app/visualize')) || + pathNameSerialized.startsWith(prepend('/app/lens')) || + pathNameSerialized.startsWith(prepend('/app/maps')) + ); + }, }, ], }, @@ -127,10 +139,20 @@ const navigationTree: NavigationTreeDefinition = { footer: [ { type: 'navGroup', - id: 'projest_settings_project_nav', - title: 'Project settings', + id: 'devTools', + title: i18n.translate('xpack.serverlessObservability.nav.devTools', { + defaultMessage: 'Developer tools', + }), + link: 'dev_tools', + icon: 'editorCodeBlock', + }, + { + type: 'navGroup', + id: 'project_settings_project_nav', + title: i18n.translate('xpack.serverlessObservability.nav.projectSettings', { + defaultMessage: 'Project settings', + }), icon: 'gear', - defaultIsCollapsed: true, breadcrumbStatus: 'hidden', children: [ { @@ -148,6 +170,18 @@ const navigationTree: NavigationTreeDefinition = { { link: 'fleet', }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkPerformance', + cloudLink: 'performance', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', + }, ], }, ], @@ -156,10 +190,13 @@ const navigationTree: NavigationTreeDefinition = { }; export const getObservabilitySideNavComponent = - (core: CoreStart, { serverless }: { serverless: ServerlessPluginStart }) => + ( + core: CoreStart, + { serverless, cloud }: { serverless: ServerlessPluginStart; cloud: CloudStart } + ) => () => { return ( - + ); diff --git a/x-pack/plugins/serverless_observability/public/plugin.ts b/x-pack/plugins/serverless_observability/public/plugin.ts index 4a0841c98ad90..76d4d4db25946 100644 --- a/x-pack/plugins/serverless_observability/public/plugin.ts +++ b/x-pack/plugins/serverless_observability/public/plugin.ts @@ -29,10 +29,10 @@ export class ServerlessObservabilityPlugin core: CoreStart, setupDeps: ServerlessObservabilityPluginStartDependencies ): ServerlessObservabilityPluginStart { - const { observabilityShared, serverless, management } = setupDeps; + const { observabilityShared, serverless, management, cloud } = setupDeps; observabilityShared.setIsSidebarEnabled(false); serverless.setProjectHome('/app/observability/landing'); - serverless.setSideNavComponent(getObservabilitySideNavComponent(core, { serverless })); + serverless.setSideNavComponent(getObservabilitySideNavComponent(core, { serverless, cloud })); management.setupCardsNavigation({ enabled: true, hideLinksTo: [appIds.RULES], diff --git a/x-pack/plugins/serverless_observability/public/services.tsx b/x-pack/plugins/serverless_observability/public/services.tsx index a70db0885020d..67b71268de0f1 100644 --- a/x-pack/plugins/serverless_observability/public/services.tsx +++ b/x-pack/plugins/serverless_observability/public/services.tsx @@ -6,6 +6,7 @@ */ import { CoreStart } from '@kbn/core/public'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; import React from 'react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { ServerlessObservabilityPluginStartDependencies } from './types'; @@ -15,7 +16,8 @@ type Services = CoreStart & ServerlessObservabilityPluginStartDependencies; export const KibanaServicesProvider: React.FC<{ core: CoreStart; pluginsStart: ServerlessObservabilityPluginStartDependencies; -}> = ({ core, pluginsStart, children }) => { - const services: Services = { ...core, ...pluginsStart }; + cloud: CloudStart; +}> = ({ core, pluginsStart, cloud, children }) => { + const services: Services = { ...core, ...pluginsStart, cloud }; return {children}; }; diff --git a/x-pack/plugins/serverless_observability/public/types.ts b/x-pack/plugins/serverless_observability/public/types.ts index 299880b2cde47..335ce677242ab 100644 --- a/x-pack/plugins/serverless_observability/public/types.ts +++ b/x-pack/plugins/serverless_observability/public/types.ts @@ -11,6 +11,7 @@ import { ObservabilitySharedPluginStart, } from '@kbn/observability-shared-plugin/public'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ServerlessObservabilityPluginSetup {} @@ -28,4 +29,5 @@ export interface ServerlessObservabilityPluginStartDependencies { observabilityShared: ObservabilitySharedPluginStart; serverless: ServerlessPluginStart; management: ManagementStart; + cloud: CloudStart; } diff --git a/x-pack/plugins/serverless_observability/tsconfig.json b/x-pack/plugins/serverless_observability/tsconfig.json index c45e5484f3ca3..379ea410f86ad 100644 --- a/x-pack/plugins/serverless_observability/tsconfig.json +++ b/x-pack/plugins/serverless_observability/tsconfig.json @@ -25,5 +25,6 @@ "@kbn/ml-plugin", "@kbn/i18n", "@kbn/management-cards-navigation", + "@kbn/cloud-plugin", ] } diff --git a/x-pack/plugins/serverless_search/public/layout/nav.tsx b/x-pack/plugins/serverless_search/public/layout/nav.tsx index 75462727b1298..a8efa012bddc8 100644 --- a/x-pack/plugins/serverless_search/public/layout/nav.tsx +++ b/x-pack/plugins/serverless_search/public/layout/nav.tsx @@ -15,6 +15,7 @@ import { import React from 'react'; import { i18n } from '@kbn/i18n'; import type { ServerlessPluginStart } from '@kbn/serverless/public'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; const navigationTree: NavigationTreeDefinition = { body: [ @@ -52,9 +53,19 @@ const navigationTree: NavigationTreeDefinition = { }, { link: 'dashboards', + getIsActive: ({ pathNameSerialized, prepend }) => { + return pathNameSerialized.startsWith(prepend('/app/dashboards')); + }, }, { link: 'visualize', + getIsActive: ({ pathNameSerialized, prepend }) => { + return ( + pathNameSerialized.startsWith(prepend('/app/visualize')) || + pathNameSerialized.startsWith(prepend('/app/lens')) || + pathNameSerialized.startsWith(prepend('/app/maps')) + ); + }, }, { link: 'observability-overview:alerts' }, ], @@ -106,13 +117,52 @@ const navigationTree: NavigationTreeDefinition = { ...getPresets('ml'), }, ], + footer: [ + { + type: 'navGroup', + id: 'project_settings_project_nav', + title: i18n.translate('xpack.serverlessSearch.nav.projectSettings', { + defaultMessage: 'Project settings', + }), + icon: 'gear', + breadcrumbStatus: 'hidden', + children: [ + { + id: 'settings', + children: [ + { + link: 'management', + title: i18n.translate('xpack.serverlessSearch.nav.mngt', { + defaultMessage: 'Management', + }), + }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkPerformance', + cloudLink: 'performance', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', + }, + ], + }, + ], + }, + ], }; export const createServerlessSearchSideNavComponent = - (core: CoreStart, { serverless }: { serverless: ServerlessPluginStart }) => + ( + core: CoreStart, + { serverless, cloud }: { serverless: ServerlessPluginStart; cloud: CloudStart } + ) => () => { return ( - + ); diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index 21d89f613d64d..fc2c00255ffb1 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -69,10 +69,10 @@ export class ServerlessSearchPlugin public start( core: CoreStart, - { serverless, management, observabilityShared }: ServerlessSearchPluginStartDependencies + { serverless, management, observabilityShared, cloud }: ServerlessSearchPluginStartDependencies ): ServerlessSearchPluginStart { serverless.setProjectHome('/app/elasticsearch'); - serverless.setSideNavComponent(createComponent(core, { serverless })); + serverless.setSideNavComponent(createComponent(core, { serverless, cloud })); observabilityShared.setIsSidebarEnabled(false); management.setupCardsNavigation({ enabled: true,