mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Chrome] Expose api to to set custom component for project left nav (#156205)
In this PR I've exposed a new api from the Chrome service to set a custom React component for the side navigation. Calling this API does not have any effect if Kibana is not started in "project" ChromeStyle. ## Api ```ts chrome.project.setSideNavComponent(/* component */); ``` ## Example ```ts // my_nav.tsx import React, { type FC } from 'react'; import type { SideNavComponent } from '@kbn/core-chrome-browser'; // SideNavComponent will receive in its props Chrome service state (the current active route, the navTree...) export const MyNav: SideNavComponent = (props) => { return <div>Custom navigation...</div>; }; ------------------- // serverless plugin.ts (public) import { MyNav } from './my_nav'; ... public start(core: CoreStart) { core.chrome.project.setSideNavComponent(MyNav); return {}; } ```
This commit is contained in:
parent
41f0b75f55
commit
06054cd4ec
13 changed files with 284 additions and 43 deletions
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
import * as Rx from 'rxjs';
|
||||
import { toArray } from 'rxjs/operators';
|
||||
|
@ -19,6 +19,7 @@ import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
|
|||
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
|
||||
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
|
||||
import { getAppInfo } from '@kbn/core-application-browser-internal';
|
||||
import { findTestSubject } from '@kbn/test-jest-helpers';
|
||||
import { ChromeService } from './chrome_service';
|
||||
|
||||
class FakeApp implements App {
|
||||
|
@ -176,6 +177,41 @@ describe('start', () => {
|
|||
// Don't capture the snapshot because it's 600+ lines long.
|
||||
expect(shallow(React.createElement(() => chrome.getHeaderComponent()))).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders the default project side navigation', async () => {
|
||||
const { chrome } = await start();
|
||||
|
||||
chrome.setChromeStyle('project');
|
||||
|
||||
const component = mount(chrome.getHeaderComponent());
|
||||
|
||||
const projectHeader = findTestSubject(component, 'kibanaProjectHeader');
|
||||
expect(projectHeader.length).toBe(1);
|
||||
|
||||
const defaultProjectSideNav = findTestSubject(component, 'defaultProjectSideNav');
|
||||
expect(defaultProjectSideNav.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders the custom project side navigation', async () => {
|
||||
const { chrome } = await start();
|
||||
|
||||
const MyNav = function MyNav() {
|
||||
return <div data-test-subj="customProjectSideNav">HELLO</div>;
|
||||
};
|
||||
chrome.setChromeStyle('project');
|
||||
chrome.project.setSideNavComponent(MyNav);
|
||||
|
||||
const component = mount(chrome.getHeaderComponent());
|
||||
|
||||
const projectHeader = findTestSubject(component, 'kibanaProjectHeader');
|
||||
expect(projectHeader.length).toBe(1);
|
||||
|
||||
const defaultProjectSideNav = findTestSubject(component, 'defaultProjectSideNav');
|
||||
expect(defaultProjectSideNav.length).toBe(0); // Default side nav not mounted
|
||||
|
||||
const customProjectSideNav = findTestSubject(component, 'customProjectSideNav');
|
||||
expect(customProjectSideNav.text()).toBe('HELLO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
|
@ -12,6 +12,7 @@ import { BehaviorSubject, combineLatest, merge, type Observable, of, ReplaySubje
|
|||
import { flatMap, map, takeUntil } from 'rxjs/operators';
|
||||
import { parse } from 'url';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-browser-internal';
|
||||
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
|
||||
import type { HttpStart } from '@kbn/core-http-browser';
|
||||
|
@ -27,14 +28,17 @@ import type {
|
|||
ChromeHelpExtension,
|
||||
ChromeUserBanner,
|
||||
ChromeStyle,
|
||||
ChromeProjectNavigation,
|
||||
} from '@kbn/core-chrome-browser';
|
||||
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
|
||||
import type { SideNavComponent as ISideNavComponent } from '@kbn/core-chrome-browser';
|
||||
import { KIBANA_ASK_ELASTIC_LINK } from './constants';
|
||||
import { DocTitleService } from './doc_title';
|
||||
import { NavControlsService } from './nav_controls';
|
||||
import { NavLinksService } from './nav_links';
|
||||
import { ProjectNavigationService } from './project_navigation';
|
||||
import { RecentlyAccessedService } from './recently_accessed';
|
||||
import { Header, ProjectHeader } from './ui';
|
||||
import { Header, ProjectHeader, ProjectSideNavigation } from './ui';
|
||||
import type { InternalChromeStart } from './types';
|
||||
|
||||
const IS_LOCKED_KEY = 'core.chrome.isLocked';
|
||||
|
@ -63,6 +67,7 @@ export class ChromeService {
|
|||
private readonly navLinks = new NavLinksService();
|
||||
private readonly recentlyAccessed = new RecentlyAccessedService();
|
||||
private readonly docTitle = new DocTitleService();
|
||||
private readonly projectNavigation = new ProjectNavigationService();
|
||||
|
||||
constructor(private readonly params: ConstructorParams) {}
|
||||
|
||||
|
@ -147,6 +152,7 @@ export class ChromeService {
|
|||
|
||||
const navControls = this.navControls.start();
|
||||
const navLinks = this.navLinks.start({ application, http });
|
||||
const projectNavigation = this.projectNavigation.start({ application, navLinks });
|
||||
const recentlyAccessed = await this.recentlyAccessed.start({ http });
|
||||
const docTitle = this.docTitle.start({ document: window.document });
|
||||
const { customBranding$ } = customBranding;
|
||||
|
@ -170,6 +176,14 @@ export class ChromeService {
|
|||
chromeStyle$.next(style);
|
||||
};
|
||||
|
||||
const setProjectSideNavComponent = (component: ISideNavComponent | null) => {
|
||||
projectNavigation.setProjectSideNavComponent(component);
|
||||
};
|
||||
|
||||
const setProjectNavigation = (config: ChromeProjectNavigation) => {
|
||||
projectNavigation.setProjectNavigation(config);
|
||||
};
|
||||
|
||||
const isIE = () => {
|
||||
const ua = window.navigator.userAgent;
|
||||
const msie = ua.indexOf('MSIE '); // IE 10 or older
|
||||
|
@ -211,8 +225,28 @@ export class ChromeService {
|
|||
}
|
||||
|
||||
const getHeaderComponent = () => {
|
||||
const Component = ({ style$ }: { style$: typeof chromeStyle$ }) => {
|
||||
if (style$.getValue() === 'project') {
|
||||
if (chromeStyle$.getValue() === 'project') {
|
||||
// const projectNavigationConfig = projectNavigation.getProjectNavigation$();
|
||||
// TODO: Uncommented when we support the project navigation config
|
||||
// if (!projectNavigationConfig) {
|
||||
// throw new Erorr(`Project navigation config must be provided for project.`);
|
||||
// }
|
||||
|
||||
const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
|
||||
|
||||
const ProjectHeaderWithNavigation = () => {
|
||||
const CustomSideNavComponent = useObservable(projectNavigationComponent$, undefined);
|
||||
|
||||
let SideNavComponent: ISideNavComponent = () => null;
|
||||
|
||||
if (CustomSideNavComponent !== undefined) {
|
||||
// We have the state from the Observable
|
||||
SideNavComponent =
|
||||
CustomSideNavComponent.current !== null
|
||||
? CustomSideNavComponent.current
|
||||
: ProjectSideNavigation;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectHeader
|
||||
{...{
|
||||
|
@ -226,41 +260,45 @@ export class ChromeService {
|
|||
navControlsRight$={navControls.getRight$()}
|
||||
kibanaDocLink={docLinks.links.kibana.guide}
|
||||
kibanaVersion={injectedMetadata.getKibanaVersion()}
|
||||
/>
|
||||
>
|
||||
{/* TODO: pass down the SideNavCompProps once they are defined */}
|
||||
<SideNavComponent />
|
||||
</ProjectHeader>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Header
|
||||
loadingCount$={http.getLoadingCount$()}
|
||||
application={application}
|
||||
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
|
||||
badge$={badge$.pipe(takeUntil(this.stop$))}
|
||||
basePath={http.basePath}
|
||||
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
|
||||
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
|
||||
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
|
||||
kibanaDocLink={docLinks.links.kibana.guide}
|
||||
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
|
||||
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
|
||||
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
|
||||
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
|
||||
homeHref={http.basePath.prepend('/app/home')}
|
||||
isVisible$={this.isVisible$}
|
||||
kibanaVersion={injectedMetadata.getKibanaVersion()}
|
||||
navLinks$={navLinks.getNavLinks$()}
|
||||
recentlyAccessed$={recentlyAccessed.get$()}
|
||||
navControlsLeft$={navControls.getLeft$()}
|
||||
navControlsCenter$={navControls.getCenter$()}
|
||||
navControlsRight$={navControls.getRight$()}
|
||||
navControlsExtension$={navControls.getExtension$()}
|
||||
onIsLockedUpdate={setIsNavDrawerLocked}
|
||||
isLocked$={getIsNavDrawerLocked$}
|
||||
customBranding$={customBranding$}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return <Component {...{ style$: chromeStyle$ }} />;
|
||||
return <ProjectHeaderWithNavigation />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Header
|
||||
loadingCount$={http.getLoadingCount$()}
|
||||
application={application}
|
||||
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
|
||||
badge$={badge$.pipe(takeUntil(this.stop$))}
|
||||
basePath={http.basePath}
|
||||
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
|
||||
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
|
||||
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
|
||||
kibanaDocLink={docLinks.links.kibana.guide}
|
||||
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
|
||||
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
|
||||
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
|
||||
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
|
||||
homeHref={http.basePath.prepend('/app/home')}
|
||||
isVisible$={this.isVisible$}
|
||||
kibanaVersion={injectedMetadata.getKibanaVersion()}
|
||||
navLinks$={navLinks.getNavLinks$()}
|
||||
recentlyAccessed$={recentlyAccessed.get$()}
|
||||
navControlsLeft$={navControls.getLeft$()}
|
||||
navControlsCenter$={navControls.getCenter$()}
|
||||
navControlsRight$={navControls.getRight$()}
|
||||
navControlsExtension$={navControls.getExtension$()}
|
||||
onIsLockedUpdate={setIsNavDrawerLocked}
|
||||
isLocked$={getIsNavDrawerLocked$}
|
||||
customBranding$={customBranding$}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -335,6 +373,10 @@ export class ChromeService {
|
|||
getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)),
|
||||
setChromeStyle,
|
||||
getChromeStyle$: () => chromeStyle$.pipe(takeUntil(this.stop$)),
|
||||
project: {
|
||||
setNavigation: setProjectNavigation,
|
||||
setSideNavComponent: setProjectSideNavComponent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { ProjectNavigationService } from './project_navigation_service';
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { InternalApplicationStart } from '@kbn/core-application-browser-internal';
|
||||
import {
|
||||
ChromeNavLinks,
|
||||
ChromeProjectNavigation,
|
||||
SideNavComponent,
|
||||
} from '@kbn/core-chrome-browser';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
interface StartDeps {
|
||||
application: InternalApplicationStart;
|
||||
navLinks: ChromeNavLinks;
|
||||
}
|
||||
|
||||
export class ProjectNavigationService {
|
||||
private customProjectSideNavComponent$ = new BehaviorSubject<{
|
||||
current: SideNavComponent | null;
|
||||
}>({ current: null });
|
||||
private projectNavigation$ = new BehaviorSubject<ChromeProjectNavigation | undefined>(undefined);
|
||||
|
||||
public start({ application, navLinks }: StartDeps) {
|
||||
// TODO: use application, navLink and projectNavigation$ to:
|
||||
// 1. validate projectNavigation$ against navLinks,
|
||||
// 2. filter disabled/missing links from projectNavigation
|
||||
// 3. keep track of currently active link / path (path will be used to highlight the link in the sidenav and display part of the breadcrumbs)
|
||||
|
||||
return {
|
||||
setProjectNavigation: (projectNavigation: ChromeProjectNavigation) => {
|
||||
this.projectNavigation$.next(projectNavigation);
|
||||
},
|
||||
getProjectNavigation$: () => {
|
||||
return this.projectNavigation$.asObservable();
|
||||
},
|
||||
setProjectSideNavComponent: (component: SideNavComponent | null) => {
|
||||
this.customProjectSideNavComponent$.next({ current: component });
|
||||
},
|
||||
getProjectSideNavComponent$: () => {
|
||||
return this.customProjectSideNavComponent$.asObservable();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -7,6 +7,6 @@
|
|||
*/
|
||||
|
||||
export { Header } from './header';
|
||||
export { ProjectHeader } from './project';
|
||||
export { ProjectHeader, SideNavigation as ProjectSideNavigation } from './project';
|
||||
export { LoadingIndicator } from './loading_indicator';
|
||||
export type { NavType } from './header';
|
||||
|
|
|
@ -34,12 +34,14 @@ interface Props {
|
|||
kibanaVersion: string;
|
||||
application: InternalApplicationStart;
|
||||
navControlsRight$: Observable<ChromeNavControl[]>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProjectHeader = ({
|
||||
application,
|
||||
kibanaDocLink,
|
||||
kibanaVersion,
|
||||
children,
|
||||
...observables
|
||||
}: Props) => {
|
||||
const renderLogo = () => (
|
||||
|
@ -53,7 +55,7 @@ export const ProjectHeader = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<EuiHeader position="fixed">
|
||||
<EuiHeader position="fixed" data-test-subj="kibanaProjectHeader">
|
||||
<EuiHeaderSection grow={false}>
|
||||
<EuiHeaderSectionItem border="right">{renderLogo()}</EuiHeaderSectionItem>
|
||||
<EuiHeaderSectionItem>
|
||||
|
@ -81,9 +83,7 @@ export const ProjectHeader = ({
|
|||
</EuiHeaderSection>
|
||||
</EuiHeader>
|
||||
<Router history={application.history}>
|
||||
<ProjectNavigation>
|
||||
<span />
|
||||
</ProjectNavigation>
|
||||
<ProjectNavigation>{children}</ProjectNavigation>
|
||||
</Router>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -7,3 +7,4 @@
|
|||
*/
|
||||
|
||||
export { ProjectHeader } from './header';
|
||||
export { SideNavigation } from './side_navigation';
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import type { SideNavComponent } from '@kbn/core-chrome-browser';
|
||||
|
||||
export const SideNavigation: SideNavComponent = () => {
|
||||
return (
|
||||
<div data-test-subj="defaultProjectSideNav">
|
||||
<EuiText color="white">TODO - Build navigation from config</EuiText>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -63,6 +63,10 @@ const createStartContractMock = () => {
|
|||
getBodyClasses$: jest.fn(),
|
||||
getChromeStyle$: jest.fn(),
|
||||
setChromeStyle: jest.fn(),
|
||||
project: {
|
||||
setNavigation: jest.fn(),
|
||||
setSideNavComponent: jest.fn(),
|
||||
},
|
||||
};
|
||||
startContract.navLinks.getAll.mockReturnValue([]);
|
||||
startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false));
|
||||
|
|
|
@ -29,4 +29,9 @@ export type {
|
|||
ChromeStart,
|
||||
ChromeStyle,
|
||||
ChromeUserBanner,
|
||||
ChromeProjectNavigationLink,
|
||||
ChromeProjectNavigation,
|
||||
ChromeProjectNavigationNode,
|
||||
SideNavCompProps,
|
||||
SideNavComponent,
|
||||
} from './src';
|
||||
|
|
|
@ -14,7 +14,8 @@ import type { ChromeNavControls } from './nav_controls';
|
|||
import type { ChromeHelpExtension } from './help_extension';
|
||||
import type { ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension } from './breadcrumb';
|
||||
import type { ChromeBadge, ChromeStyle, ChromeUserBanner } from './types';
|
||||
import { ChromeGlobalHelpExtensionMenuLink } from './help_extension';
|
||||
import type { ChromeGlobalHelpExtensionMenuLink } from './help_extension';
|
||||
import type { ChromeProjectNavigation, SideNavComponent } from './project_navigation';
|
||||
|
||||
/**
|
||||
* ChromeStart allows plugins to customize the global chrome header UI and
|
||||
|
@ -161,4 +162,26 @@ export interface ChromeStart {
|
|||
* Get an observable of the current style type of the chrome.
|
||||
*/
|
||||
getChromeStyle$(): Observable<ChromeStyle>;
|
||||
/**
|
||||
* Configuration for serverless projects
|
||||
*/
|
||||
project: {
|
||||
/**
|
||||
* Sets the project navigation config to be used for rendering project navigation.
|
||||
* It is used for default project sidenav, project breadcrumbs, tracking active deep link.
|
||||
* @param projectNavigation The project navigation config
|
||||
*
|
||||
* @remarks Has no effect if the chrome style is not `project`.
|
||||
*/
|
||||
setNavigation(projectNavigation: ChromeProjectNavigation): void;
|
||||
|
||||
/**
|
||||
* Set custom project sidenav component to be used instead of the default project sidenav.
|
||||
* @param getter A function returning a CustomNavigationComponent.
|
||||
* This component will receive Chrome navigation state as props (not yet implemented)
|
||||
*
|
||||
* @remarks Has no effect if the chrome style is not `project`.
|
||||
*/
|
||||
setSideNavComponent(component: SideNavComponent | null): void;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -27,3 +27,11 @@ export type {
|
|||
ChromeRecentlyAccessedHistoryItem,
|
||||
} from './recently_accessed';
|
||||
export type { ChromeBadge, ChromeUserBanner, ChromeStyle } from './types';
|
||||
|
||||
export type {
|
||||
ChromeProjectNavigation,
|
||||
ChromeProjectNavigationNode,
|
||||
ChromeProjectNavigationLink,
|
||||
SideNavCompProps,
|
||||
SideNavComponent,
|
||||
} from './project_navigation';
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/** @internal */
|
||||
type AppId = string;
|
||||
|
||||
/** @internal */
|
||||
type DeepLinkId = string;
|
||||
|
||||
/** @internal */
|
||||
export type AppDeepLinkId = `${AppId}:${DeepLinkId}`;
|
||||
|
||||
/** @public */
|
||||
export type ChromeProjectNavigationLink = AppId | AppDeepLinkId;
|
||||
|
||||
/** @public */
|
||||
export interface ChromeProjectNavigationNode {
|
||||
id?: string;
|
||||
link?: ChromeProjectNavigationLink;
|
||||
children?: ChromeProjectNavigationNode[];
|
||||
|
||||
title?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ChromeProjectNavigation {
|
||||
navigationTree: ChromeProjectNavigationNode[];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface SideNavCompProps {
|
||||
// TODO: provide the Chrome state to the component through props
|
||||
// e.g. "navTree", "activeRoute", "recentItems"...
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type SideNavComponent = ComponentType<SideNavCompProps>;
|
Loading…
Add table
Add a link
Reference in a new issue