Skip to content

Commit

Permalink
feat: Introduce registerCollapsibleNavHeader to ChromeService (#6475)
Browse files Browse the repository at this point in the history
This commit introduces an enhancement to the ChromeService class
within our core application. It adds a new method named
`registerCollapsibleNavHeader`, allowing plugins to customize the
rendering of the collapsible navigation header in the global chrome UI.

With this new capability, plugins can now register their own
rendering logic for the collapsible navigation header. This feature
enhances the extensibility of our core system, empowering plugins to
provide a more tailored and user-friendly navigation experience.

Key changes in this commit include:

1. The addition of the `registerCollapsibleNavHeader` method to the
ChromeService class.
2. Appropriate updates to tests and typings to support the newly
introduced functionality.

Signed-off-by: Yulong Ruan <ruanyl@amazon.com>
  • Loading branch information
ruanyl committed Apr 16, 2024
1 parent 6f262df commit 3439b74
Show file tree
Hide file tree
Showing 10 changed files with 92 additions and 0 deletions.
8 changes: 8 additions & 0 deletions src/core/public/chrome/chrome_service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InternalChromeStart> = {
getHeaderComponent: jest.fn(),
Expand Down Expand Up @@ -95,6 +101,7 @@ const createStartContractMock = () => {
type ChromeServiceContract = PublicMethodsOf<ChromeService>;
const createMock = () => {
const mocked: jest.Mocked<ChromeServiceContract> = {
setup: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
};
Expand All @@ -105,4 +112,5 @@ const createMock = () => {
export const chromeServiceMock = {
create: createMock,
createStartContract: createStartContractMock,
createSetupContract: createSetupContractMock,
};
38 changes: 38 additions & 0 deletions src/core/public/chrome/chrome_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down
32 changes: 32 additions & 0 deletions src/core/public/chrome/chrome_service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ interface StartDeps {
overlays: OverlayStart;
}

type CollapsibleNavHeaderRender = () => JSX.Element | null;

/** @internal */
export class ChromeService {
private isVisible$!: Observable<boolean>;
Expand All @@ -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) {}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -268,6 +285,7 @@ export class ChromeService {
logos={logos}
survey={injectedMetadata.getSurvey()}
sidecarConfig$={sidecarConfig$}
collapsibleNavHeaderRender={this.collapsibleNavHeaderRender}
/>
),

Expand Down Expand Up @@ -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(() => <CustomNavHeader />)
* ```
*/
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
Expand Down
1 change: 1 addition & 0 deletions src/core/public/chrome/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
ChromeBadge,
ChromeBreadcrumb,
ChromeService,
ChromeSetup,
ChromeStart,
InternalChromeStart,
ChromeHelpExtension,
Expand Down
3 changes: 3 additions & 0 deletions src/core/public/chrome/ui/header/collapsible_nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -106,6 +107,7 @@ interface Props {

export function CollapsibleNav({
basePath,
collapsibleNavHeaderRender,
id,
isLocked,
isNavOpen,
Expand Down Expand Up @@ -150,6 +152,7 @@ export function CollapsibleNav({
onClose={closeNav}
outsideClickCloses={false}
>
{collapsibleNavHeaderRender && collapsibleNavHeaderRender()}
{customNavLink && (
<Fragment>
<EuiFlexItem grow={false} style={{ flexShrink: 0 }}>
Expand Down
3 changes: 3 additions & 0 deletions src/core/public/chrome/ui/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface HeaderProps {
appTitle$: Observable<string>;
badge$: Observable<ChromeBadge | undefined>;
breadcrumbs$: Observable<ChromeBreadcrumb[]>;
collapsibleNavHeaderRender?: () => JSX.Element | null;
customNavLink$: Observable<ChromeNavLink | undefined>;
homeHref: string;
isVisible$: Observable<boolean>;
Expand Down Expand Up @@ -106,6 +107,7 @@ export function Header({
branding,
survey,
logos,
collapsibleNavHeaderRender,
...observables
}: HeaderProps) {
const isVisible = useObservable(observables.isVisible$, false);
Expand Down Expand Up @@ -253,6 +255,7 @@ export function Header({

<CollapsibleNav
appId$={application.currentAppId$}
collapsibleNavHeaderRender={collapsibleNavHeaderRender}
id={navId}
isLocked={isLocked}
navLinks$={observables.navLinks$}
Expand Down
2 changes: 2 additions & 0 deletions src/core/public/core_system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,12 @@ export class CoreSystem {
});
const application = this.application.setup({ context, http });
this.coreApp.setup({ application, http, injectedMetadata, notifications });
const chrome = this.chrome.setup();

const core: InternalCoreSetup = {
application,
context,
chrome,
fatalErrors: this.fatalErrorsSetup,
http,
injectedMetadata,
Expand Down
3 changes: 3 additions & 0 deletions src/core/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
ChromeNavLinks,
ChromeNavLinkUpdateableFields,
ChromeDocTitle,
ChromeSetup,
ChromeStart,
ChromeRecentlyAccessed,
ChromeRecentlyAccessedHistoryItem,
Expand Down Expand Up @@ -227,6 +228,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
* @deprecated
*/
context: ContextSetup;
/** {@link ChromeSetup} */
chrome: ChromeSetup;
/** {@link FatalErrorsSetup} */
fatalErrors: FatalErrorsSetup;
/** {@link HttpSetup} */
Expand Down
1 change: 1 addition & 0 deletions src/core/public/plugins/plugin_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export function createPluginSetupContext<
registerMountContext: (contextName, provider) =>
deps.application.registerMountContext(plugin.opaqueId, contextName, provider),
},
chrome: deps.chrome,
context: deps.context,
fatalErrors: deps.fatalErrors,
http: deps.http,
Expand Down
1 change: 1 addition & 0 deletions src/core/public/plugins/plugins_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe('PluginsService', () => {
];
mockSetupDeps = {
application: applicationServiceMock.createInternalSetupContract(),
chrome: chromeServiceMock.createSetupContract(),
context: contextServiceMock.createSetupContract(),
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract(),
Expand Down

0 comments on commit 3439b74

Please sign in to comment.