From 09cfe9437bb9c469f3be6c50cacf2d4ec2dd2af9 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Fri, 8 Mar 2024 17:49:43 +0800 Subject: [PATCH] Workspace left nav bar (#286) * revert unnecessary changes to recently viewed component refactor nav link updater so that the displayed links can be customized, this is majority required by workspace as with workspace, user would be able to config what features(plugins) then want to see for a workspace, this requires to filter out those links that are not configured by the user. Signed-off-by: Yulong Ruan * fix test snapshot Signed-off-by: Yulong Ruan * tweak comments Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan --- src/core/public/chrome/chrome_service.mock.ts | 3 +- src/core/public/chrome/index.ts | 7 +- src/core/public/chrome/nav_links/index.ts | 2 +- src/core/public/chrome/nav_links/nav_link.ts | 9 +- .../nav_links/nav_links_service.test.ts | 143 +- .../chrome/nav_links/nav_links_service.ts | 45 +- .../collapsible_nav.test.tsx.snap | 2356 ++++++++--------- .../header/__snapshots__/header.test.tsx.snap | 377 ++- .../chrome/ui/header/collapsible_nav.test.tsx | 6 +- .../chrome/ui/header/collapsible_nav.tsx | 203 +- src/core/public/chrome/ui/header/nav_link.tsx | 56 +- src/core/public/index.ts | 2 + .../workspace/workspaces_service.mock.ts | 1 + .../public/workspace/workspaces_service.ts | 1 + src/core/utils/default_app_categories.ts | 10 +- .../dashboard_listing.test.tsx.snap | 15 +- .../dashboard_top_nav.test.tsx.snap | 18 +- src/plugins/dev_tools/public/plugin.ts | 2 +- src/plugins/workspace/public/plugin.test.ts | 29 - src/plugins/workspace/public/plugin.ts | 94 +- src/plugins/workspace/public/utils.ts | 15 +- 21 files changed, 1544 insertions(+), 1850 deletions(-) diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 566a6b7095e5..7fd9ee35ba04 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -43,9 +43,8 @@ const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { getHeaderComponent: jest.fn(), navLinks: { - setNavLinks: jest.fn(), getNavLinks$: jest.fn(), - getAllNavLinks$: jest.fn(), + getLinkUpdaters$: jest.fn(), has: jest.fn(), get: jest.fn(), getAll: jest.fn(), diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 4004c2c323f9..6790e1678f9c 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -45,7 +45,12 @@ export { ChromeHelpExtensionMenuGitHubLink, } from './ui/header/header_help_menu'; export { NavType } from './ui'; -export { ChromeNavLink, ChromeNavLinks, ChromeNavLinkUpdateableFields } from './nav_links'; +export { + ChromeNavLink, + ChromeNavLinks, + ChromeNavLinkUpdateableFields, + LinksUpdater, +} from './nav_links'; export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; diff --git a/src/core/public/chrome/nav_links/index.ts b/src/core/public/chrome/nav_links/index.ts index 4be7e0be49b8..f0fad9fa28c2 100644 --- a/src/core/public/chrome/nav_links/index.ts +++ b/src/core/public/chrome/nav_links/index.ts @@ -29,4 +29,4 @@ */ export { ChromeNavLink, ChromeNavLinkUpdateableFields } from './nav_link'; -export { ChromeNavLinks, NavLinksService } from './nav_links_service'; +export { ChromeNavLinks, NavLinksService, LinksUpdater } from './nav_links_service'; diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index 19e2fd2eddab..cddd45234514 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -93,10 +93,8 @@ export interface ChromeNavLink { * Disables a link from being clickable. * * @internalRemarks - * This is used by the ML and Graph plugins. They use this field + * This is only used by the ML and Graph plugins currently. They use this field * to disable the nav link when the license is expired. - * This is also used by recently visited category in left menu - * to disable "No recently visited items". */ readonly disabled?: boolean; @@ -104,11 +102,6 @@ export interface ChromeNavLink { * Hides a link from the navigation. */ readonly hidden?: boolean; - - /** - * Links can be navigated through url. - */ - readonly externalLink?: boolean; } /** @public */ diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index d4cfb2630496..3fe2b57676e0 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -32,12 +32,18 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App } from '../../application'; import { BehaviorSubject } from 'rxjs'; -import { ChromeNavLink } from 'opensearch-dashboards/public'; const availableApps = new Map([ ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], - ['app2', { id: 'app2', order: -10, title: 'App 2', euiIconType: 'canvasApp' }], - ['app3', { id: 'app3', order: 10, title: 'App 3', icon: 'app3' }], + [ + 'app2', + { + id: 'app2', + order: -10, + title: 'App 2', + euiIconType: 'canvasApp', + }, + ], ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], ]); @@ -60,110 +66,7 @@ describe('NavLinksService', () => { start = service.start({ application: mockAppService, http: mockHttp }); }); - describe('#getAllNavLinks$()', () => { - it('does not include `chromeless` applications', async () => { - expect( - await start - .getAllNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).not.toContain('chromelessApp'); - }); - - it('sorts navLinks by `order` property', async () => { - expect( - await start - .getAllNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).toEqual(['app2', 'app1', 'app3']); - }); - - it('emits multiple values', async () => { - const navLinkIds$ = start.getAllNavLinks$().pipe(map((links) => links.map((l) => l.id))); - const emittedLinks: string[][] = []; - navLinkIds$.subscribe((r) => emittedLinks.push(r)); - start.update('app1', { href: '/foo' }); - - service.stop(); - expect(emittedLinks).toEqual([ - ['app2', 'app1', 'app3'], - ['app2', 'app1', 'app3'], - ]); - }); - - it('completes when service is stopped', async () => { - const last$ = start.getAllNavLinks$().pipe(takeLast(1)).toPromise(); - service.stop(); - await expect(last$).resolves.toBeInstanceOf(Array); - }); - }); - - describe('#getNavLinks$() when non null', () => { - // set filtered nav links, nav link with order smaller than 0 will be filtered - beforeEach(() => { - const filteredNavLinks = new Map(); - start.getAllNavLinks$().subscribe((links) => - links.forEach((link) => { - if (link.order !== undefined && link.order >= 0) { - filteredNavLinks.set(link.id, link); - } - }) - ); - start.setNavLinks(filteredNavLinks); - }); - - it('does not include `app2` applications', async () => { - expect( - await start - .getNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).not.toContain('app2'); - }); - - it('sorts navLinks by `order` property', async () => { - expect( - await start - .getNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).toEqual(['app1', 'app3']); - }); - - it('emits multiple values', async () => { - const navLinkIds$ = start.getNavLinks$().pipe(map((links) => links.map((l) => l.id))); - const emittedLinks: string[][] = []; - navLinkIds$.subscribe((r) => emittedLinks.push(r)); - start.update('app1', { href: '/foo' }); - - service.stop(); - expect(emittedLinks).toEqual([ - ['app1', 'app3'], - ['app1', 'app3'], - ]); - }); - - it('completes when service is stopped', async () => { - const last$ = start.getNavLinks$().pipe(takeLast(1)).toPromise(); - service.stop(); - await expect(last$).resolves.toBeInstanceOf(Array); - }); - }); - - describe('#getNavLinks$() when null', () => { + describe('#getNavLinks$()', () => { it('does not include `chromeless` applications', async () => { expect( await start @@ -176,19 +79,7 @@ describe('NavLinksService', () => { ).not.toContain('chromelessApp'); }); - it('include `app2` applications', async () => { - expect( - await start - .getNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).toContain('app2'); - }); - - it('sorts navLinks by `order` property', async () => { + it('sorts navlinks by `order` property', async () => { expect( await start .getNavLinks$() @@ -197,7 +88,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'app3']); + ).toEqual(['app2', 'app1']); }); it('emits multiple values', async () => { @@ -208,8 +99,8 @@ describe('NavLinksService', () => { service.stop(); expect(emittedLinks).toEqual([ - ['app2', 'app1', 'app3'], - ['app2', 'app1', 'app3'], + ['app2', 'app1'], + ['app2', 'app1'], ]); }); @@ -232,7 +123,7 @@ describe('NavLinksService', () => { describe('#getAll()', () => { it('returns a sorted array of navlinks', () => { - expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1', 'app3']); + expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1']); }); }); @@ -257,7 +148,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'app3']); + ).toEqual(['app2', 'app1']); }); it('does nothing on chromeless applications', async () => { @@ -270,7 +161,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'app3']); + ).toEqual(['app2', 'app1']); }); it('removes all other links', async () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index d4c899a57be8..58adb653527b 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -54,14 +54,11 @@ export interface ChromeNavLinks { getNavLinks$(): Observable>>; /** - * Get an observable for a sorted list of all navlinks. + * Get an observable for the current link updaters. Link updater is used to modify the + * nav links, for example, filter the nav links or update a specific nav link's properties. + * {@link LinksUpdater} */ - getAllNavLinks$(): Observable>>; - - /** - * Set navlinks. - */ - setNavLinks(navLinks: ReadonlyMap): void; + getLinkUpdaters$(): BehaviorSubject; /** * Get the state of a navlink at this point in time. @@ -122,7 +119,7 @@ export interface ChromeNavLinks { getForceAppSwitcherNavigation$(): Observable; } -type LinksUpdater = (navLinks: Map) => Map; +export type LinksUpdater = (navLinks: Map) => Map; export class NavLinksService { private readonly stop$ = new ReplaySubject(1); @@ -142,10 +139,7 @@ export class NavLinksService { // manual link modifications to be able to re-apply then after every // availableApps$ changes. const linkUpdaters$ = new BehaviorSubject([]); - const displayedNavLinks$ = new BehaviorSubject | undefined>( - undefined - ); - const allNavLinks$ = new BehaviorSubject>(new Map()); + const navLinks$ = new BehaviorSubject>(new Map()); combineLatest([appLinks$, linkUpdaters$]) .pipe( @@ -154,40 +148,31 @@ export class NavLinksService { }) ) .subscribe((navLinks) => { - allNavLinks$.next(navLinks); + navLinks$.next(navLinks); }); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { getNavLinks$: () => { - return combineLatest([allNavLinks$, displayedNavLinks$]).pipe( - map(([allNavLinks, displayedNavLinks]) => - displayedNavLinks === undefined ? sortLinks(allNavLinks) : sortLinks(displayedNavLinks) - ), - takeUntil(this.stop$) - ); - }, - - setNavLinks: (navLinks: ReadonlyMap) => { - displayedNavLinks$.next(navLinks); + return navLinks$.pipe(map(sortNavLinks), takeUntil(this.stop$)); }, - getAllNavLinks$: () => { - return allNavLinks$.pipe(map(sortLinks), takeUntil(this.stop$)); + getLinkUpdaters$: () => { + return linkUpdaters$; }, get(id: string) { - const link = allNavLinks$.value.get(id); + const link = navLinks$.value.get(id); return link && link.properties; }, getAll() { - return sortLinks(allNavLinks$.value); + return sortNavLinks(navLinks$.value); }, has(id: string) { - return allNavLinks$.value.has(id); + return navLinks$.value.has(id); }, showOnly(id: string) { @@ -235,9 +220,9 @@ export class NavLinksService { } } -function sortLinks(links: ReadonlyMap) { +function sortNavLinks(navLinks: ReadonlyMap) { return sortBy( - [...links.values()].map((link) => ('properties' in link ? link.properties : link)), + [...navLinks.values()].map((link) => link.properties), 'order' ); } diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index f3fa72c923ff..f0cd8afddfa3 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -122,7 +122,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` } } homeHref="/" - id="collapsible-nav" + id="collapsibe-nav" isLocked={false} isNavOpen={true} logos={ @@ -242,7 +242,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "managementApp", "id": "management", "label": "Management", - "order": 6000, + "order": 5000, }, "data-test-subj": "monitoring", "href": "monitoring", @@ -422,7 +422,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` - -
+ + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - - - - - - - -

- Recently Visited -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="clock" - data-test-subj="collapsibleNavGroup-recentlyVisited" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- +
+ + + +
+
+ +
+
-
-
+ -
- - - -
-
+ recent 2 + + + + + +
- +
-
- - + +
+
+ + + +
+
+ +
- -
+ + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - - - - - - - -

- Recently Visited -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="clock" - data-test-subj="collapsibleNavGroup-recentlyVisited" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- +
+ + + +
+
+ +
+
-
-
+
- -
    - -
  • - -
  • -
    -
-
+

+ No recently viewed items +

+
+
-
+
- +
-
-
-
+ +
+
+ +
+ +
+
+ +
- -
+ + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - - - - - - - -

- Recently Visited -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="clock" - data-test-subj="collapsibleNavGroup-recentlyVisited" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- +
+ + + +
+
+ +
+
-
-
+ -
- - - -
-
+ recent + + + + + +
- +
-
- - + +
+
+ + + +
+
+ +
- -
+ + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - - - - - - - -

- Recently Visited -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="clock" - data-test-subj="collapsibleNavGroup-recentlyVisited" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
+ + + - -
-
+
+
- +

+ Recently viewed +

+ +
+
+
+ + + +
+
+ +
+
+
+ +
-
+ recent + + + + + +
-
+
-
- - + +
+
+
+
+ +
+
+ +
- -
+ + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - - - - - - - -

- Recently Visited -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="clock" - data-test-subj="collapsibleNavGroup-recentlyVisited" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- +
+ + + +
+
+ +
+
-
-
+ -
- - - -
-
+ recent + + + + + +
- +
-
- - + +
+
+ +
+ +
+
+ +
- -
+ + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - - - - - - - -

- Recently Visited -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="clock" - data-test-subj="collapsibleNavGroup-recentlyVisited" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- +
+ + + +
+
+ +
+
-
-
+ -
- - - -
-
+ recent + + + + + +
- +
-
- - + +
+
+ +
+ +
+
+ +
- -
+ + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - - - - - - - -

- Recently Visited -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="clock" - data-test-subj="collapsibleNavGroup-recentlyVisited" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- +
+ + + +
+
+ +
+
-
- + dashboard + + + + + +
- +
-
- - + +
+
+ +
+ +
+
+ +
{ ); expectShownNavLinksCount(component, 3); clickGroup(component, 'opensearchDashboards'); - clickGroup(component, 'recentlyVisited'); + clickGroup(component, 'recentlyViewed'); expectShownNavLinksCount(component, 1); component.setProps({ isNavOpen: false }); expectNavIsClosed(component); @@ -205,7 +205,7 @@ describe('CollapsibleNav', () => { }, }); - component.find('[data-test-subj="collapsibleNavGroup-recentlyVisited"] a').simulate('click'); + component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click'); expect(onClose.callCount).toEqual(1); expectNavIsClosed(component); component.setProps({ isNavOpen: true }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 3ac2575c7faa..9c9223aa501b 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -37,27 +37,22 @@ import { EuiListGroup, EuiListGroupItem, EuiShowFor, + EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; import React, { Fragment, useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; -import { DEFAULT_APP_CATEGORIES } from '../../../../utils'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; -import { InternalApplicationStart } from '../../../application'; +import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import type { Logos } from '../../../../common'; -import { - createEuiListItem, - createRecentChromeNavLink, - emptyRecentlyVisited, - CollapsibleNavLink, -} from './nav_link'; +import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; +import type { Logos } from '../../../../common/types'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -67,28 +62,14 @@ function getAllCategories(allCategorizedLinks: Record, categoryDictionary: ReturnType -): Array { - // uncategorized links and categories are ranked according the order - // if order is not defined, categories will be placed above uncategorized links - const categories = Object.values(categoryDictionary).filter( - (category) => category !== undefined - ) as AppCategory[]; - const uncategorizedLinksWithOrder = uncategorizedLinks.filter((link) => link.order !== null); - const uncategorizedLinksWithoutOrder = uncategorizedLinks.filter((link) => link.order === null); - const categoriesWithOrder = categories.filter((category) => category.order !== null); - const categoriesWithoutOrder = categories.filter((category) => category.order === null); - const sortedLinksAndCategories = sortBy( - [...uncategorizedLinksWithOrder, ...categoriesWithOrder], - 'order' +) { + return sortBy( + Object.keys(mainCategories), + (categoryName) => categoryDictionary[categoryName]?.order ); - return [ - ...sortedLinksAndCategories, - ...categoriesWithoutOrder, - ...uncategorizedLinksWithoutOrder, - ]; } function getCategoryLocalStorageKey(id: string) { @@ -140,30 +121,15 @@ export function CollapsibleNav({ ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); - let customNavLink = useObservable(observables.customNavLink$, undefined); - if (customNavLink) { - customNavLink = { ...customNavLink, externalLink: true }; - } const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); - const allNavLinks: CollapsibleNavLink[] = [...navLinks]; - if (recentlyAccessed.length) { - allNavLinks.push( - ...recentlyAccessed.map((link) => createRecentChromeNavLink(link, navLinks, basePath)) - ); - } else { - allNavLinks.push(emptyRecentlyVisited); - } + const customNavLink = useObservable(observables.customNavLink$, undefined); const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); - const groupedNavLinks = groupBy(allNavLinks, (link) => link?.category?.id); - const { undefined: uncategorizedLinks = [], ...allCategorizedLinks } = groupedNavLinks; + const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); + const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); - const sortedLinksAndCategories = getSortedLinksAndCategories( - uncategorizedLinks, - categoryDictionary - ); - - const readyForEUI = (link: CollapsibleNavLink, needsIcon: boolean = false) => { + const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); + const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, appId, @@ -203,6 +169,7 @@ export function CollapsibleNav({ navigateToApp, dataTestSubj: 'collapsibleNavCustomNavLink', onClick: closeNav, + externalLink: true, }), ]} maxWidth="none" @@ -217,53 +184,103 @@ export function CollapsibleNav({ )} + {/* Recently viewed */} + setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} + data-test-subj="collapsibleNavGroup-recentlyViewed" + > + {recentlyAccessed.length > 0 ? ( + { + // TODO #64541 + // Can remove icon from recent links completely + const { iconType, onClick, ...hydratedLink } = createRecentNavLink( + link, + navLinks, + basePath, + navigateToUrl + ); + + return { + ...hydratedLink, + 'data-test-subj': 'collapsibleNavAppLink--recent', + onClick: (event) => { + if (!isModifiedOrPrevented(event)) { + closeNav(); + onClick(event); + } + }, + }; + })} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + className="osdCollapsibleNav__recentsListGroup" + /> + ) : ( + +

