Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -51,16 +51,16 @@ export const Navigation = (props: ChromeNavigationProps) => {
return null;
}

const { navItems, logoItem } = state;
const { navItems, logoItem, activeItemId } = state;

return (
<RedirectNavigationAppLinks application={props.application}>
<NavigationComponent
items={navItems}
logoLabel={logoItem.label}
logoType={logoItem.logoType}
logo={logoItem}
isCollapsed={props.isCollapsed}
setWidth={props.setWidth}
activeItemId={activeItemId}
/>
</RedirectNavigationAppLinks>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,18 @@

import { waitFor } from '@testing-library/dom';
import { toNavigationItems } from './to_navigation_items';
import { NavigationTreeDefinitionUI } from '@kbn/core-chrome-browser';
import { ChromeProjectNavigationNode, NavigationTreeDefinitionUI } from '@kbn/core-chrome-browser';

// use require to bypass unnecessary TypeScript checks for JSON imports
// eslint-disable-next-line @typescript-eslint/no-var-requires
const navigationTree = require('./mocks/mock_security_tree.json') as NavigationTreeDefinitionUI;

const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
beforeEach(() => {
consoleWarnSpy.mockClear();
});

describe('toNavigationItems', () => {
beforeEach(() => {
consoleWarnSpy.mockClear();
});

const {
logoItem,
navItems: { footerItems, primaryItems },
Expand All @@ -30,8 +29,10 @@ describe('toNavigationItems', () => {
it('should return logo from navigation tree', () => {
expect(logoItem).toMatchInlineSnapshot(`
Object {
"href": "/missing-href-😭",
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.

😢

"iconType": "logoSecurity",
"id": "security_solution_nav",
"label": "Security",
"logoType": "logoSecurity",
}
`);
});
Expand All @@ -52,6 +53,7 @@ describe('toNavigationItems', () => {
expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
"
=== Navigation Warnings ===
• Navigation item \\"security_solution_nav\\" is missing a \\"href\\". Using fallback value: \\"/missing-href-😭\\".
• Navigation item \\"discover\\" is missing a \\"icon\\". Using fallback value: \\"discoverApp\\".
• Navigation item \\"dashboards\\" is missing a \\"icon\\". Using fallback value: \\"dashboardApp\\".
• Navigation node \\"node-2\\" is missing href and is not a panel opener. This node was likely used as a sub-section. Ignoring this node and flattening its children: securityGroup:rules, alerts, attack_discovery, cloud_security_posture-findings, cases.
Expand Down Expand Up @@ -80,3 +82,111 @@ describe('toNavigationItems', () => {
`);
});
});

describe('isActive', () => {
it('should return null if no active paths', () => {
const { activeItemId } = toNavigationItems(navigationTree, [], []);
expect(activeItemId).toBeUndefined();
});

it('should return logo node as active item', () => {
const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode;

const { activeItemId } = toNavigationItems(navigationTree, [], [[logoNode]]);
expect(activeItemId).toBe(logoNode.id);
});

it('should return primary menu node as active item', () => {
const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode;
const primaryNode = logoNode.children![0]! as ChromeProjectNavigationNode;

const { activeItemId } = toNavigationItems(navigationTree, [], [[logoNode, primaryNode]]);
expect(activeItemId).toBe(primaryNode.id);
});

it('should return 1st primary menu node as active item if multiple matching', () => {
const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode;
const primaryNode1 = logoNode.children![0]! as ChromeProjectNavigationNode;
const primaryNode2 = logoNode.children![1]! as ChromeProjectNavigationNode;

const { activeItemId } = toNavigationItems(
navigationTree,
[],
[
[logoNode, primaryNode1],
[logoNode, primaryNode2],
]
);
expect(activeItemId).toBe(primaryNode1.id);
});

it('should return secondary node as active item', () => {
const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode;
const primaryNode = logoNode.children![2]! as ChromeProjectNavigationNode;
const secondaryNode = primaryNode.children![1]! as ChromeProjectNavigationNode;

const { activeItemId } = toNavigationItems(
navigationTree,
[],
[[logoNode, primaryNode, secondaryNode]]
);
expect(activeItemId).toBe(secondaryNode.id);
});

it('should return secondary node as active item if active path is beyond navigation', () => {
const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode;
const primaryNode = logoNode.children![2]! as ChromeProjectNavigationNode;
const secondaryNode = primaryNode.children![0]! as ChromeProjectNavigationNode;
const beyondNavNode = secondaryNode.children![0]! as ChromeProjectNavigationNode;

const { activeItemId } = toNavigationItems(
navigationTree,
[],
[[logoNode, primaryNode, secondaryNode, beyondNavNode]]
);
expect(activeItemId).toBe(secondaryNode.id);
});

it('out of two matching paths should pick the deepest', () => {
const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode;
const primaryNode = logoNode.children![2]! as ChromeProjectNavigationNode;
const secondaryNode = primaryNode.children![0]! as ChromeProjectNavigationNode;
const beyondNavNode = secondaryNode.children![0]! as ChromeProjectNavigationNode;

const { activeItemId } = toNavigationItems(
navigationTree,
[],
[
[logoNode, primaryNode, secondaryNode, beyondNavNode],
[logoNode, primaryNode],
]
);
expect(activeItemId).toBe(secondaryNode.id);
});

it('should support footer items as active', () => {
const footerRootNode = navigationTree.footer![0]! as ChromeProjectNavigationNode;
const managementAccordion = footerRootNode.children![2]! as ChromeProjectNavigationNode;
const managementPrimary = managementAccordion.children![0]! as ChromeProjectNavigationNode;
const managementSecondarySection =
managementPrimary.children![0]! as ChromeProjectNavigationNode;
const managementSecondaryItem =
managementSecondarySection.children![0]! as ChromeProjectNavigationNode;

const { activeItemId } = toNavigationItems(
navigationTree,
[],
[
[
footerRootNode,
managementAccordion,
managementPrimary,
managementSecondarySection,
managementSecondaryItem,
],
]
);

expect(activeItemId).toBe(managementSecondaryItem.id);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,16 @@ import type {
NavigationStructure,
SecondaryMenuItem,
SecondaryMenuSection,
SideNavLogo,
} from '@kbn/core-chrome-navigation/types';

import { isActiveFromUrl } from '@kbn/shared-ux-chrome-navigation/src/utils';
import { AppDeepLinkIdToIcon } from './hack_icons_mappings';

export interface NavigationItems {
logoItem: LogoItem;
logoItem: SideNavLogo;
navItems: NavigationStructure;
}

export interface LogoItem {
logoType: string;
label: string;
activeItemId?: string;
}

/**
Expand Down Expand Up @@ -66,6 +65,18 @@ export const toNavigationItems = (
let primaryNodes: ChromeProjectNavigationNode[] = [];
let footerNodes: ChromeProjectNavigationNode[] = [];

let deepestActiveItemId: string | undefined;
let currentActiveItemIdLevel = -1;

const maybeMarkActive = (navNode: ChromeProjectNavigationNode, level: number) => {
if (deepestActiveItemId == null || currentActiveItemIdLevel < level) {
if (isActiveFromUrl(navNode.path, activeNodes, false)) {
deepestActiveItemId = navNode.id;
currentActiveItemIdLevel = level;
}
}
};

if (navigationTree.body.length === 1) {
const firstNode = navigationTree.body[0];
if (!isRecentlyAccessedDefinition(firstNode)) {
Expand All @@ -88,8 +99,18 @@ export const toNavigationItems = (
);
}

const logoItem: LogoItem = {
logoType: warnIfMissing(logoNode, 'icon', 'logoKibana') as string,
if (logoNode) {
maybeMarkActive(logoNode, 0);
} else {
warnOnce(
'Navigation tree is missing a logo node. The first level should contain a logo node with solution logo, name and home page href.'
);
}

const logoItem: SideNavLogo = {
href: warnIfMissing(logoNode, 'href', '/missing-href-😭'),
iconType: warnIfMissing(logoNode, 'icon', 'logoKibana') as string,
Copy link
Copy Markdown
Contributor Author

@weronikaolejniczak weronikaolejniczak Aug 6, 2025

Choose a reason for hiding this comment

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

Ahhh, fair, I must've copied the line but as string should only be applied to iconType because it expects a narrowed type. href, id and label are just strings. Great catch 👌🏻

id: warnIfMissing(logoNode, 'id', 'kibana'),
label: warnIfMissing(logoNode, 'title', 'Kibana'),
};

Expand Down Expand Up @@ -167,6 +188,7 @@ export const toNavigationItems = (
label: null,
items: navNode.children.map((child) => {
warnUnsupportedNavNodeOptions(child);
maybeMarkActive(child, 2);
return {
id: child.id,
label: warnIfMissing(child, 'title', 'Missing Title 😭'),
Expand All @@ -192,6 +214,7 @@ export const toNavigationItems = (
.filter((subChild) => subChild.sideNavStatus !== 'hidden')
.map((subChild) => {
warnUnsupportedNavNodeOptions(subChild);
maybeMarkActive(subChild, 2);
return {
id: subChild.id,
label: warnIfMissing(subChild, 'title', 'Missing Title 😭'),
Expand Down Expand Up @@ -238,6 +261,8 @@ export const toNavigationItems = (
itemHref = warnIfMissing(navNode, 'href', 'missing-href-😭');
}

maybeMarkActive(navNode, 1);

return {
id: navNode.id,
label: warnIfMissing(navNode, 'title', 'Missing Title 😭'),
Expand All @@ -263,7 +288,7 @@ export const toNavigationItems = (
);
}

return { logoItem, navItems: { primaryItems, footerItems } };
return { logoItem, navItems: { primaryItems, footerItems }, activeItemId: deepestActiveItemId };
};

// =====================
Expand Down
14 changes: 10 additions & 4 deletions src/core/packages/chrome/navigation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,16 @@ function App() {
<div className="app">
<TopBar isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed} />
<Navigation
title="Observability"
logo="observabilityApp"
items={navigationItems}
activeItemId={activeItemId}
isCollapsed={isCollapsed}
items={navigationItems}
logo={{
label: 'Observability',
id: 'observability',
iconType: 'observabilityApp',
href: '/observability',
}}
setWidth={setNavigationWidth}
/>
<main className="app-content">{/* Your application content */}</main>
</div>
Expand Down Expand Up @@ -108,7 +114,7 @@ export const navigationItems = {
label: 'Reports', // or null for unlabeled sections
items: [
{
id: 'overview',
id: 'analytics', // has the same `id` as the parent item
label: 'Overview',
href: '/analytics/reports', // has the same `href` as the parent item
},
Expand Down
9 changes: 8 additions & 1 deletion src/core/packages/chrome/navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

export { Navigation } from './src/components/navigation';
export { Navigation, type NavigationProps } from './src/components/navigation';
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.

@Dosant this is totally valid, I'm just wondering, couldn't we use ComponentProps<typeof Navigation>?

Copy link
Copy Markdown
Contributor

@Dosant Dosant Aug 6, 2025

Choose a reason for hiding this comment

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

I was fixing the storybook issue. I'm not sure if this was necessary.

ComponentProps

Isn't it still problematic because it would reference private type? if not, then I am good with typeof

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.

Right, I remember seeing that one. It was complaining about a private type. Then yup, it's best if we export 😄

export { useNavigation } from './src/hooks/use_navigation';
export type {
MenuItem,
SecondaryMenuItem,
SecondaryMenuSection,
NavigationStructure,
SideNavLogo,
} from './types';
Loading