mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
8636011d7c
commit
5f52cf0bf3
56 changed files with 2790 additions and 168 deletions
10
examples/state_containers_examples/kibana.json
Normal file
10
examples/state_containers_examples/kibana.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "stateContainersExamples",
|
||||
"version": "0.0.1",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["state_containers_examples"],
|
||||
"server": false,
|
||||
"ui": true,
|
||||
"requiredPlugins": [],
|
||||
"optionalPlugins": []
|
||||
}
|
17
examples/state_containers_examples/package.json
Normal file
17
examples/state_containers_examples/package.json
Normal 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"
|
||||
}
|
||||
}
|
69
examples/state_containers_examples/public/app.tsx
Normal file
69
examples/state_containers_examples/public/app.tsx
Normal 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);
|
||||
};
|
|
@ -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();
|
52
examples/state_containers_examples/public/plugin.ts
Normal file
52
examples/state_containers_examples/public/plugin.ts
Normal 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() {}
|
||||
}
|
327
examples/state_containers_examples/public/todo.tsx
Normal file
327
examples/state_containers_examples/public/todo.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
15
examples/state_containers_examples/tsconfig.json
Normal file
15
examples/state_containers_examples/tsconfig.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./target",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"public/**/*.ts",
|
||||
"public/**/*.tsx",
|
||||
"server/**/*.ts",
|
||||
"../../typings/**/*"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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('/');
|
||||
});
|
||||
});
|
|
@ -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]);
|
||||
}
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
);
|
||||
}
|
|
@ -18,3 +18,4 @@
|
|||
*/
|
||||
|
||||
export * from './defer';
|
||||
export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value';
|
||||
|
|
|
@ -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))"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
70
src/plugins/kibana_utils/demos/state_sync/url.ts
Normal file
70
src/plugins/kibana_utils/demos/state_sync/url.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
{},
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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));
|
||||
}
|
|
@ -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';
|
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export * from './state_hash';
|
||||
export { isStateHash, createStateHash, persistState, retrieveState } from './state_hash';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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"`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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:'')"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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());
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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' : '+');
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
33
src/plugins/kibana_utils/public/state_sync/index.ts
Normal file
33
src/plugins/kibana_utils/public/state_sync/index.ts
Normal 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';
|
308
src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
Normal file
308
src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
171
src/plugins/kibana_utils/public/state_sync/state_sync.ts
Normal file
171
src/plugins/kibana_utils/public/state_sync/state_sync.ts
Normal 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());
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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)!),
|
||||
};
|
||||
};
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
56
src/plugins/kibana_utils/public/state_sync/types.ts
Normal file
56
src/plugins/kibana_utils/public/state_sync/types.ts
Normal 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;
|
||||
}
|
|
@ -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": "リクエストのタイムスタンプ",
|
||||
|
|
|
@ -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": "请求时间戳",
|
||||
|
|
11
x-pack/test/typings/encode_uri_query.d.ts
vendored
11
x-pack/test/typings/encode_uri_query.d.ts
vendored
|
@ -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;
|
||||
}
|
11
x-pack/typings/encode_uri_query.d.ts
vendored
11
x-pack/typings/encode_uri_query.d.ts
vendored
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue