[Dashboard] Read App State from URL on Soft Refresh (#109354)

Subscribe to app changes from URL to allow dashboard URL to be used as an API. On URL change, update filters, timerange, and query
This commit is contained in:
Devon Thomson 2021-09-02 17:50:02 -04:00 committed by GitHub
parent c692ad3724
commit e704a52f6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 129 additions and 44 deletions

View file

@ -35,7 +35,7 @@ import {
syncDashboardFilterState,
loadSavedDashboardState,
buildDashboardContainer,
loadDashboardUrlState,
syncDashboardUrlState,
diffDashboardState,
areTimeRangesEqual,
} from '../lib';
@ -151,15 +151,20 @@ export const useDashboardAppState = ({
* Combine initial state from the saved object, session storage, and URL, then dispatch it to Redux.
*/
const dashboardSessionStorageState = dashboardSessionStorage.getState(savedDashboardId) || {};
const dashboardURLState = loadDashboardUrlState(dashboardBuildContext);
const forwardedAppState = loadDashboardHistoryLocationState(
scopedHistory()?.location?.state as undefined | DashboardAppLocatorParams
);
const { initialDashboardStateFromUrl, stopWatchingAppStateInUrl } = syncDashboardUrlState({
...dashboardBuildContext,
savedDashboard,
});
const initialDashboardState = {
...savedDashboardState,
...dashboardSessionStorageState,
...dashboardURLState,
...initialDashboardStateFromUrl,
...forwardedAppState,
// if there is an incoming embeddable, dashboard always needs to be in edit mode to receive it.
@ -291,6 +296,7 @@ export const useDashboardAppState = ({
onDestroy = () => {
stopSyncingContainerInput();
stopWatchingAppStateInUrl();
stopSyncingDashboardFilterState();
lastSavedSubscription.unsubscribe();
indexPatternsSubscription.unsubscribe();

View file

@ -12,7 +12,7 @@ export { saveDashboard } from './save_dashboard';
export { migrateAppState } from './migrate_app_state';
export { addHelpMenuToAppChrome } from './help_menu_util';
export { getTagsFromSavedDashboard } from './dashboard_tagging';
export { loadDashboardUrlState } from './load_dashboard_url_state';
export { syncDashboardUrlState } from './sync_dashboard_url_state';
export { DashboardSessionStorage } from './dashboard_session_storage';
export { loadSavedDashboardState } from './load_saved_dashboard_state';
export { attemptLoadDashboardByTitle } from './load_dashboard_by_title';

View file

@ -48,34 +48,13 @@ export const syncDashboardFilterState = ({
const { filterManager, queryString, timefilter } = queryService;
const { timefilter: timefilterService } = timefilter;
// apply initial filters to the query service and to the saved dashboard
filterManager.setAppFilters(_.cloneDeep(initialDashboardState.filters));
savedDashboard.searchSource.setField('filter', initialDashboardState.filters);
// apply initial query to the query service and to the saved dashboard
queryString.setQuery(initialDashboardState.query);
savedDashboard.searchSource.setField('query', initialDashboardState.query);
/**
* If a global time range is not set explicitly and the time range was saved with the dashboard, apply
* initial time range and refresh interval to the query service.
*/
if (initialDashboardState.timeRestore) {
const initialGlobalQueryState = kbnUrlStateStorage.get<QueryState>('_g');
if (!initialGlobalQueryState?.time) {
if (savedDashboard.timeFrom && savedDashboard.timeTo) {
timefilterService.setTime({
from: savedDashboard.timeFrom,
to: savedDashboard.timeTo,
});
}
}
if (!initialGlobalQueryState?.refreshInterval) {
if (savedDashboard.refreshInterval) {
timefilterService.setRefreshInterval(savedDashboard.refreshInterval);
}
}
}
// apply initial dashboard filter state.
applyDashboardFilterState({
currentDashboardState: initialDashboardState,
kbnUrlStateStorage,
savedDashboard,
queryService,
});
// this callback will be used any time new filters and query need to be applied.
const applyFilters = (query: Query, filters: Filter[]) => {
@ -155,3 +134,49 @@ export const syncDashboardFilterState = ({
return { applyFilters, stopSyncingDashboardFilterState };
};
interface ApplyDashboardFilterStateProps {
kbnUrlStateStorage: DashboardBuildContext['kbnUrlStateStorage'];
queryService: DashboardBuildContext['query'];
currentDashboardState: DashboardState;
savedDashboard: DashboardSavedObject;
}
export const applyDashboardFilterState = ({
currentDashboardState,
kbnUrlStateStorage,
savedDashboard,
queryService,
}: ApplyDashboardFilterStateProps) => {
const { filterManager, queryString, timefilter } = queryService;
const { timefilter: timefilterService } = timefilter;
// apply filters to the query service and to the saved dashboard
filterManager.setAppFilters(_.cloneDeep(currentDashboardState.filters));
savedDashboard.searchSource.setField('filter', currentDashboardState.filters);
// apply query to the query service and to the saved dashboard
queryString.setQuery(currentDashboardState.query);
savedDashboard.searchSource.setField('query', currentDashboardState.query);
/**
* If a global time range is not set explicitly and the time range was saved with the dashboard, apply
* time range and refresh interval to the query service.
*/
if (currentDashboardState.timeRestore) {
const globalQueryState = kbnUrlStateStorage.get<QueryState>('_g');
if (!globalQueryState?.time) {
if (savedDashboard.timeFrom && savedDashboard.timeTo) {
timefilterService.setTime({
from: savedDashboard.timeFrom,
to: savedDashboard.timeTo,
});
}
}
if (!globalQueryState?.refreshInterval) {
if (savedDashboard.refreshInterval) {
timefilterService.setRefreshInterval(savedDashboard.refreshInterval);
}
}
}
};

View file

@ -9,7 +9,11 @@
import _ from 'lodash';
import { migrateAppState } from '.';
import { DashboardSavedObject } from '../..';
import { setDashboardState } from '../state';
import { migrateLegacyQuery } from './migrate_legacy_query';
import { replaceUrlHashQuery } from '../../../../kibana_utils/public';
import { applyDashboardFilterState } from './sync_dashboard_filter_state';
import { DASHBOARD_STATE_STORAGE_KEY } from '../../dashboard_constants';
import type {
DashboardBuildContext,
@ -17,17 +21,60 @@ import type {
DashboardState,
RawDashboardState,
} from '../../types';
import { migrateLegacyQuery } from './migrate_legacy_query';
import { convertSavedPanelsToPanelMap } from './convert_saved_panels_to_panel_map';
type SyncDashboardUrlStateProps = DashboardBuildContext & { savedDashboard: DashboardSavedObject };
export const syncDashboardUrlState = ({
dispatchDashboardStateChange,
getLatestDashboardState,
query: queryService,
kbnUrlStateStorage,
usageCollection,
savedDashboard,
kibanaVersion,
}: SyncDashboardUrlStateProps) => {
// load initial state before subscribing to avoid state removal triggering update.
const loadDashboardStateProps = { kbnUrlStateStorage, usageCollection, kibanaVersion };
const initialDashboardStateFromUrl = loadDashboardUrlState(loadDashboardStateProps);
const appStateSubscription = kbnUrlStateStorage
.change$(DASHBOARD_STATE_STORAGE_KEY)
.subscribe(() => {
const stateFromUrl = loadDashboardUrlState(loadDashboardStateProps);
const updatedDashboardState = { ...getLatestDashboardState(), ...stateFromUrl };
applyDashboardFilterState({
currentDashboardState: updatedDashboardState,
kbnUrlStateStorage,
queryService,
savedDashboard,
});
if (Object.keys(stateFromUrl).length === 0) return;
dispatchDashboardStateChange(setDashboardState(updatedDashboardState));
});
const stopWatchingAppStateInUrl = () => {
appStateSubscription.unsubscribe();
};
return { initialDashboardStateFromUrl, stopWatchingAppStateInUrl };
};
interface LoadDashboardUrlStateProps {
kibanaVersion: DashboardBuildContext['kibanaVersion'];
usageCollection: DashboardBuildContext['usageCollection'];
kbnUrlStateStorage: DashboardBuildContext['kbnUrlStateStorage'];
}
/**
* Loads any dashboard state from the URL, and removes the state from the URL.
*/
export const loadDashboardUrlState = ({
const loadDashboardUrlState = ({
kibanaVersion,
usageCollection,
kbnUrlStateStorage,
}: DashboardBuildContext): Partial<DashboardState> => {
}: LoadDashboardUrlStateProps): Partial<DashboardState> => {
const rawAppStateInUrl = kbnUrlStateStorage.get<RawDashboardState>(DASHBOARD_STATE_STORAGE_KEY);
if (!rawAppStateInUrl) return {};

View file

@ -171,7 +171,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
};
const hardRefresh = async (newUrl: string) => {
// We need to add a timestamp to the URL because URL changes now only work with a hard refresh.
// We add a timestamp here to force a hard refresh
await browser.get(newUrl.toString());
const alert = await browser.getAlert();
await alert?.accept();
@ -186,16 +186,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.timePicker.setHistoricalDataRange();
});
it('for query parameter', async function () {
const currentQuery = await queryBar.getQueryString();
expect(currentQuery).to.equal('');
const changeQuery = async (useHardRefresh: boolean, newQuery: string) => {
await queryBar.clickQuerySubmitButton();
const oldQuery = await queryBar.getQueryString();
const currentUrl = await getUrlFromShare();
const newUrl = currentUrl.replace(`query:''`, `query:'hi:hello'`);
const newUrl = currentUrl.replace(`query:'${oldQuery}'`, `query:'${newQuery}'`);
// We need to add a timestamp to the URL because URL changes now only work with a hard refresh.
await browser.get(newUrl.toString());
const newQuery = await queryBar.getQueryString();
expect(newQuery).to.equal('hi:hello');
await browser.get(newUrl.toString(), !useHardRefresh);
const queryBarContentsAfterRefresh = await queryBar.getQueryString();
expect(queryBarContentsAfterRefresh).to.equal(newQuery);
};
it('for query parameter with soft refresh', async function () {
await changeQuery(false, 'hi:goodbye');
});
it('for query parameter with hard refresh', async function () {
await changeQuery(true, 'hi:hello');
await queryBar.clearQuery();
await queryBar.clickQuerySubmitButton();
});