mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Stateful sidenav] Breadcrumb switcher (#178112)
This commit is contained in:
parent
e34a6d54e6
commit
059e4a57d0
44 changed files with 1102 additions and 440 deletions
|
@ -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
2
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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$(),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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?.();
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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(),
|
||||
|
|
|
@ -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 {
|
||||
|
|
3
packages/solution-nav/es/README.md
Normal file
3
packages/solution-nav/es/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/solution-nav-es
|
||||
|
||||
## This package contains the navigation definition for the Search solution in Kibana stateful.
|
42
packages/solution-nav/es/definition.ts
Normal file
42
packages/solution-nav/es/definition.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
9
packages/solution-nav/es/index.ts
Normal file
9
packages/solution-nav/es/index.ts
Normal 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';
|
13
packages/solution-nav/es/jest.config.js
Normal file
13
packages/solution-nav/es/jest.config.js
Normal 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'],
|
||||
};
|
5
packages/solution-nav/es/kibana.jsonc
Normal file
5
packages/solution-nav/es/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/solution-nav-es",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
6
packages/solution-nav/es/package.json
Normal file
6
packages/solution-nav/es/package.json
Normal 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"
|
||||
}
|
21
packages/solution-nav/es/tsconfig.json
Normal file
21
packages/solution-nav/es/tsconfig.json
Normal 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",
|
||||
],
|
||||
}
|
3
packages/solution-nav/oblt/README.md
Normal file
3
packages/solution-nav/oblt/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/solution-nav-oblt
|
||||
|
||||
## This package contains the navigation definition for the Observability solution in Kibana stateful.
|
37
packages/solution-nav/oblt/definition.ts
Normal file
37
packages/solution-nav/oblt/definition.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
9
packages/solution-nav/oblt/index.ts
Normal file
9
packages/solution-nav/oblt/index.ts
Normal 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';
|
13
packages/solution-nav/oblt/jest.config.js
Normal file
13
packages/solution-nav/oblt/jest.config.js
Normal 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'],
|
||||
};
|
5
packages/solution-nav/oblt/kibana.jsonc
Normal file
5
packages/solution-nav/oblt/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/solution-nav-oblt",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
6
packages/solution-nav/oblt/package.json
Normal file
6
packages/solution-nav/oblt/package.json
Normal 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"
|
||||
}
|
21
packages/solution-nav/oblt/tsconfig.json
Normal file
21
packages/solution-nav/oblt/tsconfig.json
Normal 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",
|
||||
],
|
||||
}
|
|
@ -27,6 +27,7 @@ const createStartContract = (): jest.Mocked<Start> => {
|
|||
AggregateQueryTopNavMenu: jest.fn(),
|
||||
},
|
||||
addSolutionNavigation: jest.fn(),
|
||||
isSolutionNavigationEnabled: jest.fn(),
|
||||
};
|
||||
return startContract;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
241
src/plugins/navigation/public/plugin.tsx
Normal file
241
src/plugins/navigation/public/plugin.tsx
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
38
test/functional/apps/navigation/_solution_nav_switcher.ts
Normal file
38
test/functional/apps/navigation/_solution_nav_switcher.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
}
|
35
test/functional/apps/navigation/config.ts
Normal file
35
test/functional/apps/navigation/config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
15
test/functional/apps/navigation/index.ts
Normal file
15
test/functional/apps/navigation/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
|
@ -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}`}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)',
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue