[6.x] [ui/config] extract core config logic into vanilla JS UiSettingsClient (#17169) (#17534)

* [ui/config] extract core config logic into vanilla JS UiSettingsClient

* [ui/config] stub the uiSettings individually for each test

* [ui/config] ensure that change events are emitted sync

* [uiSettings/batchSet] send request immediately, buffer when needed

Rather than buffering all writes and waiting 200ms before sending config
request to the uiSettings API, send updates as soon as they are received
but buffer updates that are received while another request is in
progress. This eliminates the 200ms delay and ensures that the server
receives requests from a single user in the correct order in the
unlikely event that many calls to `config.set()` are made in a very
short period of time.
This commit is contained in:
Spencer 2018-04-03 20:34:31 -07:00 committed by GitHub
parent 82f915b885
commit ca2aef936e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1017 additions and 300 deletions

View file

@ -0,0 +1,15 @@
import { metadata } from 'ui/metadata';
import { Notifier } from 'ui/notify';
import { UiSettingsClient } from '../../../ui_settings/public/ui_settings_client';
export function initUiSettingsApi(chrome) {
const uiSettings = new UiSettingsClient({
defaults: metadata.uiSettings.defaults,
initialSettings: metadata.uiSettings.user,
notify: new Notifier({ location: 'Config' })
});
chrome.getUiSettingsClient = function () {
return uiSettings;
};
}

View file

@ -22,6 +22,7 @@ import templateApi from './api/template';
import themeApi from './api/theme';
import translationsApi from './api/translations';
import { initChromeXsrfApi } from './api/xsrf';
import { initUiSettingsApi } from './api/ui_settings';
export const chrome = {};
const internals = _.defaults(
@ -39,6 +40,7 @@ const internals = _.defaults(
}
);
initUiSettingsApi(chrome);
appsApi(chrome, internals);
initChromeXsrfApi(chrome, internals);
initChromeNavApi(chrome, internals);

View file

@ -1,120 +1,182 @@
import expect from 'expect.js';
import sinon from 'sinon';
import ngMock from 'ng_mock';
import chrome from 'ui/chrome';
import { Notifier } from 'ui/notify';
describe('config component', function () {
describe('Config service', () => {
let config;
let $scope;
let uiSettings;
let $q;
let $rootScope;
beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject(function ($injector) {
beforeEach(ngMock.inject(($injector) => {
config = $injector.get('config');
$scope = $injector.get('$rootScope');
uiSettings = chrome.getUiSettingsClient();
$q = $injector.get('$q');
$rootScope = $injector.get('$rootScope');
}));
describe('#get', function () {
it('gives access to config values', function () {
expect(config.get('dateFormat')).to.be.a('string');
});
it('supports the default value overload', function () {
// default values are consumed and returned atomically
expect(config.get('obscureProperty1', 'default')).to.be('default');
});
it('after a get for an unknown property, the property is not persisted', function () {
const throwaway = config.get('obscureProperty2', 'default'); //eslint-disable-line no-unused-vars
// after a get, default values are NOT persisted
expect(config.get).withArgs('obscureProperty2').to.throwException();
});
it('honors the default parameter for unset options that are exported', () => {
// if you are hitting this error, then a test is setting this config value globally and not unsetting it!
expect(config.isDefault('dateFormat')).to.be(true);
const defaultDateFormat = config.get('dateFormat');
expect(config.get('dateFormat', 'xyz')).to.be('xyz');
// shouldn't change other usages
expect(config.get('dateFormat')).to.be(defaultDateFormat);
expect(config.get('dataFormat', defaultDateFormat)).to.be(defaultDateFormat);
});
it('throws on unknown properties that don\'t have a value yet.', function () {
const msg = 'Unexpected `config.get("throwableProperty")` call on unrecognized configuration setting';
expect(config.get).withArgs('throwableProperty').to.throwException(msg);
describe('#getAll', () => {
it('calls uiSettings.getAll()', () => {
sinon.stub(uiSettings, 'getAll');
config.getAll();
sinon.assert.calledOnce(uiSettings.getAll);
sinon.assert.calledWithExactly(uiSettings.getAll);
});
});
describe('#set', function () {
it('stores a value in the config val set', function () {
const original = config.get('dateFormat');
config.set('dateFormat', 'notaformat');
expect(config.get('dateFormat')).to.be('notaformat');
config.set('dateFormat', original);
});
it('stores a value in a previously unknown config key', function () {
expect(config.set).withArgs('unrecognizedProperty', 'somevalue').to.not.throwException();
expect(config.get('unrecognizedProperty')).to.be('somevalue');
describe('#get', () => {
it('calls uiSettings.get(key, default)', () => {
sinon.stub(uiSettings, 'get');
config.get('key', 'default');
sinon.assert.calledOnce(uiSettings.get);
sinon.assert.calledWithExactly(uiSettings.get, 'key', 'default');
});
});
describe('#$bind', function () {
it('binds a config key to a $scope property', function () {
const dateFormat = config.get('dateFormat');
config.bindToScope($scope, 'dateFormat');
expect($scope).to.have.property('dateFormat', dateFormat);
describe('#isDeclared', () => {
it('calls uiSettings.isDeclared(key)', () => {
sinon.stub(uiSettings, 'isDeclared');
config.isDeclared('key');
sinon.assert.calledOnce(uiSettings.isDeclared);
sinon.assert.calledWithExactly(uiSettings.isDeclared, 'key');
});
it('allows overriding the property name', function () {
const dateFormat = config.get('dateFormat');
config.bindToScope($scope, 'dateFormat', 'defaultDateFormat');
expect($scope).to.not.have.property('dateFormat');
expect($scope).to.have.property('defaultDateFormat', dateFormat);
});
it('keeps the property up to date', function () {
const original = config.get('dateFormat');
const newDateFormat = original + ' NEW NEW NEW!';
config.bindToScope($scope, 'dateFormat');
expect($scope).to.have.property('dateFormat', original);
config.set('dateFormat', newDateFormat);
expect($scope).to.have.property('dateFormat', newDateFormat);
config.set('dateFormat', original);
});
});
describe('#_change', () => {
it('returns true for success', async () => {
// immediately resolve to avoid timing issues
const delayedUpdate = () => Promise.resolve();
expect(await config._change('expect_true', 'value', { _delayedUpdate: delayedUpdate })).to.be(true);
// setting to the same should set it to true as well
expect(await config._change('expect_true', 'value')).to.be(true);
config.remove('expect_true');
describe('#isDefault', () => {
it('calls uiSettings.isDefault(key)', () => {
sinon.stub(uiSettings, 'isDefault');
config.isDefault('key');
sinon.assert.calledOnce(uiSettings.isDefault);
sinon.assert.calledWithExactly(uiSettings.isDefault, 'key');
});
it('returns false for failure', async () => {
const message = 'TEST - _change - EXPECTED';
// immediately resolve to avoid timing issues
const delayedUpdate = () => Promise.reject(new Error(message));
expect(await config._change('expected_false', 'value', { _delayedUpdate: delayedUpdate })).to.be(false);
// cleanup the notification so that the test harness does not complain
const notif = Notifier.prototype._notifs.find(notif => notif.content.indexOf(message) !== -1);
notif.clear();
});
});
describe('#isCustom', () => {
it('calls uiSettings.isCustom(key)', () => {
sinon.stub(uiSettings, 'isCustom');
config.isCustom('key');
sinon.assert.calledOnce(uiSettings.isCustom);
sinon.assert.calledWithExactly(uiSettings.isCustom, 'key');
});
});
describe('#remove', () => {
it('calls uiSettings.remove(key)', () => {
sinon.stub(uiSettings, 'remove');
config.remove('foobar');
sinon.assert.calledOnce(uiSettings.remove);
sinon.assert.calledWithExactly(uiSettings.remove, 'foobar');
});
it('returns an angular promise', () => {
const promise = config.remove('dateFormat:tz');
expect(promise).to.be.a($q);
});
});
describe('#set', () => {
it('returns an angular promise', () => {
const promise = config.set('dateFormat:tz', 'foo');
expect(promise).to.be.a($q);
});
it('strips $$-prefixed properties from plain objects', () => {
config.set('dateFormat:scaled', {
foo: 'bar',
$$bax: 'box'
});
expect(config.get('dateFormat:scaled')).to.eql({
foo: 'bar'
});
});
});
describe('$scope events', () => {
it('synchronously emits change:config on $rootScope when config changes', () => {
const stub = sinon.stub();
$rootScope.$on('change:config', stub);
config.set('foobar', 'baz');
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
it('synchronously emits change:config.${key} on $rootScope when config changes', () => {
const stub = sinon.stub();
$rootScope.$on('change:config.foobar', stub);
config.set('foobar', 'baz');
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
it('synchronously emits change:config on child scope when config changes', () => {
const stub = sinon.stub();
const $parent = $rootScope.$new(false);
const $scope = $rootScope.$new(false, $parent);
$scope.$on('change:config', stub);
config.set('foobar', 'baz');
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
it('synchronously emits change:config.${key} on child scope when config changes', () => {
const stub = sinon.stub();
const $parent = $rootScope.$new(false);
const $scope = $rootScope.$new(false, $parent);
$scope.$on('change:config.foobar', stub);
config.set('foobar', 'baz');
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
it('synchronously emits change:config on isolate scope when config changes', () => {
const stub = sinon.stub();
const $scope = $rootScope.$new(true);
$scope.$on('change:config', stub);
config.set('foobar', 'baz');
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
it('synchronously emits change:config.${key} on isolate scope when config changes', () => {
const stub = sinon.stub();
const $scope = $rootScope.$new(true);
$scope.$on('change:config.foobar', stub);
config.set('foobar', 'baz');
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
it('synchronously emits events when changes are inside a digest cycle', async () => {
const stub = sinon.stub();
$rootScope.$apply(() => {
$rootScope.$on('change:config.foobar', stub);
config.set('foobar', 'baz');
});
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
it('synchronously emits events when changes are outside a digest cycle', async () => {
const stub = sinon.stub();
await new Promise((resolve) => {
setTimeout(function () {
const off = $rootScope.$on('change:config.foobar', stub);
config.set('foobar', 'baz');
// we unlisten to make sure that stub is not called before our assetions below
off();
resolve();
}, 0);
});
sinon.assert.calledOnce(stub);
sinon.assert.calledWithExactly(stub, sinon.match({}), 'baz', undefined, 'foobar', config);
});
});
});

View file

@ -1,68 +0,0 @@
export function ConfigDelayedUpdaterProvider($http, chrome, Promise) {
let unsavedChanges = {};
let unresolvedPromises = [];
let saveTimeout = null;
return function delayedUpdate(key, value) {
unsavedChanges[key] = value;
return new Promise(saveSoon)
.then(res => res.data.settings);
};
function saveSoon(resolve, reject) {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
saveTimeout = setTimeout(fire, 200);
unresolvedPromises.push({ resolve, reject });
}
function fire() {
const changes = unsavedChanges;
const promises = unresolvedPromises;
unresolvedPromises = [];
unsavedChanges = {};
persist(changes)
.then(result => settle(promises, `resolve`, result))
.catch(reason => settle(promises, `reject`, reason));
}
function settle(listeners, decision, data) {
listeners.forEach(listener => listener[decision](data));
}
function persist(changes) {
const keys = Object.keys(changes);
if (keys.length === 1) {
const [key] = keys;
const value = changes[key];
const update = value === null ? remove : edit;
return update(key, value);
}
return editMany(changes);
}
function remove(key) {
return sync(`delete`, { postfix: `/${key}` });
}
function edit(key, value) {
return sync(`post`, { postfix: `/${key}`, data: { value } });
}
function editMany(changes) {
return sync(`post`, { data: { changes } });
}
function sync(method, { postfix = '', data } = {}) {
return $http({
method,
url: chrome.addBasePath(`/api/kibana/settings${postfix}`),
data
});
}
}

View file

@ -1,27 +1,86 @@
import angular from 'angular';
import { cloneDeep, defaultsDeep, isPlainObject } from 'lodash';
import chrome from 'ui/chrome';
import { isPlainObject } from 'lodash';
import { uiModules } from 'ui/modules';
import { Notifier } from 'ui/notify';
import { ConfigDelayedUpdaterProvider } from 'ui/config/_delayed_updater';
const module = uiModules.get('kibana/config');
// service for delivering config variables to everywhere else
module.service(`config`, function (Private, $rootScope, chrome, uiSettings) {
const config = this;
const notify = new Notifier({ location: `Config` });
const { defaults, user: initialUserSettings } = uiSettings;
const delayedUpdate = Private(ConfigDelayedUpdaterProvider);
let settings = mergeSettings(defaults, initialUserSettings);
/**
* Angular tie-in to UiSettingsClient, which is implemented in vanilla JS. Designed
* to expose the exact same API as the config service that has existed since forever.
* @name config
*/
module.service(`config`, function ($rootScope, Promise) {
const uiSettings = chrome.getUiSettingsClient();
config.getAll = () => cloneDeep(settings);
config.get = (key, defaultValue) => getCurrentValue(key, defaultValue);
config.set = (key, val) => config._change(key, isPlainObject(val) ? angular.toJson(val) : val);
config.remove = key => config._change(key, null);
config.isDeclared = key => key in settings;
config.isDefault = key => !config.isDeclared(key) || nullOrEmpty(settings[key].userValue);
config.isCustom = key => config.isDeclared(key) && !('value' in settings[key]);
config.watchAll = (fn, scope) => watchAll(scope, fn);
config.watch = (key, fn, scope) => watch(key, scope, fn);
// direct bind sync methods
this.getAll = (...args) => uiSettings.getAll(...args);
this.get = (...args) => uiSettings.get(...args);
this.isDeclared = (...args) => uiSettings.isDeclared(...args);
this.isDefault = (...args) => uiSettings.isDefault(...args);
this.isCustom = (...args) => uiSettings.isCustom(...args);
// modify remove() to use angular Promises
this.remove = (key) => (
Promise.resolve(uiSettings.remove(key))
);
// modify set() to use angular Promises and angular.toJson()
this.set = (key, value) => (
Promise.resolve(uiSettings.set(
key,
isPlainObject(value)
? angular.toJson(value)
: value
))
);
//////////////////////////////
//* angular specific methods *
//////////////////////////////
const subscription = uiSettings.subscribe(({ key, newValue, oldValue }) => {
const emit = () => {
$rootScope.$broadcast('change:config', newValue, oldValue, key, this);
$rootScope.$broadcast(`change:config.${key}`, newValue, oldValue, key, this);
};
// this is terrible, but necessary to emulate the same API
// that the `config` service had before where changes were
// emitted to scopes synchronously. All methods that don't
// require knowing if we are currently in a digest cycle are
// async and would deliver events too late for several usecases
//
// If you copy this code elsewhere you better have a good reason :)
$rootScope.$$phase ? emit() : $rootScope.$apply(emit);
});
$rootScope.$on('$destroy', () => subscription.unsubscribe());
this.watchAll = function (handler, scope = $rootScope) {
// call handler immediately to initialize
handler(null, null, null, this);
return scope.$on('change:config', (event, ...args) => {
handler(...args);
});
};
this.watch = function (key, handler, scope = $rootScope) {
if (!this.isDeclared(key)) {
throw new Error(`Unexpected \`config.watch("${key}", fn)\` call on unrecognized configuration setting "${key}".
Setting an initial value via \`config.set("${key}", value)\` before binding
any custom setting configuration watchers for "${key}" may fix this issue.`);
}
// call handler immediately with current value
handler(this.get(key), null, key, uiSettings);
// call handler again on each change for this key
return scope.$on(`change:config.${key}`, (event, ...args) => {
handler(...args);
});
};
/**
* A little helper for binding config variables to $scopes
@ -32,120 +91,11 @@ module.service(`config`, function (Private, $rootScope, chrome, uiSettings) {
* be stored. Defaults to the config key
* @return {function} - an unbind function
*/
config.bindToScope = function (scope, key, property = key) {
return watch(key, scope, update);
function update(newVal) {
this.bindToScope = function (scope, key, property = key) {
const onUpdate = (newVal) => {
scope[property] = newVal;
}
};
return this.watch(key, onUpdate, scope);
};
function watch(key, scope = $rootScope, fn) {
if (!config.isDeclared(key)) {
throw new Error(`Unexpected \`config.watch("${key}", fn)\` call on unrecognized configuration setting "${key}".
Setting an initial value via \`config.set("${key}", value)\` before binding
any custom setting configuration watchers for "${key}" may fix this issue.`);
}
const newVal = config.get(key);
const update = (e, ...args) => fn(...args);
fn(newVal, null, key, config);
return scope.$on(`change:config.${key}`, update);
}
function watchAll(scope = $rootScope, fn) {
const update = (e, ...args) => fn(...args);
fn(null, null, null, config);
return scope.$on(`change:config`, update);
}
config._change = (key, value, { _delayedUpdate = delayedUpdate } = { }) => {
const declared = config.isDeclared(key);
const oldVal = declared ? settings[key].userValue : undefined;
const newVal = key in defaults && defaults[key].defaultValue === value ? null : value;
const unchanged = oldVal === newVal;
if (unchanged) {
return Promise.resolve(true);
}
const initialVal = declared ? config.get(key) : undefined;
localUpdate(key, newVal, initialVal);
return _delayedUpdate(key, newVal)
.then(updatedSettings => {
settings = mergeSettings(defaults, updatedSettings);
return true;
})
.catch(reason => {
localUpdate(key, initialVal, config.get(key));
notify.error(reason);
return false;
});
};
function localUpdate(key, newVal, oldVal) {
patch(key, newVal);
advertise(key, oldVal);
}
function patch(key, value) {
if (!config.isDeclared(key)) {
settings[key] = {};
}
if (value === null) {
delete settings[key].userValue;
} else {
const { type } = settings[key];
if (type === 'json' && typeof value !== 'string') {
settings[key].userValue = angular.toJson(value);
} else {
settings[key].userValue = value;
}
}
}
function advertise(key, oldVal) {
const newVal = config.get(key);
notify.log(`config change: ${key}: ${oldVal} -> ${newVal}`);
$rootScope.$broadcast(`change:config.${key}`, newVal, oldVal, key, config);
$rootScope.$broadcast(`change:config`, newVal, oldVal, key, config);
}
function nullOrEmpty(value) {
return value === undefined || value === null;
}
function getCurrentValue(key, defaultValueForGetter) {
if (!config.isDeclared(key)) {
if (defaultValueForGetter === undefined) {
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.`);
}
// the key is not a declared setting
// pass through the caller's desired default value
// without persisting anything in the config document
return defaultValueForGetter;
}
const { userValue, value: defaultValue, type } = settings[key];
let currentValue;
if (config.isDefault(key)) {
// honor the second parameter if it was passed
currentValue = defaultValueForGetter === undefined ? defaultValue : defaultValueForGetter;
} else {
currentValue = userValue;
}
if (type === 'json') {
return JSON.parse(currentValue);
} else if (type === 'number') {
return parseFloat(currentValue);
}
return currentValue;
}
});
function mergeSettings(extended, defaults) {
return defaultsDeep(extended, defaults);
}

View file

@ -4,6 +4,8 @@ import chrome from 'ui/chrome';
import { parse as parseUrl } from 'url';
import sinon from 'sinon';
import { Notifier } from 'ui/notify';
import { metadata } from 'ui/metadata';
import { UiSettingsClient } from '../../ui_settings/public/ui_settings_client';
import './test_harness.less';
import 'ng_mock';
@ -23,12 +25,30 @@ setupTestSharding();
// allows test_harness.less to have higher priority selectors
document.body.setAttribute('id', 'test-harness-body');
// prevent accidental ajax requests
before(() => {
// prevent accidental ajax requests
sinon.useFakeXMLHttpRequest();
});
beforeEach(function () {
// stub the UiSettingsClient
if (chrome.getUiSettingsClient.restore) {
chrome.getUiSettingsClient.restore();
}
const stubUiSettings = new UiSettingsClient({
defaults: metadata.uiSettings.defaults,
initialSettings: {},
notify: new Notifier({ location: 'Config' }),
api: {
batchSet() {
return { settings: stubUiSettings.getAll() };
}
}
});
sinon.stub(chrome, 'getUiSettingsClient', () => stubUiSettings);
// ensure that notifications are not left in the notifiers
if (Notifier.prototype._notifs.length) {
const notifs = JSON.stringify(Notifier.prototype._notifs);
Notifier.prototype._notifs.length = 0;

View file

@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#batchSet Buffers are always clear of previously buffered changes: buffered bar=foo 1`] = `
Array [
Object {
"body": Object {
"changes": Object {
"bar": "foo",
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
]
`;
exports[`#batchSet Buffers are always clear of previously buffered changes: buffered baz=box 1`] = `
Array [
Object {
"body": Object {
"changes": Object {
"baz": "box",
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
]
`;
exports[`#batchSet Buffers are always clear of previously buffered changes: unbuffered foo=bar 1`] = `
Array [
Array [
Object {
"body": Object {
"changes": Object {
"foo": "bar",
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
],
]
`;
exports[`#batchSet Overwrites previously buffered values with new values for the same key: buffered bar=null 1`] = `
Array [
Array [
Object {
"body": Object {
"changes": Object {
"bar": null,
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
],
]
`;
exports[`#batchSet Overwrites previously buffered values with new values for the same key: unbuffered foo=bar 1`] = `
Array [
Array [
Object {
"body": Object {
"changes": Object {
"foo": "bar",
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
],
]
`;
exports[`#batchSet buffers changes while first request is in progress, sends buffered changes after first request completes: buffered foo=baz bar=bug 1`] = `
Array [
Array [
Object {
"body": Object {
"changes": Object {
"bar": "bug",
"foo": "baz",
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
],
]
`;
exports[`#batchSet buffers changes while first request is in progress, sends buffered changes after first request completes: unbuffered foo=bar 1`] = `
Array [
Array [
Object {
"body": Object {
"changes": Object {
"foo": "bar",
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
],
]
`;
exports[`#batchSet sends a single change immediately: unbuffered foo=bar 1`] = `
Array [
Array [
Object {
"body": Object {
"changes": Object {
"foo": "bar",
},
},
"method": "POST",
"path": "/api/kibana/settings",
},
],
]
`;

View file

@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#get after a get for an unknown property, the property is not persisted 1`] = `
"Unexpected \`config.get(\\"obscureProperty2\\")\` call on unrecognized configuration setting \\"obscureProperty2\\".
Setting an initial value via \`config.set(\\"obscureProperty2\\", value)\` before attempting to retrieve
any custom setting value for \\"obscureProperty2\\" may fix this issue.
You can use \`config.get(\\"obscureProperty2\\", defaultValue)\`, which will just return
\`defaultValue\` when the key is unrecognized."
`;
exports[`#get gives access to config values 1`] = `"Browser"`;
exports[`#get supports the default value overload 1`] = `"default"`;
exports[`#get throws on unknown properties that don't have a value yet. 1`] = `
"Unexpected \`config.get(\\"throwableProperty\\")\` call on unrecognized configuration setting \\"throwableProperty\\".
Setting an initial value via \`config.set(\\"throwableProperty\\", value)\` before attempting to retrieve
any custom setting value for \\"throwableProperty\\" may fix this issue.
You can use \`config.get(\\"throwableProperty\\", defaultValue)\`, which will just return
\`defaultValue\` when the key is unrecognized."
`;
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,
},
],
]
`;

View file

@ -0,0 +1,20 @@
import chrome from 'ui/chrome';
export async function sendRequest({ method, path, body }) {
const response = await fetch(chrome.addBasePath(path), {
method,
body: JSON.stringify(body),
headers: {
accept: 'application/json',
'content-type': 'application/json',
'kbn-xsrf': 'kibana',
},
credentials: 'same-origin'
});
if (response.status >= 300) {
throw new Error(`Request failed with status code: ${response.status}`);
}
return await response.json();
}

View file

@ -0,0 +1,67 @@
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();
});
}
};
}

View file

@ -0,0 +1,115 @@
import { createUiSettingsApi } from './ui_settings_api';
import { sendRequest } from './send_request';
jest.mock('./send_request', () => {
let resolve;
const sendRequest = jest.fn(() => new Promise((res) => {
resolve = res;
}));
return {
sendRequest,
resolveMockedSendRequest(value = {}) {
resolve(value);
},
async resolveMockedSendRequestAndWaitForNext(value = {}) {
const currentCallCount = sendRequest.mock.calls.length;
resolve(value);
const waitStart = Date.now();
while (sendRequest.mock.calls.length === currentCallCount) {
await new Promise(resolve => {
setImmediate(resolve);
});
if (Date.now() - waitStart > 10000) {
throw new Error('Waiting for subsequent call to sendRequest() timed out after 10 seconds');
}
}
},
};
});
beforeEach(() => {
sendRequest.mockRestore();
jest.clearAllMocks();
});
describe('#batchSet', () => {
it('sends a single change immediately', () => {
const uiSettingsApi = createUiSettingsApi();
const { sendRequest } = require('./send_request');
uiSettingsApi.batchSet('foo', 'bar');
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest.mock.calls).toMatchSnapshot('unbuffered foo=bar');
});
it('buffers changes while first request is in progress, sends buffered changes after first request completes', async () => {
const uiSettingsApi = createUiSettingsApi();
const { sendRequest, resolveMockedSendRequestAndWaitForNext } = require('./send_request');
uiSettingsApi.batchSet('foo', 'bar');
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest.mock.calls).toMatchSnapshot('unbuffered foo=bar');
sendRequest.mock.calls.length = 0;
uiSettingsApi.batchSet('foo', 'baz');
uiSettingsApi.batchSet('bar', 'bug');
expect(sendRequest).not.toHaveBeenCalled();
await resolveMockedSendRequestAndWaitForNext();
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest.mock.calls).toMatchSnapshot('buffered foo=baz bar=bug');
});
it('Overwrites previously buffered values with new values for the same key', async () => {
const uiSettingsApi = createUiSettingsApi();
const { sendRequest, resolveMockedSendRequestAndWaitForNext } = require('./send_request');
uiSettingsApi.batchSet('foo', 'bar');
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest.mock.calls).toMatchSnapshot('unbuffered foo=bar');
sendRequest.mock.calls.length = 0;
// if changes were sent to the API now they would be { bar: 'foo' }
uiSettingsApi.batchSet('bar', 'foo');
// these changes override the preivous one, we should now send { bar: null }
uiSettingsApi.batchSet('bar', null);
await resolveMockedSendRequestAndWaitForNext();
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest.mock.calls).toMatchSnapshot('buffered bar=null');
});
it('Buffers are always clear of previously buffered changes', async () => {
const uiSettingsApi = createUiSettingsApi();
const { sendRequest, resolveMockedSendRequestAndWaitForNext } = require('./send_request');
uiSettingsApi.batchSet('foo', 'bar');
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest.mock.calls).toMatchSnapshot('unbuffered foo=bar');
sendRequest.mock.calls.length = 0;
// buffer a change
uiSettingsApi.batchSet('bar', 'foo');
// flush the buffer and wait for next request to start
await resolveMockedSendRequestAndWaitForNext();
// buffer another change
uiSettingsApi.batchSet('baz', 'box');
// flush the buffer and wait for next request to start
await resolveMockedSendRequestAndWaitForNext();
expect(sendRequest).toHaveBeenCalledTimes(2);
expect(sendRequest.mock.calls[0]).toMatchSnapshot('buffered bar=foo');
expect(sendRequest.mock.calls[1]).toMatchSnapshot('buffered baz=box');
});
});

View file

@ -0,0 +1,148 @@
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]);
}
subscribe(observer) {
this._updateObservers.add(observer);
return {
unsubscribe: () => {
this._updateObservers.delete(observer);
}
};
}
async _update(key, value) {
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, initialVal);
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) {
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._notify.log(`config change: ${key}: ${oldValue} -> ${newValue}`);
for (const observer of this._updateObservers) {
observer({ key, newValue, oldValue });
}
}
}

View file

@ -0,0 +1,203 @@
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;
const batchSet = jest.fn(() => ({
settings: {}
}));
const config = new UiSettingsClient({
defaults,
initialSettings,
api: {
batchSet
},
notify: {
log: jest.fn(),
error: jest.fn(),
}
});
return { config, batchSet };
}
describe('#get', () => {
it('gives access to config values', () => {
const { config } = setup();
expect(config.get('dateFormat')).toMatchSnapshot();
});
it('supports the default value overload', () => {
const { config } = setup();
// default values are consumed and returned atomically
expect(config.get('obscureProperty1', 'default')).toMatchSnapshot();
});
it('after a get for an unknown property, the property is not persisted', () => {
const { config } = setup();
config.get('obscureProperty2', 'default');
// after a get, default values are NOT persisted
expect(() => config.get('obscureProperty2')).toThrowErrorMatchingSnapshot();
});
it('honors the default parameter for unset options that are exported', () => {
const { config } = setup();
// if you are hitting this error, then a test is setting this config value globally and not unsetting it!
expect(config.isDefault('dateFormat')).toBe(true);
const defaultDateFormat = config.get('dateFormat');
expect(config.get('dateFormat', 'xyz')).toBe('xyz');
// shouldn't change other usages
expect(config.get('dateFormat')).toBe(defaultDateFormat);
expect(config.get('dataFormat', defaultDateFormat)).toBe(defaultDateFormat);
});
it('throws on unknown properties that don\'t have a value yet.', () => {
const { config } = setup();
expect(() => config.get('throwableProperty')).toThrowErrorMatchingSnapshot();
});
});
describe('#set', () => {
it('stores a value in the config val set', () => {
const { config } = setup();
const original = config.get('dateFormat');
config.set('dateFormat', 'notaformat');
expect(config.get('dateFormat')).toBe('notaformat');
config.set('dateFormat', original);
});
it('stores a value in a previously unknown config key', () => {
const { config } = setup();
expect(() => config.set('unrecognizedProperty', 'somevalue')).not.toThrowError();
expect(config.get('unrecognizedProperty')).toBe('somevalue');
});
it('resolves to true on success', async () => {
const { config } = setup();
await expect(config.set('foo', 'bar')).resolves.toBe(true);
});
it('resolves to false on failure', async () => {
const { config, batchSet } = setup();
batchSet.mockImplementation(() => {
throw new Error('Error in request');
});
await expect(config.set('foo', 'bar')).resolves.toBe(false);
});
});
describe('#remove', () => {
it('resolves to true on success', async () => {
const { config } = setup();
await expect(config.remove('dateFormat')).resolves.toBe(true);
});
it('resolves to false on failure', async () => {
const { config, batchSet } = setup();
batchSet.mockImplementation(() => {
throw new Error('Error in request');
});
await expect(config.remove('dateFormat')).resolves.toBe(false);
});
});
describe('#isDeclared', () => {
it('returns true if name is know', () => {
const { config } = setup();
expect(config.isDeclared('dateFormat')).toBe(true);
});
it('returns false if name is not known', () => {
const { config } = setup();
expect(config.isDeclared('dateFormat')).toBe(true);
});
});
describe('#isDefault', () => {
it('returns true if value is default', () => {
const { config } = setup();
expect(config.isDefault('dateFormat')).toBe(true);
});
it('returns false if name is not known', () => {
const { config } = setup();
config.set('dateFormat', 'foo');
expect(config.isDefault('dateFormat')).toBe(false);
});
});
describe('#isCustom', () => {
it('returns false if name is in from defaults', () => {
const { config } = setup();
expect(config.isCustom('dateFormat')).toBe(false);
});
it('returns false for unkown name', () => {
const { config } = setup();
expect(config.isCustom('foo')).toBe(false);
});
it('returns true if name is from unknown set()', () => {
const { config } = setup();
config.set('foo', 'bar');
expect(config.isCustom('foo')).toBe(true);
});
});
describe('#subscribe', () => {
it('calls handler with { key, newValue, oldValue } when config changes', () => {
const handler = jest.fn();
const { config } = setup();
config.subscribe(handler);
expect(handler).not.toHaveBeenCalled();
config.set('foo', 'bar');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls).toMatchSnapshot();
handler.mockClear();
config.set('foo', 'baz');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls).toMatchSnapshot();
});
it('returns a subscription object which unsubs when .unsubscribe() is called', () => {
const handler = 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();
subscription.unsubscribe();
config.set('foo', 'baz');
expect(handler).not.toHaveBeenCalled();
});
});