diff --git a/package.json b/package.json index c3d19367e8b92..59592dfdd4890 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "encode-uri-query": "1.0.1", "execa": "^3.2.0", "expiry-js": "0.1.7", + "fast-deep-equal": "^3.1.1", "file-loader": "4.2.0", "font-awesome": "4.7.0", "getos": "^3.1.0", @@ -228,6 +229,7 @@ "react-resize-detector": "^4.2.0", "react-router-dom": "^4.3.1", "react-sizeme": "^2.3.6", + "react-use": "^13.10.2", "reactcss": "1.2.3", "redux": "4.0.0", "redux-actions": "2.2.1", diff --git a/packages/kbn-utility-types/README.md b/packages/kbn-utility-types/README.md index ff6c7c7268a15..9707ff5a1ed9c 100644 --- a/packages/kbn-utility-types/README.md +++ b/packages/kbn-utility-types/README.md @@ -18,7 +18,9 @@ type B = UnwrapPromise; // string ## Reference -- `UnwrapPromise` — Returns wrapped type of a promise. -- `UnwrapObservable` — Returns wrapped type of an observable. -- `ShallowPromise` — Same as `Promise` type, but it flat maps the wrapped type. +- `Ensure` — Makes sure `T` is of type `X`. - `ObservableLike` — Minimal interface for an object resembling an `Observable`. +- `RecursiveReadonly` — Like `Readonly`, but freezes object recursively. +- `ShallowPromise` — Same as `Promise` type, but it flat maps the wrapped type. +- `UnwrapObservable` — Returns wrapped type of an observable. +- `UnwrapPromise` — Returns wrapped type of a promise. diff --git a/packages/kbn-utility-types/index.ts b/packages/kbn-utility-types/index.ts index f17890528bfd2..495b5fb374b43 100644 --- a/packages/kbn-utility-types/index.ts +++ b/packages/kbn-utility-types/index.ts @@ -42,3 +42,19 @@ export type UnwrapObservable> = T extends Observab * Converts a type to a `Promise`, unless it is already a `Promise`. Useful when proxying the return value of a possibly async function. */ export type ShallowPromise = T extends Promise ? Promise : Promise; + +/** + * Ensures T is of type X. + */ +export type Ensure = T extends X ? T : never; + +// If we define this inside RecursiveReadonly TypeScript complains. +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface RecursiveReadonlyArray extends Array> {} +export type RecursiveReadonly = T extends (...args: any) => any + ? T + : T extends any[] + ? RecursiveReadonlyArray + : T extends object + ? Readonly<{ [K in keyof T]: RecursiveReadonly }> + : T; diff --git a/src/plugins/kibana_utils/README.md b/src/plugins/kibana_utils/README.md index 61ceea2b18385..5501505dbb7e2 100644 --- a/src/plugins/kibana_utils/README.md +++ b/src/plugins/kibana_utils/README.md @@ -2,4 +2,4 @@ Utilities for building Kibana plugins. -- [Store reactive serializable app state in state containers, `createStore`](./docs/store/README.md). +- [State containers](./docs/state_containers/README.md). diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts new file mode 100644 index 0000000000000..4e792ceef117a --- /dev/null +++ b/src/plugins/kibana_utils/demos/demos.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { result as counterResult } from './state_containers/counter'; +import { result as todomvcResult } from './state_containers/todomvc'; + +describe('demos', () => { + describe('state containers', () => { + test('counter demo works', () => { + expect(counterResult).toBe(10); + }); + + test('TodoMVC demo works', () => { + expect(todomvcResult).toEqual([ + { id: 0, text: 'Learning state containers', completed: true }, + { id: 1, text: 'Learning transitions...', completed: true }, + ]); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/store/types.ts b/src/plugins/kibana_utils/demos/state_containers/counter.ts similarity index 51% rename from src/plugins/kibana_utils/public/store/types.ts rename to src/plugins/kibana_utils/demos/state_containers/counter.ts index 952ee07f18baf..643763cc4cee9 100644 --- a/src/plugins/kibana_utils/public/store/types.ts +++ b/src/plugins/kibana_utils/demos/state_containers/counter.ts @@ -17,26 +17,16 @@ * under the License. */ -import { Observable } from 'rxjs'; -import { Store as ReduxStore } from 'redux'; +import { createStateContainer } from '../../public/state_containers'; -export interface AppStore< - State extends {}, - StateMutators extends Mutators> = {} -> { - redux: ReduxStore; - get: () => State; - set: (state: State) => void; - state$: Observable; - createMutators: >(pureMutators: M) => Mutators; - mutators: StateMutators; -} +const container = createStateContainer(0, { + increment: (cnt: number) => (by: number) => cnt + by, + double: (cnt: number) => () => cnt * 2, +}); -export type PureMutator = (state: State) => (...args: any[]) => State; -export type Mutator> = (...args: Parameters>) => void; +container.transitions.increment(5); +container.transitions.double(); -export interface PureMutators { - [name: string]: PureMutator; -} +console.log(container.get()); // eslint-disable-line -export type Mutators> = { [K in keyof M]: Mutator }; +export const result = container.get(); diff --git a/src/plugins/kibana_utils/demos/state_containers/todomvc.ts b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts new file mode 100644 index 0000000000000..6d0c960e2a5b2 --- /dev/null +++ b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts @@ -0,0 +1,69 @@ +/* + * 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, PureTransition } from '../../public/state_containers'; + +export interface TodoItem { + text: string; + completed: boolean; + id: number; +} + +export type TodoState = TodoItem[]; + +export const defaultState: TodoState = [ + { + id: 0, + text: 'Learning state containers', + completed: false, + }, +]; + +export interface TodoActions { + add: PureTransition; + edit: PureTransition; + delete: PureTransition; + complete: PureTransition; + completeAll: PureTransition; + clearCompleted: PureTransition; +} + +export const pureTransitions: TodoActions = { + add: state => todo => [...state, todo], + edit: state => todo => state.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), + delete: state => id => state.filter(item => item.id !== id), + complete: state => id => + state.map(item => (item.id === id ? { ...item, completed: true } : item)), + completeAll: state => () => state.map(item => ({ ...item, completed: true })), + clearCompleted: state => () => state.filter(({ completed }) => !completed), +}; + +const container = createStateContainer(defaultState, pureTransitions); + +container.transitions.add({ + id: 1, + text: 'Learning transitions...', + completed: false, +}); +container.transitions.complete(0); +container.transitions.complete(1); + +console.log(container.get()); // eslint-disable-line + +export const result = container.get(); diff --git a/src/plugins/kibana_utils/docs/state_containers/README.md b/src/plugins/kibana_utils/docs/state_containers/README.md new file mode 100644 index 0000000000000..3b7a8b8bd4621 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_containers/README.md @@ -0,0 +1,50 @@ +# State containers + +State containers are Redux-store-like objects meant to help you manage state in +your services or apps. + +- State containers are strongly typed, you will get TypeScript autocompletion suggestions from + your editor when accessing state, executing transitions and using React helpers. +- State containers can be easily hooked up with your React components. +- State containers can be used without React, too. +- State containers provide you central place where to store state, instead of spreading + state around multiple RxJs observables, which you need to coordinate. With state + container you can always access the latest state snapshot synchronously. +- Unlike Redux, state containers are less verbose, see example below. + + +## Example + +```ts +import { createStateContainer } from 'src/plugins/kibana_utils'; + +const container = createStateContainer(0, { + increment: (cnt: number) => (by: number) => cnt + by, + double: (cnt: number) => () => cnt * 2, +}); + +container.transitions.increment(5); +container.transitions.double(); +console.log(container.get()); // 10 +``` + + +## Demos + +See demos [here](../../demos/state_containers/). + +You can run them with + +``` +npx -q ts-node src/plugins/kibana_utils/demos/state_containers/counter.ts +npx -q ts-node src/plugins/kibana_utils/demos/state_containers/todomvc.ts +``` + + +## Reference + +- [Creating a state container](./creation.md). +- [State transitions](./transitions.md). +- [Using with React](./react.md). +- [Using without React`](./no_react.md). +- [Parallels with Redux](./redux.md). diff --git a/src/plugins/kibana_utils/docs/store/creation.md b/src/plugins/kibana_utils/docs/state_containers/creation.md similarity index 54% rename from src/plugins/kibana_utils/docs/store/creation.md rename to src/plugins/kibana_utils/docs/state_containers/creation.md index b0184ad45eb84..66d28bbd8603f 100644 --- a/src/plugins/kibana_utils/docs/store/creation.md +++ b/src/plugins/kibana_utils/docs/state_containers/creation.md @@ -17,7 +17,7 @@ interface MyState { } ``` -Create default state of your *store*. +Create default state of your container. ```ts const defaultState: MyState = { @@ -27,17 +27,12 @@ const defaultState: MyState = { }; ``` -Create your state container, i.e *store*. +Create your a state container. ```ts -import { createStore } from 'kibana-utils'; +import { createStateContainer } from 'src/plugins/kibana_utils'; -const store = createStore(defaultState); -console.log(store.get()); -``` +const container = createStateContainer(defaultState, {}); -> ##### N.B. -> -> State must always be an object `{}`. -> -> You cannot create a store out of an array, e.g ~~`createStore([])`~~. +console.log(container.get()); +``` diff --git a/src/plugins/kibana_utils/docs/store/getters.md b/src/plugins/kibana_utils/docs/state_containers/no_react.md similarity index 83% rename from src/plugins/kibana_utils/docs/store/getters.md rename to src/plugins/kibana_utils/docs/state_containers/no_react.md index 508d0c6ebc18d..7a15483d83b44 100644 --- a/src/plugins/kibana_utils/docs/store/getters.md +++ b/src/plugins/kibana_utils/docs/state_containers/no_react.md @@ -1,4 +1,4 @@ -# Reading state +# Consuming state in non-React setting To read the current `state` of the store use `.get()` method. diff --git a/src/plugins/kibana_utils/docs/state_containers/react.md b/src/plugins/kibana_utils/docs/state_containers/react.md new file mode 100644 index 0000000000000..363fd9253d44f --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_containers/react.md @@ -0,0 +1,41 @@ +# React + +`createStateContainerReactHelpers` factory allows you to easily use state containers with React. + + +## Example + + +```ts +import { createStateContainer, createStateContainerReactHelpers } from 'src/plugins/kibana_utils'; + +const container = createStateContainer({}, {}); +export const { + Provider, + Consumer, + context, + useContainer, + useState, + useTransitions, + useSelector, + connect, +} = createStateContainerReactHelpers(); +``` + +Wrap your app with ``. + +```tsx + + + +``` + + +## Reference + +- [`useContainer()`](./react/use_container.md) +- [`useState()`](./react/use_state.md) +- [`useSelector()`](./react/use_selector.md) +- [`useTransitions()`](./react/use_transitions.md) +- [`connect()()`](./react/connect.md) +- [Context](./react/context.md) diff --git a/src/plugins/kibana_utils/docs/state_containers/react/connect.md b/src/plugins/kibana_utils/docs/state_containers/react/connect.md new file mode 100644 index 0000000000000..56b7e0fbc5673 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_containers/react/connect.md @@ -0,0 +1,22 @@ +# `connect()()` higher order component + +Use `connect()()` higher-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 = createStateContainer({ userName: 'John' }); +const { Provider, connect } = createStateContainerReactHelpers(store); + +const mapStateToProps = ({ userName }) => ({ name: userName }); +const DemoConnected = connect(mapStateToProps)(Demo); + + + + +``` diff --git a/src/plugins/kibana_utils/docs/state_containers/react/context.md b/src/plugins/kibana_utils/docs/state_containers/react/context.md new file mode 100644 index 0000000000000..33f084fdfe9d7 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_containers/react/context.md @@ -0,0 +1,24 @@ +# React context + +`createStateContainerReactHelpers` returns `` and `` components +as well as `context` React context object. + +```ts +export const { + Provider, + Consumer, + context, +} = createStateContainerReactHelpers(); +``` + +`` and `` 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/yarn.lock b/yarn.lock index 49216f9dac056..fcb2df8988ed3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6809,6 +6809,11 @@ bounce@1.x.x: boom "7.x.x" hoek "5.x.x" +bowser@^1.7.3: + version "1.9.4" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" + integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== + boxen@^1.2.1, boxen@^1.2.2: version "1.3.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -8719,6 +8724,13 @@ copy-to-clipboard@^3.0.8: dependencies: toggle-selection "^1.0.3" +copy-to-clipboard@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.2.0.tgz#d2724a3ccbfed89706fac8a894872c979ac74467" + integrity sha512-eOZERzvCmxS8HWzugj4Uxl8OJxa7T2k1Gi0X5qavwydHIfuSHq2dTD09LOg/XyGq4Zpb5IsR/2OJ5lbOegz78w== + dependencies: + toggle-selection "^1.0.6" + copy-webpack-plugin@^5.0.4: version "5.0.4" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-5.0.4.tgz#c78126f604e24f194c6ec2f43a64e232b5d43655" @@ -9096,6 +9108,14 @@ css-color-keywords@^1.0.0: resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" integrity sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU= +css-in-js-utils@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz#3b472b398787291b47cfe3e44fecfdd9e914ba99" + integrity sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA== + dependencies: + hyphenate-style-name "^1.0.2" + isobject "^3.0.1" + css-loader@2.1.1, css-loader@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-2.1.1.tgz#d8254f72e412bb2238bb44dd674ffbef497333ea" @@ -9181,6 +9201,14 @@ css-tree@1.0.0-alpha.29: mdn-data "~1.1.0" source-map "^0.5.3" +css-tree@^1.0.0-alpha.28: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb" + integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA== + dependencies: + mdn-data "2.0.6" + source-map "^0.6.1" + css-url-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/css-url-regex/-/css-url-regex-1.1.0.tgz#83834230cc9f74c457de59eebd1543feeb83b7ec" @@ -9252,7 +9280,7 @@ cssstyle@^2.0.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.7, csstype@^2.6.7: +csstype@^2.2.0, csstype@^2.5.5, csstype@^2.5.7, csstype@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.7.tgz#20b0024c20b6718f4eda3853a1f5a1cce7f5e4a5" integrity sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ== @@ -10998,6 +11026,13 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +error-stack-parser@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.4.tgz#a757397dc5d9de973ac9a5d7d4e8ade7cfae9101" + integrity sha512-fZ0KkoxSjLFmhW5lHbUT3tLwy3nX1qEzMYo8koY1vrsAco53CMT1djnBSeC/wUjTEZRhZl9iRw7PaMaxfJ4wzQ== + dependencies: + stackframe "^1.1.0" + error@^7.0.0, error@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02" @@ -12174,6 +12209,11 @@ fast-deep-equal@^2.0.1: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= +fast-deep-equal@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" + integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA== + fast-diff@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" @@ -12240,6 +12280,11 @@ fast-stream-to-buffer@^1.0.0: dependencies: end-of-stream "^1.4.1" +fastest-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" + integrity sha1-kSLUBtTJ2YvqZEpraFPVh0uHsCg= + fastparse@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" @@ -15158,6 +15203,11 @@ hyperlinker@^1.0.0: resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== +hyphenate-style-name@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" + integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ== + i18n-iso-countries@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/i18n-iso-countries/-/i18n-iso-countries-4.3.1.tgz#f110a8824ce14edbb0eb8f3b0bd817ff950af37c" @@ -15437,6 +15487,14 @@ ini@^1.2.0, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== +inline-style-prefixer@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-4.0.2.tgz#d390957d26f281255fe101da863158ac6eb60911" + integrity sha512-N8nVhwfYga9MiV9jWlwfdj1UDIaZlBFu4cJSJkIr7tZX7sHpHhGR5su1qdpW+7KPL8ISTvCIkcaFi/JdBknvPg== + dependencies: + bowser "^1.7.3" + css-in-js-utils "^2.0.0" + inline-style@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/inline-style/-/inline-style-2.0.0.tgz#2fa9cf624596a8109355b925094e138bbd5ea29b" @@ -18958,6 +19016,11 @@ mdast-add-list-metadata@1.0.1: dependencies: unist-util-visit-parents "1.1.2" +mdn-data@2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" + integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== + mdn-data@~1.1.0: version "1.1.4" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-1.1.4.tgz#50b5d4ffc4575276573c4eedb8780812a8419f01" @@ -19828,6 +19891,20 @@ nan@^2.10.0, nan@^2.9.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA== +nano-css@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/nano-css/-/nano-css-5.2.1.tgz#73b8470fa40b028a134d3393ae36bbb34b9fa332" + integrity sha512-T54okxMAha0+de+W8o3qFtuWhTxYvqQh2ku1cYEqTTP9mR62nWV2lLK9qRuAGWmoaYWhU7K4evT9Lc1iF65wuw== + dependencies: + css-tree "^1.0.0-alpha.28" + csstype "^2.5.5" + fastest-stable-stringify "^1.0.1" + inline-style-prefixer "^4.0.0" + rtl-css-js "^1.9.0" + sourcemap-codec "^1.4.1" + stacktrace-js "^2.0.0" + stylis "3.5.0" + nano-time@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/nano-time/-/nano-time-1.0.0.tgz#b0554f69ad89e22d0907f7a12b0993a5d96137ef" @@ -23530,6 +23607,21 @@ react-transition-group@^2.2.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" +react-use@^13.10.2: + version "13.10.2" + resolved "https://registry.yarnpkg.com/react-use/-/react-use-13.10.2.tgz#4250d258ca9068662943299c01794a136408c8e9" + integrity sha512-z3VFSiPHW6arViGVnajO7YKY5OD+Z9LWcImoJdYHkau23cLSoTctxM3XENLpGxjhJlHaYiQZ6pPgq7pwGTqSZA== + dependencies: + copy-to-clipboard "^3.2.0" + nano-css "^5.2.1" + react-fast-compare "^2.0.4" + resize-observer-polyfill "^1.5.1" + screenfull "^5.0.0" + set-harmonic-interval "^1.0.1" + throttle-debounce "^2.1.0" + ts-easing "^0.2.0" + tslib "^1.10.0" + react-virtualized@^9.18.5: version "9.20.1" resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.20.1.tgz#02dc08fe9070386b8c48e2ac56bce7af0208d22d" @@ -24892,6 +24984,13 @@ rsvp@^4.8.4: resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== +rtl-css-js@^1.9.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/rtl-css-js/-/rtl-css-js-1.13.1.tgz#80deabf6e8f36d6767d495cd3eb60fecb20c67e1" + integrity sha512-jgkIDj6Xi25kAEm5oYM3ZMFiOQhpLEcXi2LY/6bVr91cVz73hciHKneL5AMVPxOcks/JuizSaaNsvNRkeAWe3w== + dependencies: + "@babel/runtime" "^7.1.2" + run-async@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" @@ -25189,6 +25288,11 @@ scoped-regex@^1.0.0: resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8" integrity sha1-o0a7Gs1CB65wvXwMfKnlZra63bg= +screenfull@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.0.tgz#5c2010c0e84fd4157bf852877698f90b8cbe96f6" + integrity sha512-yShzhaIoE9OtOhWVyBBffA6V98CDCoyHTsp8228blmqYy1Z5bddzE/4FPiJKlr8DVR4VBiiUyfPzIQPIYDkeMA== + script-loader@0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/script-loader/-/script-loader-0.7.2.tgz#2016db6f86f25f5cf56da38915d83378bb166ba7" @@ -25426,6 +25530,11 @@ set-getter@^0.1.0: dependencies: to-object-path "^0.3.0" +set-harmonic-interval@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz#e1773705539cdfb80ce1c3d99e7f298bb3995249" + integrity sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g== + set-immediate-shim@^1.0.0, set-immediate-shim@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" @@ -25902,6 +26011,11 @@ source-map@0.1.32: dependencies: amdefine ">=0.0.4" +source-map@0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= + "source-map@>= 0.1.2": version "0.7.3" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" @@ -25938,6 +26052,11 @@ source-map@~0.2.0: dependencies: amdefine ">=0.0.4" +sourcemap-codec@^1.4.1: + version "1.4.6" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.6.tgz#e30a74f0402bad09807640d39e971090a08ce1e9" + integrity sha512-1ZooVLYFxC448piVLBbtOxFcXwnymH9oUF8nRd3CuYDVvkRBxRl6pB4Mtas5a4drtL+E8LDgFkQNcgIw6tc8Hg== + space-separated-tokens@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.2.tgz#e95ab9d19ae841e200808cd96bc7bd0adbbb3412" @@ -26139,6 +26258,13 @@ stable@^0.1.8: resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== +stack-generator@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.4.tgz#027513eab2b195bbb43b9c8360ba2dd0ab54de09" + integrity sha512-ha1gosTNcgxwzo9uKTQ8zZ49aUp5FIUW58YHFxCqaAHtE0XqBg0chGFYA1MfmW//x1KWq3F4G7Ug7bJh4RiRtg== + dependencies: + stackframe "^1.1.0" + stack-trace@0.0.10, stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" @@ -26149,6 +26275,11 @@ stack-utils@^1.0.1: resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.1.tgz#d4f33ab54e8e38778b0ca5cfd3b3afb12db68620" integrity sha1-1PM6tU6OOHeLDKXP07OvsS22hiA= +stackframe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.1.0.tgz#e3fc2eb912259479c9822f7d1f1ff365bd5cbc83" + integrity sha512-Vx6W1Yvy+AM1R/ckVwcHQHV147pTPBKWCRLrXMuPrFVfvBUc3os7PR1QLIWCMhPpRg5eX9ojzbQIMLGBwyLjqg== + stackman@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/stackman/-/stackman-4.0.0.tgz#3ccdc8682fee36373ed2492dc3dad546eb44647d" @@ -26160,6 +26291,23 @@ stackman@^4.0.0: error-callsites "^2.0.2" load-source-map "^1.0.0" +stacktrace-gps@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.3.tgz#b89f84cc13bb925b96607e737b617c8715facf57" + integrity sha512-51Rr7dXkyFUKNmhY/vqZWK+EvdsfFSRiQVtgHTFlAdNIYaDD7bVh21yBHXaNWAvTD+w+QSjxHg7/v6Tz4veExA== + dependencies: + source-map "0.5.6" + stackframe "^1.1.0" + +stacktrace-js@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/stacktrace-js/-/stacktrace-js-2.0.1.tgz#ebdb0e9a16e6f171f96ca7878404e7f15c3d42ba" + integrity sha512-13oDNgBSeWtdGa4/2BycNyKqe+VktCoJ8VLx4pDoJkwGGJVtiHdfMOAj3aW9xTi8oR2v34z9IcvfCvT6XNdNAw== + dependencies: + error-stack-parser "^2.0.4" + stack-generator "^2.0.4" + stacktrace-gps "^3.0.3" + state-toggle@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.0.tgz#d20f9a616bb4f0c3b98b91922d25b640aa2bc425" @@ -26682,6 +26830,11 @@ stylis-rule-sheet@^0.0.10: resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" integrity sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw== +stylis@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.0.tgz#016fa239663d77f868fef5b67cf201c4b7c701e1" + integrity sha512-pP7yXN6dwMzAR29Q0mBrabPCe0/mNO1MSr93bhay+hcZondvMMTpeGyd8nbhYJdyperNT2DRxONQuUGcJr5iPw== + stylus-lookup@^3.0.1: version "3.0.2" resolved "https://registry.yarnpkg.com/stylus-lookup/-/stylus-lookup-3.0.2.tgz#c9eca3ff799691020f30b382260a67355fefdddd" @@ -27525,7 +27678,7 @@ to-through@^2.0.0: dependencies: through2 "^2.0.3" -toggle-selection@^1.0.3: +toggle-selection@^1.0.3, toggle-selection@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI= @@ -27671,6 +27824,11 @@ ts-dedent@^1.1.0: resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-1.1.0.tgz#67983940793183dc7c7f820acb66ba02cdc33c6e" integrity sha512-CVCvDwMBWZKjDxpN3mU/Dx1v3k+sJgE8nrhXcC9vRopRfoa7vVzilNvHEAUi5jQnmFHpnxDx5jZdI1TpG8ny2g== +ts-easing@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec" + integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ== + ts-invariant@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.2.1.tgz#3d587f9d6e3bded97bf9ec17951dd9814d5a9d3f" @@ -27726,7 +27884,7 @@ tsd@^0.7.4: typescript "^3.0.1" update-notifier "^2.5.0" -tslib@^1: +tslib@^1, tslib@^1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==