From f910529d4e1f51509bcbfc812c8e61c22e564c4b Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 7 Sep 2018 21:10:05 -0700 Subject: [PATCH] Migrate base path APIs and UiSettings client to new platform (#22694) Fixes #20697 This PR migrates the base path related methods from `ui/chrome` to `core.basePath` and the uiSettings to `core.uiSettings`. The two are not split into separate PRs because I'm kinda cramped for time right now so rather I split the changes up into two commits so that you can review them separately if you like. If you'd like I can submit them as separate PRs but the basePath PR will block the uiSettings PR either way. There shouldn't be any API changes except one thing, since the existing implementation is so close to what we'd want from the new platform API I made the one change that is inconsistent with what we've been doing so far and moved `uiSettings.subscribe()` to `uiSettings.getUpdate$().subscribe()`. This method isn't super commonly used, but it is a breaking change that will likely impact plugins so I'll notify some folks if we decide to move forward this way. I can also make a super-light wrapper for angular that just updates this method if you prefer. --- .../base_path/base_path_service.test.ts | 104 ++++++++ .../public/base_path/base_path_service.ts | 74 ++++++ .../public/base_path/index.ts} | 3 +- src/core/public/core_system.test.ts | 43 +++ src/core/public/core_system.ts | 23 +- .../injected_metadata_service.ts | 9 + .../legacy_platform_service.test.ts.snap | 26 ++ .../legacy_platform_service.test.ts | 70 +++-- .../legacy_platform_service.ts | 15 +- .../ui_settings_api.test.ts.snap | 168 ++++++++++++ .../ui_settings_client.test.ts.snap} | 60 ++--- .../ui_settings_service.test.ts.snap | 78 ++++++ src/core/public/ui_settings/index.ts | 21 ++ src/core/public/ui_settings/types.ts | 39 +++ .../ui_settings/ui_settings_api.test.ts | 242 +++++++++++++++++ .../public/ui_settings/ui_settings_api.ts | 163 ++++++++++++ .../ui_settings/ui_settings_client.test.ts} | 112 +++----- .../public/ui_settings/ui_settings_client.ts | 251 ++++++++++++++++++ .../ui_settings/ui_settings_service.test.ts | 127 +++++++++ .../public/ui_settings/ui_settings_service.ts | 72 +++++ src/core/public/utils/index.ts | 20 ++ src/core/public/utils/modify_url.test.ts | 59 ++++ .../public/utils/modify_url.ts} | 39 ++- src/ui/public/autoload/settings.js | 2 +- src/ui/public/chrome/api/__tests__/nav.js | 46 +--- src/ui/public/chrome/api/base_path.test.ts | 67 +++++ .../chrome/api/base_path.ts} | 34 +-- src/ui/public/chrome/api/nav.js | 23 -- src/ui/public/chrome/api/ui_settings.js | 20 +- src/ui/public/chrome/chrome.js | 2 + src/ui/public/config/config.js | 2 +- .../notify/app_redirect/app_redirect.js | 4 +- src/ui/public/registry/field_formats.js | 2 +- .../disable_animations/disable_animations.js | 2 +- src/ui/public/test_harness/test_harness.js | 40 +-- src/ui/public/url/index.js | 2 +- src/ui/public/url/kibana_parsed_url.js | 2 +- src/ui/ui_render/ui_render_mixin.js | 1 + src/ui/ui_settings/public/ui_settings_api.js | 86 ------ .../ui_settings/public/ui_settings_client.js | 207 --------------- src/utils/__tests__/modify_url.js | 61 ----- src/utils/index.js | 1 - .../services/remote/interceptors.js | 2 +- 43 files changed, 1799 insertions(+), 625 deletions(-) create mode 100644 src/core/public/base_path/base_path_service.test.ts create mode 100644 src/core/public/base_path/base_path_service.ts rename src/{ui/public/url/modify_url.js => core/public/base_path/index.ts} (83%) create mode 100644 src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap rename src/{ui/ui_settings/public/__snapshots__/ui_settings_client.test.js.snap => core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap} (87%) create mode 100644 src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap create mode 100644 src/core/public/ui_settings/index.ts create mode 100644 src/core/public/ui_settings/types.ts create mode 100644 src/core/public/ui_settings/ui_settings_api.test.ts create mode 100644 src/core/public/ui_settings/ui_settings_api.ts rename src/{ui/ui_settings/public/ui_settings_client.test.js => core/public/ui_settings/ui_settings_client.test.ts} (82%) create mode 100644 src/core/public/ui_settings/ui_settings_client.ts create mode 100644 src/core/public/ui_settings/ui_settings_service.test.ts create mode 100644 src/core/public/ui_settings/ui_settings_service.ts create mode 100644 src/core/public/utils/index.ts create mode 100644 src/core/public/utils/modify_url.test.ts rename src/{utils/modify_url.js => core/public/utils/modify_url.ts} (77%) create mode 100644 src/ui/public/chrome/api/base_path.test.ts rename src/ui/{ui_settings/public/send_request.js => public/chrome/api/base_path.ts} (54%) delete mode 100644 src/ui/ui_settings/public/ui_settings_api.js delete mode 100644 src/ui/ui_settings/public/ui_settings_client.js delete mode 100644 src/utils/__tests__/modify_url.js diff --git a/src/core/public/base_path/base_path_service.test.ts b/src/core/public/base_path/base_path_service.test.ts new file mode 100644 index 00000000000000..ed44c322f158c5 --- /dev/null +++ b/src/core/public/base_path/base_path_service.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { BasePathService } from './base_path_service'; + +function setup(options: any = {}) { + const injectedBasePath: string = + options.injectedBasePath === undefined ? '/foo/bar' : options.injectedBasePath; + + const service = new BasePathService(); + + const injectedMetadata = { + getBasePath: jest.fn().mockReturnValue(injectedBasePath), + } as any; + + const startContract = service.start({ + injectedMetadata, + }); + + return { + service, + startContract, + injectedBasePath, + }; +} + +describe('startContract.get()', () => { + it('returns an empty string if no basePath is injected', () => { + const { startContract } = setup({ injectedBasePath: null }); + expect(startContract.get()).toBe(''); + }); + + it('returns the injected basePath', () => { + const { startContract } = setup(); + expect(startContract.get()).toBe('/foo/bar'); + }); +}); + +describe('startContract.addToPath()', () => { + it('adds the base path to the path if it is relative and starts with a slash', () => { + const { startContract } = setup(); + expect(startContract.addToPath('/a/b')).toBe('/foo/bar/a/b'); + }); + + it('leaves the query string and hash of path unchanged', () => { + const { startContract } = setup(); + expect(startContract.addToPath('/a/b?x=y#c/d/e')).toBe('/foo/bar/a/b?x=y#c/d/e'); + }); + + it('returns the path unchanged if it does not start with a slash', () => { + const { startContract } = setup(); + expect(startContract.addToPath('a/b')).toBe('a/b'); + }); + + it('returns the path unchanged it it has a hostname', () => { + const { startContract } = setup(); + expect(startContract.addToPath('http://localhost:5601/a/b')).toBe('http://localhost:5601/a/b'); + }); +}); + +describe('startContract.removeFromPath()', () => { + it('removes the basePath if relative path starts with it', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/bar/a/b')).toBe('/a/b'); + }); + + it('leaves query string and hash intact', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/bar/a/b?c=y#1234')).toBe('/a/b?c=y#1234'); + }); + + it('ignores urls with hostnames', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('http://localhost:5601/foo/bar/a/b')).toBe( + 'http://localhost:5601/foo/bar/a/b' + ); + }); + + it('returns slash if path is just basePath', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/bar')).toBe('/'); + }); + + it('returns full path if basePath is not its own segment', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/barhop')).toBe('/foo/barhop'); + }); +}); diff --git a/src/core/public/base_path/base_path_service.ts b/src/core/public/base_path/base_path_service.ts new file mode 100644 index 00000000000000..bd6f665abdf9e6 --- /dev/null +++ b/src/core/public/base_path/base_path_service.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 { InjectedMetadataStartContract } from '../injected_metadata'; +import { modifyUrl } from '../utils'; + +interface Deps { + injectedMetadata: InjectedMetadataStartContract; +} + +export class BasePathService { + public start({ injectedMetadata }: Deps) { + const basePath = injectedMetadata.getBasePath() || ''; + + return { + /** + * Get the current basePath as defined by the server + */ + get() { + return basePath; + }, + + /** + * Add the current basePath to a path string. + * @param path A relative url including the leading `/`, otherwise it will be returned without modification + */ + addToPath(path: string) { + return modifyUrl(path, parts => { + if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { + parts.pathname = `${basePath}${parts.pathname}`; + } + }); + }, + + /** + * Remove the basePath from a path that starts with it + * @param path A relative url that starts with the basePath, which will be stripped + */ + removeFromPath(path: string) { + if (!basePath) { + return path; + } + + if (path === basePath) { + return '/'; + } + + if (path.startsWith(basePath + '/')) { + return path.slice(basePath.length); + } + + return path; + }, + }; + } +} + +export type BasePathStartContract = ReturnType; diff --git a/src/ui/public/url/modify_url.js b/src/core/public/base_path/index.ts similarity index 83% rename from src/ui/public/url/modify_url.js rename to src/core/public/base_path/index.ts index 33a17f7ac531ca..13ff2350cab846 100644 --- a/src/ui/public/url/modify_url.js +++ b/src/core/public/base_path/index.ts @@ -17,5 +17,4 @@ * under the License. */ -// we select the modify_url directly so the other utils, which are not browser compatible, are not included -export { modifyUrl } from '../../../utils/modify_url'; +export { BasePathService, BasePathStartContract } from './base_path_service'; diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index d867c8f49b6a09..64f71cd4fb00ce 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -17,11 +17,13 @@ * under the License. */ +import { BasePathService } from './base_path'; import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformService } from './legacy_platform'; import { LoadingCountService } from './loading_count'; import { NotificationsService } from './notifications'; +import { UiSettingsService } from './ui_settings'; const MockLegacyPlatformService = jest.fn( function _MockLegacyPlatformService(this: any) { @@ -76,6 +78,24 @@ jest.mock('./loading_count', () => ({ LoadingCountService: MockLoadingCountService, })); +const mockBasePathStartContract = {}; +const MockBasePathService = jest.fn(function _MockNotificationsService(this: any) { + this.start = jest.fn().mockReturnValue(mockBasePathStartContract); +}); +jest.mock('./base_path', () => ({ + BasePathService: MockBasePathService, +})); + +const mockUiSettingsContract = {}; +const MockUiSettingsService = jest.fn(function _MockNotificationsService( + this: any +) { + this.start = jest.fn().mockReturnValue(mockUiSettingsContract); +}); +jest.mock('./ui_settings', () => ({ + UiSettingsService: MockUiSettingsService, +})); + import { CoreSystem } from './core_system'; jest.spyOn(CoreSystem.prototype, 'stop'); @@ -101,6 +121,8 @@ describe('constructor', () => { expect(MockFatalErrorsService).toHaveBeenCalledTimes(1); expect(MockNotificationsService).toHaveBeenCalledTimes(1); expect(MockLoadingCountService).toHaveBeenCalledTimes(1); + expect(MockBasePathService).toHaveBeenCalledTimes(1); + expect(MockUiSettingsService).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -221,6 +243,27 @@ describe('#start()', () => { }); }); + it('calls basePath#start()', () => { + startCore(); + const [mockInstance] = MockBasePathService.mock.instances; + expect(mockInstance.start).toHaveBeenCalledTimes(1); + expect(mockInstance.start).toHaveBeenCalledWith({ + injectedMetadata: mockInjectedMetadataStartContract, + }); + }); + + it('calls uiSettings#start()', () => { + startCore(); + const [mockInstance] = MockUiSettingsService.mock.instances; + expect(mockInstance.start).toHaveBeenCalledTimes(1); + expect(mockInstance.start).toHaveBeenCalledWith({ + notifications: mockNotificationStartContract, + loadingCount: mockLoadingCountContract, + injectedMetadata: mockInjectedMetadataStartContract, + basePath: mockBasePathStartContract, + }); + }); + it('calls fatalErrors#start()', () => { startCore(); const [mockInstance] = MockFatalErrorsService.mock.instances; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index db5500cb2ffdbe..05c00e5a633aa9 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -18,11 +18,14 @@ */ import './core.css'; + +import { BasePathService } from './base_path'; import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy_platform'; import { LoadingCountService } from './loading_count'; import { NotificationsService } from './notifications'; +import { UiSettingsService } from './ui_settings'; interface Params { rootDomElement: HTMLElement; @@ -43,6 +46,8 @@ export class CoreSystem { private readonly legacyPlatform: LegacyPlatformService; private readonly notifications: NotificationsService; private readonly loadingCount: LoadingCountService; + private readonly uiSettings: UiSettingsService; + private readonly basePath: BasePathService; private readonly rootDomElement: HTMLElement; private readonly notificationsTargetDomElement: HTMLDivElement; @@ -71,6 +76,8 @@ export class CoreSystem { }); this.loadingCount = new LoadingCountService(); + this.basePath = new BasePathService(); + this.uiSettings = new UiSettingsService(); this.legacyPlatformTargetDomElement = document.createElement('div'); this.legacyPlatform = new LegacyPlatformService({ @@ -92,7 +99,21 @@ export class CoreSystem { const injectedMetadata = this.injectedMetadata.start(); const fatalErrors = this.fatalErrors.start(); const loadingCount = this.loadingCount.start({ fatalErrors }); - this.legacyPlatform.start({ injectedMetadata, fatalErrors, notifications, loadingCount }); + const basePath = this.basePath.start({ injectedMetadata }); + const uiSettings = this.uiSettings.start({ + notifications, + loadingCount, + injectedMetadata, + basePath, + }); + this.legacyPlatform.start({ + injectedMetadata, + fatalErrors, + notifications, + loadingCount, + basePath, + uiSettings, + }); } catch (error) { this.fatalErrors.add(error); } diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index e756d99b1f854f..85c3fce0ba9deb 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -23,6 +23,7 @@ export interface InjectedMetadataParams { injectedMetadata: { version: string; buildNumber: number; + basePath: string; legacyMetadata: { [key: string]: any; }; @@ -42,6 +43,14 @@ export class InjectedMetadataService { public start() { return { + getBasePath: () => { + return this.state.basePath; + }, + + getKibanaVersion: () => { + return this.getKibanaVersion(); + }, + getLegacyMetadata: () => { return this.state.legacyMetadata; }, 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 e012b43d5977a6..8d318e8e57673c 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 @@ -1,5 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`#start() load order useLegacyTestHarness = false loads ui/modules before ui/chrome, and both before legacy files 1`] = ` +Array [ + "ui/metadata", + "ui/notify/fatal_error", + "ui/notify/toasts", + "ui/chrome/api/loading_count", + "ui/chrome/api/base_path", + "ui/chrome/api/ui_settings", + "ui/chrome", + "legacy files", +] +`; + +exports[`#start() load order useLegacyTestHarness = true loads ui/modules before ui/test_harness, and both before legacy files 1`] = ` +Array [ + "ui/metadata", + "ui/notify/fatal_error", + "ui/notify/toasts", + "ui/chrome/api/loading_count", + "ui/chrome/api/base_path", + "ui/chrome/api/ui_settings", + "ui/test_harness", + "legacy files", +] +`; + exports[`#stop() destroys the angular scope and empties the targetDomElement if angular is bootstraped to targetDomElement 1`] = `
{ }; }); +const mockBasePathInit = jest.fn(); +jest.mock('ui/chrome/api/base_path', () => { + mockLoadOrder.push('ui/chrome/api/base_path'); + return { + __newPlatformInit__: mockBasePathInit, + }; +}); + +const mockUiSettingsInit = jest.fn(); +jest.mock('ui/chrome/api/ui_settings', () => { + mockLoadOrder.push('ui/chrome/api/ui_settings'); + return { + __newPlatformInit__: mockUiSettingsInit, + }; +}); + import { LegacyPlatformService } from './legacy_platform_service'; const fatalErrorsStartContract = {} as any; @@ -77,7 +93,8 @@ const notificationsStartContract = { toasts: {}, } as any; -const injectedMetadataStartContract = { +const injectedMetadataStartContract: any = { + getBasePath: jest.fn(), getLegacyMetadata: jest.fn(), }; @@ -86,6 +103,14 @@ const loadingCountStartContract = { getCount$: jest.fn().mockImplementation(() => new Rx.Observable(observer => observer.next(0))), }; +const basePathStartContract = { + get: jest.fn(), + addToPath: jest.fn(), + removeFromPath: jest.fn(), +}; + +const uiSettingsStartContract: any = {}; + const defaultParams = { targetDomElement: document.createElement('div'), requireLegacyFiles: jest.fn(() => { @@ -98,6 +123,8 @@ const defaultStartDeps = { injectedMetadata: injectedMetadataStartContract, notifications: notificationsStartContract, loadingCount: loadingCountStartContract, + basePath: basePathStartContract, + uiSettings: uiSettingsStartContract, }; afterEach(() => { @@ -156,6 +183,28 @@ describe('#start()', () => { expect(mockLoadingCountInit).toHaveBeenCalledWith(loadingCountStartContract); }); + it('passes basePath service to ui/chrome/api/base_path', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockBasePathInit).toHaveBeenCalledTimes(1); + expect(mockBasePathInit).toHaveBeenCalledWith(basePathStartContract); + }); + + it('passes basePath service to ui/chrome/api/ui_settings', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockUiSettingsInit).toHaveBeenCalledTimes(1); + expect(mockUiSettingsInit).toHaveBeenCalledWith(uiSettingsStartContract); + }); + describe('useLegacyTestHarness = false', () => { it('passes the targetDomElement to ui/chrome', () => { const legacyPlatform = new LegacyPlatformService({ @@ -169,6 +218,7 @@ describe('#start()', () => { expect(mockUiChromeBootstrap).toHaveBeenCalledWith(defaultParams.targetDomElement); }); }); + describe('useLegacyTestHarness = true', () => { it('passes the targetDomElement to ui/test_harness', () => { const legacyPlatform = new LegacyPlatformService({ @@ -196,14 +246,7 @@ describe('#start()', () => { legacyPlatform.start(defaultStartDeps); - expect(mockLoadOrder).toEqual([ - 'ui/metadata', - 'ui/notify/fatal_error', - 'ui/notify/toasts', - 'ui/chrome/api/loading_count', - 'ui/chrome', - 'legacy files', - ]); + expect(mockLoadOrder).toMatchSnapshot(); }); }); @@ -218,14 +261,7 @@ describe('#start()', () => { legacyPlatform.start(defaultStartDeps); - expect(mockLoadOrder).toEqual([ - 'ui/metadata', - 'ui/notify/fatal_error', - 'ui/notify/toasts', - 'ui/chrome/api/loading_count', - 'ui/test_harness', - 'legacy files', - ]); + expect(mockLoadOrder).toMatchSnapshot(); }); }); }); diff --git a/src/core/public/legacy_platform/legacy_platform_service.ts b/src/core/public/legacy_platform/legacy_platform_service.ts index 52d2534c8b8e6e..45c7cf76c3cb68 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.ts @@ -18,16 +18,20 @@ */ import angular from 'angular'; +import { BasePathStartContract } from '../base_path'; import { FatalErrorsStartContract } from '../fatal_errors'; import { InjectedMetadataStartContract } from '../injected_metadata'; import { LoadingCountStartContract } from '../loading_count'; import { NotificationsStartContract } from '../notifications'; +import { UiSettingsClient } from '../ui_settings'; interface Deps { injectedMetadata: InjectedMetadataStartContract; fatalErrors: FatalErrorsStartContract; notifications: NotificationsStartContract; loadingCount: LoadingCountStartContract; + basePath: BasePathStartContract; + uiSettings: UiSettingsClient; } export interface LegacyPlatformParams { @@ -46,13 +50,22 @@ export interface LegacyPlatformParams { export class LegacyPlatformService { constructor(private readonly params: LegacyPlatformParams) {} - public start({ injectedMetadata, fatalErrors, notifications, loadingCount }: Deps) { + public start({ + injectedMetadata, + fatalErrors, + notifications, + loadingCount, + basePath, + uiSettings, + }: Deps) { // Inject parts of the new platform into parts of the legacy platform // so that legacy APIs/modules can mimic their new platform counterparts require('ui/metadata').__newPlatformInit__(injectedMetadata.getLegacyMetadata()); require('ui/notify/fatal_error').__newPlatformInit__(fatalErrors); require('ui/notify/toasts').__newPlatformInit__(notifications.toasts); require('ui/chrome/api/loading_count').__newPlatformInit__(loadingCount); + require('ui/chrome/api/base_path').__newPlatformInit__(basePath); + require('ui/chrome/api/ui_settings').__newPlatformInit__(uiSettings); // 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/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap new file mode 100644 index 00000000000000..1f69bc37b81cd5 --- /dev/null +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#batchSet Buffers are always clear of previously buffered changes: two requests, second only sends bar, not foo 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"bar\\":\\"box\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet Overwrites previously buffered values with new values for the same key: two requests, foo=d in final 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"a\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"d\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet buffers changes while first request is in progress, sends buffered changes after first request completes: final, includes both requests 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"box\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet buffers changes while first request is in progress, sends buffered changes after first request completes: initial, only one request 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet rejects all promises for batched requests that fail: promise rejections 1`] = ` +Array [ + Object { + "error": [Error: Request failed with status code: 400], + "isRejected": true, + }, + Object { + "error": [Error: Request failed with status code: 400], + "isRejected": true, + }, + Object { + "error": [Error: Request failed with status code: 400], + "isRejected": true, + }, +] +`; + +exports[`#batchSet rejects on 301 1`] = `"Request failed with status code: 301"`; + +exports[`#batchSet rejects on 404 response 1`] = `"Request failed with status code: 404"`; + +exports[`#batchSet rejects on 500 1`] = `"Request failed with status code: 500"`; + +exports[`#batchSet sends a single change immediately: synchronous fetch 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; diff --git a/src/ui/ui_settings/public/__snapshots__/ui_settings_client.test.js.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap similarity index 87% rename from src/ui/ui_settings/public/__snapshots__/ui_settings_client.test.js.snap rename to src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap index 8915553b36bf15..e49c546f3550ca 100644 --- a/src/ui/ui_settings/public/__snapshots__/ui_settings_client.test.js.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap @@ -20,7 +20,29 @@ You can use \`config.get(\\"throwableProperty\\", defaultValue)\`, which will ju \`defaultValue\` when the key is unrecognized." `; -exports[`#overrideLocalDefault #assertUpdateAllowed() throws error when keys is overridden 1`] = `"Unable to update \\"foo\\" because its value is overridden by the Kibana server"`; +exports[`#getUpdate$ sends { key, newValue, oldValue } notifications when config changes 1`] = ` +Array [ + Array [ + Object { + "key": "foo", + "newValue": "bar", + "oldValue": undefined, + }, + ], +] +`; + +exports[`#getUpdate$ sends { key, newValue, oldValue } notifications when config changes 2`] = ` +Array [ + Array [ + Object { + "key": "foo", + "newValue": "baz", + "oldValue": "bar", + }, + ], +] +`; exports[`#overrideLocalDefault key has no user value calls subscriber with new and previous value: single subscriber call 1`] = ` Array [ @@ -100,39 +122,3 @@ Object { exports[`#remove throws an error if key is overridden 1`] = `"Unable to update \\"bar\\" because its value is overridden by the Kibana server"`; exports[`#set throws an error if key is overridden 1`] = `"Unable to update \\"foo\\" because its value is overridden by the Kibana server"`; - -exports[`#subscribe calls handler with { key, newValue, oldValue } when config changes 1`] = ` -Array [ - Array [ - Object { - "key": "foo", - "newValue": "bar", - "oldValue": undefined, - }, - ], -] -`; - -exports[`#subscribe calls handler with { key, newValue, oldValue } when config changes 2`] = ` -Array [ - Array [ - Object { - "key": "foo", - "newValue": "baz", - "oldValue": "bar", - }, - ], -] -`; - -exports[`#subscribe returns a subscription object which unsubs when .unsubscribe() is called 1`] = ` -Array [ - Array [ - Object { - "key": "foo", - "newValue": "bar", - "oldValue": undefined, - }, - ], -] -`; diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap new file mode 100644 index 00000000000000..e7e42c42c8b876 --- /dev/null +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#start constructs UiSettingsClient and UiSettingsApi: UiSettingsApi args 1`] = ` +[MockFunction MockUiSettingsApi] { + "calls": Array [ + Array [ + Object { + "basePathStartContract": true, + }, + "kibanaVersion", + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], +} +`; + +exports[`#start constructs UiSettingsClient and UiSettingsApi: UiSettingsClient args 1`] = ` +[MockFunction MockUiSettingsClient] { + "calls": Array [ + Array [ + Object { + "api": MockUiSettingsApi { + "getLoadingCount$": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "loadingCountObservable": true, + }, + }, + ], + }, + "stop": [MockFunction], + }, + "defaults": Object { + "legacyInjectedUiSettingDefaults": true, + }, + "initialSettings": Object { + "legacyInjectedUiSettingUserValues": true, + }, + "onUpdateError": [Function], + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], +} +`; + +exports[`#start passes the uiSettings loading count to the loading count api: loadingCount.add calls 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "loadingCountObservable": true, + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], +} +`; diff --git a/src/core/public/ui_settings/index.ts b/src/core/public/ui_settings/index.ts new file mode 100644 index 00000000000000..36c3d864d81192 --- /dev/null +++ b/src/core/public/ui_settings/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { UiSettingsService, UiSettingsStartContract } from './ui_settings_service'; +export { UiSettingsClient } from './ui_settings_client'; diff --git a/src/core/public/ui_settings/types.ts b/src/core/public/ui_settings/types.ts new file mode 100644 index 00000000000000..4fa4109c7bc262 --- /dev/null +++ b/src/core/public/ui_settings/types.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ + +// properties that come from legacyInjectedMetadata.uiSettings.defaults +interface InjectedUiSettingsDefault { + name?: string; + value?: any; + description?: string; + category?: string[]; + type?: string; + readOnly?: boolean; + options?: string[] | { [key: string]: any }; +} + +// properties that come from legacyInjectedMetadata.uiSettings.user +interface InjectedUiSettingsUser { + userValue?: any; + isOverridden?: boolean; +} + +export interface UiSettingsState { + [key: string]: InjectedUiSettingsDefault & InjectedUiSettingsUser; +} diff --git a/src/core/public/ui_settings/ui_settings_api.test.ts b/src/core/public/ui_settings/ui_settings_api.test.ts new file mode 100644 index 00000000000000..75358297a56613 --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_api.test.ts @@ -0,0 +1,242 @@ +/* + * 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 fetchMock from 'fetch-mock'; +import * as Rx from 'rxjs'; +import { takeUntil, toArray } from 'rxjs/operators'; + +import { UiSettingsApi } from './ui_settings_api'; + +function setup() { + const basePath: any = { + addToPath: jest.fn(path => `/foo/bar${path}`), + }; + + const uiSettingsApi = new UiSettingsApi(basePath, 'v9.9.9'); + + return { + basePath, + uiSettingsApi, + }; +} + +async function settlePromise(promise: Promise) { + try { + return { + isResolved: true, + result: await promise, + }; + } catch (error) { + return { + isRejected: true, + error, + }; + } +} + +afterEach(() => { + fetchMock.restore(); +}); + +describe('#batchSet', () => { + it('sends a single change immediately', () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + uiSettingsApi.batchSet('foo', 'bar'); + expect(fetchMock.calls()).toMatchSnapshot('synchronous fetch'); + }); + + it('buffers changes while first request is in progress, sends buffered changes after first request completes', async () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + + uiSettingsApi.batchSet('foo', 'bar'); + const finalPromise = uiSettingsApi.batchSet('box', 'bar'); + + expect(fetchMock.calls()).toMatchSnapshot('initial, only one request'); + await finalPromise; + expect(fetchMock.calls()).toMatchSnapshot('final, includes both requests'); + }); + + it('Overwrites previously buffered values with new values for the same key', async () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + + uiSettingsApi.batchSet('foo', 'a'); + uiSettingsApi.batchSet('foo', 'b'); + uiSettingsApi.batchSet('foo', 'c'); + await uiSettingsApi.batchSet('foo', 'd'); + + expect(fetchMock.calls()).toMatchSnapshot('two requests, foo=d in final'); + }); + + it('Buffers are always clear of previously buffered changes', async () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + uiSettingsApi.batchSet('foo', 'bar'); + uiSettingsApi.batchSet('bar', 'foo'); + await uiSettingsApi.batchSet('bar', 'box'); + + expect(fetchMock.calls()).toMatchSnapshot('two requests, second only sends bar, not foo'); + }); + + it('rejects on 404 response', async () => { + fetchMock.mock('*', { + status: 404, + body: 'not found', + }); + + const { uiSettingsApi } = setup(); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('rejects on 301', async () => { + fetchMock.mock('*', { + status: 301, + body: 'redirect', + }); + + const { uiSettingsApi } = setup(); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('rejects on 500', async () => { + fetchMock.mock('*', { + status: 500, + body: 'redirect', + }); + + const { uiSettingsApi } = setup(); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('rejects all promises for batched requests that fail', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + fetchMock.once('*', { + status: 400, + body: 'invalid', + }); + + const { uiSettingsApi } = setup(); + // trigger the initial sync request, which enabled buffering + uiSettingsApi.batchSet('foo', 'bar'); + + // buffer some requests so they will be sent together + await expect( + Promise.all([ + settlePromise(uiSettingsApi.batchSet('foo', 'a')), + settlePromise(uiSettingsApi.batchSet('bar', 'b')), + settlePromise(uiSettingsApi.batchSet('baz', 'c')), + ]) + ).resolves.toMatchSnapshot('promise rejections'); + + // ensure only two requests were sent + expect(fetchMock.calls().matched).toHaveLength(2); + }); +}); + +describe('#getLoadingCount$()', () => { + it('emits the current number of active requests', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + const done$ = new Rx.Subject(); + const promise = uiSettingsApi + .getLoadingCount$() + .pipe( + takeUntil(done$), + toArray() + ) + .toPromise(); + + await uiSettingsApi.batchSet('foo', 'bar'); + done$.next(); + + await expect(promise).resolves.toEqual([0, 1, 0]); + }); + + it('decrements loading count when requests fail', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + fetchMock.once('*', { + status: 400, + body: 'invalid', + }); + + const { uiSettingsApi } = setup(); + const done$ = new Rx.Subject(); + const promise = uiSettingsApi + .getLoadingCount$() + .pipe( + takeUntil(done$), + toArray() + ) + .toPromise(); + + await uiSettingsApi.batchSet('foo', 'bar'); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowError(); + + done$.next(); + await expect(promise).resolves.toEqual([0, 1, 0, 1, 0]); + }); +}); + +describe('#stop', () => { + it('completes any loading count observables', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + const promise = Promise.all([ + uiSettingsApi + .getLoadingCount$() + .pipe(toArray()) + .toPromise(), + uiSettingsApi + .getLoadingCount$() + .pipe(toArray()) + .toPromise(), + ]); + + const batchSetPromise = uiSettingsApi.batchSet('foo', 'bar'); + uiSettingsApi.stop(); + + // both observables should emit the same values, and complete before the request is done loading + await expect(promise).resolves.toEqual([[0, 1], [0, 1]]); + await batchSetPromise; + }); +}); diff --git a/src/core/public/ui_settings/ui_settings_api.ts b/src/core/public/ui_settings/ui_settings_api.ts new file mode 100644 index 00000000000000..6d43384fa6d025 --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_api.ts @@ -0,0 +1,163 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; + +import { BasePathStartContract } from '../base_path'; +import { UiSettingsState } from './types'; + +export interface UiSettingsApiResponse { + settings: UiSettingsState; +} + +interface Changes { + values: { + [key: string]: any; + }; + + callback(error?: Error, response?: UiSettingsApiResponse): void; +} + +const NOOP_CHANGES = { + values: {}, + callback: () => { + // noop + }, +}; + +export class UiSettingsApi { + private pendingChanges?: Changes; + private sendInProgress = false; + + private readonly loadingCount$ = new BehaviorSubject(0); + + constructor( + private readonly basePath: BasePathStartContract, + private readonly kibanaVersion: string + ) {} + + /** + * Adds a key+value that will be sent to the server ASAP. If a request is + * already in progress it will wait until the previous request is complete + * before sending the next request + */ + public batchSet(key: string, value: any) { + return new Promise((resolve, reject) => { + const prev = this.pendingChanges || NOOP_CHANGES; + + this.pendingChanges = { + values: { + ...prev.values, + [key]: value, + }, + + callback(error, resp) { + prev.callback(error, resp); + + if (error) { + reject(error); + } else { + resolve(resp); + } + }, + }; + + this.flushPendingChanges(); + }); + } + + /** + * Gets an observable that notifies subscribers of the current number of active requests + */ + public getLoadingCount$() { + return this.loadingCount$.asObservable(); + } + + /** + * Prepares the uiSettings API to be discarded + */ + public stop() { + this.loadingCount$.complete(); + } + + /** + * If there are changes that need to be sent to the server and there is not already a + * request in progress, this method will start a request sending those changes. Once + * the request is complete `flushPendingChanges()` will be called again, and if the + * prerequisites are still true (because changes were queued while the request was in + * progress) then another request will be started until all pending changes have been + * sent to the server. + */ + private async flushPendingChanges() { + if (!this.pendingChanges) { + return; + } + + if (this.sendInProgress) { + return; + } + + const changes = this.pendingChanges; + this.pendingChanges = undefined; + + try { + this.sendInProgress = true; + changes.callback( + undefined, + await this.sendRequest('POST', '/api/kibana/settings', { + changes: changes.values, + }) + ); + } catch (error) { + changes.callback(error); + } finally { + this.sendInProgress = false; + this.flushPendingChanges(); + } + } + + /** + * Calls window.fetch() with the proper headers and error handling logic. + * + * TODO: migrate this to kfetch or whatever the new platform equivalent is once it exists + */ + private async sendRequest(method: string, path: string, body: any) { + try { + this.loadingCount$.next(this.loadingCount$.getValue() + 1); + const response = await fetch(this.basePath.addToPath(path), { + method, + body: JSON.stringify(body), + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'kbn-version': this.kibanaVersion, + }, + credentials: 'same-origin', + }); + + if (response.status >= 300) { + throw new Error(`Request failed with status code: ${response.status}`); + } + + return await response.json(); + } finally { + this.loadingCount$.next(this.loadingCount$.getValue() - 1); + } + } +} diff --git a/src/ui/ui_settings/public/ui_settings_client.test.js b/src/core/public/ui_settings/ui_settings_client.test.ts similarity index 82% rename from src/ui/ui_settings/public/ui_settings_client.test.js rename to src/core/public/ui_settings/ui_settings_client.test.ts index f41c9bc0018ca7..53cf4b7347e1be 100644 --- a/src/ui/ui_settings/public/ui_settings_client.test.js +++ b/src/core/public/ui_settings/ui_settings_client.test.ts @@ -18,41 +18,26 @@ */ import { UiSettingsClient } from './ui_settings_client'; -import { sendRequest } from './send_request'; -jest.useFakeTimers(); -jest.mock('./send_request', () => ({ - sendRequest: jest.fn(() => ({})) -})); - -beforeEach(() => { - sendRequest.mockRestore(); - jest.clearAllMocks(); -}); - -function setup(options = {}) { - const { - defaults = { dateFormat: { value: 'Browser' } }, - initialSettings = {} - } = options; +function setup(options: { defaults?: any; initialSettings?: any } = {}) { + const { defaults = { dateFormat: { value: 'Browser' } }, initialSettings = {} } = options; const batchSet = jest.fn(() => ({ - settings: {} + settings: {}, })); + const onUpdateError = jest.fn(); + const config = new UiSettingsClient({ defaults, initialSettings, api: { - batchSet - }, - notify: { - log: jest.fn(), - error: jest.fn(), - } + batchSet, + } as any, + onUpdateError, }); - return { config, batchSet }; + return { config, batchSet, onUpdateError }; } describe('#get', () => { @@ -88,7 +73,7 @@ describe('#get', () => { expect(config.get('dataFormat', defaultDateFormat)).toBe(defaultDateFormat); }); - it('throws on unknown properties that don\'t have a value yet.', () => { + it("throws on unknown properties that don't have a value yet.", () => { const { config } = setup(); expect(() => config.get('throwableProperty')).toThrowErrorMatchingSnapshot(); }); @@ -129,9 +114,9 @@ describe('#set', () => { initialSettings: { foo: { isOverridden: true, - value: 'bar' - } - } + value: 'bar', + }, + }, }); await expect(config.set('foo', true)).rejects.toThrowErrorMatchingSnapshot(); }); @@ -158,9 +143,9 @@ describe('#remove', () => { initialSettings: { bar: { isOverridden: true, - userValue: true - } - } + userValue: true, + }, + }, }); await expect(config.remove('bar')).rejects.toThrowErrorMatchingSnapshot(); }); @@ -209,12 +194,12 @@ describe('#isCustom', () => { }); }); -describe('#subscribe', () => { - it('calls handler with { key, newValue, oldValue } when config changes', () => { +describe('#getUpdate$', () => { + it('sends { key, newValue, oldValue } notifications when config changes', () => { const handler = jest.fn(); const { config } = setup(); - config.subscribe(handler); + config.getUpdate$().subscribe(handler); expect(handler).not.toHaveBeenCalled(); config.set('foo', 'bar'); @@ -227,21 +212,17 @@ describe('#subscribe', () => { expect(handler.mock.calls).toMatchSnapshot(); }); - it('returns a subscription object which unsubs when .unsubscribe() is called', () => { - const handler = jest.fn(); + it('observables complete when client is stopped', () => { + const onComplete = jest.fn(); const { config } = setup(); - const subscription = config.subscribe(handler); - expect(handler).not.toHaveBeenCalled(); - - config.set('foo', 'bar'); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls).toMatchSnapshot(); - handler.mockClear(); + config.getUpdate$().subscribe({ + complete: onComplete, + }); - subscription.unsubscribe(); - config.set('foo', 'baz'); - expect(handler).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + config.stop(); + expect(onComplete).toHaveBeenCalled(); }); }); @@ -267,7 +248,7 @@ describe('#overrideLocalDefault', () => { const handler = jest.fn(); const { config } = setup(); - config.subscribe(handler); + config.getUpdate$().subscribe(handler); config.overrideLocalDefault('dateFormat', 'bar'); expect(handler.mock.calls).toMatchSnapshot('single subscriber call'); }); @@ -297,7 +278,7 @@ describe('#overrideLocalDefault', () => { const { config } = setup(); config.set('dateFormat', 'foo'); - config.subscribe(handler); + config.getUpdate$().subscribe(handler); config.overrideLocalDefault('dateFormat', 'bar'); expect(handler).not.toHaveBeenCalled(); }); @@ -323,55 +304,40 @@ describe('#overrideLocalDefault', () => { const { config } = setup(); expect(config.isOverridden('foo')).toBe(false); }); + it('returns false if key is no overridden', () => { const { config } = setup({ initialSettings: { foo: { - userValue: 1 + userValue: 1, }, bar: { isOverridden: true, - userValue: 2 - } - } + userValue: 2, + }, + }, }); expect(config.isOverridden('foo')).toBe(false); }); + it('returns true when key is overridden', () => { const { config } = setup({ initialSettings: { foo: { - userValue: 1 + userValue: 1, }, bar: { isOverridden: true, - userValue: 2 + userValue: 2, }, - } + }, }); expect(config.isOverridden('bar')).toBe(true); }); + it('returns false for object prototype properties', () => { const { config } = setup(); expect(config.isOverridden('hasOwnProperty')).toBe(false); }); }); - - describe('#assertUpdateAllowed()', () => { - it('returns false if no settings defined', () => { - const { config } = setup(); - expect(config.assertUpdateAllowed('foo')).toBe(undefined); - }); - it('throws error when keys is overridden', () => { - const { config } = setup({ - initialSettings: { - foo: { - isOverridden: true, - userValue: 'bar' - } - } - }); - expect(() => config.assertUpdateAllowed('foo')).toThrowErrorMatchingSnapshot(); - }); - }); }); diff --git a/src/core/public/ui_settings/ui_settings_client.ts b/src/core/public/ui_settings/ui_settings_client.ts new file mode 100644 index 00000000000000..3eb818ee453aaf --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_client.ts @@ -0,0 +1,251 @@ +/* + * 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 { cloneDeep, defaultsDeep } from 'lodash'; +import { Subject } from 'rxjs'; + +import { UiSettingsState } from './types'; +import { UiSettingsApi } from './ui_settings_api'; + +interface Params { + api: UiSettingsApi; + onUpdateError: UiSettingsClient['onUpdateError']; + defaults: UiSettingsState; + initialSettings: UiSettingsState; +} + +export class UiSettingsClient { + private readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>(); + + private readonly api: UiSettingsApi; + private readonly onUpdateError: (error: Error) => void; + private readonly defaults: UiSettingsState; + private cache: UiSettingsState; + + constructor(readonly params: Params) { + this.api = params.api; + this.onUpdateError = params.onUpdateError; + this.defaults = cloneDeep(params.defaults); + this.cache = defaultsDeep({}, this.defaults, cloneDeep(params.initialSettings)); + } + + /** + * Gets the metadata about all uiSettings, including the type, default value, and user value + * for each key. + */ + public getAll() { + return cloneDeep(this.cache); + } + + /** + * Gets the value for a specific uiSetting. If this setting has no user-defined value + * then the `defaultOverride` parameter is returned (and parsed if setting is of type + * "json" or "number). If the parameter is not defined and the key is not defined by a + * uiSettingDefaults then an error is thrown, otherwise the default is read + * from the uiSettingDefaults. + */ + public get(key: string, defaultOverride?: any) { + const declared = this.isDeclared(key); + + if (!declared && defaultOverride !== undefined) { + return defaultOverride; + } + + if (!declared) { + throw new Error( + `Unexpected \`config.get("${key}")\` call on unrecognized configuration setting "${key}". +Setting an initial value via \`config.set("${key}", value)\` before attempting to retrieve +any custom setting value for "${key}" may fix this issue. +You can use \`config.get("${key}", defaultValue)\`, which will just return +\`defaultValue\` when the key is unrecognized.` + ); + } + + const type = this.cache[key].type; + const userValue = this.cache[key].userValue; + const defaultValue = defaultOverride !== undefined ? defaultOverride : this.cache[key].value; + const value = userValue == null ? defaultValue : userValue; + + if (type === 'json') { + return JSON.parse(value); + } + + if (type === 'number') { + return parseFloat(value); + } + + return value; + } + + /** + * Sets the value for a uiSetting. If the setting is not defined in the uiSettingDefaults + * it will be stored as a custom setting. The new value will be synchronously available via + * the `get()` method and sent to the server in the background. If the request to the + * server fails then a toast notification will be displayed and the setting will be + * reverted it its value before `set()` was called. + */ + public async set(key: string, val: any) { + return await this.update(key, val); + } + + /** + * Removes the user-defined value for a setting, causing it to revert to the default. This + * method behaves the same as calling `set(key, null)`, including the synchronization, custom + * setting, and error behavior of that method. + */ + public async remove(key: string) { + return await this.update(key, null); + } + + /** + * Returns true if the key is a "known" uiSetting, meaning it is either defined in the + * uiSettingDefaults or was previously added as a custom setting via the `set()` method. + */ + public isDeclared(key: string) { + return key in this.cache; + } + + /** + * Returns true if the setting has no user-defined value or is unknown + */ + public isDefault(key: string) { + return !this.isDeclared(key) || this.cache[key].userValue == null; + } + + /** + * Returns true if the setting is not a part of the uiSettingDefaults, but was either + * added directly via `set()`, or is an unknown setting found in the uiSettings saved + * object + */ + public isCustom(key: string) { + return this.isDeclared(key) && !('value' in this.cache[key]); + } + + /** + * Returns true if a settings value is overridden by the server. When a setting is overridden + * its value can not be changed via `set()` or `remove()`. + */ + public isOverridden(key: string) { + return this.isDeclared(key) && Boolean(this.cache[key].isOverridden); + } + + /** + * Overrides the default value for a setting in this specific browser tab. If the page + * is reloaded the default override is lost. + */ + public overrideLocalDefault(key: string, newDefault: any) { + // capture the previous value + const prevDefault = this.defaults[key] ? this.defaults[key].value : undefined; + + // update defaults map + this.defaults[key] = { + ...(this.defaults[key] || {}), + value: newDefault, + }; + + // update cached default value + this.cache[key] = { + ...(this.cache[key] || {}), + value: newDefault, + }; + + // don't broadcast change if userValue was already overriding the default + if (this.cache[key].userValue == null) { + this.update$.next({ + key, + newValue: newDefault, + oldValue: prevDefault, + }); + } + } + + /** + * Returns an Observable that notifies subscribers of each update to the uiSettings, + * including the key, newValue, and oldValue of the setting that changed. + */ + public getUpdate$() { + return this.update$.asObservable(); + } + + /** + * Prepares the uiSettingsClient to be discarded, completing any update$ observables + * that have been created. + */ + public stop() { + this.update$.complete(); + } + + private assertUpdateAllowed(key: string) { + if (this.isOverridden(key)) { + throw new Error( + `Unable to update "${key}" because its value is overridden by the Kibana server` + ); + } + } + + private async update(key: string, newVal: any) { + this.assertUpdateAllowed(key); + + const declared = this.isDeclared(key); + const defaults = this.defaults; + + const oldVal = declared ? this.cache[key].userValue : undefined; + + const unchanged = oldVal === newVal; + if (unchanged) { + return true; + } + + const initialVal = declared ? this.get(key) : undefined; + this.setLocally(key, newVal); + + try { + const { settings } = await this.api.batchSet(key, newVal); + this.cache = defaultsDeep({}, defaults, settings); + return true; + } catch (error) { + this.setLocally(key, initialVal); + this.onUpdateError(error); + return false; + } + } + + private setLocally(key: string, newValue: any) { + this.assertUpdateAllowed(key); + + if (!this.isDeclared(key)) { + this.cache[key] = {}; + } + + const oldValue = this.get(key); + + if (newValue === null) { + delete this.cache[key].userValue; + } else { + const { type } = this.cache[key]; + if (type === 'json' && typeof newValue !== 'string') { + this.cache[key].userValue = JSON.stringify(newValue); + } else { + this.cache[key].userValue = newValue; + } + } + + this.update$.next({ key, newValue, oldValue }); + } +} diff --git a/src/core/public/ui_settings/ui_settings_service.test.ts b/src/core/public/ui_settings/ui_settings_service.test.ts new file mode 100644 index 00000000000000..2b31cedd070948 --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_service.test.ts @@ -0,0 +1,127 @@ +/* + * 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. + */ + +function mockClass( + module: string, + Class: { new (...args: any[]): T }, + setup: (instance: any, args: any[]) => void +) { + const MockClass = jest.fn(function(this: any, ...args: any[]) { + setup(this, args); + }); + + // define the mock name which is used in some snapshots + MockClass.mockName(`Mock${Class.name}`); + + // define the class name for the MockClass which is used in other snapshots + Object.defineProperty(MockClass, 'name', { + value: `Mock${Class.name}`, + }); + + jest.mock(module, () => ({ + [Class.name]: MockClass, + })); + + return MockClass; +} + +// Mock the UiSettingsApi class +import { UiSettingsApi } from './ui_settings_api'; +const MockUiSettingsApi = mockClass('./ui_settings_api', UiSettingsApi, inst => { + inst.stop = jest.fn(); + inst.getLoadingCount$ = jest.fn().mockReturnValue({ + loadingCountObservable: true, + }); +}); + +// Mock the UiSettingsClient class +import { UiSettingsClient } from './ui_settings_client'; +const MockUiSettingsClient = mockClass('./ui_settings_client', UiSettingsClient, inst => { + inst.stop = jest.fn(); +}); + +// Load the service +import { UiSettingsService } from './ui_settings_service'; + +const loadingCountStartContract = { + loadingCountStartContract: true, + add: jest.fn(), +}; + +const defaultDeps: any = { + notifications: { + notificationsStartContract: true, + }, + loadingCount: loadingCountStartContract, + injectedMetadata: { + injectedMetadataStartContract: true, + getKibanaVersion: jest.fn().mockReturnValue('kibanaVersion'), + getLegacyMetadata: jest.fn().mockReturnValue({ + uiSettings: { + defaults: { legacyInjectedUiSettingDefaults: true }, + user: { legacyInjectedUiSettingUserValues: true }, + }, + }), + }, + basePath: { + basePathStartContract: true, + }, +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('#start', () => { + it('returns an instance of UiSettingsClient', () => { + const start = new UiSettingsService().start(defaultDeps); + expect(start).toBeInstanceOf(MockUiSettingsClient); + }); + + it('constructs UiSettingsClient and UiSettingsApi', () => { + new UiSettingsService().start(defaultDeps); + + expect(MockUiSettingsApi).toMatchSnapshot('UiSettingsApi args'); + expect(MockUiSettingsClient).toMatchSnapshot('UiSettingsClient args'); + }); + + it('passes the uiSettings loading count to the loading count api', () => { + new UiSettingsService().start(defaultDeps); + + expect(loadingCountStartContract.add).toMatchSnapshot('loadingCount.add calls'); + }); +}); + +describe('#stop', () => { + it('runs fine if service never started', () => { + const service = new UiSettingsService(); + expect(() => service.stop()).not.toThrowError(); + }); + + it('stops the uiSettingsClient and uiSettingsApi', () => { + const service = new UiSettingsService(); + const client = service.start(defaultDeps); + const [[{ api }]] = MockUiSettingsClient.mock.calls; + jest.spyOn(client, 'stop'); + jest.spyOn(api, 'stop'); + service.stop(); + expect(api.stop).toHaveBeenCalledTimes(1); + expect(client.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/public/ui_settings/ui_settings_service.ts b/src/core/public/ui_settings/ui_settings_service.ts new file mode 100644 index 00000000000000..e11f903507dc4c --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_service.ts @@ -0,0 +1,72 @@ +/* + * 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 { BasePathStartContract } from '../base_path'; +import { InjectedMetadataStartContract } from '../injected_metadata'; +import { LoadingCountStartContract } from '../loading_count'; +import { NotificationsStartContract } from '../notifications'; + +import { UiSettingsApi } from './ui_settings_api'; +import { UiSettingsClient } from './ui_settings_client'; + +interface Deps { + notifications: NotificationsStartContract; + loadingCount: LoadingCountStartContract; + injectedMetadata: InjectedMetadataStartContract; + basePath: BasePathStartContract; +} + +export class UiSettingsService { + private uiSettingsApi?: UiSettingsApi; + private uiSettingsClient?: UiSettingsClient; + + public start({ notifications, loadingCount, injectedMetadata, basePath }: Deps) { + this.uiSettingsApi = new UiSettingsApi(basePath, injectedMetadata.getKibanaVersion()); + loadingCount.add(this.uiSettingsApi.getLoadingCount$()); + + // TODO: when we have time to refactor the UiSettingsClient and all consumers + // we should stop using the legacy format and pick a better one + const legacyMetadata = injectedMetadata.getLegacyMetadata(); + this.uiSettingsClient = new UiSettingsClient({ + api: this.uiSettingsApi, + onUpdateError: error => { + notifications.toasts.addDanger({ + title: 'Unable to update UI setting', + text: error.message, + }); + }, + defaults: legacyMetadata.uiSettings.defaults, + initialSettings: legacyMetadata.uiSettings.user, + }); + + return this.uiSettingsClient; + } + + public stop() { + if (this.uiSettingsClient) { + this.uiSettingsClient.stop(); + } + + if (this.uiSettingsApi) { + this.uiSettingsApi.stop(); + } + } +} + +export type UiSettingsStartContract = UiSettingsClient; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts new file mode 100644 index 00000000000000..17de85bbfecce1 --- /dev/null +++ b/src/core/public/utils/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 { modifyUrl } from './modify_url'; diff --git a/src/core/public/utils/modify_url.test.ts b/src/core/public/utils/modify_url.test.ts new file mode 100644 index 00000000000000..d1b7081093c285 --- /dev/null +++ b/src/core/public/utils/modify_url.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { modifyUrl } from './modify_url'; + +it('supports returning a new url spec', () => { + expect(modifyUrl('http://localhost', () => ({}))).toBe(''); +}); + +it('supports modifying the passed object', () => { + expect( + modifyUrl('http://localhost', parsed => { + parsed.port = 9999; + parsed.auth = 'foo:bar'; + }) + ).toBe('http://foo:bar@localhost:9999/'); +}); + +it('supports changing pathname', () => { + expect( + modifyUrl('http://localhost/some/path', parsed => { + parsed.pathname += '/subpath'; + }) + ).toBe('http://localhost/some/path/subpath'); +}); + +it('supports changing port', () => { + expect( + modifyUrl('http://localhost:5601', parsed => { + parsed.port = parsed.port! + 1; + }) + ).toBe('http://localhost:5602/'); +}); + +it('supports changing protocol', () => { + expect( + modifyUrl('http://localhost', parsed => { + parsed.protocol = 'mail'; + parsed.slashes = false; + parsed.pathname = undefined; + }) + ).toBe('mail:localhost'); +}); diff --git a/src/utils/modify_url.js b/src/core/public/utils/modify_url.ts similarity index 77% rename from src/utils/modify_url.js rename to src/core/public/utils/modify_url.ts index f988d5218ebf37..15a5532226c60a 100644 --- a/src/utils/modify_url.js +++ b/src/core/public/utils/modify_url.ts @@ -17,7 +17,29 @@ * under the License. */ -import { parse as parseUrl, format as formatUrl } from 'url'; +import { format as formatUrl, parse as parseUrl } from 'url'; + +interface UrlParts { + protocol?: string; + slashes?: boolean; + auth?: string; + hostname?: string; + port?: number; + pathname?: string; + query: { [key: string]: string | string[] | undefined }; + hash?: string; +} + +interface UrlFormatParts { + protocol?: string; + slashes?: boolean; + auth?: string; + hostname?: string; + port?: string | number; + pathname?: string; + query?: { [key: string]: string | string[] | undefined }; + hash?: string; +} /** * Takes a URL and a function that takes the meaningful parts @@ -42,17 +64,12 @@ import { parse as parseUrl, format as formatUrl } from 'url'; * lead to the modifications being ignored (depending on which * property was modified) * - It's not always clear wither to use path/pathname, host/hostname, - * so this trys to add helpful constraints + * so this tries to add helpful constraints * - * @param {String} url - the url to parse - * @param {Function} block - a function that will modify the parsed url, or return a new one - * @return {String} the modified and reformatted url + * @param url the url to parse + * @param block a function that will modify the parsed url, or return a new one */ -export function modifyUrl(url, block) { - if (typeof block !== 'function') { - throw new TypeError('You must pass a block to define the modifications desired'); - } - +export function modifyUrl(url: string, block: (parts: UrlParts) => UrlFormatParts | void) { const parsed = parseUrl(url, true); // copy over the most specific version of each @@ -66,7 +83,7 @@ export function modifyUrl(url, block) { slashes: parsed.slashes, auth: parsed.auth, hostname: parsed.hostname, - port: parsed.port, + port: parsed.port ? Number(parsed.port) : undefined, pathname: parsed.pathname, query: parsed.query || {}, hash: parsed.hash, diff --git a/src/ui/public/autoload/settings.js b/src/ui/public/autoload/settings.js index 48505037dffe4c..c496839dda5d2d 100644 --- a/src/ui/public/autoload/settings.js +++ b/src/ui/public/autoload/settings.js @@ -41,7 +41,7 @@ const uiSettings = chrome.getUiSettingsClient(); setDefaultTimezone(uiSettings.get('dateFormat:tz')); setStartDayOfWeek(uiSettings.get('dateFormat:dow')); -uiSettings.subscribe(({ key, newValue }) => { +uiSettings.getUpdate$().subscribe(({ key, newValue }) => { if (key === 'dateFormat:tz') { setDefaultTimezone(newValue); } else if (key === 'dateFormat:dow') { diff --git a/src/ui/public/chrome/api/__tests__/nav.js b/src/ui/public/chrome/api/__tests__/nav.js index ac6a9d961b77f2..169c9546a4a37e 100644 --- a/src/ui/public/chrome/api/__tests__/nav.js +++ b/src/ui/public/chrome/api/__tests__/nav.js @@ -26,7 +26,9 @@ import { KibanaParsedUrl } from '../../../url/kibana_parsed_url'; const basePath = '/someBasePath'; function init(customInternals = { basePath }) { - const chrome = {}; + const chrome = { + getBasePath: () => customInternals.basePath || '', + }; const internals = { nav: [], ...customInternals, @@ -36,48 +38,6 @@ function init(customInternals = { basePath }) { } describe('chrome nav apis', function () { - describe('#getBasePath()', function () { - it('returns the basePath', function () { - const { chrome } = init(); - expect(chrome.getBasePath()).to.be(basePath); - }); - }); - - describe('#addBasePath()', function () { - it('returns undefined when nothing is passed', function () { - const { chrome } = init(); - expect(chrome.addBasePath()).to.be(undefined); - }); - - it('prepends the base path when the input is a path', function () { - const { chrome } = init(); - expect(chrome.addBasePath('/other/path')).to.be(`${basePath}/other/path`); - }); - - it('ignores non-path urls', function () { - const { chrome } = init(); - expect(chrome.addBasePath('http://github.com/elastic/kibana')).to.be('http://github.com/elastic/kibana'); - }); - - it('includes the query string', function () { - const { chrome } = init(); - expect(chrome.addBasePath('/app/kibana?a=b')).to.be(`${basePath}/app/kibana?a=b`); - }); - }); - - describe('#removeBasePath', () => { - it ('returns the given URL as-is when no basepath is set', () => { - const basePath = ''; - const { chrome } = init({ basePath }); - expect(chrome.removeBasePath('/app/kibana?a=b')).to.be('/app/kibana?a=b'); - }); - - it ('returns the given URL with the basepath stripped out when basepath is set', () => { - const { chrome } = init(); - expect(chrome.removeBasePath(`${basePath}/app/kibana?a=b`)).to.be('/app/kibana?a=b'); - }); - }); - describe('#getNavLinkById', () => { it ('retrieves the correct nav link, given its ID', () => { const appUrlStore = new StubBrowserStorage(); diff --git a/src/ui/public/chrome/api/base_path.test.ts b/src/ui/public/chrome/api/base_path.test.ts new file mode 100644 index 00000000000000..e6c0c7fb217087 --- /dev/null +++ b/src/ui/public/chrome/api/base_path.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { __newPlatformInit__, initChromeBasePathApi } from './base_path'; + +function initChrome() { + const chrome: any = {}; + initChromeBasePathApi(chrome); + return chrome; +} + +const newPlatformBasePath = { + get: jest.fn().mockReturnValue('get'), + addToPath: jest.fn().mockReturnValue('addToPath'), + removeFromPath: jest.fn().mockReturnValue('removeFromPath'), +}; +__newPlatformInit__(newPlatformBasePath); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('#getBasePath()', () => { + it('proxies to newPlatformBasePath.get()', () => { + const chrome = initChrome(); + expect(newPlatformBasePath.get).not.toHaveBeenCalled(); + expect(chrome.getBasePath()).toBe('get'); + expect(newPlatformBasePath.get).toHaveBeenCalledTimes(1); + expect(newPlatformBasePath.get).toHaveBeenCalledWith(); + }); +}); + +describe('#addBasePath()', () => { + it('proxies to newPlatformBasePath.addToPath(path)', () => { + const chrome = initChrome(); + expect(newPlatformBasePath.addToPath).not.toHaveBeenCalled(); + expect(chrome.addBasePath('foo/bar')).toBe('addToPath'); + expect(newPlatformBasePath.addToPath).toHaveBeenCalledTimes(1); + expect(newPlatformBasePath.addToPath).toHaveBeenCalledWith('foo/bar'); + }); +}); + +describe('#removeBasePath', () => { + it('proxies to newPlatformBasePath.removeFromPath(path)', () => { + const chrome = initChrome(); + expect(newPlatformBasePath.removeFromPath).not.toHaveBeenCalled(); + expect(chrome.removeBasePath('foo/bar')).toBe('removeFromPath'); + expect(newPlatformBasePath.removeFromPath).toHaveBeenCalledTimes(1); + expect(newPlatformBasePath.removeFromPath).toHaveBeenCalledWith('foo/bar'); + }); +}); diff --git a/src/ui/ui_settings/public/send_request.js b/src/ui/public/chrome/api/base_path.ts similarity index 54% rename from src/ui/ui_settings/public/send_request.js rename to src/ui/public/chrome/api/base_path.ts index 3f138fe0373c87..49343faa8f7145 100644 --- a/src/ui/ui_settings/public/send_request.js +++ b/src/ui/public/chrome/api/base_path.ts @@ -17,29 +17,19 @@ * under the License. */ -import chrome from 'ui/chrome'; -import { metadata } from 'ui/metadata'; +import { BasePathStartContract } from '../../../../core/public/base_path'; +let newPlatformBasePath: BasePathStartContract; -export async function sendRequest({ method, path, body }) { - chrome.loadingCount.increment(); - try { - const response = await fetch(chrome.addBasePath(path), { - method, - body: JSON.stringify(body), - headers: { - accept: 'application/json', - 'content-type': 'application/json', - 'kbn-version': metadata.version, - }, - credentials: 'same-origin' - }); +export function __newPlatformInit__(instance: BasePathStartContract) { + if (newPlatformBasePath) { + throw new Error('ui/chrome/api/base_path is already initialized'); + } - if (response.status >= 300) { - throw new Error(`Request failed with status code: ${response.status}`); - } + newPlatformBasePath = instance; +} - return await response.json(); - } finally { - chrome.loadingCount.decrement(); - } +export function initChromeBasePathApi(chrome: any) { + chrome.getBasePath = () => newPlatformBasePath.get(); + chrome.addBasePath = (path: string) => newPlatformBasePath.addToPath(path); + chrome.removeBasePath = (path: string) => newPlatformBasePath.removeFromPath(path); } diff --git a/src/ui/public/chrome/api/nav.js b/src/ui/public/chrome/api/nav.js index 5163c083d3013d..04aab308396c4c 100644 --- a/src/ui/public/chrome/api/nav.js +++ b/src/ui/public/chrome/api/nav.js @@ -18,7 +18,6 @@ */ import { remove } from 'lodash'; -import { prependPath } from '../../url/prepend_path'; import { relativeToAbsolute } from '../../url/relative_to_absolute'; import { absoluteToParsedUrl } from '../../url/absolute_to_parsed_url'; @@ -43,28 +42,6 @@ export function initChromeNavApi(chrome, internals) { remove(internals.nav, app => app.id !== id); }; - chrome.getBasePath = function () { - return internals.basePath || ''; - }; - - /** - * - * @param url {string} a relative url. ex: /app/kibana#/management - * @return {string} the relative url with the basePath prepended to it. ex: rkz/app/kibana#/management - */ - chrome.addBasePath = function (url) { - return prependPath(url, chrome.getBasePath()); - }; - - chrome.removeBasePath = function (url) { - if (!internals.basePath) { - return url; - } - - const basePathRegExp = new RegExp(`^${internals.basePath}`); - return url.replace(basePathRegExp, ''); - }; - function lastSubUrlKey(link) { return `lastSubUrl:${link.url}`; } diff --git a/src/ui/public/chrome/api/ui_settings.js b/src/ui/public/chrome/api/ui_settings.js index e602c67f0e57fd..2ca945f0b025fc 100644 --- a/src/ui/public/chrome/api/ui_settings.js +++ b/src/ui/public/chrome/api/ui_settings.js @@ -17,18 +17,18 @@ * under the License. */ -import { metadata } from '../../metadata'; -import { Notifier } from '../../notify'; -import { UiSettingsClient } from '../../../ui_settings/public/ui_settings_client'; +let newPlatformUiSettingsClient; -export function initUiSettingsApi(chrome) { - const uiSettings = new UiSettingsClient({ - defaults: metadata.uiSettings.defaults, - initialSettings: metadata.uiSettings.user, - notify: new Notifier({ location: 'Config' }) - }); +export function __newPlatformInit__(instance) { + if (newPlatformUiSettingsClient) { + throw new Error('ui/chrome/api/ui_settings already initialized'); + } + + newPlatformUiSettingsClient = instance; +} +export function initUiSettingsApi(chrome) { chrome.getUiSettingsClient = function () { - return uiSettings; + return newPlatformUiSettingsClient; }; } diff --git a/src/ui/public/chrome/chrome.js b/src/ui/public/chrome/chrome.js index 741d1eb629b62e..79787e9ea14eb8 100644 --- a/src/ui/public/chrome/chrome.js +++ b/src/ui/public/chrome/chrome.js @@ -42,6 +42,7 @@ import { initChromeXsrfApi } from './api/xsrf'; import { initUiSettingsApi } from './api/ui_settings'; import { initLoadingCountApi } from './api/loading_count'; import { initSavedObjectClient } from './api/saved_object_client'; +import { initChromeBasePathApi } from './api/base_path'; export const chrome = {}; const internals = _.defaults( @@ -63,6 +64,7 @@ initUiSettingsApi(chrome); initSavedObjectClient(chrome); appsApi(chrome, internals); initChromeXsrfApi(chrome, internals); +initChromeBasePathApi(chrome); initChromeNavApi(chrome, internals); initLoadingCountApi(chrome, internals); initAngularApi(chrome, internals); diff --git a/src/ui/public/config/config.js b/src/ui/public/config/config.js index a4f668529d4759..d5ac7edd1a16eb 100644 --- a/src/ui/public/config/config.js +++ b/src/ui/public/config/config.js @@ -59,7 +59,7 @@ module.service(`config`, function ($rootScope, Promise) { //* angular specific methods * ////////////////////////////// - const subscription = uiSettings.subscribe(({ key, newValue, oldValue }) => { + const subscription = uiSettings.getUpdate$().subscribe(({ key, newValue, oldValue }) => { const emit = () => { $rootScope.$broadcast('change:config', newValue, oldValue, key, this); $rootScope.$broadcast(`change:config.${key}`, newValue, oldValue, key, this); diff --git a/src/ui/public/notify/app_redirect/app_redirect.js b/src/ui/public/notify/app_redirect/app_redirect.js index 7fd6553c11b3f7..9a283fcaad9c50 100644 --- a/src/ui/public/notify/app_redirect/app_redirect.js +++ b/src/ui/public/notify/app_redirect/app_redirect.js @@ -17,9 +17,7 @@ * under the License. */ -// Use the util instead of the export from ui/url because that module is tightly coupled with -// Angular. -import { modifyUrl } from '../../../../utils/modify_url'; +import { modifyUrl } from '../../../../core/public/utils'; import { toastNotifications } from '../toasts'; const APP_REDIRECT_MESSAGE_PARAM = 'app_redirect_message'; diff --git a/src/ui/public/registry/field_formats.js b/src/ui/public/registry/field_formats.js index 45c9d87bc3b239..464b8dbc5386f2 100644 --- a/src/ui/public/registry/field_formats.js +++ b/src/ui/public/registry/field_formats.js @@ -39,7 +39,7 @@ class FieldFormatRegistry extends IndexedArray { init() { this.parseDefaultTypeMap(this._uiSettings.get('format:defaultTypeMap')); - this._uiSettings.subscribe(({ key, newValue }) => { + this._uiSettings.getUpdate$().subscribe(({ key, newValue }) => { if (key === 'format:defaultTypeMap') { this.parseDefaultTypeMap(newValue); } diff --git a/src/ui/public/styles/disable_animations/disable_animations.js b/src/ui/public/styles/disable_animations/disable_animations.js index 067bbd040df33e..c70aaa2165691f 100644 --- a/src/ui/public/styles/disable_animations/disable_animations.js +++ b/src/ui/public/styles/disable_animations/disable_animations.js @@ -38,7 +38,7 @@ function updateStyleSheet() { } updateStyleSheet(); -uiSettings.subscribe(({ key }) => { +uiSettings.getUpdate$().subscribe(({ key }) => { if (key === 'accessibility:disableAnimations') { updateStyleSheet(); } diff --git a/src/ui/public/test_harness/test_harness.js b/src/ui/public/test_harness/test_harness.js index 3b5144145de69e..2c6203ab9e2eb6 100644 --- a/src/ui/public/test_harness/test_harness.js +++ b/src/ui/public/test_harness/test_harness.js @@ -24,7 +24,7 @@ import { parse as parseUrl } from 'url'; import sinon from 'sinon'; import { Notifier } from '../notify'; import { metadata } from '../metadata'; -import { UiSettingsClient } from '../../ui_settings/public/ui_settings_client'; +import { UiSettingsClient } from '../../../core/public/ui_settings'; import './test_harness.less'; import 'ng_mock'; @@ -46,16 +46,25 @@ before(() => { sinon.useFakeXMLHttpRequest(); }); -let stubUiSettings = new UiSettingsClient({ - defaults: metadata.uiSettings.defaults, - initialSettings: {}, - notify: new Notifier({ location: 'Config' }), - api: { - batchSet() { - return { settings: stubUiSettings.getAll() }; - } +let stubUiSettings; +function createStubUiSettings() { + if (stubUiSettings) { + stubUiSettings.stop(); } -}); + + stubUiSettings = new UiSettingsClient({ + api: { + async batchSet() { + return { settings: stubUiSettings.getAll() }; + } + }, + onUpdateError: () => {}, + defaults: metadata.uiSettings.defaults, + initialSettings: {}, + }); +} + +createStubUiSettings(); sinon.stub(chrome, 'getUiSettingsClient').callsFake(() => stubUiSettings); beforeEach(function () { @@ -68,16 +77,7 @@ beforeEach(function () { }); afterEach(function () { - stubUiSettings = new UiSettingsClient({ - defaults: metadata.uiSettings.defaults, - initialSettings: {}, - notify: new Notifier({ location: 'Config' }), - api: { - batchSet() { - return { settings: stubUiSettings.getAll() }; - } - } - }); + createStubUiSettings(); }); // Kick off mocha, called at the end of test entry files diff --git a/src/ui/public/url/index.js b/src/ui/public/url/index.js index 1e0f1f62b9284a..b95c477d8916cd 100644 --- a/src/ui/public/url/index.js +++ b/src/ui/public/url/index.js @@ -19,4 +19,4 @@ export { KbnUrlProvider } from './url'; export { RedirectWhenMissingProvider } from './redirect_when_missing'; -export { modifyUrl } from './modify_url'; +export { modifyUrl } from '../../../core/public/utils'; diff --git a/src/ui/public/url/kibana_parsed_url.js b/src/ui/public/url/kibana_parsed_url.js index 683af3b4bb2cc9..ef84440ef7af8e 100644 --- a/src/ui/public/url/kibana_parsed_url.js +++ b/src/ui/public/url/kibana_parsed_url.js @@ -20,7 +20,7 @@ import { parse } from 'url'; import { prependPath } from './prepend_path'; -import { modifyUrl } from '../../../utils'; +import { modifyUrl } from '../../../core/public/utils'; /** * Represents the pieces that make up a url in Kibana, offering some helpful functionality for diff --git a/src/ui/ui_render/ui_render_mixin.js b/src/ui/ui_render/ui_render_mixin.js index 629affb6d0ff68..5929f5bc0e73c9 100644 --- a/src/ui/ui_render/ui_render_mixin.js +++ b/src/ui/ui_render/ui_render_mixin.js @@ -149,6 +149,7 @@ export function uiRenderMixin(kbnServer, server, config) { injectedMetadata: { version: kbnServer.version, buildNumber: config.get('pkg.buildNum'), + basePath, legacyMetadata: await getLegacyKibanaPayload({ app, translations, diff --git a/src/ui/ui_settings/public/ui_settings_api.js b/src/ui/ui_settings/public/ui_settings_api.js deleted file mode 100644 index efa667d30af2d1..00000000000000 --- a/src/ui/ui_settings/public/ui_settings_api.js +++ /dev/null @@ -1,86 +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 { sendRequest } from './send_request'; - -const NOOP_CHANGES = { - values: {}, - callback: () => {}, -}; - -export function createUiSettingsApi() { - let pendingChanges = null; - let sendInProgress = false; - - async function flushPendingChanges() { - if (!pendingChanges) { - return; - } - - if (sendInProgress) { - return; - } - - const changes = pendingChanges; - pendingChanges = null; - - try { - sendInProgress = true; - changes.callback(null, await sendRequest({ - method: 'POST', - path: '/api/kibana/settings', - body: { - changes: changes.values - }, - })); - } catch (error) { - changes.callback(error); - } finally { - sendInProgress = false; - flushPendingChanges(); - } - } - - return new class Api { - batchSet(key, value) { - return new Promise((resolve, reject) => { - const prev = pendingChanges || NOOP_CHANGES; - - pendingChanges = { - values: { - ...prev.values, - [key]: value, - }, - - callback(error, resp) { - prev.callback(error, resp); - - if (error) { - reject(error); - } else { - resolve(resp); - } - }, - }; - - flushPendingChanges(); - }); - } - }; -} diff --git a/src/ui/ui_settings/public/ui_settings_client.js b/src/ui/ui_settings/public/ui_settings_client.js deleted file mode 100644 index 60505065143d7b..00000000000000 --- a/src/ui/ui_settings/public/ui_settings_client.js +++ /dev/null @@ -1,207 +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 { cloneDeep, defaultsDeep } from 'lodash'; -import { createUiSettingsApi } from './ui_settings_api'; - -export class UiSettingsClient { - constructor(options) { - const { - defaults, - initialSettings, - notify, - api = createUiSettingsApi(), - } = options; - - this._defaults = cloneDeep(defaults); - this._cache = defaultsDeep({}, this._defaults, cloneDeep(initialSettings)); - this._api = api; - this._notify = notify; - this._updateObservers = new Set(); - } - - getAll() { - return cloneDeep(this._cache); - } - - get(key, defaultValue) { - if (!this.isDeclared(key)) { - // the key is not a declared setting - // pass through the caller's desired default value - // without persisting anything in the config document - if (defaultValue !== undefined) { - return defaultValue; - } - - throw new Error( - `Unexpected \`config.get("${key}")\` call on unrecognized configuration setting "${key}". -Setting an initial value via \`config.set("${key}", value)\` before attempting to retrieve -any custom setting value for "${key}" may fix this issue. -You can use \`config.get("${key}", defaultValue)\`, which will just return -\`defaultValue\` when the key is unrecognized.` - ); - } - - const { - userValue, - value: definedDefault, - type - } = this._cache[key]; - - let currentValue; - - if (this.isDefault(key)) { - // honor the second parameter if it was passed - currentValue = defaultValue === undefined ? definedDefault : defaultValue; - } else { - currentValue = userValue; - } - - if (type === 'json') { - return JSON.parse(currentValue); - } else if (type === 'number') { - return parseFloat(currentValue); - } - - return currentValue; - } - - async set(key, val) { - return await this._update(key, val); - } - - async remove(key) { - return await this._update(key, null); - } - - isDeclared(key) { - return Boolean(key in this._cache); - } - - isDefault(key) { - return !this.isDeclared(key) || this._cache[key].userValue == null; - } - - isCustom(key) { - return this.isDeclared(key) && !('value' in this._cache[key]); - } - - isOverridden(key) { - return this.isDeclared(key) && Boolean(this._cache[key].isOverridden); - } - - assertUpdateAllowed(key) { - if (this.isOverridden(key)) { - throw new Error(`Unable to update "${key}" because its value is overridden by the Kibana server`); - } - } - - overrideLocalDefault(key, newDefault) { - // capture the previous value - const prevDefault = this._defaults[key] - ? this._defaults[key].value - : undefined; - - // update defaults map - this._defaults[key] = { - ...this._defaults[key] || {}, - value: newDefault - }; - - // update cached default value - this._cache[key] = { - ...this._cache[key] || {}, - value: newDefault - }; - - // don't broadcast change if userValue was already overriding the default - if (this._cache[key].userValue == null) { - this._broadcastUpdate(key, newDefault, prevDefault); - } - } - - subscribe(observer) { - this._updateObservers.add(observer); - - return { - unsubscribe: () => { - this._updateObservers.delete(observer); - } - }; - } - - async _update(key, value) { - this.assertUpdateAllowed(key); - - const declared = this.isDeclared(key); - const defaults = this._defaults; - - const oldVal = declared ? this._cache[key].userValue : undefined; - const newVal = key in defaults && defaults[key].defaultValue === value - ? null - : value; - - const unchanged = oldVal === newVal; - if (unchanged) { - return true; - } - - const initialVal = declared ? this.get(key) : undefined; - this._setLocally(key, newVal); - - try { - const { settings } = await this._api.batchSet(key, newVal); - this._cache = defaultsDeep({}, defaults, settings); - return true; - } catch (error) { - this._setLocally(key, initialVal); - this._notify.error(error); - return false; - } - } - - _setLocally(key, newValue) { - this.assertUpdateAllowed(key); - - if (!this.isDeclared(key)) { - this._cache[key] = {}; - } - - const oldValue = this.get(key); - - if (newValue === null) { - delete this._cache[key].userValue; - } else { - const { type } = this._cache[key]; - if (type === 'json' && typeof newValue !== 'string') { - this._cache[key].userValue = JSON.stringify(newValue); - } else { - this._cache[key].userValue = newValue; - } - } - - this._broadcastUpdate(key, newValue, oldValue); - } - - _broadcastUpdate(key, newValue, oldValue) { - for (const observer of this._updateObservers) { - observer({ key, newValue, oldValue }); - } - } -} diff --git a/src/utils/__tests__/modify_url.js b/src/utils/__tests__/modify_url.js deleted file mode 100644 index 9aa7ba1fb99601..00000000000000 --- a/src/utils/__tests__/modify_url.js +++ /dev/null @@ -1,61 +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 expect from 'expect.js'; - -import { modifyUrl } from '../modify_url'; - -describe('modifyUrl()', () => { - it('throws an error with invalid input', () => { - expect(() => modifyUrl(1, () => {})).to.throwError(); - expect(() => modifyUrl(undefined, () => {})).to.throwError(); - expect(() => modifyUrl('http://localhost')).to.throwError(); // no block - }); - - it('supports returning a new url spec', () => { - expect(modifyUrl('http://localhost', () => ({}))).to.eql(''); - }); - - it('supports modifying the passed object', () => { - expect(modifyUrl('http://localhost', parsed => { - parsed.port = 9999; - parsed.auth = 'foo:bar'; - })).to.eql('http://foo:bar@localhost:9999/'); - }); - - it('supports changing pathname', () => { - expect(modifyUrl('http://localhost/some/path', parsed => { - parsed.pathname += '/subpath'; - })).to.eql('http://localhost/some/path/subpath'); - }); - - it('supports changing port', () => { - expect(modifyUrl('http://localhost:5601', parsed => { - parsed.port = (parsed.port * 1) + 1; - })).to.eql('http://localhost:5602/'); - }); - - it('supports changing protocol', () => { - expect(modifyUrl('http://localhost', parsed => { - parsed.protocol = 'mail'; - parsed.slashes = false; - parsed.pathname = null; - })).to.eql('mail:localhost'); - }); -}); diff --git a/src/utils/index.js b/src/utils/index.js index f79690b4392eaf..cef2c5a61c5116 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -24,7 +24,6 @@ export { fromRoot } from './from_root'; export { pkg } from './package_json'; export { unset } from './unset'; export { encodeQueryComponent } from './encode_query_component'; -export { modifyUrl } from './modify_url'; export { getFlattenedObject } from './get_flattened_object'; export { watchStdioForLine } from './watch_stdio_for_line'; export { IS_KIBANA_DISTRIBUTABLE } from './artifact_type'; diff --git a/test/functional/services/remote/interceptors.js b/test/functional/services/remote/interceptors.js index 2b2b5792eac9f8..936c8f50bef3b9 100644 --- a/test/functional/services/remote/interceptors.js +++ b/test/functional/services/remote/interceptors.js @@ -17,7 +17,7 @@ * under the License. */ -import { modifyUrl } from '../../../../src/utils'; +import { modifyUrl } from '../../../../src/core/utils'; export const createRemoteInterceptors = remote => ({ // inject _t=Date query param on navigation