mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[State Management] ScopedHistory support for state syncing utils (#62761)
The needed change is to rely on history as source of truth for location instead of window.location. btw, This makes possible to test state syncing utils integration using createMemoryHistory() One issue was discovered after this change: When switching from context to discover url was incorrect. history.location inside state syncing utils didn't get the last update. This happened, because history instance created in discover wasn't used in context app and when all listeners unsubscribed from it - it stopped receiving location updates. To fix this I just reused one history instance in discover, context and their kbnUrlTracker
This commit is contained in:
parent
02d55db6cd
commit
3b1d0e0c6b
20 changed files with 403 additions and 44 deletions
|
@ -20,7 +20,7 @@
|
|||
import { AppMountParameters } from 'kibana/public';
|
||||
import ReactDOM from 'react-dom';
|
||||
import React from 'react';
|
||||
import { createHashHistory, createBrowserHistory } from 'history';
|
||||
import { createHashHistory } from 'history';
|
||||
import { TodoAppPage } from './todo';
|
||||
|
||||
export interface AppOptions {
|
||||
|
@ -35,13 +35,10 @@ export enum History {
|
|||
}
|
||||
|
||||
export const renderApp = (
|
||||
{ appBasePath, element }: AppMountParameters,
|
||||
{ appBasePath, element, history: platformHistory }: AppMountParameters,
|
||||
{ appInstanceId, appTitle, historyType }: AppOptions
|
||||
) => {
|
||||
const history =
|
||||
historyType === History.Browser
|
||||
? createBrowserHistory({ basename: appBasePath })
|
||||
: createHashHistory();
|
||||
const history = historyType === History.Browser ? platformHistory : createHashHistory();
|
||||
ReactDOM.render(
|
||||
<TodoAppPage
|
||||
history={history}
|
||||
|
@ -54,8 +51,7 @@ export const renderApp = (
|
|||
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;
|
||||
return currentAppUrl === '' && !history.location.search && !history.location.hash;
|
||||
} else {
|
||||
// hashed history
|
||||
return currentAppUrl === '#' && !history.location.search;
|
||||
|
|
|
@ -91,17 +91,20 @@ const TodoApp: React.FC<TodoAppProps> = ({ filter }) => {
|
|||
return (
|
||||
<>
|
||||
<div>
|
||||
<Link to={{ ...location, pathname: '/' }}>
|
||||
<Link to={{ ...location, pathname: '/' }} data-test-subj={'filterLinkAll'}>
|
||||
<EuiButton size={'s'} color={!filter ? 'primary' : 'secondary'}>
|
||||
All
|
||||
</EuiButton>
|
||||
</Link>
|
||||
<Link to={{ ...location, pathname: '/completed' }}>
|
||||
<Link to={{ ...location, pathname: '/completed' }} data-test-subj={'filterLinkCompleted'}>
|
||||
<EuiButton size={'s'} color={filter === 'completed' ? 'primary' : 'secondary'}>
|
||||
Completed
|
||||
</EuiButton>
|
||||
</Link>
|
||||
<Link to={{ ...location, pathname: '/not-completed' }}>
|
||||
<Link
|
||||
to={{ ...location, pathname: '/not-completed' }}
|
||||
data-test-subj={'filterLinkNotCompleted'}
|
||||
>
|
||||
<EuiButton size={'s'} color={filter === 'not-completed' ? 'primary' : 'secondary'}>
|
||||
Not Completed
|
||||
</EuiButton>
|
||||
|
@ -121,6 +124,7 @@ const TodoApp: React.FC<TodoAppProps> = ({ filter }) => {
|
|||
});
|
||||
}}
|
||||
label={todo.text}
|
||||
data-test-subj={`todoCheckbox-${todo.id}`}
|
||||
/>
|
||||
<EuiButton
|
||||
style={{ marginLeft: '8px' }}
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { AppMountParameters, CoreStart } from '../../../../src/core/public';
|
||||
import { AppPluginDependencies } from './types';
|
||||
import { StateDemoApp } from './components/app';
|
||||
|
@ -28,9 +27,8 @@ import { createKbnUrlStateStorage } from '../../../../src/plugins/kibana_utils/p
|
|||
export const renderApp = (
|
||||
{ notifications, http }: CoreStart,
|
||||
{ navigation, data }: AppPluginDependencies,
|
||||
{ appBasePath, element }: AppMountParameters
|
||||
{ appBasePath, element, history }: AppMountParameters
|
||||
) => {
|
||||
const history = createBrowserHistory({ basename: appBasePath });
|
||||
const kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history });
|
||||
|
||||
ReactDOM.render(
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createHashHistory, History } from 'history';
|
||||
import { History } from 'history';
|
||||
|
||||
import {
|
||||
Capabilities,
|
||||
|
@ -51,7 +51,7 @@ export interface DiscoverServices {
|
|||
data: DataPublicPluginStart;
|
||||
docLinks: DocLinksStart;
|
||||
DocViewer: DocViewerComponent;
|
||||
history: History;
|
||||
history: () => History;
|
||||
theme: ChartsPluginStart['theme'];
|
||||
filterManager: FilterManager;
|
||||
indexPatterns: IndexPatternsContract;
|
||||
|
@ -67,7 +67,8 @@ export interface DiscoverServices {
|
|||
}
|
||||
export async function buildServices(
|
||||
core: CoreStart,
|
||||
plugins: DiscoverStartPlugins
|
||||
plugins: DiscoverStartPlugins,
|
||||
getHistory: () => History
|
||||
): Promise<DiscoverServices> {
|
||||
const services = {
|
||||
savedObjectsClient: core.savedObjects.client,
|
||||
|
@ -77,6 +78,7 @@ export async function buildServices(
|
|||
overlays: core.overlays,
|
||||
};
|
||||
const savedObjectService = createSavedSearchesLoader(services);
|
||||
|
||||
return {
|
||||
addBasePath: core.http.basePath.prepend,
|
||||
capabilities: core.application.capabilities,
|
||||
|
@ -85,11 +87,11 @@ export async function buildServices(
|
|||
data: plugins.data,
|
||||
docLinks: core.docLinks,
|
||||
DocViewer: plugins.discover.docViews.DocViewer,
|
||||
history: createHashHistory(),
|
||||
theme: plugins.charts.theme,
|
||||
filterManager: plugins.data.query.filterManager,
|
||||
getSavedSearchById: async (id: string) => savedObjectService.get(id),
|
||||
getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id),
|
||||
history: getHistory,
|
||||
indexPatterns: plugins.data.indexPatterns,
|
||||
inspector: plugins.inspector,
|
||||
// @ts-ignore
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createHashHistory } from 'history';
|
||||
import { DiscoverServices } from './build_services';
|
||||
import { createGetterSetter } from '../../../../../plugins/kibana_utils/public';
|
||||
import { search } from '../../../../../plugins/data/public';
|
||||
|
@ -52,6 +53,11 @@ export const [getUrlTracker, setUrlTracker] = createGetterSetter<{
|
|||
setTrackedUrl: (url: string) => void;
|
||||
}>('urlTracker');
|
||||
|
||||
/**
|
||||
* Makes sure discover and context are using one instance of history
|
||||
*/
|
||||
export const getHistory = _.once(() => createHashHistory());
|
||||
|
||||
export const { getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse } = search;
|
||||
export {
|
||||
unhashUrl,
|
||||
|
|
|
@ -81,6 +81,7 @@ function ContextAppRouteController($routeParams, $scope, $route) {
|
|||
defaultStepSize: getServices().uiSettings.get('context:defaultSize'),
|
||||
timeFieldName: indexPattern.timeFieldName,
|
||||
storeInSessionStorage: getServices().uiSettings.get('state:storeInSessionStorage'),
|
||||
history: getServices().history(),
|
||||
});
|
||||
this.state = { ...appState.getState() };
|
||||
this.anchorId = $routeParams.id;
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
import _ from 'lodash';
|
||||
import { createBrowserHistory, History } from 'history';
|
||||
import { History } from 'history';
|
||||
import {
|
||||
createStateContainer,
|
||||
createKbnUrlStateStorage,
|
||||
|
@ -71,9 +71,9 @@ interface GetStateParams {
|
|||
*/
|
||||
storeInSessionStorage?: boolean;
|
||||
/**
|
||||
* Browser history used for testing
|
||||
* History instance to use
|
||||
*/
|
||||
history?: History;
|
||||
history: History;
|
||||
}
|
||||
|
||||
interface GetStateReturn {
|
||||
|
@ -126,7 +126,7 @@ export function getState({
|
|||
}: GetStateParams): GetStateReturn {
|
||||
const stateStorage = createKbnUrlStateStorage({
|
||||
useHash: storeInSessionStorage,
|
||||
history: history ? history : createBrowserHistory(),
|
||||
history,
|
||||
});
|
||||
|
||||
const globalStateInitial = stateStorage.get(GLOBAL_STATE_URL_KEY) as GlobalState;
|
||||
|
|
|
@ -57,7 +57,7 @@ const {
|
|||
core,
|
||||
chrome,
|
||||
data,
|
||||
history,
|
||||
history: getHistory,
|
||||
indexPatterns,
|
||||
filterManager,
|
||||
share,
|
||||
|
@ -116,6 +116,7 @@ app.config($routeProvider => {
|
|||
reloadOnSearch: false,
|
||||
resolve: {
|
||||
savedObjects: function($route, Promise) {
|
||||
const history = getHistory();
|
||||
const savedSearchId = $route.current.params.id;
|
||||
return ensureDefaultIndexPattern(core, data, history).then(() => {
|
||||
const { appStateContainer } = getState({ history });
|
||||
|
@ -204,6 +205,8 @@ function discoverController(
|
|||
return isDefaultType($scope.indexPattern) ? $scope.indexPattern.timeFieldName : undefined;
|
||||
};
|
||||
|
||||
const history = getHistory();
|
||||
|
||||
const {
|
||||
appStateContainer,
|
||||
startSync: startStateSync,
|
||||
|
|
|
@ -31,11 +31,11 @@ import { IndexPatternField } from '../../../../../../../../plugins/data/public';
|
|||
|
||||
jest.mock('../../../kibana_services', () => ({
|
||||
getServices: () => ({
|
||||
history: {
|
||||
history: () => ({
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
|
|
|
@ -36,11 +36,11 @@ import { SavedObject } from '../../../../../../../../core/types';
|
|||
|
||||
jest.mock('../../../kibana_services', () => ({
|
||||
getServices: () => ({
|
||||
history: {
|
||||
history: () => ({
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
|
|
|
@ -125,7 +125,7 @@ export function getVisualizeUrl(
|
|||
services: DiscoverServices
|
||||
) {
|
||||
const aggsTermSize = services.uiSettings.get('discover:aggs:terms:size');
|
||||
const urlParams = parse(services.history.location.search) as Record<string, string>;
|
||||
const urlParams = parse(services.history().location.search) as Record<string, string>;
|
||||
|
||||
if (
|
||||
(field.type === KBN_FIELD_TYPES.GEO_POINT || field.type === KBN_FIELD_TYPES.GEO_SHAPE) &&
|
||||
|
|
|
@ -31,7 +31,7 @@ import { registerFeature } from './np_ready/register_feature';
|
|||
import './kibana_services';
|
||||
import { EmbeddableStart, EmbeddableSetup } from '../../../../../plugins/embeddable/public';
|
||||
import { getInnerAngularModule, getInnerAngularModuleEmbeddable } from './get_inner_angular';
|
||||
import { setAngularModule, setServices, setUrlTracker } from './kibana_services';
|
||||
import { getHistory, setAngularModule, setServices, setUrlTracker } from './kibana_services';
|
||||
import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public';
|
||||
import { ChartsPluginStart } from '../../../../../plugins/charts/public';
|
||||
import { buildServices } from './build_services';
|
||||
|
@ -98,6 +98,10 @@ export class DiscoverPlugin implements Plugin<void, void> {
|
|||
stop: stopUrlTracker,
|
||||
setActiveUrl: setTrackedUrl,
|
||||
} = createKbnUrlTracker({
|
||||
// we pass getter here instead of plain `history`,
|
||||
// so history is lazily created (when app is mounted)
|
||||
// this prevents redundant `#` when not in discover app
|
||||
getHistory,
|
||||
baseUrl: core.http.basePath.prepend('/app/kibana'),
|
||||
defaultSubUrl: '#/discover',
|
||||
storageKey: `lastUrl:${core.http.basePath.get()}:discover`,
|
||||
|
@ -174,7 +178,7 @@ export class DiscoverPlugin implements Plugin<void, void> {
|
|||
if (this.servicesInitialized) {
|
||||
return { core, plugins };
|
||||
}
|
||||
const services = await buildServices(core, plugins);
|
||||
const services = await buildServices(core, plugins, getHistory);
|
||||
setServices(services);
|
||||
this.servicesInitialized = true;
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
setStateToKbnUrl,
|
||||
getStateFromKbnUrl,
|
||||
} from './kbn_url_storage';
|
||||
import { ScopedHistory } from '../../../../../core/public';
|
||||
|
||||
describe('kbn_url_storage', () => {
|
||||
describe('getStateFromUrl & setStateToUrl', () => {
|
||||
|
@ -187,23 +188,54 @@ describe('kbn_url_storage', () => {
|
|||
urlControls.update('/', true);
|
||||
});
|
||||
|
||||
const getCurrentUrl = () => window.location.href;
|
||||
const getCurrentUrl = () => history.createHref(history.location);
|
||||
|
||||
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('http://localhost/');
|
||||
expect(urlControls.flush()).toBe('http://localhost/3');
|
||||
expect(getCurrentUrl()).toBe('http://localhost/3');
|
||||
expect(getCurrentUrl()).toBe('/');
|
||||
expect(urlControls.flush()).toBe('/3');
|
||||
expect(getCurrentUrl()).toBe('/3');
|
||||
await Promise.all([pr1, pr2, pr3]);
|
||||
expect(getCurrentUrl()).toBe('http://localhost/3');
|
||||
expect(getCurrentUrl()).toBe('/3');
|
||||
});
|
||||
|
||||
it('flush() should return undefined, if no url updates happened', () => {
|
||||
expect(urlControls.flush()).toBeUndefined();
|
||||
urlControls.updateAsync(() => 'http://localhost/1', false);
|
||||
urlControls.updateAsync(() => 'http://localhost/', false);
|
||||
urlControls.updateAsync(() => '/1', false);
|
||||
urlControls.updateAsync(() => '/', false);
|
||||
expect(urlControls.flush()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('urlControls - scoped history integration', () => {
|
||||
let history: History;
|
||||
let urlControls: IKbnUrlControls;
|
||||
beforeEach(() => {
|
||||
const parentHistory = createBrowserHistory();
|
||||
parentHistory.replace('/app/kibana/');
|
||||
history = new ScopedHistory(parentHistory, '/app/kibana/');
|
||||
urlControls = createKbnUrlControls(history);
|
||||
});
|
||||
|
||||
const getCurrentUrl = () => history.createHref(history.location);
|
||||
|
||||
it('should flush async url updates', async () => {
|
||||
const pr1 = urlControls.updateAsync(() => '/app/kibana/1', false);
|
||||
const pr2 = urlControls.updateAsync(() => '/app/kibana/2', false);
|
||||
const pr3 = urlControls.updateAsync(() => '/app/kibana/3', false);
|
||||
expect(getCurrentUrl()).toBe('/app/kibana/');
|
||||
expect(urlControls.flush()).toBe('/app/kibana/3');
|
||||
expect(getCurrentUrl()).toBe('/app/kibana/3');
|
||||
await Promise.all([pr1, pr2, pr3]);
|
||||
expect(getCurrentUrl()).toBe('/app/kibana/3');
|
||||
});
|
||||
|
||||
it('flush() should return undefined, if no url updates happened', () => {
|
||||
expect(urlControls.flush()).toBeUndefined();
|
||||
urlControls.updateAsync(() => '/app/kibana/1', false);
|
||||
urlControls.updateAsync(() => '/app/kibana/', false);
|
||||
expect(urlControls.flush()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -154,7 +154,7 @@ export const createKbnUrlControls = (
|
|||
let shouldReplace = true;
|
||||
|
||||
function updateUrl(newUrl: string, replace = false): string | undefined {
|
||||
const currentUrl = getCurrentUrl();
|
||||
const currentUrl = getCurrentUrl(history);
|
||||
if (newUrl === currentUrl) return undefined; // skip update
|
||||
|
||||
const historyPath = getRelativeToHistoryPath(newUrl, history);
|
||||
|
@ -165,7 +165,7 @@ export const createKbnUrlControls = (
|
|||
history.push(historyPath);
|
||||
}
|
||||
|
||||
return getCurrentUrl();
|
||||
return getCurrentUrl(history);
|
||||
}
|
||||
|
||||
// queue clean up
|
||||
|
@ -187,7 +187,10 @@ export const createKbnUrlControls = (
|
|||
|
||||
function getPendingUrl() {
|
||||
if (updateQueue.length === 0) return undefined;
|
||||
const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl());
|
||||
const resultUrl = updateQueue.reduce(
|
||||
(url, nextUpdate) => nextUpdate(url),
|
||||
getCurrentUrl(history)
|
||||
);
|
||||
|
||||
return resultUrl;
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ export function createKbnUrlTracker({
|
|||
navLinkUpdater$,
|
||||
toastNotifications,
|
||||
history,
|
||||
getHistory,
|
||||
storage,
|
||||
shouldTrackUrlUpdate = pathname => {
|
||||
const currentAppName = defaultSubUrl.slice(2); // cut hash and slash symbols
|
||||
|
@ -103,6 +104,12 @@ export function createKbnUrlTracker({
|
|||
* History object to use to track url changes. If this isn't provided, a local history instance will be created.
|
||||
*/
|
||||
history?: History;
|
||||
|
||||
/**
|
||||
* Lazily retrieve history instance
|
||||
*/
|
||||
getHistory?: () => History;
|
||||
|
||||
/**
|
||||
* Storage object to use to persist currently active url. If this isn't provided, the browser wide session storage instance will be used.
|
||||
*/
|
||||
|
@ -158,7 +165,7 @@ export function createKbnUrlTracker({
|
|||
|
||||
function onMountApp() {
|
||||
unsubscribe();
|
||||
const historyInstance = history || createHashHistory();
|
||||
const historyInstance = history || (getHistory && getHistory()) || createHashHistory();
|
||||
// track current hash when within app
|
||||
unsubscribeURLHistory = historyInstance.listen(location => {
|
||||
if (shouldTrackUrlUpdate(location.pathname)) {
|
||||
|
|
|
@ -18,12 +18,11 @@
|
|||
*/
|
||||
|
||||
import { parse as _parseUrl } from 'url';
|
||||
import { History } from 'history';
|
||||
|
||||
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());
|
||||
export const getCurrentUrl = (history: History) => history.createHref(history.location);
|
||||
|
|
|
@ -21,6 +21,7 @@ import { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_
|
|||
import { History, createBrowserHistory } from 'history';
|
||||
import { takeUntil, toArray } from 'rxjs/operators';
|
||||
import { Subject } from 'rxjs';
|
||||
import { ScopedHistory } from '../../../../../core/public';
|
||||
|
||||
describe('KbnUrlStateStorage', () => {
|
||||
describe('useHash: false', () => {
|
||||
|
@ -132,4 +133,78 @@ describe('KbnUrlStateStorage', () => {
|
|||
expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScopedHistory integration', () => {
|
||||
let urlStateStorage: IKbnUrlStateStorage;
|
||||
let history: ScopedHistory;
|
||||
const getCurrentUrl = () => history.createHref(history.location);
|
||||
beforeEach(() => {
|
||||
const parentHistory = createBrowserHistory();
|
||||
parentHistory.push('/kibana/app/');
|
||||
history = new ScopedHistory(parentHistory, '/kibana/app/');
|
||||
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(`"/kibana/app/#?_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(`"/kibana/app/"`);
|
||||
expect(urlStateStorage.flush()).toBe(true);
|
||||
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/#?_s=(ok:1,test:test)"`);
|
||||
expect(urlStateStorage.get(key)).toEqual(state);
|
||||
|
||||
expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update
|
||||
});
|
||||
|
||||
it('should cancel url updates', async () => {
|
||||
const state = { test: 'test', ok: 1 };
|
||||
const key = '_s';
|
||||
const pr = urlStateStorage.set(key, state);
|
||||
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
|
||||
urlStateStorage.cancel();
|
||||
await pr;
|
||||
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
|
||||
expect(urlStateStorage.get(key)).toEqual(null);
|
||||
});
|
||||
|
||||
it('should cancel url updates if synchronously returned to the same state', async () => {
|
||||
const state1 = { test: 'test', ok: 1 };
|
||||
const state2 = { test: 'test', ok: 2 };
|
||||
const key = '_s';
|
||||
const pr1 = urlStateStorage.set(key, state1);
|
||||
await pr1;
|
||||
const historyLength = history.length;
|
||||
const pr2 = urlStateStorage.set(key, state2);
|
||||
const pr3 = urlStateStorage.set(key, state1);
|
||||
await Promise.all([pr2, pr3]);
|
||||
expect(history.length).toBe(historyLength);
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -28,6 +28,7 @@ export default async function({ readConfigFile }) {
|
|||
require.resolve('./search'),
|
||||
require.resolve('./embeddables'),
|
||||
require.resolve('./ui_actions'),
|
||||
require.resolve('./state_sync'),
|
||||
],
|
||||
services: {
|
||||
...functionalConfig.get('services'),
|
||||
|
|
39
test/examples/state_sync/index.ts
Normal file
39
test/examples/state_sync/index.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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 { PluginFunctionalProviderContext } from 'test/plugin_functional/services';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function({
|
||||
getService,
|
||||
getPageObjects,
|
||||
loadTestFile,
|
||||
}: PluginFunctionalProviderContext) {
|
||||
const browser = getService('browser');
|
||||
const PageObjects = getPageObjects(['common']);
|
||||
|
||||
describe('state sync examples', function() {
|
||||
before(async () => {
|
||||
await browser.setWindowSize(1300, 900);
|
||||
await PageObjects.common.navigateToApp('settings');
|
||||
});
|
||||
|
||||
loadTestFile(require.resolve('./todo_app'));
|
||||
});
|
||||
}
|
189
test/examples/state_sync/todo_app.ts
Normal file
189
test/examples/state_sync/todo_app.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
|
||||
import { PluginFunctionalProviderContext } from 'test/plugin_functional/services';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) {
|
||||
const testSubjects = getService('testSubjects');
|
||||
const find = getService('find');
|
||||
const retry = getService('retry');
|
||||
const appsMenu = getService('appsMenu');
|
||||
const browser = getService('browser');
|
||||
const PageObjects = getPageObjects(['common']);
|
||||
const log = getService('log');
|
||||
|
||||
describe('TODO app', () => {
|
||||
describe("TODO app with browser history (platform's ScopedHistory)", async () => {
|
||||
const appId = 'stateContainersExampleBrowserHistory';
|
||||
let base: string;
|
||||
|
||||
before(async () => {
|
||||
base = await PageObjects.common.getHostPort();
|
||||
await appsMenu.clickLink('State containers example - browser history routing');
|
||||
});
|
||||
|
||||
it('links are rendered correctly and state is preserved in links', async () => {
|
||||
const getHrefByLinkTestSubj = async (linkTestSubj: string) =>
|
||||
(await testSubjects.find(linkTestSubj)).getAttribute('href');
|
||||
|
||||
await expectPathname(await getHrefByLinkTestSubj('filterLinkCompleted'), '/completed');
|
||||
await expectPathname(
|
||||
await getHrefByLinkTestSubj('filterLinkNotCompleted'),
|
||||
'/not-completed'
|
||||
);
|
||||
await expectPathname(await getHrefByLinkTestSubj('filterLinkAll'), '/');
|
||||
});
|
||||
|
||||
it('TODO app state is synced with url, back navigation works', async () => {
|
||||
// checking that in initial state checkbox is unchecked and state is synced with url
|
||||
expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(false);
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!f');
|
||||
|
||||
// check the checkbox by clicking the label (clicking checkbox directly fails as it is "no intractable")
|
||||
(await find.byCssSelector('label[for="0"]')).click();
|
||||
|
||||
// wait for react to update dom and checkbox in checked state
|
||||
await retry.tryForTime(1000, async () => {
|
||||
await expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(true);
|
||||
});
|
||||
// checking that url is updated with checked state
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!t');
|
||||
|
||||
// checking back and forward button
|
||||
await browser.goBack();
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!f');
|
||||
await retry.tryForTime(1000, async () => {
|
||||
await expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(false);
|
||||
});
|
||||
|
||||
await browser.goForward();
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!t');
|
||||
await retry.tryForTime(1000, async () => {
|
||||
await expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('links navigation works', async () => {
|
||||
// click link to filter only not completed
|
||||
await testSubjects.click('filterLinkNotCompleted');
|
||||
await expectPathname(await browser.getCurrentUrl(), '/not-completed');
|
||||
// checkbox should be missing because it is "completed"
|
||||
await testSubjects.missingOrFail('todoCheckbox-0');
|
||||
});
|
||||
|
||||
/**
|
||||
* Parses app's scoped pathname from absolute url and asserts it against `expectedPathname`
|
||||
* Also checks that hashes are equal (detail of todo app that state is rendered in links)
|
||||
* @param absoluteUrl
|
||||
* @param expectedPathname
|
||||
*/
|
||||
async function expectPathname(absoluteUrl: string, expectedPathname: string) {
|
||||
const scoped = await getScopedUrl(absoluteUrl);
|
||||
const [pathname, newHash] = scoped.split('#');
|
||||
expect(pathname).to.be(expectedPathname);
|
||||
const [, currentHash] = (await browser.getCurrentUrl()).split('#');
|
||||
expect(newHash.replace(/%27/g, "'")).to.be(currentHash.replace(/%27/g, "'"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get's part of url scoped to this app (removed kibana's host and app's pathname)
|
||||
* @param url - absolute url
|
||||
*/
|
||||
async function getScopedUrl(url: string): Promise<string> {
|
||||
expect(url).to.contain(base);
|
||||
expect(url).to.contain(appId);
|
||||
const scopedUrl = url.slice(url.indexOf(appId) + appId.length);
|
||||
expect(scopedUrl).not.to.contain(appId); // app id in url only once
|
||||
return scopedUrl;
|
||||
}
|
||||
});
|
||||
|
||||
describe('TODO app with hash history ', async () => {
|
||||
before(async () => {
|
||||
await appsMenu.clickLink('State containers example - hash history routing');
|
||||
});
|
||||
|
||||
it('Links are rendered correctly and state is preserved in links', async () => {
|
||||
const getHrefByLinkTestSubj = async (linkTestSubj: string) =>
|
||||
(await testSubjects.find(linkTestSubj)).getAttribute('href');
|
||||
await expectHashPathname(await getHrefByLinkTestSubj('filterLinkCompleted'), '/completed');
|
||||
await expectHashPathname(
|
||||
await getHrefByLinkTestSubj('filterLinkNotCompleted'),
|
||||
'/not-completed'
|
||||
);
|
||||
await expectHashPathname(await getHrefByLinkTestSubj('filterLinkAll'), '/');
|
||||
});
|
||||
|
||||
it('TODO app state is synced with url, back navigation works', async () => {
|
||||
// checking that in initial state checkbox is unchecked and state is synced with url
|
||||
expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(false);
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!f');
|
||||
// check the checkbox by clicking the label (clicking checkbox directly fails as it is "no intractable")
|
||||
(await find.byCssSelector('label[for="0"]')).click();
|
||||
|
||||
// wait for react to update dom and checkbox in checked state
|
||||
await retry.tryForTime(1000, async () => {
|
||||
await expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(true);
|
||||
});
|
||||
// checking that url is updated with checked state
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!t');
|
||||
|
||||
// checking back and forward button
|
||||
await browser.goBack();
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!f');
|
||||
await retry.tryForTime(1000, async () => {
|
||||
await expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(false);
|
||||
});
|
||||
|
||||
await browser.goForward();
|
||||
expect(await browser.getCurrentUrl()).to.contain('completed:!t');
|
||||
await retry.tryForTime(1000, async () => {
|
||||
await expect(await testSubjects.isChecked('todoCheckbox-0')).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('links navigation works', async () => {
|
||||
// click link to filter only not completed
|
||||
await testSubjects.click('filterLinkNotCompleted');
|
||||
await expectHashPathname(await browser.getCurrentUrl(), '/not-completed');
|
||||
// checkbox should be missing because it is "completed"
|
||||
await testSubjects.missingOrFail('todoCheckbox-0');
|
||||
});
|
||||
|
||||
/**
|
||||
* Parses app's pathname in hash from absolute url and asserts it against `expectedPathname`
|
||||
* Also checks that queries in hashes are equal (detail of todo app that state is rendered in links)
|
||||
* @param absoluteUrl
|
||||
* @param expectedPathname
|
||||
*/
|
||||
async function expectHashPathname(hash: string, expectedPathname: string) {
|
||||
log.debug(`expect hash pathname ${hash} to be ${expectedPathname}`);
|
||||
const hashPath = hash.split('#')[1];
|
||||
const [hashPathname, hashQuery] = hashPath.split('?');
|
||||
const [, currentHash] = (await browser.getCurrentUrl()).split('#');
|
||||
const [, currentHashQuery] = currentHash.split('?');
|
||||
expect(currentHashQuery.replace(/%27/g, "'")).to.be(hashQuery.replace(/%27/g, "'"));
|
||||
expect(hashPathname).to.be(expectedPathname);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue