[State Management] State syncing utilities (#53582) (#54454)

Today, apps rely on AppState and GlobalState in the ui/state_management module to deal with internal (app) and shared (global) state. These classes give apps an ability to read/write state, when is then synced to the URL as well as sessionStorage. They also react to changes in the URL and automatically update state & emit events when changes occur.

This PR introduces new state synching utilities, which together with state containers src/plugins/kibana_utils/public/state_containers will be a replacement for AppState and GlobalState in New Platform.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Anton Dosov 2020-01-13 10:10:56 +03:00 committed by GitHub
parent 8636011d7c
commit 5f52cf0bf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 2790 additions and 168 deletions

View file

@ -0,0 +1,10 @@
{
"id": "stateContainersExamples",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["state_containers_examples"],
"server": false,
"ui": true,
"requiredPlugins": [],
"optionalPlugins": []
}

View file

@ -0,0 +1,17 @@
{
"name": "state_containers_examples",
"version": "1.0.0",
"main": "target/examples/state_containers_examples",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"scripts": {
"kbn": "node ../../scripts/kbn.js",
"build": "rm -rf './target' && tsc"
},
"devDependencies": {
"typescript": "3.7.2"
}
}

View file

@ -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 { AppMountParameters } from 'kibana/public';
import ReactDOM from 'react-dom';
import React from 'react';
import { createHashHistory, createBrowserHistory } from 'history';
import { TodoAppPage } from './todo';
export interface AppOptions {
appInstanceId: string;
appTitle: string;
historyType: History;
}
export enum History {
Browser,
Hash,
}
export const renderApp = (
{ appBasePath, element }: AppMountParameters,
{ appInstanceId, appTitle, historyType }: AppOptions
) => {
const history =
historyType === History.Browser
? createBrowserHistory({ basename: appBasePath })
: createHashHistory();
ReactDOM.render(
<TodoAppPage
history={history}
appInstanceId={appInstanceId}
appTitle={appTitle}
appBasePath={appBasePath}
isInitialRoute={() => {
const stripTrailingSlash = (path: string) =>
path.charAt(path.length - 1) === '/' ? path.substr(0, path.length - 1) : path;
const currentAppUrl = stripTrailingSlash(history.createHref(history.location));
if (historyType === History.Browser) {
// browser history
const basePath = stripTrailingSlash(appBasePath);
return currentAppUrl === basePath && !history.location.search && !history.location.hash;
} else {
// hashed history
return currentAppUrl === '#' && !history.location.search;
}
}}
/>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -17,8 +17,6 @@
* under the License.
*/
declare module 'encode-uri-query' {
function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
// eslint-disable-next-line import/no-default-export
export default encodeUriQuery;
}
import { StateContainersExamplesPlugin } from './plugin';
export const plugin = () => new StateContainersExamplesPlugin();

View file

@ -0,0 +1,52 @@
/*
* 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 { AppMountParameters, CoreSetup, Plugin } from 'kibana/public';
export class StateContainersExamplesPlugin implements Plugin {
public setup(core: CoreSetup) {
core.application.register({
id: 'state-containers-example-browser-history',
title: 'State containers example - browser history routing',
async mount(params: AppMountParameters) {
const { renderApp, History } = await import('./app');
return renderApp(params, {
appInstanceId: '1',
appTitle: 'Routing with browser history',
historyType: History.Browser,
});
},
});
core.application.register({
id: 'state-containers-example-hash-history',
title: 'State containers example - hash history routing',
async mount(params: AppMountParameters) {
const { renderApp, History } = await import('./app');
return renderApp(params, {
appInstanceId: '2',
appTitle: 'Routing with hash history',
historyType: History.Hash,
});
},
});
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,327 @@
/*
* 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 React, { useEffect } from 'react';
import { Link, Route, Router, Switch, useLocation } from 'react-router-dom';
import { History } from 'history';
import {
EuiButton,
EuiCheckbox,
EuiFieldText,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
} from '@elastic/eui';
import {
BaseStateContainer,
INullableBaseStateContainer,
createKbnUrlStateStorage,
createSessionStorageStateStorage,
createStateContainer,
createStateContainerReactHelpers,
PureTransition,
syncStates,
getStateFromKbnUrl,
} from '../../../src/plugins/kibana_utils/public';
import { useUrlTracker } from '../../../src/plugins/kibana_react/public';
import {
defaultState,
pureTransitions,
TodoActions,
TodoState,
} from '../../../src/plugins/kibana_utils/demos/state_containers/todomvc';
interface GlobalState {
text: string;
}
interface GlobalStateAction {
setText: PureTransition<GlobalState, [string]>;
}
const defaultGlobalState: GlobalState = { text: '' };
const globalStateContainer = createStateContainer<GlobalState, GlobalStateAction>(
defaultGlobalState,
{
setText: state => text => ({ ...state, text }),
}
);
const GlobalStateHelpers = createStateContainerReactHelpers<typeof globalStateContainer>();
const container = createStateContainer<TodoState, TodoActions>(defaultState, pureTransitions);
const { Provider, connect, useTransitions, useState } = createStateContainerReactHelpers<
typeof container
>();
interface TodoAppProps {
filter: 'completed' | 'not-completed' | null;
}
const TodoApp: React.FC<TodoAppProps> = ({ filter }) => {
const { setText } = GlobalStateHelpers.useTransitions();
const { text } = GlobalStateHelpers.useState();
const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions();
const todos = useState();
const filteredTodos = todos.filter(todo => {
if (!filter) return true;
if (filter === 'completed') return todo.completed;
if (filter === 'not-completed') return !todo.completed;
return true;
});
const location = useLocation();
return (
<>
<div>
<Link to={{ ...location, pathname: '/' }}>
<EuiButton size={'s'} color={!filter ? 'primary' : 'secondary'}>
All
</EuiButton>
</Link>
<Link to={{ ...location, pathname: '/completed' }}>
<EuiButton size={'s'} color={filter === 'completed' ? 'primary' : 'secondary'}>
Completed
</EuiButton>
</Link>
<Link to={{ ...location, pathname: '/not-completed' }}>
<EuiButton size={'s'} color={filter === 'not-completed' ? 'primary' : 'secondary'}>
Not Completed
</EuiButton>
</Link>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id} style={{ display: 'flex', alignItems: 'center', margin: '16px 0px' }}>
<EuiCheckbox
id={todo.id + ''}
key={todo.id}
checked={todo.completed}
onChange={e => {
editTodo({
...todo,
completed: e.target.checked,
});
}}
label={todo.text}
/>
<EuiButton
style={{ marginLeft: '8px' }}
size={'s'}
onClick={() => {
deleteTodo(todo.id);
}}
>
Delete
</EuiButton>
</li>
))}
</ul>
<form
onSubmit={e => {
const inputRef = (e.target as HTMLFormElement).elements.namedItem(
'newTodo'
) as HTMLInputElement;
if (!inputRef || !inputRef.value) return;
addTodo({
text: inputRef.value,
completed: false,
id: todos.map(todo => todo.id).reduce((a, b) => Math.max(a, b), 0) + 1,
});
inputRef.value = '';
e.preventDefault();
}}
>
<EuiFieldText placeholder="Type your todo and press enter to submit" name="newTodo" />
</form>
<div style={{ margin: '16px 0px' }}>
<label htmlFor="globalInput">Global state piece: </label>
<input name="globalInput" value={text} onChange={e => setText(e.target.value)} />
</div>
</>
);
};
const TodoAppConnected = GlobalStateHelpers.connect<TodoAppProps, never>(() => ({}))(
connect<TodoAppProps, never>(() => ({}))(TodoApp)
);
export const TodoAppPage: React.FC<{
history: History;
appInstanceId: string;
appTitle: string;
appBasePath: string;
isInitialRoute: () => boolean;
}> = props => {
const initialAppUrl = React.useRef(window.location.href);
const [useHashedUrl, setUseHashedUrl] = React.useState(false);
/**
* Replicates what src/legacy/ui/public/chrome/api/nav.ts did
* Persists the url in sessionStorage and tries to restore it on "componentDidMount"
*/
useUrlTracker(`lastUrlTracker:${props.appInstanceId}`, props.history, urlToRestore => {
// shouldRestoreUrl:
// App decides if it should restore url or not
// In this specific case, restore only if navigated to initial route
if (props.isInitialRoute()) {
// navigated to the base path, so should restore the url
return true;
} else {
// navigated to specific route, so should not restore the url
return false;
}
});
useEffect(() => {
// have to sync with history passed to react-router
// history v5 will be singleton and this will not be needed
const kbnUrlStateStorage = createKbnUrlStateStorage({
useHash: useHashedUrl,
history: props.history,
});
const sessionStorageStateStorage = createSessionStorageStateStorage();
/**
* Restoring global state:
* State restoration similar to what GlobalState in legacy world did
* It restores state both from url and from session storage
*/
const globalStateKey = `_g`;
const globalStateFromInitialUrl = getStateFromKbnUrl<GlobalState>(
globalStateKey,
initialAppUrl.current
);
const globalStateFromCurrentUrl = kbnUrlStateStorage.get<GlobalState>(globalStateKey);
const globalStateFromSessionStorage = sessionStorageStateStorage.get<GlobalState>(
globalStateKey
);
const initialGlobalState: GlobalState = {
...defaultGlobalState,
...globalStateFromCurrentUrl,
...globalStateFromSessionStorage,
...globalStateFromInitialUrl,
};
globalStateContainer.set(initialGlobalState);
kbnUrlStateStorage.set(globalStateKey, initialGlobalState, { replace: true });
sessionStorageStateStorage.set(globalStateKey, initialGlobalState);
/**
* Restoring app local state:
* State restoration similar to what AppState in legacy world did
* It restores state both from url
*/
const appStateKey = `_todo-${props.appInstanceId}`;
const initialAppState: TodoState =
getStateFromKbnUrl<TodoState>(appStateKey, initialAppUrl.current) ||
kbnUrlStateStorage.get<TodoState>(appStateKey) ||
defaultState;
container.set(initialAppState);
kbnUrlStateStorage.set(appStateKey, initialAppState, { replace: true });
// start syncing only when made sure, that state in synced
const { stop, start } = syncStates([
{
stateContainer: withDefaultState(container, defaultState),
storageKey: appStateKey,
stateStorage: kbnUrlStateStorage,
},
{
stateContainer: withDefaultState(globalStateContainer, defaultGlobalState),
storageKey: globalStateKey,
stateStorage: kbnUrlStateStorage,
},
{
stateContainer: withDefaultState(globalStateContainer, defaultGlobalState),
storageKey: globalStateKey,
stateStorage: sessionStorageStateStorage,
},
]);
start();
return () => {
stop();
// reset state containers
container.set(defaultState);
globalStateContainer.set(defaultGlobalState);
};
}, [props.appInstanceId, props.history, useHashedUrl]);
return (
<Router history={props.history}>
<GlobalStateHelpers.Provider value={globalStateContainer}>
<Provider value={container}>
<EuiPageBody>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>
State sync example. Instance: ${props.appInstanceId}. {props.appTitle}
</h1>
</EuiTitle>
<EuiButton onClick={() => setUseHashedUrl(!useHashedUrl)}>
{useHashedUrl ? 'Use Expanded State' : 'Use Hashed State'}
</EuiButton>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<Switch>
<Route path={'/completed'}>
<TodoAppConnected filter={'completed'} />
</Route>
<Route path={'/not-completed'}>
<TodoAppConnected filter={'not-completed'} />
</Route>
<Route path={'/'}>
<TodoAppConnected filter={null} />
</Route>
</Switch>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</Provider>
</GlobalStateHelpers.Provider>
</Router>
);
};
function withDefaultState<State>(
stateContainer: BaseStateContainer<State>,
// eslint-disable-next-line no-shadow
defaultState: State
): INullableBaseStateContainer<State> {
return {
...stateContainer,
set: (state: State | null) => {
if (Array.isArray(defaultState)) {
stateContainer.set(state || defaultState);
} else {
stateContainer.set({
...defaultState,
...state,
});
}
},
};
}

View file

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": []
}

View file

@ -167,7 +167,6 @@
"elastic-apm-node": "^3.2.0",
"elasticsearch": "^16.5.0",
"elasticsearch-browser": "^16.5.0",
"encode-uri-query": "1.0.1",
"execa": "^3.2.0",
"expiry-js": "0.1.7",
"fast-deep-equal": "^3.1.1",

View file

@ -25,4 +25,5 @@ export * from './overlays';
export * from './ui_settings';
export * from './field_icon';
export * from './table_list_view';
export { useUrlTracker } from './use_url_tracker';
export { toMountPoint } from './util';

View file

@ -17,8 +17,4 @@
* under the License.
*/
declare module 'encode-uri-query' {
function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
// eslint-disable-next-line import/no-default-export
export default encodeUriQuery;
}
export { useUrlTracker } from './use_url_tracker';

View file

@ -0,0 +1,70 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useUrlTracker } from './use_url_tracker';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { createMemoryHistory } from 'history';
describe('useUrlTracker', () => {
const key = 'key';
let storage = new StubBrowserStorage();
let history = createMemoryHistory();
beforeEach(() => {
storage = new StubBrowserStorage();
history = createMemoryHistory();
});
it('should track history changes and save them to storage', () => {
expect(storage.getItem(key)).toBeNull();
const { unmount } = renderHook(() => {
useUrlTracker(key, history, () => false, storage);
});
expect(storage.getItem(key)).toBe('/');
history.push('/change');
expect(storage.getItem(key)).toBe('/change');
unmount();
history.push('/other-change');
expect(storage.getItem(key)).toBe('/change');
});
it('by default should restore initial url', () => {
storage.setItem(key, '/change');
renderHook(() => {
useUrlTracker(key, history, undefined, storage);
});
expect(history.location.pathname).toBe('/change');
});
it('should restore initial url if shouldRestoreUrl cb returns true', () => {
storage.setItem(key, '/change');
renderHook(() => {
useUrlTracker(key, history, () => true, storage);
});
expect(history.location.pathname).toBe('/change');
});
it('should not restore initial url if shouldRestoreUrl cb returns false', () => {
storage.setItem(key, '/change');
renderHook(() => {
useUrlTracker(key, history, () => false, storage);
});
expect(history.location.pathname).toBe('/');
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 { History } from 'history';
import { useLayoutEffect } from 'react';
import { createUrlTracker } from '../../../kibana_utils/public/';
/**
* State management url_tracker in react hook form
*
* Replicates what src/legacy/ui/public/chrome/api/nav.ts did
* Persists the url in sessionStorage so it could be restored if navigated back to the app
*
* @param key - key to use in storage
* @param history - history instance to use
* @param shouldRestoreUrl - cb if url should be restored
* @param storage - storage to use. window.sessionStorage is default
*/
export function useUrlTracker(
key: string,
history: History,
shouldRestoreUrl: (urlToRestore: string) => boolean = () => true,
storage: Storage = sessionStorage
) {
useLayoutEffect(() => {
const urlTracker = createUrlTracker(key, storage);
const urlToRestore = urlTracker.getTrackedUrl();
if (urlToRestore && shouldRestoreUrl(urlToRestore)) {
history.replace(urlToRestore);
}
const stopTrackingUrl = urlTracker.startTrackingUrl(history);
return () => {
stopTrackingUrl();
};
}, [key, history]);
}

View file

@ -0,0 +1,75 @@
/*
* 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 { Subject } from 'rxjs';
import { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value';
import { toArray } from 'rxjs/operators';
import deepEqual from 'fast-deep-equal';
describe('distinctUntilChangedWithInitialValue', () => {
it('should skip updates with the same value', async () => {
const subject = new Subject<number>();
const result = subject.pipe(distinctUntilChangedWithInitialValue(1), toArray()).toPromise();
subject.next(2);
subject.next(3);
subject.next(3);
subject.next(3);
subject.complete();
expect(await result).toEqual([2, 3]);
});
it('should accept promise as initial value', async () => {
const subject = new Subject<number>();
const result = subject
.pipe(
distinctUntilChangedWithInitialValue(
new Promise(resolve => {
resolve(1);
setTimeout(() => {
subject.next(2);
subject.next(3);
subject.next(3);
subject.next(3);
subject.complete();
});
})
),
toArray()
)
.toPromise();
expect(await result).toEqual([2, 3]);
});
it('should accept custom comparator', async () => {
const subject = new Subject<any>();
const result = subject
.pipe(distinctUntilChangedWithInitialValue({ test: 1 }, deepEqual), toArray())
.toPromise();
subject.next({ test: 1 });
subject.next({ test: 2 });
subject.next({ test: 2 });
subject.next({ test: 3 });
subject.complete();
expect(await result).toEqual([{ test: 2 }, { test: 3 }]);
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { MonoTypeOperatorFunction, queueScheduler, scheduled, from } from 'rxjs';
import { concatAll, distinctUntilChanged, skip } from 'rxjs/operators';
export function distinctUntilChangedWithInitialValue<T>(
initialValue: T | Promise<T>,
compare?: (x: T, y: T) => boolean
): MonoTypeOperatorFunction<T> {
return input$ =>
scheduled(
[isPromise(initialValue) ? from(initialValue) : [initialValue], input$],
queueScheduler
).pipe(concatAll(), distinctUntilChanged(compare), skip(1));
}
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return (
!!value &&
typeof value === 'object' &&
'then' in value &&
typeof value.then === 'function' &&
!('subscribe' in value)
);
}

View file

@ -18,3 +18,4 @@
*/
export * from './defer';
export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value';

View file

@ -19,6 +19,7 @@
import { result as counterResult } from './state_containers/counter';
import { result as todomvcResult } from './state_containers/todomvc';
import { result as urlSyncResult } from './state_sync/url';
describe('demos', () => {
describe('state containers', () => {
@ -33,4 +34,12 @@ describe('demos', () => {
]);
});
});
describe('state sync', () => {
test('url sync demo works', async () => {
expect(await urlSyncResult).toMatchInlineSnapshot(
`"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"`
);
});
});
});

View file

@ -0,0 +1,70 @@
/*
* 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 { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc';
import { BaseStateContainer, createStateContainer } from '../../public/state_containers';
import {
createKbnUrlStateStorage,
syncState,
INullableBaseStateContainer,
} from '../../public/state_sync';
const tick = () => new Promise(resolve => setTimeout(resolve));
const stateContainer = createStateContainer<TodoState, TodoActions>(defaultState, pureTransitions);
const { start, stop } = syncState({
stateContainer: withDefaultState(stateContainer, defaultState),
storageKey: '_s',
stateStorage: createKbnUrlStateStorage(),
});
start();
export const result = Promise.resolve()
.then(() => {
// http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers')"
stateContainer.transitions.add({
id: 2,
text: 'test',
completed: false,
});
// http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers'),(completed:!f,id:2,text:test))"
/* actual url updates happens async */
return tick();
})
.then(() => {
stop();
return window.location.href;
});
function withDefaultState<State>(
// eslint-disable-next-line no-shadow
stateContainer: BaseStateContainer<State>,
// eslint-disable-next-line no-shadow
defaultState: State
): INullableBaseStateContainer<State> {
return {
...stateContainer,
set: (state: State | null) => {
stateContainer.set(state || defaultState);
},
};
}

