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