[Serverless nav] Update footer + project settings cloud links (#161971)

This commit is contained in:
Sébastien Loix 2023-07-18 14:39:14 +01:00 committed by GitHub
parent af4a047006
commit 209d35365f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1491 additions and 226 deletions

View file

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

View file

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

View file

@ -254,6 +254,29 @@ describe('findActiveNodes', () => {
]);
});
test('should find active node at the root', () => {
const flattendNavTree: Record<string, ChromeProjectNavigationNode> = {
'[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<string, ChromeProjectNavigationNode> = {
'[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',

View file

@ -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<string, ChromeProjectNavigationNode>) {
// 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<string[]>((acc, currentValue, currentIndex) => {
acc.push(arr.slice(0, currentIndex + 1).join(''));
return acc;
}, []);
return arr
.reduce<string[]>((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<string, ChromeProjectNavigationNode>,
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));
});
}

View file

@ -34,6 +34,7 @@ export type {
ChromeUserBanner,
ChromeProjectNavigation,
ChromeProjectNavigationNode,
CloudLinkId,
SideNavCompProps,
SideNavComponent,
ChromeProjectBreadcrumb,

View file

@ -33,6 +33,7 @@ export type {
ChromeProjectNavigationNode,
AppDeepLinkId,
AppId,
CloudLinkId,
SideNavCompProps,
SideNavComponent,
ChromeSetProjectBreadcrumbsParams,

View file

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

View file

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

View file

@ -28,6 +28,7 @@ const allNavLinks: AppDeepLinkId[] = [
'discover',
'fleet',
'integrations',
'management',
'management:api_keys',
'management:cases',
'management:cross_cluster_replication',

View file

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

View file

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

View file

@ -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<NavigationServices | null>(null);
@ -25,11 +26,13 @@ export const NavigationKibanaProvider: FC<NavigationKibanaDependencies> = ({
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<NavigationKibanaDependencies> = ({
navIsOpen: true,
onProjectNavigationChange: serverless.setNavigation,
activeNodes$: serverless.getActiveNavigationNodes$(),
cloudLinks,
};
return <Context.Provider value={value}>{children}</Context.Provider>;

View file

@ -0,0 +1,678 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DefaultNavigation /> 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",
},
]
`;

View file

@ -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 (
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false}>
{iconType && (
<EuiFlexItem grow={false}>
<EuiIcon type={iconType} size="m" />
</EuiFlexItem>
)}
<EuiFlexItem>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
color="text"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigateToUrl(href);
}}
href={href}
>
<EuiTitle size="xxs">
<TitleElement id={titleID} className="euiCollapsibleNavGroup__title">
{title}
</TitleElement>
</EuiTitle>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -735,4 +735,36 @@ describe('<Navigation />', () => {
);
});
});
describe('cloud links', () => {
test('render the cloud link', async () => {
const onProjectNavigationChange = jest.fn();
const { findByTestId } = render(
<NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}>
<Navigation>
<Navigation.Group id="group1">
<Navigation.Item id="cloudLink1" cloudLink="userAndRoles" />
<Navigation.Item id="cloudLink2" cloudLink="performance" />
<Navigation.Item id="cloudLink3" cloudLink="billingAndSub" />
</Navigation.Group>
</Navigation>
</NavigationProvider>
);
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'
);
});
});
});

View file

@ -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<LinkId, Id, ChildrenId>) {
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;

View file

@ -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<LinkId, Id, ChildrenId>) {
const { cloudLinks } = useNavigationServices();
const navigationContext = useNavigation();
const navNodeRef = React.useRef<ChromeProjectNavigationNodeEnhanced | null>(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;

View file

@ -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<unknown>['renderItem'];
@ -39,7 +40,7 @@ const navigationNodeToEuiItem = (
return () => (
<div className="euiSideNavItemButton" data-test-subj={dataTestSubj}>
<EuiLink href={href} external>
<EuiLink href={href} external color="text">
{item.title}
</EuiLink>
</div>
@ -108,6 +109,10 @@ export const NavigationSectionUI: FC<Props> = ({ 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<Props> = ({ 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: (
<GroupAsLink
title={title}
iconType={icon}
href={groupHref}
navigateToUrl={navigateToUrl}
/>
),
arrowProps: { style: { display: 'none' } },
}
: {};
return (
<EuiCollapsibleNavGroup
id={id}
title={title}
iconType={icon}
iconSize={'m'}
isCollapsible={true}
iconSize="m"
isCollapsible
initialIsOpen={isActive}
onToggle={(isOpen) => {
setIsCollapsed(!isOpen);
@ -133,6 +156,7 @@ export const NavigationSectionUI: FC<Props> = ({ navNode, items = [] }) => {
}}
forceState={isCollapsed ? 'closed' : 'open'}
data-test-subj={`nav-bucket-${id}`}
{...propsForGroupAsLink}
>
<EuiText color="default">
<EuiSideNav

View file

@ -106,60 +106,105 @@ describe('<DefaultNavigation />', () => {
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('<DefaultNavigation />', () => {
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('<DefaultNavigation />', () => {
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('<DefaultNavigation />', () => {
});
// 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(
<NavigationProvider {...services}>
<DefaultNavigation projectNavigationTree={[]} />
</NavigationProvider>
);
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');
});
});
});

View file

@ -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<ProjectNavigationDefinition & { dataTestSubj?
);
}
const { ...copy } = item as GroupDefinition;
delete (copy as any).type;
return copy.children ? (
<Navigation.Group {...copy} key={id}>
{renderItems(copy.children, [...path, id])}
return item.children || (item as GroupDefinition).type === 'navGroup' ? (
<Navigation.Group {...item} key={id}>
{renderItems(item.children, [...path, id])}
</Navigation.Group>
) : (
<Navigation.Item {...copy} key={id} />
<Navigation.Item {...item} key={id} />
);
});
},

View file

@ -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<LinkId, Id, ChildrenId>,
{ 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<LinkId, Id, ChildrenId>) {
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<LinkId, Id, ChildrenId>,
deepLinks: Readonly<ChromeNavLink[]>,
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<LinkId, Id, ChildrenId>
node: NodePropsEnhanced<LinkId, Id, ChildrenId>,
{ 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

View file

@ -363,8 +363,20 @@ export const WithUIComponents = (args: NavigationServices) => {
<Navigation.Group preset="ml" />
<Navigation.Footer>
<Navigation.Group preset="devtools" />
<Navigation.Group preset="management" />
<Navigation.Group link="dev_tools" icon="editorCodeBlock" title="Developer tools" />
<Navigation.Group
id="project_settings_project_nav"
title="Project settings"
breadcrumbStatus="hidden"
icon="gear"
>
<Navigation.Group id="settings">
<Navigation.Item link="management" title="Management" />
<Navigation.Item id="cloudLinkUserAndRoles" cloudLink="userAndRoles" />
<Navigation.Item id="cloudLinkPerformance" cloudLink="performance" />
<Navigation.Item id="cloudLinkBilling" cloudLink="billingAndSub" />
</Navigation.Group>
</Navigation.Group>
</Navigation.Footer>
</Navigation>
</NavigationProvider>

View file

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

View file

@ -26,6 +26,7 @@
"@kbn/default-nav-devtools",
"@kbn/shared-ux-storybook-mock",
"@kbn/core-http-browser",
"@kbn/cloud-plugin",
],
"exclude": [
"target/**/*"

View file

@ -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<ChromeProjectNavigationNode[][]>;
cloudLinks: CloudLinks;
}
/**
@ -55,4 +58,5 @@ export interface NavigationKibanaDependencies {
) => void;
getActiveNavigationNodes$: () => Observable<ChromeProjectNavigationNode[][]>;
};
cloud: CloudStart;
}

View file

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

View file

@ -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<CloudSetup> {
@ -110,7 +114,14 @@ export class CloudPlugin implements Plugin<CloudSetup> {
);
};
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<CloudSetup> {
serverless: {
projectId: this.config.serverless?.project_id,
},
performanceUrl,
usersAndRolesUrl,
};
}
@ -143,12 +156,16 @@ export class CloudPlugin implements Plugin<CloudSetup> {
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<CloudSetup> {
billingUrl: fullCloudBillingUrl,
organizationUrl: fullCloudOrganizationUrl,
snapshotsUrl: fullCloudSnapshotsUrl,
performanceUrl: fullCloudPerformanceUrl,
usersAndRolesUrl: fullCloudUsersAndRolesUrl,
};
}
}

View file

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

View file

@ -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<CloudConfigType> = {
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,

View file

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

View file

@ -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 (
<NavigationKibanaProvider core={core} serverless={serverless}>
<NavigationKibanaProvider core={core} serverless={serverless} cloud={cloud}>
<DefaultNavigation navigationTree={navigationTree} dataTestSubj="svlObservabilitySideNav" />
</NavigationKibanaProvider>
);

View file

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

View file

@ -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 <KibanaContextProvider services={services}>{children}</KibanaContextProvider>;
};

View file

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

View file

@ -25,5 +25,6 @@
"@kbn/ml-plugin",
"@kbn/i18n",
"@kbn/management-cards-navigation",
"@kbn/cloud-plugin",
]
}

View file

@ -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 (
<NavigationKibanaProvider core={core} serverless={serverless}>
<NavigationKibanaProvider core={core} serverless={serverless} cloud={cloud}>
<DefaultNavigation navigationTree={navigationTree} dataTestSubj="svlSearchSideNav" />
</NavigationKibanaProvider>
);

View file

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