diff --git a/src/plugins/data_explorer/public/utils/state_management/store.ts b/src/plugins/data_explorer/public/utils/state_management/store.ts index cd967d25fc2..daf0b3d7e36 100644 --- a/src/plugins/data_explorer/public/utils/state_management/store.ts +++ b/src/plugins/data_explorer/public/utils/state_management/store.ts @@ -9,6 +9,13 @@ import { reducer as metadataReducer } from './metadata_slice'; import { loadReduxState, persistReduxState } from './redux_persistence'; import { DataExplorerServices } from '../../types'; +const HYDRATE = 'HYDRATE'; + +export const hydrate = (newState: RootState) => ({ + type: HYDRATE, + payload: newState, +}); + const commonReducers = { metadata: metadataReducer, }; @@ -22,9 +29,20 @@ let dynamicReducers: { const rootReducer = combineReducers(dynamicReducers); +const createRootReducer = (): Reducer => { + const combinedReducer = combineReducers(dynamicReducers); + + return (state: RootState | undefined, action: any): RootState => { + if (action.type === HYDRATE) { + return action.payload; + } + return combinedReducer(state, action); + }; +}; + export const configurePreloadedStore = (preloadedState: PreloadedState) => { // After registering the slices the root reducer needs to be updated - const updatedRootReducer = combineReducers(dynamicReducers); + const updatedRootReducer = createRootReducer(); return configureStore({ reducer: updatedRootReducer, @@ -62,6 +80,18 @@ export const getPreloadedStore = async (services: DataExplorerServices) => { // the store subscriber will automatically detect changes and call handleChange function const unsubscribe = store.subscribe(handleChange); + // This is necessary because browser navigation updates URL state that isnt reflected in the redux state + services.scopedHistory.listen(async (location, action) => { + const urlState = await loadReduxState(services); + const currentState = store.getState(); + + // If the url state is different from the current state, then we need to update the store + // the state should have a view property if it was loaded from the url + if (action === 'POP' && urlState.metadata?.view && !isEqual(urlState, currentState)) { + store.dispatch(hydrate(urlState as RootState)); + } + }); + const onUnsubscribe = () => { dynamicReducers = { ...commonReducers, diff --git a/src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx index 99b3cac1f5f..88db7d75818 100644 --- a/src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx +++ b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx @@ -47,7 +47,7 @@ export function SurroundingDocsApp() { useEffect(() => { chrome.setBreadcrumbs([ - ...getRootBreadcrumbs(baseUrl), + ...getRootBreadcrumbs(), { text: i18n.translate('discover.context.breadcrumb', { defaultMessage: `Context of #{docId}`, diff --git a/src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx index f32dabf67c9..6374d6f3776 100644 --- a/src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx +++ b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx @@ -93,7 +93,7 @@ export const SurroundingDocsView = ({ id, indexPattern }: SurroundingDocsViewPar field, values, operation, - indexPattern.id + indexPattern.id || '' ); return filterManager.addFilters(newFilters); }, @@ -115,23 +115,25 @@ export const SurroundingDocsView = ({ id, indexPattern }: SurroundingDocsViewPar [onAddFilter, rows, indexPattern, setContextAppState, contextQueryState, contextAppState] ); + if (isLoading) { + return null; + } + return ( - !isLoading && ( - - - - - {contextAppMemoized} - - - - ) + + + + + {contextAppMemoized} + + + ); }; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.scss b/src/plugins/discover/public/application/components/sidebar/discover_field.scss index 643f74b809c..5512136431b 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.scss @@ -11,4 +11,8 @@ &:focus { opacity: 1; } + + @include ouiBreakpoint("xs", "s", "m") { + opacity: 1; + } } diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx index 3a0cdd17d23..134a5e7c06f 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -19,6 +19,7 @@ import { import { DiscoverState, setSavedSearchId } from '../../utils/state_management'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../common'; import { getSortForSearchSource } from '../../view_components/utils/get_sort_for_search_source'; +import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; export const getTopNavLinks = ( services: DiscoverViewServices, @@ -45,11 +46,9 @@ export const getTopNavLinks = ( defaultMessage: 'New Search', }), run() { - setTimeout(() => { - history().push('/'); - // TODO: figure out why a history push doesn't update the app state. The page reload is a hack around it - window.location.reload(); - }, 0); + core.application.navigateToApp('discover', { + path: '#/', + }); }, testId: 'discoverNewButton', }; @@ -103,15 +102,7 @@ export const getTopNavLinks = ( history().push(`/view/${encodeURIComponent(id)}`); } else { chrome.docTitle.change(savedSearch.lastSavedTitle); - chrome.setBreadcrumbs([ - { - text: i18n.translate('discover.discoverBreadcrumbTitle', { - defaultMessage: 'Discover', - }), - href: '#/', - }, - { text: savedSearch.title }, - ]); + chrome.setBreadcrumbs([...getRootBreadcrumbs(), { text: savedSearch.title }]); } // set App state to clean diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx index 2dd5bb9bbe9..31c7e4044c0 100644 --- a/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx @@ -55,7 +55,7 @@ interface Props { export function OpenSearchPanel({ onClose, makeUrl }: Props) { const { services: { - core: { uiSettings, savedObjects }, + core: { uiSettings, savedObjects, application }, addBasePath, }, } = useOpenSearchDashboards(); @@ -90,12 +90,8 @@ export function OpenSearchPanel({ onClose, makeUrl }: Props) { }, ]} onChoose={(id) => { - setTimeout(() => { - window.location.assign(makeUrl(id)); - // TODO: figure out why a history push doesn't update the app state. The page reload is a hack around it - window.location.reload(); - onClose(); - }, 0); + application.navigateToApp('discover', { path: `#/view/${id}` }); + onClose(); }} uiSettings={uiSettings} savedObjects={savedObjects} diff --git a/src/plugins/discover/public/application/helpers/breadcrumbs.ts b/src/plugins/discover/public/application/helpers/breadcrumbs.ts index 5463c05c166..382deec77ee 100644 --- a/src/plugins/discover/public/application/helpers/breadcrumbs.ts +++ b/src/plugins/discover/public/application/helpers/breadcrumbs.ts @@ -29,14 +29,17 @@ */ import { i18n } from '@osd/i18n'; +import { EuiBreadcrumb } from '@elastic/eui'; +import { getServices } from '../../opensearch_dashboards_services'; -export function getRootBreadcrumbs(baseUrl: string) { +export function getRootBreadcrumbs(): EuiBreadcrumb[] { + const { core } = getServices(); return [ { text: i18n.translate('discover.rootBreadcrumb', { defaultMessage: 'Discover', }), - href: baseUrl, + onClick: () => core.application.navigateToApp('discover'), }, ]; } diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx index f7910efde91..270198149e6 100644 --- a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -11,6 +11,7 @@ import { RootState, DefaultViewState } from '../../../../../data_explorer/public import { buildColumns } from '../columns'; import * as utils from './common'; import { SortOrder } from '../../../saved_searches/types'; +import { PLUGIN_ID } from '../../../../common'; export interface DiscoverState { /** @@ -79,6 +80,7 @@ export const getPreloadedState = async ({ preloadedState.root = { metadata: { indexPattern: indexPatternId, + view: PLUGIN_ID, }, }; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx index ec57e086f8d..bade3ecae7a 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx @@ -13,7 +13,7 @@ import { DiscoverChart } from '../../components/chart/chart'; export const DiscoverChartContainer = ({ hits, bucketInterval, chartData }: SearchData) => { const { services } = useOpenSearchDashboards(); - const { uiSettings, data } = services; + const { uiSettings, data, core } = services; const { indexPattern, savedSearch } = useDiscoverContext(); const isTimeBased = useMemo(() => (indexPattern ? indexPattern.isTimeBased() : false), [ @@ -30,8 +30,7 @@ export const DiscoverChartContainer = ({ hits, bucketInterval, chartData }: Sear data={data} hits={hits} resetQuery={() => { - window.location.href = `#/view/${savedSearch?.id}`; - window.location.reload(); + core.application.navigateToApp('discover', { path: `#/view/${savedSearch?.id}` }); }} services={services} showResetButton={!!savedSearch && !!savedSearch.id} diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index d5a5e6444eb..7be8cc8585c 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import { TopNav } from './top_nav'; import { ViewProps } from '../../../../../data_explorer/public'; import { DiscoverTable } from './discover_table'; @@ -20,6 +20,7 @@ import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_re import { filterColumns } from '../utils/filter_columns'; import { DEFAULT_COLUMNS_SETTING } from '../../../../common'; import './discover_canvas.scss'; + // eslint-disable-next-line import/no-default-export export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) { const { data$, refetch$, indexPattern } = useDiscoverContext(); @@ -77,19 +78,21 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; return ( - - - - + + {status === ResultStatus.NO_RESULTS && ( - - - + )} {status === ResultStatus.UNINITIALIZED && ( refetch$.next()} /> @@ -97,18 +100,14 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro {status === ResultStatus.LOADING && } {status === ResultStatus.READY && ( <> - - - - - + + + - - - - + + )} - + ); } diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index 3d1795c1f15..b75a48d3acf 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -62,12 +62,9 @@ export const TopNav = ({ opts }: TopNavProps) => { chrome.docTitle.change(`Discover${pageTitleSuffix}`); if (savedSearch?.id) { - chrome.setBreadcrumbs([ - ...getRootBreadcrumbs(getUrlForApp(PLUGIN_ID)), - { text: savedSearch.title }, - ]); + chrome.setBreadcrumbs([...getRootBreadcrumbs(), { text: savedSearch.title }]); } else { - chrome.setBreadcrumbs([...getRootBreadcrumbs(getUrlForApp(PLUGIN_ID))]); + chrome.setBreadcrumbs([...getRootBreadcrumbs()]); } }, [chrome, getUrlForApp, savedSearch?.id, savedSearch?.title]); diff --git a/src/plugins/discover/public/application/view_components/context/index.tsx b/src/plugins/discover/public/application/view_components/context/index.tsx index 29daca73171..7052ed2887f 100644 --- a/src/plugins/discover/public/application/view_components/context/index.tsx +++ b/src/plugins/discover/public/application/view_components/context/index.tsx @@ -17,12 +17,14 @@ const SearchContext = React.createContext({} as SearchContex // eslint-disable-next-line import/no-default-export export default function DiscoverContext({ children }: React.PropsWithChildren) { + const { services: deServices } = useOpenSearchDashboards(); const services = getServices(); - const searchParams = useSearch(services); + const searchParams = useSearch({ + ...deServices, + ...services, + }); - const { - services: { osdUrlStateStorage }, - } = useOpenSearchDashboards(); + const { osdUrlStateStorage } = deServices; // Connect the query service to the url state useEffect(() => { diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts index aa14f6e69dd..4066d0063a3 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -10,7 +10,7 @@ import { i18n } from '@osd/i18n'; import { useEffect } from 'react'; import { cloneDeep } from 'lodash'; import { RequestAdapter } from '../../../../../inspector/public'; -import { DiscoverServices } from '../../../build_services'; +import { DiscoverViewServices } from '../../../build_services'; import { search } from '../../../../../data/public'; import { validateTimeRange } from '../../helpers/validate_time_range'; import { updateSearchSource } from './update_search_source'; @@ -66,7 +66,7 @@ export type RefetchSubject = Subject; * return () => subscription.unsubscribe(); * }, [data$]); */ -export const useSearch = (services: DiscoverServices) => { +export const useSearch = (services: DiscoverViewServices) => { const initalSearchComplete = useRef(false); const [savedSearch, setSavedSearch] = useState(undefined); const { savedSearch: savedSearchId, sort, interval } = useSelector((state) => state.discover); diff --git a/src/plugins/discover/public/opensearch_dashboards_services.ts b/src/plugins/discover/public/opensearch_dashboards_services.ts index 7149454a34c..85d8ba7976c 100644 --- a/src/plugins/discover/public/opensearch_dashboards_services.ts +++ b/src/plugins/discover/public/opensearch_dashboards_services.ts @@ -91,11 +91,7 @@ export const [getScopedHistory, setScopedHistory] = createGetterSetter(() => ({})); - private stopUrlTracking?: () => void; - ``` - * within the `setup()` function in the plugin class, call `createOsdUrlTracker` by passing in the corresponding baseUrl, defaultSubUrl, storageKey, navLinkUpdater observable and stateParams. StorageKey should follow format: `lastUrl:${core.http.basePath.get()}:pluginID`. - - `this.appStateUpdater` is passed into the function as `navLinkUpdater`. - - return three functions `appMounted()`, `appUnMounted()` and `stopUrlTracker()`. Then class variable `stopUrlTracking()` is set to be `stopUrlTracker()` - * call `appMounted()` in the `mount()` function - * call `appUnMounted()` in return of `mount()` - * call `stopUrlTracking()` in `stop()` function for the plugin - - ```ts - const { appMounted, appUnMounted, stop: stopUrlTracker } = createOsdUrlTracker({ - baseUrl: core.http.basePath.prepend('/app/vis-builder'), - defaultSubUrl: '#/', - storageKey: `lastUrl:${core.http.basePath.get()}:vis-builder`, - navLinkUpdater$: this.appStateUpdater, - toastNotifications: core.notifications.toasts, - stateParams: [ - { - osdUrlKey: '_g', - stateUpdate$: data.query.state$.pipe( - filter( - ({ changes }) => - !!(changes.globalFilters || changes.time || changes.refreshInterval) - ), - map(({ state }) => ({ - ...state, - filters: state.filters?.filter(opensearchFilters.isFilterPinned), - })) - ), - }, - ], - getHistory: () => { - return this.currentHistory!; - }, - }); - this.stopUrlTracking = () => { - stopUrlTracker(); - }; - ``` - -2. Set [`osdUrlStateStorage()`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/opensearch_dashboards_utils/public/state_sync/state_sync_state_storage/create_osd_url_state_storage.ts#L83) service. This step initializes the store, and indicates global storage by using '_g' flag. - * when setting the plugin services, set osdUrlStateStorage service by calling `createOsdUrlStateStorage()` with the current history, useHash and withNotifyErrors - - ```ts - const services: VisBuilderServices = { - ...coreStart, - history: params.history, - osdUrlStateStorage: createOsdUrlStateStorage({ - history: params.history, - useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(coreStart.notifications.toasts), - }), - ... - - ``` + + - declare two private variables: `appStateUpdater` observable and `stopUrlTracking()` + + ```ts + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking?: () => void; + ``` + + - within the `setup()` function in the plugin class, call `createOsdUrlTracker` by passing in the corresponding baseUrl, defaultSubUrl, storageKey, navLinkUpdater observable and stateParams. StorageKey should follow format: `lastUrl:${core.http.basePath.get()}:pluginID`. + - `this.appStateUpdater` is passed into the function as `navLinkUpdater`. + - return three functions `appMounted()`, `appUnMounted()` and `stopUrlTracker()`. Then class variable `stopUrlTracking()` is set to be `stopUrlTracker()` + - call `appMounted()` in the `mount()` function + - call `appUnMounted()` in return of `mount()` + - call `stopUrlTracking()` in `stop()` function for the plugin + + ```ts + const { appMounted, appUnMounted, stop: stopUrlTracker } = createOsdUrlTracker({ + baseUrl: core.http.basePath.prepend('/app/vis-builder'), + defaultSubUrl: '#/', + storageKey: `lastUrl:${core.http.basePath.get()}:vis-builder`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + osdUrlKey: '_g', + stateUpdate$: data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(opensearchFilters.isFilterPinned), + })) + ), + }, + ], + getHistory: () => { + return this.currentHistory!; + }, + }); + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + ``` + +2. Set [`osdUrlStateStorage()`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/opensearch_dashboards_utils/public/state_sync/state_sync_state_storage/create_osd_url_state_storage.ts#L83) service. This step initializes the store, and indicates global storage by using '\_g' flag. + + - when setting the plugin services, set osdUrlStateStorage service by calling `createOsdUrlStateStorage()` with the current history, useHash and withNotifyErrors + + ```ts + const services: VisBuilderServices = { + ...coreStart, + history: params.history, + osdUrlStateStorage: createOsdUrlStateStorage({ + history: params.history, + useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(coreStart.notifications.toasts), + }), + ... + + ``` + 3. Sync states with storage. There are many ways to do this and use whatever makes sense for your specific use cases. One such implementation is for syncing the query data in `syncQueryStateWithUrl` from the data plugin. - * import [`syncQueryStateWithUrl`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/data/public/query/state_sync/sync_state_with_url.ts#L48) from data plugin and call it with query service and osdUrlStateStorage service that we set in step 2. This function completes two jobs: 1. When we first enter the app and there is no data stored in the URL, it initializes the URL by putting the `_g` key followed by default data values. 2. When we refresh the page, this function is responsible to retrive the stored states in the URL, and apply them to the app. - - ```ts - export const VisBuilderApp = () => { - const { - services: { - data: { query }, - osdUrlStateStorage, - }, - } = useOpenSearchDashboards(); - const { pathname } = useLocation(); - - useEffect(() => { - // syncs `_g` portion of url with query services - const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); - - return () => stop(); - - // this effect should re-run when pathname is changed to preserve querystring part, - // so the global state is always preserved - }, [query, osdUrlStateStorage, pathname]); - ``` - - * If not already, add query services from data plugin in public/plugin_services.ts - - ```ts - export const [getQueryService, setQueryService] = createGetterSetter('Query'); - ``` - + + - import [`syncQueryStateWithUrl`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/data/public/query/state_sync/sync_state_with_url.ts#L48) from data plugin and call it with query service and osdUrlStateStorage service that we set in step 2. This function completes two jobs: 1. When we first enter the app and there is no data stored in the URL, it initializes the URL by putting the `_g` key followed by default data values. 2. When we refresh the page, this function is responsible to retrieve the stored states in the URL, and apply them to the app. + + ```ts + export const VisBuilderApp = () => { + const { + services: { + data: { query }, + osdUrlStateStorage, + }, + } = useOpenSearchDashboards(); + const { pathname } = useLocation(); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve query string part, + // so the global state is always preserved + }, [query, osdUrlStateStorage, pathname]); + ``` + + - If not already, add query services from data plugin in public/plugin_services.ts + + ```ts + export const [getQueryService, setQueryService] = createGetterSetter< + DataPublicPluginStart['query'] + >('Query'); + ```