[Step 2] ui/persisted_state 👉 src/plugins/visualizations (#58501) (#58770)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Alexey Antonov 2020-02-28 09:48:04 +03:00 committed by GitHub
parent 37c656ab18
commit 80349f282d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 289 additions and 680 deletions

View file

@ -40,7 +40,7 @@ import {
import { buildTabularInspectorData } from './build_tabular_inspector_data';
import { calculateObjectHash } from '../../../../visualizations/public';
import { tabifyAggResponse } from '../../../../../core_plugins/data/public';
import { PersistedState } from '../../../../../ui/public/persisted_state';
import { PersistedState } from '../../../../../../plugins/visualizations/public';
import { Adapters } from '../../../../../../plugins/inspector/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { getQueryService, getIndexPatterns } from '../../../../../../plugins/data/public/services';

View file

@ -29,7 +29,6 @@ export { State } from 'ui/state_management/state';
export { GlobalStateProvider } from 'ui/state_management/global_state';
// @ts-ignore
export { StateManagementConfigProvider } from 'ui/state_management/config_provider';
export { PersistedState } from 'ui/persisted_state';
export { subscribeWithScope } from 'ui/utils/subscribe_with_scope';
// @ts-ignore

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { PersistedState } from '../../../legacy_imports';
import { PersistedState } from '../../../../../../../../plugins/visualizations/public';
import { ReduxLikeStateContainer } from '../../../../../../../../plugins/kibana_utils/public';
import { VisualizeAppState, VisualizeAppStateTransitions } from '../../types';

View file

@ -19,9 +19,10 @@
import { TimeRange, Query, Filter, DataPublicPluginStart } from 'src/plugins/data/public';
import { IEmbeddableStart } from 'src/plugins/embeddable/public';
import { PersistedState } from 'src/plugins/visualizations/public';
import { LegacyCoreStart } from 'kibana/public';
import { Vis } from 'src/legacy/core_plugins/visualizations/public';
import { VisSavedObject, PersistedState } from '../legacy_imports';
import { VisSavedObject } from '../legacy_imports';
export type PureVisState = ReturnType<Vis['getCurrentState']>;

View file

@ -21,13 +21,13 @@ import React, { useMemo, useState, useCallback, KeyboardEventHandler, useEffect
import { get, isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import { Vis } from 'src/legacy/core_plugins/visualizations/public';
import { PersistedState, AggGroupNames } from '../../legacy_imports';
import { AggGroupNames } from '../../legacy_imports';
import { DefaultEditorNavBar, OptionTab } from './navbar';
import { DefaultEditorControls } from './controls';
import { setStateParamValue, useEditorReducer, useEditorFormState, discardChanges } from './state';
import { DefaultEditorAggCommonProps } from '../agg_common_props';
import { PersistedState } from '../../../../../../plugins/visualizations/public';
interface DefaultEditorSideBarProps {
isCollapsed: boolean;

View file

@ -48,5 +48,4 @@ export { isValidJson, isValidInterval } from 'ui/agg_types';
export { AggParamOption } from 'ui/agg_types';
export { CidrMask } from 'ui/agg_types';
export { PersistedState } from 'ui/persisted_state';
export * from 'ui/vis/lib';

View file

@ -17,7 +17,8 @@
* under the License.
*/
import { IAggConfigs, PersistedState } from './legacy_imports';
import { PersistedState } from '../../../../plugins/visualizations/public';
import { IAggConfigs } from './legacy_imports';
import { Vis } from '../../visualizations/public';
export interface VisOptionsProps<VisParamType = unknown> {

View file

@ -17,7 +17,6 @@
* under the License.
*/
export { PersistedState } from 'ui/persisted_state';
// @ts-ignore
export { defaultFeedbackMessage } from 'ui/vis/default_feedback_message';
// @ts-ignore

View file

@ -24,10 +24,10 @@ import {
KibanaContext,
Render,
} from '../../../../plugins/expressions/public';
import { PersistedState } from '../../../../plugins/visualizations/public';
// @ts-ignore
import { metricsRequestHandler } from './request_handler';
import { PersistedState } from './legacy_imports';
type Input = KibanaContext | null;
type Output = Promise<Render<RenderValue>>;

View file

@ -17,7 +17,6 @@
* under the License.
*/
export { PersistedState } from '../../../ui/public/persisted_state';
export {
AggConfigs,
IAggConfig,

View file

@ -19,8 +19,7 @@
import { get } from 'lodash';
import React from 'react';
import { PersistedState } from '../../../legacy_imports';
import { PersistedState } from '../../../../../../../plugins/visualizations/public';
import { memoizeLast } from '../legacy/memoize';
import { VisualizationChart } from './visualization_chart';
import { VisualizationNoResults } from './visualization_noresults';

View file

@ -20,8 +20,7 @@
import React from 'react';
import * as Rx from 'rxjs';
import { debounceTime, filter, share, switchMap } from 'rxjs/operators';
import { PersistedState } from '../../../legacy_imports';
import { PersistedState } from '../../../../../../../plugins/visualizations/public';
import { Vis, VisualizationController } from '../vis';
import { getUpdateStatus } from '../legacy/update_status';
import { ResizeChecker } from '../../../../../../../plugins/kibana_utils/public';

View file

@ -43,10 +43,10 @@ import {
IExpressionLoaderParams,
ExpressionsStart,
} from '../../../../../../../plugins/expressions/public';
import { PersistedState } from '../../../../../../../plugins/visualizations/public';
import { buildPipeline } from '../legacy/build_pipeline';
import { Vis } from '../vis';
import { getExpressions, getUiActions } from '../services';
import { PersistedState } from '../../../legacy_imports';
import { VisSavedObject } from '../types';
const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>;

View file

@ -29,7 +29,7 @@
import { EventEmitter } from 'events';
import _ from 'lodash';
import { PersistedState } from '../../../legacy_imports';
import { PersistedState } from '../../../../../../../plugins/visualizations/public';
import { getTypes } from '../services';

View file

@ -19,12 +19,14 @@
import { get } from 'lodash';
import { i18n } from '@kbn/i18n';
import { VisResponseValue } from '../../../../../../../plugins/visualizations/public';
import {
VisResponseValue,
PersistedState,
} from '../../../../../../../plugins/visualizations/public';
import {
ExpressionFunctionDefinition,
Render,
} from '../../../../../../../plugins/expressions/public';
import { PersistedState } from '../../../legacy_imports';
import { getTypes, getIndexPatterns, getFilterManager } from '../services';
interface Arguments {

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { PersistedState } from '../../../legacy_imports';
import { PersistedState } from '../../../../../../../plugins/visualizations/public';
import { calculateObjectHash } from './calculate_object_hash';
import { Vis } from '../vis';

View file

@ -29,7 +29,8 @@
import { EventEmitter } from 'events';
import _ from 'lodash';
import { AggConfigs, PersistedState } from '../../legacy_imports';
import { PersistedState } from '../../../../../../../src/plugins/visualizations/public';
import { AggConfigs } from '../../legacy_imports';
import { updateVisualizationConfig } from './legacy/vis_update';
import { getTypes } from './services';

View file

@ -20,19 +20,19 @@
/**
* @name Events
*
* @extends SimpleEmitter
* @extends EventEmitter
*/
import _ from 'lodash';
import { EventEmitter } from 'events';
import { fatalError } from './notify';
import { SimpleEmitter } from './utils/simple_emitter';
import { createLegacyClass } from './utils/legacy_class';
import { createDefer } from 'ui/promises';
const location = 'EventEmitter';
export function EventsProvider(Promise) {
createLegacyClass(Events).inherits(SimpleEmitter);
createLegacyClass(Events).inherits(EventEmitter);
function Events() {
Events.Super.call(this);
this._listeners = {};
@ -79,6 +79,7 @@ export function EventsProvider(Promise) {
*/
Events.prototype.off = function(name, handler) {
if (!name && !handler) {
this._listeners = {};
return this.removeAllListeners();
}

View file

@ -1,26 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { KbnError } from '../../../../plugins/kibana_utils/public';
export class PersistedStateError extends KbnError {
constructor() {
super('Error with the persisted state');
}
}

View file

@ -1,20 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { PersistedState } from './persisted_state';

View file

@ -1,29 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// It's currenty hard to properly type PersistedState, since it dynamically
// inherits the class passed into the constructor. These typings are really pretty bad
// but needed in the short term to make incremental progress elsewhere. Can't even
// just use `any` since then typescript complains about using PersistedState as a
// constructor.
export class PersistedState {
constructor(value?: any, path?: any, EmitterClass?: any);
// method you want typed so far
[prop: string]: any;
}

View file

@ -1,253 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @name PersistedState
*
* @extends Events
*/
import _ from 'lodash';
import toPath from 'lodash/internal/toPath';
import { PersistedStateError } from './errors';
import { SimpleEmitter } from '../utils/simple_emitter';
function prepSetParams(key, value, path) {
// key must be the value, set the entire state using it
if (_.isUndefined(value) && (_.isPlainObject(key) || path.length > 0)) {
// setting entire tree, swap the key and value to write to the state
value = key;
key = undefined;
}
// ensure the value being passed in is never mutated
return {
value: _.cloneDeep(value),
key: key,
};
}
export class PersistedState {
/**
*
* @param value
* @param path
* @param EmitterClass {SimpleEmitter} - a SimpleEmitter class that this class will extend. Can be used to
* inherit a custom event emitter. For example, the EventEmitter is an "angular-ized" version
* for angular components which automatically triggers a digest loop for every registered
* handler. TODO: replace angularized SimpleEmitter and force angular callers to handle digest loops manually ala
* https://github.com/elastic/kibana/issues/13855
*/
constructor(value, path, EmitterClass = SimpleEmitter) {
EmitterClass.call(this);
this._path = this._setPath(path);
_.forOwn(EmitterClass.prototype, (method, methodName) => {
this[methodName] = function() {
return EmitterClass.prototype[methodName].apply(this, arguments);
};
});
// Some validations
if (!this._path.length && value && !_.isPlainObject(value)) {
throw new PersistedStateError('State value must be a plain object');
}
value = value || this._getDefault();
// copy passed state values and create internal trackers
this.set(value);
this._initialized = true; // used to track state changes
}
get(key, def) {
return _.cloneDeep(this._get(key, def));
}
set(key, value) {
const params = prepSetParams(key, value, this._path);
const val = this._set(params.key, params.value);
this.emit('set');
return val;
}
setSilent(key, value) {
const params = prepSetParams(key, value, this._path);
return this._set(params.key, params.value, true);
}
clearAllKeys() {
Object.getOwnPropertyNames(this._changedState).forEach(key => {
this.set(key, null);
});
}
reset(path) {
const keyPath = this._getIndex(path);
const origValue = _.get(this._defaultState, keyPath);
const currentValue = _.get(this._mergedState, keyPath);
if (_.isUndefined(origValue)) {
this._cleanPath(path, this._mergedState);
} else {
_.set(this._mergedState, keyPath, origValue);
}
// clean up the changedState tree
this._cleanPath(path, this._changedState);
if (!_.isEqual(currentValue, origValue)) this.emit('change');
}
getChanges() {
return _.cloneDeep(this._changedState);
}
toJSON() {
return this.get();
}
toString() {
return JSON.stringify(this.toJSON());
}
fromString(input) {
return this.set(JSON.parse(input));
}
_getIndex(key) {
if (_.isUndefined(key)) return this._path;
return (this._path || []).concat(toPath(key));
}
_getPartialIndex(key) {
const keyPath = this._getIndex(key);
return keyPath.slice(this._path.length);
}
_cleanPath(path, stateTree) {
const partialPath = this._getPartialIndex(path);
let remove = true;
// recursively delete value tree, when no other keys exist
while (partialPath.length > 0) {
const lastKey = partialPath.splice(partialPath.length - 1, 1)[0];
const statePath = this._path.concat(partialPath);
const stateVal = statePath.length > 0 ? _.get(stateTree, statePath) : stateTree;
// if stateVal isn't an object, do nothing
if (!_.isPlainObject(stateVal)) return;
if (remove) delete stateVal[lastKey];
if (Object.keys(stateVal).length > 0) remove = false;
}
}
_getDefault() {
return this._hasPath() ? undefined : {};
}
_setPath(path) {
const isString = _.isString(path);
const isArray = Array.isArray(path);
if (!isString && !isArray) return [];
return isString ? [this._getIndex(path)] : path;
}
_hasPath() {
return this._path.length > 0;
}
_get(key, def) {
// no path and no key, get the whole state
if (!this._hasPath() && _.isUndefined(key)) {
return this._mergedState;
}
return _.get(this._mergedState, this._getIndex(key), def);
}
_set(key, value, silent) {
const self = this;
let stateChanged = false;
const initialState = !this._initialized;
const keyPath = this._getIndex(key);
const hasKeyPath = keyPath.length > 0;
// if this is the initial state value, save value as the default
if (initialState) {
this._changedState = {};
if (!this._hasPath() && _.isUndefined(key)) this._defaultState = value;
else this._defaultState = _.set({}, keyPath, value);
}
if (!initialState) {
// no path and no key, set the whole state
if (!this._hasPath() && _.isUndefined(key)) {
// compare changedState and new state, emit an event when different
stateChanged = !_.isEqual(this._changedState, value);
this._changedState = value;
this._mergedState = _.cloneDeep(value);
} else {
// check for changes at path, emit an event when different
const curVal = hasKeyPath ? this.get(keyPath) : this._mergedState;
stateChanged = !_.isEqual(curVal, value);
// arrays are merge by index, not desired - ensure they are replaced
if (Array.isArray(_.get(this._mergedState, keyPath))) {
if (hasKeyPath) _.set(this._mergedState, keyPath, undefined);
else this._mergedState = undefined;
}
if (hasKeyPath) {
_.set(this._changedState, keyPath, value);
} else {
this._changedState = _.isPlainObject(value) ? value : {};
}
}
}
// update the merged state value
const targetObj = this._mergedState || _.cloneDeep(this._defaultState);
const sourceObj = _.merge({}, this._changedState);
// handler arguments are (targetValue, sourceValue, key, target, source)
const mergeMethod = function(targetValue, sourceValue, mergeKey) {
// if not initial state, skip default merge method (ie. return value, see note below)
if (!initialState && _.isEqual(keyPath, self._getIndex(mergeKey))) {
// use the sourceValue or fall back to targetValue
return !_.isUndefined(sourceValue) ? sourceValue : targetValue;
}
};
// If `mergeMethod` is provided it is invoked to produce the merged values of the
// destination and source properties.
// If `mergeMethod` returns `undefined` the default merging method is used
this._mergedState = _.merge(targetObj, sourceObj, mergeMethod);
// sanity check; verify that there are actually changes
if (_.isEqual(this._mergedState, this._defaultState)) this._changedState = {};
if (!silent && stateChanged) this.emit('change', key);
return this;
}
}

View file

@ -19,9 +19,9 @@
import expect from '@kbn/expect';
import sinon from 'sinon';
import { EventEmitter } from 'events';
import { cloneDeep } from 'lodash';
import { stateMonitorFactory } from '../state_monitor_factory';
import { SimpleEmitter } from '../../utils/simple_emitter';
describe('stateMonitorFactory', function() {
const noop = () => {};
@ -35,7 +35,7 @@ describe('stateMonitorFactory', function() {
}
function createMockState(state = {}) {
const mockState = new SimpleEmitter();
const mockState = new EventEmitter();
setState(mockState, state, false);
return mockState;
}

View file

@ -29,7 +29,7 @@
import { uiModules } from '../modules';
import { StateProvider } from './state';
import { PersistedState } from '../persisted_state';
import { PersistedState } from '../../../../plugins/visualizations/public';
import { createLegacyClass } from '../utils/legacy_class';
const urlParam = '_a';

View file

@ -1,141 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
/**
* Simple event emitter class used in the vislib. Calls
* handlers synchronously and implements a chainable api
*
* @class
*/
export function SimpleEmitter() {
this._listeners = {};
}
/**
* Add an event handler
*
* @param {string} name
* @param {function} handler
* @return {SimpleEmitter} - this, for chaining
*/
SimpleEmitter.prototype.on = function(name, handler) {
let handlers = this._listeners[name];
if (!handlers) handlers = this._listeners[name] = [];
handlers.push(handler);
return this;
};
/**
* Remove an event handler
*
* @param {string} name
* @param {function} [handler] - optional handler to remove, if no handler is
* passed then all are removed
* @return {SimpleEmitter} - this, for chaining
*/
SimpleEmitter.prototype.off = function(name, handler) {
if (!this._listeners[name]) {
return this;
}
// remove a specific handler
if (handler) _.pull(this._listeners[name], handler);
// or remove all listeners
else this._listeners[name] = null;
return this;
};
/**
* Remove all event listeners bound to this emitter.
*
* @return {SimpleEmitter} - this, for chaining
*/
SimpleEmitter.prototype.removeAllListeners = function() {
this._listeners = {};
return this;
};
/**
* Emit an event and all arguments to all listeners for an event name
*
* @param {string} name
* @param {*} [arg...] - any number of arguments that will be applied to each handler
* @return {SimpleEmitter} - this, for chaining
*/
SimpleEmitter.prototype.emit = _.restParam(function(name, args) {
if (!this._listeners[name]) return this;
const listeners = this.listeners(name);
let i = -1;
while (++i < listeners.length) {
listeners[i].apply(this, args);
}
return this;
});
/**
* Get a list of the event names that currently have listeners
*
* @return {array[string]}
*/
SimpleEmitter.prototype.activeEvents = function() {
return _.reduce(
this._listeners,
function(active, listeners, name) {
return active.concat(_.size(listeners) ? name : []);
},
[]
);
};
/**
* Get a list of the handler functions for a specific event
*
* @param {string} name
* @return {array[function]}
*/
SimpleEmitter.prototype.listeners = function(name) {
return this._listeners[name] ? this._listeners[name].slice(0) : [];
};
/**
* Get the count of handlers for a specific event
*
* @param {string} [name] - optional event name to filter by
* @return {number}
*/
SimpleEmitter.prototype.listenerCount = function(name) {
if (name) {
return _.size(this._listeners[name]);
}
return _.reduce(
this._listeners,
function(count, handlers) {
return count + _.size(handlers);
},
0
);
};

View file

@ -1,176 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SimpleEmitter } from './simple_emitter';
import sinon from 'sinon';
describe('SimpleEmitter class', () => {
let emitter;
beforeEach(() => {
emitter = new SimpleEmitter();
});
it('constructs an event emitter', () => {
expect(emitter).toHaveProperty('on');
expect(emitter).toHaveProperty('off');
expect(emitter).toHaveProperty('emit');
expect(emitter).toHaveProperty('listenerCount');
expect(emitter).toHaveProperty('removeAllListeners');
});
describe('#listenerCount', () => {
it('counts all event listeners without any arg', () => {
expect(emitter.listenerCount()).toBe(0);
emitter.on('a', () => {});
expect(emitter.listenerCount()).toBe(1);
emitter.on('b', () => {});
expect(emitter.listenerCount()).toBe(2);
});
it('limits to the event that is passed in', () => {
expect(emitter.listenerCount()).toBe(0);
emitter.on('a', () => {});
expect(emitter.listenerCount('a')).toBe(1);
emitter.on('a', () => {});
expect(emitter.listenerCount('a')).toBe(2);
emitter.on('b', () => {});
expect(emitter.listenerCount('a')).toBe(2);
expect(emitter.listenerCount('b')).toBe(1);
expect(emitter.listenerCount()).toBe(3);
});
});
describe('#on', () => {
it('registers a handler', () => {
const handler = sinon.stub();
emitter.on('a', handler);
expect(emitter.listenerCount('a')).toBe(1);
expect(handler.callCount).toBe(0);
emitter.emit('a');
expect(handler.callCount).toBe(1);
});
it('allows multiple event handlers for the same event', () => {
emitter.on('a', () => {});
emitter.on('a', () => {});
expect(emitter.listenerCount('a')).toBe(2);
});
it('allows the same function to be registered multiple times', () => {
const handler = () => {};
emitter.on('a', handler);
expect(emitter.listenerCount()).toBe(1);
emitter.on('a', handler);
expect(emitter.listenerCount()).toBe(2);
});
});
describe('#off', () => {
it('removes a listener if it was registered', () => {
const handler = sinon.stub();
expect(emitter.listenerCount()).toBe(0);
emitter.on('a', handler);
expect(emitter.listenerCount('a')).toBe(1);
emitter.off('a', handler);
expect(emitter.listenerCount('a')).toBe(0);
});
it('clears all listeners if no handler is passed', () => {
emitter.on('a', () => {});
emitter.on('a', () => {});
expect(emitter.listenerCount()).toBe(2);
emitter.off('a');
expect(emitter.listenerCount()).toBe(0);
});
it('does not mind if the listener is not registered', () => {
emitter.off('a', () => {});
});
it('does not mind if the event has no listeners', () => {
emitter.off('a');
});
});
describe('#emit', () => {
it('calls the handlers in the order they were defined', () => {
let i = 0;
const incr = () => ++i;
const one = sinon.spy(incr);
const two = sinon.spy(incr);
const three = sinon.spy(incr);
const four = sinon.spy(incr);
emitter
.on('a', one)
.on('a', two)
.on('a', three)
.on('a', four)
.emit('a');
expect(one).toHaveProperty('callCount', 1);
expect(one.returned(1)).toBeDefined();
expect(two).toHaveProperty('callCount', 1);
expect(two.returned(2)).toBeDefined();
expect(three).toHaveProperty('callCount', 1);
expect(three.returned(3)).toBeDefined();
expect(four).toHaveProperty('callCount', 1);
expect(four.returned(4)).toBeDefined();
});
it('always emits the handlers that were initially registered', () => {
const destructive = sinon.spy(() => {
emitter.removeAllListeners();
expect(emitter.listenerCount()).toBe(0);
});
const stub = sinon.stub();
emitter
.on('run', destructive)
.on('run', stub)
.emit('run');
expect(destructive).toHaveProperty('callCount', 1);
expect(stub).toHaveProperty('callCount', 1);
});
it('applies all arguments except the first', () => {
emitter
.on('a', (a, b, c) => {
expect(a).toBe('foo');
expect(b).toBe('bar');
expect(c).toBe('baz');
})
.emit('a', 'foo', 'bar', 'baz');
});
it('uses the SimpleEmitter as the this context', () => {
emitter
.on('a', function() {
expect(this).toBe(emitter);
})
.emit('a');
});
});
});

View file

@ -21,7 +21,7 @@ export function registerListenEventListener($rootScope) {
* Helper that registers an event listener, and removes that listener when
* the $scope is destroyed.
*
* @param {SimpleEmitter} emitter - the event emitter to listen to
* @param {EventEmitter} emitter - the event emitter to listen to
* @param {string} eventName - the event name
* @param {Function} handler - the event handler
* @return {undefined}

View file

@ -27,3 +27,5 @@ export function plugin(initializerContext: PluginInitializerContext) {
export { VisualizationsPublicPlugin as Plugin };
export * from './plugin';
export * from './types';
export { PersistedState } from './persisted_state';

View file

@ -0,0 +1,254 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EventEmitter } from 'events';
import { isPlainObject, cloneDeep, get, set, isEqual, isString, merge } from 'lodash';
import toPath from 'lodash/internal/toPath';
function prepSetParams(key: PersistedStateKey, value: any, path: PersistedStatePath) {
// key must be the value, set the entire state using it
if (value === undefined && (isPlainObject(key) || path.length > 0)) {
// setting entire tree, swap the key and value to write to the state
return {
value: key,
key: undefined,
};
}
// ensure the value being passed in is never mutated
return {
value: cloneDeep(value),
key,
};
}
type PersistedStateKey = string | string[] | undefined;
type PersistedStatePath = string | string[];
export class PersistedState extends EventEmitter {
private readonly _path: PersistedStatePath;
private readonly _initialized: boolean;
private _changedState: any;
private _defaultState: any;
private _mergedState: any;
constructor(value?: any, path?: PersistedStatePath) {
super();
this._path = this.setPath(path);
// Some validations
if (!this._path.length && value && !isPlainObject(value)) {
throw new Error('State value must be a plain object');
}
value = value || this.getDefault();
// copy passed state values and create internal trackers
this.set(value);
this._initialized = true; // used to track state changes
}
get(key?: PersistedStateKey, defaultValue?: any) {
// no path and no key, get the whole state
if (!this.hasPath() && key === undefined) {
return this._mergedState;
}
return cloneDeep(get(this._mergedState, this.getIndex(key || ''), defaultValue));
}
set(key: PersistedStateKey | any, value?: any) {
const params = prepSetParams(key, value, this._path);
const val = this.setValue(params.key, params.value);
this.emit('set');
return val;
}
setSilent(key: PersistedStateKey | any, value?: any) {
const params = prepSetParams(key, value, this._path);
if (params.key) {
return this.setValue(params.key, params.value, true);
}
}
clearAllKeys() {
Object.getOwnPropertyNames(this._changedState).forEach(key => {
this.set(key, null);
});
}
reset(path: PersistedStatePath) {
const keyPath = this.getIndex(path);
const origValue = get(this._defaultState, keyPath);
const currentValue = get(this._mergedState, keyPath);
if (origValue === undefined) {
this.cleanPath(path, this._mergedState);
} else {
set(this._mergedState, keyPath, origValue);
}
// clean up the changedState tree
this.cleanPath(path, this._changedState);
if (!isEqual(currentValue, origValue)) this.emit('change');
}
getChanges() {
return cloneDeep(this._changedState);
}
toJSON() {
return this.get();
}
toString() {
return JSON.stringify(this.toJSON());
}
fromString(input: string) {
return this.set(JSON.parse(input));
}
private getIndex(key: PersistedStateKey) {
if (key === undefined) return this._path;
return [...(this._path || []), ...toPath(key)];
}
private getPartialIndex(key: PersistedStateKey) {
const keyPath = this.getIndex(key);
return keyPath.slice(this._path.length);
}
private cleanPath(path: PersistedStatePath, stateTree: any) {
const partialPath = this.getPartialIndex(path);
let remove = true;
if (Array.isArray(partialPath)) {
// recursively delete value tree, when no other keys exist
while (partialPath.length > 0) {
const lastKey = partialPath.splice(partialPath.length - 1, 1)[0];
const statePath = [...this._path, partialPath];
const stateVal = statePath.length > 0 ? get(stateTree, statePath) : stateTree;
// if stateVal isn't an object, do nothing
if (!isPlainObject(stateVal)) return;
if (remove) delete stateVal[lastKey];
if (Object.keys(stateVal).length > 0) remove = false;
}
}
}
private getDefault() {
return this.hasPath() ? undefined : {};
}
private setPath(path?: PersistedStatePath): string[] {
if (Array.isArray(path)) {
return path;
}
if (isString(path)) {
return [...this.getIndex(path)];
}
return [];
}
private hasPath() {
return this._path.length > 0;
}
private setValue(key: PersistedStateKey, value: any, silent: boolean = false) {
const self = this;
let stateChanged = false;
const initialState = !this._initialized;
const keyPath = this.getIndex(key);
const hasKeyPath = keyPath.length > 0;
// if this is the initial state value, save value as the default
if (initialState) {
this._changedState = {};
if (!this.hasPath() && key === undefined) this._defaultState = value;
else this._defaultState = set({}, keyPath, value);
}
if (!initialState) {
// no path and no key, set the whole state
if (!this.hasPath() && key === undefined) {
// compare changedState and new state, emit an event when different
stateChanged = !isEqual(this._changedState, value);
this._changedState = value;
this._mergedState = cloneDeep(value);
} else {
// check for changes at path, emit an event when different
const curVal = hasKeyPath ? this.get(keyPath) : this._mergedState;
stateChanged = !isEqual(curVal, value);
// arrays are merge by index, not desired - ensure they are replaced
if (Array.isArray(get(this._mergedState, keyPath))) {
if (hasKeyPath) {
set(this._mergedState, keyPath, undefined);
} else {
this._mergedState = undefined;
}
}
if (hasKeyPath) {
set(this._changedState, keyPath, value);
} else {
this._changedState = isPlainObject(value) ? value : {};
}
}
}
// update the merged state value
const targetObj = this._mergedState || cloneDeep(this._defaultState);
const sourceObj = merge({}, this._changedState);
// handler arguments are (targetValue, sourceValue, key, target, source)
const mergeMethod = function(targetValue: any, sourceValue: any, mergeKey: string) {
// if not initial state, skip default merge method (ie. return value, see note below)
if (!initialState && isEqual(keyPath, self.getIndex(mergeKey))) {
// use the sourceValue or fall back to targetValue
return sourceValue === undefined ? targetValue : sourceValue;
}
};
// If `mergeMethod` is provided it is invoked to produce the merged values of the
// destination and source properties.
// If `mergeMethod` returns `undefined` the default merging method is used
this._mergedState = merge(targetObj, sourceObj, mergeMethod);
// sanity check; verify that there are actually changes
if (isEqual(this._mergedState, this._defaultState)) this._changedState = {};
if (!silent && stateChanged) this.emit('change', key);
return this;
}
}

View file

@ -17,7 +17,6 @@
* under the License.
*/
import { PersistedStateError } from './errors';
import { PersistedState } from './persisted_state';
describe('Persisted State Provider', () => {
@ -47,7 +46,7 @@ describe('Persisted State Provider', () => {
});
test('should throw if given an invalid value', () => {
expect(() => new PersistedState('bananas')).toThrow(PersistedStateError);
expect(() => new PersistedState('bananas')).toThrow(Error);
});
});
@ -224,13 +223,13 @@ describe('Persisted State Provider', () => {
describe('internal state tracking', () => {
test('should be an empty object', () => {
const persistedState = new PersistedState();
expect(persistedState._defaultState).toEqual({});
expect(persistedState).toHaveProperty('_defaultState', {});
});
test('should store the default state value', () => {
const val = { one: 1, two: 2 };
const persistedState = new PersistedState(val);
expect(persistedState._defaultState).toEqual(val);
expect(persistedState).toHaveProperty('_defaultState', val);
});
test('should keep track of changes', () => {
@ -238,8 +237,8 @@ describe('Persisted State Provider', () => {
const persistedState = new PersistedState(val);
persistedState.set('two', 22);
expect(persistedState._defaultState).toEqual(val);
expect(persistedState._changedState).toEqual({ two: 22 });
expect(persistedState).toHaveProperty('_defaultState', val);
expect(persistedState).toHaveProperty('_changedState', { two: 22 });
});
});

View file

@ -17,7 +17,6 @@ export { StateManagementConfigProvider } from 'ui/state_management/config_provid
export { AppStateProvider } from 'ui/state_management/app_state';
// @ts-ignore
export { EventsProvider } from 'ui/events';
export { PersistedState } from 'ui/persisted_state';
// @ts-ignore
export { KbnUrlProvider, RedirectWhenMissingProvider } from 'ui/url';
export { registerTimefilterWithGlobalStateFactory } from 'ui/timefilter/setup_router';