[Stateful sidenav] Breadcrumb switcher (#178112)

This commit is contained in:
Sébastien Loix 2024-03-18 11:01:12 +00:00 committed by GitHub
parent e34a6d54e6
commit 059e4a57d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1102 additions and 440 deletions

View file

@ -122,6 +122,7 @@ enabled:
- test/functional/apps/home/config.ts
- test/functional/apps/kibana_overview/config.ts
- test/functional/apps/management/config.ts
- test/functional/apps/navigation/config.ts
- test/functional/apps/saved_objects_management/config.ts
- test/functional/apps/sharing/config.ts
- test/functional/apps/status_page/config.ts

2
.github/CODEOWNERS vendored
View file

@ -784,6 +784,8 @@ packages/shared-ux/storybook/mock @elastic/appex-sharedux
packages/kbn-shared-ux-utility @elastic/appex-sharedux
x-pack/packages/kbn-slo-schema @elastic/obs-ux-management-team
x-pack/plugins/snapshot_restore @elastic/platform-deployment-management
packages/solution-nav/es @elastic/appex-sharedux
packages/solution-nav/oblt @elastic/appex-sharedux
packages/kbn-some-dev-log @elastic/kibana-operations
packages/kbn-sort-package-json @elastic/kibana-operations
packages/kbn-sort-predicates @elastic/kibana-visualizations

View file

@ -786,6 +786,8 @@
"@kbn/shared-ux-utility": "link:packages/kbn-shared-ux-utility",
"@kbn/slo-schema": "link:x-pack/packages/kbn-slo-schema",
"@kbn/snapshot-restore-plugin": "link:x-pack/plugins/snapshot_restore",
"@kbn/solution-nav-es": "link:packages/solution-nav/es",
"@kbn/solution-nav-oblt": "link:packages/solution-nav/oblt",
"@kbn/sort-predicates": "link:packages/kbn-sort-predicates",
"@kbn/spaces-plugin": "link:x-pack/plugins/spaces",
"@kbn/spaces-test-plugin": "link:x-pack/test/spaces_api_integration/common/plugins/spaces_test_plugin",

View file

@ -34,7 +34,6 @@ import type {
ChromeSetProjectBreadcrumbsParams,
NavigationTreeDefinition,
AppDeepLinkId,
CloudURLs,
} from '@kbn/core-chrome-browser';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
import type {
@ -213,6 +212,7 @@ export class ChromeService {
};
const setChromeStyle = (style: ChromeStyle) => {
if (style === chromeStyle$.getValue()) return;
chromeStyle$.next(style);
};
@ -283,12 +283,9 @@ export class ChromeService {
LinkId extends AppDeepLinkId = AppDeepLinkId,
Id extends string = string,
ChildrenId extends string = Id
>(
navigationTree$: Observable<NavigationTreeDefinition<LinkId, Id, ChildrenId>>,
deps: { cloudUrls: CloudURLs }
) {
>(navigationTree$: Observable<NavigationTreeDefinition<LinkId, Id, ChildrenId>>) {
validateChromeStyle();
projectNavigation.initNavigation(navigationTree$, deps);
projectNavigation.initNavigation(navigationTree$);
}
const setProjectBreadcrumbs = (
@ -303,21 +300,11 @@ export class ChromeService {
projectNavigation.setProjectHome(homeHref);
};
const setProjectsUrl = (projectsUrl: string) => {
validateChromeStyle();
projectNavigation.setProjectsUrl(projectsUrl);
};
const setProjectName = (projectName: string) => {
validateChromeStyle();
projectNavigation.setProjectName(projectName);
};
const setProjectUrl = (projectUrl: string) => {
validateChromeStyle();
projectNavigation.setProjectUrl(projectUrl);
};
const isIE = () => {
const ua = window.navigator.userAgent;
const msie = ua.indexOf('MSIE '); // IE 10 or older
@ -543,8 +530,7 @@ export class ChromeService {
getIsSideNavCollapsed$: () => this.isSideNavCollapsed$.asObservable(),
project: {
setHome: setProjectHome,
setProjectsUrl,
setProjectUrl,
setCloudUrls: projectNavigation.setCloudUrls.bind(projectNavigation),
setProjectName,
initNavigation: initProjectNavigation,
getNavigationTreeUi$: () => projectNavigation.getNavigationTreeUi$(),

View file

@ -6,37 +6,49 @@
* Side Public License, v 1.
*/
import React from 'react';
import { EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import {
import type {
AppDeepLinkId,
ChromeProjectBreadcrumb,
ChromeProjectNavigationNode,
ChromeSetProjectBreadcrumbsParams,
ChromeBreadcrumb,
SolutionNavigationDefinitions,
CloudLinks,
} from '@kbn/core-chrome-browser';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { getSolutionNavSwitcherBreadCrumb } from '../ui/solution_nav_switcher_breadcrumbs';
export function buildBreadcrumbs({
projectsUrl,
projectName,
projectUrl,
cloudLinks,
projectBreadcrumbs,
activeNodes,
chromeBreadcrumbs,
solutionNavigations,
}: {
projectsUrl?: string;
projectName?: string;
projectUrl?: string;
projectBreadcrumbs: {
breadcrumbs: ChromeProjectBreadcrumb[];
params: ChromeSetProjectBreadcrumbsParams;
};
chromeBreadcrumbs: ChromeBreadcrumb[];
cloudLinks: CloudLinks;
activeNodes: ChromeProjectNavigationNode[][];
solutionNavigations?: {
definitions: SolutionNavigationDefinitions;
activeId: string;
onChange: (id: string, options?: { redirect?: boolean }) => void;
};
}): ChromeProjectBreadcrumb[] {
const rootCrumb = buildRootCrumb({ projectsUrl, projectName, projectUrl });
const rootCrumb = buildRootCrumb({
projectName,
solutionNavigations,
cloudLinks,
});
if (projectBreadcrumbs.params.absolute) {
return [rootCrumb, ...projectBreadcrumbs.breadcrumbs];
@ -86,14 +98,30 @@ export function buildBreadcrumbs({
}
function buildRootCrumb({
projectsUrl,
projectName,
projectUrl,
solutionNavigations,
cloudLinks,
}: {
projectsUrl?: string;
projectName?: string;
projectUrl?: string;
cloudLinks: CloudLinks;
solutionNavigations?: {
definitions: SolutionNavigationDefinitions;
activeId: string;
onChange: (id: string, options?: { redirect?: boolean }) => void;
};
}): ChromeProjectBreadcrumb {
if (solutionNavigations) {
// if there are solution navigations, it means that we are in Kibana stateful and not
// in serverless with projects.
const { definitions, activeId, onChange } = solutionNavigations;
return getSolutionNavSwitcherBreadCrumb({
definitions,
onChange,
activeId,
cloudLinks,
});
}
return {
text:
projectName ??
@ -106,13 +134,13 @@ function buildRootCrumb({
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem key="project" href={projectUrl} icon={'gear'}>
<EuiContextMenuItem key="project" href={cloudLinks.deployment?.href} icon={'gear'}>
<FormattedMessage
id="core.ui.primaryNav.cloud.linkToProject"
defaultMessage="Manage project"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem key="projects" href={projectsUrl} icon={'grid'}>
<EuiContextMenuItem key="projects" href={cloudLinks.projects?.href} icon={'grid'}>
<FormattedMessage
id="core.ui.primaryNav.cloud.linkToAllProjects"
defaultMessage="View all projects"

View file

@ -13,7 +13,7 @@ const stripTrailingForwardSlash = (str: string) => {
};
const parseCloudURLs = (cloudLinks: CloudLinks): CloudLinks => {
const { userAndRoles, billingAndSub, deployment, performance } = cloudLinks;
const { userAndRoles, billingAndSub, deployment, deployments, performance } = cloudLinks;
// We remove potential trailing forward slash ("/") at the end of the URL
// because it breaks future navigation in Cloud console once we navigate there.
@ -27,12 +27,20 @@ const parseCloudURLs = (cloudLinks: CloudLinks): CloudLinks => {
userAndRoles: parseLink(userAndRoles),
billingAndSub: parseLink(billingAndSub),
deployment: parseLink(deployment),
deployments: parseLink(deployments),
performance: parseLink(performance),
};
};
export const getCloudLinks = (cloud: CloudURLs): CloudLinks => {
const { billingUrl, deploymentUrl, performanceUrl, usersAndRolesUrl } = cloud;
const {
billingUrl,
deploymentsUrl,
deploymentUrl,
projectsUrl,
performanceUrl,
usersAndRolesUrl,
} = cloud;
const links: CloudLinks = {};
@ -72,5 +80,23 @@ export const getCloudLinks = (cloud: CloudURLs): CloudLinks => {
};
}
if (deploymentsUrl) {
links.deployments = {
title: i18n.translate('core.ui.chrome.sideNavigation.cloudLinks.allDeploymentsLinkText', {
defaultMessage: 'View all deployments',
}),
href: deploymentsUrl,
};
}
if (projectsUrl) {
links.projects = {
title: i18n.translate('core.ui.chrome.sideNavigation.cloudLinks.allProjectsLinkText', {
defaultMessage: 'View all projects',
}),
href: projectsUrl,
};
}
return parseCloudURLs(links);
};

View file

@ -7,7 +7,7 @@
*/
import { createMemoryHistory } from 'history';
import { firstValueFrom, lastValueFrom, take, BehaviorSubject, of } from 'rxjs';
import { firstValueFrom, lastValueFrom, take, BehaviorSubject, of, type Observable } from 'rxjs';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { loggerMock } from '@kbn/logging-mocks';
@ -23,6 +23,14 @@ import type {
} from '@kbn/core-chrome-browser';
import { ProjectNavigationService } from './project_navigation_service';
jest.mock('rxjs', () => {
const original = jest.requireActual('rxjs');
return {
...original,
debounceTime: () => (source: Observable<any>) => source,
};
});
const getNavLink = (partial: Partial<ChromeNavLink> = {}): ChromeNavLink => ({
id: 'kibana',
title: 'Kibana',
@ -36,7 +44,7 @@ const getNavLink = (partial: Partial<ChromeNavLink> = {}): ChromeNavLink => ({
const getNavLinksService = (ids: Readonly<string[]> = []) => {
const navLinks = ids.map((id) => getNavLink({ id, title: id.toUpperCase() }));
const navLinksMock: ChromeNavLinks = {
const navLinksMock: jest.Mocked<ChromeNavLinks> = {
getNavLinks$: jest.fn().mockReturnValue(of(navLinks)),
has: jest.fn(),
get: jest.fn(),
@ -58,18 +66,23 @@ const setup = ({
navLinkIds?: Readonly<string[]>;
setChromeStyle?: () => void;
} = {}) => {
const history = createMemoryHistory();
const history = createMemoryHistory({
initialEntries: [locationPathName],
});
history.replace(locationPathName);
const projectNavigationService = new ProjectNavigationService();
const chromeBreadcrumbs$ = new BehaviorSubject<ChromeBreadcrumb[]>([]);
const navLinksService = getNavLinksService(navLinkIds);
const application = {
...applicationServiceMock.createInternalStartContract(),
history,
};
application.navigateToUrl.mockImplementation(async (url) => {
history.push(url);
});
const projectNavigation = projectNavigationService.start({
application: {
...applicationServiceMock.createInternalStartContract(),
history,
},
application,
navLinksService,
http: httpServiceMock.createStartContract(),
chromeBreadcrumbs$,
@ -77,7 +90,7 @@ const setup = ({
setChromeStyle,
});
return { projectNavigation, history, chromeBreadcrumbs$ };
return { projectNavigation, history, chromeBreadcrumbs$, navLinksService, application };
};
describe('initNavigation()', () => {
@ -135,8 +148,7 @@ describe('initNavigation()', () => {
],
},
],
}),
{ cloudUrls: {} }
})
);
});
@ -185,8 +197,7 @@ describe('initNavigation()', () => {
],
},
],
}),
{ cloudUrls: {} }
})
);
const treeDefinition = await getNavTree();
const [node] = treeDefinition.body as [ChromeProjectNavigationNode];
@ -210,8 +221,7 @@ describe('initNavigation()', () => {
],
},
],
}),
{ cloudUrls: {} }
})
);
expect(logger.error).toHaveBeenCalledWith(
@ -390,8 +400,7 @@ describe('initNavigation()', () => {
children: [{ link: 'foo' }],
},
],
}),
{ cloudUrls: {} }
})
);
// 3. getNavigationTreeUi$() is resolved
@ -402,6 +411,13 @@ describe('initNavigation()', () => {
test('should add the Cloud links to the navigation tree', async () => {
const { projectNavigation } = setup();
projectNavigation.setCloudUrls({
usersAndRolesUrl: 'https://cloud.elastic.co/userAndRoles/', // trailing slash should be removed!
performanceUrl: 'https://cloud.elastic.co/performance/',
billingUrl: 'https://cloud.elastic.co/billing/',
deploymentUrl: 'https://cloud.elastic.co/deployment/',
});
projectNavigation.initNavigation<any>(
// @ts-expect-error - We pass a non valid cloudLink that is not TS valid
of({
@ -418,15 +434,7 @@ describe('initNavigation()', () => {
],
},
],
}),
{
cloudUrls: {
usersAndRolesUrl: 'https://cloud.elastic.co/userAndRoles/', // trailing slash should be removed!
performanceUrl: 'https://cloud.elastic.co/performance/',
billingUrl: 'https://cloud.elastic.co/billing/',
deploymentUrl: 'https://cloud.elastic.co/deployment/',
},
}
})
);
const treeDefinition = await lastValueFrom(
@ -516,7 +524,7 @@ describe('breadcrumbs', () => {
const obs = subj.asObservable();
if (initiateNavigation) {
projectNavigation.initNavigation(obs, { cloudUrls: {} });
projectNavigation.initNavigation(obs);
}
return {
@ -729,7 +737,7 @@ describe('breadcrumbs', () => {
{ text: 'custom1', href: '/custom1' },
{ text: 'custom2', href: '/custom1/custom2' },
]);
projectNavigation.initNavigation(of(mockNavigation), { cloudUrls: {} }); // init navigation
projectNavigation.initNavigation(of(mockNavigation)); // init navigation
const breadcrumbs = await firstValueFrom(projectNavigation.getProjectBreadcrumbs$());
expect(breadcrumbs).toHaveLength(4);
@ -781,8 +789,7 @@ describe('getActiveNodes$()', () => {
],
},
],
}),
{ cloudUrls: {} }
})
);
activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
@ -838,8 +845,7 @@ describe('getActiveNodes$()', () => {
],
},
],
}),
{ cloudUrls: {} }
})
);
activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
@ -877,26 +883,29 @@ describe('getActiveNodes$()', () => {
});
describe('solution navigations', () => {
const solution1: SolutionNavigationDefinition = {
const solution1: SolutionNavigationDefinition<any> = {
id: 'solution1',
title: 'Solution 1',
icon: 'logoSolution1',
homePage: 'discover',
navigationTree$: of({ body: [{ type: 'navItem', link: 'app1' }] }),
};
const solution2: SolutionNavigationDefinition = {
const solution2: SolutionNavigationDefinition<any> = {
id: 'solution2',
title: 'Solution 2',
icon: 'logoSolution2',
homePage: 'discover',
sideNavComponentGetter: () => () => null,
homePage: 'app2',
navigationTree$: of({ body: [{ type: 'navItem', link: 'app2' }] }),
sideNavComponent: () => null,
};
const solution3: SolutionNavigationDefinition = {
const solution3: SolutionNavigationDefinition<any> = {
id: 'solution3',
title: 'Solution 3',
icon: 'logoSolution3',
homePage: 'discover',
navigationTree$: of({ body: [{ type: 'navItem', link: 'app3' }] }),
};
const localStorageGetItem = jest.fn();
@ -973,9 +982,10 @@ describe('solution navigations', () => {
const activeSolution = await lastValueFrom(
projectNavigation.getActiveSolutionNavDefinition$().pipe(take(1))
);
expect(activeSolution).not.toBeNull();
// sideNavComponentGetter should not be exposed to consumers
expect('sideNavComponentGetter' in activeSolution!).toBe(false);
const { sideNavComponentGetter, ...rest } = solution2;
expect('sideNavComponent' in activeSolution!).toBe(false);
const { sideNavComponent, ...rest } = solution2;
expect(activeSolution).toEqual(rest);
}
@ -993,11 +1003,10 @@ describe('solution navigations', () => {
const { projectNavigation } = setup();
projectNavigation.updateSolutionNavigations({ 1: solution1, 2: solution2 });
projectNavigation.changeActiveSolutionNavigation('3');
expect(() => {
return lastValueFrom(projectNavigation.getActiveSolutionNavDefinition$().pipe(take(1)));
}).rejects.toThrowErrorMatchingInlineSnapshot(
projectNavigation.changeActiveSolutionNavigation('3');
}).toThrowErrorMatchingInlineSnapshot(
`"Solution navigation definition with id \\"3\\" does not exist."`
);
});
@ -1009,7 +1018,7 @@ describe('solution navigations', () => {
expect(setChromeStyle).not.toHaveBeenCalled();
projectNavigation.updateSolutionNavigations({ 1: solution1, 2: solution2 });
expect(setChromeStyle).toHaveBeenCalledWith('classic'); // No active solution yet, we are still on classic Kibana
expect(setChromeStyle).not.toHaveBeenCalled();
projectNavigation.changeActiveSolutionNavigation('2');
expect(setChromeStyle).toHaveBeenCalledWith('project'); // We have an active solution nav, we should switch to project style
@ -1017,4 +1026,38 @@ describe('solution navigations', () => {
projectNavigation.changeActiveSolutionNavigation(null);
expect(setChromeStyle).toHaveBeenCalledWith('classic'); // No active solution, we should switch back to classic Kibana
});
it('should change the active solution if no node match the current Location', async () => {
const { projectNavigation, navLinksService } = setup({
locationPathName: '/app/app3', // we are on app3 which only exists in solution3
navLinkIds: ['app1', 'app2', 'app3'],
});
const getActiveDefinition = () =>
lastValueFrom(projectNavigation.getActiveSolutionNavDefinition$().pipe(take(1)));
projectNavigation.updateSolutionNavigations({ 1: solution1, 2: solution2, 3: solution3 });
{
const definition = await getActiveDefinition();
expect(definition).toBe(null); // No active solution id yet
}
// Change to solution 2, but we are still on '/app/app3' which only exists in solution3
projectNavigation.changeActiveSolutionNavigation('2');
{
const definition = await getActiveDefinition();
expect(definition?.id).toBe('solution3'); // The solution3 was activated as it matches the "/app/app3" location
}
navLinksService.get.mockReturnValue({ url: '/app/app2', href: '/app/app2' } as any);
projectNavigation.changeActiveSolutionNavigation('2', { redirect: true }); // We ask to redirect to the home page of solution 2
{
const definition = await getActiveDefinition();
expect(definition?.id).toBe('solution2');
}
navLinksService.get.mockReset();
});
});

View file

@ -17,11 +17,11 @@ import type {
NavigationTreeDefinition,
SolutionNavigationDefinitions,
ChromeStyle,
CloudLinks,
} from '@kbn/core-chrome-browser';
import type { InternalHttpStart } from '@kbn/core-http-browser-internal';
import {
BehaviorSubject,
Observable,
combineLatest,
map,
takeUntil,
@ -30,8 +30,13 @@ import {
distinctUntilChanged,
skipWhile,
filter,
of,
type Observable,
type Subscription,
take,
debounceTime,
} from 'rxjs';
import type { Location } from 'history';
import { type Location, createLocation } from 'history';
import deepEqual from 'react-fast-compare';
import {
@ -61,9 +66,7 @@ export class ProjectNavigationService {
current: SideNavComponent | null;
}>({ current: null });
private projectHome$ = new BehaviorSubject<string | undefined>(undefined);
private projectsUrl$ = new BehaviorSubject<string | undefined>(undefined);
private projectName$ = new BehaviorSubject<string | undefined>(undefined);
private projectUrl$ = new BehaviorSubject<string | undefined>(undefined);
private navigationTree$ = new BehaviorSubject<ChromeProjectNavigationNode[] | undefined>(
undefined
);
@ -80,8 +83,13 @@ export class ProjectNavigationService {
private readonly stop$ = new ReplaySubject<void>(1);
private readonly solutionNavDefinitions$ = new BehaviorSubject<SolutionNavigationDefinitions>({});
private readonly activeSolutionNavDefinitionId$ = new BehaviorSubject<string | null>(null);
private readonly location$ = new BehaviorSubject<Location>(createLocation('/'));
private deepLinksMap$: Observable<Record<string, ChromeNavLink>> = of({});
private cloudLinks$ = new BehaviorSubject<CloudLinks>({});
private application?: InternalApplicationStart;
private navLinksService?: ChromeNavLinks;
private http?: InternalHttpStart;
private navigationChangeSubscription?: Subscription;
private unlistenHistory?: () => void;
private setChromeStyle: StartDeps['setChromeStyle'] = () => {};
@ -94,6 +102,7 @@ export class ProjectNavigationService {
setChromeStyle,
}: StartDeps) {
this.application = application;
this.navLinksService = navLinksService;
this.http = http;
this.logger = logger;
this.onHistoryLocationChange(application.history.location);
@ -101,7 +110,16 @@ export class ProjectNavigationService {
this.setChromeStyle = setChromeStyle;
this.handleActiveNodesChange();
this.handleSolutionNavDefinitionsChange();
this.handleEmptyActiveNodes();
this.deepLinksMap$ = navLinksService.getNavLinks$().pipe(
map((navLinks) => {
return navLinks.reduce((acc, navLink) => {
acc[navLink.id] = navLink;
return acc;
}, {} as Record<string, ChromeNavLink>);
})
);
return {
setProjectHome: (homeHref: string) => {
@ -110,11 +128,11 @@ export class ProjectNavigationService {
getProjectHome$: () => {
return this.projectHome$.asObservable();
},
setProjectsUrl: (projectsUrl: string) => {
this.projectsUrl$.next(projectsUrl);
},
getProjectsUrl$: () => {
return this.projectsUrl$.asObservable();
setCloudUrls: (cloudUrls: CloudURLs) => {
// Cloud links never change, so we only need to parse them once
if (Object.keys(this.cloudLinks$.getValue()).length > 0) return;
this.cloudLinks$.next(getCloudLinks(cloudUrls));
},
setProjectName: (projectName: string) => {
this.projectName$.next(projectName);
@ -122,14 +140,10 @@ export class ProjectNavigationService {
getProjectName$: () => {
return this.projectName$.asObservable();
},
setProjectUrl: (projectUrl: string) => {
this.projectUrl$.next(projectUrl);
},
initNavigation: <LinkId extends AppDeepLinkId = AppDeepLinkId>(
navTreeDefinition: Observable<NavigationTreeDefinition<LinkId>>,
{ cloudUrls }: { cloudUrls: CloudURLs }
navTreeDefinition: Observable<NavigationTreeDefinition<LinkId>>
) => {
this.initNavigation(navTreeDefinition, { navLinksService, cloudUrls });
this.initNavigation(navTreeDefinition);
},
getNavigationTreeUi$: this.getNavigationTreeUi$.bind(this),
getActiveNodes$: () => {
@ -153,26 +167,38 @@ export class ProjectNavigationService {
this.projectBreadcrumbs$,
this.activeNodes$,
chromeBreadcrumbs$,
this.projectsUrl$,
this.projectUrl$,
this.projectName$,
this.solutionNavDefinitions$,
this.activeSolutionNavDefinitionId$,
this.cloudLinks$,
]).pipe(
map(
([
projectBreadcrumbs,
activeNodes,
chromeBreadcrumbs,
projectsUrl,
projectUrl,
projectName,
solutionNavDefinitions,
activeSolutionNavDefinitionId,
cloudLinks,
]) => {
const solutionNavigations =
Object.keys(solutionNavDefinitions).length > 0 &&
activeSolutionNavDefinitionId !== null
? {
definitions: solutionNavDefinitions,
activeId: activeSolutionNavDefinitionId,
onChange: this.changeActiveSolutionNavigation.bind(this),
}
: undefined;
return buildBreadcrumbs({
projectUrl,
projectName,
projectsUrl,
projectBreadcrumbs,
activeNodes,
chromeBreadcrumbs,
solutionNavigations,
cloudLinks,
});
}
)
@ -189,28 +215,30 @@ export class ProjectNavigationService {
};
}
/**
* Initialize a "serverless style" navigation. For stateful deployments (not serverless), this
* handler initialize one of the solution navigations registered.
*
* @param navTreeDefinition$ The navigation tree definition
* @param location Optional location to use to detect the active node in the new navigation tree
*/
private initNavigation(
navTreeDefinition: Observable<NavigationTreeDefinition>,
{ navLinksService, cloudUrls }: { navLinksService: ChromeNavLinks; cloudUrls: CloudURLs }
navTreeDefinition$: Observable<NavigationTreeDefinition>,
location?: Location
) {
if (this.navigationTree$.getValue() !== undefined) {
throw new Error('Project navigation has already been initiated.');
if (this.navigationChangeSubscription) {
this.navigationChangeSubscription.unsubscribe();
}
const deepLinksMap$ = navLinksService.getNavLinks$().pipe(
map((navLinks) => {
return navLinks.reduce((acc, navLink) => {
acc[navLink.id] = navLink;
return acc;
}, {} as Record<string, ChromeNavLink>);
})
);
const cloudLinks = getCloudLinks(cloudUrls);
combineLatest([navTreeDefinition.pipe(takeUntil(this.stop$)), deepLinksMap$])
this.projectNavigationNavTreeFlattened = {};
this.navigationChangeSubscription = combineLatest([
navTreeDefinition$,
this.deepLinksMap$,
this.cloudLinks$,
])
.pipe(
map(([def, deepLinksMap]) => {
takeUntil(this.stop$),
map(([def, deepLinksMap, cloudLinks]) => {
return parseNavigationTree(def, {
deepLinks: deepLinksMap,
cloudLinks,
@ -223,7 +251,7 @@ export class ProjectNavigationService {
this.navigationTreeUi$.next(navigationTreeUI);
this.projectNavigationNavTreeFlattened = flattenNav(navigationTree);
this.setActiveProjectNavigationNodes();
this.updateActiveProjectNavigationNodes(location);
},
error: (err) => {
this.logger?.error(err);
@ -237,9 +265,15 @@ export class ProjectNavigationService {
.pipe(filter((v): v is NavigationTreeDefinitionUI => v !== null));
}
private setActiveProjectNavigationNodes(_location?: Location) {
if (!this.application) return;
if (!Object.keys(this.projectNavigationNavTreeFlattened).length) return;
private findActiveNodes({
location: _location,
flattendTree = this.projectNavigationNavTreeFlattened,
}: {
location?: Location;
flattendTree?: Record<string, ChromeProjectNavigationNode>;
} = {}): ChromeProjectNavigationNode[][] {
if (!this.application) return [];
if (!Object.keys(this.projectNavigationNavTreeFlattened).length) return [];
const location = _location ?? this.application.history.location;
let currentPathname = this.http?.basePath.prepend(location.pathname) ?? location.pathname;
@ -248,13 +282,17 @@ export class ProjectNavigationService {
// e.g. /app/kibana#/management
currentPathname = stripQueryParams(`${currentPathname}${location.hash}`);
const activeNodes = findActiveNodes(
currentPathname,
this.projectNavigationNavTreeFlattened,
location,
this.http?.basePath.prepend
);
return findActiveNodes(currentPathname, flattendTree, location, this.http?.basePath.prepend);
}
/**
* Find the active nodes in the navigation tree based on the current location (or a location passed in params)
* and update the activeNodes$ Observable.
*
* @param location Optional location to use to detect the active node in the new navigation tree, if not set the current location is used
*/
private updateActiveProjectNavigationNodes(location?: Location) {
const activeNodes = this.findActiveNodes({ location });
// Each time we call findActiveNodes() we create a new array of activeNodes. As this array is used
// in React in useCallback() and useMemo() dependencies arrays it triggers an infinite navigation
// tree registration loop. To avoid that we only notify the listeners when the activeNodes array
@ -267,7 +305,8 @@ export class ProjectNavigationService {
}
private onHistoryLocationChange(location: Location) {
this.setActiveProjectNavigationNodes(location);
this.location$.next(location);
this.updateActiveProjectNavigationNodes(location);
}
private handleActiveNodesChange() {
@ -292,20 +331,116 @@ export class ProjectNavigationService {
});
}
private handleSolutionNavDefinitionsChange() {
combineLatest([this.solutionNavDefinitions$, this.activeSolutionNavDefinitionId$])
.pipe(takeUntil(this.stop$))
.subscribe(this.onSolutionNavDefinitionsChange.bind(this));
/**
* When we are in stateful Kibana with multiple solution navigations, it is possible that a user
* lands on a page that does not belong to the current active solution navigation. In this case,
* we need to find the correct solution navigation based on the current location and switch to it.
*/
private handleEmptyActiveNodes() {
combineLatest([
this.activeNodes$,
this.solutionNavDefinitions$,
this.activeSolutionNavDefinitionId$.pipe(distinctUntilChanged()),
this.location$,
])
.pipe(takeUntil(this.stop$), debounceTime(20))
.subscribe(([activeNodes, definitions, activeSolution, location]) => {
if (
activeNodes.length > 0 ||
activeSolution === null ||
Object.keys(definitions).length === 0 ||
Object.keys(this.projectNavigationNavTreeFlattened).length === 0
) {
return;
}
// We have an active solution navigation but no active nodes, this means that
// the current location is not part of the current solution navigation.
// We need to find the correct solution navigation based on the current location.
let found = false;
Object.entries(definitions).forEach(([id, definition]) => {
combineLatest([definition.navigationTree$, this.deepLinksMap$, this.cloudLinks$])
.pipe(
take(1),
map(([def, deepLinksMap, cloudLinks]) =>
parseNavigationTree(def, {
deepLinks: deepLinksMap,
cloudLinks,
})
)
)
.subscribe(({ navigationTree }) => {
if (found) return;
const maybeActiveNodes = this.findActiveNodes({
location,
flattendTree: flattenNav(navigationTree),
});
if (maybeActiveNodes.length > 0) {
found = true;
this.changeActiveSolutionNavigation(id);
}
});
});
});
}
private setSideNavComponent(component: SideNavComponent | null) {
this.customProjectSideNavComponent$.next({ current: component });
}
private changeActiveSolutionNavigation(id: string | null, { onlyIfNotSet = false } = {}) {
private changeActiveSolutionNavigation(
id: string | null,
{ onlyIfNotSet = false, redirect = false } = {}
) {
if (this.activeSolutionNavDefinitionId$.getValue() === id) return;
if (onlyIfNotSet && this.activeSolutionNavDefinitionId$.getValue() !== null) {
return;
}
const definitions = this.solutionNavDefinitions$.getValue();
this.activeSolutionNavDefinitionId$.next(null);
// We don't want to change to "classic" if `id` is `null` when we haven't received
// any definitions yet. Serverless Kibana could be impacted by this.
// When we **do** have definitions, then passing `null` does mean we should change to "classic".
if (Object.keys(definitions).length > 0) {
if (id === null) {
this.setChromeStyle('classic');
this.navigationTree$.next(undefined);
} else {
const definition = definitions[id];
if (!definition) {
throw new Error(`Solution navigation definition with id "${id}" does not exist.`);
}
this.setChromeStyle('project');
const { sideNavComponent } = definition;
if (sideNavComponent) {
this.setSideNavComponent(sideNavComponent);
}
let location: Location | undefined;
if (redirect) {
// Navigate to the new home page if it's defined, otherwise navigate to the default home page
const link = this.navLinksService?.get(definition.homePage ?? 'home');
if (link) {
const linkUrl = this.http?.basePath.remove(link.url) ?? link.url;
location = createLocation(linkUrl);
this.location$.next(location);
this.application?.navigateToUrl(link.href);
}
}
// We want to pass the upcoming location where we are going to navigate to
// so we can immediately set the active nodes based on the new location and we
// don't have to wait for the location change event to be triggered.
this.initNavigation(definition.navigationTree$, location);
}
}
this.activeSolutionNavDefinitionId$.next(id);
}
@ -322,8 +457,8 @@ export class ProjectNavigationService {
if (!definitions[id]) {
throw new Error(`Solution navigation definition with id "${id}" does not exist.`);
}
// We strip out the sideNavComponentGetter from the definition as it should only be used internally
const { sideNavComponentGetter, ...definition } = definitions[id];
// We strip out the sideNavComponent from the definition as it should only be used internally
const { sideNavComponent, ...definition } = definitions[id];
return definition;
})
);
@ -343,33 +478,6 @@ export class ProjectNavigationService {
}
}
private onSolutionNavDefinitionsChange([definitions, id]: [
SolutionNavigationDefinitions,
string | null
]) {
// We don't want to change to "classic" if `id` is `null` when we haven't received
// any definitions yet. Serverless Kibana could be impacted by this.
// When we do have definitions, then passing `null` does mean we should change to "classic".
if (Object.keys(definitions).length === 0) return;
if (id === null) {
this.setChromeStyle('classic');
this.navigationTree$.next(undefined);
} else {
const definition = definitions[id];
if (!definition) {
throw new Error(`Solution navigation definition with id "${id}" does not exist.`);
}
this.setChromeStyle('project');
const { sideNavComponentGetter } = definition;
if (sideNavComponentGetter) {
this.setSideNavComponent(sideNavComponentGetter());
}
}
}
public stop() {
this.stop$.next();
this.unlistenHistory?.();

View file

@ -49,10 +49,10 @@ export interface InternalChromeStart extends ChromeStart {
setHome(homeHref: string): void;
/**
* Sets the cloud's projects page.
* @param projectsUrl
* Sets the cloud's URLs.
* @param cloudUrls
*/
setProjectsUrl(projectsUrl: string): void;
setCloudUrls(cloudUrls: CloudURLs): void;
/**
* Sets the project name.
@ -60,19 +60,12 @@ export interface InternalChromeStart extends ChromeStart {
*/
setProjectName(projectName: string): void;
/**
* Sets the project url.
* @param projectUrl
*/
setProjectUrl(projectUrl: string): void;
initNavigation<
LinkId extends AppDeepLinkId = AppDeepLinkId,
Id extends string = string,
ChildrenId extends string = Id
>(
navigationTree$: Observable<NavigationTreeDefinition<LinkId, Id, ChildrenId>>,
deps: { cloudUrls: CloudURLs }
navigationTree$: Observable<NavigationTreeDefinition<LinkId, Id, ChildrenId>>
): void;
getNavigationTreeUi$: () => Observable<NavigationTreeDefinitionUI>;
@ -119,6 +112,14 @@ export interface InternalChromeStart extends ChromeStart {
* @param id The id of the active solution navigation. If `null` is provided, the solution navigation
* will be replaced with the legacy Kibana navigation.
*/
changeActiveSolutionNavigation(id: string | null, options?: { onlyIfNotSet?: boolean }): void;
changeActiveSolutionNavigation(
id: string | null,
options?: {
/** only change if there isn't any active solution yet */
onlyIfNotSet?: boolean;
/** redirect to the new navigation home page */
redirect?: boolean;
}
): void;
};
}

View file

@ -0,0 +1,93 @@
/*
* 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 { EuiListGroup, EuiListGroupItem, EuiTitle, EuiSpacer, EuiButtonEmpty } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import type {
ChromeProjectBreadcrumb,
SolutionNavigationDefinitions,
CloudLinks,
} from '@kbn/core-chrome-browser';
export const getSolutionNavSwitcherBreadCrumb = ({
definitions,
activeId,
onChange,
cloudLinks,
}: {
definitions: SolutionNavigationDefinitions;
activeId: string;
onChange: (id: string, options?: { redirect?: boolean }) => void;
cloudLinks: CloudLinks;
}): ChromeProjectBreadcrumb => {
const text = Object.values(definitions).find(({ id }) => id === activeId)?.title;
return {
text,
'data-test-subj': 'solutionNavSwitcher',
popoverContent: (
<>
<EuiTitle size="xxxs">
<h3>
{i18n.translate('core.ui.primaryNav.cloud.breadCrumbDropdown.title', {
defaultMessage: 'Solution view',
})}
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiListGroup bordered size="s">
{Object.values(definitions).map(({ id, title, icon = 'gear' }) => [
<EuiListGroupItem
key={id}
label={title}
isActive={id === activeId}
iconType={icon as string}
data-test-subj={`solutionNavSwitcher-${id}`}
onClick={() => {
onChange(id, { redirect: true });
}}
/>,
])}
</EuiListGroup>
<EuiSpacer size="s" />
{cloudLinks.deployment && (
<EuiButtonEmpty
href={cloudLinks.deployment.href}
color="text"
iconType="gear"
data-test-subj="manageDeploymentBtn"
>
{i18n.translate('core.ui.primaryNav.cloud.breadCrumbDropdown.manageDeploymentLabel', {
defaultMessage: 'Manage this deployment',
})}
</EuiButtonEmpty>
)}
{cloudLinks.deployments && (
<EuiButtonEmpty
href={cloudLinks.deployments.href}
color="text"
iconType="spaces"
data-test-subj="viewDeploymentsBtn"
>
{cloudLinks.deployments.title}
</EuiButtonEmpty>
)}
</>
),
popoverProps: {
panelPaddingSize: 'm',
zIndex: 6000,
panelStyle: { width: 260 },
panelProps: {
'data-test-subj': 'solutionNavSwitcherPanel',
},
},
};
};

View file

@ -70,8 +70,7 @@ const createStartContractMock = () => {
setChromeStyle: jest.fn(),
project: {
setHome: jest.fn(),
setProjectsUrl: jest.fn(),
setProjectUrl: jest.fn(),
setCloudUrls: jest.fn(),
setProjectName: jest.fn(),
initNavigation: jest.fn(),
setSideNavComponent: jest.fn(),

View file

@ -49,11 +49,20 @@ export type AppDeepLinkId =
| ObservabilityLink;
/** @public */
export type CloudLinkId = 'userAndRoles' | 'performance' | 'billingAndSub' | 'deployment';
export type CloudLinkId =
| 'userAndRoles'
| 'performance'
| 'billingAndSub'
| 'deployment'
| 'deployments'
| 'projects';
export interface CloudURLs {
baseUrl?: string;
billingUrl?: string;
deploymentsUrl?: string;
deploymentUrl?: string;
projectsUrl?: string;
performanceUrl?: string;
usersAndRolesUrl?: string;
}
@ -383,16 +392,19 @@ export interface NavigationTreeDefinitionUI {
* for the side navigation evolution to align with the Serverless UX.
*/
export interface SolutionNavigationDefinition {
export interface SolutionNavigationDefinition<LinkId extends AppDeepLinkId = AppDeepLinkId> {
/** Unique id for the solution navigation. */
id: string;
/** Title for the solution navigation. */
title: string;
/** Optional icon for the solution navigation. */
/** The navigation tree definition */
navigationTree$: Observable<NavigationTreeDefinition<LinkId>>;
/** Optional icon for the solution navigation to render in the select dropdown. */
icon?: IconType;
sideNavComponentGetter?: () => SideNavComponent;
/** React component to render in the side nav for the navigation */
sideNavComponent?: SideNavComponent;
/** The page to navigate to when switching to this solution navigation. */
homePage?: AppDeepLinkId;
homePage?: LinkId;
}
export interface SolutionNavigationDefinitions {

View file

@ -0,0 +1,3 @@
# @kbn/solution-nav-es
## This package contains the navigation definition for the Search solution in Kibana stateful.

View file

@ -0,0 +1,42 @@
/*
* 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 type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser';
import { of } from 'rxjs';
export const definition: SolutionNavigationDefinition = {
id: 'es',
title: 'Search',
icon: 'logoElasticsearch',
homePage: 'dev_tools', // Temp. Wil be updated when all links are registered
navigationTree$: of({
body: [
// Temp. In future work this will be loaded from a package
{
type: 'navGroup',
id: 'search_project_nav',
title: 'Search',
icon: 'logoElasticsearch',
defaultIsCollapsed: false,
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [
{
id: 'dev_tools',
title: 'Dev Tools',
link: 'dev_tools:console',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dev_tools'));
},
spaceBefore: 'm',
},
],
},
],
}),
};

View file

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

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/solution-nav/es'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/solution-nav-es",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/solution-nav-es",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core-chrome-browser",
],
}

View file

@ -0,0 +1,3 @@
# @kbn/solution-nav-oblt
## This package contains the navigation definition for the Observability solution in Kibana stateful.

View file

@ -0,0 +1,37 @@
/*
* 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 type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser';
import { of } from 'rxjs';
export const definition: SolutionNavigationDefinition = {
id: 'oblt',
title: 'Observability',
icon: 'logoObservability',
homePage: 'discover', // Temp. Wil be updated when all links are registered
navigationTree$: of({
body: [
// Temp. In future work this will be loaded from a package
{
type: 'navGroup',
id: 'observability_project_nav',
title: 'Observability',
icon: 'logoObservability',
defaultIsCollapsed: false,
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [
{
link: 'discover',
spaceBefore: 'm',
},
],
},
],
}),
};

View file

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

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/solution-nav/oblt'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/solution-nav-oblt",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/solution-nav-oblt",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core-chrome-browser",
],
}

View file

@ -27,6 +27,7 @@ const createStartContract = (): jest.Mocked<Start> => {
AggregateQueryTopNavMenu: jest.fn(),
},
addSolutionNavigation: jest.fn(),
isSolutionNavigationEnabled: jest.fn(),
};
return startContract;
};

View file

@ -8,6 +8,7 @@
import { coreMock } from '@kbn/core/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { of } from 'rxjs';
import {
DEFAULT_SOLUTION_NAV_UI_SETTING_ID,
@ -16,6 +17,7 @@ import {
} from '../common';
import { NavigationPublicPlugin } from './plugin';
import { ConfigSchema } from './types';
import type { BuildFlavor } from '@kbn/config';
const defaultConfig: ConfigSchema['solutionNavigation'] = {
featureOn: true,
@ -27,18 +29,23 @@ const defaultConfig: ConfigSchema['solutionNavigation'] = {
const setup = (
partialConfig: Partial<ConfigSchema['solutionNavigation']> & {
featureOn: boolean;
}
},
{ buildFlavor = 'traditional' }: { buildFlavor?: BuildFlavor } = {}
) => {
const initializerContext = coreMock.createPluginInitializerContext({
solutionNavigation: {
...defaultConfig,
...partialConfig,
const initializerContext = coreMock.createPluginInitializerContext(
{
solutionNavigation: {
...defaultConfig,
...partialConfig,
},
},
});
{ buildFlavor }
);
const plugin = new NavigationPublicPlugin(initializerContext);
const coreStart = coreMock.createStart();
const unifiedSearch = unifiedSearchPluginMock.createStartContract();
const cloud = cloudMock.createStart();
const getGlobalSetting$ = jest.fn();
const settingsGlobalClient = {
@ -47,7 +54,7 @@ const setup = (
};
coreStart.settings.globalClient = settingsGlobalClient;
return { plugin, coreStart, unifiedSearch, getGlobalSetting$ };
return { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ };
};
describe('Navigation Plugin', () => {
@ -60,13 +67,18 @@ describe('Navigation Plugin', () => {
expect(coreStart.chrome.project.updateSolutionNavigations).not.toHaveBeenCalled();
expect(coreStart.chrome.project.changeActiveSolutionNavigation).not.toHaveBeenCalled();
});
it('should return flag to indicate that the solution navigation is disabled', () => {
const { plugin, coreStart, unifiedSearch } = setup({ featureOn });
expect(plugin.start(coreStart, { unifiedSearch }).isSolutionNavigationEnabled()).toBe(false);
});
});
describe('feature flag enabled', () => {
const featureOn = true;
it('should add the default solution navs but **not** set the active nav', () => {
const { plugin, coreStart, unifiedSearch, getGlobalSetting$ } = setup({ featureOn });
const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup({ featureOn });
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // NOT enabled, so we should not set the active nav
@ -79,17 +91,17 @@ describe('Navigation Plugin', () => {
return of(value);
});
plugin.start(coreStart, { unifiedSearch });
plugin.start(coreStart, { unifiedSearch, cloud });
expect(coreStart.chrome.project.updateSolutionNavigations).toHaveBeenCalled();
const [arg] = coreStart.chrome.project.updateSolutionNavigations.mock.calls[0];
expect(Object.keys(arg)).toEqual(['es', 'oblt', 'security']);
expect(Object.keys(arg)).toEqual(['es', 'oblt']);
expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith(null);
});
it('should add the default solution navs **and** set the active nav', () => {
const { plugin, coreStart, unifiedSearch, getGlobalSetting$ } = setup({ featureOn });
const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup({ featureOn });
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
@ -102,7 +114,7 @@ describe('Navigation Plugin', () => {
return of(value);
});
plugin.start(coreStart, { unifiedSearch });
plugin.start(coreStart, { unifiedSearch, cloud });
expect(coreStart.chrome.project.updateSolutionNavigations).toHaveBeenCalled();
@ -113,7 +125,7 @@ describe('Navigation Plugin', () => {
});
it('if not "visible", should not set the active nav', () => {
const { plugin, coreStart, unifiedSearch, getGlobalSetting$ } = setup({ featureOn });
const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup({ featureOn });
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
@ -126,12 +138,29 @@ describe('Navigation Plugin', () => {
return of(value);
});
plugin.start(coreStart, { unifiedSearch });
plugin.start(coreStart, { unifiedSearch, cloud });
expect(coreStart.chrome.project.updateSolutionNavigations).toHaveBeenCalled();
expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith(null, {
onlyIfNotSet: true,
});
});
it('should return flag to indicate that the solution navigation is enabled', () => {
const { plugin, coreStart, unifiedSearch, cloud } = setup({ featureOn });
expect(plugin.start(coreStart, { unifiedSearch, cloud }).isSolutionNavigationEnabled()).toBe(
true
);
});
it('on serverless should return flag to indicate that the solution navigation is disabled', () => {
const { plugin, coreStart, unifiedSearch, cloud } = setup(
{ featureOn },
{ buildFlavor: 'serverless' }
);
expect(plugin.start(coreStart, { unifiedSearch, cloud }).isSolutionNavigationEnabled()).toBe(
false
);
});
});
});

View file

@ -1,233 +0,0 @@
/*
* 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 { combineLatest, debounceTime, of, ReplaySubject, takeUntil } from 'rxjs';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type {
CloudURLs,
NavigationTreeDefinition,
SolutionNavigationDefinition,
SolutionNavigationDefinitions,
} from '@kbn/core-chrome-browser';
import { InternalChromeStart } from '@kbn/core-chrome-browser-internal';
import {
ENABLE_SOLUTION_NAV_UI_SETTING_ID,
OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID,
DEFAULT_SOLUTION_NAV_UI_SETTING_ID,
} from '../common';
import {
NavigationPublicSetup,
NavigationPublicStart,
NavigationPublicSetupDependencies,
NavigationPublicStartDependencies,
ConfigSchema,
SolutionNavigation,
} from './types';
import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
import { getSideNavComponent } from './side_navigation';
export class NavigationPublicPlugin
implements
Plugin<
NavigationPublicSetup,
NavigationPublicStart,
NavigationPublicSetupDependencies,
NavigationPublicStartDependencies
>
{
private readonly topNavMenuExtensionsRegistry: TopNavMenuExtensionsRegistry =
new TopNavMenuExtensionsRegistry();
private readonly stop$ = new ReplaySubject<void>(1);
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
public setup(_core: CoreSetup): NavigationPublicSetup {
return {
registerMenuItem: this.topNavMenuExtensionsRegistry.register.bind(
this.topNavMenuExtensionsRegistry
),
};
}
public start(
core: CoreStart,
{ unifiedSearch, cloud }: NavigationPublicStartDependencies
): NavigationPublicStart {
const extensions = this.topNavMenuExtensionsRegistry.getAll();
const chrome = core.chrome as InternalChromeStart;
/*
*
* This helps clients of navigation to create
* a TopNav Search Bar which does not uses global unifiedSearch/data/query service
*
* Useful in creating multiple stateful SearchBar in the same app without affecting
* global filters
*
* */
const createCustomTopNav = (
/*
* Custom instance of unified search if it needs to be overridden
*
* */
customUnifiedSearch?: UnifiedSearchPublicPluginStart,
customExtensions?: RegisteredTopNavMenuData[]
) => {
return createTopNav(customUnifiedSearch ?? unifiedSearch, customExtensions ?? extensions);
};
const config = this.initializerContext.config.get();
const {
solutionNavigation: { featureOn: isSolutionNavigationFeatureOn },
} = config;
if (isSolutionNavigationFeatureOn) {
this.addDefaultSolutionNavigation({ core, chrome, cloud });
combineLatest([
core.settings.globalClient.get$(ENABLE_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$(OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$(DEFAULT_SOLUTION_NAV_UI_SETTING_ID),
])
.pipe(takeUntil(this.stop$), debounceTime(10))
.subscribe(([enabled, status, defaultSolution]) => {
if (!enabled) {
chrome.project.changeActiveSolutionNavigation(null);
} else {
// TODO: Here we will need to check if the user has opt-in or not.... (value set in their user profile)
const changeImmediately = status === 'visible';
chrome.project.changeActiveSolutionNavigation(
changeImmediately ? defaultSolution : null,
{ onlyIfNotSet: true }
);
}
});
}
return {
ui: {
TopNavMenu: createTopNav(unifiedSearch, extensions),
AggregateQueryTopNavMenu: createTopNav(unifiedSearch, extensions),
createTopNavWithCustomContext: createCustomTopNav,
},
addSolutionNavigation: this.addSolutionNavigation.bind(this),
};
}
public stop() {
this.stop$.next();
}
private addSolutionNavigation(solutionNavigation: SolutionNavigation) {
// TODO: Implement. This handler will allow any plugin (e.g. security) to register a solution navigation.
}
private addDefaultSolutionNavigation({
core,
chrome,
cloud = {},
}: {
core: CoreStart;
chrome: InternalChromeStart;
cloud?: CloudURLs;
}) {
const { project } = chrome;
const activeNavigationNodes$ = project.getActiveNavigationNodes$();
const navigationTreeUi$ = project.getNavigationTreeUi$();
const getSideNavComponentGetter: (
navTree: NavigationTreeDefinition,
id: string
) => SolutionNavigationDefinition['sideNavComponentGetter'] = (navTree, id) => () => {
project.initNavigation(of(navTree), { cloudUrls: cloud });
return getSideNavComponent({
navProps: { navigationTree$: navigationTreeUi$ },
deps: { core, activeNodes$: activeNavigationNodes$ },
});
};
const solutionNavs: SolutionNavigationDefinitions = {
es: {
id: 'es',
title: 'Search',
icon: 'logoElasticsearch',
homePage: 'discover', // Temp. Wil be updated when all links are registered
sideNavComponentGetter: getSideNavComponentGetter(
{
body: [
// Temp. In future work this will be loaded from a package
{
type: 'navGroup',
id: 'search_project_nav',
title: 'Search',
icon: 'logoElasticsearch',
defaultIsCollapsed: false,
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [],
},
],
},
'search'
),
},
oblt: {
id: 'oblt',
title: 'Observability',
icon: 'logoObservability',
homePage: 'discover', // Temp. Wil be updated when all links are registered
sideNavComponentGetter: getSideNavComponentGetter(
{
body: [
// Temp. In future work this will be loaded from a package
{
type: 'navGroup',
id: 'observability_project_nav',
title: 'Observability',
icon: 'logoObservability',
defaultIsCollapsed: false,
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [],
},
],
},
'oblt'
),
},
security: {
id: 'security',
title: 'Security',
icon: 'logoSecurity',
homePage: 'discover', // Temp. Wil be updated when all links are registered
sideNavComponentGetter: getSideNavComponentGetter(
{
body: [
// Temp. In future work this will be loaded from a package
{
type: 'navGroup',
id: 'security_project_nav',
title: 'Security',
icon: 'logoSecurity',
breadcrumbStatus: 'hidden',
defaultIsCollapsed: false,
children: [],
},
],
},
'security'
),
},
};
chrome.project.updateSolutionNavigations(solutionNavs, true);
}
}

View file

@ -0,0 +1,241 @@
/*
* 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 { combineLatest, debounceTime, of, ReplaySubject, takeUntil } from 'rxjs';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type {
SolutionNavigationDefinition,
SolutionNavigationDefinitions,
} from '@kbn/core-chrome-browser';
import { InternalChromeStart } from '@kbn/core-chrome-browser-internal';
import { definition as esDefinition } from '@kbn/solution-nav-es';
import { definition as obltDefinition } from '@kbn/solution-nav-oblt';
import type { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation';
import {
ENABLE_SOLUTION_NAV_UI_SETTING_ID,
OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID,
DEFAULT_SOLUTION_NAV_UI_SETTING_ID,
} from '../common';
import {
NavigationPublicSetup,
NavigationPublicStart,
NavigationPublicSetupDependencies,
NavigationPublicStartDependencies,
ConfigSchema,
SolutionNavigation,
} from './types';
import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
import { SideNavComponent } from './side_navigation';
export class NavigationPublicPlugin
implements
Plugin<
NavigationPublicSetup,
NavigationPublicStart,
NavigationPublicSetupDependencies,
NavigationPublicStartDependencies
>
{
private readonly topNavMenuExtensionsRegistry: TopNavMenuExtensionsRegistry =
new TopNavMenuExtensionsRegistry();
private readonly stop$ = new ReplaySubject<void>(1);
private coreStart?: CoreStart;
private depsStart?: NavigationPublicStartDependencies;
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
public setup(_core: CoreSetup): NavigationPublicSetup {
return {
registerMenuItem: this.topNavMenuExtensionsRegistry.register.bind(
this.topNavMenuExtensionsRegistry
),
};
}
public start(
core: CoreStart,
depsStart: NavigationPublicStartDependencies
): NavigationPublicStart {
this.coreStart = core;
this.depsStart = depsStart;
const { unifiedSearch, cloud } = depsStart;
const extensions = this.topNavMenuExtensionsRegistry.getAll();
const chrome = core.chrome as InternalChromeStart;
/*
*
* This helps clients of navigation to create
* a TopNav Search Bar which does not uses global unifiedSearch/data/query service
*
* Useful in creating multiple stateful SearchBar in the same app without affecting
* global filters
*
* */
const createCustomTopNav = (
/*
* Custom instance of unified search if it needs to be overridden
*
* */
customUnifiedSearch?: UnifiedSearchPublicPluginStart,
customExtensions?: RegisteredTopNavMenuData[]
) => {
return createTopNav(customUnifiedSearch ?? unifiedSearch, customExtensions ?? extensions);
};
const config = this.initializerContext.config.get();
const {
solutionNavigation: { featureOn: isSolutionNavigationFeatureOn },
} = config;
const onCloud = cloud !== undefined; // The new side nav will initially only be available to cloud users
const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless';
const isSolutionNavEnabled = isSolutionNavigationFeatureOn && onCloud && !isServerless;
if (isSolutionNavEnabled) {
chrome.project.setCloudUrls(cloud);
this.addDefaultSolutionNavigation({ chrome });
this.susbcribeToSolutionNavUiSettings(core);
// Temp. This is temporary to simulate adding a solution nav after bootstrapping
setTimeout(() => {
this.addSolutionNavigation({
id: 'security',
title: 'Security',
icon: 'logoSecurity',
homePage: 'dashboards', // Temp. Wil be updated when all links are registered
navigationTree$: of({
body: [
// Temp. In future work this will be loaded from a package
{
type: 'navGroup',
id: 'security_project_nav',
title: 'Security',
icon: 'logoSecurity',
breadcrumbStatus: 'hidden',
defaultIsCollapsed: false,
children: [
{
link: 'dashboards',
spaceBefore: 'm',
},
],
},
],
}),
});
}, 5000);
}
return {
ui: {
TopNavMenu: createTopNav(unifiedSearch, extensions),
AggregateQueryTopNavMenu: createTopNav(unifiedSearch, extensions),
createTopNavWithCustomContext: createCustomTopNav,
},
addSolutionNavigation: (
solutionNavigation: Omit<SolutionNavigation, 'sideNavComponent'> & {
/** Data test subj for the side navigation */
dataTestSubj?: string;
/** Panel content provider for the side navigation */
panelContentProvider?: PanelContentProvider;
}
) => {
if (!isSolutionNavEnabled) return;
return this.addSolutionNavigation(solutionNavigation);
},
isSolutionNavigationEnabled: () => isSolutionNavEnabled,
};
}
public stop() {
this.stop$.next();
}
private susbcribeToSolutionNavUiSettings(core: CoreStart) {
const chrome = core.chrome as InternalChromeStart;
combineLatest([
core.settings.globalClient.get$(ENABLE_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$(OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$(DEFAULT_SOLUTION_NAV_UI_SETTING_ID),
])
.pipe(takeUntil(this.stop$), debounceTime(10))
.subscribe(([enabled, status, defaultSolution]) => {
if (!enabled) {
chrome.project.changeActiveSolutionNavigation(null);
} else {
// TODO: Here we will need to check if the user has opt-in or not.... (value set in their user profile)
const changeImmediately = status === 'visible';
chrome.project.changeActiveSolutionNavigation(
changeImmediately ? defaultSolution : null,
{ onlyIfNotSet: true }
);
}
});
}
private getSideNavComponent({
dataTestSubj,
panelContentProvider,
}: {
panelContentProvider?: PanelContentProvider;
dataTestSubj?: string;
} = {}): SolutionNavigationDefinition['sideNavComponent'] {
if (!this.coreStart) throw new Error('coreStart is not available');
if (!this.depsStart) throw new Error('depsStart is not available');
const core = this.coreStart;
const { project } = core.chrome as InternalChromeStart;
const activeNavigationNodes$ = project.getActiveNavigationNodes$();
const navigationTreeUi$ = project.getNavigationTreeUi$();
return () => (
<SideNavComponent
navProps={{ navigationTree$: navigationTreeUi$, dataTestSubj, panelContentProvider }}
deps={{ core, activeNodes$: activeNavigationNodes$ }}
/>
);
}
private addSolutionNavigation(
solutionNavigation: SolutionNavigation & {
/** Data test subj for the side navigation */
dataTestSubj?: string;
/** Panel content provider for the side navigation */
panelContentProvider?: PanelContentProvider;
}
) {
if (!this.coreStart) throw new Error('coreStart is not available');
const { dataTestSubj, panelContentProvider, ...rest } = solutionNavigation;
const sideNavComponent =
solutionNavigation.sideNavComponent ??
this.getSideNavComponent({ dataTestSubj, panelContentProvider });
const { project } = this.coreStart.chrome as InternalChromeStart;
project.updateSolutionNavigations({
[solutionNavigation.id]: { ...rest, sideNavComponent },
});
}
private addDefaultSolutionNavigation({ chrome }: { chrome: InternalChromeStart }) {
const solutionNavs: SolutionNavigationDefinitions = {
es: {
...esDefinition,
sideNavComponent: this.getSideNavComponent({ dataTestSubj: 'svlSearchSideNav' }),
},
oblt: {
...obltDefinition,
sideNavComponent: this.getSideNavComponent({ dataTestSubj: 'svlObservabilitySideNav' }),
},
};
chrome.project.updateSolutionNavigations(solutionNavs, true);
}
}

View file

@ -8,18 +8,13 @@
import React, { Suspense, type FC } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import type { SideNavComponent as SideNavComponentType } from '@kbn/core-chrome-browser';
import type { Props as NavigationProps } from './side_navigation';
const SideNavComponentLazy = React.lazy(() => import('./side_navigation'));
const SideNavComponent: FC<NavigationProps> = (props) => (
export const SideNavComponent: FC<NavigationProps> = (props) => (
<Suspense fallback={<EuiLoadingSpinner size="s" />}>
<SideNavComponentLazy {...props} />
</Suspense>
);
export const getSideNavComponent = (props: NavigationProps): SideNavComponentType => {
return () => <SideNavComponent {...props} />;
};

View file

@ -31,6 +31,11 @@ export interface NavigationPublicStart {
};
/** Add a solution navigation to the header nav switcher. */
addSolutionNavigation: (solutionNavigation: SolutionNavigation) => void;
/**
* Use this handler verify if the solution navigation is enabled.
* @returns true if the solution navigation is enabled, false otherwise.
*/
isSolutionNavigationEnabled: () => boolean;
}
export interface NavigationPublicSetupDependencies {

View file

@ -24,6 +24,9 @@
"@kbn/config-schema",
"@kbn/core-plugins-server",
"@kbn/i18n",
"@kbn/solution-nav-es",
"@kbn/solution-nav-oblt",
"@kbn/config",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,38 @@
/*
* 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 { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'header', 'home', 'dashboard']);
const testSubjects = getService('testSubjects');
const navigation = getService('globalNav');
describe('solution navigation switcher', function describeIndexTests() {
it('should be able to switch between solutions', async () => {
await PageObjects.common.navigateToApp('home');
// Default to "search" solution
await testSubjects.existOrFail('svlSearchSideNav');
await testSubjects.missingOrFail('svlObservabilitySideNav');
// Change to "observability" solution
await navigation.changeSolutionNavigation('oblt');
await testSubjects.existOrFail('svlObservabilitySideNav');
await testSubjects.missingOrFail('svlSearchSideNav');
});
it('should contain links to manage deployment and view all deployments', async () => {
await PageObjects.common.navigateToApp('home');
await navigation.openSolutionNavSwitcher();
await testSubjects.existOrFail('manageDeploymentBtn', { timeout: 2000 });
await testSubjects.existOrFail('viewDeploymentsBtn', { timeout: 2000 });
});
});
}

View file

@ -0,0 +1,35 @@
/*
* 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 { FtrConfigProviderContext } from '@kbn/test';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const functionalConfig = await readConfigFile(require.resolve('../../config.base.js'));
return {
...functionalConfig.getAll(),
testFiles: [require.resolve('.')],
kbnTestServer: {
...functionalConfig.get('kbnTestServer'),
serverArgs: [
...functionalConfig.get('kbnTestServer.serverArgs'),
'--navigation.solutionNavigation.featureOn=true',
'--navigation.solutionNavigation.enabled=true',
'--navigation.solutionNavigation.optInStatus=visible',
'--navigation.solutionNavigation.defaultSolution=es',
// Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests
'--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=',
'--xpack.cloud.base_url=https://cloud.elastic.co',
'--xpack.cloud.deployment_url=/deployments/deploymentId',
'--xpack.cloud.organization_url=/organization/organizationId',
'--xpack.cloud.billing_url=/billing',
'--xpack.cloud.profile_url=/user/userId',
],
},
};
}

View file

@ -0,0 +1,15 @@
/*
* 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 { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('navigation app', function () {
loadTestFile(require.resolve('./_solution_nav_switcher'));
});
}

View file

@ -52,4 +52,18 @@ export class GlobalNavService extends FtrService {
public async badgeMissingOrFail(): Promise<void> {
await this.testSubjects.missingOrFail('headerBadge');
}
public async openSolutionNavSwitcher(): Promise<void> {
if (await this.testSubjects.exists(`~solutionNavSwitcherPanel`, { timeout: 0 })) return;
await this.testSubjects.click('~solutionNavSwitcher');
await this.testSubjects.existOrFail('~solutionNavSwitcherPanel');
}
public async changeSolutionNavigation(id: 'es' | 'oblt' | 'search'): Promise<void> {
if (!(await this.testSubjects.exists(`~solutionNavSwitcherPanel`, { timeout: 0 }))) {
await this.openSolutionNavSwitcher();
}
await this.testSubjects.click(`~solutionNavSwitcherPanel > ${`~solutionNavSwitcher-${id}`}`);
}
}

View file

@ -232,6 +232,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cloud.base_url (string)',
'xpack.cloud.cname (string)',
'xpack.cloud.deployment_url (string)',
'xpack.cloud.deployments_url (string)',
'xpack.cloud.is_elastic_staff_owned (boolean)',
'xpack.cloud.trial_end_date (string)',
'xpack.cloud_integrations.chat.chatURL (string)',

View file

@ -1562,6 +1562,10 @@
"@kbn/slo-schema/*": ["x-pack/packages/kbn-slo-schema/*"],
"@kbn/snapshot-restore-plugin": ["x-pack/plugins/snapshot_restore"],
"@kbn/snapshot-restore-plugin/*": ["x-pack/plugins/snapshot_restore/*"],
"@kbn/solution-nav-es": ["packages/solution-nav/es"],
"@kbn/solution-nav-es/*": ["packages/solution-nav/es/*"],
"@kbn/solution-nav-oblt": ["packages/solution-nav/oblt"],
"@kbn/solution-nav-oblt/*": ["packages/solution-nav/oblt/*"],
"@kbn/some-dev-log": ["packages/kbn-some-dev-log"],
"@kbn/some-dev-log/*": ["packages/kbn-some-dev-log/*"],
"@kbn/sort-package-json": ["packages/kbn-sort-package-json"],

View file

@ -22,6 +22,7 @@ export interface CloudConfigType {
cname?: string;
base_url?: string;
profile_url?: string;
deployments_url?: string;
deployment_url?: string;
projects_url?: string;
billing_url?: string;
@ -38,6 +39,9 @@ export interface CloudConfigType {
}
interface CloudUrls {
/** Link to all deployments page on cloud */
deploymentsUrl?: string;
/** Link to the current deployment on cloud */
deploymentUrl?: string;
profileUrl?: string;
billingUrl?: string;
@ -122,6 +126,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
};
const {
deploymentsUrl,
deploymentUrl,
profileUrl,
billingUrl,
@ -141,6 +146,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
isCloudEnabled: this.isCloudEnabled,
cloudId: this.config.id,
billingUrl,
deploymentsUrl,
deploymentUrl,
profileUrl,
organizationUrl,
@ -165,6 +171,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
profile_url: profileUrl,
billing_url: billingUrl,
organization_url: organizationUrl,
deployments_url: deploymentsUrl,
deployment_url: deploymentUrl,
base_url: baseUrl,
performance_url: performanceUrl,
@ -172,6 +179,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
projects_url: projectsUrl,
} = this.config;
const fullCloudDeploymentsUrl = getFullCloudUrl(baseUrl, deploymentsUrl);
const fullCloudDeploymentUrl = getFullCloudUrl(baseUrl, deploymentUrl);
const fullCloudProfileUrl = getFullCloudUrl(baseUrl, profileUrl);
const fullCloudBillingUrl = getFullCloudUrl(baseUrl, billingUrl);
@ -182,6 +190,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
const fullCloudSnapshotsUrl = `${fullCloudDeploymentUrl}/${CLOUD_SNAPSHOTS_PATH}`;
return {
deploymentsUrl: fullCloudDeploymentsUrl,
deploymentUrl: fullCloudDeploymentUrl,
profileUrl: fullCloudProfileUrl,
billingUrl: fullCloudBillingUrl,

View file

@ -20,6 +20,12 @@ export interface CloudStart {
* Cloud ID. Undefined if not running on Cloud.
*/
cloudId?: string;
/**
* This is the path to the Cloud deployments management page. The value is already prepended with `baseUrl`.
*
* @example `{baseUrl}/deployments`
*/
deploymentsUrl?: string;
/**
* This is the path to the Cloud deployment management page for the deployment to which the Kibana instance belongs. The value is already prepended with `baseUrl`.
*

View file

@ -22,6 +22,7 @@ const configSchema = schema.object({
apm: schema.maybe(apmConfigSchema),
base_url: schema.maybe(schema.string()),
cname: schema.maybe(schema.string()),
deployments_url: schema.string({ defaultValue: '/deployments' }),
deployment_url: schema.maybe(schema.string()),
id: schema.maybe(schema.string()),
billing_url: schema.maybe(schema.string()),
@ -51,6 +52,7 @@ export const config: PluginConfigDescriptor<CloudConfigType> = {
exposeToBrowser: {
base_url: true,
cname: true,
deployments_url: true,
deployment_url: true,
id: true,
billing_url: true,

View file

@ -65,15 +65,11 @@ export class ServerlessPlugin
// Casting the "chrome.projects" service to an "internal" type: this is intentional to obscure the property from Typescript.
const { project } = core.chrome as InternalChromeStart;
const { cloud } = dependencies;
if (cloud.projectsUrl) {
project.setProjectsUrl(cloud.projectsUrl);
}
if (cloud.serverless.projectName) {
project.setProjectName(cloud.serverless.projectName);
}
if (cloud.deploymentUrl) {
project.setProjectUrl(cloud.deploymentUrl);
}
project.setCloudUrls(cloud);
const activeNavigationNodes$ = project.getActiveNavigationNodes$();
const navigationTreeUi$ = project.getNavigationTreeUi$();
@ -82,8 +78,7 @@ export class ServerlessPlugin
setSideNavComponentDeprecated: (sideNavigationComponent) =>
project.setSideNavComponent(sideNavigationComponent),
initNavigation: (navigationTree$, { panelContentProvider, dataTestSubj } = {}) => {
project.initNavigation(navigationTree$, { cloudUrls: cloud });
project.initNavigation(navigationTree$);
project.setSideNavComponent(() => (
<SideNavComponent
navProps={{

View file

@ -6173,6 +6173,14 @@
version "0.0.0"
uid ""
"@kbn/solution-nav-es@link:packages/solution-nav/es":
version "0.0.0"
uid ""
"@kbn/solution-nav-oblt@link:packages/solution-nav/oblt":
version "0.0.0"
uid ""
"@kbn/some-dev-log@link:packages/kbn-some-dev-log":
version "0.0.0"
uid ""