[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:
Anton Dosov 2020-04-21 13:53:17 +02:00 committed by GitHub
parent 02d55db6cd
commit 3b1d0e0c6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 403 additions and 44 deletions

View file

@ -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;

View file

@ -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' }}

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -31,11 +31,11 @@ import { IndexPatternField } from '../../../../../../../../plugins/data/public';
jest.mock('../../../kibana_services', () => ({
getServices: () => ({
history: {
history: () => ({
location: {
search: '',
},
},
}),
capabilities: {
visualize: {
show: true,

View file

@ -36,11 +36,11 @@ import { SavedObject } from '../../../../../../../../core/types';
jest.mock('../../../kibana_services', () => ({
getServices: () => ({
history: {
history: () => ({
location: {
search: '',
},
},
}),
capabilities: {
visualize: {
show: true,

View file

@ -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) &&

View file

@ -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;

View file

@ -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();
});
});

View file

@ -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;
}

View file

@ -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)) {

View file

@ -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);

View file

@ -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]);
});
});
});

View file

@ -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'),

View 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'));
});
}

View 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);
}
});
});
}