diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e275d71c7..8a615a76a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Decouple] Add new cross compatibility check core service which export functionality for plugins to verify if their OpenSearch plugin counterpart is installed on the cluster or has incompatible version to configure the plugin behavior([#4710](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4710)) - [Discover] Display inner properties in the left navigation bar [#5429](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5429) - [Discover] Added customizable pagination options based on Discover UI settings [#5610](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5610) +- [Chrome] Introduce registerCollapsibleNavHeader to allow plugins to customize the rendering of nav menu header ([#5244](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5244)) - [Custom Branding] Relative URL should be allowed for logos ([#5572](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5572)) ### 🐛 Bug Fixes diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 14b516ff95b..b6ce429528a 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -33,6 +33,12 @@ import type { PublicMethodsOf } from '@osd/utility-types'; import { ChromeBadge, ChromeBreadcrumb, ChromeService, InternalChromeStart } from './'; import { getLogosMock } from '../../common/mocks'; +const createSetupContractMock = () => { + return { + registerCollapsibleNavHeader: jest.fn(), + }; +}; + const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { getHeaderComponent: jest.fn(), @@ -95,6 +101,7 @@ const createStartContractMock = () => { type ChromeServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { + setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; @@ -105,4 +112,5 @@ const createMock = () => { export const chromeServiceMock = { create: createMock, createStartContract: createStartContractMock, + createSetupContract: createSetupContractMock, }; diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index e91056ed776..be879bb4b5e 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -108,6 +108,44 @@ afterAll(() => { (window as any).localStorage = originalLocalStorage; }); +describe('setup', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('register custom Nav Header render', async () => { + const customHeaderMock = React.createElement('TestCustomNavHeader'); + const renderMock = jest.fn().mockReturnValue(customHeaderMock); + const chrome = new ChromeService({ browserSupportsCsp: true }); + + const chromeSetup = chrome.setup(); + chromeSetup.registerCollapsibleNavHeader(renderMock); + + const chromeStart = await chrome.start(defaultStartDeps()); + const wrapper = shallow(React.createElement(() => chromeStart.getHeaderComponent())); + expect(wrapper.prop('collapsibleNavHeaderRender')).toBeDefined(); + expect(wrapper.prop('collapsibleNavHeaderRender')()).toEqual(customHeaderMock); + }); + + it('should output warning message if calling `registerCollapsibleNavHeader` more than once', () => { + const warnMock = jest.fn(); + jest.spyOn(console, 'warn').mockImplementation(warnMock); + const customHeaderMock = React.createElement('TestCustomNavHeader'); + const renderMock = jest.fn().mockReturnValue(customHeaderMock); + const chrome = new ChromeService({ browserSupportsCsp: true }); + + const chromeSetup = chrome.setup(); + // call 1st time + chromeSetup.registerCollapsibleNavHeader(renderMock); + // call 2nd time + chromeSetup.registerCollapsibleNavHeader(renderMock); + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock).toHaveBeenCalledWith( + '[ChromeService] An existing custom collapsible navigation bar header render has been overridden.' + ); + }); +}); + describe('start', () => { it('adds legacy browser warning if browserSupportsCsp is disabled and warnLegacyBrowsers is enabled', async () => { const { startDeps } = await start({ options: { browserSupportsCsp: false } }); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 37ac2ba508a..57c9f11d906 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -98,6 +98,8 @@ export interface StartDeps { uiSettings: IUiSettingsClient; } +type CollapsibleNavHeaderRender = () => JSX.Element | null; + /** @internal */ export class ChromeService { private isVisible$!: Observable; @@ -107,6 +109,7 @@ export class ChromeService { private readonly navLinks = new NavLinksService(); private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); + private collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; constructor(private readonly params: ConstructorParams) {} @@ -142,6 +145,20 @@ export class ChromeService { ); } + public setup() { + return { + registerCollapsibleNavHeader: (render: CollapsibleNavHeaderRender) => { + if (this.collapsibleNavHeaderRender) { + // eslint-disable-next-line no-console + console.warn( + '[ChromeService] An existing custom collapsible navigation bar header render has been overridden.' + ); + } + this.collapsibleNavHeaderRender = render; + }, + }; + } + public async start({ application, docLinks, @@ -262,6 +279,7 @@ export class ChromeService { branding={injectedMetadata.getBranding()} logos={logos} survey={injectedMetadata.getSurvey()} + collapsibleNavHeaderRender={this.collapsibleNavHeaderRender} /> ), @@ -325,6 +343,20 @@ export class ChromeService { } } +/** + * ChromeSetup allows plugins to customize the global chrome header UI rendering + * before the header UI is mounted. + * + * @example + * Customize the Collapsible Nav's (left nav menu) header section: + * ```ts + * core.chrome.registerCollapsibleNavHeader(() => ) + * ``` + */ +export interface ChromeSetup { + registerCollapsibleNavHeader: (render: CollapsibleNavHeaderRender) => void; +} + /** * ChromeStart allows plugins to customize the global chrome header UI and * enrich the UX with additional information about the current location of the diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 4cd43362767..4004c2c323f 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -32,6 +32,7 @@ export { ChromeBadge, ChromeBreadcrumb, ChromeService, + ChromeSetup, ChromeStart, InternalChromeStart, ChromeHelpExtension, diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 8b178200114..9c9223aa501 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -89,6 +89,7 @@ function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { interface Props { appId$: InternalApplicationStart['currentAppId$']; basePath: HttpStart['basePath']; + collapsibleNavHeaderRender?: () => JSX.Element | null; id: string; isLocked: boolean; isNavOpen: boolean; @@ -106,6 +107,7 @@ interface Props { export function CollapsibleNav({ basePath, + collapsibleNavHeaderRender, id, isLocked, isNavOpen, @@ -150,6 +152,7 @@ export function CollapsibleNav({ onClose={closeNav} outsideClickCloses={false} > + {collapsibleNavHeaderRender && collapsibleNavHeaderRender()} {customNavLink && ( diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 8eb594802b8..2ca0f254894 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -72,6 +72,7 @@ export interface HeaderProps { appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; + collapsibleNavHeaderRender?: () => JSX.Element | null; customNavLink$: Observable; homeHref: string; isVisible$: Observable; @@ -105,6 +106,7 @@ export function Header({ branding, survey, logos, + collapsibleNavHeaderRender, ...observables }: HeaderProps) { const isVisible = useObservable(observables.isVisible$, false); @@ -246,6 +248,7 @@ export function Header({ deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, + chrome: deps.chrome, context: deps.context, fatalErrors: deps.fatalErrors, http: deps.http, diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index acca058fe65..2135e92d87b 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -103,6 +103,7 @@ describe('PluginsService', () => { ]; mockSetupDeps = { application: applicationServiceMock.createInternalSetupContract(), + chrome: chromeServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(),