` are just regular React context components.
+
+```tsx
+
+
+
{container =>
+ {JSON.stringify(container.get())}
+ }
+
+
+```
diff --git a/src/plugins/kibana_utils/docs/state_containers/react/use_container.md b/src/plugins/kibana_utils/docs/state_containers/react/use_container.md
new file mode 100644
index 0000000000000..5e698edb8529c
--- /dev/null
+++ b/src/plugins/kibana_utils/docs/state_containers/react/use_container.md
@@ -0,0 +1,10 @@
+# `useContainer` hook
+
+`useContainer` React hook will simply return you `container` object from React context.
+
+```tsx
+const Demo = () => {
+ const store = useContainer();
+ return {store.get().isDarkMode ? '🌑' : '☀️'}
;
+};
+```
diff --git a/src/plugins/kibana_utils/docs/state_containers/react/use_selector.md b/src/plugins/kibana_utils/docs/state_containers/react/use_selector.md
new file mode 100644
index 0000000000000..2ecf772fba367
--- /dev/null
+++ b/src/plugins/kibana_utils/docs/state_containers/react/use_selector.md
@@ -0,0 +1,20 @@
+# `useSelector()` hook
+
+With `useSelector` React hook you specify a selector function, which will pick specific
+data from the state. *Your component will update only when that specific part of the state changes.*
+
+```tsx
+const selector = state => state.isDarkMode;
+const Demo = () => {
+ const isDarkMode = useSelector(selector);
+ return {isDarkMode ? '🌑' : '☀️'}
;
+};
+```
+
+As an optional second argument for `useSelector` you can provide a `comparator` function, which
+compares currently selected value with the previous and your component will re-render only if
+`comparator` returns `true`. By default it uses [`fast-deep-equal`](https://github.com/epoberezkin/fast-deep-equal).
+
+```
+useSelector(selector, comparator?)
+```
diff --git a/src/plugins/kibana_utils/docs/state_containers/react/use_state.md b/src/plugins/kibana_utils/docs/state_containers/react/use_state.md
new file mode 100644
index 0000000000000..5db1d46897aad
--- /dev/null
+++ b/src/plugins/kibana_utils/docs/state_containers/react/use_state.md
@@ -0,0 +1,11 @@
+# `useState()` hook
+
+- `useState` hook returns you directly the state of the container.
+- It also forces component to re-render every time state changes.
+
+```tsx
+const Demo = () => {
+ const { isDarkMode } = useState();
+ return {isDarkMode ? '🌑' : '☀️'}
;
+};
+```
diff --git a/src/plugins/kibana_utils/docs/state_containers/react/use_transitions.md b/src/plugins/kibana_utils/docs/state_containers/react/use_transitions.md
new file mode 100644
index 0000000000000..c6783bf0e0f0a
--- /dev/null
+++ b/src/plugins/kibana_utils/docs/state_containers/react/use_transitions.md
@@ -0,0 +1,17 @@
+# `useTransitions` hook
+
+Access [state transitions](../transitions.md) by `useTransitions` React hook.
+
+```tsx
+const Demo = () => {
+ const { isDarkMode } = useState();
+ const { setDarkMode } = useTransitions();
+ return (
+ <>
+ {isDarkMode ? '🌑' : '☀️'}
+
+
+ >
+ );
+};
+```
diff --git a/src/plugins/kibana_utils/docs/state_containers/redux.md b/src/plugins/kibana_utils/docs/state_containers/redux.md
new file mode 100644
index 0000000000000..1a60d841a8b75
--- /dev/null
+++ b/src/plugins/kibana_utils/docs/state_containers/redux.md
@@ -0,0 +1,40 @@
+# Redux
+
+State containers similar to Redux stores but without the boilerplate.
+
+State containers expose Redux-like API:
+
+```js
+container.getState()
+container.dispatch()
+container.replaceReducer()
+container.subscribe()
+container.addMiddleware()
+```
+
+State containers have a reducer and every time you execute a state transition it
+actually dispatches an "action". For example, this
+
+```js
+container.transitions.increment(25);
+```
+
+is equivalent to
+
+```js
+container.dispatch({
+ type: 'increment',
+ args: [25],
+});
+```
+
+Because all transitions happen through `.dispatch()` interface, you can add middleware—similar how you
+would do with Redux—to monitor or intercept transitions.
+
+For example, you can add `redux-logger` middleware to log in console all transitions happening with your store.
+
+```js
+import logger from 'redux-logger';
+
+container.addMiddleware(logger);
+```
diff --git a/src/plugins/kibana_utils/docs/state_containers/transitions.md b/src/plugins/kibana_utils/docs/state_containers/transitions.md
new file mode 100644
index 0000000000000..51d52cdf3daaf
--- /dev/null
+++ b/src/plugins/kibana_utils/docs/state_containers/transitions.md
@@ -0,0 +1,61 @@
+# State transitions
+
+*State transitions* describe possible state changes over time. Transitions are pure functions which
+receive `state` object and other—optional—arguments and must return a new `state` object back.
+
+```ts
+type Transition = (state: State) => (...args) => State;
+```
+
+Transitions must not mutate `state` object in-place, instead they must return a
+shallow copy of it, e.g. `{ ...state }`. Example:
+
+```ts
+const setUiMode: PureTransition = state => uiMode => ({ ...state, uiMode });
+```
+
+You provide transitions as a second argument when you create your state container.
+
+```ts
+import { createStateContainer } from 'src/plugins/kibana_utils';
+
+const container = createStateContainer(0, {
+ increment: (cnt: number) => (by: number) => cnt + by,
+ double: (cnt: number) => () => cnt * 2,
+});
+```
+
+Now you can execute the transitions by calling them with only optional parameters (`state` is
+provided to your transitions automatically).
+
+```ts
+container.transitions.increment(25);
+container.transitions.increment(5);
+container.state; // 30
+```
+
+Your transitions are bound to the container so you can treat each of them as a
+standalone function for export.
+
+```ts
+const defaultState = {
+ uiMode: 'light',
+};
+
+const container = createStateContainer(defaultState, {
+ setUiMode: state => uiMode => ({ ...state, uiMode }),
+ resetUiMode: state => () => ({ ...state, uiMode: defaultState.uiMode }),
+});
+
+export const {
+ setUiMode,
+ resetUiMode
+} = container.transitions;
+```
+
+You can add TypeScript annotations for your transitions as the second generic argument
+to `createStateContainer()` function.
+
+```ts
+const container = createStateContainer(defaultState, pureTransitions);
+```
diff --git a/src/plugins/kibana_utils/docs/store/README.md b/src/plugins/kibana_utils/docs/store/README.md
deleted file mode 100644
index e1cb098fe04ce..0000000000000
--- a/src/plugins/kibana_utils/docs/store/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# State containers
-
-- State containers for holding serializable state.
-- [Each plugin/app that needs runtime state will create a *store* using `store = createStore()`](./creation.md).
-- [*Store* can be updated using mutators `mutators = store.createMutators({ ... })`](./mutators.md).
-- [*Store* can be connected to React `{Provider, connect} = createContext(store)`](./react.md).
-- [In no-React setting *store* is consumed using `store.get()` and `store.state$`](./getters.md).
-- [Under-the-hood uses Redux `store.redux`](./redux.md) (but you should never need it explicitly).
-- [See idea doc with samples and rationale](https://docs.google.com/document/d/18eitHkcyKSsEHUfUIqFKChc8Pp62Z4gcRxdu903hbA0/edit#heading=h.iaxc9whxifl5).
diff --git a/src/plugins/kibana_utils/docs/store/mutators.md b/src/plugins/kibana_utils/docs/store/mutators.md
deleted file mode 100644
index 9db1b1bb60b3c..0000000000000
--- a/src/plugins/kibana_utils/docs/store/mutators.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Mutators
-
-State *mutators* are pure functions which receive `state` object and other—optional—arguments
-and must return a new `state` object back.
-
-```ts
-type Mutator = (state: State) => (...args) => State;
-```
-
-Mutator must not mutate `state` object in-place, instead it should return a
-shallow copy of it, e.g. `{ ...state }`.
-
-```ts
-const setUiMode: Mutator = state => uiMode => ({ ...state, uiMode });
-```
-
-You create mutators using `.createMutator(...)` method.
-
-```ts
-const store = createStore({uiMode: 'light'});
-const mutators = store.createMutators({
- setUiMode: state => uiMode => ({ ...state, uiMode }),
-});
-```
-
-Now you can use your mutators by calling them with only optional parameters (`state` is
-provided to your mutator automatically).
-
-```ts
-mutators.setUiMode('dark');
-```
-
-Your mutators are bound to the `store` so you can treat each of them as a
-standalone function for export.
-
-```ts
-const { setUiMode, resetUiMode } = store.createMutators({
- setUiMode: state => uiMode => ({ ...state, uiMode }),
- resetUiMode: state => () => ({ ...state, uiMode: 'light' }),
-});
-
-export {
- setUiMode,
- resetUiMode,
-};
-```
-
-The mutators you create are also available on the `store` object.
-
-```ts
-const store = createStore({ cnt: 0 });
-store.createMutators({
- add: state => value => ({ ...state, cnt: state.cnt + value }),
-});
-
-store.mutators.add(5);
-store.get(); // { cnt: 5 }
-```
-
-You can add TypeScript annotations to your `.mutators` property of `store` object.
-
-```ts
-const store = createStore<{
- cnt: number;
-}, {
- add: (value: number) => void;
-}>({
- cnt: 0
-});
-```
diff --git a/src/plugins/kibana_utils/docs/store/react.md b/src/plugins/kibana_utils/docs/store/react.md
deleted file mode 100644
index 68a016ed6d3ca..0000000000000
--- a/src/plugins/kibana_utils/docs/store/react.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# React
-
-`createContext` factory allows you to easily use state containers with React.
-
-```ts
-import { createStore, createContext } from 'kibana-utils';
-
-const store = createStore({});
-const {
- Provider,
- Consumer,
- connect,
- context,
- useStore,
- useState,
- useMutators,
- useSelector,
-} = createContext(store);
-```
-
-Wrap your app with ``.
-
-```tsx
-
-
-
-```
-
-Use `connect()()` higer-order-component to inject props from state into your component.
-
-```tsx
-interface Props {
- name: string;
- punctuation: '.' | ',' | '!',
-}
-const Demo: React.FC = ({ name, punctuation }) =>
- Hello, {name}{punctuation}
;
-
-const store = createStore({ userName: 'John' });
-const { Provider, connect } = createContext(store);
-
-const mapStateToProps = ({ userName }) => ({ name: userName });
-const DemoConnected = connect(mapStateToProps)(Demo);
-
-
-
-
-```
-
-`useStore` React hook will fetch the `store` object from the context.
-
-```tsx
-const Demo = () => {
- const store = useStore();
- return {store.get().isDarkMode ? '🌑' : '☀️'}
;
-};
-```
-
-If you want your component to always re-render when the state changes use `useState` React hook.
-
-```tsx
-const Demo = () => {
- const { isDarkMode } = useState();
- return {isDarkMode ? '🌑' : '☀️'}
;
-};
-```
-
-For `useSelector` React hook you specify a selector function, which will pick specific
-data from the state. *Your component will update only when that specific part of the state changes.*
-
-```tsx
-const selector = state => state.isDarkMode;
-const Demo = () => {
- const isDarkMode = useSelector(selector);
- return {isDarkMode ? '🌑' : '☀️'}
;
-};
-```
-
-As an optional second argument for `useSelector` you can provide a `comparator` function, which
-compares currently selected value with the previous and your component will re-render only if
-`comparator` returns `true`. By default, it simply uses tripple equals `===` comparison.
-
-```
-useSelector(selector, comparator?)
-```
-
-Access state mutators by `useMutators` React hook.
-
-```tsx
-const Demo = () => {
- const { isDarkMode } = useState();
- const { setDarkMode } = useMutators();
- return (
- <>
- {isDarkMode ? '🌑' : '☀️'}
-
-
- >
- );
-};
-```
diff --git a/src/plugins/kibana_utils/docs/store/redux.md b/src/plugins/kibana_utils/docs/store/redux.md
deleted file mode 100644
index 23be76f35b36e..0000000000000
--- a/src/plugins/kibana_utils/docs/store/redux.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Redux
-
-Internally `createStore()` uses Redux to manage the state. When you call `store.get()`
-it is actually calling the Redux `.getState()` method. When you execute a mutation
-it is actually dispatching a Redux action.
-
-You can access Redux *store* using `.redux`.
-
-```ts
-store.redux;
-```
-
-But you should never need it, if you think you do, consult with Kibana App Architecture team.
-
-We use Redux internally for 3 main reasons:
-
-- We can reuse `react-redux` library to easily connect state containers to React.
-- We can reuse Redux devtools.
-- We can reuse battle-tested Redux library and action/reducer paradigm.
diff --git a/src/plugins/kibana_utils/public/index.test.ts b/src/plugins/kibana_utils/public/index.test.ts
index 0e2a4acf15f04..27c4d6c1c06e9 100644
--- a/src/plugins/kibana_utils/public/index.test.ts
+++ b/src/plugins/kibana_utils/public/index.test.ts
@@ -17,9 +17,9 @@
* under the License.
*/
-import { createStore, createContext } from '.';
+import { createStateContainer, createStateContainerReactHelpers } from '.';
test('exports store methods', () => {
- expect(typeof createStore).toBe('function');
- expect(typeof createContext).toBe('function');
+ expect(typeof createStateContainer).toBe('function');
+ expect(typeof createStateContainerReactHelpers).toBe('function');
});
diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts
index c5c129eca8fd3..3f5aeebac54d8 100644
--- a/src/plugins/kibana_utils/public/index.ts
+++ b/src/plugins/kibana_utils/public/index.ts
@@ -19,13 +19,12 @@
export * from './core';
export * from './errors';
-export * from './store';
-export * from './parse';
-export * from './resize_checker';
-export * from './render_complete';
-export * from './store';
export * from './errors';
export * from './field_mapping';
+export * from './parse';
+export * from './render_complete';
+export * from './resize_checker';
+export * from './state_containers';
export * from './storage';
export * from './storage/hashed_item_store';
export * from './state_management/state_hash';
diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts
new file mode 100644
index 0000000000000..9165181299a90
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts
@@ -0,0 +1,303 @@
+/*
+ * 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 { createStateContainer } from './create_state_container';
+
+const create = (state: S, transitions: T = {} as T) => {
+ const pureTransitions = {
+ set: () => (newState: S) => newState,
+ ...transitions,
+ };
+ const store = createStateContainer(state, pureTransitions);
+ return { store, mutators: store.transitions };
+};
+
+test('can create store', () => {
+ const { store } = create({});
+ expect(store).toMatchObject({
+ getState: expect.any(Function),
+ state$: expect.any(Object),
+ transitions: expect.any(Object),
+ dispatch: expect.any(Function),
+ subscribe: expect.any(Function),
+ replaceReducer: expect.any(Function),
+ addMiddleware: expect.any(Function),
+ });
+});
+
+test('can set default state', () => {
+ const defaultState = {
+ foo: 'bar',
+ };
+ const { store } = create(defaultState);
+ expect(store.get()).toEqual(defaultState);
+ expect(store.getState()).toEqual(defaultState);
+});
+
+test('can set state', () => {
+ const defaultState = {
+ foo: 'bar',
+ };
+ const newState = {
+ foo: 'baz',
+ };
+ const { store, mutators } = create(defaultState);
+
+ mutators.set(newState);
+
+ expect(store.get()).toEqual(newState);
+ expect(store.getState()).toEqual(newState);
+});
+
+test('does not shallow merge states', () => {
+ const defaultState = {
+ foo: 'bar',
+ };
+ const newState = {
+ foo2: 'baz',
+ };
+ const { store, mutators } = create(defaultState);
+
+ mutators.set(newState as any);
+
+ expect(store.get()).toEqual(newState);
+ expect(store.getState()).toEqual(newState);
+});
+
+test('can subscribe and unsubscribe to state changes', () => {
+ const { store, mutators } = create({});
+ const spy = jest.fn();
+ const subscription = store.state$.subscribe(spy);
+ mutators.set({ a: 1 });
+ mutators.set({ a: 2 });
+ subscription.unsubscribe();
+ mutators.set({ a: 3 });
+
+ expect(spy).toHaveBeenCalledTimes(2);
+ expect(spy.mock.calls[0][0]).toEqual({ a: 1 });
+ expect(spy.mock.calls[1][0]).toEqual({ a: 2 });
+});
+
+test('multiple subscribers can subscribe', () => {
+ const { store, mutators } = create({});
+ const spy1 = jest.fn();
+ const spy2 = jest.fn();
+ const subscription1 = store.state$.subscribe(spy1);
+ const subscription2 = store.state$.subscribe(spy2);
+ mutators.set({ a: 1 });
+ subscription1.unsubscribe();
+ mutators.set({ a: 2 });
+ subscription2.unsubscribe();
+ mutators.set({ a: 3 });
+
+ expect(spy1).toHaveBeenCalledTimes(1);
+ expect(spy2).toHaveBeenCalledTimes(2);
+ expect(spy1.mock.calls[0][0]).toEqual({ a: 1 });
+ expect(spy2.mock.calls[0][0]).toEqual({ a: 1 });
+ expect(spy2.mock.calls[1][0]).toEqual({ a: 2 });
+});
+
+test('creates impure mutators from pure mutators', () => {
+ const { mutators } = create(
+ {},
+ {
+ setFoo: () => (bar: any) => ({ foo: bar }),
+ }
+ );
+
+ expect(typeof mutators.setFoo).toBe('function');
+});
+
+test('mutators can update state', () => {
+ const { store, mutators } = create(
+ {
+ value: 0,
+ foo: 'bar',
+ },
+ {
+ add: (state: any) => (increment: any) => ({ ...state, value: state.value + increment }),
+ setFoo: (state: any) => (bar: any) => ({ ...state, foo: bar }),
+ }
+ );
+
+ expect(store.get()).toEqual({
+ value: 0,
+ foo: 'bar',
+ });
+
+ mutators.add(11);
+ mutators.setFoo('baz');
+
+ expect(store.get()).toEqual({
+ value: 11,
+ foo: 'baz',
+ });
+
+ mutators.add(-20);
+ mutators.setFoo('bazooka');
+
+ expect(store.get()).toEqual({
+ value: -9,
+ foo: 'bazooka',
+ });
+});
+
+test('mutators methods are not bound', () => {
+ const { store, mutators } = create(
+ { value: -3 },
+ {
+ add: (state: { value: number }) => (increment: number) => ({
+ ...state,
+ value: state.value + increment,
+ }),
+ }
+ );
+
+ expect(store.get()).toEqual({ value: -3 });
+ mutators.add(4);
+ expect(store.get()).toEqual({ value: 1 });
+});
+
+test('created mutators are saved in store object', () => {
+ const { store, mutators } = create(
+ { value: -3 },
+ {
+ add: (state: { value: number }) => (increment: number) => ({
+ ...state,
+ value: state.value + increment,
+ }),
+ }
+ );
+
+ expect(typeof store.transitions.add).toBe('function');
+ mutators.add(5);
+ expect(store.get()).toEqual({ value: 2 });
+});
+
+test('throws when state is modified inline - 1', () => {
+ const container = createStateContainer({ a: 'b' }, {});
+
+ let error: TypeError | null = null;
+ try {
+ (container.get().a as any) = 'c';
+ } catch (err) {
+ error = err;
+ }
+
+ expect(error).toBeInstanceOf(TypeError);
+});
+
+test('throws when state is modified inline - 2', () => {
+ const container = createStateContainer({ a: 'b' }, {});
+
+ let error: TypeError | null = null;
+ try {
+ (container.getState().a as any) = 'c';
+ } catch (err) {
+ error = err;
+ }
+
+ expect(error).toBeInstanceOf(TypeError);
+});
+
+test('throws when state is modified inline in subscription', done => {
+ const container = createStateContainer({ a: 'b' }, { set: () => (newState: any) => newState });
+
+ container.subscribe(value => {
+ let error: TypeError | null = null;
+ try {
+ (value.a as any) = 'd';
+ } catch (err) {
+ error = err;
+ }
+ expect(error).toBeInstanceOf(TypeError);
+ done();
+ });
+ container.transitions.set({ a: 'c' });
+});
+
+describe('selectors', () => {
+ test('can specify no selectors, or can skip them', () => {
+ createStateContainer({}, {});
+ createStateContainer({}, {}, {});
+ });
+
+ test('selector object is available on .selectors key', () => {
+ const container1 = createStateContainer({}, {}, {});
+ const container2 = createStateContainer({}, {}, { foo: () => () => 123 });
+ const container3 = createStateContainer({}, {}, { bar: () => () => 1, baz: () => () => 1 });
+
+ expect(Object.keys(container1.selectors).sort()).toEqual([]);
+ expect(Object.keys(container2.selectors).sort()).toEqual(['foo']);
+ expect(Object.keys(container3.selectors).sort()).toEqual(['bar', 'baz']);
+ });
+
+ test('selector without arguments returns correct state slice', () => {
+ const container = createStateContainer(
+ { name: 'Oleg' },
+ {
+ changeName: (state: { name: string }) => (name: string) => ({ ...state, name }),
+ },
+ { getName: (state: { name: string }) => () => state.name }
+ );
+
+ expect(container.selectors.getName()).toBe('Oleg');
+ container.transitions.changeName('Britney');
+ expect(container.selectors.getName()).toBe('Britney');
+ });
+
+ test('selector can accept an argument', () => {
+ const container = createStateContainer(
+ {
+ users: {
+ 1: {
+ name: 'Darth',
+ },
+ },
+ },
+ {},
+ {
+ getUser: (state: any) => (id: number) => state.users[id],
+ }
+ );
+
+ expect(container.selectors.getUser(1)).toEqual({ name: 'Darth' });
+ expect(container.selectors.getUser(2)).toBe(undefined);
+ });
+
+ test('selector can accept multiple arguments', () => {
+ const container = createStateContainer(
+ {
+ users: {
+ 5: {
+ name: 'Darth',
+ surname: 'Vader',
+ },
+ },
+ },
+ {},
+ {
+ getName: (state: any) => (id: number, which: 'name' | 'surname') => state.users[id][which],
+ }
+ );
+
+ expect(container.selectors.getName(5, 'name')).toEqual('Darth');
+ expect(container.selectors.getName(5, 'surname')).toEqual('Vader');
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts
new file mode 100644
index 0000000000000..1ef4a1c012817
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts
@@ -0,0 +1,89 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { BehaviorSubject } from 'rxjs';
+import { skip } from 'rxjs/operators';
+import { RecursiveReadonly } from '@kbn/utility-types';
+import {
+ PureTransitionsToTransitions,
+ PureTransition,
+ ReduxLikeStateContainer,
+ PureSelectorsToSelectors,
+} from './types';
+
+const $$observable = (typeof Symbol === 'function' && (Symbol as any).observable) || '@@observable';
+
+const freeze: (value: T) => RecursiveReadonly =
+ process.env.NODE_ENV !== 'production'
+ ? (value: T): RecursiveReadonly => {
+ if (!value) return value as RecursiveReadonly;
+ if (value instanceof Array) return value as RecursiveReadonly;
+ if (typeof value === 'object') return Object.freeze({ ...value }) as RecursiveReadonly;
+ else return value as RecursiveReadonly;
+ }
+ : (value: T) => value as RecursiveReadonly;
+
+export const createStateContainer = <
+ State,
+ PureTransitions extends object,
+ PureSelectors extends object = {}
+>(
+ defaultState: State,
+ pureTransitions: PureTransitions,
+ pureSelectors: PureSelectors = {} as PureSelectors
+): ReduxLikeStateContainer => {
+ const data$ = new BehaviorSubject>(freeze(defaultState));
+ const state$ = data$.pipe(skip(1));
+ const get = () => data$.getValue();
+ const container: ReduxLikeStateContainer = {
+ get,
+ state$,
+ getState: () => data$.getValue(),
+ set: (state: State) => {
+ data$.next(freeze(state));
+ },
+ reducer: (state, action) => {
+ const pureTransition = (pureTransitions as Record>)[
+ action.type
+ ];
+ return pureTransition ? freeze(pureTransition(state)(...action.args)) : state;
+ },
+ replaceReducer: nextReducer => (container.reducer = nextReducer),
+ dispatch: action => data$.next(container.reducer(get(), action)),
+ transitions: Object.keys(pureTransitions).reduce>(
+ (acc, type) => ({ ...acc, [type]: (...args: any) => container.dispatch({ type, args }) }),
+ {} as PureTransitionsToTransitions
+ ),
+ selectors: Object.keys(pureSelectors).reduce>(
+ (acc, selector) => ({
+ ...acc,
+ [selector]: (...args: any) => (pureSelectors as any)[selector](get())(...args),
+ }),
+ {} as PureSelectorsToSelectors
+ ),
+ addMiddleware: middleware =>
+ (container.dispatch = middleware(container as any)(container.dispatch)),
+ subscribe: (listener: (state: RecursiveReadonly) => void) => {
+ const subscription = state$.subscribe(listener);
+ return () => subscription.unsubscribe();
+ },
+ [$$observable]: state$,
+ };
+ return container;
+};
diff --git a/src/plugins/kibana_utils/public/store/react.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx
similarity index 65%
rename from src/plugins/kibana_utils/public/store/react.test.tsx
rename to src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx
index e629e9d0e1257..8f5810f3e147d 100644
--- a/src/plugins/kibana_utils/public/store/react.test.tsx
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx
@@ -20,8 +20,17 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { act, Simulate } from 'react-dom/test-utils';
-import { createStore } from './create_store';
-import { createContext } from './react';
+import { createStateContainer } from './create_state_container';
+import { createStateContainerReactHelpers } from './create_state_container_react_helpers';
+
+const create = (state: S, transitions: T = {} as T) => {
+ const pureTransitions = {
+ set: () => (newState: S) => newState,
+ ...transitions,
+ };
+ const store = createStateContainer(state, pureTransitions);
+ return { store, mutators: store.transitions };
+};
let container: HTMLDivElement | null;
@@ -36,27 +45,23 @@ afterEach(() => {
});
test('can create React context', () => {
- const store = createStore({ foo: 'bar' });
- const context = createContext(store);
+ const context = createStateContainerReactHelpers();
expect(context).toMatchObject({
- Provider: expect.any(Function),
- Consumer: expect.any(Function),
+ Provider: expect.any(Object),
+ Consumer: expect.any(Object),
connect: expect.any(Function),
- context: {
- Provider: expect.any(Object),
- Consumer: expect.any(Object),
- },
+ context: expect.any(Object),
});
});
test(' passes state to ', () => {
- const store = createStore({ hello: 'world' });
- const { Provider, Consumer } = createContext(store);
+ const { store } = create({ hello: 'world' });
+ const { Provider, Consumer } = createStateContainerReactHelpers();
ReactDOM.render(
-
- {({ hello }) => hello}
+
+ {(s: typeof store) => s.get().hello}
,
container
);
@@ -74,8 +79,8 @@ interface Props1 {
}
test(' passes state to connect()()', () => {
- const store = createStore({ hello: 'Bob' });
- const { Provider, connect } = createContext(store);
+ const { store } = create({ hello: 'Bob' });
+ const { Provider, connect } = createStateContainerReactHelpers();
const Demo: React.FC = ({ message, stop }) => (
<>
@@ -87,7 +92,7 @@ test(' passes state to connect()()', () => {
const DemoConnected = connect(mergeProps)(Demo);
ReactDOM.render(
-
+
,
container
@@ -97,13 +102,13 @@ test(' passes state to connect()()', () => {
});
test('context receives Redux store', () => {
- const store = createStore({ foo: 'bar' });
- const { Provider, context } = createContext(store);
+ const { store } = create({ foo: 'bar' });
+ const { Provider, context } = createStateContainerReactHelpers();
ReactDOM.render(
/* eslint-disable no-shadow */
-
- {({ store }) => store.getState().foo}
+
+ {store => store.get().foo}
,
/* eslint-enable no-shadow */
container
@@ -117,16 +122,16 @@ xtest('can use multiple stores in one React app', () => {});
describe('hooks', () => {
describe('useStore', () => {
test('can select store using useStore hook', () => {
- const store = createStore({ foo: 'bar' });
- const { Provider, useStore } = createContext(store);
+ const { store } = create({ foo: 'bar' });
+ const { Provider, useContainer } = createStateContainerReactHelpers();
const Demo: React.FC<{}> = () => {
// eslint-disable-next-line no-shadow
- const store = useStore();
+ const store = useContainer();
return <>{store.get().foo}>;
};
ReactDOM.render(
-
+
,
container
@@ -138,15 +143,15 @@ describe('hooks', () => {
describe('useState', () => {
test('can select state using useState hook', () => {
- const store = createStore({ foo: 'qux' });
- const { Provider, useState } = createContext(store);
+ const { store } = create({ foo: 'qux' });
+ const { Provider, useState } = createStateContainerReactHelpers();
const Demo: React.FC<{}> = () => {
const { foo } = useState();
return <>{foo}>;
};
ReactDOM.render(
-
+
,
container
@@ -156,18 +161,23 @@ describe('hooks', () => {
});
test('re-renders when state changes', () => {
- const store = createStore({ foo: 'bar' });
- const { setFoo } = store.createMutators({
- setFoo: state => foo => ({ ...state, foo }),
- });
- const { Provider, useState } = createContext(store);
+ const {
+ store,
+ mutators: { setFoo },
+ } = create(
+ { foo: 'bar' },
+ {
+ setFoo: (state: { foo: string }) => (foo: string) => ({ ...state, foo }),
+ }
+ );
+ const { Provider, useState } = createStateContainerReactHelpers();
const Demo: React.FC<{}> = () => {
const { foo } = useState();
return <>{foo}>;
};
ReactDOM.render(
-
+
,
container
@@ -181,26 +191,31 @@ describe('hooks', () => {
});
});
- describe('useMutations', () => {
- test('useMutations hook returns mutations that can update state', () => {
- const store = createStore<
+ describe('useTransitions', () => {
+ test('useTransitions hook returns mutations that can update state', () => {
+ const { store } = create<
{
cnt: number;
},
+ any
+ >(
{
- increment: (value: number) => void;
+ cnt: 0,
+ },
+ {
+ increment: (state: { cnt: number }) => (value: number) => ({
+ ...state,
+ cnt: state.cnt + value,
+ }),
}
- >({
- cnt: 0,
- });
- store.createMutators({
- increment: state => value => ({ ...state, cnt: state.cnt + value }),
- });
+ );
- const { Provider, useState, useMutators } = createContext(store);
+ const { Provider, useState, useTransitions } = createStateContainerReactHelpers<
+ typeof store
+ >();
const Demo: React.FC<{}> = () => {
const { cnt } = useState();
- const { increment } = useMutators();
+ const { increment } = useTransitions();
return (
<>
{cnt}
@@ -210,7 +225,7 @@ describe('hooks', () => {
};
ReactDOM.render(
-
+
,
container
@@ -230,7 +245,7 @@ describe('hooks', () => {
describe('useSelector', () => {
test('can select deeply nested value', () => {
- const store = createStore({
+ const { store } = create({
foo: {
bar: {
baz: 'qux',
@@ -238,14 +253,14 @@ describe('hooks', () => {
},
});
const selector = (state: { foo: { bar: { baz: string } } }) => state.foo.bar.baz;
- const { Provider, useSelector } = createContext(store);
+ const { Provider, useSelector } = createStateContainerReactHelpers();
const Demo: React.FC<{}> = () => {
const value = useSelector(selector);
return <>{value}>;
};
ReactDOM.render(
-
+
,
container
@@ -255,7 +270,7 @@ describe('hooks', () => {
});
test('re-renders when state changes', () => {
- const store = createStore({
+ const { store, mutators } = create({
foo: {
bar: {
baz: 'qux',
@@ -263,14 +278,14 @@ describe('hooks', () => {
},
});
const selector = (state: { foo: { bar: { baz: string } } }) => state.foo.bar.baz;
- const { Provider, useSelector } = createContext(store);
+ const { Provider, useSelector } = createStateContainerReactHelpers();
const Demo: React.FC<{}> = () => {
const value = useSelector(selector);
return <>{value}>;
};
ReactDOM.render(
-
+
,
container
@@ -278,7 +293,7 @@ describe('hooks', () => {
expect(container!.innerHTML).toBe('qux');
act(() => {
- store.set({
+ mutators.set({
foo: {
bar: {
baz: 'quux',
@@ -290,9 +305,9 @@ describe('hooks', () => {
});
test("re-renders only when selector's result changes", async () => {
- const store = createStore({ a: 'b', foo: 'bar' });
+ const { store, mutators } = create({ a: 'b', foo: 'bar' });
const selector = (state: { foo: string }) => state.foo;
- const { Provider, useSelector } = createContext(store);
+ const { Provider, useSelector } = createStateContainerReactHelpers();
let cnt = 0;
const Demo: React.FC<{}> = () => {
@@ -301,7 +316,7 @@ describe('hooks', () => {
return <>{value}>;
};
ReactDOM.render(
-
+
,
container
@@ -311,24 +326,24 @@ describe('hooks', () => {
expect(cnt).toBe(1);
act(() => {
- store.set({ a: 'c', foo: 'bar' });
+ mutators.set({ a: 'c', foo: 'bar' });
});
await new Promise(r => setTimeout(r, 1));
expect(cnt).toBe(1);
act(() => {
- store.set({ a: 'd', foo: 'bar 2' });
+ mutators.set({ a: 'd', foo: 'bar 2' });
});
await new Promise(r => setTimeout(r, 1));
expect(cnt).toBe(2);
});
- test('re-renders on same shape object', async () => {
- const store = createStore({ foo: { bar: 'baz' } });
+ test('does not re-render on same shape object', async () => {
+ const { store, mutators } = create({ foo: { bar: 'baz' } });
const selector = (state: { foo: any }) => state.foo;
- const { Provider, useSelector } = createContext(store);
+ const { Provider, useSelector } = createStateContainerReactHelpers();
let cnt = 0;
const Demo: React.FC<{}> = () => {
@@ -337,7 +352,7 @@ describe('hooks', () => {
return <>{JSON.stringify(value)}>;
};
ReactDOM.render(
-
+
,
container
@@ -347,7 +362,14 @@ describe('hooks', () => {
expect(cnt).toBe(1);
act(() => {
- store.set({ foo: { bar: 'baz' } });
+ mutators.set({ foo: { bar: 'baz' } });
+ });
+
+ await new Promise(r => setTimeout(r, 1));
+ expect(cnt).toBe(1);
+
+ act(() => {
+ mutators.set({ foo: { bar: 'qux' } });
});
await new Promise(r => setTimeout(r, 1));
@@ -355,10 +377,15 @@ describe('hooks', () => {
});
test('can set custom comparator function to prevent re-renders on deep equality', async () => {
- const store = createStore({ foo: { bar: 'baz' } });
+ const { store, mutators } = create(
+ { foo: { bar: 'baz' } },
+ {
+ set: () => (newState: { foo: { bar: string } }) => newState,
+ }
+ );
const selector = (state: { foo: any }) => state.foo;
const comparator = (prev: any, curr: any) => JSON.stringify(prev) === JSON.stringify(curr);
- const { Provider, useSelector } = createContext(store);
+ const { Provider, useSelector } = createStateContainerReactHelpers();
let cnt = 0;
const Demo: React.FC<{}> = () => {
@@ -367,7 +394,7 @@ describe('hooks', () => {
return <>{JSON.stringify(value)}>;
};
ReactDOM.render(
-
+
,
container
@@ -377,7 +404,7 @@ describe('hooks', () => {
expect(cnt).toBe(1);
act(() => {
- store.set({ foo: { bar: 'baz' } });
+ mutators.set({ foo: { bar: 'baz' } });
});
await new Promise(r => setTimeout(r, 1));
diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts
new file mode 100644
index 0000000000000..e94165cc48376
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 * as React from 'react';
+import useObservable from 'react-use/lib/useObservable';
+import defaultComparator from 'fast-deep-equal';
+import { Comparator, Connect, StateContainer, UnboxState } from './types';
+
+const { useContext, useLayoutEffect, useRef, createElement: h } = React;
+
+export const createStateContainerReactHelpers = >() => {
+ const context = React.createContext(null as any);
+
+ const useContainer = (): Container => useContext(context);
+
+ const useState = (): UnboxState => {
+ const { state$, get } = useContainer();
+ const value = useObservable(state$, get());
+ return value;
+ };
+
+ const useTransitions = () => useContainer().transitions;
+
+ const useSelector = (
+ selector: (state: UnboxState) => Result,
+ comparator: Comparator = defaultComparator
+ ): Result => {
+ const { state$, get } = useContainer();
+ const lastValueRef = useRef(get());
+ const [value, setValue] = React.useState(() => {
+ const newValue = selector(get());
+ lastValueRef.current = newValue;
+ return newValue;
+ });
+ useLayoutEffect(() => {
+ const subscription = state$.subscribe((currentState: UnboxState) => {
+ const newValue = selector(currentState);
+ if (!comparator(lastValueRef.current, newValue)) {
+ lastValueRef.current = newValue;
+ setValue(newValue);
+ }
+ });
+ return () => subscription.unsubscribe();
+ }, [state$, comparator]);
+ return value;
+ };
+
+ const connect: Connect> = mapStateToProp => component => props =>
+ h(component, { ...useSelector(mapStateToProp), ...props } as any);
+
+ return {
+ Provider: context.Provider,
+ Consumer: context.Consumer,
+ context,
+ useContainer,
+ useState,
+ useTransitions,
+ useSelector,
+ connect,
+ };
+};
diff --git a/src/plugins/kibana_utils/public/store/index.ts b/src/plugins/kibana_utils/public/state_containers/index.ts
similarity index 86%
rename from src/plugins/kibana_utils/public/store/index.ts
rename to src/plugins/kibana_utils/public/state_containers/index.ts
index 468e8ab8c5ade..43e204ecb79f7 100644
--- a/src/plugins/kibana_utils/public/store/index.ts
+++ b/src/plugins/kibana_utils/public/state_containers/index.ts
@@ -17,5 +17,6 @@
* under the License.
*/
-export * from './create_store';
-export * from './react';
+export * from './types';
+export * from './create_state_container';
+export * from './create_state_container_react_helpers';
diff --git a/src/plugins/kibana_utils/public/state_containers/types.ts b/src/plugins/kibana_utils/public/state_containers/types.ts
new file mode 100644
index 0000000000000..e0a1a18972635
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_containers/types.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 { Observable } from 'rxjs';
+import { Ensure, RecursiveReadonly } from '@kbn/utility-types';
+
+export interface TransitionDescription {
+ type: Type;
+ args: Args;
+}
+export type Transition = (...args: Args) => State;
+export type PureTransition = (
+ state: RecursiveReadonly
+) => Transition;
+export type EnsurePureTransition = Ensure>;
+export type PureTransitionToTransition> = ReturnType;
+export type PureTransitionsToTransitions = {
+ [K in keyof T]: PureTransitionToTransition>;
+};
+
+export interface BaseStateContainer {
+ get: () => RecursiveReadonly;
+ set: (state: State) => void;
+ state$: Observable>;
+}
+
+export interface StateContainer<
+ State,
+ PureTransitions extends object,
+ PureSelectors extends object = {}
+> extends BaseStateContainer {
+ transitions: Readonly>;
+ selectors: Readonly>;
+}
+
+export interface ReduxLikeStateContainer<
+ State,
+ PureTransitions extends object,
+ PureSelectors extends object = {}
+> extends StateContainer {
+ getState: () => RecursiveReadonly;
+ reducer: Reducer>;
+ replaceReducer: (nextReducer: Reducer>) => void;
+ dispatch: (action: TransitionDescription) => void;
+ addMiddleware: (middleware: Middleware>) => void;
+ subscribe: (listener: (state: RecursiveReadonly) => void) => () => void;
+}
+
+export type Dispatch = (action: T) => void;
+
+export type Middleware = (
+ store: Pick, 'getState' | 'dispatch'>
+) => (
+ next: (action: TransitionDescription) => TransitionDescription | any
+) => Dispatch;
+
+export type Reducer = (state: State, action: TransitionDescription) => State;
+
+export type UnboxState<
+ Container extends StateContainer
+> = Container extends StateContainer ? T : never;
+export type UnboxTransitions<
+ Container extends StateContainer
+> = Container extends StateContainer ? T : never;
+
+export type Selector = (...args: Args) => Result;
+export type PureSelector = (
+ state: State
+) => Selector;
+export type EnsurePureSelector = Ensure>;
+export type PureSelectorToSelector> = ReturnType<
+ EnsurePureSelector
+>;
+export type PureSelectorsToSelectors = {
+ [K in keyof T]: PureSelectorToSelector>;
+};
+
+export type Comparator = (previous: Result, current: Result) => boolean;
+
+export type MapStateToProps = (state: State) => StateProps;
+export type Connect = (
+ mapStateToProp: MapStateToProps>
+) => (component: React.ComponentType) => React.FC>;
diff --git a/src/plugins/kibana_utils/public/store/create_store.test.ts b/src/plugins/kibana_utils/public/store/create_store.test.ts
deleted file mode 100644
index cfdeb76254003..0000000000000
--- a/src/plugins/kibana_utils/public/store/create_store.test.ts
+++ /dev/null
@@ -1,177 +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 { createStore } from './create_store';
-
-test('can create store', () => {
- const store = createStore({});
- expect(store).toMatchObject({
- get: expect.any(Function),
- set: expect.any(Function),
- state$: expect.any(Object),
- createMutators: expect.any(Function),
- mutators: expect.any(Object),
- redux: {
- getState: expect.any(Function),
- dispatch: expect.any(Function),
- subscribe: expect.any(Function),
- },
- });
-});
-
-test('can set default state', () => {
- const defaultState = {
- foo: 'bar',
- };
- const store = createStore(defaultState);
- expect(store.get()).toEqual(defaultState);
- expect(store.redux.getState()).toEqual(defaultState);
-});
-
-test('can set state', () => {
- const defaultState = {
- foo: 'bar',
- };
- const newState = {
- foo: 'baz',
- };
- const store = createStore(defaultState);
-
- store.set(newState);
-
- expect(store.get()).toEqual(newState);
- expect(store.redux.getState()).toEqual(newState);
-});
-
-test('does not shallow merge states', () => {
- const defaultState = {
- foo: 'bar',
- };
- const newState = {
- foo2: 'baz',
- };
- const store = createStore(defaultState);
-
- store.set(newState);
-
- expect(store.get()).toEqual(newState);
- expect(store.redux.getState()).toEqual(newState);
-});
-
-test('can subscribe and unsubscribe to state changes', () => {
- const store = createStore({});
- const spy = jest.fn();
- const subscription = store.state$.subscribe(spy);
- store.set({ a: 1 });
- store.set({ a: 2 });
- subscription.unsubscribe();
- store.set({ a: 3 });
-
- expect(spy).toHaveBeenCalledTimes(2);
- expect(spy.mock.calls[0][0]).toEqual({ a: 1 });
- expect(spy.mock.calls[1][0]).toEqual({ a: 2 });
-});
-
-test('multiple subscribers can subscribe', () => {
- const store = createStore({});
- const spy1 = jest.fn();
- const spy2 = jest.fn();
- const subscription1 = store.state$.subscribe(spy1);
- const subscription2 = store.state$.subscribe(spy2);
- store.set({ a: 1 });
- subscription1.unsubscribe();
- store.set({ a: 2 });
- subscription2.unsubscribe();
- store.set({ a: 3 });
-
- expect(spy1).toHaveBeenCalledTimes(1);
- expect(spy2).toHaveBeenCalledTimes(2);
- expect(spy1.mock.calls[0][0]).toEqual({ a: 1 });
- expect(spy2.mock.calls[0][0]).toEqual({ a: 1 });
- expect(spy2.mock.calls[1][0]).toEqual({ a: 2 });
-});
-
-test('creates impure mutators from pure mutators', () => {
- const store = createStore({});
- const mutators = store.createMutators({
- setFoo: _ => bar => ({ foo: bar }),
- });
-
- expect(typeof mutators.setFoo).toBe('function');
-});
-
-test('mutators can update state', () => {
- const store = createStore({
- value: 0,
- foo: 'bar',
- });
- const mutators = store.createMutators({
- add: state => increment => ({ ...state, value: state.value + increment }),
- setFoo: state => bar => ({ ...state, foo: bar }),
- });
-
- expect(store.get()).toEqual({
- value: 0,
- foo: 'bar',
- });
-
- mutators.add(11);
- mutators.setFoo('baz');
-
- expect(store.get()).toEqual({
- value: 11,
- foo: 'baz',
- });
-
- mutators.add(-20);
- mutators.setFoo('bazooka');
-
- expect(store.get()).toEqual({
- value: -9,
- foo: 'bazooka',
- });
-});
-
-test('mutators methods are not bound', () => {
- const store = createStore({ value: -3 });
- const { add } = store.createMutators({
- add: state => increment => ({ ...state, value: state.value + increment }),
- });
-
- expect(store.get()).toEqual({ value: -3 });
- add(4);
- expect(store.get()).toEqual({ value: 1 });
-});
-
-test('created mutators are saved in store object', () => {
- const store = createStore<
- any,
- {
- add: (increment: number) => void;
- }
- >({ value: -3 });
-
- store.createMutators({
- add: state => increment => ({ ...state, value: state.value + increment }),
- });
-
- expect(typeof store.mutators.add).toBe('function');
- store.mutators.add(5);
- expect(store.get()).toEqual({ value: 2 });
-});
diff --git a/src/plugins/kibana_utils/public/store/create_store.ts b/src/plugins/kibana_utils/public/store/create_store.ts
deleted file mode 100644
index 315523360f92d..0000000000000
--- a/src/plugins/kibana_utils/public/store/create_store.ts
+++ /dev/null
@@ -1,85 +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 { createStore as createReduxStore, Reducer } from 'redux';
-import { Subject, Observable } from 'rxjs';
-import { AppStore, Mutators, PureMutators } from './types';
-
-const SET = '__SET__';
-
-export const createStore = <
- State extends {},
- StateMutators extends Mutators> = {}
->(
- defaultState: State
-): AppStore => {
- const pureMutators: PureMutators = {};
- const mutators: StateMutators = {} as StateMutators;
- const reducer: Reducer = (state, action) => {
- const pureMutator = pureMutators[action.type];
- if (pureMutator) {
- return pureMutator(state)(...action.args);
- }
-
- switch (action.type) {
- case SET:
- return action.state;
- default:
- return state;
- }
- };
- const redux = createReduxStore(reducer, defaultState as any);
-
- const get = redux.getState;
-
- const set = (state: State) =>
- redux.dispatch({
- type: SET,
- state,
- });
-
- const state$ = new Subject();
- redux.subscribe(() => {
- state$.next(get());
- });
-
- const createMutators: AppStore['createMutators'] = newPureMutators => {
- const result: Mutators = {};
- for (const type of Object.keys(newPureMutators)) {
- result[type] = (...args) => {
- redux.dispatch({
- type,
- args,
- });
- };
- }
- Object.assign(pureMutators, newPureMutators);
- Object.assign(mutators, result);
- return result;
- };
-
- return {
- get,
- set,
- redux,
- state$: (state$ as unknown) as Observable,
- createMutators,
- mutators,
- };
-};
diff --git a/src/plugins/kibana_utils/public/store/observable_selector.ts b/src/plugins/kibana_utils/public/store/observable_selector.ts
deleted file mode 100644
index 6ba6f42296a6c..0000000000000
--- a/src/plugins/kibana_utils/public/store/observable_selector.ts
+++ /dev/null
@@ -1,47 +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 { Observable, BehaviorSubject } from 'rxjs';
-
-export type Selector = (state: State) => Result;
-export type Comparator = (previous: Result, current: Result) => boolean;
-export type Unsubscribe = () => void;
-
-const defaultComparator: Comparator = (previous, current) => previous === current;
-
-export const observableSelector = (
- state: State,
- state$: Observable,
- selector: Selector,
- comparator: Comparator = defaultComparator
-): [Observable, Unsubscribe] => {
- let previousResult: Result = selector(state);
- const result$ = new BehaviorSubject(previousResult);
-
- const subscription = state$.subscribe(value => {
- const result = selector(value);
- const isEqual: boolean = comparator(previousResult, result);
- if (!isEqual) {
- result$.next(result);
- }
- previousResult = result;
- });
-
- return [(result$ as unknown) as Observable, subscription.unsubscribe];
-};
diff --git a/src/plugins/kibana_utils/public/store/react.ts b/src/plugins/kibana_utils/public/store/react.ts
deleted file mode 100644
index 00861b2b0b8fe..0000000000000
--- a/src/plugins/kibana_utils/public/store/react.ts
+++ /dev/null
@@ -1,126 +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 * as React from 'react';
-import { Provider as ReactReduxProvider, connect as reactReduxConnect } from 'react-redux';
-import { Store } from 'redux';
-import { AppStore, Mutators, PureMutators } from './types';
-import { observableSelector, Selector, Comparator } from './observable_selector';
-// TODO: Below import is temporary, use `react-use` lib instead.
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { useObservable } from '../../../kibana_react/public/util/use_observable';
-
-const { useMemo, useLayoutEffect, useContext, createElement, Fragment } = React;
-
-/**
- * @note
- * Types in `react-redux` seem to be quite off compared to reality
- * that's why a lot of `any`s below.
- */
-
-export interface ConsumerProps {
- children: (state: State) => React.ReactChild;
-}
-
-export type MapStateToProps = (state: State) => StateProps;
-
-// TODO: `Omit` is generally part of TypeScript, but it currently does not exist in our build.
-type Omit = Pick>;
-export type Connect = (
- mapStateToProp: MapStateToProps>
-) => (component: React.ComponentType) => React.FC>;
-
-interface ReduxContextValue {
- store: Store;
-}
-
-const mapDispatchToProps = () => ({});
-const mergeProps: any = (stateProps: any, dispatchProps: any, ownProps: any) => ({
- ...ownProps,
- ...stateProps,
- ...dispatchProps,
-});
-
-export const createContext = <
- State extends {},
- StateMutators extends Mutators> = {}
->(
- store: AppStore
-) => {
- const { redux } = store;
- (redux as any).__appStore = store;
- const context = React.createContext({ store: redux });
-
- const useStore = (): AppStore => {
- // eslint-disable-next-line no-shadow
- const { store } = useContext(context);
- return (store as any).__appStore;
- };
-
- const useState = (): State => {
- const { state$, get } = useStore();
- const state = useObservable(state$, get());
- return state;
- };
-
- const useMutators = (): StateMutators => useStore().mutators;
-
- const useSelector = (
- selector: Selector,
- comparator?: Comparator
- ): Result => {
- const { state$, get } = useStore();
- /* eslint-disable react-hooks/exhaustive-deps */
- const [observable$, unsubscribe] = useMemo(
- () => observableSelector(get(), state$, selector, comparator),
- [state$]
- );
- /* eslint-enable react-hooks/exhaustive-deps */
- useLayoutEffect(() => unsubscribe, [observable$, unsubscribe]);
- const value = useObservable(observable$, selector(get()));
- return value;
- };
-
- const Provider: React.FC<{}> = ({ children }) =>
- createElement(ReactReduxProvider, {
- store: redux,
- context,
- children,
- } as any);
-
- const Consumer: React.FC> = ({ children }) => {
- const state = useState();
- return createElement(Fragment, { children: children(state) });
- };
-
- const options: any = { context };
- const connect: Connect = mapStateToProps =>
- reactReduxConnect(mapStateToProps, mapDispatchToProps, mergeProps, options) as any;
-
- return {
- Provider,
- Consumer,
- connect,
- context,
- useStore,
- useState,
- useMutators,
- useSelector,
- };
-};
diff --git a/src/test_utils/public/stub_index_pattern.js b/src/test_utils/public/stub_index_pattern.js
index b76da4b4eca3e..f4659ffa120d4 100644
--- a/src/test_utils/public/stub_index_pattern.js
+++ b/src/test_utils/public/stub_index_pattern.js
@@ -21,20 +21,26 @@ import sinon from 'sinon';
// TODO: We should not be importing from the data plugin directly here; this is only necessary
// because it is one of the few places that we need to access the IndexPattern class itself, rather
// than just the type. Doing this as a temporary measure; it will be left behind when migrating to NP.
-import { IndexPattern } from '../../legacy/core_plugins/data/public/';
+
import {
FieldList,
- getRoutes,
- formatHitProvider,
- flattenHitWrapper,
-} from 'ui/index_patterns';
-import {
FIELD_FORMAT_IDS,
+ IndexPattern,
+ indexPatterns,
} from '../../plugins/data/public';
+import { setFieldFormats } from '../../plugins/data/public/services';
+
+setFieldFormats({
+ getDefaultInstance: () => ({
+ getConverterFor: () => value => value,
+ convert: value => JSON.stringify(value)
+ }),
+});
+
import { getFieldFormatsRegistry } from './stub_field_formats';
-export default function StubIndexPattern(pattern, getConfig, timeField, fields, uiSettings) {
+export default function StubIndexPattern(pattern, getConfig, timeField, fields, uiSettings) {
const registeredFieldFormats = getFieldFormatsRegistry(uiSettings);
this.id = pattern;
@@ -49,11 +55,11 @@ export default function StubIndexPattern(pattern, getConfig, timeField, fields,
this.getSourceFiltering = sinon.stub();
this.metaFields = ['_id', '_type', '_source'];
this.fieldFormatMap = {};
- this.routes = getRoutes();
+ this.routes = indexPatterns.getRoutes();
this.getComputedFields = IndexPattern.prototype.getComputedFields.bind(this);
- this.flattenHit = flattenHitWrapper(this, this.metaFields);
- this.formatHit = formatHitProvider(this, registeredFieldFormats.getDefaultInstance(FIELD_FORMAT_IDS.STRING));
+ this.flattenHit = indexPatterns.flattenHitWrapper(this, this.metaFields);
+ this.formatHit = indexPatterns.formatHitProvider(this, registeredFieldFormats.getDefaultInstance(FIELD_FORMAT_IDS.STRING));
this.fieldsFetcher = { apiClient: { baseUrl: '' } };
this.formatField = this.formatHit.formatField;
diff --git a/test/functional/apps/discover/_saved_queries.js b/test/functional/apps/discover/_saved_queries.js
index 8fbc40f86e8dc..3ae8f51fb76dc 100644
--- a/test/functional/apps/discover/_saved_queries.js
+++ b/test/functional/apps/discover/_saved_queries.js
@@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker']);
+ const browser = getService('browser');
const defaultSettings = {
defaultIndex: 'logstash-*',
@@ -86,6 +87,17 @@ export default function ({ getService, getPageObjects }) {
expect(timePickerValues.end).to.not.eql(PageObjects.timePicker.defaultEndTime);
});
+ it('preserves the currently loaded query when the page is reloaded', async () => {
+ await browser.refresh();
+ const timePickerValues = await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes();
+ expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true);
+ expect(timePickerValues.start).to.not.eql(PageObjects.timePicker.defaultStartTime);
+ expect(timePickerValues.end).to.not.eql(PageObjects.timePicker.defaultEndTime);
+ expect(await PageObjects.discover.getHitCount()).to.be('2,792');
+ expect(await savedQueryManagementComponent.getCurrentlyLoadedQueryID()).to.be('OkResponse');
+ });
+
+
it('allows saving changes to a currently loaded query via the saved query management component', async () => {
await queryBar.setQuery('response:404');
await savedQueryManagementComponent.updateCurrentlyLoadedQuery(
diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts
index 937f703308881..ed45b3fbe069b 100644
--- a/test/functional/page_objects/common_page.ts
+++ b/test/functional/page_objects/common_page.ts
@@ -288,7 +288,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
}
async getSharedItemContainers() {
- const cssSelector = '[data-shared-item-container]';
+ const cssSelector = '[data-shared-items-container]';
return find.allByCssSelector(cssSelector);
}
diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts
index d6de0be0c172e..9f0a8ded649b2 100644
--- a/test/functional/services/saved_query_management_component.ts
+++ b/test/functional/services/saved_query_management_component.ts
@@ -26,6 +26,15 @@ export function SavedQueryManagementComponentProvider({ getService }: FtrProvide
const retry = getService('retry');
class SavedQueryManagementComponent {
+ public async getCurrentlyLoadedQueryID() {
+ await this.openSavedQueryManagementComponent();
+ try {
+ return await testSubjects.getVisibleText('~saved-query-list-item-selected');
+ } catch {
+ return undefined;
+ }
+ }
+
public async saveNewQuery(
name: string,
description: string,
diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy
index 5b3cd071316e6..77907a07addd1 100644
--- a/vars/kibanaPipeline.groovy
+++ b/vars/kibanaPipeline.groovy
@@ -262,10 +262,13 @@ def buildXpack() {
}
def runErrorReporter() {
+ def status = buildUtils.getBuildStatus()
+ def dryRun = status != "ABORTED" ? "" : "--no-github-update"
+
bash(
"""
source src/dev/ci_setup/setup_env.sh
- node scripts/report_failed_tests
+ node scripts/report_failed_tests ${dryRun}
""",
"Report failed tests, if necessary"
)
diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json
index 6d0da2f0b693d..180aafe504c63 100644
--- a/x-pack/.i18nrc.json
+++ b/x-pack/.i18nrc.json
@@ -9,6 +9,7 @@
"xpack.canvas": "legacy/plugins/canvas",
"xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication",
"xpack.dashboardMode": "legacy/plugins/dashboard_mode",
+ "xpack.endpoint": "plugins/endpoint",
"xpack.features": "plugins/features",
"xpack.fileUpload": "legacy/plugins/file_upload",
"xpack.graph": "legacy/plugins/graph",
@@ -18,20 +19,20 @@
"xpack.infra": "legacy/plugins/infra",
"xpack.kueryAutocomplete": "legacy/plugins/kuery_autocomplete",
"xpack.lens": "legacy/plugins/lens",
- "xpack.licensing": "plugins/licensing",
"xpack.licenseMgmt": "legacy/plugins/license_management",
- "xpack.maps": "legacy/plugins/maps",
- "xpack.ml": "legacy/plugins/ml",
+ "xpack.licensing": "plugins/licensing",
"xpack.logstash": "legacy/plugins/logstash",
"xpack.main": "legacy/plugins/xpack_main",
+ "xpack.maps": "legacy/plugins/maps",
+ "xpack.ml": "legacy/plugins/ml",
"xpack.monitoring": "legacy/plugins/monitoring",
"xpack.remoteClusters": "legacy/plugins/remote_clusters",
"xpack.reporting": [ "plugins/reporting", "legacy/plugins/reporting" ],
"xpack.rollupJobs": "legacy/plugins/rollup",
"xpack.searchProfiler": "legacy/plugins/searchprofiler",
- "xpack.siem": "legacy/plugins/siem",
"xpack.security": ["legacy/plugins/security", "plugins/security"],
"xpack.server": "legacy/server",
+ "xpack.siem": "legacy/plugins/siem",
"xpack.snapshotRestore": "legacy/plugins/snapshot_restore",
"xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"],
"xpack.taskManager": "legacy/plugins/task_manager",
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx
new file mode 100644
index 0000000000000..21a39e19657a1
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { ServiceNodeMetrics } from '.';
+
+describe('ServiceNodeMetrics', () => {
+ describe('render', () => {
+ it('renders', () => {
+ expect(() => shallow(