Implement custom global header banner (#87438)

* first draft

* update plugin list

* fix tsproject

* update bundle limits file

* remove unused start dep

* adapt imports

* POC of footer banner

* update styles, mostly

* plug banner to uiSettings

* adding some unit tests

* add tests on sort_fields

* cleanup sums in sass mixins

* some self review stuff

* update generated doc

* add tests for color field

* update chrome header test snapshots

* retrieve license info from the server

* switch from uiSettings to plugin config

* update plugin list description

* update default colors

* NIT

* add markdown support

* fix banner overlap in fullscreen mode

* change banner height to 32px

* change banner's font size to 14

* delete unused uiSettings
This commit is contained in:
Pierre Gayvallet 2021-02-11 10:12:24 +01:00 committed by GitHub
parent 9870ade971
commit 570dcc07b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 1855 additions and 282 deletions

View file

@ -32,6 +32,7 @@ rules:
- function
- return
- for
- at-root
comment-no-empty: true
no-duplicate-at-import-rules: true
no-duplicate-selectors: true

View file

@ -301,6 +301,10 @@ which will load the visualization's editor.
|To access an elasticsearch instance that has live data you have two options:
|{kib-repo}blob/{branch}/x-pack/plugins/banners/README.md[banners]
|Allow to add a header banner that will be displayed on every page of the Kibana application
|{kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement]
|Notes:
Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place

View file

@ -67,6 +67,7 @@ core.chrome.setHelpExtension(elem => {
| [setBreadcrumbs(newBreadcrumbs)](./kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | Override the current set of breadcrumbs |
| [setBreadcrumbsAppendExtension(breadcrumbsAppendExtension)](./kibana-plugin-core-public.chromestart.setbreadcrumbsappendextension.md) | Mount an element next to the last breadcrumb |
| [setCustomNavLink(newCustomNavLink)](./kibana-plugin-core-public.chromestart.setcustomnavlink.md) | Override the current set of custom nav link |
| [setHeaderBanner(headerBanner)](./kibana-plugin-core-public.chromestart.setheaderbanner.md) | Set the banner that will appear on top of the chrome header. |
| [setHelpExtension(helpExtension)](./kibana-plugin-core-public.chromestart.sethelpextension.md) | Override the current set of custom help content |
| [setHelpSupportUrl(url)](./kibana-plugin-core-public.chromestart.sethelpsupporturl.md) | Override the default support URL shown in the help menu |
| [setIsVisible(isVisible)](./kibana-plugin-core-public.chromestart.setisvisible.md) | Set the temporary visibility for the chrome. This does nothing if the chrome is hidden by default and should be used to hide the chrome for things like full-screen modes with an exit button. |

View file

@ -0,0 +1,28 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [ChromeStart](./kibana-plugin-core-public.chromestart.md) &gt; [setHeaderBanner](./kibana-plugin-core-public.chromestart.setheaderbanner.md)
## ChromeStart.setHeaderBanner() method
Set the banner that will appear on top of the chrome header.
<b>Signature:</b>
```typescript
setHeaderBanner(headerBanner?: ChromeUserBanner): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| headerBanner | <code>ChromeUserBanner</code> | |
<b>Returns:</b>
`void`
## Remarks
Using `undefined` when invoking this API will remove the banner.

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) &gt; [content](./kibana-plugin-core-public.chromeuserbanner.content.md)
## ChromeUserBanner.content property
<b>Signature:</b>
```typescript
content: MountPoint<HTMLDivElement>;
```

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md)
## ChromeUserBanner interface
<b>Signature:</b>
```typescript
export interface ChromeUserBanner
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [content](./kibana-plugin-core-public.chromeuserbanner.content.md) | <code>MountPoint&lt;HTMLDivElement&gt;</code> | |

File diff suppressed because one or more lines are too long

View file

@ -56,6 +56,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [ChromeRecentlyAccessed](./kibana-plugin-core-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-core-public.chromerecentlyaccessed.md) for recently accessed history. |
| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-core-public.chromerecentlyaccessedhistoryitem.md) | |
| [ChromeStart](./kibana-plugin-core-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. |
| [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) | |
| [CoreSetup](./kibana-plugin-core-public.coresetup.md) | Core services exposed to the <code>Plugin</code> setup lifecycle |
| [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the <code>Plugin</code> start lifecycle |
| [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | |

View file

@ -23,6 +23,7 @@ export interface UiSettingsParams<T = unknown>
| [name](./kibana-plugin-core-public.uisettingsparams.name.md) | <code>string</code> | title in the UI |
| [optionLabels](./kibana-plugin-core-public.uisettingsparams.optionlabels.md) | <code>Record&lt;string, string&gt;</code> | text labels for 'select' type UI element |
| [options](./kibana-plugin-core-public.uisettingsparams.options.md) | <code>string[]</code> | array of permitted values for this setting |
| [order](./kibana-plugin-core-public.uisettingsparams.order.md) | <code>number</code> | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name |
| [readonly](./kibana-plugin-core-public.uisettingsparams.readonly.md) | <code>boolean</code> | a flag indicating that value cannot be changed |
| [requiresPageReload](./kibana-plugin-core-public.uisettingsparams.requirespagereload.md) | <code>boolean</code> | a flag indicating whether new value applying requires page reloading |
| [schema](./kibana-plugin-core-public.uisettingsparams.schema.md) | <code>Type&lt;T&gt;</code> | |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) &gt; [order](./kibana-plugin-core-public.uisettingsparams.order.md)
## UiSettingsParams.order property
index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI.
settings without order defined will be displayed last and ordered by name
<b>Signature:</b>
```typescript
order?: number;
```

View file

@ -9,5 +9,5 @@ UI element type to represent the settings.
<b>Signature:</b>
```typescript
export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image';
export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color';
```

View file

@ -23,6 +23,7 @@ export interface UiSettingsParams<T = unknown>
| [name](./kibana-plugin-core-server.uisettingsparams.name.md) | <code>string</code> | title in the UI |
| [optionLabels](./kibana-plugin-core-server.uisettingsparams.optionlabels.md) | <code>Record&lt;string, string&gt;</code> | text labels for 'select' type UI element |
| [options](./kibana-plugin-core-server.uisettingsparams.options.md) | <code>string[]</code> | array of permitted values for this setting |
| [order](./kibana-plugin-core-server.uisettingsparams.order.md) | <code>number</code> | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name |
| [readonly](./kibana-plugin-core-server.uisettingsparams.readonly.md) | <code>boolean</code> | a flag indicating that value cannot be changed |
| [requiresPageReload](./kibana-plugin-core-server.uisettingsparams.requirespagereload.md) | <code>boolean</code> | a flag indicating whether new value applying requires page reloading |
| [schema](./kibana-plugin-core-server.uisettingsparams.schema.md) | <code>Type&lt;T&gt;</code> | |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) &gt; [order](./kibana-plugin-core-server.uisettingsparams.order.md)
## UiSettingsParams.order property
index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI.
settings without order defined will be displayed last and ordered by name
<b>Signature:</b>
```typescript
order?: number;
```

View file

@ -9,5 +9,5 @@ UI element type to represent the settings.
<b>Signature:</b>
```typescript
export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image';
export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color';
```

View file

@ -105,3 +105,4 @@ pageLoadAssetSize:
spacesOss: 18817
osquery: 107090
fileUpload: 25664
banners: 17946

View file

@ -0,0 +1,43 @@
@import './variables';
/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */
@mixin kibanaFullBodyHeight($additionalOffset: 0px) {
// default - header, no banner
height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset});
@at-root {
// no header, no banner
.kbnBody--chromeHidden & {
height: calc(100vh - #{$additionalOffset});
}
// header, banner
.kbnBody--hasHeaderBanner & {
height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset});
}
// no header, banner
.kbnBody--chromeHidden.kbnBody--hasHeaderBanner & {
height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset});
}
}
}
/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */
@mixin kibanaFullBodyMinHeight($additionalOffset: 0px) {
// default - header, no banner
min-height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset});
@at-root {
// no header, no banner
.kbnBody--chromeHidden & {
min-height: calc(100vh - #{$additionalOffset});
}
// header, banner
.kbnBody--hasHeaderBanner & {
min-height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset});
}
// no header, banner
.kbnBody--chromeHidden.kbnBody--hasHeaderBanner & {
min-height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset});
}
}
}

View file

@ -1,3 +1,8 @@
@import '@elastic/eui/src/global_styling/variables/header';
// height of the header banner
$kbnHeaderBannerHeight: $euiSizeXL;
// total height of the header (when the banner is *not* present)
$kbnHeaderOffset: $euiHeaderHeightCompensation * 2;
// total height of the header when the banner is present
$kbnHeaderOffsetWithBanner: $kbnHeaderOffset + $kbnHeaderBannerHeight;

View file

