Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[State Management] State syncing utils docs #56479

Merged
merged 11 commits into from
Feb 7, 2020
3 changes: 2 additions & 1 deletion src/plugins/kibana_utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
12 changes: 5 additions & 7 deletions src/plugins/kibana_utils/docs/state_containers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
);

Expand All @@ -35,7 +34,6 @@ container.transitions.double();
console.log(container.selectors.count()); // 10
```


## Demos

See demos [here](../../demos/state_containers/).
Expand All @@ -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)
60 changes: 60 additions & 0 deletions src/plugins/kibana_utils/docs/state_sync/README.md
Original file line number Diff line number Diff line change
@@ -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.
- 2 storage types for `syncState` util:
Dosant marked this conversation as resolved.
Show resolved Hide resolved
- [KbnUrlStateStorage](./storages/kbn_url_storage.md) - Serializes state and persists it to URL's query param in rison or hashed format (similar to what `AppState` & `GlobalState` did in legacy world).
Dosant marked this conversation as resolved.
Show resolved Hide resolved
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-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).
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Handling empty or incomplete incoming state

Users have direct access to storages where we sync our state to. For example, URL.
Dosant marked this conversation as resolved.
Show resolved Hide resolved
User can manually change the URL and remove or corrupt important data which we expect to be there.
Or users may programmatically generate URLs to Kibana and these URLs could have mistakes which application can't handle.
Dosant marked this conversation as resolved.
Show resolved Hide resolved

`syncState` utility doesn't do any handling for such edge cases and passes whatever received from storage to state container as is.
Dosant marked this conversation as resolved.
Show resolved Hide resolved
So it is up to application to decide, how to handle such scenarios. Consider the 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();

// on this point state in the storage and in the state is out of sync
Dosant marked this conversation as resolved.
Show resolved Hide resolved
// state: {count: 0}
// storage: {count: 0}

// And now user changed the URL to (let's assume) "/#?_a=(corrupt:0)"
Dosant marked this conversation as resolved.
Show resolved Hide resolved
// on this point state will recieve an update: {corrupt: 0}
```

Application could handle this gracefully by using simple composition during setup:
Dosant marked this conversation as resolved.
Show resolved Hide resolved

```ts
const { start, stop } = syncState({
storageKey: '_a',
stateContainer: {
...stateContainer,
set: state => stateContainer.set({ ...defaultState, ...state }),
},
stateStorage,
});
```

In this case, app will not get into state, which is not shaped as app expects.
Dosant marked this conversation as resolved.
Show resolved Hide resolved

To help application developers to not forget about such edge cases,
Dosant marked this conversation as resolved.
Show resolved Hide resolved
`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`.
71 changes: 71 additions & 0 deletions src/plugins/kibana_utils/docs/state_sync/initial_state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Setting up initial state

The `syncState` util doesn't do any initial state syncing between state and storage.
Consider the scenario:
Dosant marked this conversation as resolved.
Show resolved Hide resolved

```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();

// on this point state in the storage and in the state is out of sync
Dosant marked this conversation as resolved.
Show resolved Hide resolved
// state: {count: 0}
// storage: {count: 2}
```

The `syncState` doesn't make a decision, how initial state should be synced and which state should take precedence.
Dosant marked this conversation as resolved.
Show resolved Hide resolved
It is up to the application to decide, 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 it all together?
Dosant marked this conversation as resolved.
Show resolved Hide resolved
6. Are we storing the state both in the URL and in the `sessionStorage`? Which one should take precedence and in which case?

So if initial state syncing is required, for simple example above it could look like this:
Dosant marked this conversation as resolved.
Show resolved Hide resolved

```ts
// window.location.href is "/#?_a=(count:2)"
const defaultState = { count: 0 }; // default application state

const urlStateStorage = createKbnUrlStateStorage();

const initialStateFromUrl = urlStateStorage.get('_a');

// merge together default state and initial state
Dosant marked this conversation as resolved.
Show resolved Hide resolved
const initialState = {
...defaultState,
...initialStateFromUrl,
};

const stateContainer = createStateContainer(initialState);

if (!initialStateFromUrl) {
Dosant marked this conversation as resolved.
Show resolved Hide resolved
// for consistency put default state to the url
urlStateStorage.set('_a', defaultState, { replace: true });
}

const { start, stop } = syncState({
storageKey: '_a',
stateContainer,
stateStorage: urlStateStorage,
});

start();

// on this point state in the storage and in state are synced
Dosant marked this conversation as resolved.
Show resolved Hide resolved
// state: {count: 2}
// storage: {count: 2}
```
46 changes: 46 additions & 0 deletions src/plugins/kibana_utils/docs/state_sync/no_state_containers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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 just implement this simple interface:
Dosant marked this conversation as resolved.
Show resolved Hide resolved

```ts
export interface BaseStateContainer<State extends BaseState> {
get: () => State;
set: (state: State | null) => void;
state$: Observable<State>;
}
```

For example, assuming you have a custom state manager, setting up syncing state with URL could look something like this:
Dosant marked this conversation as resolved.
Show resolved Hide resolved

```ts
import { Subject } from 'rxjs';
import { map } from 'rxjs/operators';

class MyStateManager {
Dosant marked this conversation as resolved.
Show resolved Hide resolved
private count: number = 0;

updated$ = new Subject();

setCount(count: number) {
if (this.count !== count) {
this.count = count;
this.updated$.next();
}
}

getCount() {
return this.count;
}
}

import { syncState, createKbnUrlStateStorage } from 'src/plugins/kibana_utils/public';

const myStateManager = new MyStateManager();

syncState({
get: () => ({ count: myStateManager.getCount() }),
set: state => state && myStateManager.setCount(state.count),
state$: myStateManager.updated$.pipe(map(() => ({ count: myStateManager.getCount() }))),
});
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# On-fly state migrations
Dosant marked this conversation as resolved.
Show resolved Hide resolved

When retrieving initial state from storage we shouldn't forget about possible outdated state.
Dosant marked this conversation as resolved.
Show resolved Hide resolved
Similar to [handling initial state](./initial_state.md) example, applications could handle migrations during initialisation.
Dosant marked this conversation as resolved.
Show resolved Hide resolved

```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 example in [handling empty or incomplete state](./empty_or_incomplete_incoming_state.md).
Dosant marked this conversation as resolved.
Show resolved Hide resolved

```ts
import { migrate } from '../app/state_helpers';

const urlStateStorage = createKbnUrlStateStorage();
const initialStateFromUrl = urlStateStorage.get('_a');

// merge together default state and initial state and migrate it to current version if needed
Dosant marked this conversation as resolved.
Show resolved Hide resolved
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,
});
```

This should cover edge case, when user already have, for example, dashboard opened and then user pastes an older dashboard link into browser window.
Dosant marked this conversation as resolved.
Show resolved Hide resolved
No dashboard remount will happen, so, as we are transitioning to a new state on-fly, we are also applying necessary migrations on-fly.
7 changes: 7 additions & 0 deletions src/plugins/kibana_utils/docs/state_sync/storages/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# State Storage

2 storage types are supported:

- [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).
Dosant marked this conversation as resolved.
Show resolved Hide resolved
Listens for state updates in the URL and pushes updates back to state.
- [SessionStorageStateStorage](./session_storage.md) - Serialises state and persists it to session storage.
Loading