mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[GlobalExperience] Global UI settings: API & service changes (#147069)
## Summary This is a follow-up on: https://github.com/elastic/kibana/pull/146270 This PR: 1. adds server-side service changes 2. registers new routes for global settings 3. add a new `UiSettingsGlobalClient` on the browser side, similarly to what was done on the server side There are no browser-side service changes yet, as this will be bigger change worthy of its own PR. ### Checklist Delete any items that are not applicable to this PR. ~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)~ ~- [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials~ - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ~- [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))~ ~- [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~ ~- [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~ ~- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))~ ~- [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)~ ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
431c32b894
commit
8e3fbe22ae
27 changed files with 1082 additions and 245 deletions
|
@ -45,6 +45,7 @@ export function createCoreSetupMock({
|
|||
|
||||
const uiSettingsMock = {
|
||||
register: uiSettingsServiceMock.createSetupContract().register,
|
||||
registerGlobal: uiSettingsServiceMock.createSetupContract().registerGlobal,
|
||||
};
|
||||
|
||||
const mock: CoreSetupMockType = {
|
||||
|
|
|
@ -250,6 +250,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
|
|||
},
|
||||
uiSettings: {
|
||||
register: deps.uiSettings.register,
|
||||
registerGlobal: deps.uiSettings.registerGlobal,
|
||||
},
|
||||
getStartServices: () => plugin.startDependencies,
|
||||
deprecations: deps.deprecations.getRegistry(plugin.name),
|
||||
|
@ -311,6 +312,7 @@ export function createPluginStartContext<TPlugin, TPluginDependencies>(
|
|||
},
|
||||
uiSettings: {
|
||||
asScopedToClient: deps.uiSettings.asScopedToClient,
|
||||
globalAsScopedToClient: deps.uiSettings.globalAsScopedToClient,
|
||||
},
|
||||
coreUsageData: deps.coreUsageData,
|
||||
};
|
||||
|
|
|
@ -119,3 +119,123 @@ Array [
|
|||
],
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`#batchSetGlobal Buffers are always clear of previously buffered changes: two requests, second only sends bar, not foo 1`] = `
|
||||
Array [
|
||||
Array [
|
||||
"/foo/bar/api/kibana/global_settings",
|
||||
Object {
|
||||
"headers": Object {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
],
|
||||
Array [
|
||||
"/foo/bar/api/kibana/global_settings",
|
||||
Object {
|
||||
"headers": Object {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`#batchSetGlobal Overwrites previously buffered values with new values for the same key: two requests, foo=d in final 1`] = `
|
||||
Array [
|
||||
Array [
|
||||
"/foo/bar/api/kibana/global_settings",
|
||||
Object {
|
||||
"headers": Object {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
],
|
||||
Array [
|
||||
"/foo/bar/api/kibana/global_settings",
|
||||
Object {
|
||||
"headers": Object {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`#batchSetGlobal buffers changes while first request is in progress, sends buffered changes after first request completes: final, includes both requests 1`] = `
|
||||
Array [
|
||||
Array [
|
||||
"/foo/bar/api/kibana/global_settings",
|
||||
Object {
|
||||
"headers": Object {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
],
|
||||
Array [
|
||||
"/foo/bar/api/kibana/global_settings",
|
||||
Object {
|
||||
"headers": Object {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`#batchSetGlobal rejects all promises for batched requests that fail: promise rejections 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"error": [Error: invalid],
|
||||
"isRejected": true,
|
||||
},
|
||||
Object {
|
||||
"error": [Error: invalid],
|
||||
"isRejected": true,
|
||||
},
|
||||
Object {
|
||||
"error": [Error: invalid],
|
||||
"isRejected": true,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`#batchSetGlobal rejects on 301 1`] = `"Moved Permanently"`;
|
||||
|
||||
exports[`#batchSetGlobal rejects on 404 response 1`] = `"Request failed with status code: 404"`;
|
||||
|
||||
exports[`#batchSetGlobal rejects on 500 1`] = `"Request failed with status code: 500"`;
|
||||
|
||||
exports[`#batchSetGlobal sends a single change immediately: single change 1`] = `
|
||||
Array [
|
||||
Array [
|
||||
"/foo/bar/api/kibana/global_settings",
|
||||
Object {
|
||||
"headers": Object {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
|
|
@ -162,6 +162,123 @@ describe('#batchSet', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#batchSetGlobal', () => {
|
||||
it('sends a single change immediately', async () => {
|
||||
fetchMock.mock('*', {
|
||||
body: { settings: {} },
|
||||
});
|
||||
|
||||
const { uiSettingsApi } = setup();
|
||||
await uiSettingsApi.batchSetGlobal('foo', 'bar');
|
||||
expect(fetchMock.calls()).toMatchSnapshot('single change');
|
||||
});
|
||||
|
||||
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.batchSetGlobal('foo', 'bar');
|
||||
const finalPromise = uiSettingsApi.batchSet('box', 'bar');
|
||||
|
||||
expect(uiSettingsApi.hasPendingChanges()).toBe(true);
|
||||
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.batchSetGlobal('foo', 'a');
|
||||
uiSettingsApi.batchSetGlobal('foo', 'b');
|
||||
uiSettingsApi.batchSetGlobal('foo', 'c');
|
||||
await uiSettingsApi.batchSetGlobal('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.batchSetGlobal('foo', 'bar');
|
||||
uiSettingsApi.batchSetGlobal('bar', 'foo');
|
||||
await uiSettingsApi.batchSetGlobal('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.batchSetGlobal('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
it('rejects on 301', async () => {
|
||||
fetchMock.mock('*', {
|
||||
status: 301,
|
||||
body: 'redirect',
|
||||
});
|
||||
|
||||
const { uiSettingsApi } = setup();
|
||||
await expect(uiSettingsApi.batchSetGlobal('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
it('rejects on 500', async () => {
|
||||
fetchMock.mock('*', {
|
||||
status: 500,
|
||||
body: 'redirect',
|
||||
});
|
||||
|
||||
const { uiSettingsApi } = setup();
|
||||
await expect(uiSettingsApi.batchSetGlobal('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
it('rejects all promises for batched requests that fail', async () => {
|
||||
fetchMock.once('*', {
|
||||
body: { settings: {} },
|
||||
});
|
||||
fetchMock.once(
|
||||
'*',
|
||||
{
|
||||
status: 400,
|
||||
body: { message: 'invalid' },
|
||||
},
|
||||
{
|
||||
overwriteRoutes: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { uiSettingsApi } = setup();
|
||||
// trigger the initial sync request, which enabled buffering
|
||||
uiSettingsApi.batchSetGlobal('foo', 'bar');
|
||||
|
||||
// buffer some requests so they will be sent together
|
||||
await expect(
|
||||
Promise.all([
|
||||
settlePromise(uiSettingsApi.batchSetGlobal('foo', 'a')),
|
||||
settlePromise(uiSettingsApi.batchSetGlobal('bar', 'b')),
|
||||
settlePromise(uiSettingsApi.batchSetGlobal('baz', 'c')),
|
||||
])
|
||||
).resolves.toMatchSnapshot('promise rejections');
|
||||
|
||||
// ensure only two requests were sent
|
||||
expect(fetchMock.calls()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getLoadingCount$()', () => {
|
||||
it('emits the current number of active requests', async () => {
|
||||
fetchMock.once('*', {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { BehaviorSubject } from 'rxjs';
|
|||
import type { HttpSetup } from '@kbn/core-http-browser';
|
||||
|
||||
import type { UiSettingsState } from '@kbn/core-ui-settings-browser';
|
||||
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
|
||||
|
||||
export interface UiSettingsApiResponse {
|
||||
settings: UiSettingsState;
|
||||
|
@ -64,7 +65,32 @@ export class UiSettingsApi {
|
|||
},
|
||||
};
|
||||
|
||||
this.flushPendingChanges();
|
||||
this.flushPendingChanges('namespace');
|
||||
});
|
||||
}
|
||||
|
||||
public batchSetGlobal(key: string, value: any) {
|
||||
return new Promise<UiSettingsApiResponse>((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('global');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -97,7 +123,7 @@ export class UiSettingsApi {
|
|||
* progress) then another request will be started until all pending changes have been
|
||||
* sent to the server.
|
||||
*/
|
||||
private async flushPendingChanges() {
|
||||
private async flushPendingChanges(scope: UiSettingsScope) {
|
||||
if (!this.pendingChanges) {
|
||||
return;
|
||||
}
|
||||
|
@ -111,10 +137,10 @@ export class UiSettingsApi {
|
|||
|
||||
try {
|
||||
this.sendInProgress = true;
|
||||
|
||||
const path = scope === 'namespace' ? '/api/kibana/settings' : '/api/kibana/global_settings';
|
||||
changes.callback(
|
||||
undefined,
|
||||
await this.sendRequest('POST', '/api/kibana/settings', {
|
||||
await this.sendRequest('POST', path, {
|
||||
changes: changes.values,
|
||||
})
|
||||
);
|
||||
|
@ -122,7 +148,7 @@ export class UiSettingsApi {
|
|||
changes.callback(error);
|
||||
} finally {
|
||||
this.sendInProgress = false;
|
||||
this.flushPendingChanges();
|
||||
this.flushPendingChanges(scope);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,17 +19,21 @@ function setup(options: { defaults?: any; initialSettings?: any } = {}) {
|
|||
const batchSet = jest.fn(() => ({
|
||||
settings: {},
|
||||
}));
|
||||
const batchSetGlobal = jest.fn(() => ({
|
||||
settings: {},
|
||||
}));
|
||||
done$ = new Subject();
|
||||
const client = new UiSettingsClient({
|
||||
defaults,
|
||||
initialSettings,
|
||||
api: {
|
||||
batchSet,
|
||||
batchSetGlobal,
|
||||
} as any,
|
||||
done$,
|
||||
});
|
||||
|
||||
return { client, batchSet };
|
||||
return { client, batchSet, batchSetGlobal };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
@ -6,131 +6,15 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { cloneDeep, defaultsDeep } from 'lodash';
|
||||
import { Observable, Subject, concat, defer, of } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
|
||||
import { UserProvidedValues, PublicUiSettingsParams } from '@kbn/core-ui-settings-common';
|
||||
import { IUiSettingsClient, UiSettingsState } from '@kbn/core-ui-settings-browser';
|
||||
|
||||
import { UiSettingsApi } from './ui_settings_api';
|
||||
|
||||
interface UiSettingsClientParams {
|
||||
api: UiSettingsApi;
|
||||
defaults: Record<string, PublicUiSettingsParams>;
|
||||
initialSettings?: UiSettingsState;
|
||||
done$: Observable<unknown>;
|
||||
}
|
||||
|
||||
export class UiSettingsClient implements IUiSettingsClient {
|
||||
private readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>();
|
||||
private readonly updateErrors$ = new Subject<Error>();
|
||||
|
||||
private readonly api: UiSettingsApi;
|
||||
private readonly defaults: Record<string, PublicUiSettingsParams>;
|
||||
private cache: Record<string, PublicUiSettingsParams & UserProvidedValues>;
|
||||
import { defaultsDeep } from 'lodash';
|
||||
import { UiSettingsClientCommon, UiSettingsClientParams } from './ui_settings_client_common';
|
||||
|
||||
export class UiSettingsClient extends UiSettingsClientCommon {
|
||||
constructor(params: UiSettingsClientParams) {
|
||||
this.api = params.api;
|
||||
this.defaults = cloneDeep(params.defaults);
|
||||
this.cache = defaultsDeep({}, this.defaults, cloneDeep(params.initialSettings));
|
||||
|
||||
params.done$.subscribe({
|
||||
complete: () => {
|
||||
this.update$.complete();
|
||||
this.updateErrors$.complete();
|
||||
},
|
||||
});
|
||||
super(params);
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return cloneDeep(this.cache);
|
||||
}
|
||||
|
||||
get<T = any>(key: string, defaultOverride?: T) {
|
||||
const declared = this.isDeclared(key);
|
||||
|
||||
if (!declared && defaultOverride !== undefined) {
|
||||
return defaultOverride;
|
||||
}
|
||||
|
||||
if (!declared) {
|
||||
throw new Error(
|
||||
`Unexpected \`IUiSettingsClient.get("${key}")\` call on unrecognized configuration setting "${key}".
|
||||
Setting an initial value via \`IUiSettingsClient.set("${key}", value)\` before attempting to retrieve
|
||||
any custom setting value for "${key}" may fix this issue.
|
||||
You can use \`IUiSettingsClient.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;
|
||||
}
|
||||
|
||||
get$<T = any>(key: string, defaultOverride?: T) {
|
||||
return concat(
|
||||
defer(() => of(this.get(key, defaultOverride))),
|
||||
this.update$.pipe(
|
||||
filter((update) => update.key === key),
|
||||
map(() => this.get(key, defaultOverride))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async set(key: string, value: any) {
|
||||
return await this.update(key, value);
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
return await this.update(key, null);
|
||||
}
|
||||
|
||||
isDeclared(key: string) {
|
||||
return key in this.cache;
|
||||
}
|
||||
|
||||
isDefault(key: string) {
|
||||
return !this.isDeclared(key) || this.cache[key].userValue == null;
|
||||
}
|
||||
|
||||
isCustom(key: string) {
|
||||
return this.isDeclared(key) && !('value' in this.cache[key]);
|
||||
}
|
||||
|
||||
isOverridden(key: string) {
|
||||
return this.isDeclared(key) && Boolean(this.cache[key].isOverridden);
|
||||
}
|
||||
|
||||
getUpdate$() {
|
||||
return this.update$.asObservable();
|
||||
}
|
||||
|
||||
getUpdateErrors$() {
|
||||
return this.updateErrors$.asObservable();
|
||||
}
|
||||
|
||||
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): Promise<boolean> {
|
||||
async update(key: string, newVal: any): Promise<boolean> {
|
||||
this.assertUpdateAllowed(key);
|
||||
|
||||
const declared = this.isDeclared(key);
|
||||
|
@ -156,27 +40,4 @@ You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just r
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { cloneDeep, defaultsDeep } from 'lodash';
|
||||
import { Observable, Subject, concat, defer, of } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
|
||||
import { UserProvidedValues, PublicUiSettingsParams } from '@kbn/core-ui-settings-common';
|
||||
import { IUiSettingsClient, UiSettingsState } from '@kbn/core-ui-settings-browser';
|
||||
|
||||
import { UiSettingsApi } from './ui_settings_api';
|
||||
|
||||
export interface UiSettingsClientParams {
|
||||
api: UiSettingsApi;
|
||||
defaults: Record<string, PublicUiSettingsParams>;
|
||||
initialSettings?: UiSettingsState;
|
||||
done$: Observable<unknown>;
|
||||
}
|
||||
|
||||
export abstract class UiSettingsClientCommon implements IUiSettingsClient {
|
||||
protected readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>();
|
||||
protected readonly updateErrors$ = new Subject<Error>();
|
||||
|
||||
protected readonly api: UiSettingsApi;
|
||||
protected readonly defaults: Record<string, PublicUiSettingsParams>;
|
||||
protected cache: Record<string, PublicUiSettingsParams & UserProvidedValues>;
|
||||
|
||||
constructor(params: UiSettingsClientParams) {
|
||||
this.api = params.api;
|
||||
this.defaults = cloneDeep(params.defaults);
|
||||
this.cache = defaultsDeep({}, this.defaults, cloneDeep(params.initialSettings));
|
||||
|
||||
params.done$.subscribe({
|
||||
complete: () => {
|
||||
this.update$.complete();
|
||||
this.updateErrors$.complete();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return cloneDeep(this.cache);
|
||||
}
|
||||
|
||||
get<T = any>(key: string, defaultOverride?: T) {
|
||||
const declared = this.isDeclared(key);
|
||||
if (!declared && defaultOverride !== undefined) {
|
||||
return defaultOverride;
|
||||
}
|
||||
|
||||
if (!declared) {
|
||||
throw new Error(
|
||||
`Unexpected \`IUiSettingsClient.get("${key}")\` call on unrecognized configuration setting "${key}".
|
||||
Setting an initial value via \`IUiSettingsClient.set("${key}", value)\` before attempting to retrieve
|
||||
any custom setting value for "${key}" may fix this issue.
|
||||
You can use \`IUiSettingsClient.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;
|
||||
}
|
||||
|
||||
get$<T = any>(key: string, defaultOverride?: T) {
|
||||
return concat(
|
||||
defer(() => of(this.get(key, defaultOverride))),
|
||||
this.update$.pipe(
|
||||
filter((update) => update.key === key),
|
||||
map(() => this.get(key, defaultOverride))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async set(key: string, value: any) {
|
||||
return await this.update(key, value);
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
return await this.update(key, null);
|
||||
}
|
||||
|
||||
isDeclared(key: string) {
|
||||
return key in this.cache;
|
||||
}
|
||||
|
||||
isDefault(key: string) {
|
||||
return !this.isDeclared(key) || this.cache[key].userValue == null;
|
||||
}
|
||||
|
||||
isCustom(key: string) {
|
||||
return this.isDeclared(key) && !('value' in this.cache[key]);
|
||||
}
|
||||
|
||||
isOverridden(key: string) {
|
||||
return this.isDeclared(key) && Boolean(this.cache[key].isOverridden);
|
||||
}
|
||||
|
||||
getUpdate$() {
|
||||
return this.update$.asObservable();
|
||||
}
|
||||
|
||||
getUpdateErrors$() {
|
||||
return this.updateErrors$.asObservable();
|
||||
}
|
||||
|
||||
protected assertUpdateAllowed(key: string) {
|
||||
if (this.isOverridden(key)) {
|
||||
throw new Error(
|
||||
`Unable to update "${key}" because its value is overridden by the Kibana server`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract update(key: string, newVal: any): Promise<boolean>;
|
||||
|
||||
protected 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 });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Subject } from 'rxjs';
|
||||
import { take, toArray } from 'rxjs/operators';
|
||||
|
||||
import { UiSettingsGlobalClient } from './ui_settings_global_client';
|
||||
|
||||
let done$: Subject<unknown>;
|
||||
|
||||
function setup(options: { defaults?: any; initialSettings?: any } = {}) {
|
||||
const { defaults = { dateFormat: { value: 'Browser' } }, initialSettings = {} } = options;
|
||||
|
||||
const batchSetGlobal = jest.fn(() => ({
|
||||
settings: {},
|
||||
}));
|
||||
done$ = new Subject();
|
||||
const client = new UiSettingsGlobalClient({
|
||||
defaults,
|
||||
initialSettings,
|
||||
api: {
|
||||
batchSetGlobal,
|
||||
} as any,
|
||||
done$,
|
||||
});
|
||||
|
||||
return { client, batchSetGlobal };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
done$.complete();
|
||||
});
|
||||
|
||||
describe('#get$', () => {
|
||||
it('emits the default override if no value is set, or if the value is removed', async () => {
|
||||
const { client } = setup();
|
||||
|
||||
setTimeout(() => {
|
||||
client.set('dateFormat', 'new format');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
client.remove('dateFormat');
|
||||
}, 20);
|
||||
|
||||
const values = await client
|
||||
.get$('dateFormat', 'my default')
|
||||
.pipe(take(3), toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(values).toEqual(['my default', 'new format', 'my default']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#set', () => {
|
||||
it('resolves to false on failure', async () => {
|
||||
const { client, batchSetGlobal } = setup();
|
||||
|
||||
batchSetGlobal.mockImplementation(() => {
|
||||
throw new Error('Error in request');
|
||||
});
|
||||
|
||||
await expect(client.set('foo', 'bar')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#remove', () => {
|
||||
it('resolves to false on failure', async () => {
|
||||
const { client, batchSetGlobal } = setup();
|
||||
|
||||
batchSetGlobal.mockImplementation(() => {
|
||||
throw new Error('Error in request');
|
||||
});
|
||||
|
||||
await expect(client.remove('dateFormat')).resolves.toBe(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { defaultsDeep } from 'lodash';
|
||||
import { UiSettingsClientCommon, UiSettingsClientParams } from './ui_settings_client_common';
|
||||
|
||||
export class UiSettingsGlobalClient extends UiSettingsClientCommon {
|
||||
constructor(params: UiSettingsClientParams) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
async update(key: string, newVal: any): Promise<boolean> {
|
||||
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.batchSetGlobal(key, newVal);
|
||||
this.cache = defaultsDeep({}, defaults, settings);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.setLocally(key, initialVal);
|
||||
this.updateErrors$.next(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,8 +9,11 @@
|
|||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
|
||||
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
|
||||
import type { InternalUiSettingsRouter } from '../internal_types';
|
||||
import { CannotOverrideError } from '../ui_settings_errors';
|
||||
import { InternalUiSettingsRequestHandlerContext } from '../internal_types';
|
||||
|
||||
const validate = {
|
||||
params: schema.object({
|
||||
|
@ -19,33 +22,47 @@ const validate = {
|
|||
};
|
||||
|
||||
export function registerDeleteRoute(router: InternalUiSettingsRouter) {
|
||||
const deleteFromRequest = async (
|
||||
uiSettingsClient: IUiSettingsClient,
|
||||
context: InternalUiSettingsRequestHandlerContext,
|
||||
request: KibanaRequest<Readonly<{} & { key: string }>, unknown, unknown, 'delete'>,
|
||||
response: KibanaResponseFactory
|
||||
) => {
|
||||
try {
|
||||
await uiSettingsClient.remove(request.params.key);
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
settings: await uiSettingsClient.getUserProvided(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
|
||||
return response.customError({
|
||||
body: error,
|
||||
statusCode: error.output.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof CannotOverrideError) {
|
||||
return response.badRequest({ body: error });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
router.delete(
|
||||
{ path: '/api/kibana/settings/{key}', validate },
|
||||
async (context, request, response) => {
|
||||
try {
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
|
||||
await uiSettingsClient.remove(request.params.key);
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
settings: await uiSettingsClient.getUserProvided(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
|
||||
return response.customError({
|
||||
body: error,
|
||||
statusCode: error.output.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof CannotOverrideError) {
|
||||
return response.badRequest({ body: error });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
return await deleteFromRequest(uiSettingsClient, context, request, response);
|
||||
}
|
||||
);
|
||||
router.delete(
|
||||
{ path: '/api/kibana/global_settings/{key}', validate },
|
||||
async (context, request, response) => {
|
||||
const uiSettingsClient = (await context.core).uiSettings.globalClient;
|
||||
return await deleteFromRequest(uiSettingsClient, context, request, response);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,29 +7,47 @@
|
|||
*/
|
||||
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
|
||||
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
|
||||
import { InternalUiSettingsRequestHandlerContext } from '../internal_types';
|
||||
import type { InternalUiSettingsRouter } from '../internal_types';
|
||||
|
||||
export function registerGetRoute(router: InternalUiSettingsRouter) {
|
||||
const getFromRequest = async (
|
||||
uiSettingsClient: IUiSettingsClient,
|
||||
context: InternalUiSettingsRequestHandlerContext,
|
||||
request: KibanaRequest<unknown, unknown, unknown, 'get'>,
|
||||
response: KibanaResponseFactory
|
||||
) => {
|
||||
try {
|
||||
return response.ok({
|
||||
body: {
|
||||
settings: await uiSettingsClient.getUserProvided(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
|
||||
return response.customError({
|
||||
body: error,
|
||||
statusCode: error.output.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
router.get(
|
||||
{ path: '/api/kibana/settings', validate: false },
|
||||
async (context, request, response) => {
|
||||
try {
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
return response.ok({
|
||||
body: {
|
||||
settings: await uiSettingsClient.getUserProvided(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
|
||||
return response.customError({
|
||||
body: error,
|
||||
statusCode: error.output.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
return await getFromRequest(uiSettingsClient, context, request, response);
|
||||
}
|
||||
);
|
||||
router.get(
|
||||
{ path: '/api/kibana/global_settings', validate: false },
|
||||
async (context, request, response) => {
|
||||
const uiSettingsClient = (await context.core).uiSettings.globalClient;
|
||||
return await getFromRequest(uiSettingsClient, context, request, response);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,9 +7,13 @@
|
|||
*/
|
||||
|
||||
import { schema, ValidationError } from '@kbn/config-schema';
|
||||
|
||||
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import type { InternalUiSettingsRouter } from '../internal_types';
|
||||
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
|
||||
import type {
|
||||
InternalUiSettingsRequestHandlerContext,
|
||||
InternalUiSettingsRouter,
|
||||
} from '../internal_types';
|
||||
import { CannotOverrideError } from '../ui_settings_errors';
|
||||
|
||||
const validate = {
|
||||
|
@ -22,36 +26,55 @@ const validate = {
|
|||
};
|
||||
|
||||
export function registerSetRoute(router: InternalUiSettingsRouter) {
|
||||
const setFromRequest = async (
|
||||
uiSettingsClient: IUiSettingsClient,
|
||||
context: InternalUiSettingsRequestHandlerContext,
|
||||
request: KibanaRequest<
|
||||
Readonly<{} & { key: string }>,
|
||||
unknown,
|
||||
Readonly<{ value?: any } & {}>,
|
||||
'post'
|
||||
>,
|
||||
response: KibanaResponseFactory
|
||||
) => {
|
||||
try {
|
||||
const { key } = request.params;
|
||||
const { value } = request.body;
|
||||
|
||||
await uiSettingsClient.set(key, value);
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
settings: await uiSettingsClient.getUserProvided(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
|
||||
return response.customError({
|
||||
body: error,
|
||||
statusCode: error.output.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
|
||||
return response.badRequest({ body: error });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
router.post(
|
||||
{ path: '/api/kibana/settings/{key}', validate },
|
||||
async (context, request, response) => {
|
||||
try {
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
|
||||
const { key } = request.params;
|
||||
const { value } = request.body;
|
||||
|
||||
await uiSettingsClient.set(key, value);
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
settings: await uiSettingsClient.getUserProvided(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
|
||||
return response.customError({
|
||||
body: error,
|
||||
statusCode: error.output.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
|
||||
return response.badRequest({ body: error });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
return await setFromRequest(uiSettingsClient, context, request, response);
|
||||
}
|
||||
);
|
||||
router.post(
|
||||
{ path: '/api/kibana/global_settings/{key}', validate },
|
||||
async (context, request, response) => {
|
||||
const uiSettingsClient = (await context.core).uiSettings.globalClient;
|
||||
return await setFromRequest(uiSettingsClient, context, request, response);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,10 +7,12 @@
|
|||
*/
|
||||
|
||||
import { schema, ValidationError } from '@kbn/config-schema';
|
||||
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
|
||||
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
|
||||
import type { InternalUiSettingsRouter } from '../internal_types';
|
||||
import { CannotOverrideError } from '../ui_settings_errors';
|
||||
import { InternalUiSettingsRequestHandlerContext } from '../internal_types';
|
||||
|
||||
const validate = {
|
||||
body: schema.object({
|
||||
|
@ -19,10 +21,13 @@ const validate = {
|
|||
};
|
||||
|
||||
export function registerSetManyRoute(router: InternalUiSettingsRouter) {
|
||||
router.post({ path: '/api/kibana/settings', validate }, async (context, request, response) => {
|
||||
const setManyFromRequest = async (
|
||||
uiSettingsClient: IUiSettingsClient,
|
||||
context: InternalUiSettingsRequestHandlerContext,
|
||||
request: KibanaRequest<unknown, unknown, Readonly<{} & { changes?: any & {} }>, 'post'>,
|
||||
response: KibanaResponseFactory
|
||||
) => {
|
||||
try {
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
|
||||
const { changes } = request.body;
|
||||
|
||||
await uiSettingsClient.setMany(changes);
|
||||
|
@ -46,5 +51,17 @@ export function registerSetManyRoute(router: InternalUiSettingsRouter) {
|
|||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
router.post({ path: '/api/kibana/settings', validate }, async (context, request, response) => {
|
||||
const uiSettingsClient = (await context.core).uiSettings.client;
|
||||
return await setManyFromRequest(uiSettingsClient, context, request, response);
|
||||
});
|
||||
|
||||
router.post(
|
||||
{ path: '/api/kibana/global_settings', validate },
|
||||
async (context, request, response) => {
|
||||
const uiSettingsClient = (await context.core).uiSettings.globalClient;
|
||||
return await setManyFromRequest(uiSettingsClient, context, request, response);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ export const uiSettingsGlobalType: SavedObjectsType = {
|
|||
};
|
||||
},
|
||||
getTitle(obj) {
|
||||
return `Global Setting [${obj.id}]`;
|
||||
return `Global Settings [${obj.id}]`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -11,8 +11,8 @@ import type {
|
|||
UiSettingsServiceSetup,
|
||||
UiSettingsServiceStart,
|
||||
} from '@kbn/core-ui-settings-server';
|
||||
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import { UiSettingsParams } from '@kbn/core-ui-settings-common';
|
||||
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import type { UiSettingsParams } from '@kbn/core-ui-settings-common';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
|
||||
/** @internal */
|
||||
|
|
|
@ -19,6 +19,7 @@ import type { InternalUiSettingsServiceStart } from './types';
|
|||
*/
|
||||
export class CoreUiSettingsRouteHandlerContext implements UiSettingsRequestHandlerContext {
|
||||
#client?: IUiSettingsClient;
|
||||
#globalClient?: IUiSettingsClient;
|
||||
|
||||
constructor(
|
||||
private readonly uiSettingsStart: InternalUiSettingsServiceStart,
|
||||
|
@ -33,4 +34,13 @@ export class CoreUiSettingsRouteHandlerContext implements UiSettingsRequestHandl
|
|||
}
|
||||
return this.#client;
|
||||
}
|
||||
|
||||
public get globalClient() {
|
||||
if (this.#globalClient == null) {
|
||||
this.#globalClient = this.uiSettingsStart.globalAsScopedToClient(
|
||||
this.savedObjectsRouterHandlerContext.client
|
||||
);
|
||||
}
|
||||
return this.#globalClient;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,11 @@ jest.doMock('./clients/ui_settings_client', () => ({
|
|||
UiSettingsClient: MockUiSettingsClientConstructor,
|
||||
}));
|
||||
|
||||
export const MockUiSettingsGlobalClientConstructor = jest.fn();
|
||||
jest.doMock('./clients/ui_settings_global_client', () => ({
|
||||
UiSettingsGlobalClient: MockUiSettingsGlobalClientConstructor,
|
||||
}));
|
||||
|
||||
export const MockUiSettingsDefaultsClientConstructor = jest.fn();
|
||||
jest.doMock('./clients/ui_settings_defaults_client', () => ({
|
||||
UiSettingsDefaultsClient: MockUiSettingsDefaultsClientConstructor,
|
||||
|
|
|
@ -13,6 +13,7 @@ import { mockCoreContext } from '@kbn/core-base-server-mocks';
|
|||
import { httpServiceMock } from '@kbn/core-http-server-mocks';
|
||||
import {
|
||||
MockUiSettingsClientConstructor,
|
||||
MockUiSettingsGlobalClientConstructor,
|
||||
MockUiSettingsDefaultsClientConstructor,
|
||||
getCoreSettingsMock,
|
||||
} from './ui_settings_service.test.mock';
|
||||
|
@ -53,6 +54,7 @@ describe('uiSettings', () => {
|
|||
|
||||
afterEach(() => {
|
||||
MockUiSettingsClientConstructor.mockClear();
|
||||
MockUiSettingsGlobalClientConstructor.mockClear();
|
||||
getCoreSettingsMock.mockClear();
|
||||
});
|
||||
|
||||
|
@ -95,6 +97,20 @@ describe('uiSettings', () => {
|
|||
`"uiSettings for the key [foo] has been already registered"`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if registers the same key twice to global settings', async () => {
|
||||
const setup = await service.setup(setupDeps);
|
||||
setup.registerGlobal(defaults);
|
||||
expect(() => setup.registerGlobal(defaults)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Global uiSettings for the key [foo] has been already registered"`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when registering a global and namespaced setting with the same name', async () => {
|
||||
const setup = await service.setup(setupDeps);
|
||||
setup.register(defaults);
|
||||
expect(() => setup.registerGlobal(defaults)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -118,6 +134,20 @@ describe('uiSettings', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('throws if validation schema is not provided for global settings', async () => {
|
||||
const { registerGlobal } = await service.setup(setupDeps);
|
||||
registerGlobal({
|
||||
// @ts-expect-error schema is required key
|
||||
custom: {
|
||||
value: 42,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.start()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: Validation schema is not provided for [custom] Global UI Setting]`
|
||||
);
|
||||
});
|
||||
|
||||
it('validates registered definitions', async () => {
|
||||
const { register } = await service.setup(setupDeps);
|
||||
register({
|
||||
|
@ -132,6 +162,20 @@ describe('uiSettings', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('validates registered definitions for global settings', async () => {
|
||||
const { registerGlobal } = await service.setup(setupDeps);
|
||||
registerGlobal({
|
||||
custom: {
|
||||
value: 42,
|
||||
schema: schema.string(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.start()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: expected value of type [string] but got [number]]`
|
||||
);
|
||||
});
|
||||
|
||||
it('validates overrides', async () => {
|
||||
const coreContext = mockCoreContext.create();
|
||||
coreContext.configService.atPath.mockReturnValueOnce(
|
||||
|
@ -201,5 +245,35 @@ describe('uiSettings', () => {
|
|||
expect(MockUiSettingsClientConstructor.mock.calls[0][0].defaults).not.toBe(defaults);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#asScopedToGlobalClient', () => {
|
||||
it('passes saved object type "config-global" to UiSettingsGlobalClient', async () => {
|
||||
await service.setup(setupDeps);
|
||||
const start = await service.start();
|
||||
start.globalAsScopedToClient(savedObjectsClient);
|
||||
|
||||
expect(MockUiSettingsGlobalClientConstructor).toBeCalledTimes(1);
|
||||
expect(MockUiSettingsGlobalClientConstructor.mock.calls[0][0].type).toBe('config-global');
|
||||
});
|
||||
|
||||
it('passes overrides to UiSettingsGlobalClient', async () => {
|
||||
await service.setup(setupDeps);
|
||||
const start = await service.start();
|
||||
start.globalAsScopedToClient(savedObjectsClient);
|
||||
|
||||
expect(MockUiSettingsGlobalClientConstructor).toBeCalledTimes(1);
|
||||
expect(MockUiSettingsGlobalClientConstructor.mock.calls[0][0].overrides).toEqual({});
|
||||
});
|
||||
|
||||
it('passes a copy of set defaults to UiSettingsGlobalClient', async () => {
|
||||
const setup = await service.setup(setupDeps);
|
||||
setup.register(defaults);
|
||||
const start = await service.start();
|
||||
start.globalAsScopedToClient(savedObjectsClient);
|
||||
|
||||
expect(MockUiSettingsGlobalClientConstructor).toBeCalledTimes(1);
|
||||
expect(MockUiSettingsGlobalClientConstructor.mock.calls[0][0].defaults).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,9 +14,9 @@ import type { CoreContext, CoreService } from '@kbn/core-base-server-internal';
|
|||
import type { InternalHttpServiceSetup } from '@kbn/core-http-server-internal';
|
||||
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import type { InternalSavedObjectsServiceSetup } from '@kbn/core-saved-objects-server-internal';
|
||||
import type { UiSettingsParams } from '@kbn/core-ui-settings-common';
|
||||
import type { UiSettingsParams, UiSettingsScope } from '@kbn/core-ui-settings-common';
|
||||
import { UiSettingsConfigType, uiSettingsConfig as uiConfigDefinition } from './ui_settings_config';
|
||||
import { UiSettingsClient } from './clients/ui_settings_client';
|
||||
import { UiSettingsClient, UiSettingsClientFactory, UiSettingsGlobalClient } from './clients';
|
||||
import type {
|
||||
InternalUiSettingsServicePreboot,
|
||||
InternalUiSettingsServiceSetup,
|
||||
|
@ -32,6 +32,11 @@ export interface SetupDeps {
|
|||
http: InternalHttpServiceSetup;
|
||||
savedObjects: InternalSavedObjectsServiceSetup;
|
||||
}
|
||||
type ClientType<T> = T extends 'global'
|
||||
? UiSettingsGlobalClient
|
||||
: T extends 'namespace'
|
||||
? UiSettingsClient
|
||||
: never;
|
||||
|
||||
/** @internal */
|
||||
export class UiSettingsService
|
||||
|
@ -41,6 +46,7 @@ export class UiSettingsService
|
|||
private readonly config$: Observable<UiSettingsConfigType>;
|
||||
private readonly isDist: boolean;
|
||||
private readonly uiSettingsDefaults = new Map<string, UiSettingsParams>();
|
||||
private readonly uiSettingsGlobalDefaults = new Map<string, UiSettingsParams>();
|
||||
private overrides: Record<string, any> = {};
|
||||
|
||||
constructor(private readonly coreContext: CoreContext) {
|
||||
|
@ -78,7 +84,8 @@ export class UiSettingsService
|
|||
this.overrides = config.overrides;
|
||||
|
||||
return {
|
||||
register: this.register.bind(this),
|
||||
register: this.register,
|
||||
registerGlobal: this.registerGlobal,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -87,36 +94,52 @@ export class UiSettingsService
|
|||
this.validatesOverrides();
|
||||
|
||||
return {
|
||||
asScopedToClient: this.getScopedClientFactory(),
|
||||
asScopedToClient: this.getScopedClientFactory('namespace'),
|
||||
globalAsScopedToClient: this.getScopedClientFactory('global'),
|
||||
};
|
||||
}
|
||||
|
||||
public async stop() {}
|
||||
|
||||
private getScopedClientFactory(): (
|
||||
savedObjectsClient: SavedObjectsClientContract
|
||||
) => UiSettingsClient {
|
||||
private getScopedClientFactory<T extends UiSettingsScope>(
|
||||
scope: UiSettingsScope
|
||||
): (savedObjectsClient: SavedObjectsClientContract) => ClientType<T> {
|
||||
const { version, buildNum } = this.coreContext.env.packageInfo;
|
||||
return (savedObjectsClient: SavedObjectsClientContract) =>
|
||||
new UiSettingsClient({
|
||||
type: 'config',
|
||||
return (savedObjectsClient: SavedObjectsClientContract): ClientType<T> => {
|
||||
const isNamespaceScope = scope === 'namespace';
|
||||
|
||||
const options = {
|
||||
type: (isNamespaceScope ? 'config' : 'config-global') as 'config' | 'config-global',
|
||||
id: version,
|
||||
buildNum,
|
||||
savedObjectsClient,
|
||||
defaults: mapToObject(this.uiSettingsDefaults),
|
||||
overrides: this.overrides,
|
||||
defaults: isNamespaceScope
|
||||
? mapToObject(this.uiSettingsDefaults)
|
||||
: mapToObject(this.uiSettingsGlobalDefaults),
|
||||
overrides: isNamespaceScope ? this.overrides : {},
|
||||
log: this.log,
|
||||
});
|
||||
};
|
||||
return UiSettingsClientFactory.create(options) as ClientType<T>;
|
||||
};
|
||||
}
|
||||
|
||||
private register(settings: Record<string, UiSettingsParams> = {}) {
|
||||
private register = (settings: Record<string, UiSettingsParams> = {}) => {
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
if (this.uiSettingsDefaults.has(key)) {
|
||||
throw new Error(`uiSettings for the key [${key}] has been already registered`);
|
||||
}
|
||||
this.uiSettingsDefaults.set(key, value);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private registerGlobal = (settings: Record<string, UiSettingsParams> = {}) => {
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
if (this.uiSettingsGlobalDefaults.has(key)) {
|
||||
throw new Error(`Global uiSettings for the key [${key}] has been already registered`);
|
||||
}
|
||||
this.uiSettingsGlobalDefaults.set(key, value);
|
||||
});
|
||||
};
|
||||
|
||||
private validatesDefinitions() {
|
||||
for (const [key, definition] of this.uiSettingsDefaults) {
|
||||
|
@ -125,6 +148,12 @@ export class UiSettingsService
|
|||
}
|
||||
definition.schema.validate(definition.value, {}, `ui settings defaults [${key}]`);
|
||||
}
|
||||
for (const [key, definition] of this.uiSettingsGlobalDefaults) {
|
||||
if (!definition.schema) {
|
||||
throw new Error(`Validation schema is not provided for [${key}] Global UI Setting`);
|
||||
}
|
||||
definition.schema.validate(definition.value, {});
|
||||
}
|
||||
}
|
||||
|
||||
private validatesOverrides() {
|
||||
|
|
|
@ -48,6 +48,7 @@ const createPrebootMock = () => {
|
|||
const createSetupMock = () => {
|
||||
const mocked: jest.Mocked<InternalUiSettingsServiceSetup> = {
|
||||
register: jest.fn(),
|
||||
registerGlobal: jest.fn(),
|
||||
};
|
||||
|
||||
return mocked;
|
||||
|
@ -56,6 +57,7 @@ const createSetupMock = () => {
|
|||
const createStartMock = () => {
|
||||
const mocked: jest.Mocked<InternalUiSettingsServiceStart> = {
|
||||
asScopedToClient: jest.fn(),
|
||||
globalAsScopedToClient: jest.fn(),
|
||||
};
|
||||
|
||||
mocked.asScopedToClient.mockReturnValue(createClientMock());
|
||||
|
|
|
@ -13,7 +13,7 @@ import type { IUiSettingsClient } from './ui_settings_client';
|
|||
/** @public */
|
||||
export interface UiSettingsServiceSetup {
|
||||
/**
|
||||
* Sets settings with default values for the uiSettings.
|
||||
* Sets settings with default values for the uiSettings
|
||||
* @param settings
|
||||
*
|
||||
* @example
|
||||
|
@ -30,6 +30,24 @@ export interface UiSettingsServiceSetup {
|
|||
* ```
|
||||
*/
|
||||
register(settings: Record<string, UiSettingsParams>): void;
|
||||
/**
|
||||
* Sets settings with default values for the global uiSettings
|
||||
* @param settings
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* setup(core: CoreSetup){
|
||||
* core.uiSettings.register([{
|
||||
* foo: {
|
||||
* name: i18n.translate('my foo settings'),
|
||||
* value: true,
|
||||
* description: 'add some awesomeness',
|
||||
* },
|
||||
* }]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
registerGlobal(settings: Record<string, UiSettingsParams>): void;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
@ -49,4 +67,20 @@ export interface UiSettingsServiceStart {
|
|||
* ```
|
||||
*/
|
||||
asScopedToClient(savedObjectsClient: SavedObjectsClientContract): IUiSettingsClient;
|
||||
|
||||
/**
|
||||
* Creates a global {@link IUiSettingsClient} with provided *scoped* saved objects client.
|
||||
*
|
||||
* This should only be used in the specific case where the client needs to be accessed
|
||||
* from outside of the scope of a {@link RequestHandler}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* start(core: CoreStart) {
|
||||
* const soClient = core.savedObjects.getScopedClient(arbitraryRequest);
|
||||
* const uiSettingsClient = core.uiSettings.asScopedToGlobalClient(soClient);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
globalAsScopedToClient(savedObjectsClient: SavedObjectsClientContract): IUiSettingsClient;
|
||||
}
|
||||
|
|
|
@ -14,4 +14,5 @@ import type { IUiSettingsClient } from './ui_settings_client';
|
|||
*/
|
||||
export interface UiSettingsRequestHandlerContext {
|
||||
client: IUiSettingsClient;
|
||||
globalClient: IUiSettingsClient;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ export const docExistsSuite = (savedObjectsIndex: string) => () => {
|
|||
async function setup(options: { initialSettings?: Record<string, any> } = {}) {
|
||||
const { initialSettings } = options;
|
||||
|
||||
const { uiSettings, esClient, supertest } = getServices();
|
||||
const { uiSettings, uiSettingsGlobal, esClient, supertest } = getServices();
|
||||
|
||||
// delete the kibana index to ensure we start fresh
|
||||
await esClient.deleteByQuery({
|
||||
|
@ -27,9 +27,10 @@ export const docExistsSuite = (savedObjectsIndex: string) => () => {
|
|||
|
||||
if (initialSettings) {
|
||||
await uiSettings.setMany(initialSettings);
|
||||
await uiSettingsGlobal.setMany(uiSettingsGlobal);
|
||||
}
|
||||
|
||||
return { uiSettings, supertest };
|
||||
return { uiSettings, uiSettingsGlobal, supertest };
|
||||
}
|
||||
|
||||
describe('get route', () => {
|
||||
|
@ -190,4 +191,168 @@ export const docExistsSuite = (savedObjectsIndex: string) => () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('global', () => {
|
||||
describe('get route', () => {
|
||||
it('returns a 200 and includes userValues', async () => {
|
||||
const defaultIndex = chance.word({ length: 10 });
|
||||
|
||||
const { supertest } = await setup({
|
||||
initialSettings: {
|
||||
defaultIndex,
|
||||
},
|
||||
});
|
||||
|
||||
const { body } = await supertest('get', '/api/kibana/global_settings').expect(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
settings: {
|
||||
buildNum: {
|
||||
userValue: expect.any(Number),
|
||||
},
|
||||
defaultIndex: {
|
||||
userValue: defaultIndex,
|
||||
},
|
||||
foo: {
|
||||
userValue: 'bar',
|
||||
isOverridden: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('set route', () => {
|
||||
it('returns a 200 and all values including update', async () => {
|
||||
const { supertest } = await setup();
|
||||
|
||||
const defaultIndex = chance.word();
|
||||
|
||||
const { body } = await supertest('post', '/api/kibana/global_settings/defaultIndex')
|
||||
.send({
|
||||
value: defaultIndex,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
settings: {
|
||||
buildNum: {
|
||||
userValue: expect.any(Number),
|
||||
},
|
||||
defaultIndex: {
|
||||
userValue: defaultIndex,
|
||||
},
|
||||
foo: {
|
||||
userValue: 'bar',
|
||||
isOverridden: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a 400 if trying to set overridden value', async () => {
|
||||
const { supertest } = await setup();
|
||||
|
||||
const { body } = await supertest('delete', '/api/kibana/global_settings/foo')
|
||||
.send({
|
||||
value: 'baz',
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(body).toEqual({
|
||||
error: 'Bad Request',
|
||||
message: 'Unable to update "foo" because it is overridden',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMany route', () => {
|
||||
it('returns a 200 and all values including updates', async () => {
|
||||
const { supertest } = await setup();
|
||||
|
||||
const defaultIndex = chance.word();
|
||||
const { body } = await supertest('post', '/api/kibana/global_settings')
|
||||
.send({
|
||||
changes: {
|
||||
defaultIndex,
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
settings: {
|
||||
buildNum: {
|
||||
userValue: expect.any(Number),
|
||||
},
|
||||
defaultIndex: {
|
||||
userValue: defaultIndex,
|
||||
},
|
||||
foo: {
|
||||
userValue: 'bar',
|
||||
isOverridden: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a 400 if trying to set overridden value', async () => {
|
||||
const { supertest } = await setup();
|
||||
|
||||
const { body } = await supertest('post', '/api/kibana/global_settings')
|
||||
.send({
|
||||
changes: {
|
||||
foo: 'baz',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(body).toEqual({
|
||||
error: 'Bad Request',
|
||||
message: 'Unable to update "foo" because it is overridden',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete route', () => {
|
||||
it('returns a 200 and deletes the setting', async () => {
|
||||
const defaultIndex = chance.word({ length: 10 });
|
||||
|
||||
const { uiSettingsGlobal, supertest } = await setup({
|
||||
initialSettings: { defaultIndex },
|
||||
});
|
||||
|
||||
expect(await uiSettingsGlobal.get('defaultIndex')).toBe(defaultIndex);
|
||||
|
||||
const { body } = await supertest(
|
||||
'delete',
|
||||
'/api/kibana/global_settings/defaultIndex'
|
||||
).expect(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
settings: {
|
||||
buildNum: {
|
||||
userValue: expect.any(Number),
|
||||
},
|
||||
foo: {
|
||||
userValue: 'bar',
|
||||
isOverridden: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('returns a 400 if deleting overridden value', async () => {
|
||||
const { supertest } = await setup();
|
||||
|
||||
const { body } = await supertest('delete', '/api/kibana/global_settings/foo').expect(400);
|
||||
|
||||
expect(body).toEqual({
|
||||
error: 'Bad Request',
|
||||
message: 'Unable to update "foo" because it is overridden',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@ interface AllServices {
|
|||
savedObjectsClient: SavedObjectsClientContract;
|
||||
esClient: Client;
|
||||
uiSettings: IUiSettingsClient;
|
||||
uiSettingsGlobal: IUiSettingsClient;
|
||||
supertest: (method: HttpMethod, path: string) => supertest.Test;
|
||||
}
|
||||
|
||||
|
@ -62,12 +63,14 @@ export function getServices() {
|
|||
);
|
||||
|
||||
const uiSettings = kbn.coreStart.uiSettings.asScopedToClient(savedObjectsClient);
|
||||
const uiSettingsGlobal = kbn.coreStart.uiSettings.globalAsScopedToClient(savedObjectsClient);
|
||||
|
||||
services = {
|
||||
supertest: (method: HttpMethod, path: string) => getSupertest(kbn.root, method, path),
|
||||
esClient,
|
||||
savedObjectsClient,
|
||||
uiSettings,
|
||||
uiSettingsGlobal,
|
||||
};
|
||||
|
||||
return services;
|
||||
|
|
|
@ -55,5 +55,32 @@ describe('ui settings service', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global', () => {
|
||||
describe('set', () => {
|
||||
it('validates value', async () => {
|
||||
const response = await request
|
||||
.post(root, '/api/kibana/global_settings/custom')
|
||||
.send({ value: 100 })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toBe(
|
||||
'[validation [custom]]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('set many', () => {
|
||||
it('validates value', async () => {
|
||||
const response = await request
|
||||
.post(root, '/api/kibana/global_settings')
|
||||
.send({ changes: { custom: 100, foo: 'bar' } })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toBe(
|
||||
'[validation [custom]]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -124,6 +124,7 @@ function createCoreRequestHandlerContextMock() {
|
|||
},
|
||||
uiSettings: {
|
||||
client: uiSettingsServiceMock.createClient(),
|
||||
globalClient: uiSettingsServiceMock.createClient(),
|
||||
},
|
||||
deprecations: {
|
||||
client: deprecationsServiceMock.createClient(),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue