From 5b809afa0f7a64b78e3cc79df73667b62ce54ba9 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 17:16:22 +0800 Subject: [PATCH] [navigation] display workspace picker content when outside workspace and nav group (#7716) (#7880) (#7882) * feat: show workspace picker content in left nav * fix: bootstrap error * fix: unit test error * feat: finish picker content * feat: finish picker content * feat: only register index patterns to settings and setup when workspace is disabled * fix: unit test * feat: put discover 2.0 behind discover * feat: add coverage * feat: improve test coverage * feat: merge conflict * feat: optimize code based on comment * feat: optimize code based on comment * feat: optimize filter code * feat: update --------- (cherry picked from commit 56fc8f4057c1d6b3e58f4b77394f9bf8c4a9447c) (cherry picked from commit b02dd2b725f75403af60e64d1bbc5815904454f8) Signed-off-by: SuZhou-Joe Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] --- changelogs/fragments/7716.yml | 2 + .../nav_group/nav_group_service.test.ts | 50 ++- .../chrome/nav_group/nav_group_service.ts | 46 ++- ...ollapsible_nav_group_enabled.test.tsx.snap | 203 ---------- .../collapsible_nav_groups.test.tsx.snap | 204 ++++++++++ .../header/__snapshots__/header.test.tsx.snap | 370 ------------------ .../collapsible_nav_group_enabled.test.tsx | 108 ++--- .../header/collapsible_nav_group_enabled.tsx | 327 ++++++---------- ...collapsible_nav_group_enabled_top.test.tsx | 8 +- .../collapsible_nav_group_enabled_top.tsx | 4 +- .../ui/header/collapsible_nav_groups.test.tsx | 79 ++++ .../ui/header/collapsible_nav_groups.tsx | 150 +++++++ .../public/chrome/ui/header/header.test.tsx | 18 - src/core/public/chrome/ui/header/header.tsx | 23 +- src/core/public/chrome/utils.ts | 11 +- src/plugins/data_explorer/public/plugin.ts | 36 ++ src/plugins/home/public/plugin.ts | 1 - .../public/plugin.test.ts | 5 +- .../index_pattern_management/public/plugin.ts | 24 +- .../workspace_menu/workspace_menu.tsx | 84 +--- .../workspace_picker_content.tsx | 111 ++++++ src/plugins/workspace/public/plugin.test.ts | 16 + src/plugins/workspace/public/plugin.ts | 29 +- 23 files changed, 907 insertions(+), 1002 deletions(-) create mode 100644 changelogs/fragments/7716.yml create mode 100644 src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_groups.test.tsx.snap create mode 100644 src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx create mode 100644 src/core/public/chrome/ui/header/collapsible_nav_groups.tsx create mode 100644 src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx diff --git a/changelogs/fragments/7716.yml b/changelogs/fragments/7716.yml new file mode 100644 index 000000000000..d1b91ff51d89 --- /dev/null +++ b/changelogs/fragments/7716.yml @@ -0,0 +1,2 @@ +feat: +- Display workspace picker content when outside workspace ([#7716](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7716)) \ No newline at end of file diff --git a/src/core/public/chrome/nav_group/nav_group_service.test.ts b/src/core/public/chrome/nav_group/nav_group_service.test.ts index 91a6b2a0a6de..06712058fb23 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.test.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.test.ts @@ -14,7 +14,7 @@ import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.moc import { NavLinksService } from '../nav_links'; import { applicationServiceMock, httpServiceMock, workspacesServiceMock } from '../../mocks'; import { AppCategory } from 'opensearch-dashboards/public'; -import { DEFAULT_NAV_GROUPS } from '../../'; +import { DEFAULT_NAV_GROUPS, NavGroupStatus, ALL_USE_CASE_ID } from '../../'; import { ChromeBreadcrumbEnricher } from '../chrome_service'; const mockedGroupFoo = { @@ -381,7 +381,50 @@ describe('ChromeNavGroupService#start()', () => { expect(currentNavGroup?.title).toEqual('barGroupTitle'); }); - it('should erase current nav group if application is home', async () => { + it('should be able to find the right nav group when visible nav group is all', async () => { + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const navGroupEnabled$ = new Rx.BehaviorSubject(true); + uiSettings.get$.mockImplementation(() => navGroupEnabled$); + + const chromeNavGroupService = new ChromeNavGroupService(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup( + { + id: ALL_USE_CASE_ID, + title: 'fooGroupTitle', + description: 'foo description', + }, + [mockedNavLinkFoo] + ); + + chromeNavGroupServiceSetup.addNavLinksToGroup( + { + id: 'bar-group', + title: 'barGroupTitle', + description: 'bar description', + status: NavGroupStatus.Hidden, + }, + [mockedNavLinkFoo, mockedNavLinkBar] + ); + + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + application: mockedApplicationService, + breadcrumbsEnricher$: new Rx.BehaviorSubject(undefined), + workspaces: workspacesServiceMock.createStartContract(), + }); + mockedApplicationService.navigateToApp(mockedNavLinkBar.id); + + const currentNavGroup = await chromeNavGroupServiceStart + .getCurrentNavGroup$() + .pipe(first()) + .toPromise(); + + expect(currentNavGroup?.id).toEqual('bar-group'); + }); + + it('should erase current nav group if application can not be found in any of the visible nav groups', async () => { const uiSettings = uiSettingsServiceMock.createSetupContract(); const navGroupEnabled$ = new Rx.BehaviorSubject(true); uiSettings.get$.mockImplementation(() => navGroupEnabled$); @@ -403,6 +446,7 @@ describe('ChromeNavGroupService#start()', () => { id: 'bar-group', title: 'barGroupTitle', description: 'bar description', + status: NavGroupStatus.Hidden, }, [mockedNavLinkFoo, mockedNavLinkBar] ); @@ -416,7 +460,7 @@ describe('ChromeNavGroupService#start()', () => { chromeNavGroupServiceStart.setCurrentNavGroup('foo-group'); - mockedApplicationService.navigateToApp('home'); + mockedApplicationService.navigateToApp(mockedNavLinkBar.id); const currentNavGroup = await chromeNavGroupServiceStart .getCurrentNavGroup$() .pipe(first()) diff --git a/src/core/public/chrome/nav_group/nav_group_service.ts b/src/core/public/chrome/nav_group/nav_group_service.ts index 88689d88f2fc..729239081b42 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.ts @@ -14,11 +14,16 @@ import { import { map, switchMap, takeUntil } from 'rxjs/operators'; import { i18n } from '@osd/i18n'; import { IUiSettingsClient } from '../../ui_settings'; -import { fulfillRegistrationLinksToChromeNavLinks, getSortedNavLinks } from '../utils'; +import { + fulfillRegistrationLinksToChromeNavLinks, + getSortedNavLinks, + getVisibleUseCases, +} from '../utils'; import { ChromeNavLinks } from '../nav_links'; import { InternalApplicationStart } from '../../application'; import { NavGroupStatus } from '../../../../core/types'; import { ChromeBreadcrumb, ChromeBreadcrumbEnricher } from '../chrome_service'; +import { ALL_USE_CASE_ID } from '../../../utils'; export const CURRENT_NAV_GROUP_ID = 'core.chrome.currentNavGroupId'; @@ -211,7 +216,7 @@ export class ChromeNavGroupService { const setCurrentNavGroup = (navGroupId: string | undefined) => { const navGroup = navGroupId ? this.navGroupsMap$.getValue()[navGroupId] : undefined; - if (navGroup && navGroup.status !== NavGroupStatus.Hidden) { + if (navGroup) { this.currentNavGroup$.next(navGroup); sessionStorage.setItem(CURRENT_NAV_GROUP_ID, navGroup.id); } else { @@ -254,28 +259,37 @@ export class ChromeNavGroupService { application.currentAppId$, this.getSortedNavGroupsMap$(), ]).subscribe(([appId, navGroupMap]) => { - if (appId === 'home') { - setCurrentNavGroup(undefined); - return; - } if (appId && navGroupMap) { const appIdNavGroupMap = new Map>(); - // iterate navGroupMap - Object.keys(navGroupMap) + const visibleUseCases = getVisibleUseCases(navGroupMap); + const mapAppIdToNavGroup = (navGroup: NavGroupItemInMap) => { + navGroup.navLinks.forEach((navLink) => { + const navLinkId = navLink.id; + const navGroupSet = appIdNavGroupMap.get(navLinkId) || new Set(); + navGroupSet.add(navGroup.id); + appIdNavGroupMap.set(navLinkId, navGroupSet); + }); + }; + if (visibleUseCases.length === 1 && visibleUseCases[0].id === ALL_USE_CASE_ID) { + // If the only visible use case is all use case + // All the other nav groups will be visible because all use case can visit all of the nav groups. + Object.values(navGroupMap).forEach((navGroup) => mapAppIdToNavGroup(navGroup)); + } else { // Nav group of Hidden status should be filtered out when counting navGroups the currentApp belongs to - .filter((navGroupId) => navGroupMap[navGroupId].status !== NavGroupStatus.Hidden) - .forEach((navGroupId) => { - navGroupMap[navGroupId].navLinks.forEach((navLink) => { - const navLinkId = navLink.id; - const navGroupSet = appIdNavGroupMap.get(navLinkId) || new Set(); - navGroupSet.add(navGroupId); - appIdNavGroupMap.set(navLinkId, navGroupSet); - }); + Object.values(navGroupMap).forEach((navGroup) => { + if (navGroup.status === NavGroupStatus.Hidden) { + return; + } + + mapAppIdToNavGroup(navGroup); }); + } const navGroups = appIdNavGroupMap.get(appId); if (navGroups && navGroups.size === 1) { setCurrentNavGroup(navGroups.values().next().value); + } else if (!navGroups) { + setCurrentNavGroup(undefined); } } }); diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap index 7f896674faac..55554ffabf70 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap @@ -412,206 +412,3 @@ exports[` should show all use case when current na `; - -exports[` should render correctly 1`] = ` -
-
- -
-
-`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_groups.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_groups.test.tsx.snap new file mode 100644 index 000000000000..3d923a49dd80 --- /dev/null +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_groups.test.tsx.snap @@ -0,0 +1,204 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render correctly 1`] = ` +
+
+ +
+
+`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 168407a7de4f..88f06ee9edcb 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -160,43 +160,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -6358,43 +6321,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -7346,43 +7272,6 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -9331,43 +9220,6 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -9853,43 +9705,6 @@ exports[`Header renders condensed header 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -14822,43 +14637,6 @@ exports[`Header renders condensed header 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -15307,43 +15085,6 @@ exports[`Header renders page header with application title 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -18669,43 +18410,6 @@ exports[`Header renders page header with application title 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -19191,43 +18895,6 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -24160,43 +23827,6 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx index 98b2ade3e257..418dca694e21 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx @@ -10,13 +10,17 @@ import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; import { CollapsibleNavGroupEnabled, CollapsibleNavGroupEnabledProps, - NavGroups, } from './collapsible_nav_group_enabled'; import { ChromeNavLink } from '../../nav_links'; -import { ChromeRegistrationNavLink, NavGroupItemInMap } from '../../nav_group'; +import { NavGroupItemInMap } from '../../nav_group'; import { httpServiceMock } from '../../../mocks'; import { getLogos } from '../../../../common'; -import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS, WorkspaceObject } from '../../../../public'; +import { + ALL_USE_CASE_ID, + DEFAULT_APP_CATEGORIES, + DEFAULT_NAV_GROUPS, + WorkspaceObject, +} from '../../../../public'; import { capabilitiesServiceMock } from '../../../application/capabilities/capabilities_service.mock'; jest.mock('./collapsible_nav_group_enabled_top', () => ({ @@ -25,75 +29,6 @@ jest.mock('./collapsible_nav_group_enabled_top', () => ({ const mockBasePath = httpServiceMock.createSetupContract({ basePath: '/test' }).basePath; -describe('', () => { - const getMockedNavLink = ( - navLink: Partial - ): ChromeNavLink & ChromeRegistrationNavLink => ({ - baseUrl: '', - href: '', - id: '', - title: '', - ...navLink, - }); - it('should render correctly', () => { - const navigateToApp = jest.fn(); - const onNavItemClick = jest.fn(); - const { container, getByTestId, queryByTestId } = render( - - ); - expect(container).toMatchSnapshot(); - expect(container.querySelectorAll('.nav-link-item-btn').length).toEqual(5); - fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); - expect(navigateToApp).toBeCalledTimes(0); - // The accordion is collapsed - expect(queryByTestId('collapsibleNavAppLink-subLink')).toBeNull(); - - // Expand the accordion - fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); - fireEvent.click(getByTestId('collapsibleNavAppLink-subLink')); - expect(navigateToApp).toBeCalledWith('subLink'); - }); -}); - const defaultNavGroupMap = { [ALL_USE_CASE_ID]: { ...DEFAULT_NAV_GROUPS[ALL_USE_CASE_ID], @@ -342,4 +277,33 @@ describe('', () => { expect(getByTestId('collapsibleNavAppLink-link-in-essentials')).toBeInTheDocument(); expect(queryAllByTestId('collapsibleNavAppLink-link-in-all').length).toEqual(1); }); + + it('should render manage category when in all use case if workspace disabled', () => { + const props = mockProps({ + currentNavGroupId: ALL_USE_CASE_ID, + navGroupsMap: { + ...defaultNavGroupMap, + [DEFAULT_NAV_GROUPS.dataAdministration.id]: { + ...DEFAULT_NAV_GROUPS.dataAdministration, + navLinks: [ + { + id: 'link-in-dataAdministration', + title: 'link-in-dataAdministration', + }, + ], + }, + }, + navLinks: [ + { + id: 'link-in-dataAdministration', + title: 'link-in-dataAdministration', + baseUrl: '', + href: '', + }, + ], + }); + const { getByText } = render(); + // Should render manage category + expect(getByText(DEFAULT_APP_CATEGORIES.manage.label)).toBeInTheDocument(); + }); }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx index d196e760e43b..d8867d973d7d 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -4,15 +4,7 @@ */ import './collapsible_nav_group_enabled.scss'; -import { - EuiFlexItem, - EuiFlyout, - EuiSideNavItemType, - EuiSideNav, - EuiPanel, - EuiText, - EuiHorizontalRule, -} from '@elastic/eui'; +import { EuiFlyout, EuiPanel, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React, { useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; @@ -20,7 +12,7 @@ import * as Rx from 'rxjs'; import classNames from 'classnames'; import { WorkspacesStart } from 'src/core/public/workspace'; import { ChromeNavControl, ChromeNavLink } from '../..'; -import { AppCategory, NavGroupStatus } from '../../../../types'; +import { AppCategory, NavGroupType } from '../../../../types'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; @@ -31,18 +23,15 @@ import { ChromeRegistrationNavLink, NavGroupItemInMap, } from '../../nav_group'; -import { - fulfillRegistrationLinksToChromeNavLinks, - getOrderedLinksOrCategories, - LinkItem, - LinkItemType, -} from '../../utils'; +import { fulfillRegistrationLinksToChromeNavLinks, getVisibleUseCases, sortBy } from '../../utils'; import { ALL_USE_CASE_ID, DEFAULT_APP_CATEGORIES } from '../../../../../core/utils'; import { CollapsibleNavTop } from './collapsible_nav_group_enabled_top'; import { HeaderNavControls } from './header_nav_controls'; +import { NavGroups } from './collapsible_nav_groups'; export interface CollapsibleNavGroupEnabledProps { appId$: InternalApplicationStart['currentAppId$']; + collapsibleNavHeaderRender?: () => JSX.Element | null; basePath: HttpStart['basePath']; id: string; isLocked: boolean; @@ -63,147 +52,16 @@ export interface CollapsibleNavGroupEnabledProps { currentWorkspace$: WorkspacesStart['currentWorkspace$']; } -interface NavGroupsProps { - navLinks: ChromeNavLink[]; - suffix?: React.ReactElement; - style?: React.CSSProperties; - appId?: string; - navigateToApp: InternalApplicationStart['navigateToApp']; - onNavItemClick: ( - event: React.MouseEvent, - navItem: ChromeNavLink - ) => void; -} - const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { defaultMessage: 'See all...', }); -const LEVEL_FOR_ROOT_ITEMS = 1; - -export function NavGroups({ - navLinks, - suffix, - style, - appId, - navigateToApp, - onNavItemClick, -}: NavGroupsProps) { - const createNavItem = ({ - link, - className, - }: { - link: ChromeNavLink; - className?: string; - }): EuiSideNavItemType<{}> => { - const euiListItem = createEuiListItem({ - link, - appId, - dataTestSubj: `collapsibleNavAppLink-${link.id}`, - navigateToApp, - onClick: (event) => { - onNavItemClick(event, link); - }, - }); - - return { - id: `${link.id}-${link.title}`, - name: {link.title}, - onClick: euiListItem.onClick, - href: euiListItem.href, - emphasize: euiListItem.isActive, - className: `nav-link-item ${className || ''}`, - buttonClassName: 'nav-link-item-btn', - 'data-test-subj': euiListItem['data-test-subj'], - 'aria-label': link.title, - }; - }; - const createSideNavItem = ( - navLink: LinkItem, - level: number, - className?: string - ): EuiSideNavItemType<{}> => { - if (navLink.itemType === LinkItemType.LINK) { - if (navLink.link.title === titleForSeeAll) { - const navItem = createNavItem({ - link: navLink.link, - }); - - return { - ...navItem, - name: {navItem.name}, - emphasize: false, - }; - } - - return createNavItem({ - link: navLink.link, - className, - }); - } - - if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { - const props = createNavItem({ link: navLink.link }); - const parentItem = { - ...props, - forceOpen: true, - /** - * The href and onClick should both be undefined to make parent item rendered as accordion. - */ - href: undefined, - onClick: undefined, - className: classNames(props.className, 'nav-link-parent-item'), - buttonClassName: classNames(props.buttonClassName, 'nav-link-parent-item-button'), - items: navLink.links.map((subNavLink) => - createSideNavItem(subNavLink, level + 1, 'nav-nested-item') - ), - }; - /** - * OuiSideBar will never render items of first level as accordion, - * in order to display accordion, we need to render a fake parent item. - */ - if (level === LEVEL_FOR_ROOT_ITEMS) { - return { - className: 'nav-link-fake-item', - buttonClassName: 'nav-link-fake-item-button', - name: '', - items: [parentItem], - id: `fake_${props.id}`, - }; - } - - return parentItem; - } - - if (navLink.itemType === LinkItemType.CATEGORY) { - return { - id: navLink.category?.id ?? '', - name:
{navLink.category?.label ?? ''}
, - items: navLink.links?.map((link) => createSideNavItem(link, level + 1)), - 'aria-label': navLink.category?.label, - }; - } - - return {} as EuiSideNavItemType<{}>; - }; - const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); - const sideNavItems = orderedLinksOrCategories - .map((navLink) => createSideNavItem(navLink, LEVEL_FOR_ROOT_ITEMS)) - .filter((item): item is EuiSideNavItemType<{}> => !!item); - return ( - - - {suffix} - - ); -} - // Custom category is used for those features not belong to any of use cases in all use case. -// and the custom category should always sit before manage category +// and the custom category should always sit after manage category const customCategory: AppCategory = { id: 'custom', label: i18n.translate('core.ui.customNavList.label', { defaultMessage: 'Custom' }), - order: (DEFAULT_APP_CATEGORIES.manage.order || 0) - 500, + order: (DEFAULT_APP_CATEGORIES.manage.order || 0) + 500, }; enum NavWidth { @@ -224,6 +82,7 @@ export function CollapsibleNavGroupEnabled({ logos, setCurrentNavGroup, capabilities, + collapsibleNavHeaderRender, ...observables }: CollapsibleNavGroupEnabledProps) { const allNavLinks = useObservable(observables.navLinks$, []); @@ -241,73 +100,119 @@ export function CollapsibleNavGroupEnabled({ [navGroupsMap, navLinks] ); - const visibleUseCases = useMemo( - () => - Object.values(navGroupsMap).filter( - (group) => group.type === undefined && group.status !== NavGroupStatus.Hidden - ), - [navGroupsMap] - ); + const visibleUseCases = useMemo(() => getVisibleUseCases(navGroupsMap), [navGroupsMap]); - const navLinksForRender: ChromeNavLink[] = useMemo(() => { - if (currentNavGroup && currentNavGroup.id !== ALL_USE_CASE_ID) { - return fulfillRegistrationLinksToChromeNavLinks( - navGroupsMap[currentNavGroup.id].navLinks || [], - navLinks - ); - } + const currentNavGroupId = useMemo(() => { + if (!currentNavGroup) { + if (visibleUseCases.length === 1) { + return visibleUseCases[0].id; + } - if (visibleUseCases.length === 1) { - return fulfillRegistrationLinksToChromeNavLinks( - navGroupsMap[visibleUseCases[0].id].navLinks || [], - navLinks - ); + if (!capabilities.workspaces.enabled) { + return ALL_USE_CASE_ID; + } } - const navLinksForAll: ChromeRegistrationNavLink[] = []; + return currentNavGroup?.id; + }, [capabilities, currentNavGroup, visibleUseCases]); + + const shouldAppendManageCategory = capabilities.workspaces.enabled + ? !currentNavGroupId + : currentNavGroupId === ALL_USE_CASE_ID; + + const shouldShowCollapsedNavHeaderContent = + isNavOpen && !!collapsibleNavHeaderRender && !currentNavGroupId; + + const navLinksForRender: ChromeNavLink[] = useMemo(() => { + const getSystemNavGroups = () => { + const result: ChromeNavLink[] = []; + Object.values(navGroupsMap) + .sort(sortBy('order')) + .forEach((navGroup) => { + if (navGroup.type !== NavGroupType.SYSTEM) { + return; + } + const visibleNavLinksWithinNavGroup = fulfillRegistrationLinksToChromeNavLinks( + navGroup.navLinks, + navLinks + ); + /** + * We will take the first visible app inside the system nav groups + * when customers click the menu. If there is not a visible nav links, + * we should not show the nav group. + */ + if (visibleNavLinksWithinNavGroup[0]) { + result.push({ + ...visibleNavLinksWithinNavGroup[0], + title: navGroup.title, + category: DEFAULT_APP_CATEGORIES.manage, + }); + } + }); + + return result; + }; + + const navLinksResult: ChromeRegistrationNavLink[] = []; - // Append all the links that do not have use case info to keep backward compatible - const linkIdsWithUseGroupInfo = Object.values(navGroupsMap).reduce((total, navGroup) => { - return [...total, ...navGroup.navLinks.map((navLink) => navLink.id)]; - }, [] as string[]); - navLinks - .filter((link) => !linkIdsWithUseGroupInfo.includes(link.id)) - .forEach((navLink) => { - navLinksForAll.push({ + if (currentNavGroupId && currentNavGroupId !== ALL_USE_CASE_ID) { + navLinksResult.push(...(navGroupsMap[currentNavGroupId].navLinks || [])); + } + + if (currentNavGroupId === ALL_USE_CASE_ID) { + // Append all the links that do not have use case info to keep backward compatible + const linkIdsWithNavGroupInfo = Object.values(navGroupsMap).reduce((total, navGroup) => { + return [...total, ...navGroup.navLinks.map((navLink) => navLink.id)]; + }, [] as string[]); + navLinks.forEach((navLink) => { + if (linkIdsWithNavGroupInfo.includes(navLink.id)) { + return; + } + navLinksResult.push({ ...navLink, category: customCategory, }); }); - // Append all the links registered to all use case - navGroupsMap[ALL_USE_CASE_ID]?.navLinks.forEach((navLink) => { - navLinksForAll.push(navLink); - }); + // Append all the links registered to all use case + navGroupsMap[ALL_USE_CASE_ID]?.navLinks.forEach((navLink) => { + navLinksResult.push(navLink); + }); - // Append use case section into left navigation - Object.values(navGroupsMap) - .filter((group) => !group.type) - .forEach((group) => { + // Append use case section into left navigation + Object.values(navGroupsMap).forEach((group) => { + if (group.type) { + return; + } const categoryInfo = { id: group.id, label: group.title, order: group.order, }; - const linksForAllUseCaseWithinNavGroup = fulfillRegistrationLinksToChromeNavLinks( + + const fulfilledLinksOfNavGroup = fulfillRegistrationLinksToChromeNavLinks( group.navLinks, navLinks - ) - .filter((navLink) => navLink.showInAllNavGroup) - .map((navLink) => ({ + ); + + const linksForAllUseCaseWithinNavGroup: ChromeRegistrationNavLink[] = []; + + fulfilledLinksOfNavGroup.forEach((navLink) => { + if (!navLink.showInAllNavGroup) { + return; + } + + linksForAllUseCaseWithinNavGroup.push({ ...navLink, category: categoryInfo, - })); + }); + }); - navLinksForAll.push(...linksForAllUseCaseWithinNavGroup); + navLinksResult.push(...linksForAllUseCaseWithinNavGroup); if (linksForAllUseCaseWithinNavGroup.length) { - navLinksForAll.push({ - id: group.navLinks[0].id, + navLinksResult.push({ + id: fulfilledLinksOfNavGroup[0].id, title: titleForSeeAll, order: Number.MAX_SAFE_INTEGER, category: categoryInfo, @@ -317,22 +222,26 @@ export function CollapsibleNavGroupEnabled({ * Find if there are any links inside a use case but without a `see all` entry. * If so, append these features into custom category as a fallback */ - fulfillRegistrationLinksToChromeNavLinks(group.navLinks, navLinks) - // Filter out links that already exists in all use case - .filter( - (navLink) => !navLinksForAll.find((navLinkInAll) => navLinkInAll.id === navLink.id) - ) - .forEach((navLink) => { - navLinksForAll.push({ - ...navLink, - category: customCategory, - }); + fulfillRegistrationLinksToChromeNavLinks(group.navLinks, navLinks).forEach((navLink) => { + // Links that already exists in all use case do not need to reappend + if (navLinksResult.find((navLinkInAll) => navLinkInAll.id === navLink.id)) { + return; + } + navLinksResult.push({ + ...navLink, + category: customCategory, }); + }); } }); + } + + if (shouldAppendManageCategory) { + navLinksResult.push(...getSystemNavGroups()); + } - return fulfillRegistrationLinksToChromeNavLinks(navLinksForAll, navLinks); - }, [navLinks, navGroupsMap, currentNavGroup, visibleUseCases]); + return fulfillRegistrationLinksToChromeNavLinks(navLinksResult, navLinks); + }, [navLinks, navGroupsMap, currentNavGroupId, shouldAppendManageCategory]); const width = useMemo(() => { if (!isNavOpen) { @@ -398,7 +307,7 @@ export function CollapsibleNavGroupEnabled({ navigateToApp={navigateToApp} logos={logos} setCurrentNavGroup={setCurrentNavGroup} - currentNavGroup={currentNavGroup} + currentNavGroup={currentNavGroupId ? navGroupsMap[currentNavGroupId] : undefined} shouldShrinkNavigation={!isNavOpen} onClickShrink={closeNav} visibleUseCases={visibleUseCases} @@ -414,6 +323,12 @@ export function CollapsibleNavGroupEnabled({ hasShadow={false} className="eui-yScroll flex-1-container" > + {shouldShowCollapsedNavHeaderContent && collapsibleNavHeaderRender ? ( + <> + {collapsibleNavHeaderRender()} + + + ) : null} ', () => { currentWorkspace$: new BehaviorSubject({ id: 'foo', name: 'foo' }), visibleUseCases: [ { - id: 'navGroupFoo', + id: ALL_USE_CASE_ID, title: 'navGroupFoo', description: 'navGroupFoo', navLinks: [], }, - { - id: 'navGroupBar', - title: 'navGroupBar', - description: 'navGroupBar', - navLinks: [], - }, ], currentNavGroup: { id: 'navGroupFoo', diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx index 23e2f7e6108c..067fb2ffd2e1 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx @@ -56,7 +56,9 @@ export const CollapsibleNavTop = ({ * 3. current nav group is not all use case */ const isInsideSecondLevelOfAllWorkspace = - visibleUseCases.length > 1 && !!currentWorkspace && currentNavGroup?.id !== ALL_USE_CASE_ID; + !!currentWorkspace && + visibleUseCases[0].id === ALL_USE_CASE_ID && + currentNavGroup?.id !== ALL_USE_CASE_ID; const shouldShowBackButton = !shouldShrinkNavigation && isInsideSecondLevelOfAllWorkspace; const shouldShowHomeLink = !shouldShrinkNavigation && !shouldShowBackButton; diff --git a/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx new file mode 100644 index 000000000000..75865190cad8 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { NavGroups } from './collapsible_nav_groups'; +import { ChromeRegistrationNavLink } from '../../nav_group'; +import { ChromeNavLink } from '../../nav_links'; + +describe('', () => { + const getMockedNavLink = ( + navLink: Partial + ): ChromeNavLink & ChromeRegistrationNavLink => ({ + baseUrl: '', + href: '', + id: '', + title: '', + ...navLink, + }); + it('should render correctly', () => { + const navigateToApp = jest.fn(); + const onNavItemClick = jest.fn(); + const { container, getByTestId, queryByTestId } = render( + + ); + expect(container).toMatchSnapshot(); + expect(container.querySelectorAll('.nav-link-item-btn').length).toEqual(5); + fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); + expect(navigateToApp).toBeCalledTimes(0); + // The accordion is collapsed + expect(queryByTestId('collapsibleNavAppLink-subLink')).toBeNull(); + + // Expand the accordion + fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); + fireEvent.click(getByTestId('collapsibleNavAppLink-subLink')); + expect(navigateToApp).toBeCalledWith('subLink'); + }); +}); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx b/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx new file mode 100644 index 000000000000..53a75aeaaddd --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx @@ -0,0 +1,150 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './collapsible_nav_group_enabled.scss'; +import { EuiFlexItem, EuiSideNavItemType, EuiSideNav, EuiText } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React from 'react'; +import classNames from 'classnames'; +import { ChromeNavLink } from '../..'; +import { InternalApplicationStart } from '../../../application/types'; +import { createEuiListItem } from './nav_link'; +import { getOrderedLinksOrCategories, LinkItem, LinkItemType } from '../../utils'; + +export interface NavGroupsProps { + navLinks: ChromeNavLink[]; + suffix?: React.ReactElement; + style?: React.CSSProperties; + appId?: string; + navigateToApp: InternalApplicationStart['navigateToApp']; + onNavItemClick: ( + event: React.MouseEvent, + navItem: ChromeNavLink + ) => void; +} + +const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { + defaultMessage: 'See all...', +}); + +const LEVEL_FOR_ROOT_ITEMS = 1; + +export function NavGroups({ + navLinks, + suffix, + style, + appId, + navigateToApp, + onNavItemClick, +}: NavGroupsProps) { + const createNavItem = ({ + link, + className, + }: { + link: ChromeNavLink; + className?: string; + }): EuiSideNavItemType<{}> => { + const euiListItem = createEuiListItem({ + link, + appId, + dataTestSubj: `collapsibleNavAppLink-${link.id}`, + navigateToApp, + onClick: (event) => { + onNavItemClick(event, link); + }, + }); + + return { + id: `${link.id}-${link.title}`, + name: {link.title}, + onClick: euiListItem.onClick, + href: euiListItem.href, + emphasize: euiListItem.isActive, + className: `nav-link-item ${className || ''}`, + buttonClassName: 'nav-link-item-btn', + 'data-test-subj': euiListItem['data-test-subj'], + 'aria-label': link.title, + }; + }; + const createSideNavItem = ( + navLink: LinkItem, + level: number, + className?: string + ): EuiSideNavItemType<{}> => { + if (navLink.itemType === LinkItemType.LINK) { + if (navLink.link.title === titleForSeeAll) { + const navItem = createNavItem({ + link: navLink.link, + }); + + return { + ...navItem, + name: {navItem.name}, + emphasize: false, + }; + } + + return createNavItem({ + link: navLink.link, + className, + }); + } + + if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { + const props = createNavItem({ link: navLink.link }); + const parentItem = { + ...props, + forceOpen: true, + /** + * The href and onClick should both be undefined to make parent item rendered as accordion. + */ + href: undefined, + onClick: undefined, + className: classNames(props.className, 'nav-link-parent-item'), + buttonClassName: classNames(props.buttonClassName, 'nav-link-parent-item-button'), + items: navLink.links.map((subNavLink) => + createSideNavItem(subNavLink, level + 1, 'nav-nested-item') + ), + }; + /** + * OuiSideBar will never render items of first level as accordion, + * in order to display accordion, we need to render a fake parent item. + */ + if (level === LEVEL_FOR_ROOT_ITEMS) { + return { + className: 'nav-link-fake-item', + buttonClassName: 'nav-link-fake-item-button', + name: '', + items: [parentItem], + id: `fake_${props.id}`, + }; + } + + return parentItem; + } + + if (navLink.itemType === LinkItemType.CATEGORY) { + return { + id: navLink.category?.id ?? '', + name:
{navLink.category?.label ?? ''}
, + items: navLink.links?.map((link) => createSideNavItem(link, level + 1)), + 'aria-label': navLink.category?.label, + }; + } + + return {} as EuiSideNavItemType<{}>; + }; + const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); + const sideNavItems = orderedLinksOrCategories + .map((navLink) => createSideNavItem(navLink, LEVEL_FOR_ROOT_ITEMS)) + .filter((navItem) => !!navItem.id); + + return ( + + + {suffix} + + ); +} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 9cc8652c3e41..7edf893826ac 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -194,24 +194,6 @@ describe('Header', () => { expect(component.find('CollapsibleNavGroupEnabled').exists()).toBeTruthy(); }); - it('show hide expand icon in top left navigation when workspace enabled + homepage + new navigation enabled', () => { - const branding = { - useExpandedHeader: false, - }; - const props = { - ...mockProps(), - branding, - }; - props.application.currentAppId$ = new BehaviorSubject('home'); - props.application.capabilities = { ...props.application.capabilities }; - (props.application.capabilities.workspaces as Record) = {}; - (props.application.capabilities.workspaces as Record).enabled = true; - - const component = mountWithIntl(
); - - expect(component.find('.header__toggleNavButtonSection').exists()).toBeFalsy(); - }); - it('toggles primary navigation menu when clicked', () => { const branding = { useExpandedHeader: false, diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 492d9c7b3e78..6fa8d9a43a75 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -138,18 +138,10 @@ export function Header({ const isVisible = useObservable(observables.isVisible$, false); const headerVariant = useObservable(observables.headerVariant$, HeaderVariant.PAGE); const isLocked = useObservable(observables.isLocked$, false); - const appId = useObservable(application.currentAppId$, ''); const [isNavOpen, setIsNavOpen] = useState(false); const sidecarConfig = useObservable(observables.sidecarConfig$, undefined); const breadcrumbs = useObservable(observables.breadcrumbs$, []); - /** - * This is a workaround on 2.16 to hide the navigation items within left navigation - * when user is in homepage with workspace enabled + new navigation enabled - */ - const shouldHideExpandIcon = - navGroupEnabled && appId === 'home' && application.capabilities.workspaces.enabled; - const sidecarPaddingStyle = useMemo(() => { return getOsdSidecarPaddingStyle(sidecarConfig); }, [sidecarConfig]); @@ -365,11 +357,9 @@ export function Header({ const renderLegacyHeader = () => ( - {shouldHideExpandIcon ? null : ( - - {renderNavToggle()} - - )} + + {renderNavToggle()} + {renderLeftControls()} @@ -402,7 +392,7 @@ export function Header({ const renderPageHeader = () => (
- {shouldHideExpandIcon || isNavOpen ? null : renderNavToggle()} + {isNavOpen ? null : renderNavToggle()} {renderRecentItems()} @@ -450,7 +440,7 @@ export function Header({ const renderApplicationHeader = () => (
- {shouldHideExpandIcon || isNavOpen ? null : renderNavToggle()} + {isNavOpen ? null : renderNavToggle()} {renderRecentItems()} {renderActionMenu()} @@ -475,10 +465,11 @@ export function Header({ {navGroupEnabled ? ( = keyof T; -const sortBy = (key: KeyOf) => { +export const sortBy = (key: KeyOf) => { return (a: T, b: T): number => (a[key] > b[key] ? 1 : b[key] > a[key] ? -1 : 0); }; @@ -214,3 +215,9 @@ export const getSortedNavLinks = ( ); return acc; }; + +export const getVisibleUseCases = (navGroupMap: Record) => { + return Object.values(navGroupMap).filter( + (navGroup) => navGroup.status !== NavGroupStatus.Hidden && navGroup.type === undefined + ); +}; diff --git a/src/plugins/data_explorer/public/plugin.ts b/src/plugins/data_explorer/public/plugin.ts index 3b953567fbe8..d2c8da53a697 100644 --- a/src/plugins/data_explorer/public/plugin.ts +++ b/src/plugins/data_explorer/public/plugin.ts @@ -13,6 +13,7 @@ import { AppNavLinkStatus, ScopedHistory, AppUpdater, + DEFAULT_NAV_GROUPS, } from '../../../core/public'; import { DataExplorerPluginSetup, @@ -123,6 +124,41 @@ export class DataExplorerPlugin }, }); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: PLUGIN_ID, + order: 301, // The nav link should be put behind discover + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: PLUGIN_ID, + order: 301, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.essentials, [ + { + id: PLUGIN_ID, + order: 201, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: PLUGIN_ID, + order: 201, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + { + id: PLUGIN_ID, + order: 201, + }, + ]); + return { ...this.viewService.setup(), }; diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 8e32537d0a0b..ac5f4c508821 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -65,7 +65,6 @@ import { PLUGIN_ID, HOME_APP_BASE_PATH, IMPORT_SAMPLE_DATA_APP_ID } from '../com import { DataSourcePluginStart } from '../../data_source/public'; import { workWithDataSection } from './application/components/homepage/sections/work_with_data'; import { learnBasicsSection } from './application/components/homepage/sections/learn_basics'; -import { DEFAULT_NAV_GROUPS } from '../../../core/public'; import { ContentManagementPluginSetup, ContentManagementPluginStart, diff --git a/src/plugins/index_pattern_management/public/plugin.test.ts b/src/plugins/index_pattern_management/public/plugin.test.ts index ec9a6137ffcf..4947c3d2749a 100644 --- a/src/plugins/index_pattern_management/public/plugin.test.ts +++ b/src/plugins/index_pattern_management/public/plugin.test.ts @@ -12,6 +12,7 @@ import { ManagementAppMountParams, RegisterManagementAppArgs, } from 'src/plugins/management/public'; +import { waitFor } from '@testing-library/dom'; describe('DiscoverPlugin', () => { it('setup successfully', () => { @@ -25,7 +26,9 @@ describe('DiscoverPlugin', () => { }) ).not.toThrow(); expect(setupMock.application.register).toBeCalledTimes(1); - expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(1); + waitFor(() => { + expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(1); + }); }); it('when new navigation is enabled, should navigate to standard IPM app', async () => { diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index d74cdaffe97e..b64e81a92151 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -167,13 +167,23 @@ export class IndexPatternManagementPlugin }, }); - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ - { - id: IPM_APP_ID, - title: sectionsHeader, - order: 400, - }, - ]); + core.getStartServices().then(([coreStart]) => { + /** + * The `capabilities.workspaces.enabled` indicates + * if workspace feature flag is turned on or not and + * the global index pattern management page should only be registered + * to settings and setup when workspace is turned off, + */ + if (!coreStart.application.capabilities.workspaces.enabled) { + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ + { + id: IPM_APP_ID, + title: sectionsHeader, + order: 400, + }, + ]); + } + }); return this.indexPatternManagementService.setup({ httpClient: core.http }); } diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index 4c5fb9f0e9d2..d2ce42ed6097 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -4,48 +4,33 @@ */ import { i18n } from '@osd/i18n'; -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { useObservable } from 'react-use'; import { EuiText, EuiPanel, - EuiTitle, EuiAvatar, EuiPopover, EuiToolTip, EuiFlexItem, EuiFlexGroup, - EuiListGroup, EuiSmallButtonIcon, EuiSmallButtonEmpty, - EuiListGroupItem, EuiSmallButton, } from '@elastic/eui'; import { BehaviorSubject } from 'rxjs'; -import { - WORKSPACE_CREATE_APP_ID, - WORKSPACE_LIST_APP_ID, - MAX_WORKSPACE_PICKER_NUM, -} from '../../../common/constants'; +import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID } from '../../../common/constants'; import { CoreStart, WorkspaceObject } from '../../../../../core/public'; -import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; -import { recentWorkspaceManager } from '../../recent_workspace_manager'; +import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { WorkspaceUseCase } from '../../types'; import { navigateToWorkspaceDetail } from '../utils/workspace'; import { validateWorkspaceColor } from '../../../common/utils'; +import { WorkspacePickerContent } from '../workspace_picker_content/workspace_picker_content'; const defaultHeaderName = i18n.translate('workspace.menu.defaultHeaderName', { defaultMessage: 'Workspaces', }); -const allWorkspacesTitle = i18n.translate('workspace.menu.title.allWorkspaces', { - defaultMessage: 'All workspaces', -}); - -const recentWorkspacesTitle = i18n.translate('workspace.menu.title.recentWorkspaces', { - defaultMessage: 'Recent workspaces', -}); - const createWorkspaceButton = i18n.translate('workspace.menu.button.createWorkspace', { defaultMessage: 'Create workspace', }); @@ -73,22 +58,9 @@ interface Props { export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { const [isPopoverOpen, setPopover] = useState(false); const currentWorkspace = useObservable(coreStart.workspaces.currentWorkspace$, null); - const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); const isDashboardAdmin = coreStart.application.capabilities?.dashboards?.isDashboardAdmin; const availableUseCases = useObservable(registeredUseCases$, []); - const filteredWorkspaceList = useMemo(() => { - return workspaceList.slice(0, MAX_WORKSPACE_PICKER_NUM); - }, [workspaceList]); - - const filteredRecentWorkspaces = useMemo(() => { - return recentWorkspaceManager - .getRecentWorkspaces() - .map((workspace) => workspaceList.find((ws) => ws.id === workspace.id)) - .filter((workspace): workspace is WorkspaceObject => workspace !== undefined) - .slice(0, MAX_WORKSPACE_PICKER_NUM); - }, [workspaceList]); - const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; const getUseCase = (workspace: WorkspaceObject) => { @@ -130,46 +102,6 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { /> ); - const getWorkspaceListGroup = (filterWorkspaceList: WorkspaceObject[], itemType: string) => { - const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { - const useCase = getUseCase(workspace); - const useCaseURL = getUseCaseUrl(useCase, workspace, coreStart.application, coreStart.http); - return ( - - } - label={workspace.name} - onClick={() => { - closePopover(); - window.location.assign(useCaseURL); - }} - /> - ); - }); - return ( - <> - -

{itemType === 'all' ? allWorkspacesTitle : recentWorkspacesTitle}

-
- - {listItems} - - - ); - }; - return ( { - {filteredRecentWorkspaces.length > 0 && - getWorkspaceListGroup(filteredRecentWorkspaces, 'recent')} - {filteredWorkspaceList.length > 0 && getWorkspaceListGroup(filteredWorkspaceList, 'all')} + setPopover(false)} + /> diff --git a/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx b/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx new file mode 100644 index 000000000000..4dace89ea119 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useMemo } from 'react'; +import { useObservable } from 'react-use'; +import { EuiTitle, EuiAvatar, EuiListGroup, EuiListGroupItem } from '@elastic/eui'; +import { BehaviorSubject } from 'rxjs'; +import { MAX_WORKSPACE_PICKER_NUM } from '../../../common/constants'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { recentWorkspaceManager } from '../../recent_workspace_manager'; +import { WorkspaceUseCase } from '../../types'; +import { validateWorkspaceColor } from '../../../common/utils'; +import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; + +const allWorkspacesTitle = i18n.translate('workspace.menu.title.allWorkspaces', { + defaultMessage: 'All workspaces', +}); + +const recentWorkspacesTitle = i18n.translate('workspace.menu.title.recentWorkspaces', { + defaultMessage: 'Recent workspaces', +}); + +const getValidWorkspaceColor = (color?: string) => + validateWorkspaceColor(color) ? color : undefined; + +interface Props { + coreStart: CoreStart; + registeredUseCases$: BehaviorSubject; + onClickWorkspace?: () => void; +} + +export const WorkspacePickerContent = ({ + coreStart, + registeredUseCases$, + onClickWorkspace, +}: Props) => { + const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); + const availableUseCases = useObservable(registeredUseCases$, []); + + const filteredWorkspaceList = useMemo(() => { + return workspaceList.slice(0, MAX_WORKSPACE_PICKER_NUM); + }, [workspaceList]); + + const filteredRecentWorkspaces = useMemo(() => { + return recentWorkspaceManager + .getRecentWorkspaces() + .map((workspace) => workspaceList.find((ws) => ws.id === workspace.id)) + .filter((workspace): workspace is WorkspaceObject => workspace !== undefined) + .slice(0, MAX_WORKSPACE_PICKER_NUM); + }, [workspaceList]); + + const getUseCase = (workspace: WorkspaceObject) => { + if (!workspace.features) { + return; + } + const useCaseId = getFirstUseCaseOfFeatureConfigs(workspace.features); + return availableUseCases.find((useCase) => useCase.id === useCaseId); + }; + + const getWorkspaceListGroup = (filterWorkspaceList: WorkspaceObject[], itemType: string) => { + const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { + const useCase = getUseCase(workspace); + const useCaseURL = getUseCaseUrl(useCase, workspace, coreStart.application, coreStart.http); + return ( + + } + label={workspace.name} + onClick={() => { + onClickWorkspace?.(); + window.location.assign(useCaseURL); + }} + /> + ); + }); + return ( + + +

{itemType === 'all' ? allWorkspacesTitle : recentWorkspacesTitle}

+ + } + /> + {listItems} +
+ ); + }; + + return ( + <> + {filteredRecentWorkspaces.length > 0 && + getWorkspaceListGroup(filteredRecentWorkspaces, 'recent')} + {filteredWorkspaceList.length > 0 && getWorkspaceListGroup(filteredWorkspaceList, 'all')} + + ); +}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 3be162a4522e..c6bc16e1c939 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -35,6 +35,7 @@ describe('Workspace plugin', () => { WorkspaceClientMock.mockClear(); Object.values(workspaceClientMock).forEach((item) => item.mockClear()); }); + it('#setup', async () => { const setupMock = getSetupMock(); const savedObjectManagementSetupMock = savedObjectsManagementPluginMock.createSetupContract(); @@ -218,6 +219,21 @@ describe('Workspace plugin', () => { ); }); + it('#setup should register registerCollapsibleNavHeader when new left nav is turned on', async () => { + const setupMock = coreMock.createSetup(); + let collapsibleNavHeaderImplementation = () => null; + setupMock.chrome.navGroup.getNavGroupEnabled.mockReturnValue(true); + setupMock.chrome.registerCollapsibleNavHeader.mockImplementation( + (func) => (collapsibleNavHeaderImplementation = func) + ); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock, {}); + expect(collapsibleNavHeaderImplementation()).toEqual(null); + const startMock = coreMock.createStart(); + await workspacePlugin.start(startMock, mockDependencies); + expect(collapsibleNavHeaderImplementation()).not.toEqual(null); + }); + it('#setup should register workspace essential use case when new home is disabled', async () => { const setupMock = { ...coreMock.createSetup(), diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 03c6b544b64d..3e04e61a8404 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; import React from 'react'; import { i18n } from '@osd/i18n'; import { map } from 'rxjs/operators'; -import { EuiIcon } from '@elastic/eui'; +import { EuiIcon, EuiPanel } from '@elastic/eui'; import { Plugin, CoreStart, @@ -61,6 +61,7 @@ import { UseCaseService } from './services/use_case_service'; import { WorkspaceListCard } from './components/service_card'; import { UseCaseFooter } from './components/home_get_start_card'; import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; +import { WorkspacePickerContent } from './components/workspace_picker_content/workspace_picker_content'; import { HOME_CONTENT_AREAS } from '../../../plugins/content_management/public'; import { registerEssentialOverviewContent, @@ -169,15 +170,13 @@ export class WorkspacePlugin * It checks the following conditions: * 1. The navigation group is not a system-level group. * 2. The current workspace has feature configurations set up. - * 3. The current workspace's use case is not "All use case". - * 4. The current navigation group is not included in the feature configurations of the workspace. + * 3. The current navigation group is not included in the feature configurations of the workspace. * * If all these conditions are true, it means that the navigation group should be hidden. */ if ( navGroup.type !== NavGroupType.SYSTEM && currentWorkspace.features && - getFirstUseCaseOfFeatureConfigs(currentWorkspace.features) !== ALL_USE_CASE_ID && !isNavGroupInFeatureConfigs(navGroup.id, currentWorkspace.features) ) { return { @@ -530,6 +529,28 @@ export class WorkspacePlugin }, ]); + if (core.chrome.navGroup.getNavGroupEnabled()) { + /** + * Show workspace picker content when outside of workspace and not in any nav group + */ + core.chrome.registerCollapsibleNavHeader(() => { + if (!this.coreStart) { + return null; + } + return React.createElement(EuiPanel, { + hasShadow: false, + hasBorder: false, + children: [ + React.createElement(WorkspacePickerContent, { + key: 'workspacePickerContent', + coreStart: this.coreStart, + registeredUseCases$: this.registeredUseCases$, + }), + ], + }); + }); + } + return {}; }