View file

@ -19,7 +19,9 @@
import { mapValues, isString } from 'lodash';
import { FieldMappingSpec, MappingObject } from './types';
import { ES_FIELD_TYPES } from '../../../data/public';
// import from ./common/types to prevent circular dependency of kibana_utils <-> data plugin
import { ES_FIELD_TYPES } from '../../../data/common/types';
/** @private */
type ShorthandFieldMapObject = FieldMappingSpec | ES_FIELD_TYPES | 'json';

View file

@ -27,6 +27,34 @@ 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';
export * from './state_management/url';
export { hashedItemStore, HashedItemStore } from './storage/hashed_item_store';
export {
createStateHash,
persistState,
retrieveState,
isStateHash,
} from './state_management/state_hash';
export {
hashQuery,
hashUrl,
unhashUrl,
unhashQuery,
createUrlTracker,
createKbnUrlControls,
getStateFromKbnUrl,
getStatesFromKbnUrl,
setStateToKbnUrl,
} from './state_management/url';
export {
syncState,
syncStates,
createKbnUrlStateStorage,
createSessionStorageStateStorage,
IStateSyncConfig,
ISyncStateRef,
IKbnUrlStateStorage,
INullableBaseStateContainer,
ISessionStorageStateStorage,
StartSyncStateFnType,
StopSyncStateFnType,
} from './state_sync';

