Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SharedUxChromeNavigation] Detect active nav route(s) #159906

Merged
merged 40 commits into from
Jun 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5ea1ea5
Add utility function to detect active nodes
sebelga Jun 14, 2023
832f6fb
Update utils to find the longest matching path
sebelga Jun 15, 2023
664ead9
Listen to history location change in project nav service
sebelga Jun 15, 2023
db15d1e
Expose getActiveNavigationNodes$ from the serverless plugin
sebelga Jun 15, 2023
f5cef46
Pass the activeNodes through context and add "isActive" state
sebelga Jun 15, 2023
9431b37
Add control to collapse state
sebelga Jun 15, 2023
6affdfb
Check that tree is different before calling the chrome API
sebelga Jun 19, 2023
03a1446
Fix re-order issue
sebelga Jun 19, 2023
a38ee62
Remove unnecessary fragment
sebelga Jun 19, 2023
7f1f25a
Refactor useInitNavnode hook
sebelga Jun 19, 2023
323acda
Only update the active nodes when there is actually a change
sebelga Jun 19, 2023
bea5338
Set node as selected in the UI
sebelga Jun 19, 2023
1fecb56
Small refactor
sebelga Jun 19, 2023
cf5be4f
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Jun 19, 2023
04a89be
Fix tests and storybook stories
sebelga Jun 19, 2023
78b42ae
Fix TS issue
sebelga Jun 19, 2023
ea5f08f
Add getIsActive handler to override history location match
sebelga Jun 19, 2023
66dd22c
Add more tests
sebelga Jun 19, 2023
9e6752e
Add more tests
sebelga Jun 20, 2023
1c5da0f
Fix TS issue
sebelga Jun 20, 2023
af27ff8
Update limits
sebelga Jun 20, 2023
b3d55f7
Merge branch 'main' into chrome-nav/detect-active-nav-route
sebelga Jun 20, 2023
74318d7
Return Subject instead of Observable and pass it through props
sebelga Jun 21, 2023
8379059
Merge branch 'chrome-nav/detect-active-nav-route' of github.com:sebel…
sebelga Jun 21, 2023
bd7848d
Use for of loop
sebelga Jun 21, 2023
b4da37b
[CI] Auto-commit changed files from 'node scripts/precommit_hook.js -…
kibanamachine Jun 21, 2023
e19913a
Debounce update navigation tree on chrome service
sebelga Jun 21, 2023
d5648e7
Use deepEqual to check if activeNode array has changed
sebelga Jun 21, 2023
e3d4a95
Fix jest tests
sebelga Jun 21, 2023
8c5369f
Cleanup unused interfaces
sebelga Jun 21, 2023
2ebbb7f
Merge branch 'chrome-nav/detect-active-nav-route' of github.com:sebel…
sebelga Jun 21, 2023
4d0b114
Merge branch 'main' into chrome-nav/detect-active-nav-route
sebelga Jun 21, 2023
25b7c08
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Jun 21, 2023
252f1f6
Refactor requiresUpdate check
sebelga Jun 21, 2023
105368e
Merge branch 'chrome-nav/detect-active-nav-route' of github.com:sebel…
sebelga Jun 21, 2023
3a47dcd
Merge branch 'main' into chrome-nav/detect-active-nav-route
sebelga Jun 21, 2023
cc39d6c
Merge branch 'main' into chrome-nav/detect-active-nav-route
sebelga Jun 21, 2023
a1f673b
Fix tests
sebelga Jun 21, 2023
e149dfc
Merge branch 'chrome-nav/detect-active-nav-route' of github.com:sebel…
sebelga Jun 21, 2023
87e7816
Merge branch 'main' into chrome-nav/detect-active-nav-route
sebelga Jun 22, 2023
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 @@ -164,7 +164,7 @@ export class ChromeService {

const navControls = this.navControls.start();
const navLinks = this.navLinks.start({ application, http });
const projectNavigation = this.projectNavigation.start({ application, navLinks });
const projectNavigation = this.projectNavigation.start({ application, navLinks, http });
const recentlyAccessed = await this.recentlyAccessed.start({ http });
const docTitle = this.docTitle.start();
const { customBranding$ } = customBranding;
Expand Down Expand Up @@ -262,18 +262,12 @@ export class ChromeService {

const getHeaderComponent = () => {
if (chromeStyle$.getValue() === 'project') {
// const projectNavigationConfig = projectNavigation.getProjectNavigation$();
// TODO: Uncommented when we support the project navigation config
// if (!projectNavigationConfig) {
// throw new Erorr(`Project navigation config must be provided for project.`);
// }

const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
// const projectNavigation$ = projectNavigation.getProjectNavigation$();
const activeNodes$ = projectNavigation.getActiveNodes$();

const ProjectHeaderWithNavigation = () => {
const CustomSideNavComponent = useObservable(projectNavigationComponent$, undefined);
// const projectNavigationConfig = useObservable(projectNavigation$, undefined);
const activeNodes = useObservable(activeNodes$, []);

let SideNavComponent: ISideNavComponent = () => null;

Expand Down Expand Up @@ -311,8 +305,7 @@ export class ChromeService {
kibanaVersion={injectedMetadata.getKibanaVersion()}
prependBasePath={http.basePath.prepend}
>
{/* TODO: pass down the SideNavCompProps once they are defined */}
<SideNavComponent />
<SideNavComponent activeNodes={activeNodes} />
</ProjectHeader>
);
};
Expand Down Expand Up @@ -428,12 +421,14 @@ export class ChromeService {
setNavigation: setProjectNavigation,
setSideNavComponent: setProjectSideNavComponent,
setBreadcrumbs: setProjectBreadcrumbs,
getActiveNavigationNodes$: () => projectNavigation.getActiveNodes$(),
},
};
}

public stop() {
this.navLinks.stop();
this.projectNavigation.stop();
this.stop$.next();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,46 @@
* Side Public License, v 1.
*/

import { firstValueFrom } from 'rxjs';
import { ProjectNavigationService } from './project_navigation_service';
import { History } from 'history';
import { firstValueFrom, lastValueFrom, take } from 'rxjs';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import type { ChromeNavLinks } from '@kbn/core-chrome-browser';
import { ProjectNavigationService } from './project_navigation_service';

const createHistoryMock = ({
locationPathName = '/',
}: { locationPathName?: string } = {}): jest.Mocked<History> => {
return {
block: jest.fn(),
createHref: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
listen: jest.fn(),
push: jest.fn(),
replace: jest.fn(),
action: 'PUSH',
length: 1,
location: {
pathname: locationPathName,
search: '',
hash: '',
key: '',
state: undefined,
},
};
};

const setup = () => {
const setup = ({ locationPathName = '/' }: { locationPathName?: string } = {}) => {
const projectNavigationService = new ProjectNavigationService();
const projectNavigation = projectNavigationService.start({
application: applicationServiceMock.createInternalStartContract(),
application: {
...applicationServiceMock.createInternalStartContract(),
history: createHistoryMock({ locationPathName }),
},
navLinks: {} as unknown as ChromeNavLinks,
http: httpServiceMock.createStartContract(),
});

return { projectNavigation };
Expand Down Expand Up @@ -89,3 +119,108 @@ describe('breadcrumbs', () => {
`);
});
});

describe('getActiveNodes$()', () => {
test('should set the active nodes from history location', async () => {
const currentLocationPathName = '/foo/item1';
const { projectNavigation } = setup({ locationPathName: currentLocationPathName });

let activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
expect(activeNodes).toEqual([]);

projectNavigation.setProjectNavigation({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
children: [
{
id: 'item1',
title: 'Item 1',
path: ['root', 'item1'],
deepLink: {
id: 'item1',
title: 'Item 1',
url: '/foo/item1',
baseUrl: '',
href: '',
},
},
],
},
],
});

activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));

expect(activeNodes).toEqual([
[
{
id: 'root',
title: 'Root',
isActive: true,
path: ['root'],
},
{
id: 'item1',
title: 'Item 1',
isActive: true,
path: ['root', 'item1'],
deepLink: {
id: 'item1',
title: 'Item 1',
url: '/foo/item1',
baseUrl: '',
href: '',
},
},
],
]);
});

test('should set the active nodes from getIsActive() handler', async () => {
const { projectNavigation } = setup();

let activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
expect(activeNodes).toEqual([]);

projectNavigation.setProjectNavigation({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
children: [
{
id: 'item1',
title: 'Item 1',
path: ['root', 'item1'],
getIsActive: () => true,
},
],
},
],
});

activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));

expect(activeNodes).toEqual([
[
{
id: 'root',
title: 'Root',
isActive: true,
path: ['root'],
},
{
id: 'item1',
title: 'Item 1',
isActive: true,
path: ['root', 'item1'],
getIsActive: expect.any(Function),
},
],
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,20 @@ import {
SideNavComponent,
ChromeProjectBreadcrumb,
ChromeSetProjectBreadcrumbsParams,
ChromeProjectNavigationNode,
} from '@kbn/core-chrome-browser';
import { BehaviorSubject, Observable, combineLatest, map } from 'rxjs';
import type { HttpStart } from '@kbn/core-http-browser';
import { BehaviorSubject, Observable, combineLatest, map, takeUntil, ReplaySubject } from 'rxjs';
import type { Location } from 'history';
import deepEqual from 'react-fast-compare';

import { createHomeBreadcrumb } from './home_breadcrumbs';
import { findActiveNodes, flattenNav, stripQueryParams } from './utils';

interface StartDeps {
application: InternalApplicationStart;
navLinks: ChromeNavLinks;
http: HttpStart;
}

export class ProjectNavigationService {
Expand All @@ -28,17 +35,23 @@ export class ProjectNavigationService {
}>({ current: null });
private projectHome$ = new BehaviorSubject<string | undefined>(undefined);
private projectNavigation$ = new BehaviorSubject<ChromeProjectNavigation | undefined>(undefined);
private activeNodes$ = new BehaviorSubject<ChromeProjectNavigationNode[][]>([]);
private projectNavigationNavTreeFlattened: Record<string, ChromeProjectNavigationNode> = {};

private projectBreadcrumbs$ = new BehaviorSubject<{
breadcrumbs: ChromeProjectBreadcrumb[];
params: ChromeSetProjectBreadcrumbsParams;
}>({ breadcrumbs: [], params: { absolute: false } });
private readonly stop$ = new ReplaySubject<void>(1);
private application?: InternalApplicationStart;
private http?: HttpStart;
private unlistenHistory?: () => void;

public start({ application, navLinks }: StartDeps) {
// TODO: use application, navLink and projectNavigation$ to:
// 1. validate projectNavigation$ against navLinks,
// 2. filter disabled/missing links from projectNavigation
// 3. keep track of currently active link / path (path will be used to highlight the link in the sidenav and display part of the breadcrumbs)
public start({ application, navLinks, http }: StartDeps) {
this.application = application;
this.http = http;
this.onHistoryLocationChange(application.history.location);
this.unlistenHistory = application.history.listen(this.onHistoryLocationChange.bind(this));

return {
setProjectHome: (homeHref: string) => {
Expand All @@ -49,10 +62,15 @@ export class ProjectNavigationService {
},
setProjectNavigation: (projectNavigation: ChromeProjectNavigation) => {
this.projectNavigation$.next(projectNavigation);
this.projectNavigationNavTreeFlattened = flattenNav(projectNavigation.navigationTree);
this.setActiveProjectNavigationNodes();
},
getProjectNavigation$: () => {
return this.projectNavigation$.asObservable();
},
getActiveNodes$: () => {
return this.activeNodes$.pipe(takeUntil(this.stop$));
},
setProjectSideNavComponent: (component: SideNavComponent | null) => {
this.customProjectSideNavComponent$.next({ current: component });
},
Expand Down Expand Up @@ -88,4 +106,41 @@ export class ProjectNavigationService {
},
};
}

private setActiveProjectNavigationNodes(_location?: Location) {
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;

// We add possible hash to the current pathname
// e.g. /app/kibana#/management
currentPathname = stripQueryParams(`${currentPathname}${location.hash}`);

const activeNodes = findActiveNodes(
currentPathname,
this.projectNavigationNavTreeFlattened,
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
// has actually changed.
const requiresUpdate = !deepEqual(activeNodes, this.activeNodes$.value);

if (!requiresUpdate) return;

this.activeNodes$.next(activeNodes);
}

private onHistoryLocationChange(location: Location) {
this.setActiveProjectNavigationNodes(location);
}

public stop() {
this.stop$.next();
this.unlistenHistory?.();
}
}
Loading