From dd151d66f98a5472bed454659c2cfd227cca090b Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 12 Sep 2018 17:37:27 -0700 Subject: [PATCH] [core/public/chrome] migrate controls, theme, and visibility apis --- src/core/public/chrome/chrome_service.test.ts | 215 ++++++++++++++++++ src/core/public/chrome/chrome_service.ts | 147 ++++++++++++ src/core/public/chrome/index.ts | 20 ++ src/core/public/chrome/local_store.ts | 48 ++++ src/core/public/core_system.ts | 6 + .../legacy_platform_service.test.ts.snap | 6 + .../legacy_platform_service.test.ts | 48 ++++ .../legacy_platform_service.ts | 6 + src/ui/public/chrome/api/controls.js | 62 ----- src/ui/public/chrome/api/controls.test.ts | 74 ++++++ src/ui/public/chrome/api/controls.ts | 53 +++++ src/ui/public/chrome/api/theme.js | 100 -------- src/ui/public/chrome/api/theme.test.ts | 153 +++++++++++++ src/ui/public/chrome/api/theme.ts | 76 +++++++ src/ui/public/chrome/chrome.js | 8 +- src/ui/public/chrome/directives/kbn_chrome.js | 7 +- .../chrome/services/global_nav_state.js | 36 +-- 17 files changed, 879 insertions(+), 186 deletions(-) create mode 100644 src/core/public/chrome/chrome_service.test.ts create mode 100644 src/core/public/chrome/chrome_service.ts create mode 100644 src/core/public/chrome/index.ts create mode 100644 src/core/public/chrome/local_store.ts delete mode 100644 src/ui/public/chrome/api/controls.js create mode 100644 src/ui/public/chrome/api/controls.test.ts create mode 100644 src/ui/public/chrome/api/controls.ts delete mode 100644 src/ui/public/chrome/api/theme.js create mode 100644 src/ui/public/chrome/api/theme.test.ts create mode 100644 src/ui/public/chrome/api/theme.ts diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts new file mode 100644 index 000000000000000..aec6ef58da02b6f --- /dev/null +++ b/src/core/public/chrome/chrome_service.test.ts @@ -0,0 +1,215 @@ +/* + * 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 * as Rx from 'rxjs'; +import { toArray } from 'rxjs/operators'; + +const store = new Map(); +(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)), +}; + +import { ChromeService } from './chrome_service'; + +beforeEach(() => { + store.clear(); +}); + +describe('start', () => { + describe('brand', () => { + it('updates/emits the brand as it changes', async () => { + const service = new ChromeService(); + const start = service.start(); + const promise = start + .getBrand$() + .pipe(toArray()) + .toPromise(); + + start.setBrand({ + logo: 'big logo', + smallLogo: 'not so big logo', + }); + start.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, + }, +] +`); + }); + }); + + describe('visibility', () => { + it('updates/emits the visibility', async () => { + const service = new ChromeService(); + const start = service.start(); + const promise = start + .getIsVisible$() + .pipe(toArray()) + .toPromise(); + + start.setIsVisible(true); + start.setIsVisible(false); + start.setIsVisible(true); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` +Array [ + true, + true, + false, + true, +] +`); + }); + + it('always emits false if embed query string is in hash when started', async () => { + window.history.pushState(undefined, undefined, '#/home?a=b&embed=true'); + + const service = new ChromeService(); + const start = service.start(); + const promise = start + .getIsVisible$() + .pipe(toArray()) + .toPromise(); + + start.setIsVisible(true); + start.setIsVisible(false); + start.setIsVisible(true); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` +Array [ + false, + false, + false, + false, +] +`); + }); + }); + + describe('is collapsed', () => { + it('updates/emits isCollapsed', async () => { + const service = new ChromeService(); + const start = service.start(); + const promise = start + .getIsCollapsed$() + .pipe(toArray()) + .toPromise(); + + start.setIsCollapsed(true); + start.setIsCollapsed(false); + start.setIsCollapsed(true); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` +Array [ + false, + true, + false, + true, +] +`); + }); + + it('only stores true in localStorage', async () => { + const service = new ChromeService(); + const start = service.start(); + + start.setIsCollapsed(true); + expect(store.size).toBe(1); + + start.setIsCollapsed(false); + expect(store.size).toBe(0); + }); + }); + + describe('application classes', () => { + it('updates/emits the application classes', async () => { + const service = new ChromeService(); + const start = service.start(); + const promise = start + .getApplicationClasses$() + .pipe(toArray()) + .toPromise(); + + start.addApplicationClass('foo'); + start.addApplicationClass('bar'); + start.addApplicationClass('baz'); + start.removeApplicationClass('bar'); + start.removeApplicationClass('foo'); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` +Array [ + Array [], + Array [ + "foo", + ], + Array [ + "foo", + "bar", + ], + Array [ + "foo", + "bar", + "baz", + ], + Array [ + "foo", + "baz", + ], + Array [ + "baz", + ], +] +`); + }); + }); +}); + +describe('stop', () => { + it('completes applicationClass$, isCollapsed$, isVisible$, and brand$ observables', async () => { + const service = new ChromeService(); + const start = service.start(); + const promise = Rx.combineLatest( + start.getBrand$(), + start.getApplicationClasses$(), + start.getIsCollapsed$(), + start.getIsVisible$() + ).toPromise(); + + service.stop(); + await promise; + }); +}); diff --git a/src/core/public/chrome/chrome_service.ts b/src/core/public/chrome/chrome_service.ts new file mode 100644 index 000000000000000..d2b3cd6eb70d04d --- /dev/null +++ b/src/core/public/chrome/chrome_service.ts @@ -0,0 +1,147 @@ +/* + * 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 * as Url from 'url'; + +import * as Rx from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; + +import { LocalStore } from './local_store'; + +const isCollapsedStore = new LocalStore('core.chrome.isCollapsed'); + +function isEmbedParamInHash() { + const { query } = Url.parse(String(window.location.hash).slice(1), true); + return Boolean(query.embed); +} + +export interface Brand { + logo?: string; + smallLogo?: string; +} + +export class ChromeService { + private readonly stop$ = new Rx.ReplaySubject(1); + + public start() { + const FORCE_HIDDEN = isEmbedParamInHash(); + + const brand$ = new Rx.BehaviorSubject({}); + const isVisible$ = new Rx.BehaviorSubject(true); + const isCollapsed$ = new Rx.BehaviorSubject(!!isCollapsedStore.get()); + const applicationClasses$ = new Rx.BehaviorSubject>(new Set()); + + return { + /** + * Set the brand configuration. Normally the `logo` property will be rendered as the + * CSS background for the home link in the chrome navigation, but when the page is renderd + * in a small window the `smallLogo` will be used and rendered at about 45px wide. + * + * example: + * + * chrome.setBrand({ + * logo: 'url(/plugins/app/logo.png) center no-repeat' + * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' + * }) + * + */ + setBrand: (brand: Brand) => { + brand$.next( + Object.freeze({ + logo: brand.logo, + smallLogo: brand.smallLogo, + }) + ); + }, + + /** + * Get an observable of the current brand information. + */ + getBrand$: () => brand$.pipe(takeUntil(this.stop$)), + + /** + * 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: (visibility: boolean) => { + isVisible$.next(visibility); + }, + + /** + * Get an observable of the current visiblity state of the chrome. + */ + getIsVisible$: () => + isVisible$.pipe( + map(visibility => (FORCE_HIDDEN ? false : visibility)), + takeUntil(this.stop$) + ), + + /** + * Set the collapsed state of the chrome navigation. + */ + setIsCollapsed: (isCollapsed: boolean) => { + isCollapsed$.next(isCollapsed); + if (isCollapsed) { + isCollapsedStore.set('true'); + } else { + isCollapsedStore.delete(); + } + }, + + /** + * Get an observable of the current collapsed state of the chrome. + */ + getIsCollapsed$: () => isCollapsed$.pipe(takeUntil(this.stop$)), + + /** + * Add a className that should be set on the application container. + */ + addApplicationClass: (className: string) => { + const update = new Set([...applicationClasses$.getValue()]); + update.add(className); + applicationClasses$.next(update); + }, + + /** + * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. + */ + removeApplicationClass: (className: string) => { + const update = new Set([...applicationClasses$.getValue()]); + update.delete(className); + applicationClasses$.next(update); + }, + + /** + * Get the current set of classNames that will be set on the application container. + */ + getApplicationClasses$: () => + applicationClasses$.pipe( + map(set => [...set]), + takeUntil(this.stop$) + ), + }; + } + + public stop() { + this.stop$.next(); + } +} + +export type ChromeStartContract = ReturnType; diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts new file mode 100644 index 000000000000000..afc3d237ececbf8 --- /dev/null +++ b/src/core/public/chrome/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { ChromeService, ChromeStartContract, Brand } from './chrome_service'; diff --git a/src/core/public/chrome/local_store.ts b/src/core/public/chrome/local_store.ts new file mode 100644 index 000000000000000..5381f19be742c4f --- /dev/null +++ b/src/core/public/chrome/local_store.ts @@ -0,0 +1,48 @@ +/* + * 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. + */ + +/** + * Super simple abstraction that prevents us from requiring every test + * to mock out localStorage + */ +export class LocalStore { + private storage? = window.localStorage; + + constructor(private key: string) {} + + public get() { + if (this.storage) { + return this.storage.getItem(this.key); + } + + return null; + } + + public set(value: string) { + if (this.storage) { + this.storage.setItem(this.key, value); + } + } + + public delete() { + if (this.storage) { + this.storage.removeItem(this.key); + } + } +} diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 5d39a883e39f837..521bc0f5e5b0a92 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -20,6 +20,7 @@ import './core.css'; import { BasePathService } from './base_path'; +import { ChromeService } from './chrome'; import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy_platform'; @@ -48,6 +49,7 @@ export class CoreSystem { private readonly loadingCount: LoadingCountService; private readonly uiSettings: UiSettingsService; private readonly basePath: BasePathService; + private readonly chrome: ChromeService; private readonly rootDomElement: HTMLElement; private readonly notificationsTargetDomElement: HTMLDivElement; @@ -78,6 +80,7 @@ export class CoreSystem { this.loadingCount = new LoadingCountService(); this.basePath = new BasePathService(); this.uiSettings = new UiSettingsService(); + this.chrome = new ChromeService(); this.legacyPlatformTargetDomElement = document.createElement('div'); this.legacyPlatform = new LegacyPlatformService({ @@ -106,6 +109,8 @@ export class CoreSystem { injectedMetadata, basePath, }); + const chrome = this.chrome.start(); + this.legacyPlatform.start({ injectedMetadata, fatalErrors, @@ -113,6 +118,7 @@ export class CoreSystem { loadingCount, basePath, uiSettings, + chrome, }); } catch (error) { this.fatalErrors.add(error); diff --git a/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap index 9e9d34ebfe02af4..b4a3fb8eface6ca 100644 --- a/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap +++ b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap @@ -9,6 +9,9 @@ Array [ "ui/chrome/api/base_path", "ui/chrome/api/ui_settings", "ui/chrome/api/injected_vars", + "ui/chrome/api/controls", + "ui/chrome/api/theme", + "ui/chrome/services/global_nav_state", "ui/chrome", "legacy files", ] @@ -23,6 +26,9 @@ Array [ "ui/chrome/api/base_path", "ui/chrome/api/ui_settings", "ui/chrome/api/injected_vars", + "ui/chrome/api/controls", + "ui/chrome/api/theme", + "ui/chrome/services/global_nav_state", "ui/test_harness", "legacy files", ] diff --git a/src/core/public/legacy_platform/legacy_platform_service.test.ts b/src/core/public/legacy_platform/legacy_platform_service.test.ts index 913cfc79a6ae96c..7943cc114ac673a 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.test.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.test.ts @@ -94,6 +94,30 @@ jest.mock('ui/chrome/api/injected_vars', () => { }; }); +const mockChromeControlsInit = jest.fn(); +jest.mock('ui/chrome/api/controls', () => { + mockLoadOrder.push('ui/chrome/api/controls'); + return { + __newPlatformInit__: mockChromeControlsInit, + }; +}); + +const mockChromeThemeInit = jest.fn(); +jest.mock('ui/chrome/api/theme', () => { + mockLoadOrder.push('ui/chrome/api/theme'); + return { + __newPlatformInit__: mockChromeThemeInit, + }; +}); + +const mockGlobalNavStateInit = jest.fn(); +jest.mock('ui/chrome/services/global_nav_state', () => { + mockLoadOrder.push('ui/chrome/services/global_nav_state'); + return { + __newPlatformInit__: mockGlobalNavStateInit, + }; +}); + import { LegacyPlatformService } from './legacy_platform_service'; const fatalErrorsStartContract = {} as any; @@ -118,6 +142,7 @@ const basePathStartContract = { }; const uiSettingsStartContract: any = {}; +const chromeStartContract: any = {}; const defaultParams = { targetDomElement: document.createElement('div'), @@ -133,6 +158,7 @@ const defaultStartDeps = { loadingCount: loadingCountStartContract, basePath: basePathStartContract, uiSettings: uiSettingsStartContract, + chrome: chromeStartContract, }; afterEach(() => { @@ -224,6 +250,28 @@ describe('#start()', () => { expect(mockInjectedVarsInit).toHaveBeenCalledWith(injectedMetadataStartContract); }); + it('passes chrome service to ui/chrome/api/controls', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockChromeControlsInit).toHaveBeenCalledTimes(1); + expect(mockChromeControlsInit).toHaveBeenCalledWith(chromeStartContract); + }); + + it('passes chrome service to ui/chrome/api/theme', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockChromeThemeInit).toHaveBeenCalledTimes(1); + expect(mockChromeThemeInit).toHaveBeenCalledWith(chromeStartContract); + }); + describe('useLegacyTestHarness = false', () => { it('passes the targetDomElement to ui/chrome', () => { const legacyPlatform = new LegacyPlatformService({ diff --git a/src/core/public/legacy_platform/legacy_platform_service.ts b/src/core/public/legacy_platform/legacy_platform_service.ts index 2b7ae5f2bc8941e..8354b9592f840a5 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.ts @@ -19,6 +19,7 @@ import angular from 'angular'; import { BasePathStartContract } from '../base_path'; +import { ChromeStartContract } from '../chrome'; import { FatalErrorsStartContract } from '../fatal_errors'; import { InjectedMetadataStartContract } from '../injected_metadata'; import { LoadingCountStartContract } from '../loading_count'; @@ -32,6 +33,7 @@ interface Deps { loadingCount: LoadingCountStartContract; basePath: BasePathStartContract; uiSettings: UiSettingsClient; + chrome: ChromeStartContract; } export interface LegacyPlatformParams { @@ -57,6 +59,7 @@ export class LegacyPlatformService { loadingCount, basePath, uiSettings, + chrome, }: Deps) { // Inject parts of the new platform into parts of the legacy platform // so that legacy APIs/modules can mimic their new platform counterparts @@ -67,6 +70,9 @@ export class LegacyPlatformService { require('ui/chrome/api/base_path').__newPlatformInit__(basePath); require('ui/chrome/api/ui_settings').__newPlatformInit__(uiSettings); require('ui/chrome/api/injected_vars').__newPlatformInit__(injectedMetadata); + require('ui/chrome/api/controls').__newPlatformInit__(chrome); + require('ui/chrome/api/theme').__newPlatformInit__(chrome); + require('ui/chrome/services/global_nav_state').__newPlatformInit__(chrome); // Load the bootstrap module before loading the legacy platform files so that // the bootstrap module can modify the environment a bit first diff --git a/src/ui/public/chrome/api/controls.js b/src/ui/public/chrome/api/controls.js deleted file mode 100644 index a8dde867c371f00..000000000000000 --- a/src/ui/public/chrome/api/controls.js +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 _ from 'lodash'; - -// eslint-disable-next-line @elastic/kibana-custom/no-default-export -export default function (chrome, internals) { - /** - * ui/chrome Controls API - * - * Exposes controls for the Kibana chrome - * - * Visible - * determines if the Kibana chrome should be displayed - */ - - let permanentlyHideChrome = false; - internals.permanentlyHideChrome = () => { - permanentlyHideChrome = true; - internals.visible = false; - }; - - chrome.getIsChromePermanentlyHidden = () => { - return permanentlyHideChrome; - }; - - /** - * @param {boolean} display - should the chrome be displayed - * @return {chrome} - */ - chrome.setVisible = function (display) { - if (permanentlyHideChrome) { - return chrome; - } - internals.visible = Boolean(display); - return chrome; - }; - - /** - * @return {boolean} - display state of the chrome - */ - chrome.getVisible = function () { - if (_.isUndefined(internals.visible)) return !permanentlyHideChrome; - return internals.visible; - }; -} diff --git a/src/ui/public/chrome/api/controls.test.ts b/src/ui/public/chrome/api/controls.test.ts new file mode 100644 index 000000000000000..f6a7fecf0f2c9e6 --- /dev/null +++ b/src/ui/public/chrome/api/controls.test.ts @@ -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 * as Rx from 'rxjs'; + +import { __newPlatformInit__, initChromeControlsApi } from './controls'; + +const newPlatformChrome = { + setIsVisible: jest.fn(), + getIsVisible$: jest.fn(), +}; + +__newPlatformInit__(newPlatformChrome as any); + +function setup() { + const isVisible$ = new Rx.BehaviorSubject(true); + newPlatformChrome.getIsVisible$.mockReturnValue(isVisible$); + + const chrome: any = {}; + initChromeControlsApi(chrome); + return { chrome, isVisible$ }; +} + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('setVisible', () => { + it('passes the visibility to the newPlatform', () => { + const { chrome } = setup(); + chrome.setVisible(true); + chrome.setVisible(false); + chrome.setVisible(false); + expect(newPlatformChrome.setIsVisible.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + true, + ], + Array [ + false, + ], + Array [ + false, + ], +] +`); + }); +}); + +describe('getVisible', () => { + it('returns a the cached value emitted by the newPlatformChrome', () => { + const { chrome, isVisible$ } = setup(); + isVisible$.next(true); + expect(chrome.getVisible()).toBe(true); + isVisible$.next(false); + expect(chrome.getVisible()).toBe(false); + }); +}); diff --git a/src/ui/public/chrome/api/controls.ts b/src/ui/public/chrome/api/controls.ts new file mode 100644 index 000000000000000..5ef9bd67cedc69d --- /dev/null +++ b/src/ui/public/chrome/api/controls.ts @@ -0,0 +1,53 @@ +/* + * 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 * as Rx from 'rxjs'; +import { ChromeStartContract } from '../../../../core/public/chrome'; + +let newPlatformChrome: ChromeStartContract; + +export function __newPlatformInit__(instance: ChromeStartContract) { + if (newPlatformChrome) { + throw new Error('ui/chrome/api/chrome is already initialized'); + } + + newPlatformChrome = instance; +} + +export function initChromeControlsApi(chrome: { [key: string]: any }) { + // cache of chrome visibility state + const visible$ = new Rx.BehaviorSubject(false); + newPlatformChrome.getIsVisible$().subscribe(visible$); + + /** + * 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. + */ + chrome.setVisible = (visibility: boolean) => { + newPlatformChrome.setIsVisible(visibility); + return chrome; + }; + + /** + * Get the current visiblity state of the chrome. Note that this drives the UI so it + * might be incorrect in the moments just before the UI is updated. + */ + chrome.getVisible = () => visible$.getValue(); +} diff --git a/src/ui/public/chrome/api/theme.js b/src/ui/public/chrome/api/theme.js deleted file mode 100644 index 2adba3386ed67cf..000000000000000 --- a/src/ui/public/chrome/api/theme.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 _ from 'lodash'; - -// eslint-disable-next-line @elastic/kibana-custom/no-default-export -export default function (chrome, internals) { - /** - * ui/chrome Theme API - * - * Logo - * Set the background for the logo and small logo in the navbar. - * When the app is in the "small" category, a modified version of the - * logo is displayed that is 45px wide. - * e.g., 'url(/plugins/app/logo.png) center no-repeat' - * - * Brand - * Similar to a logo, but is just text with styles to make it stick out. - */ - - /** - * @param {string|object} item - brand key to set, or object to apply - * @param {mixed} val - value to put on the brand item - * @return {chrome} - */ - chrome.setBrand = function (item, val) { - internals.brand = internals.brand || {}; - - // allow objects to be passed in - if (_.isPlainObject(item)) { - internals.brand = _.clone(item); - } else { - internals.brand[item] = val; - } - - return chrome; - }; - - /** - * @return {string} - the brand text - */ - chrome.getBrand = function (item) { - if (!internals.brand) return; - return internals.brand[item]; - }; - - /** - * Adds a class to the application node - * @param {string} - the class name to add - * @return {chrome} - */ - chrome.addApplicationClass = function (val) { - let classes = internals.applicationClasses || []; - classes.push(val); - classes = _.uniq(classes); - - internals.applicationClasses = classes; - return chrome; - }; - - /** - * Removes a class from the application node. Note: this only - * removes classes that were added via the addApplicationClass method - * @param {string|[string]} - class or classes to be removed - * @return {chrome} - */ - chrome.removeApplicationClass = function (val) { - const classesToRemove = [].concat(val || []); - const classes = internals.applicationClasses || []; - _.pull(classes, ...classesToRemove); - - internals.applicationClasses = classes; - return chrome; - }; - - /** - * @return {string} - a space delimited string of the classes added by the - * addApplicationClass method - */ - chrome.getApplicationClasses = function () { - return internals.applicationClasses.join(' '); - }; - -} diff --git a/src/ui/public/chrome/api/theme.test.ts b/src/ui/public/chrome/api/theme.test.ts new file mode 100644 index 000000000000000..2974da98451d978 --- /dev/null +++ b/src/ui/public/chrome/api/theme.test.ts @@ -0,0 +1,153 @@ +/* + * 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 * as Rx from 'rxjs'; + +import { __newPlatformInit__, initChromeThemeApi } from './theme'; + +const newPlatformChrome = { + setBrand: jest.fn(), + getBrand$: jest.fn(), + addApplicationClass: jest.fn(), + removeApplicationClass: jest.fn(), + getApplicationClasses$: jest.fn(), +}; + +__newPlatformInit__(newPlatformChrome as any); + +function setup() { + const brand$ = new Rx.BehaviorSubject({ logo: 'foo', smallLogo: 'foo' }); + newPlatformChrome.getBrand$.mockReturnValue(brand$); + + const applicationClasses$ = new Rx.BehaviorSubject([] as string[]); + newPlatformChrome.getApplicationClasses$.mockReturnValue(applicationClasses$); + + const chrome: any = {}; + initChromeThemeApi(chrome); + return { chrome, brand$, applicationClasses$ }; +} + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('setBrand', () => { + it('proxies to newPlatformChrome', () => { + const { chrome } = setup(); + + chrome.setBrand({ + logo: 'foo', + smallLogo: 'bar', + }); + + chrome.setBrand({ + logo: 'baz', + }); + + expect(newPlatformChrome.setBrand.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + Object { + "logo": "foo", + "smallLogo": "bar", + }, + ], + Array [ + Object { + "logo": "baz", + }, + ], +] +`); + }); +}); + +describe('getBrand', () => { + it('returns named properies from cached values emitted from newPlatformChrome', () => { + const { chrome, brand$ } = setup(); + expect(chrome.getBrand('logo')).toBe('foo'); + expect(chrome.getBrand('smallLogo')).toBe('foo'); + expect(chrome.getBrand()).toBe(undefined); + + brand$.next({ + logo: 'bar', + smallLogo: 'bar', + }); + + expect(chrome.getBrand('logo')).toBe('bar'); + expect(chrome.getBrand('smallLogo')).toBe('bar'); + expect(chrome.getBrand()).toBe(undefined); + }); +}); + +describe('addApplicationClass', () => { + it('proxies each class as a separate argument to newPlatformChrome', () => { + const { chrome } = setup(); + chrome.addApplicationClass('foo'); + chrome.addApplicationClass(['bar', 'baz']); + chrome.addApplicationClass([]); + expect(newPlatformChrome.addApplicationClass.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + "foo", + ], + Array [ + "bar", + ], + Array [ + "baz", + ], +] +`); + }); +}); + +describe('removeApplicationClass', () => { + it('proxies each class as a separate argument to newPlatformChrome', () => { + const { chrome } = setup(); + chrome.removeApplicationClass('foo'); + chrome.removeApplicationClass(['bar', 'baz']); + chrome.removeApplicationClass([]); + expect(newPlatformChrome.removeApplicationClass.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + "foo", + ], + Array [ + "bar", + ], + Array [ + "baz", + ], +] +`); + }); +}); + +describe('getApplicationClasses', () => { + it('returns cached values emitted from newPlatformChrome as a single string', () => { + const { chrome, applicationClasses$ } = setup(); + + expect(chrome.getApplicationClasses()).toBe(''); + applicationClasses$.next(['foo', 'bar']); + expect(chrome.getApplicationClasses()).toBe('foo bar'); + applicationClasses$.next(['bar']); + expect(chrome.getApplicationClasses()).toBe('bar'); + }); +}); diff --git a/src/ui/public/chrome/api/theme.ts b/src/ui/public/chrome/api/theme.ts new file mode 100644 index 000000000000000..e00dbca03008dad --- /dev/null +++ b/src/ui/public/chrome/api/theme.ts @@ -0,0 +1,76 @@ +/* + * 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 * as Rx from 'rxjs'; + +import { Brand, ChromeStartContract } from '../../../../core/public/chrome'; + +let newPlatformChrome: ChromeStartContract; + +export function __newPlatformInit__(instance: ChromeStartContract) { + if (newPlatformChrome) { + throw new Error('ui/chrome/api/theme is already initialized'); + } + + newPlatformChrome = instance; +} + +export function initChromeThemeApi(chrome: { [key: string]: any }) { + const brandCache$ = new Rx.BehaviorSubject({}); + newPlatformChrome.getBrand$().subscribe(brandCache$); + + const applicationClassesCache$ = new Rx.BehaviorSubject([]); + newPlatformChrome.getApplicationClasses$().subscribe(applicationClassesCache$); + + chrome.setBrand = (brand: Brand) => { + newPlatformChrome.setBrand(brand); + return chrome; + }; + + chrome.getBrand = (key: keyof Brand) => { + return brandCache$.getValue()[key]; + }; + + chrome.addApplicationClass = (classNames: string | string[] = []) => { + if (typeof classNames === 'string') { + classNames = [classNames]; + } + + for (const className of classNames) { + newPlatformChrome.addApplicationClass(className); + } + + return chrome; + }; + + chrome.removeApplicationClass = (classNames: string | string[]) => { + if (typeof classNames === 'string') { + classNames = [classNames]; + } + + for (const className of classNames) { + newPlatformChrome.removeApplicationClass(className); + } + return chrome; + }; + + chrome.getApplicationClasses = () => { + return applicationClassesCache$.getValue().join(' '); + }; +} diff --git a/src/ui/public/chrome/chrome.js b/src/ui/public/chrome/chrome.js index 57922d39f6136fb..9fbbab8f2e41afb 100644 --- a/src/ui/public/chrome/chrome.js +++ b/src/ui/public/chrome/chrome.js @@ -33,10 +33,10 @@ import './services'; import { initAngularApi } from './api/angular'; import appsApi from './api/apps'; -import controlsApi from './api/controls'; +import { initChromeControlsApi } from './api/controls'; import { initChromeNavApi } from './api/nav'; import templateApi from './api/template'; -import themeApi from './api/theme'; +import { initChromeThemeApi } from './api/theme'; import translationsApi from './api/translations'; import { initChromeXsrfApi } from './api/xsrf'; import { initUiSettingsApi } from './api/ui_settings'; @@ -70,9 +70,9 @@ initChromeInjectedVarsApi(chrome); initChromeNavApi(chrome, internals); initLoadingCountApi(chrome, internals); initAngularApi(chrome, internals); -controlsApi(chrome, internals); +initChromeControlsApi(chrome); templateApi(chrome, internals); -themeApi(chrome, internals); +initChromeThemeApi(chrome, internals); translationsApi(chrome, internals); const waitForBootstrap = new Promise(resolve => { diff --git a/src/ui/public/chrome/directives/kbn_chrome.js b/src/ui/public/chrome/directives/kbn_chrome.js index 31bdf62646a7fb1..7119234bcb096c3 100644 --- a/src/ui/public/chrome/directives/kbn_chrome.js +++ b/src/ui/public/chrome/directives/kbn_chrome.js @@ -57,14 +57,9 @@ export function kbnChromeProvider(chrome, internals) { }, controllerAs: 'chrome', - controller($scope, $rootScope, $location, $http, Private) { + controller($scope, $rootScope, Private) { const getUnhashableStates = Private(getUnhashableStatesProvider); - // are we showing the embedded version of the chrome? - if (Boolean($location.search().embed)) { - internals.permanentlyHideChrome(); - } - const subUrlRouteFilter = Private(SubUrlRouteFilterProvider); function updateSubUrls() { diff --git a/src/ui/public/chrome/services/global_nav_state.js b/src/ui/public/chrome/services/global_nav_state.js index 9f2131926c50a3f..c168e495edf2838 100644 --- a/src/ui/public/chrome/services/global_nav_state.js +++ b/src/ui/public/chrome/services/global_nav_state.js @@ -17,25 +17,33 @@ * under the License. */ - +import { distinctUntilChanged } from 'rxjs/operators'; import { uiModules } from '../../modules'; +let newPlatformChrome; +export function __newPlatformInit__(instance) { + if (newPlatformChrome) { + throw new Error('ui/chrome/global_nav_state is already initialized'); + } + + newPlatformChrome = instance; +} + uiModules.get('kibana') - .service('globalNavState', (localStorage, $rootScope) => { + .service('globalNavState', ($rootScope) => { + let isOpen = false; + newPlatformChrome.getIsCollapsed$().pipe(distinctUntilChanged()).subscribe(isCollapsed => { + $rootScope.$evalAsync(() => { + isOpen = !isCollapsed; + $rootScope.$broadcast('globalNavState:change'); + }); + }); + return { - isOpen: () => { - const isOpen = localStorage.get('kibana.isGlobalNavOpen'); - if (isOpen === null) { - // The global nav should default to being open for the initial experience. - return true; - } - return isOpen; - }, + isOpen: () => isOpen, - setOpen: isOpen => { - localStorage.set('kibana.isGlobalNavOpen', isOpen); - $rootScope.$broadcast('globalNavState:change'); - return isOpen; + setOpen: newValue => { + newPlatformChrome.setIsCollapsed(!newValue); } }; });