diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2aa952df04..51f9e1c474bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075)) - [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) +- [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 @@ -948,4 +949,4 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### 🔩 Tests -- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322)) +- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322)) \ No newline at end of file diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 14b516ff95bc..b6ce429528a7 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 e91056ed7766..be879bb4b5e9 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 37ac2ba508a1..57c9f11d9061 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 4cd43362767c..4004c2c323f9 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 8b178200114a..9c9223aa501b 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 8eb594802b8d..2ca0f2548942 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 acca058fe657..2135e92d87b8 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(),