Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

import type { AppDeepLinkLocations } from '@kbn/core-application-browser/src/application';

export const DEFAULT_APP_VISIBILITY: AppDeepLinkLocations[] = ['globalSearch', 'sideNav'];
export const DEFAULT_APP_VISIBILITY: AppDeepLinkLocations[] = [
'globalSearch',
'classicSideNav',
'solutionSideNav',
];

export const DEFAULT_LINK_VISIBILITY: AppDeepLinkLocations[] = ['globalSearch'];
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('getAppInfo', () => {
id: 'some-id',
title: 'some-title',
status: AppStatus.accessible,
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
appRoute: `/app/some-id`,
keywords: [],
deepLinks: [],
Expand Down Expand Up @@ -70,7 +70,7 @@ describe('getAppInfo', () => {
id: 'some-id',
title: 'some-title',
status: AppStatus.accessible,
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
appRoute: `/app/some-id`,
keywords: [],
deepLinks: [
Expand Down Expand Up @@ -98,7 +98,7 @@ describe('getAppInfo', () => {
expect(
getAppInfo(
createApp({
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
status: AppStatus.inaccessible,
})
)
Expand All @@ -110,25 +110,25 @@ describe('getAppInfo', () => {
expect(
getAppInfo(
createApp({
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
status: AppStatus.accessible,
})
)
).toEqual(
expect.objectContaining({
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
})
);
expect(
getAppInfo(
createApp({
// status is not set, default to accessible
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
})
)
).toEqual(
expect.objectContaining({
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
})
);
});
Expand Down Expand Up @@ -159,7 +159,7 @@ describe('getAppInfo', () => {
id: 'some-id',
title: 'some-title',
status: AppStatus.accessible,
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
appRoute: `/app/some-id`,
keywords: [],
order: 3,
Expand Down Expand Up @@ -192,7 +192,7 @@ describe('getAppInfo', () => {
createApp({
deepLinks: [
createDeepLink({
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
}),
],
})
Expand All @@ -201,7 +201,7 @@ describe('getAppInfo', () => {
expect.objectContaining({
deepLinks: [
expect.objectContaining({
visibleIn: ['globalSearch', 'sideNav'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
}),
],
})
Expand Down
33 changes: 19 additions & 14 deletions src/core/packages/application/browser/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,16 @@ export interface App<HistoryLocationState = unknown> extends AppNavOptions {
status?: AppStatus;

/**
* Optional list of locations where the app is visible.
* Locations where the app is visible. Each value enables one surface:
* - `globalSearch`: top-bar search results
* - `classicSideNav`: classic (hamburger) side navigation
* - `solutionSideNav`: solution side navs (Observability, Security, Search)
* - `home`: Kibana home page
* - `kibanaOverview`: Kibana overview page
*
* Accepts the following values:
* - "globalSearch": the link will appear in the global search bar
* - "home": the link will appear on the Kibana home page
* - "kibanaOverview": the link will appear in the Kibana overview page
* - "sideNav": the link will appear in the side navigation.
* Note: "sideNav" will be deprecated when we change the navigation to "solutions" style.
*
* @default ['globalSearch', 'sideNav']
* @default ['globalSearch', 'classicSideNav', 'solutionSideNav']
* unless the status is marked as `inaccessible`.
* @note Set to `[]` (empty array) to hide this link
* @note Set to `[]` (empty array) to hide this link from every surface.
*/
visibleIn?: AppDeepLinkLocations[];

Expand Down Expand Up @@ -150,9 +148,9 @@ export interface App<HistoryLocationState = unknown> extends AppNavOptions {
*
* start() {
* // later, when the navlink needs to be updated
* appUpdater.next(() => {
* appUpdater.next(() => ({
* visibleIn: ['globalSearch'],
* })
* }))
* }
* ```
*/
Expand Down Expand Up @@ -254,7 +252,13 @@ export type PublicAppDeepLinkInfo = Omit<AppDeepLink, 'deepLinks' | 'keywords' |
};

/** The places in the UI where a deepLink can be shown */
export type AppDeepLinkLocations = 'globalSearch' | 'sideNav' | 'home' | 'kibanaOverview';
export type AppDeepLinkLocations =
| 'globalSearch'
| 'classicSideNav'
// TODO: rename to 'sideNav' when 'classicSideNav' is removed (classic nav deprecation).
| 'solutionSideNav'
| 'home'
| 'kibanaOverview';

/**
* Input type for registering secondary in-app locations for an application.
Expand All @@ -272,7 +276,8 @@ export type AppDeepLink<Id extends string = string> = {
/** Optional keywords to match with in deep links search. Omit if this part of the hierarchy does not have a page URL. */
keywords?: string[];
/**
* Optional list of locations where the deepLink is visible. By default the deepLink is visible in "globalSearch".
* Locations where the deep link is visible. See {@link App.visibleIn} for the list of surfaces.
* @default ['globalSearch']
*/
visibleIn?: AppDeepLinkLocations[];
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { CollapsibleNav } from './collapsible_nav';

const { kibana, observability, security, management } = DEFAULT_APP_CATEGORIES;

const visibleIn = ['globalSearch' as const, 'sideNav' as const];
const visibleIn = ['globalSearch' as const, 'classicSideNav' as const, 'solutionSideNav' as const];

function mockLink({ title = 'discover', category }: Partial<ChromeNavLink>) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function CollapsibleNav({
allLinks.filter(
(link) =>
// Filterting out hidden links,
link.visibleIn.includes('sideNav') &&
link.visibleIn.includes('classicSideNav') &&
// and non-data overview pages
!overviewIDsToHide.includes(link.id)
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ describe('hidden panel link', () => {
baseUrl: '/',
href: '/app/management',
url: '/app/management',
visibleIn: ['sideNav'],
visibleIn: ['classicSideNav', 'solutionSideNav'],
},
sideNavStatus: 'hidden',
id: 'stack_management',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const availableApps: ReadonlyMap<string, App> = new Map([
order: 50,
title: 'Deep App 1',
path: '/deepapp1',
visibleIn: ['sideNav'],
visibleIn: ['classicSideNav', 'solutionSideNav'],
deepLinks: [
{
id: 'deepApp2',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('toNavLink', () => {
order: 12,
tooltip: 'tooltip',
euiIconType: 'my-icon',
visibleIn: ['sideNav'],
visibleIn: ['classicSideNav', 'solutionSideNav'],
}),
basePath
);
Expand All @@ -58,7 +58,7 @@ describe('toNavLink', () => {
order: 12,
tooltip: 'tooltip',
euiIconType: 'my-icon',
visibleIn: ['sideNav'],
visibleIn: ['classicSideNav', 'solutionSideNav'],
})
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const getNavLink = (partial: Partial<ChromeNavLink> = {}): ChromeNavLink => ({
baseUrl: '/app',
url: `/app/${partial.id ?? 'kibana'}`,
href: `/app/${partial.id ?? 'kibana'}`,
visibleIn: ['globalSearch'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
...partial,
});

Expand Down Expand Up @@ -208,6 +208,89 @@ describe('initNavigation()', () => {
expect(node.children?.[0].href).toBe('https://elastic.co');
});

test('should filter out deepLinks that exclude solutionSideNav from visibleIn', async () => {
const { projectNavigation: svc, navLinksService: nls } = setup({
navLinkIds: ['management:genAiSettings'],
});

// Add a second link that is registered but explicitly excludes solutionSideNav
const evalsNoSideNav: ChromeNavLink = getNavLink({
id: 'management:evals',
title: 'MANAGEMENT:EVALS',
visibleIn: ['globalSearch'],
});
const existing = nls.getAll();
nls.getNavLinks$.mockReturnValue(of([...existing, evalsNoSideNav]));
nls.getAll.mockReturnValue([...existing, evalsNoSideNav]);

svc.initNavigation<any>(
'es',
of({
body: [
{
id: 'group1',
type: 'navGroup',
children: [
{ link: 'management:genAiSettings' },
{ link: 'management:evals' }, // registered but no solutionSideNav → removed
],
},
],
})
);

const treeDefinition = await lastValueFrom(
svc.getNavigation$().pipe(
take(1),
map((nav) => nav.navigationTree)
)
);

const [node] = treeDefinition.body as [ChromeProjectNavigationNode];
expect(node.children?.map((c) => c.id)).toEqual(['management:genAiSettings']);
});

test('removes deepLinks from the solution nav tree when their visibleIn excludes solutionSideNav', async () => {
const { projectNavigation: svc, navLinksService: nls } = setup({
navLinkIds: ['management:genAiSettings'],
});

const classicOnlyLink: ChromeNavLink = getNavLink({
id: 'management:evals',
title: 'MANAGEMENT:EVALS',
visibleIn: ['globalSearch', 'classicSideNav'],
});
const existing = nls.getAll();
nls.getNavLinks$.mockReturnValue(of([...existing, classicOnlyLink]));
nls.getAll.mockReturnValue([...existing, classicOnlyLink]);

svc.initNavigation<any>(
'es',
of({
body: [
{
id: 'group1',
type: 'navGroup',
children: [
{ link: 'management:genAiSettings' },
{ link: 'management:evals' }, // visibleIn excludes solutionSideNav → removed
],
},
],
})
);

const treeDefinition = await lastValueFrom(
svc.getNavigation$().pipe(
take(1),
map((nav) => nav.navigationTree)
)
);

const [node] = treeDefinition.body as [ChromeProjectNavigationNode];
expect(node.children?.map((c) => c.id)).toEqual(['management:genAiSettings']);
});

test('should filter out missing deepLinks (e.g. evals) from the navigation tree', async () => {
const { projectNavigation: projectNavigationService } = setup({
navLinkIds: ['management:genAiSettings'],
Expand Down Expand Up @@ -289,7 +372,7 @@ describe('initNavigation()', () => {
id: 'foo',
title: 'FOO',
url: '/app/foo',
visibleIn: ['globalSearch'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
},
href: '/app/foo',
id: 'foo',
Expand Down Expand Up @@ -838,7 +921,7 @@ describe('getNavigation$() active nodes', () => {
baseUrl: '/app',
url: '/app/item1',
href: '/app/item1',
visibleIn: ['globalSearch'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
},
},
],
Expand Down Expand Up @@ -892,7 +975,7 @@ describe('getNavigation$() active nodes', () => {
baseUrl: '/app',
url: '/app/item1',
href: '/app/item1',
visibleIn: ['globalSearch'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
},
getIsActive: expect.any(Function),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const getDeepLink = (id: string, path: string, title = ''): ChromeNavLink => ({
href: `http://mocked/kibana/foo/${path}`,
title,
baseUrl: '',
visibleIn: ['globalSearch'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav'],
});

describe('flattenNav', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ function getNodeStatus(
},
{ cloudLinks }: { cloudLinks: CloudLinks }
): SideNavNodeStatus | 'remove' {
if (link && !deepLink) {
// If a link is provided, but no deepLink is found, don't render anything
if (link && (!deepLink || !deepLink.visibleIn.includes('solutionSideNav'))) {
// If a link is provided but no deepLink found, or the app excluded solutionSideNav
return 'remove';
Comment thread
paulinashakirova marked this conversation as resolved.
Outdated
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class KibanaOverviewPlugin
order: 1,
updater$: appUpdater$,
appRoute: PLUGIN_PATH,
visibleIn: ['globalSearch', 'home', 'sideNav'],
visibleIn: ['globalSearch', 'home', 'classicSideNav', 'solutionSideNav'],
async mount(params: AppMountParameters) {
// Load application bundle
const { renderApp } = await import('./application');
Expand Down
1 change: 1 addition & 0 deletions src/platform/plugins/shared/dev_tools/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export class DevToolsPlugin implements Plugin<DevToolsSetup, void> {
id: tool.id,
title: tool.title as string,
path: `#/${tool.id}`,
visibleIn: ['globalSearch', 'solutionSideNav'],
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this line these deep links default to ['globalSearch'] (deep-link default, not the app one). On main that still worked in solution navs because the resolver was silently ignoring visibleIn — this PR makes it actually respect it, so without 'solutionSideNav' here the Console / Grok Debugger / Painless Lab / Search Profiler entries would disappear from any solution nav that references them.

Skipping 'classicSideNav' on purpose: the classic hamburger already has the single "Dev Tools" app entry. Adding it here would surface all four tools as separate top-level hamburger items, which isn't what main shows.

};
if (!devtoolsDeeplinkIds.some((id) => id === deepLink.id)) {
throw new Error('Deeplink must be registered in package.');
Expand Down
2 changes: 1 addition & 1 deletion src/platform/plugins/shared/discover/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export class DiscoverPlugin
euiIconType: 'logoKibana',
defaultPath: '#/',
category: DEFAULT_APP_CATEGORIES.kibana,
visibleIn: ['globalSearch', 'sideNav', 'kibanaOverview'],
visibleIn: ['globalSearch', 'classicSideNav', 'solutionSideNav', 'kibanaOverview'],
mount: async (params: AppMountParameters) => {
const [[coreStart, discoverStartPlugins], historyService, ebtManager, { renderApp }] =
await Promise.all([
Expand Down
2 changes: 1 addition & 1 deletion src/platform/plugins/shared/management/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class ManagementPlugin
title: mgmtApp.title,
path: mgmtApp.basePath,
keywords: mgmtApp.keywords,
...(mgmtApp.visibleIn ? { visibleIn: mgmtApp.visibleIn } : {}),
visibleIn: mgmtApp.visibleIn ?? ['globalSearch', 'solutionSideNav'],
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default excludes 'classicSideNav' so management sub-apps do not surface as top-level entries in the classic nav.
'solutionSideNav' is included so solution navigation trees can keep referencing them by id (e.g. 'management:dataViews').

})),
}));

Expand Down
Loading
Loading