add per space configuration to custom header banner (#94449)

* restore the banners ui settings

* fix banner init logic

* fix unit tests

* update telemetry schema

* add basic server-side plugin tests

* add FTR tests for banners plugin

* use keyword for sensitive setting

* update snapshots

* setting name consistency with configuration properties

* fix setting names in telemetry files

* open banner links in new tab

* add config.disableSpaceBanners property

* fix types

* add descriptions to banner settings

* change label and value header->top

* finishing header->top replacement

* doc nits

* add banners section to advanced options doc

* feedback on advanced options doc

* adapt deprecation to new format
This commit is contained in:
Pierre Gayvallet 2021-03-31 10:57:06 +02:00 committed by GitHub
parent ae1014bdd8
commit ddac0e9501
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1045 additions and 86 deletions

View file

@ -209,6 +209,32 @@ from *{stack-monitor-app}*.
Turns off all unnecessary animations in the {kib} UI. Refresh the page to apply
the changes.
[float]
[[kibana-banners-settings]]
==== Banners
[NOTE]
====
Banners are a https://www.elastic.co/subscriptions[subscription feature].
====
[horizontal]
[[banners-placement]]`banners:placement`::
Set to `Top` to display a banner above the Elastic header for this space. Defaults to the value of
the `xpack.banners.placement` configuration property.
[[banners-textcontent]]`banners:textContent`::
The text to display inside the banner for this space, either plain text or Markdown.
Defaults to the value of the `xpack.banners.textContent` configuration property.
[[banners-textcolor]]`banners:textColor`::
The color for the banner text for this space. Defaults to the value of
the `xpack.banners.textColor` configuration property.
[[banners-backgroundcolor]]`banners:backgroundColor`::
The color of the banner background for this space. Defaults to the value of
the `xpack.banners.backgroundColor` configuration property.
[float]
[[kibana-dashboard-settings]]
==== Dashboard

View file

@ -9,6 +9,11 @@ Banners are disabled by default. You need to manually configure them in order to
You can configure the `xpack.banners` settings in your `kibana.yml` file.
[NOTE]
====
Banners are a https://www.elastic.co/subscriptions[subscription feature].
====
[[general-banners-settings-kb]]
==== General banner settings
@ -16,7 +21,7 @@ You can configure the `xpack.banners` settings in your `kibana.yml` file.
|===
| `xpack.banners.placement`
| Set to `header` to enable the header banner. Defaults to `disabled`.
| Set to `top` to display a banner above the Elastic header. Defaults to `disabled`.
| `xpack.banners.textContent`
| The text to display inside the banner, either plain text or Markdown.
@ -27,9 +32,7 @@ You can configure the `xpack.banners` settings in your `kibana.yml` file.
| `xpack.banners.backgroundColor`
| The color of the banner background. Defaults to `#FFF9E8`.
|===
| `xpack.banners.disableSpaceBanners`
| If true, per-space banner overrides will be disabled. Defaults to `false`.
[NOTE]
====
The `banners` plugin is a https://www.elastic.co/subscriptions[subscription feature]
====
|===

View file

@ -52,6 +52,8 @@ export { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_serv
export { i18nServiceMock } from './i18n/i18n_service.mock';
export { deprecationsServiceMock } from './deprecations/deprecations_service.mock';
type MockedPluginInitializerConfig<T> = jest.Mocked<PluginInitializerContext<T>['config']>;
export function pluginInitializerContextConfigMock<T>(config: T) {
const globalConfig: SharedGlobalConfig = {
kibana: {
@ -70,7 +72,7 @@ export function pluginInitializerContextConfigMock<T>(config: T) {
},
};
const mock: jest.Mocked<PluginInitializerContext<T>['config']> = {
const mock: MockedPluginInitializerConfig<T> = {
legacy: {
globalConfig$: of(globalConfig),
get: () => globalConfig,
@ -82,8 +84,12 @@ export function pluginInitializerContextConfigMock<T>(config: T) {
return mock;
}
type PluginInitializerContextMock<T> = Omit<PluginInitializerContext<T>, 'config'> & {
config: MockedPluginInitializerConfig<T>;
};
function pluginInitializerContextMock<T>(config: T = {} as T) {
const mock: PluginInitializerContext<T> = {
const mock: PluginInitializerContextMock<T> = {
opaqueId: Symbol(),
logger: loggingSystemMock.create(),
env: {

View file

@ -1791,6 +1791,7 @@ exports[`Field for json setting should render as read only if saving is disabled
maxLines={30}
minLines={6}
mode="json"
name="advancedSetting-editField-json:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -1897,6 +1898,7 @@ exports[`Field for json setting should render as read only with help text if ove
maxLines={30}
minLines={6}
mode="json"
name="advancedSetting-editField-json:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -1979,6 +1981,7 @@ exports[`Field for json setting should render custom setting icon if it is custo
maxLines={30}
minLines={6}
mode="json"
name="advancedSetting-editField-json:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2092,6 +2095,7 @@ exports[`Field for json setting should render default value if there is no user
maxLines={30}
minLines={6}
mode="json"
name="advancedSetting-editField-json:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2181,6 +2185,7 @@ exports[`Field for json setting should render unsaved value if there are unsaved
maxLines={30}
minLines={6}
mode="json"
name="advancedSetting-editField-json:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2305,6 +2310,7 @@ exports[`Field for json setting should render user value if there is user value
maxLines={30}
minLines={6}
mode="json"
name="advancedSetting-editField-json:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2376,6 +2382,7 @@ exports[`Field for markdown setting should render as read only if saving is disa
maxLines={30}
minLines={6}
mode="markdown"
name="advancedSetting-editField-markdown:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2479,6 +2486,7 @@ exports[`Field for markdown setting should render as read only with help text if
maxLines={30}
minLines={6}
mode="markdown"
name="advancedSetting-editField-markdown:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2561,6 +2569,7 @@ exports[`Field for markdown setting should render custom setting icon if it is c
maxLines={30}
minLines={6}
mode="markdown"
name="advancedSetting-editField-markdown:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2632,6 +2641,7 @@ exports[`Field for markdown setting should render default value if there is no u
maxLines={30}
minLines={6}
mode="markdown"
name="advancedSetting-editField-markdown:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2721,6 +2731,7 @@ exports[`Field for markdown setting should render unsaved value if there are uns
maxLines={30}
minLines={6}
mode="markdown"
name="advancedSetting-editField-markdown:test:setting-editor"
onChange={[Function]}
setOptions={
Object {
@ -2838,6 +2849,7 @@ exports[`Field for markdown setting should render user value if there is user va
maxLines={30}
minLines={6}
mode="markdown"
name="advancedSetting-editField-markdown:test:setting-editor"
onChange={[Function]}
setOptions={
Object {

View file

@ -326,6 +326,7 @@ export class Field extends PureComponent<FieldProps> {
<div data-test-subj={`advancedSetting-editField-${name}`}>
<EuiCodeEditor
{...a11yProps}
name={`advancedSetting-editField-${name}-editor`}
mode={type}
theme="textmate"
value={currentValue}

View file

@ -43,6 +43,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'keyword',
_meta: { description: 'Default value of the setting was changed.' },
},
'banners:textContent': {
type: 'keyword',
_meta: { description: 'Default value of the setting was changed.' },
},
// non-sensitive
'visualize:enableLabs': {
type: 'boolean',
@ -408,6 +412,18 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'banners:placement': {
type: 'keyword',
_meta: { description: 'Non-default value of setting.' },
},
'banners:textColor': {
type: 'text',
_meta: { description: 'Non-default value of setting.' },
},
'banners:backgroundColor': {
type: 'text',
_meta: { description: 'Non-default value of setting.' },
},
'observability:enableAlertingExperience': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },

View file

@ -18,6 +18,7 @@ export interface UsageStats {
'timelion:graphite.url': string;
'xpackDashboardMode:roles': string;
'securitySolution:ipReputationLinks': string;
'banners:textContent': string;
/**
* non-sensitive settings
*/
@ -114,4 +115,7 @@ export interface UsageStats {
'csv:quoteValues': boolean;
'dateFormat:dow': string;
dateFormat: string;
'banners:placement': string;
'banners:textColor': string;
'banners:backgroundColor': string;
}

View file

@ -7478,6 +7478,12 @@
"description": "Default value of the setting was changed."
}
},
"banners:textContent": {
"type": "keyword",
"_meta": {
"description": "Default value of the setting was changed."
}
},
"visualize:enableLabs": {
"type": "boolean",
"_meta": {
@ -8027,6 +8033,24 @@
"description": "Non-default value of setting."
}
},
"banners:placement": {
"type": "keyword",
"_meta": {
"description": "Non-default value of setting."
}
},
"banners:textColor": {
"type": "text",
"_meta": {
"description": "Non-default value of setting."
}
},
"banners:backgroundColor": {
"type": "text",
"_meta": {
"description": "Non-default value of setting."
}
},
"observability:enableAlertingExperience": {
"type": "boolean",
"_meta": {

View file

@ -28,6 +28,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
async clickLinkText(text: string) {
await find.clickByDisplayedLinkText(text);
}
async clickKibanaSettings() {
await testSubjects.click('settings');
await PageObjects.header.waitUntilLoadingHasFinished();
@ -89,6 +90,22 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
await PageObjects.header.waitUntilLoadingHasFinished();
}
async setAdvancedSettingsTextArea(propertyName: string, propertyValue: string) {
const wrapper = await testSubjects.find(`advancedSetting-editField-${propertyName}`);
const textarea = await wrapper.findByTagName('textarea');
await textarea.focus();
// only way to properly replace the value of the ace editor is via the JS api
await browser.execute(
(editor: string, value: string) => {
return (window as any).ace.edit(editor).setValue(value);
},
`advancedSetting-editField-${propertyName}-editor`,
propertyValue
);
await testSubjects.click(`advancedSetting-saveButton`);
await PageObjects.header.waitUntilLoadingHasFinished();
}
async toggleAdvancedSettingCheckbox(propertyName: string) {
await testSubjects.click(`advancedSetting-editField-${propertyName}`);
await PageObjects.header.waitUntilLoadingHasFinished();
@ -162,6 +179,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
async sortBy(columnName: string) {
const chartTypes = await find.allByCssSelector('table.euiTable thead tr th button');
async function getChartType(chart: Record<string, any>) {
const chartString = await chart.getVisibleText();
if (chartString === columnName) {
@ -169,6 +187,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
await PageObjects.header.waitUntilLoadingHasFinished();
}
}
const getChartTypesPromises = chartTypes.map(getChartType);
return Promise.all(getChartTypesPromises);
}

View file

@ -12,7 +12,7 @@ The options are
The placement of the banner. The allowed values are:
- `disabled` - The banner will be disabled
- `header` - The banner will be displayed in the header
- `top` - The banner will be displayed in the header
- `textContent`
@ -31,7 +31,7 @@ The color for the banner's background. Must be a valid hex color
`kibana.yml`
```yaml
xpack.banners:
placement: 'header'
placement: 'top'
textContent: 'Production environment - Proceed with **special levels** of caution'
textColor: '#FF0000'
backgroundColor: '#CC2211'

View file

@ -10,7 +10,7 @@ export interface BannerInfoResponse {
banner: BannerConfiguration;
}
export type BannerPlacement = 'disabled' | 'header';
export type BannerPlacement = 'disabled' | 'top';
export interface BannerConfiguration {
placement: BannerPlacement;

View file

@ -26,7 +26,7 @@ export const Banner: FC<BannerProps> = ({ bannerConfig }) => {
}}
>
<div data-test-subj="bannerInnerWrapper">
<Markdown markdown={textContent} />
<Markdown markdown={textContent} openLinksInNewTab={true} />
</div>
</div>
);

View file

@ -7,11 +7,19 @@
import { getBannerInfoMock } from './plugin.test.mocks';
import { coreMock } from '../../../../src/core/public/mocks';
import { BannerConfiguration } from '../common/types';
import { BannersPlugin } from './plugin';
import { BannerClientConfig } from './types';
const nextTick = async () => await new Promise<void>((resolve) => resolve());
const createBannerConfig = (parts: Partial<BannerConfiguration> = {}): BannerConfiguration => ({
placement: 'disabled',
textContent: 'foo',
textColor: '#FFFFFF',
backgroundColor: '#000000',
...parts,
});
describe('BannersPlugin', () => {
let plugin: BannersPlugin;
let pluginInitContext: ReturnType<typeof coreMock.createPluginInitializerContext>;
@ -25,11 +33,12 @@ describe('BannersPlugin', () => {
getBannerInfoMock.mockResolvedValue({
allowed: false,
banner: createBannerConfig(),
});
});
const startPlugin = async (config: BannerClientConfig) => {
pluginInitContext = coreMock.createPluginInitializerContext(config);
const startPlugin = async () => {
pluginInitContext = coreMock.createPluginInitializerContext();
plugin = new BannersPlugin(pluginInitContext);
plugin.setup(coreSetup);
plugin.start(coreStart);
@ -41,46 +50,62 @@ describe('BannersPlugin', () => {
getBannerInfoMock.mockReset();
});
it('calls `getBannerInfo` if `config.placement !== disabled`', async () => {
await startPlugin({
placement: 'header',
describe('when banner is allowed', () => {
it('registers the header banner if `banner.placement` is `top`', async () => {
getBannerInfoMock.mockResolvedValue({
allowed: true,
banner: createBannerConfig({
placement: 'top',
}),
});
await startPlugin();
expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledWith({
content: expect.any(Function),
});
});
expect(getBannerInfoMock).toHaveBeenCalledTimes(1);
});
it('does not register the header banner if `banner.placement` is `disabled`', async () => {
getBannerInfoMock.mockResolvedValue({
allowed: true,
banner: createBannerConfig({
placement: 'disabled',
}),
});
it('does not call `getBannerInfo` if `config.placement === disabled`', async () => {
await startPlugin({
placement: 'disabled',
});
await startPlugin();
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),
expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledTimes(0);
});
});
it('does not register the header banner if `getBannerInfo` return `allowed=false`', async () => {
getBannerInfoMock.mockResolvedValue({
allowed: false,
describe('when banner is not allowed', () => {
it('does not register the header banner if `banner.placement` is `top`', async () => {
getBannerInfoMock.mockResolvedValue({
allowed: false,
banner: createBannerConfig({
placement: 'top',
}),
});
await startPlugin();
expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledTimes(0);
});
await startPlugin({
placement: 'header',
});
it('does not register the header banner if `banner.placement` is `disabled`', async () => {
getBannerInfoMock.mockResolvedValue({
allowed: false,
banner: createBannerConfig({
placement: 'disabled',
}),
});
expect(coreStart.chrome.setHeaderBanner).not.toHaveBeenCalled();
await startPlugin();
expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -9,35 +9,28 @@ 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>();
}
constructor(context: PluginInitializerContext) {}
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);
getBannerInfo(http).then(
({ allowed, banner }) => {
if (allowed && banner.placement === 'top') {
chrome.setHeaderBanner({
content: toMountPoint(<Banner bannerConfig={banner} />),
});
}
);
}
},
() => {
chrome.setHeaderBanner(undefined);
}
);
return {};
}

View file

@ -5,12 +5,13 @@
* 2.0.
*/
import { get } from 'lodash';
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')], {
placement: schema.oneOf([schema.literal('disabled'), schema.literal('top')], {
defaultValue: 'disabled',
}),
textContent: schema.string({ defaultValue: '' }),
@ -30,13 +31,25 @@ const configSchema = schema.object({
},
defaultValue: '#FFF9E8',
}),
disableSpaceBanners: schema.boolean({ defaultValue: false }),
});
export type BannersConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<BannersConfigType> = {
schema: configSchema,
exposeToBrowser: {
placement: true,
},
exposeToBrowser: {},
deprecations: () => [
(rootConfig, fromPath, addDeprecation) => {
const pluginConfig = get(rootConfig, fromPath);
if (pluginConfig?.placement === 'header') {
addDeprecation({
message: 'The `header` value for xpack.banners.placement has been replaced by `top`',
});
pluginConfig.placement = 'top';
}
return rootConfig;
},
],
};

View file

@ -5,8 +5,12 @@
* 2.0.
*/
import { BannerPlacement } from '../common';
export const registerRoutesMock = jest.fn();
jest.doMock('./routes', () => ({
registerRoutes: registerRoutesMock,
}));
export interface BannerClientConfig {
placement: BannerPlacement;
}
export const registerSettingsMock = jest.fn();
jest.doMock('./ui_settings', () => ({
registerSettings: registerSettingsMock,
}));

View file

@ -0,0 +1,54 @@
/*
* 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 { registerRoutesMock, registerSettingsMock } from './plugin.test.mocks';
import { coreMock } from '../../../../src/core/server/mocks';
import { BannersPlugin } from './plugin';
import { BannersConfigType } from './config';
describe('BannersPlugin', () => {
let plugin: BannersPlugin;
let pluginInitContext: ReturnType<typeof coreMock.createPluginInitializerContext>;
let coreSetup: ReturnType<typeof coreMock.createSetup>;
let bannerConfig: BannersConfigType;
beforeEach(() => {
bannerConfig = {
placement: 'top',
textContent: 'foo',
backgroundColor: '#000000',
textColor: '#FFFFFF',
disableSpaceBanners: false,
};
pluginInitContext = coreMock.createPluginInitializerContext();
pluginInitContext.config.get.mockReturnValue(bannerConfig);
coreSetup = coreMock.createSetup();
plugin = new BannersPlugin(pluginInitContext);
});
afterEach(() => {
registerRoutesMock.mockReset();
registerSettingsMock.mockReset();
});
describe('#setup', () => {
it('calls `registerRoutes` with the correct parameters', () => {
plugin.setup(coreSetup);
expect(registerRoutesMock).toHaveBeenCalledTimes(1);
expect(registerRoutesMock).toHaveBeenCalledWith(expect.any(Object), bannerConfig);
});
it('calls `registerSettings` with the correct parameters', () => {
plugin.setup(coreSetup);
expect(registerSettingsMock).toHaveBeenCalledTimes(1);
expect(registerSettingsMock).toHaveBeenCalledWith(coreSetup.uiSettings, bannerConfig);
});
});
});

View file

@ -6,21 +6,22 @@
*/
import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/server';
import { BannerConfiguration } from '../common';
import { BannersConfigType } from './config';
import { BannersRequestHandlerContext } from './types';
import { registerRoutes } from './routes';
import { registerSettings } from './ui_settings';
export class BannersPlugin implements Plugin<{}, {}, {}, {}> {
private readonly config: BannerConfiguration;
private readonly config: BannersConfigType;
constructor(context: PluginInitializerContext) {
this.config = convertConfig(context.config.get<BannersConfigType>());
this.config = context.config.get<BannersConfigType>();
}
setup({ uiSettings, getStartServices, http }: CoreSetup<{}, {}>) {
const router = http.createRouter<BannersRequestHandlerContext>();
registerRoutes(router, this.config);
registerSettings(uiSettings, this.config);
return {};
}
@ -29,5 +30,3 @@ export class BannersPlugin implements Plugin<{}, {}, {}, {}> {
return {};
}
}
const convertConfig = (raw: BannersConfigType): BannerConfiguration => raw;

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import { BannerConfiguration } from '../../common';
import { BannersConfigType } from '../config';
import { BannersRouter } from '../types';
import { registerInfoRoute } from './info';
export const registerRoutes = (router: BannersRouter, config: BannerConfiguration) => {
export const registerRoutes = (router: BannersRouter, config: BannersConfigType) => {
registerInfoRoute(router, config);
};

View file

@ -5,26 +5,33 @@
* 2.0.
*/
import { IUiSettingsClient } from 'kibana/server';
import { ILicense } from '../../../licensing/server';
import { BannerInfoResponse, BannerConfiguration } from '../../common';
import { BannersConfigType } from '../config';
import { BannerInfoResponse, BannerConfiguration, BannerPlacement } from '../../common';
import { BannersRouter } from '../types';
export const registerInfoRoute = (router: BannersRouter, config: BannerConfiguration) => {
export const registerInfoRoute = (router: BannersRouter, config: BannersConfigType) => {
router.get(
{
path: '/api/banners/info',
validate: false,
options: {
authRequired: false,
authRequired: 'optional',
},
},
(ctx, req, res) => {
async (ctx, req, res) => {
const allowed = isValidLicense(ctx.licensing.license);
const bannerConfig =
req.auth.isAuthenticated && config.disableSpaceBanners === false
? await getBannerConfig(ctx.core.uiSettings.client)
: config;
return res.ok({
body: {
allowed,
banner: config,
banner: bannerConfig,
} as BannerInfoResponse,
});
}
@ -34,3 +41,19 @@ export const registerInfoRoute = (router: BannersRouter, config: BannerConfigura
const isValidLicense = (license: ILicense): boolean => {
return license.hasAtLeast('gold');
};
const getBannerConfig = async (client: IUiSettingsClient): Promise<BannerConfiguration> => {
const [placement, textContent, textColor, backgroundColor] = await Promise.all([
client.get<BannerPlacement>('banners:placement'),
client.get<string>('banners:textContent'),
client.get<string>('banners:textColor'),
client.get<string>('banners:backgroundColor'),
]);
return {
placement,
textContent,
textColor,
backgroundColor,
};
};

View file

@ -0,0 +1,72 @@
/*
* 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 { uiSettingsServiceMock } from '../../../../src/core/server/mocks';
import { BannersConfigType } from './config';
import { registerSettings } from './ui_settings';
const createConfig = (parts: Partial<BannersConfigType> = {}): BannersConfigType => ({
placement: 'disabled',
backgroundColor: '#0000',
textColor: '#FFFFFF',
textContent: 'Hello from the banner',
disableSpaceBanners: false,
...parts,
});
describe('registerSettings', () => {
let uiSettings: ReturnType<typeof uiSettingsServiceMock.createSetupContract>;
beforeEach(() => {
uiSettings = uiSettingsServiceMock.createSetupContract();
});
it('registers the settings', () => {
registerSettings(uiSettings, createConfig());
expect(uiSettings.register).toHaveBeenCalledTimes(1);
expect(uiSettings.register).toHaveBeenCalledWith({
'banners:placement': expect.any(Object),
'banners:textContent': expect.any(Object),
'banners:textColor': expect.any(Object),
'banners:backgroundColor': expect.any(Object),
});
});
it('does not register the settings if `config.disableSpaceBanners` is `true`', () => {
registerSettings(uiSettings, createConfig({ disableSpaceBanners: true }));
expect(uiSettings.register).not.toHaveBeenCalled();
});
it('uses the configuration values as defaults', () => {
const config = createConfig({
placement: 'top',
backgroundColor: '#FF00CC',
textColor: '#AAFFEE',
textContent: 'Some text',
});
registerSettings(uiSettings, config);
expect(uiSettings.register).toHaveBeenCalledTimes(1);
expect(uiSettings.register).toHaveBeenCalledWith({
'banners:placement': expect.objectContaining({
value: config.placement,
}),
'banners:textContent': expect.objectContaining({
value: config.textContent,
}),
'banners:textColor': expect.objectContaining({
value: config.textColor,
}),
'banners:backgroundColor': expect.objectContaining({
value: config.backgroundColor,
}),
});
});
});

View file

@ -0,0 +1,120 @@
/*
* 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 } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { UiSettingsServiceSetup } from 'src/core/server';
import { BannersConfigType } from './config';
import { isHexColor } from './utils';
export const registerSettings = (uiSettings: UiSettingsServiceSetup, config: BannersConfigType) => {
if (config.disableSpaceBanners) {
return;
}
const subscriptionLink = `
<a href="https://www.elastic.co/subscriptions" target="_blank" rel="noopener noreferrer">
${i18n.translate('xpack.banners.settings.subscriptionRequiredLink.text', {
defaultMessage: 'Subscription required.',
})}
</a>
`;
uiSettings.register({
'banners:placement': {
name: i18n.translate('xpack.banners.settings.placement.title', {
defaultMessage: 'Banner placement',
}),
description: i18n.translate('xpack.banners.settings.placement.description', {
defaultMessage:
'Display a top banner for this space, above the Elastic header. {subscriptionLink}',
values: {
subscriptionLink,
},
}),
category: ['banner'],
order: 1,
type: 'select',
value: config.placement,
options: ['disabled', 'top'],
optionLabels: {
disabled: i18n.translate('xpack.banners.settings.placement.disabled', {
defaultMessage: 'Disabled',
}),
top: i18n.translate('xpack.banners.settings.placement.top', {
defaultMessage: 'Top',
}),
},
requiresPageReload: true,
schema: schema.oneOf([schema.literal('disabled'), schema.literal('top')]),
},
'banners:textContent': {
name: i18n.translate('xpack.banners.settings.textContent.title', {
defaultMessage: 'Banner text',
}),
description: i18n.translate('xpack.banners.settings.text.description', {
defaultMessage: 'Add Markdown-formatted text to the banner. {subscriptionLink}',
values: {
subscriptionLink,
},
}),
sensitive: true,
category: ['banner'],
order: 2,
type: 'markdown',
value: config.textContent,
requiresPageReload: true,
schema: schema.string(),
},
'banners:textColor': {
name: i18n.translate('xpack.banners.settings.textColor.title', {
defaultMessage: 'Banner text color',
}),
description: i18n.translate('xpack.banners.settings.textColor.description', {
defaultMessage: 'Set the color of the banner text. {subscriptionLink}',
values: {
subscriptionLink,
},
}),
category: ['banner'],
order: 3,
type: 'color',
value: config.textColor,
requiresPageReload: true,
schema: schema.string({
validate: (color) => {
if (!isHexColor(color)) {
return `'banners:textColor' must be an hex color`;
}
},
}),
},
'banners:backgroundColor': {
name: i18n.translate('xpack.banners.settings.backgroundColor.title', {
defaultMessage: 'Banner background color',
}),
description: i18n.translate('xpack.banners.settings.backgroundColor.description', {
defaultMessage: 'Set the background color for the banner. {subscriptionLink}',
values: {
subscriptionLink,
},
}),
category: ['banner'],
order: 4,
type: 'color',
value: config.backgroundColor,
requiresPageReload: true,
schema: schema.string({
validate: (color) => {
if (!isHexColor(color)) {
return `'banners:backgroundColor' must be an hex color`;
}
},
}),
},
});
};

View file

@ -0,0 +1,45 @@
/*
* 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 path from 'path';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
import { services, pageObjects } from './ftr_provider_context';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const kibanaFunctionalConfig = await readConfigFile(require.resolve('../functional/config.js'));
return {
testFiles: [require.resolve('./tests')],
servers: {
...kibanaFunctionalConfig.get('servers'),
},
services,
pageObjects,
junit: {
reportName: 'X-Pack Banners Functional Tests',
},
esTestCluster: kibanaFunctionalConfig.get('esTestCluster'),
apps: {
...kibanaFunctionalConfig.get('apps'),
},
esArchiver: {
directory: path.resolve(__dirname, '..', 'functional', 'es_archives'),
},
kbnTestServer: {
...kibanaFunctionalConfig.get('kbnTestServer'),
serverArgs: [
...kibanaFunctionalConfig.get('kbnTestServer.serverArgs'),
'--xpack.banners.placement=header',
'--xpack.banners.textContent="global banner text"',
],
},
};
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { GenericFtrProviderContext } from '@kbn/test/types/ftr';
import { services } from '../functional/services';
import { pageObjects } from '../functional/page_objects';
export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
export { services, pageObjects };

View file

@ -0,0 +1,22 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'security', 'banners']);
describe('global pages', () => {
it('displays the global banner on the login page', async () => {
await PageObjects.common.navigateToApp('login');
expect(await PageObjects.banners.isTopBannerVisible()).to.eql(true);
expect(await PageObjects.banners.getTopBannerText()).to.eql('global banner text');
});
});
}

View file

@ -0,0 +1,17 @@
/*
* 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 { FtrProviderContext } from '../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('banners - functional tests', function () {
this.tags('ciGroup2');
loadTestFile(require.resolve('./global'));
loadTestFile(require.resolve('./spaces'));
});
}

View file

@ -0,0 +1,67 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects([
'common',
'security',
'banners',
'settings',
'spaceSelector',
]);
describe('per-spaces banners', () => {
before(async () => {
await esArchiver.load('banners/multispace');
});
after(async () => {
await esArchiver.unload('banners/multispace');
});
before(async () => {
await PageObjects.security.login(undefined, undefined, {
expectSpaceSelector: true,
});
await PageObjects.spaceSelector.clickSpaceCard('default');
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSettings();
await PageObjects.settings.setAdvancedSettingsTextArea(
'banners:textContent',
'default space banner text'
);
});
it('displays the space-specific banner within the space', async () => {
await PageObjects.common.navigateToApp('home');
expect(await PageObjects.banners.isTopBannerVisible()).to.eql(true);
expect(await PageObjects.banners.getTopBannerText()).to.eql('default space banner text');
});
it('displays the global banner within another space', async () => {
await PageObjects.common.navigateToApp('home', { basePath: '/s/another-space' });
expect(await PageObjects.banners.isTopBannerVisible()).to.eql(true);
expect(await PageObjects.banners.getTopBannerText()).to.eql('global banner text');
});
it('displays the global banner on the login page', async () => {
await PageObjects.security.forceLogout();
await PageObjects.common.navigateToApp('login');
expect(await PageObjects.banners.isTopBannerVisible()).to.eql(true);
expect(await PageObjects.banners.getTopBannerText()).to.eql('global banner text');
});
});
}

View file

@ -0,0 +1,62 @@
{
"type": "doc",
"value": {
"id": "config:6.0.0",
"index": ".kibana",
"source": {
"config": {
"buildNum": 8467,
"dateFormat:tz": "UTC",
"defaultRoute": "http://example.com/evil"
},
"type": "config"
}
}
}
{
"type": "doc",
"value": {
"id": "another-space:config:6.0.0",
"index": ".kibana",
"source": {
"namespace": "another-space",
"config": {
"buildNum": 8467,
"dateFormat:tz": "UTC",
"defaultRoute": "/app/canvas"
},
"type": "config"
}
}
}
{
"type": "doc",
"value": {
"id": "space:default",
"index": ".kibana",
"source": {
"space": {
"description": "This is the default space!",
"name": "Default"
},
"type": "space"
}
}
}
{
"type": "doc",
"value": {
"id": "space:another-space",
"index": ".kibana",
"source": {
"space": {
"description": "This is another space",
"name": "Another Space"
},
"type": "space"
}
}
}

View file

@ -0,0 +1,287 @@
{
"type": "index",
"value": {
"index": ".kibana",
"mappings": {
"properties": {
"config": {
"dynamic": "true",
"properties": {
"buildNum": {
"type": "keyword"
},
"dateFormat:tz": {
"fields": {
"keyword": {
"ignore_above": 256,
"type": "keyword"
}
},
"type": "text"
},
"defaultRoute": {
"type": "keyword"
}
}
},
"dashboard": {
"dynamic": "strict",
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"optionsJSON": {
"type": "text"
},
"panelsJSON": {
"type": "text"
},
"refreshInterval": {
"properties": {
"display": {
"type": "keyword"
},
"pause": {
"type": "boolean"
},
"section": {
"type": "integer"
},
"value": {
"type": "integer"
}
}
},
"timeFrom": {
"type": "keyword"
},
"timeRestore": {
"type": "boolean"
},
"timeTo": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"index-pattern": {
"dynamic": "strict",
"properties": {
"fieldFormatMap": {
"type": "text"
},
"fields": {
"type": "text"
},
"intervalName": {
"type": "keyword"
},
"notExpandable": {
"type": "boolean"
},
"sourceFilters": {
"type": "text"
},
"timeFieldName": {
"type": "keyword"
},
"title": {
"type": "text"
}
}
},
"search": {
"dynamic": "strict",
"properties": {
"columns": {
"type": "keyword"
},
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"sort": {
"type": "keyword"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"server": {
"dynamic": "strict",
"properties": {
"uuid": {
"type": "keyword"
}
}
},
"space": {
"properties": {
"_reserved": {
"type": "boolean"
},
"color": {
"type": "keyword"
},
"description": {
"type": "text"
},
"disabledFeatures": {
"type": "keyword"
},
"initials": {
"type": "keyword"
},
"name": {
"fields": {
"keyword": {
"ignore_above": 2048,
"type": "keyword"
}
},
"type": "text"
}
}
},
"spaceId": {
"type": "keyword"
},
"timelion-sheet": {
"dynamic": "strict",
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"timelion_chart_height": {
"type": "integer"
},
"timelion_columns": {
"type": "integer"
},
"timelion_interval": {
"type": "keyword"
},
"timelion_other_interval": {
"type": "keyword"
},
"timelion_rows": {
"type": "integer"
},
"timelion_sheet": {
"type": "text"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"type": {
"type": "keyword"
},
"url": {
"dynamic": "strict",
"properties": {
"accessCount": {
"type": "long"
},
"accessDate": {
"type": "date"
},
"createDate": {
"type": "date"
},
"url": {
"fields": {
"keyword": {
"ignore_above": 2048,
"type": "keyword"
}
},
"type": "text"
}
}
},
"visualization": {
"dynamic": "strict",
"properties": {
"description": {
"type": "text"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"savedSearchId": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
},
"visState": {
"type": "text"
}
}
}
}
},
"settings": {
"index": {
"number_of_replicas": "1",
"number_of_shards": "1"
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* 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 { FtrProviderContext } from '../ftr_provider_context';
export function BannersPageProvider({ getService }: FtrProviderContext) {
const find = getService('find');
class BannersPage {
isTopBannerVisible() {
return find.existsByCssSelector('.header__topBanner .kbnUserBanner__container');
}
async getTopBannerText() {
if (!(await this.isTopBannerVisible())) {
return '';
}
const bannerContainer = await find.byCssSelector(
'.header__topBanner .kbnUserBanner__container'
);
return bannerContainer.getVisibleText();
}
}
return new BannersPage();
}

View file

@ -41,6 +41,7 @@ import { TagManagementPageProvider } from './tag_management_page';
import { NavigationalSearchProvider } from './navigational_search';
import { SearchSessionsPageProvider } from './search_sessions_management_page';
import { DetectionsPageProvider } from '../../security_solution_ftr/page_objects/detections';
import { BannersPageProvider } from './banners_page';
// just like services, PageObjects are defined as a map of
// names to Providers. Merge in Kibana's or pick specific ones
@ -78,5 +79,6 @@ export const pageObjects = {
roleMappings: RoleMappingsPageProvider,
ingestPipelines: IngestPipelinesPageProvider,
navigationalSearch: NavigationalSearchProvider,
banners: BannersPageProvider,
detections: DetectionsPageProvider,
};

View file

@ -9,7 +9,7 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
describe('TOTO GlobalSearchBar', function () {
describe('GlobalSearchBar', function () {
const { common, navigationalSearch } = getPageObjects(['common', 'navigationalSearch']);
const esArchiver = getService('esArchiver');
const browser = getService('browser');