[Search Sessions] Improve session restoration back button (#87635)

This commit is contained in:
Anton Dosov 2021-01-28 09:54:26 +01:00 committed by GitHub
parent 9c410b81ca
commit 07b210e42d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 560 additions and 148 deletions

View file

@ -1,13 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) &gt; [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) &gt; [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md)
## IKbnUrlStateStorage.cancel property
cancels any pending url updates
<b>Signature:</b>
```typescript
cancel: () => void;
```

View file

@ -1,15 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) &gt; [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) &gt; [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md)
## IKbnUrlStateStorage.flush property
Synchronously runs any pending url updates, returned boolean indicates if change occurred.
<b>Signature:</b>
```typescript
flush: (opts?: {
replace?: boolean;
}) => boolean;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) &gt; [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) &gt; [kbnUrlControls](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md)
## IKbnUrlStateStorage.kbnUrlControls property
Lower level wrapper around history library that handles batching multiple URL updates into one history change
<b>Signature:</b>
```typescript
kbnUrlControls: IKbnUrlControls;
```

View file

@ -20,9 +20,8 @@ export interface IKbnUrlStateStorage extends IStateStorage
| Property | Type | Description |
| --- | --- | --- |
| [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md) | <code>() =&gt; void</code> | cancels any pending url updates |
| [change$](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.change_.md) | <code>&lt;State = unknown&gt;(key: string) =&gt; Observable&lt;State &#124; null&gt;</code> | |
| [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md) | <code>(opts?: {</code><br/><code> replace?: boolean;</code><br/><code> }) =&gt; boolean</code> | Synchronously runs any pending url updates, returned boolean indicates if change occurred. |
| [get](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.get.md) | <code>&lt;State = unknown&gt;(key: string) =&gt; State &#124; null</code> | |
| [kbnUrlControls](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md) | <code>IKbnUrlControls</code> | Lower level wrapper around history library that handles batching multiple URL updates into one history change |
| [set](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.set.md) | <code>&lt;State&gt;(key: string, state: State, opts?: {</code><br/><code> replace: boolean;</code><br/><code> }) =&gt; Promise&lt;string &#124; undefined&gt;</code> | |

View file

@ -6,22 +6,22 @@
* Public License, v 1.
*/
import _ from 'lodash';
import { History } from 'history';
import { merge, Subscription } from 'rxjs';
import React, { useEffect, useCallback, useState } from 'react';
import { merge, Subject, Subscription } from 'rxjs';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { debounceTime, tap } from 'rxjs/operators';
import { useKibana } from '../../../kibana_react/public';
import { DashboardConstants } from '../dashboard_constants';
import { DashboardTopNav } from './top_nav/dashboard_top_nav';
import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from './types';
import {
getChangesFromAppStateForContainerState,
getDashboardContainerInput,
getFiltersSubscription,
getInputSubscription,
getOutputSubscription,
getFiltersSubscription,
getSearchSessionIdFromURL,
getDashboardContainerInput,
getChangesFromAppStateForContainerState,
} from './dashboard_app_functions';
import {
useDashboardBreadcrumbs,
@ -30,11 +30,11 @@ import {
useSavedDashboard,
} from './hooks';
import { removeQueryParam } from '../services/kibana_utils';
import { IndexPattern } from '../services/data';
import { EmbeddableRenderer } from '../services/embeddable';
import { DashboardContainerInput } from '.';
import { leaveConfirmStrings } from '../dashboard_strings';
import { createQueryParamObservable, replaceUrlHashQuery } from '../../../kibana_utils/public';
export interface DashboardAppProps {
history: History;
@ -59,7 +59,7 @@ export function DashboardApp({
indexPatterns: indexPatternService,
} = useKibana<DashboardAppServices>().services;
const [lastReloadTime, setLastReloadTime] = useState(0);
const triggerRefresh$ = useMemo(() => new Subject<{ force?: boolean }>(), []);
const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>([]);
const savedDashboard = useSavedDashboard(savedDashboardId, history);
@ -68,9 +68,13 @@ export function DashboardApp({
history
);
const dashboardContainer = useDashboardContainer(dashboardStateManager, history, false);
const searchSessionIdQuery$ = useMemo(
() => createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID),
[history]
);
const refreshDashboardContainer = useCallback(
(lastReloadRequestTime?: number) => {
(force?: boolean) => {
if (!dashboardContainer || !dashboardStateManager) {
return;
}
@ -80,7 +84,7 @@ export function DashboardApp({
appStateDashboardInput: getDashboardContainerInput({
isEmbeddedExternally: Boolean(embedSettings),
dashboardStateManager,
lastReloadRequestTime,
lastReloadRequestTime: force ? Date.now() : undefined,
dashboardCapabilities,
query: data.query,
}),
@ -100,10 +104,35 @@ export function DashboardApp({
const shouldRefetch = Object.keys(changes).some(
(changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput)
);
if (getSearchSessionIdFromURL(history)) {
// going away from a background search results
removeQueryParam(history, DashboardConstants.SEARCH_SESSION_ID, true);
}
const newSearchSessionId: string | undefined = (() => {
// do not update session id if this is irrelevant state change to prevent excessive searches
if (!shouldRefetch) return;
let searchSessionIdFromURL = getSearchSessionIdFromURL(history);
if (searchSessionIdFromURL) {
if (
data.search.session.isRestore() &&
data.search.session.isCurrentSession(searchSessionIdFromURL)
) {
// navigating away from a restored session
dashboardStateManager.kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => {
if (nextUrl.includes(DashboardConstants.SEARCH_SESSION_ID)) {
return replaceUrlHashQuery(nextUrl, (query) => {
delete query[DashboardConstants.SEARCH_SESSION_ID];
return query;
});
}
return nextUrl;
});
searchSessionIdFromURL = undefined;
} else {
data.search.session.restore(searchSessionIdFromURL);
}
}
return searchSessionIdFromURL ?? data.search.session.start();
})();
if (changes.viewMode) {
setViewMode(changes.viewMode);
@ -111,8 +140,7 @@ export function DashboardApp({
dashboardContainer.updateInput({
...changes,
// do not start a new session if this is irrelevant state change to prevent excessive searches
...(shouldRefetch && { searchSessionId: data.search.session.start() }),
...(newSearchSessionId && { searchSessionId: newSearchSessionId }),
});
}
},
@ -159,23 +187,42 @@ export function DashboardApp({
subscriptions.add(
merge(
...[timeFilter.getRefreshIntervalUpdate$(), timeFilter.getTimeUpdate$()]
).subscribe(() => refreshDashboardContainer())
).subscribe(() => triggerRefresh$.next())
);
subscriptions.add(
merge(
data.search.session.onRefresh$,
data.query.timefilter.timefilter.getAutoRefreshFetch$()
data.query.timefilter.timefilter.getAutoRefreshFetch$(),
searchSessionIdQuery$
).subscribe(() => {
setLastReloadTime(() => new Date().getTime());
triggerRefresh$.next({ force: true });
})
);
dashboardStateManager.registerChangeListener(() => {
// we aren't checking dirty state because there are changes the container needs to know about
// that won't make the dashboard "dirty" - like a view mode change.
refreshDashboardContainer();
triggerRefresh$.next();
});
// debounce `refreshDashboardContainer()`
// use `forceRefresh=true` in case at least one debounced trigger asked for it
let forceRefresh: boolean = false;
subscriptions.add(
triggerRefresh$
.pipe(
tap((trigger) => {
forceRefresh = forceRefresh || (trigger?.force ?? false);
}),
debounceTime(50)
)
.subscribe(() => {
refreshDashboardContainer(forceRefresh);
forceRefresh = false;
})
);
return () => {
subscriptions.unsubscribe();
};
@ -187,6 +234,8 @@ export function DashboardApp({
data.search.session,
indexPatternService,
dashboardStateManager,
searchSessionIdQuery$,
triggerRefresh$,
refreshDashboardContainer,
]);
@ -216,11 +265,6 @@ export function DashboardApp({
};
}, [dashboardStateManager, dashboardContainer, onAppLeave, embeddable]);
// Refresh the dashboard container when lastReloadTime changes
useEffect(() => {
refreshDashboardContainer(lastReloadTime);
}, [lastReloadTime, refreshDashboardContainer]);
return (
<div className="app-container dshAppContainer">
{savedDashboard && dashboardStateManager && dashboardContainer && viewMode && (
@ -242,7 +286,7 @@ export function DashboardApp({
// The user can still request a reload in the query bar, even if the
// query is the same, and in that case, we have to explicitly ask for
// a reload, since no state changes will cause it.
setLastReloadTime(() => new Date().getTime());
triggerRefresh$.next({ force: true });
}
}}
/>

View file

@ -72,7 +72,7 @@ export class DashboardStateManager {
>;
private readonly stateContainerChangeSub: Subscription;
private readonly STATE_STORAGE_KEY = '_a';
private readonly kbnUrlStateStorage: IKbnUrlStateStorage;
public readonly kbnUrlStateStorage: IKbnUrlStateStorage;
private readonly stateSyncRef: ISyncStateRef;
private readonly history: History;
private readonly usageCollection: UsageCollectionSetup | undefined;
@ -596,7 +596,7 @@ export class DashboardStateManager {
this.toUrlState(this.stateContainer.get())
);
// immediately forces scheduled updates and changes location
return this.kbnUrlStateStorage.flush({ replace });
return !!this.kbnUrlStateStorage.kbnUrlControls.flush(replace);
}
// TODO: find nicer solution for this

View file

@ -4,10 +4,16 @@ exports[`after fetch When given a title that matches multiple dashboards, filter
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"flush": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
@ -144,10 +150,16 @@ exports[`after fetch hideWriteControls 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"flush": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
@ -237,10 +249,16 @@ exports[`after fetch initialFilter 1`] = `
initialFilter="testFilter"
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"flush": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
@ -376,10 +394,16 @@ exports[`after fetch renders all table rows 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"flush": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
@ -515,10 +539,16 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"flush": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
@ -654,10 +684,16 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"flush": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}

View file

@ -90,7 +90,7 @@ describe('sync_query_state_with_url', () => {
test('url is actually changed when data in services changes', () => {
const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
filterManager.setFilters([gF, aF]);
kbnUrlStateStorage.flush(); // sync force location change
kbnUrlStateStorage.kbnUrlControls.flush(); // sync force location change
expect(history.location.hash).toMatchInlineSnapshot(
`"#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!t,index:'logstash-*',key:query,negate:!t,type:custom,value:'%7B%22match%22:%7B%22key1%22:%22value1%22%7D%7D'),query:(match:(key1:value1)))),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"`
);
@ -126,7 +126,7 @@ describe('sync_query_state_with_url', () => {
test('when url is changed, filters synced back to filterManager', () => {
const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
kbnUrlStateStorage.cancel(); // stop initial syncing pending update
kbnUrlStateStorage.kbnUrlControls.cancel(); // stop initial syncing pending update
history.push(pathWithFilter);
expect(filterManager.getGlobalFilters()).toHaveLength(1);
stop();

View file

@ -206,7 +206,7 @@ export function getState({
}
},
// helper function just needed for testing
flushToUrl: (replace?: boolean) => stateStorage.flush({ replace }),
flushToUrl: (replace?: boolean) => stateStorage.kbnUrlControls.flush(replace),
};
}

View file

@ -47,8 +47,6 @@ import { popularizeField } from '../helpers/popularize_field';
import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state';
import { addFatalError } from '../../../../kibana_legacy/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator';
import { getQueryParams, removeQueryParam } from '../../../../kibana_utils/public';
import {
DEFAULT_COLUMNS_SETTING,
MODIFY_COLUMNS_ON_SWITCH,
@ -62,6 +60,7 @@ import { getTopNavLinks } from '../components/top_nav/get_top_nav_links';
import { updateSearchSource } from '../helpers/update_search_source';
import { calcFieldCounts } from '../helpers/calc_field_counts';
import { getDefaultSort } from './doc_table/lib/get_default_sort';
import { DiscoverSearchSessionManager } from './discover_search_session';
const services = getServices();
@ -86,9 +85,6 @@ const fetchStatuses = {
ERROR: 'error',
};
const getSearchSessionIdFromURL = (history) =>
getQueryParams(history.location)[SEARCH_SESSION_ID_QUERY_PARAM];
const app = getAngularModule();
app.config(($routeProvider) => {
@ -177,7 +173,9 @@ function discoverController($route, $scope, Promise) {
const { isDefault: isDefaultType } = indexPatternsUtils;
const subscriptions = new Subscription();
const refetch$ = new Subject();
let inspectorRequest;
let isChangingIndexPattern = false;
const savedSearch = $route.current.locals.savedObjects.savedSearch;
$scope.searchSource = savedSearch.searchSource;
$scope.indexPattern = resolveIndexPattern(
@ -195,15 +193,10 @@ function discoverController($route, $scope, Promise) {
};
const history = getHistory();
// used for restoring a search session
let isInitialSearch = true;
// search session requested a data refresh
subscriptions.add(
data.search.session.onRefresh$.subscribe(() => {
refetch$.next();
})
);
const searchSessionManager = new DiscoverSearchSessionManager({
history,
session: data.search.session,
});
const state = getState({
getStateDefaults,
@ -255,6 +248,7 @@ function discoverController($route, $scope, Promise) {
$scope.$evalAsync(async () => {
if (oldStatePartial.index !== newStatePartial.index) {
//in case of index pattern switch the route has currently to be reloaded, legacy
isChangingIndexPattern = true;
$route.reload();
return;
}
@ -351,7 +345,12 @@ function discoverController($route, $scope, Promise) {
if (abortController) abortController.abort();
savedSearch.destroy();
subscriptions.unsubscribe();
data.search.session.clear();
if (!isChangingIndexPattern) {
// HACK:
// do not clear session when changing index pattern due to how state management around it is setup
// it will be cleared by searchSessionManager on controller reload instead
data.search.session.clear();
}
appStateUnsubscribe();
stopStateSync();
stopSyncingGlobalStateWithUrl();
@ -475,7 +474,8 @@ function discoverController($route, $scope, Promise) {
return (
config.get(SEARCH_ON_PAGE_LOAD_SETTING) ||
savedSearch.id !== undefined ||
timefilter.getRefreshInterval().pause === false
timefilter.getRefreshInterval().pause === false ||
searchSessionManager.hasSearchSessionIdInURL()
);
};
@ -486,7 +486,8 @@ function discoverController($route, $scope, Promise) {
filterManager.getFetches$(),
timefilter.getFetch$(),
timefilter.getAutoRefreshFetch$(),
data.query.queryString.getUpdates$()
data.query.queryString.getUpdates$(),
searchSessionManager.newSearchSessionIdFromURL$
).pipe(debounceTime(100));
subscriptions.add(
@ -512,6 +513,13 @@ function discoverController($route, $scope, Promise) {
)
);
subscriptions.add(
data.search.session.onRefresh$.subscribe(() => {
searchSessionManager.removeSearchSessionIdFromURL({ replace: false });
refetch$.next();
})
);
$scope.changeInterval = (interval) => {
if (interval) {
setAppState({ interval });
@ -591,20 +599,7 @@ function discoverController($route, $scope, Promise) {
if (abortController) abortController.abort();
abortController = new AbortController();
const searchSessionId = (() => {
const searchSessionIdFromURL = getSearchSessionIdFromURL(history);
if (searchSessionIdFromURL) {
if (isInitialSearch) {
data.search.session.restore(searchSessionIdFromURL);
isInitialSearch = false;
return searchSessionIdFromURL;
} else {
// navigating away from background search
removeQueryParam(history, SEARCH_SESSION_ID_QUERY_PARAM);
}
}
return data.search.session.start();
})();
const searchSessionId = searchSessionManager.getNextSearchSessionId();
$scope
.updateDataSource()
@ -631,6 +626,7 @@ function discoverController($route, $scope, Promise) {
$scope.handleRefresh = function (_payload, isUpdate) {
if (isUpdate === false) {
searchSessionManager.removeSearchSessionIdFromURL({ replace: false });
refetch$.next();
}
};

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { DiscoverSearchSessionManager } from './discover_search_session';
import { createMemoryHistory } from 'history';
import { dataPluginMock } from '../../../../data/public/mocks';
import { DataPublicPluginStart } from '../../../../data/public';
describe('DiscoverSearchSessionManager', () => {
const history = createMemoryHistory();
const session = dataPluginMock.createStartContract().search.session as jest.Mocked<
DataPublicPluginStart['search']['session']
>;
const searchSessionManager = new DiscoverSearchSessionManager({
history,
session,
});
beforeEach(() => {
history.push('/');
session.start.mockReset();
session.restore.mockReset();
session.getSessionId.mockReset();
session.isCurrentSession.mockReset();
session.isRestore.mockReset();
});
describe('getNextSearchSessionId', () => {
test('starts a new session', () => {
const nextId = 'id';
session.start.mockImplementationOnce(() => nextId);
const id = searchSessionManager.getNextSearchSessionId();
expect(id).toEqual(nextId);
expect(session.start).toBeCalled();
});
test('restores a session using query param from the URL', () => {
const nextId = 'id_from_url';
history.push(`/?searchSessionId=${nextId}`);
const id = searchSessionManager.getNextSearchSessionId();
expect(id).toEqual(nextId);
expect(session.restore).toBeCalled();
});
test('removes query param from the URL when navigating away from a restored session', () => {
const idFromUrl = 'id_from_url';
history.push(`/?searchSessionId=${idFromUrl}`);
const nextId = 'id';
session.start.mockImplementationOnce(() => nextId);
session.isCurrentSession.mockImplementationOnce(() => true);
session.isRestore.mockImplementationOnce(() => true);
const id = searchSessionManager.getNextSearchSessionId();
expect(id).toEqual(nextId);
expect(session.start).toBeCalled();
expect(history.location.search).toMatchInlineSnapshot(`""`);
});
});
describe('newSearchSessionIdFromURL$', () => {
test('notifies about searchSessionId changes in the URL', () => {
const emits: Array<string | null> = [];
const sub = searchSessionManager.newSearchSessionIdFromURL$.subscribe((newId) => {
emits.push(newId);
});
history.push(`/?searchSessionId=id1`);
history.push(`/?searchSessionId=id1`);
session.isCurrentSession.mockImplementationOnce(() => true);
history.replace(`/?searchSessionId=id2`); // should skip current this
history.replace(`/`);
history.push(`/?searchSessionId=id1`);
history.push(`/`);
expect(emits).toMatchInlineSnapshot(`
Array [
"id1",
null,
"id1",
null,
]
`);
sub.unsubscribe();
});
});
});

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { History } from 'history';
import { filter } from 'rxjs/operators';
import { DataPublicPluginStart } from '../../../../data/public';
import {
createQueryParamObservable,
getQueryParams,
removeQueryParam,
} from '../../../../kibana_utils/public';
import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator';
export interface DiscoverSearchSessionManagerDeps {
history: History;
session: DataPublicPluginStart['search']['session'];
}
/**
* Helps with state management of search session and {@link SEARCH_SESSION_ID_QUERY_PARAM} in the URL
*/
export class DiscoverSearchSessionManager {
/**
* Notifies about `searchSessionId` changes in the URL,
* skips if `searchSessionId` matches current search session id
*/
readonly newSearchSessionIdFromURL$ = createQueryParamObservable<string>(
this.deps.history,
SEARCH_SESSION_ID_QUERY_PARAM
).pipe(
filter((searchSessionId) => {
if (!searchSessionId) return true;
return !this.deps.session.isCurrentSession(searchSessionId);
})
);
constructor(private readonly deps: DiscoverSearchSessionManagerDeps) {}
/**
* Get next session id by either starting or restoring a session.
* When navigating away from the restored session {@link SEARCH_SESSION_ID_QUERY_PARAM} is removed from the URL using history.replace
*/
getNextSearchSessionId() {
let searchSessionIdFromURL = this.getSearchSessionIdFromURL();
if (searchSessionIdFromURL) {
if (
this.deps.session.isRestore() &&
this.deps.session.isCurrentSession(searchSessionIdFromURL)
) {
// navigating away from a restored session
this.removeSearchSessionIdFromURL({ replace: true });
searchSessionIdFromURL = undefined;
} else {
this.deps.session.restore(searchSessionIdFromURL);
}
}
return searchSessionIdFromURL ?? this.deps.session.start();
}
/**
* Removes Discovers {@link SEARCH_SESSION_ID_QUERY_PARAM} from the URL
* @param replace - methods to change the URL
*/
removeSearchSessionIdFromURL({ replace = true }: { replace?: boolean } = { replace: true }) {
if (this.hasSearchSessionIdInURL()) {
removeQueryParam(this.deps.history, SEARCH_SESSION_ID_QUERY_PARAM, replace);
}
}
/**
* If there is a {@link SEARCH_SESSION_ID_QUERY_PARAM} currently in the URL
*/
hasSearchSessionIdInURL(): boolean {
return !!this.getSearchSessionIdFromURL();
}
private getSearchSessionIdFromURL = () =>
getQueryParams(this.deps.history.location)[SEARCH_SESSION_ID_QUERY_PARAM] as string | undefined;
}

View file

@ -200,7 +200,7 @@ export function getState({
setState(appStateContainerModified, defaultState);
},
getPreviousAppState: () => previousAppState,
flushToUrl: () => stateStorage.flush(),
flushToUrl: () => stateStorage.kbnUrlControls.flush(),
isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()),
};
}

View file

@ -96,11 +96,11 @@ setTimeout(() => {
}, 0);
```
For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` provides these advanced apis:
For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` exposes `kbnUrlStateStorage.kbnUrlControls` that exposes these advanced apis:
- `kbnUrlStateStorage.flush({replace: boolean})` - allows to synchronously apply any pending updates.
`replace` option allows to use `history.replace()` instead of `history.push()`. Returned boolean indicates if any update happened
- `kbnUrlStateStorage.cancel()` - cancels any pending updates
- `kbnUrlStateStorage.kbnUrlControls.flush({replace: boolean})` - allows to synchronously apply any pending updates.
`replace` option allows using `history.replace()` instead of `history.push()`.
- `kbnUrlStateStorage.kbnUrlControls.cancel()` - cancels any pending updates.
### Sharing one `kbnUrlStateStorage` instance

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import {
createHistoryObservable,
createQueryParamObservable,
createQueryParamsObservable,
} from './history_observable';
import { createMemoryHistory, History } from 'history';
import { ParsedQuery } from 'query-string';
let history: History;
beforeEach(() => {
history = createMemoryHistory();
});
test('createHistoryObservable', () => {
const obs$ = createHistoryObservable(history);
const emits: string[] = [];
obs$.subscribe(({ location }) => {
emits.push(location.pathname + location.search);
});
history.push('/test');
history.push('/');
expect(emits.length).toEqual(2);
expect(emits).toMatchInlineSnapshot(`
Array [
"/test",
"/",
]
`);
});
test('createQueryParamsObservable', () => {
const obs$ = createQueryParamsObservable(history);
const emits: ParsedQuery[] = [];
obs$.subscribe((params) => {
emits.push(params);
});
history.push('/test');
history.push('/test?foo=bar');
history.push('/?foo=bar');
history.push('/test?foo=bar&foo1=bar1');
expect(emits.length).toEqual(2);
expect(emits).toMatchInlineSnapshot(`
Array [
Object {
"foo": "bar",
},
Object {
"foo": "bar",
"foo1": "bar1",
},
]
`);
});
test('createQueryParamObservable', () => {
const obs$ = createQueryParamObservable(history, 'foo');
const emits: unknown[] = [];
obs$.subscribe((param) => {
emits.push(param);
});
history.push('/test');
history.push('/test?foo=bar');
history.push('/?foo=bar');
history.push('/test?foo=baaaar&foo1=bar1');
history.push('/test?foo1=bar1');
expect(emits.length).toEqual(3);
expect(emits).toMatchInlineSnapshot(`
Array [
"bar",
"baaaar",
null,
]
`);
});

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { Action, History, Location } from 'history';
import { Observable } from 'rxjs';
import { ParsedQuery } from 'query-string';
import deepEqual from 'fast-deep-equal';
import { map } from 'rxjs/operators';
import { getQueryParams } from './get_query_params';
import { distinctUntilChangedWithInitialValue } from '../../common';
/**
* Convert history.listen into an observable
* @param history - {@link History} instance
*/
export function createHistoryObservable(
history: History
): Observable<{ location: Location; action: Action }> {
return new Observable((observer) => {
const unlisten = history.listen((location, action) => observer.next({ location, action }));
return () => {
unlisten();
};
});
}
/**
* Create an observable that emits every time any of query params change.
* Uses deepEqual check.
* @param history - {@link History} instance
*/
export function createQueryParamsObservable(history: History): Observable<ParsedQuery> {
return createHistoryObservable(history).pipe(
map(({ location }) => ({ ...getQueryParams(location) })),
distinctUntilChangedWithInitialValue({ ...getQueryParams(history.location) }, deepEqual)
);
}
/**
* Create an observable that emits every time _paramKey_ changes
* @param history - {@link History} instance
* @param paramKey - query param key to observe
*/
export function createQueryParamObservable<Param = unknown>(
history: History,
paramKey: string
): Observable<Param | null> {
return createQueryParamsObservable(history).pipe(
map((params) => (params[paramKey] ?? null) as Param | null),
distinctUntilChangedWithInitialValue(
(getQueryParams(history.location)[paramKey] ?? null) as Param | null,
deepEqual
)
);
}

View file

@ -9,3 +9,4 @@
export { removeQueryParam } from './remove_query_param';
export { redirectWhenMissing } from './redirect_when_missing';
export { getQueryParams } from './get_query_params';
export * from './history_observable';

View file

@ -68,7 +68,14 @@ export {
StopSyncStateFnType,
} from './state_sync';
export { Configurable, CollectConfigProps } from './ui';
export { removeQueryParam, redirectWhenMissing, getQueryParams } from './history';
export {
removeQueryParam,
redirectWhenMissing,
getQueryParams,
createQueryParamsObservable,
createHistoryObservable,
createQueryParamObservable,
} from './history';
export { applyDiff } from './state_management/utils/diff_object';
export { createStartServicesGetter, StartServicesGetter } from './core/create_start_service_getter';

View file

@ -22,14 +22,12 @@ export const createSessionStorageStateStorage: (storage?: Storage) => ISessionSt
// @public
export interface IKbnUrlStateStorage extends IStateStorage {
cancel: () => void;
// (undocumented)
change$: <State = unknown>(key: string) => Observable<State | null>;
flush: (opts?: {
replace?: boolean;
}) => boolean;
// (undocumented)
get: <State = unknown>(key: string) => State | null;
// Warning: (ae-forgotten-export) The symbol "IKbnUrlControls" needs to be exported by the entry point index.d.ts
kbnUrlControls: IKbnUrlControls;
// (undocumented)
set: <State>(key: string, state: State, opts?: {
replace: boolean;

View file

@ -255,7 +255,7 @@ describe('state_sync', () => {
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlSyncStrategy.flush();
urlSyncStrategy.kbnUrlControls.flush();
expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot(
@ -290,7 +290,7 @@ describe('state_sync', () => {
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlSyncStrategy.cancel();
urlSyncStrategy.kbnUrlControls.cancel();
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);

View file

@ -39,11 +39,11 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
expect(urlStateStorage.flush()).toBe(true);
expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`);
expect(urlStateStorage.get(key)).toEqual(state);
expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update
expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update
});
it('should cancel url updates', async () => {
@ -51,7 +51,7 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
const pr = urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlStateStorage.cancel();
urlStateStorage.kbnUrlControls.cancel();
await pr;
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
expect(urlStateStorage.get(key)).toEqual(null);
@ -215,11 +215,11 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
expect(urlStateStorage.flush()).toBe(true);
expect(!!urlStateStorage.kbnUrlControls.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
expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update
});
it('should cancel url updates', async () => {
@ -227,7 +227,7 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
const pr = urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
urlStateStorage.cancel();
urlStateStorage.kbnUrlControls.cancel();
await pr;
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
expect(urlStateStorage.get(key)).toEqual(null);

View file

@ -13,6 +13,7 @@ import { IStateStorage } from './types';
import {
createKbnUrlControls,
getStateFromKbnUrl,
IKbnUrlControls,
setStateToKbnUrl,
} from '../../state_management/url';
@ -39,16 +40,9 @@ export interface IKbnUrlStateStorage extends IStateStorage {
change$: <State = unknown>(key: string) => Observable<State | null>;
/**
* cancels any pending url updates
* Lower level wrapper around history library that handles batching multiple URL updates into one history change
*/
cancel: () => void;
/**
* Synchronously runs any pending url updates, returned boolean indicates if change occurred.
* @param opts: {replace? boolean} - allows to specify if push or replace should be used for flushing update
* @returns boolean - indicates if there was an update to flush
*/
flush: (opts?: { replace?: boolean }) => boolean;
kbnUrlControls: IKbnUrlControls;
}
/**
@ -114,11 +108,6 @@ export const createKbnUrlStateStorage = (
}),
share()
),
flush: ({ replace = false }: { replace?: boolean } = {}) => {
return !!url.flush(replace);
},
cancel() {
url.cancel();
},
kbnUrlControls: url,
};
};

View file

@ -227,7 +227,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
});
describe('usage of discover:searchOnPageLoad', () => {
it('should fetch data from ES initially when discover:searchOnPageLoad is false', async function () {
it('should not fetch data from ES initially when discover:searchOnPageLoad is false', async function () {
await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': false });
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.awaitKibanaChrome();
@ -235,7 +235,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.discover.getNrOfFetches()).to.be(0);
});
it('should not fetch data from ES initially when discover:searchOnPageLoad is true', async function () {
it('should fetch data from ES initially when discover:searchOnPageLoad is true', async function () {
await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': true });
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.awaitKibanaChrome();

View file

@ -42,10 +42,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
await PageObjects.header.waitUntilLoadingHasFinished();
const sessionIds = await getSessionIds();
// Discover calls destroy on index pattern change, which explicitly closes a session
expect(sessionIds.length).to.be(2);
expect(sessionIds[0].length).to.be(0);
expect(sessionIds[1].length).not.to.be(0);
expect(sessionIds.length).to.be(1);
});
it('Starts on a refresh', async () => {

View file

@ -30,6 +30,6 @@ export function updateGlobalState(newState: MapsGlobalState, flushUrlState = fal
...newState,
});
if (flushUrlState) {
kbnUrlStateStorage.flush({ replace: true });
kbnUrlStateStorage.kbnUrlControls.flush(true);
}
}

View file

@ -30,9 +30,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await searchSessions.deleteAllSearchSessions();
});
it('Restore using non-existing sessionId errors out. Refresh starts a new session and completes.', async () => {
it('Restore using non-existing sessionId errors out. Refresh starts a new session and completes. Back button restores a session.', async () => {
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
const url = await browser.getCurrentUrl();
let url = await browser.getCurrentUrl();
const fakeSessionId = '__fake__';
const savedSessionURL = `${url}&searchSessionId=${fakeSessionId}`;
await browser.get(savedSessionURL);
@ -53,6 +53,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'Sum of Bytes by Extension'
);
expect(session2).not.to.be(fakeSessionId);
// back button should restore the session:
url = await browser.getCurrentUrl();
expect(url).not.to.contain('searchSessionId');
await browser.goBack();
url = await browser.getCurrentUrl();
expect(url).to.contain('searchSessionId');
await PageObjects.header.waitUntilLoadingHasFinished();
await searchSessions.expectState('restored');
expect(
await dashboardPanelActions.getSearchSessionIdByTitle('Sum of Bytes by Extension')
).to.be(fakeSessionId);
});
it('Saves and restores a session', async () => {

View file

@ -13,6 +13,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const browser = getService('browser');
const inspector = getService('inspector');
const PageObjects = getPageObjects(['discover', 'common', 'timePicker', 'header']);
const searchSessions = getService('searchSessions');
describe('discover async search', () => {
before(async () => {
@ -31,18 +32,33 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
expect(searchSessionId2).not.to.be(searchSessionId1);
});
it('search session id should be picked up from the URL, non existing session id errors out', async () => {
const url = await browser.getCurrentUrl();
it('search session id should be picked up from the URL, non existing session id errors out, back button restores a session', async () => {
let url = await browser.getCurrentUrl();
const fakeSearchSessionId = '__test__';
const savedSessionURL = url + `&searchSessionId=${fakeSearchSessionId}`;
await browser.navigateTo(savedSessionURL);
await PageObjects.header.waitUntilLoadingHasFinished();
await searchSessions.expectState('restored');
await testSubjects.existOrFail('discoverNoResultsError'); // expect error because of fake searchSessionId
const searchSessionId1 = await getSearchSessionId();
expect(searchSessionId1).to.be(fakeSearchSessionId);
await queryBar.clickQuerySubmitButton();
await PageObjects.header.waitUntilLoadingHasFinished();
await searchSessions.expectState('completed');
const searchSessionId2 = await getSearchSessionId();
expect(searchSessionId2).not.to.be(searchSessionId1);
// back button should restore the session:
url = await browser.getCurrentUrl();
expect(url).not.to.contain('searchSessionId');
await browser.goBack();
url = await browser.getCurrentUrl();
expect(url).to.contain('searchSessionId');
await PageObjects.header.waitUntilLoadingHasFinished();
await searchSessions.expectState('restored');
expect(await getSearchSessionId()).to.be(fakeSearchSessionId);
});
});