From b73b06928bd355b7755e3fdfb4bb07e7244d1ec0 Mon Sep 17 00:00:00 2001 From: Eli Perelman Date: Wed, 13 Nov 2019 14:57:15 -0600 Subject: [PATCH] Allow registered applications to hide Kibana chrome (#49795) * Allow registered applications to hide Kibana chrome * Fix bug in flipped value of application chromeHidden * Add additional test for app chrome hidden versus chrome visibility * Rename chromeHidden to chromeless * Default chrome service app hidden observable to same value as force hidden * Consolidate force hiding in chrome, add functional tests * Move chromeless flag to App interface to prevent legacy applications from specifying * Address review nits to improve separation --- .../kibana-plugin-public.app.chromeless.md | 13 + .../core/public/kibana-plugin-public.app.md | 1 + ...n-public.appmountparameters.appbasepath.md | 13 +- src/core/public/application/types.ts | 19 +- src/core/public/chrome/chrome_service.test.ts | 473 ++++++++++-------- src/core/public/chrome/chrome_service.tsx | 62 ++- src/core/public/public.api.md | 1 + .../core_plugin_chromeless/kibana.json | 8 + .../core_plugin_chromeless/package.json | 17 + .../public/application.tsx | 74 +++ .../core_plugin_chromeless/public/index.ts | 30 ++ .../core_plugin_chromeless/public/plugin.tsx | 47 ++ .../core_plugin_chromeless/tsconfig.json | 14 + .../test_suites/core_plugins/applications.ts | 12 + 14 files changed, 550 insertions(+), 234 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.app.chromeless.md create mode 100644 test/plugin_functional/plugins/core_plugin_chromeless/kibana.json create mode 100644 test/plugin_functional/plugins/core_plugin_chromeless/package.json create mode 100644 test/plugin_functional/plugins/core_plugin_chromeless/public/application.tsx create mode 100644 test/plugin_functional/plugins/core_plugin_chromeless/public/index.ts create mode 100644 test/plugin_functional/plugins/core_plugin_chromeless/public/plugin.tsx create mode 100644 test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json diff --git a/docs/development/core/public/kibana-plugin-public.app.chromeless.md b/docs/development/core/public/kibana-plugin-public.app.chromeless.md new file mode 100644 index 0000000000000..dc1e19bab80b2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.app.chromeless.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [App](./kibana-plugin-public.app.md) > [chromeless](./kibana-plugin-public.app.chromeless.md) + +## App.chromeless property + +Hide the UI chrome when the application is mounted. Defaults to `false`. Takes precedence over chrome service visibility settings. + +Signature: + +```typescript +chromeless?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.app.md b/docs/development/core/public/kibana-plugin-public.app.md index 60cac357d1fe0..c500c080a5feb 100644 --- a/docs/development/core/public/kibana-plugin-public.app.md +++ b/docs/development/core/public/kibana-plugin-public.app.md @@ -16,5 +16,6 @@ export interface App extends AppBase | Property | Type | Description | | --- | --- | --- | +| [chromeless](./kibana-plugin-public.app.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [mount](./kibana-plugin-public.app.mount.md) | (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise<AppUnmount> | A mount function called when the user navigates to this app's route. | diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md index 16c8ffe07fc15..31513bda2e879 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md @@ -21,12 +21,13 @@ How to configure react-router with a base path: export class MyPlugin implements Plugin { setup({ application }) { application.register({ - id: 'my-app', - async mount(context, params) { - const { renderApp } = await import('./application'); - return renderApp(context, params); - }, - }); + id: 'my-app', + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); + } } ``` diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 5b1d4affe8840..5be22ea151c32 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -80,6 +80,12 @@ export interface App extends AppBase { * @returns An unmounting function that will be called to unmount the application. */ mount: (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; + + /** + * Hide the UI chrome when the application is mounted. Defaults to `false`. + * Takes precedence over chrome service visibility settings. + */ + chromeless?: boolean; } /** @internal */ @@ -145,12 +151,13 @@ export interface AppMountParameters { * export class MyPlugin implements Plugin { * setup({ application }) { * application.register({ - * id: 'my-app', - * async mount(context, params) { - * const { renderApp } = await import('./application'); - * return renderApp(context, params); - * }, - * }); + * id: 'my-app', + * async mount(context, params) { + * const { renderApp } = await import('./application'); + * return renderApp(context, params); + * }, + * }); + * } * } * ``` * diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 45e94040eeb4a..3390480e56bdd 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -26,351 +26,423 @@ import { applicationServiceMock } from '../application/application_service.mock' import { httpServiceMock } from '../http/http_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { notificationServiceMock } from '../notifications/notifications_service.mock'; -import { ChromeService } from './chrome_service'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; +import { ChromeService } from './chrome_service'; +import { App } from '../application'; +class FakeApp implements App { + public title = `${this.id} App`; + public mount = () => () => {}; + constructor(public id: string, public chromeless?: boolean) {} +} const store = new Map(); +const originalLocalStorage = window.localStorage; + (window as any).localStorage = { setItem: (key: string, value: string) => store.set(String(key), String(value)), getItem: (key: string) => store.get(String(key)), removeItem: (key: string) => store.delete(String(key)), }; -function defaultStartDeps() { - return { +function defaultStartDeps(availableApps?: App[]) { + const deps = { application: applicationServiceMock.createInternalStartContract(), docLinks: docLinksServiceMock.createStartContract(), http: httpServiceMock.createStartContract(), injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), }; + + if (availableApps) { + deps.application.availableApps = new Map(availableApps.map(app => [app.id, app])); + } + + return deps; +} + +async function start({ + options = { browserSupportsCsp: true }, + cspConfigMock = { warnLegacyBrowsers: true }, + startDeps = defaultStartDeps(), +}: { options?: any; cspConfigMock?: any; startDeps?: ReturnType } = {}) { + const service = new ChromeService(options); + + if (cspConfigMock) { + startDeps.injectedMetadata.getCspConfig.mockReturnValue(cspConfigMock); + } + + return { + service, + startDeps, + chrome: await service.start(startDeps), + }; } beforeEach(() => { store.clear(); + window.history.pushState(undefined, '', '#/home?a=b'); +}); + +afterAll(() => { + (window as any).localStorage = originalLocalStorage; }); describe('start', () => { it('adds legacy browser warning if browserSupportsCsp is disabled and warnLegacyBrowsers is enabled', async () => { - const service = new ChromeService({ browserSupportsCsp: false }); - const startDeps = defaultStartDeps(); - startDeps.injectedMetadata.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); - await service.start(startDeps); + const { startDeps } = await start({ options: { browserSupportsCsp: false } }); + expect(startDeps.notifications.toasts.addWarning.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - "Your browser does not meet the security requirements for Kibana.", - ], -] -`); + Array [ + Array [ + "Your browser does not meet the security requirements for Kibana.", + ], + ] + `); }); it('does not add legacy browser warning if browser supports CSP', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const startDeps = defaultStartDeps(); - startDeps.injectedMetadata.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); - await service.start(startDeps); + const { startDeps } = await start(); + expect(startDeps.notifications.toasts.addWarning).not.toBeCalled(); }); it('does not add legacy browser warning if warnLegacyBrowsers is disabled', async () => { - const service = new ChromeService({ browserSupportsCsp: false }); - const startDeps = defaultStartDeps(); - startDeps.injectedMetadata.getCspConfig.mockReturnValue({ warnLegacyBrowsers: false }); - await service.start(startDeps); + const { startDeps } = await start({ + options: { browserSupportsCsp: false }, + cspConfigMock: { warnLegacyBrowsers: false }, + }); + expect(startDeps.notifications.toasts.addWarning).not.toBeCalled(); }); describe('getComponent', () => { it('returns a renderable React component', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); + const { chrome } = await start(); + // Have to do some fanagling to get the type system and enzyme to accept this. // Don't capture the snapshot because it's 600+ lines long. - expect(shallow(React.createElement(() => start.getHeaderComponent()))).toBeDefined(); + expect(shallow(React.createElement(() => chrome.getHeaderComponent()))).toBeDefined(); }); }); describe('brand', () => { it('updates/emits the brand as it changes', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome .getBrand$() .pipe(toArray()) .toPromise(); - start.setBrand({ + chrome.setBrand({ logo: 'big logo', smallLogo: 'not so big logo', }); - start.setBrand({ + chrome.setBrand({ logo: 'big logo without small logo', }); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - Object {}, - Object { - "logo": "big logo", - "smallLogo": "not so big logo", - }, - Object { - "logo": "big logo without small logo", - "smallLogo": undefined, - }, -] -`); + Array [ + Object {}, + Object { + "logo": "big logo", + "smallLogo": "not so big logo", + }, + Object { + "logo": "big logo without small logo", + "smallLogo": undefined, + }, + ] + `); }); }); describe('visibility', () => { it('updates/emits the visibility', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); - start.setIsVisible(true); - start.setIsVisible(false); - start.setIsVisible(true); + chrome.setIsVisible(true); + chrome.setIsVisible(false); + chrome.setIsVisible(true); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - true, - true, - false, - true, -] -`); + Array [ + true, + true, + false, + true, + ] + `); }); - it('always emits false if embed query string is in hash when set up', async () => { + it('always emits false if embed query string is preset when set up', async () => { window.history.pushState(undefined, '', '#/home?a=b&embed=true'); - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome + .getIsVisible$() + .pipe(toArray()) + .toPromise(); + + chrome.setIsVisible(true); + chrome.setIsVisible(false); + chrome.setIsVisible(true); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + false, + false, + false, + false, + ] + `); + }); + + it('application-specified visibility on mount', async () => { + const startDeps = defaultStartDeps([ + new FakeApp('alpha'), // An undefined `chromeless` is the same as setting to false. + new FakeApp('beta', true), + new FakeApp('gamma', false), + ]); + const { availableApps, currentAppId$ } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + const promise = chrome + .getIsVisible$() + .pipe(toArray()) + .toPromise(); + + [...availableApps.keys()].forEach(appId => currentAppId$.next(appId)); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + true, + true, + false, + true, + ] + `); + }); + + it('changing visibility has no effect on chrome-hiding application', async () => { + const startDeps = defaultStartDeps([new FakeApp('alpha', true)]); + const { currentAppId$ } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); - start.setIsVisible(true); - start.setIsVisible(false); - start.setIsVisible(true); + currentAppId$.next('alpha'); + chrome.setIsVisible(true); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - false, - false, - false, - false, -] -`); + Array [ + true, + false, + false, + ] + `); }); }); describe('is collapsed', () => { it('updates/emits isCollapsed', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome .getIsCollapsed$() .pipe(toArray()) .toPromise(); - start.setIsCollapsed(true); - start.setIsCollapsed(false); - start.setIsCollapsed(true); + chrome.setIsCollapsed(true); + chrome.setIsCollapsed(false); + chrome.setIsCollapsed(true); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - false, - true, - false, - true, -] -`); + Array [ + false, + true, + false, + true, + ] + `); }); it('only stores true in localStorage', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); + const { chrome } = await start(); - start.setIsCollapsed(true); + chrome.setIsCollapsed(true); expect(store.size).toBe(1); - start.setIsCollapsed(false); + chrome.setIsCollapsed(false); expect(store.size).toBe(0); }); }); describe('application classes', () => { it('updates/emits the application classes', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome .getApplicationClasses$() .pipe(toArray()) .toPromise(); - start.addApplicationClass('foo'); - start.addApplicationClass('foo'); - start.addApplicationClass('bar'); - start.addApplicationClass('bar'); - start.addApplicationClass('baz'); - start.removeApplicationClass('bar'); - start.removeApplicationClass('foo'); + chrome.addApplicationClass('foo'); + chrome.addApplicationClass('foo'); + chrome.addApplicationClass('bar'); + chrome.addApplicationClass('bar'); + chrome.addApplicationClass('baz'); + chrome.removeApplicationClass('bar'); + chrome.removeApplicationClass('foo'); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - Array [], - Array [ - "foo", - ], - Array [ - "foo", - ], - Array [ - "foo", - "bar", - ], - Array [ - "foo", - "bar", - ], - Array [ - "foo", - "bar", - "baz", - ], - Array [ - "foo", - "baz", - ], - Array [ - "baz", - ], -] -`); + Array [ + Array [], + Array [ + "foo", + ], + Array [ + "foo", + ], + Array [ + "foo", + "bar", + ], + Array [ + "foo", + "bar", + ], + Array [ + "foo", + "bar", + "baz", + ], + Array [ + "foo", + "baz", + ], + Array [ + "baz", + ], + ] + `); }); }); describe('badge', () => { it('updates/emits the current badge', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome .getBadge$() .pipe(toArray()) .toPromise(); - start.setBadge({ text: 'foo', tooltip: `foo's tooltip` }); - start.setBadge({ text: 'bar', tooltip: `bar's tooltip` }); - start.setBadge(undefined); + chrome.setBadge({ text: 'foo', tooltip: `foo's tooltip` }); + chrome.setBadge({ text: 'bar', tooltip: `bar's tooltip` }); + chrome.setBadge(undefined); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - undefined, - Object { - "text": "foo", - "tooltip": "foo's tooltip", - }, - Object { - "text": "bar", - "tooltip": "bar's tooltip", - }, - undefined, -] -`); + Array [ + undefined, + Object { + "text": "foo", + "tooltip": "foo's tooltip", + }, + Object { + "text": "bar", + "tooltip": "bar's tooltip", + }, + undefined, + ] + `); }); }); describe('breadcrumbs', () => { it('updates/emits the current set of breadcrumbs', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome .getBreadcrumbs$() .pipe(toArray()) .toPromise(); - start.setBreadcrumbs([{ text: 'foo' }, { text: 'bar' }]); - start.setBreadcrumbs([{ text: 'foo' }]); - start.setBreadcrumbs([{ text: 'bar' }]); - start.setBreadcrumbs([]); + chrome.setBreadcrumbs([{ text: 'foo' }, { text: 'bar' }]); + chrome.setBreadcrumbs([{ text: 'foo' }]); + chrome.setBreadcrumbs([{ text: 'bar' }]); + chrome.setBreadcrumbs([]); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - Array [], - Array [ - Object { - "text": "foo", - }, - Object { - "text": "bar", - }, - ], - Array [ - Object { - "text": "foo", - }, - ], - Array [ - Object { - "text": "bar", - }, - ], - Array [], -] -`); + Array [ + Array [], + Array [ + Object { + "text": "foo", + }, + Object { + "text": "bar", + }, + ], + Array [ + Object { + "text": "foo", + }, + ], + Array [ + Object { + "text": "bar", + }, + ], + Array [], + ] + `); }); }); describe('help extension', () => { it('updates/emits the current help extension', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); - const promise = start + const { chrome, service } = await start(); + const promise = chrome .getHelpExtension$() .pipe(toArray()) .toPromise(); - start.setHelpExtension(() => () => undefined); - start.setHelpExtension(undefined); + chrome.setHelpExtension(() => () => undefined); + chrome.setHelpExtension(undefined); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` -Array [ - undefined, - [Function], - undefined, -] -`); + Array [ + undefined, + [Function], + undefined, + ] + `); }); }); }); describe('stop', () => { it('completes applicationClass$, isCollapsed$, breadcrumbs$, isVisible$, and brand$ observables', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); + const { chrome, service } = await start(); const promise = Rx.combineLatest( - start.getBrand$(), - start.getApplicationClasses$(), - start.getIsCollapsed$(), - start.getBreadcrumbs$(), - start.getIsVisible$(), - start.getHelpExtension$() + chrome.getBrand$(), + chrome.getApplicationClasses$(), + chrome.getIsCollapsed$(), + chrome.getBreadcrumbs$(), + chrome.getIsVisible$(), + chrome.getHelpExtension$() ).toPromise(); service.stop(); @@ -378,18 +450,17 @@ describe('stop', () => { }); it('completes immediately if service already stopped', async () => { - const service = new ChromeService({ browserSupportsCsp: true }); - const start = await service.start(defaultStartDeps()); + const { chrome, service } = await start(); service.stop(); await expect( Rx.combineLatest( - start.getBrand$(), - start.getApplicationClasses$(), - start.getIsCollapsed$(), - start.getBreadcrumbs$(), - start.getIsVisible$(), - start.getHelpExtension$() + chrome.getBrand$(), + chrome.getApplicationClasses$(), + chrome.getIsCollapsed$(), + chrome.getBreadcrumbs$(), + chrome.getIsVisible$(), + chrome.getHelpExtension$() ).toPromise() ).resolves.toBe(undefined); }); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index a5532faec19ed..e686f03413dd5 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -18,9 +18,9 @@ */ import React from 'react'; -import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; +import { BehaviorSubject, Observable, ReplaySubject, combineLatest, of, merge } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import * as Url from 'url'; +import { parse } from 'url'; import { i18n } from '@kbn/i18n'; import { IconType, Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; @@ -41,11 +41,6 @@ export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; const IS_COLLAPSED_KEY = 'core.chrome.isCollapsed'; -function isEmbedParamInHash() { - const { query } = Url.parse(String(window.location.hash).slice(1), true); - return Boolean(query.embed); -} - /** @public */ export interface ChromeBadge { text: string; @@ -79,6 +74,9 @@ interface StartDeps { /** @internal */ export class ChromeService { + private isVisible$!: Observable; + private appHidden$!: Observable; + private toggleHidden$!: BehaviorSubject; private readonly stop$ = new ReplaySubject(1); private readonly navControls = new NavControlsService(); private readonly navLinks = new NavLinksService(); @@ -87,6 +85,38 @@ export class ChromeService { constructor(private readonly params: ConstructorParams) {} + /** + * These observables allow consumers to toggle the chrome visibility via either: + * 1. Using setIsVisible() to trigger the next chromeHidden$ + * 2. Setting `chromeless` when registering an application, which will + * reset the visibility whenever the next application is mounted + * 3. Having "embed" in the query string + */ + private initVisibility(application: StartDeps['application']) { + // Start off the chrome service hidden if "embed" is in the hash query string. + const isEmbedded = 'embed' in parse(location.hash.slice(1), true).query; + + this.toggleHidden$ = new BehaviorSubject(isEmbedded); + this.appHidden$ = merge( + // Default the app being hidden to the same value initial value as the chrome visibility + // in case the application service has not emitted an app ID yet, since we want to trigger + // combineLatest below regardless of having an application value yet. + of(isEmbedded), + application.currentAppId$.pipe( + map( + appId => + !!appId && + application.availableApps.has(appId) && + !!application.availableApps.get(appId)!.chromeless + ) + ) + ); + this.isVisible$ = combineLatest(this.appHidden$, this.toggleHidden$).pipe( + map(([appHidden, chromeHidden]) => !(appHidden || chromeHidden)), + takeUntil(this.stop$) + ); + } + public async start({ application, docLinks, @@ -94,11 +124,10 @@ export class ChromeService { injectedMetadata, notifications, }: StartDeps): Promise { - const FORCE_HIDDEN = isEmbedParamInHash(); + this.initVisibility(application); const appTitle$ = new BehaviorSubject('Kibana'); const brand$ = new BehaviorSubject({}); - const isVisible$ = new BehaviorSubject(true); const isCollapsed$ = new BehaviorSubject(!!localStorage.getItem(IS_COLLAPSED_KEY)); const applicationClasses$ = new BehaviorSubject>(new Set()); const helpExtension$ = new BehaviorSubject(undefined); @@ -139,10 +168,7 @@ export class ChromeService { forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} homeHref={http.basePath.prepend('/app/kibana#/home')} - isVisible$={isVisible$.pipe( - map(visibility => (FORCE_HIDDEN ? false : visibility)), - takeUntil(this.stop$) - )} + isVisible$={this.isVisible$} kibanaVersion={injectedMetadata.getKibanaVersion()} legacyMode={injectedMetadata.getLegacyMode()} navLinks$={navLinks.getNavLinks$()} @@ -166,15 +192,9 @@ export class ChromeService { ); }, - getIsVisible$: () => - isVisible$.pipe( - map(visibility => (FORCE_HIDDEN ? false : visibility)), - takeUntil(this.stop$) - ), + getIsVisible$: () => this.isVisible$, - setIsVisible: (visibility: boolean) => { - isVisible$.next(visibility); - }, + setIsVisible: (isVisible: boolean) => this.toggleHidden$.next(!isVisible), getIsCollapsed$: () => isCollapsed$.pipe(takeUntil(this.stop$)), diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index a596ea394abda..d3ce86d76d7cc 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -16,6 +16,7 @@ import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/type // @public export interface App extends AppBase { + chromeless?: boolean; mount: (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; } diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json b/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json new file mode 100644 index 0000000000000..a8a5616627726 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_plugin_chromeless", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_plugin_chromeless"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/package.json b/test/plugin_functional/plugins/core_plugin_chromeless/package.json new file mode 100644 index 0000000000000..eff6c1e1f142a --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_chromeless/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_plugin_chromeless", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_plugin_chromeless", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/public/application.tsx b/test/plugin_functional/plugins/core_plugin_chromeless/public/application.tsx new file mode 100644 index 0000000000000..556a9ca140715 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_chromeless/public/application.tsx @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountContext, AppMountParameters } from 'kibana/public'; + +const Home = () => ( + + + + +

Welcome to Chromeless!

+
+
+
+ + + + +

Chromeless home page section title

+
+
+
+ Where did all the chrome go? +
+
+); + +const ChromelessApp = ({ basename }: { basename: string; context: AppMountContext }) => ( + + + + + +); + +export const renderApp = ( + context: AppMountContext, + { appBasePath, element }: AppMountParameters +) => { + render(, element); + + return () => unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/public/index.ts b/test/plugin_functional/plugins/core_plugin_chromeless/public/index.ts new file mode 100644 index 0000000000000..6e9959ecbdf9e --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_chromeless/public/index.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { + CorePluginChromelessPlugin, + CorePluginChromelessPluginSetup, + CorePluginChromelessPluginStart, +} from './plugin'; + +export const plugin: PluginInitializer< + CorePluginChromelessPluginSetup, + CorePluginChromelessPluginStart +> = () => new CorePluginChromelessPlugin(); diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/public/plugin.tsx b/test/plugin_functional/plugins/core_plugin_chromeless/public/plugin.tsx new file mode 100644 index 0000000000000..03870410fb334 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_chromeless/public/plugin.tsx @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup } from 'kibana/public'; + +export class CorePluginChromelessPlugin + implements Plugin { + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'chromeless', + title: 'Chromeless', + chromeless: true, + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); + + return { + getGreeting() { + return 'Hello from Plugin Chromeless!'; + }, + }; + } + + public start() {} + public stop() {} +} + +export type CorePluginChromelessPluginSetup = ReturnType; +export type CorePluginChromelessPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json b/test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json new file mode 100644 index 0000000000000..5fcaeafbb0d85 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index eec2ec019a515..138e20b987761 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -91,6 +91,18 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider await testSubjects.existOrFail('fooAppPageA'); }); + it('navigating to chromeless application hides chrome', async () => { + await appsMenu.clickLink('Chromeless'); + await loadingScreenNotShown(); + expect(await testSubjects.exists('headerGlobalNav')).to.be(false); + }); + + it('navigating away from chromeless application shows chrome', async () => { + await browser.goBack(); + await loadingScreenNotShown(); + expect(await testSubjects.exists('headerGlobalNav')).to.be(true); + }); + it('can navigate from NP apps to legacy apps', async () => { await appsMenu.clickLink('Management'); await loadingScreenShown();