+ {i18n.translate('core.ui.EmptyRecentlyViewed', { + defaultMessage: 'No recently viewed items', + })} +

+
+ )} +
+ + + - {sortedLinksAndCategories.map((item, i) => { - if (!('href' in item)) { - // CollapsibleNavLink has href property, while AppCategory does not have - const category = item; - const opensearchLinkLogo = - category.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id - ? logos.Mark.url - : category.euiIconType; + {/* OpenSearchDashboards, Observability, Security, and Management sections */} + {orderedCategories.map((categoryName) => { + const category = categoryDictionary[categoryName]!; + const opensearchLinkLogo = + category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; - return ( - - setIsCategoryOpen(category.id, isCategoryOpen, storage) - } - data-test-subj={`collapsibleNavGroup-${category.id}`} - data-test-opensearch-logo={opensearchLinkLogo} - > - readyForEUI(link))} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - /> - - ); - } else { - return ( - - - - - - ); - } + return ( + setIsCategoryOpen(category.id, isCategoryOpen, storage)} + data-test-subj={`collapsibleNavGroup-${category.id}`} + data-test-opensearch-logo={opensearchLinkLogo} + > + readyForEUI(link))} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + + ); })} + {/* Things with no category (largely for custom plugins) */} + {unknowns.map((link, i) => ( + + + + + + ))} + {/* Docking button only for larger screens that can support it*/} diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 55482708e09f..38d31dbc09c9 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,8 +31,9 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { AppCategory, ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; +import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; export const isModifiedOrPrevented = (event: React.MouseEvent) => @@ -46,9 +47,8 @@ const aliasedApps: { [key: string]: string[] } = { export const isActiveNavLink = (appId: string | undefined, linkId: string): boolean => !!(appId === linkId || aliasedApps[linkId]?.includes(appId || '')); -export type CollapsibleNavLink = ChromeNavLink | RecentNavLink; interface Props { - link: CollapsibleNavLink; + link: ChromeNavLink; appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; @@ -68,8 +68,9 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, + externalLink = false, }: Props) { - const { href, id, title, disabled, euiIconType, icon, tooltip, externalLink } = link; + const { href, id, title, disabled, euiIconType, icon, tooltip } = link; return { label: tooltip ?? title, @@ -100,16 +101,14 @@ export function createEuiListItem({ }; } -export type RecentNavLink = Omit; - -const recentlyVisitedCategory: AppCategory = { - id: 'recentlyVisited', - label: i18n.translate('core.ui.recentlyVisited.label', { - defaultMessage: 'Recently Visited', - }), - order: 0, - euiIconType: 'clock', -}; +export interface RecentNavLink { + href: string; + label: string; + title: string; + 'aria-label': string; + iconType?: string; + onClick: React.MouseEventHandler; +} /** * Add saved object type info to recently links @@ -121,10 +120,11 @@ const recentlyVisitedCategory: AppCategory = { * @param navLinks * @param basePath */ -export function createRecentChromeNavLink( +export function createRecentNavLink( recentLink: ChromeRecentlyAccessedHistoryItem, navLinks: ChromeNavLink[], - basePath: HttpStart['basePath'] + basePath: HttpStart['basePath'], + navigateToUrl: InternalApplicationStart['navigateToUrl'] ): RecentNavLink { const { link, label } = recentLink; const href = relativeToAbsolute(basePath.prepend(link)); @@ -143,20 +143,16 @@ export function createRecentChromeNavLink( return { href, - id: recentLink.id, - externalLink: true, - category: recentlyVisitedCategory, + label, title: titleAndAriaLabel, + 'aria-label': titleAndAriaLabel, + iconType: navLink?.euiIconType, + /* Use href and onClick to support "open in new tab" and SPA navigation in the same link */ + onClick(event: React.MouseEvent) { + if (event.button === 0 && !isModifiedOrPrevented(event)) { + event.preventDefault(); + navigateToUrl(href); + } + }, }; } - -// As emptyRecentlyVisited is disabled, values for id, href and baseUrl does not affect -export const emptyRecentlyVisited: RecentNavLink = { - id: '', - href: '', - disabled: true, - category: recentlyVisitedCategory, - title: i18n.translate('core.ui.EmptyRecentlyVisited', { - defaultMessage: 'No recently visited items', - }), -}; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 50e3621b0ab8..271d68f4cf12 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -67,6 +67,7 @@ import { ChromeStart, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + LinksUpdater, NavType, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; @@ -324,6 +325,7 @@ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, ChromeStart, + LinksUpdater, IContextContainer, HandlerFunction, HandlerContextType, diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index 2c81cd888916..7f38a898fea1 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -5,6 +5,7 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@osd/utility-types'; + import { WorkspacesService } from './workspaces_service'; import { WorkspaceObject } from '..'; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index d235f3322571..674ec6a8d19a 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -5,6 +5,7 @@ import { BehaviorSubject, combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; + import { CoreService, WorkspaceObject } from '../../types'; interface WorkspaceObservables { diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index e6e53f9101ed..3c0920624e1b 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -65,20 +65,12 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze order: 4000, euiIconType: 'logoSecurity', }, - openSearchFeatures: { - id: 'openSearchFeatures', - label: i18n.translate('core.ui.openSearchFeaturesNavList.label', { - defaultMessage: 'OpenSearch Features', - }), - order: 5000, - euiIconType: 'folderClosed', - }, management: { id: 'management', label: i18n.translate('core.ui.managementNavList.label', { defaultMessage: 'Management', }), - order: 6000, + order: 5000, euiIconType: 'managementApp', }, }); diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index a00b1e1429f4..0a259ca26c7c 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -223,11 +223,10 @@ exports[`dashboard listing hideWriteControls 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1360,11 +1359,10 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2558,11 +2556,10 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3756,11 +3753,10 @@ exports[`dashboard listing renders table rows 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4954,11 +4950,10 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 1fcd1c30b40b..4c5945a3d266 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -211,11 +211,10 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1173,11 +1172,10 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2135,11 +2133,10 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3097,11 +3094,10 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4059,11 +4055,10 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5021,11 +5016,10 @@ exports[`Dashboard top nav render with all components 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index e22f12b9234a..bb0b6ee1d981 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -85,7 +85,7 @@ export class DevToolsPlugin implements Plugin { icon: '/ui/logos/opensearch_mark.svg', /* the order of dev tools, it shows as last item of management section */ order: 9070, - category: DEFAULT_APP_CATEGORIES.openSearchFeatures, + category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { const { element, history } = params; element.classList.add('devAppWrapper'); diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 04b891c87841..8e494d9f0236 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,9 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BehaviorSubject, of } from 'rxjs'; import { waitFor } from '@testing-library/dom'; -import { ChromeNavLink } from 'opensearch-dashboards/public'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; @@ -115,36 +113,9 @@ describe('Workspace plugin', () => { windowSpy.mockRestore(); }); - it('#start filter nav links according to workspace feature', () => { - const workspacePlugin = new WorkspacePlugin(); - const coreStart = coreMock.createStart(); - const navLinksService = coreStart.chrome.navLinks; - const devToolsNavLink = { - id: 'dev_tools', - category: { id: 'management', label: 'Management' }, - }; - const discoverNavLink = { - id: 'discover', - category: { id: 'opensearchDashboards', label: 'Library' }, - }; - const workspace = { - id: 'test', - name: 'test', - features: ['dev_tools'], - }; - const allNavLinks = of([devToolsNavLink, discoverNavLink] as ChromeNavLink[]); - const filteredNavLinksMap = new Map(); - filteredNavLinksMap.set(devToolsNavLink.id, devToolsNavLink as ChromeNavLink); - navLinksService.getAllNavLinks$.mockReturnValue(allNavLinks); - coreStart.workspaces.currentWorkspace$.next(workspace); - workspacePlugin.start(coreStart); - expect(navLinksService.setNavLinks).toHaveBeenCalledWith(filteredNavLinksMap); - }); - it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { const workspacePlugin = new WorkspacePlugin(); const coreStart = coreMock.createStart(); - coreStart.chrome.navLinks.getAllNavLinks$.mockReturnValueOnce(new BehaviorSubject([])); workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 62112e194950..09015cbf4a1f 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -2,78 +2,79 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { combineLatest } from 'rxjs'; + import type { Subscription } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { i18n } from '@osd/i18n'; import { featureMatchesConfig } from './utils'; import { AppMountParameters, AppNavLinkStatus, - ChromeNavLink, CoreSetup, CoreStart, + LinksUpdater, Plugin, WorkspaceObject, - DEFAULT_APP_CATEGORIES, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +import { NavLinkWrapper } from '../../../core/public/chrome/nav_links/nav_link'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; export class WorkspacePlugin implements Plugin<{}, {}> { private coreStart?: CoreStart; + private currentWorkspaceIdSubscription?: Subscription; private currentWorkspaceSubscription?: Subscription; private getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } - private filterByWorkspace(workspace: WorkspaceObject | null, allNavLinks: ChromeNavLink[]) { - if (!workspace) return allNavLinks; - const features = workspace.features ?? ['*']; - return allNavLinks.filter(featureMatchesConfig(features)); - } + /** + * Filter the nav links based on the feature configuration of workspace + */ + private filterByWorkspace(allNavLinks: NavLinkWrapper[], workspace: WorkspaceObject | null) { + if (!workspace || !workspace.features) return allNavLinks; - private filterNavLinks(core: CoreStart) { - const navLinksService = core.chrome.navLinks; - const allNavLinks$ = navLinksService.getAllNavLinks$(); - const currentWorkspace$ = core.workspaces.currentWorkspace$; - combineLatest([ - allNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), - currentWorkspace$, - ]).subscribe(([allNavLinks, currentWorkspace]) => { - const filteredNavLinks = this.filterByWorkspace(currentWorkspace, allNavLinks); - const navLinks = new Map(); - filteredNavLinks.forEach((chromeNavLink) => { - navLinks.set(chromeNavLink.id, chromeNavLink); - }); - navLinksService.setNavLinks(navLinks); - }); + const featureFilter = featureMatchesConfig(workspace.features); + return allNavLinks.filter((linkWrapper) => featureFilter(linkWrapper.properties)); } /** - * The category "Opensearch Dashboards" needs to be renamed as "Library" - * when workspace feature flag is on, we need to do it here and generate - * a new item without polluting the original ChromeNavLink. + * Filter nav links by the current workspace, once the current workspace change, the nav links(left nav bar) + * should also be updated according to the configured features of the current workspace */ - private changeCategoryNameByWorkspaceFeatureFlag(chromeLinks: ChromeNavLink[]): ChromeNavLink[] { - return chromeLinks.map((item) => { - if (item.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { - return { - ...item, - category: { - ...item.category, - label: i18n.translate('core.ui.libraryNavList.label', { - defaultMessage: 'Library', - }), - }, - }; - } - return item; + private filterNavLinks(core: CoreStart) { + const currentWorkspace$ = core.workspaces.currentWorkspace$; + let filterLinksByWorkspace: LinksUpdater; + + this.currentWorkspaceSubscription?.unsubscribe(); + this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { + const linkUpdaters$ = core.chrome.navLinks.getLinkUpdaters$(); + let linkUpdaters = linkUpdaters$.value; + + /** + * It should only have one link filter exist based on the current workspace at a given time + * So we need to filter out previous workspace link filter before adding new one after changing workspace + */ + linkUpdaters = linkUpdaters.filter((updater) => updater !== filterLinksByWorkspace); + + /** + * Whenever workspace changed, this function will filter out those links that should not + * be displayed. For example, some workspace may not have Observability features configured, in such case, + * the nav links of Observability features should not be displayed in left nav bar + */ + filterLinksByWorkspace = (navLinks) => { + const filteredNavLinks = this.filterByWorkspace([...navLinks.values()], currentWorkspace); + const newNavLinks = new Map(); + filteredNavLinks.forEach((chromeNavLink) => { + newNavLinks.set(chromeNavLink.id, chromeNavLink); + }); + return newNavLinks; + }; + + linkUpdaters$.next([...linkUpdaters, filterLinksByWorkspace]); }); } @@ -166,14 +167,15 @@ export class WorkspacePlugin implements Plugin<{}, {}> { public start(core: CoreStart) { this.coreStart = core; - this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); - if (core) { - this.filterNavLinks(core); - } + this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); + + // When starts, filter the nav links based on the current workspace + this.filterNavLinks(core); return {}; } public stop() { + this.currentWorkspaceIdSubscription?.unsubscribe(); this.currentWorkspaceSubscription?.unsubscribe(); } } diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index f7c59dbfc53c..444b3aadadf3 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -6,14 +6,15 @@ import { AppCategory } from '../../../core/public'; /** - * Given a list of feature config, check if a feature matches config + * Checks if a given feature matches the provided feature configuration. + * * Rules: - * 1. `*` matches any feature - * 2. config starts with `@` matches category, for example, @management matches any feature of `management` category - * 3. to match a specific feature, just use the feature id, such as `discover` - * 4. to exclude feature or category, use `!@management` or `!discover` - * 5. the order of featureConfig array matters, from left to right, the later config override the previous config, - * for example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management' + * 1. `*` matches any feature. + * 2. Config starts with `@` matches category, for example, @management matches any feature of `management` category, + * 3. To match a specific feature, use the feature id, such as `discover`, + * 4. To exclude a feature or category, prepend with `!`, e.g., `!discover` or `!@management`. + * 5. The order of featureConfig array matters. From left to right, later configs override the previous ones. + * For example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management'. */ export const featureMatchesConfig = (featureConfigs: string[]) => ({ id,