mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
9870ade971
commit
570dcc07b5
82 changed files with 1855 additions and 282 deletions
|
@ -32,6 +32,7 @@ rules:
|
|||
- function
|
||||
- return
|
||||
- for
|
||||
- at-root
|
||||
comment-no-empty: true
|
||||
no-duplicate-at-import-rules: true
|
||||
no-duplicate-selectors: true
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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. |
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [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.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) > [content](./kibana-plugin-core-public.chromeuserbanner.content.md)
|
||||
|
||||
## ChromeUserBanner.content property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
content: MountPoint<HTMLDivElement>;
|
||||
```
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [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<HTMLDivElement></code> | |
|
||||
|
File diff suppressed because one or more lines are too long
|
@ -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) | |
|
||||
|
|
|
@ -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<string, string></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<T></code> | |
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) > [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;
|
||||
```
|
|
@ -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';
|
||||
```
|
||||
|
|
|
@ -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<string, string></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<T></code> | |
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) > [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;
|
||||
```
|
|
@ -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';
|
||||
```
|
||||
|
|
|
@ -105,3 +105,4 @@ pageLoadAssetSize:
|
|||
spacesOss: 18817
|
||||
osquery: 107090
|
||||
fileUpload: 25664
|
||||
banners: 17946
|
||||
|
|
43
src/core/public/_mixins.scss
Normal file
43
src/core/public/_mixins.scss
Normal 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});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
241
src/core/public/chrome/types.ts
Normal file
241
src/core/public/chrome/types.ts
Normal 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[]>;
|
||||
}
|
|
@ -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}
|
||||
|
|
22
src/core/public/chrome/ui/header/_banner.scss
Normal file
22
src/core/public/chrome/ui/header/_banner.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@include euiHeaderAffordForFixed;
|
||||
@import './banner';
|
||||
|
||||
.euiDataGrid__restrictBody {
|
||||
.headerGlobalNav,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
34
src/core/public/chrome/ui/header/header_top_banner.tsx
Normal file
34
src/core/public/chrome/ui/header/header_top_banner.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -194,7 +194,6 @@ export class CoreSystem {
|
|||
http,
|
||||
injectedMetadata,
|
||||
notifications,
|
||||
uiSettings,
|
||||
});
|
||||
|
||||
this.coreApp.start({ application, http, notifications, uiSettings });
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@import './variables';
|
||||
@import './mixins';
|
||||
@import './core';
|
||||
@import './chrome/index';
|
||||
@import './overlays/index';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -25,6 +25,7 @@ export interface FieldSetting {
|
|||
isCustom: boolean;
|
||||
validation?: StringValidation | ImageValidation;
|
||||
readOnly?: boolean;
|
||||
order?: number;
|
||||
deprecation?: {
|
||||
message: string;
|
||||
docLinksKey: string;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -51,6 +51,9 @@ export class Home extends Component {
|
|||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
|
||||
const body = document.querySelector('body');
|
||||
body.classList.remove('isHomPage');
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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": [
|
||||
|
|
38
x-pack/plugins/banners/README.md
Normal file
38
x-pack/plugins/banners/README.md
Normal 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'
|
||||
```
|
8
x-pack/plugins/banners/common/index.ts
Normal file
8
x-pack/plugins/banners/common/index.ts
Normal 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';
|
20
x-pack/plugins/banners/common/types.ts
Normal file
20
x-pack/plugins/banners/common/types.ts
Normal 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;
|
||||
}
|
12
x-pack/plugins/banners/jest.config.js
Normal file
12
x-pack/plugins/banners/jest.config.js
Normal 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'],
|
||||
};
|
11
x-pack/plugins/banners/kibana.json
Normal file
11
x-pack/plugins/banners/kibana.json
Normal 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"]
|
||||
}
|
7
x-pack/plugins/banners/public/components/banner.scss
Normal file
7
x-pack/plugins/banners/public/components/banner.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
.kbnUserBanner__container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: $euiFontSizeS;
|
||||
}
|
33
x-pack/plugins/banners/public/components/banner.tsx
Normal file
33
x-pack/plugins/banners/public/components/banner.tsx
Normal 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>
|
||||
);
|
||||
};
|
8
x-pack/plugins/banners/public/components/index.ts
Normal file
8
x-pack/plugins/banners/public/components/index.ts
Normal 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';
|
35
x-pack/plugins/banners/public/get_banner_info.test.ts
Normal file
35
x-pack/plugins/banners/public/get_banner_info.test.ts
Normal 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);
|
||||
});
|
||||
});
|
13
x-pack/plugins/banners/public/get_banner_info.ts
Normal file
13
x-pack/plugins/banners/public/get_banner_info.ts
Normal 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');
|
||||
};
|
12
x-pack/plugins/banners/public/index.ts
Normal file
12
x-pack/plugins/banners/public/index.ts
Normal 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);
|
11
x-pack/plugins/banners/public/plugin.test.mocks.ts
Normal file
11
x-pack/plugins/banners/public/plugin.test.mocks.ts
Normal 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,
|
||||
}));
|
86
x-pack/plugins/banners/public/plugin.test.tsx
Normal file
86
x-pack/plugins/banners/public/plugin.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
44
x-pack/plugins/banners/public/plugin.tsx
Normal file
44
x-pack/plugins/banners/public/plugin.tsx
Normal 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 {};
|
||||
}
|
||||
}
|
12
x-pack/plugins/banners/public/types.ts
Normal file
12
x-pack/plugins/banners/public/types.ts
Normal 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;
|
||||
}
|
42
x-pack/plugins/banners/server/config.ts
Normal file
42
x-pack/plugins/banners/server/config.ts
Normal 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,
|
||||
},
|
||||
};
|
12
x-pack/plugins/banners/server/index.ts
Normal file
12
x-pack/plugins/banners/server/index.ts
Normal 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);
|
33
x-pack/plugins/banners/server/plugin.ts
Normal file
33
x-pack/plugins/banners/server/plugin.ts
Normal 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;
|
14
x-pack/plugins/banners/server/routes/index.ts
Normal file
14
x-pack/plugins/banners/server/routes/index.ts
Normal 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);
|
||||
};
|
36
x-pack/plugins/banners/server/routes/info.ts
Normal file
36
x-pack/plugins/banners/server/routes/info.ts
Normal 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');
|
||||
};
|
15
x-pack/plugins/banners/server/types.ts
Normal file
15
x-pack/plugins/banners/server/types.ts
Normal 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>;
|
26
x-pack/plugins/banners/server/utils.test.ts
Normal file
26
x-pack/plugins/banners/server/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
12
x-pack/plugins/banners/server/utils.ts
Normal file
12
x-pack/plugins/banners/server/utils.ts
Normal 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);
|
||||
};
|
22
x-pack/plugins/banners/tsconfig.json
Normal file
22
x-pack/plugins/banners/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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" },
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
"plugins/apm/e2e/cypress/**/*",
|
||||
"plugins/apm/ftr_e2e/**/*",
|
||||
"plugins/apm/scripts/**/*",
|
||||
"plugins/banners/**/*",
|
||||
"plugins/canvas/**/*",
|
||||
"plugins/console_extensions/**/*",
|
||||
"plugins/code/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue