[Cloud] Update support and user profile header menus (#160535)

This commit is contained in:
Sébastien Loix 2023-07-02 20:29:07 +01:00 committed by GitHub
parent a5d4d9d948
commit dea3423b2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 383 additions and 134 deletions

View file

@ -15,7 +15,7 @@ import { EuiLink } from '@elastic/eui';
import useObservable from 'react-use/lib/useObservable';
import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-browser-internal';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { type DocLinksStart } from '@kbn/core-doc-links-browser';
import type { HttpStart } from '@kbn/core-http-browser';
import { mountReactNode } from '@kbn/core-mount-utils-browser-internal';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
@ -33,8 +33,11 @@ import type {
ChromeSetProjectBreadcrumbsParams,
} 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 type {
SideNavComponent as ISideNavComponent,
ChromeHelpMenuLink,
} from '@kbn/core-chrome-browser';
import { DocTitleService } from './doc_title';
import { NavControlsService } from './nav_controls';
import { NavLinksService } from './nav_links';
@ -135,7 +138,7 @@ export class ChromeService {
>(undefined);
const badge$ = new BehaviorSubject<ChromeBadge | undefined>(undefined);
const customNavLink$ = new BehaviorSubject<ChromeNavLink | undefined>(undefined);
const helpSupportUrl$ = new BehaviorSubject<string>(KIBANA_ASK_ELASTIC_LINK);
const helpSupportUrl$ = new BehaviorSubject<string>(docLinks.links.kibana.askElastic);
const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true');
const chromeStyle$ = new BehaviorSubject<ChromeStyle>('classic');
@ -168,6 +171,7 @@ export class ChromeService {
const recentlyAccessed = await this.recentlyAccessed.start({ http });
const docTitle = this.docTitle.start();
const { customBranding$ } = customBranding;
const helpMenuLinks$ = navControls.getHelpMenuLinks$();
// erase chrome fields from a previous app while switching to a next app
application.currentAppId$.subscribe(() => {
@ -301,12 +305,13 @@ export class ChromeService {
breadcrumbs$={currentProjectBreadcrumbs$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
helpMenuLinks$={helpMenuLinks$}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
loadingCount$={http.getLoadingCount$()}
homeHref$={projectNavigation.getProjectHome$()}
kibanaDocLink={docLinks.links.kibana.guide}
docLinks={docLinks}
kibanaVersion={injectedMetadata.getKibanaVersion()}
prependBasePath={http.basePath.prepend}
>
@ -329,10 +334,12 @@ export class ChromeService {
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
kibanaDocLink={docLinks.links.kibana.guide}
docLinks={docLinks}
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
helpMenuLinks$={helpMenuLinks$}
homeHref={http.basePath.prepend('/app/home')}
isVisible$={this.isVisible$}
kibanaVersion={injectedMetadata.getKibanaVersion()}
@ -399,6 +406,8 @@ export class ChromeService {
setHelpSupportUrl: (url: string) => helpSupportUrl$.next(url),
getHelpSupportUrl$: () => helpSupportUrl$.pipe(takeUntil(this.stop$)),
getIsNavDrawerLocked$: () => getIsNavDrawerLocked$,
getCustomNavLink$: () => customNavLink$.pipe(takeUntil(this.stop$)),
@ -407,6 +416,10 @@ export class ChromeService {
customNavLink$.next(customNavLink);
},
setHelpMenuLinks: (helpMenuLinks: ChromeHelpMenuLink[]) => {
navControls.setHelpMenuLinks(helpMenuLinks);
},
setHeaderBanner: (headerBanner?: ChromeUserBanner) => {
headerBanner$.next(headerBanner);
},

View file

@ -1,13 +0,0 @@
/*
* 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 const KIBANA_FEEDBACK_LINK =
'https://www.elastic.co/products/kibana/feedback?blade=kibanafeedback';
export const KIBANA_ASK_ELASTIC_LINK =
'https://www.elastic.co/products/kibana/ask-elastic?blade=kibanaaskelastic';
export const GITHUB_CREATE_ISSUE_LINK = 'https://github.com/elastic/kibana/issues/new/choose';

View file

@ -9,7 +9,11 @@
import { sortBy } from 'lodash';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import type { ChromeNavControl, ChromeNavControls } from '@kbn/core-chrome-browser';
import type {
ChromeNavControl,
ChromeNavControls,
ChromeHelpMenuLink,
} from '@kbn/core-chrome-browser';
/** @internal */
export class NavControlsService {
@ -20,6 +24,7 @@ export class NavControlsService {
const navControlsRight$ = new BehaviorSubject<ReadonlySet<ChromeNavControl>>(new Set());
const navControlsCenter$ = new BehaviorSubject<ReadonlySet<ChromeNavControl>>(new Set());
const navControlsExtension$ = new BehaviorSubject<ReadonlySet<ChromeNavControl>>(new Set());
const helpMenuLinks$ = new BehaviorSubject<ChromeHelpMenuLink[]>([]);
return {
// In the future, registration should be moved to the setup phase. This
@ -36,6 +41,14 @@ export class NavControlsService {
registerExtension: (navControl: ChromeNavControl) =>
navControlsExtension$.next(new Set([...navControlsExtension$.value.values(), navControl])),
setHelpMenuLinks: (links: ChromeHelpMenuLink[]) => {
// This extension point is only intended to be used once by the cloud integration > cloud_links plugin
if (helpMenuLinks$.value.length > 0) {
throw new Error(`Help menu links have already been set`);
}
helpMenuLinks$.next(links);
},
getLeft$: () =>
navControlsLeft$.pipe(
map((controls) => sortBy([...controls.values()], 'order')),
@ -56,6 +69,7 @@ export class NavControlsService {
map((controls) => sortBy([...controls.values()], 'order')),
takeUntil(this.stop$)
),
getHelpMenuLinks$: () => helpMenuLinks$.pipe(takeUntil(this.stop$)),
};
}

View file

@ -8,10 +8,11 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { StubBrowserStorage, mountWithIntl } from '@kbn/test-jest-helpers';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { docLinksServiceMock } from '@kbn/core-doc-links-browser-mocks';
import type { ChromeBreadcrumbsAppendExtension } from '@kbn/core-chrome-browser';
import { Header } from './header';
@ -30,6 +31,7 @@ function mockProps() {
isVisible$: new BehaviorSubject(true),
customBranding$: new BehaviorSubject({}),
kibanaDocLink: '/docs',
docLinks: docLinksServiceMock.createStartContract(),
navLinks$: new BehaviorSubject([]),
customNavLink$: new BehaviorSubject(undefined),
recentlyAccessed$: new BehaviorSubject([]),
@ -87,6 +89,7 @@ describe('Header', () => {
customNavLink$={customNavLink$}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$}
headerBanner$={headerBanner$}
helpMenuLinks$={of([])}
/>
);
expect(component.find('EuiHeader').exists()).toBeFalsy();

View file

@ -27,6 +27,7 @@ import type {
ChromeBreadcrumb,
ChromeNavControl,
ChromeNavLink,
ChromeHelpMenuLink,
ChromeRecentlyAccessedHistoryItem,
ChromeBreadcrumbsAppendExtension,
ChromeHelpExtension,
@ -34,6 +35,7 @@ import type {
ChromeUserBanner,
} from '@kbn/core-chrome-browser';
import { CustomBranding } from '@kbn/core-custom-branding-common';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { LoadingIndicator } from '../loading_indicator';
import type { OnIsLockedUpdate } from './types';
import { CollapsibleNav } from './collapsible_nav';
@ -59,12 +61,14 @@ export interface HeaderProps {
homeHref: string;
isVisible$: Observable<boolean>;
kibanaDocLink: string;
docLinks: DocLinksStart;
navLinks$: Observable<ChromeNavLink[]>;
recentlyAccessed$: Observable<ChromeRecentlyAccessedHistoryItem[]>;
forceAppSwitcherNavigation$: Observable<boolean>;
globalHelpExtensionMenuLinks$: Observable<ChromeGlobalHelpExtensionMenuLink[]>;
helpExtension$: Observable<ChromeHelpExtension | undefined>;
helpSupportUrl$: Observable<string>;
helpMenuLinks$: Observable<ChromeHelpMenuLink[]>;
navControlsLeft$: Observable<readonly ChromeNavControl[]>;
navControlsCenter$: Observable<readonly ChromeNavControl[]>;
navControlsRight$: Observable<readonly ChromeNavControl[]>;
@ -79,6 +83,7 @@ export interface HeaderProps {
export function Header({
kibanaVersion,
kibanaDocLink,
docLinks,
application,
basePath,
onIsLockedUpdate,
@ -163,7 +168,9 @@ export function Header({
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={observables.helpExtension$}
helpSupportUrl$={observables.helpSupportUrl$}
defaultContentLinks$={observables.helpMenuLinks$}
kibanaDocLink={kibanaDocLink}
docLinks={docLinks}
kibanaVersion={kibanaVersion}
navigateToUrl={application.navigateToUrl}
/>,

View file

@ -10,6 +10,8 @@ import React from 'react';
import { BehaviorSubject, of } from 'rxjs';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { docLinksServiceMock } from '@kbn/core-doc-links-browser-mocks';
import { HeaderHelpMenu } from './header_help_menu';
describe('HeaderHelpMenu', () => {
@ -26,14 +28,23 @@ describe('HeaderHelpMenu', () => {
helpSupportUrl$={helpSupportUrl$}
kibanaVersion={'version'}
kibanaDocLink={''}
docLinks={docLinksServiceMock.createStartContract()}
defaultContentLinks$={of([])}
/>
);
expect(component.find('EuiButtonEmpty').length).toBe(1); // only the toggle view on/off button
component.find('EuiButtonEmpty').simulate('click');
// 4 default links + the toggle button
expect(component.find('EuiButtonEmpty').length).toBe(5);
const buttons = component.find('EuiButtonEmpty');
const buttonTexts = buttons.map((button) => button.text()).filter((text) => text.trim() !== '');
expect(buttonTexts).toEqual([
'Kibana documentation',
'Ask Elastic',
'Give feedback',
'Open an issue in GitHub',
]);
});
test('it renders the global custom content + the default content', () => {
@ -63,6 +74,8 @@ describe('HeaderHelpMenu', () => {
helpSupportUrl$={helpSupportUrl$}
kibanaVersion={'version'}
kibanaDocLink={''}
docLinks={docLinksServiceMock.createStartContract()}
defaultContentLinks$={of([])}
/>
);

View file

@ -29,17 +29,56 @@ import type {
ChromeHelpExtension,
ChromeGlobalHelpExtensionMenuLink,
} from '@kbn/core-chrome-browser';
import { GITHUB_CREATE_ISSUE_LINK, KIBANA_FEEDBACK_LINK } from '../../constants';
import type { ChromeHelpMenuLink } from '@kbn/core-chrome-browser/src';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { HeaderExtension } from './header_extension';
import { isModifiedOrPrevented } from './nav_link';
const buildDefaultContentLinks = ({
kibanaDocLink,
docLinks,
helpSupportUrl,
}: {
kibanaDocLink: string;
docLinks: DocLinksStart;
helpSupportUrl: string;
}): ChromeHelpMenuLink[] => [
{
title: i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuKibanaDocumentationTitle', {
defaultMessage: 'Kibana documentation',
}),
href: kibanaDocLink,
},
{
title: i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle', {
defaultMessage: 'Ask Elastic',
}),
href: helpSupportUrl,
},
{
title: i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackTitle', {
defaultMessage: 'Give feedback',
}),
href: docLinks.links.kibana.feedback,
},
{
title: i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuOpenGitHubIssueTitle', {
defaultMessage: 'Open an issue in GitHub',
}),
href: docLinks.links.kibana.createGithubIssue,
},
];
interface Props {
navigateToUrl: InternalApplicationStart['navigateToUrl'];
globalHelpExtensionMenuLinks$: Observable<ChromeGlobalHelpExtensionMenuLink[]>;
helpExtension$: Observable<ChromeHelpExtension | undefined>;
helpSupportUrl$: Observable<string>;
defaultContentLinks$: Observable<ChromeHelpMenuLink[]>;
kibanaVersion: string;
kibanaDocLink: string;
docLinks: DocLinksStart;
}
interface State {
@ -47,6 +86,7 @@ interface State {
helpExtension?: ChromeHelpExtension;
helpSupportUrl: string;
globalHelpExtensionMenuLinks: ChromeGlobalHelpExtensionMenuLink[];
defaultContentLinks: ChromeHelpMenuLink[];
}
export class HeaderHelpMenu extends Component<Props, State> {
@ -60,6 +100,7 @@ export class HeaderHelpMenu extends Component<Props, State> {
helpExtension: undefined,
helpSupportUrl: '',
globalHelpExtensionMenuLinks: [],
defaultContentLinks: [],
};
}
@ -67,14 +108,21 @@ export class HeaderHelpMenu extends Component<Props, State> {
this.subscription = combineLatest(
this.props.helpExtension$,
this.props.helpSupportUrl$,
this.props.globalHelpExtensionMenuLinks$
).subscribe(([helpExtension, helpSupportUrl, globalHelpExtensionMenuLinks]) => {
this.setState({
helpExtension,
helpSupportUrl,
globalHelpExtensionMenuLinks,
});
});
this.props.globalHelpExtensionMenuLinks$,
this.props.defaultContentLinks$
).subscribe(
([helpExtension, helpSupportUrl, globalHelpExtensionMenuLinks, defaultContentLinks]) => {
this.setState({
helpExtension,
helpSupportUrl,
globalHelpExtensionMenuLinks,
defaultContentLinks:
defaultContentLinks.length === 0
? buildDefaultContentLinks({ ...this.props, helpSupportUrl })
: defaultContentLinks,
});
}
);
}
public componentWillUnmount() {
@ -137,58 +185,33 @@ export class HeaderHelpMenu extends Component<Props, State> {
<div style={{ maxWidth: 240 }}>
{globalCustomContent}
{defaultContent}
{(defaultContent || customContent) && <EuiHorizontalRule margin="m" />}
{customContent}
{customContent && (
<>
<EuiHorizontalRule margin="m" />
{customContent}
</>
)}
</div>
</EuiPopover>
);
}
private renderDefaultContent() {
const { kibanaDocLink } = this.props;
const { helpSupportUrl } = this.state;
const { defaultContentLinks } = this.state;
return (
<Fragment>
<EuiButtonEmpty href={kibanaDocLink} target="_blank" size="s" flush="left">
<FormattedMessage
id="core.ui.chrome.headerGlobalNav.helpMenuKibanaDocumentationTitle"
defaultMessage="Kibana documentation"
/>
</EuiButtonEmpty>
<EuiSpacer size="xs" />
<EuiButtonEmpty href={helpSupportUrl} target="_blank" size="s" flush="left">
<FormattedMessage
id="core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle"
defaultMessage="Ask Elastic"
/>
</EuiButtonEmpty>
<EuiSpacer size="xs" />
<EuiButtonEmpty href={KIBANA_FEEDBACK_LINK} target="_blank" size="s" flush="left">
<FormattedMessage
id="core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackTitle"
defaultMessage="Give feedback"
/>
</EuiButtonEmpty>
<EuiSpacer size="xs" />
<EuiButtonEmpty
href={GITHUB_CREATE_ISSUE_LINK}
target="_blank"
size="s"
iconType="logoGithub"
flush="left"
>
<FormattedMessage
id="core.ui.chrome.headerGlobalNav.helpMenuOpenGitHubIssueTitle"
defaultMessage="Open an issue in GitHub"
/>
</EuiButtonEmpty>
{defaultContentLinks.map(({ href, title, iconType }, i) => {
const isLast = i === defaultContentLinks.length - 1;
return (
<Fragment key={i}>
<EuiButtonEmpty href={href} target="_blank" size="s" flush="left" iconType={iconType}>
{title}
</EuiButtonEmpty>
{!isLast && <EuiSpacer size="xs" />}
</Fragment>
);
})}
</Fragment>
);
}

View file

@ -8,6 +8,7 @@
import { EuiHeader } from '@elastic/eui';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { docLinksServiceMock } from '@kbn/core-doc-links-browser-mocks';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import * as Rx from 'rxjs';
@ -20,10 +21,11 @@ describe('Header', () => {
application: mockApplication,
breadcrumbs$: Rx.of([]),
actionMenu$: Rx.of(undefined),
kibanaDocLink: 'app/help/doclinks',
docLinks: docLinksServiceMock.createStartContract(),
globalHelpExtensionMenuLinks$: Rx.of([]),
helpExtension$: Rx.of(undefined),
helpSupportUrl$: Rx.of('app/help'),
helpMenuLinks$: Rx.of([]),
homeHref$: Rx.of('app/home'),
kibanaVersion: '8.9',
loadingCount$: Rx.of(0),

View file

@ -24,6 +24,7 @@ import {
ChromeBreadcrumb,
ChromeGlobalHelpExtensionMenuLink,
ChromeHelpExtension,
ChromeHelpMenuLink,
ChromeNavControl,
} from '@kbn/core-chrome-browser/src';
import type { HttpStart } from '@kbn/core-http-browser';
@ -34,6 +35,8 @@ import { Router } from '@kbn/shared-ux-router';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import useObservable from 'react-use/lib/useObservable';
import { Observable, debounceTime } from 'rxjs';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { HeaderActionMenu, useHeaderActionMenuMounter } from '../header/header_action_menu';
import { HeaderBreadcrumbs } from '../header/header_breadcrumbs';
import { HeaderHelpMenu } from '../header/header_help_menu';
@ -87,11 +90,12 @@ const headerStrings = {
export interface Props {
breadcrumbs$: Observable<ChromeBreadcrumb[]>;
actionMenu$: Observable<MountPoint | undefined>;
kibanaDocLink: string;
docLinks: DocLinksStart;
children: React.ReactNode;
globalHelpExtensionMenuLinks$: Observable<ChromeGlobalHelpExtensionMenuLink[]>;
helpExtension$: Observable<ChromeHelpExtension | undefined>;
helpSupportUrl$: Observable<string>;
helpMenuLinks$: Observable<ChromeHelpMenuLink[]>;
homeHref$: Observable<string | undefined>;
kibanaVersion: string;
application: InternalApplicationStart;
@ -158,10 +162,10 @@ const Logo = (
export const ProjectHeader = ({
application,
kibanaDocLink,
kibanaVersion,
children,
prependBasePath,
docLinks,
...observables
}: Props) => {
const [navId] = useState(htmlIdGenerator()());
@ -239,7 +243,9 @@ export const ProjectHeader = ({
globalHelpExtensionMenuLinks$={observables.globalHelpExtensionMenuLinks$}
helpExtension$={observables.helpExtension$}
helpSupportUrl$={observables.helpSupportUrl$}
kibanaDocLink={kibanaDocLink}
defaultContentLinks$={observables.helpMenuLinks$}
kibanaDocLink={docLinks.links.elasticStackGetStarted}
docLinks={docLinks}
kibanaVersion={kibanaVersion}
navigateToUrl={application.navigateToUrl}
/>

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import type { ChromeBadge, ChromeBreadcrumb } from '@kbn/core-chrome-browser';
@ -41,6 +41,8 @@ const createStartContractMock = () => {
getCenter$: jest.fn(),
getRight$: jest.fn(),
getExtension$: jest.fn(),
setHelpMenuLinks: jest.fn(),
getHelpMenuLinks$: jest.fn(),
},
setIsVisible: jest.fn(),
getIsVisible$: jest.fn(),
@ -54,7 +56,9 @@ const createStartContractMock = () => {
registerGlobalHelpExtensionMenuLink: jest.fn(),
getHelpExtension$: jest.fn(),
setHelpExtension: jest.fn(),
setHelpMenuLinks: jest.fn(),
setHelpSupportUrl: jest.fn(),
getHelpSupportUrl$: jest.fn(() => of('https://www.elastic.co/support')),
getIsNavDrawerLocked$: jest.fn(),
getCustomNavLink$: jest.fn(),
setCustomNavLink: jest.fn(),

View file

@ -14,6 +14,7 @@ export type {
ChromeBreadcrumbsAppendExtension,
ChromeDocTitle,
ChromeGlobalHelpExtensionMenuLink,
ChromeHelpMenuLink,
ChromeHelpExtension,
ChromeHelpExtensionLinkBase,
ChromeHelpExtensionMenuCustomLink,

View file

@ -10,7 +10,7 @@ import type { Observable } from 'rxjs';
import type { ChromeNavLink, ChromeNavLinks } from './nav_links';
import type { ChromeRecentlyAccessed } from './recently_accessed';
import type { ChromeDocTitle } from './doc_title';
import type { ChromeNavControls } from './nav_controls';
import type { ChromeHelpMenuLink, ChromeNavControls } from './nav_controls';
import type { ChromeHelpExtension } from './help_extension';
import type { ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension } from './breadcrumb';
import type { ChromeBadge, ChromeStyle, ChromeUserBanner } from './types';
@ -106,6 +106,11 @@ export interface ChromeStart {
*/
setCustomNavLink(newCustomNavLink?: Partial<ChromeNavLink>): void;
/**
* Override the default links shown in the help menu
*/
setHelpMenuLinks(links: ChromeHelpMenuLink[]): void;
/**
* Get the list of the registered global help extension menu links
*/
@ -134,6 +139,11 @@ export interface ChromeStart {
*/
setHelpSupportUrl(url: string): void;
/**
* Get the support URL shown in the help menu
*/
getHelpSupportUrl$(): Observable<string>;
/**
* Get an observable of the current locked state of the nav drawer.
*/

View file

@ -20,7 +20,7 @@ export type {
ChromeHelpExtensionMenuGitHubLink,
ChromeGlobalHelpExtensionMenuLink,
} from './help_extension';
export type { ChromeNavControls, ChromeNavControl } from './nav_controls';
export type { ChromeNavControls, ChromeNavControl, ChromeHelpMenuLink } from './nav_controls';
export type { ChromeNavLinks, ChromeNavLink } from './nav_links';
export type {
ChromeRecentlyAccessed,

View file

@ -15,6 +15,13 @@ export interface ChromeNavControl {
mount: MountPoint;
}
/** @public */
export interface ChromeHelpMenuLink {
title: string;
href: string;
iconType?: string;
}
/**
* {@link ChromeNavControls | APIs} for registering new controls to be displayed in the navigation bar.
*
@ -44,6 +51,9 @@ export interface ChromeNavControls {
/** Register an extension to be presented to the left of the top-right side of the chrome header. */
registerExtension(navControl: ChromeNavControl): void;
/** Set the help menu links */
setHelpMenuLinks(links: ChromeHelpMenuLink[]): void;
/** @internal */
getLeft$(): Observable<ChromeNavControl[]>;
@ -55,4 +65,7 @@ export interface ChromeNavControls {
/** @internal */
getExtension$(): Observable<ChromeNavControl[]>;
/** @internal */
getHelpMenuLinks$(): Observable<ChromeHelpMenuLink[]>;
}

View file

@ -20,6 +20,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
const DOC_LINK_VERSION = meta.version;
const ELASTIC_WEBSITE_URL = meta.elasticWebsiteUrl;
const DOCS_WEBSITE_URL = meta.docsWebsiteUrl;
const ELASTIC_GITHUB = meta.elasticGithubUrl;
const ELASTICSEARCH_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`;
const KIBANA_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/`;
@ -305,6 +306,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
},
addData: `${KIBANA_DOCS}connect-to-elasticsearch.html`,
kibana: {
askElastic: `${ELASTIC_WEBSITE_URL}products/kibana/ask-elastic?blade=kibanaaskelastic`,
createGithubIssue: `${ELASTIC_GITHUB}kibana/issues/new/choose`,
feedback: `${ELASTIC_WEBSITE_URL}products/kibana/feedback?blade=kibanafeedback`,
guide: `${KIBANA_DOCS}index.html`,
autocompleteSuggestions: `${KIBANA_DOCS}kibana-concepts-analysts.html#autocomplete-suggestions`,
secureSavedObject: `${KIBANA_DOCS}xpack-security-secure-saved-objects.html`,

View file

@ -16,6 +16,7 @@ export const getDocLinksMeta = ({ kibanaBranch }: GetDocLinksMetaOptions): DocLi
return {
version: kibanaBranch === 'main' ? 'master' : kibanaBranch,
elasticWebsiteUrl: 'https://www.elastic.co/',
elasticGithubUrl: 'https://github.com/elastic/',
docsWebsiteUrl: 'https://docs.elastic.co/',
};
};

View file

@ -12,6 +12,7 @@
export interface DocLinksMeta {
version: string;
elasticWebsiteUrl: string;
elasticGithubUrl: string;
docsWebsiteUrl: string;
}
@ -284,6 +285,9 @@ export interface DocLinks {
};
readonly addData: string;
readonly kibana: {
readonly askElastic: string;
readonly createGithubIssue: string;
readonly feedback: string;
readonly guide: string;
readonly autocompleteSuggestions: string;
readonly secureSavedObject: string;

View file

@ -17,7 +17,7 @@ pageLoadAssetSize:
cloudExperiments: 59358
cloudFullStory: 18493
cloudGainsight: 18710
cloudLinks: 17629
cloudLinks: 55984
cloudSecurityPosture: 19109
console: 46091
contentManagement: 16254

View file

@ -215,6 +215,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cloud_integrations.gain_sight.org_id (any)',
'xpack.cloud.id (string)',
'xpack.cloud.organization_url (string)',
'xpack.cloud.billing_url (string)',
'xpack.cloud.profile_url (string)',
'xpack.discoverEnhanced.actions.exploreDataInChart.enabled (boolean)',
'xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled (boolean)',

View file

@ -39,6 +39,7 @@ const createStartMock = (): jest.Mocked<CloudStart> => ({
cloudId: 'mock-cloud-id',
isCloudEnabled: true,
deploymentUrl: 'deployment-url',
billingUrl: 'billing-url',
profileUrl: 'profile-url',
organizationUrl: 'organization-url',
});

View file

@ -22,6 +22,7 @@ export interface CloudConfigType {
base_url?: string;
profile_url?: string;
deployment_url?: string;
billing_url?: string;
organization_url?: string;
trial_end_date?: string;
is_elastic_staff_owned?: boolean;
@ -30,6 +31,7 @@ export interface CloudConfigType {
interface CloudUrls {
deploymentUrl?: string;
profileUrl?: string;
billingUrl?: string;
organizationUrl?: string;
snapshotsUrl?: string;
}
@ -99,12 +101,13 @@ export class CloudPlugin implements Plugin<CloudSetup> {
);
};
const { deploymentUrl, profileUrl, organizationUrl } = this.getCloudUrls();
const { deploymentUrl, profileUrl, billingUrl, organizationUrl } = this.getCloudUrls();
return {
CloudContextProvider,
isCloudEnabled: this.isCloudEnabled,
cloudId: this.config.id,
billingUrl,
deploymentUrl,
profileUrl,
organizationUrl,
@ -116,6 +119,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
private getCloudUrls(): CloudUrls {
const {
profile_url: profileUrl,
billing_url: billingUrl,
organization_url: organizationUrl,
deployment_url: deploymentUrl,
base_url: baseUrl,
@ -123,12 +127,14 @@ export class CloudPlugin implements Plugin<CloudSetup> {
const fullCloudDeploymentUrl = getFullCloudUrl(baseUrl, deploymentUrl);
const fullCloudProfileUrl = getFullCloudUrl(baseUrl, profileUrl);
const fullCloudBillingUrl = getFullCloudUrl(baseUrl, billingUrl);
const fullCloudOrganizationUrl = getFullCloudUrl(baseUrl, organizationUrl);
const fullCloudSnapshotsUrl = `${fullCloudDeploymentUrl}/${CLOUD_SNAPSHOTS_PATH}`;
return {
deploymentUrl: fullCloudDeploymentUrl,
profileUrl: fullCloudProfileUrl,
billingUrl: fullCloudBillingUrl,
organizationUrl: fullCloudOrganizationUrl,
snapshotsUrl: fullCloudSnapshotsUrl,
};

View file

@ -28,6 +28,10 @@ export interface CloudStart {
* The full URL to the user profile page on Elastic Cloud. Undefined if not running on Cloud.
*/
profileUrl?: string;
/**
* The full URL to the billing page on Elastic Cloud. Undefined if not running on Cloud.
*/
billingUrl?: string;
/**
* The full URL to the organization management page on Elastic Cloud. Undefined if not running on Cloud.
*/

View file

@ -24,6 +24,7 @@ const configSchema = schema.object({
cname: schema.maybe(schema.string()),
deployment_url: schema.maybe(schema.string()),
id: schema.maybe(schema.string()),
billing_url: schema.maybe(schema.string()),
organization_url: schema.maybe(schema.string()),
profile_url: schema.maybe(schema.string()),
trial_end_date: schema.maybe(schema.string()),
@ -38,6 +39,7 @@ export const config: PluginConfigDescriptor<CloudConfigType> = {
cname: true,
deployment_url: true,
id: true,
billing_url: true,
organization_url: true,
profile_url: true,
trial_end_date: true,

View file

@ -0,0 +1,40 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { ChromeHelpMenuLink } from '@kbn/core-chrome-browser';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
export const createHelpMenuLinks = ({
docLinks,
helpSupportUrl,
}: {
docLinks: DocLinksStart;
helpSupportUrl: string;
}) => {
const helpMenuLinks: ChromeHelpMenuLink[] = [
{
title: i18n.translate('xpack.cloudLinks.helpMenuLinks.documentation', {
defaultMessage: 'Documentation',
}),
href: docLinks.links.elasticStackGetStarted,
},
{
title: i18n.translate('xpack.cloudLinks.helpMenuLinks.support', {
defaultMessage: 'Support',
}),
href: helpSupportUrl,
},
{
title: i18n.translate('xpack.cloudLinks.helpMenuLinks.giveFeedback', {
defaultMessage: 'Give feedback',
}),
href: docLinks.links.kibana.feedback,
},
];
return helpMenuLinks;
};

View file

@ -18,6 +18,7 @@ describe('maybeAddCloudLinks', () => {
security,
chrome: coreMock.createStart().chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: false },
docLinks: coreMock.createStart().docLinks,
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
@ -29,11 +30,12 @@ describe('maybeAddCloudLinks', () => {
security.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({ elastic_cloud_user: true })
);
const chrome = coreMock.createStart().chrome;
const { chrome, docLinks } = coreMock.createStart();
maybeAddCloudLinks({
security,
chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: true },
docLinks,
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
@ -55,15 +57,41 @@ describe('maybeAddCloudLinks', () => {
Object {
"href": "profile-url",
"iconType": "user",
"label": "Edit profile",
"label": "Profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "billing-url",
"iconType": "visGauge",
"label": "Billing",
"order": 200,
},
Object {
"href": "organization-url",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
"label": "Organization",
"order": 300,
},
],
]
`);
expect(chrome.setHelpMenuLinks).toHaveBeenCalledTimes(1);
expect(chrome.setHelpMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "https://www.elastic.co/guide/en/index.html",
"title": "Documentation",
},
Object {
"href": "https://www.elastic.co/support",
"title": "Support",
},
Object {
"href": "https://www.elastic.co/products/kibana/feedback?blade=kibanafeedback",
"title": "Give feedback",
},
],
]
@ -73,11 +101,12 @@ describe('maybeAddCloudLinks', () => {
it('when cloud enabled and it fails to fetch the user, it sets the links', async () => {
const security = securityMock.createStart();
security.authc.getCurrentUser.mockRejectedValue(new Error('Something went terribly wrong'));
const chrome = coreMock.createStart().chrome;
const { chrome, docLinks } = coreMock.createStart();
maybeAddCloudLinks({
security,
chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: true },
docLinks,
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
@ -99,15 +128,40 @@ describe('maybeAddCloudLinks', () => {
Object {
"href": "profile-url",
"iconType": "user",
"label": "Edit profile",
"label": "Profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "billing-url",
"iconType": "visGauge",
"label": "Billing",
"order": 200,
},
Object {
"href": "organization-url",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
"label": "Organization",
"order": 300,
},
],
]
`);
expect(chrome.setHelpMenuLinks).toHaveBeenCalledTimes(1);
expect(chrome.setHelpMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "https://www.elastic.co/guide/en/index.html",
"title": "Documentation",
},
Object {
"href": "https://www.elastic.co/support",
"title": "Support",
},
Object {
"href": "https://www.elastic.co/products/kibana/feedback?blade=kibanafeedback",
"title": "Give feedback",
},
],
]
@ -119,16 +173,18 @@ describe('maybeAddCloudLinks', () => {
security.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({ elastic_cloud_user: false })
);
const chrome = coreMock.createStart().chrome;
const { chrome, docLinks } = coreMock.createStart();
maybeAddCloudLinks({
security,
chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: true },
docLinks,
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
expect(security.authc.getCurrentUser).toHaveBeenCalledTimes(1);
expect(chrome.setCustomNavLink).not.toHaveBeenCalled();
expect(security.navControlService.addUserMenuLinks).not.toHaveBeenCalled();
expect(chrome.setHelpMenuLinks).not.toHaveBeenCalledTimes(1);
});
});

View file

@ -5,44 +5,63 @@
* 2.0.
*/
import { catchError, defer, filter, map, of } from 'rxjs';
import { catchError, defer, filter, map, of, combineLatest } from 'rxjs';
import { i18n } from '@kbn/i18n';
import type { CloudStart } from '@kbn/cloud-plugin/public';
import type { ChromeStart } from '@kbn/core/public';
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { createUserMenuLinks } from './user_menu_links';
import { createHelpMenuLinks } from './help_menu_links';
export interface MaybeAddCloudLinksDeps {
security: SecurityPluginStart;
chrome: ChromeStart;
cloud: CloudStart;
docLinks: DocLinksStart;
}
export function maybeAddCloudLinks({ security, chrome, cloud }: MaybeAddCloudLinksDeps): void {
export function maybeAddCloudLinks({
security,
chrome,
cloud,
docLinks,
}: MaybeAddCloudLinksDeps): void {
const userObservable = defer(() => security.authc.getCurrentUser()).pipe(
// Check if user is a cloud user.
map((user) => user.elastic_cloud_user),
// If user is not defined due to an unexpected error, then fail *open*.
catchError(() => of(true)),
filter((isElasticCloudUser) => isElasticCloudUser === true),
map(() => {
if (cloud.deploymentUrl) {
chrome.setCustomNavLink({
title: i18n.translate('xpack.cloudLinks.deploymentLinkLabel', {
defaultMessage: 'Manage this deployment',
}),
euiIconType: 'logoCloud',
href: cloud.deploymentUrl,
});
}
const userMenuLinks = createUserMenuLinks(cloud);
security.navControlService.addUserMenuLinks(userMenuLinks);
})
);
const helpObservable = chrome.getHelpSupportUrl$();
if (cloud.isCloudEnabled) {
defer(() => security.authc.getCurrentUser())
.pipe(
// Check if user is a cloud user.
map((user) => user.elastic_cloud_user),
// If user is not defined due to an unexpected error, then fail *open*.
catchError(() => of(true)),
filter((isElasticCloudUser) => isElasticCloudUser === true),
map(() => {
if (cloud.deploymentUrl) {
chrome.setCustomNavLink({
title: i18n.translate('xpack.cloudLinks.deploymentLinkLabel', {
defaultMessage: 'Manage this deployment',
}),
euiIconType: 'logoCloud',
href: cloud.deploymentUrl,
});
}
const userMenuLinks = createUserMenuLinks(cloud);
security.navControlService.addUserMenuLinks(userMenuLinks);
})
)
.subscribe();
combineLatest({ user: userObservable, helpSupportUrl: helpObservable }).subscribe(
({ helpSupportUrl }) => {
const helpMenuLinks = createHelpMenuLinks({
docLinks,
helpSupportUrl,
});
chrome.setHelpMenuLinks(helpMenuLinks);
}
);
}
}

View file

@ -10,13 +10,14 @@ import type { CloudStart } from '@kbn/cloud-plugin/public';
import type { UserMenuLink } from '@kbn/security-plugin/public';
export const createUserMenuLinks = (cloud: CloudStart): UserMenuLink[] => {
const { profileUrl, organizationUrl } = cloud;
const { profileUrl, billingUrl, organizationUrl } = cloud;
const userMenuLinks = [] as UserMenuLink[];
if (profileUrl) {
userMenuLinks.push({
label: i18n.translate('xpack.cloudLinks.userMenuLinks.profileLinkText', {
defaultMessage: 'Edit profile',
defaultMessage: 'Profile',
}),
iconType: 'user',
href: profileUrl,
@ -25,14 +26,25 @@ export const createUserMenuLinks = (cloud: CloudStart): UserMenuLink[] => {
});
}
if (billingUrl) {
userMenuLinks.push({
label: i18n.translate('xpack.cloudLinks.userMenuLinks.billingLinkText', {
defaultMessage: 'Billing',
}),
iconType: 'visGauge',
href: billingUrl,
order: 200,
});
}
if (organizationUrl) {
userMenuLinks.push({
label: i18n.translate('xpack.cloudLinks.userMenuLinks.accountLinkText', {
defaultMessage: 'Account & Billing',
label: i18n.translate('xpack.cloudLinks.userMenuLinks.organizationLinkText', {
defaultMessage: 'Organization',
}),
iconType: 'gear',
href: organizationUrl,
order: 200,
order: 300,
});
}

View file

@ -43,7 +43,7 @@ export class CloudLinksPlugin
});
}
if (security) {
maybeAddCloudLinks({ security, chrome: core.chrome, cloud });
maybeAddCloudLinks({ security, chrome: core.chrome, cloud, docLinks: core.docLinks });
}
}
}

View file

@ -17,6 +17,8 @@
"@kbn/i18n",
"@kbn/i18n-react",
"@kbn/guided-onboarding-plugin",
"@kbn/core-chrome-browser",
"@kbn/core-doc-links-browser",
],
"exclude": [
"target/**/*",

View file

@ -39320,7 +39320,6 @@
"xpack.cloudDataMigration.upgrade.text": "Effectuer la mise à niveau vers les versions plus récentes beaucoup plus facilement",
"xpack.cloudLinks.deploymentLinkLabel": "Gérer ce déploiement",
"xpack.cloudLinks.setupGuide": "Guides de configuration",
"xpack.cloudLinks.userMenuLinks.accountLinkText": "Compte et facturation",
"xpack.cloudLinks.userMenuLinks.profileLinkText": "Modifier le profil",
"xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "Choisir le tableau de bord de destination",
"xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "Ouvrir le tableau de bord dans un nouvel onglet",

View file

@ -39294,7 +39294,6 @@
"xpack.cloudDataMigration.upgrade.text": "新しいバージョンへのアップグレードがより簡単に",
"xpack.cloudLinks.deploymentLinkLabel": "このデプロイの管理",
"xpack.cloudLinks.setupGuide": "セットアップガイド",
"xpack.cloudLinks.userMenuLinks.accountLinkText": "会計・請求",
"xpack.cloudLinks.userMenuLinks.profileLinkText": "プロフィールを編集",
"xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "対象ダッシュボードを選択",
"xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "新しいタブでダッシュボードを開く",

View file

@ -39288,7 +39288,6 @@
"xpack.cloudDataMigration.upgrade.text": "更轻松地升级到较新版本",
"xpack.cloudLinks.deploymentLinkLabel": "管理此部署",
"xpack.cloudLinks.setupGuide": "设置指南",
"xpack.cloudLinks.userMenuLinks.accountLinkText": "帐户和帐单",
"xpack.cloudLinks.userMenuLinks.profileLinkText": "编辑配置文件",
"xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "选择目标仪表板",
"xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "在新选项卡中打开仪表板",

View file

@ -48,6 +48,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'--xpack.cloud.base_url=https://cloud.elastic.co',
'--xpack.cloud.deployment_url=/deployments/deploymentId',
'--xpack.cloud.organization_url=/organization/organizationId',
'--xpack.cloud.billing_url=/billing',
'--xpack.cloud.profile_url=/user/userId',
'--xpack.security.authc.selector.enabled=false',
`--xpack.security.authc.providers=${JSON.stringify({

View file

@ -55,18 +55,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('Fills up the user menu items', () => {
it('Shows the button Edit profile', async () => {
await PageObjects.common.clickAndValidate('userMenuButton', 'userMenuLink__Edit profile');
const cloudLink = await find.byLinkText('Edit profile');
it('Shows the button Profile', async () => {
await PageObjects.common.clickAndValidate('userMenuButton', 'userMenuLink__Profile');
const cloudLink = await find.byLinkText('Profile');
expect(cloudLink).to.not.be(null);
});
it('Shows the button Account & Billing', async () => {
await PageObjects.common.clickAndValidate(
'userMenuButton',
'userMenuLink__Account & Billing'
);
const cloudLink = await find.byLinkText('Account & Billing');
it('Shows the button Billing', async () => {
await PageObjects.common.clickAndValidate('userMenuButton', 'userMenuLink__Billing');
const cloudLink = await find.byLinkText('Billing');
expect(cloudLink).to.not.be(null);
});
it('Shows the button Organization', async () => {
await PageObjects.common.clickAndValidate('userMenuButton', 'userMenuLink__Organization');
const cloudLink = await find.byLinkText('Organization');
expect(cloudLink).to.not.be(null);
});
});