@ -61,6 +61,8 @@ const createStartContractMock = () => {
getIsNavDrawerLocked$: jest.fn(),
getCustomNavLink$: jest.fn(),
setCustomNavLink: jest.fn(),
setHeaderBanner: jest.fn(),
getBodyClasses$: jest.fn(),
};
startContract.navLinks.getAll.mockReturnValue([]);
startContract.getBrand$.mockReturnValue(new BehaviorSubject({} as ChromeBrand));
@ -72,6 +74,7 @@ const createStartContractMock = () => {
startContract.getCustomNavLink$.mockReturnValue(new BehaviorSubject(undefined));
startContract.getHelpExtension$.mockReturnValue(new BehaviorSubject(undefined));
startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false));
startContract.getBodyClasses$.mockReturnValue(new BehaviorSubject([]));
return startContract;
};

View file

@ -6,69 +6,37 @@
* Side Public License, v 1.
*/
import { EuiBreadcrumb, IconType } from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs';
import { flatMap, map, takeUntil } from 'rxjs/operators';
import { parse } from 'url';
import { EuiLink } from '@elastic/eui';
import { MountPoint } from '../types';
import { mountReactNode } from '../utils/mount';
import { InternalApplicationStart } from '../application';
import { DocLinksStart } from '../doc_links';
import { HttpStart } from '../http';
import { InjectedMetadataStart } from '../injected_metadata';
import { NotificationsStart } from '../notifications';
import { IUiSettingsClient } from '../ui_settings';
import { KIBANA_ASK_ELASTIC_LINK } from './constants';
import { ChromeDocTitle, DocTitleService } from './doc_title';
import { ChromeNavControls, NavControlsService } from './nav_controls';
import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links';
import { NavLinksService, ChromeNavLink } from './nav_links';
import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed';
import { Header } from './ui';
import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu';
export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle };
import {
ChromeBadge,
ChromeBrand,
ChromeBreadcrumb,
ChromeBreadcrumbsAppendExtension,
ChromeHelpExtension,
InternalChromeStart,
ChromeUserBanner,
} from './types';
const IS_LOCKED_KEY = 'core.chrome.isLocked';
/** @public */
export interface ChromeBadge {
text: string;
tooltip: string;
iconType?: IconType;
}
/** @public */
export interface ChromeBrand {
logo?: string;
smallLogo?: string;
}
/** @public */
export type ChromeBreadcrumb = EuiBreadcrumb;
/** @public */
export interface ChromeBreadcrumbsAppendExtension {
content: MountPoint<HTMLDivElement>;
}
/** @public */
export interface ChromeHelpExtension {
/**
* Provide your plugin's name to create a header for separation
*/
appName: string;
/**
* Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button
*/
links?: ChromeHelpExtensionMenuLink[];
/**
* Custom content to occur below the list of links
*/
content?: (element: HTMLDivElement) => () => void;
}
interface ConstructorParams {
browserSupportsCsp: boolean;
}
@ -79,7 +47,6 @@ interface StartDeps {
http: HttpStart;
injectedMetadata: InjectedMetadataStart;
notifications: NotificationsStart;
uiSettings: IUiSettingsClient;
}
/** @internal */
@ -132,7 +99,6 @@ export class ChromeService {
http,
injectedMetadata,
notifications,
uiSettings,
}: StartDeps): Promise<InternalChromeStart> {
this.initVisibility(application);
@ -149,6 +115,17 @@ export class ChromeService {
const helpSupportUrl$ = new BehaviorSubject<string>(KIBANA_ASK_ELASTIC_LINK);
const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true');
const headerBanner$ = new BehaviorSubject<ChromeUserBanner | undefined>(undefined);
const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe(
map(([headerBanner, isVisible]) => {
return [
'kbnBody',
headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner',
isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden',
];
})
);
const navControls = this.navControls.start();
const navLinks = this.navLinks.start({ application, http });
const recentlyAccessed = await this.recentlyAccessed.start({ http });
@ -220,6 +197,7 @@ export class ChromeService {
loadingCount$={http.getLoadingCount$()}
application={application}
appTitle$={appTitle$.pipe(takeUntil(this.stop$))}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
badge$={badge$.pipe(takeUntil(this.stop$))}
basePath={http.basePath}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
@ -312,6 +290,12 @@ export class ChromeService {
setCustomNavLink: (customNavLink?: ChromeNavLink) => {
customNavLink$.next(customNavLink);
},
setHeaderBanner: (headerBanner?: ChromeUserBanner) => {
headerBanner$.next(headerBanner);
},
getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)),
};
}
@ -320,173 +304,3 @@ export class ChromeService {
this.stop$.next();
}
}
/**
* ChromeStart allows plugins to customize the global chrome header UI and
* enrich the UX with additional information about the current location of the
* browser.
*
* @remarks
* While ChromeStart exposes many APIs, they should be used sparingly and the
* developer should understand how they affect other plugins and applications.
*
* @example
* How to add a recently accessed item to the sidebar:
* ```ts
* core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234');
* ```
*
* @example
* How to set the help dropdown extension:
* ```tsx
* core.chrome.setHelpExtension(elem => {
* ReactDOM.render(<MyHelpComponent />, elem);
* return () => ReactDOM.unmountComponentAtNode(elem);
* });
* ```
*
* @public
*/
export interface ChromeStart {
/** {@inheritdoc ChromeNavLinks} */
navLinks: ChromeNavLinks;
/** {@inheritdoc ChromeNavControls} */
navControls: ChromeNavControls;
/** {@inheritdoc ChromeRecentlyAccessed} */
recentlyAccessed: ChromeRecentlyAccessed;
/** {@inheritdoc ChromeDocTitle} */
docTitle: ChromeDocTitle;
/**
* Sets the current app's title
*
* @internalRemarks
* This should be handled by the application service once it is in charge
* of mounting applications.
*/
setAppTitle(appTitle: string): void;
/**
* Get an observable of the current brand information.
*/
getBrand$(): Observable<ChromeBrand>;
/**
* Set the brand configuration.
*
* @remarks
* Normally the `logo` property will be rendered as the
* CSS background for the home link in the chrome navigation, but when the page is
* rendered in a small window the `smallLogo` will be used and rendered at about
* 45px wide.
*
* @example
* ```js
* chrome.setBrand({
* logo: 'url(/plugins/app/logo.png) center no-repeat'
* smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat'
* })
* ```
*
*/
setBrand(brand: ChromeBrand): void;
/**
* Get an observable of the current visibility state of the chrome.
*/
getIsVisible$(): Observable<boolean>;
/**
* Set the temporary visibility for the chrome. This does nothing if the chrome is hidden
* by default and should be used to hide the chrome for things like full-screen modes
* with an exit button.
*/
setIsVisible(isVisible: boolean): void;
/**
* Get the current set of classNames that will be set on the application container.
*/
getApplicationClasses$(): Observable<string[]>;
/**
* Add a className that should be set on the application container.
*/
addApplicationClass(className: string): void;
/**
* Remove a className added with `addApplicationClass()`. If className is unknown it is ignored.
*/
removeApplicationClass(className: string): void;
/**
* Get an observable of the current badge
*/
getBadge$(): Observable<ChromeBadge | undefined>;
/**
* Override the current badge
*/
setBadge(badge?: ChromeBadge): void;
/**
* Get an observable of the current list of breadcrumbs
*/
getBreadcrumbs$(): Observable<ChromeBreadcrumb[]>;
/**
* Override the current set of breadcrumbs
*/
setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void;
/**
* Get an observable of the current extension appended to breadcrumbs
*/
getBreadcrumbsAppendExtension$(): Observable<ChromeBreadcrumbsAppendExtension | undefined>;
/**
* Mount an element next to the last breadcrumb
*/
setBreadcrumbsAppendExtension(
breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension
): void;
/**
* Get an observable of the current custom nav link
*/
getCustomNavLink$(): Observable<Partial<ChromeNavLink> | undefined>;
/**
* Override the current set of custom nav link
*/
setCustomNavLink(newCustomNavLink?: Partial<ChromeNavLink>): void;
/**
* Get an observable of the current custom help conttent
*/
getHelpExtension$(): Observable<ChromeHelpExtension | undefined>;
/**
* Override the current set of custom help content
*/
setHelpExtension(helpExtension?: ChromeHelpExtension): void;
/**
* Override the default support URL shown in the help menu
* @param url The updated support URL
*/
setHelpSupportUrl(url: string): void;
/**
* Get an observable of the current locked state of the nav drawer.
*/
getIsNavDrawerLocked$(): Observable<boolean>;
}
/** @internal */
export interface InternalChromeStart extends ChromeStart {
/**
* Used only by MountingService to render the header UI
* @internal
*/
getHeaderComponent(): JSX.Element;
}

View file

