diff --git a/src/plugins/kibana_utils/README.md b/src/plugins/kibana_utils/README.md index 5501505dbb7e27..72a8479c7ac3d6 100644 --- a/src/plugins/kibana_utils/README.md +++ b/src/plugins/kibana_utils/README.md @@ -2,4 +2,5 @@ Utilities for building Kibana plugins. -- [State containers](./docs/state_containers/README.md). +- [State containers](./docs/state_containers). +- [State syncing utilities](./docs/state_sync). diff --git a/src/plugins/kibana_utils/docs/state_containers/README.md b/src/plugins/kibana_utils/docs/state_containers/README.md index 583f8f65ce6b69..ee5ebde9258e6a 100644 --- a/src/plugins/kibana_utils/docs/state_containers/README.md +++ b/src/plugins/kibana_utils/docs/state_containers/README.md @@ -12,7 +12,6 @@ your services or apps. container you can always access the latest state snapshot synchronously. - Unlike Redux, state containers are less verbose, see example below. - ## Example ```ts @@ -21,11 +20,11 @@ import { createStateContainer } from 'src/plugins/kibana_utils'; const container = createStateContainer( { count: 0 }, { - increment: (state: {count: number}) => (by: number) => ({ count: state.count + by }), - double: (state: {count: number}) => () => ({ count: state.count * 2 }), + increment: (state: { count: number }) => (by: number) => ({ count: state.count + by }), + double: (state: { count: number }) => () => ({ count: state.count * 2 }), }, { - count: (state: {count: number}) => () => state.count, + count: (state: { count: number }) => () => state.count, } ); @@ -35,7 +34,6 @@ container.transitions.double(); console.log(container.selectors.count()); // 10 ``` - ## Demos See demos [here](../../demos/state_containers/). @@ -47,11 +45,11 @@ 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). +- [Using without React](./no_react.md). - [Parallels with Redux](./redux.md). +- [Syncing state with URL or SessionStorage](../state_sync) diff --git a/src/plugins/kibana_utils/docs/state_sync/README.md b/src/plugins/kibana_utils/docs/state_sync/README.md new file mode 100644 index 00000000000000..acfe6dcf76fe97 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/README.md @@ -0,0 +1,60 @@ +# State Syncing Utilities + +State syncing utilities are a set of helpers for syncing your application state +with URL or browser storage. + +They are designed to work together with [state containers](../state_containers). But state containers are not required. + +State syncing utilities include: + +- `syncState` util which: + - Subscribes to state changes and pushes them to state storage. + - Optionally subscribes to state storage changes and pushes them to state. +- Two types of storage compatible with `syncState`: + - [KbnUrlStateStorage](./storages/kbn_url_storage.md) - Serializes state and persists it to URL's query param in rison or hashed format. + Listens for state updates in the URL and pushes them back to state. + - [SessionStorageStateStorage](./storages/session_storage.md) - Serializes state and persists it to session storage. + +## Example + +```ts +import { + createStateContainer, + syncState, + createKbnUrlStateStorage, +} from 'src/plugins/kibana_utils/public'; + +const stateContainer = createStateContainer({ count: 0 }); +const stateStorage = createKbnUrlStateStorage(); + +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer, + stateStorage, +}); + +start(); + +// state container change is synched to state storage +// kbnUrlStateStorage updates the URL to "/#?_a=(count:2)" +stateContainer.set({ count: 2 }); + +stop(); +``` + +## Demos Plugins + +See demos plugins [here](../../../../../examples/state_containers_examples). + +To run them, start kibana with `--run-examples` flag. + +## Reference + +- [Syncing state with URL](./storages/kbn_url_storage.md). +- [Syncing state with sessionStorage](./storages/session_storage.md). +- [Setting up initial state](./initial_state.md). +- [Using without state containers](./no_state_containers.md). +- [Handling empty or incomplete incoming state](./empty_or_incomplete_incoming_state.md). +- [On-the-fly state migrations](./on_fly_state_migrations.md). +- [syncStates helper](./sync_states.md). +- [Helpers for Data plugin (syncing TimeRange, RefreshInterval and Filters)](./data_plugin_helpers.md). diff --git a/src/plugins/kibana_utils/docs/state_sync/data_plugin_helpers.md b/src/plugins/kibana_utils/docs/state_sync/data_plugin_helpers.md new file mode 100644 index 00000000000000..a6abf19c584017 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/data_plugin_helpers.md @@ -0,0 +1,7 @@ +# Helpers for syncing state with data plugins (TimeRange, RefreshInterval and Filters) + +```ts +// TODO: Waiting for +// https://github.com/elastic/kibana/issues/55977 +// https://github.com/elastic/kibana/pull/56128 +``` diff --git a/src/plugins/kibana_utils/docs/state_sync/empty_or_incomplete_incoming_state.md b/src/plugins/kibana_utils/docs/state_sync/empty_or_incomplete_incoming_state.md new file mode 100644 index 00000000000000..22163d6f51ead2 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/empty_or_incomplete_incoming_state.md @@ -0,0 +1,54 @@ +# Handling empty or incomplete incoming state + +Users have direct access to storages where we sync our state to. +For example, in the URL, a user can manually change the URL and remove or corrupt important data which we expect to be there. +URLs may also be programmatically generated, increasing the risk for mistakes which application can't handle. + +`syncState` doesn't handle such edge cases passing input from storage to the state container as is. +It is up to the application to handle such scenarios. + +Consider the following example: + +```ts +// window.location.href is "/#?_a=(count:0" +const defaultState = { count: 0 }; // default application state + +const stateContainer = createStateContainer(defaultState); +const stateStorage = createKbnUrlStateStorage(); + +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer, + stateStorage, +}); + +start(); + +// At this point, state and storage are in sync +// state: {count: 0} +// storage: {count: 0} + +// Now user changes the URL manually to "/#?_a=(corrupt:0)", +// triggering a state update with {corrupt: 0} +``` + +The application could, for example, handle this gracefully, by using simple composition during setup: + +```ts +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer: { + ...stateContainer, + set: state => stateContainer.set({ ...defaultState, ...state }), + }, + stateStorage, +}); +``` + +In this case, the corrupt value will not get into state, preventing misshaped state. + +To help application developers remember such edge cases, +`syncState` util sets a constraint, +that setter to state container should be able to handle `null` value (see [IStateSyncConfig](../../public/state_sync/types.ts)). +Incoming `null` value from state storage usually means that state is empty (e.g. URL without `storageKey` query param). +So when using `syncState` util applications are required to at least handle incoming `null`. diff --git a/src/plugins/kibana_utils/docs/state_sync/initial_state.md b/src/plugins/kibana_utils/docs/state_sync/initial_state.md new file mode 100644 index 00000000000000..306c9313178f35 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/initial_state.md @@ -0,0 +1,71 @@ +# Setting up initial state + +The `syncState` util doesn't do any initial state syncing between state and storage. +Consider the following scenario: + +```ts +// window.location.href is "/#?_a=(count:2)" +const defaultState = { count: 0 }; // default application state + +const stateContainer = createStateContainer(defaultState); +const stateStorage = createKbnUrlStateStorage(); + +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer, + stateStorage, +}); + +start(); + +// Now the storage and state are out of sync +// state: {count: 0} +// storage: {count: 2} +``` + +It is up to the application to decide, how initial state should be synced and which state should take precedence, depending on the specific use case. + +Questions to consider: + +1. Should default state take precedence over URL? +2. Should URL take precedence? +3. Do we have to do any state migrations for what is coming from the URL? +4. If URL doesn't have the whole state, should we merge it with default one or leave it behind? +5. Is there any other state loading in parallel (e.g. from a `SavedObject`)? How should we merge those? +6. Are we storing the state both in the URL and in the `sessionStorage`? Which one should take precedence and in which case? + +A possible synchronization for the state conflict above could look like this: + +```ts +// window.location.href is "/#?_a=(count:2)" +const defaultState = { count: 0 }; // default application state + +const urlStateStorage = createKbnUrlStateStorage(); + +const initialStateFromUrl = urlStateStorage.get('_a'); + +// merge the default state and initial state from the url and use it as initial application state +const initialState = { + ...defaultState, + ...initialStateFromUrl, +}; + +const stateContainer = createStateContainer(initialState); + +// preserve initial application state in the URL +if (!initialStateFromUrl) { + urlStateStorage.set('_a', initialState, { replace: true }); +} + +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer, + stateStorage: urlStateStorage, +}); + +start(); + +// Now the storage and state are in sync +// state: {count: 2} +// storage: {count: 2} +``` diff --git a/src/plugins/kibana_utils/docs/state_sync/no_state_containers.md b/src/plugins/kibana_utils/docs/state_sync/no_state_containers.md new file mode 100644 index 00000000000000..ad78774435825e --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/no_state_containers.md @@ -0,0 +1,51 @@ +# Using state syncing utilities without state containers + +It is possible to use `syncState` utility even if your app is not using [state containers](../state_containers). +The `state` which is passed into `syncState` function should implement this simple interface: + +```ts +export interface BaseStateContainer { + get: () => State; + set: (state: State | null) => void; + state$: Observable; +} +``` + +For example, assuming you have a custom state manager, setting up syncing state with the URL could look something like this: + +```ts +// my_state_manager.ts +import { Subject } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export class MyStateManager { + private count: number = 0; + + updated$ = new Subject(); + + setCount(count: number) { + if (this.count !== count) { + this.count = count; + this.updated$.next(); + } + } + + getCount() { + return this.count; + } +} +``` + +```ts +// app.ts +import { syncState, createKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; +import { MyStateManager } from './my_state_manager'; + +const myStateManager = new MyStateManager(); + +syncState({ + get: () => ({ count: myStateManager.getCount() }), + set: state => state && myStateManager.setCount(state.count), + state$: myStateManager.updated$.pipe(map(() => ({ count: myStateManager.getCount() }))), +}); +``` diff --git a/src/plugins/kibana_utils/docs/state_sync/on_fly_state_migrations.md b/src/plugins/kibana_utils/docs/state_sync/on_fly_state_migrations.md new file mode 100644 index 00000000000000..65d1bce588eed3 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/on_fly_state_migrations.md @@ -0,0 +1,48 @@ +# On-the-fly state migrations + +When retrieving initial state from storage we shouldn't forget about possible outdated state. +Consider the scenario, where user launches application from a bookmarked link, which contains outdated state. + +Similar to [handling initial state](./initial_state.md) example, applications could handle migrations during Initialization. + +```ts +import { migrate } from '../app/state_helpers'; +const urlStateStorage = createKbnUrlStateStorage(); +const initialStateFromUrl = urlStateStorage.get('_a'); + +// merge default state and initial state and migrate it to the current version +const initialState = migrate({ + ...defaultState, + ...initialStateFromUrl, +}); + +const stateContainer = createStateContainer(initialState); +``` + +It is also possible to apply migrations for any incoming state, similar to [handling empty or incomplete state](./empty_or_incomplete_incoming_state.md). + +Imagine an edge case scenario, where a user is working in your application, and then pastes an old link for the same application, containing older state with a different structure. +Since no application remount will happen, we need to transition to a new state on-the-fly, by applying necessary migrations. + +```ts +import { migrate } from '../app/state_helpers'; + +const urlStateStorage = createKbnUrlStateStorage(); +const initialStateFromUrl = urlStateStorage.get('_a'); + +// merge default state and initial state and migrate them to current version if needed +const initialState = migrate({ + ...defaultState, + ...initialStateFromUrl, +}); + +const stateContainer = createStateContainer(initialState); +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer: { + ...stateContainer, + set: state => stateContainer.set(migrate({ ...defaultState, ...state })), + }, + stateStorage, +}); +``` diff --git a/src/plugins/kibana_utils/docs/state_sync/storages/README.md b/src/plugins/kibana_utils/docs/state_sync/storages/README.md new file mode 100644 index 00000000000000..8cfffa31350f75 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/storages/README.md @@ -0,0 +1,7 @@ +# State Storage + +Two types of storage compatible with `syncState`: + +- [KbnUrlStateStorage](./kbn_url_storage.md) - Serialises state and persists it to URL's query param in rison or hashed format (similar to what AppState & GlobalState did in legacy world). + Listens for state updates in the URL and pushes updates back to state. +- [SessionStorageStateStorage](./session_storage.md) - Serializes state and persists it to session storage. diff --git a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md new file mode 100644 index 00000000000000..3a31f5a326edb8 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md @@ -0,0 +1,162 @@ +# Kbn Url Storage + +`KbnUrlStateStorage` is a state storage for `syncState` utility which: + +- Keeps state in sync with the URL. +- Serializes data and stores it in the URL in one of the supported formats: + 1. [Rison](https://github.com/w33ble/rison-node) encoded. + 2. Hashed URL: In URL we store only the hash from the serialized state, but the state itself is stored in `sessionStorage`. + See kibana's advanced option for more context `state:storeInSessionStorage` +- Takes care of listening to the URL updates and notifies state about the updates. +- Takes care of batching URL updates to prevent redundant browser history records. + +### Basic Example + +With state sync utility: + +```ts +import { + createStateContainer, + syncState, + createKbnUrlStateStorage, +} from 'src/plugins/kibana_utils/public'; + +const stateContainer = createStateContainer({ count: 0 }); +const stateStorage = createKbnUrlStateStorage(); + +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer, + stateStorage, +}); + +start(); + +// state container change is synced to state storage +// in this case, kbnUrlStateStorage updates the URL to "/#?_a=(count:2)" +stateContainer.set({ count: 2 }); + +stop(); +``` + +Or just by itself: + +```ts +// assuming url is "/#?_a=(count:2)" +const stateStorage = createKbnUrlStateStorage(); + +stateStorage.get('_a'); // returns {count: a} +stateStorage.set('_a', { count: 0 }); // updates url to "/#?_a=(count:0)" +``` + +### Setting URL format option + +```ts +const stateStorage = createKbnUrlStateStorage({ + useHash: core.uiSettings.get('state:storeInSessionStorage'), // put the complete encoded rison or just the hash into the URL +}); +``` + +### Passing [history](https://github.com/ReactTraining/history) instance + +Under the hood `kbnUrlStateStorage` uses [history](https://github.com/ReactTraining/history) for updating the URL and for listening to the URL updates. +To prevent bugs caused by missing history updates, make sure your app uses one instance of history for URL changes which may interfere with each other. + +For example, if you use `react-router`: + +```tsx +const App = props => { + useEffect(() => { + const stateStorage = createKbnUrlStateStorage({ + useHash: props.uiSettings.get('state:storeInSessionStorage'), + history: props.history, + }); + + //.... + }); + + return ; +}; +``` + +### Url updates batching + +`kbnUrlStateStorage` batches synchronous URL updates. Consider the example. + +```ts +const urlStateStorage = createKbnUrlStateStorage(); + +urlStateStorage.set('_a', { state: 1 }); +urlStateStorage.set('_b', { state: 2 }); + +// URL hasn't been updated yet +setTimeout(() => { + // now URL is actually "/#?_a=(state:1)&_b=(state:2)" + // and 2 updates were batched into 1 history.push() call +}, 0); +``` + +For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` provides these advanced apis: + +- `kbnUrlStateStorage.flush({replace: boolean})` - allows to synchronously apply any pending updates. + `replace` option allows to use `history.replace()` instead of `history.push()`. Returned boolean indicates if any update happened +- `kbnUrlStateStorage.cancel()` - cancels any pending updates + +### Sharing one `kbnUrlStateStorage` instance + +`kbnUrlStateStorage` is stateful, as it keeps track of pending updates. +So if there are multiple state syncs happening in the same time, then one instance of `kbnUrlStateStorage` should be used to make sure, that the same update queue is used. +Otherwise it could cause redundant browser history records. + +```ts +// wrong: + +const { start, stop } = syncStates([ + { + storageKey: '_a', + stateContainerA, + stateStorage: createKbnUrlStateStorage(), + }, + { + storageKey: '_b', + stateContainerB, + stateStorage: createKbnUrlStateStorage(), + }, +]); + +// better: + +const kbnUrlStateStorage = createKbnUrlStateStorage(); +const { start, stop } = syncStates([ + { + storageKey: '_a', + stateContainerA, + stateStorage: kbnUrlStateStorage, + }, + { + storageKey: '_b', + stateContainerB, + stateStorage: kbnUrlStateStorage, + }, +]); + +// the best: + +import { createBrowserHistory } from 'history'; +const history = createBrowserHistory(); +const kbnUrlStateStorage = createKbnUrlStateStorage({ history }); +const { start, stop } = syncStates([ + { + storageKey: '_a', + stateContainerA, + stateStorage: kbnUrlStateStorage, + }, + { + storageKey: '_b', + stateContainerB, + stateStorage: kbnUrlStateStorage, + }, +]); + +; +``` diff --git a/src/plugins/kibana_utils/docs/state_sync/storages/session_storage.md b/src/plugins/kibana_utils/docs/state_sync/storages/session_storage.md new file mode 100644 index 00000000000000..cfefc6688f09dc --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/storages/session_storage.md @@ -0,0 +1,39 @@ +# Session Storage + +To sync state from state containers to `sessionStorage` use `createSessionStorageStateStorage`. + +```ts +import { + createStateContainer, + syncState, + createSessionStorageStateStorage, +} from 'src/plugins/kibana_utils/public'; + +const stateContainer = createStateContainer({ count: 0 }); +const stateStorage = createSessionStorageStateStorage(); + +const { start, stop } = syncState({ + storageKey: '_a', + stateContainer, + stateStorage, +}); + +start(); + +// state container change is synced to state storage +// in this case, stateStorage serialises state and stores it in `window.sessionStorage` by key `_a` +stateContainer.set({ count: 2 }); + +stop(); +``` + +You can also use `createSessionStorageStateStorage` imperatively: + +```ts +const stateStorage = createSessionStorageStateStorage(); + +stateStorage.set('_a', { count: 2 }); +stateStorage.get('_a'); +``` + +**Note**: external changes to `sessionStorageStateStorage` or `window.sessionStorage` don't trigger state container updates. diff --git a/src/plugins/kibana_utils/docs/state_sync/sync_states.md b/src/plugins/kibana_utils/docs/state_sync/sync_states.md new file mode 100644 index 00000000000000..9a040cb9ba405b --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/sync_states.md @@ -0,0 +1,34 @@ +# Sync states utility + +In case you need to sync multiple states or one state to multiple storages, there is a handy util for that. +It saves a bit of boilerplate by returning `start` and `stop` functions for all `syncState` configs at once. + +```ts +import { + createStateContainer, + syncStates, + createKbnUrlStateStorage, + createSessionStorageStateStorage, +} from 'src/plugins/kibana_utils/public'; + +const stateContainer = createStateContainer({ count: 0 }); +const urlStateStorage = createKbnUrlStateStorage(); +const sessionStorageStateStorage = createSessionStorageStateStorage(); + +const { start, stop } = syncStates([ + { + storageKey: '_a', + stateContainer, + stateStorage: urlStateStorage, + }, + { + storageKey: '_a', + stateContainer, + stateStorage: sessionStorageStateStorage, + }, +]); + +start(); // start syncing to all storages at once + +stop(); // stop syncing to all storages at once +``` diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts index c55c60f9b0f890..1a172d76926cc5 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -151,6 +151,28 @@ describe('state_sync', () => { stop(); }); + + it('storage change with incomplete or differently shaped object should notify state and set new object as is', () => { + container.set({ todos: [{ completed: false, id: 1, text: 'changed' }] }); + const { stop, start } = syncStates([ + { + stateContainer: container, + storageKey: '_s', + stateStorage: testStateStorage, + }, + ]); + start(); + + const differentlyShapedObject = { + different: 'test', + }; + (testStateStorage.get as jest.Mock).mockImplementation(() => differentlyShapedObject); + storageChange$.next(differentlyShapedObject as any); + + expect(container.getState()).toStrictEqual(differentlyShapedObject); + + stop(); + }); }); describe('integration', () => {