diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index b6ce429528a7..74ee7474bbf7 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -69,6 +69,7 @@ const createStartContractMock = () => { getLeft$: jest.fn(), getCenter$: jest.fn(), getRight$: jest.fn(), + registerRightNavigation: jest.fn(), }, setAppTitle: jest.fn(), setIsVisible: jest.fn(), diff --git a/src/core/public/chrome/nav_controls/nav_controls_service.test.ts b/src/core/public/chrome/nav_controls/nav_controls_service.test.ts index 6e2a71537e17..aa4d9dff2b15 100644 --- a/src/core/public/chrome/nav_controls/nav_controls_service.test.ts +++ b/src/core/public/chrome/nav_controls/nav_controls_service.test.ts @@ -30,6 +30,12 @@ import { NavControlsService } from './nav_controls_service'; import { take } from 'rxjs/operators'; +import { applicationServiceMock, httpServiceMock } from '../../../../core/public/mocks'; + +const mockMountReactNode = jest.fn().mockReturnValue('mock mount point'); +jest.mock('../../utils', () => ({ + mountReactNode: () => mockMountReactNode(), +})); describe('RecentlyAccessed#start()', () => { const getStart = () => { @@ -76,6 +82,45 @@ describe('RecentlyAccessed#start()', () => { }); }); + describe('top right navigation', () => { + const mockProps = { + application: applicationServiceMock.createStartContract(), + http: httpServiceMock.createStartContract(), + appId: 'app_id', + iconType: 'icon', + title: 'title', + order: 1, + }; + it('allows registration', async () => { + const navControls = getStart(); + navControls.registerRightNavigation(mockProps); + expect(await navControls.getRight$().pipe(take(1)).toPromise()).toEqual([ + { + mount: 'mock mount point', + order: 1, + }, + ]); + }); + + it('sorts controls by order property', async () => { + const navControls = getStart(); + const props1 = { ...mockProps, order: 10 }; + const props2 = { ...mockProps, order: 0 }; + navControls.registerRightNavigation(props1); + navControls.registerRightNavigation(props2); + expect(await navControls.getRight$().pipe(take(1)).toPromise()).toEqual([ + { + mount: 'mock mount point', + order: 0, + }, + { + mount: 'mock mount point', + order: 10, + }, + ]); + }); + }); + describe('center controls', () => { it('allows registration', async () => { const navControls = getStart(); diff --git a/src/core/public/chrome/nav_controls/nav_controls_service.ts b/src/core/public/chrome/nav_controls/nav_controls_service.ts index 57298dac39ff..6e2d8b05b452 100644 --- a/src/core/public/chrome/nav_controls/nav_controls_service.ts +++ b/src/core/public/chrome/nav_controls/nav_controls_service.ts @@ -28,10 +28,16 @@ * under the License. */ +import React from 'react'; import { sortBy } from 'lodash'; import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { MountPoint } from '../../types'; +import { + RightNavigationButton, + RightNavigationButtonProps, +} from '../ui/header/right_navigation_button'; +import { mountReactNode } from '../../utils'; /** @public */ export interface ChromeNavControl { @@ -39,6 +45,10 @@ export interface ChromeNavControl { mount: MountPoint; } +interface RightNavigationProps extends RightNavigationButtonProps { + order: number; +} + /** * {@link ChromeNavControls | APIs} for registering new controls to be displayed in the navigation bar. * @@ -62,6 +72,8 @@ export interface ChromeNavControls { registerRight(navControl: ChromeNavControl): void; /** Register a nav control to be presented on the top-center side of the chrome header. */ registerCenter(navControl: ChromeNavControl): void; + /** Register a nav control to be presented on the top-right side of the chrome header. The component and style will be uniformly maintained in chrome */ + registerRightNavigation(props: RightNavigationProps): void; /** @internal */ getLeft$(): Observable; /** @internal */ @@ -104,6 +116,17 @@ export class NavControlsService { navControlsExpandedCenter$.next( new Set([...navControlsExpandedCenter$.value.values(), navControl]) ), + registerRightNavigation: (props: RightNavigationProps) => { + const nav = { + order: props.order, + mount: mountReactNode( + React.createElement(RightNavigationButton, { + ...props, + }) + ), + }; + return navControlsRight$.next(new Set([...navControlsRight$.value.values(), nav])); + }, getLeft$: () => navControlsLeft$.pipe( diff --git a/src/core/public/chrome/ui/header/__snapshots__/right_navigation_button.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/right_navigation_button.test.tsx.snap new file mode 100644 index 000000000000..3bd3bc57c9c7 --- /dev/null +++ b/src/core/public/chrome/ui/header/__snapshots__/right_navigation_button.test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Right navigation button should render base element normally 1`] = ` + +
+ +
+ +`; diff --git a/src/core/public/chrome/ui/header/right_navigation_button.test.tsx b/src/core/public/chrome/ui/header/right_navigation_button.test.tsx new file mode 100644 index 000000000000..bbc77af24111 --- /dev/null +++ b/src/core/public/chrome/ui/header/right_navigation_button.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { RightNavigationButton } from './right_navigation_button'; +import { applicationServiceMock, httpServiceMock } from '../../../../../core/public/mocks'; + +const mockProps = { + application: applicationServiceMock.createStartContract(), + http: httpServiceMock.createStartContract(), + appId: 'app_id', + iconType: 'mock_icon', + title: 'title', +}; + +describe('Right navigation button', () => { + it('should render base element normally', () => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); + }); + + it('should call application getUrlForApp and navigateToUrl after clicked', () => { + const navigateToUrl = jest.fn(); + const getUrlForApp = jest.fn(); + const props = { + ...mockProps, + application: { + ...applicationServiceMock.createStartContract(), + getUrlForApp, + navigateToUrl, + }, + }; + const { getByTestId } = render(); + const icon = getByTestId('rightNavigationButton'); + fireEvent.click(icon); + expect(getUrlForApp).toHaveBeenCalledWith('app_id', { + path: '/', + absolute: false, + }); + expect(navigateToUrl).toHaveBeenCalled(); + }); +}); diff --git a/src/core/public/chrome/ui/header/right_navigation_button.tsx b/src/core/public/chrome/ui/header/right_navigation_button.tsx new file mode 100644 index 000000000000..eac47b1ce812 --- /dev/null +++ b/src/core/public/chrome/ui/header/right_navigation_button.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiHeaderSectionItemButton, EuiIcon } from '@elastic/eui'; +import React from 'react'; +import { CoreStart } from 'src/core/public'; + +export interface RightNavigationButtonProps { + application: CoreStart['application']; + http: CoreStart['http']; + appId: string; + iconType: string; + title: string; +} + +export const RightNavigationButton = ({ + application, + http, + appId, + iconType, + title, +}: RightNavigationButtonProps) => { + const navigateToApp = () => { + const appUrl = application.getUrlForApp(appId, { + path: '/', + absolute: false, + }); + // Remove prefix in Url including workspace and other prefix + const targetUrl = http.basePath.prepend(http.basePath.remove(appUrl), { + withoutClientBasePath: true, + }); + application.navigateToUrl(targetUrl); + }; + + return ( + + + + ); +}; diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index bb0b6ee1d981..59f68beb4b8b 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -29,7 +29,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { Plugin, CoreSetup, AppMountParameters } from 'src/core/public'; +import { Plugin, CoreSetup, AppMountParameters, CoreStart } from 'src/core/public'; import { AppUpdater } from 'opensearch-dashboards/public'; import { i18n } from '@osd/i18n'; import { sortBy } from 'lodash'; @@ -74,12 +74,14 @@ export class DevToolsPlugin implements Plugin { defaultMessage: 'Dev Tools', }); + private id = 'dev_tools'; + public setup(coreSetup: CoreSetup, deps: DevToolsSetupDependencies) { const { application: applicationSetup, getStartServices } = coreSetup; const { urlForwarding, managementOverview } = deps; applicationSetup.register({ - id: 'dev_tools', + id: this.id, title: this.title, updater$: this.appStateUpdater, icon: '/ui/logos/opensearch_mark.svg', @@ -98,7 +100,7 @@ export class DevToolsPlugin implements Plugin { }); managementOverview?.register({ - id: 'dev_tools', + id: this.id, title: this.title, description: i18n.translate('devTools.devToolsDescription', { defaultMessage: @@ -124,10 +126,19 @@ export class DevToolsPlugin implements Plugin { }; } - public start() { + public start(core: CoreStart) { if (this.getSortedDevTools().length === 0) { this.appStateUpdater.next(() => ({ navLinkStatus: AppNavLinkStatus.hidden })); } + core.chrome.navControls.registerRightNavigation({ + // order of dev tool should be after advance settings + order: 2, + appId: this.id, + http: core.http, + application: core.application, + iconType: 'consoleApp', + title: this.title, + }); } public stop() {}