@ -6,15 +6,7 @@
* Side Public License, v 1.
*/
export {
ChromeBadge,
ChromeBreadcrumb,
ChromeService,
ChromeStart,
InternalChromeStart,
ChromeBrand,
ChromeHelpExtension,
} from './chrome_service';
export { ChromeService } from './chrome_service';
export {
ChromeHelpExtensionLinkBase,
ChromeHelpExtensionMenuLink,
@ -28,3 +20,13 @@ export { ChromeNavLink, ChromeNavLinks, ChromeNavLinkUpdateableFields } from './
export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './recently_accessed';
export { ChromeNavControl, ChromeNavControls } from './nav_controls';
export { ChromeDocTitle } from './doc_title';
export {
InternalChromeStart,
ChromeStart,
ChromeHelpExtension,
ChromeBreadcrumbsAppendExtension,
ChromeBreadcrumb,
ChromeBrand,
ChromeBadge,
ChromeUserBanner,
} from './types';

View file

@ -0,0 +1,241 @@
/*
* 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 { EuiBreadcrumb, IconType } from '@elastic/eui';
import { Observable } from 'rxjs';
import { MountPoint } from '../types';
import { ChromeDocTitle } from './doc_title';
import { ChromeNavControls } from './nav_controls';
import { ChromeNavLinks, ChromeNavLink } from './nav_links';
import { ChromeRecentlyAccessed } from './recently_accessed';
import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu';
/** @public */
export interface ChromeBadge {
text: string;
tooltip: string;
iconType?: IconType;
}
/** @public */
export interface ChromeBrand {
logo?: string;
smallLogo?: string;
}
/** @public */
export type ChromeBreadcrumb = EuiBreadcrumb;
/** @public */
export interface ChromeBreadcrumbsAppendExtension {
content: MountPoint<HTMLDivElement>;
}
/** @public */
export interface ChromeUserBanner {
content: MountPoint<HTMLDivElement>;
}
/** @public */
export interface ChromeHelpExtension {
/**
* Provide your plugin's name to create a header for separation
*/
appName: string;
/**
* Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button
*/
links?: ChromeHelpExtensionMenuLink[];
/**
* Custom content to occur below the list of links
*/
content?: (element: HTMLDivElement) => () => void;
}
/**
* ChromeStart allows plugins to customize the global chrome header UI and
* enrich the UX with additional information about the current location of the
* browser.
*
* @remarks
* While ChromeStart exposes many APIs, they should be used sparingly and the
* developer should understand how they affect other plugins and applications.
*
* @example
* How to add a recently accessed item to the sidebar:
* ```ts
* core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234');
* ```
*
* @example
* How to set the help dropdown extension:
* ```tsx
* core.chrome.setHelpExtension(elem => {
* ReactDOM.render(<MyHelpComponent />, elem);
* return () => ReactDOM.unmountComponentAtNode(elem);
* });
* ```
*
* @public
*/
export interface ChromeStart {
/** {@inheritdoc ChromeNavLinks} */
navLinks: ChromeNavLinks;
/** {@inheritdoc ChromeNavControls} */
navControls: ChromeNavControls;
/** {@inheritdoc ChromeRecentlyAccessed} */
recentlyAccessed: ChromeRecentlyAccessed;
/** {@inheritdoc ChromeDocTitle} */
docTitle: ChromeDocTitle;
/**
* Sets the current app's title
*
* @internalRemarks
* This should be handled by the application service once it is in charge
* of mounting applications.
*/
setAppTitle(appTitle: string): void;
/**
* Get an observable of the current brand information.
*/
getBrand$(): Observable<ChromeBrand>;
/**
* Set the brand configuration.
*
* @remarks
* Normally the `logo` property will be rendered as the
* CSS background for the home link in the chrome navigation, but when the page is
* rendered in a small window the `smallLogo` will be used and rendered at about
* 45px wide.
*
* @example
* ```js
* chrome.setBrand({
* logo: 'url(/plugins/app/logo.png) center no-repeat'
* smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat'
* })
* ```
*
*/
setBrand(brand: ChromeBrand): void;
/**
* Get an observable of the current visibility state of the chrome.
*/
getIsVisible$(): Observable<boolean>;
/**
* Set the temporary visibility for the chrome. This does nothing if the chrome is hidden
* by default and should be used to hide the chrome for things like full-screen modes
* with an exit button.
*/
setIsVisible(isVisible: boolean): void;
/**
* Get the current set of classNames that will be set on the application container.
*/
getApplicationClasses$(): Observable<string[]>;
/**
* Add a className that should be set on the application container.
*/
addApplicationClass(className: string): void;
/**
* Remove a className added with `addApplicationClass()`. If className is unknown it is ignored.
*/
removeApplicationClass(className: string): void;
/**
* Get an observable of the current badge
*/
getBadge$(): Observable<ChromeBadge | undefined>;
/**
* Override the current badge
*/
setBadge(badge?: ChromeBadge): void;
/**
* Get an observable of the current list of breadcrumbs
*/
getBreadcrumbs$(): Observable<ChromeBreadcrumb[]>;
/**
* Override the current set of breadcrumbs
*/
setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void;
/**
* Get an observable of the current extension appended to breadcrumbs
*/
getBreadcrumbsAppendExtension$(): Observable<ChromeBreadcrumbsAppendExtension | undefined>;
/**
* Mount an element next to the last breadcrumb
*/
setBreadcrumbsAppendExtension(
breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension
): void;
/**
* Get an observable of the current custom nav link
*/
getCustomNavLink$(): Observable<Partial<ChromeNavLink> | undefined>;
/**
* Override the current set of custom nav link
*/
setCustomNavLink(newCustomNavLink?: Partial<ChromeNavLink>): void;
/**
* Get an observable of the current custom help conttent
*/
getHelpExtension$(): Observable<ChromeHelpExtension | undefined>;
/**
* Override the current set of custom help content
*/
setHelpExtension(helpExtension?: ChromeHelpExtension): void;
/**
* Override the default support URL shown in the help menu
* @param url The updated support URL
*/
setHelpSupportUrl(url: string): void;
/**
* Get an observable of the current locked state of the nav drawer.
*/
getIsNavDrawerLocked$(): Observable<boolean>;
/**
* Set the banner that will appear on top of the chrome header.
*
* @remarks Using `undefined` when invoking this API will remove the banner.
*/
setHeaderBanner(headerBanner?: ChromeUserBanner): void;
}
/** @internal */
export interface InternalChromeStart extends ChromeStart {
/**
* Used only by the rendering service to render the header UI
* @internal
*/
getHeaderComponent(): JSX.Element;
/**
* Used only by the rendering service to retrieve the set of classNames
* that will be set on the body element.
* @internal
*/
getBodyClasses$(): Observable<string[]>;
}

View file

@ -452,6 +452,55 @@ exports[`Header renders 1`] = `
"thrownError": null,
}
}
headerBanner$={
BehaviorSubject {
"_isScalar": false,
"_value": undefined,
"closed": false,
"hasError": false,
"isStopped": false,
"observers": Array [
Subscriber {
"_parentOrParents": null,
"_subscriptions": Array [
SubjectSubscription {
"_parentOrParents": [Circular],
"_subscriptions": null,
"closed": false,
"subject": [Circular],
"subscriber": [Circular],
},
],
"closed": false,
"destination": SafeSubscriber {
"_complete": undefined,
"_context": [Circular],
"_error": undefined,
"_next": [Function],
"_parentOrParents": null,
"_parentSubscriber": [Circular],
"_subscriptions": null,
"closed": false,
"destination": Object {
"closed": true,
"complete": [Function],
"error": [Function],
"next": [Function],
},
"isStopped": false,
"syncErrorThrowable": false,
"syncErrorThrown": false,
"syncErrorValue": null,
},
"isStopped": false,
"syncErrorThrowable": true,
"syncErrorThrown": false,
"syncErrorValue": null,
},
],
"thrownError": null,
}
}
helpExtension$={
BehaviorSubject {
"_isScalar": false,
@ -1699,14 +1748,67 @@ exports[`Header renders 1`] = `
}
}
>
<HeaderTopBanner
headerBanner$={
BehaviorSubject {
"_isScalar": false,
"_value": undefined,
"closed": false,
"hasError": false,
"isStopped": false,
"observers": Array [
Subscriber {
"_parentOrParents": null,
"_subscriptions": Array [
SubjectSubscription {
"_parentOrParents": [Circular],
"_subscriptions": null,
"closed": false,
"subject": [Circular],
"subscriber": [Circular],
},
],
"closed": false,
"destination": SafeSubscriber {
"_complete": undefined,
"_context": [Circular],
"_error": undefined,
"_next": [Function],
"_parentOrParents": null,
"_parentSubscriber": [Circular],
"_subscriptions": null,
"closed": false,
"destination": Object {
"closed": true,
"complete": [Function],
"error": [Function],
"next": [Function],
},
"isStopped": false,
"syncErrorThrowable": false,
"syncErrorThrown": false,
"syncErrorValue": null,
},
"isStopped": false,
"syncErrorThrowable": true,
"syncErrorThrown": false,
"syncErrorValue": null,
},
],
"thrownError": null,
}
}
/>
<header
className="hide-for-sharing headerGlobalNav"
data-test-subj="headerGlobalNav"
>
<div
className="header__bars"
id="globalHeaderBars"
>
<EuiHeader
className="header__firstBar"
position="fixed"
sections={
Array [
@ -2801,7 +2903,7 @@ exports[`Header renders 1`] = `
theme="dark"
>
<div
className="euiHeader euiHeader--dark euiHeader--fixed"
className="euiHeader euiHeader--dark euiHeader--fixed header__firstBar"
>
<EuiHeaderSection
key="items-0"
@ -4065,10 +4167,11 @@ exports[`Header renders 1`] = `
</div>
</EuiHeader>
<EuiHeader
className="header__secondBar"
position="fixed"
>
<div
className="euiHeader euiHeader--default euiHeader--fixed"
className="euiHeader euiHeader--default euiHeader--fixed header__secondBar"
>
<EuiHeaderSection
grow={false}

View file

@ -0,0 +1,22 @@
.header__topBanner {
position: fixed;
top: 0;
height: $kbnHeaderBannerHeight;
width: 100%;
z-index: $euiZHeader;
}
.header__topBannerContainer {
height: 100%;
width: 100%;
}
// overriding `top` positioning of the chrome headers when the top banner is present.
.kbnBody--hasHeaderBanner .header__bars {
.header__firstBar {
top: $kbnHeaderBannerHeight;
}
.header__secondBar {
top: $kbnHeaderBannerHeight + $euiHeaderHeightCompensation;
}
}

View file

@ -1,4 +1,4 @@
@include euiHeaderAffordForFixed;
@import './banner';
.euiDataGrid__restrictBody {
.headerGlobalNav,

View file

@ -13,7 +13,7 @@ import { StubBrowserStorage, mountWithIntl } from '@kbn/test/jest';
import { httpServiceMock } from '../../../http/http_service.mock';
import { applicationServiceMock } from '../../../mocks';
import { Header } from './header';
import { ChromeBreadcrumbsAppendExtension } from '../../chrome_service';
import { ChromeBreadcrumbsAppendExtension } from '../../types';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => 'mockId',
@ -63,6 +63,7 @@ describe('Header', () => {
const navLinks$ = new BehaviorSubject([
{ id: 'kibana', title: 'kibana', baseUrl: '', href: '' },
]);
const headerBanner$ = new BehaviorSubject(undefined);
const customNavLink$ = new BehaviorSubject({
id: 'cloud-deployment-link',
title: 'Manage cloud deployment',
@ -85,6 +86,7 @@ describe('Header', () => {
isLocked$={isLocked$}
customNavLink$={customNavLink$}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$}
headerBanner$={headerBanner$}
/>
);
expect(component.find('EuiHeader').exists()).toBeFalsy();

View file

@ -32,7 +32,11 @@ import {
} from '../..';
import { InternalApplicationStart } from '../../../application/types';
import { HttpStart } from '../../../http';
import { ChromeBreadcrumbsAppendExtension, ChromeHelpExtension } from '../../chrome_service';
import {
ChromeBreadcrumbsAppendExtension,
ChromeHelpExtension,
ChromeUserBanner,
} from '../../types';
import { OnIsLockedUpdate } from './';
import { CollapsibleNav } from './collapsible_nav';
import { HeaderBadge } from './header_badge';
@ -42,10 +46,12 @@ import { HeaderLogo } from './header_logo';
import { HeaderNavControls } from './header_nav_controls';
import { HeaderActionMenu } from './header_action_menu';
import { HeaderExtension } from './header_extension';
import { HeaderTopBanner } from './header_top_banner';
export interface HeaderProps {
kibanaVersion: string;
application: InternalApplicationStart;
headerBanner$: Observable<ChromeUserBanner | undefined>;
appTitle$: Observable<string>;
badge$: Observable<ChromeBadge | undefined>;
breadcrumbs$: Observable<ChromeBreadcrumb[]>;
@ -84,7 +90,12 @@ export function Header({
const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$);
if (!isVisible) {
return <LoadingIndicator loadingCount$={observables.loadingCount$} showAsBar />;
return (
<>
<LoadingIndicator loadingCount$={observables.loadingCount$} showAsBar />
<HeaderTopBanner headerBanner$={observables.headerBanner$} />
</>
);
}
const toggleCollapsibleNavRef = createRef<HTMLButtonElement>();
@ -97,11 +108,13 @@ export function Header({
return (
<>
<HeaderTopBanner headerBanner$={observables.headerBanner$} />
<header className={className} data-test-subj="headerGlobalNav">
<div id="globalHeaderBars">
<div id="globalHeaderBars" className="header__bars">
<EuiHeader
theme="dark"
position="fixed"
className="header__firstBar"
sections={[
{
items: [
@ -144,7 +157,7 @@ export function Header({
]}
/>
<EuiHeader position="fixed">
<EuiHeader position="fixed" className="header__secondBar">
<EuiHeaderSection grow={false}>
<EuiHeaderSectionItem border="right" className="header__toggleNavButtonSection">
<EuiHeaderSectionItemButton

View file

@ -10,7 +10,7 @@ import { EuiBetaBadge } from '@elastic/eui';
import React, { Component } from 'react';
import * as Rx from 'rxjs';
import { ChromeBadge } from '../../chrome_service';
import { ChromeBadge } from '../../types';
interface Props {
badge$: Rx.Observable<ChromeBadge | undefined>;

View file

@ -11,7 +11,7 @@ import classNames from 'classnames';
import React from 'react';
import useObservable from 'react-use/lib/useObservable';
import { Observable } from 'rxjs';
import { ChromeBreadcrumb } from '../../chrome_service';
import { ChromeBreadcrumb } from '../../types';
interface Props {
appTitle$: Observable<string>;

View file

@ -26,7 +26,7 @@ import {
import { InternalApplicationStart } from '../../../application';
import { GITHUB_CREATE_ISSUE_LINK, KIBANA_FEEDBACK_LINK } from '../../constants';
import { ChromeHelpExtension } from '../../chrome_service';
import { ChromeHelpExtension } from '../../types';
import { HeaderExtension } from './header_extension';
import { isModifiedOrPrevented } from './nav_link';

View file

@ -0,0 +1,34 @@
/*
* 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, { FC } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { Observable } from 'rxjs';
import { ChromeUserBanner } from '../../types';
import { HeaderExtension } from './header_extension';
export interface HeaderTopBannerProps {
headerBanner$: Observable<ChromeUserBanner | undefined>;
}
export const HeaderTopBanner: FC<HeaderTopBannerProps> = ({ headerBanner$ }) => {
const headerBanner = useObservable(headerBanner$, undefined);
if (!headerBanner) {
return null;
}
return (
<div className="header__topBanner" data-test-subj="headerTopBanner">
<HeaderExtension
containerClassName="header__topBannerContainer"
display="block"
extension={headerBanner.content}
/>
</div>
);
};

View file

@ -1,3 +1,5 @@
@import '../../variables';
@mixin flexParent($grow: 1, $shrink: 1, $basis: auto, $direction: column) {
flex: $grow $shrink $basis;
display: flex;
@ -82,6 +84,12 @@
overflow: auto;
animation: kibanaFullScreenGraphics_FadeIn $euiAnimSpeedExtraSlow $euiAnimSlightResistance 0s forwards;
@at-root {
.kbnBody--hasHeaderBanner & {
top: $kbnHeaderBannerHeight;
}
}
&::before {
position: absolute;
top: 0;

View file

@ -194,7 +194,6 @@ export class CoreSystem {
http,
injectedMetadata,
notifications,
uiSettings,
});
this.coreApp.start({ application, http, notifications, uiSettings });

View file

@ -1,4 +1,5 @@
@import './variables';
@import './mixins';
@import './core';
@import './chrome/index';
@import './overlays/index';

View file

@ -46,6 +46,7 @@ import {
ChromeStart,
ChromeRecentlyAccessed,
ChromeRecentlyAccessedHistoryItem,
ChromeUserBanner,
NavType,
} from './chrome';
import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors';
@ -299,6 +300,7 @@ export {
ChromeDocTitle,
ChromeRecentlyAccessed,
ChromeRecentlyAccessedHistoryItem,
ChromeUserBanner,
ChromeStart,
DocLinksStart,
FatalErrorInfo,

View file

@ -378,11 +378,18 @@ export interface ChromeStart {
setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void;
setBreadcrumbsAppendExtension(breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension): void;
setCustomNavLink(newCustomNavLink?: Partial<ChromeNavLink>): void;
setHeaderBanner(headerBanner?: ChromeUserBanner): void;
setHelpExtension(helpExtension?: ChromeHelpExtension): void;
setHelpSupportUrl(url: string): void;
setIsVisible(isVisible: boolean): void;
}
// @public (undocumented)
export interface ChromeUserBanner {
// (undocumented)
content: MountPoint<HTMLDivElement>;
}
// @internal (undocumented)
export interface CoreContext {
// Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts
@ -1519,6 +1526,7 @@ export interface UiSettingsParams<T = unknown> {
name?: string;
optionLabels?: Record<string, string>;
options?: string[];
order?: number;
readonly?: boolean;
requiresPageReload?: boolean;
// (undocumented)
@ -1537,7 +1545,7 @@ export interface UiSettingsState {
}
// @public
export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image';
export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color';
// @public
export type UnmountCallback = () => void;

View file

@ -1,4 +1,4 @@
@include euiHeaderAffordForFixed($kbnHeaderOffset);
@import '../mixins';
/**
* stretch the root element of the Kibana application to set the base-size that
@ -15,11 +15,8 @@
display: flex;
flex-flow: column nowrap;
margin: 0 auto;
min-height: calc(100vh - #{$kbnHeaderOffset});
&.hidden-chrome {
min-height: 100vh;
}
@include kibanaFullBodyMinHeight();
}
.app-wrapper-panel {
@ -33,3 +30,28 @@
flex-shrink: 0;
}
}
// adapted from euiHeaderAffordForFixed as we need to handle the top banner
@mixin kbnAffordForHeader($headerHeight) {
padding-top: $headerHeight;
.euiFlyout,
.euiCollapsibleNav {
top: $headerHeight;
height: calc(100% - #{$headerHeight});
}
}
.kbnBody {
@include kbnAffordForHeader($kbnHeaderOffset);
&.kbnBody--hasHeaderBanner {
@include kbnAffordForHeader($kbnHeaderOffsetWithBanner);
}
&.kbnBody--chromeHidden {
@include kbnAffordForHeader(0);
}
&.kbnBody--chromeHidden.kbnBody--hasHeaderBanner {
@include kbnAffordForHeader($kbnHeaderBannerHeight);
}
}

View file

@ -9,6 +9,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { pairwise, startWith } from 'rxjs/operators';
import { InternalChromeStart } from '../chrome';
import { InternalApplicationStart } from '../application';
@ -32,19 +33,27 @@ interface StartDeps {
*/
export class RenderingService {
start({ application, chrome, overlays, targetDomElement }: StartDeps) {
const chromeUi = chrome.getHeaderComponent();
const appUi = application.getComponent();
const bannerUi = overlays.banners.getComponent();
const chromeHeader = chrome.getHeaderComponent();
const appComponent = application.getComponent();
const bannerComponent = overlays.banners.getComponent();
const body = document.querySelector('body')!;
chrome
.getBodyClasses$()
.pipe(startWith<string[]>([]), pairwise())
.subscribe(([previousClasses, newClasses]) => {
body.classList.remove(...previousClasses);
body.classList.add(...newClasses);
});
ReactDOM.render(
<I18nProvider>
<div className="content" data-test-subj="kibanaChrome">
{chromeUi}
{chromeHeader}
<AppWrapper chromeVisible$={chrome.getIsVisible$()}>
<div className="app-wrapper-panel">
<div id="globalBannerList">{bannerUi}</div>
<AppContainer classes$={chrome.getApplicationClasses$()}>{appUi}</AppContainer>
<div id="globalBannerList">{bannerComponent}</div>
<AppContainer classes$={chrome.getApplicationClasses$()}>{appComponent}</AppContainer>
</div>
</AppWrapper>
</div>

View file

@ -3112,6 +3112,7 @@ export interface UiSettingsParams<T = unknown> {
name?: string;
optionLabels?: Record<string, string>;
options?: string[];
order?: number;
readonly?: boolean;
requiresPageReload?: boolean;
// (undocumented)
@ -3134,7 +3135,7 @@ export interface UiSettingsServiceStart {
}
// @public
export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image';
export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color';
// @public
export interface UserProvidedValues<T = any> {

View file

@ -22,7 +22,8 @@ export type UiSettingsType =
| 'boolean'
| 'string'
| 'array'
| 'image';
| 'image'
| 'color';
/**
* UiSettings deprecation field options.
@ -65,6 +66,13 @@ export interface UiSettingsParams<T = unknown> {
type?: UiSettingsType;
/** optional deprecation information. Used to generate a deprecation warning. */
deprecation?: DeprecationSettings;
/**
* index of the settings within its category (ascending order, smallest will be displayed first).
* Used for ordering in the UI.
*
* @remark settings without order defined will be displayed last and ordered by name
*/
order?: number;
/*
* Allows defining a custom validation applicable to value change on the client.
* @deprecated

View file

@ -12,7 +12,7 @@ import { UnregisterCallback } from 'history';
import { parse } from 'query-string';
import { UiCounterMetricType } from '@kbn/analytics';
import { Comparators, EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui';
import {
IUiSettingsClient,
@ -28,7 +28,7 @@ import { Form } from './components/form';
import { AdvancedSettingsVoiceAnnouncement } from './components/advanced_settings_voice_announcement';
import { ComponentRegistry } from '../';
import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib';
import { getAriaName, toEditableConfig, fieldSorter, DEFAULT_CATEGORY } from './lib';
import { FieldSetting, SettingsChanges } from './types';
import { parseErrorMsg } from './components/search/search';
@ -185,17 +185,17 @@ export class AdvancedSettings extends Component<AdvancedSettingsProps, AdvancedS
mapConfig(config: IUiSettingsClient) {
const all = config.getAll();
return Object.entries(all)
.map((setting) => {
.map(([settingId, settingDef]) => {
return toEditableConfig({
def: setting[1],
name: setting[0],
value: setting[1].userValue,
isCustom: config.isCustom(setting[0]),
isOverridden: config.isOverridden(setting[0]),
def: settingDef,
name: settingId,
value: settingDef.userValue,
isCustom: config.isCustom(settingId),
isOverridden: config.isOverridden(settingId),
});
})
.filter((c) => !c.readonly)
.sort(Comparators.property('name', Comparators.default('asc')));
.filter((c) => !c.readOnly)
.sort(fieldSorter);
}
mapSettings(settings: FieldSetting[]) {

View file

@ -866,6 +866,419 @@ exports[`Field for boolean setting should render user value if there is user val
</EuiDescribedFormGroup>
`;
exports[`Field for color setting should render as read only if saving is disabled 1`] = `
<EuiDescribedFormGroup
className="mgtAdvancedSettings__field"
description={
<React.Fragment>
<div
dangerouslySetInnerHTML={
Object {
"__html": "Description for Color test setting",
}
}
/>
</React.Fragment>
}
fullWidth={true}
id="color:test:setting-group"
title={
<h3>
<span
className="mgtAdvancedSettings__fieldTitle"
>
Color test setting
</span>
</h3>
}
>
<EuiFormRow
className="mgtAdvancedSettings__fieldRow"
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText={null}
label="color:test:setting"
labelType="label"
>
<EuiColorPicker
aria-label="color test setting"
color=""
data-test-subj="advancedSetting-editField-color:test:setting"
disabled={true}
format="hex"
onChange={[Function]}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
`;
exports[`Field for color setting should render as read only with help text if overridden 1`] = `
<EuiDescribedFormGroup
className="mgtAdvancedSettings__field"
description={
<React.Fragment>
<div
dangerouslySetInnerHTML={
Object {
"__html": "Description for Color test setting",
}
}
/>
<React.Fragment>
<EuiSpacer
size="s"
/>
<EuiText
size="xs"
>
<React.Fragment>
<FormattedMessage
defaultMessage="Default: {value}"
id="advancedSettings.field.defaultValueText"
values={
Object {
"value": <EuiCode>
null
</EuiCode>,
}
}
/>
</React.Fragment>
</EuiText>
</React.Fragment>
</React.Fragment>
}
fullWidth={true}
id="color:test:setting-group"
title={
<h3>
<span
className="mgtAdvancedSettings__fieldTitle"
>
Color test setting
</span>
</h3>
}
>
<EuiFormRow
className="mgtAdvancedSettings__fieldRow"
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText={
<EuiText
size="xs"
>
<FormattedMessage
defaultMessage="This setting is overridden by the Kibana server and can not be changed."
id="advancedSettings.field.helpText"
values={Object {}}
/>
</EuiText>
}
label="color:test:setting"
labelType="label"
>
<EuiColorPicker
aria-label="color test setting"
color="#FACF0C"
data-test-subj="advancedSetting-editField-color:test:setting"
disabled={true}
format="hex"
onChange={[Function]}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
`;
exports[`Field for color setting should render custom setting icon if it is custom 1`] = `
<EuiDescribedFormGroup
className="mgtAdvancedSettings__field"
description={
<React.Fragment>
<div
dangerouslySetInnerHTML={
Object {
"__html": "Description for Color test setting",
}
}
/>
</React.Fragment>
}
fullWidth={true}
id="color:test:setting-group"
title={
<h3>
<span
className="mgtAdvancedSettings__fieldTitle"
>
Color test setting
</span>
<EuiIconTip
aria-label="Custom setting"
color="primary"
content={
<FormattedMessage
defaultMessage="Custom setting"
id="advancedSettings.field.customSettingTooltip"
values={Object {}}
/>
}
type="asterisk"
/>
</h3>
}
>
<EuiFormRow
className="mgtAdvancedSettings__fieldRow"
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText={null}
label="color:test:setting"
labelType="label"
>
<EuiColorPicker
aria-label="color test setting"
color=""
data-test-subj="advancedSetting-editField-color:test:setting"
disabled={false}
format="hex"
onChange={[Function]}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
`;
exports[`Field for color setting should render default value if there is no user value set 1`] = `
<EuiDescribedFormGroup
className="mgtAdvancedSettings__field"
description={
<React.Fragment>
<div
dangerouslySetInnerHTML={
Object {
"__html": "Description for Color test setting",
}
}
/>
</React.Fragment>
}
fullWidth={true}
id="color:test:setting-group"
title={
<h3>
<span
className="mgtAdvancedSettings__fieldTitle"
>
Color test setting
</span>
</h3>
}
>
<EuiFormRow
className="mgtAdvancedSettings__fieldRow"
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText={null}
label="color:test:setting"
labelType="label"
>
<EuiColorPicker
aria-label="color test setting"
color=""
data-test-subj="advancedSetting-editField-color:test:setting"
disabled={false}
format="hex"
onChange={[Function]}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
`;
exports[`Field for color setting should render unsaved value if there are unsaved changes 1`] = `
<EuiDescribedFormGroup
className="mgtAdvancedSettings__field mgtAdvancedSettings__field--unsaved"
description={
<React.Fragment>
<div
dangerouslySetInnerHTML={
Object {
"__html": "Description for Color test setting",
}
}
/>
</React.Fragment>
}
fullWidth={true}
id="color:test:setting-group"
title={
<h3>
<span
className="mgtAdvancedSettings__fieldTitle"
>
Color test setting
</span>
<EuiIconTip
aria-label="Custom setting"
color="primary"
content={
<FormattedMessage
defaultMessage="Custom setting"
id="advancedSettings.field.customSettingTooltip"
values={Object {}}
/>
}
type="asterisk"
/>
<EuiIconTip
anchorClassName="mgtAdvancedSettings__fieldTitleUnsavedIcon"
aria-label="Unsaved"
color="warning"
content="Unsaved"
type="dot"
/>
</h3>
}
>
<EuiFormRow
className="mgtAdvancedSettings__fieldRow"
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText={null}
label="color:test:setting"
labelType="label"
>
<EuiColorPicker
aria-describedby="color:test:setting-group color:test:setting-unsaved"
aria-label="color test setting"
color="#FF00CC"
data-test-subj="advancedSetting-editField-color:test:setting"
disabled={false}
format="hex"
onChange={[Function]}
/>
<EuiScreenReaderOnly>
<p
id="color:test:setting-unsaved"
>
Setting is currently not saved.
</p>
</EuiScreenReaderOnly>
</EuiFormRow>
</EuiDescribedFormGroup>
`;
exports[`Field for color setting should render user value if there is user value is set 1`] = `
<EuiDescribedFormGroup
className="mgtAdvancedSettings__field"
description={
<React.Fragment>
<div
dangerouslySetInnerHTML={
Object {
"__html": "Description for Color test setting",
}
}
/>
<React.Fragment>
<EuiSpacer
size="s"
/>
<EuiText
size="xs"
>
<React.Fragment>
<FormattedMessage
defaultMessage="Default: {value}"
id="advancedSettings.field.defaultValueText"
values={
Object {
"value": <EuiCode>
null
</EuiCode>,
}
}
/>
</React.Fragment>
</EuiText>
</React.Fragment>
</React.Fragment>
}
fullWidth={true}
id="color:test:setting-group"
title={
<h3>
<span
className="mgtAdvancedSettings__fieldTitle"
>
Color test setting
</span>
</h3>
}
>
<EuiFormRow
className="mgtAdvancedSettings__fieldRow"
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText={
<span>
<span>
<EuiLink
aria-label="Reset color test setting to default"
data-test-subj="advancedSetting-resetField-color:test:setting"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Reset to default"
id="advancedSettings.field.resetToDefaultLinkText"
values={Object {}}
/>
</EuiLink>
   
</span>
</span>
}
label="color:test:setting"
labelType="label"
>
<EuiColorPicker
aria-label="color test setting"
color="#FACF0C"
data-test-subj="advancedSetting-editField-color:test:setting"
disabled={false}
format="hex"
onChange={[Function]}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
`;
exports[`Field for image setting should render as read only if saving is disabled 1`] = `
<EuiDescribedFormGroup
className="mgtAdvancedSettings__field"

View file

@ -29,6 +29,7 @@ const defaults = {
const exampleValues = {
array: ['example_value'],
boolean: false,
color: '#FF00CC',
image: 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=',
json: { foo: 'bar2' },
markdown: 'Hello World',
@ -163,6 +164,18 @@ const settings: Record<string, FieldSetting> = {
isOverridden: false,
...defaults,
},
color: {
name: 'color:test:setting',
ariaName: 'color test setting',
displayName: 'Color test setting',
description: 'Description for Color test setting',
type: 'color',
value: undefined,
defVal: null,
isCustom: false,
isOverridden: false,
...defaults,
},
};
const userValues = {
array: ['user', 'value'],
@ -174,6 +187,7 @@ const userValues = {
select: 'banana',
string: 'foo',
stringWithValidation: 'fooUserValue',
color: '#FACF0C',
};
const invalidUserValues = {
@ -187,6 +201,8 @@ const getFieldSettingValue = (wrapper: ReactWrapper, name: string, type: string)
const field = findTestSubject(wrapper, `advancedSetting-editField-${name}`);
if (type === 'boolean') {
return field.props()['aria-checked'];
} else if (type === 'color') {
return field.props().color;
} else {
return field.props().value;
}
@ -423,6 +439,36 @@ describe('Field', () => {
});
}
});
} else if (type === 'color') {
describe(`for changing ${type} setting`, () => {
const { wrapper, component } = setup();
const userValue = userValues[type];
it('should be able to change value', async () => {
await (component.instance() as Field).onFieldChange(userValue);
const updated = wrapper.update();
expect(handleChange).toBeCalledWith(setting.name, { value: userValue });
updated.setProps({ unsavedChanges: { value: userValue } });
const currentValue = wrapper.find('EuiColorPicker').prop('color');
expect(currentValue).toEqual(userValue);
});
it('should be able to reset to default value', async () => {
await wrapper.setProps({
unsavedChanges: {},
setting: { ...setting, value: userValue },
});
const updated = wrapper.update();
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
const expectedEditableValue = getEditableValue(setting.type, setting.defVal);
expect(handleChange).toBeCalledWith(setting.name, {
value: expectedEditableValue,
});
updated.setProps({ unsavedChanges: { value: expectedEditableValue } });
const currentValue = wrapper.find('EuiColorPicker').prop('color');
expect(currentValue).toEqual(expectedEditableValue);
});
});
} else {
describe(`for changing ${type} setting`, () => {
const { wrapper, component } = setup();

View file

@ -17,6 +17,7 @@ import {
EuiBadge,
EuiCode,
EuiCodeBlock,
EuiColorPicker,
EuiScreenReaderOnly,
EuiCodeEditor,
EuiDescribedFormGroup,
@ -392,6 +393,17 @@ export class Field extends PureComponent<FieldProps> {
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
case 'color':
return (
<EuiColorPicker
{...a11yProps}
color={currentValue}
onChange={this.onFieldChange}
disabled={loading || isOverridden || !enableSaving}
format="hex"
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
default:
return (
<EuiFieldText

View file

@ -11,3 +11,4 @@ export { toEditableConfig } from './to_editable_config';
export { getCategoryName } from './get_category_name';
export { DEFAULT_CATEGORY } from './default_category';
export { getAriaName } from './get_aria_name';
export { fieldSorter } from './sort_fields';

View file

@ -0,0 +1,56 @@
/*
* 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 { FieldSetting } from '../types';
import { fieldSorter } from './sort_fields';
const createField = (parts: Partial<FieldSetting>): FieldSetting => ({
displayName: 'displayName',
name: 'field',
value: 'value',
requiresPageReload: false,
type: 'string',
category: [],
ariaName: 'ariaName',
isOverridden: false,
defVal: 'defVal',
isCustom: false,
...parts,
});
describe('fieldSorter', () => {
it('sort fields based on their `order` field if present on both', () => {
const fieldA = createField({ order: 3 });
const fieldB = createField({ order: 1 });
const fieldC = createField({ order: 2 });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]);
});
it('fields with order defined are ordered first', () => {
const fieldA = createField({ order: 2 });
const fieldB = createField({ order: undefined });
const fieldC = createField({ order: 1 });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldA, fieldB]);
});
it('sorts by `name` when fields have the same `order`', () => {
const fieldA = createField({ order: 2, name: 'B' });
const fieldB = createField({ order: 1 });
const fieldC = createField({ order: 2, name: 'A' });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]);
});
it('sorts by `name` when fields have no `order`', () => {
const fieldA = createField({ order: undefined, name: 'B' });
const fieldB = createField({ order: undefined, name: 'A' });
const fieldC = createField({ order: 1 });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldB, fieldA]);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { Comparators } from '@elastic/eui';
import { FieldSetting } from '../types';
const cmp = Comparators.default('asc');
export const fieldSorter = (a: FieldSetting, b: FieldSetting): number => {
const aOrder = a.order !== undefined;
const bOrder = b.order !== undefined;
if (aOrder && bOrder) {
if (a.order === b.order) {
return cmp(a.name, b.name);
}
return cmp(a.order, b.order);
}
if (aOrder) {
return -1;
}
if (bOrder) {
return 1;
}
return cmp(a.name, b.name);
};

View file

@ -12,6 +12,7 @@ import {
StringValidationRegexString,
SavedObjectAttribute,
} from 'src/core/public';
import { FieldSetting } from '../types';
import { getValType } from './get_val_type';
import { getAriaName } from './get_aria_name';
import { DEFAULT_CATEGORY } from './default_category';
@ -41,7 +42,7 @@ export function toEditableConfig({
const validationTyped = def.validation as StringValidationRegexString;
const conf = {
const conf: FieldSetting = {
name,
displayName: def.name || name,
ariaName: def.name || getAriaName(name),
@ -49,7 +50,7 @@ export function toEditableConfig({
category: def.category && def.category.length ? def.category : [DEFAULT_CATEGORY],
isCustom,
isOverridden,
readonly: !!def.readonly,
readOnly: !!def.readonly,
defVal: def.value,
type: getValType(def, value),
description: def.description,
@ -63,6 +64,7 @@ export function toEditableConfig({
: def.validation,
options: def.options,
optionLabels: def.optionLabels,
order: def.order,
requiresPageReload: !!def.requiresPageReload,
metric: def.metric,
};

View file

@ -25,6 +25,7 @@ export interface FieldSetting {
isCustom: boolean;
validation?: StringValidation | ImageValidation;
readOnly?: boolean;
order?: number;
deprecation?: {
message: string;
docLinksKey: string;

View file

@ -1,10 +1,12 @@
@import '../../../../../core/public/mixins';
discover-app {
flex-grow: 1;
}
.dscPage {
@include euiBreakpoint('m', 'l', 'xl') {
height: calc(100vh - #{($euiHeaderHeightCompensation * 2)});
@include kibanaFullBodyHeight();
}
flex-direction: column;

View file

@ -1,8 +1,10 @@
@import '../../../../../core/public/mixins';
.homWrapper {
@include kibanaFullBodyMinHeight();
background-color: $euiColorEmptyShade;
display: flex;
flex-direction: column;
min-height: calc(100vh - #{$euiHeaderHeightCompensation});
}
.homContent {

View file

@ -51,6 +51,9 @@ export class Home extends Component {
componentWillUnmount() {
this._isMounted = false;
const body = document.querySelector('body');
body.classList.remove('isHomPage');
}
componentDidMount() {

View file

@ -1,8 +1,10 @@
@import '../../../../core/public/mixins';
.kbnOverviewWrapper {
@include kibanaFullBodyMinHeight();
background-color: $euiColorEmptyShade;
display: flex;
flex-direction: column;
min-height: calc(100vh - #{$euiHeaderHeightCompensation});
}
.kbnOverviewContent {

View file

@ -58,7 +58,8 @@
"xpack.uptime": ["plugins/uptime"],
"xpack.urlDrilldown": "plugins/drilldowns/url_drilldown",
"xpack.watcher": "plugins/watcher",
"xpack.observability": "plugins/observability"
"xpack.observability": "plugins/observability",
"xpack.banners": "plugins/banners"
},
"exclude": ["examples"],
"translations": [

View file

@ -0,0 +1,38 @@
# Kibana banners plugin
Allow to add a header banner that will be displayed on every page of the Kibana application
## Configuration
The plugin's configuration prefix is `xpack.banners`
The options are
- `placement`
The placement of the banner. The allowed values are:
- `disabled` - The banner will be disabled
- `header` - The banner will be displayed in the header
- `textContent`
The text content that will be displayed inside the banner, either plain text or markdown
- `textColor`
The color of the banner's text. Must be a valid hex color
- `backgroundColor`
The color for the banner's background. Must be a valid hex color
### Configuration example
`kibana.yml`
```yaml
xpack.banners:
placement: 'header'
textContent: 'Production environment - Proceed with **special levels** of caution'
textColor: '#FF0000'
backgroundColor: '#CC2211'
```

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { BannerInfoResponse, BannerPlacement, BannerConfiguration } from './types';

View file

@ -0,0 +1,20 @@
/*
* 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.
*/
export interface BannerInfoResponse {
allowed: boolean;
banner: BannerConfiguration;
}
export type BannerPlacement = 'disabled' | 'header';
export interface BannerConfiguration {
placement: BannerPlacement;
textContent: string;
textColor: string;
backgroundColor: string;
}

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/x-pack/plugins/banners'],
};

View file

@ -0,0 +1,11 @@
{
"id": "banners",
"version": "8.0.0",
"kibanaVersion": "kibana",
"server": true,
"ui": true,
"requiredPlugins": ["licensing"],
"optionalPlugins": [],
"requiredBundles": ["kibanaReact"],
"configPath": ["xpack", "banners"]
}

View file

@ -0,0 +1,7 @@
.kbnUserBanner__container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: $euiFontSizeS;
}

View file

@ -0,0 +1,33 @@
/*
* 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 React, { FC } from 'react';
import { Markdown } from '../../../../../src/plugins/kibana_react/public';
import { BannerConfiguration } from '../../common';
import './banner.scss';
interface BannerProps {
bannerConfig: BannerConfiguration;
}
export const Banner: FC<BannerProps> = ({ bannerConfig }) => {
const { textContent, textColor, backgroundColor } = bannerConfig;
return (
<div
className="kbnUserBanner__container"
style={{
backgroundColor,
color: textColor,
}}
>
<div data-test-subj="bannerInnerWrapper">
<Markdown markdown={textContent} />
</div>
</div>
);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { Banner } from './banner';

View file

@ -0,0 +1,35 @@
/*
* 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 { httpServiceMock } from '../../../../src/core/public/mocks';
import { getBannerInfo } from './get_banner_info';
describe('getBannerInfo', () => {
let http: ReturnType<typeof httpServiceMock.createStartContract>;
beforeEach(() => {
http = httpServiceMock.createStartContract();
});
it('calls `http.get` with the correct parameters', async () => {
await getBannerInfo(http);
expect(http.get).toHaveBeenCalledTimes(1);
expect(http.get).toHaveBeenCalledWith('/api/banners/info');
});
it('returns the value from the service', async () => {
const expected = {
allowed: true,
};
http.get.mockResolvedValue(expected);
const response = await getBannerInfo(http);
expect(response).toEqual(expected);
});
});

View file

@ -0,0 +1,13 @@
/*
* 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 { HttpStart } from 'src/core/public';
import { BannerInfoResponse } from '../common';
export const getBannerInfo = async (http: HttpStart): Promise<BannerInfoResponse> => {
return await http.get<BannerInfoResponse>('/api/banners/info');
};

View file

@ -0,0 +1,12 @@
/*
* 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 { PluginInitializer } from 'src/core/public';
import { BannersPlugin } from './plugin';
export const plugin: PluginInitializer<{}, {}, {}, {}> = (contextInitializer) =>
new BannersPlugin(contextInitializer);

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export const getBannerInfoMock = jest.fn();
jest.doMock('./get_banner_info', () => ({
getBannerInfo: getBannerInfoMock,
}));

View file

@ -0,0 +1,86 @@
/*
* 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 { getBannerInfoMock } from './plugin.test.mocks';
import { coreMock } from '../../../../src/core/public/mocks';
import { BannersPlugin } from './plugin';
import { BannerClientConfig } from './types';
const nextTick = async () => await new Promise<void>((resolve) => resolve());
describe('BannersPlugin', () => {
let plugin: BannersPlugin;
let pluginInitContext: ReturnType<typeof coreMock.createPluginInitializerContext>;
let coreSetup: ReturnType<typeof coreMock.createSetup>;
let coreStart: ReturnType<typeof coreMock.createStart>;
beforeEach(() => {
pluginInitContext = coreMock.createPluginInitializerContext();
coreSetup = coreMock.createSetup();
coreStart = coreMock.createStart();
getBannerInfoMock.mockResolvedValue({
allowed: false,
});
});
const startPlugin = async (config: BannerClientConfig) => {
pluginInitContext = coreMock.createPluginInitializerContext(config);
plugin = new BannersPlugin(pluginInitContext);
plugin.setup(coreSetup);
plugin.start(coreStart);
// await for the `getBannerInfo` promise to resolve
await nextTick();
};
afterEach(() => {
getBannerInfoMock.mockReset();
});
it('calls `getBannerInfo` if `config.placement !== disabled`', async () => {
await startPlugin({
placement: 'header',
});
expect(getBannerInfoMock).toHaveBeenCalledTimes(1);
});
it('does not call `getBannerInfo` if `config.placement === disabled`', async () => {
await startPlugin({
placement: 'disabled',
});
expect(getBannerInfoMock).not.toHaveBeenCalled();
});
it('registers the header banner if `getBannerInfo` return `allowed=true`', async () => {
getBannerInfoMock.mockResolvedValue({
allowed: true,
});
await startPlugin({
placement: 'header',
});
expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledWith({
content: expect.any(Function),
});
});
it('does not register the header banner if `getBannerInfo` return `allowed=false`', async () => {
getBannerInfoMock.mockResolvedValue({
allowed: false,
});
await startPlugin({
placement: 'header',
});
expect(coreStart.chrome.setHeaderBanner).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,44 @@
/*
* 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 React from 'react';
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
import { toMountPoint } from '../../../../src/plugins/kibana_react/public';
import { Banner } from './components';
import { BannerClientConfig } from './types';
import { getBannerInfo } from './get_banner_info';
export class BannersPlugin implements Plugin<{}, {}, {}, {}> {
private readonly config: BannerClientConfig;
constructor(context: PluginInitializerContext) {
this.config = context.config.get<BannerClientConfig>();
}
setup({}: CoreSetup<{}, {}>) {
return {};
}
start({ chrome, uiSettings, http }: CoreStart) {
if (this.config.placement !== 'disabled') {
getBannerInfo(http).then(
({ allowed, banner }) => {
if (allowed) {
chrome.setHeaderBanner({
content: toMountPoint(<Banner bannerConfig={banner} />),
});
}
},
() => {
chrome.setHeaderBanner(undefined);
}
);
}
return {};
}
}

View file

@ -0,0 +1,12 @@
/*
* 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 { BannerPlacement } from '../common';
export interface BannerClientConfig {
placement: BannerPlacement;
}

View file

@ -0,0 +1,42 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from 'kibana/server';
import { isHexColor } from './utils';
const configSchema = schema.object({
placement: schema.oneOf([schema.literal('disabled'), schema.literal('header')], {
defaultValue: 'disabled',
}),
textContent: schema.string({ defaultValue: '' }),
textColor: schema.string({
validate: (color) => {
if (!isHexColor(color)) {
return `must be an hex color`;
}
},
defaultValue: '#8A6A0A',
}),
backgroundColor: schema.string({
validate: (color) => {
if (!isHexColor(color)) {
return `must be an hex color`;
}
},
defaultValue: '#FFF9E8',
}),
});
export type BannersConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<BannersConfigType> = {
schema: configSchema,
exposeToBrowser: {
placement: true,
},
};

View file

@ -0,0 +1,12 @@
/*
* 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 { PluginInitializer } from 'src/core/server';
import { BannersPlugin } from './plugin';
export { config } from './config';
export const plugin: PluginInitializer<{}, {}, {}, {}> = (context) => new BannersPlugin(context);

View file

@ -0,0 +1,33 @@
/*
* 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 { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/server';
import { BannerConfiguration } from '../common';
import { BannersConfigType } from './config';
import { BannersRequestHandlerContext } from './types';
import { registerRoutes } from './routes';
export class BannersPlugin implements Plugin<{}, {}, {}, {}> {
private readonly config: BannerConfiguration;
constructor(context: PluginInitializerContext) {
this.config = convertConfig(context.config.get<BannersConfigType>());
}
setup({ uiSettings, getStartServices, http }: CoreSetup<{}, {}>) {
const router = http.createRouter<BannersRequestHandlerContext>();
registerRoutes(router, this.config);
return {};
}
start() {
return {};
}
}
const convertConfig = (raw: BannersConfigType): BannerConfiguration => raw;

View file

@ -0,0 +1,14 @@
/*
* 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 { BannerConfiguration } from '../../common';
import { BannersRouter } from '../types';
import { registerInfoRoute } from './info';
export const registerRoutes = (router: BannersRouter, config: BannerConfiguration) => {
registerInfoRoute(router, config);
};

View file

@ -0,0 +1,36 @@
/*
* 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 { ILicense } from '../../../licensing/server';
import { BannerInfoResponse, BannerConfiguration } from '../../common';
import { BannersRouter } from '../types';
export const registerInfoRoute = (router: BannersRouter, config: BannerConfiguration) => {
router.get(
{
path: '/api/banners/info',
validate: false,
options: {
authRequired: false,
},
},
(ctx, req, res) => {
const allowed = isValidLicense(ctx.licensing.license);
return res.ok({
body: {
allowed,
banner: config,
} as BannerInfoResponse,
});
}
);
};
const isValidLicense = (license: ILicense): boolean => {
return license.hasAtLeast('gold');
};

View file

@ -0,0 +1,15 @@
/*
* 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 { RequestHandlerContext, IRouter } from 'src/core/server';
import { LicensingApiRequestHandlerContext } from '../../licensing/server';
export interface BannersRequestHandlerContext extends RequestHandlerContext {
licensing: LicensingApiRequestHandlerContext;
}
export type BannersRouter = IRouter<BannersRequestHandlerContext>;

View file

@ -0,0 +1,26 @@
/*
* 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 { isHexColor } from './utils';
describe('isHexColor', () => {
it('returns true for valid 3-length hex colors', () => {
expect(isHexColor('#FEC')).toBe(true);
expect(isHexColor('#0a4')).toBe(true);
});
it('returns true for valid 6-length hex colors', () => {
expect(isHexColor('#FF00CC')).toBe(true);
expect(isHexColor('#fab47e')).toBe(true);
});
it('returns false for other strings', () => {
expect(isHexColor('#FAZ')).toBe(false);
expect(isHexColor('#FFAAUU')).toBe(false);
expect(isHexColor('foobar')).toBe(false);
});
});

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
const hexColorRegexp = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
export const isHexColor = (color: string) => {
return hexColorRegexp.test(color);
};

View file

@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true
},
"include": [
"public/**/*",
"server/**/*",
"common/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../licensing/tsconfig.json" }
]
}

View file

@ -1,19 +1,15 @@
@import '../../../../src/core/public/variables';
@import '../../../../src/core/public/mixins';
// sass-lint:disable no-ids
#maps-plugin {
@include kibanaFullBodyHeight();
display: flex;
flex-direction: column;
height: calc(100vh - #{$kbnHeaderOffset});
width: 100%;
overflow: hidden;
}
.mapFullScreen {
// sass-lint:disable no-important
height: 100vh !important;
}
#react-maps-root {
flex-grow: 1;
display: flex;

View file

@ -1,4 +1,5 @@
@import '@elastic/eui/src/global_styling/variables/header';
@import '../../../../../src/core/public/mixins';
/**
* This is a very brittle way of preventing the editor and other content from disappearing
@ -39,11 +40,11 @@ $bottomBarHeight: $euiSize * 3;
line-height: 0;
}
// This value is calculated to static value using SCSS because calc in calc has issues in IE11
$headerOffset: $euiHeaderHeightCompensation * 3;
// adding dev tool top bar + bottom bar height to the body offset
$bodyOffset: $euiHeaderHeightCompensation + $bottomBarHeight;
.painlessLabMainContainer {
height: calc(100vh - #{$headerOffset} - #{$bottomBarHeight});
@include kibanaFullBodyHeight($bodyOffset);
}
.painlessLabPanelsContainer {

View file

@ -1,3 +1,5 @@
@import '../../../../../src/core/public/mixins';
.prfDevTool__page {
flex: 1 1 auto;
@ -28,11 +30,11 @@
}
}
// This value is calculated to static value using SCSS because calc in calc has issues in IE11
$headerHeightOffset: $euiHeaderHeightCompensation * 3;
// adding dev tool top bar to the body offset
$bodyOffset: $euiHeaderHeightCompensation;
.appRoot {
height: calc(100vh - #{$headerHeightOffset});
@include kibanaFullBodyHeight($bodyOffset);
overflow: hidden;
flex-shrink: 1;
}

View file

@ -40,6 +40,7 @@
{ "path": "../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../plugins/actions/tsconfig.json" },
{ "path": "../plugins/alerts/tsconfig.json" },
{ "path": "../plugins/banners/tsconfig.json" },
{ "path": "../plugins/beats_management/tsconfig.json" },
{ "path": "../plugins/cloud/tsconfig.json" },
{ "path": "../plugins/code/tsconfig.json" },

View file

@ -7,6 +7,7 @@
"plugins/apm/e2e/cypress/**/*",
"plugins/apm/ftr_e2e/**/*",
"plugins/apm/scripts/**/*",
"plugins/banners/**/*",
"plugins/canvas/**/*",
"plugins/console_extensions/**/*",
"plugins/code/**/*",