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 46a6612fd37..b6599d6cbae 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -107,6 +107,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 480c10e4186..7285463887e 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -101,6 +101,8 @@ interface StartDeps { overlays: OverlayStart; } +type CollapsibleNavHeaderRender = () => JSX.Element | null; + /** @internal */ export class ChromeService { private isVisible$!: Observable; @@ -110,6 +112,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) {} @@ -145,6 +148,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, @@ -268,6 +285,7 @@ export class ChromeService { logos={logos} survey={injectedMetadata.getSurvey()} sidecarConfig$={sidecarConfig$} + collapsibleNavHeaderRender={this.collapsibleNavHeaderRender} /> ), @@ -331,6 +349,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 8ff9529288b..b8b40fa6c39 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; @@ -106,6 +107,7 @@ export function Header({ branding, survey, logos, + collapsibleNavHeaderRender, ...observables }: HeaderProps) { const isVisible = useObservable(observables.isVisible$, false); @@ -253,6 +255,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(),