From e704a52f6eb4d250eb8f547e344343ca71a8d4bc Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 2 Sep 2021 17:50:02 -0400 Subject: [PATCH] [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 --- .../hooks/use_dashboard_app_state.ts | 12 ++- .../dashboard/public/application/lib/index.ts | 2 +- .../lib/sync_dashboard_filter_state.ts | 81 ++++++++++++------- ...l_state.ts => sync_dashboard_url_state.ts} | 53 +++++++++++- .../apps/dashboard/dashboard_state.ts | 25 +++--- 5 files changed, 129 insertions(+), 44 deletions(-) rename src/plugins/dashboard/public/application/lib/{load_dashboard_url_state.ts => sync_dashboard_url_state.ts} (52%) diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index c01cd43b1f1e..7bf390b0bee5 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -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(); diff --git a/src/plugins/dashboard/public/application/lib/index.ts b/src/plugins/dashboard/public/application/lib/index.ts index 9aba481c3fb8..845cfcb096c5 100644 --- a/src/plugins/dashboard/public/application/lib/index.ts +++ b/src/plugins/dashboard/public/application/lib/index.ts @@ -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'; diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts index 2cfd7da6145e..ea4ecf8dc7a3 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts @@ -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('_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('_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); + } + } + } +}; diff --git a/src/plugins/dashboard/public/application/lib/load_dashboard_url_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts similarity index 52% rename from src/plugins/dashboard/public/application/lib/load_dashboard_url_state.ts rename to src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts index f76382c1fbbd..00b8f8217e25 100644 --- a/src/plugins/dashboard/public/application/lib/load_dashboard_url_state.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts @@ -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 => { +}: LoadDashboardUrlStateProps): Partial => { const rawAppStateInUrl = kbnUrlStateStorage.get(DASHBOARD_STATE_STORAGE_KEY); if (!rawAppStateInUrl) return {}; diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index 1a9cf3b7593a..cd51faa4be0b 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -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(); });