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.
- 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).
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,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`.
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 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) {
Dosant marked this conversation as resolved.
Show resolved Hide resolved
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}
```
51 changes: 51 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,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<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 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() }))),
});
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# On-the-fly state migrations

When retrieving initial state from storage we shouldn't forget about possible outdated state.
Dosant marked this conversation as resolved.
Show resolved Hide resolved
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,
});
```
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

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).
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) - Serializes state and persists it to session storage.
Loading