View file

@ -113,6 +113,13 @@ test('multiple subscribers can subscribe', () => {
expect(spy2.mock.calls[1][0]).toEqual({ a: 2 });
});
test('can create state container without transitions', () => {
const state = { foo: 'bar' };
const stateContainer = createStateContainer(state);
expect(stateContainer.transitions).toEqual({});
expect(stateContainer.get()).toEqual(state);
});
test('creates impure mutators from pure mutators', () => {
const { mutators } = create(
{},

View file

@ -41,11 +41,11 @@ const freeze: <T>(value: T) => RecursiveReadonly<T> =
export const createStateContainer = <
State,
PureTransitions extends object,
PureTransitions extends object = {},
PureSelectors extends object = {}
>(
defaultState: State,
pureTransitions: PureTransitions,
pureTransitions: PureTransitions = {} as PureTransitions,
pureSelectors: PureSelectors = {} as PureSelectors
): ReduxLikeStateContainer<State, PureTransitions, PureSelectors> => {
const data$ = new BehaviorSubject<RecursiveReadonly<State>>(freeze(defaultState));

View file

@ -193,12 +193,7 @@ describe('hooks', () => {
describe('useTransitions', () => {
test('useTransitions hook returns mutations that can update state', () => {
const { store } = create<
{
cnt: number;
},
any
>(
const { store } = create(
{
cnt: 0,
},

View file

@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = <Container extends StateContaine
return value;
};
const useTransitions = () => useContainer().transitions;
const useTransitions = (): Container['transitions'] => useContainer().transitions;
const useSelector = <Result>(
selector: (state: UnboxState<Container>) => Result,

View file

@ -42,7 +42,7 @@ export interface BaseStateContainer<State> {
export interface StateContainer<
State,
PureTransitions extends object,
PureTransitions extends object = {},
PureSelectors extends object = {}
> extends BaseStateContainer<State> {
transitions: Readonly<PureTransitionsToTransitions<PureTransitions>>;
@ -51,7 +51,7 @@ export interface StateContainer<
export interface ReduxLikeStateContainer<
State,
PureTransitions extends object,
PureTransitions extends object = {},
PureSelectors extends object = {}
> extends StateContainer<State, PureTransitions, PureSelectors> {
getState: () => RecursiveReadonly<State>;

View file

@ -0,0 +1,61 @@
/*
* 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 rison, { RisonValue } from 'rison-node';
import { isStateHash, retrieveState, persistState } from '../state_hash';
// should be:
// export function decodeState<State extends RisonValue>(expandedOrHashedState: string)
// but this leads to the chain of types mismatches up to BaseStateContainer interfaces,
// as in state containers we don't have any restrictions on state shape
export function decodeState<State>(expandedOrHashedState: string): State {
if (isStateHash(expandedOrHashedState)) {
return retrieveState(expandedOrHashedState);
} else {
return (rison.decode(expandedOrHashedState) as unknown) as State;
}
}
// should be:
// export function encodeState<State extends RisonValue>(expandedOrHashedState: string)
// but this leads to the chain of types mismatches up to BaseStateContainer interfaces,
// as in state containers we don't have any restrictions on state shape
export function encodeState<State>(state: State, useHash: boolean): string {
if (useHash) {
return persistState(state);
} else {
return rison.encode((state as unknown) as RisonValue);
}
}
export function hashedStateToExpandedState(expandedOrHashedState: string): string {
if (isStateHash(expandedOrHashedState)) {
return encodeState(retrieveState(expandedOrHashedState), false);
}
return expandedOrHashedState;
}
export function expandedStateToHashedState(expandedOrHashedState: string): string {
if (isStateHash(expandedOrHashedState)) {
return expandedOrHashedState;
}
return persistState(decodeState(expandedOrHashedState));
}

View file

@ -0,0 +1,25 @@
/*
* 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.
*/
export {
encodeState,
decodeState,
expandedStateToHashedState,
hashedStateToExpandedState,
} from './encode_decode_state';

View file

@ -17,4 +17,4 @@
* under the License.
*/
export * from './state_hash';
export { isStateHash, createStateHash, persistState, retrieveState } from './state_hash';

View file

@ -17,6 +17,7 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { Sha256 } from '../../../../../core/public/utils';
import { hashedItemStore } from '../../storage/hashed_item_store';
@ -52,3 +53,46 @@ export function createStateHash(
export function isStateHash(str: string) {
return String(str).indexOf(HASH_PREFIX) === 0;
}
export function retrieveState<State>(stateHash: string): State {
const json = hashedItemStore.getItem(stateHash);
const throwUnableToRestoreUrlError = () => {
throw new Error(
i18n.translate('kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage', {
defaultMessage:
'Unable to completely restore the URL, be sure to use the share functionality.',
})
);
};
if (json === null) {
return throwUnableToRestoreUrlError();
}
try {
return JSON.parse(json);
} catch (e) {
return throwUnableToRestoreUrlError();
}
}
export function persistState<State>(state: State): string {
const json = JSON.stringify(state);
const hash = createStateHash(json);
const isItemSet = hashedItemStore.setItem(hash, json);
if (isItemSet) return hash;
// If we ran out of space trying to persist the state, notify the user.
const message = i18n.translate(
'kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage',
{
defaultMessage:
'Kibana is unable to store history items in your session ' +
`because it is full and there don't seem to be items any items safe ` +
'to delete.\n\n' +
'This can usually be fixed by moving to a fresh tab, but could ' +
'be caused by a larger issue. If you are seeing this message regularly, ' +
'please file an issue at {gitHubIssuesUrl}.',
values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' },
}
);
throw new Error(message);
}

View file

@ -0,0 +1,41 @@
/*
* 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 { replaceUrlHashQuery } from './format';
describe('format', () => {
describe('replaceUrlHashQuery', () => {
it('should add hash query to url without hash', () => {
const url = 'http://localhost:5601/oxf/app/kibana';
expect(replaceUrlHashQuery(url, () => ({ test: 'test' }))).toMatchInlineSnapshot(
`"http://localhost:5601/oxf/app/kibana#?test=test"`
);
});
it('should replace hash query', () => {
const url = 'http://localhost:5601/oxf/app/kibana#?test=test';
expect(
replaceUrlHashQuery(url, query => ({
...query,
test1: 'test1',
}))
).toMatchInlineSnapshot(`"http://localhost:5601/oxf/app/kibana#?test=test&test1=test1"`);
});
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 { format as formatUrl } from 'url';
import { ParsedUrlQuery } from 'querystring';
import { parseUrl, parseUrlHash } from './parse';
import { stringifyQueryString } from './stringify_query_string';
export function replaceUrlHashQuery(
rawUrl: string,
queryReplacer: (query: ParsedUrlQuery) => ParsedUrlQuery
) {
const url = parseUrl(rawUrl);
const hash = parseUrlHash(rawUrl);
const newQuery = queryReplacer(hash?.query || {});
const searchQueryString = stringifyQueryString(newQuery);
if ((!hash || !hash.search) && !searchQueryString) return rawUrl; // nothing to change. return original url
return formatUrl({
...url,
hash: formatUrl({
pathname: hash?.pathname || '',
search: searchQueryString,
}),
});
}

View file

@ -29,13 +29,6 @@ describe('hash unhash url', () => {
describe('hash url', () => {
describe('does nothing', () => {
it('if missing input', () => {
expect(() => {
// @ts-ignore
hashUrl();
}).not.toThrowError();
});
it('if url is empty', () => {
const url = '';
expect(hashUrl(url)).toBe(url);

View file

@ -17,13 +17,8 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
import rison, { RisonObject } from 'rison-node';
import { stringify as stringifyQueryString } from 'querystring';
import encodeUriQuery from 'encode-uri-query';
import { format as formatUrl, parse as parseUrl } from 'url';
import { hashedItemStore } from '../../storage/hashed_item_store';
import { createStateHash, isStateHash } from '../state_hash';
import { expandedStateToHashedState, hashedStateToExpandedState } from '../state_encoder';
import { replaceUrlHashQuery } from './format';
export type IParsedUrlQuery = Record<string, any>;
@ -32,8 +27,8 @@ interface IUrlQueryMapperOptions {
}
export type IUrlQueryReplacerOptions = IUrlQueryMapperOptions;
export const unhashQuery = createQueryMapper(stateHashToRisonState);
export const hashQuery = createQueryMapper(risonStateToStateHash);
export const unhashQuery = createQueryMapper(hashedStateToExpandedState);
export const hashQuery = createQueryMapper(expandedStateToHashedState);
export const unhashUrl = createQueryReplacer(unhashQuery);
export const hashUrl = createQueryReplacer(hashQuery);
@ -61,97 +56,5 @@ function createQueryReplacer(
queryMapper: (q: IParsedUrlQuery, options?: IUrlQueryMapperOptions) => IParsedUrlQuery,
options?: IUrlQueryReplacerOptions
) {
return (url: string) => {
if (!url) return url;
const parsedUrl = parseUrl(url, true);
if (!parsedUrl.hash) return url;
const appUrl = parsedUrl.hash.slice(1); // trim the #
if (!appUrl) return url;
const appUrlParsed = parseUrl(appUrl, true);
if (!appUrlParsed.query) return url;
const changedAppQuery = queryMapper(appUrlParsed.query, options);
// encodeUriQuery implements the less-aggressive encoding done naturally by
// the browser. We use it to generate the same urls the browser would
const changedAppQueryString = stringifyQueryString(changedAppQuery, undefined, undefined, {
encodeURIComponent: encodeUriQuery,
});
return formatUrl({
...parsedUrl,
hash: formatUrl({
pathname: appUrlParsed.pathname,
search: changedAppQueryString,
}),
});
};
}
// TODO: this helper should be merged with or replaced by
// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts
// maybe to become simplified stateless version
export function retrieveState(stateHash: string): RisonObject {
const json = hashedItemStore.getItem(stateHash);
const throwUnableToRestoreUrlError = () => {
throw new Error(
i18n.translate('kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage', {
defaultMessage:
'Unable to completely restore the URL, be sure to use the share functionality.',
})
);
};
if (json === null) {
return throwUnableToRestoreUrlError();
}
try {
return JSON.parse(json);
} catch (e) {
return throwUnableToRestoreUrlError();
}
}
// TODO: this helper should be merged with or replaced by
// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts
// maybe to become simplified stateless version
export function persistState(state: RisonObject): string {
const json = JSON.stringify(state);
const hash = createStateHash(json);
const isItemSet = hashedItemStore.setItem(hash, json);
if (isItemSet) return hash;
// If we ran out of space trying to persist the state, notify the user.
const message = i18n.translate(
'kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage',
{
defaultMessage:
'Kibana is unable to store history items in your session ' +
`because it is full and there don't seem to be items any items safe ` +
'to delete.\n\n' +
'This can usually be fixed by moving to a fresh tab, but could ' +
'be caused by a larger issue. If you are seeing this message regularly, ' +
'please file an issue at {gitHubIssuesUrl}.',
values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' },
}
);
throw new Error(message);
}
function stateHashToRisonState(stateHashOrRison: string): string {
if (isStateHash(stateHashOrRison)) {
return rison.encode(retrieveState(stateHashOrRison));
}
return stateHashOrRison;
}
function risonStateToStateHash(stateHashOrRison: string): string | null {
if (isStateHash(stateHashOrRison)) {
return stateHashOrRison;
}
return persistState(rison.decode(stateHashOrRison) as RisonObject);
return (url: string) => replaceUrlHashQuery(url, query => queryMapper(query, options));
}

View file

@ -17,4 +17,12 @@
* under the License.
*/
export * from './hash_unhash_url';
export { hashUrl, hashQuery, unhashUrl, unhashQuery } from './hash_unhash_url';
export {
createKbnUrlControls,
setStateToKbnUrl,
getStateFromKbnUrl,
getStatesFromKbnUrl,
IKbnUrlControls,
} from './kbn_url_storage';
export { createUrlTracker } from './url_tracker';

View file

@ -0,0 +1,246 @@
/*
* 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 '../../storage/hashed_item_store/mock';
import {
History,
createBrowserHistory,
createHashHistory,
createMemoryHistory,
createPath,
} from 'history';
import {
getRelativeToHistoryPath,
createKbnUrlControls,
IKbnUrlControls,
setStateToKbnUrl,
getStateFromKbnUrl,
} from './kbn_url_storage';
describe('kbn_url_storage', () => {
describe('getStateFromUrl & setStateToUrl', () => {
const url = 'http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id';
const state1 = {
testStr: '123',
testNumber: 0,
testObj: { test: '123' },
testNull: null,
testArray: [1, 2, {}],
};
const state2 = {
test: '123',
};
it('should set expanded state to url', () => {
let newUrl = setStateToKbnUrl('_s', state1, { useHash: false }, url);
expect(newUrl).toMatchInlineSnapshot(
`"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(testArray:!(1,2,()),testNull:!n,testNumber:0,testObj:(test:'123'),testStr:'123')"`
);
const retrievedState1 = getStateFromKbnUrl('_s', newUrl);
expect(retrievedState1).toEqual(state1);
newUrl = setStateToKbnUrl('_s', state2, { useHash: false }, newUrl);
expect(newUrl).toMatchInlineSnapshot(
`"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(test:'123')"`
);
const retrievedState2 = getStateFromKbnUrl('_s', newUrl);
expect(retrievedState2).toEqual(state2);
});
it('should set hashed state to url', () => {
let newUrl = setStateToKbnUrl('_s', state1, { useHash: true }, url);
expect(newUrl).toMatchInlineSnapshot(
`"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@a897fac"`
);
const retrievedState1 = getStateFromKbnUrl('_s', newUrl);
expect(retrievedState1).toEqual(state1);
newUrl = setStateToKbnUrl('_s', state2, { useHash: true }, newUrl);
expect(newUrl).toMatchInlineSnapshot(
`"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@40f94d5"`
);
const retrievedState2 = getStateFromKbnUrl('_s', newUrl);
expect(retrievedState2).toEqual(state2);
});
});
describe('urlControls', () => {
let history: History;
let urlControls: IKbnUrlControls;
beforeEach(() => {
history = createMemoryHistory();
urlControls = createKbnUrlControls(history);
});
const getCurrentUrl = () => createPath(history.location);
it('should update url', () => {
urlControls.update('/1', false);
expect(getCurrentUrl()).toBe('/1');
expect(history.length).toBe(2);
urlControls.update('/2', true);
expect(getCurrentUrl()).toBe('/2');
expect(history.length).toBe(2);
});
it('should update url async', async () => {
const pr1 = urlControls.updateAsync(() => '/1', false);
const pr2 = urlControls.updateAsync(() => '/2', false);
const pr3 = urlControls.updateAsync(() => '/3', false);
expect(getCurrentUrl()).toBe('/');
await Promise.all([pr1, pr2, pr3]);
expect(getCurrentUrl()).toBe('/3');
});
it('should push url state if at least 1 push in async chain', async () => {
const pr1 = urlControls.updateAsync(() => '/1', true);
const pr2 = urlControls.updateAsync(() => '/2', false);
const pr3 = urlControls.updateAsync(() => '/3', true);
expect(getCurrentUrl()).toBe('/');
await Promise.all([pr1, pr2, pr3]);
expect(getCurrentUrl()).toBe('/3');
expect(history.length).toBe(2);
});
it('should replace url state if all updates in async chain are replace', async () => {
const pr1 = urlControls.updateAsync(() => '/1', true);
const pr2 = urlControls.updateAsync(() => '/2', true);
const pr3 = urlControls.updateAsync(() => '/3', true);
expect(getCurrentUrl()).toBe('/');
await Promise.all([pr1, pr2, pr3]);
expect(getCurrentUrl()).toBe('/3');
expect(history.length).toBe(1);
});
it('should listen for url updates', async () => {
const cb = jest.fn();
urlControls.listen(cb);
const pr1 = urlControls.updateAsync(() => '/1', true);
const pr2 = urlControls.updateAsync(() => '/2', true);
const pr3 = urlControls.updateAsync(() => '/3', true);
await Promise.all([pr1, pr2, pr3]);
urlControls.update('/4', false);
urlControls.update('/5', true);
expect(cb).toHaveBeenCalledTimes(3);
});
it('should flush async url updates', async () => {
const pr1 = urlControls.updateAsync(() => '/1', false);
const pr2 = urlControls.updateAsync(() => '/2', false);
const pr3 = urlControls.updateAsync(() => '/3', false);
expect(getCurrentUrl()).toBe('/');
urlControls.flush();
expect(getCurrentUrl()).toBe('/3');
await Promise.all([pr1, pr2, pr3]);
expect(getCurrentUrl()).toBe('/3');
});
it('flush should take priority over regular replace behaviour', async () => {
const pr1 = urlControls.updateAsync(() => '/1', true);
const pr2 = urlControls.updateAsync(() => '/2', false);
const pr3 = urlControls.updateAsync(() => '/3', true);
urlControls.flush(false);
expect(getCurrentUrl()).toBe('/3');
await Promise.all([pr1, pr2, pr3]);
expect(getCurrentUrl()).toBe('/3');
expect(history.length).toBe(2);
});
it('should cancel async url updates', async () => {
const pr1 = urlControls.updateAsync(() => '/1', true);
const pr2 = urlControls.updateAsync(() => '/2', false);
const pr3 = urlControls.updateAsync(() => '/3', true);
urlControls.cancel();
expect(getCurrentUrl()).toBe('/');
await Promise.all([pr1, pr2, pr3]);
expect(getCurrentUrl()).toBe('/');
});
});
describe('getRelativeToHistoryPath', () => {
it('should extract path relative to browser history without basename', () => {
const history = createBrowserHistory();
const url =
"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
const relativePath = getRelativeToHistoryPath(url, history);
expect(relativePath).toEqual(
"/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
);
});
it('should extract path relative to browser history with basename', () => {
const url =
"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
const history1 = createBrowserHistory({ basename: '/oxf/app/' });
const relativePath1 = getRelativeToHistoryPath(url, history1);
expect(relativePath1).toEqual(
"/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
);
const history2 = createBrowserHistory({ basename: '/oxf/app/kibana/' });
const relativePath2 = getRelativeToHistoryPath(url, history2);
expect(relativePath2).toEqual(
"#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
);
});
it('should extract path relative to browser history with basename from relative url', () => {
const history = createBrowserHistory({ basename: '/oxf/app/' });
const url =
"/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
const relativePath = getRelativeToHistoryPath(url, history);
expect(relativePath).toEqual(
"/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
);
});
it('should extract path relative to hash history without basename', () => {
const history = createHashHistory();
const url =
"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
const relativePath = getRelativeToHistoryPath(url, history);
expect(relativePath).toEqual(
"/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
);
});
it('should extract path relative to hash history with basename', () => {
const history = createHashHistory({ basename: 'management' });
const url =
"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
const relativePath = getRelativeToHistoryPath(url, history);
expect(relativePath).toEqual(
"/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
);
});
it('should extract path relative to hash history with basename from relative url', () => {
const history = createHashHistory({ basename: 'management' });
const url =
"/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
const relativePath = getRelativeToHistoryPath(url, history);
expect(relativePath).toEqual(
"/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
);
});
});
});

View file

@ -0,0 +1,235 @@
/*
* 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 { format as formatUrl } from 'url';
import { createBrowserHistory, History } from 'history';
import { decodeState, encodeState } from '../state_encoder';
import { getCurrentUrl, parseUrl, parseUrlHash } from './parse';
import { stringifyQueryString } from './stringify_query_string';
import { replaceUrlHashQuery } from './format';
/**
* Parses a kibana url and retrieves all the states encoded into url,
* Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage)
* e.g.:
*
* given an url:
* http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')
* will return object:
* {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}};
*/
export function getStatesFromKbnUrl(
url: string = window.location.href,
keys?: string[]
): Record<string, unknown> {
const query = parseUrlHash(url)?.query;
if (!query) return {};
const decoded: Record<string, unknown> = {};
Object.entries(query)
.filter(([key]) => (keys ? keys.includes(key) : true))
.forEach(([q, value]) => {
decoded[q] = decodeState(value as string);
});
return decoded;
}
/**
* Retrieves specific state from url by key
* e.g.:
*
* given an url:
* http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')
* and key '_a'
* will return object:
* {tab: 'indexedFields'}
*/
export function getStateFromKbnUrl<State>(
key: string,
url: string = window.location.href
): State | null {
return (getStatesFromKbnUrl(url, [key])[key] as State) || null;
}
/**
* Sets state to the url by key and returns a new url string.
* Doesn't actually updates history
*
* e.g.:
* given a url: http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')
* key: '_a'
* and state: {tab: 'other'}
*
* will return url:
* http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:other)&_b=(f:test,i:'',l:'')
*/
export function setStateToKbnUrl<State>(
key: string,
state: State,
{ useHash = false }: { useHash: boolean } = { useHash: false },
rawUrl = window.location.href
): string {
return replaceUrlHashQuery(rawUrl, query => {
const encoded = encodeState(state, useHash);
return {
...query,
[key]: encoded,
};
});
}
/**
* A tiny wrapper around history library to listen for url changes and update url
* History library handles a bunch of cross browser edge cases
*/
export interface IKbnUrlControls {
/**
* Listen for url changes
* @param cb - get's called when url has been changed
*/
listen: (cb: () => void) => () => void;
/**
* Updates url synchronously
* @param url - url to update to
* @param replace - use replace instead of push
*/
update: (url: string, replace: boolean) => string;
/**
* Schedules url update to next microtask,
* Useful to batch sync changes to url to cause only one browser history update
* @param updater - fn which receives current url and should return next url to update to
* @param replace - use replace instead of push
*/
updateAsync: (updater: UrlUpdaterFnType, replace?: boolean) => Promise<string>;
/**
* Synchronously flushes scheduled url updates
* @param replace - if replace passed in, then uses it instead of push. Otherwise push or replace is picked depending on updateQueue
*/
flush: (replace?: boolean) => string;
/**
* Cancels any pending url updates
*/
cancel: () => void;
}
export type UrlUpdaterFnType = (currentUrl: string) => string;
export const createKbnUrlControls = (
history: History = createBrowserHistory()
): IKbnUrlControls => {
const updateQueue: Array<(currentUrl: string) => string> = [];
// if we should replace or push with next async update,
// if any call in a queue asked to push, then we should push
let shouldReplace = true;
function updateUrl(newUrl: string, replace = false): string {
const currentUrl = getCurrentUrl();
if (newUrl === currentUrl) return currentUrl; // skip update
const historyPath = getRelativeToHistoryPath(newUrl, history);
if (replace) {
history.replace(historyPath);
} else {
history.push(historyPath);
}
return getCurrentUrl();
}
// queue clean up
function cleanUp() {
updateQueue.splice(0, updateQueue.length);
shouldReplace = true;
}
// runs scheduled url updates
function flush(replace = shouldReplace) {
if (updateQueue.length === 0) return getCurrentUrl();
const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl());
cleanUp();
const newUrl = updateUrl(resultUrl, replace);
return newUrl;
}
return {
listen: (cb: () => void) =>
history.listen(() => {
cb();
}),
update: (newUrl: string, replace = false) => updateUrl(newUrl, replace),
updateAsync: (updater: (currentUrl: string) => string, replace = false) => {
updateQueue.push(updater);
if (shouldReplace) {
shouldReplace = replace;
}
// Schedule url update to the next microtask
// this allows to batch synchronous url changes
return Promise.resolve().then(() => {
return flush();
});
},
flush: (replace?: boolean) => {
return flush(replace);
},
cancel: () => {
cleanUp();
},
};
};
/**
* Depending on history configuration extracts relative path for history updates
* 4 possible cases (see tests):
* 1. Browser history with empty base path
* 2. Browser history with base path
* 3. Hash history with empty base path
* 4. Hash history with base path
*/
export function getRelativeToHistoryPath(absoluteUrl: string, history: History): History.Path {
function stripBasename(path: string = '') {
const stripLeadingHash = (_: string) => (_.charAt(0) === '#' ? _.substr(1) : _);
const stripTrailingSlash = (_: string) =>
_.charAt(_.length - 1) === '/' ? _.substr(0, _.length - 1) : _;
const baseName = stripLeadingHash(stripTrailingSlash(history.createHref({})));
return path.startsWith(baseName) ? path.substr(baseName.length) : path;
}
const isHashHistory = history.createHref({}).includes('#');
const parsedUrl = isHashHistory ? parseUrlHash(absoluteUrl)! : parseUrl(absoluteUrl);
const parsedHash = isHashHistory ? null : parseUrlHash(absoluteUrl);
return formatUrl({
pathname: stripBasename(parsedUrl.pathname),
search: stringifyQueryString(parsedUrl.query),
hash: parsedHash
? formatUrl({
pathname: parsedHash.pathname,
search: stringifyQueryString(parsedHash.query),
})
: parsedUrl.hash,
});
}

View file

@ -0,0 +1,35 @@
/*
* 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 { parseUrlHash } from './parse';
describe('parseUrlHash', () => {
it('should return null if no hash', () => {
expect(parseUrlHash('http://localhost:5601/oxf/app/kibana')).toBeNull();
});
it('should return parsed hash', () => {
expect(parseUrlHash('http://localhost:5601/oxf/app/kibana/#/path?test=test')).toMatchObject({
pathname: '/path',
query: {
test: 'test',
},
});
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { parse as _parseUrl } from 'url';
export const parseUrl = (url: string) => _parseUrl(url, true);
export const parseUrlHash = (url: string) => {
const hash = parseUrl(url).hash;
return hash ? parseUrl(hash.slice(1)) : null;
};
export const getCurrentUrl = () => window.location.href;
export const parseCurrentUrl = () => parseUrl(getCurrentUrl());
export const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl());

View file

@ -0,0 +1,65 @@
/*
* 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 { encodeUriQuery, stringifyQueryString } from './stringify_query_string';
describe('stringifyQueryString', () => {
it('stringifyQueryString', () => {
expect(
stringifyQueryString({
a: 'asdf1234asdf',
b: "-_.!~*'() -_.!~*'()",
c: ':@$, :@$,',
d: "&;=+# &;=+#'",
f: ' ',
g: 'null',
})
).toMatchInlineSnapshot(
`"a=asdf1234asdf&b=-_.!~*'()%20-_.!~*'()&c=:@$,%20:@$,&d=%26;%3D%2B%23%20%26;%3D%2B%23'&f=%20&g=null"`
);
});
});
describe('encodeUriQuery', function() {
it('should correctly encode uri query and not encode chars defined as pchar set in rfc3986', () => {
// don't encode alphanum
expect(encodeUriQuery('asdf1234asdf')).toBe('asdf1234asdf');
// don't encode unreserved
expect(encodeUriQuery("-_.!~*'() -_.!~*'()")).toBe("-_.!~*'()+-_.!~*'()");
// don't encode the rest of pchar
expect(encodeUriQuery(':@$, :@$,')).toBe(':@$,+:@$,');
// encode '&', ';', '=', '+', and '#'
expect(encodeUriQuery('&;=+# &;=+#')).toBe('%26;%3D%2B%23+%26;%3D%2B%23');
// encode ' ' as '+'
expect(encodeUriQuery(' ')).toBe('++');
// encode ' ' as '%20' when a flag is used
expect(encodeUriQuery(' ', true)).toBe('%20%20');
// do not encode `null` as '+' when flag is used
expect(encodeUriQuery('null', true)).toBe('null');
// do not encode `null` with no flag
expect(encodeUriQuery('null')).toBe('null');
});
});

View file

@ -0,0 +1,57 @@
/*
* 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 { stringify, ParsedUrlQuery } from 'querystring';
// encodeUriQuery implements the less-aggressive encoding done naturally by
// the browser. We use it to generate the same urls the browser would
export const stringifyQueryString = (query: ParsedUrlQuery) =>
stringify(query, undefined, undefined, {
// encode spaces with %20 is needed to produce the same queries as angular does
// https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1377
encodeURIComponent: (val: string) => encodeUriQuery(val, true),
});
/**
* Extracted from angular.js
* repo: https://github.com/angular/angular.js
* license: MIT - https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/LICENSE
* source: https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1413-L1432
*/
/**
* This method is intended for encoding *key* or *value* parts of query component. We need a custom
* method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be
* encoded per http://tools.ietf.org/html/rfc3986:
* query = *( pchar / "/" / "?" )
* pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
* pct-encoded = "%" HEXDIG HEXDIG
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
* / "*" / "+" / "," / ";" / "="
*/
export function encodeUriQuery(val: string, pctEncodeSpaces: boolean = false) {
return encodeURIComponent(val)
.replace(/%40/gi, '@')
.replace(/%3A/gi, ':')
.replace(/%24/g, '$')
.replace(/%2C/gi, ',')
.replace(/%3B/gi, ';')
.replace(/%20/g, pctEncodeSpaces ? '%20' : '+');
}

View file

@ -0,0 +1,56 @@
/*
* 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 { createUrlTracker, IUrlTracker } from './url_tracker';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { createMemoryHistory, History } from 'history';
describe('urlTracker', () => {
let storage: StubBrowserStorage;
let history: History;
let urlTracker: IUrlTracker;
beforeEach(() => {
storage = new StubBrowserStorage();
history = createMemoryHistory();
urlTracker = createUrlTracker('test', storage);
});
it('should return null if no tracked url', () => {
expect(urlTracker.getTrackedUrl()).toBeNull();
});
it('should return last tracked url', () => {
urlTracker.trackUrl('http://localhost:4200');
urlTracker.trackUrl('http://localhost:4201');
urlTracker.trackUrl('http://localhost:4202');
expect(urlTracker.getTrackedUrl()).toBe('http://localhost:4202');
});
it('should listen to history and track updates', () => {
const stop = urlTracker.startTrackingUrl(history);
expect(urlTracker.getTrackedUrl()).toBe('/');
history.push('/1');
history.replace('/2');
expect(urlTracker.getTrackedUrl()).toBe('/2');
stop();
history.replace('/3');
expect(urlTracker.getTrackedUrl()).toBe('/2');
});
});

View file

@ -0,0 +1,49 @@
/*
* 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 { createBrowserHistory, History, Location } from 'history';
import { getRelativeToHistoryPath } from './kbn_url_storage';
export interface IUrlTracker {
startTrackingUrl: (history?: History) => () => void;
getTrackedUrl: () => string | null;
trackUrl: (url: string) => void;
}
/**
* Replicates what src/legacy/ui/public/chrome/api/nav.ts did
* Persists the url in sessionStorage so it could be restored if navigated back to the app
*/
export function createUrlTracker(key: string, storage: Storage = sessionStorage): IUrlTracker {
return {
startTrackingUrl(history: History = createBrowserHistory()) {
const track = (location: Location<any>) => {
const url = getRelativeToHistoryPath(history.createHref(location), history);
storage.setItem(key, url);
};
track(history.location);
return history.listen(track);
},
getTrackedUrl() {
return storage.getItem(key);
},
trackUrl(url: string) {
storage.setItem(key, url);
},
};
}

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
export {
createSessionStorageStateStorage,
createKbnUrlStateStorage,
IKbnUrlStateStorage,
ISessionStorageStateStorage,
} from './state_sync_state_storage';
export { IStateSyncConfig, INullableBaseStateContainer } from './types';
export {
syncState,
syncStates,
StopSyncStateFnType,
StartSyncStateFnType,
ISyncStateRef,
} from './state_sync';

View file

@ -0,0 +1,308 @@
/*
* 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 { BaseStateContainer, createStateContainer } from '../state_containers';
import {
defaultState,
pureTransitions,
TodoActions,
TodoState,
} from '../../demos/state_containers/todomvc';
import { syncState, syncStates } from './state_sync';
import { IStateStorage } from './state_sync_state_storage/types';
import { Observable, Subject } from 'rxjs';
import {
createSessionStorageStateStorage,
createKbnUrlStateStorage,
IKbnUrlStateStorage,
ISessionStorageStateStorage,
} from './state_sync_state_storage';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { createBrowserHistory, History } from 'history';
import { INullableBaseStateContainer } from './types';
describe('state_sync', () => {
describe('basic', () => {
const container = createStateContainer<TodoState, TodoActions>(defaultState, pureTransitions);
beforeEach(() => {
container.set(defaultState);
});
const storageChange$ = new Subject<TodoState | null>();
let testStateStorage: IStateStorage;
beforeEach(() => {
testStateStorage = {
set: jest.fn(),
get: jest.fn(),
change$: <State>(key: string) => storageChange$.asObservable() as Observable<State | null>,
};
});
it('should sync state to storage', () => {
const key = '_s';
const { start, stop } = syncState({
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: testStateStorage,
});
start();
// initial sync of state to storage is not happening
expect(testStateStorage.set).not.toBeCalled();
container.transitions.add({
id: 1,
text: 'Learning transitions...',
completed: false,
});
expect(testStateStorage.set).toBeCalledWith(key, container.getState());
stop();
});
it('should sync storage to state', () => {
const key = '_s';
const storageState1 = [{ id: 1, text: 'todo', completed: false }];
(testStateStorage.get as jest.Mock).mockImplementation(() => storageState1);
const { stop, start } = syncState({
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: testStateStorage,
});
start();
// initial sync of storage to state is not happening
expect(container.getState()).toEqual(defaultState);
const storageState2 = [{ id: 1, text: 'todo', completed: true }];
(testStateStorage.get as jest.Mock).mockImplementation(() => storageState2);
storageChange$.next(storageState2);
expect(container.getState()).toEqual(storageState2);
stop();
});
it('should not update storage if no actual state change happened', () => {
const key = '_s';
const { stop, start } = syncState({
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: testStateStorage,
});
start();
(testStateStorage.set as jest.Mock).mockClear();
container.set(defaultState);
expect(testStateStorage.set).not.toBeCalled();
stop();
});
it('should not update state container if no actual storage change happened', () => {
const key = '_s';
const { stop, start } = syncState({
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: testStateStorage,
});
start();
const originalState = container.getState();
const storageState = [...originalState];
(testStateStorage.get as jest.Mock).mockImplementation(() => storageState);
storageChange$.next(storageState);
expect(container.getState()).toBe(originalState);
stop();
});
it('storage change to null should notify state', () => {
container.set([{ completed: false, id: 1, text: 'changed' }]);
const { stop, start } = syncStates([
{
stateContainer: withDefaultState(container, defaultState),
storageKey: '_s',
stateStorage: testStateStorage,
},
]);
start();
(testStateStorage.get as jest.Mock).mockImplementation(() => null);
storageChange$.next(null);
expect(container.getState()).toEqual(defaultState);
stop();
});
});
describe('integration', () => {
const key = '_s';
const container = createStateContainer<TodoState, TodoActions>(defaultState, pureTransitions);
let sessionStorage: StubBrowserStorage;
let sessionStorageSyncStrategy: ISessionStorageStateStorage;
let history: History;
let urlSyncStrategy: IKbnUrlStateStorage;
const getCurrentUrl = () => history.createHref(history.location);
const tick = () => new Promise(resolve => setTimeout(resolve));
beforeEach(() => {
container.set(defaultState);
window.location.href = '/';
sessionStorage = new StubBrowserStorage();
sessionStorageSyncStrategy = createSessionStorageStateStorage(sessionStorage);
history = createBrowserHistory();
urlSyncStrategy = createKbnUrlStateStorage({ useHash: false, history });
});
it('change to one storage should also update other storage', () => {
const { stop, start } = syncStates([
{
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: urlSyncStrategy,
},
{
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: sessionStorageSyncStrategy,
},
]);
start();
const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }];
history.replace('/#?_s=!((completed:!f,id:1,text:changed))');
expect(container.getState()).toEqual(newStateFromUrl);
expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl);
stop();
});
it('KbnUrlSyncStrategy applies url updates asynchronously to trigger single history change', async () => {
const { stop, start } = syncStates([
{
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: urlSyncStrategy,
},
]);
start();
const startHistoryLength = history.length;
container.transitions.add({ id: 2, text: '2', completed: false });
container.transitions.add({ id: 3, text: '3', completed: false });
container.transitions.completeAll();
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
await tick();
expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"`
);
stop();
});
it('KbnUrlSyncStrategy supports flushing url updates synchronously and triggers single history change', async () => {
const { stop, start } = syncStates([
{
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: urlSyncStrategy,
},
]);
start();
const startHistoryLength = history.length;
container.transitions.add({ id: 2, text: '2', completed: false });
container.transitions.add({ id: 3, text: '3', completed: false });
container.transitions.completeAll();
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlSyncStrategy.flush();
expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"`
);
await tick();
expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"`
);
stop();
});
it('KbnUrlSyncStrategy supports cancellation of pending updates ', async () => {
const { stop, start } = syncStates([
{
stateContainer: withDefaultState(container, defaultState),
storageKey: key,
stateStorage: urlSyncStrategy,
},
]);
start();
const startHistoryLength = history.length;
container.transitions.add({ id: 2, text: '2', completed: false });
container.transitions.add({ id: 3, text: '3', completed: false });
container.transitions.completeAll();
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlSyncStrategy.cancel();
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
await tick();
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
stop();
});
});
});
function withDefaultState<State>(
stateContainer: BaseStateContainer<State>,
// eslint-disable-next-line no-shadow
defaultState: State
): INullableBaseStateContainer<State> {
return {
...stateContainer,
set: (state: State | null) => {
stateContainer.set(state || defaultState);
},
};
}

View file

@ -0,0 +1,171 @@
/*
* 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 { EMPTY, Subscription } from 'rxjs';
import { tap } from 'rxjs/operators';
import defaultComparator from 'fast-deep-equal';
import { IStateSyncConfig } from './types';
import { IStateStorage } from './state_sync_state_storage';
import { distinctUntilChangedWithInitialValue } from '../../common';
/**
* Utility for syncing application state wrapped in state container
* with some kind of storage (e.g. URL)
*
* Examples:
*
* 1. the simplest use case
* const stateStorage = createKbnUrlStateStorage();
* syncState({
* storageKey: '_s',
* stateContainer,
* stateStorage
* });
*
* 2. conditionally configuring sync strategy
* const stateStorage = createKbnUrlStateStorage({useHash: config.get('state:stateContainerInSessionStorage')})
* syncState({
* storageKey: '_s',
* stateContainer,
* stateStorage
* });
*
* 3. implementing custom sync strategy
* const localStorageStateStorage = {
* set: (storageKey, state) => localStorage.setItem(storageKey, JSON.stringify(state)),
* get: (storageKey) => localStorage.getItem(storageKey) ? JSON.parse(localStorage.getItem(storageKey)) : null
* };
* syncState({
* storageKey: '_s',
* stateContainer,
* stateStorage: localStorageStateStorage
* });
*
* 4. Transform state before serialising
* Useful for:
* * Migration / backward compatibility
* * Syncing part of state
* * Providing default values
* const stateToStorage = (s) => ({ tab: s.tab });
* syncState({
* storageKey: '_s',
* stateContainer: {
* get: () => stateToStorage(stateContainer.get()),
* set: stateContainer.set(({ tab }) => ({ ...stateContainer.get(), tab }),
* state$: stateContainer.state$.pipe(map(stateToStorage))
* },
* stateStorage
* });
*
* Caveats:
*
* 1. It is responsibility of consumer to make sure that initial app state and storage are in sync before starting syncing
* No initial sync happens when syncState() is called
*/
export type StopSyncStateFnType = () => void;
export type StartSyncStateFnType = () => void;
export interface ISyncStateRef<stateStorage extends IStateStorage = IStateStorage> {
// stop syncing state with storage
stop: StopSyncStateFnType;
// start syncing state with storage
start: StartSyncStateFnType;
}
export function syncState<State = unknown, StateStorage extends IStateStorage = IStateStorage>({
storageKey,
stateStorage,
stateContainer,
}: IStateSyncConfig<State, IStateStorage>): ISyncStateRef {
const subscriptions: Subscription[] = [];
const updateState = () => {
const newState = stateStorage.get<State>(storageKey);
const oldState = stateContainer.get();
if (!defaultComparator(newState, oldState)) {
stateContainer.set(newState);
}
};
const updateStorage = () => {
const newStorageState = stateContainer.get();
const oldStorageState = stateStorage.get<State>(storageKey);
if (!defaultComparator(newStorageState, oldStorageState)) {
stateStorage.set(storageKey, newStorageState);
}
};
const onStateChange$ = stateContainer.state$.pipe(
distinctUntilChangedWithInitialValue(stateContainer.get(), defaultComparator),
tap(() => updateStorage())
);
const onStorageChange$ = stateStorage.change$
? stateStorage.change$(storageKey).pipe(
distinctUntilChangedWithInitialValue(stateStorage.get(storageKey), defaultComparator),
tap(() => {
updateState();
})
)
: EMPTY;
return {
stop: () => {
// if stateStorage has any cancellation logic, then run it
if (stateStorage.cancel) {
stateStorage.cancel();
}
subscriptions.forEach(s => s.unsubscribe());
subscriptions.splice(0, subscriptions.length);
},
start: () => {
if (subscriptions.length > 0) {
throw new Error("syncState: can't start syncing state, when syncing is in progress");
}
subscriptions.push(onStateChange$.subscribe(), onStorageChange$.subscribe());
},
};
}
/**
* multiple different sync configs
* syncStates([
* {
* storageKey: '_s1',
* stateStorage: stateStorage1,
* stateContainer: stateContainer1,
* },
* {
* storageKey: '_s2',
* stateStorage: stateStorage2,
* stateContainer: stateContainer2,
* },
* ]);
* @param stateSyncConfigs - Array of IStateSyncConfig to sync
*/
export function syncStates(stateSyncConfigs: Array<IStateSyncConfig<any>>): ISyncStateRef {
const syncRefs = stateSyncConfigs.map(config => syncState(config));
return {
stop: () => {
syncRefs.forEach(s => s.stop());
},
start: () => {
syncRefs.forEach(s => s.start());
},
};
}

View file

@ -0,0 +1,120 @@
/*
* 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 '../../storage/hashed_item_store/mock';
import { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage';
import { History, createBrowserHistory } from 'history';
import { takeUntil, toArray } from 'rxjs/operators';
import { Subject } from 'rxjs';
describe('KbnUrlStateStorage', () => {
describe('useHash: false', () => {
let urlStateStorage: IKbnUrlStateStorage;
let history: History;
const getCurrentUrl = () => history.createHref(history.location);
beforeEach(() => {
history = createBrowserHistory();
history.push('/');
urlStateStorage = createKbnUrlStateStorage({ useHash: false, history });
});
it('should persist state to url', async () => {
const state = { test: 'test', ok: 1 };
const key = '_s';
await urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`);
expect(urlStateStorage.get(key)).toEqual(state);
});
it('should flush state to url', () => {
const state = { test: 'test', ok: 1 };
const key = '_s';
urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlStateStorage.flush();
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`);
expect(urlStateStorage.get(key)).toEqual(state);
});
it('should cancel url updates', async () => {
const state = { test: 'test', ok: 1 };
const key = '_s';
const pr = urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlStateStorage.cancel();
await pr;
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
expect(urlStateStorage.get(key)).toEqual(null);
});
it('should notify about url changes', async () => {
expect(urlStateStorage.change$).toBeDefined();
const key = '_s';
const destroy$ = new Subject();
const result = urlStateStorage.change$!(key)
.pipe(takeUntil(destroy$), toArray())
.toPromise();
history.push(`/#?${key}=(ok:1,test:test)`);
history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`);
history.push(`/?query=test#?some=test`);
destroy$.next();
destroy$.complete();
expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]);
});
});
describe('useHash: true', () => {
let urlStateStorage: IKbnUrlStateStorage;
let history: History;
const getCurrentUrl = () => history.createHref(history.location);
beforeEach(() => {
history = createBrowserHistory();
history.push('/');
urlStateStorage = createKbnUrlStateStorage({ useHash: true, history });
});
it('should persist state to url', async () => {
const state = { test: 'test', ok: 1 };
const key = '_s';
await urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=h@487e077"`);
expect(urlStateStorage.get(key)).toEqual(state);
});
it('should notify about url changes', async () => {
expect(urlStateStorage.change$).toBeDefined();
const key = '_s';
const destroy$ = new Subject();
const result = urlStateStorage.change$!(key)
.pipe(takeUntil(destroy$), toArray())
.toPromise();
history.push(`/#?${key}=(ok:1,test:test)`);
history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`);
history.push(`/?query=test#?some=test`);
destroy$.next();
destroy$.complete();
expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]);
});
});
});

View file

@ -0,0 +1,84 @@
/*
* 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 { map, share } from 'rxjs/operators';
import { History } from 'history';
import { IStateStorage } from './types';
import {
createKbnUrlControls,
getStateFromKbnUrl,
setStateToKbnUrl,
} from '../../state_management/url';
export interface IKbnUrlStateStorage extends IStateStorage {
set: <State>(key: string, state: State, opts?: { replace: boolean }) => Promise<string>;
get: <State = unknown>(key: string) => State | null;
change$: <State = unknown>(key: string) => Observable<State | null>;
// cancels any pending url updates
cancel: () => void;
// synchronously runs any pending url updates
flush: (opts?: { replace?: boolean }) => void;
}
/**
* Implements syncing to/from url strategies.
* Replicates what was implemented in state (AppState, GlobalState)
* Both expanded and hashed use cases
*/
export const createKbnUrlStateStorage = (
{ useHash = false, history }: { useHash: boolean; history?: History } = { useHash: false }
): IKbnUrlStateStorage => {
const url = createKbnUrlControls(history);
return {
set: <State>(
key: string,
state: State,
{ replace = false }: { replace: boolean } = { replace: false }
) => {
// syncState() utils doesn't wait for this promise
return url.updateAsync(
currentUrl => setStateToKbnUrl(key, state, { useHash }, currentUrl),
replace
);
},
get: key => getStateFromKbnUrl(key),
change$: <State>(key: string) =>
new Observable(observer => {
const unlisten = url.listen(() => {
observer.next();
});
return () => {
unlisten();
};
}).pipe(
map(() => getStateFromKbnUrl<State>(key)),
share()
),
flush: ({ replace = false }: { replace?: boolean } = {}) => {
url.flush(replace);
},
cancel() {
url.cancel();
},
};
};

View file

@ -0,0 +1,44 @@
/*
* 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 {
createSessionStorageStateStorage,
ISessionStorageStateStorage,
} from './create_session_storage_state_storage';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
describe('SessionStorageStateStorage', () => {
let browserStorage: StubBrowserStorage;
let stateStorage: ISessionStorageStateStorage;
beforeEach(() => {
browserStorage = new StubBrowserStorage();
stateStorage = createSessionStorageStateStorage(browserStorage);
});
it('should synchronously sync to storage', () => {
const state = { state: 'state' };
stateStorage.set('key', state);
expect(stateStorage.get('key')).toEqual(state);
expect(browserStorage.getItem('key')).not.toBeNull();
});
it('should not implement change$', () => {
expect(stateStorage.change$).not.toBeDefined();
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { IStateStorage } from './types';
export interface ISessionStorageStateStorage extends IStateStorage {
set: <State>(key: string, state: State) => void;
get: <State = unknown>(key: string) => State | null;
}
export const createSessionStorageStateStorage = (
storage: Storage = window.sessionStorage
): ISessionStorageStateStorage => {
return {
set: <State>(key: string, state: State) => storage.setItem(key, JSON.stringify(state)),
get: (key: string) => JSON.parse(storage.getItem(key)!),
};
};

View file

@ -0,0 +1,25 @@
/*
* 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.
*/
export { IStateStorage } from './types';
export { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage';
export {
createSessionStorageStateStorage,
ISessionStorageStateStorage,
} from './create_session_storage_state_storage';

View file

@ -0,0 +1,51 @@
/*
* 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';
/**
* Any StateStorage have to implement IStateStorage interface
* StateStorage is responsible for:
* * state serialisation / deserialization
* * persisting to and retrieving from storage
*
* For an example take a look at already implemented KbnUrl state storage
*/
export interface IStateStorage {
/**
* Take in a state object, should serialise and persist
*/
set: <State>(key: string, state: State) => any;
/**
* Should retrieve state from the storage and deserialize it
*/
get: <State = unknown>(key: string) => State | null;
/**
* Should notify when the stored state has changed
*/
change$?: <State = unknown>(key: string) => Observable<State | null>;
/**
* Optional method to cancel any pending activity
* syncState() will call it, if it is provided by IStateStorage
*/
cancel?: () => void;
}

View file

@ -0,0 +1,56 @@
/*
* 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 { BaseStateContainer } from '../state_containers/types';
import { IStateStorage } from './state_sync_state_storage';
export interface INullableBaseStateContainer<State> extends BaseStateContainer<State> {
// State container for stateSync() have to accept "null"
// for example, set() implementation could handle null and fallback to some default state
// this is required to handle edge case, when state in storage becomes empty and syncing is in progress.
// state container will be notified about about storage becoming empty with null passed in
set: (state: State | null) => void;
}
export interface IStateSyncConfig<
State = unknown,
StateStorage extends IStateStorage = IStateStorage
> {
/**
* Storage key to use for syncing,
* e.g. storageKey '_a' should sync state to ?_a query param
*/
storageKey: string;
/**
* State container to keep in sync with storage, have to implement INullableBaseStateContainer<State> interface
* The idea is that ./state_containers/ should be used as a state container,
* but it is also possible to implement own custom container for advanced use cases
*/
stateContainer: INullableBaseStateContainer<State>;
/**
* State storage to use,
* State storage is responsible for serialising / deserialising and persisting / retrieving stored state
*
* There are common strategies already implemented:
* './state_sync_state_storage/'
* which replicate what State (AppState, GlobalState) in legacy world did
*
*/
stateStorage: StateStorage;
}

View file

@ -970,8 +970,8 @@
"kibana-react.savedObjects.saveModal.saveButtonLabel": "保存",
"kibana-react.savedObjects.saveModal.saveTitle": "{objectType} を保存",
"kibana-react.savedObjects.saveModal.titleLabel": "タイトル",
"kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。",
"kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。",
"kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。",
"kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。",
"inspector.closeButton": "インスペクターを閉じる",
"inspector.reqTimestampDescription": "リクエストの開始が記録された時刻です",
"inspector.reqTimestampKey": "リクエストのタイムスタンプ",

View file

@ -971,8 +971,8 @@
"kibana-react.savedObjects.saveModal.saveButtonLabel": "保存",
"kibana-react.savedObjects.saveModal.saveTitle": "保存 {objectType}",
"kibana-react.savedObjects.saveModal.titleLabel": "标题",
"kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "无法完整还原 URL确保使用共享功能。",
"kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题但这会导致更大的问题。如果您有规律地看到此消息请在 {gitHubIssuesUrl} 提交问题。",
"kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "无法完整还原 URL确保使用共享功能。",
"kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题但这会导致更大的问题。如果您有规律地看到此消息请在 {gitHubIssuesUrl} 提交问题。",
"inspector.closeButton": "关闭检查器",
"inspector.reqTimestampDescription": "记录请求启动的时间",
"inspector.reqTimestampKey": "请求时间戳",

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
declare module 'encode-uri-query' {
function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
// eslint-disable-next-line import/no-default-export
export default encodeUriQuery;
}

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
declare module 'encode-uri-query' {
function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
// eslint-disable-next-line import/no-default-export
export default encodeUriQuery;
}

View file

@ -11452,11 +11452,6 @@ enabled@1.0.x:
dependencies:
env-variable "0.0.x"
encode-uri-query@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/encode-uri-query/-/encode-uri-query-1.0.1.tgz#e9c70d3e1aab71b039e55b38a166013508803ba8"
integrity sha1-6ccNPhqrcbA55Vs4oWYBNQiAO6g=
encodeurl@^1.0.2, encodeurl@~1.0.1, encodeurl@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"